diff --git a/dikte/i18n.py b/dikte/i18n.py index 23ff6ff..961a663 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -787,6 +787,12 @@ TR = { "Ready: {name}.": "Hazır: {name}.", "Nothing downloaded yet.": "Henüz bir şey indirilmedi.", "{name} has not been downloaded yet.": "{name} henüz indirilmedi.", + "{name} is here, but the program above is not. Download it first.": + "{name} burada, ama yukarıdaki program değil. Önce onu indirin.", + "{name} is not on this machine and this publisher does not offer it. " + "Choose another model, or another publisher.": + "{name} bu makinede yok ve bu yayıncı da sunmuyor. Başka bir model, " + "ya da başka bir yayıncı seçin.", "downloaded": "indirildi", "not downloaded": "indirilmedi", "Delete model": "Modeli sil", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 56771f8..ccf136c 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -6,7 +6,7 @@ import shutil import sys import threading -from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal +from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog, @@ -247,6 +247,13 @@ class LocalModelBox(QGroupBox): self._pending = False self._stop = False self._wanted = "" # the model to select once a list arrives + self._chosen_in = "" # the publisher the selected model is from + # Typing or arrowing through the publisher box changes its text a + # character at a time, and each of those would otherwise be a request. + self._later = QTimer(self) + self._later.setSingleShot(True) + self._later.setInterval(400) + self._later.timeout.connect(self._later_fetch) form = QFormLayout(self) @@ -328,6 +335,8 @@ class LocalModelBox(QGroupBox): self._wanted = model self._pending = True self._show_program() + self._chosen_in = repo or (ggml.SUGGESTED_LLM[0] if self._repos is not None + else "") if self._repos is not None: self.repo.blockSignals(True) self.repo.clear() @@ -367,11 +376,18 @@ class LocalModelBox(QGroupBox): def _fill_repos(self, current): def work(): - self._listed.emit([("repos", ggml.llm_repos())], "") + self._listed.emit([("repos", ggml.llm_repos(), "")], "") threading.Thread(target=work, daemon=True).start() def _repo_changed(self): + if not self._downloading: + self._later.start() + + def _later_fetch(self): + # A download that started inside the wait was not there to be seen when + # the timer went off, and rebuilding the rows underneath one is exactly + # what the guard above is for. if not self._downloading: self._fetch_models(self.repository()) @@ -381,18 +397,29 @@ class LocalModelBox(QGroupBox): def work(): try: found = self._models(repo) if self._repos is not None else self._models() - self._listed.emit([("models", found)], "") + self._listed.emit([("models", found, repo)], "") except ggml.LocalError as exc: - self._listed.emit([], str(exc)) + self._listed.emit([("models", [], repo)], str(exc)) threading.Thread(target=work, daemon=True).start() def _on_listed(self, payload, error): + kind, found, repo = payload[0] if payload else ("repos", [], "") + # A publisher changed while its predecessor's list was still on the way + # would otherwise be answered with the wrong models, whichever request + # happened to come back last. + if kind == "models" and repo != self.repository(): + return if error: + # The list is the publisher's, so a failed one leaves the box no + # longer showing this publisher's models: emptying it is what keeps + # the two boxes saying the same thing. The message goes on after, + # because filling the box writes a status of its own. + if kind == "models": + self._fill_models([]) + self._refresh_buttons() self.status.setText(error) - self._refresh_buttons() return - kind, found = payload[0] if kind == "repos": current = self.repo.currentText() self.repo.blockSignals(True) @@ -406,7 +433,12 @@ class LocalModelBox(QGroupBox): def _fill_models(self, items): """One row per model, saying what it weighs and whether it is here.""" - wanted = self._wanted or self.selected() + # The selection is only worth carrying over within the publisher it was + # made in. Carried across one, a model this repository does not publish + # would be added back as "not downloaded" and selected again, and + # changing the publisher would leave the model box looking untouched. + same = self._repos is None or self.repository() == self._chosen_in + wanted = self._wanted or (self.selected() if same else "") here = [name for name in (self._model_path(i.name).name for i in items)] self.model.blockSignals(True) self.model.clear() @@ -431,6 +463,7 @@ class LocalModelBox(QGroupBox): self.model.blockSignals(False) self._fit_popup(self.model) self._wanted = "" + self._chosen_in = self.repository() self._model_changed() def _on_disk(self): @@ -459,6 +492,9 @@ class LocalModelBox(QGroupBox): self._show_program() if error: self.program_label.setText(error) + # The model line says whether the program is here, so installing one + # changes what it should read. + self._refresh_buttons() self.changed.emit() def _current_item(self): @@ -545,15 +581,30 @@ class LocalModelBox(QGroupBox): def _refresh_buttons(self): name = self.selected() here = bool(name) and ggml.have_model(self._model_path(name)) + # A row carries what it takes to fetch it. The ones that do not are the + # models found on this disk and the one the settings name but the list + # does not offer: there is nothing to press Download for on those, and + # a button that can only do nothing is worse than one that is out. + item = self._current_item() self.delete_button.setEnabled(here and not self._downloading) self.download_button.setText(t("Stop") if self._downloading else t("Download")) - self.download_button.setEnabled(self._downloading or (bool(name) and not here)) + self.download_button.setEnabled(self._downloading or (item is not None + and not here)) if self._downloading: return if not name: self.status.setText(t("Nothing downloaded yet.")) + elif here and not ggml.program_path(self.program): + # The model alone runs nothing, and "Ready" over a missing program + # reads as though it does. + self.status.setText(t("{name} is here, but the program above is " + "not. Download it first.", name=name)) elif here: self.status.setText(t("Ready: {name}.", name=name)) + elif item is None: + self.status.setText(t("{name} is not on this machine and this " + "publisher does not offer it. Choose another " + "model, or another publisher.", name=name)) else: self.status.setText(t("{name} has not been downloaded yet.", name=name)) diff --git a/tests/test_ui.py b/tests/test_ui.py index 6273447..ca821a4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -8,6 +8,7 @@ next time anybody presses Save. That is the failure this catches. import os import sys +import time import unittest from typing import ClassVar from unittest import mock @@ -21,6 +22,7 @@ from dikte import cleanup from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc from dikte import overlay as overlay_module from dikte import paste @@ -1214,6 +1216,75 @@ class LocalModels(DikteTest): for row in range(box.repo.count())) self.assertGreaterEqual(view.minimumWidth(), widest) + @staticmethod + def _item(name, size=1 << 20): + return hub.Item(name, f"https://example.invalid/{name}", size, "") + + def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self): + # The model the settings name is not in the list any more, so its row + # was rebuilt from the name alone and carries no file to fetch. The + # button stayed lit and the press did nothing at all. + box = self.window(self.config(local_llm_model="gone.gguf")).local_llm + box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF") + self.assertEqual(box.selected(), "gone.gguf") + self.assertFalse(box.download_button.isEnabled()) + self.assertIn("gone.gguf", box.status.text()) + self.assertIn("publisher", box.status.text()) + + def test_a_model_without_its_program_does_not_say_it_is_ready(self): + # The model runs on the program above it, and "Ready" over a missing + # one is what had people asking why nothing transcribed. + box = self.window(cfg.Config()).local_whisper + path = ggml.whisper_model_path("ggml-small.bin") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"not really a model") + box.load("ggml-small.bin") + self.assertFalse(ggml.program_path(ggml.WHISPER)) + self.assertNotIn("Ready", box.status.text()) + self.assertIn("program", box.status.text()) + + def test_changing_the_publisher_changes_the_model(self): + # The model chosen under the old publisher is not published by the new + # one. Carried over, it was added back as "not downloaded" and selected + # again, and the box looked as though the change had not taken. + box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf", + local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm + box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + self.assertEqual(box.model.count(), 1) + + def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self): + # Every change starts its own request, and they do not come back in the + # order they went out. + box = self.window(cfg.Config()).local_llm + box.load("", "ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")], + "ggml-org/gemma-3-4b-it-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + + def test_the_publisher_box_is_not_asked_on_every_keystroke(self): + box = self.window(cfg.Config()).local_llm + with mock.patch.object(box, "_fetch_models") as fetch: + for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"): + box.repo.setCurrentText(text) + fetch.assert_not_called() + box._later.setInterval(0) + box._later.start() + _app.processEvents() + time.sleep(0.05) + _app.processEvents() + self.assertEqual(fetch.call_count, 1) + def test_only_the_chosen_transcriber_is_on_screen(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))