OllamaInterface/OllamaWidget.py

changeset 5
6e8af43d537d
parent 4
7dd1b9cd3150
child 6
d8064fb63eac
equal deleted inserted replaced
4:7dd1b9cd3150 5:6e8af43d537d
7 """ 7 """
8 8
9 import json 9 import json
10 import os 10 import os
11 11
12 from PyQt6.QtCore import Qt, pyqtSlot 12 from PyQt6.QtCore import Qt, QTimer, pyqtSlot
13 from PyQt6.QtWidgets import QInputDialog, QLineEdit, QVBoxLayout, QWidget 13 from PyQt6.QtWidgets import QInputDialog, QLineEdit, QVBoxLayout, QWidget
14 14
15 from eric7 import Globals 15 from eric7 import Globals
16 from eric7.EricGui import EricPixmapCache 16 from eric7.EricGui import EricPixmapCache
17 from eric7.EricWidgets import EricMessageBox 17 from eric7.EricWidgets import EricMessageBox
18 18 from eric7.EricWidgets.EricApplication import ericApp
19
20 from .OllamaChatWidget import OllamaChatWidget
19 from .OllamaClient import OllamaClient 21 from .OllamaClient import OllamaClient
20 from .OllamaHistoryWidget import OllamaHistoryWidget 22 from .OllamaHistoryWidget import OllamaHistoryWidget
21 from .Ui_OllamaWidget import Ui_OllamaWidget 23 from .Ui_OllamaWidget import Ui_OllamaWidget
22 24
23 25
49 if fromEric: 51 if fromEric:
50 self.layout().setContentsMargins(0, 3, 0, 0) 52 self.layout().setContentsMargins(0, 3, 0, 0)
51 else: 53 else:
52 self.layout().setContentsMargins(0, 0, 0, 0) 54 self.layout().setContentsMargins(0, 0, 0, 0)
53 55
56 iconSuffix = "-dark" if ericApp().usesDarkPalette() else "-light"
57
54 self.ollamaMenuButton.setIcon(EricPixmapCache.getIcon("superMenu")) 58 self.ollamaMenuButton.setIcon(EricPixmapCache.getIcon("superMenu"))
55 self.reloadModelsButton.setIcon(EricPixmapCache.getIcon("reload")) 59 self.reloadModelsButton.setIcon(EricPixmapCache.getIcon("reload"))
56 self.newChatButton.setIcon(EricPixmapCache.getIcon("plus")) 60 self.newChatButton.setIcon(EricPixmapCache.getIcon("plus"))
61 self.sendButton.setIcon(
62 EricPixmapCache.getIcon(
63 os.path.join("OllamaInterface", "icons", "send{0}".format(iconSuffix))
64 )
65 )
57 66
58 self.__chatHistoryLayout = QVBoxLayout() 67 self.__chatHistoryLayout = QVBoxLayout()
59 self.historyScrollWidget.setLayout(self.__chatHistoryLayout) 68 self.historyScrollWidget.setLayout(self.__chatHistoryLayout)
60 self.__chatHistoryLayout.addStretch(1) 69 self.__chatHistoryLayout.addStretch(1)
61 70
64 self.newChatButton.setEnabled(False) 73 self.newChatButton.setEnabled(False)
65 self.__handleServerStateChanged(False) 74 self.__handleServerStateChanged(False)
66 75
67 self.__connectClient() 76 self.__connectClient()
68 77
78 self.sendButton.clicked.connect(self.__sendMessage)
79 self.messageEdit.returnPressed.connect(self.__sendMessage)
80
69 self.__loadHistory() 81 self.__loadHistory()
82 self.__updateMessageEditState()
70 83
71 def __connectClient(self): 84 def __connectClient(self):
72 """ 85 """
73 Private method to connect the client signals. 86 Private method to connect the client signals.
74 """ 87 """
75 self.__client.serverStateChanged.connect(self.__handleServerStateChanged) 88 self.__client.serverStateChanged.connect(self.__handleServerStateChanged)
76 self.__client.serverVersion.connect(self.__setHeaderLabel) 89 self.__client.serverVersion.connect(self.__setHeaderLabel)
77 self.__client.modelsList.connect(self.__populateModelSelector) 90 self.__client.modelsList.connect(self.__populateModelSelector)
91 self.__client.replyReceived.connect(self.__handleServerMessage)
78 92
79 @pyqtSlot(bool) 93 @pyqtSlot(bool)
80 def __handleServerStateChanged(self, ok): 94 def __handleServerStateChanged(self, ok):
81 """ 95 """
82 Private slot handling a change in the 'ollama' server responsiveness. 96 Private slot handling a change in the 'ollama' server responsiveness.
115 @param model name of the selected model 129 @param model name of the selected model
116 @type str 130 @type str
117 """ 131 """
118 self.newChatButton.setEnabled(bool(model)) 132 self.newChatButton.setEnabled(bool(model))
119 133
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 ############################################################################ 134 ############################################################################
145 ## Methods handling signals from the 'ollama' client. 135 ## Methods handling signals from the 'ollama' client.
146 ############################################################################ 136 ############################################################################
147 137
148 @pyqtSlot(str) 138 @pyqtSlot(str)
185 @param model name of the model 175 @param model name of the model
186 @type str 176 @type str
187 @param jsonStr string containing JSON serialize chat history data (defaults 177 @param jsonStr string containing JSON serialize chat history data (defaults
188 to None) 178 to None)
189 @type str (optional) 179 @type str (optional)
180 @return reference to the created history widget
181 @rtype OllamaHistoryWidget
190 """ 182 """
191 history = OllamaHistoryWidget(title=title, model=model, jsonStr=jsonStr) 183 history = OllamaHistoryWidget(title=title, model=model, jsonStr=jsonStr)
192 self.__chatHistoryLayout.insertWidget( 184 self.__chatHistoryLayout.insertWidget(
193 self.__chatHistoryLayout.count() - 1, history 185 self.__chatHistoryLayout.count() - 1, history
194 ) 186 )
195 187
188 history.deleteChatHistory.connect(self.__deleteHistory)
189 history.dataChanged.connect(self.__saveHistory)
190 history.newChatWithHistory.connect(self.__newChatWithHistory)
191
192 self.__saveHistory()
193
194 QTimer.singleShot(0, self.__scrollHistoryToBottom)
195
196 return history
197
198 @pyqtSlot()
199 def __scrollHistoryToBottom(self):
200 """
201 Private slot to scroll the history widget to the bottom.
202 """
196 scrollbar = self.historyScrollArea.verticalScrollBar() 203 scrollbar = self.historyScrollArea.verticalScrollBar()
197 scrollbar.setMaximum(self.historyScrollWidget.height()) 204 scrollbar.setMaximum(self.historyScrollWidget.height())
198 scrollbar.setValue(scrollbar.maximum()) 205 scrollbar.setValue(scrollbar.maximum())
199 206
200 history.deleteChatHistory.connect(self.__deleteHistory) 207 def __findHistoryWidget(self, hid):
201 history.dataChanged.connect(self.__saveHistory) 208 """
202 history.newChatWithHistory.connect(self.__newChatWithHistory) 209 Private method to find the widget of a given chat history ID.
203 210
204 self.__saveHistory() 211 @param hid ID of the chat history
205 212 @type str
206 def __findHistoryWidgetIndex(self, uid): 213 @return reference to the chat history widget
207 """ 214 @rtype OllamaHistoryWidget
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 """
215 for index in range(self.__chatHistoryLayout.count() - 1): 216 for index in range(self.__chatHistoryLayout.count() - 1):
216 widget = self.__chatHistoryLayout.itemAt(index).widget() 217 widget = self.__chatHistoryLayout.itemAt(index).widget()
217 if widget.getId() == uid: 218 if widget.getId() == hid:
218 return index 219 return widget
219 220
220 return None 221 return None
221 222
222 def __historyFilePath(self): 223 def __historyFilePath(self):
223 """ 224 """
235 """ 236 """
236 # step 1: collect all history entries 237 # step 1: collect all history entries
237 entries = {} 238 entries = {}
238 for index in range(self.__chatHistoryLayout.count() - 1): 239 for index in range(self.__chatHistoryLayout.count() - 1):
239 widget = self.__chatHistoryLayout.itemAt(index).widget() 240 widget = self.__chatHistoryLayout.itemAt(index).widget()
240 uid = widget.getId() 241 hid = widget.getId()
241 entries[uid] = widget.saveToJson() 242 entries[hid] = widget.saveToJson()
242 243
243 # step 2: save the collected chat histories 244 # step 2: save the collected chat histories
244 filePath = self.__historyFilePath() 245 filePath = self.__historyFilePath()
245 try: 246 try:
246 with open(filePath, "w") as f: 247 with open(filePath, "w") as f:
277 ).format(filePath, str(err)), 278 ).format(filePath, str(err)),
278 ) 279 )
279 return 280 return
280 281
281 # step 2: create history widgets 282 # step 2: create history widgets
282 for uid in entries: 283 for hid in entries:
283 self.__createHistoryWidget("", "", jsonStr=entries[uid]) 284 self.__createHistoryWidget("", "", jsonStr=entries[hid])
284 285
285 def clearHistory(self): 286 def clearHistory(self):
286 """ 287 """
287 Public method to clear the history entries and close all chats. 288 Public method to clear the history entries and close all chats.
288 """ 289 """
293 item.widget().deleteLater() 294 item.widget().deleteLater()
294 295
295 self.__saveHistory() 296 self.__saveHistory()
296 297
297 @pyqtSlot(str) 298 @pyqtSlot(str)
298 def __deleteHistory(self, uid): 299 def __deleteHistory(self, hid):
299 """ 300 """
300 Private slot to delete the history with the given ID. 301 Private slot to delete the history with the given ID.
301 302
302 @param uid ID of the history to be deleted 303 @param hid ID of the history to be deleted
303 @type str 304 @type str
304 """ 305 """
305 widgetIndex = self.__findHistoryWidgetIndex(uid) 306 widget = self.__findHistoryWidget(hid)
306 if widgetIndex is not None: 307 if widget is not None:
308 widgetIndex = self.__chatHistoryLayout.indexOf(widget)
307 item = self.__chatHistoryLayout.takeAt(widgetIndex) 309 item = self.__chatHistoryLayout.takeAt(widgetIndex)
308 if item is not None: 310 if item is not None:
309 item.widget().deleteLater() 311 item.widget().deleteLater()
310 312
311 self.__saveHistory() 313 self.__saveHistory()
314
315 self.__removeChatWidget(hid)
316
317 #######################################################################
318 ## Chat related methods below
319 #######################################################################
320
321 def __findChatWidget(self, hid):
322 """
323 Private method to find a chat widget given a chat history ID.
324
325 @param hid chat history ID
326 @type str
327 @return reference to the chat widget related to the given ID
328 @rtype OllamaChatWidget
329 """
330 for index in range(self.chatStackWidget.count()):
331 widget = self.chatStackWidget.widget(index)
332 if widget.getHistoryId() == hid:
333 return widget
334
335 return None
336
337 @pyqtSlot()
338 def on_newChatButton_clicked(self):
339 """
340 Private slot to start a new chat with the 'ollama' server.
341 """
342 model = self.modelComboBox.currentText()
343 if not model:
344 EricMessageBox.critical(
345 self,
346 self.tr("New Chat"),
347 self.tr("""A model has to be selected first. Aborting..."""),
348 )
349 return
350
351 title, ok = QInputDialog.getText(
352 self,
353 self.tr("New Chat"),
354 self.tr("Enter a title for the new chat:"),
355 QLineEdit.EchoMode.Normal,
356 )
357 if ok and title:
358 historyWidget = self.__createHistoryWidget(title, model)
359 hid = historyWidget.getId()
360 chatWidget = OllamaChatWidget(hid=hid, title=title, model=model)
361 index = self.chatStackWidget.addWidget(chatWidget)
362 self.chatStackWidget.setCurrentIndex(index)
363
364 self.__updateMessageEditState()
365 self.messageEdit.setFocus(Qt.FocusReason.OtherFocusReason)
312 366
313 @pyqtSlot(str) 367 @pyqtSlot(str)
314 def __newChatWithHistory(self, uid): 368 def __newChatWithHistory(self, hid):
315 """ 369 """
316 Private slot to start a new chat using a previously saved history. 370 Private slot to start a new chat using a previously saved history.
317 371
318 @param uid ID of the history to be used 372 @param hid ID of the history to be used
319 @type str 373 @type str
320 """ 374 """
321 # TODO: not implemented yet 375 chatWidget = self.__findChatWidget(hid)
322 pass 376 if chatWidget is None:
377 historyWidget = self.__findHistoryWidget(hid)
378 if historyWidget is None:
379 # Oops, treat it as a new chat.
380 self.on_newChatButton_clicked()
381 return
382
383 chatWidget = OllamaChatWidget(
384 hid=hid, title=historyWidget.getTitle(), model=historyWidget.getModel()
385 )
386 index = self.chatStackWidget.addWidget(chatWidget)
387 self.chatStackWidget.setCurrentIndex(index)
388 for message in historyWidget.getMessages():
389 chatWidget.addMessage(role=message["role"], message=message["content"])
390 else:
391 # simply switch to the already existing chatWidget
392 self.chatStackWidget.setCurrentWidget(chatWidget)
393
394 self.__updateMessageEditState()
395 self.messageEdit.setFocus(Qt.FocusReason.OtherFocusReason)
396
397 def __removeChatWidget(self, hid):
398 """
399 Private method to remove a chat widget given its chat history ID.
400
401 @param hid chat history ID
402 @type str
403 """
404 widget = self.__findChatWidget(hid)
405 if widget is not None:
406 self.chatStackWidget.removeWidget(widget)
407
408 @pyqtSlot()
409 def __updateMessageEditState(self):
410 """
411 Private slot to set the enabled state of the message line edit and the send
412 button.
413 """
414 chatActive = bool(self.chatStackWidget.count())
415 hasText = bool(self.messageEdit.text())
416
417 self.messageEdit.setEnabled(chatActive)
418 self.sendButton.setEnabled(chatActive and hasText)
419
420 @pyqtSlot(str)
421 def on_messageEdit_textChanged(self, msg):
422 """
423 Private slot to handle a change of the entered message.
424
425 @param msg text of the message line edit
426 @type str
427 """
428 self.sendButton.setEnabled(bool(msg))
429
430 @pyqtSlot()
431 def __sendMessage(self):
432 """
433 Private method to send the given message of the current chat to the
434 'ollama' server.
435
436 This sends the message with context (i.e. the history of the current chat).
437 """
438 msg = self.messageEdit.text()
439 if not msg:
440 # empty message => ignore
441 return
442
443 if not bool(self.chatStackWidget.count()):
444 # no current stack => ignore
445 return
446
447 # 1. determine hid of the current chat via chat stack widget
448 chatWidget = self.chatStackWidget.currentWidget()
449 hid = chatWidget.getHistoryId()
450
451 # 2. get chat history widget via hid from chat history widget
452 historyWidget = self.__findHistoryWidget(hid)
453 if historyWidget is not None:
454 # 3. append the message to the history
455 historyWidget.addToMessages("user", msg)
456
457 # 4. get the complete messages list from the history
458 messages = historyWidget.getMessages()
459
460 # 5. add the message to the current chat and an empty one
461 # for the response
462 chatWidget.addMessage("user", msg)
463 chatWidget.addMessage("assistant", "")
464
465 # 6. send the request via the client (non-streaming (?))
466 model = historyWidget.getModel()
467 self.__client.chat(
468 model=model,
469 messages=messages,
470 streaming=self.__plugin.getPreferences("StreamingChatResponse"),
471 )
472
473 # 7. clear the message editor and give input focus back
474 self.messageEdit.clear()
475 self.messageEdit.setFocus(Qt.FocusReason.OtherFocusReason)
476
477 @pyqtSlot(str, str, bool)
478 def __handleServerMessage(self, content, role, done):
479 """
480 Private slot handling an 'ollama' server chat response.
481
482 @param content message sent by the server
483 @type str
484 @param role role name
485 @type str
486 @param done flag indicating the last chat response
487 @type bool
488 """
489 if not bool(self.chatStackWidget.count()):
490 # no current stack => ignore
491 return
492
493 chatWidget = self.chatStackWidget.currentWidget()
494 chatWidget.appendMessage(content)
495 if done:
496 hid = chatWidget.getHistoryId()
497 historyWidget = self.__findHistoryWidget(hid)
498 if historyWidget is not None:
499 historyWidget.addToMessages(role, chatWidget.getRecentMessage())
323 500
324 ####################################################################### 501 #######################################################################
325 ## Menu related methods below 502 ## Menu related methods below
326 ####################################################################### 503 #######################################################################
327 504

eric ide

mercurial