OllamaInterface/OllamaWidget.py

changeset 5
6e8af43d537d
parent 4
7dd1b9cd3150
child 6
d8064fb63eac
--- a/OllamaInterface/OllamaWidget.py	Mon Aug 05 18:37:16 2024 +0200
+++ b/OllamaInterface/OllamaWidget.py	Tue Aug 06 18:18:39 2024 +0200
@@ -9,13 +9,15 @@
 import json
 import os
 
-from PyQt6.QtCore import Qt, pyqtSlot
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot
 from PyQt6.QtWidgets import QInputDialog, QLineEdit, QVBoxLayout, QWidget
 
 from eric7 import Globals
 from eric7.EricGui import EricPixmapCache
 from eric7.EricWidgets import EricMessageBox
+from eric7.EricWidgets.EricApplication import ericApp
 
+from .OllamaChatWidget import OllamaChatWidget
 from .OllamaClient import OllamaClient
 from .OllamaHistoryWidget import OllamaHistoryWidget
 from .Ui_OllamaWidget import Ui_OllamaWidget
@@ -51,9 +53,16 @@
         else:
             self.layout().setContentsMargins(0, 0, 0, 0)
 
+        iconSuffix = "-dark" if ericApp().usesDarkPalette() else "-light"
+
         self.ollamaMenuButton.setIcon(EricPixmapCache.getIcon("superMenu"))
         self.reloadModelsButton.setIcon(EricPixmapCache.getIcon("reload"))
         self.newChatButton.setIcon(EricPixmapCache.getIcon("plus"))
+        self.sendButton.setIcon(
+            EricPixmapCache.getIcon(
+                os.path.join("OllamaInterface", "icons", "send{0}".format(iconSuffix))
+            )
+        )
 
         self.__chatHistoryLayout = QVBoxLayout()
         self.historyScrollWidget.setLayout(self.__chatHistoryLayout)
@@ -66,7 +75,11 @@
 
         self.__connectClient()
 
+        self.sendButton.clicked.connect(self.__sendMessage)
+        self.messageEdit.returnPressed.connect(self.__sendMessage)
+
         self.__loadHistory()
+        self.__updateMessageEditState()
 
     def __connectClient(self):
         """
@@ -75,6 +88,7 @@
         self.__client.serverStateChanged.connect(self.__handleServerStateChanged)
         self.__client.serverVersion.connect(self.__setHeaderLabel)
         self.__client.modelsList.connect(self.__populateModelSelector)
+        self.__client.replyReceived.connect(self.__handleServerMessage)
 
     @pyqtSlot(bool)
     def __handleServerStateChanged(self, ok):
@@ -117,30 +131,6 @@
         """
         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.
     ############################################################################
@@ -187,35 +177,46 @@
         @param jsonStr string containing JSON serialize chat history data (defaults
             to None)
         @type str (optional)
+        @return reference to the created history widget
+        @rtype OllamaHistoryWidget
         """
         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.
+        QTimer.singleShot(0, self.__scrollHistoryToBottom)
+
+        return history
 
-        @param uid ID of the history widget
+    @pyqtSlot()
+    def __scrollHistoryToBottom(self):
+        """
+        Private slot to scroll the history widget to the bottom.
+        """
+        scrollbar = self.historyScrollArea.verticalScrollBar()
+        scrollbar.setMaximum(self.historyScrollWidget.height())
+        scrollbar.setValue(scrollbar.maximum())
+
+    def __findHistoryWidget(self, hid):
+        """
+        Private method to find the widget of a given chat history ID.
+
+        @param hid ID of the chat history
         @type str
-        @return index of the history widget
-        @rtype int
+        @return reference to the chat history widget
+        @rtype OllamaHistoryWidget
         """
         for index in range(self.__chatHistoryLayout.count() - 1):
             widget = self.__chatHistoryLayout.itemAt(index).widget()
-            if widget.getId() == uid:
-                return index
+            if widget.getId() == hid:
+                return widget
 
         return None
 
@@ -237,8 +238,8 @@
         entries = {}
         for index in range(self.__chatHistoryLayout.count() - 1):
             widget = self.__chatHistoryLayout.itemAt(index).widget()
-            uid = widget.getId()
-            entries[uid] = widget.saveToJson()
+            hid = widget.getId()
+            entries[hid] = widget.saveToJson()
 
         # step 2: save the collected chat histories
         filePath = self.__historyFilePath()
@@ -279,8 +280,8 @@
             return
 
         # step 2: create history widgets
-        for uid in entries:
-            self.__createHistoryWidget("", "", jsonStr=entries[uid])
+        for hid in entries:
+            self.__createHistoryWidget("", "", jsonStr=entries[hid])
 
     def clearHistory(self):
         """
@@ -295,31 +296,207 @@
         self.__saveHistory()
 
     @pyqtSlot(str)
-    def __deleteHistory(self, uid):
+    def __deleteHistory(self, hid):
         """
         Private slot to delete the history with the given ID.
 
-        @param uid ID of the history to be deleted
+        @param hid ID of the history to be deleted
         @type str
         """
-        widgetIndex = self.__findHistoryWidgetIndex(uid)
-        if widgetIndex is not None:
+        widget = self.__findHistoryWidget(hid)
+        if widget is not None:
+            widgetIndex = self.__chatHistoryLayout.indexOf(widget)
             item = self.__chatHistoryLayout.takeAt(widgetIndex)
             if item is not None:
                 item.widget().deleteLater()
 
-        self.__saveHistory()
+            self.__saveHistory()
+
+        self.__removeChatWidget(hid)
+
+    #######################################################################
+    ## Chat related methods below
+    #######################################################################
+
+    def __findChatWidget(self, hid):
+        """
+        Private method to find a chat widget given a chat history ID.
+
+        @param hid chat history ID
+        @type str
+        @return reference to the chat widget related to the given ID
+        @rtype OllamaChatWidget
+        """
+        for index in range(self.chatStackWidget.count()):
+            widget = self.chatStackWidget.widget(index)
+            if widget.getHistoryId() == hid:
+                return widget
+
+        return None
+
+    @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:
+            historyWidget = self.__createHistoryWidget(title, model)
+            hid = historyWidget.getId()
+            chatWidget = OllamaChatWidget(hid=hid, title=title, model=model)
+            index = self.chatStackWidget.addWidget(chatWidget)
+            self.chatStackWidget.setCurrentIndex(index)
+
+            self.__updateMessageEditState()
+            self.messageEdit.setFocus(Qt.FocusReason.OtherFocusReason)
 
     @pyqtSlot(str)
-    def __newChatWithHistory(self, uid):
+    def __newChatWithHistory(self, hid):
         """
         Private slot to start a new chat using a previously saved history.
 
-        @param uid ID of the history to be used
+        @param hid ID of the history to be used
+        @type str
+        """
+        chatWidget = self.__findChatWidget(hid)
+        if chatWidget is None:
+            historyWidget = self.__findHistoryWidget(hid)
+            if historyWidget is None:
+                # Oops, treat it as a new chat.
+                self.on_newChatButton_clicked()
+                return
+
+            chatWidget = OllamaChatWidget(
+                hid=hid, title=historyWidget.getTitle(), model=historyWidget.getModel()
+            )
+            index = self.chatStackWidget.addWidget(chatWidget)
+            self.chatStackWidget.setCurrentIndex(index)
+            for message in historyWidget.getMessages():
+                chatWidget.addMessage(role=message["role"], message=message["content"])
+        else:
+            # simply switch to the already existing chatWidget
+            self.chatStackWidget.setCurrentWidget(chatWidget)
+
+        self.__updateMessageEditState()
+        self.messageEdit.setFocus(Qt.FocusReason.OtherFocusReason)
+
+    def __removeChatWidget(self, hid):
+        """
+        Private method to remove a chat widget given its chat history ID.
+
+        @param hid chat history ID
+        @type str
+        """
+        widget = self.__findChatWidget(hid)
+        if widget is not None:
+            self.chatStackWidget.removeWidget(widget)
+
+    @pyqtSlot()
+    def __updateMessageEditState(self):
+        """
+        Private slot to set the enabled state of the message line edit and the send
+        button.
+        """
+        chatActive = bool(self.chatStackWidget.count())
+        hasText = bool(self.messageEdit.text())
+
+        self.messageEdit.setEnabled(chatActive)
+        self.sendButton.setEnabled(chatActive and hasText)
+
+    @pyqtSlot(str)
+    def on_messageEdit_textChanged(self, msg):
+        """
+        Private slot to handle a change of the entered message.
+
+        @param msg text of the message line edit
         @type str
         """
-        # TODO: not implemented yet
-        pass
+        self.sendButton.setEnabled(bool(msg))
+
+    @pyqtSlot()
+    def __sendMessage(self):
+        """
+        Private method to send the given message of the current chat to the
+        'ollama' server.
+
+        This sends the message with context (i.e. the history of the current chat).
+        """
+        msg = self.messageEdit.text()
+        if not msg:
+            # empty message => ignore
+            return
+
+        if not bool(self.chatStackWidget.count()):
+            # no current stack => ignore
+            return
+
+        # 1. determine hid of the current chat via chat stack widget
+        chatWidget = self.chatStackWidget.currentWidget()
+        hid = chatWidget.getHistoryId()
+
+        # 2. get chat history widget via hid from chat history widget
+        historyWidget = self.__findHistoryWidget(hid)
+        if historyWidget is not None:
+            # 3. append the message to the history
+            historyWidget.addToMessages("user", msg)
+
+            # 4. get the complete messages list from the history
+            messages = historyWidget.getMessages()
+
+            # 5. add the message to the current chat and an empty one
+            #    for the response
+            chatWidget.addMessage("user", msg)
+            chatWidget.addMessage("assistant", "")
+
+            # 6. send the request via the client (non-streaming (?))
+            model = historyWidget.getModel()
+            self.__client.chat(
+                model=model,
+                messages=messages,
+                streaming=self.__plugin.getPreferences("StreamingChatResponse"),
+            )
+
+        # 7. clear the message editor and give input focus back
+        self.messageEdit.clear()
+        self.messageEdit.setFocus(Qt.FocusReason.OtherFocusReason)
+
+    @pyqtSlot(str, str, bool)
+    def __handleServerMessage(self, content, role, done):
+        """
+        Private slot handling an 'ollama' server chat response.
+
+        @param content message sent by the server
+        @type str
+        @param role role name
+        @type str
+        @param done flag indicating the last chat response
+        @type bool
+        """
+        if not bool(self.chatStackWidget.count()):
+            # no current stack => ignore
+            return
+
+        chatWidget = self.chatStackWidget.currentWidget()
+        chatWidget.appendMessage(content)
+        if done:
+            hid = chatWidget.getHistoryId()
+            historyWidget = self.__findHistoryWidget(hid)
+            if historyWidget is not None:
+                historyWidget.addToMessages(role, chatWidget.getRecentMessage())
 
     #######################################################################
     ## Menu related methods below

eric ide

mercurial