More refactoring to make conversation widget re-useable

This commit is contained in:
Kovid Goyal 2025-11-29 12:28:25 +05:30
parent 6aeb3ddfcc
commit ca724cc144
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
2 changed files with 45 additions and 25 deletions

View File

@ -5,6 +5,7 @@ from collections.abc import Iterator
from html import escape
from itertools import count
from threading import Thread
from typing import Any
from qt.core import (
QApplication,
@ -32,6 +33,7 @@ from calibre.customize import AIProviderPlugin
from calibre.gui2 import safe_open_url
from calibre.gui2.chat_widget import Button, ChatWidget, Header
from calibre.utils.icu import primary_sort_key
from calibre.utils.localization import ui_language_as_english
from calibre.utils.logging import ERROR, WARN
from calibre.utils.short_uuid import uuid4
from polyglot.binary import as_hex_unicode
@ -65,10 +67,9 @@ def show_reasoning(reasoning: str, parent: QWidget | None = None):
class ConversationHistory:
def __init__(self, conversation_text: str = ''):
def __init__(self):
self.accumulator = StreamedResponseAccumulator()
self.items: list[ChatMessage] = []
self.conversation_text: str = conversation_text
self.model_used = ''
self.api_call_active = False
self.current_response_completed = True
@ -92,7 +93,7 @@ class ConversationHistory:
self.items.append(x)
def copy(self, upto: int | None = None) -> 'ConversationHistory':
ans = ConversationHistory(self.conversation_text)
ans = ConversationHistory()
ans.model_used = self.model_used
if upto is None:
ans.items = list(self.items)
@ -216,6 +217,10 @@ class ConverseWidget(QWidget):
self.show_initial_message()
self.update_cost()
def language_instruction(self):
lang = ui_language_as_english()
return f'If you can speak in {lang}, then respond in {lang}.'
def quick_actions_as_html(self, actions) -> str:
actions = sorted(actions, key=lambda a: primary_sort_key(a.human_name))
if not actions:
@ -223,7 +228,7 @@ class ConverseWidget(QWidget):
ans = []
for action in actions:
hn = action.human_name.replace(' ', '\xa0')
ans.append(f'''<a title="{action.prompt_text()}"
ans.append(f'''<a title="{self.prompt_text_for_action(action)}"
href="http://{self.quick_action_hostname}/{as_hex_unicode(action.name)}"
style="text-decoration: none">{hn}</a>''')
links = '\xa0\xa0\xa0 '.join(ans)
@ -253,11 +258,6 @@ class ConverseWidget(QWidget):
if prompt := prompt.strip():
self.start_api_call(prompt)
def start_new_conversation(self):
self.clear_current_conversation()
self.latched_conversation_text = ''
self.update_ui_state()
@property
def assistant_name(self) -> str:
return self.ai_provider_plugin.human_readable_model_name(self.conversation_history.model_used) or _('Assistant')
@ -303,20 +303,19 @@ class ConverseWidget(QWidget):
def scroll_to_bottom(self) -> None:
self.result_display.scroll_to_bottom()
def start_api_call(self, action_prompt: str, uses_selected_text: bool = False):
def start_api_call(self, action_prompt: str, **kwargs: Any) -> None:
if not self.is_ready_for_use:
self.show_error(f'''<b>{_('AI provider not configured.')}</b> <a href="http://{self.configure_ai_hostname}">{_(
'Configure AI provider')}</a>''', is_critical=False)
return
if not self.latched_conversation_text:
self.show_error(f"<b>{_('Error')}:</b> {_('No text is selected for this conversation.')}", is_critical=True)
if err := self.ready_to_start_api_call():
self.show_error(f"<b>{_('Error')}:</b> {err}", is_critical=True)
return
if self.conversation_history:
self.conversation_history.append(ChatMessage(action_prompt))
else:
self.conversation_history.conversation_text = self.latched_conversation_text
for msg in self.create_initial_messages(action_prompt, self.latched_conversation_text if uses_selected_text else ''):
for msg in self.create_initial_messages(action_prompt, **kwargs):
self.conversation_history.append(msg)
self.current_api_call_number = next(self.counter)
self.conversation_history.new_api_call()
@ -454,7 +453,7 @@ class ConverseWidget(QWidget):
def handle_chat_link(self, qurl: QUrl) -> bool:
raise NotImplementedError('implement in subclass')
def create_initial_messages(self, action_prompt: str, selected_text: str) -> Iterator[ChatMessage]:
def create_initial_messages(self, action_prompt: str, **kwargs: Any) -> Iterator[ChatMessage]:
raise NotImplementedError('implement in sub class')
def ready_message(self) -> str:
@ -462,4 +461,14 @@ class ConverseWidget(QWidget):
def choose_action_message(self) -> str:
raise NotImplementedError('implement in sub class')
def prompt_text_for_action(self, action) -> str:
raise NotImplementedError('implement in sub class')
def start_new_conversation(self) -> None:
self.clear_current_conversation()
self.update_ui_state()
def ready_to_start_api_call(self) -> str:
return ''
# }}}

View File

@ -107,13 +107,6 @@ def current_actions(include_disabled=False):
yield x
def get_language_instruction() -> str:
if vprefs['llm_localized_results'] != 'always':
return ''
lang = ui_language_as_english()
return f'If you can speak in {lang}, then respond in {lang}.'
class LLMPanel(ConverseWidget):
add_note_requested = pyqtSignal(str, str)
@ -136,7 +129,7 @@ class LLMPanel(ConverseWidget):
self.book_authors = authors_to_string(authors)
def activate_action(self, action: Action) -> None:
self.start_api_call(action.prompt_text(self.latched_conversation_text), action.uses_selected_text)
self.start_api_call(self.prompt_text_for_action(action), uses_selected_text=action.uses_selected_text)
def settings_dialog(self) -> QDialog:
return LLMSettingsDialog(self)
@ -164,7 +157,13 @@ class LLMPanel(ConverseWidget):
yield Button('save.png', f'http://{self.save_note_hostname}/{msgnum}', _(
'Save this specific response as the note'))
def create_initial_messages(self, action_prompt: str, selected_text: str) -> Iterator[ChatMessage]:
def get_language_instruction(self) -> str:
if vprefs['llm_localized_results'] != 'always':
return ''
return self.language_instruction()
def create_initial_messages(self, action_prompt: str, **kwargs: Any) -> Iterator[ChatMessage]:
selected_text = self.latched_conversation_text if kwargs.get('uses_selected_text') else ''
if self.book_title:
context_header = f'I am currently reading the book: {self.book_title}'
if self.book_authors:
@ -174,7 +173,7 @@ class LLMPanel(ConverseWidget):
else:
context_header += '. I have some questions about this book.'
context_header += ' When you answer the questions use markdown formatting for the answers wherever possible.'
if language_instruction := get_language_instruction():
if language_instruction := self.get_language_instruction():
context_header += ' ' + language_instruction
yield ChatMessage(context_header, type=ChatMessageType.system)
yield ChatMessage(action_prompt)
@ -191,6 +190,9 @@ class LLMPanel(ConverseWidget):
msg += '<i>Summarize this book.</i>'
return msg
def prompt_text_for_action(self, action) -> str:
return action.prompt_text(self.latched_conversation_text)
def save_as_note(self):
if self.conversation_history.response_count > 0 and self.latched_conversation_text:
if not self.current_selected_text:
@ -221,6 +223,15 @@ class LLMPanel(ConverseWidget):
return True
return False
def start_new_conversation(self) -> None:
self.latched_conversation_text = ''
super().start_new_conversation()
def ready_to_start_api_call(self) -> str:
if self.latched_conversation_text:
return ''
return _('No text is selected for this conversation.')
# Settings {{{