Implemented the 'chat' functionality.

Tue, 06 Aug 2024 18:18:39 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Tue, 06 Aug 2024 18:18:39 +0200
changeset 5
6e8af43d537d
parent 4
7dd1b9cd3150
child 6
d8064fb63eac

Implemented the 'chat' functionality.

OllamaInterface/AutoResizeTextBrowser.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaChatMessageBox.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaChatWidget.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaChatWidget.ui file | annotate | diff | comparison | revisions
OllamaInterface/OllamaClient.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaHistoryWidget.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaHistoryWidget.ui file | annotate | diff | comparison | revisions
OllamaInterface/OllamaWidget.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaWidget.ui file | annotate | diff | comparison | revisions
OllamaInterface/Ui_OllamaChatWidget.py file | annotate | diff | comparison | revisions
OllamaInterface/Ui_OllamaHistoryWidget.py file | annotate | diff | comparison | revisions
OllamaInterface/Ui_OllamaWidget.py file | annotate | diff | comparison | revisions
PluginAiOllama.epj file | annotate | diff | comparison | revisions
PluginAiOllama.py file | annotate | diff | comparison | revisions
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/AutoResizeTextBrowser.py	Tue Aug 06 18:18:39 2024 +0200
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a QTextBrowser widget that resizes automatically.
+"""
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QFrame, QSizePolicy, QTextBrowser
+
+
+class AutoResizeTextBrowser(QTextBrowser):
+    """
+    Class implementing a QTextBrowser widget that resizes automatically.
+    """
+
+    def __init__(self, parent=None):
+        """
+        Constructor
+
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent=parent)
+
+        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.setFrameShape(QFrame.Shape.NoFrame)
+
+        self.textChanged.connect(self.updateGeometry)
+
+    def resizeEvent(self, evt):
+        """
+        Protected method to handle resize events.
+
+        @param evt reference to the resize event
+        @type QResizeEvent
+        """
+        super().resizeEvent(evt)
+        self.updateGeometry()
+
+    def updateGeometry(self):
+        """
+        Public method to update the geometry depending on the current text.
+        """
+        # Set the text width of the document to match the width of the text browser.
+        self.document().setTextWidth(
+            self.width() - 2 * int(self.document().documentMargin())
+        )
+
+        # Get the document height and set it as the fixed height of the text browser.
+        docHeight = self.document().size().height()
+        self.setFixedHeight(int(docHeight))
+
+        # Call the base class updateGeometry() method.
+        super().updateGeometry()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/OllamaChatMessageBox.py	Tue Aug 06 18:18:39 2024 +0200
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a message box widget showing the role and content of a message.
+"""
+
+import os
+
+from PyQt6.QtCore import QSize, Qt
+from PyQt6.QtWidgets import QHBoxLayout, QLabel, QWidget
+
+from eric7.EricGui import EricPixmapCache
+from eric7.EricWidgets.EricApplication import ericApp
+
+from .AutoResizeTextBrowser import AutoResizeTextBrowser
+
+
+class OllamaChatMessageBox(QWidget):
+    """
+    Class implementing a message box widget showing the role and content of a message.
+    """
+
+    def __init__(self, role, message, parent=None):
+        """
+        Constructor
+
+        @param role role of the message sender (one of 'user' or 'assistant')
+        @type str
+        @param message message text
+        @type str
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+
+        self.__roleLabel = QLabel(self)
+        self.__roleLabel.setFixedSize(22, 22)
+        pixmapName = "{0}-{1}".format(
+            "user" if role == "user" else "ollama22",
+            "dark" if ericApp().usesDarkPalette() else "light",
+        )
+        self.__roleLabel.setPixmap(
+            EricPixmapCache.getPixmap(
+                os.path.join("OllamaInterface", "icons", pixmapName),
+                QSize(22, 22),
+            )
+        )
+        self.__roleLabel.setAlignment(
+            Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
+        )
+
+        self.__messageBrowser = AutoResizeTextBrowser(self)
+
+        self.__layout = QHBoxLayout(self)
+        self.__layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+        self.__layout.setContentsMargins(0, 0, 0, 0)
+        self.__layout.addWidget(self.__roleLabel, Qt.AlignmentFlag.AlignTop)
+        self.__layout.addWidget(self.__messageBrowser)
+        self.setLayout(self.__layout)
+
+        self.__message = ""
+        self.appendMessage(message)
+
+    def appendMessage(self, msg):
+        """
+        Public method to append the given message text to the current content.
+
+        @param msg message to be appended
+        @type str
+        """
+        if msg:
+            self.__message += msg
+            self.__messageBrowser.setMarkdown(self.__message)
+
+    def getMessage(self):
+        """
+        Public method to get the message content.
+
+        @return message content
+        @rtype str
+        """
+        return self.__message
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/OllamaChatWidget.py	Tue Aug 06 18:18:39 2024 +0200
@@ -0,0 +1,103 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a widget showing the chat with the 'ollama' server.
+"""
+
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot
+from PyQt6.QtWidgets import QVBoxLayout, QWidget
+
+from .OllamaChatMessageBox import OllamaChatMessageBox
+from .Ui_OllamaChatWidget import Ui_OllamaChatWidget
+
+
+class OllamaChatWidget(QWidget, Ui_OllamaChatWidget):
+    """
+    Class implementing a widget showing the chat with the 'ollama' server.
+    """
+
+    def __init__(self, hid, title, model, parent=None):
+        """
+        Constructor
+
+        @param hid ID of the chat history
+        @type str
+        @param title title of the chat
+        @type str
+        @param model model name used for the chat
+        @type str
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+        self.setupUi(self)
+
+        self.__hid = hid
+
+        self.headerLabel.setText(
+            self.tr("<b>{0} - {1}</b>", "title, model name").format(title, model)
+        )
+
+        self.__messagesLayout = QVBoxLayout()
+        self.__messagesLayout.setContentsMargins(4, 4, 4, 4)
+        self.__messagesLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
+        self.chatMessagesWidget.setLayout(self.__messagesLayout)
+
+    def addMessage(self, role, message):
+        """
+        Public method to add a new message.
+
+        @param role role of the message sender (one of 'user' or 'assistant')
+        @type str
+        @param message message text
+        @type str
+        """
+        msgWidget = OllamaChatMessageBox(role=role, message=message)
+        self.__messagesLayout.addWidget(msgWidget)
+
+        QTimer.singleShot(0, self.__scrollChatToBottom)
+
+    def appendMessage(self, message):
+        """
+        Public method to append a given message to the bottom most message box.
+
+        @param message message text to be appended
+        @type str
+        """
+        msgBox = self.__messagesLayout.itemAt(
+            self.__messagesLayout.count() - 1
+        ).widget()
+        msgBox.appendMessage(message)
+
+        QTimer.singleShot(0, self.__scrollChatToBottom)
+
+    @pyqtSlot()
+    def __scrollChatToBottom(self):
+        """
+        Private slot to scroll the chat scroll area to the bottom.
+        """
+        scrollbar = self.chatMessagesScrollArea.verticalScrollBar()
+        scrollbar.setValue(scrollbar.maximum())
+
+    def getHistoryId(self):
+        """
+        Public method to get the history ID of this chat.
+
+        @return DESCRIPTION
+        @rtype TYPE
+        """
+        return self.__hid
+
+    def getRecentMessage(self):
+        """
+        Public method to get the message of the last message box.
+
+        @return message content
+        @rtype str
+        """
+        msgBox = self.__messagesLayout.itemAt(
+            self.__messagesLayout.count() - 1
+        ).widget()
+        return msgBox.getMessage()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/OllamaChatWidget.ui	Tue Aug 06 18:18:39 2024 +0200
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OllamaChatWidget</class>
+ <widget class="QWidget" name="OllamaChatWidget">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>598</width>
+    <height>514</height>
+   </rect>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <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="headerLabel">
+       <property name="text">
+        <string notr="true">Chat Header</string>
+       </property>
+      </widget>
+     </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>
+    </layout>
+   </item>
+   <item>
+    <widget class="QScrollArea" name="chatMessagesScrollArea">
+     <property name="horizontalScrollBarPolicy">
+      <enum>Qt::ScrollBarAlwaysOff</enum>
+     </property>
+     <property name="widgetResizable">
+      <bool>true</bool>
+     </property>
+     <widget class="QWidget" name="chatMessagesWidget">
+      <property name="geometry">
+       <rect>
+        <x>0</x>
+        <y>0</y>
+        <width>578</width>
+        <height>466</height>
+       </rect>
+      </property>
+     </widget>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
--- a/OllamaInterface/OllamaClient.py	Mon Aug 05 18:37:16 2024 +0200
+++ b/OllamaInterface/OllamaClient.py	Tue Aug 06 18:18:39 2024 +0200
@@ -41,8 +41,8 @@
     """
     Class implementing the 'ollama' client.
 
-    @signal replyReceived(content:str, role:str) emitted after a response from the
-        'ollama' server was received
+    @signal replyReceived(content:str, role:str, done:bool) 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
     @signal detailedModelsList(models:list[dict]) emitted after the list of
@@ -61,7 +61,7 @@
         responsiveness
     """
 
-    replyReceived = pyqtSignal(str, str)
+    replyReceived = pyqtSignal(str, str, bool)
     modelsList = pyqtSignal(list)
     detailedModelsList = pyqtSignal(list)
     runningModelsList = pyqtSignal(list)
@@ -101,7 +101,7 @@
         self.__plugin.preferencesChanged.connect(self.__setHeartbeatTimer)
         self.__setHeartbeatTimer()
 
-    def chat(self, model, messages):
+    def chat(self, model, messages, streaming=True):
         """
         Public method to request a chat completion from the 'ollama' server.
 
@@ -109,11 +109,13 @@
         @type str
         @param messages list of message objects
         @type list of dict
+        @param streaming flag indicating to receive a streaming response
+        @type bool
         """
-        # TODO: not implemented yet
         ollamaRequest = {
             "model": model,
             "messages": messages,
+            "stream": streaming,
         }
         self.__sendRequest(
             "chat", data=ollamaRequest, processResponse=self.__processChatResponse
@@ -128,8 +130,9 @@
         """
         with contextlib.suppress(KeyError):
             message = response["message"]
+            done = response["done"]
             if message:
-                self.replyReceived.emit(message["content"], message["role"])
+                self.replyReceived.emit(message["content"], message["role"], done)
 
     def generate(self, model, prompt, suffix=None):
         """
@@ -142,7 +145,6 @@
         @param suffix text after the model response (defaults to None)
         @type str (optional)
         """
-        # TODO: not implemented yet
         ollamaRequest = {
             "model": model,
             "prompt": prompt,
@@ -163,7 +165,7 @@
         @type dict
         """
         with contextlib.suppress(KeyError):
-            self.replyReceived.emit(response["response"], "")
+            self.replyReceived.emit(response["response"], "", response["done"])
 
     def pull(self, model):
         """
--- a/OllamaInterface/OllamaHistoryWidget.py	Mon Aug 05 18:37:16 2024 +0200
+++ b/OllamaInterface/OllamaHistoryWidget.py	Tue Aug 06 18:18:39 2024 +0200
@@ -10,7 +10,7 @@
 import uuid
 
 from PyQt6.QtCore import pyqtSignal, pyqtSlot
-from PyQt6.QtWidgets import QWidget
+from PyQt6.QtWidgets import QInputDialog, QLineEdit, QWidget
 
 from eric7.EricGui import EricPixmapCache
 
@@ -50,6 +50,7 @@
         self.setupUi(self)
 
         self.newChatButton.setIcon(EricPixmapCache.getIcon("plus"))
+        self.editButton.setIcon(EricPixmapCache.getIcon("editRename"))
         self.deleteButton.setIcon(EricPixmapCache.getIcon("trash"))
 
         if jsonStr is None:
@@ -64,15 +65,42 @@
         self.titleEdit.setText(self.__title)
         self.modelEdit.setText(self.__model)
 
+    def getTitle(self):
+        """
+        Public method to get the chat title.
+
+        @return chat title
+        @rtype str
+        """
+        return self.__title
+
+    def getModel(self):
+        """
+        Public method to get the model used by the chat.
+
+        @return model name
+        @rtype str
+        """
+        return self.__model
+
     def getId(self):
         """
         Public method to get the chat history ID.
-        
+
         @return ID of the history entry
         @rtype str
         """
         return self.__id
 
+    def getMessages(self):
+        """
+        Public method to get the list of messages.
+
+        @return list of stored messages
+        @rtype list[dict[str, str]]
+        """
+        return self.__messages
+
     @pyqtSlot()
     def on_deleteButton_clicked(self):
         """
@@ -87,6 +115,23 @@
         """
         self.newChatWithHistory.emit(self.__id)
 
+    @pyqtSlot()
+    def on_editButton_clicked(self):
+        """
+        Private slot to edit the chat title.
+        """
+        title, ok = QInputDialog.getText(
+            self,
+            self.tr("Edit Chat Title"),
+            self.tr("Enter the new title:"),
+            QLineEdit.EchoMode.Normal,
+            self.__title,
+        )
+        if ok and bool(title):
+            self.__title = title
+            self.titleEdit.setText(title)
+            self.dataChanged.emit(self.__id)
+
     def loadFromJson(self, jsonStr):
         """
         Public method to load the chat history data from a JSON string.
--- a/OllamaInterface/OllamaHistoryWidget.ui	Mon Aug 05 18:37:16 2024 +0200
+++ b/OllamaInterface/OllamaHistoryWidget.ui	Tue Aug 06 18:18:39 2024 +0200
@@ -51,6 +51,13 @@
     </widget>
    </item>
    <item>
+    <widget class="QToolButton" name="editButton">
+     <property name="toolTip">
+      <string>Press to edit the chat title.</string>
+     </property>
+    </widget>
+   </item>
+   <item>
     <widget class="QToolButton" name="deleteButton">
      <property name="toolTip">
       <string>Press to delete this chat history.</string>
@@ -63,6 +70,7 @@
   <tabstop>titleEdit</tabstop>
   <tabstop>modelEdit</tabstop>
   <tabstop>newChatButton</tabstop>
+  <tabstop>editButton</tabstop>
   <tabstop>deleteButton</tabstop>
  </tabstops>
  <resources/>
--- 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
--- a/OllamaInterface/OllamaWidget.ui	Mon Aug 05 18:37:16 2024 +0200
+++ b/OllamaInterface/OllamaWidget.ui	Tue Aug 06 18:18:39 2024 +0200
@@ -109,7 +109,7 @@
          <x>0</x>
          <y>0</y>
          <width>533</width>
-         <height>674</height>
+         <height>641</height>
         </rect>
        </property>
       </widget>
@@ -124,6 +124,30 @@
      </widget>
     </widget>
    </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout_3">
+     <item>
+      <widget class="QLineEdit" name="messageEdit">
+       <property name="toolTip">
+        <string>Enter the message to be sent to the 'ollama' server.</string>
+       </property>
+       <property name="placeholderText">
+        <string>Enter Message</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QToolButton" name="sendButton">
+       <property name="enabled">
+        <bool>false</bool>
+       </property>
+       <property name="toolTip">
+        <string>Press to send the message of the current chat to the 'ollama' server.</string>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
   </layout>
  </widget>
  <customwidgets>
@@ -138,6 +162,8 @@
   <tabstop>newChatButton</tabstop>
   <tabstop>reloadModelsButton</tabstop>
   <tabstop>historyScrollArea</tabstop>
+  <tabstop>messageEdit</tabstop>
+  <tabstop>sendButton</tabstop>
   <tabstop>ollamaMenuButton</tabstop>
  </tabstops>
  <resources/>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/Ui_OllamaChatWidget.py	Tue Aug 06 18:18:39 2024 +0200
@@ -0,0 +1,43 @@
+# Form implementation generated from reading ui file 'OllamaInterface/OllamaChatWidget.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_OllamaChatWidget(object):
+    def setupUi(self, OllamaChatWidget):
+        OllamaChatWidget.setObjectName("OllamaChatWidget")
+        OllamaChatWidget.resize(598, 514)
+        self.verticalLayout = QtWidgets.QVBoxLayout(OllamaChatWidget)
+        self.verticalLayout.setObjectName("verticalLayout")
+        self.horizontalLayout = QtWidgets.QHBoxLayout()
+        self.horizontalLayout.setObjectName("horizontalLayout")
+        spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
+        self.horizontalLayout.addItem(spacerItem)
+        self.headerLabel = QtWidgets.QLabel(parent=OllamaChatWidget)
+        self.headerLabel.setText("Chat Header")
+        self.headerLabel.setObjectName("headerLabel")
+        self.horizontalLayout.addWidget(self.headerLabel)
+        spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
+        self.horizontalLayout.addItem(spacerItem1)
+        self.verticalLayout.addLayout(self.horizontalLayout)
+        self.chatMessagesScrollArea = QtWidgets.QScrollArea(parent=OllamaChatWidget)
+        self.chatMessagesScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.chatMessagesScrollArea.setWidgetResizable(True)
+        self.chatMessagesScrollArea.setObjectName("chatMessagesScrollArea")
+        self.chatMessagesWidget = QtWidgets.QWidget()
+        self.chatMessagesWidget.setGeometry(QtCore.QRect(0, 0, 578, 466))
+        self.chatMessagesWidget.setObjectName("chatMessagesWidget")
+        self.chatMessagesScrollArea.setWidget(self.chatMessagesWidget)
+        self.verticalLayout.addWidget(self.chatMessagesScrollArea)
+
+        self.retranslateUi(OllamaChatWidget)
+        QtCore.QMetaObject.connectSlotsByName(OllamaChatWidget)
+
+    def retranslateUi(self, OllamaChatWidget):
+        pass
--- a/OllamaInterface/Ui_OllamaHistoryWidget.py	Mon Aug 05 18:37:16 2024 +0200
+++ b/OllamaInterface/Ui_OllamaHistoryWidget.py	Tue Aug 06 18:18:39 2024 +0200
@@ -28,6 +28,9 @@
         self.newChatButton = QtWidgets.QToolButton(parent=OllamaHistoryWidget)
         self.newChatButton.setObjectName("newChatButton")
         self.horizontalLayout.addWidget(self.newChatButton)
+        self.editButton = QtWidgets.QToolButton(parent=OllamaHistoryWidget)
+        self.editButton.setObjectName("editButton")
+        self.horizontalLayout.addWidget(self.editButton)
         self.deleteButton = QtWidgets.QToolButton(parent=OllamaHistoryWidget)
         self.deleteButton.setObjectName("deleteButton")
         self.horizontalLayout.addWidget(self.deleteButton)
@@ -36,9 +39,11 @@
         QtCore.QMetaObject.connectSlotsByName(OllamaHistoryWidget)
         OllamaHistoryWidget.setTabOrder(self.titleEdit, self.modelEdit)
         OllamaHistoryWidget.setTabOrder(self.modelEdit, self.newChatButton)
-        OllamaHistoryWidget.setTabOrder(self.newChatButton, self.deleteButton)
+        OllamaHistoryWidget.setTabOrder(self.newChatButton, self.editButton)
+        OllamaHistoryWidget.setTabOrder(self.editButton, 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.editButton.setToolTip(_translate("OllamaHistoryWidget", "Press to edit the chat title."))
         self.deleteButton.setToolTip(_translate("OllamaHistoryWidget", "Press to delete this chat history."))
--- a/OllamaInterface/Ui_OllamaWidget.py	Mon Aug 05 18:37:16 2024 +0200
+++ b/OllamaInterface/Ui_OllamaWidget.py	Tue Aug 06 18:18:39 2024 +0200
@@ -60,7 +60,7 @@
         self.historyScrollArea.setWidgetResizable(True)
         self.historyScrollArea.setObjectName("historyScrollArea")
         self.historyScrollWidget = QtWidgets.QWidget()
-        self.historyScrollWidget.setGeometry(QtCore.QRect(0, 0, 533, 674))
+        self.historyScrollWidget.setGeometry(QtCore.QRect(0, 0, 533, 641))
         self.historyScrollWidget.setObjectName("historyScrollWidget")
         self.historyScrollArea.setWidget(self.historyScrollWidget)
         self.chatStackWidget = QtWidgets.QStackedWidget(parent=self.mainSplitter)
@@ -71,17 +71,32 @@
         self.chatStackWidget.setSizePolicy(sizePolicy)
         self.chatStackWidget.setObjectName("chatStackWidget")
         self.verticalLayout.addWidget(self.mainSplitter)
+        self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
+        self.horizontalLayout_3.setObjectName("horizontalLayout_3")
+        self.messageEdit = QtWidgets.QLineEdit(parent=OllamaWidget)
+        self.messageEdit.setObjectName("messageEdit")
+        self.horizontalLayout_3.addWidget(self.messageEdit)
+        self.sendButton = QtWidgets.QToolButton(parent=OllamaWidget)
+        self.sendButton.setEnabled(False)
+        self.sendButton.setObjectName("sendButton")
+        self.horizontalLayout_3.addWidget(self.sendButton)
+        self.verticalLayout.addLayout(self.horizontalLayout_3)
 
         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)
+        OllamaWidget.setTabOrder(self.historyScrollArea, self.messageEdit)
+        OllamaWidget.setTabOrder(self.messageEdit, self.sendButton)
+        OllamaWidget.setTabOrder(self.sendButton, 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."))
+        self.messageEdit.setToolTip(_translate("OllamaWidget", "Enter the message to be sent to the \'ollama\' server."))
+        self.messageEdit.setPlaceholderText(_translate("OllamaWidget", "Enter Message"))
+        self.sendButton.setToolTip(_translate("OllamaWidget", "Press to send the message of the current chat to the \'ollama\' server."))
 from eric7.EricWidgets.EricToolButton import EricToolButton
--- a/PluginAiOllama.epj	Mon Aug 05 18:37:16 2024 +0200
+++ b/PluginAiOllama.epj	Tue Aug 06 18:18:39 2024 +0200
@@ -198,6 +198,7 @@
       "makefile": "OTHERS"
     },
     "FORMS": [
+      "OllamaInterface/OllamaChatWidget.ui",
       "OllamaInterface/OllamaHistoryWidget.ui",
       "OllamaInterface/OllamaWidget.ui"
     ],
@@ -290,9 +291,13 @@
     },
     "RESOURCES": [],
     "SOURCES": [
+      "OllamaInterface/AutoResizeTextBrowser.py",
+      "OllamaInterface/OllamaChatMessageBox.py",
+      "OllamaInterface/OllamaChatWidget.py",
       "OllamaInterface/OllamaClient.py",
       "OllamaInterface/OllamaHistoryWidget.py",
       "OllamaInterface/OllamaWidget.py",
+      "OllamaInterface/Ui_OllamaChatWidget.py",
       "OllamaInterface/Ui_OllamaHistoryWidget.py",
       "OllamaInterface/Ui_OllamaWidget.py",
       "OllamaInterface/__init__.py",
--- a/PluginAiOllama.py	Mon Aug 05 18:37:16 2024 +0200
+++ b/PluginAiOllama.py	Tue Aug 06 18:18:39 2024 +0200
@@ -12,7 +12,7 @@
 from PyQt6.QtCore import QObject, Qt, QTranslator, pyqtSignal
 from PyQt6.QtGui import QKeySequence
 
-from eric7 import Preferences
+from eric7 import Globals, Preferences
 from eric7.EricGui import EricPixmapCache
 from eric7.EricGui.EricAction import EricAction
 from eric7.EricWidgets.EricApplication import ericApp
@@ -128,6 +128,7 @@
             "OllamaHost": "localhost",
             "OllamaPort": 11434,
             "OllamaHeartbeatInterval": 5,  # 5 seconds heartbeat time; 0 = disabled
+            "StreamingChatResponse": True,
         }
 
         self.__translator = None
@@ -273,6 +274,12 @@
                     self.PreferencesKey + "/" + key, self.__defaults[key]
                 )
             )
+        elif key in ("StreamingChatResponse",):
+            return Globals.toBool(
+                Preferences.Prefs.settings.value(
+                    self.PreferencesKey + "/" + key, self.__defaults[key]
+                )
+            )
         else:
             return Preferences.Prefs.settings.value(
                 self.PreferencesKey + "/" + key, self.__defaults[key]

eric ide

mercurial