diff --git a/src/calibre/ai/open_router/backend.py b/src/calibre/ai/open_router/backend.py index 0ece35046d..e3929bd78e 100644 --- a/src/calibre/ai/open_router/backend.py +++ b/src/calibre/ai/open_router/backend.py @@ -13,7 +13,7 @@ from functools import lru_cache from pprint import pprint from threading import Thread from typing import Any, NamedTuple -from urllib.error import HTTPError +from urllib.error import HTTPError, URLError from urllib.request import ProxyHandler, Request, build_opener from calibre import browser, get_proxies @@ -346,7 +346,14 @@ def text_chat(messages: Iterable[ChatMessage], use_model: str = '') -> Iterator[ details = e.fp.read().decode() except Exception: details = '' + try: + error_json = json.loads(details) + details = error_json.get('error', {}).get('message', details) + except Exception: + pass yield ChatResponse(exception=e, error_details=details) + except URLError as e: + yield ChatResponse(exception=e, error_details=f'Network error: {e.reason}') except Exception as e: import traceback yield ChatResponse(exception=e, error_details=traceback.format_exc()) diff --git a/src/calibre/gui2/viewer/llm.py b/src/calibre/gui2/viewer/llm.py index 9a30f74aa0..f84daa7368 100644 --- a/src/calibre/gui2/viewer/llm.py +++ b/src/calibre/gui2/viewer/llm.py @@ -1,15 +1,11 @@ # License: GPL v3 Copyright: 2025, Amir Tehrani and Kovid Goyal -import json import textwrap from collections.abc import Iterator from functools import lru_cache, partial from itertools import count from threading import Thread from typing import NamedTuple -from urllib import request -from urllib.error import HTTPError, URLError -from urllib.parse import parse_qs, urlparse from qt.core import ( QAbstractItemView, @@ -33,14 +29,17 @@ from qt.core import ( Qt, QTabWidget, QTextBrowser, + QUrl, QVBoxLayout, QWidget, pyqtSignal, ) -from calibre.ai import AICapabilities, ChatMessage, ChatMessageType +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.utils import StreamedResponseAccumulator +from calibre.customize import AIProviderPlugin from calibre.ebooks.metadata import authors_to_string from calibre.gui2 import Application, error_dialog from calibre.gui2.dialogs.confirm_delete import confirm @@ -48,69 +47,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 - -# --- Backend Abstraction & Cost Data --- -API_PROVIDERS = { - 'openrouter': { - 'url': 'https://openrouter.ai/api/v1/chat/completions', - 'headers': lambda api_key: { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://calibre-ebook.com', - 'X-Title': 'calibre' - }, - 'payload': lambda model_id, messages: { - 'model': model_id, - 'messages': messages - }, - 'parse_response': lambda r_json: ( - r_json['choices'][0]['message']['content'], - r_json.get('usage', {'prompt_tokens': 0, 'completion_tokens': 0}) - ) - } -} -# --- End Backend Abstraction --- - - -class LLMAPICall(Thread): - def __init__(self, conversation_history, signal_emitter): - super().__init__(daemon=True) - self.conversation_history = conversation_history - self.signal_emitter = signal_emitter - - def run(self): - try: - url = self.provider_config['url'] - headers = self.provider_config['headers'](self.api_key) - payload = self.provider_config['payload'](self.model_id, self.conversation_history) - - encoded_data = json.dumps(payload).encode('utf-8') - req = request.Request(url, data=encoded_data, headers=headers, method='POST') - - with request.urlopen(req, timeout=90) as response: - response_data = response.read().decode('utf-8') - response_json = json.loads(response_data) - - if 'error' in response_json: - raise Exception(response_json['error'].get('message', 'Unknown API error')) - if not response_json.get('choices'): - raise Exception('API response did not contain any choices.') - - result_text, usage_data = self.provider_config['parse_response'](response_json) - self.signal_emitter.emit(result_text, usage_data) - - except HTTPError as e: - error_body = e.read().decode('utf-8') - try: - error_json = json.loads(error_body) - msg = error_json.get('error', {}).get('message', error_body) - except json.JSONDecodeError: - msg = error_body - self.signal_emitter.emit(f"
API Error ({e.code}): {msg}
", {}) - except URLError as e: - self.signal_emitter.emit(f"Network Error: {e.reason}
", {}) - except Exception as e: - self.signal_emitter.emit(f"An unexpected error occurred: {e}
", {}) +from calibre.utils.short_uuid import uuid4 class Action(NamedTuple): @@ -128,12 +65,12 @@ class Action(NamedTuple): @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', _('Correct 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('summarize', _('Summarize'), 'Provide a concise summary of the selected text.'), + Action('explain', _('Explain'), 'Explain the selected text in simple, easy-to-understand terms.'), + Action('points', _('Key points'), 'Extract the key points from the selected text as a bulleted list.'), + Action('define', _('Define'), 'Identify and define any technical or complex terms in the selected text.'), + Action('grammar', _('Correct grammar'), 'Correct any grammatical errors in the selected text and provide the corrected version.'), + Action('english', _('As English'), 'Translate the selected text into English.'), ) @@ -153,8 +90,10 @@ def current_actions(include_disabled=False): class ConversationHistory: def __init__(self, conversation_text: str = ''): + self.accumulator = StreamedResponseAccumulator() self.items: list[ChatMessage] = [] self.conversation_text: str = conversation_text + self.model_used = '' def __iter__(self) -> Iterator[ChatMessage]: return iter(self.items) @@ -170,6 +109,7 @@ class ConversationHistory: def copy(self, upto: int | None = None) -> 'ConversationHistory': ans = ConversationHistory(self.conversation_text) + ans.model_used = self.model_used if upto is None: ans.items = list(self.items) else: @@ -223,12 +163,13 @@ def format_llm_note(conversation: ConversationHistory) -> str: class LLMPanel(QWidget): - response_received = pyqtSignal(str, dict) + response_received = pyqtSignal(int, object) add_note_requested = pyqtSignal(dict) - _SAVE_ACTION_URL_SCHEME = 'calibre-llm-action' def __init__(self, parent=None, viewer=None, lookup_widget=None): super().__init__(parent) + self.save_note_hostname = f'{uuid4().lower()}.calibre' + self.configure_ai_hostname = f'{uuid4().lower()}.calibre' self.viewer = viewer self.counter = count(start=1) self.lookup_widget = lookup_widget @@ -262,7 +203,7 @@ class LLMPanel(QWidget): self.layout.addWidget(custom_prompt_group) self.result_display = QTextBrowser(self) - self.result_display.setOpenExternalLinks(False) + self.result_display.setOpenLinks(False) self.result_display.setMinimumHeight(150) self.result_display.anchorClicked.connect(self._on_chat_link_clicked) self.layout.addWidget(self.result_display) @@ -293,7 +234,7 @@ class LLMPanel(QWidget): self.custom_prompt_button.clicked.connect(self.run_custom_prompt) self.custom_prompt_edit.returnPressed.connect(self.run_custom_prompt) - self.response_received.connect(self.show_response) + self.response_received.connect(self.on_response_from_ai, type=Qt.ConnectionType.QueuedConnection) self.settings_button.clicked.connect(self.show_settings) self.show_initial_message() @@ -326,18 +267,21 @@ class LLMPanel(QWidget): dialog.actions_updated.connect(self.rebuild_actions_ui) dialog.exec() + @property + def ai_provider_plugin(self) -> AIProviderPlugin | None: + return plugin_for_purpose(AICapabilities.text_to_text) + @property def is_ready_for_use(self) -> bool: - p = plugin_for_purpose(AICapabilities.text_to_text) + p = self.ai_provider_plugin return p is not None and p.is_ready_for_use def show_initial_message(self): self.save_note_button.setEnabled(False) if not self.is_ready_for_use: - self.show_response('' + _( - 'Please configure an AI provider by clicking the Settings button below.'), is_error_or_status=True) + self.show_html(f'