Implemented a menu, dialog and actions to start a local 'ollama' server with the capability to monitor its log output.

Thu, 08 Aug 2024 18:33:49 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Thu, 08 Aug 2024 18:33:49 +0200
changeset 7
eb1dec15b2f0
parent 6
d8064fb63eac
child 8
3118d16e526e

Implemented a menu, dialog and actions to start a local 'ollama' server with the capability to monitor its log output.

OllamaInterface/OllamaClient.py file | annotate | diff | comparison | revisions
OllamaInterface/OllamaWidget.py file | annotate | diff | comparison | revisions
OllamaInterface/RunOllamaServerDialog.py file | annotate | diff | comparison | revisions
OllamaInterface/RunOllamaServerDialog.ui file | annotate | diff | comparison | revisions
OllamaInterface/Ui_RunOllamaServerDialog.py file | annotate | diff | comparison | revisions
PluginAiOllama.epj file | annotate | diff | comparison | revisions
PluginAiOllama.py file | annotate | diff | comparison | revisions
--- a/OllamaInterface/OllamaClient.py	Wed Aug 07 18:19:25 2024 +0200
+++ b/OllamaInterface/OllamaClient.py	Thu Aug 08 18:33:49 2024 +0200
@@ -95,12 +95,23 @@
         self.__heartbeatTimer.timeout.connect(self.__periodicHeartbeat)
 
         self.__state = OllamaClientState.Waiting
+        self.__localServer = False
 
         self.__serverResponding = False  # start with a faulty state
 
         self.__plugin.preferencesChanged.connect(self.__setHeartbeatTimer)
         self.__setHeartbeatTimer()
 
+    def setMode(self, local):
+        """
+        Public method to set the client mode to local.
+
+        @param local flag indicating to connect to a locally started ollama server
+        @type bool
+        """
+        self.__localServer = local
+        self.__serverResponding = False
+
     def chat(self, model, messages, streaming=True):
         """
         Public method to request a chat completion from the 'ollama' server.
@@ -353,8 +364,16 @@
         ollamaUrl = QUrl(
             "{0}://{1}:{2}/api/{3}".format(
                 self.__plugin.getPreferences("OllamaScheme"),
-                self.__plugin.getPreferences("OllamaHost"),
-                self.__plugin.getPreferences("OllamaPort"),
+                (
+                    "127.0.0.1"
+                    if self.__localServer
+                    else self.__plugin.getPreferences("OllamaHost")
+                ),
+                (
+                    self.__plugin.getPreferences("OllamaLocalPort")
+                    if self.__localServer
+                    else self.__plugin.getPreferences("OllamaPort")
+                ),
                 endpoint,
             )
         )
--- a/OllamaInterface/OllamaWidget.py	Wed Aug 07 18:19:25 2024 +0200
+++ b/OllamaInterface/OllamaWidget.py	Thu Aug 08 18:33:49 2024 +0200
@@ -9,7 +9,7 @@
 import json
 import os
 
-from PyQt6.QtCore import Qt, QTimer, pyqtSlot
+from PyQt6.QtCore import QProcessEnvironment, Qt, QTimer, pyqtSlot
 from PyQt6.QtWidgets import (
     QDialog,
     QInputDialog,
@@ -84,6 +84,9 @@
         self.newChatButton.setEnabled(False)
         self.__handleServerStateChanged(False)
 
+        self.__localServerDialog = None
+        self.__localServerProcess = None
+
         self.__connectClient()
 
         self.__initOllamaMenu()
@@ -619,12 +622,30 @@
         self.__chatHistoryMenu.addAction(self.tr("Export"), self.__menuExportHistories)
 
         ###################################################################
+        ## Menu with Local Server related actions
+        ###################################################################
+
+        self.__localServerMenu = QMenu(self.tr("Local Server"))
+        self.__localServerStartMonitorAct = self.__localServerMenu.addAction(
+            self.tr("Start with Monitoring"), self.__startLocalServerMonitoring
+        )
+        self.__localServerMenu.addSeparator()
+        self.__startLocalServerAct = self.__localServerMenu.addAction(
+            self.tr("Start"), self.__startLocalServer
+        )
+        self.__stopLocalServerAct = self.__localServerMenu.addAction(
+            self.tr("Stop"), self.__stopLocalServer
+        )
+
+        ###################################################################
         ## Main menu
         ###################################################################
 
         self.__ollamaMenu = QMenu()
         self.__ollamaMenu.addMenu(self.__chatHistoryMenu)
         self.__ollamaMenu.addSeparator()
+        self.__ollamaMenu.addMenu(self.__localServerMenu)
+        self.__ollamaMenu.addSeparator()
         self.__ollamaMenu.addAction(self.tr("Configure..."), self.__ollamaConfigure)
 
         self.__ollamaMenu.aboutToShow.connect(self.__aboutToShowOllamaMenu)
@@ -638,6 +659,16 @@
         """
         self.__clearHistoriesAct.setEnabled(self.__chatHistoryLayout.count() > 1)
 
+        self.__localServerStartMonitorAct.setEnabled(
+            self.__localServerProcess is None and self.__localServerDialog is None
+        )
+        self.__startLocalServerAct.setEnabled(
+            self.__localServerProcess is None and self.__localServerDialog is None
+        )
+        self.__stopLocalServerAct.setEnabled(
+            self.__localServerProcess is not None and self.__localServerDialog is None
+        )
+
     @pyqtSlot()
     def __ollamaConfigure(self):
         """
@@ -717,3 +748,76 @@
                     if historyWidget is not None:
                         entries[hid] = historyWidget.saveToJson()
                 self.__saveChatHistoryFile(fileName, entries)
+
+    def prepareServerRuntimeEnvironment(self):
+        """
+        Public method to prepare a QProcessEnvironment object.
+
+        @return prepared environment object to be used with QProcess
+        @rtype QProcessEnvironment
+        """
+        env = QProcessEnvironment.systemEnvironment()
+        env.insert(
+            "OLLAMA_HOST",
+            "127.0.0.1:{0}".format(self.__plugin.getPreferences("OllamaLocalPort")),
+        )
+
+        return env
+
+    @pyqtSlot()
+    def __startLocalServerMonitoring(self):
+        """
+        Private slot to open a dialog for running a local 'ollama' server instance
+        and monitor its output.
+        """
+        # TODO: not implemented yet
+        from .RunOllamaServerDialog import RunOllamaServerDialog
+
+        self.__localServerDialog = RunOllamaServerDialog(
+            self.__client, self.__plugin, self
+        )
+        self.__localServerDialog.serverStarted.connect(self.__serverStarted)
+        self.__localServerDialog.serverStopped.connect(self.__serverStopped)
+        self.__localServerDialog.finished.connect(self.__serverDialogClosed)
+        self.__localServerDialog.show()
+        self.__localServerDialog.startServer()
+
+    @pyqtSlot()
+    def __startLocalServer(self):
+        """
+        Private slot to start a local 'ollama' server instance in the background.
+        """
+        # TODO: not implemented yet
+        pass
+
+    @pyqtSlot()
+    def __stopLocalServer(self):
+        """
+        Private slot to stop a running local 'ollama' server instance.
+        """
+        # TODO: not implemented yet
+        pass
+
+    @pyqtSlot()
+    def __serverStarted(self):
+        """
+        Private slot to handle the start of a local server.
+        """
+        self.__client.setMode(True)
+        self.on_reloadModelsButton_clicked()
+
+    @pyqtSlot()
+    def __serverStopped(self):
+        """
+        Private slot to handle the stopping of a local server.
+        """
+        self.__client.setMode(False)
+        self.on_reloadModelsButton_clicked()
+
+    @pyqtSlot()
+    def __serverDialogClosed(self):
+        """
+        Private slot handling the closing of the local server dialog.
+        """
+        self.__localServerDialog.deleteLater()
+        self.__localServerDialog = None
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/RunOllamaServerDialog.py	Thu Aug 08 18:33:49 2024 +0200
@@ -0,0 +1,155 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2020 - 2024 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a dialog to run the ollama server locally.
+"""
+
+from PyQt6.QtCore import QProcess, Qt, QTimer, pyqtSignal, pyqtSlot
+from PyQt6.QtWidgets import QDialog, QDialogButtonBox
+
+from eric7.EricWidgets import EricMessageBox
+
+from .Ui_RunOllamaServerDialog import Ui_RunOllamaServerDialog
+
+
+class RunOllamaServerDialog(QDialog, Ui_RunOllamaServerDialog):
+    """
+    Class implementing a dialog to run the ollama server locally.
+
+    @signal serverStarted() emitted after the start of the 'ollama' server
+    @signal serverStopped() emitted after the 'ollama' server was stopped
+    """
+
+    serverStarted = pyqtSignal()
+    serverStopped = pyqtSignal()
+
+    def __init__(self, ollamaClient, plugin, parent=None):
+        """
+        Constructor
+
+        @param ollamaClient reference to the 'ollama' client object
+        @type OllamaClient
+        @param plugin reference to the plug-in object
+        @type PluginOllamaInterface
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        super().__init__(parent)
+        self.setupUi(self)
+
+        self.__plugin = plugin
+        self.__ui = parent
+        self.__client = ollamaClient
+
+        self.__process = None
+
+        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
+        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
+
+        self.__defaultTextFormat = self.outputEdit.currentCharFormat()
+
+    def startServer(self):
+        """
+        Public method to start the ollama server process.
+
+        @return flag indicating success
+        @rtype bool
+        """
+        env = self.__ui.prepareServerRuntimeEnvironment()
+        self.__process = QProcess()
+        self.__process.setProcessEnvironment(env)
+        self.__process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
+
+        self.__process.readyReadStandardOutput.connect(self.__readStdOut)
+        self.__process.finished.connect(self.__processFinished)
+
+        self.outputEdit.clear()
+
+        command = "ollama"
+        args = ["serve"]
+
+        self.__process.start(command, args)
+        ok = self.__process.waitForStarted(10000)
+        if not ok:
+            EricMessageBox.critical(
+                None,
+                self.tr("Run Local ollama Server"),
+                self.tr("""The loacl ollama server process could not be started."""),
+            )
+        else:
+            self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(
+                False
+            )
+            self.stopServerButton.setEnabled(True)
+            self.stopServerButton.setDefault(True)
+            self.restartServerButton.setEnabled(True)
+
+            self.serverStarted.emit()
+
+        return ok
+
+    def closeEvent(self, evt):
+        """
+        Protected method handling a close event.
+
+        @param evt reference to the close event
+        @type QCloseEvent
+        """
+        self.on_stopServerButton_clicked()
+        evt.accept()
+
+    @pyqtSlot()
+    def __readStdOut(self):
+        """
+        Private slot to add the server process output to the output pane.
+        """
+        if self.__process is not None:
+            out = str(self.__process.readAllStandardOutput(), "utf-8")
+            self.outputEdit.insertPlainText(out)
+
+    @pyqtSlot()
+    def __processFinished(self):
+        """
+        Private slot handling the finishing of the server process.
+        """
+        if (
+            self.__process is not None
+            and self.__process.state() != QProcess.ProcessState.NotRunning
+        ):
+            self.__process.terminate()
+            QTimer.singleShot(2000, self.__process.kill)
+            self.__process.waitForFinished(3000)
+
+        self.__process = None
+
+        self.restartServerButton.setEnabled(True)
+        self.stopServerButton.setEnabled(False)
+        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
+        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
+        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setFocus(
+            Qt.FocusReason.OtherFocusReason
+        )
+
+        self.serverStopped.emit()
+
+    @pyqtSlot()
+    def on_stopServerButton_clicked(self):
+        """
+        Private slot to stop the running server.
+        """
+        self.__process.terminate()
+
+    @pyqtSlot()
+    def on_restartServerButton_clicked(self):
+        """
+        Private slot to re-start the ollama server.
+        """
+        # step 1: stop the current server
+        if self.__process is not None:
+            self.on_stopServerButton_clicked()
+
+        # step 2: start a new server
+        self.startServer()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/RunOllamaServerDialog.ui	Thu Aug 08 18:33:49 2024 +0200
@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>RunOllamaServerDialog</class>
+ <widget class="QDialog" name="RunOllamaServerDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>600</width>
+    <height>500</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>ollama Server</string>
+  </property>
+  <property name="sizeGripEnabled">
+   <bool>true</bool>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout_2">
+   <item>
+    <widget class="QGroupBox" name="groupBox">
+     <property name="title">
+      <string>Output</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout">
+      <item>
+       <widget class="QPlainTextEdit" name="outputEdit">
+        <property name="sizePolicy">
+         <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+          <horstretch>0</horstretch>
+          <verstretch>0</verstretch>
+         </sizepolicy>
+        </property>
+        <property name="lineWrapMode">
+         <enum>QPlainTextEdit::WidgetWidth</enum>
+        </property>
+        <property name="readOnly">
+         <bool>true</bool>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <item>
+      <widget class="QPushButton" name="restartServerButton">
+       <property name="enabled">
+        <bool>false</bool>
+       </property>
+       <property name="toolTip">
+        <string>Press to restart the loacl ollama server.</string>
+       </property>
+       <property name="text">
+        <string>Re-Start Server</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="stopServerButton">
+       <property name="enabled">
+        <bool>false</bool>
+       </property>
+       <property name="toolTip">
+        <string>Press to stop the running ollama server.</string>
+       </property>
+       <property name="text">
+        <string>Stop Server</string>
+       </property>
+      </widget>
+     </item>
+     <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="QDialogButtonBox" name="buttonBox">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="standardButtons">
+        <set>QDialogButtonBox::Close</set>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <tabstops>
+  <tabstop>outputEdit</tabstop>
+  <tabstop>restartServerButton</tabstop>
+  <tabstop>stopServerButton</tabstop>
+ </tabstops>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>RunOllamaServerDialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>505</x>
+     <y>474</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>593</x>
+     <y>419</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OllamaInterface/Ui_RunOllamaServerDialog.py	Thu Aug 08 18:33:49 2024 +0200
@@ -0,0 +1,66 @@
+# Form implementation generated from reading ui file 'OllamaInterface/RunOllamaServerDialog.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_RunOllamaServerDialog(object):
+    def setupUi(self, RunOllamaServerDialog):
+        RunOllamaServerDialog.setObjectName("RunOllamaServerDialog")
+        RunOllamaServerDialog.resize(600, 500)
+        RunOllamaServerDialog.setSizeGripEnabled(True)
+        self.verticalLayout_2 = QtWidgets.QVBoxLayout(RunOllamaServerDialog)
+        self.verticalLayout_2.setObjectName("verticalLayout_2")
+        self.groupBox = QtWidgets.QGroupBox(parent=RunOllamaServerDialog)
+        self.groupBox.setObjectName("groupBox")
+        self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox)
+        self.verticalLayout.setObjectName("verticalLayout")
+        self.outputEdit = QtWidgets.QPlainTextEdit(parent=self.groupBox)
+        sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
+        sizePolicy.setHorizontalStretch(0)
+        sizePolicy.setVerticalStretch(0)
+        sizePolicy.setHeightForWidth(self.outputEdit.sizePolicy().hasHeightForWidth())
+        self.outputEdit.setSizePolicy(sizePolicy)
+        self.outputEdit.setLineWrapMode(QtWidgets.QPlainTextEdit.LineWrapMode.WidgetWidth)
+        self.outputEdit.setReadOnly(True)
+        self.outputEdit.setObjectName("outputEdit")
+        self.verticalLayout.addWidget(self.outputEdit)
+        self.verticalLayout_2.addWidget(self.groupBox)
+        self.horizontalLayout = QtWidgets.QHBoxLayout()
+        self.horizontalLayout.setObjectName("horizontalLayout")
+        self.restartServerButton = QtWidgets.QPushButton(parent=RunOllamaServerDialog)
+        self.restartServerButton.setEnabled(False)
+        self.restartServerButton.setObjectName("restartServerButton")
+        self.horizontalLayout.addWidget(self.restartServerButton)
+        self.stopServerButton = QtWidgets.QPushButton(parent=RunOllamaServerDialog)
+        self.stopServerButton.setEnabled(False)
+        self.stopServerButton.setObjectName("stopServerButton")
+        self.horizontalLayout.addWidget(self.stopServerButton)
+        spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
+        self.horizontalLayout.addItem(spacerItem)
+        self.buttonBox = QtWidgets.QDialogButtonBox(parent=RunOllamaServerDialog)
+        self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
+        self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Close)
+        self.buttonBox.setObjectName("buttonBox")
+        self.horizontalLayout.addWidget(self.buttonBox)
+        self.verticalLayout_2.addLayout(self.horizontalLayout)
+
+        self.retranslateUi(RunOllamaServerDialog)
+        self.buttonBox.rejected.connect(RunOllamaServerDialog.reject) # type: ignore
+        QtCore.QMetaObject.connectSlotsByName(RunOllamaServerDialog)
+        RunOllamaServerDialog.setTabOrder(self.outputEdit, self.restartServerButton)
+        RunOllamaServerDialog.setTabOrder(self.restartServerButton, self.stopServerButton)
+
+    def retranslateUi(self, RunOllamaServerDialog):
+        _translate = QtCore.QCoreApplication.translate
+        RunOllamaServerDialog.setWindowTitle(_translate("RunOllamaServerDialog", "ollama Server"))
+        self.groupBox.setTitle(_translate("RunOllamaServerDialog", "Output"))
+        self.restartServerButton.setToolTip(_translate("RunOllamaServerDialog", "Press to restart the loacl ollama server."))
+        self.restartServerButton.setText(_translate("RunOllamaServerDialog", "Re-Start Server"))
+        self.stopServerButton.setToolTip(_translate("RunOllamaServerDialog", "Press to stop the running ollama server."))
+        self.stopServerButton.setText(_translate("RunOllamaServerDialog", "Stop Server"))
--- a/PluginAiOllama.epj	Wed Aug 07 18:19:25 2024 +0200
+++ b/PluginAiOllama.epj	Thu Aug 08 18:33:49 2024 +0200
@@ -200,7 +200,8 @@
     "FORMS": [
       "OllamaInterface/OllamaChatWidget.ui",
       "OllamaInterface/OllamaHistoryWidget.ui",
-      "OllamaInterface/OllamaWidget.ui"
+      "OllamaInterface/OllamaWidget.ui",
+      "OllamaInterface/RunOllamaServerDialog.ui"
     ],
     "HASH": "92d9e369bad01266911c1d6eefedae578e76ceb4",
     "IDLPARAMS": {
@@ -297,9 +298,11 @@
       "OllamaInterface/OllamaClient.py",
       "OllamaInterface/OllamaHistoryWidget.py",
       "OllamaInterface/OllamaWidget.py",
+      "OllamaInterface/RunOllamaServerDialog.py",
       "OllamaInterface/Ui_OllamaChatWidget.py",
       "OllamaInterface/Ui_OllamaHistoryWidget.py",
       "OllamaInterface/Ui_OllamaWidget.py",
+      "OllamaInterface/Ui_RunOllamaServerDialog.py",
       "OllamaInterface/__init__.py",
       "PluginAiOllama.py",
       "__init__.py"
--- a/PluginAiOllama.py	Wed Aug 07 18:19:25 2024 +0200
+++ b/PluginAiOllama.py	Thu Aug 08 18:33:49 2024 +0200
@@ -127,6 +127,7 @@
             "OllamaScheme": "http",
             "OllamaHost": "localhost",
             "OllamaPort": 11434,
+            "OllamaLocalPort": 11435,  # port for locally started ollama server
             "OllamaHeartbeatInterval": 5,  # 5 seconds heartbeat time; 0 = disabled
             "StreamingChatResponse": True,
         }
@@ -268,7 +269,7 @@
         @return the requested setting value
         @rtype Any
         """
-        if key in ("OllamaPort", "OllamaHeartbeatInterval"):
+        if key in ("OllamaPort", "OllamaLocalPort", "OllamaHeartbeatInterval"):
             return int(
                 Preferences.Prefs.settings.value(
                     self.PreferencesKey + "/" + key, self.__defaults[key]

eric ide

mercurial