From 7c595c932bca68d4c6c001d89636dc4a65e6251d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 2 Sep 2025 13:47:59 +0530 Subject: [PATCH] Use the new AI config widget in viewer Ask AI panel --- imgsrc/ai.svg | 42 ++++++++++++++++++++ imgsrc/srv/ai.svg | 6 +++ resources/images/ai.png | Bin 0 -> 2206 bytes src/calibre/ai/config.py | 24 ++++++++--- src/calibre/ai/open_router/config.py | 11 ++++-- src/calibre/customize/__init__.py | 5 +++ src/calibre/gui2/viewer/llm.py | 57 ++++++++++++++++----------- 7 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 imgsrc/ai.svg create mode 100644 imgsrc/srv/ai.svg create mode 100644 resources/images/ai.png diff --git a/imgsrc/ai.svg b/imgsrc/ai.svg new file mode 100644 index 0000000000..c4646cdac4 --- /dev/null +++ b/imgsrc/ai.svg @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/imgsrc/srv/ai.svg b/imgsrc/srv/ai.svg new file mode 100644 index 0000000000..4976537316 --- /dev/null +++ b/imgsrc/srv/ai.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/resources/images/ai.png b/resources/images/ai.png new file mode 100644 index 0000000000000000000000000000000000000000..c21f2933d5cb10891949a8e291923199a5f60c70 GIT binary patch literal 2206 zcmV;P2x0e$P)Zl5e9x=j+cmr9}! z-myJ=v5Wx!=Mwn;+X+tLIe5vg@S|132;`sof!}32;3=DmeBF-?0KdaZmB1``%Z8#~ zcRB$;@`Ls8mfhgT%S+RNpS1SkxG)}Gvr_;_!Uq`yK*B4FE8sPos5m}vv`^+wW)c9z z4*R$HhroNbnEUyf4B;np0r7r#&mPHNV>SUm>`Qph>a~Ao<0p}&T`Y~yE`}GaT>DGH zXE_8w!YlFbz>5~u-(_#W>&#^R!s0x`r*^?BmCuIv&4wQj+)H4b zGa&#HbdyO*oSy~?CW#*8ARyYzf!}R^OpcY^0vdT1w?Ul5$-?g@!|yVUt~`QDzP|ie z;C9^>dUP^UfgB{$Q4#1E4POb&Vqnl>xgW6&{?fgKK1EnXS0zC&*RMV%+)W&+S_ppX;?dV78v|ZY%tF(n{MGa;%0qA8 zXltjx1N#coRMD1-;T;D#6tTtP;Cn44(;TKMT6F~|_X9q;k)zpO;Cq#e`#DK`9D$J3 zv}nckoFY6&ODH>o@2hV>e>bK@2hO2ewZrHE_4QXiJwV+bbUvu zV!1qFMs&uN1p4b_ zZu=V`b+NxlAiQ9S%7G4BB*Xm*(2S@Y#C6OEfKL*a0hW;~j{Jjc2Y@W>0VqRlBkKWh z29ONx8~n(60JH*M5c(bka+hbiSRm$#M2jSaU!Uv20-WAf|+me+8|Cc|F2IBB2YJ_3BSWg zt`MjH59Cw3gBjmm#@93P^@r5DOyGAXgkfztsHY}_3co0$+F#*$^WitmB{0ut69BYX z8y9ip%I=$c86tH&4*fFqz}Yyh+|9stKd$;bc*sw-Iz08dzb8)Rtax2D!Q$kF4`CYpUm;Au^42Xa&eKK%fX(owg6tWTGueBR?rO{Il@0(SlbkU zH!YhNsO9tv&Jun*b_3a=N(gF;vux@tg!gPyDr##o3V^WsF}!A{#?caW6g~EpW&r$( zL!!3K2bvyAYv>PY55QaYEf05I9=`ewykzG92x^rB91O(+6%H5$_V%~q7km30AX45v z)z9!>4evNW$!lVc_T0*aKEI bool: + @property + def is_ready_for_use(self) -> bool: + if not self.available_plugins: + return False + return self.plugin_config_widgets[self.current_idx].is_ready_for_use + + @property + def current_idx(self) -> int: + if len(self.available_plugins) < 2: + return 0 + return self.provider_combo.currentIndex() + + def validate(self) -> bool: if not self.available_plugins: error_dialog(self, _('No AI providers'), self.none_label.text(), show=True) return False - if len(self.available_plugins) == 1: - idx = 0 - else: - idx = self.provider_combo.currentIndex() + return self.plugin_config_widgets[self.current_idx].validate() + + def commit(self) -> bool: + if not self.validate(): + return False + idx = self.current_idx p, w = self.available_plugins[idx], self.plugin_config_widgets[idx] if not w.validate(): return False diff --git a/src/calibre/ai/open_router/config.py b/src/calibre/ai/open_router/config.py index 2dcdd25bde..992b48e89e 100644 --- a/src/calibre/ai/open_router/config.py +++ b/src/calibre/ai/open_router/config.py @@ -41,6 +41,7 @@ from calibre.gui2 import Application, error_dialog, gprefs, safe_open_url from calibre.gui2.widgets2 import Dialog from calibre.utils.date import qt_from_dt from calibre.utils.icu import primary_sort_key +from polyglot.binary import as_hex_unicode, from_hex_unicode pref = partial(pref_for_provider, OpenRouterAI.name) @@ -161,7 +162,7 @@ class ModelDetails(QTextBrowser):

{_('Pick an AI model to use. Generally, newer models are more capable but also more expensive.')}

{_('By default, an appropriate AI model is chosen automatically based on the query being made.' ' By picking a model explicitly, you have more control over this process.')}

-

{_('Another critera to look for is if the model is moderated (that is, its output is filtered by the provider).')}

+

{_('Another criterion to look for is if the model is moderated (that is, its output is filtered by the provider).')}

''') def show_model_details(self, m: 'AIModel'): @@ -378,7 +379,7 @@ class ConfigWidget(QWidget): a.setPlaceholderText(_('An API key is required to use OpenRouter')) l.addRow(_('API &key:'), a) if key := pref('api_key'): - a.setText(key) + a.setText(from_hex_unicode(key)) self.text_model = tm = Model(parent=self) tm.select_model.connect(self.select_model) l.addRow(_('Model for &text tasks:'), tm) @@ -396,7 +397,11 @@ class ConfigWidget(QWidget): @property def settings(self) -> dict[str, Any]: - return {'api_key': self.api_key} + return {'api_key': as_hex_unicode(self.api_key)} + + @property + def is_ready_for_use(self) -> bool: + return bool(self.api_key) def validate(self) -> bool: if self.api_key: diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py index 66ce3c43b3..b89cc4ce07 100644 --- a/src/calibre/customize/__init__.py +++ b/src/calibre/customize/__init__.py @@ -853,6 +853,11 @@ class AIProviderPlugin(Plugin): # {{{ return '' def config_widget(self): + ''' + The config widget for an AI plugin must support validate() and additionally the property + is_ready_for_use which must be true iff the plugin is ready to be used, + i.e. it does not require configuration such as an API key or the API key is already set. + ''' if self.builtin_live_module_name: return self.builtin_live_module.config_widget() raise NotImplementedError() diff --git a/src/calibre/gui2/viewer/llm.py b/src/calibre/gui2/viewer/llm.py index 82ebee0e0b..ba1f0afebb 100644 --- a/src/calibre/gui2/viewer/llm.py +++ b/src/calibre/gui2/viewer/llm.py @@ -27,12 +27,14 @@ from qt.core import ( QPushButton, QSizePolicy, Qt, + QTabWidget, QTextBrowser, QVBoxLayout, QWidget, pyqtSignal, ) +from calibre.ai.config import ConfigureAI from calibre.ebooks.metadata import authors_to_string from calibre.gui2 import Application, error_dialog from calibre.gui2.dialogs.confirm_delete import confirm @@ -40,7 +42,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 polyglot.binary import as_hex_unicode, from_hex_unicode +from polyglot.binary import from_hex_unicode # --- Backend Abstraction & Cost Data --- MODEL_COSTS = { @@ -559,30 +561,17 @@ class ActionEditDialog(QDialog): return Action(f'custom-{title}', title, self.prompt_edit.toPlainText().strip()) -class LLMSettingsDialog(Dialog): - actions_updated = pyqtSignal() +class LLMSettingsWidget(QWidget): def __init__(self, parent=None): - super().__init__(title=_('LLM Settings'), name='llm-settings-dialog', prefs=vprefs, parent=parent) - - def setup_ui(self): + super().__init__(parent) self.setMinimumWidth(550) self.layout = QVBoxLayout(self) api_model_layout = QFormLayout() api_model_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) - self.api_key_edit = QLineEdit(self) - self.api_key_edit.setPlaceholderText(_('Paste your API key here')) - self.api_key_edit.setClearButtonEnabled(True) - self.model_edit = QLineEdit(self) - self.model_edit.setPlaceholderText('google/gemini-flash-1.5') self.highlight_color_combo = HighlightColorCombo(self) - model_label = QLabel(_('&Model (see list):').format('https://openrouter.ai/models')) - model_label.setOpenExternalLinks(True) - model_label.setBuddy(self.model_edit) - api_model_layout.addRow(_('API &key:'), self.api_key_edit) - api_model_layout.addRow(model_label, self.model_edit) api_model_layout.addRow(_('&Highlight style:'), self.highlight_color_combo) self.layout.addLayout(api_model_layout) self.qa_gb = gb = QGroupBox(_('&Quick actions:'), self) @@ -600,7 +589,6 @@ class LLMSettingsDialog(Dialog): actions_button_layout.addWidget(self.remove_button) actions_button_layout.addStretch(100) l.addLayout(actions_button_layout) - self.layout.addWidget(self.bb) self.add_button.clicked.connect(self.add_action) self.edit_button.clicked.connect(self.edit_action) self.remove_button.clicked.connect(self.remove_action) @@ -609,8 +597,6 @@ class LLMSettingsDialog(Dialog): self.actions_list.setFocus() def load_settings(self): - self.api_key_edit.setText(from_hex_unicode(vprefs.get('llm_api_key', ''))) - self.model_edit.setText(vprefs.get('llm_model_id', 'google/gemini-flash-1.5')) if hsn := vprefs.get('llm_highlight_style'): self.highlight_color_combo.highlight_style_name = hsn self.load_actions_from_prefs() @@ -662,13 +648,9 @@ class LLMSettingsDialog(Dialog): ): self.actions_list.takeItem(self.actions_list.row(item)) - def accept(self): - vprefs.set('llm_api_key', as_hex_unicode(self.api_key_edit.text().strip())) - vprefs.set('llm_model_id', self.model_edit.text().strip() or 'google/gemini-flash-1.5') - + def commit(self) -> bool: selected_internal_name = self.highlight_color_combo.currentData() vprefs.set('llm_highlight_style', selected_internal_name) - disabled_defaults = [] custom_actions = {} for i in range(self.actions_list.count()): @@ -686,6 +668,33 @@ class LLMSettingsDialog(Dialog): if custom_actions: s['custom_actions'] = custom_actions vprefs.set('llm_quick_actions', s) + return True + + +class LLMSettingsDialog(Dialog): + actions_updated = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(title=_('AI Settings'), name='llm-settings-dialog', prefs=vprefs, parent=parent) + + def setup_ui(self): + l = QVBoxLayout(self) + self.tabs = tabs = QTabWidget(self) + self.ai_config = ai = ConfigureAI(parent=self) + tabs.addTab(ai, QIcon.ic('ai.png'), _('AI &Provider')) + self.llm_config = llm = LLMSettingsWidget(self) + tabs.addTab(llm, QIcon.ic('config.png'), _('Actions and &highlights')) + tabs.setCurrentWidget(llm if self.ai_config.is_ready_for_use else ai) + l.addWidget(tabs) + l.addWidget(self.bb) + + def accept(self): + if not self.ai_config.commit(): + self.tabs.setCurrentWidget(self.ai_config) + return + if not self.llm_config.commit(): + self.tabs.setCurrentWidget(self.llm_config) + return self.actions_updated.emit() super().accept()