From a3ade0dfd25f03982baf52264cd6fd985a86875b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 30 Nov 2025 21:50:57 +0530 Subject: [PATCH] Implement basic settings for llm book --- src/calibre/ai/prefs.py | 1 + src/calibre/gui2/dialogs/llm_book.py | 78 +++++++++++++++------------- src/calibre/gui2/llm.py | 13 +++-- src/calibre/gui2/viewer/config.py | 1 - src/calibre/gui2/viewer/llm.py | 14 ++--- 5 files changed, 57 insertions(+), 50 deletions(-) diff --git a/src/calibre/ai/prefs.py b/src/calibre/ai/prefs.py index 11726b07ea..d5e0d1c917 100644 --- a/src/calibre/ai/prefs.py +++ b/src/calibre/ai/prefs.py @@ -19,6 +19,7 @@ def prefs() -> JSONConfig: ans = JSONConfig('ai', permissions=0o600) # make readable only by user as it stores secrets ans.defaults['providers'] = {} ans.defaults['purpose_map'] = {} + ans.defaults['llm_localized_results'] = 'never' return ans diff --git a/src/calibre/gui2/dialogs/llm_book.py b/src/calibre/gui2/dialogs/llm_book.py index a4fc2250a6..6832e13dbf 100644 --- a/src/calibre/gui2/dialogs/llm_book.py +++ b/src/calibre/gui2/dialogs/llm_book.py @@ -3,15 +3,14 @@ from collections.abc import Iterator from functools import lru_cache -from typing import Any, NamedTuple +from typing import Any from qt.core import QDialog, QUrl, QVBoxLayout, QWidget from calibre.ai import ChatMessage, ChatMessageType from calibre.ebooks.metadata.book.base import Metadata from calibre.gui2 import Application, gprefs -from calibre.gui2.llm import ConverseWidget -from calibre.gui2.viewer.config import vprefs +from calibre.gui2.llm import ActionData, ConverseWidget, LLMActionsSettingsWidget, LLMSettingsDialogBase, LocalisedResults from polyglot.binary import from_hex_unicode @@ -55,51 +54,62 @@ def get_allowed_fields() -> set[str]: return set() -class Action(NamedTuple): - name: str - human_name: str - prompt_template: str - is_builtin: bool = True - is_disabled: bool = False - - @property - def as_custom_action_dict(self) -> dict[str, Any]: - return {'disabled': self.is_disabled, 'title': self.human_name, 'prompt_template': self.prompt_template} +class Action(ActionData): def prompt_text(self, books: list[Metadata]) -> str: pt = self.prompt_template return pt.format( - books=format_books_for_query(books), books_word='book' if len(books) < 2 else 'books', - plural_word='is' if len(books) < 2 else 'are', + is_are='is' if len(books) < 2 else 'are', + title=books[0].title, authors=books[0].format_authors(), series=books[0].series or '', ) @lru_cache(2) def default_actions() -> tuple[Action, ...]: return ( - Action('summarize', _('Summarize'), '{books} Provide a concise summary of the previously described {books_word}.'), - Action('chapters', _('Chapters'), '{books} Provide a chapter by chapter summary of the previously described {books_word}.'), + Action('summarize', _('Summarize'), 'Provide a concise summary of the previously described {books_word}.'), + Action('chapters', _('Chapters'), 'Provide a chapter by chapter summary of the previously described {books_word}.'), Action('read_next', _('Read next'), 'Suggest some good books to read after the previously described {books_word}.'), - Action('universe', _('Universe'), 'Describe the fictional universe the previously described {books_word} {plural_word} set in.' + Action('universe', _('Universe'), 'Describe the fictional universe the previously described {books_word} {is_are} set in.' ' Outline major plots, themes and characters in the universe.'), - Action('series', _('Series'), 'Give the series the previously described {books_word} {plural_word} in.' + Action('series', _('Series'), 'Give the series the previously described {books_word} {is_are} in.' ' List all the books in the series, in both published and internal chronological order.' ' Also describe any prominent spin-off series.') ) -def current_actions(include_disabled=False): - p = gprefs.get('llm_converse_quick_actions') or {} - dd = p.get('disabled_default_actions', ()) - for x in default_actions(): - x = x._replace(is_disabled=x.name in dd) - if include_disabled or not x.is_disabled: - yield x - for title, c in p.get('custom_actions', {}).items(): - x = Action(f'custom-{title}', title, c['prompt_template'], is_builtin=False, is_disabled=c['disabled']) - if include_disabled or not x.is_disabled: - yield x +def current_actions(include_disabled=False) -> Iterator[Action]: + p = gprefs.get('llm_book_quick_actions') or {} + return Action.unserialize(p, default_actions(), include_disabled) + + +class LLMSettingsWidget(LLMActionsSettingsWidget): + + action_edit_help_text = '

' + _( + 'The prompt is a template. The expression {0} will be replaced by "book"' + ' when there is only a single book being discussed and "books" otherwise.' + ' Similarly {1} becomes "is" or "are", as needed. {2}, {3}, {4} are replaced ' + ' by the title, authors and series of the first book, respectively.' + ).format('{books_word}', '{is_are}', '{title}', '{authors}', '{series}') + + def get_actions_from_prefs(self) -> Iterator[ActionData]: + yield from current_actions(include_disabled=True) + + def set_actions_in_prefs(self, s: dict[str, Any]) -> None: + gprefs.set('llm_book_quick_actions', s) + + def create_custom_widgets(self) -> Iterator[str, QWidget]: + yield '', LocalisedResults() + + +class LLMSettingsDialog(LLMSettingsDialogBase): + + def __init__(self, parent=None): + super().__init__(title=_('AI Settings'), name='llm-book-settings-dialog', prefs=gprefs, parent=parent) + + def custom_tabs(self) -> Iterator[str, str, QWidget]: + yield 'config.png', _('&Actions'), LLMSettingsWidget(self) class LLMPanel(ConverseWidget): @@ -109,6 +119,9 @@ class LLMPanel(ConverseWidget): self.books = books super().__init__(parent) + def settings_dialog(self) -> QDialog: + return LLMSettingsDialog(self) + def handle_chat_link(self, qurl: QUrl) -> bool: match qurl.host(): case self.quick_action_hostname: @@ -135,11 +148,6 @@ class LLMPanel(ConverseWidget): return msg ready_message = choose_action_message - 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]: context_header = format_books_for_query(self.books) context_header += ' When you answer the questions use markdown formatting for the answers wherever possible.' diff --git a/src/calibre/gui2/llm.py b/src/calibre/gui2/llm.py index 1c658b07ec..214cafeea3 100644 --- a/src/calibre/gui2/llm.py +++ b/src/calibre/gui2/llm.py @@ -41,6 +41,7 @@ from qt.core import ( from calibre.ai import AICapabilities, ChatMessage, ChatMessageType, ChatResponse from calibre.ai.config import ConfigureAI from calibre.ai.prefs import plugin_for_purpose +from calibre.ai.prefs import prefs as aiprefs from calibre.ai.utils import ContentType, StreamedResponseAccumulator, response_to_html from calibre.customize import AIProviderPlugin from calibre.gui2 import error_dialog, safe_open_url @@ -315,6 +316,11 @@ class ConverseWidget(QWidget): self.result_display.re_render() self.scroll_to_bottom() + def get_language_instruction(self) -> str: + if aiprefs['llm_localized_results'] != 'always': + return '' + return self.language_instruction() + def scroll_to_bottom(self) -> None: self.result_display.scroll_to_bottom() @@ -569,8 +575,7 @@ class ActionEditDialog(QDialog): class LocalisedResults(QCheckBox): - def __init__(self, prefs): - self.prefs = prefs + def __init__(self): super().__init__(_('Ask the AI to respond in the current language')) self.setToolTip('

' + _( 'Ask the AI to respond in the current calibre user interface language. Note that how well' @@ -578,10 +583,10 @@ class LocalisedResults(QCheckBox): ' different languages.')) def load_settings(self): - self.setChecked(self.prefs['llm_localized_results'] == 'always') + self.setChecked(aiprefs['llm_localized_results'] == 'always') def commit(self) -> bool: - self.prefs.set('llm_localized_results', 'always' if self.isChecked() else 'never') + aiprefs['llm_localized_results'] = 'always' if self.isChecked() else 'never' return True diff --git a/src/calibre/gui2/viewer/config.py b/src/calibre/gui2/viewer/config.py index 579b95bbdf..c87e93f563 100644 --- a/src/calibre/gui2/viewer/config.py +++ b/src/calibre/gui2/viewer/config.py @@ -20,7 +20,6 @@ vprefs.defaults['old_prefs_migrated'] = False vprefs.defaults['bookmarks_sort'] = 'title' vprefs.defaults['highlight_export_format'] = 'txt' vprefs.defaults['auto_update_lookup'] = True -vprefs.defaults['llm_localized_results'] = 'never' def get_session_pref(name, default=None, group='standalone_misc_settings'): diff --git a/src/calibre/gui2/viewer/llm.py b/src/calibre/gui2/viewer/llm.py index 4080bcb653..ad25114388 100644 --- a/src/calibre/gui2/viewer/llm.py +++ b/src/calibre/gui2/viewer/llm.py @@ -121,11 +121,6 @@ class LLMPanel(ConverseWidget): yield Button('save.png', f'http://{self.save_note_hostname}/{msgnum}', _( 'Save this specific response as the note')) - 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: @@ -214,10 +209,9 @@ class HighlightWidget(HighlightColorCombo): class LLMSettingsWidget(LLMActionsSettingsWidget): action_edit_help_text = '

' + _( - '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}') + '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.' + ).format('{selected}') def get_actions_from_prefs(self) -> Iterator[ActionData]: yield from current_actions(include_disabled=True) @@ -227,7 +221,7 @@ class LLMSettingsWidget(LLMActionsSettingsWidget): def create_custom_widgets(self) -> Iterator[str, QWidget]: yield _('&Highlight style:'), HighlightWidget(self) - yield '', LocalisedResults(vprefs) + yield '', LocalisedResults() # }}}