|
1 # -*- coding: utf-8 -*- |
|
2 # Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> |
|
3 # |
|
4 |
|
5 """ |
|
6 Module implementing the main ollama interface widget. |
|
7 """ |
|
8 |
|
9 import json |
|
10 import os |
|
11 |
|
12 from PyQt6.QtCore import Qt, pyqtSlot |
|
13 from PyQt6.QtWidgets import QInputDialog, QLineEdit, QVBoxLayout, QWidget |
|
14 |
|
15 from eric7 import Globals |
|
16 from eric7.EricGui import EricPixmapCache |
|
17 from eric7.EricWidgets import EricMessageBox |
|
18 |
|
19 from .OllamaClient import OllamaClient |
|
20 from .OllamaHistoryWidget import OllamaHistoryWidget |
|
21 from .Ui_OllamaWidget import Ui_OllamaWidget |
|
22 |
|
23 |
|
24 class OllamaWidget(QWidget, Ui_OllamaWidget): |
|
25 """ |
|
26 Class implementing the main ollama interface widget. |
|
27 """ |
|
28 |
|
29 OllamaHistoryFile = "ollama_history.json" |
|
30 |
|
31 def __init__(self, plugin, fromEric=True, parent=None): |
|
32 """ |
|
33 Constructor |
|
34 |
|
35 @param plugin reference to the plug-in object |
|
36 @type PluginOllamaInterface |
|
37 @param fromEric flag indicating the eric-ide mode (defaults to True) |
|
38 (True = eric-ide mode, False = application mode) |
|
39 @type bool (optional) |
|
40 @param parent reference to the parent widget (defaults to None) |
|
41 @type QWidget (optional) |
|
42 """ |
|
43 super().__init__(parent) |
|
44 self.setupUi(self) |
|
45 |
|
46 self.__plugin = plugin |
|
47 self.__client = OllamaClient(plugin, self) |
|
48 |
|
49 if fromEric: |
|
50 self.layout().setContentsMargins(0, 3, 0, 0) |
|
51 else: |
|
52 self.layout().setContentsMargins(0, 0, 0, 0) |
|
53 |
|
54 self.ollamaMenuButton.setIcon(EricPixmapCache.getIcon("superMenu")) |
|
55 self.reloadModelsButton.setIcon(EricPixmapCache.getIcon("reload")) |
|
56 self.newChatButton.setIcon(EricPixmapCache.getIcon("plus")) |
|
57 |
|
58 self.__chatHistoryLayout = QVBoxLayout() |
|
59 self.historyScrollWidget.setLayout(self.__chatHistoryLayout) |
|
60 self.__chatHistoryLayout.addStretch(1) |
|
61 |
|
62 self.mainSplitter.setSizes([200, 2000]) |
|
63 |
|
64 self.newChatButton.setEnabled(False) |
|
65 self.__handleServerStateChanged(False) |
|
66 |
|
67 self.__connectClient() |
|
68 |
|
69 self.__loadHistory() |
|
70 |
|
71 def __connectClient(self): |
|
72 """ |
|
73 Private method to connect the client signals. |
|
74 """ |
|
75 self.__client.serverStateChanged.connect(self.__handleServerStateChanged) |
|
76 self.__client.serverVersion.connect(self.__setHeaderLabel) |
|
77 self.__client.modelsList.connect(self.__populateModelSelector) |
|
78 |
|
79 @pyqtSlot(bool) |
|
80 def __handleServerStateChanged(self, ok): |
|
81 """ |
|
82 Private slot handling a change in the 'ollama' server responsiveness. |
|
83 |
|
84 @param ok flag indicating a responsive 'ollama' server |
|
85 @type bool |
|
86 """ |
|
87 if ok: |
|
88 self.__finishSetup() |
|
89 else: |
|
90 self.ollamaVersionLabel.setText( |
|
91 self.tr("<b>Error: The configured server is not responding.</b>") |
|
92 ) |
|
93 self.setEnabled(ok) |
|
94 |
|
95 @pyqtSlot() |
|
96 def __finishSetup(self): |
|
97 """ |
|
98 Private slot to finish the UI setup. |
|
99 """ |
|
100 self.__client.version() |
|
101 self.__client.list() |
|
102 |
|
103 @pyqtSlot() |
|
104 def on_reloadModelsButton_clicked(self): |
|
105 """ |
|
106 Private slot to reload the list of available models. |
|
107 """ |
|
108 self.__client.list() |
|
109 |
|
110 @pyqtSlot(str) |
|
111 def on_modelComboBox_currentTextChanged(self, model): |
|
112 """ |
|
113 Private slot handling the selection of a model. |
|
114 |
|
115 @param model name of the selected model |
|
116 @type str |
|
117 """ |
|
118 self.newChatButton.setEnabled(bool(model)) |
|
119 |
|
120 @pyqtSlot() |
|
121 def on_newChatButton_clicked(self): |
|
122 """ |
|
123 Private slot to start a new chat with the 'ollama' server. |
|
124 """ |
|
125 model = self.modelComboBox.currentText() |
|
126 if not model: |
|
127 EricMessageBox.critical( |
|
128 self, |
|
129 self.tr("New Chat"), |
|
130 self.tr("""A model has to be selected first. Aborting..."""), |
|
131 ) |
|
132 return |
|
133 |
|
134 title, ok = QInputDialog.getText( |
|
135 self, |
|
136 self.tr("New Chat"), |
|
137 self.tr("Enter a title for the new chat:"), |
|
138 QLineEdit.EchoMode.Normal, |
|
139 ) |
|
140 if ok and title: |
|
141 self.__createHistoryWidget(title, model) |
|
142 # TODO: create an empty chat widget for new chat |
|
143 |
|
144 ############################################################################ |
|
145 ## Methods handling signals from the 'ollama' client. |
|
146 ############################################################################ |
|
147 |
|
148 @pyqtSlot(str) |
|
149 def __setHeaderLabel(self, version): |
|
150 """ |
|
151 Private slot to receive the 'ollama' server version and set the header. |
|
152 |
|
153 @param version 'ollama' server version' |
|
154 @type str |
|
155 """ |
|
156 self.ollamaVersionLabel.setText( |
|
157 self.tr("<b>ollama Server Version {0}</b>").format(version) |
|
158 ) |
|
159 |
|
160 @pyqtSlot(list) |
|
161 def __populateModelSelector(self, modelNames): |
|
162 """ |
|
163 Private slot to receive the list of available model names and populate |
|
164 the model selector with them. |
|
165 |
|
166 @param modelNames list of model names |
|
167 @type list[str] |
|
168 """ |
|
169 self.modelComboBox.clear() |
|
170 |
|
171 self.modelComboBox.addItem("") |
|
172 self.modelComboBox.addItems(sorted(modelNames)) |
|
173 |
|
174 ############################################################################ |
|
175 ## Methods handling signals from the chat history widgets. |
|
176 ############################################################################ |
|
177 |
|
178 def __createHistoryWidget(self, title, model, jsonStr=None): |
|
179 """ |
|
180 Private method to create a chat history widget and insert it into the |
|
181 respective layout. |
|
182 |
|
183 @param title title of the chat |
|
184 @type str |
|
185 @param model name of the model |
|
186 @type str |
|
187 @param jsonStr string containing JSON serialize chat history data (defaults |
|
188 to None) |
|
189 @type str (optional) |
|
190 """ |
|
191 history = OllamaHistoryWidget(title=title, model=model, jsonStr=jsonStr) |
|
192 self.__chatHistoryLayout.insertWidget( |
|
193 self.__chatHistoryLayout.count() - 1, history |
|
194 ) |
|
195 |
|
196 scrollbar = self.historyScrollArea.verticalScrollBar() |
|
197 scrollbar.setMaximum(self.historyScrollWidget.height()) |
|
198 scrollbar.setValue(scrollbar.maximum()) |
|
199 |
|
200 history.deleteChatHistory.connect(self.__deleteHistory) |
|
201 history.dataChanged.connect(self.__saveHistory) |
|
202 history.newChatWithHistory.connect(self.__newChatWithHistory) |
|
203 |
|
204 self.__saveHistory() |
|
205 |
|
206 def __findHistoryWidgetIndex(self, uid): |
|
207 """ |
|
208 Private method to find the index of the reference history widget. |
|
209 |
|
210 @param uid ID of the history widget |
|
211 @type str |
|
212 @return index of the history widget |
|
213 @rtype int |
|
214 """ |
|
215 for index in range(self.__chatHistoryLayout.count() - 1): |
|
216 widget = self.__chatHistoryLayout.itemAt(index).widget() |
|
217 if widget.getId() == uid: |
|
218 return index |
|
219 |
|
220 return None |
|
221 |
|
222 def __historyFilePath(self): |
|
223 """ |
|
224 Private method to get the path name of the chat history file. |
|
225 |
|
226 @return file path of the chat history file |
|
227 @rtype str |
|
228 """ |
|
229 return os.path.join(Globals.getConfigDir(), OllamaWidget.OllamaHistoryFile) |
|
230 |
|
231 @pyqtSlot() |
|
232 def __saveHistory(self): |
|
233 """ |
|
234 Private method to save the current chat history to the history file. |
|
235 """ |
|
236 # step 1: collect all history entries |
|
237 entries = {} |
|
238 for index in range(self.__chatHistoryLayout.count() - 1): |
|
239 widget = self.__chatHistoryLayout.itemAt(index).widget() |
|
240 uid = widget.getId() |
|
241 entries[uid] = widget.saveToJson() |
|
242 |
|
243 # step 2: save the collected chat histories |
|
244 filePath = self.__historyFilePath() |
|
245 try: |
|
246 with open(filePath, "w") as f: |
|
247 json.dump(entries, f) |
|
248 except OSError as err: |
|
249 EricMessageBox.critical( |
|
250 self, |
|
251 self.tr("Save Chat History"), |
|
252 self.tr( |
|
253 "<p>The chat history could not be saved to <b>{0}</b>.</p>" |
|
254 "<p>Reason: {1}</p>" |
|
255 ).format(filePath, str(err)), |
|
256 ) |
|
257 |
|
258 def __loadHistory(self): |
|
259 """ |
|
260 Private method to load a previously saved history file. |
|
261 """ |
|
262 # step 1: load the history file, if it exists |
|
263 filePath = self.__historyFilePath() |
|
264 if not os.path.exists(filePath): |
|
265 return |
|
266 |
|
267 try: |
|
268 with open(filePath, "r") as f: |
|
269 entries = json.load(f) |
|
270 except OSError as err: |
|
271 EricMessageBox.critical( |
|
272 self, |
|
273 self.tr("Load Chat History"), |
|
274 self.tr( |
|
275 "<p>The chat history could not be loaded from <b>{0}</b>.</p>" |
|
276 "<p>Reason: {1}</p>" |
|
277 ).format(filePath, str(err)), |
|
278 ) |
|
279 return |
|
280 |
|
281 # step 2: create history widgets |
|
282 for uid in entries: |
|
283 self.__createHistoryWidget("", "", jsonStr=entries[uid]) |
|
284 |
|
285 def clearHistory(self): |
|
286 """ |
|
287 Public method to clear the history entries and close all chats. |
|
288 """ |
|
289 while self.__chatHistoryLayout.count() > 1: |
|
290 # do not delete the spacer at the end of the list |
|
291 item = self.__chatHistoryLayout.takeAt(0) |
|
292 if item is not None: |
|
293 item.widget().deleteLater() |
|
294 |
|
295 self.__saveHistory() |
|
296 |
|
297 @pyqtSlot(str) |
|
298 def __deleteHistory(self, uid): |
|
299 """ |
|
300 Private slot to delete the history with the given ID. |
|
301 |
|
302 @param uid ID of the history to be deleted |
|
303 @type str |
|
304 """ |
|
305 widgetIndex = self.__findHistoryWidgetIndex(uid) |
|
306 if widgetIndex is not None: |
|
307 item = self.__chatHistoryLayout.takeAt(widgetIndex) |
|
308 if item is not None: |
|
309 item.widget().deleteLater() |
|
310 |
|
311 self.__saveHistory() |
|
312 |
|
313 @pyqtSlot(str) |
|
314 def __newChatWithHistory(self, uid): |
|
315 """ |
|
316 Private slot to start a new chat using a previously saved history. |
|
317 |
|
318 @param uid ID of the history to be used |
|
319 @type str |
|
320 """ |
|
321 # TODO: not implemented yet |
|
322 pass |
|
323 |
|
324 ####################################################################### |
|
325 ## Menu related methods below |
|
326 ####################################################################### |
|
327 |
|
328 def __initOllamaMenu(self): |
|
329 """ |
|
330 Private method to create the super menu and attach it to the super |
|
331 menu button. |
|
332 """ |
|
333 # TODO: implement the menu and menu methods |
|
334 # * Clear Chat History |
|
335 # * Show Model Details |
|
336 # * Show Model Processes |
|
337 # * Pull Model |
|
338 # * Show Model Shop (via a web browser) |
|
339 # * Remove Model |