diff --git a/src/calibre/gui2/llm.py b/src/calibre/gui2/llm.py index e9051dc874..bdf8fea0ea 100644 --- a/src/calibre/gui2/llm.py +++ b/src/calibre/gui2/llm.py @@ -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'''{hn}''') 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'''{_('AI provider not configured.')} {_( 'Configure AI provider')}''', is_critical=False) return - if not self.latched_conversation_text: - self.show_error(f"{_('Error')}: {_('No text is selected for this conversation.')}", is_critical=True) + if err := self.ready_to_start_api_call(): + self.show_error(f"{_('Error')}: {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 '' # }}} diff --git a/src/calibre/gui2/viewer/llm.py b/src/calibre/gui2/viewer/llm.py index a4e53fb672..f968746579 100644 --- a/src/calibre/gui2/viewer/llm.py +++ b/src/calibre/gui2/viewer/llm.py @@ -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 += 'Summarize this book.' 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 {{{