mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Keep the publisher and the model boxes saying the same thing
Changing the publisher left the model box untouched: the old selection was carried over, added back as "not downloaded" and selected again, so a model the new repository does not publish could be saved against it. The selection is now only carried within the publisher it was made in, every keystroke in the publisher box no longer starts its own request, and a list that comes back for a publisher that is no longer chosen is dropped rather than answering the wrong one. The status line grew two things it could not say before. A row rebuilt from a name alone carries no file to fetch, and the Download button stayed lit over it doing nothing; those rows now say the publisher does not offer the model, and the button is out. A model that is here while the program above it is not no longer reads "Ready", which is what had people asking why nothing transcribed.
This commit is contained in:
@@ -787,6 +787,12 @@ TR = {
|
|||||||
"Ready: {name}.": "Hazır: {name}.",
|
"Ready: {name}.": "Hazır: {name}.",
|
||||||
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
||||||
"{name} has not been downloaded yet.": "{name} henüz 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",
|
"downloaded": "indirildi",
|
||||||
"not downloaded": "indirilmedi",
|
"not downloaded": "indirilmedi",
|
||||||
"Delete model": "Modeli sil",
|
"Delete model": "Modeli sil",
|
||||||
|
|||||||
+59
-8
@@ -6,7 +6,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
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.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
||||||
@@ -247,6 +247,13 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._pending = False
|
self._pending = False
|
||||||
self._stop = False
|
self._stop = False
|
||||||
self._wanted = "" # the model to select once a list arrives
|
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)
|
form = QFormLayout(self)
|
||||||
|
|
||||||
@@ -328,6 +335,8 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._wanted = model
|
self._wanted = model
|
||||||
self._pending = True
|
self._pending = True
|
||||||
self._show_program()
|
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:
|
if self._repos is not None:
|
||||||
self.repo.blockSignals(True)
|
self.repo.blockSignals(True)
|
||||||
self.repo.clear()
|
self.repo.clear()
|
||||||
@@ -367,11 +376,18 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
def _fill_repos(self, current):
|
def _fill_repos(self, current):
|
||||||
def work():
|
def work():
|
||||||
self._listed.emit([("repos", ggml.llm_repos())], "")
|
self._listed.emit([("repos", ggml.llm_repos(), "")], "")
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _repo_changed(self):
|
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:
|
if not self._downloading:
|
||||||
self._fetch_models(self.repository())
|
self._fetch_models(self.repository())
|
||||||
|
|
||||||
@@ -381,18 +397,29 @@ class LocalModelBox(QGroupBox):
|
|||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
found = self._models(repo) if self._repos is not None else self._models()
|
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:
|
except ggml.LocalError as exc:
|
||||||
self._listed.emit([], str(exc))
|
self._listed.emit([("models", [], repo)], str(exc))
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _on_listed(self, payload, error):
|
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:
|
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.status.setText(error)
|
||||||
self._refresh_buttons()
|
|
||||||
return
|
return
|
||||||
kind, found = payload[0]
|
|
||||||
if kind == "repos":
|
if kind == "repos":
|
||||||
current = self.repo.currentText()
|
current = self.repo.currentText()
|
||||||
self.repo.blockSignals(True)
|
self.repo.blockSignals(True)
|
||||||
@@ -406,7 +433,12 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
def _fill_models(self, items):
|
def _fill_models(self, items):
|
||||||
"""One row per model, saying what it weighs and whether it is here."""
|
"""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)]
|
here = [name for name in (self._model_path(i.name).name for i in items)]
|
||||||
self.model.blockSignals(True)
|
self.model.blockSignals(True)
|
||||||
self.model.clear()
|
self.model.clear()
|
||||||
@@ -431,6 +463,7 @@ class LocalModelBox(QGroupBox):
|
|||||||
self.model.blockSignals(False)
|
self.model.blockSignals(False)
|
||||||
self._fit_popup(self.model)
|
self._fit_popup(self.model)
|
||||||
self._wanted = ""
|
self._wanted = ""
|
||||||
|
self._chosen_in = self.repository()
|
||||||
self._model_changed()
|
self._model_changed()
|
||||||
|
|
||||||
def _on_disk(self):
|
def _on_disk(self):
|
||||||
@@ -459,6 +492,9 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._show_program()
|
self._show_program()
|
||||||
if error:
|
if error:
|
||||||
self.program_label.setText(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()
|
self.changed.emit()
|
||||||
|
|
||||||
def _current_item(self):
|
def _current_item(self):
|
||||||
@@ -545,15 +581,30 @@ class LocalModelBox(QGroupBox):
|
|||||||
def _refresh_buttons(self):
|
def _refresh_buttons(self):
|
||||||
name = self.selected()
|
name = self.selected()
|
||||||
here = bool(name) and ggml.have_model(self._model_path(name))
|
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.delete_button.setEnabled(here and not self._downloading)
|
||||||
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
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:
|
if self._downloading:
|
||||||
return
|
return
|
||||||
if not name:
|
if not name:
|
||||||
self.status.setText(t("Nothing downloaded yet."))
|
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:
|
elif here:
|
||||||
self.status.setText(t("Ready: {name}.", name=name))
|
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:
|
else:
|
||||||
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ next time anybody presses Save. That is the failure this catches.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -21,6 +22,7 @@ from dikte import cleanup
|
|||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
from dikte import ggml
|
from dikte import ggml
|
||||||
from dikte import hotkey
|
from dikte import hotkey
|
||||||
|
from dikte import hub
|
||||||
from dikte import ipc
|
from dikte import ipc
|
||||||
from dikte import overlay as overlay_module
|
from dikte import overlay as overlay_module
|
||||||
from dikte import paste
|
from dikte import paste
|
||||||
@@ -1214,6 +1216,75 @@ class LocalModels(DikteTest):
|
|||||||
for row in range(box.repo.count()))
|
for row in range(box.repo.count()))
|
||||||
self.assertGreaterEqual(view.minimumWidth(), widest)
|
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):
|
def test_only_the_chosen_transcriber_is_on_screen(self):
|
||||||
window = self.window(self.config(transcribe_provider="openai"))
|
window = self.window(self.config(transcribe_provider="openai"))
|
||||||
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
|
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
|
||||||
|
|||||||
Reference in New Issue
Block a user