From b42f526ca3965125268941fde87061183b141b03 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 5 Jun 2026 09:02:14 +0200 Subject: [PATCH] Searchable model dropdown fetched from {url}/models The engine Model field is now an editable combo with a type-to-search EntryCompletion (substring match). When you enter a remote engine URL it fetches the model list from {url}/models (OpenAI `data[].id` or Ollama `models[].name`) and fills the dropdown; local STT offers the whisper sizes. stt.list_models added; tested live (8010 -> 428 models, 28080 -> 18, 19001 -> 1). You can still type a custom model name. Co-Authored-By: Claude Opus 4.8 --- linux/blitztext/gtksettings.py | 100 +++++++++++++++++++++++++++++++-- linux/blitztext/stt.py | 27 +++++++++ 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 45a6888..56e332c 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -63,6 +63,47 @@ def _combo(options, active=None) -> Gtk.ComboBoxText: return c +def _model_combo(placeholder="") -> Gtk.ComboBoxText: + """Editable combo with a type-to-search completion (dropdown + searchbar).""" + c = Gtk.ComboBoxText.new_with_entry() + c.set_hexpand(True) + entry = c.get_child() + if placeholder: + entry.set_placeholder_text(placeholder) + + store = Gtk.ListStore(str) + comp = Gtk.EntryCompletion() + comp.set_model(store) + comp.set_text_column(0) + comp.set_popup_completion(True) + comp.set_minimum_key_length(1) + + def _match(completion, key, it, *_a): + row = completion.get_model()[it][0] + return key.lower() in row.lower() # substring, anywhere + + comp.set_match_func(_match) + entry.set_completion(comp) + c._models_store = store + return c + + +def _combo_text(c: Gtk.ComboBoxText) -> str: + return (c.get_active_text() or "").strip() + + +def _fill_combo(combo: Gtk.ComboBoxText, options, current: str) -> None: + combo.remove_all() + store = getattr(combo, "_models_store", None) + if store is not None: + store.clear() + for o in options: + combo.append_text(o) + if store is not None: + store.append([o]) + combo.get_child().set_text(current or "") + + def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) for m in ("top", "bottom", "start", "end"): @@ -214,8 +255,11 @@ class SettingsDialog: form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) self.stt_type = _labeled(form, "Type", _combo(["local", "openai"])) self.stt_url = _labeled(form, "URL", _entry(placeholder="http://localhost:8010/v1 (blank for local)")) - self.stt_model = _labeled(form, "Model", _entry(placeholder="e.g. Systran/faster-whisper-base · 'small' for local")) + self.stt_model = _labeled(form, "Model", _model_combo("pick after entering URL · tiny/base/small… for local")) self.stt_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. GROQ_API_KEY (optional)")) + self.stt_url.connect("changed", lambda _e: self._schedule_models("stt")) + self.stt_key.connect("changed", lambda _e: self._schedule_models("stt")) + self.stt_type.connect("changed", self._stt_type_changed) self.stt_result = Gtk.Label(xalign=0.0); self.stt_result.set_line_wrap(True) box.pack_start(self.stt_result, False, False, 2) self._stt_load(self.stt_combo.get_active()) @@ -241,9 +285,11 @@ class SettingsDialog: form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) self.llm_url = _labeled(form, "Base URL", _entry(placeholder="http://localhost:28080/v1 · https://api.openai.com/v1")) - self.llm_model = _labeled(form, "Model", _entry(placeholder="e.g. gpt-4o-mini, Qwen3.5-4B")) + self.llm_model = _labeled(form, "Model", _model_combo("pick after entering URL")) self.llm_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. OPENAI_API_KEY (blank for local)")) self.llm_temp = _labeled(form, "Temperature", _entry(placeholder="0.3")) + self.llm_url.connect("changed", lambda _e: self._schedule_models("llm")) + self.llm_key.connect("changed", lambda _e: self._schedule_models("llm")) self._llm_load(self.llm_combo.get_active()) return box @@ -253,7 +299,12 @@ class SettingsDialog: return e = self.cfg.stt_engines[idx] self.stt_type.set_active(["local", "openai"].index(e.type) if e.type in ("local", "openai") else 0) - self.stt_url.set_text(e.url); self.stt_model.set_text(e.model); self.stt_key.set_text(e.api_key_env) + self.stt_url.set_text(e.url); self.stt_key.set_text(e.api_key_env) + if e.type == "local": + _fill_combo(self.stt_model, ["tiny", "base", "small", "medium", "large-v3"], e.model) + else: + _fill_combo(self.stt_model, [], e.model) + self._populate_models(self.stt_model, e.url, e.api_key_env) self._stt_idx = idx def _stt_commit(self) -> None: @@ -263,7 +314,7 @@ class SettingsDialog: e = self.cfg.stt_engines[idx] e.type = self.stt_type.get_active_text() or "local" e.url = self.stt_url.get_text().strip().rstrip("/") - e.model = self.stt_model.get_text().strip() + e.model = _combo_text(self.stt_model) e.api_key_env = self.stt_key.get_text().strip() def _stt_changed(self, combo): @@ -314,8 +365,10 @@ class SettingsDialog: if not (0 <= idx < len(self.cfg.llm_engines)): return e = self.cfg.llm_engines[idx] - self.llm_url.set_text(e.url); self.llm_model.set_text(e.model) + self.llm_url.set_text(e.url) self.llm_key.set_text(e.api_key_env); self.llm_temp.set_text(str(e.temperature)) + _fill_combo(self.llm_model, [], e.model) + self._populate_models(self.llm_model, e.url, e.api_key_env) self._llm_idx = idx def _llm_commit(self) -> None: @@ -324,7 +377,7 @@ class SettingsDialog: return e = self.cfg.llm_engines[idx] e.url = self.llm_url.get_text().strip().rstrip("/") - e.model = self.llm_model.get_text().strip() + e.model = _combo_text(self.llm_model) e.api_key_env = self.llm_key.get_text().strip() if _isfloat(self.llm_temp.get_text().strip()): e.temperature = float(self.llm_temp.get_text().strip()) @@ -349,6 +402,41 @@ class SettingsDialog: self.llm_combo.remove(self._llm_idx); self._llm_idx = -1 self.llm_combo.set_active(0); self._llm_load(0) + def _stt_type_changed(self, _c) -> None: + if self.stt_type.get_active_text() == "local": + _fill_combo(self.stt_model, ["tiny", "base", "small", "medium", "large-v3"], _combo_text(self.stt_model)) + else: + self._schedule_models("stt") + + # -- model dropdowns (fetched from {url}/models) --- + def _populate_models(self, combo, url: str, key_env: str) -> None: + def work(): + models = stt.list_models(url, key_env) if url else [] + + def apply(): + cur = _combo_text(combo) + _fill_combo(combo, models, cur) + return False + GLib.idle_add(apply) + threading.Thread(target=work, daemon=True).start() + + def _schedule_models(self, which: str) -> None: + attr = f"_mt_{which}" + old = getattr(self, attr, 0) + if old: + GLib.source_remove(old) + + def fire(): + setattr(self, attr, 0) + if which == "stt" and self.stt_type.get_active_text() != "local": + self._populate_models(self.stt_model, self.stt_url.get_text().strip(), + self.stt_key.get_text().strip()) + elif which == "llm": + self._populate_models(self.llm_model, self.llm_url.get_text().strip(), + self.llm_key.get_text().strip()) + return False + setattr(self, attr, GLib.timeout_add(700, fire)) + # -- status dots (threaded) --- def _refresh_status(self) -> None: s = self.cfg.stt_engines[self._stt_idx] if 0 <= self._stt_idx < len(self.cfg.stt_engines) else None diff --git a/linux/blitztext/stt.py b/linux/blitztext/stt.py index 681a50e..8167b0b 100644 --- a/linux/blitztext/stt.py +++ b/linux/blitztext/stt.py @@ -57,6 +57,33 @@ def status(engine: STTEngine, timeout: float = 2.0) -> bool: return reachable(engine.url, timeout) +def list_models(base_url: str, api_key_env: str = "", timeout: float = 5.0) -> list[str]: + """Fetch model ids from an OpenAI-compatible (or Ollama-style) /models endpoint.""" + import os + + if not base_url: + return [] + url = base_url.rstrip("/") + "/models" + headers = {} + key = os.environ.get(api_key_env) if api_key_env else None + if key: + headers["Authorization"] = f"Bearer {key}" + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, json.JSONDecodeError, OSError): + return [] + + items = data.get("data") if isinstance(data, dict) else None + if isinstance(items, list): # OpenAI shape: {"data":[{"id":...}]} + return [m["id"] for m in items if isinstance(m, dict) and m.get("id")] + items = data.get("models") if isinstance(data, dict) else None + if isinstance(items, list): # Ollama shape: {"models":[{"name"/"model":...}]} + return [m.get("name") or m.get("model") for m in items if (m.get("name") or m.get("model"))] + return [] + + def _host_port(url: str) -> tuple[str | None, int]: try: u = urlparse(url if "://" in url else "http://" + url)