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 0000000000..c21f2933d5 Binary files /dev/null and b/resources/images/ai.png differ diff --git a/src/calibre/ai/config.py b/src/calibre/ai/config.py index 6d49132bf6..d4c51404bf 100644 --- a/src/calibre/ai/config.py +++ b/src/calibre/ai/config.py @@ -42,14 +42,28 @@ class ConfigureAI(QWidget): s.addWidget() v.addWidget(self.gb) - def commit(self) -> 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()