Merge current master into local model status

This commit is contained in:
2026-09-09 11:31:09 +03:00
6 changed files with 110 additions and 9 deletions
+1 -1
View File
@@ -945,7 +945,7 @@ def cmd_doctor(opts):
# Recording, the device list, and KDE's shortcut registry. # Recording, the device list, and KDE's shortcut registry.
wanted += ["pw-record", "pactl", "kwriteconfig6"] wanted += ["pw-record", "pactl", "kwriteconfig6"]
wanted += ["ffmpeg", wanted += ["ffmpeg",
assistant.executable(assistant.provider(conf)) or "claude", assistant.executable(assistant.provider(conf)),
cleanup.executable(cleanup.provider(conf))] cleanup.executable(cleanup.provider(conf))]
programs = {name: shutil.which(name) or "" for name in wanted if name} programs = {name: shutil.which(name) or "" for name in wanted if name}
target = conf.transcribe_target() target = conf.transcribe_target()
+1 -1
View File
@@ -57,7 +57,7 @@ SHORTCUTS = {
"Dikte: pause/resume the recording", "pause_shortcut", ""), "Dikte: pause/resume the recording", "pause_shortcut", ""),
"cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording", "cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
"cancel_shortcut", ""), "cancel_shortcut", ""),
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code", "ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask the agent",
"assistant_shortcut", ""), "assistant_shortcut", ""),
"meeting": Shortcut("meeting", MEETING_DESKTOP_ID, "meeting": Shortcut("meeting", MEETING_DESKTOP_ID,
"Dikte: start/end a meeting recording", "Dikte: start/end a meeting recording",
+1
View File
@@ -819,6 +819,7 @@ TR = {
"İşlemciye yüklendi: ekran kartı açık, ama kullanılamadı.", "İşlemciye yüklendi: ekran kartı açık, ama kullanılamadı.",
"Not installed.": "Kurulu değil.", "Not installed.": "Kurulu değil.",
"Installed on the system: {path}": "Sistemde kurulu: {path}", "Installed on the system: {path}": "Sistemde kurulu: {path}",
"Using custom build: {path}": "Özel derleme kullanılıyor: {path}",
"Download again": "Yeniden indir", "Download again": "Yeniden indir",
"Downloaded, version {version}.": "İndirildi, sürüm {version}.", "Downloaded, version {version}.": "İndirildi, sürüm {version}.",
"Downloaded, version {version}. There was no Vulkan build, " "Downloaded, version {version}. There was no Vulkan build, "
+23 -6
View File
@@ -223,7 +223,9 @@ class WheelGuard(QObject):
""" """
def eventFilter(self, box, event): def eventFilter(self, box, event):
if event.type() == QEvent.Type.Wheel and not box.hasFocus(): win = box.window()
focused = box.hasFocus() or (win is not None and win.focusWidget() is box)
if event.type() == QEvent.Type.Wheel and not focused:
# Refused rather than swallowed. An unaccepted wheel event carries # Refused rather than swallowed. An unaccepted wheel event carries
# on up the parents to the scroll area, so the page still moves. # on up the parents to the scroll area, so the page still moves.
event.ignore() event.ignore()
@@ -253,9 +255,11 @@ class LocalModelBox(QGroupBox):
changed = pyqtSignal() changed = pyqtSignal()
def __init__(self, program, title, models, model_path, repos=None, parent=None): def __init__(self, program, title, models, model_path, binary=None,
repos=None, parent=None):
super().__init__(title, parent) super().__init__(title, parent)
self.program = program self.program = program
self._binary = binary # () -> a path set by hand, or ""
self._models = models # () -> [hub.Item], or (repo) -> [hub.Item] self._models = models # () -> [hub.Item], or (repo) -> [hub.Item]
self._model_path = model_path # (name) -> Path self._model_path = model_path # (name) -> Path
self._repos = repos # None, or () -> [repo id] self._repos = repos # None, or () -> [repo id]
@@ -419,13 +423,23 @@ class LocalModelBox(QGroupBox):
self._fill_repos(self.repository()) self._fill_repos(self.repository())
self._fetch_models(self.repository()) self._fetch_models(self.repository())
def _program_path(self):
return ggml.program_path(self.program,
self._binary() if self._binary else "")
def _show_program(self): def _show_program(self):
path = ggml.program_path(self.program) path = self._program_path()
if not path: if not path:
self.program_label.setText(t("Not installed.")) self.program_label.setText(t("Not installed."))
self.install_button.setText(t("Download")) self.install_button.setText(t("Download"))
self.install_button.setVisible(True) self.install_button.setVisible(True)
return return
if self._binary and self._binary():
# Neither a system copy nor one Dikte fetched, and "Downloaded"
# over a build someone made themselves is not true.
self.program_label.setText(t("Using custom build: {path}", path=path))
self.install_button.setVisible(False)
return
# A copy that is here is not a copy that is right. whisper.cpp releases # A copy that is here is not a copy that is right. whisper.cpp releases
# every few weeks, and a graphics card installed after Dikte was # every few weeks, and a graphics card installed after Dikte was
# changes which build this machine should be running; the button was # changes which build this machine should be running; the button was
@@ -837,7 +851,7 @@ class LocalModelBox(QGroupBox):
repo=self.repository(), cap=ggml.human_size(ggml.GGUF_MAX_BYTES))) repo=self.repository(), cap=ggml.human_size(ggml.GGUF_MAX_BYTES)))
elif not name: elif 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): elif here and not self._program_path():
# The model alone runs nothing, and "Ready" over a missing program # The model alone runs nothing, and "Ready" over a missing program
# reads as though it does. # reads as though it does.
self.status.setText(t("{name} is here, but the program above is " self.status.setText(t("{name} is here, but the program above is "
@@ -1248,7 +1262,8 @@ class SettingsWindow(QDialog):
self.local_whisper = LocalModelBox( self.local_whisper = LocalModelBox(
ggml.WHISPER, t("On this machine"), ggml.WHISPER, t("On this machine"),
ggml.whisper_models, ggml.whisper_model_path) ggml.whisper_models, ggml.whisper_model_path,
binary=lambda: self.conf["local_binary"])
stt_form.addRow(self.local_whisper) stt_form.addRow(self.local_whisper)
self.local_gpu = QCheckBox(t("Use the graphics card")) self.local_gpu = QCheckBox(t("Use the graphics card"))
@@ -1365,7 +1380,9 @@ class SettingsWindow(QDialog):
self.local_llm = LocalModelBox( self.local_llm = LocalModelBox(
ggml.LLAMA, t("On this machine"), ggml.LLAMA, t("On this machine"),
ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos) ggml.llm_quants, ggml.llm_model_path,
binary=lambda: self.conf["local_llm_binary"],
repos=ggml.llm_repos)
orr_form.addRow(self.local_llm) orr_form.addRow(self.local_llm)
self.local_llm_gpu = QCheckBox(t("Use the graphics card")) self.local_llm_gpu = QCheckBox(t("Use the graphics card"))
+15
View File
@@ -578,6 +578,21 @@ class Doctor(DikteTest):
self.run_doctor(as_json=False, cleanup_provider="codex", self.run_doctor(as_json=False, cleanup_provider="codex",
cleanup_codex_model="gpt-5.4")) cleanup_codex_model="gpt-5.4"))
def test_agent_on_hosted_provider_does_not_ask_for_a_cli_program(self):
for provider in ("openrouter", "opencode"):
with self.subTest(provider=provider):
reply = self.run_doctor(assistant_provider=provider)
self.assertEqual(reply["agent"]["provider"], provider)
for cli_name in ("claude", "codex", "agy"):
self.assertNotIn(cli_name, reply["programs"])
def test_agent_on_a_cli_asks_for_the_program(self):
for provider, binary in (("claude", "claude"), ("codex", "codex"), ("agy", "agy")):
with self.subTest(provider=provider):
reply = self.run_doctor(assistant_provider=provider)
self.assertEqual(reply["agent"]["provider"], provider)
self.assertIn(binary, reply["programs"])
class Devices(DikteTest): class Devices(DikteTest):
def test_a_machine_with_nothing_names_its_own_missing_program(self): def test_a_machine_with_nothing_names_its_own_missing_program(self):
+69 -1
View File
@@ -16,7 +16,7 @@ from unittest import mock
from PyQt6.QtCore import QPoint, QPointF, QRect, Qt from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
from PyQt6.QtGui import QHideEvent, QShowEvent, QWheelEvent from PyQt6.QtGui import QHideEvent, QShowEvent, QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox from PyQt6.QtWidgets import QApplication, QComboBox, QMessageBox, QSpinBox, QWidget
from dikte import audio from dikte import audio
from dikte import cleanup from dikte import cleanup
@@ -230,6 +230,51 @@ class Settings(DikteTest):
QApplication.sendEvent(box, self.wheel()) QApplication.sendEvent(box, self.wheel())
self.assertNotEqual(box.currentIndex(), before) self.assertNotEqual(box.currentIndex(), before)
def test_the_wheel_uses_remembered_focus_in_an_inactive_window(self):
# Keep the window hidden so no desktop activation policy can give it
# keyboard focus. Its remembered focus still selects the wheel target.
for widget_type in (QComboBox, QSpinBox):
with self.subTest(widget=widget_type.__name__):
window = QWidget()
self.addCleanup(window.deleteLater)
box = widget_type(window)
other = QComboBox(window)
if isinstance(box, QComboBox):
box.addItems(["first", "second", "third"])
box.setCurrentIndex(1)
value = box.currentIndex
else:
box.setValue(5)
value = box.value
box.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
guard = settings_ui.WheelGuard(window)
box.installEventFilter(guard)
box.setFocus()
self.assertFalse(window.isActiveWindow())
self.assertFalse(box.hasFocus())
self.assertIs(window.focusWidget(), box)
before = value()
QApplication.sendEvent(box, self.wheel())
self.assertNotEqual(value(), before)
other.setFocus()
self.assertIs(window.focusWidget(), other)
before = value()
rolled = self.wheel()
QApplication.sendEvent(box, rolled)
self.assertEqual(value(), before)
self.assertFalse(rolled.isAccepted())
def test_the_wheel_is_refused_when_another_widget_has_focus(self):
window = self.window(cfg.Config())
box = window.ui_language
other = window.corner
other.setFocus()
before = box.currentIndex()
rolled = self.wheel()
QApplication.sendEvent(box, rolled)
self.assertEqual(box.currentIndex(), before)
self.assertFalse(rolled.isAccepted())
def test_a_wrapped_label_keeps_the_room_its_lines_need(self): def test_a_wrapped_label_keeps_the_room_its_lines_need(self):
# The program path shares a row with a button, and a row is measured # The program path shares a row with a button, and a row is measured
# before its width is known: the label has to claim the second line back # before its width is known: the label has to claim the second line back
@@ -1578,6 +1623,29 @@ class LocalModels(DikteTest):
self.assertNotIn("Ready", box.status.text()) self.assertNotIn("Ready", box.status.text())
self.assertIn("program", box.status.text()) self.assertIn("program", box.status.text())
def test_a_program_set_in_the_settings_is_not_called_downloaded(self):
mine = self.path("my-whisper-server")
mine.write_text("#!/bin/sh\n")
mine.chmod(0o755)
self.patch_attr(ggml.shutil, "which", lambda name: None)
box = self.window(self.config(local_binary=str(mine))).local_whisper
self.assertIn(str(mine), box.program_label.text())
self.assertFalse(box.install_button.isVisibleTo(box))
def test_a_model_over_a_program_set_by_hand_is_ready(self):
# The program is there, it is just named by the settings rather than
# downloaded, and the status line looked past it.
mine = self.path("my-whisper-server")
mine.write_text("#!/bin/sh\n")
mine.chmod(0o755)
self.patch_attr(ggml.shutil, "which", lambda name: None)
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 = self.window(self.config(local_binary=str(mine))).local_whisper
box.load("ggml-small.bin")
self.assertIn("Ready", box.status.text())
def test_changing_the_publisher_changes_the_model(self): def test_changing_the_publisher_changes_the_model(self):
# The model chosen under the old publisher is not published by the new # 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 # one. Carried over, it was added back as "not downloaded" and selected