Use the new AI config widget in viewer Ask AI panel

This commit is contained in:
Kovid Goyal 2025-09-02 13:47:59 +05:30
parent ca8653bba4
commit 7c595c932b
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 113 additions and 32 deletions

42
imgsrc/ai.svg Normal file
View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 64 64"
version="1.1"
id="svg2"
sodipodi:docname="ai.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.8561553"
inkscape:cx="31.786133"
inkscape:cy="31.516759"
inkscape:window-width="976"
inkscape:window-height="581"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<!-- First (large) star -->
<path
d="M 26.222656 3.8691406 C 25.308656 3.8691406 24.394656 4.3789844 23.972656 5.3964844 L 21.697266 10.878906 C 19.732266 15.613906 16.068844 19.3935 11.464844 21.4375 L 5.4746094 24.097656 C 3.5086094 24.969656 3.5086094 27.828172 5.4746094 28.701172 L 11.658203 31.447266 C 16.146203 33.439266 19.745141 37.083109 21.744141 41.662109 L 23.990234 46.808594 C 24.530234 48.044844 25.802187 48.508281 26.890625 48.199219 C 27.543688 48.013781 28.131078 47.550344 28.455078 46.808594 L 30.701172 41.662109 C 32.700172 37.083109 36.298109 33.439266 40.787109 31.447266 L 46.970703 28.701172 C 47.462203 28.483172 47.830422 28.142672 48.076172 27.738281 C 48.199047 27.536086 48.292078 27.317299 48.353516 27.091797 C 48.414953 26.866295 48.445312 26.633672 48.445312 26.400391 C 48.445312 26.167109 48.414953 25.932564 48.353516 25.707031 C 48.169203 25.030432 47.707953 24.425031 46.970703 24.097656 L 40.980469 21.4375 C 36.376469 19.3935 32.713047 15.613906 30.748047 10.878906 L 28.472656 5.3964844 C 28.050656 4.3789844 27.136656 3.8691406 26.222656 3.8691406 z"
id="path1"
style="fill:#2caf45;fill-opacity:1" />
<!-- Second (small) star -->
<path
d="M 49.75 39.640625 C 49.26 39.640625 48.770922 39.913484 48.544922 40.458984 L 47.894531 42.023438 C 46.787531 44.693438 44.722953 46.825516 42.126953 47.978516 L 40.289062 48.794922 C 39.237062 49.261922 39.237063 50.791766 40.289062 51.259766 L 42.234375 52.125 C 44.765375 53.25 46.795875 55.304719 47.921875 57.886719 L 48.552734 59.335938 C 48.842109 59.998438 49.523857 60.245703 50.107422 60.080078 C 50.457561 59.980703 50.772062 59.733438 50.945312 59.335938 L 51.578125 57.886719 C 52.704125 55.304719 54.732672 53.25 57.263672 52.125 L 59.210938 51.259766 C 59.474187 51.143016 59.671109 50.960563 59.802734 50.744141 C 59.868547 50.63593 59.918266 50.519115 59.951172 50.398438 C 60.016984 50.157082 60.016984 49.899621 59.951172 49.658203 C 59.885359 49.416785 59.752125 49.190699 59.554688 49.015625 C 59.455969 48.928088 59.342562 48.853422 59.210938 48.794922 L 57.373047 47.978516 C 56.399547 47.545766 55.500857 46.975572 54.699219 46.291016 C 54.432006 46.06283 54.174469 45.823031 53.929688 45.570312 C 53.195344 44.812156 52.566889 43.945994 52.064453 42.998047 C 51.896975 42.682064 51.743844 42.357062 51.605469 42.023438 L 50.955078 40.458984 C 50.729078 39.913484 50.24 39.640625 49.75 39.640625 z"
id="path2"
style="fill:#2271d5;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

6
imgsrc/srv/ai.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- First (large) star -->
<path d="M 26.222656 3.8691406 C 25.308656 3.8691406 24.394656 4.3789844 23.972656 5.3964844 L 21.697266 10.878906 C 19.732266 15.613906 16.068844 19.3935 11.464844 21.4375 L 5.4746094 24.097656 C 3.5086094 24.969656 3.5086094 27.828172 5.4746094 28.701172 L 11.658203 31.447266 C 16.146203 33.439266 19.745141 37.083109 21.744141 41.662109 L 23.990234 46.808594 C 24.530234 48.044844 25.802187 48.508281 26.890625 48.199219 C 27.543688 48.013781 28.131078 47.550344 28.455078 46.808594 L 30.701172 41.662109 C 32.700172 37.083109 36.298109 33.439266 40.787109 31.447266 L 46.970703 28.701172 C 47.462203 28.483172 47.830422 28.142672 48.076172 27.738281 C 48.199047 27.536086 48.292078 27.317299 48.353516 27.091797 C 48.414953 26.866295 48.445312 26.633672 48.445312 26.400391 C 48.445312 26.167109 48.414953 25.932564 48.353516 25.707031 C 48.169203 25.030432 47.707953 24.425031 46.970703 24.097656 L 40.980469 21.4375 C 36.376469 19.3935 32.713047 15.613906 30.748047 10.878906 L 28.472656 5.3964844 C 28.050656 4.3789844 27.136656 3.8691406 26.222656 3.8691406 z"/>
<!-- Second (small) star -->
<path d="M 49.75 39.640625 C 49.26 39.640625 48.770922 39.913484 48.544922 40.458984 L 47.894531 42.023438 C 46.787531 44.693438 44.722953 46.825516 42.126953 47.978516 L 40.289062 48.794922 C 39.237062 49.261922 39.237063 50.791766 40.289062 51.259766 L 42.234375 52.125 C 44.765375 53.25 46.795875 55.304719 47.921875 57.886719 L 48.552734 59.335938 C 48.842109 59.998438 49.523857 60.245703 50.107422 60.080078 C 50.457561 59.980703 50.772062 59.733438 50.945312 59.335938 L 51.578125 57.886719 C 52.704125 55.304719 54.732672 53.25 57.263672 52.125 L 59.210938 51.259766 C 59.474187 51.143016 59.671109 50.960563 59.802734 50.744141 C 59.868547 50.63593 59.918266 50.519115 59.951172 50.398438 C 60.016984 50.157082 60.016984 49.899621 59.951172 49.658203 C 59.885359 49.416785 59.752125 49.190699 59.554688 49.015625 C 59.455969 48.928088 59.342562 48.853422 59.210938 48.794922 L 57.373047 47.978516 C 56.399547 47.545766 55.500857 46.975572 54.699219 46.291016 C 54.432006 46.06283 54.174469 45.823031 53.929688 45.570312 C 53.195344 44.812156 52.566889 43.945994 52.064453 42.998047 C 51.896975 42.682064 51.743844 42.357062 51.605469 42.023438 L 50.955078 40.458984 C 50.729078 39.913484 50.24 39.640625 49.75 39.640625 z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
resources/images/ai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -42,14 +42,28 @@ class ConfigureAI(QWidget):
s.addWidget() s.addWidget()
v.addWidget(self.gb) 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: if not self.available_plugins:
error_dialog(self, _('No AI providers'), self.none_label.text(), show=True) error_dialog(self, _('No AI providers'), self.none_label.text(), show=True)
return False return False
if len(self.available_plugins) == 1: return self.plugin_config_widgets[self.current_idx].validate()
idx = 0
else: def commit(self) -> bool:
idx = self.provider_combo.currentIndex() if not self.validate():
return False
idx = self.current_idx
p, w = self.available_plugins[idx], self.plugin_config_widgets[idx] p, w = self.available_plugins[idx], self.plugin_config_widgets[idx]
if not w.validate(): if not w.validate():
return False return False

View File

@ -41,6 +41,7 @@ from calibre.gui2 import Application, error_dialog, gprefs, safe_open_url
from calibre.gui2.widgets2 import Dialog from calibre.gui2.widgets2 import Dialog
from calibre.utils.date import qt_from_dt from calibre.utils.date import qt_from_dt
from calibre.utils.icu import primary_sort_key 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) pref = partial(pref_for_provider, OpenRouterAI.name)
@ -161,7 +162,7 @@ class ModelDetails(QTextBrowser):
<p>{_('Pick an AI model to use. Generally, newer models are more capable but also more expensive.')}</p> <p>{_('Pick an AI model to use. Generally, newer models are more capable but also more expensive.')}</p>
<p>{_('By default, an appropriate AI model is chosen automatically based on the query being made.' <p>{_('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.')}</p> ' By picking a model explicitly, you have more control over this process.')}</p>
<p>{_('Another critera to look for is if the model is <i>moderated</i> (that is, its output is filtered by the provider).')}</p> <p>{_('Another criterion to look for is if the model is <i>moderated</i> (that is, its output is filtered by the provider).')}</p>
''') ''')
def show_model_details(self, m: 'AIModel'): def show_model_details(self, m: 'AIModel'):
@ -378,7 +379,7 @@ class ConfigWidget(QWidget):
a.setPlaceholderText(_('An API key is required to use OpenRouter')) a.setPlaceholderText(_('An API key is required to use OpenRouter'))
l.addRow(_('API &key:'), a) l.addRow(_('API &key:'), a)
if key := pref('api_key'): if key := pref('api_key'):
a.setText(key) a.setText(from_hex_unicode(key))
self.text_model = tm = Model(parent=self) self.text_model = tm = Model(parent=self)
tm.select_model.connect(self.select_model) tm.select_model.connect(self.select_model)
l.addRow(_('Model for &text tasks:'), tm) l.addRow(_('Model for &text tasks:'), tm)
@ -396,7 +397,11 @@ class ConfigWidget(QWidget):
@property @property
def settings(self) -> dict[str, Any]: 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: def validate(self) -> bool:
if self.api_key: if self.api_key:

View File

@ -853,6 +853,11 @@ class AIProviderPlugin(Plugin): # {{{
return '' return ''
def config_widget(self): 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: if self.builtin_live_module_name:
return self.builtin_live_module.config_widget() return self.builtin_live_module.config_widget()
raise NotImplementedError() raise NotImplementedError()

View File

@ -27,12 +27,14 @@ from qt.core import (
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
Qt, Qt,
QTabWidget,
QTextBrowser, QTextBrowser,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
pyqtSignal, pyqtSignal,
) )
from calibre.ai.config import ConfigureAI
from calibre.ebooks.metadata import authors_to_string from calibre.ebooks.metadata import authors_to_string
from calibre.gui2 import Application, error_dialog from calibre.gui2 import Application, error_dialog
from calibre.gui2.dialogs.confirm_delete import confirm 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.viewer.highlights import HighlightColorCombo
from calibre.gui2.widgets2 import Dialog from calibre.gui2.widgets2 import Dialog
from calibre.utils.icu import primary_sort_key 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 --- # --- Backend Abstraction & Cost Data ---
MODEL_COSTS = { MODEL_COSTS = {
@ -559,30 +561,17 @@ class ActionEditDialog(QDialog):
return Action(f'custom-{title}', title, self.prompt_edit.toPlainText().strip()) return Action(f'custom-{title}', title, self.prompt_edit.toPlainText().strip())
class LLMSettingsDialog(Dialog): class LLMSettingsWidget(QWidget):
actions_updated = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(title=_('LLM Settings'), name='llm-settings-dialog', prefs=vprefs, parent=parent) super().__init__(parent)
def setup_ui(self):
self.setMinimumWidth(550) self.setMinimumWidth(550)
self.layout = QVBoxLayout(self) self.layout = QVBoxLayout(self)
api_model_layout = QFormLayout() api_model_layout = QFormLayout()
api_model_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) 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) self.highlight_color_combo = HighlightColorCombo(self)
model_label = QLabel(_('&Model (<a href="{}">see list</a>):').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) api_model_layout.addRow(_('&Highlight style:'), self.highlight_color_combo)
self.layout.addLayout(api_model_layout) self.layout.addLayout(api_model_layout)
self.qa_gb = gb = QGroupBox(_('&Quick actions:'), self) 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.addWidget(self.remove_button)
actions_button_layout.addStretch(100) actions_button_layout.addStretch(100)
l.addLayout(actions_button_layout) l.addLayout(actions_button_layout)
self.layout.addWidget(self.bb)
self.add_button.clicked.connect(self.add_action) self.add_button.clicked.connect(self.add_action)
self.edit_button.clicked.connect(self.edit_action) self.edit_button.clicked.connect(self.edit_action)
self.remove_button.clicked.connect(self.remove_action) self.remove_button.clicked.connect(self.remove_action)
@ -609,8 +597,6 @@ class LLMSettingsDialog(Dialog):
self.actions_list.setFocus() self.actions_list.setFocus()
def load_settings(self): 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'): if hsn := vprefs.get('llm_highlight_style'):
self.highlight_color_combo.highlight_style_name = hsn self.highlight_color_combo.highlight_style_name = hsn
self.load_actions_from_prefs() self.load_actions_from_prefs()
@ -662,13 +648,9 @@ class LLMSettingsDialog(Dialog):
): ):
self.actions_list.takeItem(self.actions_list.row(item)) self.actions_list.takeItem(self.actions_list.row(item))
def accept(self): def commit(self) -> bool:
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')
selected_internal_name = self.highlight_color_combo.currentData() selected_internal_name = self.highlight_color_combo.currentData()
vprefs.set('llm_highlight_style', selected_internal_name) vprefs.set('llm_highlight_style', selected_internal_name)
disabled_defaults = [] disabled_defaults = []
custom_actions = {} custom_actions = {}
for i in range(self.actions_list.count()): for i in range(self.actions_list.count()):
@ -686,6 +668,33 @@ class LLMSettingsDialog(Dialog):
if custom_actions: if custom_actions:
s['custom_actions'] = custom_actions s['custom_actions'] = custom_actions
vprefs.set('llm_quick_actions', s) 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() self.actions_updated.emit()
super().accept() super().accept()