Implemented the 'Install Model' menu action.

Tue, 27 Aug 2024 14:06:50 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Tue, 27 Aug 2024 14:06:50 +0200
changeset 11
3641ea6b55d5
parent 10
734921ab2b89
child 12
cf507e6f12d7

Implemented the 'Install Model' menu action.

OllamaInterface/OllamaClient.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaPullProgressDialog.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaPullProgressDialog.ui file | annotate | diff | comparison | revisions
OllamaInterface/OllamaWidget.py file | annotate | diff | comparison | revisions
OllamaInterface/Ui_OllamaPullProgressDialog.py file | annotate | diff | comparison | revisions
PluginAiOllama.epj file | annotate | diff | comparison | revisions
--- a/OllamaInterface/OllamaClient.py	Tue Aug 27 09:19:39 2024 +0200
+++ b/OllamaInterface/OllamaClient.py	Tue Aug 27 14:06:50 2024 +0200
@@ -47,6 +47,7 @@
         names was obtained from the 'ollama' server
     @signal pullStatus(msg:str, id:str, total:int, completed:int) emitted to indicate
         the status of a pull request as reported by the 'ollama' server
+    @signal pullError(msg:str) emitted to indicate an error during a pull operation
     @signal serverVersion(version:str) emitted after the server version was obtained
         from the 'ollama' server
     @signal finished() emitted to indicate the completion of a request
@@ -58,7 +59,8 @@
 
     replyReceived = pyqtSignal(str, str, bool)
     modelsList = pyqtSignal(list)
-    pullStatus = pyqtSignal(str, str, int, int)
+    pullStatus = pyqtSignal(str, str, "unsigned long int", "unsigned long int")
+    pullError = pyqtSignal(str)
     serverVersion = pyqtSignal(str)
     finished = pyqtSignal()
     errorOccurred = pyqtSignal(str)
@@ -77,6 +79,7 @@
 
         self.__plugin = plugin
         self.__replies = []
+        self.__pullReply = None
 
         self.__networkManager = QNetworkAccessManager(self)
         self.__networkManager.proxyAuthenticationRequired.connect(
@@ -178,9 +181,8 @@
         @param model name of the model
         @type str
         """
-        # TODO: not implemented yet
         ollamaRequest = {
-            "name": model,
+            "model": model,
         }
         self.__sendRequest(
             "pull", data=ollamaRequest, processResponse=self.__processPullResponse
@@ -193,12 +195,22 @@
         @param response dictionary containing the pull response
         @type dict
         """
-        with contextlib.suppress(KeyError):
-            status = response["status"]
-            idStr = response.get("digest", "")[:20]
-            total = response.get("total", 0)
-            completed = response.get("completed", 0)
-            self.pullStatus.emit(status, idStr, total, completed)
+        if "error" in response:
+            self.pullError.emit(response["error"])
+        else:
+            with contextlib.suppress(KeyError):
+                status = response["status"]
+                idStr = response.get("digest", "")[:20]
+                total = response.get("total", 0)
+                completed = response.get("completed", 0)
+                self.pullStatus.emit(status, idStr, total, completed)
+
+    def abortPull(self):
+        """
+        Public method to abort an ongoing pull operation.
+        """
+        if self.__pullReply is not None:
+            self.__pullReply.close()
 
     def remove(self, model):
         """
@@ -399,7 +411,10 @@
         reply = self.__getServerReply(endpoint=endpoint, data=data)
         reply.finished.connect(lambda: self.__replyFinished(reply))
         reply.readyRead.connect(lambda: self.__processData(reply, processResponse))
-        self.__replies.append(reply)
+        if endpoint == "pull":
+            self.__pullReply = reply
+        else:
+            self.__replies.append(reply)
 
     def __replyFinished(self, reply):
         """
@@ -410,11 +425,15 @@
         """
         self.__state = OllamaClientState.Finished
 
-        if reply in self.__replies:
+        if reply == self.__pullReply:
+            self.__pullReply = None
+        elif reply in self.__replies:
             self.__replies.remove(reply)
 
         reply.deleteLater()
 
+        self.finished.emit()
+
     def __errorOccurred(self, errorCode, reply):
         """
         Private method to handle a network error of the given reply.
@@ -424,7 +443,10 @@
         @param reply reference to the network reply object
         @type QNetworkReply
         """
-        if errorCode != QNetworkReply.NetworkError.NoError:
+        if errorCode not in (
+            QNetworkReply.NetworkError.NoError,
+            QNetworkReply.NetworkError.OperationCanceledError,
+        ):
             self.errorOccurred.emit(
                 self.tr("<p>A network error occurred.</p><p>Error: {0}</p>").format(
                     reply.errorString()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/OllamaPullProgressDialog.py	Tue Aug 27 14:06:50 2024 +0200
@@ -0,0 +1,218 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a dialog showing the progress of a model pull action..
+"""
+
+from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
+from PyQt6.QtWidgets import (
+    QAbstractButton,
+    QDialog,
+    QDialogButtonBox,
+    QProgressBar,
+    QTreeWidgetItem,
+)
+
+from eric7 import Globals
+
+from .Ui_OllamaPullProgressDialog import Ui_OllamaPullProgressDialog
+
+
+class OllamaPullProgressBar(QProgressBar):
+    """
+    Class implementing a progress bar allowing values outside the standard range.
+    """
+
+    def __init__(self, parent=None):
+        """
+        Constructor
+
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+
+        self.__maximum = 100
+        self.__minimum = 0
+        self.__value = 0
+
+    def maximum(self):
+        """
+        Public method to get the maximum value.
+
+        @return maximum value
+        @rtype int
+        """
+        return self.__maximum
+
+    def setMaximum(self, value):
+        """
+        Public method to set the maximum value.
+
+        @param value new maximum value
+        @type int
+        """
+        if value != self.__maximum:
+            self.__maximum = value
+            self.setValue(self.__value)
+
+    def value(self):
+        """
+        Public method to get the current value.
+
+        @return current value
+        @rtype int
+        """
+        return self.__value
+
+    def setValue(self, value):
+        """
+        Public method to set the current value.
+
+        @param value new value
+        @type int
+        """
+        if value != self.__value:
+            self.__value = value
+            super().setValue(self.__value * 100 // self.__maximum)
+
+
+class OllamaPullProgressDialog(QDialog, Ui_OllamaPullProgressDialog):
+    """
+    Class implementing a dialog showing the progress of a model pull action.
+
+    @signal abortPull() emitted to abort the current model pull operation
+    """
+
+    abortPull = pyqtSignal()
+
+    def __init__(self, parent=None):
+        """
+        Constructor
+
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+        self.setupUi(self)
+
+        self.header.clear()
+        self.__progressBarItems = {}
+
+        self.setFinished(True)
+
+    @pyqtSlot(QAbstractButton)
+    def on_buttonBox_clicked(self, button):
+        """
+        Private slot handling a button of the button box being clicked.
+
+        @param button reference to the clicked button
+        @type QAbstractButton
+        """
+        if button == self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel):
+            self.abortPull.emit()
+        elif button == self.buttonBox.button(QDialogButtonBox.StandardButton.Close):
+            self.close()
+
+    def closeEvent(self, evt):
+        """
+        Protected method to handle a close event.
+
+        @param evt reference to the close event object
+        @type QCloseEvent
+        """
+        if not self.__finished:
+            evt.ignore()
+
+    def setModel(self, model):
+        """
+        Public method to show the model name in the header.
+
+        @param model model name
+        @type str
+        """
+        self.header.setText(
+            self.tr("<p>Installing model <b>{0}</b>.</p>").format(model)
+        )
+
+    def clear(self):
+        """
+        Public method to clear the progress information.
+        """
+        self.__progressBarItems.clear()
+        self.progressList.clear()
+
+    def setStatus(self, status, idStr, total, completed):
+        """
+        Public method to show the status update.
+
+        @param status status message reported by the 'ollama' server
+        @type str
+        @param idStr ID of the file being pulled or empty
+        @type str
+        @param total size of the file being pulled or 0 in case of an empty ID
+        @type int
+        @param completed downloaded bytes or 0 in case of an empty ID
+        @type int
+        """
+        self.setFinished(False)
+
+        if idStr:
+            try:
+                itm = self.__progressBarItems[idStr]
+            except KeyError:
+                itm = QTreeWidgetItem(self.progressList, [status, "", ""])
+                itm.setTextAlignment(2, Qt.AlignmentFlag.AlignCenter)
+                self.progressList.resizeColumnToContents(0)
+
+                bar = OllamaPullProgressBar()
+                bar.setMaximum(total)
+                self.progressList.setItemWidget(itm, 1, bar)
+
+                self.__progressBarItems[idStr] = itm
+
+            if completed == total:
+                itm.setText(2, Globals.dataString(total))
+            else:
+                itm.setText(
+                    2,
+                    self.tr("{0} / {1}", "completed / total").format(
+                        Globals.dataString(completed), Globals.dataString(total)
+                    ),
+                )
+            self.progressList.itemWidget(itm, 1).setValue(completed)
+        else:
+            itm = QTreeWidgetItem(self.progressList, [status])
+            itm.setFirstColumnSpanned(True)
+
+            if status == "success":
+                self.setFinished(True)
+
+    def showError(self, errMsg):
+        """
+        Public method to show an error message reported by the server.
+
+        @param errMsg error message
+        @type str
+        """
+        itm = QTreeWidgetItem(self.progressList, [self.tr("Error: {0}").format(errMsg)])
+        itm.setFirstColumnSpanned(True)
+
+    def setFinished(self, finished):
+        """
+        Public method to set the finished state.
+
+        @param finished flag indicating the finished state
+        @type bool
+        """
+        self.__finished = finished
+
+        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(
+            finished
+        )
+        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(
+            not finished
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/OllamaPullProgressDialog.ui	Tue Aug 27 14:06:50 2024 +0200
@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OllamaPullProgressDialog</class>
+ <widget class="QDialog" name="OllamaPullProgressDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>600</width>
+    <height>400</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Install Model</string>
+  </property>
+  <property name="sizeGripEnabled">
+   <bool>true</bool>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QLabel" name="header">
+     <property name="text">
+      <string notr="true">Header</string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QTreeWidget" name="progressList">
+     <property name="editTriggers">
+      <set>QAbstractItemView::NoEditTriggers</set>
+     </property>
+     <property name="alternatingRowColors">
+      <bool>true</bool>
+     </property>
+     <property name="selectionMode">
+      <enum>QAbstractItemView::NoSelection</enum>
+     </property>
+     <property name="rootIsDecorated">
+      <bool>false</bool>
+     </property>
+     <property name="itemsExpandable">
+      <bool>false</bool>
+     </property>
+     <property name="columnCount">
+      <number>3</number>
+     </property>
+     <attribute name="headerVisible">
+      <bool>false</bool>
+     </attribute>
+     <attribute name="headerDefaultSectionSize">
+      <number>200</number>
+     </attribute>
+     <column>
+      <property name="text">
+       <string notr="true">1</string>
+      </property>
+     </column>
+     <column>
+      <property name="text">
+       <string notr="true">2</string>
+      </property>
+     </column>
+     <column>
+      <property name="text">
+       <string notr="true">3</string>
+      </property>
+     </column>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Close</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
--- a/OllamaInterface/OllamaWidget.py	Tue Aug 27 09:19:39 2024 +0200
+++ b/OllamaInterface/OllamaWidget.py	Tue Aug 27 14:06:50 2024 +0200
@@ -86,6 +86,9 @@
         self.newChatButton.setEnabled(False)
         self.__handleServerStateChanged(False)
 
+        self.__pullProgressDialog = None
+        self.__pulling = False
+
         self.__localServerDialog = None
         self.__localServerProcess = None
 
@@ -110,6 +113,11 @@
         self.__client.modelsList.connect(self.__populateModelSelector)
         self.__client.modelsList.connect(self.__checkHistoryModels)
         self.__client.replyReceived.connect(self.__handleServerMessage)
+        self.__client.pullStatus.connect(self.__handlePullStatus)
+        self.__client.pullError.connect(self.__handlePullError)
+
+        self.__client.errorOccurred.connect(self.__handleClientError)
+        self.__client.finished.connect(self.__handleClientFinished)
 
     @pyqtSlot(bool)
     def __handleServerStateChanged(self, ok):
@@ -605,8 +613,6 @@
         Private method to create the super menu and attach it to the super
         menu button.
         """
-        # TODO: implement the menu and menu methods
-        #       * Pull Model
         ###################################################################
         ## Menu with Chat History related actions
         ###################################################################
@@ -635,7 +641,9 @@
             self.tr("Show Model Library"), self.__showModelLibrary
         )
         self.__modelMenu.addSeparator()
-        self.__modelMenu.addAction(self.tr("Download Model"), self.__pullModel)
+        self.__pullModelAct = self.__modelMenu.addAction(
+            self.tr("Install Model"), self.__pullModel
+        )
         self.__removeModelAct = self.__modelMenu.addAction(
             self.tr("Remove Model"), self.__removeModel
         )
@@ -690,6 +698,7 @@
             self.__localServerProcess is not None and self.__localServerDialog is None
         )
 
+        self.__pullModelAct.setEnabled(not self.__pulling)
         self.__removeModelAct.setEnabled(bool(self.__availableModels))
 
     @pyqtSlot()
@@ -929,8 +938,58 @@
         """
         Private slot to download a model from the 'ollama' model library.
         """
-        # TODO: not implemented yet
-        pass
+        from .OllamaPullProgressDialog import OllamaPullProgressDialog
+
+        if self.__pulling:
+            # only one pull operation supported
+            return
+
+        model, ok = QInputDialog.getText(
+            self,
+            self.tr("Install Model"),
+            self.tr("Enter the name of the model to be installed:"),
+            QLineEdit.EchoMode.Normal,
+        )
+        if ok and model:
+            self.__pulling = True
+
+            if self.__pullProgressDialog is None:
+                self.__pullProgressDialog = OllamaPullProgressDialog(self)
+                self.__pullProgressDialog.abortPull.connect(self.__client.abortPull)
+
+            self.__pullProgressDialog.setModel(model)
+            self.__pullProgressDialog.clear()
+            self.__pullProgressDialog.show()
+
+            self.__client.pull(model)
+
+    @pyqtSlot(str, str, "unsigned long int", "unsigned long int")
+    def __handlePullStatus(self, status, idStr, total, completed):
+        """
+        Private slot to handle a pull status update.
+
+        @param status status message reported by the 'ollama' server
+        @type str
+        @param idStr ID of the file being pulled or empty
+        @type str
+        @param total size of the file being pulled or 0 in case of an empty ID
+        @type int
+        @param completed downloaded bytes or 0 in case of an empty ID
+        @type int
+        """
+        if self.__pullProgressDialog is not None:
+            self.__pullProgressDialog.setStatus(status, idStr, total, completed)
+
+    @pyqtSlot(str)
+    def __handlePullError(self, errMsg):
+        """
+        Private slot to handle an error during a pull operation.
+
+        @param errMsg error message
+        @type str
+        """
+        if self.__pullProgressDialog is not None:
+            self.__pullProgressDialog.showError(errMsg)
 
     @pyqtSlot()
     def __removeModel(self):
@@ -966,3 +1025,27 @@
                             " 'ollama' server.</p>"
                         ).format(modelName),
                     )
+
+    @pyqtSlot(str)
+    def __handleClientError(self, errMsg):
+        """
+        Private slot to handle an error message sent by the server.
+
+        @param errMsg error message
+        @type str
+        """
+        EricMessageBox.warning(
+            self,
+            self.tr("Network Error"),
+            errMsg,
+        )
+
+    @pyqtSlot()
+    def __handleClientFinished(self):
+        """
+        Private slot to handle the end of a client server interaction.
+        """
+        if self.__pullProgressDialog is not None and self.__pulling:
+            self.__pullProgressDialog.setFinished(True)
+            self.__pulling = False
+            self.__client.list()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/Ui_OllamaPullProgressDialog.py	Tue Aug 27 14:06:50 2024 +0200
@@ -0,0 +1,48 @@
+# Form implementation generated from reading ui file 'OllamaInterface/OllamaPullProgressDialog.ui'
+#
+# Created by: PyQt6 UI code generator 6.7.1
+#
+# WARNING: Any manual changes made to this file will be lost when pyuic6 is
+# run again.  Do not edit this file unless you know what you are doing.
+
+
+from PyQt6 import QtCore, QtGui, QtWidgets
+
+
+class Ui_OllamaPullProgressDialog(object):
+    def setupUi(self, OllamaPullProgressDialog):
+        OllamaPullProgressDialog.setObjectName("OllamaPullProgressDialog")
+        OllamaPullProgressDialog.resize(600, 400)
+        OllamaPullProgressDialog.setSizeGripEnabled(True)
+        self.verticalLayout = QtWidgets.QVBoxLayout(OllamaPullProgressDialog)
+        self.verticalLayout.setObjectName("verticalLayout")
+        self.header = QtWidgets.QLabel(parent=OllamaPullProgressDialog)
+        self.header.setText("Header")
+        self.header.setObjectName("header")
+        self.verticalLayout.addWidget(self.header)
+        self.progressList = QtWidgets.QTreeWidget(parent=OllamaPullProgressDialog)
+        self.progressList.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
+        self.progressList.setAlternatingRowColors(True)
+        self.progressList.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
+        self.progressList.setRootIsDecorated(False)
+        self.progressList.setItemsExpandable(False)
+        self.progressList.setColumnCount(3)
+        self.progressList.setObjectName("progressList")
+        self.progressList.headerItem().setText(0, "1")
+        self.progressList.headerItem().setText(1, "2")
+        self.progressList.headerItem().setText(2, "3")
+        self.progressList.header().setVisible(False)
+        self.progressList.header().setDefaultSectionSize(200)
+        self.verticalLayout.addWidget(self.progressList)
+        self.buttonBox = QtWidgets.QDialogButtonBox(parent=OllamaPullProgressDialog)
+        self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
+        self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Close)
+        self.buttonBox.setObjectName("buttonBox")
+        self.verticalLayout.addWidget(self.buttonBox)
+
+        self.retranslateUi(OllamaPullProgressDialog)
+        QtCore.QMetaObject.connectSlotsByName(OllamaPullProgressDialog)
+
+    def retranslateUi(self, OllamaPullProgressDialog):
+        _translate = QtCore.QCoreApplication.translate
+        OllamaPullProgressDialog.setWindowTitle(_translate("OllamaPullProgressDialog", "Install Model"))
--- a/PluginAiOllama.epj	Tue Aug 27 09:19:39 2024 +0200
+++ b/PluginAiOllama.epj	Tue Aug 27 14:06:50 2024 +0200
@@ -201,6 +201,7 @@
       "OllamaInterface/OllamaChatWidget.ui",
       "OllamaInterface/OllamaDetailedModelsDialog.ui",
       "OllamaInterface/OllamaHistoryWidget.ui",
+      "OllamaInterface/OllamaPullProgressDialog.ui",
       "OllamaInterface/OllamaRunningModelsDialog.ui",
       "OllamaInterface/OllamaWidget.ui",
       "OllamaInterface/RunOllamaServerDialog.ui"
@@ -300,12 +301,14 @@
       "OllamaInterface/OllamaClient.py",
       "OllamaInterface/OllamaDetailedModelsDialog.py",
       "OllamaInterface/OllamaHistoryWidget.py",
+      "OllamaInterface/OllamaPullProgressDialog.py",
       "OllamaInterface/OllamaRunningModelsDialog.py",
       "OllamaInterface/OllamaWidget.py",
       "OllamaInterface/RunOllamaServerDialog.py",
       "OllamaInterface/Ui_OllamaChatWidget.py",
       "OllamaInterface/Ui_OllamaDetailedModelsDialog.py",
       "OllamaInterface/Ui_OllamaHistoryWidget.py",
+      "OllamaInterface/Ui_OllamaPullProgressDialog.py",
       "OllamaInterface/Ui_OllamaRunningModelsDialog.py",
       "OllamaInterface/Ui_OllamaWidget.py",
       "OllamaInterface/Ui_RunOllamaServerDialog.py",

eric ide

mercurial