aboutsummaryrefslogtreecommitdiffstats
path: root/llamachat/ui.py
diff options
context:
space:
mode:
Diffstat (limited to 'llamachat/ui.py')
-rw-r--r--llamachat/ui.py104
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)