diff --git a/src/calibre/gui2/viewer/llm.py b/src/calibre/gui2/viewer/llm.py index 3b77f8a0e8..6194831fc0 100644 --- a/src/calibre/gui2/viewer/llm.py +++ b/src/calibre/gui2/viewer/llm.py @@ -1,12 +1,13 @@ # License: GPL v3 Copyright: 2025, Amir Tehrani and Kovid Goyal +import string import textwrap from collections.abc import Callable, Iterator from functools import lru_cache from html import escape from itertools import count from threading import Thread -from typing import NamedTuple +from typing import Any, NamedTuple from qt.core import ( QAbstractItemView, @@ -26,6 +27,7 @@ from qt.core import ( QLocale, QPlainTextEdit, QPushButton, + QSize, QSizePolicy, Qt, QTabWidget, @@ -49,6 +51,7 @@ from calibre.gui2.viewer.config import vprefs from calibre.gui2.viewer.highlights import HighlightColorCombo from calibre.gui2.widgets2 import Dialog from calibre.utils.icu import primary_sort_key +from calibre.utils.localization import calibre_langcode_to_name, canonicalize_lang, get_lang from calibre.utils.logging import ERROR, WARN from calibre.utils.short_uuid import uuid4 from polyglot.binary import as_hex_unicode, from_hex_unicode @@ -70,24 +73,49 @@ def for_display_to_human(self: ChatMessage, is_initial_query: bool = False) -> s class Action(NamedTuple): name: str human_name: str - prompt_text: str + prompt_template: str is_builtin: bool = True is_disabled: bool = False @property - def as_custom_action_dict(self): - return {'disabled': self.is_disabled, 'title': self.human_name, 'prompt_text': self.prompt_text} + def as_custom_action_dict(self) -> dict[str, Any]: + return {'disabled': self.is_disabled, 'title': self.human_name, 'prompt_template': self.prompt_template} + + @property + def uses_selected_text(self) -> bool: + for _, fname, _, _ in string.Formatter().parse(self.prompt_template): + if fname == 'selected': + return True + return False + + def prompt_text(self, selected_text: str = '') -> str: + probably_has_multiple_words = len(selected_text) > 20 or ' ' in selected_text + pt = self.prompt_template + what = 'Text to analyze: ' if probably_has_multiple_words else 'Word to analyze: ' + if not probably_has_multiple_words: + match self.name: + case 'explain': + pt = 'Explain the meaning, etymology and common usages of the following word in simple, easy to understand language. {selected}' + case 'define': + pt = 'Explain the meaning and common usages of the following word. {selected}' + case 'translate': + pt = 'Translate the following word into the language {language}. {selected}' + + selected_text = (prompt_sep + what + selected_text) if selected_text else '' + return pt.format( + selected=selected_text, language=calibre_langcode_to_name(canonicalize_lang(get_lang()) or 'English', localize=False) + ).strip() @lru_cache(2) def default_actions() -> tuple[Action, ...]: return ( - Action('summarize', _('Summarize'), 'Provide a concise summary of the following text.'), - Action('explain', _('Explain'), 'Explain the following text in simple, easy-to-understand terms.'), - Action('points', _('Key points'), 'Extract the key points from the following text as a bulleted list.'), - Action('define', _('Define'), 'Identify and define any technical or complex terms in the following text.'), - Action('grammar', _('Fix grammar'), 'Correct any grammatical errors in the following text and provide the corrected version.'), - Action('english', _('As English'), 'Translate the following text into English.'), + Action('explain', _('Explain'), 'Explain the following text in simple, easy to understand language. {selected}'), + Action('define', _('Define'), 'Identify and define any technical or complex terms in the following text. {selected}'), + Action('summarize', _('Summarize'), 'Provide a concise summary of the following text. {selected}'), + Action('points', _('Key points'), 'Extract the key points from the following text as a bulleted list. {selected}'), + Action('grammar', _('Fix grammar'), 'Correct any grammatical errors in the following text and provide the corrected version. {selected}'), + Action('translate', _('Translate'), 'Translate the following text into the language {language}. {selected}'), ) @@ -289,14 +317,14 @@ class LLMPanel(QWidget): ans = [] for action in actions: hn = action.human_name.replace(' ', '\xa0') - ans.append(f'''{hn}''') links = '\xa0\xa0\xa0 '.join(ans) return f'

{_("Quick actions")}

{links}' def activate_action(self, action: Action) -> None: - self.start_api_call(action.prompt_text) + self.start_api_call(action.prompt_text(self.latched_conversation_text), action.uses_selected_text) def show_settings(self): LLMSettingsDialog(self).exec() @@ -396,11 +424,14 @@ class LLMPanel(QWidget): context_header = f'I am currently reading the book: {self.book_title}' if self.book_authors: context_header += f' by {self.book_authors}' - context_header += '. I have some questions about content from this book.' + if selected_text: + context_header += '. I have some questions about content from this book.' + else: + context_header += '. I have some questions about this book.' yield ChatMessage(context_header, type=ChatMessageType.system) - yield ChatMessage(f'{action_prompt}{prompt_sep}Text to analyze: {selected_text}') + yield ChatMessage(action_prompt) - def start_api_call(self, action_prompt): + def start_api_call(self, action_prompt: str, uses_selected_text: bool = False): if not self.is_ready_for_use: self.show_error(f'''{_('AI provider not configured.')} {_( 'Configure AI provider')}''', is_critical=False) @@ -409,11 +440,12 @@ class LLMPanel(QWidget): self.show_error(f"{_('Error')}: {_('No text is selected for this conversation.')}", is_critical=True) return - if not self.conversation_history: + 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.conversation_history.conversation_text): + for msg in self.create_initial_messages(action_prompt, self.latched_conversation_text if uses_selected_text else ''): self.conversation_history.append(msg) - self.conversation_history.append(ChatMessage(action_prompt)) self.current_api_call_number = next(self.counter) self.conversation_history.new_api_call() Thread(name='LLMAPICall', daemon=True, target=self.do_api_call, args=( @@ -459,7 +491,7 @@ class LLMPanel(QWidget): msg = f"

{_('Selected text')}

{st}" msg += self.quick_actions_as_html msg += '

' + _('Or, type a question to the AI below, for example:') + '
' - msg += 'Explain the etymology of the following text' + msg += 'Summarize this book.' self.result_display.show_message(msg) else: self.show_initial_message() @@ -568,16 +600,28 @@ class ActionEditDialog(QDialog): self.prompt_edit.setMinimumHeight(100) self.layout.addRow(_('Name:'), self.name_edit) self.layout.addRow(_('Prompt:'), self.prompt_edit) + self.help_label = la = QLabel('

' + _( + 'The prompt is a template. If you want the prompt to operate on the currently selected' + ' text, add {0} to the end of the prompt. Similarly, use {1}' + ' when you want the AI to respond in the current language (not all AIs work well with all languages).' + ).format('selected', 'Respond in {language}')) + la.setWordWrap(True) + self.layout.addRow(la) self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) self.layout.addWidget(self.button_box) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) if action is not None: self.name_edit.setText(action.human_name) - self.prompt_edit.setPlainText(action.prompt_text) + self.prompt_edit.setPlainText(action.prompt_template) self.name_edit.installEventFilter(self) self.prompt_edit.installEventFilter(self) + def sizeHint(self) -> QSize: + ans = super().sizeHint() + ans.setWidth(max(500, ans.width())) + return ans + def eventFilter(self, obj, event): if event.type() == QEvent.Type.KeyPress: if obj is self.name_edit and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): @@ -638,7 +682,7 @@ class LLMSettingsWidget(QWidget): item = QListWidgetItem(ac.human_name, self.actions_list) item.setData(Qt.ItemDataRole.UserRole, ac) item.setCheckState(Qt.CheckState.Unchecked if ac.is_disabled else Qt.CheckState.Checked) - item.setToolTip(textwrap.fill(ac.prompt_text)) + item.setToolTip(textwrap.fill(ac.prompt_template)) def load_actions_from_prefs(self): self.actions_list.clear() @@ -649,7 +693,7 @@ class LLMSettingsWidget(QWidget): dialog = ActionEditDialog(parent=self) if dialog.exec() == QDialog.DialogCode.Accepted: action = dialog.get_action() - if action.human_name and action.prompt_text: + if action.human_name and action.prompt_template: self.action_as_item(action) def edit_action(self): @@ -663,7 +707,7 @@ class LLMSettingsWidget(QWidget): dialog = ActionEditDialog(action, parent=self) if dialog.exec() == QDialog.DialogCode.Accepted: new_action = dialog.get_action() - if new_action.human_name and new_action.prompt_text: + if new_action.human_name and new_action.prompt_template: item.setText(new_action.human_name) item.setData(Qt.ItemDataRole.UserRole, new_action)