mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-12 03:16:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b06d6d16d | ||
|
|
e9488a51a4 |
@@ -139,8 +139,7 @@ set next to it.
|
|||||||
|
|
||||||
An indicator in the screen corner shows a red dot, a live waveform and the
|
An indicator in the screen corner shows a red dot, a live waveform and the
|
||||||
elapsed time, then the stage it is on. It never takes focus. Pressing
|
elapsed time, then the stage it is on. It never takes focus. Pressing
|
||||||
`Ctrl+Space` again while the last dictation is still being cleaned up starts
|
`Ctrl+Space` again while Dikte is still working does nothing; nothing queues up.
|
||||||
the next one; it is transcribed and pasted in turn, behind the one still going.
|
|
||||||
A dictation and a command to the agent do wait on each other for the microphone,
|
A dictation and a command to the agent do wait on each other for the microphone,
|
||||||
which is one device, but for nothing else: each has its own indicator, and the
|
which is one device, but for nothing else: each has its own indicator, and the
|
||||||
second one stacks above the first while both are up.
|
second one stacks above the first while both are up.
|
||||||
|
|||||||
+2
-3
@@ -134,9 +134,8 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
|||||||
| Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` |
|
| Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` |
|
||||||
|
|
||||||
Ekranın köşesindeki gösterge kırmızı kayıt noktasını, canlı ses dalgasını ve
|
Ekranın köşesindeki gösterge kırmızı kayıt noktasını, canlı ses dalgasını ve
|
||||||
süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Önceki dikte daha
|
süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Dikte çalışırken
|
||||||
temizlenirken `Ctrl+Space`'e tekrar basmak yenisini başlatır; o da sırası
|
`Ctrl+Space`'e tekrar basmak bir şey yapmaz, sıraya da girmez. Dikte ile ajana
|
||||||
gelince, öndekinin ardından yazılıp yapıştırılır. Dikte ile ajana
|
|
||||||
verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu
|
verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu
|
||||||
için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi
|
için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi
|
||||||
birden ekrandayken ikincisi birincinin üstüne yerleşir.
|
birden ekrandayken ikincisi birincinin üstüne yerleşir.
|
||||||
|
|||||||
+18
-72
@@ -145,11 +145,6 @@ class Dikte:
|
|||||||
# Which recording is the current one, so a timer set for the run that
|
# Which recording is the current one, so a timer set for the run that
|
||||||
# started it cannot stop the one that came after.
|
# started it cannot stop the one that came after.
|
||||||
self._run_id = 0
|
self._run_id = 0
|
||||||
# Dictations handed to the pipeline and not yet out of it. More than
|
|
||||||
# one is normal: the microphone is free while a transcript is being
|
|
||||||
# cleaned up, so the next dictation can already be spoken, and it then
|
|
||||||
# queues up behind the one still going.
|
|
||||||
self._transcripts_pending = 0
|
|
||||||
|
|
||||||
self.overlay = Overlay(self.conf["overlay_corner"])
|
self.overlay = Overlay(self.conf["overlay_corner"])
|
||||||
# The agent's indicator sits on top of the dictation one when both are
|
# The agent's indicator sits on top of the dictation one when both are
|
||||||
@@ -169,9 +164,9 @@ class Dikte:
|
|||||||
self.recorder.level.connect(self._on_level)
|
self.recorder.level.connect(self._on_level)
|
||||||
self.recorder.stopped.connect(self._on_recorded)
|
self.recorder.stopped.connect(self._on_recorded)
|
||||||
self.recorder.failed.connect(self._on_recorder_error)
|
self.recorder.failed.connect(self._on_recorder_error)
|
||||||
self.pipeline.stage.connect(self._on_stage)
|
self.pipeline.stage.connect(self.overlay.show_busy)
|
||||||
self.pipeline.finished.connect(self._on_finished)
|
self.pipeline.finished.connect(self._on_finished)
|
||||||
self.pipeline.failed.connect(self._on_pipeline_failed)
|
self.pipeline.failed.connect(self._on_error)
|
||||||
self.ask_pipeline.stage.connect(self.ask_overlay.show_busy)
|
self.ask_pipeline.stage.connect(self.ask_overlay.show_busy)
|
||||||
self.ask_pipeline.finished.connect(self._on_ask_finished)
|
self.ask_pipeline.finished.connect(self._on_ask_finished)
|
||||||
self.ask_pipeline.failed.connect(self._on_ask_error)
|
self.ask_pipeline.failed.connect(self._on_ask_error)
|
||||||
@@ -341,18 +336,13 @@ class Dikte:
|
|||||||
BUSY: ("Working…", "view-refresh", "Dikte: working"),
|
BUSY: ("Working…", "view-refresh", "Dikte: working"),
|
||||||
}
|
}
|
||||||
label, icon, tip = labels[self.state]
|
label, icon, tip = labels[self.state]
|
||||||
if self.state == BUSY:
|
|
||||||
# Still working, but the microphone is free again: the menu offers
|
|
||||||
# the next dictation rather than a wait.
|
|
||||||
label = "Start recording"
|
|
||||||
agent = assistant.display_name(self.conf)
|
agent = assistant.display_name(self.conf)
|
||||||
|
|
||||||
self.toggle_action.setText(t(label))
|
self.toggle_action.setText(t(label))
|
||||||
# Blocked only while something is holding the microphone: a transcript
|
# Free while the other one is thinking, blocked only while it is holding
|
||||||
# still being cleaned up queues the next dictation behind it, and the
|
# the microphone.
|
||||||
# agent thinking never blocked it at all.
|
|
||||||
self.toggle_action.setEnabled(
|
self.toggle_action.setEnabled(
|
||||||
self.state == RECORDING or not self.recording
|
self.state == RECORDING or (self.state == IDLE and not self.recording)
|
||||||
)
|
)
|
||||||
asked = i18n.name(agent, "dative")
|
asked = i18n.name(agent, "dative")
|
||||||
self.ask_action.setText(
|
self.ask_action.setText(
|
||||||
@@ -597,11 +587,9 @@ class Dikte:
|
|||||||
return
|
return
|
||||||
if self.state == RECORDING:
|
if self.state == RECORDING:
|
||||||
self.stop()
|
self.stop()
|
||||||
else:
|
elif self.state == IDLE:
|
||||||
# BUSY does not block: the microphone is free while the last
|
|
||||||
# dictation is being cleaned up, and the next one starts now and
|
|
||||||
# waits its turn in the pipeline.
|
|
||||||
self.start()
|
self.start()
|
||||||
|
# a request during its own BUSY is ignored; nothing queues up
|
||||||
|
|
||||||
def _toggle_ask(self):
|
def _toggle_ask(self):
|
||||||
if self._repeated():
|
if self._repeated():
|
||||||
@@ -618,10 +606,7 @@ class Dikte:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Only a held microphone blocks: a previous dictation still being
|
if self.state != IDLE or self.recording:
|
||||||
# transcribed or cleaned up is the pipeline's business, not the
|
|
||||||
# recorder's.
|
|
||||||
if self.state == RECORDING or self.recording:
|
|
||||||
return
|
return
|
||||||
self.overlay.show_recording()
|
self.overlay.show_recording()
|
||||||
self._begin_recording(DICTATION)
|
self._begin_recording(DICTATION)
|
||||||
@@ -649,9 +634,7 @@ class Dikte:
|
|||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
self._clear_pause()
|
self._clear_pause()
|
||||||
self._set_state(BUSY)
|
self._set_state(BUSY)
|
||||||
self.overlay.show_busy(t("Waiting for the one before it…")
|
self.overlay.show_busy(t("Transcribing…"))
|
||||||
if self._transcripts_pending
|
|
||||||
else t("Transcribing…"))
|
|
||||||
self.recorder.stop()
|
self.recorder.stop()
|
||||||
|
|
||||||
def stop_ask(self):
|
def stop_ask(self):
|
||||||
@@ -714,9 +697,7 @@ class Dikte:
|
|||||||
self._settle(ASK, dropped)
|
self._settle(ASK, dropped)
|
||||||
else:
|
else:
|
||||||
self.overlay.dismiss()
|
self.overlay.dismiss()
|
||||||
# An earlier dictation may still be in the pipeline; only the
|
self._set_state(IDLE)
|
||||||
# recording was thrown away.
|
|
||||||
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
|
||||||
self._settle(DICTATION, dropped)
|
self._settle(DICTATION, dropped)
|
||||||
|
|
||||||
def cancel_ask(self):
|
def cancel_ask(self):
|
||||||
@@ -899,50 +880,27 @@ class Dikte:
|
|||||||
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
||||||
paste=wants_paste)
|
paste=wants_paste)
|
||||||
else:
|
else:
|
||||||
self._transcripts_pending += 1
|
|
||||||
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
|
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
|
||||||
|
|
||||||
def _on_stage(self, message):
|
|
||||||
# The corner belongs to the recording when one is on: the previous
|
|
||||||
# run's progress must not wipe the waveform mid-sentence.
|
|
||||||
if self.state != RECORDING:
|
|
||||||
self.overlay.show_busy(message)
|
|
||||||
|
|
||||||
def _transcript_settled(self, payload):
|
|
||||||
"""One run out of the pipeline; where dictation stands now.
|
|
||||||
|
|
||||||
A request that asked to wait is answered once the queue is empty: with
|
|
||||||
runs finishing in the order they were spoken, the one it stopped is the
|
|
||||||
last of them, and an earlier run's result would be the wrong answer.
|
|
||||||
"""
|
|
||||||
self._transcripts_pending -= 1
|
|
||||||
if self.state != RECORDING:
|
|
||||||
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
|
||||||
if not self._transcripts_pending:
|
|
||||||
self._settle(DICTATION, payload)
|
|
||||||
|
|
||||||
def _on_finished(self, _raw, text, warning):
|
def _on_finished(self, _raw, text, warning):
|
||||||
if warning:
|
if warning:
|
||||||
# The text was still pasted, but cleanup did not run. Say so loudly:
|
# The text was still pasted, but cleanup did not run. Say so loudly:
|
||||||
# a rejected key otherwise looks exactly like working dictation.
|
# a rejected key otherwise looks exactly like working dictation.
|
||||||
if self.state != RECORDING:
|
self.overlay.show_warning(
|
||||||
self.overlay.show_warning(
|
t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0])
|
||||||
t("Pasted raw, cleanup failed: {error}",
|
)
|
||||||
error=warning.splitlines()[0])
|
|
||||||
)
|
|
||||||
self.tray.showMessage(
|
self.tray.showMessage(
|
||||||
t("Dikte: cleanup failed"), warning,
|
t("Dikte: cleanup failed"), warning,
|
||||||
QSystemTrayIcon.MessageIcon.Warning, 10000,
|
QSystemTrayIcon.MessageIcon.Warning, 10000,
|
||||||
)
|
)
|
||||||
elif self.state != RECORDING:
|
else:
|
||||||
# While a new recording is on, the flash is skipped: the text
|
|
||||||
# arriving where the cursor is says everything it would have.
|
|
||||||
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
|
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
|
||||||
self.overlay.show_done(
|
self.overlay.show_done(
|
||||||
t("{action}: {preview}", action=action, preview=_preview(text))
|
t("{action}: {preview}", action=action, preview=_preview(text))
|
||||||
)
|
)
|
||||||
self._transcript_settled({"ok": True, "text": text, "raw": _raw,
|
self._set_state(IDLE)
|
||||||
"warning": warning})
|
self._settle(DICTATION, {"ok": True, "text": text, "raw": _raw,
|
||||||
|
"warning": warning})
|
||||||
|
|
||||||
def _on_ask_finished(self, _raw, text, warning):
|
def _on_ask_finished(self, _raw, text, warning):
|
||||||
agent = assistant.display_name(self.conf)
|
agent = assistant.display_name(self.conf)
|
||||||
@@ -978,21 +936,9 @@ class Dikte:
|
|||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
(self._on_ask_error if owner == ASK else self._on_error)(message)
|
(self._on_ask_error if owner == ASK else self._on_error)(message)
|
||||||
|
|
||||||
def _on_pipeline_failed(self, message):
|
|
||||||
"""A run the pipeline gave up on; whatever queued behind it still runs."""
|
|
||||||
if self.state == RECORDING:
|
|
||||||
# The corner belongs to the new recording; the failure still has to
|
|
||||||
# be seen somewhere.
|
|
||||||
self.tray.showMessage("Dikte", message,
|
|
||||||
QSystemTrayIcon.MessageIcon.Warning, 8000)
|
|
||||||
else:
|
|
||||||
self._report(message, self.overlay)
|
|
||||||
self._transcript_settled({"ok": False, "error": message})
|
|
||||||
|
|
||||||
def _on_error(self, message):
|
def _on_error(self, message):
|
||||||
"""The recorder or the key listener failed; no run reached the pipeline."""
|
|
||||||
self._report(message, self.overlay)
|
self._report(message, self.overlay)
|
||||||
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
self._set_state(IDLE)
|
||||||
self._settle(DICTATION, {"ok": False, "error": message})
|
self._settle(DICTATION, {"ok": False, "error": message})
|
||||||
|
|
||||||
def _on_ask_error(self, message):
|
def _on_ask_error(self, message):
|
||||||
|
|||||||
@@ -338,6 +338,33 @@ def _codex_label(item):
|
|||||||
return t("Using {name}…", name=item_type or "a tool")
|
return t("Using {name}…", name=item_type or "a tool")
|
||||||
|
|
||||||
|
|
||||||
|
def codex_models():
|
||||||
|
"""The models Codex itself would offer right now, best first.
|
||||||
|
|
||||||
|
`codex debug models` prints the catalog the CLI's own model picker reads,
|
||||||
|
fetched from OpenAI and cached beside Codex's config, so the list is as
|
||||||
|
current as the installed Codex and there is no second list to keep up to
|
||||||
|
date here. Entries the picker hides are internal and stay hidden. A machine
|
||||||
|
without Codex, or one too old to have the command, answers with nothing and
|
||||||
|
the caller keeps its built-in list.
|
||||||
|
"""
|
||||||
|
if not shutil.which("codex"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(["codex", "debug", "models"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
catalog = json.loads(proc.stdout or "null")
|
||||||
|
except (OSError, subprocess.SubprocessError, ValueError):
|
||||||
|
return []
|
||||||
|
if not isinstance(catalog, dict):
|
||||||
|
return []
|
||||||
|
rows = [row for row in catalog.get("models") or []
|
||||||
|
if isinstance(row, dict) and row.get("slug")
|
||||||
|
and row.get("visibility") != "hide"]
|
||||||
|
rows.sort(key=lambda row: row.get("priority") or 0)
|
||||||
|
return [row["slug"] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
# --- OpenRouter -----------------------------------------------------------
|
# --- OpenRouter -----------------------------------------------------------
|
||||||
|
|
||||||
def _ask_openrouter(prompt, conf, on_stage):
|
def _ask_openrouter(prompt, conf, on_stage):
|
||||||
|
|||||||
+4
-1
@@ -69,7 +69,6 @@ TR = {
|
|||||||
|
|
||||||
# --- overlay / pipeline -------------------------------------------
|
# --- overlay / pipeline -------------------------------------------
|
||||||
"Transcribing…": "Yazıya çevriliyor…",
|
"Transcribing…": "Yazıya çevriliyor…",
|
||||||
"Waiting for the one before it…": "Öncekinin bitmesi bekleniyor…",
|
|
||||||
"Cleaning up…": "Temizleniyor…",
|
"Cleaning up…": "Temizleniyor…",
|
||||||
"Pasting…": "Yapıştırılıyor…",
|
"Pasting…": "Yapıştırılıyor…",
|
||||||
"Pasted": "Yapıştırıldı",
|
"Pasted": "Yapıştırıldı",
|
||||||
@@ -557,6 +556,10 @@ TR = {
|
|||||||
"“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha "
|
"“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha "
|
||||||
"çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, "
|
"çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, "
|
||||||
"çünkü ekranın başında bekliyorsun.",
|
"çünkü ekranın başında bekliyorsun.",
|
||||||
|
"The list is a starting point, not a fence: any model name {name} accepts "
|
||||||
|
"can be typed straight in.":
|
||||||
|
"Liste başlangıç için, sınır değil: {name} hangi model adını kabul "
|
||||||
|
"ediyorsa buraya elle yazılabilir.",
|
||||||
"Permissions": "İzinler",
|
"Permissions": "İzinler",
|
||||||
"Decide on its own, with the safety checks on":
|
"Decide on its own, with the safety checks on":
|
||||||
"Kendi karar versin, güvenlik denetimleri açık",
|
"Kendi karar versin, güvenlik denetimleri açık",
|
||||||
|
|||||||
+45
-2
@@ -79,7 +79,11 @@ ASSISTANT_PROVIDERS = [
|
|||||||
# Aliases resolve to the newest model of that name, so they age better than an
|
# Aliases resolve to the newest model of that name, so they age better than an
|
||||||
# id does; a full id can be typed in when a particular one is wanted.
|
# id does; a full id can be typed in when a particular one is wanted.
|
||||||
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"]
|
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"]
|
||||||
CODEX_MODELS = ["gpt-5.4-codex", "gpt-5.4", "o4-mini"]
|
# What the Codex boxes offer before Codex itself has answered, and everything
|
||||||
|
# they offer when it cannot: the real list comes from `codex debug models` when
|
||||||
|
# the window opens, so this only has to be roughly right.
|
||||||
|
CODEX_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
|
||||||
|
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
|
||||||
# Starting points only; the box is editable and OpenRouter has hundreds.
|
# Starting points only; the box is editable and OpenRouter has hundreds.
|
||||||
ASSISTANT_OR_MODELS = [
|
ASSISTANT_OR_MODELS = [
|
||||||
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
|
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
|
||||||
@@ -92,6 +96,12 @@ PERMISSION_MODES = [
|
|||||||
("Allow everything", "bypassPermissions"),
|
("Allow everything", "bypassPermissions"),
|
||||||
("Only what needs no permission", "manual"),
|
("Only what needs no permission", "manual"),
|
||||||
]
|
]
|
||||||
|
def _typed_model_note(name):
|
||||||
|
"""Every model box takes a typed name too; the tooltip that says so."""
|
||||||
|
return t("The list is a starting point, not a fence: any model name "
|
||||||
|
"{name} accepts can be typed straight in.", name=name)
|
||||||
|
|
||||||
|
|
||||||
# Codex confines the commands it runs instead of asking about them.
|
# Codex confines the commands it runs instead of asking about them.
|
||||||
CODEX_SANDBOXES = [
|
CODEX_SANDBOXES = [
|
||||||
("Read anything, write in the working directory", "workspace-write"),
|
("Read anything, write in the working directory", "workspace-write"),
|
||||||
@@ -521,6 +531,7 @@ class SettingsWindow(QDialog):
|
|||||||
|
|
||||||
_models_loaded = pyqtSignal(list, str)
|
_models_loaded = pyqtSignal(list, str)
|
||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
|
_codex_models_loaded = pyqtSignal(list)
|
||||||
# Which key was tested, whether it worked, and what to write under it.
|
# Which key was tested, whether it worked, and what to write under it.
|
||||||
_test_done = pyqtSignal(str, bool, str)
|
_test_done = pyqtSignal(str, bool, str)
|
||||||
# The release that was found, or None, and what went wrong instead.
|
# The release that was found, or None, and what went wrong instead.
|
||||||
@@ -577,6 +588,7 @@ class SettingsWindow(QDialog):
|
|||||||
|
|
||||||
self._models_loaded.connect(self._on_models_loaded)
|
self._models_loaded.connect(self._on_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
|
self._codex_models_loaded.connect(self._on_codex_models_loaded)
|
||||||
self._test_done.connect(self._on_test_done)
|
self._test_done.connect(self._on_test_done)
|
||||||
self._update_checked.connect(self._on_update_checked)
|
self._update_checked.connect(self._on_update_checked)
|
||||||
self.transcriber.progress.connect(self._on_file_progress)
|
self.transcriber.progress.connect(self._on_file_progress)
|
||||||
@@ -587,6 +599,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.meetings.finished.connect(self._on_minutes_finished)
|
self.meetings.finished.connect(self._on_minutes_finished)
|
||||||
self.meetings.failed.connect(self._on_minutes_failed)
|
self.meetings.failed.connect(self._on_minutes_failed)
|
||||||
self._load()
|
self._load()
|
||||||
|
self._load_codex_models()
|
||||||
# Connected after the load, so that filling the boxes in is not taken
|
# Connected after the load, so that filling the boxes in is not taken
|
||||||
# for the user ticking them.
|
# for the user ticking them.
|
||||||
self.file_timestamps.toggled.connect(self._remember_file_choices)
|
self.file_timestamps.toggled.connect(self._remember_file_choices)
|
||||||
@@ -841,11 +854,13 @@ class SettingsWindow(QDialog):
|
|||||||
self.cleanup_claude_model = QComboBox()
|
self.cleanup_claude_model = QComboBox()
|
||||||
self.cleanup_claude_model.setEditable(True)
|
self.cleanup_claude_model.setEditable(True)
|
||||||
self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
|
self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
|
||||||
|
self.cleanup_claude_model.setToolTip(_typed_model_note("Claude Code"))
|
||||||
orr_form.addRow(t("Model"), self.cleanup_claude_model)
|
orr_form.addRow(t("Model"), self.cleanup_claude_model)
|
||||||
|
|
||||||
self.cleanup_codex_model = QComboBox()
|
self.cleanup_codex_model = QComboBox()
|
||||||
self.cleanup_codex_model.setEditable(True)
|
self.cleanup_codex_model.setEditable(True)
|
||||||
self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
|
self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
|
||||||
|
self.cleanup_codex_model.setToolTip(_typed_model_note("Codex"))
|
||||||
orr_form.addRow(t("Model"), self.cleanup_codex_model)
|
orr_form.addRow(t("Model"), self.cleanup_codex_model)
|
||||||
|
|
||||||
self.cleanup_reasoning = QComboBox()
|
self.cleanup_reasoning = QComboBox()
|
||||||
@@ -1005,7 +1020,7 @@ class SettingsWindow(QDialog):
|
|||||||
"A name like “sonnet” always means the newest model of that line. "
|
"A name like “sonnet” always means the newest model of that line. "
|
||||||
"Opus thinks harder and answers slower, which is felt here more "
|
"Opus thinks harder and answers slower, which is felt here more "
|
||||||
"than anywhere else: you are standing in front of the screen."
|
"than anywhere else: you are standing in front of the screen."
|
||||||
))
|
) + " " + _typed_model_note("Claude Code"))
|
||||||
claude_form.addRow(t("Model"), self.assistant_model)
|
claude_form.addRow(t("Model"), self.assistant_model)
|
||||||
self.assistant_permission = QComboBox()
|
self.assistant_permission = QComboBox()
|
||||||
for label, value in PERMISSION_MODES:
|
for label, value in PERMISSION_MODES:
|
||||||
@@ -1020,6 +1035,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.assistant_codex_model.addItem(t("Codex's own default"), "")
|
self.assistant_codex_model.addItem(t("Codex's own default"), "")
|
||||||
for name in CODEX_MODELS:
|
for name in CODEX_MODELS:
|
||||||
self.assistant_codex_model.addItem(name, name)
|
self.assistant_codex_model.addItem(name, name)
|
||||||
|
self.assistant_codex_model.setToolTip(_typed_model_note("Codex"))
|
||||||
codex_form.addRow(t("Model"), self.assistant_codex_model)
|
codex_form.addRow(t("Model"), self.assistant_codex_model)
|
||||||
self.assistant_codex_sandbox = QComboBox()
|
self.assistant_codex_sandbox = QComboBox()
|
||||||
for label, value in CODEX_SANDBOXES:
|
for label, value in CODEX_SANDBOXES:
|
||||||
@@ -1876,6 +1892,33 @@ class SettingsWindow(QDialog):
|
|||||||
combo.setCurrentText(current)
|
combo.setCurrentText(current)
|
||||||
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
||||||
|
|
||||||
|
def _load_codex_models(self):
|
||||||
|
"""Ask Codex which models it offers, off the interface thread.
|
||||||
|
|
||||||
|
No button and no network of ours: the CLI answers from its own cache in
|
||||||
|
well under a second. Skipped when Codex is not installed, which is also
|
||||||
|
when the built-in list stays on screen and nobody is running Codex
|
||||||
|
anyway.
|
||||||
|
"""
|
||||||
|
if not shutil.which("codex"):
|
||||||
|
return
|
||||||
|
|
||||||
|
def work():
|
||||||
|
found = assistant.codex_models()
|
||||||
|
if found:
|
||||||
|
self._codex_models_loaded.emit(found)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_codex_models_loaded(self, models):
|
||||||
|
for combo in (self.cleanup_codex_model, self.assistant_codex_model):
|
||||||
|
current = combo.currentText()
|
||||||
|
combo.clear()
|
||||||
|
combo.addItem(t("Codex's own default"), "")
|
||||||
|
for name in models:
|
||||||
|
combo.addItem(name, name)
|
||||||
|
combo.setCurrentText(current)
|
||||||
|
|
||||||
def _test_openai(self):
|
def _test_openai(self):
|
||||||
key, base = self._typed_key("openai")
|
key, base = self._typed_key("openai")
|
||||||
self._test_key("openai", lambda: t(
|
self._test_key("openai", lambda: t(
|
||||||
|
|||||||
+8
-29
@@ -6,7 +6,6 @@ whatever came of it: an answer to a question, or a sentence saying what was
|
|||||||
done.
|
done.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import collections
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -46,13 +45,6 @@ class Pipeline(QObject):
|
|||||||
self.conf = conf
|
self.conf = conf
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
# Recordings waiting their turn, and whether a thread is working them
|
|
||||||
# off. The flag rather than the thread's own liveness, because a thread
|
|
||||||
# stays alive for a moment after deciding it is done, and a job arriving
|
|
||||||
# in that moment would be left in the queue with nobody coming back.
|
|
||||||
self._jobs = collections.deque()
|
|
||||||
self._draining = False
|
|
||||||
self._jobs_lock = threading.Lock()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def busy(self):
|
def busy(self):
|
||||||
@@ -61,30 +53,17 @@ class Pipeline(QObject):
|
|||||||
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
|
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
|
||||||
"""`paste` overrides the setting for this one run, which is what a
|
"""`paste` overrides the setting for this one run, which is what a
|
||||||
dictation asked for from a terminal wants: the text comes back down the
|
dictation asked for from a terminal wants: the text comes back down the
|
||||||
socket, and pasting it into whatever had focus is nobody's intention.
|
socket, and pasting it into whatever had focus is nobody's intention."""
|
||||||
|
if self.busy:
|
||||||
A run started while one is going waits its turn rather than being
|
return
|
||||||
dropped: the next dictation can be spoken while the last one is still
|
|
||||||
being cleaned up, and each one is finished, pasted and reported in the
|
|
||||||
order it was spoken."""
|
|
||||||
with self._jobs_lock:
|
|
||||||
self._jobs.append((wav_path, duration, list(rms_values), ask, paste))
|
|
||||||
if self._draining:
|
|
||||||
return
|
|
||||||
self._draining = True
|
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._thread = threading.Thread(target=self._drain, daemon=True)
|
self._thread = threading.Thread(
|
||||||
|
target=self._work,
|
||||||
|
args=(wav_path, duration, list(rms_values), ask, paste),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _drain(self):
|
|
||||||
while True:
|
|
||||||
with self._jobs_lock:
|
|
||||||
if not self._jobs:
|
|
||||||
self._draining = False
|
|
||||||
return
|
|
||||||
job = self._jobs.popleft()
|
|
||||||
self._work(*job)
|
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
"""Give up on a job already under way.
|
"""Give up on a job already under way.
|
||||||
|
|
||||||
|
|||||||
+47
-1
@@ -15,7 +15,8 @@ import unittest
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from dikte import assistant
|
from dikte import assistant
|
||||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
from tests.support import (DikteTest, FakeCompleted, fake_urlopen,
|
||||||
|
only_these_tools)
|
||||||
|
|
||||||
|
|
||||||
class FakeCli:
|
class FakeCli:
|
||||||
@@ -534,5 +535,50 @@ class Ask(DikteTest):
|
|||||||
self.assertEqual(assistant.stored_provider(), "")
|
self.assertEqual(assistant.stored_provider(), "")
|
||||||
|
|
||||||
|
|
||||||
|
class CodexModels(DikteTest):
|
||||||
|
"""The model list read off `codex debug models`."""
|
||||||
|
|
||||||
|
CATALOG = {"models": [
|
||||||
|
{"slug": "gpt-6-mini", "visibility": "list", "priority": 9},
|
||||||
|
{"slug": "gpt-6", "visibility": "list", "priority": 1},
|
||||||
|
{"slug": "codex-auto-review", "visibility": "hide", "priority": 3},
|
||||||
|
]}
|
||||||
|
|
||||||
|
def models(self, reply, code=0):
|
||||||
|
with only_these_tools("codex"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted(
|
||||||
|
returncode=code, stdout=reply)) as run:
|
||||||
|
found = assistant.codex_models()
|
||||||
|
self.run_call = run
|
||||||
|
return found
|
||||||
|
|
||||||
|
def test_the_catalog_arrives_best_first_without_the_hidden_ones(self):
|
||||||
|
found = self.models(json.dumps(self.CATALOG))
|
||||||
|
self.assertEqual(found, ["gpt-6", "gpt-6-mini"])
|
||||||
|
self.assertEqual(self.run_call.call_args.args[0],
|
||||||
|
["codex", "debug", "models"])
|
||||||
|
|
||||||
|
def test_a_codex_that_is_not_installed_is_not_run(self):
|
||||||
|
with only_these_tools(), \
|
||||||
|
mock.patch.object(subprocess, "run") as run:
|
||||||
|
self.assertEqual(assistant.codex_models(), [])
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_codex_too_old_to_have_the_command(self):
|
||||||
|
self.assertEqual(self.models("error: unknown subcommand", code=2), [])
|
||||||
|
|
||||||
|
def test_a_catalog_that_is_not_what_was_expected(self):
|
||||||
|
self.assertEqual(self.models(json.dumps(["gpt-6"])), [])
|
||||||
|
self.assertEqual(self.models(""), [])
|
||||||
|
|
||||||
|
def test_a_codex_that_hangs_is_given_up_on(self):
|
||||||
|
with only_these_tools("codex"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
side_effect=subprocess.TimeoutExpired(
|
||||||
|
["codex"], 30)):
|
||||||
|
self.assertEqual(assistant.codex_models(), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+21
-1
@@ -133,6 +133,8 @@ class Settings(DikteTest):
|
|||||||
"_load_models"))
|
"_load_models"))
|
||||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_transcribe_models"))
|
"_load_transcribe_models"))
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_codex_models"))
|
||||||
# The local model boxes fetch their own list the moment they are shown,
|
# The local model boxes fetch their own list the moment they are shown,
|
||||||
# from a thread, which is nobody's test failing but a real request.
|
# from a thread, which is nobody's test failing but a real request.
|
||||||
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
||||||
@@ -254,6 +256,19 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(shown, [provider])
|
self.assertEqual(shown, [provider])
|
||||||
self.assertFalse(box.isHidden())
|
self.assertFalse(box.isHidden())
|
||||||
|
|
||||||
|
def test_codex_answering_refills_both_of_its_boxes(self):
|
||||||
|
"""The list Codex gave replaces the built-in one, in both places, and
|
||||||
|
neither loses what was already picked."""
|
||||||
|
conf = self.config(cleanup_codex_model="my-own-model")
|
||||||
|
window = self.window(conf)
|
||||||
|
window._on_codex_models_loaded(["gpt-6", "gpt-6-mini"])
|
||||||
|
for combo in (window.cleanup_codex_model, window.assistant_codex_model):
|
||||||
|
with self.subTest(combo=combo.objectName() or "combo"):
|
||||||
|
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||||
|
self.assertEqual(offered[1:], ["gpt-6", "gpt-6-mini"])
|
||||||
|
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||||
|
"my-own-model")
|
||||||
|
|
||||||
def test_the_update_line_names_the_version_that_is_running(self):
|
def test_the_update_line_names_the_version_that_is_running(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
self.assertIn(settings_ui.__version__, window.update_status.text())
|
self.assertIn(settings_ui.__version__, window.update_status.text())
|
||||||
@@ -654,7 +669,9 @@ class MeetingSources(DikteTest):
|
|||||||
only_these_tools(), \
|
only_these_tools(), \
|
||||||
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
||||||
mock.patch.object(settings_ui.SettingsWindow,
|
mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_transcribe_models"):
|
"_load_transcribe_models"), \
|
||||||
|
mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_codex_models"):
|
||||||
window = settings_ui.SettingsWindow(cfg.Config())
|
window = settings_ui.SettingsWindow(cfg.Config())
|
||||||
self.addCleanup(window.deleteLater)
|
self.addCleanup(window.deleteLater)
|
||||||
self.addCleanup(window.close)
|
self.addCleanup(window.close)
|
||||||
@@ -683,6 +700,9 @@ class LocalModels(DikteTest):
|
|||||||
# "nothing can transcribe" question from its real binary and model.
|
# "nothing can transcribe" question from its real binary and model.
|
||||||
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
||||||
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||||
|
# And one with Codex on it would ask it for its model list.
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_codex_models"))
|
||||||
|
|
||||||
def window(self, conf):
|
def window(self, conf):
|
||||||
window = settings_ui.SettingsWindow(conf)
|
window = settings_ui.SettingsWindow(conf)
|
||||||
|
|||||||
+6
-35
@@ -8,7 +8,6 @@ afterwards. A pull request that reorders any of it shows up here.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -279,41 +278,13 @@ class Chain(DikteTest):
|
|||||||
|
|
||||||
|
|
||||||
class Busy(DikteTest):
|
class Busy(DikteTest):
|
||||||
def test_a_second_run_while_one_is_going_waits_its_turn(self):
|
def test_a_second_run_while_one_is_going_is_ignored(self):
|
||||||
"""The microphone is free while a transcript is being cleaned up, so
|
|
||||||
the next dictation can already have been spoken by then. It has to run
|
|
||||||
once the first is done, in the order they were spoken, on one thread."""
|
|
||||||
pipeline = worker.Pipeline(self.config())
|
pipeline = worker.Pipeline(self.config())
|
||||||
order = []
|
pipeline._thread = mock.Mock(is_alive=lambda: True)
|
||||||
started, gate = threading.Event(), threading.Event()
|
self.assertTrue(pipeline.busy)
|
||||||
|
with mock.patch.object(worker.threading, "Thread") as thread:
|
||||||
def work(wav_path, *_rest):
|
pipeline.run("/tmp/nope.wav", 1.0)
|
||||||
order.append(wav_path)
|
thread.assert_not_called()
|
||||||
started.set()
|
|
||||||
if wav_path == "first.wav":
|
|
||||||
gate.wait(5)
|
|
||||||
|
|
||||||
with mock.patch.object(pipeline, "_work", side_effect=work):
|
|
||||||
pipeline.run("first.wav", 1.0)
|
|
||||||
self.assertTrue(started.wait(5))
|
|
||||||
pipeline.run("second.wav", 1.0)
|
|
||||||
# Held, not dropped and not running beside the first.
|
|
||||||
self.assertEqual(order, ["first.wav"])
|
|
||||||
gate.set()
|
|
||||||
pipeline._thread.join(5)
|
|
||||||
self.assertEqual(order, ["first.wav", "second.wav"])
|
|
||||||
|
|
||||||
def test_a_run_arriving_after_the_queue_drained(self):
|
|
||||||
"""The worker thread ends with the queue; the next run brings one."""
|
|
||||||
pipeline = worker.Pipeline(self.config())
|
|
||||||
order = []
|
|
||||||
with mock.patch.object(pipeline, "_work",
|
|
||||||
side_effect=lambda wav, *rest: order.append(wav)):
|
|
||||||
pipeline.run("first.wav", 1.0)
|
|
||||||
pipeline._thread.join(5)
|
|
||||||
pipeline.run("second.wav", 1.0)
|
|
||||||
pipeline._thread.join(5)
|
|
||||||
self.assertEqual(order, ["first.wav", "second.wav"])
|
|
||||||
|
|
||||||
def test_the_chunk_length_matches_the_level_meter(self):
|
def test_the_chunk_length_matches_the_level_meter(self):
|
||||||
"""The silence thresholds are read in seconds, so the two must agree."""
|
"""The silence thresholds are read in seconds, so the two must agree."""
|
||||||
|
|||||||
Reference in New Issue
Block a user