diff options
Diffstat (limited to 'llamachat/ui.py')
| -rw-r--r-- | llamachat/ui.py | 104 |
1 files changed, 78 insertions, 26 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py index 6c26ca2..94eb000 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -13,6 +13,7 @@ # GNU General Public License for more details. """The chat window.""" +import dataclasses import datetime import html import json @@ -33,6 +34,7 @@ from PySide6.QtWidgets import ( ) from . import backend, models as models_mod, prompts +from . import providers as providers_mod from .backend import Attachment, BackendError, SearchConfig from .config import GLOBAL_PROMPT @@ -213,9 +215,20 @@ class StreamWorker(QObject): @Slot() def run(self) -> None: + # Resolve the client and wire name on the worker thread, not the + # GUI thread: client_for() resolves the API key, and a pass: key + # runs `pass show` which blocks on a pinentry for as long as the user + # takes to find and touch their hardware token. Resolving in + # _start_stream before moveToThread would freeze the whole window. try: - stream = self.client.stream_chat( - self.model, self.messages, self.search_cfg + client = self.client.client_for(self.model) + wire = self.client.wire_name(self.model) + except providers_mod.KeyResolutionError as exc: + self.failed.emit(str(exc)) + return + try: + stream = client.stream_chat( + wire, self.messages, self.search_cfg ) for kind, piece in stream: if self._stop: @@ -262,9 +275,16 @@ class TitleWorker(QObject): @Slot() def run(self) -> None: + # Resolve on the worker thread for the same reason as StreamWorker: + # a pass: key blocks on a pinentry. Titling is never the user's own + # turn, so a key error skips silently rather than showing a banner. try: - raw = self.client.complete(self.model, self.messages) + client = self.client.client_for(self.model) + wire = self.client.wire_name(self.model) + raw = client.complete(wire, self.messages) title = backend.clean_title(raw) + except providers_mod.KeyResolutionError: + return # silent skip, titling is never the user's turn except Exception: # noqa: BLE001 - a missing title is not an error title = "" if title: @@ -406,12 +426,13 @@ class PromptDialog(QDialog): class ChatWindow(QMainWindow): """Model picker, mode toggle, transcript, input, history panel.""" - def __init__(self, cfg, history, client, presets): + def __init__(self, cfg, history, client, presets, store): super().__init__() self.cfg = cfg self.history = history self.client = client self.presets = presets + self.store = store self.prompts = prompts.PromptStore(cfg.prompts_dir) self.prompts.ensure_default() @@ -717,19 +738,20 @@ class ChatWindow(QMainWindow): # -- model handling --------------------------------------------------- def refresh_models(self) -> None: - """Repopulate the picker from the router, keeping the selection.""" + """Repopulate the picker from every provider, keeping the selection.""" previous = self.model_box.currentText() - try: - available = self.client.models() - except BackendError as exc: - self.show_status(str(exc), error=True) + available, listing_problems = self.client.models() + if not available: + self.show_status( + "; ".join(listing_problems) or "No models available", error=True + ) return self.model_box.blockSignals(True) self.model_box.clear() for name in available: - preset = self.presets.get(name) - label = f"{name} 👁" if preset and preset.vision else name + info = self.model_info(name) + label = f"{name} 👁" if info.vision else name self.model_box.addItem(label, name) self.model_box.blockSignals(False) @@ -740,29 +762,54 @@ class ChatWindow(QMainWindow): index = self.model_box.findText(target) if index >= 0: self.model_box.setCurrentIndex(index) - if available: + # Skipped providers from config parsing and listing failures from + # unreachable providers are both worth saying, even when others worked. + all_problems = list(self.cfg.provider_warnings) + list(listing_problems) + if all_problems: + self.show_status("; ".join(all_problems), error=True) + else: self.hide_status() def current_model(self) -> str: return self.model_box.currentData() or self.model_box.currentText() + def model_info(self, model_id: str): + """Metadata for one model: models.ini, then provider, then presets. + + Local models get their context and vision from presets.ini, which + the first two layers can still override if the user entered values. + Uses dataclasses.replace so the ModelInfo that resolve() returns is + never mutated: when a model has no configured provider, resolve() + returns the stored instance directly, and mutating it would corrupt + the store's in-memory representation. + """ + info = models_mod.resolve(model_id, self.cfg.providers, self.store) + preset = self.presets.get(model_id) + if preset is not None: + updates = {} + if info.ctx_size is None: + updates["ctx_size"] = preset.ctx_size + if info.vision is None: + updates["vision"] = preset.vision + if updates: + info = dataclasses.replace(info, **updates) + return info + + def current_info(self): + return self.model_info(self.current_model()) + def current_preset(self): return self.presets.get(self.current_model()) def char_budget(self) -> int: - preset = self.current_preset() - if preset is None: - return int(4096 * self.cfg.chars_per_token * self.cfg.attach_ctx_fraction) - return preset.char_budget( - self.cfg.attach_ctx_fraction, self.cfg.chars_per_token - ) + ctx = self.current_info().ctx_size or 4096 + return int(ctx * self.cfg.chars_per_token * self.cfg.attach_ctx_fraction) def vision_models(self) -> list[str]: names = [] for i in range(self.model_box.count()): name = self.model_box.itemData(i) - preset = self.presets.get(name) - if preset and preset.vision: + if self.model_info(name).vision: names.append(name) return names @@ -891,8 +938,13 @@ class ChatWindow(QMainWindow): def _ensure_vision_model(self) -> bool: """Offer to switch to a vision preset. True when one is active.""" - preset = self.current_preset() - if preset and preset.vision: + info = self.current_info() + if info.vision: + return True + # Unknown is not the same as "no": a cloud model we know nothing + # about may well accept images, and the API will say so if it does + # not. Only a model known to lack vision gets stopped here. + if info.vision is None: return True candidates = self.vision_models() @@ -1049,8 +1101,8 @@ class ChatWindow(QMainWindow): """ if not hasattr(self, "meter"): return # still building the window - preset = self.current_preset() - limit = preset.ctx_size if preset else 0 + info = self.current_info() + limit = info.ctx_size or 0 draft = self.input.toPlainText() pending = backend.build_user_content(draft, self.attachments) @@ -1124,8 +1176,8 @@ class ChatWindow(QMainWindow): @Slot(int, int) def _on_usage(self, prompt_tokens: int, total_tokens: int) -> None: """Replace the estimate with the counts the server reported.""" - preset = self.current_preset() - limit = preset.ctx_size if preset else 0 + info = self.current_info() + limit = info.ctx_size or 0 # What the next turn starts from is everything sent plus the reply. self.exact_tokens = total_tokens or prompt_tokens self.meter.set_usage(self.exact_tokens, limit, exact=True) |
