mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge master: Groq, and cleanup on a subscription
Three of master's changes land on the same lines as this branch, so most of this is picking the newer shape and putting the local half back into it. cleanup.py arrived while this was being written and is the right place for a fourth provider, so the Target refactor of api.cleanup goes away: llama.cpp becomes a name in cleanup.PROVIDERS next to OpenRouter, Claude Code and Codex, and worker.py, meeting.py and filetranscribe.py go back to master's. The settings window keeps master's one row per provider, hidden with setRowVisible, rather than the two wrapper widgets this branch had. So does speech to text, which was doing the same thing its own way. The transcriber table has no room for a provider with no key and no base URL, so the local one is answered before the lookup rather than added to it, and an unknown name now falls back to openai by name: the shipped default is no longer a key of that table. The minutes stay on OpenRouter, which master already decided by routing only the transcript through cleanup.run, so meeting_provider goes.
This commit is contained in:
+4
-4
@@ -79,10 +79,10 @@ next port breaks all of them.
|
||||
|
||||
## What a pull request should carry
|
||||
|
||||
A change to behaviour comes with a test for it. Adding a provider means a test
|
||||
that the request goes to the right URL with the right fields; adding a platform
|
||||
means a test for whatever the parsing of its device list, clipboard or shortcuts
|
||||
looks like. Adding a setting means both halves of `settings_ui.py`: the round
|
||||
A change to behaviour comes with a test for it. Adding a provider means a row in
|
||||
`config.TRANSCRIBERS` and a test that the request goes to the right URL with the
|
||||
right fields; adding a platform means a test for whatever the parsing of its
|
||||
device list, clipboard or shortcuts looks like. Adding a setting means both halves of `settings_ui.py`: the round
|
||||
trip in `tests/test_ui.py` is what catches only one of them being written.
|
||||
|
||||
Match the surrounding code: it is plain Python with no framework, comments
|
||||
|
||||
@@ -25,7 +25,7 @@ just the Python standard library and PyQt6.
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
systemctl --user enable --now ydotool # needed for auto-paste
|
||||
|
||||
./install.sh # or: ./install.sh "Ctrl+Alt+Space"
|
||||
./install.sh # or: ./install.sh "Meta+Space" "Meta+Shift+Space"
|
||||
dikte # the settings window opens on first run
|
||||
```
|
||||
|
||||
@@ -36,15 +36,19 @@ tools instead:
|
||||
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||
```
|
||||
|
||||
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
||||
settings window installs a GNOME or KDE global shortcut.
|
||||
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
|
||||
two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and
|
||||
puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away
|
||||
again and leaves your settings and dictations alone unless you pass `--purge`.
|
||||
|
||||
Speech to text and cleanup each pick a provider in the settings window. Both can
|
||||
run here, on whisper.cpp and llama.cpp: the program and the model are downloaded
|
||||
from that window (checksummed, into `~/.local/share/dikte`), so nothing has to be
|
||||
installed first and nothing leaves the machine. The hosted alternatives want a
|
||||
key: **OpenAI** or **OpenRouter** for speech to text, **OpenRouter** for cleanup
|
||||
(`google/gemini-3.5-flash-lite`), so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
|
||||
Speech to text and cleanup each pick a provider in the settings window, and both
|
||||
can run here, on whisper.cpp and llama.cpp: the program and the model are
|
||||
downloaded from that window, checksummed, so nothing has to be installed first
|
||||
and nothing leaves the machine. Otherwise speech to text runs on **OpenAI**,
|
||||
**Groq** or **OpenRouter** (`gpt-4o-transcribe` by default) and cleanup on
|
||||
OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is installed, on
|
||||
Claude Code or Codex. The keys fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
|
||||
`OPENROUTER_API_KEY`, and are
|
||||
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
|
||||
in which case the raw transcript is pasted, and a thinking model's effort can be
|
||||
set next to it.
|
||||
@@ -54,7 +58,7 @@ set next to it.
|
||||
| What | How |
|
||||
| --- | --- |
|
||||
| Start / stop recording | `Ctrl+Space`, or click the tray icon |
|
||||
| Cancel a recording | Tray menu → *Cancel recording*, or `dikte cancel` |
|
||||
| Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` |
|
||||
| Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` |
|
||||
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
|
||||
| Settings | Tray menu → *Settings*, or `dikte settings` |
|
||||
@@ -128,11 +132,11 @@ running.
|
||||
right-click to delete.
|
||||
- **Turkish and English interface**, following the system locale by default.
|
||||
|
||||
## The global shortcut needs one logout
|
||||
## The global shortcuts need one logout
|
||||
|
||||
KWin only reads `kglobalshortcutsrc` at startup, so the shortcut `install.sh`
|
||||
KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh`
|
||||
writes will not fire until you log out and back in. Until then, Settings →
|
||||
Shortcut → **built-in listener** reads `/dev/input` and catches the combination
|
||||
Shortcuts → **built-in listener** reads `/dev/input` and catches the combination
|
||||
itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches
|
||||
the focused application (some editors will pop up autocomplete). The listener
|
||||
needs your user in the `input` group: `sudo usermod -aG input $USER`.
|
||||
@@ -146,7 +150,8 @@ ipc.py one request and one reply over the local socket
|
||||
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
|
||||
meeting.py channel split, speaker labelling, cleanup, minutes
|
||||
assistant.py running a dictation through Claude Code, Codex or OpenRouter
|
||||
api.py transcription and cleanup on any provider (stdlib only)
|
||||
api.py transcription and cleanup requests (stdlib only)
|
||||
cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex
|
||||
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
||||
hub.py what GitHub and Hugging Face have on offer today
|
||||
worker.py transcribe → clean up → clipboard → paste
|
||||
|
||||
+19
-15
@@ -24,7 +24,7 @@ sadece Python standart kütüphanesi ve PyQt6.
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
systemctl --user enable --now ydotool # otomatik yapıştırma için
|
||||
|
||||
./install.sh # ya da: ./install.sh "Ctrl+Alt+Space"
|
||||
./install.sh # ya da: ./install.sh "Meta+Space" "Meta+Shift+Space"
|
||||
dikte # ilk açılışta ayarlar penceresi gelir
|
||||
```
|
||||
|
||||
@@ -35,17 +35,20 @@ araçlarıyla çalışır:
|
||||
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||
```
|
||||
|
||||
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
||||
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
||||
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
|
||||
başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh`
|
||||
son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar;
|
||||
`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve
|
||||
diktelerine dokunmaz.
|
||||
|
||||
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
|
||||
seçer. İkisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program
|
||||
da model de o pencereden indirilir (sha256 doğrulamasıyla,
|
||||
`~/.local/share/dikte` altına), yani önceden hiçbir şey kurman gerekmez ve
|
||||
makineden hiçbir şey çıkmaz. Bulut seçenekleri anahtar ister: yazıya çevirme
|
||||
için **OpenAI** ya da **OpenRouter**, temizleme için **OpenRouter**
|
||||
(`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de
|
||||
yeter. Boş bırakırsan `OPENAI_API_KEY` ve
|
||||
seçer; ikisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program da
|
||||
model de o pencereden, sha256 doğrulamasıyla indirilir, yani önceden hiçbir şey
|
||||
kurman gerekmez ve makineden hiçbir şey çıkmaz. Bulutu seçersen sesi yazıya
|
||||
çevirme **OpenAI**, **Groq** ya da **OpenRouter**'da (varsayılan
|
||||
`gpt-4o-transcribe`), temizleme OpenRouter'da
|
||||
(`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude Code veya Codex'te
|
||||
çalışır. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve
|
||||
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
|
||||
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
|
||||
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||
@@ -55,7 +58,7 @@ yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebili
|
||||
| Ne | Nasıl |
|
||||
| --- | --- |
|
||||
| Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla |
|
||||
| Kaydı iptal et | Tepsi menüsü → *Kaydı iptal et*, ya da `dikte cancel` |
|
||||
| Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` |
|
||||
| Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
|
||||
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
|
||||
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
|
||||
@@ -127,11 +130,11 @@ olmasını ister.
|
||||
silebilirsin.
|
||||
- **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler.
|
||||
|
||||
## Global kısayol için bir kez oturum kapatmak gerekir
|
||||
## Global kısayollar için bir kez oturum kapatmak gerekir
|
||||
|
||||
KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın
|
||||
yazdığı kısayol oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
|
||||
Kısayol → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
|
||||
yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
|
||||
Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
|
||||
yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir
|
||||
(bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input`
|
||||
grubunda olmasını gerektirir: `sudo usermod -aG input $USER`.
|
||||
@@ -145,7 +148,8 @@ ipc.py yerel sokette bir istek, bir cevap
|
||||
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
|
||||
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
|
||||
assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme
|
||||
api.py her sağlayıcıda transkript ve temizleme (yalnız stdlib)
|
||||
api.py transkript ve temizleme istekleri (yalnız stdlib)
|
||||
cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex
|
||||
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
|
||||
hub.py GitHub ve Hugging Face'te bugün ne olduğu
|
||||
worker.py transkript → temizleme → pano → yapıştırma
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""OpenAI, OpenRouter and this machine, stdlib only.
|
||||
"""OpenAI, Groq, OpenRouter and this machine, stdlib only.
|
||||
|
||||
Transcription runs on any of three providers and cleanup on two, and none of
|
||||
them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions
|
||||
endpoint field for field, and ggml.py starts whisper.cpp on that same path, so
|
||||
one multipart request serves all three; llama.cpp answers /chat/completions the
|
||||
way OpenRouter does, so one JSON request serves both. What changes between them
|
||||
is the key, the base URL and the model id.
|
||||
Transcription runs on any of the four: Groq and OpenRouter both mirror OpenAI's
|
||||
/audio/transcriptions endpoint field for field, and ggml.py starts whisper.cpp
|
||||
on that same path, so one multipart request serves all of them and only the key,
|
||||
the base URL and the model id change. llama.cpp answers /chat/completions the way
|
||||
OpenRouter does, so cleanup here is the same request too.
|
||||
|
||||
The local ones have no key, and their base URL is not known until a server is
|
||||
up, which is the one thing this module has to fill in for them.
|
||||
What is on this machine has no key, and its base URL is not known until a server
|
||||
is up, which is the one thing this module has to fill in for it.
|
||||
"""
|
||||
|
||||
import collections
|
||||
@@ -25,6 +24,7 @@ from i18n import t
|
||||
APP_URL = "https://github.com/yusufipk/dikte"
|
||||
USER_AGENT = f"dikte/1.0 (+{APP_URL})"
|
||||
OPENAI_URL = "https://api.openai.com/v1"
|
||||
GROQ_URL = "https://api.groq.com/openai/v1"
|
||||
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# The floor for a local request. The timeouts elsewhere are sized for a hosted
|
||||
@@ -33,23 +33,23 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
||||
# deal of it. Cutting that off would throw the work away for nothing.
|
||||
LOCAL_TIMEOUT = 3600
|
||||
|
||||
# Where a request goes; built by config.Config's *_target() methods. `service`
|
||||
# is the name the user sees in an error, `provider` the one the code branches
|
||||
# on. `reasoning` is only read by cleanup, which is the only job with a model
|
||||
# that might think about anything.
|
||||
Target = collections.namedtuple(
|
||||
"Target", "provider service api_key base_url model reasoning", defaults=("",))
|
||||
# Where a transcription request goes; built by config.Config.transcribe_target().
|
||||
# `service` is the name the user sees in an error, `provider` the one the code
|
||||
# branches on.
|
||||
Target = collections.namedtuple("Target", "provider service api_key base_url model")
|
||||
|
||||
|
||||
def timestamp_model(provider, model):
|
||||
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id.
|
||||
def timestamp_model(provider, selected=""):
|
||||
"""Which model answers with segment times.
|
||||
|
||||
Whisper is what the local server runs whatever the file is called, so there
|
||||
it stays on the model that is already loaded; asking for another one would
|
||||
name a model that server has never heard of.
|
||||
OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything
|
||||
Groq transcribes with is a whisper, so the model already chosen does it and
|
||||
the fallback is only for a provider left on its default. So is everything the
|
||||
local server runs, whatever the file is called, and there asking for another
|
||||
model would name one it has never heard of.
|
||||
"""
|
||||
if provider == "local":
|
||||
return model
|
||||
if provider in ("groq", "local"):
|
||||
return selected or "whisper-large-v3-turbo"
|
||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
||||
|
||||
|
||||
@@ -141,34 +141,36 @@ def _headers(provider, api_key, content_type=None):
|
||||
return headers
|
||||
|
||||
|
||||
def _serving(target, server, timeout):
|
||||
"""A local target with the address of a running server in it.
|
||||
def serving(server):
|
||||
"""The base URL of a local server, started if it is not up yet.
|
||||
|
||||
The server is started on demand and picks its own port, so this is the first
|
||||
moment its address exists. serve() is idempotent: once it is up this costs
|
||||
nothing.
|
||||
It picks its own port, so this is the first moment its address exists.
|
||||
serve() is idempotent: once it is running this costs nothing.
|
||||
"""
|
||||
try:
|
||||
return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT)
|
||||
return server.serve()
|
||||
except ggml.LocalError as exc:
|
||||
raise ApiError(str(exc)) from None
|
||||
|
||||
|
||||
def _local_failure(target, server, exc):
|
||||
def local_failure(service, server, exc):
|
||||
"""A server that died mid-request, explained by its own output.
|
||||
|
||||
Without this the message is that the connection dropped, when the reason for
|
||||
it was printed by the process at the other end.
|
||||
"""
|
||||
detail = server.error()
|
||||
return ApiError(f"{target.service}: {exc}" + (f" ({detail})" if detail else ""),
|
||||
return ApiError(f"{service}: {exc}" + (f" ({detail})" if detail else ""),
|
||||
exc.status)
|
||||
|
||||
|
||||
def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||
granularity=None, timeout=300):
|
||||
if target.provider == "local":
|
||||
target, timeout = _serving(target, ggml.whisper, timeout)
|
||||
# The timeouts here are sized for a hosted API, where a slow answer is a
|
||||
# bill running. Locally the only thing being spent is time.
|
||||
target = target._replace(base_url=serving(ggml.whisper))
|
||||
timeout = max(timeout, LOCAL_TIMEOUT)
|
||||
elif not target.api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service=target.service))
|
||||
@@ -178,7 +180,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
||||
# The same words still reach the cleanup model as a glossary. whisper.cpp
|
||||
# takes it as the initial prompt, the way OpenAI does.
|
||||
if prompt and target.provider in ("openai", "local"):
|
||||
if prompt and target.provider != "openrouter":
|
||||
fields.append(("prompt", prompt))
|
||||
if granularity:
|
||||
fields.append(("timestamp_granularities[]", granularity))
|
||||
@@ -190,7 +192,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||
)
|
||||
except ApiError as exc:
|
||||
if target.provider == "local":
|
||||
raise _local_failure(target, ggml.whisper, exc) from None
|
||||
raise local_failure(target.service, ggml.whisper, exc) from None
|
||||
raise explain(exc, target.service) from None
|
||||
|
||||
|
||||
@@ -274,29 +276,27 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
||||
return out
|
||||
|
||||
|
||||
def _thinking(target, payload):
|
||||
def _thinking(payload, provider, reasoning):
|
||||
"""Ask for as much thinking as this provider understands, or for none.
|
||||
|
||||
An empty level means "whatever the model does on its own", so nothing is
|
||||
sent. The two providers mean opposite things by that, which is why the
|
||||
setting is kept per provider: OpenRouter's cleanup models answer straight
|
||||
away, while a local model that was trained to think will think, and cleanup
|
||||
is punctuation rather than a job worth thinking about.
|
||||
sent. The two mean opposite things by that, which is why the setting is kept
|
||||
per provider: OpenRouter's cleanup models answer straight away, while a local
|
||||
model that was trained to think will think, and cleanup is punctuation rather
|
||||
than a job worth thinking about.
|
||||
"""
|
||||
if not target.reasoning:
|
||||
if not reasoning:
|
||||
return
|
||||
if target.provider == "local-llm":
|
||||
# What llama.cpp passes to the chat template. The models that think
|
||||
# read it; the ones that do not ignore it.
|
||||
payload["chat_template_kwargs"] = {
|
||||
"enable_thinking": target.reasoning != "none"}
|
||||
return
|
||||
if target.reasoning != "none":
|
||||
if provider == "local-llm":
|
||||
# What llama.cpp passes to the chat template. The models that think read
|
||||
# it; the ones that do not ignore it.
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": reasoning != "none"}
|
||||
elif reasoning != "none":
|
||||
# The thinking itself is never shown, so ask for it to be left out.
|
||||
payload["reasoning"] = {"effort": target.reasoning, "exclude": True}
|
||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
||||
|
||||
|
||||
def _local_ceiling(text):
|
||||
def local_ceiling(text):
|
||||
"""How much of a reply is worth waiting for from a model on this machine.
|
||||
|
||||
Cleanup gives back what it was given, near enough, so a reply several times
|
||||
@@ -309,34 +309,32 @@ def _local_ceiling(text):
|
||||
return max(512, len(text))
|
||||
|
||||
|
||||
def cleanup(target, text, system_prompt, timeout=180):
|
||||
if target.provider == "local-llm":
|
||||
target, timeout = _serving(target, ggml.llm, timeout)
|
||||
elif not target.api_key:
|
||||
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||
base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
|
||||
service="OpenRouter"):
|
||||
if not api_key and provider != "local-llm":
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service=target.service))
|
||||
service=service))
|
||||
payload = {
|
||||
"model": target.model,
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"<transcript>\n{text}\n</transcript>"},
|
||||
],
|
||||
}
|
||||
if target.provider == "local-llm":
|
||||
payload["max_tokens"] = _local_ceiling(text)
|
||||
_thinking(target, payload)
|
||||
if provider == "local-llm":
|
||||
payload["max_tokens"] = local_ceiling(text)
|
||||
_thinking(payload, provider, reasoning)
|
||||
try:
|
||||
data = _request(
|
||||
f"{target.base_url.rstrip('/')}/chat/completions",
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
_headers(target.provider, target.api_key, "application/json"),
|
||||
_headers(provider, api_key, "application/json"),
|
||||
timeout=timeout,
|
||||
)
|
||||
except ApiError as exc:
|
||||
if target.provider == "local-llm":
|
||||
raise _local_failure(target, ggml.llm, exc) from None
|
||||
raise explain(exc, target.service) from None
|
||||
raise explain(exc, service) from None
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise ApiError(_extract_error(json.dumps(data)))
|
||||
@@ -348,7 +346,7 @@ def cleanup(target, text, system_prompt, timeout=180):
|
||||
# than a retry: cleanup is not a job that wants thinking.
|
||||
if message.get("reasoning_content") or message.get("reasoning"):
|
||||
raise ApiError(t("The cleanup model spent its whole reply on "
|
||||
"thinking. Set Thinking to “Off”."))
|
||||
"thinking. Set Thinking to \u201cOff\u201d."))
|
||||
raise ApiError(t("The cleanup model returned an empty reply."))
|
||||
return content
|
||||
|
||||
@@ -441,17 +439,22 @@ def openrouter_models(api_key="", transcription=False):
|
||||
return sorted(m["id"] for m in models if m.get("id"))
|
||||
|
||||
|
||||
def openai_models(api_key, base_url=OPENAI_URL):
|
||||
def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"):
|
||||
"""The audio models of anything that speaks OpenAI's /models, Groq included.
|
||||
|
||||
`service` is only the name an error is written in, so a Groq key that is
|
||||
refused says Groq rather than OpenAI.
|
||||
"""
|
||||
if not api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service="OpenAI"))
|
||||
service=service))
|
||||
try:
|
||||
data = _get_json(
|
||||
f"{base_url.rstrip('/')}/models",
|
||||
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
|
||||
)
|
||||
except ApiError as exc:
|
||||
raise explain(exc, "OpenAI") from None
|
||||
raise explain(exc, service) from None
|
||||
ids = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
audio = [i for i in ids if "transcribe" in i or "whisper" in i]
|
||||
return sorted(audio or ids)
|
||||
|
||||
+11
-3
@@ -75,7 +75,10 @@ CODEX_ITEMS = {
|
||||
CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
"medium": "medium", "high": "high", "xhigh": "xhigh",
|
||||
"max": "max"}
|
||||
CODEX_EFFORT = {"none": "minimal", "minimal": "minimal", "low": "low",
|
||||
# "minimal" was Codex's bottom rung until the newer models replaced it with
|
||||
# "none", and each of them rejects the other's word for it with a 400. "low" is
|
||||
# the one every model has, so the two lowest rungs land there instead.
|
||||
CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
"medium": "medium", "high": "high", "xhigh": "high",
|
||||
"max": "high"}
|
||||
|
||||
@@ -421,7 +424,7 @@ def _conclude(found, code, stderr, session, service):
|
||||
if code != 0 and not found["answer"]:
|
||||
if session and _session_missing(stderr):
|
||||
raise _SessionGone()
|
||||
raise AssistantError(_last_line(stderr) or found["failure"] or t(
|
||||
raise AssistantError(last_line(stderr) or found["failure"] or t(
|
||||
"{service} exited with code {code}.", service=service, code=code))
|
||||
if found["failure"] and not found["answer"]:
|
||||
raise AssistantError(found["failure"])
|
||||
@@ -480,6 +483,11 @@ def _finish(proc):
|
||||
return stderr
|
||||
|
||||
|
||||
def _last_line(text):
|
||||
def last_line(text):
|
||||
"""The line worth showing out of a CLI's stderr: the last one it wrote.
|
||||
|
||||
Shared with cleanup, which runs the same two programs for a different job
|
||||
and fails the same way when they are unhappy.
|
||||
"""
|
||||
lines = [line for line in (text or "").splitlines() if line.strip()]
|
||||
return lines[-1].strip() if lines else ""
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
"""Who rewrites the transcript once it has been heard.
|
||||
|
||||
Normally a small model on OpenRouter: one request, a second, a few tenths of a
|
||||
cent. A machine with Claude Code or Codex on it is already paying for a model
|
||||
though, and the subscription that answers "put that in my calendar on Thursday"
|
||||
can just as well take the "eee"s out of a sentence. No second key, no second
|
||||
bill. It costs seconds rather than one, because a CLI opens a whole session to
|
||||
do it, which is the trade.
|
||||
|
||||
Whoever does it, the job is the same one: no tools, no files, no memory of the
|
||||
last dictation. There is nothing here to look up and nothing to carry over, and
|
||||
a transcript is text from a microphone rather than an instruction, so the less
|
||||
the agent can reach while it reads one, the better.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import api
|
||||
import assistant
|
||||
import ggml
|
||||
from i18n import t
|
||||
|
||||
PROVIDERS = ("openrouter", "local", "claude", "codex")
|
||||
|
||||
|
||||
class CleanupError(api.ApiError):
|
||||
"""What a CLI could not do.
|
||||
|
||||
An ApiError because to the chain a cleanup that failed is a cleanup that
|
||||
failed, whichever way it was run, and every caller already catches one and
|
||||
keeps the raw transcript.
|
||||
"""
|
||||
|
||||
|
||||
def provider(conf):
|
||||
chosen = conf["cleanup_provider"]
|
||||
return chosen if chosen in PROVIDERS else "openrouter"
|
||||
|
||||
|
||||
def executable(name):
|
||||
"""The CLI a provider runs, or "" when it needs none."""
|
||||
return {"claude": "claude", "codex": "codex"}.get(name, "")
|
||||
|
||||
|
||||
def model(conf):
|
||||
"""Which model does the cleaning, for the history and the settings window."""
|
||||
name = provider(conf)
|
||||
if name == "local":
|
||||
return conf["local_llm_model"]
|
||||
if name == "claude":
|
||||
return conf["cleanup_claude_model"].strip() or "haiku"
|
||||
if name == "codex":
|
||||
# Codex is left on whatever it is set to unless a model is typed in, so
|
||||
# here there is only the name of the thing that did it.
|
||||
return conf["cleanup_codex_model"].strip() or "codex"
|
||||
return conf["cleanup_model"]
|
||||
|
||||
|
||||
def run(text, conf, system_prompt, timeout=180):
|
||||
"""Hand the transcript to whoever is set to clean it up."""
|
||||
name = provider(conf)
|
||||
if name == "openrouter":
|
||||
return api.cleanup(
|
||||
text, conf.openrouter_key(), conf["cleanup_model"], system_prompt,
|
||||
reasoning=conf["cleanup_reasoning"],
|
||||
base_url=conf["openrouter_base_url"], timeout=timeout,
|
||||
)
|
||||
if name == "local":
|
||||
return _local(text, conf, system_prompt, timeout)
|
||||
runner = _claude if name == "claude" else _codex
|
||||
return runner(text, conf, system_prompt, timeout)
|
||||
|
||||
|
||||
def _local(text, conf, system_prompt, timeout):
|
||||
"""llama.cpp, on this machine, answering the request OpenRouter answers.
|
||||
|
||||
No key and no bill, and the address does not exist until the server is up,
|
||||
which is what starting it here is for. The timeout is the hosted one raised:
|
||||
the only thing being spent is time.
|
||||
"""
|
||||
service = t("Local model")
|
||||
try:
|
||||
return api.cleanup(
|
||||
text, "", conf["local_llm_model"], system_prompt,
|
||||
reasoning=conf["local_llm_reasoning"],
|
||||
base_url=api.serving(ggml.llm),
|
||||
timeout=max(timeout, api.LOCAL_TIMEOUT),
|
||||
provider="local-llm", service=service,
|
||||
)
|
||||
except api.ApiError as exc:
|
||||
# A server that died mid-request would otherwise report only that the
|
||||
# connection dropped, when the reason is in its own output.
|
||||
raise api.local_failure(service, ggml.llm, exc) from None
|
||||
|
||||
|
||||
def _wrap(text):
|
||||
"""The same fence the OpenRouter call puts around it: this is the material,
|
||||
not the instruction, however much of it reads like one."""
|
||||
return f"<transcript>\n{text}\n</transcript>"
|
||||
|
||||
|
||||
# --- Claude Code ----------------------------------------------------------
|
||||
|
||||
def _claude(text, conf, system_prompt, timeout):
|
||||
cmd = [
|
||||
"claude", "-p", _wrap(text),
|
||||
# --system-prompt rather than --append-system-prompt: the cleanup rules
|
||||
# are the whole job, and Claude Code's own instructions are about
|
||||
# working on a codebase.
|
||||
"--system-prompt", system_prompt,
|
||||
"--model", model(conf),
|
||||
"--output-format", "text",
|
||||
"--tools", "", # nothing to run
|
||||
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
||||
"--no-session-persistence", # nothing to resume
|
||||
]
|
||||
effort = assistant.CLAUDE_EFFORT.get(conf["cleanup_reasoning"], "")
|
||||
if effort:
|
||||
cmd += ["--effort", effort]
|
||||
|
||||
answer = _output(cmd, timeout, "Claude")
|
||||
if not answer:
|
||||
raise CleanupError(t("{service} answered with nothing.", service="Claude"))
|
||||
return answer
|
||||
|
||||
|
||||
# --- Codex ----------------------------------------------------------------
|
||||
|
||||
def _codex(text, conf, system_prompt, timeout):
|
||||
# Codex takes no system prompt of its own, so the rules ride in front of the
|
||||
# transcript, kept apart from it so the two are not read as one.
|
||||
body = f"{system_prompt}\n\n---\n\n{_wrap(text)}"
|
||||
cmd = [
|
||||
"codex", "exec",
|
||||
"--sandbox", "read-only", # it has no reason to touch the disk
|
||||
"--skip-git-repo-check",
|
||||
"--ephemeral", # nothing to resume
|
||||
"--color", "never",
|
||||
"-c", 'approval_policy="never"', # there is nobody here to approve
|
||||
]
|
||||
if conf["cleanup_codex_model"].strip():
|
||||
cmd += ["-m", conf["cleanup_codex_model"].strip()]
|
||||
effort = assistant.CODEX_EFFORT.get(conf["cleanup_reasoning"], "")
|
||||
if effort:
|
||||
cmd += ["-c", f'model_reasoning_effort="{effort}"']
|
||||
|
||||
# `codex exec` prints a header, its thinking and a token count around the
|
||||
# answer; the file it writes on the way out is the answer on its own.
|
||||
handle, last_message = tempfile.mkstemp(prefix="dikte-cleanup-", suffix=".txt")
|
||||
os.close(handle)
|
||||
cmd += ["-o", last_message, body]
|
||||
try:
|
||||
_output(cmd, timeout, "Codex")
|
||||
answer = _read(last_message)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(last_message)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not answer:
|
||||
raise CleanupError(t("{service} answered with nothing.", service="Codex"))
|
||||
return answer
|
||||
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
return fh.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
# --- running a CLI --------------------------------------------------------
|
||||
|
||||
def _output(cmd, timeout, service):
|
||||
"""Run cmd to the end and return what it printed.
|
||||
|
||||
It runs in the home directory rather than wherever the agent is pointed: a
|
||||
project's instructions have opinions about how text should be written, and
|
||||
none of them are about this transcript.
|
||||
"""
|
||||
binary = cmd[0]
|
||||
if not shutil.which(binary):
|
||||
raise CleanupError(t(
|
||||
"{binary} not found. Install it, or have OpenRouter clean up "
|
||||
"instead, under Settings → API and models.", binary=binary,
|
||||
))
|
||||
try:
|
||||
done = subprocess.run(
|
||||
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
|
||||
service=service, seconds=timeout)) from None
|
||||
except OSError as exc:
|
||||
raise CleanupError(t("Could not run {binary}: {error}",
|
||||
binary=binary, error=exc)) from exc
|
||||
if done.returncode != 0:
|
||||
raise CleanupError(assistant.last_line(done.stderr) or t(
|
||||
"{service} exited with code {code}.",
|
||||
service=service, code=done.returncode))
|
||||
return (done.stdout or "").strip()
|
||||
@@ -25,6 +25,7 @@ from PyQt6.QtCore import QCoreApplication, QTimer
|
||||
import api
|
||||
import assistant
|
||||
import audio
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import hotkey
|
||||
@@ -44,14 +45,6 @@ GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
|
||||
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
|
||||
"ask-reset", "meeting-cancel"}
|
||||
|
||||
# Which desktop entry, name and setting belong to each of the three shortcuts.
|
||||
SHORTCUTS = {
|
||||
"toggle": (hotkey.DESKTOP_ID, "Dikte: start/stop recording", "shortcut"),
|
||||
"ask": (hotkey.ASK_DESKTOP_ID, "Dikte: ask Claude Code", "assistant_shortcut"),
|
||||
"meeting": (hotkey.MEETING_DESKTOP_ID, "Dikte: start/end a meeting recording",
|
||||
"meeting_shortcut"),
|
||||
}
|
||||
|
||||
_app = None
|
||||
|
||||
|
||||
@@ -662,12 +655,14 @@ def cmd_devices(opts):
|
||||
|
||||
def cmd_models(opts):
|
||||
conf = cfg.Config()
|
||||
who = cfg.TRANSCRIBERS[opts.provider]
|
||||
try:
|
||||
if opts.provider == "openai":
|
||||
models = api.openai_models(conf.openai_key(), conf["openai_base_url"])
|
||||
else:
|
||||
if opts.provider == "openrouter":
|
||||
models = api.openrouter_models(conf.openrouter_key(),
|
||||
transcription=opts.transcription)
|
||||
else:
|
||||
models = api.openai_models(conf.api_key(who.key), conf[who.url],
|
||||
who.service)
|
||||
except api.ApiError as exc:
|
||||
return fail(opts, exc)
|
||||
return out(opts, {"ok": True, "provider": opts.provider, "models": models},
|
||||
@@ -677,19 +672,21 @@ def cmd_models(opts):
|
||||
def cmd_test_key(opts):
|
||||
conf = cfg.Config()
|
||||
results = {}
|
||||
if opts.which in ("openai", "all"):
|
||||
for name, who in cfg.TRANSCRIBERS.items():
|
||||
if opts.which not in (name, "all"):
|
||||
continue
|
||||
try:
|
||||
count = len(api.openai_models(conf.openai_key(), conf["openai_base_url"]))
|
||||
results["openai"] = {"ok": True,
|
||||
"message": f"connection works, {count} models visible"}
|
||||
if name == "openrouter":
|
||||
# The one key that also pays for cleanup, so it reports credit
|
||||
# rather than a model count.
|
||||
message = api.openrouter_key_status(conf.openrouter_key())
|
||||
else:
|
||||
count = len(api.openai_models(conf.api_key(who.key), conf[who.url],
|
||||
who.service))
|
||||
message = f"connection works, {count} models visible"
|
||||
results[name] = {"ok": True, "message": message}
|
||||
except api.ApiError as exc:
|
||||
results["openai"] = {"ok": False, "message": str(exc)}
|
||||
if opts.which in ("openrouter", "all"):
|
||||
try:
|
||||
results["openrouter"] = {"ok": True,
|
||||
"message": api.openrouter_key_status(conf.openrouter_key())}
|
||||
except api.ApiError as exc:
|
||||
results["openrouter"] = {"ok": False, "message": str(exc)}
|
||||
results[name] = {"ok": False, "message": str(exc)}
|
||||
everything_ok = all(item["ok"] for item in results.values())
|
||||
lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}"
|
||||
for name, item in results.items()]
|
||||
@@ -701,9 +698,9 @@ def cmd_shortcut(opts):
|
||||
conf = cfg.Config()
|
||||
if opts.shortcut == "status":
|
||||
rows = {}
|
||||
for name, (desktop_id, _label, key) in SHORTCUTS.items():
|
||||
rows[name] = {"registered": hotkey.shortcut_status(desktop_id),
|
||||
"configured": conf[key]}
|
||||
for name, spec in hotkey.SHORTCUTS.items():
|
||||
rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id),
|
||||
"configured": conf[spec.setting]}
|
||||
lines = [f"{name:8} {row['registered'] or '(not installed)':16} "
|
||||
f"setting: {row['configured'] or '(none)'}"
|
||||
for name, row in rows.items()]
|
||||
@@ -711,28 +708,29 @@ def cmd_shortcut(opts):
|
||||
return out(opts, {"ok": True, "shortcuts": rows,
|
||||
"listener": conf["evdev_hotkey"]}, "\n".join(lines))
|
||||
|
||||
desktop_id, label, key = SHORTCUTS[opts.which]
|
||||
spec = hotkey.SHORTCUTS[opts.which]
|
||||
if opts.shortcut == "remove":
|
||||
hotkey.remove_shortcut(desktop_id)
|
||||
hotkey.remove_shortcut(spec.desktop_id)
|
||||
return out(opts, {"ok": True, "removed": opts.which},
|
||||
f"Removed the {opts.which} shortcut.")
|
||||
|
||||
combo = (opts.combo or conf[key] or ("Ctrl+Space" if opts.which == "toggle" else "")).strip()
|
||||
combo = (opts.combo or conf[spec.setting] or spec.fallback).strip()
|
||||
if not combo:
|
||||
return fail(opts, "no combination given and none stored; pass --combo", 2)
|
||||
if hotkey.parse_shortcut(combo) == (None, None):
|
||||
return fail(opts, f"cannot parse that combination: {combo}", 2)
|
||||
clashes = hotkey.conflicting_shortcuts(combo, desktop_id)
|
||||
clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
|
||||
if clashes and not opts.force:
|
||||
return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. "
|
||||
"Pass --force to install it anyway.", 1, conflicts=clashes)
|
||||
|
||||
ok, message = hotkey.install_shortcut(
|
||||
combo, ipc.command_for(opts.which), name=label, desktop_id=desktop_id,
|
||||
combo, ipc.command_for(spec.verb), name=spec.name,
|
||||
desktop_id=spec.desktop_id,
|
||||
)
|
||||
if not ok:
|
||||
return fail(opts, message)
|
||||
conf[key] = combo
|
||||
conf[spec.setting] = combo
|
||||
try:
|
||||
conf.save()
|
||||
except OSError as exc:
|
||||
@@ -767,16 +765,18 @@ def cmd_status(opts):
|
||||
def cmd_doctor(opts):
|
||||
"""What the settings window checks behind its buttons, in one pass."""
|
||||
conf = cfg.Config()
|
||||
programs = {name: shutil.which(name) or ""
|
||||
for name in ("pw-record", "wl-copy", "ydotool", "ffmpeg",
|
||||
"pactl", "kwriteconfig6",
|
||||
assistant.executable(assistant.provider(conf)) or "claude")}
|
||||
wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6",
|
||||
assistant.executable(assistant.provider(conf)) or "claude",
|
||||
cleanup.executable(cleanup.provider(conf))]
|
||||
programs = {name: shutil.which(name) or "" for name in wanted if name}
|
||||
target = conf.transcribe_target()
|
||||
cleaner = cleanup.provider(conf)
|
||||
checks = {
|
||||
"programs": programs,
|
||||
"transcription": {"provider": target.provider, "model": target.model,
|
||||
"key": bool(target.api_key)},
|
||||
"cleanup": {"enabled": conf["cleanup_enabled"], "model": conf["cleanup_model"],
|
||||
"cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
|
||||
"model": cleanup.model(conf),
|
||||
"key": bool(conf.openrouter_key())},
|
||||
"agent": {"provider": assistant.provider(conf),
|
||||
"directory": assistant.working_dir(conf)},
|
||||
@@ -787,8 +787,11 @@ def cmd_doctor(opts):
|
||||
lines += [
|
||||
f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on "
|
||||
f"{target.model}",
|
||||
f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
|
||||
f"{conf['cleanup_model']}",
|
||||
# Cleanup on a CLI needs no key, so what is checked is the program.
|
||||
(f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
|
||||
f"{conf['cleanup_model']}") if cleaner == "openrouter" else
|
||||
(f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} "
|
||||
f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"),
|
||||
f"{'✓' if checks['running'] else '·'} application "
|
||||
+ ("running" if checks["running"] else "not running"),
|
||||
]
|
||||
@@ -986,30 +989,31 @@ def build_parser():
|
||||
# --- the machine ------------------------------------------------------
|
||||
leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices)
|
||||
models = leaf(subs, "models", "model ids a provider offers")
|
||||
models.add_argument("--provider", choices=("openrouter", "openai"),
|
||||
models.add_argument("--provider", choices=tuple(cfg.TRANSCRIBERS),
|
||||
default="openrouter")
|
||||
models.add_argument("--transcription", action="store_true",
|
||||
help="only the speech-to-text ones")
|
||||
models.set_defaults(func=cmd_models)
|
||||
test = leaf(subs, "test-key", "check the API keys")
|
||||
test.add_argument("which", nargs="?", default="all",
|
||||
choices=("all", "openai", "openrouter"))
|
||||
choices=("all", *cfg.TRANSCRIBERS))
|
||||
test.set_defaults(func=cmd_test_key)
|
||||
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
|
||||
|
||||
shortcut = leaf(subs, "shortcut", "the KDE global shortcuts")
|
||||
shortcut = leaf(subs, "shortcut", "the desktop's global shortcuts")
|
||||
inner = shortcut.add_subparsers(dest="shortcut", metavar="")
|
||||
shortcut.set_defaults(func=_needs_subcommand(shortcut))
|
||||
leaf(inner, "status", "what is registered").set_defaults(func=cmd_shortcut)
|
||||
install = leaf(inner, "install", "register one")
|
||||
install.add_argument("which", nargs="?", default="toggle",
|
||||
choices=tuple(SHORTCUTS))
|
||||
choices=tuple(hotkey.SHORTCUTS))
|
||||
install.add_argument("--combo", help="e.g. Ctrl+Alt+Space")
|
||||
install.add_argument("--force", action="store_true",
|
||||
help="install it even if something else uses it")
|
||||
install.set_defaults(func=cmd_shortcut)
|
||||
remove = leaf(inner, "remove", "unregister one")
|
||||
remove.add_argument("which", nargs="?", default="toggle", choices=tuple(SHORTCUTS))
|
||||
remove.add_argument("which", nargs="?", default="toggle",
|
||||
choices=tuple(hotkey.SHORTCUTS))
|
||||
remove.set_defaults(func=cmd_shortcut)
|
||||
|
||||
# --- the application --------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Settings storage in ~/.config/dikte/config.json"""
|
||||
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -365,10 +366,13 @@ DEFAULTS = {
|
||||
"ui_language": "auto", # auto | tr | en
|
||||
"openai_api_key": "",
|
||||
"openai_base_url": "https://api.openai.com/v1",
|
||||
"groq_api_key": "",
|
||||
"groq_base_url": "https://api.groq.com/openai/v1",
|
||||
"openrouter_api_key": "",
|
||||
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
||||
"transcribe_provider": "local", # local | openai | openrouter
|
||||
"transcribe_provider": "local", # "local", or a key of TRANSCRIBERS
|
||||
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
||||
"groq_transcribe_model": "whisper-large-v3-turbo",
|
||||
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
||||
"language": "tr",
|
||||
"transcribe_prompt": "",
|
||||
@@ -387,8 +391,10 @@ DEFAULTS = {
|
||||
"local_binary": "", # empty -> whichever copy ggml.py finds
|
||||
|
||||
"cleanup_enabled": True,
|
||||
"cleanup_provider": "openrouter", # openrouter | local
|
||||
"cleanup_provider": "openrouter", # a name in cleanup.PROVIDERS
|
||||
"cleanup_model": "google/gemini-3.5-flash-lite",
|
||||
"cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id
|
||||
"cleanup_codex_model": "", # empty -> whatever Codex is set to
|
||||
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
||||
|
||||
# --- llama.cpp, on this machine -----------------------------------------
|
||||
@@ -419,6 +425,10 @@ DEFAULTS = {
|
||||
"min_voiced_seconds": 0.3,
|
||||
"filter_hallucinations": True,
|
||||
"shortcut": "Ctrl+Space",
|
||||
# Ctrl+Alt+Space rather than Escape: the combination the recording started
|
||||
# with, one modifier along. Escape belongs to whatever window has focus, and
|
||||
# while you are dictating something else usually has it.
|
||||
"cancel_shortcut": "Ctrl+Alt+Space",
|
||||
"evdev_hotkey": False,
|
||||
"overlay_corner": "bottom-left",
|
||||
"keep_audio": False,
|
||||
@@ -434,7 +444,6 @@ DEFAULTS = {
|
||||
"meeting_language": "", # empty -> the dictation speech language
|
||||
"meeting_max_seconds": 14400, # 4 hours
|
||||
"meeting_cleanup": True,
|
||||
"meeting_provider": "openrouter", # openrouter | local
|
||||
"meeting_model": "google/gemini-3.5-flash",
|
||||
"meeting_reasoning": "",
|
||||
"meeting_prompt": "", # empty -> language-specific default
|
||||
@@ -474,6 +483,22 @@ LEGACY_PROMPTS = {
|
||||
"154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English
|
||||
}
|
||||
|
||||
# Every provider speech to text can run on, and the four settings that describe
|
||||
# one. A fifth is a row here rather than another branch in transcribe_target(),
|
||||
# another key row in the settings window and another line in save and load. The
|
||||
# order is the order the provider box offers them in. `service` is the name the
|
||||
# user sees; the environment variable that stands in for an empty key is the
|
||||
# name of its setting, shouted.
|
||||
Transcriber = collections.namedtuple("Transcriber", "service key url model")
|
||||
TRANSCRIBERS = {
|
||||
"openai": Transcriber("OpenAI", "openai_api_key", "openai_base_url",
|
||||
"transcribe_model"),
|
||||
"groq": Transcriber("Groq", "groq_api_key", "groq_base_url",
|
||||
"groq_transcribe_model"),
|
||||
"openrouter": Transcriber("OpenRouter", "openrouter_api_key",
|
||||
"openrouter_base_url", "openrouter_transcribe_model"),
|
||||
}
|
||||
|
||||
# Corners used to be stored with Turkish names.
|
||||
_CORNER_MIGRATION = {
|
||||
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
|
||||
@@ -522,55 +547,40 @@ class Config:
|
||||
def get(self, key, default=None):
|
||||
return self.data.get(key, DEFAULTS.get(key, default))
|
||||
|
||||
def api_key(self, setting):
|
||||
"""A stored key, or the environment variable that shares its name."""
|
||||
return self[setting].strip() or os.environ.get(setting.upper(), "").strip()
|
||||
|
||||
def openai_key(self):
|
||||
"""Fall back to the environment when no key is stored."""
|
||||
return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
return self.api_key("openai_api_key")
|
||||
|
||||
def groq_key(self):
|
||||
return self.api_key("groq_api_key")
|
||||
|
||||
def openrouter_key(self):
|
||||
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
|
||||
return self.api_key("openrouter_api_key")
|
||||
|
||||
def transcribe_target(self):
|
||||
"""Key, endpoint and model for whichever provider does speech to text.
|
||||
|
||||
The local one leaves its base URL empty on purpose: the server picks a
|
||||
port when it starts, and starting it here would make reading a setting
|
||||
launch a process. api.py fills the address in when it is about to send
|
||||
the request, which is the moment the server is needed anyway.
|
||||
The local one is not in the table and leaves its base URL empty on
|
||||
purpose: the server picks a port when it starts, and reading a setting
|
||||
must not be what launches a process. api.py fills the address in when it
|
||||
is about to send the request, which is the moment the server is needed
|
||||
anyway.
|
||||
"""
|
||||
provider = self["transcribe_provider"]
|
||||
if provider == "local":
|
||||
name = self["transcribe_provider"]
|
||||
if name == "local":
|
||||
return api.Target("local", t("Local whisper"), "", "",
|
||||
self["local_model"])
|
||||
if provider == "openrouter":
|
||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||
self["openrouter_base_url"],
|
||||
self["openrouter_transcribe_model"])
|
||||
return api.Target("openai", "OpenAI", self.openai_key(),
|
||||
self["openai_base_url"], self["transcribe_model"])
|
||||
|
||||
def cleanup_target(self):
|
||||
"""The same, for the model that tidies a transcript up."""
|
||||
if self["cleanup_provider"] == "local":
|
||||
return api.Target("local-llm", t("Local model"), "", "",
|
||||
self["local_llm_model"], self["local_llm_reasoning"])
|
||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||
self["openrouter_base_url"], self["cleanup_model"],
|
||||
self["cleanup_reasoning"])
|
||||
|
||||
def minutes_target(self):
|
||||
"""The same again, for the minutes.
|
||||
|
||||
Its own provider rather than the cleanup one. The two jobs are not the
|
||||
same size: a 4B model on this machine will strip the filler words out of
|
||||
a dictation perfectly well and will not write up an hour long meeting,
|
||||
so choosing it for the first must not quietly choose it for the second.
|
||||
"""
|
||||
if self["meeting_provider"] == "local":
|
||||
return api.Target("local-llm", t("Local model"), "", "",
|
||||
self["local_llm_model"], self["local_llm_reasoning"])
|
||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||
self["openrouter_base_url"], self["meeting_model"],
|
||||
self["meeting_reasoning"])
|
||||
if name not in TRANSCRIBERS:
|
||||
# A config written by a fork, or by a version that dropped one. The
|
||||
# shipped default is not in the table, so this names the hosted one
|
||||
# to land on rather than reading it from there.
|
||||
name = "openai"
|
||||
who = TRANSCRIBERS[name]
|
||||
return api.Target(name, who.service, self.api_key(who.key),
|
||||
self[who.url], self[who.model])
|
||||
|
||||
def transcribe_ready(self):
|
||||
"""Whether speech to text could run right now, without opening Settings."""
|
||||
@@ -606,7 +616,7 @@ class Config:
|
||||
|
||||
def uses_local_llm(self):
|
||||
"""Whether anything is set to run the local cleanup model."""
|
||||
return "local" in (self["cleanup_provider"], self["meeting_provider"])
|
||||
return self["cleanup_provider"] == "local"
|
||||
|
||||
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
||||
subtitles=False):
|
||||
|
||||
@@ -160,8 +160,10 @@ class Dikte:
|
||||
self.ask_cancel_action.setEnabled(False)
|
||||
self.menu.addAction(self.ask_cancel_action)
|
||||
|
||||
self.cancel_action = QAction(t("Cancel recording"), self.menu)
|
||||
self.cancel_action.triggered.connect(self.cancel)
|
||||
self.cancel_action = QAction(t("Discard the recording"), self.menu)
|
||||
# The inner method, so that a menu click is never mistaken for the KDE
|
||||
# shortcut echoing the built-in listener's press.
|
||||
self.cancel_action.triggered.connect(self._cancel)
|
||||
self.cancel_action.setEnabled(False)
|
||||
self.menu.addAction(self.cancel_action)
|
||||
self.menu.addSeparator()
|
||||
@@ -314,6 +316,9 @@ class Dikte:
|
||||
def toggle_meeting(self):
|
||||
self._external("meeting", self._toggle_meeting)
|
||||
|
||||
def cancel(self):
|
||||
self._external("cancel", self._cancel)
|
||||
|
||||
def _external(self, name, handler):
|
||||
# The built-in listener sees the key press the instant it happens, so a
|
||||
# toggle arriving right behind one is the KDE shortcut catching up on
|
||||
@@ -331,7 +336,8 @@ class Dikte:
|
||||
if timer is None:
|
||||
timer = self.last_evdev[name] = QElapsedTimer()
|
||||
timer.restart()
|
||||
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask}
|
||||
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
|
||||
"cancel": self._cancel}
|
||||
handlers.get(name, self._toggle)()
|
||||
|
||||
def _retire_listener(self):
|
||||
@@ -524,7 +530,7 @@ class Dikte:
|
||||
self.ask_overlay.show_busy(t("Transcribing…"))
|
||||
self.recorder.stop()
|
||||
|
||||
def cancel(self):
|
||||
def _cancel(self):
|
||||
"""Throw away whichever recording is running."""
|
||||
if not self.recording:
|
||||
return
|
||||
@@ -548,7 +554,7 @@ class Dikte:
|
||||
def cancel_ask(self):
|
||||
"""Call off the agent, whether it is still recording or already working."""
|
||||
if self.ask_state == RECORDING:
|
||||
self.cancel()
|
||||
self._cancel()
|
||||
elif self.ask_state == BUSY:
|
||||
self.ask_overlay.show_busy(t("Stopping…"))
|
||||
self.ask_pipeline.cancel()
|
||||
@@ -796,10 +802,7 @@ class Dikte:
|
||||
|
||||
def open_settings(self):
|
||||
if self.settings_window is None:
|
||||
self.settings_window = SettingsWindow(
|
||||
self.conf, launch_command(), meeting_command(), self.meetings,
|
||||
ask_command(),
|
||||
)
|
||||
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||
self.settings_window.applied.connect(self._apply_settings)
|
||||
self.settings_window.finished.connect(self._settings_closed)
|
||||
self.settings_window.show()
|
||||
@@ -851,9 +854,8 @@ class Dikte:
|
||||
self._build_tray()
|
||||
self._refresh_tray()
|
||||
if self.conf["evdev_hotkey"]:
|
||||
self.evdev.start({"toggle": self.conf["shortcut"],
|
||||
"ask": self.conf["assistant_shortcut"],
|
||||
"meeting": self.conf["meeting_shortcut"]})
|
||||
self.evdev.start({name: self.conf[spec.setting]
|
||||
for name, spec in hotkey.SHORTCUTS.items()})
|
||||
else:
|
||||
self.evdev.stop()
|
||||
|
||||
@@ -895,19 +897,6 @@ def _clock(seconds):
|
||||
else f"{minutes}:{secs:02d}")
|
||||
|
||||
|
||||
def launch_command():
|
||||
"""The command the KDE shortcut will run."""
|
||||
return ipc.command_for("toggle")
|
||||
|
||||
|
||||
def meeting_command():
|
||||
return ipc.command_for("meeting")
|
||||
|
||||
|
||||
def ask_command():
|
||||
return ipc.command_for("ask")
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
# Anything typed at a terminal is the command line's business, including
|
||||
|
||||
+2
-2
@@ -17,6 +17,7 @@ import wave
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
from i18n import t
|
||||
|
||||
CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16
|
||||
@@ -127,11 +128,10 @@ class FileTranscriber(QObject):
|
||||
def _cleanup(self, text, timestamps):
|
||||
conf = self.conf
|
||||
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
|
||||
target = conf.cleanup_target()
|
||||
out = []
|
||||
for block in split_text(text, timestamps):
|
||||
self._check()
|
||||
out.append(api.cleanup(target, block, prompt))
|
||||
out.append(cleanup.run(block, conf, prompt))
|
||||
return ("\n" if timestamps else "\n\n").join(out)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""GNOME/KDE global-shortcut installation plus a built-in evdev listener."""
|
||||
|
||||
import ast
|
||||
import collections
|
||||
import glob
|
||||
import os
|
||||
import pathlib
|
||||
@@ -16,6 +17,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from i18n import t
|
||||
|
||||
DESKTOP_ID = "dikte-toggle.desktop"
|
||||
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
|
||||
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
|
||||
ASK_DESKTOP_ID = "dikte-ask.desktop"
|
||||
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
|
||||
@@ -24,6 +26,25 @@ SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
|
||||
GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys"
|
||||
GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"
|
||||
|
||||
Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback")
|
||||
|
||||
# Every global shortcut in one place, because there are four of them and the
|
||||
# command line, the settings window and the installer each used to carry their
|
||||
# own copy of the list. `fallback` is what to register when the setting is
|
||||
# empty: only the toggle has one, since it is the key the application is
|
||||
# unusable without.
|
||||
SHORTCUTS = {
|
||||
"toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
|
||||
"shortcut", "Ctrl+Space"),
|
||||
"cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
|
||||
"cancel_shortcut", ""),
|
||||
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
|
||||
"assistant_shortcut", ""),
|
||||
"meeting": Shortcut("meeting", MEETING_DESKTOP_ID,
|
||||
"Dikte: start/end a meeting recording",
|
||||
"meeting_shortcut", ""),
|
||||
}
|
||||
|
||||
# --- evdev key codes (linux/input-event-codes.h) --------------------------
|
||||
|
||||
EV_KEY = 0x01
|
||||
@@ -371,10 +392,14 @@ def remove_kde_shortcut(desktop_id=DESKTOP_ID):
|
||||
(APPLICATIONS_DIR / desktop_id).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
# kwriteconfig6 deletes keys rather than groups, so both of the ones KDE
|
||||
# keeps in there go and the empty group is left behind harmlessly.
|
||||
for key in ("_launch", "_k_friendly_name"):
|
||||
try:
|
||||
subprocess.run(
|
||||
["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc",
|
||||
"--group", "services", "--group", desktop_id, "--key", "_launch", "--delete"],
|
||||
"--group", "services", "--group", desktop_id,
|
||||
"--key", key, "--delete"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
|
||||
@@ -56,7 +56,7 @@ TR = {
|
||||
"Start recording": "Kaydı başlat",
|
||||
"Stop and transcribe": "Kaydı bitir ve yaz",
|
||||
"Working…": "İşleniyor…",
|
||||
"Cancel recording": "Kaydı iptal et",
|
||||
"Discard the recording": "Kaydı iptal et",
|
||||
"Settings…": "Ayarlar…",
|
||||
"Restart": "Yeniden başlat",
|
||||
"Quit": "Çık",
|
||||
@@ -133,6 +133,7 @@ TR = {
|
||||
"Cleanup rules": "Temizleme kuralları",
|
||||
"Audio file": "Ses dosyası",
|
||||
"Shortcut": "Kısayol",
|
||||
"Shortcuts": "Kısayollar",
|
||||
"History": "Geçmiş",
|
||||
"Save": "Kaydet",
|
||||
"Saved successfully.": "Başarıyla kaydedildi.",
|
||||
@@ -177,6 +178,7 @@ TR = {
|
||||
"Model": "Model",
|
||||
"Provider": "Sağlayıcı",
|
||||
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
|
||||
"gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
|
||||
"sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)",
|
||||
"Test": "Test et",
|
||||
"Trying…": "Deneniyor…",
|
||||
@@ -184,6 +186,18 @@ TR = {
|
||||
"Connection works. {count} audio models visible.":
|
||||
"Bağlantı tamam. {count} ses modeli görünüyor.",
|
||||
"Clean the transcript with a model": "Transkripti bir modelle temizle",
|
||||
"OpenRouter is the quickest and the only one that needs nothing installed. "
|
||||
"Claude Code and Codex clean up on the subscription you already have, "
|
||||
"without a second key, and take a few seconds longer because each one opens "
|
||||
"a session to do it.":
|
||||
"En hızlısı OpenRouter'dır ve kurulu bir program istemeyen tek seçenektir. "
|
||||
"Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden "
|
||||
"yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından "
|
||||
"birkaç saniye daha uzun sürer.",
|
||||
"{binary} is not on your PATH, so cleanup would fail and the raw transcript "
|
||||
"would be pasted. Install it, or pick another one above.":
|
||||
"{binary} PATH'te değil; temizleme başarısız olur ve ham transkript "
|
||||
"yapıştırılır. Kur ya da yukarıdan başka birini seç.",
|
||||
"Thinking": "Düşünme",
|
||||
"Model's own default": "Modelin kendi varsayılanı",
|
||||
"Off": "Kapalı",
|
||||
@@ -281,6 +295,13 @@ TR = {
|
||||
"Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.",
|
||||
"No global shortcut installed. The tray menu asks it too.":
|
||||
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
|
||||
"No global shortcut installed. The tray menu discards it too.":
|
||||
"Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.",
|
||||
"Start and stop": "Başlat ve bitir",
|
||||
"Throws the recording away without transcribing it. Works on a dictation "
|
||||
"and on a command for the agent alike, whichever is running.":
|
||||
"Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, "
|
||||
"ajana verilen komuta da.",
|
||||
"Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}",
|
||||
"Could not register the GNOME shortcut: {error}":
|
||||
"GNOME kısayolu kaydedilemedi: {error}",
|
||||
@@ -371,6 +392,14 @@ TR = {
|
||||
"It was not allowed to use: {tools}": "Şunları kullanmasına izin yoktu: {tools}",
|
||||
"The model returned an empty reply.": "Model boş cevap döndürdü.",
|
||||
|
||||
# --- cleanup, when a CLI does it ----------------------------------------
|
||||
"{binary} not found. Install it, or have OpenRouter clean up instead, "
|
||||
"under Settings → API and models.":
|
||||
"{binary} bulunamadı. Kur ya da Ayarlar → API ve modeller sekmesinden "
|
||||
"temizliği OpenRouter'a bırak.",
|
||||
"{service} did not finish within {seconds} seconds.":
|
||||
"{service} {seconds} saniye içinde bitmedi.",
|
||||
|
||||
# --- settings: the agent ------------------------------------------------
|
||||
"Agent": "Ajan",
|
||||
"This shortcut records the same way dictation does, but the transcript is "
|
||||
|
||||
+39
-23
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dikte installer: dependency check, launchers, KDE shortcut.
|
||||
# Dikte installer: dependency check, launchers, global shortcuts.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -8,6 +8,9 @@ BIN_DIR="$HOME/.local/bin"
|
||||
APP_DIR="$HOME/.local/share/applications"
|
||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||
SHORTCUT="${1:-Ctrl+Space}"
|
||||
# Without the colon, so that a second argument given as "" stays empty. That is
|
||||
# how update.sh says "this one was turned off", as against not saying anything.
|
||||
CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}"
|
||||
|
||||
say() { printf ' %s\n' "$1"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||
@@ -88,30 +91,43 @@ StartupNotify=false
|
||||
EOF
|
||||
ok "Will start automatically on login"
|
||||
|
||||
# 4. KDE global shortcut ---------------------------------------------------
|
||||
cat > "$APP_DIR/dikte-toggle.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Exec=$PY $DIR/dikte.py toggle
|
||||
Name=Dikte: start/stop recording
|
||||
NoDisplay=true
|
||||
StartupNotify=false
|
||||
Type=Application
|
||||
X-KDE-GlobalAccel-CommandShortcut=true
|
||||
EOF
|
||||
# 4. Global shortcuts ------------------------------------------------------
|
||||
# Two of them: one to start and stop, one to throw the recording away. The
|
||||
# second is worth a key of its own because stopping is the step there is no
|
||||
# taking back, being what sends the audio off to be transcribed.
|
||||
#
|
||||
# Dikte registers them rather than this script writing the files itself: it
|
||||
# knows which desktop it is on, and it stores the combination in the settings
|
||||
# as well, which is where the built-in listener reads it from. A key written
|
||||
# to only one of the two places is a key that half works.
|
||||
if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
|
||||
warn "Both arguments are $SHORTCUT, so the discard key was left out."
|
||||
say "Pass two different combinations, or set it in Settings → Shortcuts."
|
||||
CANCEL_SHORTCUT=""
|
||||
fi
|
||||
|
||||
if [[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* || "${XDG_CURRENT_DESKTOP:-}" == *gnome* ]]; then
|
||||
ok "GNOME detected"
|
||||
say "Open Dikte Settings > Shortcut to install the global shortcut."
|
||||
elif command -v kwriteconfig6 >/dev/null; then
|
||||
kwriteconfig6 --notify --file kglobalshortcutsrc \
|
||||
--group services --group dikte-toggle.desktop \
|
||||
--key _launch "$SHORTCUT"
|
||||
ok "KDE shortcut registered: $SHORTCUT"
|
||||
warn "KWin only reads this at startup, so the shortcut goes live after your"
|
||||
say "next login. Until then open Settings → Shortcut and turn on the"
|
||||
say "built-in listener to use it right away."
|
||||
register() { # which combination label
|
||||
if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then
|
||||
ok "$3: $2"
|
||||
else
|
||||
warn "No supported shortcut manager found. Add the shortcut in desktop settings."
|
||||
# One line: the rest of what it has to say about KWin is printed below.
|
||||
warn "${out%%$'\n'*}"
|
||||
fi
|
||||
}
|
||||
|
||||
if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
||||
register toggle "$SHORTCUT" "Start and stop"
|
||||
if [[ -n "$CANCEL_SHORTCUT" ]]; then
|
||||
register cancel "$CANCEL_SHORTCUT" "Discard the recording"
|
||||
fi
|
||||
if [[ "${XDG_CURRENT_DESKTOP:-}" != *[Gg][Nn][Oo][Mm][Ee]* ]]; then
|
||||
warn "KWin only reads these at startup, so they go live after your next"
|
||||
say "login. Until then open Settings → Shortcuts and turn on the"
|
||||
say "built-in listener to use them right away."
|
||||
fi
|
||||
else
|
||||
warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:"
|
||||
say "dikte shortcut install toggle --combo '$SHORTCUT'"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
+12
-6
@@ -25,6 +25,7 @@ import wave
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import vad
|
||||
@@ -118,12 +119,18 @@ class MeetingPipeline(QObject):
|
||||
|
||||
self._check()
|
||||
self._say(t("Writing the minutes…"))
|
||||
writer = self.conf.minutes_target()
|
||||
minutes = api.cleanup(writer, transcript, self.conf.meeting_prompt(),
|
||||
timeout=600)
|
||||
minutes = api.cleanup(
|
||||
transcript,
|
||||
self.conf.openrouter_key(),
|
||||
self.conf["meeting_model"],
|
||||
self.conf.meeting_prompt(),
|
||||
reasoning=self.conf["meeting_reasoning"],
|
||||
base_url=self.conf["openrouter_base_url"],
|
||||
timeout=600,
|
||||
)
|
||||
title = self._write(doc_path, minutes, transcript, entry)
|
||||
cfg.update_meeting(base, status="done", error="", title=title,
|
||||
model=writer.model)
|
||||
model=self.conf["meeting_model"])
|
||||
self._discard_audio(wav_path)
|
||||
self.finished.emit(base, title)
|
||||
|
||||
@@ -195,7 +202,6 @@ class MeetingPipeline(QObject):
|
||||
def _cleanup(self, transcript):
|
||||
conf = self.conf
|
||||
prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True)
|
||||
target = conf.cleanup_target()
|
||||
out = []
|
||||
blocks = filetranscribe.split_text(transcript, True)
|
||||
for index, block in enumerate(blocks, start=1):
|
||||
@@ -203,7 +209,7 @@ class MeetingPipeline(QObject):
|
||||
if len(blocks) > 1:
|
||||
self._say(t("Cleaning up {index}/{count}…",
|
||||
index=index, count=len(blocks)))
|
||||
out.append(api.cleanup(target, block, prompt))
|
||||
out.append(cleanup.run(block, conf, prompt, timeout=600))
|
||||
return "\n".join(out)
|
||||
|
||||
def _write(self, doc_path, minutes, transcript, entry):
|
||||
|
||||
+257
-238
@@ -16,10 +16,12 @@ from PyQt6.QtWidgets import (
|
||||
import api
|
||||
import assistant
|
||||
import audio
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import ggml
|
||||
import hotkey
|
||||
import ipc
|
||||
import meeting
|
||||
from filetranscribe import FileTranscriber
|
||||
from i18n import t
|
||||
@@ -30,14 +32,15 @@ LANGUAGES = [
|
||||
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
|
||||
]
|
||||
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
|
||||
TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"),
|
||||
("OpenAI", "openai"), ("OpenRouter", "openrouter")]
|
||||
CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"),
|
||||
("This machine (llama.cpp)", "local")]
|
||||
# The provider box offers what config knows how to reach, this machine first.
|
||||
TRANSCRIBE_PROVIDERS = ([("This machine (whisper.cpp)", "local")]
|
||||
+ [(who.service, name)
|
||||
for name, who in cfg.TRANSCRIBERS.items()])
|
||||
# Starting points for the model box; "Fetch model list" replaces them with
|
||||
# whatever the provider offers today.
|
||||
TRANSCRIBE_MODELS = {
|
||||
"openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"],
|
||||
"groq": ["whisper-large-v3-turbo", "whisper-large-v3"],
|
||||
"openrouter": [
|
||||
"openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe",
|
||||
"openai/whisper-1", "openai/whisper-large-v3",
|
||||
@@ -50,6 +53,16 @@ CLEANUP_MODELS = [
|
||||
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
|
||||
]
|
||||
# In the order they answer in. A request to OpenRouter is over in a second, a
|
||||
# model here takes a little longer and costs nothing, and the two CLIs the agent
|
||||
# can run on open a whole session to do the smaller job.
|
||||
CLEANUP_PROVIDERS = [
|
||||
("OpenRouter", "openrouter"), ("This machine (llama.cpp)", "local"),
|
||||
("Claude Code", "claude"), ("Codex", "codex"),
|
||||
]
|
||||
# Cleaning up a sentence is the lightest thing either of them will ever be
|
||||
# asked, so the small model comes first.
|
||||
CLEANUP_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"]
|
||||
# Minutes are a harder job than cleanup: an hour of talk has to be read whole
|
||||
# and turned into decisions, so the starting points are the larger models.
|
||||
MEETING_MODELS = [
|
||||
@@ -94,9 +107,9 @@ REASONING_LEVELS = [
|
||||
("Very high", "xhigh"), ("Maximum", "max"),
|
||||
]
|
||||
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
|
||||
# Offered for all three global shortcuts, which keeps them one kind of field
|
||||
# rather than three. The boxes stay editable: this is a shortlist of
|
||||
# combinations that are usually free, not the set of ones that work.
|
||||
# Offered for every global shortcut, which keeps them one kind of field rather
|
||||
# than four. The boxes stay editable: this is a shortlist of combinations that
|
||||
# are usually free, not the set of ones that work.
|
||||
SHORTCUTS = [
|
||||
"Ctrl+Space", "Ctrl+Alt+Space", "Ctrl+Shift+Space", "Meta+Space",
|
||||
"Ctrl+Alt+A", "Ctrl+Alt+D", "Ctrl+Alt+M", "Ctrl+Alt+Q",
|
||||
@@ -444,20 +457,23 @@ class SettingsWindow(QDialog):
|
||||
|
||||
_models_loaded = pyqtSignal(list, str)
|
||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||
_test_done = pyqtSignal(bool, str)
|
||||
_or_test_done = pyqtSignal(bool, str)
|
||||
# Which key was tested, whether it worked, and what to write under it.
|
||||
_test_done = pyqtSignal(str, bool, str)
|
||||
|
||||
def __init__(self, conf, launch_command, meeting_command=None,
|
||||
meetings=None, ask_command=None, parent=None):
|
||||
def __init__(self, conf, meetings=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.conf = conf
|
||||
self.launch_command = launch_command
|
||||
self.meeting_command = meeting_command or launch_command
|
||||
self.ask_command = ask_command or launch_command
|
||||
self.meetings = meetings
|
||||
# Filled in by _shortcut_row as the tabs are built: which combination
|
||||
# box, status label and "nothing installed" line belong to each of the
|
||||
# global shortcuts. One dictionary is what lets install, remove and the
|
||||
# status line be written once instead of once per key.
|
||||
self._shortcut_rows = {}
|
||||
# Each provider keeps its own transcription model, so switching the
|
||||
# provider back and forth never overwrites the other one's.
|
||||
self._models = {"openai": "", "openrouter": ""}
|
||||
self._models = dict.fromkeys(cfg.TRANSCRIBERS, "")
|
||||
self._key_fields = {}
|
||||
self._testers = {}
|
||||
self._shown_provider = ""
|
||||
self.transcriber = FileTranscriber(conf, self)
|
||||
self.setWindowTitle(t("Dikte Settings"))
|
||||
@@ -471,7 +487,7 @@ class SettingsWindow(QDialog):
|
||||
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
||||
tabs.addTab(self._minutes_tab(), t("Minutes"))
|
||||
tabs.addTab(self._file_tab(), t("Audio file"))
|
||||
tabs.addTab(self._shortcut_tab(), t("Shortcut"))
|
||||
tabs.addTab(self._shortcut_tab(), t("Shortcuts"))
|
||||
tabs.addTab(self._history_tab(), t("History"))
|
||||
|
||||
# Save keeps the window open, so the window is closed with the titlebar
|
||||
@@ -488,7 +504,6 @@ class SettingsWindow(QDialog):
|
||||
self._models_loaded.connect(self._on_models_loaded)
|
||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||
self._test_done.connect(self._on_test_done)
|
||||
self._or_test_done.connect(self._on_or_test_done)
|
||||
self.transcriber.progress.connect(self._on_file_progress)
|
||||
self.transcriber.finished.connect(self._on_file_finished)
|
||||
self.transcriber.failed.connect(self._on_file_failed)
|
||||
@@ -584,25 +599,15 @@ class SettingsWindow(QDialog):
|
||||
# them and a key no longer belongs to a single job.
|
||||
keys = QGroupBox(t("Keys"))
|
||||
keys_form = QFormLayout(keys)
|
||||
self.openai_key = QLineEdit()
|
||||
self.openai_key.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)"))
|
||||
self.test_button = QPushButton(t("Test"))
|
||||
self.test_button.clicked.connect(self._test_openai)
|
||||
self.test_label = QLabel("")
|
||||
self.test_label.setWordWrap(True)
|
||||
keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button))
|
||||
keys_form.addRow("", self.test_label)
|
||||
|
||||
self.openrouter_key = QLineEdit()
|
||||
self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)"))
|
||||
self.or_test_button = QPushButton(t("Test"))
|
||||
self.or_test_button.clicked.connect(self._test_openrouter)
|
||||
self.or_test_label = QLabel("")
|
||||
self.or_test_label.setWordWrap(True)
|
||||
keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button))
|
||||
keys_form.addRow("", self.or_test_label)
|
||||
self.openai_key = self._key_row(
|
||||
keys_form, "openai", t("sk-… (falls back to OPENAI_API_KEY)"),
|
||||
self._test_openai)
|
||||
self.groq_key = self._key_row(
|
||||
keys_form, "groq", t("gsk_… (falls back to GROQ_API_KEY)"),
|
||||
self._test_groq)
|
||||
self.openrouter_key = self._key_row(
|
||||
keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"),
|
||||
self._test_openrouter)
|
||||
outer.addWidget(keys)
|
||||
|
||||
stt = QGroupBox(t("Speech to text"))
|
||||
@@ -612,25 +617,22 @@ class SettingsWindow(QDialog):
|
||||
self.transcribe_provider.addItem(t(label), value)
|
||||
stt_form.addRow(t("Provider"), self.transcribe_provider)
|
||||
|
||||
# The hosted providers take any model id that is typed at them; the
|
||||
# local one offers what has been published, so the two are separate
|
||||
# blocks and only one of them is ever visible.
|
||||
self.hosted_stt = QWidget()
|
||||
hosted_form = QFormLayout(self.hosted_stt)
|
||||
hosted_form.setContentsMargins(0, 0, 0, 0)
|
||||
# A hosted provider takes any model id that is typed at it; the local
|
||||
# one offers what has been published. One row each, and only the rows of
|
||||
# whoever is chosen are on screen.
|
||||
self.stt_form = stt_form
|
||||
self.transcribe_model = QComboBox()
|
||||
self.transcribe_model.setEditable(True)
|
||||
self.refresh_transcribe_models = QPushButton(t("Fetch model list"))
|
||||
self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models)
|
||||
hosted_form.addRow(t("Model"),
|
||||
self._row(self.transcribe_model,
|
||||
self.refresh_transcribe_models))
|
||||
self.transcribe_model_row = self._row(self.transcribe_model,
|
||||
self.refresh_transcribe_models)
|
||||
stt_form.addRow(t("Model"), self.transcribe_model_row)
|
||||
# A spanning row: in the narrow field column a wrapped label gets a
|
||||
# height that fits one line, and the rest of the text is cut off.
|
||||
self.transcribe_status = QLabel("")
|
||||
self.transcribe_status.setWordWrap(True)
|
||||
hosted_form.addRow(self.transcribe_status)
|
||||
stt_form.addRow(self.hosted_stt)
|
||||
stt_form.addRow(self.transcribe_status)
|
||||
|
||||
self.local_whisper = LocalModelBox(
|
||||
ggml.WHISPER, t("On this machine"),
|
||||
@@ -662,26 +664,43 @@ class SettingsWindow(QDialog):
|
||||
outer.addWidget(stt)
|
||||
|
||||
orr = QGroupBox(t("Transcript cleanup"))
|
||||
orr_form = QFormLayout(orr)
|
||||
orr_form = self.cleanup_form = QFormLayout(orr)
|
||||
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
|
||||
orr_form.addRow("", self.cleanup_enabled)
|
||||
|
||||
self.cleanup_provider = QComboBox()
|
||||
for label, value in CLEANUP_PROVIDERS:
|
||||
self.cleanup_provider.addItem(t(label), value)
|
||||
self.cleanup_provider.setToolTip(t(
|
||||
"OpenRouter is the quickest and the only one that needs nothing "
|
||||
"installed. llama.cpp runs here, on a model downloaded below. Claude "
|
||||
"Code and Codex clean up on the subscription you already have, "
|
||||
"without a second key, and take a few seconds longer because each "
|
||||
"one opens a session to do it."
|
||||
))
|
||||
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
|
||||
orr_form.addRow(t("Provider"), self.cleanup_provider)
|
||||
orr_form.addRow(t("Runs on"), self.cleanup_provider)
|
||||
|
||||
self.hosted_cleanup = QWidget()
|
||||
cleanup_form = QFormLayout(self.hosted_cleanup)
|
||||
cleanup_form.setContentsMargins(0, 0, 0, 0)
|
||||
self.cleanup_model = QComboBox()
|
||||
self.cleanup_model.setEditable(True)
|
||||
self.cleanup_model.addItems(CLEANUP_MODELS)
|
||||
self.refresh_models = QPushButton(t("Fetch model list"))
|
||||
self.refresh_models.clicked.connect(self._load_models)
|
||||
cleanup_form.addRow(t("Model"), self._row(self.cleanup_model,
|
||||
self.refresh_models))
|
||||
self.cleanup_model_row = self._row(self.cleanup_model, self.refresh_models)
|
||||
orr_form.addRow(t("Model"), self.cleanup_model_row)
|
||||
|
||||
# One row per provider rather than one box that means a different thing
|
||||
# in each: an OpenRouter id and a Claude alias do not belong in the same
|
||||
# field, and only the row of whoever is chosen is on screen.
|
||||
self.cleanup_claude_model = QComboBox()
|
||||
self.cleanup_claude_model.setEditable(True)
|
||||
self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
|
||||
orr_form.addRow(t("Model"), self.cleanup_claude_model)
|
||||
|
||||
self.cleanup_codex_model = QComboBox()
|
||||
self.cleanup_codex_model.setEditable(True)
|
||||
self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
|
||||
orr_form.addRow(t("Model"), self.cleanup_codex_model)
|
||||
|
||||
self.cleanup_reasoning = QComboBox()
|
||||
for label, value in REASONING_LEVELS:
|
||||
@@ -691,12 +710,11 @@ class SettingsWindow(QDialog):
|
||||
"a light job, so more thinking mostly costs time and tokens. Models "
|
||||
"that cannot think ignore this.")
|
||||
)
|
||||
cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning)
|
||||
orr_form.addRow(t("Thinking"), self.cleanup_reasoning)
|
||||
|
||||
self.models_label = QLabel(t("Runs on OpenRouter."))
|
||||
self.models_label.setWordWrap(True)
|
||||
cleanup_form.addRow(self.models_label)
|
||||
orr_form.addRow(self.hosted_cleanup)
|
||||
orr_form.addRow(self.models_label)
|
||||
|
||||
self.local_llm = LocalModelBox(
|
||||
ggml.LLAMA, t("On this machine"),
|
||||
@@ -717,6 +735,7 @@ class SettingsWindow(QDialog):
|
||||
"waiting. Off is what cleanup wants."))
|
||||
self.local_llm_options = QWidget()
|
||||
llm_form = QFormLayout(self.local_llm_options)
|
||||
|
||||
llm_form.setContentsMargins(0, 0, 0, 0)
|
||||
llm_form.addRow("", self.local_llm_gpu)
|
||||
llm_form.addRow("", self.local_llm_preload)
|
||||
@@ -779,16 +798,10 @@ class SettingsWindow(QDialog):
|
||||
|
||||
how = QGroupBox(t("How it runs"))
|
||||
how_form = QFormLayout(how)
|
||||
self.assistant_shortcut = self._shortcut_box(t("none"))
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_ask_shortcut)
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(self._remove_ask_shortcut)
|
||||
how_form.addRow(t("Shortcut"),
|
||||
self._row(self.assistant_shortcut, install, remove))
|
||||
self.assistant_shortcut_status = QLabel("")
|
||||
self.assistant_shortcut_status.setWordWrap(True)
|
||||
how_form.addRow(self.assistant_shortcut_status)
|
||||
self._shortcut_row(
|
||||
how_form, "ask", t("Shortcut"),
|
||||
t("No global shortcut installed. The tray menu asks it too."),
|
||||
)
|
||||
|
||||
self.assistant_provider = QComboBox()
|
||||
for label, value in ASSISTANT_PROVIDERS:
|
||||
@@ -1041,16 +1054,10 @@ class SettingsWindow(QDialog):
|
||||
))
|
||||
recording_form.addRow("", self.meeting_keep_audio)
|
||||
|
||||
self.meeting_shortcut = self._shortcut_box(t("none"))
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_meeting_shortcut)
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(self._remove_meeting_shortcut)
|
||||
recording_form.addRow(t("Shortcut"),
|
||||
self._row(self.meeting_shortcut, install, remove))
|
||||
self.meeting_shortcut_status = QLabel("")
|
||||
self.meeting_shortcut_status.setWordWrap(True)
|
||||
recording_form.addRow(self.meeting_shortcut_status)
|
||||
self._shortcut_row(
|
||||
recording_form, "meeting", t("Shortcut"),
|
||||
t("No global shortcut installed. The tray menu starts a meeting too."),
|
||||
)
|
||||
layout.addWidget(recording)
|
||||
|
||||
prompt_label = QLabel(t("System instruction given to the minutes model."))
|
||||
@@ -1191,25 +1198,27 @@ class SettingsWindow(QDialog):
|
||||
def _shortcut_tab(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
# Both keys in one form, the way the Meeting and Agent tabs already lay
|
||||
# theirs out. Two forms would give each row a label column of its own,
|
||||
# and two combination boxes starting at different places read as two
|
||||
# unrelated settings rather than the pair they are.
|
||||
form = QFormLayout()
|
||||
self.shortcut = self._shortcut_box("Ctrl+Space")
|
||||
form.addRow(t("Shortcut"), self.shortcut)
|
||||
self._shortcut_row(
|
||||
form, "toggle", t("Start and stop"),
|
||||
t("No global shortcut installed."), placeholder="Ctrl+Space",
|
||||
)
|
||||
# Stopping is what sends the recording off to be transcribed, and that
|
||||
# is the step there is no taking back. By the time the tray menu is
|
||||
# open the sentence you did not mean to dictate is already on its way.
|
||||
self._shortcut_row(
|
||||
form, "cancel", t("Discard the recording"),
|
||||
t("No global shortcut installed. The tray menu discards it too."),
|
||||
tooltip=t("Throws the recording away without transcribing it. Works "
|
||||
"on a dictation and on a command for the agent alike, "
|
||||
"whichever is running."),
|
||||
)
|
||||
layout.addLayout(form)
|
||||
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_shortcut)
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(self._remove_shortcut)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(install)
|
||||
row.addWidget(remove)
|
||||
row.addStretch(1)
|
||||
layout.addLayout(row)
|
||||
|
||||
self.shortcut_status = QLabel("")
|
||||
self.shortcut_status.setWordWrap(True)
|
||||
layout.addWidget(self.shortcut_status)
|
||||
|
||||
self.evdev_enabled = QCheckBox(t(
|
||||
"Use the built-in listener (/dev/input), for when the KDE shortcut is "
|
||||
"not active yet"
|
||||
@@ -1303,6 +1312,45 @@ class SettingsWindow(QDialog):
|
||||
box.lineEdit().setPlaceholderText(placeholder)
|
||||
return box
|
||||
|
||||
def _key_row(self, form, provider, placeholder, tester):
|
||||
"""A key field, its Test button and the line the answer lands on.
|
||||
|
||||
The field and the pair the answer needs are filed under the provider's
|
||||
name, so saving, loading and the test handler find them by name rather
|
||||
than through three attributes each.
|
||||
"""
|
||||
field = QLineEdit()
|
||||
field.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
field.setPlaceholderText(placeholder)
|
||||
button = QPushButton(t("Test"))
|
||||
button.clicked.connect(tester)
|
||||
answer = QLabel("")
|
||||
answer.setWordWrap(True)
|
||||
form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button))
|
||||
form.addRow("", answer)
|
||||
self._key_fields[provider] = field
|
||||
self._testers[provider] = (button, answer)
|
||||
return field
|
||||
|
||||
def _shortcut_row(self, form, which, label, missing, placeholder="",
|
||||
tooltip=""):
|
||||
"""One global shortcut: the combination, Install, Remove, and a line
|
||||
saying what the desktop has registered. `missing` is what that line
|
||||
says when nothing is."""
|
||||
box = self._shortcut_box(placeholder or t("none"))
|
||||
if tooltip:
|
||||
box.setToolTip(tooltip)
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(lambda: self._install_shortcut(which))
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(lambda: self._remove_shortcut(which))
|
||||
form.addRow(label, self._row(box, install, remove))
|
||||
status = QLabel("")
|
||||
status.setWordWrap(True)
|
||||
form.addRow(status)
|
||||
self._shortcut_rows[which] = (box, status, missing)
|
||||
return box
|
||||
|
||||
@staticmethod
|
||||
def _row(*widgets):
|
||||
"""Widgets side by side in one form row; the first one takes the space."""
|
||||
@@ -1331,10 +1379,9 @@ class SettingsWindow(QDialog):
|
||||
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
||||
self.keep_audio.setChecked(conf["keep_audio"])
|
||||
|
||||
self.openai_key.setText(conf["openai_api_key"])
|
||||
self.openrouter_key.setText(conf["openrouter_api_key"])
|
||||
self._models = {"openai": conf["transcribe_model"],
|
||||
"openrouter": conf["openrouter_transcribe_model"]}
|
||||
for name, who in cfg.TRANSCRIBERS.items():
|
||||
self._key_fields[name].setText(conf[who.key])
|
||||
self._models[name] = conf[who.model]
|
||||
self._shown_provider = ""
|
||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||
self._provider_changed() # selecting index 0 fires no signal
|
||||
@@ -1344,9 +1391,13 @@ class SettingsWindow(QDialog):
|
||||
self.local_whisper.load(conf["local_model"])
|
||||
|
||||
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
||||
self._select_data(self.cleanup_provider, conf["cleanup_provider"])
|
||||
self._cleanup_provider_changed()
|
||||
self.cleanup_model.setCurrentText(conf["cleanup_model"])
|
||||
self.cleanup_claude_model.setCurrentText(conf["cleanup_claude_model"])
|
||||
self.cleanup_codex_model.setCurrentText(
|
||||
conf["cleanup_codex_model"] or t("Codex's own default")
|
||||
)
|
||||
self._select_data(self.cleanup_provider, conf["cleanup_provider"])
|
||||
self._cleanup_provider_changed() # selecting index 0 fires no signal
|
||||
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
|
||||
self.local_llm_gpu.setChecked(conf["local_llm_gpu"])
|
||||
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
||||
@@ -1358,7 +1409,6 @@ class SettingsWindow(QDialog):
|
||||
)
|
||||
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
|
||||
|
||||
self.assistant_shortcut.setCurrentText(conf["assistant_shortcut"])
|
||||
self._select_data(self.assistant_provider, conf["assistant_provider"])
|
||||
self.assistant_model.setCurrentText(conf["assistant_model"])
|
||||
self._select_data(self.assistant_permission, conf["assistant_permission_mode"])
|
||||
@@ -1387,7 +1437,6 @@ class SettingsWindow(QDialog):
|
||||
self.meeting_cleanup.setChecked(conf["meeting_cleanup"])
|
||||
self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60))
|
||||
self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"])
|
||||
self.meeting_shortcut.setCurrentText(conf["meeting_shortcut"])
|
||||
self.meeting_prompt.setPlainText(
|
||||
conf["meeting_prompt"] or cfg.default_meeting_prompt()
|
||||
)
|
||||
@@ -1396,14 +1445,14 @@ class SettingsWindow(QDialog):
|
||||
self.file_cleanup.setChecked(conf["file_cleanup"])
|
||||
self.file_path = ""
|
||||
|
||||
self.shortcut.setCurrentText(conf["shortcut"])
|
||||
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
||||
box.setCurrentText(conf[hotkey.SHORTCUTS[which].setting])
|
||||
self.evdev_enabled.setChecked(conf["evdev_hotkey"])
|
||||
|
||||
self.history_limit.setValue(max(0, int(conf["history_limit"])))
|
||||
|
||||
self._refresh_shortcut_status()
|
||||
self._refresh_meeting_shortcut_status()
|
||||
self._refresh_ask_shortcut_status()
|
||||
for which in self._shortcut_rows:
|
||||
self._refresh_shortcut_status(which)
|
||||
self._refresh_assistant_status()
|
||||
self._load_history()
|
||||
self._load_minutes()
|
||||
@@ -1423,16 +1472,13 @@ class SettingsWindow(QDialog):
|
||||
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
||||
conf["keep_audio"] = self.keep_audio.isChecked()
|
||||
|
||||
conf["openai_api_key"] = self.openai_key.text().strip()
|
||||
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
|
||||
|
||||
provider = self.transcribe_provider.currentData() or "local"
|
||||
if provider in TRANSCRIBE_MODELS:
|
||||
if provider in self._models:
|
||||
self._models[provider] = self.transcribe_model.currentText().strip()
|
||||
conf["transcribe_provider"] = provider
|
||||
for key, name in (("openai", "transcribe_model"),
|
||||
("openrouter", "openrouter_transcribe_model")):
|
||||
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
|
||||
for name, who in cfg.TRANSCRIBERS.items():
|
||||
conf[who.key] = self._key_fields[name].text().strip()
|
||||
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
||||
conf["local_model"] = self.local_whisper.selected()
|
||||
conf["local_gpu"] = self.local_gpu.isChecked()
|
||||
conf["local_preload"] = self.local_preload.isChecked()
|
||||
@@ -1441,6 +1487,12 @@ class SettingsWindow(QDialog):
|
||||
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
|
||||
conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter"
|
||||
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
|
||||
conf["cleanup_claude_model"] = (self.cleanup_claude_model.currentText().strip()
|
||||
or cfg.DEFAULTS["cleanup_claude_model"])
|
||||
codex_cleanup_model = self.cleanup_codex_model.currentText().strip()
|
||||
conf["cleanup_codex_model"] = (
|
||||
"" if codex_cleanup_model == t("Codex's own default") else codex_cleanup_model
|
||||
)
|
||||
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
|
||||
conf["local_llm_model"] = self.local_llm.selected()
|
||||
conf["local_llm_repo"] = self.local_llm.repository()
|
||||
@@ -1457,7 +1509,6 @@ class SettingsWindow(QDialog):
|
||||
else file_prompt)
|
||||
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
|
||||
|
||||
conf["assistant_shortcut"] = self.assistant_shortcut.currentText().strip()
|
||||
conf["assistant_provider"] = self.assistant_provider.currentData() or "claude"
|
||||
conf["assistant_model"] = (self.assistant_model.currentText().strip()
|
||||
or cfg.DEFAULTS["assistant_model"])
|
||||
@@ -1497,7 +1548,6 @@ class SettingsWindow(QDialog):
|
||||
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
|
||||
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
|
||||
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
|
||||
conf["meeting_shortcut"] = self.meeting_shortcut.currentText().strip()
|
||||
meeting_prompt = self.meeting_prompt.toPlainText().strip()
|
||||
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt()
|
||||
else meeting_prompt)
|
||||
@@ -1505,7 +1555,12 @@ class SettingsWindow(QDialog):
|
||||
conf["file_timestamps"] = self.file_timestamps.isChecked()
|
||||
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
||||
|
||||
conf["shortcut"] = self.shortcut.currentText().strip() or "Ctrl+Space"
|
||||
# Left empty, only the toggle falls back to a default: the application
|
||||
# is unusable without it. The other three stay empty, which is what
|
||||
# turns them off.
|
||||
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
||||
spec = hotkey.SHORTCUTS[which]
|
||||
conf[spec.setting] = box.currentText().strip() or spec.fallback
|
||||
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
|
||||
conf["history_limit"] = self.history_limit.value()
|
||||
conf.save()
|
||||
@@ -1532,9 +1587,10 @@ class SettingsWindow(QDialog):
|
||||
provider = self.transcribe_provider.currentData() or "local"
|
||||
self._shown_provider = provider
|
||||
local = provider == "local"
|
||||
self.hosted_stt.setVisible(not local)
|
||||
self.local_whisper.setVisible(local)
|
||||
self.local_options.setVisible(local)
|
||||
self.stt_form.setRowVisible(self.transcribe_model_row, not local)
|
||||
self.stt_form.setRowVisible(self.transcribe_status, not local)
|
||||
self.stt_form.setRowVisible(self.local_whisper, local)
|
||||
self.stt_form.setRowVisible(self.local_options, local)
|
||||
if local:
|
||||
return
|
||||
self.transcribe_model.clear()
|
||||
@@ -1542,26 +1598,19 @@ class SettingsWindow(QDialog):
|
||||
self.transcribe_model.setCurrentText(self._models[provider])
|
||||
self.transcribe_status.setText("")
|
||||
|
||||
def _cleanup_provider_changed(self):
|
||||
local = (self.cleanup_provider.currentData() or "openrouter") == "local"
|
||||
self.hosted_cleanup.setVisible(not local)
|
||||
self.local_llm.setVisible(local)
|
||||
self.local_llm_options.setVisible(local)
|
||||
|
||||
def _load_transcribe_models(self):
|
||||
"""The model list of whichever provider is selected."""
|
||||
provider = self.transcribe_provider.currentData() or "openai"
|
||||
self.refresh_transcribe_models.setEnabled(False)
|
||||
self.transcribe_status.setText(t("Fetching model list…"))
|
||||
openai_key = self.openai_key.text().strip() or self.conf.openai_key()
|
||||
openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
|
||||
base = self.conf["openai_base_url"]
|
||||
key, base = self._typed_key(provider)
|
||||
service = cfg.TRANSCRIBERS[provider].service
|
||||
|
||||
def work():
|
||||
try:
|
||||
models = (api.openrouter_models(openrouter_key, transcription=True)
|
||||
models = (api.openrouter_models(key, transcription=True)
|
||||
if provider == "openrouter"
|
||||
else api.openai_models(openai_key, base))
|
||||
else api.openai_models(key, base, service))
|
||||
self._transcribe_models_loaded.emit(models, "")
|
||||
except api.ApiError as exc:
|
||||
self._transcribe_models_loaded.emit([], str(exc))
|
||||
@@ -1605,42 +1654,51 @@ class SettingsWindow(QDialog):
|
||||
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
||||
|
||||
def _test_openai(self):
|
||||
self.test_button.setEnabled(False)
|
||||
self.test_label.setText(t("Trying…"))
|
||||
key = self.openai_key.text().strip() or self.conf.openai_key()
|
||||
base = self.conf["openai_base_url"]
|
||||
key, base = self._typed_key("openai")
|
||||
self._test_key("openai", lambda: t(
|
||||
"Connection works. {count} audio models visible.",
|
||||
count=len(api.openai_models(key, base)),
|
||||
))
|
||||
|
||||
def work():
|
||||
try:
|
||||
models = api.openai_models(key, base)
|
||||
self._test_done.emit(
|
||||
True, t("Connection works. {count} audio models visible.", count=len(models))
|
||||
)
|
||||
except api.ApiError as exc:
|
||||
self._test_done.emit(False, str(exc))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
def _test_groq(self):
|
||||
key, base = self._typed_key("groq")
|
||||
self._test_key("groq", lambda: t(
|
||||
"Connection works. {count} audio models visible.",
|
||||
count=len(api.openai_models(key, base, cfg.TRANSCRIBERS["groq"].service)),
|
||||
))
|
||||
|
||||
def _test_openrouter(self):
|
||||
self.or_test_button.setEnabled(False)
|
||||
self.or_test_label.setText(t("Trying…"))
|
||||
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
|
||||
key, _ = self._typed_key("openrouter")
|
||||
self._test_key("openrouter", lambda: api.openrouter_key_status(key))
|
||||
|
||||
def _typed_key(self, provider):
|
||||
"""(key, base URL) for a provider, preferring what is in the field now."""
|
||||
who = cfg.TRANSCRIBERS[provider]
|
||||
typed = self._key_fields[provider].text().strip()
|
||||
return typed or self.conf.api_key(who.key), self.conf[who.url]
|
||||
|
||||
def _test_key(self, provider, ask):
|
||||
"""Run `ask` off the interface thread and write its answer under the key.
|
||||
|
||||
`ask` returns the line to show, or raises ApiError with the line to show
|
||||
instead; either way it is read from a field before the thread starts.
|
||||
"""
|
||||
button, answer = self._testers[provider]
|
||||
button.setEnabled(False)
|
||||
answer.setText(t("Trying…"))
|
||||
|
||||
def work():
|
||||
try:
|
||||
self._or_test_done.emit(True, api.openrouter_key_status(key))
|
||||
self._test_done.emit(provider, True, ask())
|
||||
except api.ApiError as exc:
|
||||
self._or_test_done.emit(False, str(exc))
|
||||
self._test_done.emit(provider, False, str(exc))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_or_test_done(self, ok, message):
|
||||
self.or_test_button.setEnabled(True)
|
||||
self.or_test_label.setText(("✓ " if ok else "✗ ") + message)
|
||||
|
||||
def _on_test_done(self, ok, message):
|
||||
self.test_button.setEnabled(True)
|
||||
self.test_label.setText(("✓ " if ok else "✗ ") + message)
|
||||
def _on_test_done(self, provider, ok, message):
|
||||
button, answer = self._testers[provider]
|
||||
button.setEnabled(True)
|
||||
answer.setText(("✓ " if ok else "✗ ") + message)
|
||||
|
||||
# ---- audio file ------------------------------------------------------
|
||||
|
||||
@@ -1722,45 +1780,17 @@ class SettingsWindow(QDialog):
|
||||
except OSError as exc:
|
||||
self.file_status.setText(t("Failed: {error}", error=exc))
|
||||
|
||||
# ---- shortcut --------------------------------------------------------
|
||||
# ---- shortcuts -------------------------------------------------------
|
||||
|
||||
def _install_shortcut(self):
|
||||
combo = self.shortcut.currentText().strip() or "Ctrl+Space"
|
||||
clashes = hotkey.conflicting_shortcuts(combo)
|
||||
if clashes:
|
||||
answer = QMessageBox.question(
|
||||
self, t("Shortcut conflict"),
|
||||
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
|
||||
shortcut=combo, list="\n".join(clashes[:6])),
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
ok, message = hotkey.install_shortcut(combo, self.launch_command)
|
||||
QMessageBox.information(self, t("Shortcut"), message)
|
||||
if ok:
|
||||
self.conf["shortcut"] = combo
|
||||
self.conf.save()
|
||||
self._refresh_shortcut_status()
|
||||
|
||||
def _remove_shortcut(self):
|
||||
hotkey.remove_shortcut()
|
||||
self._refresh_shortcut_status()
|
||||
|
||||
def _refresh_shortcut_status(self):
|
||||
current = hotkey.shortcut_status()
|
||||
self.shortcut_status.setText(
|
||||
t("Registered in {desktop}: {shortcut}",
|
||||
desktop=hotkey.desktop_name(), shortcut=current) if current
|
||||
else t("No global shortcut installed.")
|
||||
)
|
||||
|
||||
def _install_meeting_shortcut(self):
|
||||
combo = self.meeting_shortcut.currentText().strip()
|
||||
def _install_shortcut(self, which):
|
||||
spec = hotkey.SHORTCUTS[which]
|
||||
box, _status, _missing = self._shortcut_rows[which]
|
||||
combo = box.currentText().strip() or spec.fallback
|
||||
if not combo:
|
||||
QMessageBox.information(self, t("Shortcut"),
|
||||
t("Type a key combination first."))
|
||||
return
|
||||
clashes = hotkey.conflicting_shortcuts(combo, hotkey.MEETING_DESKTOP_ID)
|
||||
clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
|
||||
if clashes:
|
||||
answer = QMessageBox.question(
|
||||
self, t("Shortcut conflict"),
|
||||
@@ -1770,65 +1800,54 @@ class SettingsWindow(QDialog):
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
ok, message = hotkey.install_shortcut(
|
||||
combo, self.meeting_command, name="Dikte: start/end a meeting recording",
|
||||
desktop_id=hotkey.MEETING_DESKTOP_ID,
|
||||
combo, ipc.command_for(spec.verb), name=spec.name,
|
||||
desktop_id=spec.desktop_id,
|
||||
)
|
||||
QMessageBox.information(self, t("Shortcut"), message)
|
||||
if ok:
|
||||
self.conf["meeting_shortcut"] = combo
|
||||
self.conf[spec.setting] = combo
|
||||
self.conf.save()
|
||||
self._refresh_meeting_shortcut_status()
|
||||
self._refresh_shortcut_status(which)
|
||||
|
||||
def _remove_meeting_shortcut(self):
|
||||
hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID)
|
||||
self._refresh_meeting_shortcut_status()
|
||||
def _remove_shortcut(self, which):
|
||||
hotkey.remove_shortcut(hotkey.SHORTCUTS[which].desktop_id)
|
||||
self._refresh_shortcut_status(which)
|
||||
|
||||
def _refresh_meeting_shortcut_status(self):
|
||||
current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID)
|
||||
self.meeting_shortcut_status.setText(
|
||||
def _refresh_shortcut_status(self, which):
|
||||
_box, status, missing = self._shortcut_rows[which]
|
||||
current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id)
|
||||
status.setText(
|
||||
t("Registered in {desktop}: {shortcut}",
|
||||
desktop=hotkey.desktop_name(), shortcut=current) if current
|
||||
else t("No global shortcut installed. The tray menu starts a meeting too.")
|
||||
else missing
|
||||
)
|
||||
|
||||
# ---- Claude ----------------------------------------------------------
|
||||
|
||||
def _install_ask_shortcut(self):
|
||||
combo = self.assistant_shortcut.currentText().strip()
|
||||
if not combo:
|
||||
QMessageBox.information(self, t("Shortcut"),
|
||||
t("Type a key combination first."))
|
||||
return
|
||||
clashes = hotkey.conflicting_shortcuts(combo, hotkey.ASK_DESKTOP_ID)
|
||||
if clashes:
|
||||
answer = QMessageBox.question(
|
||||
self, t("Shortcut conflict"),
|
||||
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
|
||||
shortcut=combo, list="\n".join(clashes[:6])),
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
ok, message = hotkey.install_shortcut(
|
||||
combo, self.ask_command, name="Dikte: ask Claude Code",
|
||||
desktop_id=hotkey.ASK_DESKTOP_ID,
|
||||
)
|
||||
QMessageBox.information(self, t("Shortcut"), message)
|
||||
if ok:
|
||||
self.conf["assistant_shortcut"] = combo
|
||||
self.conf.save()
|
||||
self._refresh_ask_shortcut_status()
|
||||
|
||||
def _remove_ask_shortcut(self):
|
||||
hotkey.remove_shortcut(hotkey.ASK_DESKTOP_ID)
|
||||
self._refresh_ask_shortcut_status()
|
||||
|
||||
def _refresh_ask_shortcut_status(self):
|
||||
current = hotkey.shortcut_status(hotkey.ASK_DESKTOP_ID)
|
||||
self.assistant_shortcut_status.setText(
|
||||
t("Registered in {desktop}: {shortcut}",
|
||||
desktop=hotkey.desktop_name(), shortcut=current) if current
|
||||
else t("No global shortcut installed. The tray menu asks it too.")
|
||||
)
|
||||
def _cleanup_provider_changed(self):
|
||||
provider = self.cleanup_provider.currentData() or "openrouter"
|
||||
self.cleanup_form.setRowVisible(self.cleanup_model_row,
|
||||
provider == "openrouter")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_claude_model,
|
||||
provider == "claude")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_codex_model,
|
||||
provider == "codex")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_reasoning,
|
||||
provider != "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
||||
binary = cleanup.executable(provider)
|
||||
found = shutil.which(binary) if binary else ""
|
||||
if provider == "local":
|
||||
self.models_label.setText(t("Runs on this machine, on llama.cpp."))
|
||||
elif not binary:
|
||||
self.models_label.setText(t("Runs on OpenRouter."))
|
||||
elif found:
|
||||
self.models_label.setText(t("Found: {path}", path=found))
|
||||
else:
|
||||
self.models_label.setText(t(
|
||||
"{binary} is not on your PATH, so cleanup would fail and the raw "
|
||||
"transcript would be pasted. Install it, or pick another one "
|
||||
"above.", binary=binary,
|
||||
))
|
||||
|
||||
def _assistant_provider_changed(self):
|
||||
provider = self.assistant_provider.currentData() or "claude"
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ atexit.register(shutil.rmtree, _SANDBOX, True)
|
||||
# A key sitting in the environment would otherwise reach the code that falls
|
||||
# back to it, and the tests for "there is no key" would pass only on a machine
|
||||
# without one.
|
||||
for _var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY"):
|
||||
for _var in ("OPENAI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY"):
|
||||
os.environ.pop(_var, None)
|
||||
|
||||
# The interface language leaks through module-level state, so the tests fix it
|
||||
|
||||
+57
-86
@@ -22,24 +22,29 @@ from tests.support import (
|
||||
)
|
||||
|
||||
OPENAI = api.Target("openai", "OpenAI", "sk-test", api.OPENAI_URL, "gpt-4o-transcribe")
|
||||
GROQ = api.Target("groq", "Groq", "gsk-test", api.GROQ_URL, "whisper-large-v3-turbo")
|
||||
OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
|
||||
api.OPENROUTER_URL, "openai/gpt-4o-transcribe")
|
||||
|
||||
|
||||
class TimestampModel(unittest.TestCase):
|
||||
def test_only_whisper_returns_segment_times(self):
|
||||
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||
"whisper-1")
|
||||
self.assertEqual(api.timestamp_model("openai"), "whisper-1")
|
||||
|
||||
def test_openrouter_namespaces_the_id(self):
|
||||
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe"),
|
||||
"openai/whisper-1")
|
||||
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
|
||||
|
||||
def test_the_local_server_stays_on_the_model_it_loaded(self):
|
||||
# Asking it for whisper-1 would name a model it has never heard of, and
|
||||
# it is running whisper whatever the file is called.
|
||||
self.assertEqual(api.timestamp_model("local", "ggml-base.bin"),
|
||||
"ggml-base.bin")
|
||||
def test_groq_keeps_the_model_that_was_chosen(self):
|
||||
"""Every model it transcribes with is a whisper, so all of them do times."""
|
||||
self.assertEqual(api.timestamp_model("groq", "whisper-large-v3"),
|
||||
"whisper-large-v3")
|
||||
|
||||
def test_groq_with_nothing_chosen_falls_back(self):
|
||||
self.assertEqual(api.timestamp_model("groq"), "whisper-large-v3-turbo")
|
||||
|
||||
def test_the_others_ignore_what_was_chosen(self):
|
||||
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||
"whisper-1")
|
||||
|
||||
|
||||
class Explain(DikteTest):
|
||||
@@ -185,13 +190,28 @@ class Transcribe(DikteTest):
|
||||
self.assertEqual(multipart_fields(calls[0])["language"], "tr")
|
||||
self.assertNotIn("language", multipart_fields(calls[1]))
|
||||
|
||||
def test_the_glossary_goes_to_openai_only(self):
|
||||
def test_the_glossary_goes_everywhere_but_openrouter(self):
|
||||
"""OpenRouter takes the field and throws it away, so spare it the bytes."""
|
||||
with fake_urlopen({"text": "hi"}) as calls:
|
||||
api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame")
|
||||
api.transcribe(GROQ, self.wav, prompt="Paraşüt, OpenFrame")
|
||||
api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame")
|
||||
self.assertIn("prompt", multipart_fields(calls[0]))
|
||||
self.assertNotIn("prompt", multipart_fields(calls[1]))
|
||||
self.assertIn("prompt", multipart_fields(calls[1]))
|
||||
self.assertNotIn("prompt", multipart_fields(calls[2]))
|
||||
|
||||
def test_groq_goes_to_groq(self):
|
||||
with fake_urlopen({"text": "hi"}) as calls:
|
||||
api.transcribe(GROQ, self.wav)
|
||||
self.assertEqual(calls[0].full_url,
|
||||
"https://api.groq.com/openai/v1/audio/transcriptions")
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3-turbo")
|
||||
|
||||
def test_a_refused_groq_key_is_explained_in_groq_s_name(self):
|
||||
with fake_urlopen(http_error(401, '{"error": {"message": "bad key"}}')), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
api.transcribe(GROQ, self.wav)
|
||||
self.assertIn("Groq", str(caught.exception))
|
||||
|
||||
def test_openrouter_is_attributed(self):
|
||||
with fake_urlopen({"text": "hi"}) as calls:
|
||||
@@ -251,6 +271,12 @@ class TranscribeSegments(DikteTest):
|
||||
api.transcribe_segments(OPENROUTER, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
||||
|
||||
def test_groq_stays_on_the_model_it_was_given(self):
|
||||
target = GROQ._replace(model="whisper-large-v3")
|
||||
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||
api.transcribe_segments(target, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3")
|
||||
|
||||
def test_the_segments_come_back_as_numbers(self):
|
||||
with fake_urlopen(self.reply([
|
||||
{"start": "0.5", "end": "2.25", "text": " hello "},
|
||||
@@ -287,15 +313,10 @@ def chat_reply(content):
|
||||
return {"choices": [{"message": {"content": content}}]}
|
||||
|
||||
|
||||
def openrouter(model="some/model", key="sk-or-test", reasoning="",
|
||||
base_url="https://openrouter.ai/api/v1"):
|
||||
return api.Target("openrouter", "OpenRouter", key, base_url, model, reasoning)
|
||||
|
||||
|
||||
class Cleanup(DikteTest):
|
||||
def call(self, replies, target=None, **kwargs):
|
||||
def call(self, replies, **kwargs):
|
||||
with fake_urlopen(replies) as calls:
|
||||
result = api.cleanup(target or openrouter(), "uh, hello",
|
||||
result = api.cleanup("uh, hello", "sk-or-test", "some/model",
|
||||
"you clean up text", **kwargs)
|
||||
return result, calls
|
||||
|
||||
@@ -325,34 +346,32 @@ class Cleanup(DikteTest):
|
||||
self.assertNotIn("reasoning", sent_json(calls[0]))
|
||||
|
||||
def test_an_effort_is_passed_on_and_the_thinking_left_out(self):
|
||||
_, calls = self.call(chat_reply("Hello."),
|
||||
target=openrouter(reasoning="high"))
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning="high")
|
||||
self.assertEqual(sent_json(calls[0])["reasoning"],
|
||||
{"effort": "high", "exclude": True})
|
||||
|
||||
def test_a_local_base_url(self):
|
||||
_, calls = self.call(chat_reply("Hello."),
|
||||
target=openrouter(base_url="http://localhost:1234/v1"))
|
||||
_, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
|
||||
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
|
||||
|
||||
def test_no_key(self):
|
||||
with self.assertRaises(api.ApiError):
|
||||
api.cleanup(openrouter(key=""), "hello", "prompt")
|
||||
api.cleanup("hello", "", "some/model", "prompt")
|
||||
|
||||
def test_a_reply_with_no_choices_says_why(self):
|
||||
with fake_urlopen({"error": {"message": "model is offline"}}), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup(openrouter(), "hello", "p")
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
self.assertIn("model is offline", str(caught.exception))
|
||||
|
||||
def test_an_empty_answer(self):
|
||||
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||
api.cleanup(openrouter(), "hello", "p")
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
|
||||
def test_a_rate_limit_is_explained(self):
|
||||
with fake_urlopen(http_error(429)), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup(openrouter(), "hello", "p")
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
self.assertIn("OpenRouter", str(caught.exception))
|
||||
|
||||
|
||||
@@ -448,6 +467,18 @@ class ModelLists(DikteTest):
|
||||
with self.assertRaises(api.ApiError):
|
||||
api.openai_models("")
|
||||
|
||||
def test_the_same_list_read_from_groq(self):
|
||||
with fake_urlopen({"data": [{"id": "llama-3.3-70b"},
|
||||
{"id": "whisper-large-v3"}]}) as calls:
|
||||
models = api.openai_models("gsk-test", api.GROQ_URL, "Groq")
|
||||
self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
|
||||
self.assertEqual(models, ["whisper-large-v3"])
|
||||
|
||||
def test_a_missing_groq_key_says_groq(self):
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.openai_models("", api.GROQ_URL, "Groq")
|
||||
self.assertIn("Groq", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -473,7 +504,6 @@ class FakeServer:
|
||||
|
||||
|
||||
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
||||
LOCAL_LLM = api.Target("local-llm", "Local model", "", "", "gemma.gguf", "none")
|
||||
|
||||
|
||||
class TranscribeHere(DikteTest):
|
||||
@@ -548,62 +578,3 @@ class TranscribeHere(DikteTest):
|
||||
with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls:
|
||||
api.transcribe_segments(LOCAL, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin")
|
||||
|
||||
|
||||
class CleanupHere(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.server = FakeServer("http://127.0.0.1:8888/v1")
|
||||
self.patch_attr(ggml, "llm", self.server)
|
||||
|
||||
def test_it_goes_to_the_server_it_starts(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
result = api.cleanup(LOCAL_LLM, "uh, hello", "clean it up")
|
||||
self.assertEqual(result, "Hello.")
|
||||
self.assertEqual(calls[0].full_url,
|
||||
"http://127.0.0.1:8888/v1/chat/completions")
|
||||
|
||||
def test_no_key_is_wanted_and_none_is_sent(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertNotIn("Authorization", calls[0].headers)
|
||||
|
||||
def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertEqual(sent_json(calls[0])["chat_template_kwargs"],
|
||||
{"enable_thinking": False})
|
||||
|
||||
def test_the_models_own_default_asks_for_nothing(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM._replace(reasoning=""), "hello", "prompt")
|
||||
self.assertNotIn("chat_template_kwargs", sent_json(calls[0]))
|
||||
|
||||
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertIn("Thinking", str(caught.exception))
|
||||
|
||||
def test_a_reply_longer_than_the_transcript_is_cut_off(self):
|
||||
# A small model will repeat the transcript until the context is full,
|
||||
# and every one of those tokens is a second of somebody waiting.
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "x" * 4000, "prompt")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 4000)
|
||||
|
||||
def test_a_short_dictation_still_gets_room_to_answer(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "uh, hi", "prompt")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
||||
|
||||
def test_a_hosted_model_is_left_to_answer_at_length(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(openrouter(), "uh, hi", "prompt")
|
||||
self.assertNotIn("max_tokens", sent_json(calls[0]))
|
||||
|
||||
def test_a_server_that_will_not_start_is_the_error_shown(self):
|
||||
self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed"))
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertIn("llama.cpp", str(caught.exception))
|
||||
|
||||
@@ -79,9 +79,12 @@ class Effort(unittest.TestCase):
|
||||
self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high")
|
||||
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
|
||||
|
||||
def test_claude_has_no_rung_below_low(self):
|
||||
self.assertEqual(assistant.CLAUDE_EFFORT["none"], "low")
|
||||
self.assertEqual(assistant.CLAUDE_EFFORT["minimal"], "low")
|
||||
def test_neither_one_asks_for_a_rung_below_low(self):
|
||||
# Claude has none; Codex has one, but calls it "minimal" on the older
|
||||
# models and "none" on the newer ones, and refuses the wrong word.
|
||||
for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT):
|
||||
self.assertEqual(scale["none"], "low")
|
||||
self.assertEqual(scale["minimal"], "low")
|
||||
|
||||
def test_an_empty_setting_asks_for_nothing(self):
|
||||
self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "")
|
||||
@@ -232,10 +235,10 @@ class SessionMissing(unittest.TestCase):
|
||||
self.assertFalse(assistant._session_missing(text))
|
||||
|
||||
def test_the_last_line_is_the_one_worth_showing(self):
|
||||
self.assertEqual(assistant._last_line("warning\n\nreal error\n"),
|
||||
self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
|
||||
"real error")
|
||||
self.assertEqual(assistant._last_line(""), "")
|
||||
self.assertEqual(assistant._last_line(None), "")
|
||||
self.assertEqual(assistant.last_line(""), "")
|
||||
self.assertEqual(assistant.last_line(None), "")
|
||||
|
||||
|
||||
class Conclude(DikteTest):
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Who cleans the transcript up, and what they are asked.
|
||||
|
||||
The CLIs are faked at subprocess.run: what the tests read is the argument list
|
||||
each one is given, where the answer is picked up from, and what happens to the
|
||||
chain when the program is missing, slow or unhappy. The OpenRouter path is the
|
||||
one that was always there and is checked here only for still being taken.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
import ggml
|
||||
from tests.support import DikteTest, fake_urlopen, sent_json, url_error
|
||||
from tests.test_api import FakeServer, chat_reply
|
||||
|
||||
|
||||
def fake_run(stdout="", code=0, stderr="", last_message=""):
|
||||
"""Stand in for subprocess.run, writing the file Codex would have written."""
|
||||
calls = []
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if last_message and "-o" in cmd:
|
||||
with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh:
|
||||
fh.write(last_message)
|
||||
return subprocess.CompletedProcess(cmd, code, stdout, stderr)
|
||||
|
||||
return mock.patch.object(subprocess, "run", side_effect=run), calls
|
||||
|
||||
|
||||
class Provider(DikteTest):
|
||||
def test_the_default_is_still_openrouter(self):
|
||||
self.assertEqual(cleanup.provider(self.config()), "openrouter")
|
||||
|
||||
def test_a_provider_this_version_does_not_have(self):
|
||||
self.assertEqual(
|
||||
cleanup.provider(self.config(cleanup_provider="ollama")), "openrouter")
|
||||
|
||||
def test_each_one_is_recognised(self):
|
||||
for name in cleanup.PROVIDERS:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(
|
||||
cleanup.provider(self.config(cleanup_provider=name)), name)
|
||||
|
||||
def test_what_each_one_runs(self):
|
||||
self.assertEqual(cleanup.executable("claude"), "claude")
|
||||
self.assertEqual(cleanup.executable("codex"), "codex")
|
||||
self.assertEqual(cleanup.executable("openrouter"), "")
|
||||
|
||||
def test_the_model_named_in_the_history_is_the_one_that_did_it(self):
|
||||
self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")),
|
||||
"some/model")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="claude")), "haiku")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="claude",
|
||||
cleanup_claude_model="opus")), "opus")
|
||||
# Codex on its own default has no model id to report, only a name.
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="codex")), "codex")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="codex",
|
||||
cleanup_codex_model="gpt-5.4")), "gpt-5.4")
|
||||
|
||||
|
||||
class OpenRouter(DikteTest):
|
||||
def test_it_is_still_one_request_with_the_settings_as_they_were(self):
|
||||
conf = self.config(openrouter_api_key="sk-or-test",
|
||||
cleanup_model="some/model", cleanup_reasoning="low")
|
||||
with mock.patch.object(api, "cleanup", return_value="Done.") as call:
|
||||
self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.")
|
||||
text, key, model, prompt = call.call_args.args
|
||||
self.assertEqual((text, key, model, prompt),
|
||||
("uh, done", "sk-or-test", "some/model", "the rules"))
|
||||
self.assertEqual(call.call_args.kwargs["reasoning"], "low")
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
conf = self.config(openrouter_api_key="sk-or-test")
|
||||
patcher, calls = fake_run(stdout="never")
|
||||
with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
|
||||
cleanup.run("uh, done", conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class ClaudeCode(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = self.config(cleanup_provider="claude")
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
|
||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||
patcher, calls = fake_run(**kwargs)
|
||||
with patcher:
|
||||
answer = cleanup.run(text, self.conf, "the rules")
|
||||
return answer, calls[0]
|
||||
|
||||
def test_the_transcript_goes_in_fenced_and_the_rules_go_in_as_the_prompt(self):
|
||||
answer, cmd = self.run_cleanup(stdout="Book it.\n")
|
||||
self.assertEqual(answer, "Book it.")
|
||||
self.assertEqual(cmd[0], "claude")
|
||||
self.assertIn("<transcript>\nuh, book it\n</transcript>", cmd)
|
||||
self.assertEqual(cmd[cmd.index("--system-prompt") + 1], "the rules")
|
||||
self.assertEqual(cmd[cmd.index("--model") + 1], "haiku")
|
||||
|
||||
def test_it_is_given_nothing_to_run_and_nothing_to_remember(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--tools") + 1], "")
|
||||
self.assertIn("--strict-mcp-config", cmd)
|
||||
self.assertIn("--no-session-persistence", cmd)
|
||||
|
||||
def test_the_thinking_setting_is_carried_over_in_its_own_words(self):
|
||||
self.conf["cleanup_reasoning"] = "none"
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--effort") + 1], "low")
|
||||
|
||||
def test_no_thinking_setting_means_no_flag(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertNotIn("--effort", cmd)
|
||||
|
||||
def test_a_model_of_your_own(self):
|
||||
self.conf["cleanup_claude_model"] = "claude-sonnet-5"
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--model") + 1], "claude-sonnet-5")
|
||||
|
||||
def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self):
|
||||
with self.assertRaises(cleanup.CleanupError):
|
||||
self.run_cleanup(stdout=" \n")
|
||||
|
||||
def test_the_last_line_of_the_complaint_is_what_gets_shown(self):
|
||||
with self.assertRaises(cleanup.CleanupError) as caught:
|
||||
self.run_cleanup(code=1, stderr="a warning\nout of credit\n")
|
||||
self.assertEqual(str(caught.exception), "out of credit")
|
||||
|
||||
def test_a_failure_is_the_same_kind_the_chain_already_catches(self):
|
||||
# worker, the file transcriber and the meeting all keep the raw
|
||||
# transcript when an ApiError comes out of here.
|
||||
self.assertTrue(issubclass(cleanup.CleanupError, api.ApiError))
|
||||
|
||||
def test_a_program_that_is_not_installed_says_so_before_running_anything(self):
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: "")
|
||||
with self.assertRaises(cleanup.CleanupError) as caught:
|
||||
self.run_cleanup(stdout="Book it.")
|
||||
self.assertIn("claude", str(caught.exception))
|
||||
|
||||
def test_a_run_that_never_ends(self):
|
||||
def run(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, 180)
|
||||
|
||||
with mock.patch.object(subprocess, "run", side_effect=run):
|
||||
with self.assertRaises(cleanup.CleanupError) as caught:
|
||||
cleanup.run("uh, book it", self.conf, "the rules")
|
||||
self.assertIn("180", str(caught.exception))
|
||||
|
||||
|
||||
class Codex(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = self.config(cleanup_provider="codex")
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
|
||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||
patcher, calls = fake_run(**kwargs)
|
||||
with patcher:
|
||||
answer = cleanup.run(text, self.conf, "the rules")
|
||||
return answer, calls[0]
|
||||
|
||||
def test_the_rules_ride_in_front_of_the_transcript(self):
|
||||
answer, cmd = self.run_cleanup(last_message="Book it.\n")
|
||||
self.assertEqual(answer, "Book it.")
|
||||
self.assertEqual(cmd[:2], ["codex", "exec"])
|
||||
self.assertEqual(cmd[-1],
|
||||
"the rules\n\n---\n\n<transcript>\nuh, book it\n</transcript>")
|
||||
|
||||
def test_the_answer_is_read_from_the_file_rather_than_the_noise_on_stdout(self):
|
||||
answer, _ = self.run_cleanup(
|
||||
stdout="workdir: /home\nmodel: gpt-5.4\ntokens used 400\n",
|
||||
last_message="Book it.",
|
||||
)
|
||||
self.assertEqual(answer, "Book it.")
|
||||
|
||||
def test_that_file_does_not_stay_behind(self):
|
||||
_, cmd = self.run_cleanup(last_message="Book it.")
|
||||
self.assertFalse(os.path.exists(cmd[cmd.index("-o") + 1]))
|
||||
|
||||
def test_it_may_read_but_not_write_and_has_nobody_to_ask(self):
|
||||
_, cmd = self.run_cleanup(last_message="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only")
|
||||
self.assertIn('approval_policy="never"', cmd)
|
||||
self.assertIn("--ephemeral", cmd)
|
||||
|
||||
def test_the_model_is_left_alone_until_one_is_typed_in(self):
|
||||
_, cmd = self.run_cleanup(last_message="Book it.")
|
||||
self.assertNotIn("-m", cmd)
|
||||
self.conf["cleanup_codex_model"] = "gpt-5.4"
|
||||
_, cmd = self.run_cleanup(last_message="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("-m") + 1], "gpt-5.4")
|
||||
|
||||
def test_the_thinking_setting_lands_on_the_nearest_rung_codex_has(self):
|
||||
self.conf["cleanup_reasoning"] = "xhigh"
|
||||
_, cmd = self.run_cleanup(last_message="Book it.")
|
||||
self.assertIn('model_reasoning_effort="high"', cmd)
|
||||
|
||||
def test_an_answer_of_nothing(self):
|
||||
with self.assertRaises(cleanup.CleanupError):
|
||||
self.run_cleanup(stdout="tokens used 400", last_message="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class Here(DikteTest):
|
||||
"""llama.cpp, answering the request OpenRouter answers."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = self.config(cleanup_provider="local",
|
||||
local_llm_model="gemma.gguf")
|
||||
self.server = FakeServer()
|
||||
self.patch_attr(ggml, "llm", self.server)
|
||||
|
||||
def test_the_address_comes_from_the_server_it_starts(self):
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
self.assertEqual(cleanup.run("uh, done", self.conf, "the rules"),
|
||||
"Done.")
|
||||
self.assertEqual(self.server.starts, 1)
|
||||
self.assertEqual(calls[0].full_url,
|
||||
"http://127.0.0.1:9999/v1/chat/completions")
|
||||
|
||||
def test_no_key_is_wanted_and_none_is_sent(self):
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertNotIn("Authorization", calls[0].headers)
|
||||
|
||||
def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self):
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(sent_json(calls[0])["chat_template_kwargs"],
|
||||
{"enable_thinking": False})
|
||||
|
||||
def test_the_models_own_default_asks_for_nothing(self):
|
||||
self.conf["local_llm_reasoning"] = ""
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertNotIn("chat_template_kwargs", sent_json(calls[0]))
|
||||
|
||||
def test_a_reply_longer_than_the_transcript_is_cut_off(self):
|
||||
# A small model will repeat the transcript until the context is full,
|
||||
# and every one of those tokens is a second of somebody waiting.
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("x" * 4000, self.conf, "the rules")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 4000)
|
||||
|
||||
def test_a_short_dictation_still_gets_room_to_answer(self):
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
||||
|
||||
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertIn("Thinking", str(caught.exception))
|
||||
|
||||
def test_a_server_that_will_not_start_is_the_error_shown(self):
|
||||
self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed"))
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertIn("llama.cpp", str(caught.exception))
|
||||
|
||||
def test_a_server_that_dies_mid_request_says_what_it_printed(self):
|
||||
self.patch_attr(ggml, "llm", FakeServer(log="out of memory"))
|
||||
with fake_urlopen(url_error("connection reset")):
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertIn("out of memory", str(caught.exception))
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
patcher, calls = fake_run(stdout="never")
|
||||
with patcher, fake_urlopen(chat_reply("Done.")):
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
+79
-1
@@ -14,8 +14,9 @@ from unittest import mock
|
||||
|
||||
import cli
|
||||
import config as cfg
|
||||
import hotkey
|
||||
import ipc
|
||||
from tests.support import DikteTest
|
||||
from tests.support import DikteTest, fake_urlopen
|
||||
|
||||
|
||||
class Options:
|
||||
@@ -144,6 +145,22 @@ class Parser(unittest.TestCase):
|
||||
self.assertIsNone(opts.verb)
|
||||
self.assertEqual(opts.func, cli.cmd_plain)
|
||||
|
||||
def test_every_global_shortcut_runs_a_verb_that_exists(self):
|
||||
"""A shortcut registers a command line; a verb the parser never heard of
|
||||
is a key that does nothing at all when it is pressed."""
|
||||
for name, spec in hotkey.SHORTCUTS.items():
|
||||
with self.subTest(name=name):
|
||||
opts = self.parse(spec.verb)
|
||||
self.assertTrue(callable(opts.func))
|
||||
|
||||
def test_every_shortcut_can_be_installed_and_removed_by_name(self):
|
||||
for name in hotkey.SHORTCUTS:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(self.parse("shortcut", "install", name).which,
|
||||
name)
|
||||
self.assertEqual(self.parse("shortcut", "remove", name).which,
|
||||
name)
|
||||
|
||||
def test_every_verb_is_wired_to_something(self):
|
||||
for verb in ("record", "toggle", "start", "stop", "cancel", "ask",
|
||||
"session", "transcribe", "meeting", "meetings", "history",
|
||||
@@ -316,6 +333,67 @@ class ConfigCommands(DikteTest):
|
||||
{"cleanup", "subtitles", "meeting", "agent"})
|
||||
|
||||
|
||||
class Providers(DikteTest):
|
||||
"""The terminal reaches every provider the settings window does."""
|
||||
|
||||
def run_cmd(self, func, **values):
|
||||
with captured() as (out, err):
|
||||
code = func(Options(**values))
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
def test_a_provider_the_settings_window_offers_is_a_choice_here_too(self):
|
||||
parser = cli.build_parser()
|
||||
for provider in cfg.TRANSCRIBERS:
|
||||
with self.subTest(provider=provider):
|
||||
opts = parser.parse_args(["models", "--provider", provider])
|
||||
self.assertEqual(opts.provider, provider)
|
||||
self.assertEqual(parser.parse_args(["test-key", provider]).which,
|
||||
provider)
|
||||
|
||||
def test_the_model_list_is_read_from_the_chosen_provider(self):
|
||||
self.write_config({"groq_api_key": "gsk-test"})
|
||||
with fake_urlopen({"data": [{"id": "whisper-large-v3"}]}) as calls:
|
||||
code, out, _ = self.run_cmd(cli.cmd_models, provider="groq",
|
||||
transcription=False)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
|
||||
self.assertEqual(out.strip(), "whisper-large-v3")
|
||||
|
||||
def test_a_key_that_is_not_there_is_reported_under_its_own_name(self):
|
||||
code, out, _ = self.run_cmd(cli.cmd_test_key, which="groq")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("groq", out)
|
||||
self.assertIn("Groq", out)
|
||||
|
||||
|
||||
class Doctor(DikteTest):
|
||||
"""One pass over everything the settings window checks behind its buttons."""
|
||||
|
||||
def run_doctor(self, as_json=True, **settings):
|
||||
self.write_config(settings)
|
||||
with mock.patch.object(ipc, "send", return_value=None), \
|
||||
captured() as (out, _err):
|
||||
cli.cmd_doctor(Options(json=as_json))
|
||||
return json.loads(out.getvalue()) if as_json else out.getvalue()
|
||||
|
||||
def test_cleanup_on_openrouter_is_a_question_about_the_key(self):
|
||||
reply = self.run_doctor(cleanup_model="some/model")
|
||||
self.assertEqual(reply["cleanup"]["provider"], "openrouter")
|
||||
self.assertEqual(reply["cleanup"]["model"], "some/model")
|
||||
self.assertIn("OpenRouter key, cleaning up on some/model",
|
||||
self.run_doctor(as_json=False, cleanup_model="some/model"))
|
||||
|
||||
def test_cleanup_on_a_cli_is_a_question_about_the_program(self):
|
||||
reply = self.run_doctor(cleanup_provider="codex",
|
||||
cleanup_codex_model="gpt-5.4")
|
||||
self.assertEqual(reply["cleanup"]["provider"], "codex")
|
||||
self.assertEqual(reply["cleanup"]["model"], "gpt-5.4")
|
||||
self.assertIn("codex", reply["programs"])
|
||||
self.assertIn("codex, cleaning up on gpt-5.4",
|
||||
self.run_doctor(as_json=False, cleanup_provider="codex",
|
||||
cleanup_codex_model="gpt-5.4"))
|
||||
|
||||
|
||||
class Finding(DikteTest):
|
||||
def test_no_history_at_all(self):
|
||||
self.assertIsNone(cli._find_history("last"))
|
||||
|
||||
+36
-20
@@ -12,6 +12,7 @@ import unittest
|
||||
from unittest import mock
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import ggml
|
||||
import i18n
|
||||
@@ -126,6 +127,10 @@ class Keys(DikteTest):
|
||||
def test_no_key_anywhere(self):
|
||||
self.assertEqual(cfg.Config().openai_key(), "")
|
||||
|
||||
def test_every_provider_falls_back_to_the_variable_of_its_own_name(self):
|
||||
with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}):
|
||||
self.assertEqual(cfg.Config().groq_key(), "gsk-env")
|
||||
|
||||
|
||||
class TranscribeTarget(DikteTest):
|
||||
def test_this_machine_by_default(self):
|
||||
@@ -155,6 +160,21 @@ class TranscribeTarget(DikteTest):
|
||||
self.assertEqual(target.api_key, "sk-or-test")
|
||||
self.assertEqual(target.model, "openai/whisper-1")
|
||||
|
||||
def test_groq_when_it_is_picked(self):
|
||||
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
||||
groq_transcribe_model="whisper-large-v3")
|
||||
target = conf.transcribe_target()
|
||||
self.assertEqual(target.provider, "groq")
|
||||
self.assertEqual(target.service, "Groq")
|
||||
self.assertEqual(target.api_key, "gsk-test")
|
||||
self.assertEqual(target.base_url, api.GROQ_URL)
|
||||
self.assertEqual(target.model, "whisper-large-v3")
|
||||
|
||||
def test_a_provider_this_version_has_never_heard_of(self):
|
||||
"""A config written by a fork, or by a version that dropped one."""
|
||||
target = self.config(transcribe_provider="deepgram").transcribe_target()
|
||||
self.assertEqual(target.provider, "openai")
|
||||
|
||||
def test_a_self_hosted_endpoint(self):
|
||||
conf = self.config(transcribe_provider="openai",
|
||||
openai_base_url="http://localhost:8080/v1")
|
||||
@@ -438,31 +458,27 @@ if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class LocalTargets(DikteTest):
|
||||
def test_cleanup_can_run_here_while_the_minutes_do_not(self):
|
||||
# The two jobs are not the same size: a small model on this machine
|
||||
# strips filler words perfectly well and will not write up an hour.
|
||||
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf")
|
||||
self.assertEqual(conf.cleanup_target().provider, "local-llm")
|
||||
self.assertEqual(conf.minutes_target().provider, "openrouter")
|
||||
self.assertEqual(conf.minutes_target().model, cfg.DEFAULTS["meeting_model"])
|
||||
class LocalCleanup(DikteTest):
|
||||
def test_the_local_model_is_what_the_history_records(self):
|
||||
conf = self.config(cleanup_provider="local",
|
||||
local_llm_model="gemma-3-4b-it-Q4_K_M.gguf")
|
||||
self.assertEqual(cleanup.provider(conf), "local")
|
||||
self.assertEqual(cleanup.model(conf), "gemma-3-4b-it-Q4_K_M.gguf")
|
||||
|
||||
def test_the_minutes_can_run_here_on_their_own(self):
|
||||
conf = self.config(meeting_provider="local", local_llm_model="gemma.gguf")
|
||||
self.assertEqual(conf.minutes_target().model, "gemma.gguf")
|
||||
self.assertEqual(conf.cleanup_target().provider, "openrouter")
|
||||
def test_it_needs_no_program_on_the_path(self):
|
||||
# whisper.cpp and llama.cpp are fetched rather than installed, so unlike
|
||||
# Claude Code and Codex there is no executable to look for.
|
||||
self.assertEqual(cleanup.executable("local"), "")
|
||||
|
||||
def test_the_local_cleanup_target_carries_the_thinking_level(self):
|
||||
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf",
|
||||
local_llm_reasoning="none")
|
||||
target = conf.cleanup_target()
|
||||
self.assertEqual(target.reasoning, "none")
|
||||
self.assertEqual(target.api_key, "")
|
||||
def test_the_minutes_do_not_follow_the_cleanup_provider(self):
|
||||
# A 4B model here will strip the filler words out of a dictation and
|
||||
# will not write up an hour long meeting.
|
||||
conf = self.config(cleanup_provider="local")
|
||||
self.assertEqual(conf["meeting_model"], cfg.DEFAULTS["meeting_model"])
|
||||
|
||||
def test_either_of_them_counts_as_using_the_local_model(self):
|
||||
def test_only_the_cleanup_setting_asks_for_the_local_model(self):
|
||||
self.assertFalse(cfg.Config().uses_local_llm())
|
||||
self.assertTrue(self.config(cleanup_provider="local").uses_local_llm())
|
||||
self.assertTrue(self.config(meeting_provider="local").uses_local_llm())
|
||||
|
||||
|
||||
class ReadyToRun(DikteTest):
|
||||
|
||||
@@ -197,7 +197,7 @@ class Transcriber(DikteTest):
|
||||
|
||||
def test_cleanup_is_told_it_is_writing_subtitles(self):
|
||||
_, _, _, cleanup_call = self.run_chain(cleanup=True)
|
||||
prompt = cleanup_call.call_args.args[2]
|
||||
prompt = cleanup_call.call_args.args[3]
|
||||
self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True))
|
||||
|
||||
def test_timestamps_come_back_as_segments_and_as_stamped_lines(self):
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import config as cfg
|
||||
import hotkey
|
||||
from tests.support import DikteTest, FakeCompleted, linux_only
|
||||
|
||||
@@ -56,6 +57,28 @@ class ParseShortcut(unittest.TestCase):
|
||||
self.assertEqual(hotkey.parse_shortcut(None), (None, None))
|
||||
|
||||
|
||||
class Table(unittest.TestCase):
|
||||
"""The one list of global shortcuts. The command line, the settings window
|
||||
and install.sh read it instead of keeping a copy each, so what it has to
|
||||
hold together is checked here rather than in three places."""
|
||||
|
||||
def test_every_shortcut_remembers_itself_in_a_real_setting(self):
|
||||
for name, spec in hotkey.SHORTCUTS.items():
|
||||
with self.subTest(name=name):
|
||||
self.assertIn(spec.setting, cfg.DEFAULTS)
|
||||
|
||||
def test_no_two_share_a_desktop_entry(self):
|
||||
ids = [spec.desktop_id for spec in hotkey.SHORTCUTS.values()]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
|
||||
def test_only_the_toggle_falls_back_to_a_key_of_its_own(self):
|
||||
"""The rest are off until you pick one, and emptying the box is how you
|
||||
turn them off again."""
|
||||
self.assertEqual(hotkey.SHORTCUTS["toggle"].fallback, "Ctrl+Space")
|
||||
self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items()
|
||||
if spec.fallback], ["toggle"])
|
||||
|
||||
|
||||
class ModsMatch(unittest.TestCase):
|
||||
"""The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space."""
|
||||
|
||||
@@ -122,6 +145,23 @@ class Bindings(DikteTest):
|
||||
thread.assert_called_once()
|
||||
self.assertEqual(len(listener._bindings[57]), 2)
|
||||
|
||||
def test_starting_and_discarding_do_not_fire_on_each_other(self):
|
||||
"""The two defaults are one modifier apart on the same key code, so the
|
||||
modifier set is the only thing keeping them apart."""
|
||||
listener = hotkey.EvdevHotkey()
|
||||
self.addCleanup(listener.stop)
|
||||
with mock.patch.object(listener, "_open_devices", return_value=[99]), \
|
||||
mock.patch.object(hotkey.threading, "Thread"):
|
||||
listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Alt+Space"})
|
||||
|
||||
def fired(held):
|
||||
return [name for mods, name in listener._bindings[57]
|
||||
if hotkey.EvdevHotkey._mods_match(held, mods)]
|
||||
|
||||
self.assertEqual(fired({29}), ["toggle"]) # ctrl
|
||||
self.assertEqual(fired({29, 56}), ["cancel"]) # ctrl + alt
|
||||
self.assertEqual(fired({29, 42}), []) # ctrl + shift
|
||||
|
||||
|
||||
@linux_only
|
||||
class Chooser(DikteTest):
|
||||
|
||||
+97
-12
@@ -11,7 +11,9 @@ from unittest import mock
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import hotkey
|
||||
import overlay as overlay_module
|
||||
import settings_ui
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
@@ -36,13 +38,17 @@ CHANGED = {
|
||||
"filter_hallucinations": False,
|
||||
"keep_audio": True,
|
||||
"openai_api_key": "sk-test-key",
|
||||
"groq_api_key": "gsk-test-key",
|
||||
"openrouter_api_key": "sk-or-test-key",
|
||||
"transcribe_provider": "openrouter",
|
||||
"transcribe_model": "whisper-1",
|
||||
"groq_transcribe_model": "whisper-large-v3",
|
||||
"openrouter_transcribe_model": "openai/whisper-1",
|
||||
"cleanup_enabled": False,
|
||||
"cleanup_provider": "local",
|
||||
"cleanup_model": "some/other-model",
|
||||
"cleanup_claude_model": "opus",
|
||||
"cleanup_codex_model": "gpt-5",
|
||||
"cleanup_reasoning": "high",
|
||||
"local_model": "ggml-small.bin",
|
||||
"local_gpu": False,
|
||||
@@ -84,6 +90,7 @@ CHANGED = {
|
||||
"file_timestamps": True,
|
||||
"file_cleanup": False,
|
||||
"shortcut": "Ctrl+Alt+Space",
|
||||
"cancel_shortcut": "Meta+Shift+Space",
|
||||
"evdev_hotkey": True,
|
||||
"history_limit": 50,
|
||||
}
|
||||
@@ -106,7 +113,7 @@ class Settings(DikteTest):
|
||||
self.path("kglobalshortcutsrc")))
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf, "dikte toggle")
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
return window
|
||||
@@ -134,6 +141,20 @@ class Settings(DikteTest):
|
||||
with self.subTest(key=key):
|
||||
self.assertEqual(stored[key], value)
|
||||
|
||||
def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self):
|
||||
"""An OpenRouter id and a Claude alias are not the same field."""
|
||||
window = self.window(cfg.Config())
|
||||
boxes = {"openrouter": window.cleanup_model_row,
|
||||
"claude": window.cleanup_claude_model,
|
||||
"codex": window.cleanup_codex_model}
|
||||
for provider, box in boxes.items():
|
||||
with self.subTest(provider=provider):
|
||||
window._select_data(window.cleanup_provider, provider)
|
||||
shown = [name for name, other in boxes.items()
|
||||
if not other.isHidden()]
|
||||
self.assertEqual(shown, [provider])
|
||||
self.assertFalse(box.isHidden())
|
||||
|
||||
def test_the_settings_the_window_does_not_show_are_left_alone(self):
|
||||
"""A tab nobody wrote must not reset what the command line set."""
|
||||
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
||||
@@ -144,6 +165,37 @@ class Settings(DikteTest):
|
||||
self.assertEqual(stored["speech_margin_db"], 15.0)
|
||||
self.assertEqual(stored["openrouter_base_url"], "http://localhost:1234/v1")
|
||||
|
||||
def test_every_global_shortcut_has_a_row_of_its_own(self):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS))
|
||||
|
||||
def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
|
||||
"""The application is unusable without the toggle, so that one box
|
||||
falls back. The rest stay empty, which is how they are switched off."""
|
||||
conf = cfg.Config()
|
||||
window = self.window(conf)
|
||||
for box, _status, _missing in window._shortcut_rows.values():
|
||||
box.setCurrentText("")
|
||||
window._save()
|
||||
self.assertEqual(conf["shortcut"], "Ctrl+Space")
|
||||
self.assertEqual(conf["cancel_shortcut"], "")
|
||||
self.assertEqual(conf["assistant_shortcut"], "")
|
||||
self.assertEqual(conf["meeting_shortcut"], "")
|
||||
|
||||
def test_installing_the_discard_key_writes_its_own_entry(self):
|
||||
conf = cfg.Config()
|
||||
window = self.window(conf)
|
||||
window._shortcut_rows["cancel"][0].setCurrentText("Meta+Shift+Space")
|
||||
with mock.patch.object(settings_ui.hotkey, "install_shortcut",
|
||||
return_value=(True, "saved")) as install:
|
||||
window._install_shortcut("cancel")
|
||||
combo, command = install.call_args.args
|
||||
self.assertEqual(combo, "Meta+Shift+Space")
|
||||
self.assertTrue(command.endswith(" cancel"))
|
||||
self.assertEqual(install.call_args.kwargs["desktop_id"],
|
||||
hotkey.CANCEL_DESKTOP_ID)
|
||||
self.assertEqual(conf["cancel_shortcut"], "Meta+Shift+Space")
|
||||
|
||||
def test_a_prompt_left_at_its_default_is_stored_as_empty(self):
|
||||
"""So that switching the interface language switches the prompt too."""
|
||||
conf = cfg.Config()
|
||||
@@ -155,14 +207,44 @@ class Settings(DikteTest):
|
||||
def test_each_provider_keeps_its_own_transcription_model(self):
|
||||
self.write_config({"transcribe_provider": "openai",
|
||||
"transcribe_model": "gpt-4o-transcribe",
|
||||
"groq_transcribe_model": "whisper-large-v3",
|
||||
"openrouter_transcribe_model": "openai/whisper-1"})
|
||||
conf = cfg.Config()
|
||||
window = self.window(conf)
|
||||
for provider in ("groq", "openrouter"):
|
||||
window.transcribe_provider.setCurrentIndex(
|
||||
window.transcribe_provider.findData("openrouter"))
|
||||
window.transcribe_provider.findData(provider))
|
||||
window._save()
|
||||
self.assertEqual(conf["transcribe_provider"], "openrouter")
|
||||
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||||
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
||||
|
||||
def test_the_provider_box_offers_every_provider_config_knows(self):
|
||||
window = self.window(cfg.Config())
|
||||
offered = [window.transcribe_provider.itemData(i)
|
||||
for i in range(window.transcribe_provider.count())]
|
||||
self.assertEqual(offered, ["local"] + list(cfg.TRANSCRIBERS))
|
||||
|
||||
def test_the_cleanup_box_offers_everyone_cleanup_py_dispatches_to(self):
|
||||
window = self.window(cfg.Config())
|
||||
offered = [window.cleanup_provider.itemData(i)
|
||||
for i in range(window.cleanup_provider.count())]
|
||||
self.assertEqual(sorted(offered), sorted(cleanup.PROVIDERS))
|
||||
|
||||
def test_the_answer_to_a_test_lands_under_the_key_it_was_asked_about(self):
|
||||
"""One signal serves all three buttons, so it carries which one asked."""
|
||||
window = self.window(cfg.Config())
|
||||
window._on_test_done("groq", True, "it works")
|
||||
button, answer = window._testers["groq"]
|
||||
self.assertEqual(answer.text(), "✓ it works")
|
||||
self.assertTrue(button.isEnabled())
|
||||
self.assertEqual(window._testers["openai"][1].text(), "")
|
||||
|
||||
def test_a_key_lands_in_the_field_of_its_own_provider(self):
|
||||
self.write_config({"groq_api_key": "gsk-mine"})
|
||||
window = self.window(cfg.Config())
|
||||
self.assertEqual(window.groq_key.text(), "gsk-mine")
|
||||
self.assertEqual(window.openai_key.text(), "")
|
||||
|
||||
def test_saving_applies_the_lowered_history_limit_at_once(self):
|
||||
for index in range(10):
|
||||
@@ -275,7 +357,7 @@ class LocalModels(DikteTest):
|
||||
"""The download boxes, without a network and without either program."""
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf, "dikte toggle")
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
return window
|
||||
@@ -327,17 +409,20 @@ class LocalModels(DikteTest):
|
||||
for row in range(box.repo.count()))
|
||||
self.assertGreaterEqual(view.minimumWidth(), widest)
|
||||
|
||||
def test_the_hosted_boxes_go_away_when_the_work_happens_here(self):
|
||||
def test_only_the_chosen_transcriber_is_on_screen(self):
|
||||
window = self.window(self.config(transcribe_provider="openai"))
|
||||
self.assertTrue(window.hosted_stt.isVisibleTo(window))
|
||||
self.assertFalse(window.local_whisper.isVisibleTo(window))
|
||||
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
|
||||
self.assertFalse(window.stt_form.isRowVisible(window.local_whisper))
|
||||
window._select_data(window.transcribe_provider, "local")
|
||||
self.assertFalse(window.hosted_stt.isVisibleTo(window))
|
||||
self.assertTrue(window.local_whisper.isVisibleTo(window))
|
||||
self.assertFalse(window.stt_form.isRowVisible(window.transcribe_model_row))
|
||||
self.assertTrue(window.stt_form.isRowVisible(window.local_whisper))
|
||||
|
||||
def test_the_same_for_cleanup(self):
|
||||
def test_only_the_chosen_cleaner_is_on_screen(self):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertTrue(window.hosted_cleanup.isVisibleTo(window))
|
||||
self.assertTrue(window.cleanup_form.isRowVisible(window.cleanup_model_row))
|
||||
self.assertFalse(window.cleanup_form.isRowVisible(window.local_llm))
|
||||
window._select_data(window.cleanup_provider, "local")
|
||||
self.assertTrue(window.local_llm.isVisibleTo(window))
|
||||
self.assertFalse(window.hosted_cleanup.isVisibleTo(window))
|
||||
self.assertTrue(window.cleanup_form.isRowVisible(window.local_llm))
|
||||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_model_row))
|
||||
# Its own thinking box, because the two default to opposite things.
|
||||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
||||
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dikte uninstaller: takes back what install.sh put down, and nothing else
|
||||
# unless asked. Your settings and your dictations survive a plain run; --purge
|
||||
# is the word that deletes them.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PY="$(command -v python3 || true)"
|
||||
USER_NAME="$(id -un)"
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
APP_DIR="$HOME/.local/share/applications"
|
||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
|
||||
|
||||
PURGE=0
|
||||
ASSUME_YES=0
|
||||
|
||||
say() { printf ' %s\n' "$1"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
|
||||
gone() { printf ' \033[90m·\033[0m %s\n' "$1"; }
|
||||
# "1 dictation", "3 dictations": how many is the point of printing it at all.
|
||||
count() {
|
||||
if (( $1 == 1 )); then printf '%s %s' "$1" "$2"; else printf '%s %s' "$1" "$3"; fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ./uninstall.sh [--purge] [--yes]
|
||||
|
||||
--purge also delete the settings ($CONFIG_DIR)
|
||||
and the dictations, meetings and recordings ($DATA_DIR)
|
||||
--yes do not ask before deleting those
|
||||
|
||||
Without --purge nothing you have written is touched, and the source directory
|
||||
is left alone either way.
|
||||
EOF
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--purge) PURGE=1 ;;
|
||||
--yes|-y) ASSUME_YES=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'uninstall.sh: unknown option: %s\n' "$arg" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# A symlink whose target is gone is still a file to remove, hence -L.
|
||||
remove() {
|
||||
if [[ -e "$1" || -L "$1" ]]; then
|
||||
rm -f "$1"
|
||||
ok "Removed $1"
|
||||
else
|
||||
gone "Was not there: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
echo
|
||||
echo "Uninstalling Dikte"
|
||||
echo "──────────────────"
|
||||
|
||||
# 1. Global shortcuts ------------------------------------------------------
|
||||
# Handed to Dikte while it can still run, because it is the half that knows
|
||||
# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings.
|
||||
if [[ -n "$PY" ]] && python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
||||
for which in toggle cancel ask meeting; do
|
||||
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
|
||||
done
|
||||
ok "Global shortcuts unregistered"
|
||||
say "KWin reads that file at startup, so the keys are free after your next login."
|
||||
else
|
||||
warn "PyQt6 is missing, so the shortcuts were left registered."
|
||||
say "Remove them in your desktop's shortcut settings."
|
||||
fi
|
||||
|
||||
# 2. The running instance --------------------------------------------------
|
||||
# It holds a tray icon and a socket; asking it to quit is tidier than pulling
|
||||
# its launchers out from under it.
|
||||
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
|
||||
[[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
|
||||
warn "Dikte is still running; close it from the tray icon"
|
||||
else
|
||||
ok "Stopped the running instance"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Launchers -------------------------------------------------------------
|
||||
# Only our own symlink goes: a file of the same name that somebody else put
|
||||
# there is not ours to delete.
|
||||
if [[ -L "$BIN_DIR/dikte" ]]; then
|
||||
remove "$BIN_DIR/dikte"
|
||||
elif [[ -e "$BIN_DIR/dikte" ]]; then
|
||||
warn "$BIN_DIR/dikte is not our symlink, leaving it alone"
|
||||
else
|
||||
gone "Was not there: $BIN_DIR/dikte"
|
||||
fi
|
||||
remove "$APP_DIR/dikte.desktop"
|
||||
remove "$AUTOSTART_DIR/dikte.desktop"
|
||||
# Removing the shortcut takes its desktop file with it, but an install from
|
||||
# before this script existed may have left one behind on a desktop that never
|
||||
# used them.
|
||||
for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do
|
||||
if [[ -e "$APP_DIR/$id.desktop" ]]; then
|
||||
remove "$APP_DIR/$id.desktop"
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Settings and dictations -----------------------------------------------
|
||||
echo
|
||||
if ((PURGE)); then
|
||||
warn "--purge also deletes:"
|
||||
if [[ -f "$CONFIG_DIR/config.json" ]]; then
|
||||
say "$CONFIG_DIR/config.json (your API keys and every setting)"
|
||||
fi
|
||||
if [[ -f "$DATA_DIR/history.jsonl" ]]; then
|
||||
# grep -c rather than wc -l: a last line with no newline is still a dictation.
|
||||
say "$DATA_DIR/history.jsonl ($(count "$(grep -c '' "$DATA_DIR/history.jsonl" 2>/dev/null || echo 0)" dictation dictations))"
|
||||
fi
|
||||
if [[ -d "$DATA_DIR/meetings" ]]; then
|
||||
say "$DATA_DIR/meetings ($(count "$(find "$DATA_DIR/meetings" -name '*.md' | wc -l)" meeting meetings))"
|
||||
fi
|
||||
if [[ -d "$DATA_DIR/recordings" ]]; then
|
||||
say "$DATA_DIR/recordings ($(du -sh "$DATA_DIR/recordings" | cut -f1) of audio)"
|
||||
fi
|
||||
|
||||
if ((!ASSUME_YES)); then
|
||||
if [[ -t 0 ]]; then
|
||||
printf ' Type yes to delete them: '
|
||||
read -r reply
|
||||
[[ "$reply" == "yes" ]] || { PURGE=0; say "Kept."; }
|
||||
else
|
||||
PURGE=0
|
||||
warn "Not a terminal, so nothing was deleted. Pass --yes if you meant it."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((PURGE)); then
|
||||
rm -rf "$CONFIG_DIR" "$DATA_DIR"
|
||||
ok "Settings and dictations deleted"
|
||||
else
|
||||
say "Settings kept: $CONFIG_DIR"
|
||||
say "Dictations kept: $DATA_DIR"
|
||||
say "Delete them too with: ./uninstall.sh --purge"
|
||||
fi
|
||||
|
||||
echo
|
||||
ok "Done."
|
||||
say "The source directory is untouched: $DIR"
|
||||
echo
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dikte updater: pull, put the launchers back, restart what was running.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PY="$(command -v python3 || true)"
|
||||
USER_NAME="$(id -un)"
|
||||
|
||||
say() { printf ' %s\n' "$1"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
|
||||
die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; }
|
||||
|
||||
# The combination stored in the settings, which is where Dikte itself reads it
|
||||
# from and the one place that is the same on KDE and on GNOME.
|
||||
setting() {
|
||||
[[ -n "$PY" ]] || return 0
|
||||
"$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
echo
|
||||
echo "Updating Dikte"
|
||||
echo "──────────────"
|
||||
|
||||
cd "$DIR"
|
||||
|
||||
# 1. Somewhere there is something to pull ----------------------------------
|
||||
command -v git >/dev/null || die "git not found; update by downloading the source again"
|
||||
git rev-parse --git-dir >/dev/null 2>&1 \
|
||||
|| die "$DIR is not a git checkout; update by downloading the source again"
|
||||
|
||||
before="$(git rev-parse HEAD)"
|
||||
|
||||
# 2. Is there anything to come? ---------------------------------------------
|
||||
# Asked before anything else is complained about: an unfinished afternoon in
|
||||
# the working tree is nobody's problem on a day when nothing has been
|
||||
# published. Fetching leaves the working tree alone.
|
||||
git fetch --quiet || die "Could not reach the remote."
|
||||
upstream="$(git rev-parse '@{u}' 2>/dev/null)" \
|
||||
|| die "This branch is not tracking a remote one; pull by hand."
|
||||
|
||||
if [[ "$before" == "$upstream" ]]; then
|
||||
echo
|
||||
ok "Already up to date ($(git log -1 --format=%s))"
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3. Only now, your own edits -----------------------------------------------
|
||||
# They would be overwritten by a fast-forward or would block it, and either way
|
||||
# that is your call to make, not this script's. Untracked files are counted
|
||||
# too: a fast-forward that adds a file of that name stops on them.
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
warn "There is an update waiting, but you have changes of your own here:"
|
||||
git --no-pager status --short | sed 's/^/ /'
|
||||
say "Commit them, or put them aside with: git stash --include-untracked"
|
||||
die "Nothing was updated."
|
||||
fi
|
||||
|
||||
# --ff-only: an update should be somebody else's commits arriving, never a
|
||||
# merge this script decided to make on your behalf. The fetch above already
|
||||
# brought them, so this touches no network.
|
||||
# advice off: git's suggestion is a merge or a rebase, and which of those you
|
||||
# want is the sentence below, not a wall of hints.
|
||||
if ! merge_log="$(git -c advice.diverging=false merge --ff-only '@{u}' 2>&1)"; then
|
||||
printf '%s\n' "$merge_log" | sed 's/^/ /'
|
||||
say "Your branch has commits the remote does not. To put them on top of the"
|
||||
say "update instead: git pull --rebase"
|
||||
die "Could not fast-forward."
|
||||
fi
|
||||
after="$(git rev-parse HEAD)"
|
||||
|
||||
echo
|
||||
say "What arrived:"
|
||||
git --no-pager log --oneline "$before..$after" | sed 's/^/ /'
|
||||
echo
|
||||
|
||||
# 4. Launchers --------------------------------------------------------------
|
||||
# An update can add a dependency or move a file, so the installer runs again.
|
||||
# It would otherwise register its own defaults over the keys you chose, so it
|
||||
# is told what those are. Read before the installer runs, since it is the one
|
||||
# writing them.
|
||||
shortcut="$(setting shortcut)"
|
||||
cancel_shortcut="$(setting cancel_shortcut)"
|
||||
# Positional, so a chosen discard key cannot be passed without the other one.
|
||||
"$DIR/install.sh" "${shortcut:-Ctrl+Space}" "${cancel_shortcut:-}"
|
||||
|
||||
# 5. The running instance ---------------------------------------------------
|
||||
# It is still running the code from before the pull.
|
||||
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
|
||||
if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then
|
||||
ok "Restarted, so the new version is the one running"
|
||||
else
|
||||
warn "Could not restart it; use the tray menu → Restart"
|
||||
fi
|
||||
else
|
||||
say "Dikte was not running. Start it with: dikte"
|
||||
fi
|
||||
echo
|
||||
@@ -18,6 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
|
||||
import api
|
||||
import assistant
|
||||
import audio
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import i18n
|
||||
import paste
|
||||
@@ -106,14 +107,13 @@ class Pipeline(QObject):
|
||||
|
||||
text = raw
|
||||
warning = ""
|
||||
cleaner = conf.cleanup_target()
|
||||
# Claude reads through “eee” and “hani” without help, so a dictation
|
||||
# on its way there is normally sent as it was heard, one API call and
|
||||
# a second or two lighter.
|
||||
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
|
||||
self.stage.emit(t("Cleaning up…"))
|
||||
try:
|
||||
text = api.cleanup(cleaner, raw, conf.cleanup_prompt())
|
||||
text = cleanup.run(raw, conf, conf.cleanup_prompt())
|
||||
except api.ApiError as exc:
|
||||
# Keep the transcript, but never let the failure pass unseen:
|
||||
# a rejected key would otherwise look like working dictation.
|
||||
@@ -153,7 +153,7 @@ class Pipeline(QObject):
|
||||
"duration": round(duration, 1),
|
||||
"elapsed": round(time.monotonic() - started, 1),
|
||||
"model": target.model,
|
||||
"cleanup_model": cleaner.model if conf["cleanup_enabled"] else "",
|
||||
"cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "",
|
||||
"cleanup_error": warning,
|
||||
"mode": "ask" if ask else "",
|
||||
"question": question,
|
||||
|
||||
Reference in New Issue
Block a user