Mon, 05 Aug 2024 18:37:16 +0200
Implemented most of the Chat History widgets.
--- a/OllamaInterface/OllamaClient.py Sun Aug 04 16:57:01 2024 +0200 +++ b/OllamaInterface/OllamaClient.py Mon Aug 05 18:37:16 2024 +0200 @@ -12,8 +12,16 @@ import enum import json -from PyQt6.QtCore import pyqtSignal, QObject, QUrl -from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from PyQt6.QtCore import ( + QCoreApplication, + QObject, + QThread, + QTimer, + QUrl, + pyqtSignal, + pyqtSlot, +) +from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from eric7.EricNetwork.EricNetworkProxyFactory import proxyAuthenticationRequired @@ -22,6 +30,7 @@ """ Class defining the various client states. """ + Waiting = 0 Requesting = 1 Receiving = 2 @@ -32,7 +41,7 @@ """ Class implementing the 'ollama' client. - @signal replyReceived(content:str, role:str) emitted after a response from the + @signal replyReceived(content:str, role:str) emitted after a response from the 'ollama' server was received @signal modelsList(modelNames:list[str]) emitted after the list of model names was obtained from the 'ollama' server @@ -43,9 +52,13 @@ execution details @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 serverVersion(version:str) emitted after the server version was obtained + from the 'ollama' server @signal finished() emitted to indicate the completion of a request @signal errorOccurred(error:str) emitted to indicate a network error occurred while processing the request + @signal serverStateChanged(ok:bool) emitted to indicate a change of the server + responsiveness """ replyReceived = pyqtSignal(str, str) @@ -53,8 +66,10 @@ detailedModelsList = pyqtSignal(list) runningModelsList = pyqtSignal(list) pullStatus = pyqtSignal(str, str, int, int) + serverVersion = pyqtSignal(str) finished = pyqtSignal() errorOccurred = pyqtSignal(str) + serverStateChanged = pyqtSignal(bool) def __init__(self, plugin, parent=None): """ @@ -75,8 +90,17 @@ proxyAuthenticationRequired ) + self.__serverResponding = False + self.__heartbeatTimer = QTimer(self) + self.__heartbeatTimer.timeout.connect(self.__periodicHeartbeat) + self.__state = OllamaClientState.Waiting + self.__serverResponding = False # start with a faulty state + + self.__plugin.preferencesChanged.connect(self.__setHeartbeatTimer) + self.__setHeartbeatTimer() + def chat(self, model, messages): """ Public method to request a chat completion from the 'ollama' server. @@ -188,7 +212,6 @@ Public method to request a list of models available locally from the 'ollama' server. """ - # TODO: not implemented yet self.__sendRequest("tags", processResponse=self.__processModelsList) def __processModelsList(self, response): @@ -203,16 +226,13 @@ for model in response["models"]: name = model["name"] if name: - models.append(name) + models.append(name.replace(":latest", "")) self.modelsList.emit(models) def listDetails(self): """ Public method to request a list of models available locally from the 'ollama' server with some model details. - - @return list of available models - @rtype list of dict """ # TODO: not implemented yet self.__sendRequest("tags", processResponse=self.__processDetailedModelsList) @@ -244,7 +264,7 @@ def listRunning(self): """ - Public method to request a list of running models from the 'ollama' server + Public method to request a list of running models from the 'ollama' server. """ # TODO: not implemented yet self.__sendRequest("ps", processResponse=self.__processRunningModelsList) @@ -288,10 +308,26 @@ ) self.runningModelsList.emit(models) + def version(self): + """ + Public method to request the version from the 'ollama' server. + """ + self.__sendRequest("version", processResponse=self.__processVersion) + + def __processVersion(self, response): + """ + Private method to process the version response of the 'ollama' server. + + @param response dictionary containing the version response + @type dict + """ + with contextlib.suppress(KeyError): + self.serverVersion.emit(response["version"]) + def state(self): """ Public method to get the current client state. - + @return current client state @rtype OllamaClientState """ @@ -326,9 +362,9 @@ QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json" ) jsonData = json.dumps(data).encode("utf-8") - reply = self.__networkManager.post(request=request, data=jsonData) + reply = self.__networkManager.post(request, jsonData) else: - reply = self.__networkManager.get(request=request) + reply = self.__networkManager.get(request) reply.finished.connect(lambda: self.__replyFinished(reply)) reply.errorOccurred.connect(lambda error: self.__errorOccurred(error, reply)) @@ -383,3 +419,49 @@ data = json.loads(buffer) if data and processResponse: processResponse(data) + + def heartbeat(self): + """ + Public method to check, if the 'ollama' server has started and is responsive. + + @return flag indicating a responsive 'ollama' server + @rtype bool + """ + ollamaUrl = QUrl( + "{0}://{1}:{2}/".format( + self.__plugin.getPreferences("OllamaScheme"), + self.__plugin.getPreferences("OllamaHost"), + self.__plugin.getPreferences("OllamaPort"), + ) + ) + request = QNetworkRequest(ollamaUrl) + reply = self.__networkManager.head(request) + while not reply.isFinished(): + QCoreApplication.processEvents() + QThread.msleep(100) + + reply.deleteLater() + + return reply.error() == QNetworkReply.NetworkError.NoError + + @pyqtSlot() + def __setHeartbeatTimer(self): + """ + Private slot to configure the heartbeat timer. + """ + interval = self.__plugin.getPreferences("OllamaHeartbeatInterval") + if interval: + self.__heartbeatTimer.setInterval(interval * 1000) # interval in ms + self.__heartbeatTimer.start() + else: + self.__heartbeatTimer.stop() + + @pyqtSlot() + def __periodicHeartbeat(self): + """ + Private slot to do a periodic check of the 'ollama' server responsiveness. + """ + responding = self.heartbeat() + if responding != self.__serverResponding: + self.serverStateChanged.emit(responding) + self.__serverResponding = responding
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OllamaInterface/OllamaHistoryWidget.py Mon Aug 05 18:37:16 2024 +0200 @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a widget showing a chat title and store a chat contents. +""" + +import json +import uuid + +from PyQt6.QtCore import pyqtSignal, pyqtSlot +from PyQt6.QtWidgets import QWidget + +from eric7.EricGui import EricPixmapCache + +from .Ui_OllamaHistoryWidget import Ui_OllamaHistoryWidget + + +class OllamaHistoryWidget(QWidget, Ui_OllamaHistoryWidget): + """ + Class implementing a widget showing a chat title and store a chat contents. + + @signal deleteChatHistory(id:str) emitted to indicate, that this chat history + should be deleted + @signal newChatWithHistory(id:str) emitted to indicate, that a new chat using + the saved history should be started + @signal dataChanged(id:str) emitted to indicate a change of the chat history data + """ + + deleteChatHistory = pyqtSignal(str) + newChatWithHistory = pyqtSignal(str) + dataChanged = pyqtSignal(str) + + def __init__(self, title, model, jsonStr=None, parent=None): + """ + Constructor + + @param title title of the ollama chat + @type str + @param model name of the model used for the chat + @type str + @param jsonStr string containing JSON serialize chat history data + (defaults to None) + @type str (optional) + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + self.newChatButton.setIcon(EricPixmapCache.getIcon("plus")) + self.deleteButton.setIcon(EricPixmapCache.getIcon("trash")) + + if jsonStr is None: + self.__title = title + self.__model = model + + self.__id = str(uuid.uuid4()) + self.__messages = [] + else: + self.loadFromJson(jsonStr) + + self.titleEdit.setText(self.__title) + self.modelEdit.setText(self.__model) + + def getId(self): + """ + Public method to get the chat history ID. + + @return ID of the history entry + @rtype str + """ + return self.__id + + @pyqtSlot() + def on_deleteButton_clicked(self): + """ + Private slot to delet this chat history entry.. + """ + self.deleteChatHistory.emit(self.__id) + + @pyqtSlot() + def on_newChatButton_clicked(self): + """ + Private slot to start a new chat using the saved chat history. + """ + self.newChatWithHistory.emit(self.__id) + + def loadFromJson(self, jsonStr): + """ + Public method to load the chat history data from a JSON string. + + @param jsonStr JSON serialized chat data + @type str + """ + data = json.loads(jsonStr) + self.__id = data["id"] + self.__title = data["title"] + self.__model = data["model"] + self.__messages = data["messages"] + + def saveToJson(self): + """ + Public method to serialize the chat history data to a JSON string. + + @return JSON serialized chat data + @rtype str + """ + return json.dumps( + { + "id": self.__id, + "title": self.__title, + "model": self.__model, + "messages": self.__messages, + } + ) + + def addToMessages(self, role, content): + """ + Public method to add a chat message to the chat history. + + @param role chat role (one of 'system', 'user', 'assistant' or 'tool') + @type str + @param content content of the chat message + @type str + """ + self.__messages.append( + { + "role": role, + "content": content, + } + ) + self.dataChanged.emit(self.__id)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OllamaInterface/OllamaHistoryWidget.ui Mon Aug 05 18:37:16 2024 +0200 @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>OllamaHistoryWidget</class> + <widget class="QWidget" name="OllamaHistoryWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>400</width> + <height>25</height> + </rect> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QLineEdit" name="titleEdit"> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="modelEdit"> + <property name="maximumSize"> + <size> + <width>100</width> + <height>16777215</height> + </size> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="newChatButton"> + <property name="toolTip"> + <string>Press to start a new chat based on the current history.</string> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="deleteButton"> + <property name="toolTip"> + <string>Press to delete this chat history.</string> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>titleEdit</tabstop> + <tabstop>modelEdit</tabstop> + <tabstop>newChatButton</tabstop> + <tabstop>deleteButton</tabstop> + </tabstops> + <resources/> + <connections/> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OllamaInterface/OllamaWidget.py Mon Aug 05 18:37:16 2024 +0200 @@ -0,0 +1,339 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the main ollama interface widget. +""" + +import json +import os + +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import QInputDialog, QLineEdit, QVBoxLayout, QWidget + +from eric7 import Globals +from eric7.EricGui import EricPixmapCache +from eric7.EricWidgets import EricMessageBox + +from .OllamaClient import OllamaClient +from .OllamaHistoryWidget import OllamaHistoryWidget +from .Ui_OllamaWidget import Ui_OllamaWidget + + +class OllamaWidget(QWidget, Ui_OllamaWidget): + """ + Class implementing the main ollama interface widget. + """ + + OllamaHistoryFile = "ollama_history.json" + + def __init__(self, plugin, fromEric=True, parent=None): + """ + Constructor + + @param plugin reference to the plug-in object + @type PluginOllamaInterface + @param fromEric flag indicating the eric-ide mode (defaults to True) + (True = eric-ide mode, False = application mode) + @type bool (optional) + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + self.__plugin = plugin + self.__client = OllamaClient(plugin, self) + + if fromEric: + self.layout().setContentsMargins(0, 3, 0, 0) + else: + self.layout().setContentsMargins(0, 0, 0, 0) + + self.ollamaMenuButton.setIcon(EricPixmapCache.getIcon("superMenu")) + self.reloadModelsButton.setIcon(EricPixmapCache.getIcon("reload")) + self.newChatButton.setIcon(EricPixmapCache.getIcon("plus")) + + self.__chatHistoryLayout = QVBoxLayout() + self.historyScrollWidget.setLayout(self.__chatHistoryLayout) + self.__chatHistoryLayout.addStretch(1) + + self.mainSplitter.setSizes([200, 2000]) + + self.newChatButton.setEnabled(False) + self.__handleServerStateChanged(False) + + self.__connectClient() + + self.__loadHistory() + + def __connectClient(self): + """ + Private method to connect the client signals. + """ + self.__client.serverStateChanged.connect(self.__handleServerStateChanged) + self.__client.serverVersion.connect(self.__setHeaderLabel) + self.__client.modelsList.connect(self.__populateModelSelector) + + @pyqtSlot(bool) + def __handleServerStateChanged(self, ok): + """ + Private slot handling a change in the 'ollama' server responsiveness. + + @param ok flag indicating a responsive 'ollama' server + @type bool + """ + if ok: + self.__finishSetup() + else: + self.ollamaVersionLabel.setText( + self.tr("<b>Error: The configured server is not responding.</b>") + ) + self.setEnabled(ok) + + @pyqtSlot() + def __finishSetup(self): + """ + Private slot to finish the UI setup. + """ + self.__client.version() + self.__client.list() + + @pyqtSlot() + def on_reloadModelsButton_clicked(self): + """ + Private slot to reload the list of available models. + """ + self.__client.list() + + @pyqtSlot(str) + def on_modelComboBox_currentTextChanged(self, model): + """ + Private slot handling the selection of a model. + + @param model name of the selected model + @type str + """ + self.newChatButton.setEnabled(bool(model)) + + @pyqtSlot() + def on_newChatButton_clicked(self): + """ + Private slot to start a new chat with the 'ollama' server. + """ + model = self.modelComboBox.currentText() + if not model: + EricMessageBox.critical( + self, + self.tr("New Chat"), + self.tr("""A model has to be selected first. Aborting..."""), + ) + return + + title, ok = QInputDialog.getText( + self, + self.tr("New Chat"), + self.tr("Enter a title for the new chat:"), + QLineEdit.EchoMode.Normal, + ) + if ok and title: + self.__createHistoryWidget(title, model) + # TODO: create an empty chat widget for new chat + + ############################################################################ + ## Methods handling signals from the 'ollama' client. + ############################################################################ + + @pyqtSlot(str) + def __setHeaderLabel(self, version): + """ + Private slot to receive the 'ollama' server version and set the header. + + @param version 'ollama' server version' + @type str + """ + self.ollamaVersionLabel.setText( + self.tr("<b>ollama Server Version {0}</b>").format(version) + ) + + @pyqtSlot(list) + def __populateModelSelector(self, modelNames): + """ + Private slot to receive the list of available model names and populate + the model selector with them. + + @param modelNames list of model names + @type list[str] + """ + self.modelComboBox.clear() + + self.modelComboBox.addItem("") + self.modelComboBox.addItems(sorted(modelNames)) + + ############################################################################ + ## Methods handling signals from the chat history widgets. + ############################################################################ + + def __createHistoryWidget(self, title, model, jsonStr=None): + """ + Private method to create a chat history widget and insert it into the + respective layout. + + @param title title of the chat + @type str + @param model name of the model + @type str + @param jsonStr string containing JSON serialize chat history data (defaults + to None) + @type str (optional) + """ + history = OllamaHistoryWidget(title=title, model=model, jsonStr=jsonStr) + self.__chatHistoryLayout.insertWidget( + self.__chatHistoryLayout.count() - 1, history + ) + + scrollbar = self.historyScrollArea.verticalScrollBar() + scrollbar.setMaximum(self.historyScrollWidget.height()) + scrollbar.setValue(scrollbar.maximum()) + + history.deleteChatHistory.connect(self.__deleteHistory) + history.dataChanged.connect(self.__saveHistory) + history.newChatWithHistory.connect(self.__newChatWithHistory) + + self.__saveHistory() + + def __findHistoryWidgetIndex(self, uid): + """ + Private method to find the index of the reference history widget. + + @param uid ID of the history widget + @type str + @return index of the history widget + @rtype int + """ + for index in range(self.__chatHistoryLayout.count() - 1): + widget = self.__chatHistoryLayout.itemAt(index).widget() + if widget.getId() == uid: + return index + + return None + + def __historyFilePath(self): + """ + Private method to get the path name of the chat history file. + + @return file path of the chat history file + @rtype str + """ + return os.path.join(Globals.getConfigDir(), OllamaWidget.OllamaHistoryFile) + + @pyqtSlot() + def __saveHistory(self): + """ + Private method to save the current chat history to the history file. + """ + # step 1: collect all history entries + entries = {} + for index in range(self.__chatHistoryLayout.count() - 1): + widget = self.__chatHistoryLayout.itemAt(index).widget() + uid = widget.getId() + entries[uid] = widget.saveToJson() + + # step 2: save the collected chat histories + filePath = self.__historyFilePath() + try: + with open(filePath, "w") as f: + json.dump(entries, f) + except OSError as err: + EricMessageBox.critical( + self, + self.tr("Save Chat History"), + self.tr( + "<p>The chat history could not be saved to <b>{0}</b>.</p>" + "<p>Reason: {1}</p>" + ).format(filePath, str(err)), + ) + + def __loadHistory(self): + """ + Private method to load a previously saved history file. + """ + # step 1: load the history file, if it exists + filePath = self.__historyFilePath() + if not os.path.exists(filePath): + return + + try: + with open(filePath, "r") as f: + entries = json.load(f) + except OSError as err: + EricMessageBox.critical( + self, + self.tr("Load Chat History"), + self.tr( + "<p>The chat history could not be loaded from <b>{0}</b>.</p>" + "<p>Reason: {1}</p>" + ).format(filePath, str(err)), + ) + return + + # step 2: create history widgets + for uid in entries: + self.__createHistoryWidget("", "", jsonStr=entries[uid]) + + def clearHistory(self): + """ + Public method to clear the history entries and close all chats. + """ + while self.__chatHistoryLayout.count() > 1: + # do not delete the spacer at the end of the list + item = self.__chatHistoryLayout.takeAt(0) + if item is not None: + item.widget().deleteLater() + + self.__saveHistory() + + @pyqtSlot(str) + def __deleteHistory(self, uid): + """ + Private slot to delete the history with the given ID. + + @param uid ID of the history to be deleted + @type str + """ + widgetIndex = self.__findHistoryWidgetIndex(uid) + if widgetIndex is not None: + item = self.__chatHistoryLayout.takeAt(widgetIndex) + if item is not None: + item.widget().deleteLater() + + self.__saveHistory() + + @pyqtSlot(str) + def __newChatWithHistory(self, uid): + """ + Private slot to start a new chat using a previously saved history. + + @param uid ID of the history to be used + @type str + """ + # TODO: not implemented yet + pass + + ####################################################################### + ## Menu related methods below + ####################################################################### + + def __initOllamaMenu(self): + """ + Private method to create the super menu and attach it to the super + menu button. + """ + # TODO: implement the menu and menu methods + # * Clear Chat History + # * Show Model Details + # * Show Model Processes + # * Pull Model + # * Show Model Shop (via a web browser) + # * Remove Model
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OllamaInterface/OllamaWidget.ui Mon Aug 05 18:37:16 2024 +0200 @@ -0,0 +1,145 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>OllamaWidget</class> + <widget class="QWidget" name="OllamaWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>553</width> + <height>762</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="ollamaVersionLabel"/> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="EricToolButton" name="ollamaMenuButton"> + <property name="popupMode"> + <enum>QToolButton::InstantPopup</enum> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QToolButton" name="reloadModelsButton"> + <property name="statusTip"> + <string>Select to reload the list of selectable models.</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="modelComboBox"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="statusTip"> + <string>Select the model for the chat.</string> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="newChatButton"> + <property name="toolTip"> + <string>Press to start a new chat.</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QSplitter" name="mainSplitter"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="childrenCollapsible"> + <bool>false</bool> + </property> + <widget class="QScrollArea" name="historyScrollArea"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="horizontalScrollBarPolicy"> + <enum>Qt::ScrollBarAlwaysOff</enum> + </property> + <property name="widgetResizable"> + <bool>true</bool> + </property> + <widget class="QWidget" name="historyScrollWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>533</width> + <height>674</height> + </rect> + </property> + </widget> + </widget> + <widget class="QStackedWidget" name="chatStackWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + </widget> + </widget> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>EricToolButton</class> + <extends>QToolButton</extends> + <header>eric7/EricWidgets/EricToolButton.h</header> + </customwidget> + </customwidgets> + <tabstops> + <tabstop>modelComboBox</tabstop> + <tabstop>newChatButton</tabstop> + <tabstop>reloadModelsButton</tabstop> + <tabstop>historyScrollArea</tabstop> + <tabstop>ollamaMenuButton</tabstop> + </tabstops> + <resources/> + <connections/> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OllamaInterface/Ui_OllamaHistoryWidget.py Mon Aug 05 18:37:16 2024 +0200 @@ -0,0 +1,44 @@ +# Form implementation generated from reading ui file 'OllamaInterface/OllamaHistoryWidget.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_OllamaHistoryWidget(object): + def setupUi(self, OllamaHistoryWidget): + OllamaHistoryWidget.setObjectName("OllamaHistoryWidget") + OllamaHistoryWidget.resize(400, 25) + self.horizontalLayout = QtWidgets.QHBoxLayout(OllamaHistoryWidget) + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout.setObjectName("horizontalLayout") + self.titleEdit = QtWidgets.QLineEdit(parent=OllamaHistoryWidget) + self.titleEdit.setReadOnly(True) + self.titleEdit.setObjectName("titleEdit") + self.horizontalLayout.addWidget(self.titleEdit) + self.modelEdit = QtWidgets.QLineEdit(parent=OllamaHistoryWidget) + self.modelEdit.setMaximumSize(QtCore.QSize(100, 16777215)) + self.modelEdit.setReadOnly(True) + self.modelEdit.setObjectName("modelEdit") + self.horizontalLayout.addWidget(self.modelEdit) + self.newChatButton = QtWidgets.QToolButton(parent=OllamaHistoryWidget) + self.newChatButton.setObjectName("newChatButton") + self.horizontalLayout.addWidget(self.newChatButton) + self.deleteButton = QtWidgets.QToolButton(parent=OllamaHistoryWidget) + self.deleteButton.setObjectName("deleteButton") + self.horizontalLayout.addWidget(self.deleteButton) + + self.retranslateUi(OllamaHistoryWidget) + QtCore.QMetaObject.connectSlotsByName(OllamaHistoryWidget) + OllamaHistoryWidget.setTabOrder(self.titleEdit, self.modelEdit) + OllamaHistoryWidget.setTabOrder(self.modelEdit, self.newChatButton) + OllamaHistoryWidget.setTabOrder(self.newChatButton, self.deleteButton) + + def retranslateUi(self, OllamaHistoryWidget): + _translate = QtCore.QCoreApplication.translate + self.newChatButton.setToolTip(_translate("OllamaHistoryWidget", "Press to start a new chat based on the current history.")) + self.deleteButton.setToolTip(_translate("OllamaHistoryWidget", "Press to delete this chat history."))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OllamaInterface/Ui_OllamaWidget.py Mon Aug 05 18:37:16 2024 +0200 @@ -0,0 +1,87 @@ +# Form implementation generated from reading ui file 'OllamaInterface/OllamaWidget.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_OllamaWidget(object): + def setupUi(self, OllamaWidget): + OllamaWidget.setObjectName("OllamaWidget") + OllamaWidget.resize(553, 762) + self.verticalLayout = QtWidgets.QVBoxLayout(OllamaWidget) + self.verticalLayout.setObjectName("verticalLayout") + self.horizontalLayout_2 = QtWidgets.QHBoxLayout() + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_2.addItem(spacerItem) + self.ollamaVersionLabel = QtWidgets.QLabel(parent=OllamaWidget) + self.ollamaVersionLabel.setObjectName("ollamaVersionLabel") + self.horizontalLayout_2.addWidget(self.ollamaVersionLabel) + spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_2.addItem(spacerItem1) + self.ollamaMenuButton = EricToolButton(parent=OllamaWidget) + self.ollamaMenuButton.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) + self.ollamaMenuButton.setObjectName("ollamaMenuButton") + self.horizontalLayout_2.addWidget(self.ollamaMenuButton) + self.verticalLayout.addLayout(self.horizontalLayout_2) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.reloadModelsButton = QtWidgets.QToolButton(parent=OllamaWidget) + self.reloadModelsButton.setObjectName("reloadModelsButton") + self.horizontalLayout.addWidget(self.reloadModelsButton) + self.modelComboBox = QtWidgets.QComboBox(parent=OllamaWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.modelComboBox.sizePolicy().hasHeightForWidth()) + self.modelComboBox.setSizePolicy(sizePolicy) + self.modelComboBox.setObjectName("modelComboBox") + self.horizontalLayout.addWidget(self.modelComboBox) + self.newChatButton = QtWidgets.QToolButton(parent=OllamaWidget) + self.newChatButton.setObjectName("newChatButton") + self.horizontalLayout.addWidget(self.newChatButton) + self.verticalLayout.addLayout(self.horizontalLayout) + self.mainSplitter = QtWidgets.QSplitter(parent=OllamaWidget) + self.mainSplitter.setOrientation(QtCore.Qt.Orientation.Vertical) + self.mainSplitter.setChildrenCollapsible(False) + self.mainSplitter.setObjectName("mainSplitter") + self.historyScrollArea = QtWidgets.QScrollArea(parent=self.mainSplitter) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.historyScrollArea.sizePolicy().hasHeightForWidth()) + self.historyScrollArea.setSizePolicy(sizePolicy) + self.historyScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.historyScrollArea.setWidgetResizable(True) + self.historyScrollArea.setObjectName("historyScrollArea") + self.historyScrollWidget = QtWidgets.QWidget() + self.historyScrollWidget.setGeometry(QtCore.QRect(0, 0, 533, 674)) + self.historyScrollWidget.setObjectName("historyScrollWidget") + self.historyScrollArea.setWidget(self.historyScrollWidget) + self.chatStackWidget = QtWidgets.QStackedWidget(parent=self.mainSplitter) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.chatStackWidget.sizePolicy().hasHeightForWidth()) + self.chatStackWidget.setSizePolicy(sizePolicy) + self.chatStackWidget.setObjectName("chatStackWidget") + self.verticalLayout.addWidget(self.mainSplitter) + + self.retranslateUi(OllamaWidget) + QtCore.QMetaObject.connectSlotsByName(OllamaWidget) + OllamaWidget.setTabOrder(self.modelComboBox, self.newChatButton) + OllamaWidget.setTabOrder(self.newChatButton, self.reloadModelsButton) + OllamaWidget.setTabOrder(self.reloadModelsButton, self.historyScrollArea) + OllamaWidget.setTabOrder(self.historyScrollArea, self.ollamaMenuButton) + + def retranslateUi(self, OllamaWidget): + _translate = QtCore.QCoreApplication.translate + self.reloadModelsButton.setStatusTip(_translate("OllamaWidget", "Select to reload the list of selectable models.")) + self.modelComboBox.setStatusTip(_translate("OllamaWidget", "Select the model for the chat.")) + self.newChatButton.setToolTip(_translate("OllamaWidget", "Press to start a new chat.")) +from eric7.EricWidgets.EricToolButton import EricToolButton
--- a/PluginAiOllama.epj Sun Aug 04 16:57:01 2024 +0200 +++ b/PluginAiOllama.epj Mon Aug 05 18:37:16 2024 +0200 @@ -5,7 +5,149 @@ }, "project": { "AUTHOR": "Detlev Offenbach", - "CHECKERSPARMS": {}, + "CHECKERSPARMS": { + "Pep8Checker": { + "AnnotationsChecker": { + "AllowStarArgAny": false, + "AllowUntypedDefs": false, + "AllowUntypedNested": false, + "CheckFutureAnnotations": false, + "DispatchDecorators": [ + "singledispatch", + "singledispatchmethod" + ], + "ExemptedTypingSymbols": [ + "" + ], + "ForceFutureAnnotations": false, + "MaximumComplexity": 3, + "MaximumLength": 7, + "MinimumCoverage": 75, + "MypyInitReturn": false, + "OverloadDecorators": [ + "overload" + ], + "RespectTypeIgnore": false, + "SuppressDummyArgs": false, + "SuppressNoneReturning": true + }, + "BlankLines": [ + 2, + 1 + ], + "BuiltinsChecker": { + "bytes": [ + "unicode" + ], + "chr": [ + "unichr" + ], + "str": [ + "unicode" + ] + }, + "CommentedCodeChecker": { + "Aggressive": false, + "WhiteList": [ + "pylint", + "pyright", + "noqa", + "type:\\s*ignore", + "fmt:\\s*(on|off)", + "TODO", + "FIXME", + "WARNING", + "NOTE", + "TEST", + "DOCU", + "XXX", + "- " + ] + }, + "CopyrightAuthor": "", + "CopyrightMinFileSize": 0, + "DocstringType": "eric_black", + "EnabledCheckerCategories": "ASY, C, D, E, I, L, M, NO, N, Y, U, W", + "ExcludeFiles": "*/Ui_*.py", + "ExcludeMessages": "M201,C101,E203,E265,E266,E305,E402,M251,M701,M702,M811,M834,M852,N802,N803,N807,N808,N821,U101,W293,W503,Y119,Y401,Y402", + "FixCodes": "", + "FixIssues": false, + "FutureChecker": "", + "HangClosing": false, + "ImportsChecker": { + "ApplicationPackageNames": [ + "OllamaInterface", + "eric7" + ], + "BanRelativeImports": "", + "BannedModules": [] + }, + "IncludeMessages": "", + "LineComplexity": 25, + "LineComplexityScore": 10, + "MaxCodeComplexity": 10, + "MaxDocLineLength": 88, + "MaxLineLength": 88, + "NameOrderChecker": { + "ApplicationPackageNames": [ + "OllamaInterface", + "eric7" + ], + "CombinedAsImports": true, + "SortCaseSensitive": false, + "SortFromFirst": false, + "SortIgnoringStyle": false, + "SortOrder": "natural" + }, + "NoFixCodes": "E501", + "RepeatMessages": true, + "SecurityChecker": { + "CheckTypedException": false, + "HardcodedTmpDirectories": [ + "/tmp", + "/var/tmp", + "/dev/shm", + "~/tmp" + ], + "InsecureHashes": [ + "md4", + "md5", + "sha", + "sha1" + ], + "InsecureSslProtocolVersions": [ + "PROTOCOL_SSLv2", + "SSLv2_METHOD", + "SSLv23_METHOD", + "PROTOCOL_SSLv3", + "PROTOCOL_TLSv1", + "SSLv3_METHOD", + "TLSv1_METHOD" + ], + "WeakKeySizeDsaHigh": "1024", + "WeakKeySizeDsaMedium": "2048", + "WeakKeySizeEcHigh": "160", + "WeakKeySizeEcMedium": "224", + "WeakKeySizeRsaHigh": "1024", + "WeakKeySizeRsaMedium": "2048" + }, + "ShowIgnored": false, + "UnusedChecker": { + "IgnoreAbstract": true, + "IgnoreDunderGlobals": true, + "IgnoreDunderMethods": true, + "IgnoreEventHandlerMethods": true, + "IgnoreLambdas": false, + "IgnoreNestedFunctions": false, + "IgnoreOverload": true, + "IgnoreOverride": true, + "IgnoreSlotMethods": true, + "IgnoreStubs": true, + "IgnoreVariadicNames": false + }, + "ValidEncodings": "latin-1, utf-8" + } + }, "DESCRIPTION": "Plug-in implementing an 'ollama' client and interface widgets.", "DOCSTRING": "ericdoc", "DOCUMENTATIONPARMS": { @@ -43,7 +185,6 @@ "*.pyw3": "SOURCES", "*.qm": "TRANSLATIONS", "*.rst": "OTHERS", - "*.svg": "OTHERS", "*.toml": "OTHERS", "*.ts": "TRANSLATIONS", "*.txt": "OTHERS", @@ -56,7 +197,10 @@ "README.*": "OTHERS", "makefile": "OTHERS" }, - "FORMS": [], + "FORMS": [ + "OllamaInterface/OllamaHistoryWidget.ui", + "OllamaInterface/OllamaWidget.ui" + ], "HASH": "92d9e369bad01266911c1d6eefedae578e76ceb4", "IDLPARAMS": { "DefinedNames": [], @@ -135,7 +279,7 @@ }, "PACKAGERSPARMS": {}, "PROGLANGUAGE": "Python3", - "PROJECTTYPE": "PyQt6", + "PROJECTTYPE": "E7Plugin", "PROJECTTYPESPECIFICDATA": {}, "PROTOCOLS": [], "RCCPARAMS": { @@ -147,6 +291,10 @@ "RESOURCES": [], "SOURCES": [ "OllamaInterface/OllamaClient.py", + "OllamaInterface/OllamaHistoryWidget.py", + "OllamaInterface/OllamaWidget.py", + "OllamaInterface/Ui_OllamaHistoryWidget.py", + "OllamaInterface/Ui_OllamaWidget.py", "OllamaInterface/__init__.py", "PluginAiOllama.py", "__init__.py"
--- a/PluginAiOllama.py Sun Aug 04 16:57:01 2024 +0200 +++ b/PluginAiOllama.py Mon Aug 05 18:37:16 2024 +0200 @@ -9,11 +9,24 @@ import os -from PyQt6.QtCore import QObject, Qt, QTranslator +from PyQt6.QtCore import QObject, Qt, QTranslator, pyqtSignal +from PyQt6.QtGui import QKeySequence from eric7 import Preferences +from eric7.EricGui import EricPixmapCache +from eric7.EricGui.EricAction import EricAction from eric7.EricWidgets.EricApplication import ericApp +try: + from eric7.UI.UserInterface import UserInterfaceSide + + _Side = UserInterfaceSide.Right +except ImportError: + # backward compatibility for eric < 24.2 + from eric7.UI.UserInterface import UserInterface + + _Side = UserInterface.RightSide + # Start-Of-Header __header__ = { "name": "ollama Interface", @@ -81,17 +94,24 @@ """ Function to clear the private data of the plug-in. """ - # TODO: not implemented yet - pass + if ollamaInterfacePluginObject is not None: + widget = ollamaInterfacePluginObject.getWidget() + if widget is not None: + widget.clearHistory() class PluginOllamaInterface(QObject): """ Class implementing the ollama Interface plug-in. + + @signal preferencesChanged() emitted to signal a change of preferences. This + signal is simply relayed from the main UI. """ PreferencesKey = "Ollama" + preferencesChanged = pyqtSignal() + def __init__(self, ui): """ Constructor @@ -107,6 +127,7 @@ "OllamaScheme": "http", "OllamaHost": "localhost", "OllamaPort": 11434, + "OllamaHeartbeatInterval": 5, # 5 seconds heartbeat time; 0 = disabled } self.__translator = None @@ -125,9 +146,52 @@ @return tuple of None and activation status @rtype bool """ - global error + from OllamaInterface.OllamaWidget import OllamaWidget + + global error, ollamaInterfacePluginObject error = "" # clear previous error - # TODO: not implemented yet + ollamaInterfacePluginObject = self + + usesDarkPalette = ericApp().usesDarkPalette() + iconSuffix = "dark" if usesDarkPalette else "light" + + self.__widget = OllamaWidget(self, fromEric=True) + iconName = ( + "sbOllama96" + if self.__ui.getLayoutType() == "Sidebars" + else "ollama22-{0}".format(iconSuffix) + ) + self.__ui.addSideWidget( + _Side, + self.__widget, + EricPixmapCache.getIcon(os.path.join("OllamaInterface", "icons", iconName)), + self.tr("ollama AI Interface"), + ) + + self.__activateAct = EricAction( + self.tr("ollama AI Interface"), + self.tr("ollama AI Interface"), + QKeySequence(self.tr("Ctrl+Alt+Shift+O")), + 0, + self, + "ollama_interface_activate", + ) + self.__activateAct.setStatusTip( + self.tr("Switch the input focus to the ollama AI window.") + ) + self.__activateAct.setWhatsThis( + self.tr( + """<b>Activate ollama AI Interface</b>""" + """<p>This switches the input focus to the ollama AI window.</p>""" + ) + ) + self.__activateAct.triggered.connect(self.__activateWidget) + + self.__ui.addEricActions([self.__activateAct], "ui") + menu = self.__ui.getMenu("subwindow") + menu.addAction(self.__activateAct) + + self.__ui.preferencesChanged.connect(self.preferencesChanged) return None, True @@ -135,8 +199,14 @@ """ Public method to deactivate this plug-in. """ - # TODO: not implemented yet - pass + self.__ui.preferencesChanged.disconnect(self.preferencesChanged) + + menu = self.__ui.getMenu("subwindow") + menu.removeAction(self.__activateAct) + self.__ui.removeEricActions([self.__activateAct], "ui") + self.__ui.removeSideWidget(self.__widget) + + self.__initialize() def __loadTranslator(self): """ @@ -161,6 +231,15 @@ ) print("Using default.") + def getWidget(self): + """ + Public method to get a reference to the 'ollama' widget. + + @return reference to the 'ollama' widget + @rtype OllamaWidget + """ + return self.__widget + def __activateWidget(self): """ Private slot to handle the activation of the pipx interface. @@ -188,7 +267,7 @@ @return the requested setting value @rtype Any """ - if key in ("OllamaPort",): + if key in ("OllamaPort", "OllamaHeartbeatInterval"): return int( Preferences.Prefs.settings.value( self.PreferencesKey + "/" + key, self.__defaults[key] @@ -234,16 +313,5 @@ sidebar.setCurrentWidget(widget) -def installDependencies(pipInstall): - """ - Function to install dependencies of this plug-in. - - @param pipInstall function to be called with a list of package names. - @type function - """ - # TODO: not implemented yet - pass - - # # eflag: noqa = M801, U200