Tue, 27 Aug 2024 14:06:50 +0200
Implemented the 'Install Model' menu action.
--- 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",