mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda1398a2b | ||
|
|
6e307bd8d0 | ||
|
|
310ef8d7cf | ||
|
|
4f304e3d94 | ||
|
|
c1554c092e | ||
|
|
632804e922 | ||
|
|
ec57d154fa | ||
|
|
22a73bd939 | ||
|
|
c503d12e62 | ||
|
|
5fd32d105f | ||
|
|
d331ccb877 | ||
|
|
fa3d72f5a7 | ||
|
|
1ff18e46ab | ||
|
|
664a6f53a4 | ||
|
|
0ecd4d251e | ||
|
|
7b7df1df62 | ||
|
|
65055eeb56 | ||
|
|
23243c5171 | ||
|
|
ccc8ba596e | ||
|
|
1df2d03056 | ||
|
|
4f0d320ff1 | ||
|
|
f6c6122253 | ||
|
|
9736bd0ecd | ||
|
|
d896680a4f | ||
|
|
7c135b1063 | ||
|
|
17a55e1efc | ||
|
|
a3bb5ed29b | ||
|
|
e681accc90 | ||
|
|
66cf3f3923 | ||
|
|
e6208418f9 | ||
|
|
7ebc3bf825 | ||
|
|
1ffc3cff9d | ||
|
|
1812461612 | ||
|
|
52880bd81a | ||
|
|
e6b3fcf48f | ||
|
|
3c336e7178 | ||
|
|
0a6c6128a2 | ||
|
|
be09cc3ac6 | ||
|
|
0b06d6d16d | ||
|
|
e9488a51a4 | ||
|
|
0f30c852ab | ||
|
|
901e57e4d0 | ||
|
|
e8147f49f8 | ||
|
|
6f79e93d53 | ||
|
|
69194186c8 | ||
|
|
afc8ffd992 | ||
|
|
197f5385ee | ||
|
|
420cda9376 | ||
|
|
6f601ab969 | ||
|
|
3663c7fd57 | ||
|
|
df99456cde | ||
|
|
084c48151d | ||
|
|
6bf5c8b349 | ||
|
|
2927e15833 | ||
|
|
0be64c2ef9 | ||
|
|
c7b14ff84e | ||
|
|
45b18722be |
@@ -107,14 +107,21 @@ Meetings are not supported there yet; the details are in the
|
||||
two global shortcuts, whose keys are its two arguments, or the ones already in
|
||||
your settings when it is given none. `./scripts/update.sh` pulls and puts all of
|
||||
that back; `./scripts/uninstall.sh` takes it away again and leaves your settings
|
||||
and dictations alone unless you pass `--purge`.
|
||||
and dictations alone unless you pass `--purge`. Dikte looks at the releases page
|
||||
once a day and puts a line in the tray menu when a newer version is out, which
|
||||
opens the page rather than installing anything; the General tab turns that off
|
||||
or runs it on the spot.
|
||||
|
||||
Speech to text and cleanup each pick a provider in the settings window, and both
|
||||
run here by default, on models of your own. The cloud is the other option:
|
||||
speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`),
|
||||
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
|
||||
cleanup on OpenRouter (`google/gemini-3.5-flash-lite`), on **Google AI Studio**
|
||||
(`gemini-3.5-flash-lite`), on **OpenCode Go** (`deepseek-v4-flash`) or, when one
|
||||
of them is installed, on Claude Code, Codex or Antigravity. The first three are
|
||||
a single HTTP request; the three CLIs each open a whole session to do it, which
|
||||
is where their few extra seconds go. The keys fall back to `OPENAI_API_KEY`,
|
||||
`GROQ_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY` and `OPENCODE_API_KEY`,
|
||||
and are stored in
|
||||
`~/.config/dikte/config.json`, mode 600, or in
|
||||
`~/Library/Application Support/Dikte` on a Mac. Cleanup can be switched off, in
|
||||
which case the raw transcript is pasted, and a thinking model's effort can be
|
||||
@@ -130,12 +137,14 @@ set next to it.
|
||||
| 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` |
|
||||
| Look for a newer version | General tab → *Check now*, or `dikte update` |
|
||||
| Reload after an update | Tray menu → *Restart*, or `dikte restart` |
|
||||
| Quit | Tray menu → *Quit*, or `dikte quit` |
|
||||
|
||||
An indicator in the screen corner shows a red dot, a live waveform and the
|
||||
elapsed time, then the stage it is on. It never takes focus. Pressing
|
||||
`Ctrl+Space` again while Dikte is still working does nothing; nothing queues up.
|
||||
`Ctrl+Space` again while the last dictation is still being cleaned up starts
|
||||
the next one; it is transcribed and pasted in turn, behind the one still going.
|
||||
A dictation and a command to the agent do wait on each other for the microphone,
|
||||
which is one device, but for nothing else: each has its own indicator, and the
|
||||
second one stacks above the first while both are up.
|
||||
@@ -184,11 +193,13 @@ running.
|
||||
what comes of it: the answer, or a sentence saying what was done. It is the
|
||||
session you would have opened yourself, so your skills and connected services
|
||||
are there, which is what makes "put that in my calendar on Thursday at three"
|
||||
a thing you can say to a window that is not Claude. Codex (`codex exec`) runs
|
||||
the same way, and OpenRouter is there as a plain question-and-answer fallback
|
||||
for a machine with neither CLI on it. Provider, model, permissions and working
|
||||
directory are under Settings → Agent, and commands close together stay in one
|
||||
conversation.
|
||||
a thing you can say to a window that is not Claude. Codex (`codex exec`) and
|
||||
Antigravity (`agy -p`) run the same way, though Antigravity takes neither a
|
||||
permission mode nor a sandbox from Dikte: what it may do without asking is
|
||||
whatever its own allow-rules say. OpenRouter or OpenCode Go is there as a
|
||||
plain question-and-answer fallback for a machine with no CLI on it. Provider,
|
||||
model, permissions and working directory are under Settings → Agent, and
|
||||
commands close together stay in one conversation.
|
||||
- **Meetings** are recorded from the microphone and the speaker output at the
|
||||
same time, which settles who said what by the channel a voice arrived on
|
||||
instead of guessing at it. The two sides are transcribed separately and
|
||||
@@ -235,11 +246,12 @@ cli.py the command line: every verb, and what it answers with
|
||||
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
|
||||
assistant.py handing a dictation to Claude Code, Codex, agy or a chat model
|
||||
api.py transcription and cleanup requests (stdlib only)
|
||||
cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex
|
||||
cleanup.py who rewrites the transcript: a hosted model, one here, a CLI
|
||||
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
||||
hub.py what GitHub and Hugging Face have on offer today
|
||||
update.py whether a newer release is out, and the page it is on
|
||||
worker.py transcribe → clean up → clipboard → paste
|
||||
vad.py deciding whether a recording holds speech at all
|
||||
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
||||
|
||||
+23
-12
@@ -104,16 +104,22 @@ toplantı kaydı henüz yok, ayrıntılar [Windows README](README.windows.md)'si
|
||||
başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman
|
||||
verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve
|
||||
bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge`
|
||||
demedikçe ayarlarına ve diktelerine dokunmaz.
|
||||
demedikçe ayarlarına ve diktelerine dokunmaz. Dikte sürüm sayfasına günde bir
|
||||
kez bakar ve yeni sürüm çıkmışsa tepsi menüsüne bir satır koyar; o satır bir şey
|
||||
kurmaz, sayfayı açar. Genel sekmesi bu denetimi kapatır ya da anında çalıştırır.
|
||||
|
||||
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
|
||||
seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. 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, Mac'te ise `~/Library/Application Support/Dikte` altında.
|
||||
(`google/gemini-3.5-flash-lite`), **Google AI Studio**'da
|
||||
(`gemini-3.5-flash-lite`), **OpenCode Go**'da (`deepseek-v4-flash`) ya da
|
||||
kuruluysa Claude Code, Codex veya Antigravity'de çalışır. İlk üçü tek bir HTTP
|
||||
isteği; üç CLI ise bunun için birer oturum açar, fazladan giden birkaç saniye de
|
||||
oradan gelir. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY`,
|
||||
`OPENROUTER_API_KEY`, `GEMINI_API_KEY` ve `OPENCODE_API_KEY` kullanılır;
|
||||
anahtarlar `~/.config/dikte/config.json` içinde, izinler 600, Mac'te ise
|
||||
`~/Library/Application Support/Dikte` altında.
|
||||
Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin
|
||||
yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||
|
||||
@@ -127,12 +133,14 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||
| 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` |
|
||||
| Yeni sürüm var mı bak | Genel sekmesi → *Şimdi bak*, ya da `dikte update` |
|
||||
| Güncelleme sonrası yeniden yükle | Tepsi menüsü → *Yeniden başlat*, ya da `dikte restart` |
|
||||
| Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` |
|
||||
|
||||
Ekranın köşesindeki gösterge kırmızı kayıt noktasını, canlı ses dalgasını ve
|
||||
süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Dikte çalışırken
|
||||
`Ctrl+Space`'e tekrar basmak bir şey yapmaz, sıraya da girmez. Dikte ile ajana
|
||||
süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Önceki dikte daha
|
||||
temizlenirken `Ctrl+Space`'e tekrar basmak yenisini başlatır; o da sırası
|
||||
gelince, öndekinin ardından yazılıp yapıştırılır. Dikte ile ajana
|
||||
verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu
|
||||
için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi
|
||||
birden ekrandayken ikincisi birincinin üstüne yerleşir.
|
||||
@@ -180,9 +188,11 @@ olmasını ister.
|
||||
yapıştırır: cevabı ya da ne yapıldığını söyleyen bir cümle. Kendi açacağın
|
||||
oturumun aynısıdır, yani skill'lerin ve bağlı servislerin oradadır; "bunu
|
||||
perşembe üçe takvime koy" cümlesini Claude olmayan bir pencerede söyleyebilir
|
||||
olmanı sağlayan da budur. Codex (`codex exec`) da aynı şekilde çalışır;
|
||||
OpenRouter ise ikisi de kurulu olmayan bir makinede düz soru cevap için
|
||||
duruyor. Sağlayıcı, model, izinler ve çalışma dizini Ayarlar → Ajan
|
||||
olmanı sağlayan da budur. Codex (`codex exec`) ile Antigravity (`agy -p`) da
|
||||
aynı şekilde çalışır; ama Antigravity'ye Dikte bir izin kipi ya da sandbox
|
||||
veremiyor, sormadan ne yapabileceğini kendi allow-rule'ları belirliyor.
|
||||
OpenRouter ya da OpenCode Go ise hiçbiri kurulu olmayan bir makinede düz soru
|
||||
cevap için duruyor. Sağlayıcı, model, izinler ve çalışma dizini Ayarlar → Ajan
|
||||
sekmesinde; arka arkaya verilen komutlar tek bir konuşmada kalır.
|
||||
- **Toplantılar** mikrofonla hoparlör çıkışından aynı anda kaydedilir; kimin ne
|
||||
dediği tahmin edilmez, sesin hangi kanaldan geldiğiyle belli olur. İki taraf
|
||||
@@ -229,11 +239,12 @@ cli.py komut satırı: bütün fiiller ve verdikleri cevap
|
||||
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
|
||||
assistant.py dikteyi Claude Code, Codex, agy ya da sohbet modelinden geçirme
|
||||
api.py transkript ve temizleme istekleri (yalnız stdlib)
|
||||
cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex
|
||||
cleanup.py transkripti kim temizler: bulutta bir model, burası, bir CLI
|
||||
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
|
||||
update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada
|
||||
worker.py transkript → temizleme → pano → yapıştırma
|
||||
vad.py kayıtta gerçekten konuşma var mı kararı
|
||||
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ business loading Qt to answer one question.
|
||||
# both the .dmg's Info.plist and the AppImage's file name are built from it. A
|
||||
# build off master rather than off a tag appends the commit to it, so that a
|
||||
# bug report from someone running "latest" names a commit.
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
+107
-25
@@ -1,10 +1,17 @@
|
||||
"""OpenAI, Groq, OpenRouter and this machine, stdlib only.
|
||||
"""OpenAI, Groq, OpenRouter, Google AI Studio and this machine, stdlib only.
|
||||
|
||||
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.
|
||||
Transcription runs on the first three and on this machine: 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.
|
||||
|
||||
Google AI Studio is here for cleanup and nothing else. Its OpenAI-compatible
|
||||
endpoint answers /chat/completions and /models, but there is no
|
||||
/audio/transcriptions behind it: audio only goes in as base64 inside a chat
|
||||
message, and what comes back has none of the segment times a subtitle file or a
|
||||
meeting transcript is built out of.
|
||||
|
||||
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.
|
||||
@@ -31,6 +38,7 @@ 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"
|
||||
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
# The floor for a local request. The timeouts elsewhere are sized for a hosted
|
||||
# API, where a slow answer is a bill running; here the only thing being spent is
|
||||
@@ -40,22 +48,33 @@ LOCAL_TIMEOUT = 3600
|
||||
|
||||
# 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")
|
||||
# branches on. `file_model` is what a timestamped run asks for instead of
|
||||
# `model`, where the two differ; empty means the provider's own whisper.
|
||||
Target = collections.namedtuple(
|
||||
"Target", "provider service api_key base_url model file_model",
|
||||
defaults=[""])
|
||||
|
||||
# What answers with segment times on OpenRouter when nothing else was chosen.
|
||||
OPENROUTER_FILE_MODEL = "openai/whisper-1"
|
||||
|
||||
|
||||
def timestamp_model(provider, selected=""):
|
||||
def timestamp_model(provider, selected="", file_model=""):
|
||||
"""Which model answers with segment times.
|
||||
|
||||
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.
|
||||
OpenAI keeps them to whisper-1. 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. OpenRouter fronts several models that do times
|
||||
and several that do not, and a request to the wrong one gets a transcript
|
||||
with no segments in it, so the one to use is a setting of its own
|
||||
(`file_model`) and whisper-1 is only where that setting is left empty.
|
||||
"""
|
||||
if provider in ("groq", "local"):
|
||||
return selected or "whisper-large-v3-turbo"
|
||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
||||
if provider == "openrouter":
|
||||
return file_model or OPENROUTER_FILE_MODEL
|
||||
return "whisper-1"
|
||||
|
||||
|
||||
# What a gateway in front of the model answers of its own accord: the request
|
||||
@@ -254,10 +273,23 @@ def _request(url, data, headers, timeout=120, aborter=None):
|
||||
|
||||
|
||||
def _extract_error(body):
|
||||
"""The line worth showing out of a failed request's body.
|
||||
|
||||
Whatever comes back, this has to end in a string: it is called while an
|
||||
ApiError is being raised, and an exception thrown here would escape the
|
||||
`except ApiError` every caller is holding and lose the dictation the raw
|
||||
transcript would otherwise have been pasted from.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return body[:300]
|
||||
if isinstance(payload, list):
|
||||
# Google answers some failures with an array holding the object the
|
||||
# other providers send on its own.
|
||||
payload = next((item for item in payload if isinstance(item, dict)), None)
|
||||
if not isinstance(payload, dict):
|
||||
return body[:300]
|
||||
err = payload.get("error")
|
||||
if isinstance(err, dict):
|
||||
return err.get("message") or json.dumps(err)[:300]
|
||||
@@ -423,7 +455,8 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
|
||||
aborter=None):
|
||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||
data = _transcribe_request(
|
||||
target._replace(model=timestamp_model(target.provider, target.model)),
|
||||
target._replace(model=timestamp_model(target.provider, target.model,
|
||||
target.file_model)),
|
||||
audio_path, language, prompt, "verbose_json",
|
||||
granularity="segment", timeout=timeout, aborter=aborter,
|
||||
)
|
||||
@@ -448,14 +481,24 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
|
||||
return out
|
||||
|
||||
|
||||
# The settings window offers OpenRouter's ladder, and Google has neither end of
|
||||
# it: "none" is refused outright with a 400, and there is nothing above "high".
|
||||
# Both ends land on the nearest rung that does exist, which costs the cleanup
|
||||
# rather than the dictation when it is wrong. "minimal" is where "off" goes, and
|
||||
# it is the quickest of them by a wide margin, which is what cleanup wants
|
||||
# anyway.
|
||||
GEMINI_EFFORT = {"none": "minimal", "xhigh": "high", "max": "high"}
|
||||
|
||||
|
||||
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 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 three 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 a Gemini Flash left to
|
||||
itself thinks too. Cleanup is punctuation rather than a job worth thinking
|
||||
about.
|
||||
"""
|
||||
if not reasoning:
|
||||
return
|
||||
@@ -463,6 +506,13 @@ def _thinking(payload, provider, reasoning):
|
||||
# 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 provider == "gemini":
|
||||
# Google's compatibility layer takes OpenAI's flat field rather than
|
||||
# OpenRouter's object, and it has no word for off, so "none" is asked
|
||||
# for as the lowest rung it has rather than skipped: a Flash model left
|
||||
# to decide for itself thinks, and thinking about a comma is the second
|
||||
# this provider was chosen to save.
|
||||
payload["reasoning_effort"] = GEMINI_EFFORT.get(reasoning, reasoning)
|
||||
elif reasoning != "none":
|
||||
# The thinking itself is never shown, so ask for it to be left out.
|
||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
||||
@@ -524,15 +574,16 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||
|
||||
|
||||
def chat(messages, api_key, model, system_prompt, reasoning="",
|
||||
base_url=OPENROUTER_URL, timeout=180):
|
||||
base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
|
||||
service="OpenRouter"):
|
||||
"""A conversation, rather than one transcript rewritten.
|
||||
|
||||
The messages are the whole history and come back unchanged; the caller keeps
|
||||
them, because there is no session on OpenRouter's side to resume.
|
||||
them, because there is no session on the provider's side to resume.
|
||||
"""
|
||||
if not api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service="OpenRouter"))
|
||||
service=service))
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": "system", "content": system_prompt}] + list(messages),
|
||||
@@ -543,11 +594,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="",
|
||||
data = _request(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
_headers("openrouter", api_key, "application/json"),
|
||||
_headers(provider, api_key, "application/json"),
|
||||
timeout=timeout,
|
||||
)
|
||||
except ApiError as exc:
|
||||
raise explain(exc, "OpenRouter") from None
|
||||
raise explain(exc, service) from None
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise ApiError(_extract_error(json.dumps(data)))
|
||||
@@ -611,6 +662,37 @@ def openrouter_models(api_key="", transcription=False):
|
||||
return sorted(m["id"] for m in models if m.get("id"))
|
||||
|
||||
|
||||
# What a `gemini` id can be besides a model that answers a chat request: an
|
||||
# embedding, a picture, or a voice. None of them is any use to cleanup.
|
||||
NOT_CHAT = ("embedding", "-image", "-tts", "-audio")
|
||||
|
||||
|
||||
def gemini_models(api_key, base_url=GEMINI_URL):
|
||||
"""The Gemini models Google AI Studio will answer a chat request with.
|
||||
|
||||
Google serves its embedding, image and speech models out of the same list
|
||||
and names them all `gemini` too, so the prefix alone is not the question;
|
||||
none of those can clean up a sentence. The listing has also been known to
|
||||
hand the ids back in their long form, `models/gemini-3.5-flash`, while a
|
||||
request wants the short one; taking the prefix off costs nothing and is
|
||||
right whichever form arrives.
|
||||
"""
|
||||
service = "Google AI Studio"
|
||||
if not api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
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, service) from None
|
||||
ids = [(m.get("id") or "").removeprefix("models/") for m in data.get("data", [])]
|
||||
return sorted(i for i in ids
|
||||
if i.startswith("gemini") and not any(w in i for w in NOT_CHAT))
|
||||
|
||||
|
||||
def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"):
|
||||
"""The audio models of anything that speaks OpenAI's /models, Groq included.
|
||||
|
||||
|
||||
+447
-55
@@ -18,6 +18,7 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
# A Wayland client cannot place a window in a screen corner, so the indicator
|
||||
# is drawn through XWayland.
|
||||
@@ -33,8 +34,9 @@ if sys.platform == "darwin":
|
||||
os.environ.get("PATH", "")) if part
|
||||
)
|
||||
|
||||
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
|
||||
from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
||||
from PyQt6.QtCore import (QObject, QTimer, QElapsedTimer, QSocketNotifier, # noqa: E402
|
||||
QUrl, pyqtSignal)
|
||||
from PyQt6.QtGui import QAction, QDesktopServices, QIcon # noqa: E402
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
|
||||
|
||||
@@ -44,11 +46,14 @@ from . import cli # noqa: E402
|
||||
from . import config as cfg # noqa: E402
|
||||
from . import ggml # noqa: E402
|
||||
from . import hotkey # noqa: E402
|
||||
from . import hub # noqa: E402
|
||||
from . import i18n # noqa: E402
|
||||
from . import integrate # noqa: E402
|
||||
from . import ipc # noqa: E402
|
||||
from . import mac_window # noqa: E402
|
||||
from . import meeting # noqa: E402
|
||||
from . import trayicon # noqa: E402
|
||||
from . import update # noqa: E402
|
||||
from .i18n import t # noqa: E402
|
||||
from .meeting import MeetingPipeline # noqa: E402
|
||||
from .overlay import Overlay # noqa: E402
|
||||
@@ -77,11 +82,46 @@ ECHO_MS = 2000
|
||||
# short enough not to sit in the corner for the rest of the hour.
|
||||
PEEK_MS = 12000
|
||||
|
||||
# When the releases page is looked at, and how often it is thought about after
|
||||
# that. The delay is there so that a check never shares the first seconds of a
|
||||
# start with the model being loaded and the desktop drawing the tray; the
|
||||
# interval is not the interval between checks, which update.py holds at a day,
|
||||
# but how often that clock is read, so that a machine left running for a week
|
||||
# still asks once a day rather than once a boot.
|
||||
UPDATE_DELAY_MS = 20000
|
||||
UPDATE_POLL_MS = 3 * 3600 * 1000
|
||||
|
||||
|
||||
class UpdateCheck(QObject):
|
||||
"""One look at the releases page, off the interface thread.
|
||||
|
||||
An object of its own because the application is not one: a plain thread
|
||||
cannot touch a widget, and a signal is the only way back onto the thread
|
||||
that may.
|
||||
"""
|
||||
|
||||
# The newer release, or None when there is nothing to say, and the reason
|
||||
# nothing could be found out instead.
|
||||
done = pyqtSignal(object, str)
|
||||
|
||||
def start(self):
|
||||
def work():
|
||||
try:
|
||||
self.done.emit(update.check(), "")
|
||||
except hub.HubError as exc:
|
||||
self.done.emit(None, str(exc))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
|
||||
class Dikte:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.conf = cfg.Config()
|
||||
# run_app hands the one-instance lock over after construction; a Dikte
|
||||
# built without one (the tests) restarts without touching a lock.
|
||||
self.instance_lock = None
|
||||
self._registry_shortcuts = True # settled by _apply_settings below
|
||||
self.state = IDLE
|
||||
self.ask_state = IDLE
|
||||
# Which of the two the microphone is currently serving, or None.
|
||||
@@ -111,12 +151,25 @@ class Dikte:
|
||||
# Which recording is the current one, so a timer set for the run that
|
||||
# started it cannot stop the one that came after.
|
||||
self._run_id = 0
|
||||
# Dictations handed to the pipeline and not yet out of it. More than
|
||||
# one is normal: the microphone is free while a transcript is being
|
||||
# cleaned up, so the next dictation can already be spoken, and it then
|
||||
# queues up behind the one still going.
|
||||
self._transcripts_pending = 0
|
||||
# The application that was in front when the recording started, which
|
||||
# is where the transcript is meant to go, and the timer watching for
|
||||
# the moment it has to be put back there. macOS only; see
|
||||
# _give_the_front_back.
|
||||
self.front_before = None
|
||||
self._front_watch = None
|
||||
|
||||
self.overlay = Overlay(self.conf["overlay_corner"])
|
||||
self.overlay = Overlay(self.conf["overlay_corner"],
|
||||
screen_name=self.conf["overlay_screen"])
|
||||
# The agent's indicator sits on top of the dictation one when both are
|
||||
# up, and drops into the corner when it is alone there.
|
||||
self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay,
|
||||
dismissable=True)
|
||||
dismissable=True,
|
||||
screen_name=self.conf["overlay_screen"])
|
||||
self.recorder = audio.Recorder()
|
||||
self.pipeline = Pipeline(self.conf)
|
||||
self.ask_pipeline = Pipeline(self.conf)
|
||||
@@ -126,13 +179,20 @@ class Dikte:
|
||||
# Before anything of ours is started: a server from a Dikte that was
|
||||
# killed outright is still holding a model in memory.
|
||||
ggml.sweep()
|
||||
# The first dictation with no microphone picked would otherwise pay for
|
||||
# the ffmpeg device listing on the key press itself, seconds of nothing
|
||||
# happening at the least explicable moment. Warmed the way the models
|
||||
# are, off the main thread; the listing caches itself.
|
||||
if sys.platform == "win32" and not self.conf["mic_target"]:
|
||||
threading.Thread(target=audio.list_sources, daemon=True).start()
|
||||
|
||||
self.recorder.level.connect(self._on_level)
|
||||
self.recorder.stopped.connect(self._on_recorded)
|
||||
self.recorder.died.connect(self._on_recorder_died)
|
||||
self.recorder.failed.connect(self._on_recorder_error)
|
||||
self.pipeline.stage.connect(self.overlay.show_busy)
|
||||
self.pipeline.stage.connect(self._on_stage)
|
||||
self.pipeline.finished.connect(self._on_finished)
|
||||
self.pipeline.failed.connect(self._on_error)
|
||||
self.pipeline.failed.connect(self._on_pipeline_failed)
|
||||
self.ask_pipeline.stage.connect(self.ask_overlay.show_busy)
|
||||
self.ask_pipeline.finished.connect(self._on_ask_finished)
|
||||
self.ask_pipeline.failed.connect(self._on_ask_error)
|
||||
@@ -150,7 +210,7 @@ class Dikte:
|
||||
|
||||
self.elapsed = QElapsedTimer()
|
||||
self.meeting_elapsed = QElapsedTimer()
|
||||
self.last_toggle = QElapsedTimer()
|
||||
self.last_toggle = {} # action name -> QElapsedTimer, see _repeated
|
||||
self.last_evdev = {}
|
||||
self.ticker = QTimer()
|
||||
self.ticker.setInterval(100)
|
||||
@@ -159,6 +219,17 @@ class Dikte:
|
||||
self.meeting_ticker.setInterval(500)
|
||||
self.meeting_ticker.timeout.connect(self._meeting_tick)
|
||||
|
||||
# What the last check found, read from disk rather than asked for, so
|
||||
# that a tray built in the next line already knows to say so.
|
||||
self.update_release = update.pending()
|
||||
self.updates = UpdateCheck()
|
||||
self.updates.done.connect(self._on_update_checked)
|
||||
self.update_ticker = QTimer()
|
||||
self.update_ticker.setInterval(UPDATE_POLL_MS)
|
||||
self.update_ticker.timeout.connect(self._look_for_update)
|
||||
self.update_ticker.start()
|
||||
QTimer.singleShot(UPDATE_DELAY_MS, self._look_for_update)
|
||||
|
||||
self.tray = QSystemTrayIcon()
|
||||
self._apply_settings()
|
||||
self.tray.show()
|
||||
@@ -211,6 +282,11 @@ class Dikte:
|
||||
self.menu.addAction(self.meeting_cancel_action)
|
||||
self.menu.addSeparator()
|
||||
|
||||
# Named in _refresh_update, and hidden until a check has found one.
|
||||
self.update_action = QAction("", self.menu)
|
||||
self.update_action.triggered.connect(self.open_release_page)
|
||||
self.menu.addAction(self.update_action)
|
||||
|
||||
self.settings_action = QAction(t("Settings…"), self.menu)
|
||||
self.settings_action.triggered.connect(self.open_settings)
|
||||
self.menu.addAction(self.settings_action)
|
||||
@@ -227,6 +303,7 @@ class Dikte:
|
||||
self.tray.setContextMenu(self.menu)
|
||||
self.tray.setToolTip(t("Dikte: ready"))
|
||||
self.tray.activated.connect(self._tray_clicked)
|
||||
self._refresh_update()
|
||||
self._set_icon("audio-input-microphone")
|
||||
|
||||
def _tray_clicked(self, reason):
|
||||
@@ -285,13 +362,18 @@ class Dikte:
|
||||
BUSY: ("Working…", "view-refresh", "Dikte: working"),
|
||||
}
|
||||
label, icon, tip = labels[self.state]
|
||||
if self.state == BUSY:
|
||||
# Still working, but the microphone is free again: the menu offers
|
||||
# the next dictation rather than a wait.
|
||||
label = "Start recording"
|
||||
agent = assistant.display_name(self.conf)
|
||||
|
||||
self.toggle_action.setText(t(label))
|
||||
# Free while the other one is thinking, blocked only while it is holding
|
||||
# the microphone.
|
||||
# Blocked only while something is holding the microphone: a transcript
|
||||
# still being cleaned up queues the next dictation behind it, and the
|
||||
# agent thinking never blocked it at all.
|
||||
self.toggle_action.setEnabled(
|
||||
self.state == RECORDING or (self.state == IDLE and not self.recording)
|
||||
self.state == RECORDING or not self.recording
|
||||
)
|
||||
asked = i18n.name(agent, "dative")
|
||||
self.ask_action.setText(
|
||||
@@ -378,7 +460,10 @@ class Dikte:
|
||||
# Where nothing was installed there is no shortcut to catch up, and
|
||||
# retiring the listener would leave the keys with nowhere to arrive.
|
||||
timer = self.last_evdev.get(name)
|
||||
if (hotkey.installs_shortcuts() and self.evdev.running
|
||||
# The snapshot from _apply_settings: which desktop this is cannot
|
||||
# change under a running process, and asking hotkey again here costs a
|
||||
# PATH scan on every key press.
|
||||
if (self._registry_shortcuts and self.evdev.running
|
||||
and timer is not None and timer.elapsed() < ECHO_MS):
|
||||
self._retire_listener()
|
||||
return
|
||||
@@ -447,10 +532,6 @@ class Dikte:
|
||||
|
||||
def _dictation_request(self, cmd, request, reply):
|
||||
before = self.state
|
||||
# Only a request that said something about pasting changes it, so that
|
||||
# the stop half of a `start --paste` does not undo the start half.
|
||||
if "paste" in request:
|
||||
self.paste_override[DICTATION] = request["paste"]
|
||||
if cmd == "toggle":
|
||||
self.toggle()
|
||||
elif cmd == "stop":
|
||||
@@ -461,13 +542,20 @@ class Dikte:
|
||||
if seconds > 0 and self.state == RECORDING:
|
||||
run = self._run_id
|
||||
QTimer.singleShot(int(seconds * 1000), lambda: self._auto_stop(run))
|
||||
# Armed only when the request actually moved this run along: a request
|
||||
# that no-opped (the microphone held by the other one, or nothing to
|
||||
# stop) must not leave a preference behind for some later, unrelated
|
||||
# run to pick up. A stop that lands keeps changing the run it ends,
|
||||
# so the stop half of a `start --paste` still does not undo the start.
|
||||
if "paste" in request and self.state != before:
|
||||
self.paste_override[DICTATION] = request["paste"]
|
||||
self._answer(DICTATION, before, self.state, request, reply)
|
||||
|
||||
def _ask_request(self, request, reply):
|
||||
before = self.ask_state
|
||||
if "paste" in request:
|
||||
self.paste_override[ASK] = request["paste"]
|
||||
self.toggle_ask()
|
||||
if "paste" in request and self.ask_state != before:
|
||||
self.paste_override[ASK] = request["paste"]
|
||||
self._answer(ASK, before, self.ask_state, request, reply)
|
||||
|
||||
def _meeting_request(self, cmd, request, reply):
|
||||
@@ -532,40 +620,70 @@ class Dikte:
|
||||
def _toggle(self):
|
||||
# Two /dev/input nodes can carry the same keyboard, and a menu click can
|
||||
# land on top of a key press; swallow the immediate repeat.
|
||||
if self._repeated():
|
||||
if self._repeated("toggle"):
|
||||
return
|
||||
if self.state == RECORDING:
|
||||
self.stop()
|
||||
elif self.state == IDLE:
|
||||
else:
|
||||
# BUSY does not block: the microphone is free while the last
|
||||
# dictation is being cleaned up, and the next one starts now and
|
||||
# waits its turn in the pipeline.
|
||||
self.start()
|
||||
# a request during its own BUSY is ignored; nothing queues up
|
||||
|
||||
def _toggle_ask(self):
|
||||
if self._repeated():
|
||||
if self._repeated("ask"):
|
||||
return
|
||||
if self.ask_state == RECORDING:
|
||||
self.stop_ask()
|
||||
elif self.ask_state == IDLE:
|
||||
self.start_ask()
|
||||
|
||||
def _repeated(self):
|
||||
if self.last_toggle.isValid() and self.last_toggle.elapsed() < 400:
|
||||
def _repeated(self, name):
|
||||
# Per action, the way last_evdev already is: the window is meant to
|
||||
# swallow a duplicate delivery of the same press, not a pause landing
|
||||
# right after the toggle that started the recording.
|
||||
timer = self.last_toggle.get(name)
|
||||
if timer is None:
|
||||
timer = self.last_toggle[name] = QElapsedTimer()
|
||||
if timer.isValid() and timer.elapsed() < 400:
|
||||
return True
|
||||
self.last_toggle.restart()
|
||||
timer.restart()
|
||||
return False
|
||||
|
||||
def _the_front(self):
|
||||
"""The application a recording is about to start from, or None.
|
||||
|
||||
Asked before the indicator goes up rather than alongside the
|
||||
microphone: putting a window on screen can take the front as well, and
|
||||
once it has, the only answer left to the question is Dikte.
|
||||
"""
|
||||
return mac_window.frontmost_pid() if sys.platform == "darwin" else None
|
||||
|
||||
def start(self):
|
||||
if self.state != IDLE or self.recording:
|
||||
# Only a held microphone blocks: a previous dictation still being
|
||||
# transcribed or cleaned up is the pipeline's business, not the
|
||||
# recorder's.
|
||||
if self.state == RECORDING or self.recording:
|
||||
return
|
||||
self.front_before = self._the_front()
|
||||
self.overlay.show_recording()
|
||||
self._begin_recording(DICTATION)
|
||||
# A recorder that could not start has already said so, synchronously,
|
||||
# and the error handler put everything back; setting RECORDING on top
|
||||
# of that would strand the state machine with no signal ever coming.
|
||||
# The same guard start_meeting has always had.
|
||||
if not self.recorder.active:
|
||||
return
|
||||
self._set_state(RECORDING)
|
||||
|
||||
def start_ask(self):
|
||||
if self.ask_state != IDLE or self.recording:
|
||||
return
|
||||
self.front_before = self._the_front()
|
||||
self.ask_overlay.show_recording(asking=True)
|
||||
self._begin_recording(ASK)
|
||||
if not self.recorder.active:
|
||||
return
|
||||
self._set_ask_state(RECORDING)
|
||||
|
||||
def _begin_recording(self, owner):
|
||||
@@ -576,6 +694,80 @@ class Dikte:
|
||||
self.elapsed.restart()
|
||||
self.ticker.start()
|
||||
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
||||
self._give_the_front_back(self.front_before)
|
||||
|
||||
def _give_the_front_back(self, was_in_front):
|
||||
"""Hand the front back to whoever had it when the recording started.
|
||||
|
||||
On macOS a recording goes through ffmpeg's avfoundation input, and
|
||||
opening a capture session there brings the process that did it to the
|
||||
front. ffmpeg is a child of Dikte with no bundle of its own, so the
|
||||
system credits the move to Dikte: the window the user was typing in
|
||||
loses the front, its caret stops, its title bar greys out, and the
|
||||
Cmd+V at the end of the dictation has nowhere to land. Measured with a
|
||||
TextEdit document in front:
|
||||
|
||||
press the shortcut front = TextEdit
|
||||
recorder.start returns front = TextEdit
|
||||
89 ms later front = Dikte
|
||||
|
||||
Nothing about the capture session can be asked not to do this. It is
|
||||
not a window of ours and no flag reaches it. Starting ffmpeg in its own
|
||||
session, and clearing __CFBundleIdentifier from its environment, were
|
||||
both tried and both measured to make no difference. So it is undone
|
||||
instead. The move lands a moment after the process starts rather than
|
||||
during the call, hence the short watch rather than one attempt: it
|
||||
gives up as soon as it has put the front back, and in any case after a
|
||||
second and a half, which is longer than the microphone has ever taken
|
||||
to open.
|
||||
|
||||
Silent off macOS, and silent when the recording started from Dikte
|
||||
itself: there is nothing to give back.
|
||||
"""
|
||||
# One watch at a time. A second recording started before the first
|
||||
# watch had finished would otherwise leave two of them running, and the
|
||||
# older one would put the front back where the older recording
|
||||
# started, which by then is the wrong window.
|
||||
if self._front_watch is not None:
|
||||
self._front_watch.stop()
|
||||
self._front_watch = None
|
||||
if not was_in_front or was_in_front == os.getpid():
|
||||
return
|
||||
deadline = time.monotonic() + 1.5
|
||||
watch = QTimer(self.app)
|
||||
# Ten milliseconds because the front is already gone by the time this
|
||||
# notices, and every tick it waits is a tick of the user's window drawn
|
||||
# inactive: at forty the title bar visibly blinks, at ten it does not.
|
||||
# Two messages to AppKit per tick, for at most a second and a half.
|
||||
watch.setInterval(10)
|
||||
# activateWithOptions: answers whether macOS accepted the request, not
|
||||
# whether the other application is already back in front. Keep the
|
||||
# watch alive until that asynchronous handoff is observable; on Intel
|
||||
# Macs it can take hundreds of milliseconds after the call returned.
|
||||
restore_requested = False
|
||||
|
||||
def look():
|
||||
nonlocal restore_requested
|
||||
if time.monotonic() > deadline:
|
||||
self._stop_watching_the_front()
|
||||
return
|
||||
if mac_window.is_frontmost():
|
||||
if not restore_requested:
|
||||
restore_requested = mac_window.activate(was_in_front)
|
||||
elif restore_requested:
|
||||
# The request has landed. Stop only now, rather than as soon
|
||||
# as AppKit accepted it, so a delayed or failed handoff stays
|
||||
# under observation until the deadline guard above.
|
||||
self._stop_watching_the_front()
|
||||
|
||||
watch.timeout.connect(look)
|
||||
self._front_watch = watch
|
||||
watch.start()
|
||||
|
||||
def _stop_watching_the_front(self):
|
||||
if self._front_watch is not None:
|
||||
self._front_watch.stop()
|
||||
self._front_watch = None
|
||||
|
||||
def stop(self):
|
||||
if self.state != RECORDING:
|
||||
@@ -583,7 +775,9 @@ class Dikte:
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self._set_state(BUSY)
|
||||
self.overlay.show_busy(t("Transcribing…"))
|
||||
self.overlay.show_busy(t("Waiting for the one before it…")
|
||||
if self._transcripts_pending
|
||||
else t("Transcribing…"))
|
||||
self.recorder.stop()
|
||||
|
||||
def stop_ask(self):
|
||||
@@ -603,7 +797,7 @@ class Dikte:
|
||||
the phone call in the middle of a dictation never reaches the model and
|
||||
the sentence around it is still one sentence.
|
||||
"""
|
||||
if not self.recording or self._repeated():
|
||||
if not self.recording or self._repeated("pause"):
|
||||
return
|
||||
self.paused = not self.paused
|
||||
if self.paused:
|
||||
@@ -635,6 +829,8 @@ class Dikte:
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self.recorder.cancel()
|
||||
# The preference dies with the run it was given for.
|
||||
self.paste_override.pop(ASK if asking else DICTATION, None)
|
||||
self.recorder_owner = None
|
||||
# What goes over the socket is read by a program as often as by a
|
||||
# person, so it stays in one language; only what a run itself said
|
||||
@@ -646,7 +842,9 @@ class Dikte:
|
||||
self._settle(ASK, dropped)
|
||||
else:
|
||||
self.overlay.dismiss()
|
||||
self._set_state(IDLE)
|
||||
# An earlier dictation may still be in the pipeline; only the
|
||||
# recording was thrown away.
|
||||
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
||||
self._settle(DICTATION, dropped)
|
||||
|
||||
def cancel_ask(self):
|
||||
@@ -691,6 +889,12 @@ class Dikte:
|
||||
return
|
||||
base = meeting.new_base()
|
||||
_, wav_path = cfg.meeting_paths(base)
|
||||
# A meeting opens the same capture as a dictation does, and takes the
|
||||
# front the same way: whoever is being recorded is in a call, and
|
||||
# having their window go inactive mid-sentence is worse here than
|
||||
# anywhere else. Kept as a local rather than on self: a dictation may
|
||||
# already be waiting on its own note for where to paste.
|
||||
was_in_front = self._the_front()
|
||||
self.meeting_recorder.start(
|
||||
str(wav_path),
|
||||
self.conf["meeting_mic_target"] or self.conf["mic_target"],
|
||||
@@ -699,6 +903,7 @@ class Dikte:
|
||||
)
|
||||
if not self.meeting_recorder.active:
|
||||
return # start() has already said what went wrong
|
||||
self._give_the_front_back(was_in_front)
|
||||
self.meeting_base = base
|
||||
self.meeting_elapsed.restart()
|
||||
self.meeting_ticker.start()
|
||||
@@ -825,31 +1030,56 @@ class Dikte:
|
||||
def _on_recorded(self, wav_path, duration, rms_values):
|
||||
owner, self.recorder_owner = self.recorder_owner, None
|
||||
wants_paste = self.paste_override.pop(owner, None)
|
||||
focus, self.front_before = self.front_before, None
|
||||
if owner == ASK:
|
||||
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
||||
paste=wants_paste)
|
||||
paste=wants_paste, focus=focus)
|
||||
else:
|
||||
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
|
||||
self._transcripts_pending += 1
|
||||
self.pipeline.run(wav_path, duration, rms_values,
|
||||
paste=wants_paste, focus=focus)
|
||||
|
||||
def _on_stage(self, message):
|
||||
# The corner belongs to the recording when one is on: the previous
|
||||
# run's progress must not wipe the waveform mid-sentence.
|
||||
if self.state != RECORDING:
|
||||
self.overlay.show_busy(message)
|
||||
|
||||
def _transcript_settled(self, payload):
|
||||
"""One run out of the pipeline; where dictation stands now.
|
||||
|
||||
A request that asked to wait is answered once the queue is empty: with
|
||||
runs finishing in the order they were spoken, the one it stopped is the
|
||||
last of them, and an earlier run's result would be the wrong answer.
|
||||
"""
|
||||
self._transcripts_pending -= 1
|
||||
if self.state != RECORDING:
|
||||
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
||||
if not self._transcripts_pending:
|
||||
self._settle(DICTATION, payload)
|
||||
|
||||
def _on_finished(self, _raw, text, warning):
|
||||
if warning:
|
||||
# The text was still pasted, but cleanup did not run. Say so loudly:
|
||||
# a rejected key otherwise looks exactly like working dictation.
|
||||
self.overlay.show_warning(
|
||||
t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0])
|
||||
)
|
||||
if self.state != RECORDING:
|
||||
self.overlay.show_warning(
|
||||
t("Pasted raw, cleanup failed: {error}",
|
||||
error=warning.splitlines()[0])
|
||||
)
|
||||
self.tray.showMessage(
|
||||
t("Dikte: cleanup failed"), warning,
|
||||
QSystemTrayIcon.MessageIcon.Warning, 10000,
|
||||
)
|
||||
else:
|
||||
elif self.state != RECORDING:
|
||||
# While a new recording is on, the flash is skipped: the text
|
||||
# arriving where the cursor is says everything it would have.
|
||||
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
|
||||
self.overlay.show_done(
|
||||
t("{action}: {preview}", action=action, preview=_preview(text))
|
||||
)
|
||||
self._set_state(IDLE)
|
||||
self._settle(DICTATION, {"ok": True, "text": text, "raw": _raw,
|
||||
"warning": warning})
|
||||
self._transcript_settled({"ok": True, "text": text, "raw": _raw,
|
||||
"warning": warning})
|
||||
|
||||
def _on_ask_finished(self, _raw, text, warning):
|
||||
agent = assistant.display_name(self.conf)
|
||||
@@ -882,12 +1112,43 @@ class Dikte:
|
||||
def _on_recorder_error(self, message):
|
||||
"""The microphone itself could not run, so it belongs to whoever asked."""
|
||||
owner, self.recorder_owner = self.recorder_owner, None
|
||||
self.paste_override.pop(owner, None)
|
||||
self.ticker.stop()
|
||||
(self._on_ask_error if owner == ASK else self._on_error)(message)
|
||||
|
||||
def _on_pipeline_failed(self, message):
|
||||
"""A run the pipeline gave up on; whatever queued behind it still runs."""
|
||||
if self.state == RECORDING:
|
||||
# The corner belongs to the new recording; the failure still has to
|
||||
# be seen somewhere.
|
||||
self.tray.showMessage("Dikte", message,
|
||||
QSystemTrayIcon.MessageIcon.Warning, 8000)
|
||||
else:
|
||||
self._report(message, self.overlay)
|
||||
self._transcript_settled({"ok": False, "error": message})
|
||||
|
||||
def _on_recorder_died(self):
|
||||
"""The capture quit under a live recording: keep what it caught.
|
||||
|
||||
Ended the way a key press would end it, so the captured half is
|
||||
transcribed rather than thrown away, and said out loud, because the
|
||||
user is still talking at a microphone nobody is reading.
|
||||
"""
|
||||
owner = self.recorder_owner
|
||||
self.tray.showMessage(
|
||||
"Dikte",
|
||||
t("The recording stopped on its own; transcribing what was captured."),
|
||||
QSystemTrayIcon.MessageIcon.Warning, 8000,
|
||||
)
|
||||
if owner == ASK and self.ask_state == RECORDING:
|
||||
self.stop_ask()
|
||||
elif self.state == RECORDING:
|
||||
self.stop()
|
||||
|
||||
def _on_error(self, message):
|
||||
"""The recorder or the key listener failed; no run reached the pipeline."""
|
||||
self._report(message, self.overlay)
|
||||
self._set_state(IDLE)
|
||||
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
||||
self._settle(DICTATION, {"ok": False, "error": message})
|
||||
|
||||
def _on_ask_error(self, message):
|
||||
@@ -901,21 +1162,106 @@ class Dikte:
|
||||
if len(message) > len(first_line):
|
||||
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
|
||||
|
||||
# ---- updates ----------------------------------------------------------
|
||||
|
||||
def _look_for_update(self):
|
||||
"""The timer. update.py decides whether this is a request or a memory."""
|
||||
if not self.conf["update_check"]:
|
||||
return
|
||||
self.updates.start()
|
||||
|
||||
def _on_update_checked(self, release, error):
|
||||
if error:
|
||||
# Nobody asked for this, so nobody is waiting to be told it failed.
|
||||
# A machine that is offline, or a GitHub that is rate-limiting the
|
||||
# address, is not a thing to interrupt a dictation about.
|
||||
print(f"dikte: update check: {error}", file=sys.stderr)
|
||||
return
|
||||
if release is None:
|
||||
return
|
||||
self._found_update(release)
|
||||
# Once per version. A check that runs every day must not be a
|
||||
# notification every day for an update somebody has decided to skip.
|
||||
if update.announced() != release.version:
|
||||
update.mark_announced(release.version)
|
||||
self.tray.showMessage(
|
||||
"Dikte",
|
||||
t("Dikte {version} is out. The tray menu has the release page.",
|
||||
version=release.version),
|
||||
QSystemTrayIcon.MessageIcon.Information, 8000,
|
||||
)
|
||||
|
||||
def _found_update(self, release):
|
||||
self.update_release = release
|
||||
self._refresh_update()
|
||||
|
||||
def _refresh_update(self):
|
||||
release = self.update_release
|
||||
self.update_action.setVisible(release is not None)
|
||||
if release is not None:
|
||||
self.update_action.setText(
|
||||
t("Dikte {version} is out…", version=release.version))
|
||||
|
||||
def open_release_page(self):
|
||||
release = self.update_release
|
||||
QDesktopServices.openUrl(
|
||||
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
||||
|
||||
# ---- settings ---------------------------------------------------------
|
||||
|
||||
def open_settings(self):
|
||||
if self.settings_window is None:
|
||||
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._make_settings()
|
||||
self.settings_window.show()
|
||||
self.settings_window.raise_()
|
||||
self.settings_window.activateWindow()
|
||||
|
||||
def _make_settings(self):
|
||||
"""Build the window without showing it, so a caller that knows where
|
||||
it belongs can place it first."""
|
||||
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||
self.settings_window.applied.connect(self._apply_settings)
|
||||
self.settings_window.language_changed.connect(self._reopen_settings)
|
||||
self.settings_window.update_found.connect(self._found_update)
|
||||
self.settings_window.finished.connect(self._settings_closed)
|
||||
|
||||
def _settings_closed(self, *_):
|
||||
# Don't drop the object while its own signal is still being delivered.
|
||||
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
|
||||
|
||||
def _reopen_settings(self):
|
||||
"""Replace the settings window, so a language change reaches it too.
|
||||
|
||||
A save switches the language everywhere strings are made at the moment
|
||||
they are shown: the tray is rebuilt, the indicator and the message box
|
||||
translate as they speak. The settings window is the one place written
|
||||
once, at construction, so the window that took the new language is the
|
||||
one place still showing the old one. A fresh window comes up where the
|
||||
old one stood, on the same tab.
|
||||
"""
|
||||
old = self.settings_window
|
||||
if old is None:
|
||||
return
|
||||
tab = old.tabs.currentIndex()
|
||||
geometry = old.geometry()
|
||||
# Replaced rather than merely closed: left connected, _settings_closed
|
||||
# would drop the reference to the new window a moment after it is made.
|
||||
old.finished.disconnect(self._settings_closed)
|
||||
old.close()
|
||||
# No deleteLater: a daemon thread of the old window's may still be
|
||||
# running, and a closure holding self is what keeps the object alive
|
||||
# until the thread is done. Dropping the reference is how the ordinary
|
||||
# close path lets a window go, and it is enough here too.
|
||||
self.settings_window = None
|
||||
self._make_settings()
|
||||
# Placed and turned to the old tab before it is shown, so the new
|
||||
# window does not come up at the default size and jump.
|
||||
self.settings_window.setGeometry(geometry)
|
||||
self.settings_window.tabs.setCurrentIndex(tab)
|
||||
self.settings_window.show()
|
||||
self.settings_window.raise_()
|
||||
self.settings_window.activateWindow()
|
||||
|
||||
def _apply_local(self):
|
||||
"""Pass the local settings on, and hold the models ready if asked to.
|
||||
|
||||
@@ -952,14 +1298,19 @@ class Dikte:
|
||||
|
||||
def _apply_settings(self):
|
||||
self.overlay.corner = self.conf["overlay_corner"]
|
||||
self.overlay.screen_name = self.conf["overlay_screen"]
|
||||
self.ask_overlay.corner = self.conf["overlay_corner"]
|
||||
self.ask_overlay.screen_name = self.conf["overlay_screen"]
|
||||
self._apply_local()
|
||||
self._build_tray()
|
||||
self._refresh_tray()
|
||||
# Taken once here for _external: the answer cannot change under a
|
||||
# running process, and re-deriving it there is a PATH scan per press.
|
||||
self._registry_shortcuts = hotkey.installs_shortcuts()
|
||||
# Where the desktop has no shortcut registry of its own, the listener is
|
||||
# not the fallback the setting offers to turn on: it is the only way the
|
||||
# keys arrive at all, so it runs whatever the setting says.
|
||||
if self.conf["evdev_hotkey"] or not hotkey.installs_shortcuts():
|
||||
if self.conf["evdev_hotkey"] or not self._registry_shortcuts:
|
||||
self.evdev.start({name: self.conf[spec.setting]
|
||||
for name, spec in hotkey.SHORTCUTS.items()})
|
||||
else:
|
||||
@@ -981,22 +1332,25 @@ class Dikte:
|
||||
if self.server is not None:
|
||||
self.server.close()
|
||||
QLocalServer.removeServer(SERVER_NAME)
|
||||
args = ipc.launcher() + ["--gui"]
|
||||
if sys.platform == "win32":
|
||||
# execv on Windows mangles arguments with spaces and leaves the two
|
||||
# processes sharing a console; a detached start does neither.
|
||||
subprocess.Popen(
|
||||
args,
|
||||
creationflags=(subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NEW_PROCESS_GROUP),
|
||||
close_fds=True,
|
||||
)
|
||||
QApplication.instance().quit()
|
||||
return
|
||||
os.execv(args[0], args)
|
||||
# The lock too, or the replacement would take this restart for a
|
||||
# double start and hand the attention back to a process on its way out.
|
||||
if self.instance_lock is not None:
|
||||
self.instance_lock.unlock()
|
||||
ipc.respawn(["--gui"])
|
||||
# respawn only returns on Windows, where the replacement was started
|
||||
# detached and this process still has to leave on its own.
|
||||
QApplication.instance().quit()
|
||||
|
||||
def shutdown(self):
|
||||
self._quitting = True
|
||||
# Waiters first, while the connections still work: a `--wait` left
|
||||
# unanswered reads to the terminal as an instance too old to answer,
|
||||
# which points the user at a version problem that does not exist.
|
||||
# In one language, like every other error that goes over the socket:
|
||||
# a script reads these as often as a person does.
|
||||
for kind in (DICTATION, ASK, MEETING):
|
||||
self._settle(kind, {"ok": False,
|
||||
"error": "the instance is shutting down"})
|
||||
self.evdev.stop()
|
||||
if self.recording:
|
||||
self.recorder.cancel()
|
||||
@@ -1119,9 +1473,44 @@ def _stay_out_of_the_dock():
|
||||
pass
|
||||
|
||||
|
||||
def _hand_over(command):
|
||||
"""Give the running instance the attention this start was asking for.
|
||||
|
||||
A start carrying a verb forwards only that verb; a bare double start asks
|
||||
for the Settings window as the sign of life the click was looking for.
|
||||
Retried for a moment, because the copy that won the lock may not be
|
||||
listening yet.
|
||||
"""
|
||||
verb = command or "settings"
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if ipc.send(verb) is not None:
|
||||
return
|
||||
time.sleep(0.2)
|
||||
print("dikte: another copy holds the lock but never answered")
|
||||
|
||||
|
||||
def run_app(args):
|
||||
command = args[0] if args else ""
|
||||
|
||||
# One Dikte per user. The lock closes the simultaneous-start window two
|
||||
# probes would both fall through; the probe still runs behind it, because
|
||||
# an instance from before the lock existed holds only the socket. Both
|
||||
# sit before the QApplication, so a second copy costs a moment and not a
|
||||
# second tray icon. The lock lives in this frame, which app.exec() below
|
||||
# keeps alive for exactly the process's lifetime.
|
||||
lock = ipc.instance_lock()
|
||||
if lock is not None and not lock.tryLock(0):
|
||||
_hand_over(command)
|
||||
return 0
|
||||
if ipc.already_serving():
|
||||
print("dikte: already running; handing it the attention")
|
||||
if command:
|
||||
ipc.send(command)
|
||||
else:
|
||||
ipc.send("settings")
|
||||
return 0
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Dikte")
|
||||
app.setDesktopFileName("dikte")
|
||||
@@ -1149,6 +1538,9 @@ def run_app(args):
|
||||
print("dikte: no system tray found, running anyway")
|
||||
|
||||
dikte = Dikte(app)
|
||||
# Handed over so that restart() can let go of it before the replacement
|
||||
# tries to take it.
|
||||
dikte.instance_lock = lock
|
||||
|
||||
server = QLocalServer()
|
||||
# Qt puts the socket in /tmp, so keep it to this user: commands like
|
||||
|
||||
+307
-71
@@ -1,22 +1,29 @@
|
||||
"""Handing a dictation to an agent as a command, and pasting back its answer.
|
||||
|
||||
Three of them, because not everyone has the same one installed:
|
||||
Five of them, because not everyone has the same one installed:
|
||||
|
||||
Claude Code `claude -p`, the session you would have opened yourself
|
||||
Codex `codex exec`, the same idea from the other shop
|
||||
Antigravity `agy -p`, Google's, with a browser of its own attached
|
||||
OpenRouter a plain chat request, over the key that is already configured
|
||||
OpenCode Go a plain chat request, over a subscription to open coding models
|
||||
|
||||
The first two are the whole machine: they run commands, read files, and reach
|
||||
The first three are the whole machine: they run commands, read files, and reach
|
||||
whatever skills and services you have connected, which is what makes "put that
|
||||
in my calendar on Thursday" a thing you can say. OpenRouter cannot touch any of
|
||||
that, and is there so that a question still gets an answer on a machine with
|
||||
neither CLI installed.
|
||||
in my calendar on Thursday" a thing you can say. The two chat requests cannot
|
||||
touch any of that, and are there so that a question still gets an answer on a
|
||||
machine with no CLI installed at all.
|
||||
|
||||
What each of the three is allowed to do without asking is settled where that
|
||||
program keeps its own permissions, not here. Dikte hands Claude Code the mode
|
||||
chosen in Settings because it has a flag for one; Codex gets a sandbox for the
|
||||
same reason; Antigravity has neither, and reads its own allow-rules instead.
|
||||
|
||||
Whichever it is, the reply is pasted exactly where the transcript would have
|
||||
been, and the conversation carries across dictations so that "and move that to
|
||||
Friday" knows what "that" is.
|
||||
|
||||
The two CLIs are read as they stream rather than waited out. A command that
|
||||
The three CLIs are read as they stream rather than waited out. A command that
|
||||
reaches for the calendar or the web takes long enough that a still indicator is
|
||||
indistinguishable from a hang, so every tool they pick up is named in the corner
|
||||
while they work.
|
||||
@@ -24,21 +31,30 @@ while they work.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import api
|
||||
from . import config as cfg
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
|
||||
PROVIDERS = ("claude", "codex", "openrouter")
|
||||
PROVIDERS = ("claude", "codex", "agy", "openrouter", "opencode")
|
||||
|
||||
# How many messages of an OpenRouter conversation are carried forward. The two
|
||||
# CLIs keep their own history and need no such number; here every turn is resent
|
||||
# in full, so the window has to end somewhere.
|
||||
# What each one is called where a person reads it: the tray, the corner of
|
||||
# the screen, and the line an error is written in.
|
||||
SERVICES = {"claude": "Claude", "codex": "Codex", "agy": "Antigravity",
|
||||
"openrouter": "OpenRouter", "opencode": "OpenCode Go"}
|
||||
|
||||
# How many messages of a chat provider's conversation are carried forward. The
|
||||
# two CLIs keep their own history and need no such number; here every turn is
|
||||
# resent in full, so the window has to end somewhere.
|
||||
MAX_HISTORY = 24
|
||||
|
||||
# What to say in the indicator for a tool, keyed by the name the CLI uses.
|
||||
@@ -66,6 +82,29 @@ CODEX_ITEMS = {
|
||||
"patch_apply": "Editing a file…",
|
||||
"todo_list": "Planning…",
|
||||
}
|
||||
# Antigravity carries a browser around with it, so the handful of names below
|
||||
# stand in for the couple of dozen browser_* tools it can pick up; being told
|
||||
# which mouse button moved is not what the corner of the screen is for.
|
||||
AGY_TOOLS = {
|
||||
"run_command": "Running a command…",
|
||||
"command_status": "Running a command…",
|
||||
"send_command_input": "Running a command…",
|
||||
"view_file": "Reading…",
|
||||
"read_url_content": "Reading a web page…",
|
||||
"list_dir": "Looking through files…",
|
||||
"find_by_name": "Looking through files…",
|
||||
"grep_search": "Searching the files…",
|
||||
"search_web": "Searching the web…",
|
||||
"replace_file_content": "Editing a file…",
|
||||
"multi_replace_file_content": "Editing a file…",
|
||||
"sed_file": "Editing a file…",
|
||||
"notebook_edit": "Editing a file…",
|
||||
"write_to_file": "Writing a file…",
|
||||
"generate_image": "Drawing…",
|
||||
"manage_task": "Planning…",
|
||||
"invoke_subagent": "Handing it to a subagent…",
|
||||
"browser_subagent": "Handing it to a subagent…",
|
||||
}
|
||||
|
||||
|
||||
# How hard to think, in each provider's own vocabulary. The setting is one
|
||||
@@ -81,6 +120,12 @@ CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
"medium": "medium", "high": "high", "xhigh": "high",
|
||||
"max": "high"}
|
||||
# agy has three rungs and no word for off, so the bottom of the ladder lands on
|
||||
# "low" and the top two on "high". Shared with cleanup, which runs the same
|
||||
# program for the smaller job.
|
||||
AGY_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
"medium": "medium", "high": "high", "xhigh": "high",
|
||||
"max": "high"}
|
||||
|
||||
|
||||
class AssistantError(Exception):
|
||||
@@ -98,12 +143,30 @@ def provider(conf):
|
||||
|
||||
def executable(name):
|
||||
"""The CLI a provider runs, or "" when it needs none."""
|
||||
return {"claude": "claude", "codex": "codex"}.get(name, "")
|
||||
return {"claude": "claude", "codex": "codex", "agy": "agy"}.get(name, "")
|
||||
|
||||
|
||||
def model(conf):
|
||||
"""Which model answered, for the history to record.
|
||||
|
||||
Each provider keeps its own setting, and the one a CLI is left on has no id
|
||||
to report, only a name — the same arrangement cleanup.model() makes.
|
||||
"""
|
||||
name = provider(conf)
|
||||
if name == "codex":
|
||||
return conf["assistant_codex_model"].strip() or "codex"
|
||||
if name == "agy":
|
||||
return conf["assistant_agy_model"].strip() or "agy"
|
||||
if name == "openrouter":
|
||||
return conf["assistant_openrouter_model"]
|
||||
if name == "opencode":
|
||||
return conf["assistant_opencode_model"]
|
||||
return conf["assistant_model"]
|
||||
|
||||
|
||||
def display_name(conf):
|
||||
"""What to call the thing being asked, in the tray and in the corner."""
|
||||
return {"claude": "Claude", "codex": "Codex"}.get(provider(conf), "OpenRouter")
|
||||
return SERVICES.get(provider(conf), "OpenRouter")
|
||||
|
||||
|
||||
# --- the conversation -----------------------------------------------------
|
||||
@@ -114,13 +177,19 @@ def display_name(conf):
|
||||
# one along costs tokens and invites an answer to the wrong question. Switching
|
||||
# provider drops it too, since none of them can pick up another's thread.
|
||||
|
||||
def _read_row(name, max_age_seconds):
|
||||
def _read_session():
|
||||
"""The stored conversation row, or {} however the file fails to read."""
|
||||
try:
|
||||
with open(SESSION_FILE, encoding="utf-8") as fh:
|
||||
row = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
if not isinstance(row, dict) or row.get("provider") != name:
|
||||
return row if isinstance(row, dict) else {}
|
||||
|
||||
|
||||
def _read_row(name, max_age_seconds):
|
||||
row = _read_session()
|
||||
if row.get("provider") != name:
|
||||
return {}
|
||||
if max_age_seconds and time.time() - row.get("ts", 0) > max_age_seconds:
|
||||
return {}
|
||||
@@ -159,22 +228,13 @@ def clear_session():
|
||||
|
||||
def stored_provider():
|
||||
"""Whose conversation is on disk, whatever the setting says now."""
|
||||
try:
|
||||
with open(SESSION_FILE, encoding="utf-8") as fh:
|
||||
row = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return ""
|
||||
return str(row.get("provider", "")) if isinstance(row, dict) else ""
|
||||
return str(_read_session().get("provider", ""))
|
||||
|
||||
|
||||
def session_age():
|
||||
"""Seconds since the stored conversation was last used, or None."""
|
||||
try:
|
||||
with open(SESSION_FILE, encoding="utf-8") as fh:
|
||||
row = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(row, dict) or not (row.get("session") or row.get("messages")):
|
||||
row = _read_session()
|
||||
if not (row.get("session") or row.get("messages")):
|
||||
return None
|
||||
return time.time() - row.get("ts", 0)
|
||||
|
||||
@@ -196,8 +256,8 @@ def ask(prompt, conf, on_stage=None, should_stop=None):
|
||||
one, and only the denial explains why it did not do what it was asked to.
|
||||
"""
|
||||
name = provider(conf)
|
||||
if name == "openrouter":
|
||||
return _ask_openrouter(prompt, conf, on_stage)
|
||||
if name in ("openrouter", "opencode"):
|
||||
return _ask_chat(name, SERVICES[name], prompt, conf, on_stage)
|
||||
|
||||
binary = executable(name)
|
||||
if not shutil.which(binary):
|
||||
@@ -206,7 +266,7 @@ def ask(prompt, conf, on_stage=None, should_stop=None):
|
||||
"Settings → Agent.", binary=binary,
|
||||
))
|
||||
|
||||
run = _ask_claude if name == "claude" else _ask_codex
|
||||
run = {"claude": _ask_claude, "codex": _ask_codex, "agy": _ask_agy}[name]
|
||||
session = read_session(name, conf["assistant_session_minutes"] * 60)
|
||||
try:
|
||||
return run(prompt, conf, session, on_stage, should_stop)
|
||||
@@ -258,7 +318,7 @@ def _ask_claude(prompt, conf, session, on_stage, should_stop):
|
||||
found["warning"] = _denial_warning(event)
|
||||
|
||||
code, stderr = _stream(cmd, conf, on_event, should_stop)
|
||||
return _conclude(found, code, stderr, session, "Claude")
|
||||
return _conclude(found, code, stderr, session, "claude")
|
||||
|
||||
|
||||
def _claude_label(block):
|
||||
@@ -326,7 +386,7 @@ def _ask_codex(prompt, conf, session, on_stage, should_stop):
|
||||
else str(error)) or t("Codex ended with an error.")
|
||||
|
||||
code, stderr = _stream(cmd, conf, on_event, should_stop)
|
||||
return _conclude(found, code, stderr, session, "Codex")
|
||||
return _conclude(found, code, stderr, session, "codex")
|
||||
|
||||
|
||||
def _codex_label(item):
|
||||
@@ -338,29 +398,152 @@ def _codex_label(item):
|
||||
return t("Using {name}…", name=item_type or "a tool")
|
||||
|
||||
|
||||
# --- OpenRouter -----------------------------------------------------------
|
||||
def codex_models():
|
||||
"""The models Codex itself would offer right now, best first.
|
||||
|
||||
def _ask_openrouter(prompt, conf, on_stage):
|
||||
"""No tools, no files, no calendar: a question and an answer.
|
||||
`codex debug models` prints the catalog the CLI's own model picker reads,
|
||||
fetched from OpenAI and cached beside Codex's config, so the list is as
|
||||
current as the installed Codex and there is no second list to keep up to
|
||||
date here. Entries the picker hides are internal and stay hidden. A machine
|
||||
without Codex, or one too old to have the command, answers with nothing and
|
||||
the caller keeps its built-in list.
|
||||
"""
|
||||
if not shutil.which("codex"):
|
||||
return []
|
||||
try:
|
||||
proc = subprocess.run(["codex", "debug", "models"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
catalog = json.loads(proc.stdout or "null")
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
return []
|
||||
if not isinstance(catalog, dict):
|
||||
return []
|
||||
rows = [row for row in catalog.get("models") or []
|
||||
if isinstance(row, dict) and row.get("slug")
|
||||
and row.get("visibility") != "hide"]
|
||||
rows.sort(key=lambda row: row.get("priority") or 0)
|
||||
return [row["slug"] for row in rows]
|
||||
|
||||
It is the fallback for a machine with neither CLI on it, so it says what it
|
||||
knows and nothing else. The conversation is ours to keep here, since there
|
||||
is no session on the other end to resume.
|
||||
|
||||
# --- Antigravity ----------------------------------------------------------
|
||||
|
||||
def _ask_agy(prompt, conf, session, on_stage, should_stop):
|
||||
# Antigravity takes no system prompt of its own either, so the instruction
|
||||
# rides in front of the command, kept apart from it so the two are not read
|
||||
# as one.
|
||||
body = f"{conf.assistant_prompt()}\n\n---\n\n{prompt}"
|
||||
cmd = [
|
||||
"agy", "-p", body,
|
||||
"--output-format", "stream-json",
|
||||
# agy stops after five minutes unless it is told otherwise, which is
|
||||
# shorter than the timeout this setting offers.
|
||||
"--print-timeout", f"{conf['assistant_timeout']}s",
|
||||
]
|
||||
# One or the other, always: left with neither, agy picks up whichever
|
||||
# project it was last in and works in that project's directory rather than
|
||||
# the one _stream is about to start it in.
|
||||
cmd += ["--conversation", session] if session else ["--new-project"]
|
||||
if conf["assistant_agy_model"].strip():
|
||||
cmd += ["--model", conf["assistant_agy_model"].strip()]
|
||||
effort = AGY_EFFORT.get(conf["assistant_reasoning"], "")
|
||||
if effort:
|
||||
# Most of agy's own model ids carry the effort in their suffix already;
|
||||
# this is for the ones that do not.
|
||||
cmd += ["--effort", effort]
|
||||
|
||||
found = {"answer": "", "warning": "", "session": "", "failure": ""}
|
||||
|
||||
def on_event(event):
|
||||
kind = event.get("event")
|
||||
if kind == "init":
|
||||
found["session"] = event.get("conversation_id") or found["session"]
|
||||
elif kind == "step_update":
|
||||
step = event.get("step_update") or {}
|
||||
# A tool is reported twice, once when it starts and once when it is
|
||||
# done; the corner wants the first of those.
|
||||
if (on_stage and step.get("step_type") == "tool"
|
||||
and step.get("state") == "ACTIVE"):
|
||||
on_stage(_agy_label(step))
|
||||
elif kind == "result":
|
||||
result = event.get("result") or {}
|
||||
found["session"] = result.get("conversation_id") or found["session"]
|
||||
answer = (result.get("response") or "").strip()
|
||||
if result.get("status") == "SUCCESS":
|
||||
found["answer"] = answer
|
||||
else:
|
||||
found["failure"] = answer or t("{service} ended with an error.",
|
||||
service="Antigravity")
|
||||
|
||||
code, stderr = _stream(cmd, conf, on_event, should_stop)
|
||||
return _conclude(found, code, stderr, session, "agy")
|
||||
|
||||
|
||||
def _agy_label(step):
|
||||
name = step.get("tool_name", "")
|
||||
if name in AGY_TOOLS:
|
||||
return t(AGY_TOOLS[name])
|
||||
if name.startswith("browser_") or name.startswith("capture_browser"):
|
||||
return t("Working in the browser…")
|
||||
if name == "call_mcp_tool":
|
||||
server = (step.get("tool_info") or {}).get("parameters") or {}
|
||||
return t("Using {name}…", name=server.get("server") or "a tool")
|
||||
return t("Using {name}…", name=name or "a tool")
|
||||
|
||||
|
||||
def agy_models():
|
||||
"""The models Antigravity itself would offer right now, in its own order.
|
||||
|
||||
`agy models` prints one `id<TAB>display name` line per model, so the list
|
||||
is as current as the account behind the CLI. Unlike Codex it asks Google
|
||||
rather than a cache on disk, a couple of seconds the caller spends off the
|
||||
interface thread. A machine without agy, or a call that fails, answers
|
||||
with nothing and the caller keeps its built-in list.
|
||||
"""
|
||||
if not shutil.which("agy"):
|
||||
return []
|
||||
try:
|
||||
proc = subprocess.run(["agy", "models"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
ids = []
|
||||
for line in (proc.stdout or "").splitlines():
|
||||
model_id, tab, _ = line.partition("\t")
|
||||
if tab and model_id.strip():
|
||||
ids.append(model_id.strip())
|
||||
return ids
|
||||
|
||||
|
||||
# --- OpenRouter and OpenCode Go -------------------------------------------
|
||||
|
||||
def _ask_chat(name, service, prompt, conf, on_stage):
|
||||
"""A plain question and answer, over a chat provider's key.
|
||||
|
||||
No tools, no files, no calendar. It is the fallback for a machine with
|
||||
neither CLI on it, so it says what it knows and nothing else. The
|
||||
conversation is ours to keep here, since there is no session on the other
|
||||
end to resume.
|
||||
"""
|
||||
if on_stage:
|
||||
on_stage(t("Thinking…"))
|
||||
history = read_messages("openrouter", conf["assistant_session_minutes"] * 60)
|
||||
history = read_messages(name, conf["assistant_session_minutes"] * 60)
|
||||
messages = history + [{"role": "user", "content": prompt}]
|
||||
model = (conf["assistant_openrouter_model"] if name == "openrouter"
|
||||
else conf["assistant_opencode_model"])
|
||||
base_url = (conf["openrouter_base_url"] if name == "openrouter"
|
||||
else conf["opencode_base_url"])
|
||||
key = conf.openrouter_key() if name == "openrouter" else conf.opencode_key()
|
||||
try:
|
||||
answer = api.chat(
|
||||
messages, conf.openrouter_key(), conf["assistant_openrouter_model"],
|
||||
conf.assistant_prompt(), reasoning=conf["assistant_reasoning"],
|
||||
base_url=conf["openrouter_base_url"],
|
||||
timeout=conf["assistant_timeout"],
|
||||
messages, key, model, conf.assistant_prompt(),
|
||||
reasoning=conf["assistant_reasoning"], base_url=base_url,
|
||||
timeout=conf["assistant_timeout"], provider=name, service=service,
|
||||
)
|
||||
except api.ApiError as exc:
|
||||
raise AssistantError(str(exc)) from exc
|
||||
write_session("openrouter",
|
||||
write_session(name,
|
||||
messages=messages + [{"role": "assistant", "content": answer}])
|
||||
return answer, ""
|
||||
|
||||
@@ -373,14 +556,24 @@ def _stream(cmd, conf, on_event, should_stop):
|
||||
Returns (exit code, stderr). Raises Cancelled when the stop was asked for,
|
||||
and AssistantError when the clock ran out.
|
||||
"""
|
||||
# stderr lands in a file rather than a pipe: nobody drains it while stdout
|
||||
# is being read, and a CLI chatty enough on stderr would fill the pipe's
|
||||
# buffer and wedge both of us. A file has no such limit, and is read once
|
||||
# at the end, which is the only moment stderr matters.
|
||||
stderr_file = tempfile.TemporaryFile()
|
||||
# On POSIX the run gets its own session, so that ending it can take down
|
||||
# every subprocess it started, not just the CLI itself.
|
||||
grouped = {"start_new_session": True} if os.name == "posix" else {}
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=stderr_file,
|
||||
text=True, encoding="utf-8", errors="replace", bufsize=1,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
creationflags=paths.NO_WINDOW,
|
||||
**grouped,
|
||||
)
|
||||
except OSError as exc:
|
||||
stderr_file.close()
|
||||
raise AssistantError(t("Could not run {binary}: {error}",
|
||||
binary=cmd[0], error=exc)) from exc
|
||||
|
||||
@@ -409,7 +602,7 @@ def _stream(cmd, conf, on_event, should_stop):
|
||||
if isinstance(event, dict):
|
||||
on_event(event)
|
||||
finally:
|
||||
stderr = _finish(proc)
|
||||
stderr = _finish(proc, stderr_file)
|
||||
watchdog.join(timeout=1)
|
||||
|
||||
if ended["cancelled"]:
|
||||
@@ -420,30 +613,42 @@ def _stream(cmd, conf, on_event, should_stop):
|
||||
return proc.returncode, stderr
|
||||
|
||||
|
||||
def _conclude(found, code, stderr, session, service):
|
||||
# Failures that a fresh session cannot cure: an exhausted quota, a signed-out
|
||||
# CLI, a network that is down. A resumed run that dies with one of these is
|
||||
# reported as what it is, not retried without the session, because the retry
|
||||
# would fail the same way after making the user wait through a second run.
|
||||
_API_TROUBLE = re.compile(
|
||||
r"(?i)rate.?limit|quota|overloaded|too many requests|credit|billing|"
|
||||
r"insufficient|unauthorized|forbidden|authentication|invalid.{0,8}key|"
|
||||
r"log ?in|logged.?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|"
|
||||
r"\b(401|403|429|5\d\d)\b")
|
||||
|
||||
|
||||
def _conclude(found, code, stderr, session, name):
|
||||
"""Turn what the stream said into an answer, or into the reason there is none."""
|
||||
service = SERVICES.get(name, name)
|
||||
if code != 0 and not found["answer"]:
|
||||
if session and _session_missing(stderr):
|
||||
# A resumed run that died with nothing to show is treated as the
|
||||
# session being gone, whatever the wording: this code used to look for
|
||||
# "session ... not found" in stderr, but a CLI update or another
|
||||
# language rewords that and the recovery stops working. Retrying costs
|
||||
# one clean start, and cannot loop because the retry resumes nothing.
|
||||
# Recognised API trouble is the exception: it is not the session's
|
||||
# fault, and the retry would only repeat it.
|
||||
blame = last_line(stderr) or found["failure"] or ""
|
||||
if session and not _API_TROUBLE.search(blame):
|
||||
raise _SessionGone()
|
||||
raise AssistantError(last_line(stderr) or found["failure"] or t(
|
||||
raise AssistantError(blame or t(
|
||||
"{service} exited with code {code}.", service=service, code=code))
|
||||
if found["failure"] and not found["answer"]:
|
||||
raise AssistantError(found["failure"])
|
||||
if not found["answer"]:
|
||||
raise AssistantError(t("{service} answered with nothing.", service=service))
|
||||
if found["session"]:
|
||||
write_session("claude" if service == "Claude" else "codex", found["session"])
|
||||
write_session(name, found["session"])
|
||||
return found["answer"], found["warning"]
|
||||
|
||||
|
||||
def _session_missing(stderr):
|
||||
lowered = (stderr or "").lower()
|
||||
if "session" in lowered or "thread" in lowered or "conversation" in lowered:
|
||||
return any(word in lowered for word in ("not found", "no such", "unknown",
|
||||
"does not exist", "no conversation"))
|
||||
return False
|
||||
|
||||
|
||||
def _watch(proc, deadline, should_stop, ended):
|
||||
while proc.poll() is None:
|
||||
if should_stop is not None and should_stop():
|
||||
@@ -454,29 +659,60 @@ def _watch(proc, deadline, should_stop, ended):
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if ended["cancelled"] or ended["timed_out"]:
|
||||
_kill(proc)
|
||||
kill_tree(proc)
|
||||
|
||||
|
||||
def _kill(proc):
|
||||
def kill_tree(proc):
|
||||
"""End the process and everything it started.
|
||||
|
||||
A CLI runs tools as subprocesses of its own, and ending only the CLI would
|
||||
leave those behind, still working on a question nobody is waiting for.
|
||||
Shared with cleanup, which runs the same two programs. Every failure here
|
||||
is swallowed: the process being already gone is the outcome being asked for.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
# There is no process group to signal on Windows; taskkill walks the
|
||||
# tree instead. The wait after it is best-effort, so a tree that will
|
||||
# not die does not hang the caller on top of everything else.
|
||||
subprocess.run(
|
||||
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
||||
capture_output=True,
|
||||
creationflags=paths.NO_WINDOW,
|
||||
)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return
|
||||
# The Popen was started with start_new_session=True, so the pid names a
|
||||
# whole session to signal. SIGTERM first for a clean exit, SIGKILL for a
|
||||
# tree that ignored it.
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _finish(proc):
|
||||
try:
|
||||
stderr = proc.stderr.read() or ""
|
||||
except (OSError, ValueError):
|
||||
stderr = ""
|
||||
def _finish(proc, stderr_file):
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
for stream in (proc.stdout, proc.stderr):
|
||||
kill_tree(proc)
|
||||
# Read back what the CLI wrote to its stderr file, decoded leniently: a
|
||||
# dying CLI is exactly the one likely to print something half-encoded.
|
||||
try:
|
||||
stderr_file.seek(0)
|
||||
stderr = stderr_file.read().decode("utf-8", "replace")
|
||||
except (OSError, ValueError):
|
||||
stderr = ""
|
||||
for stream in (proc.stdout, stderr_file):
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
|
||||
+157
-50
@@ -31,11 +31,21 @@ import wave
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
# Console programs started from a windowless process would otherwise each open
|
||||
# a console window of their own on Windows.
|
||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
|
||||
# Squaring a chunk sample by sample in Python is the most expensive thing the
|
||||
# level meter does, and it does it for every chunk of every recording. sumprod
|
||||
# stays in C for the whole sum; it arrived in 3.12 and the floor here is 3.11,
|
||||
# so the plain loop remains as the fallback. Both produce the same integer.
|
||||
try:
|
||||
from math import sumprod
|
||||
except ImportError:
|
||||
sumprod = None
|
||||
|
||||
# See paths.NO_WINDOW; re-exported here because this module's callers and
|
||||
# tests have always read it under this name.
|
||||
NO_WINDOW = paths.NO_WINDOW
|
||||
|
||||
RATE = 16000
|
||||
CHANNELS = 1
|
||||
@@ -79,12 +89,15 @@ class Recorder(QObject):
|
||||
|
||||
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
||||
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
||||
died = pyqtSignal() # the capture quit mid-recording
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._proc = None
|
||||
self._thread = None
|
||||
self._log = None
|
||||
self._run = None
|
||||
self._buffer = bytearray()
|
||||
self._rms = []
|
||||
self._cancelled = False
|
||||
@@ -126,12 +139,17 @@ class Recorder(QObject):
|
||||
self.failed.emit(t(sound().missing))
|
||||
return
|
||||
|
||||
# The recorder keeps talking to stderr for as long as it runs; a pipe
|
||||
# nobody drains would eventually block it, so it writes to a file.
|
||||
self._drop_log()
|
||||
self._log = tempfile.TemporaryFile()
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0,
|
||||
cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0,
|
||||
creationflags=NO_WINDOW,
|
||||
)
|
||||
except OSError as exc:
|
||||
self._drop_log()
|
||||
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
||||
return
|
||||
|
||||
@@ -141,15 +159,21 @@ class Recorder(QObject):
|
||||
self._stopping = False
|
||||
self._paused = False
|
||||
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
||||
self._thread = threading.Thread(target=self._pump, daemon=True)
|
||||
# The pump is handed this run's objects rather than reading them off
|
||||
# self, and a token to say whose run it still is: a pump that outlives
|
||||
# its 2 s join must not touch the recording that comes after it.
|
||||
self._run = object()
|
||||
self._thread = threading.Thread(
|
||||
target=self._pump, daemon=True,
|
||||
args=(self._run, self._proc, self._proc.stdout,
|
||||
self._buffer, self._rms, self._max_bytes),
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _pump(self):
|
||||
proc = self._proc
|
||||
stdout = proc.stdout
|
||||
def _pump(self, run, proc, stdout, buffer, rms, max_bytes):
|
||||
try:
|
||||
while True:
|
||||
chunk = stdout.read(CHUNK_BYTES)
|
||||
chunk = _read_exact(stdout, CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
if self._paused:
|
||||
@@ -157,33 +181,58 @@ class Recorder(QObject):
|
||||
# nobody empties fills up, and the capture program blocks on
|
||||
# a full one instead of waiting quietly for the resume.
|
||||
continue
|
||||
peak, rms = chunk_levels(chunk)
|
||||
peak, chunk_rms = chunk_levels(chunk)
|
||||
with self._lock:
|
||||
self._buffer.extend(chunk)
|
||||
self._rms.append(rms)
|
||||
too_long = len(self._buffer) >= self._max_bytes
|
||||
buffer.extend(chunk)
|
||||
rms.append(chunk_rms)
|
||||
too_long = len(buffer) >= max_bytes
|
||||
if self._run is not run:
|
||||
# This recording was given up on; whatever happens now
|
||||
# belongs to the run that replaced it, not to this one.
|
||||
return
|
||||
self.level.emit(peak)
|
||||
if too_long:
|
||||
self._terminate()
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if self._run is not run:
|
||||
return
|
||||
if self._stopping or self._cancelled:
|
||||
return
|
||||
with self._lock:
|
||||
captured = bool(buffer)
|
||||
if captured:
|
||||
# Sound had already arrived and nobody asked it to end: the device
|
||||
# went away, or the recorder fell over mid-dictation. That has to
|
||||
# be said while there is still something worth keeping.
|
||||
self.died.emit()
|
||||
return
|
||||
# Nobody asked it to end and it captured nothing: the recorder is not
|
||||
# installed properly, or the device was refused. Said out loud here,
|
||||
# because stop() would otherwise report it as a recording that was too
|
||||
# short, which sends the user looking in the wrong place.
|
||||
with self._lock:
|
||||
captured = bool(self._buffer)
|
||||
if self._stopping or self._cancelled or captured:
|
||||
return
|
||||
try:
|
||||
detail = proc.stderr.read().decode("utf-8", "replace").strip()
|
||||
except (AttributeError, OSError):
|
||||
detail = ""
|
||||
self.failed.emit(t(
|
||||
"Audio recorder stopped before receiving sound: {error}",
|
||||
error=detail or f"exit code {proc.returncode}",
|
||||
))
|
||||
detail = self._error_tail()
|
||||
# poll() first, because returncode stays None until somebody reaps the
|
||||
# process, and "exit code None" answers nothing.
|
||||
code = proc.poll()
|
||||
if not detail and code is not None:
|
||||
detail = f"exit code {code}"
|
||||
if detail:
|
||||
self.failed.emit(t(
|
||||
"Audio recorder stopped before receiving sound: {error}",
|
||||
error=detail,
|
||||
))
|
||||
else:
|
||||
self.failed.emit(t("Audio recorder stopped before receiving sound"))
|
||||
|
||||
def _error_tail(self):
|
||||
log = self._log
|
||||
return _last_log_line(log) if log is not None else ""
|
||||
|
||||
def _drop_log(self):
|
||||
log, self._log = self._log, None
|
||||
_close_log(log)
|
||||
|
||||
def _terminate(self):
|
||||
self._stopping = True
|
||||
@@ -195,7 +244,10 @@ class Recorder(QObject):
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
# Reaped even after a kill, or the child stays a zombie
|
||||
# holding its slot in the process table.
|
||||
proc.wait(timeout=1)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
@@ -205,6 +257,8 @@ class Recorder(QObject):
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
self._proc = None
|
||||
self._run = None
|
||||
self._drop_log()
|
||||
with self._lock:
|
||||
self._buffer = bytearray()
|
||||
|
||||
@@ -217,7 +271,11 @@ class Recorder(QObject):
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
self._proc = None
|
||||
self._run = None
|
||||
self._drop_log()
|
||||
|
||||
# The same buffer object the pump was handed, harvested under the same
|
||||
# lock it appends with.
|
||||
with self._lock:
|
||||
pcm = bytes(self._buffer)
|
||||
rms = list(self._rms)
|
||||
@@ -231,7 +289,13 @@ class Recorder(QObject):
|
||||
self.failed.emit(t("Recording too short, speak for at least 0.3 s"))
|
||||
return
|
||||
|
||||
path = write_wav(pcm)
|
||||
try:
|
||||
path = write_wav(pcm)
|
||||
except (OSError, wave.Error) as exc:
|
||||
# A full disk or an unwritable temp directory costs this recording
|
||||
# either way; a message beats a traceback in the journal.
|
||||
self.failed.emit(t("Could not write the recording: {error}", error=exc))
|
||||
return
|
||||
self.stopped.emit(path, frames / RATE, rms)
|
||||
|
||||
|
||||
@@ -284,6 +348,7 @@ class MeetingRecorder(QObject):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._procs = []
|
||||
self._interrupted = set()
|
||||
self._thread = None
|
||||
self._wav = None
|
||||
self._logs = []
|
||||
@@ -336,6 +401,7 @@ class MeetingRecorder(QObject):
|
||||
# nobody drains would eventually block it, so it writes to a file.
|
||||
self._logs = [tempfile.TemporaryFile() for _ in commands]
|
||||
self._procs = []
|
||||
self._interrupted = set()
|
||||
for command, log in zip(commands, self._logs):
|
||||
self._procs.append(subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
|
||||
@@ -440,14 +506,20 @@ class MeetingRecorder(QObject):
|
||||
try:
|
||||
_interrupt(proc)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
# ffmpeg reports being interrupted as a failure; stop() needs to
|
||||
# know which exits were our own doing and which were real deaths.
|
||||
self._interrupted.add(proc)
|
||||
for proc in running:
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
# Reaped even after a kill, or the child stays a zombie
|
||||
# holding its slot in the process table.
|
||||
proc.wait(timeout=1)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
|
||||
def _close_file(self):
|
||||
@@ -460,24 +532,23 @@ class MeetingRecorder(QObject):
|
||||
pass
|
||||
|
||||
def _error_tail(self):
|
||||
tails = []
|
||||
for log in self._logs:
|
||||
try:
|
||||
log.seek(0)
|
||||
text = log.read().decode("utf-8", "replace").strip()
|
||||
except OSError:
|
||||
continue
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
if lines:
|
||||
tails.append(lines[-1])
|
||||
return " | ".join(tails)
|
||||
tails = [_last_log_line(log) for log in self._logs]
|
||||
return " | ".join(tail for tail in tails if tail)
|
||||
|
||||
def _finish_process(self):
|
||||
self._terminate()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3)
|
||||
self._thread = None
|
||||
codes = [proc.poll() for proc in self._procs]
|
||||
codes = []
|
||||
for proc in self._procs:
|
||||
code = proc.poll()
|
||||
# A nonzero exit from a process we interrupted ourselves is ffmpeg
|
||||
# complaining about our own stop; one that had already died on its
|
||||
# own keeps its code, because that one is the story.
|
||||
if code and proc in self._interrupted:
|
||||
code = 0
|
||||
codes.append(code)
|
||||
code = next((value for value in codes if value), 0)
|
||||
self._procs = []
|
||||
self._close_file()
|
||||
@@ -534,13 +605,30 @@ class MeetingRecorder(QObject):
|
||||
|
||||
def _drop_log(self):
|
||||
for log in self._logs:
|
||||
try:
|
||||
log.close()
|
||||
except OSError:
|
||||
pass
|
||||
_close_log(log)
|
||||
self._logs = []
|
||||
|
||||
|
||||
def _last_log_line(log):
|
||||
"""The last thing a recorder said before it ended, or ''."""
|
||||
try:
|
||||
log.seek(0)
|
||||
text = log.read().decode("utf-8", "replace").strip()
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
return lines[-1] if lines else ""
|
||||
|
||||
|
||||
def _close_log(log):
|
||||
if log is None:
|
||||
return
|
||||
try:
|
||||
log.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def chunk_levels(chunk):
|
||||
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
|
||||
samples = array.array("h")
|
||||
@@ -549,7 +637,9 @@ def chunk_levels(chunk):
|
||||
return 0.0, 0.0
|
||||
samples.frombytes(chunk[:usable])
|
||||
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
|
||||
rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32768.0
|
||||
power = (sumprod(samples, samples) if sumprod is not None
|
||||
else sum(s * s for s in samples))
|
||||
rms = math.sqrt(power / len(samples)) / 32768.0
|
||||
return min(1.0, peak), min(1.0, rms)
|
||||
|
||||
|
||||
@@ -638,6 +728,13 @@ MERGE_FILTER = (
|
||||
)
|
||||
|
||||
|
||||
# Whether pw-record takes --raw, asked of the binary once per process: the
|
||||
# probe costs a subprocess, and the answer cannot change under a running
|
||||
# application. Kept here rather than inside _pw_record_raw_option so the probe
|
||||
# itself stays testable against different binaries.
|
||||
_PW_RAW = None
|
||||
|
||||
|
||||
def _pulse_record(target):
|
||||
"""parec, or pw-record where PulseAudio's tools were left out.
|
||||
|
||||
@@ -645,6 +742,7 @@ def _pulse_record(target):
|
||||
service, and its source names are the same ones shown by list_sources().
|
||||
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||
"""
|
||||
global _PW_RAW
|
||||
if shutil.which("parec"):
|
||||
cmd = [
|
||||
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||
@@ -660,8 +758,10 @@ def _pulse_record(target):
|
||||
cmd.append(f"--device={target}")
|
||||
return cmd
|
||||
if shutil.which("pw-record"):
|
||||
if _PW_RAW is None:
|
||||
_PW_RAW = _pw_record_raw_option()
|
||||
cmd = [
|
||||
"pw-record", *_pw_record_raw_option(), f"--rate={RATE}",
|
||||
"pw-record", *_PW_RAW, f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16",
|
||||
]
|
||||
if target:
|
||||
@@ -682,8 +782,11 @@ def _pw_record_raw_option():
|
||||
that line, so ask the installed binary which form it understands.
|
||||
"""
|
||||
try:
|
||||
# utf-8 spelled out: subprocess otherwise decodes with the locale's
|
||||
# codec, and help text through a codec it was not written in raises.
|
||||
result = subprocess.run(
|
||||
["pw-record", "--help"], capture_output=True, text=True, timeout=2
|
||||
["pw-record", "--help"], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=2,
|
||||
)
|
||||
help_text = (result.stdout or "") + (result.stderr or "")
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
@@ -708,9 +811,12 @@ def _pactl_sources():
|
||||
if not shutil.which("pactl"):
|
||||
return []
|
||||
try:
|
||||
# utf-8 spelled out: device descriptions carry whatever alphabet the
|
||||
# machine speaks, and the locale's codec is not always able to say so.
|
||||
out = subprocess.run(
|
||||
["pactl", "-f", "json", "list", "sources"],
|
||||
capture_output=True, text=True, timeout=5, check=True,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=5, check=True,
|
||||
).stdout
|
||||
return json.loads(out)
|
||||
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
|
||||
@@ -739,7 +845,8 @@ def _pulse_default_output():
|
||||
try:
|
||||
sink = subprocess.run(
|
||||
["pactl", "get-default-sink"],
|
||||
capture_output=True, text=True, timeout=5, check=True,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=5, check=True,
|
||||
).stdout.strip()
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return ""
|
||||
|
||||
+106
-23
@@ -1,7 +1,8 @@
|
||||
"""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
|
||||
Normally a small model over one HTTP request: a second, and a few tenths of a
|
||||
cent on OpenRouter or nothing at all on Google AI Studio's free tier. A machine
|
||||
with Claude Code, Codex or Antigravity 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
|
||||
@@ -10,7 +11,11 @@ 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.
|
||||
the agent can reach while it reads one, the better. Claude Code is handed an
|
||||
empty tool list and Codex a read-only sandbox. Antigravity has neither switch,
|
||||
and this is worth saying plainly rather than implying parity: there the
|
||||
transcript is read by an agent that could go and do something. What can be done
|
||||
is done — a project of its own, the home directory, and its slash commands off.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -21,9 +26,10 @@ import tempfile
|
||||
from . import api
|
||||
from . import assistant
|
||||
from . import ggml
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
PROVIDERS = ("openrouter", "local", "claude", "codex")
|
||||
PROVIDERS = ("openrouter", "gemini", "opencode", "local", "claude", "codex", "agy")
|
||||
|
||||
|
||||
class CleanupError(api.ApiError):
|
||||
@@ -42,7 +48,7 @@ def provider(conf):
|
||||
|
||||
def executable(name):
|
||||
"""The CLI a provider runs, or "" when it needs none."""
|
||||
return {"claude": "claude", "codex": "codex"}.get(name, "")
|
||||
return {"claude": "claude", "codex": "codex", "agy": "agy"}.get(name, "")
|
||||
|
||||
|
||||
def model(conf):
|
||||
@@ -56,13 +62,20 @@ def model(conf):
|
||||
# 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"
|
||||
if name == "agy":
|
||||
# The same arrangement as Codex, and the same reason for it.
|
||||
return conf["cleanup_agy_model"].strip() or "agy"
|
||||
if name == "gemini":
|
||||
return conf["cleanup_gemini_model"]
|
||||
if name == "opencode":
|
||||
return conf["cleanup_opencode_model"]
|
||||
return conf["cleanup_model"]
|
||||
|
||||
|
||||
def run(text, conf, system_prompt, timeout=180, aborter=None):
|
||||
"""Hand the transcript to whoever is set to clean it up.
|
||||
|
||||
`aborter` is only of use to the two that answer over HTTP; a CLI is stopped
|
||||
`aborter` is only of use to the three that answer over HTTP; a CLI is stopped
|
||||
between blocks instead, which is close enough when a block is seconds.
|
||||
"""
|
||||
name = provider(conf)
|
||||
@@ -73,9 +86,23 @@ def run(text, conf, system_prompt, timeout=180, aborter=None):
|
||||
base_url=conf["openrouter_base_url"], timeout=timeout,
|
||||
aborter=aborter,
|
||||
)
|
||||
if name == "gemini":
|
||||
return api.cleanup(
|
||||
text, conf.gemini_key(), conf["cleanup_gemini_model"], system_prompt,
|
||||
reasoning=conf["cleanup_reasoning"],
|
||||
base_url=conf["gemini_base_url"], timeout=timeout,
|
||||
provider="gemini", service="Google AI Studio", aborter=aborter,
|
||||
)
|
||||
if name == "opencode":
|
||||
return api.cleanup(
|
||||
text, conf.opencode_key(), conf["cleanup_opencode_model"], system_prompt,
|
||||
reasoning=conf["cleanup_reasoning"],
|
||||
base_url=conf["opencode_base_url"], timeout=timeout,
|
||||
provider="opencode", service="OpenCode Go", aborter=aborter,
|
||||
)
|
||||
if name == "local":
|
||||
return _local(text, conf, system_prompt, timeout, aborter)
|
||||
runner = _claude if name == "claude" else _codex
|
||||
runner = {"claude": _claude, "codex": _codex, "agy": _agy}[name]
|
||||
return runner(text, conf, system_prompt, timeout)
|
||||
|
||||
|
||||
@@ -171,6 +198,40 @@ def _codex(text, conf, system_prompt, timeout):
|
||||
return answer
|
||||
|
||||
|
||||
# --- Antigravity ----------------------------------------------------------
|
||||
|
||||
def _agy(text, conf, system_prompt, timeout):
|
||||
# Antigravity takes no system prompt of its own either, 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 = [
|
||||
"agy", "-p", body,
|
||||
"--output-format", "text", # the answer, and nothing around it
|
||||
# Left to itself agy picks up whichever project it was last in and works
|
||||
# in that project's directory rather than this one. A dictation belongs
|
||||
# to no project, so each one starts on a project of its own.
|
||||
"--new-project",
|
||||
# A transcript that happens to begin with a slash is still a transcript.
|
||||
"--disable-slash-commands",
|
||||
# agy gives up after five minutes of its own accord, which would have it
|
||||
# killed from outside rather than answering.
|
||||
"--print-timeout", f"{timeout}s",
|
||||
]
|
||||
if conf["cleanup_agy_model"].strip():
|
||||
cmd += ["--model", conf["cleanup_agy_model"].strip()]
|
||||
effort = assistant.AGY_EFFORT.get(conf["cleanup_reasoning"], "")
|
||||
if effort:
|
||||
# agy's own model ids carry the effort in their suffix, so this only
|
||||
# matters for the ones that do not, and for a model typed in by hand.
|
||||
cmd += ["--effort", effort]
|
||||
|
||||
answer = _output(cmd, timeout, "Antigravity")
|
||||
if not answer:
|
||||
raise CleanupError(t("{service} answered with nothing.",
|
||||
service="Antigravity"))
|
||||
return answer
|
||||
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
@@ -194,21 +255,43 @@ def _output(cmd, timeout, service):
|
||||
"{binary} not found. Install it, or have OpenRouter clean up "
|
||||
"instead, under Settings → API and models.", binary=binary,
|
||||
))
|
||||
# Both streams land in files rather than pipes: nobody drains a pipe while
|
||||
# the process is being waited out, and a CLI chatty enough would fill the
|
||||
# buffer and wedge. And a timeout must end the CLI's tool subprocesses too,
|
||||
# not just the CLI, which subprocess.run's timeout does not do; hence the
|
||||
# own session on POSIX and assistant.kill_tree on the way out.
|
||||
out_file = tempfile.TemporaryFile()
|
||||
err_file = tempfile.TemporaryFile()
|
||||
grouped = {"start_new_session": True} if os.name == "posix" else {}
|
||||
try:
|
||||
done = subprocess.run(
|
||||
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
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(
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
|
||||
stdout=out_file, stderr=err_file,
|
||||
creationflags=paths.NO_WINDOW,
|
||||
**grouped,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise CleanupError(t("Could not run {binary}: {error}",
|
||||
binary=binary, error=exc)) from exc
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
assistant.kill_tree(proc)
|
||||
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
|
||||
service=service, seconds=timeout)) from None
|
||||
out_file.seek(0)
|
||||
stdout = out_file.read().decode("utf-8", "replace")
|
||||
err_file.seek(0)
|
||||
stderr = err_file.read().decode("utf-8", "replace")
|
||||
finally:
|
||||
for handle in (out_file, err_file):
|
||||
try:
|
||||
handle.close()
|
||||
except OSError:
|
||||
pass
|
||||
if proc.returncode != 0:
|
||||
raise CleanupError(assistant.last_line(stderr) or t(
|
||||
"{service} exited with code {code}.",
|
||||
service=service, code=done.returncode))
|
||||
return (done.stdout or "").strip()
|
||||
service=service, code=proc.returncode))
|
||||
return stdout.strip()
|
||||
|
||||
+128
-38
@@ -20,6 +20,7 @@ import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
|
||||
from PyQt6.QtCore import QCoreApplication, QTimer
|
||||
|
||||
@@ -30,10 +31,12 @@ from . import cleanup
|
||||
from . import config as cfg
|
||||
from . import filetranscribe
|
||||
from . import hotkey
|
||||
from . import hub
|
||||
from . import ipc
|
||||
from . import integrate
|
||||
from . import meeting
|
||||
from . import paste
|
||||
from . import update
|
||||
from . import __version__
|
||||
|
||||
NOT_RUNNING = 3
|
||||
@@ -133,21 +136,10 @@ def _ask_instance(opts, cmd, wait=False, **args):
|
||||
|
||||
def launch_gui(verb=""):
|
||||
"""No instance running, so become the application itself."""
|
||||
args = ipc.launcher()
|
||||
if verb:
|
||||
args.append(verb)
|
||||
args.append("--gui")
|
||||
if sys.platform == "win32":
|
||||
# execv on Windows mangles arguments with spaces and would leave the
|
||||
# application tied to this console; start it detached instead.
|
||||
subprocess.Popen(
|
||||
args,
|
||||
creationflags=(subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NEW_PROCESS_GROUP),
|
||||
close_fds=True,
|
||||
)
|
||||
sys.exit(0)
|
||||
os.execv(args[0], args)
|
||||
ipc.respawn(([verb] if verb else []) + ["--gui"])
|
||||
# respawn only returns on Windows, where the application was started
|
||||
# detached and this console process's job is over.
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _not_running(opts):
|
||||
@@ -224,7 +216,9 @@ def cmd_ask(opts):
|
||||
conf["assistant_provider"] = opts.provider
|
||||
if opts.model:
|
||||
key = {"claude": "assistant_model", "codex": "assistant_codex_model",
|
||||
"openrouter": "assistant_openrouter_model"}[assistant.provider(conf)]
|
||||
"openrouter": "assistant_openrouter_model",
|
||||
"agy": "assistant_agy_model",
|
||||
"opencode": "assistant_opencode_model"}[assistant.provider(conf)]
|
||||
conf[key] = opts.model
|
||||
if opts.dir:
|
||||
conf["assistant_dir"] = opts.dir
|
||||
@@ -263,7 +257,8 @@ def cmd_ask(opts):
|
||||
"cleanup_error": warning,
|
||||
"mode": "ask",
|
||||
"question": text,
|
||||
"assistant_model": conf["assistant_model"],
|
||||
"assistant": assistant.provider(conf),
|
||||
"assistant_model": assistant.model(conf),
|
||||
"raw": text,
|
||||
"text": answer,
|
||||
})
|
||||
@@ -516,7 +511,8 @@ def cmd_history_clear(opts):
|
||||
|
||||
# --- settings ---------------------------------------------------------------
|
||||
|
||||
SECRET_KEYS = ("openai_api_key", "openrouter_api_key")
|
||||
SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key",
|
||||
"gemini_api_key", "opencode_api_key")
|
||||
|
||||
|
||||
def _mask(key, value):
|
||||
@@ -706,6 +702,22 @@ def cmd_test_key(opts):
|
||||
results[name] = {"ok": True, "message": message}
|
||||
except api.ApiError as exc:
|
||||
results[name] = {"ok": False, "message": str(exc)}
|
||||
if opts.which in ("gemini", "all"):
|
||||
try:
|
||||
count = len(api.gemini_models(conf.gemini_key(),
|
||||
conf["gemini_base_url"]))
|
||||
message = f"connection works, {count} models visible"
|
||||
results["gemini"] = {"ok": True, "message": message}
|
||||
except api.ApiError as exc:
|
||||
results["gemini"] = {"ok": False, "message": str(exc)}
|
||||
if opts.which in ("opencode", "all"):
|
||||
try:
|
||||
count = len(api.openai_models(conf.opencode_key(),
|
||||
conf["opencode_base_url"], "OpenCode Go"))
|
||||
results["opencode"] = {"ok": True,
|
||||
"message": f"connection works, {count} models visible"}
|
||||
except api.ApiError as exc:
|
||||
results["opencode"] = {"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()]
|
||||
@@ -797,6 +809,33 @@ def cmd_integrate(opts):
|
||||
f"{verb}:\n{listing}" if paths else "Nothing to change.")
|
||||
|
||||
|
||||
def cmd_update(opts):
|
||||
"""Whether a newer Dikte has been released, and where it is.
|
||||
|
||||
It looks and nothing more: what to do about the answer is a download page,
|
||||
because the AppImage, the disk image, the Windows setup and a checkout are
|
||||
four different installations and only their owner knows which one this is.
|
||||
"""
|
||||
try:
|
||||
release = update.latest(refresh=True)
|
||||
except hub.HubError as exc:
|
||||
return fail(opts, exc)
|
||||
# Written down even when there is nothing new, so that the application does
|
||||
# not go and ask the same question an hour later.
|
||||
update.remember(release)
|
||||
waiting = update.newer(release.version)
|
||||
payload = {"ok": True, "current": __version__, "latest": release.version,
|
||||
"update": waiting, "url": release.url}
|
||||
if not waiting:
|
||||
return out(opts, payload,
|
||||
f"Dikte {__version__} is the newest release.")
|
||||
if opts.open:
|
||||
webbrowser.open(release.url)
|
||||
return out(opts, payload,
|
||||
f"Dikte {release.version} is out; this is {__version__}.\n"
|
||||
f"{release.url}")
|
||||
|
||||
|
||||
def cmd_status(opts):
|
||||
reply = ipc.send("status")
|
||||
if reply is None:
|
||||
@@ -839,27 +878,60 @@ def cmd_doctor(opts):
|
||||
programs = {name: shutil.which(name) or "" for name in wanted if name}
|
||||
target = conf.transcribe_target()
|
||||
cleaner = cleanup.provider(conf)
|
||||
# What each provider actually needs: the local ones have no key to check,
|
||||
# and marking them by the key they do not use reported every fully local
|
||||
# setup as broken.
|
||||
transcribe_ready = conf.transcribe_ready()
|
||||
# Only the ones that answer over HTTP have a key worth looking at. A CLI
|
||||
# has a program to find instead, and the model on this machine has neither,
|
||||
# so "no key" there has to read as beside the point rather than as one that
|
||||
# has gone missing.
|
||||
cleanup_service, cleanup_key = {
|
||||
"openrouter": ("OpenRouter", conf.openrouter_key()),
|
||||
"gemini": ("Google AI Studio", conf.gemini_key()),
|
||||
"opencode": ("OpenCode Go", conf.opencode_key()),
|
||||
}.get(cleaner, ("", ""))
|
||||
if cleanup_service:
|
||||
cleanup_ready = bool(cleanup_key)
|
||||
elif cleaner == "local":
|
||||
cleanup_ready = conf.local_llm_ready()
|
||||
else:
|
||||
cleanup_ready = bool(programs.get(cleanup.executable(cleaner), ""))
|
||||
checks = {
|
||||
"programs": programs,
|
||||
"transcription": {"provider": target.provider, "model": target.model,
|
||||
"key": bool(target.api_key)},
|
||||
"key": bool(target.api_key),
|
||||
"ready": transcribe_ready},
|
||||
"cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
|
||||
"model": cleanup.model(conf),
|
||||
"key": bool(conf.openrouter_key())},
|
||||
"key": bool(cleanup_key) if cleanup_service else None,
|
||||
"ready": cleanup_ready},
|
||||
"agent": {"provider": assistant.provider(conf),
|
||||
"directory": assistant.working_dir(conf)},
|
||||
"running": ipc.send("status") is not None,
|
||||
}
|
||||
if target.provider == "local":
|
||||
transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service}, "
|
||||
f"transcribing on {target.model or 'no model yet'}")
|
||||
else:
|
||||
transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service} "
|
||||
f"key, transcribing on {target.model}")
|
||||
if cleanup_service:
|
||||
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} {cleanup_service} key, "
|
||||
f"cleaning up on {cleanup.model(conf)}")
|
||||
elif cleaner == "local":
|
||||
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} Local model, "
|
||||
f"cleaning up on {conf['local_llm_model'] or 'no model yet'}")
|
||||
else:
|
||||
# Cleanup on a CLI needs no key, so what is checked is the program.
|
||||
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} "
|
||||
f"{cleanup.executable(cleaner)}, cleaning up on "
|
||||
f"{cleanup.model(conf)}")
|
||||
lines = [f"{'✓' if path else '✗'} {name:14} {path or 'not on your PATH'}"
|
||||
for name, path in programs.items()]
|
||||
lines += [
|
||||
f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on "
|
||||
f"{target.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)}"),
|
||||
transcribe_line,
|
||||
cleanup_line,
|
||||
f"{'✓' if checks['running'] else '·'} application "
|
||||
+ ("running" if checks["running"] else "not running"),
|
||||
]
|
||||
@@ -947,7 +1019,7 @@ def build_parser():
|
||||
ask = leaf(subs, "ask", "put a command to the agent")
|
||||
ask.add_argument("text", nargs="*", help="the command; read from stdin, or "
|
||||
"recorded when there is none")
|
||||
ask.add_argument("--provider", choices=("claude", "codex", "openrouter"),
|
||||
ask.add_argument("--provider", choices=assistant.PROVIDERS,
|
||||
help="just for this run")
|
||||
ask.add_argument("--model", help="just for this run")
|
||||
ask.add_argument("--dir", help="working directory, just for this run")
|
||||
@@ -982,13 +1054,14 @@ def build_parser():
|
||||
transcribe.set_defaults(func=cmd_transcribe)
|
||||
|
||||
# --- meetings ---------------------------------------------------------
|
||||
for name, help_text in (("meeting", "start a meeting, or end it and write it up"),
|
||||
("meeting-cancel", "")):
|
||||
page = leaf(subs, name, help_text)
|
||||
page.add_argument("--wait", action="store_true",
|
||||
help="wait for the minutes to be written")
|
||||
page.add_argument("--timeout", type=float, default=0)
|
||||
page.set_defaults(func=cmd_meeting)
|
||||
page = leaf(subs, "meeting", "start a meeting, or end it and write it up")
|
||||
page.add_argument("--wait", action="store_true",
|
||||
help="wait for the minutes to be written")
|
||||
page.add_argument("--timeout", type=float, default=0)
|
||||
page.set_defaults(func=cmd_meeting)
|
||||
# No --wait here: a cancel is answered on the spot, and a flag the server
|
||||
# would ignore is a promise the help text cannot keep.
|
||||
leaf(subs, "meeting-cancel", "").set_defaults(func=cmd_meeting)
|
||||
|
||||
meetings = leaf(subs, "meetings", "recorded meetings and their minutes")
|
||||
inner = meetings.add_subparsers(dest="meetings", metavar="")
|
||||
@@ -1007,12 +1080,14 @@ def build_parser():
|
||||
delete.add_argument("which", nargs="+")
|
||||
delete.set_defaults(func=cmd_meetings_delete)
|
||||
for name, verb, help_text in (("start", "meeting-start", "start recording one"),
|
||||
("stop", "meeting-stop", "end it and write it up"),
|
||||
("cancel", "meeting-cancel", "throw the recording away")):
|
||||
("stop", "meeting-stop", "end it and write it up")):
|
||||
page = leaf(inner, name, help_text)
|
||||
page.add_argument("--wait", action="store_true")
|
||||
page.add_argument("--timeout", type=float, default=0)
|
||||
page.set_defaults(func=cmd_meeting, verb=verb)
|
||||
# cancel takes no --wait: see the top-level meeting-cancel.
|
||||
leaf(inner, "cancel", "throw the recording away").set_defaults(
|
||||
func=cmd_meeting, verb="meeting-cancel")
|
||||
|
||||
# --- history ----------------------------------------------------------
|
||||
history = leaf(subs, "history", "past dictations")
|
||||
@@ -1070,7 +1145,7 @@ def build_parser():
|
||||
models.set_defaults(func=cmd_models)
|
||||
test = leaf(subs, "test-key", "check the API keys")
|
||||
test.add_argument("which", nargs="?", default="all",
|
||||
choices=("all", *cfg.TRANSCRIBERS))
|
||||
choices=("all", *cfg.TRANSCRIBERS, "gemini", "opencode"))
|
||||
test.set_defaults(func=cmd_test_key)
|
||||
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
|
||||
|
||||
@@ -1098,6 +1173,11 @@ def build_parser():
|
||||
integrated.set_defaults(func=cmd_integrate)
|
||||
|
||||
# --- the application --------------------------------------------------
|
||||
updates = leaf(subs, "update", "whether a newer Dikte has been released")
|
||||
updates.add_argument("--open", action="store_true",
|
||||
help="open the release page in a browser")
|
||||
updates.set_defaults(func=cmd_update)
|
||||
|
||||
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
|
||||
for name, help_text in (("settings", "open the settings window"),
|
||||
("restart", "reload the running instance"),
|
||||
@@ -1118,6 +1198,16 @@ def _needs_subcommand(parser):
|
||||
|
||||
def run(argv):
|
||||
global _app
|
||||
# A redirected stdout on Windows falls back to the console codepage,
|
||||
# strict, and a transcript (or doctor's ✓) with a character outside it
|
||||
# would then fail the run after the work succeeded. Interactively nothing
|
||||
# changes: the console is written through its own Unicode API.
|
||||
if sys.platform == "win32" and not os.environ.get("PYTHONIOENCODING"):
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(errors="replace")
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
parser = build_parser()
|
||||
opts = parser.parse_args(argv)
|
||||
# No verb at all is the plain `dikte`, which means the settings window.
|
||||
|
||||
+151
-35
@@ -5,6 +5,8 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import api
|
||||
from . import ggml
|
||||
@@ -385,10 +387,19 @@ DEFAULTS = {
|
||||
"groq_base_url": "https://api.groq.com/openai/v1",
|
||||
"openrouter_api_key": "",
|
||||
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
||||
"gemini_api_key": "",
|
||||
# Google's OpenAI-compatible endpoint. Cleanup only: there is no
|
||||
# /audio/transcriptions behind it, so it is not one of the TRANSCRIBERS.
|
||||
"gemini_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"opencode_api_key": "",
|
||||
"opencode_base_url": "https://opencode.ai/zen/go/v1",
|
||||
"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",
|
||||
# What a timestamped run (subtitles) asks OpenRouter for: not every model
|
||||
# there returns segment times. Empty -> openai/whisper-1.
|
||||
"openrouter_file_model": "",
|
||||
"language": "tr",
|
||||
"transcribe_prompt": "",
|
||||
|
||||
@@ -410,6 +421,9 @@ DEFAULTS = {
|
||||
"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_gemini_model": "gemini-3.5-flash-lite",
|
||||
"cleanup_agy_model": "", # empty -> whatever Antigravity is set to
|
||||
"cleanup_opencode_model": "deepseek-v4-flash",
|
||||
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
||||
|
||||
# --- llama.cpp, on this machine -----------------------------------------
|
||||
@@ -455,8 +469,12 @@ DEFAULTS = {
|
||||
"pause_shortcut": "",
|
||||
"evdev_hotkey": False,
|
||||
"overlay_corner": "bottom-left",
|
||||
"overlay_screen": "",
|
||||
"keep_audio": False,
|
||||
"history_limit": 200,
|
||||
# A look at the releases page once a day, and nothing more than a look:
|
||||
# what is found opens a browser, never an installer.
|
||||
"update_check": True,
|
||||
"file_timestamps": False,
|
||||
"file_cleanup": True,
|
||||
"file_cleanup_prompt": "", # empty -> language-specific default
|
||||
@@ -479,12 +497,14 @@ DEFAULTS = {
|
||||
|
||||
# --- speaking a command to an agent -------------------------------------
|
||||
"assistant_shortcut": "", # empty -> tray only
|
||||
"assistant_provider": "claude", # claude | codex | openrouter
|
||||
"assistant_provider": "claude", # claude | codex | agy | openrouter
|
||||
"assistant_model": "sonnet", # Claude Code: an alias, or a full model id
|
||||
"assistant_permission_mode": "auto",
|
||||
"assistant_codex_model": "", # empty -> whatever Codex is set to
|
||||
"assistant_codex_sandbox": "workspace-write",
|
||||
"assistant_openrouter_model": "google/gemini-3.5-flash",
|
||||
"assistant_agy_model": "", # empty -> whatever Antigravity is set to
|
||||
"assistant_opencode_model": "deepseek-v4-flash",
|
||||
"assistant_reasoning": "", # empty -> the model's own default
|
||||
"assistant_dir": "", # empty -> the home directory
|
||||
"assistant_prompt": "", # empty -> language-specific default
|
||||
@@ -525,6 +545,30 @@ TRANSCRIBERS = {
|
||||
"openrouter_base_url", "openrouter_transcribe_model"),
|
||||
}
|
||||
|
||||
# One lock for the history file and the meeting index both, rather than one
|
||||
# each: the files are a few kilobytes, the writes happen a handful of times an
|
||||
# hour, and a second lock would only add a way to take them in the wrong order.
|
||||
_FILES_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _replace_with_retry(tmp, target):
|
||||
"""The atomic swap, tried again briefly when the target is held.
|
||||
|
||||
On Windows an antivirus or sync tool opens a freshly written file to look
|
||||
at it, and a rename over the file fails for as long as it is held. The
|
||||
hold lasts milliseconds, so three tries with a short sleep cover it; a
|
||||
file held longer than that is a real error and is raised as one.
|
||||
"""
|
||||
for attempt in range(3):
|
||||
try:
|
||||
tmp.replace(target)
|
||||
return
|
||||
except OSError:
|
||||
if attempt == 2:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
# Corners used to be stored with Turkish names.
|
||||
_CORNER_MIGRATION = {
|
||||
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
|
||||
@@ -545,7 +589,19 @@ class Config:
|
||||
self.data.update({k: v for k, v in stored.items() if k in DEFAULTS})
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
except json.JSONDecodeError as exc:
|
||||
# Set aside rather than left in place: the next save would write
|
||||
# the defaults over it, and whatever broke the file deserves to
|
||||
# still be there to look at. Best effort; a rename that fails
|
||||
# changes nothing about falling back to the defaults.
|
||||
broken = CONFIG_FILE.with_suffix(".json.broken")
|
||||
try:
|
||||
CONFIG_FILE.replace(broken)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"dikte: could not read settings ({exc}), using defaults; "
|
||||
f"the unreadable file was kept as {broken}")
|
||||
except OSError as exc:
|
||||
print(f"dikte: could not read settings ({exc}), using defaults")
|
||||
self.data["overlay_corner"] = _CORNER_MIGRATION.get(
|
||||
self.data["overlay_corner"], self.data["overlay_corner"]
|
||||
@@ -560,8 +616,13 @@ class Config:
|
||||
tmp = CONFIG_FILE.with_suffix(".json.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(self.data, fh, ensure_ascii=False, indent=2)
|
||||
# Pushed to the disk before the rename: swapping in a file that
|
||||
# still lives in the page cache turns a power cut into a settings
|
||||
# wipe, which the atomic replace exists to prevent.
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(CONFIG_FILE)
|
||||
_replace_with_retry(tmp, CONFIG_FILE)
|
||||
i18n.set_language(self.data["ui_language"])
|
||||
|
||||
def __getitem__(self, key):
|
||||
@@ -586,6 +647,12 @@ class Config:
|
||||
def openrouter_key(self):
|
||||
return self.api_key("openrouter_api_key")
|
||||
|
||||
def gemini_key(self):
|
||||
return self.api_key("gemini_api_key")
|
||||
|
||||
def opencode_key(self):
|
||||
return self.api_key("opencode_api_key")
|
||||
|
||||
def transcribe_target(self):
|
||||
"""Key, endpoint and model for whichever provider does speech to text.
|
||||
|
||||
@@ -605,8 +672,9 @@ class Config:
|
||||
# to land on rather than reading it from there.
|
||||
name = "openai"
|
||||
who = TRANSCRIBERS[name]
|
||||
file_model = self["openrouter_file_model"] if name == "openrouter" else ""
|
||||
return api.Target(name, who.service, self.api_key(who.key),
|
||||
self[who.url], self[who.model])
|
||||
self[who.url], self[who.model], file_model.strip())
|
||||
|
||||
def transcribe_ready(self):
|
||||
"""Whether speech to text could run right now, without opening Settings."""
|
||||
@@ -727,12 +795,22 @@ def default_assistant_prompt():
|
||||
|
||||
def append_history(entry):
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
with _FILES_LOCK:
|
||||
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def read_history(limit=None):
|
||||
"""Newest last. A limit of None (or 0) reads the whole file."""
|
||||
# Locked even though the rewrites are atomic: it costs nothing, and a read
|
||||
# that waits out a rewrite in flight hands back the settled file rather
|
||||
# than whichever side of the swap it happened to land on.
|
||||
with _FILES_LOCK:
|
||||
return _read_history(limit)
|
||||
|
||||
|
||||
def _read_history(limit=None):
|
||||
"""The body of read_history, for callers already holding the lock."""
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
@@ -755,40 +833,66 @@ def _write_history(lines):
|
||||
tmp = HISTORY_FILE.with_suffix(".jsonl.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(lines)
|
||||
tmp.replace(HISTORY_FILE)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
_replace_with_retry(tmp, HISTORY_FILE)
|
||||
|
||||
|
||||
def trim_history(limit):
|
||||
"""Drop the oldest entries once the file passes `limit` rows. 0 means keep all."""
|
||||
if not limit or limit < 0:
|
||||
return
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError:
|
||||
return
|
||||
if len(lines) <= limit:
|
||||
return
|
||||
_write_history(lines[-limit:])
|
||||
# Read and rewrite under one lock, so a dictation appended in between the
|
||||
# two is not erased by a rewrite that never saw it.
|
||||
with _FILES_LOCK:
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError:
|
||||
return
|
||||
if len(lines) <= limit:
|
||||
return
|
||||
_write_history(lines[-limit:])
|
||||
|
||||
|
||||
def _row_key(row):
|
||||
return json.dumps(row, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def amend_history(entry, **changes):
|
||||
"""Patch one entry in place, matched on its whole content like delete_history.
|
||||
|
||||
For the caller that learns something after its row is already written: the
|
||||
row goes in before the paste is attempted, and a paste that then fails
|
||||
still has to end up in the record. None when the row is gone, which a trim
|
||||
in between can legitimately make true."""
|
||||
wanted = _row_key(entry)
|
||||
with _FILES_LOCK:
|
||||
rows = _read_history()
|
||||
for row in rows:
|
||||
if _row_key(row) == wanted:
|
||||
row.update(changes)
|
||||
_write_history([json.dumps(r, ensure_ascii=False) + "\n"
|
||||
for r in rows])
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def delete_history(rows):
|
||||
"""Remove the given entries, matched on their whole content rather than on a
|
||||
line number: the worker may have appended a new one since the list was read."""
|
||||
doomed = {_row_key(row) for row in rows}
|
||||
if not doomed:
|
||||
return
|
||||
kept = [json.dumps(row, ensure_ascii=False) + "\n"
|
||||
for row in read_history() if _row_key(row) not in doomed]
|
||||
_write_history(kept)
|
||||
with _FILES_LOCK:
|
||||
kept = [json.dumps(row, ensure_ascii=False) + "\n"
|
||||
for row in _read_history() if _row_key(row) not in doomed]
|
||||
_write_history(kept)
|
||||
|
||||
|
||||
def clear_history():
|
||||
HISTORY_FILE.unlink(missing_ok=True)
|
||||
with _FILES_LOCK:
|
||||
HISTORY_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --- meetings -------------------------------------------------------------
|
||||
@@ -804,6 +908,12 @@ def meeting_paths(base):
|
||||
|
||||
def read_meetings():
|
||||
"""Newest last."""
|
||||
with _FILES_LOCK:
|
||||
return _read_meetings()
|
||||
|
||||
|
||||
def _read_meetings():
|
||||
"""The body of read_meetings, for callers already holding the lock."""
|
||||
try:
|
||||
with open(MEETINGS_FILE, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
@@ -826,29 +936,33 @@ def _write_meetings(rows):
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
tmp.replace(MEETINGS_FILE)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
_replace_with_retry(tmp, MEETINGS_FILE)
|
||||
|
||||
|
||||
def save_meeting(entry):
|
||||
"""Insert the row, or replace the one with the same base."""
|
||||
rows = read_meetings()
|
||||
for index, row in enumerate(rows):
|
||||
if row["base"] == entry["base"]:
|
||||
rows[index] = entry
|
||||
break
|
||||
else:
|
||||
rows.append(entry)
|
||||
_write_meetings(rows)
|
||||
with _FILES_LOCK:
|
||||
rows = _read_meetings()
|
||||
for index, row in enumerate(rows):
|
||||
if row["base"] == entry["base"]:
|
||||
rows[index] = entry
|
||||
break
|
||||
else:
|
||||
rows.append(entry)
|
||||
_write_meetings(rows)
|
||||
|
||||
|
||||
def update_meeting(base, **changes):
|
||||
"""Patch one row and hand it back, or None when it is gone."""
|
||||
rows = read_meetings()
|
||||
for row in rows:
|
||||
if row["base"] == base:
|
||||
row.update(changes)
|
||||
_write_meetings(rows)
|
||||
return row
|
||||
with _FILES_LOCK:
|
||||
rows = _read_meetings()
|
||||
for row in rows:
|
||||
if row["base"] == base:
|
||||
row.update(changes)
|
||||
_write_meetings(rows)
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
@@ -857,7 +971,9 @@ def delete_meetings(bases):
|
||||
doomed = set(bases)
|
||||
if not doomed:
|
||||
return
|
||||
_write_meetings([row for row in read_meetings() if row["base"] not in doomed])
|
||||
with _FILES_LOCK:
|
||||
_write_meetings([row for row in _read_meetings()
|
||||
if row["base"] not in doomed])
|
||||
for base in doomed:
|
||||
for path in meeting_paths(base):
|
||||
try:
|
||||
|
||||
@@ -31,6 +31,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from . import api
|
||||
from . import cleanup
|
||||
from . import ggml
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room
|
||||
@@ -337,8 +338,11 @@ def _ffmpeg(args, out, aborter=None):
|
||||
proc = subprocess.Popen(
|
||||
["ffmpeg", "-nostdin", "-y", *args],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
# ffmpeg writes UTF-8 whatever the locale says; read as the Windows
|
||||
# codepage its messages mojibake, and a byte the codepage cannot place
|
||||
# raises from inside communicate itself.
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
creationflags=paths.NO_WINDOW,
|
||||
)
|
||||
# A two hour film is a minute of ffmpeg, which is a minute of a Stop button
|
||||
# doing nothing unless the abort reaches the process itself.
|
||||
|
||||
+211
-51
@@ -63,6 +63,10 @@ MODELS_DIR = DATA_DIR / "models"
|
||||
# Loading a large model onto a GPU is the slow part of a start, and on a cold
|
||||
# page cache a large LLM read from a spinning disk is slower still.
|
||||
STARTUP_TIMEOUT = 180.0
|
||||
# A child that loses the bind race fails and exits at once; a model that fails
|
||||
# to load takes longer than this to be read in first. The line between "worth
|
||||
# another port" and "would fail the same way again" is drawn on time.
|
||||
EARLY_EXIT_WINDOW = 5.0
|
||||
DOWNLOAD_CHUNK = 1 << 20
|
||||
|
||||
# `health` is the path that answers only once the model is in memory. whisper
|
||||
@@ -193,7 +197,16 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
|
||||
part.unlink(missing_ok=True)
|
||||
raise LocalError(t("{name} does not match its published checksum. "
|
||||
"Nothing was installed.", name=item.name))
|
||||
part.replace(target)
|
||||
try:
|
||||
part.replace(target)
|
||||
except PermissionError as exc:
|
||||
# Windows refuses to replace a file something has open, and a
|
||||
# running server holds its model and its binary open. The bytes
|
||||
# are complete and verified: keeping the .part costs a retry,
|
||||
# deleting it costs the whole download again.
|
||||
raise LocalError(t("{name} downloaded, but the old file is held "
|
||||
"open by the running server. Stop it and try "
|
||||
"again.", name=item.name)) from exc
|
||||
return True
|
||||
except urllib.error.HTTPError as exc:
|
||||
part.unlink(missing_ok=True)
|
||||
@@ -268,22 +281,23 @@ def _install_record(program):
|
||||
return BIN_DIR / program.name / "installed.json"
|
||||
|
||||
|
||||
def installed_program(program):
|
||||
"""The binary Dikte downloaded, or "" when there is none that still runs."""
|
||||
def _read_record(program):
|
||||
"""The install record, or {} however it fails to read."""
|
||||
try:
|
||||
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
||||
path = record.get("binary") or ""
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return {}
|
||||
return record if isinstance(record, dict) else {}
|
||||
|
||||
|
||||
def installed_program(program):
|
||||
"""The binary Dikte downloaded, or "" when there is none that still runs."""
|
||||
path = _read_record(program).get("binary") or ""
|
||||
return path if os.path.isfile(path) and os.access(path, os.X_OK) else ""
|
||||
|
||||
|
||||
def installed_version(program):
|
||||
try:
|
||||
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
||||
return record.get("tag") or ""
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return _read_record(program).get("tag") or ""
|
||||
|
||||
|
||||
def program_path(program, custom=""):
|
||||
@@ -339,6 +353,19 @@ def _extract(archive, into):
|
||||
name=os.path.basename(str(archive)), error=exc)) from exc
|
||||
|
||||
|
||||
def _under(path, root):
|
||||
"""Whether `path` lies inside `root`, symlinks and case resolved.
|
||||
|
||||
Resolved on both sides, because the same directory can be reached under
|
||||
two spellings and this answer decides whether a server gets stopped.
|
||||
"""
|
||||
try:
|
||||
pathlib.Path(path).resolve().relative_to(pathlib.Path(root).resolve())
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def install_program(program, tag="", on_progress=None, should_stop=None,
|
||||
refresh=False):
|
||||
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
|
||||
@@ -374,17 +401,52 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
||||
repo=program.repo, tag=tag))
|
||||
|
||||
into = BIN_DIR / program.name / tag
|
||||
shutil.rmtree(into, ignore_errors=True)
|
||||
fresh = into.with_name(tag + ".new")
|
||||
archive = BIN_DIR / program.name / item.name
|
||||
|
||||
try:
|
||||
if not download(item, archive, on_progress, should_stop):
|
||||
return ""
|
||||
_extract(archive, into)
|
||||
binary = _find_binary(into, _binary_file(program))
|
||||
if binary is None:
|
||||
raise LocalError(t("{name} was not in the download.",
|
||||
name=program.binary))
|
||||
binary.chmod(binary.stat().st_mode | 0o111)
|
||||
try:
|
||||
# Unpacked into a sibling and swapped in only once the binary is
|
||||
# known to be inside: a failure anywhere in here leaves the
|
||||
# previous install, and its record, exactly as they were.
|
||||
shutil.rmtree(fresh, ignore_errors=True)
|
||||
_extract(archive, fresh)
|
||||
binary = _find_binary(fresh, _binary_file(program))
|
||||
if binary is None:
|
||||
raise LocalError(t("{name} was not in the download.",
|
||||
name=program.binary))
|
||||
binary.chmod(binary.stat().st_mode | 0o111)
|
||||
# A running server holds its binary open, and Windows will not
|
||||
# delete an open file: whichever of our servers runs out of this
|
||||
# program's directory is stopped here, after the download and the
|
||||
# unpack are known good, so the outage is the swap and not the
|
||||
# whole transfer.
|
||||
for server in SERVERS:
|
||||
current = program_path(server.program,
|
||||
server.settings().get("binary", ""))
|
||||
if current and _under(current, BIN_DIR / program.name):
|
||||
server.stop()
|
||||
if into.exists():
|
||||
try:
|
||||
shutil.rmtree(into)
|
||||
except OSError as exc:
|
||||
# Not ignore_errors: silently losing this would rename the
|
||||
# new version somewhere it can never land, and the user can
|
||||
# actually fix it by closing whatever holds the directory.
|
||||
raise LocalError(t(
|
||||
"Could not replace {path}: a file in it is still "
|
||||
"open: {error}", path=into, error=exc)) from exc
|
||||
fresh.rename(into)
|
||||
except BaseException:
|
||||
# Half an unpacked sibling is not worth keeping, and the swap
|
||||
# never ran, so the previous install is still whole.
|
||||
shutil.rmtree(fresh, ignore_errors=True)
|
||||
raise
|
||||
# Found under the sibling, run from the final directory.
|
||||
binary = into / binary.relative_to(fresh)
|
||||
# Written last, so the record never points at anything half-made.
|
||||
_install_record(program).write_text(
|
||||
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
@@ -404,8 +466,15 @@ def _drop_old_versions(program, keep):
|
||||
root = BIN_DIR / program.name
|
||||
try:
|
||||
for path in root.iterdir():
|
||||
if path.is_dir() and path.name != keep:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if not path.is_dir() or path.name == keep:
|
||||
continue
|
||||
# A ".new" sibling belongs to an install mid-swap; housekeeping
|
||||
# must not pull it out from under it.
|
||||
if path.name.endswith(".new"):
|
||||
continue
|
||||
# ignore_errors on purpose: this is housekeeping, and a locked old
|
||||
# version is a little wasted disk rather than a failed install.
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -538,7 +607,12 @@ def _tail(path, lines=3):
|
||||
|
||||
|
||||
def _win_image_name(pid):
|
||||
"""The lower-cased file name of the process's executable, or ''."""
|
||||
"""The full, lower-cased path of the process's executable, or ''.
|
||||
|
||||
The full path rather than the base name, because the name alone is anyone's
|
||||
whisper-server.exe and this answer decides what gets killed. MAX_PATH is a
|
||||
convention rather than a limit, so the buffer grows until the query fits.
|
||||
"""
|
||||
import ctypes
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.restype = ctypes.c_void_p
|
||||
@@ -548,11 +622,18 @@ def _win_image_name(pid):
|
||||
if not handle:
|
||||
return ""
|
||||
try:
|
||||
buffer = ctypes.create_unicode_buffer(260)
|
||||
size = ctypes.c_uint32(len(buffer))
|
||||
ok = kernel32.QueryFullProcessImageNameW(
|
||||
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
|
||||
return os.path.basename(buffer.value).lower() if ok else ""
|
||||
length = 260
|
||||
while length <= 32768:
|
||||
buffer = ctypes.create_unicode_buffer(length)
|
||||
size = ctypes.c_uint32(len(buffer))
|
||||
ok = kernel32.QueryFullProcessImageNameW(
|
||||
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
|
||||
if ok:
|
||||
return buffer.value.lower()
|
||||
if ctypes.get_last_error() != 122: # ERROR_INSUFFICIENT_BUFFER
|
||||
return ""
|
||||
length *= 2
|
||||
return ""
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
@@ -578,6 +659,9 @@ class Server:
|
||||
self._port = 0
|
||||
self._log = ""
|
||||
self._key = None
|
||||
# The pid this instance last wrote to its pid file, so _forget never
|
||||
# removes a file some other Dikte wrote after us.
|
||||
self._pid = 0
|
||||
|
||||
# ---- settings --------------------------------------------------------
|
||||
|
||||
@@ -626,7 +710,9 @@ class Server:
|
||||
ready = self._current_url()
|
||||
if ready:
|
||||
return ready
|
||||
self.stop()
|
||||
# _stop_now rather than stop(): this thread already holds
|
||||
# _starting, and the public stop() waits for it.
|
||||
self._stop_now()
|
||||
with self._lock:
|
||||
settings, key = dict(self._settings), self._settings_key()
|
||||
proc, port, log = self._launch(settings)
|
||||
@@ -659,7 +745,7 @@ class Server:
|
||||
stdout=sink, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
# No console window of its own on Windows.
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
creationflags=paths.NO_WINDOW,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise LocalError(t("Could not start {name}: {error}",
|
||||
@@ -668,8 +754,9 @@ class Server:
|
||||
# Written before it is ready rather than after, so that a kill
|
||||
# during the model load leaves something for the sweep to find.
|
||||
self._remember(proc.pid)
|
||||
began = time.monotonic()
|
||||
try:
|
||||
ready = self._wait_ready(proc, port)
|
||||
reason, listened = self._wait_ready(proc, port)
|
||||
except BaseException:
|
||||
# Whatever went wrong while waiting, the process is ours and
|
||||
# nothing else is left holding a reference to it. Leaving it
|
||||
@@ -679,31 +766,52 @@ class Server:
|
||||
self._kill(proc)
|
||||
self._forget()
|
||||
raise
|
||||
if ready:
|
||||
if reason == "ready":
|
||||
return proc, port, str(log)
|
||||
last = _tail(log)
|
||||
self._forget()
|
||||
# A port taken between the probe and the bind is the one failure
|
||||
# worth another go; anything else will fail the same way again.
|
||||
if "address" not in last.lower() and "bind" not in last.lower():
|
||||
# Losing the port between the probe and the bind is the one
|
||||
# failure another port fixes, and it has a shape rather than a
|
||||
# message: the child died at once without the port ever having
|
||||
# answered as its own. Grepping the log for "bind" would tie this
|
||||
# to one program's wording in one language.
|
||||
early = time.monotonic() - began < EARLY_EXIT_WINDOW
|
||||
if reason != "exited" or listened or not early:
|
||||
break
|
||||
raise LocalError(t("{name} did not start: {error}",
|
||||
name=self.program.binary, error=last or t("no output")))
|
||||
|
||||
def _wait_ready(self, proc, port):
|
||||
"""("ready" | "exited" | "timeout", whether the port answered as ours).
|
||||
|
||||
whisper binds after the model is loaded, so the open port is the
|
||||
answer; llama binds first and answers /health with 503 until it is
|
||||
ready. For the health-less case the open port alone is not proof: a
|
||||
child that lost the bind race exits at once while the winner keeps the
|
||||
port open, so "ready" also wants our child alive a beat after the port
|
||||
was first seen open.
|
||||
"""
|
||||
deadline = time.monotonic() + STARTUP_TIMEOUT
|
||||
seen_open = False
|
||||
listened = False
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
return False
|
||||
return "exited", listened
|
||||
if seen_open:
|
||||
# Port open on the last pass and our child still alive now:
|
||||
# an imposter's port would have left our child dead by here.
|
||||
return "ready", True
|
||||
if _listening(port):
|
||||
# whisper binds after the model is loaded, so the open port is
|
||||
# the answer. llama binds first and answers /health with 503
|
||||
# until it is ready.
|
||||
if not self.program.health or _healthy(port, self.program.health):
|
||||
return True
|
||||
if self.program.health:
|
||||
# llama did the binding itself, so the port is its.
|
||||
listened = True
|
||||
if _healthy(port, self.program.health):
|
||||
return "ready", True
|
||||
else:
|
||||
seen_open = True
|
||||
time.sleep(0.1)
|
||||
self._kill(proc)
|
||||
return False
|
||||
return "timeout", listened
|
||||
|
||||
@staticmethod
|
||||
def _kill(proc, gently=False):
|
||||
@@ -724,6 +832,14 @@ class Server:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
# Taking _starting means a stop cannot slide past a launch in flight:
|
||||
# serve() finishes registering its child first, and the child is then
|
||||
# killed here rather than surviving the shutdown unowned.
|
||||
with self._starting:
|
||||
self._stop_now()
|
||||
|
||||
def _stop_now(self):
|
||||
"""stop() for a thread that already holds _starting."""
|
||||
with self._lock:
|
||||
proc, self._proc = self._proc, None
|
||||
self._port, self._log, self._key = 0, "", None
|
||||
@@ -737,6 +853,8 @@ class Server:
|
||||
return DATA_DIR / f"{self.program.name}-server.pid"
|
||||
|
||||
def _remember(self, pid):
|
||||
with self._lock:
|
||||
self._pid = pid
|
||||
try:
|
||||
path = self._pid_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -744,28 +862,62 @@ class Server:
|
||||
except OSError:
|
||||
pass # the sweep is a safety net, not something to fail a run over
|
||||
|
||||
def _forget(self):
|
||||
def _forget(self, pid=None):
|
||||
"""Remove the pid file, but only while it still holds our own pid.
|
||||
|
||||
Another Dikte started after us writes its pid over ours, and removing
|
||||
that file would hide its server from every future sweep.
|
||||
"""
|
||||
if pid is None:
|
||||
with self._lock:
|
||||
pid = self._pid
|
||||
try:
|
||||
self._pid_file().unlink()
|
||||
except OSError:
|
||||
if int(self._pid_file().read_text().strip()) == pid:
|
||||
self._pid_file().unlink()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
def _is_ours(self, pid):
|
||||
"""Whether that pid is still the server this Dikte started.
|
||||
"""True: still our server. False: definitely not. None: cannot tell now.
|
||||
|
||||
Asked because pids are handed out again: by the time anyone looks, the
|
||||
number could belong to something else entirely, and killing it would be
|
||||
a good deal worse than the leak being cleaned up. The program name alone
|
||||
could be somebody else's copy; the name together with Dikte's own data
|
||||
directory on the command line could not. Windows offers no command line
|
||||
to read, so the executable's name is the whole of the answer there.
|
||||
a good deal worse than the leak being cleaned up. The tri-state matters
|
||||
for the pid file: a definitive "not ours" means the file is stale and
|
||||
safe to drop, while "cannot tell" means it has to stay so a later start
|
||||
can ask again.
|
||||
|
||||
On Linux the program name alone could be somebody else's copy; the name
|
||||
together with Dikte's own data directory on the command line could not.
|
||||
Windows offers no command line to read, so the executable's full path
|
||||
is the answer there: under our bin directory, or exactly the binary the
|
||||
settings point this server at. Never the base name alone, which is
|
||||
anyone's whisper-server.exe.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return _win_image_name(pid) == _binary_file(self.program).lower()
|
||||
path = _win_image_name(pid)
|
||||
if not path:
|
||||
# OpenProcess said nothing: the process may be gone or merely
|
||||
# unreadable from here, and the difference decides whether the
|
||||
# pid file may be dropped, so no verdict rather than a wrong one.
|
||||
return None
|
||||
path = os.path.normcase(path)
|
||||
if path.startswith(os.path.normcase(str(BIN_DIR)) + os.sep):
|
||||
return True
|
||||
with self._lock:
|
||||
custom = self._settings.get("binary", "")
|
||||
configured = program_path(self.program, custom)
|
||||
if configured:
|
||||
resolved = os.path.normcase(str(pathlib.Path(configured).resolve()))
|
||||
if path == resolved:
|
||||
return True
|
||||
return False
|
||||
try:
|
||||
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return False # the process is definitively gone
|
||||
except OSError:
|
||||
return False
|
||||
return None # /proc would not answer just now
|
||||
return (self.program.binary.encode() in blob
|
||||
and str(DATA_DIR).encode() in blob)
|
||||
|
||||
@@ -781,13 +933,21 @@ class Server:
|
||||
pid = int(self._pid_file().read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
self._forget()
|
||||
if not self._is_ours(pid):
|
||||
owned = self._is_ours(pid)
|
||||
if owned is None:
|
||||
# Could not be verified rather than known stale: the file stays,
|
||||
# so the next start asks again instead of losing track of a server
|
||||
# that may still be holding a model.
|
||||
return False
|
||||
if not owned:
|
||||
self._forget(pid)
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
self._forget(pid)
|
||||
return False
|
||||
self._forget(pid)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+2
-4
@@ -970,9 +970,7 @@ def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
|
||||
if "=" not in line or desktop_id in section:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
if shortcut.lower() in value.lower().split(","):
|
||||
hits.append(f"{section} → {key}")
|
||||
elif any(shortcut.lower() == part.strip().lower()
|
||||
for part in re.split(r"[,\t]", value)):
|
||||
if any(shortcut.lower() == part.strip().lower()
|
||||
for part in re.split(r"[,\t]", value)):
|
||||
hits.append(f"{section} → {key}")
|
||||
return hits
|
||||
|
||||
+20
-6
@@ -12,19 +12,18 @@ means one for every whisper.cpp release; both of those are somebody else's news,
|
||||
not Dikte's. Answers are cached for a few hours, and a cache that has gone stale
|
||||
is still a better answer than none when the network is down.
|
||||
|
||||
Nothing here imports the rest of Dikte apart from the string table: this module
|
||||
knows two websites and nothing about dictation.
|
||||
Nothing here imports the rest of Dikte apart from two leaves, the string table
|
||||
and the path map: this module knows two websites and nothing about dictation.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
@@ -32,8 +31,7 @@ HF_API = "https://huggingface.co/api"
|
||||
HF_FILES = "https://huggingface.co"
|
||||
USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)"
|
||||
|
||||
CACHE_DIR = (pathlib.Path(os.environ.get("XDG_CACHE_HOME")
|
||||
or os.path.expanduser("~/.cache")) / "dikte")
|
||||
CACHE_DIR = paths.cache_dir()
|
||||
# Long enough that opening the settings window twice in an evening asks nobody
|
||||
# anything, short enough that a model published this morning is offered today.
|
||||
CACHE_TTL = 6 * 3600
|
||||
@@ -136,6 +134,22 @@ def release(repo, tag="latest", refresh=False):
|
||||
return data.get("tag_name") or tag, assets
|
||||
|
||||
|
||||
def newest_release(repo, refresh=False):
|
||||
"""(tag, page, published) for the newest release of a repository.
|
||||
|
||||
release() above is for taking a file out of one and insists on there being
|
||||
files to take; this is for the number, which a release with nothing
|
||||
attached answers just as well. GitHub keeps prereleases out of "latest" on
|
||||
its own, which is what leaves the nightly build off this answer.
|
||||
"""
|
||||
data = _fetch(f"gh-newest-{repo}",
|
||||
f"{GITHUB_API}/repos/{repo}/releases/latest", refresh=refresh)
|
||||
if not isinstance(data, dict) or not data.get("tag_name"):
|
||||
raise HubError(t("{repo} has published no release.", repo=repo))
|
||||
return (data["tag_name"], data.get("html_url") or "",
|
||||
data.get("published_at") or "")
|
||||
|
||||
|
||||
def files(repo, revision="main", refresh=False):
|
||||
"""[Item] for every file in a Hugging Face repository.
|
||||
|
||||
|
||||
+190
-31
@@ -40,8 +40,16 @@ def t(text, /, **kwargs):
|
||||
# by the sentence, so it arrives already inflected. English takes the name as it
|
||||
# is and puts the preposition in the sentence, where it belongs.
|
||||
_TR_CASES = {
|
||||
"dative": {"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a"},
|
||||
"accusative": {"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı"},
|
||||
"dative": {
|
||||
"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a",
|
||||
"Google AI Studio": "Google AI Studio'ya", "Antigravity": "Antigravity'ye",
|
||||
"OpenCode Go": "OpenCode Go'ya",
|
||||
},
|
||||
"accusative": {
|
||||
"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı",
|
||||
"Google AI Studio": "Google AI Studio'yu", "Antigravity": "Antigravity'yi",
|
||||
"OpenCode Go": "OpenCode Go'yu",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +77,7 @@ TR = {
|
||||
|
||||
# --- overlay / pipeline -------------------------------------------
|
||||
"Transcribing…": "Yazıya çevriliyor…",
|
||||
"Waiting for the one before it…": "Öncekinin bitmesi bekleniyor…",
|
||||
"Cleaning up…": "Temizleniyor…",
|
||||
"Pasting…": "Yapıştırılıyor…",
|
||||
"Pasted": "Yapıştırıldı",
|
||||
@@ -150,6 +159,7 @@ TR = {
|
||||
# --- settings: tabs and general ------------------------------------
|
||||
"Dikte Settings": "Dikte Ayarları",
|
||||
"General": "Genel",
|
||||
"Display": "Ekran",
|
||||
"API and models": "API ve modeller",
|
||||
"Cleanup rules": "Temizleme kuralları",
|
||||
"Audio file": "Ses dosyası",
|
||||
@@ -162,8 +172,6 @@ TR = {
|
||||
"Automatic (system)": "Otomatik (sistem)",
|
||||
"Turkish": "Türkçe",
|
||||
"English": "İngilizce",
|
||||
"Restart Dikte for the language change to reach every window.":
|
||||
"Dil değişikliğinin her pencereye işlemesi için Dikte'yi yeniden başlat.",
|
||||
"Microphone": "Mikrofon",
|
||||
"Default microphone": "Varsayılan mikrofon",
|
||||
"Speech language": "Konuşma dili",
|
||||
@@ -180,6 +188,9 @@ TR = {
|
||||
"macOS bu ilk gönderildiğinde Erişilebilirlik izni ister.",
|
||||
"Restore the previous clipboard after pasting":
|
||||
"Yapıştırdıktan sonra eski pano içeriğini geri koy",
|
||||
"Indicator screen": "Gösterge ekranı",
|
||||
"Follow the mouse pointer": "Fare imlecini takip et",
|
||||
"{name} (not connected)": "{name} (bağlı değil)",
|
||||
"Indicator corner": "Gösterge köşesi",
|
||||
"bottom-left": "sol-alt",
|
||||
"bottom-right": "sağ-alt",
|
||||
@@ -192,36 +203,59 @@ TR = {
|
||||
"Silence threshold": "Sessizlik eşiği",
|
||||
"Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
|
||||
|
||||
# --- updates --------------------------------------------------------
|
||||
"Updates": "Güncelleme",
|
||||
"Look for a newer version once a day": "Günde bir kez yeni sürüm var mı diye bak",
|
||||
"Dikte only looks. What it finds opens the release page in your browser; "
|
||||
"it downloads and installs nothing by itself.":
|
||||
"Dikte yalnızca bakar. Bulduğu şey tarayıcında sürüm sayfasını açar; "
|
||||
"kendi başına hiçbir şey indirmez ve kurmaz.",
|
||||
"Check now": "Şimdi bak",
|
||||
"Looking…": "Bakılıyor…",
|
||||
"Open the release page": "Sürüm sayfasını aç",
|
||||
"This is Dikte {version}.": "Buradaki sürüm Dikte {version}.",
|
||||
"Dikte {version} is the newest release.": "En yeni sürüm zaten bu: Dikte {version}.",
|
||||
"Dikte {version} is out; this is {current}.":
|
||||
"Dikte {version} çıkmış; buradaki sürüm {current}.",
|
||||
"Dikte {version} is out…": "Dikte {version} çıkmış…",
|
||||
"Dikte {version} is out. The tray menu has the release page.":
|
||||
"Dikte {version} çıkmış. Sürüm sayfası tepsi menüsünde.",
|
||||
"{repo} has published no release.": "{repo} için yayımlanmış sürüm yok.",
|
||||
|
||||
# --- settings: api --------------------------------------------------
|
||||
"Keys": "Anahtarlar",
|
||||
"Speech to text": "Sesi yazıya çevirme",
|
||||
"Transcript cleanup": "Transkripti temizleme",
|
||||
"API key": "API anahtarı",
|
||||
"Model": "Model",
|
||||
"Audio file model": "Ses dosyası modeli",
|
||||
"The model a timestamped audio file (subtitles) is sent to. Not every model on "
|
||||
"OpenRouter returns segment times; empty means openai/whisper-1.":
|
||||
"Zaman damgalı bir ses dosyasının (altyazı) gönderildiği model. OpenRouter'daki her "
|
||||
"model segment zamanı döndürmez; boşsa openai/whisper-1 kullanılır.",
|
||||
"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)",
|
||||
"(falls back to GEMINI_API_KEY)": "(boşsa GEMINI_API_KEY kullanılır)",
|
||||
"(falls back to OPENCODE_API_KEY)": "(boşsa OPENCODE_API_KEY kullanılır)",
|
||||
"Test": "Test et",
|
||||
"Trying…": "Deneniyor…",
|
||||
"Runs on OpenRouter.": "OpenRouter üzerinde çalışır.",
|
||||
"Runs on Google AI Studio.": "Google AI Studio üzerinde çalışır.",
|
||||
"Runs on OpenCode Go.": "OpenCode Go üzerinde çalışır.",
|
||||
"Connection works. {count} audio models visible.":
|
||||
"Bağlantı tamam. {count} ses modeli görünüyor.",
|
||||
"Connection works. {count} models visible.":
|
||||
"Bağlantı tamam. {count} model 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ı",
|
||||
"Antigravity's own default": "Antigravity'nin kendi varsayılanı",
|
||||
"Off": "Kapalı",
|
||||
"Minimal": "En az",
|
||||
"Low": "Düşük",
|
||||
@@ -484,18 +518,6 @@ TR = {
|
||||
|
||||
# --- settings: the agent ------------------------------------------------
|
||||
"Agent": "Ajan",
|
||||
"This shortcut records the same way dictation does, but the transcript is "
|
||||
"not what gets pasted. It goes to an agent as a command, and what comes "
|
||||
"back is pasted instead: the answer to a question, or a sentence saying "
|
||||
"what was done. Claude Code and Codex run as the session you would have "
|
||||
"opened yourself, with your skills, your connected services and your "
|
||||
"account.":
|
||||
"Bu kısayol dikte ile aynı şekilde kaydeder, ama yapıştırılan şey "
|
||||
"transkript değildir. Transkript bir ajana komut olarak gider ve yerine "
|
||||
"oradan döneni yapıştırılır: bir sorunun cevabı ya da ne yapıldığını "
|
||||
"söyleyen bir cümle. Claude Code ve Codex, kendi açacağın oturumun "
|
||||
"aynısı olarak çalışır: skill'lerinle, bağlı servislerinle ve kendi "
|
||||
"hesabınla.",
|
||||
"How it runs": "Nasıl çalışıyor",
|
||||
"Runs on": "Şunun üstünde çalışır",
|
||||
"More thinking is slower, and you are standing in front of the screen while "
|
||||
@@ -522,6 +544,24 @@ TR = {
|
||||
"Yukarıdaki çalışma dizini ve izinler burada bir şey ifade etmez.",
|
||||
"Needs no program installed, only the OpenRouter key.":
|
||||
"Kurulu bir programa değil, yalnızca OpenRouter anahtarına ihtiyaç duyar.",
|
||||
"A plain question and a plain answer, over the OpenCode Go key you already "
|
||||
"have. It runs no commands, opens no files and reaches none of your "
|
||||
"services, so it can tell you what the capital of Peru is but not what is "
|
||||
"in your calendar. Working directory and permissions above mean nothing "
|
||||
"here.":
|
||||
"Elindeki OpenCode Go anahtarı üzerinden düz bir soru ve düz bir cevap. "
|
||||
"Komut çalıştırmaz, dosya açmaz, servislerinin hiçbirine erişmez; yani "
|
||||
"Peru'nun başkentini söyler ama takviminde ne olduğunu söyleyemez. "
|
||||
"Yukarıdaki çalışma dizini ve izinler burada bir şey ifade etmez.",
|
||||
"Needs no program installed, only an OpenCode Go key.":
|
||||
"Kurulu bir programa değil, yalnızca bir OpenCode Go anahtarına ihtiyaç duyar.",
|
||||
"Antigravity has neither a permission mode nor a sandbox to hand it, so "
|
||||
"what it may do without asking is whatever its own allow-rules say. The "
|
||||
"Permissions and Sandbox boxes above belong to the other two; the working "
|
||||
"directory still applies.":
|
||||
"Antigravity'ye verilebilecek bir izin kipi ya da sandbox yok; sormadan "
|
||||
"ne yapabileceğini kendi allow-rule'ları belirler. Yukarıdaki İzinler ve "
|
||||
"Sandbox kutuları diğer ikisine ait; çalışma dizini burada da geçerli.",
|
||||
"{binary} is not on your PATH, so this cannot run yet. Install it, or pick "
|
||||
"another one above.":
|
||||
"{binary} PATH'te değil, dolayısıyla bu henüz çalışamaz. Kur ya da "
|
||||
@@ -537,6 +577,10 @@ TR = {
|
||||
"“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha "
|
||||
"çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, "
|
||||
"çünkü ekranın başında bekliyorsun.",
|
||||
"The list is a starting point, not a fence: any model name {name} accepts "
|
||||
"can be typed straight in.":
|
||||
"Liste başlangıç için, sınır değil: {name} hangi model adını kabul "
|
||||
"ediyorsa buraya elle yazılabilir.",
|
||||
"Permissions": "İzinler",
|
||||
"Decide on its own, with the safety checks on":
|
||||
"Kendi karar versin, güvenlik denetimleri açık",
|
||||
@@ -576,7 +620,7 @@ TR = {
|
||||
"configuration already says.":
|
||||
"Her komutla birlikte ajana söylenir, kendi yapılandırmanın zaten "
|
||||
"söylediklerinin üstüne eklenir.",
|
||||
" · asked Claude: {question}": " · Claude'a soruldu: {question}",
|
||||
" · asked {who}: {question}": " · {who} soruldu: {question}",
|
||||
|
||||
# --- meetings: tray and pipeline ---------------------------------------
|
||||
"Record a meeting": "Toplantı kaydet",
|
||||
@@ -646,12 +690,6 @@ TR = {
|
||||
|
||||
# --- settings: meeting --------------------------------------------------
|
||||
"Minutes": "Tutanaklar",
|
||||
"A meeting is recorded from two devices at once: your microphone and "
|
||||
"whatever comes out of your speakers. Nothing has to guess who was "
|
||||
"speaking, because the two never share a channel.":
|
||||
"Toplantı iki aygıttan aynı anda kaydedilir: mikrofonun ve hoparlöründen "
|
||||
"çıkan ses. Kimin konuştuğunun tahmin edilmesi gerekmez, çünkü ikisi hiç "
|
||||
"aynı kanala girmez.",
|
||||
"Sound": "Ses",
|
||||
"Same as dictation": "Diktedekiyle aynı",
|
||||
"Current output": "Geçerli çıkış",
|
||||
@@ -729,4 +767,125 @@ TR = {
|
||||
"This one is being written up right now.": "Bunun tutanağı şu anda çıkarılıyor.",
|
||||
"Delete this meeting, its minutes and its recording?":
|
||||
"Bu toplantı, tutanağı ve ses kaydı silinsin mi?",
|
||||
|
||||
# --- local models and downloads ------------------------------------
|
||||
# The whole box was born after the last translation pass, which left the
|
||||
# first-run screen half English on a Turkish machine.
|
||||
"Download": "İndir",
|
||||
"Delete": "Sil",
|
||||
"Program": "Program",
|
||||
"Publisher": "Yayıncı",
|
||||
"Automatic": "Otomatik",
|
||||
"Threads": "İş parçacığı",
|
||||
"On this machine": "Bu makinede",
|
||||
"Use the graphics card": "Ekran kartını kullan",
|
||||
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
||||
"Local whisper": "Yerel whisper",
|
||||
"Local model": "Yerel model",
|
||||
"Not installed.": "Kurulu değil.",
|
||||
"Installed on the system: {path}": "Sistemde kurulu: {path}",
|
||||
"Downloaded, version {version}.": "İndirildi, sürüm {version}.",
|
||||
"Fetching the model list…": "Model listesi çekiliyor…",
|
||||
"Downloading…": "İndiriliyor…",
|
||||
"Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}",
|
||||
"Download stopped.": "İndirme durduruldu.",
|
||||
"Ready: {name}.": "Hazır: {name}.",
|
||||
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
||||
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
|
||||
"downloaded": "indirildi",
|
||||
"not downloaded": "indirilmedi",
|
||||
"Delete model": "Modeli sil",
|
||||
"Delete {name} from this machine?": "{name} bu makineden silinsin mi?",
|
||||
"Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.",
|
||||
"A Hugging Face repository of GGUF files. The list is fetched; any other "
|
||||
"one can be typed in.":
|
||||
"GGUF dosyaları içeren bir Hugging Face deposu. Liste internetten "
|
||||
"çekilir; başka bir depo da yazılabilir.",
|
||||
"A large model takes a second or two to load. Loading it up front spends "
|
||||
"that once instead of on the first dictation, at the cost of the memory "
|
||||
"it sits in.":
|
||||
"Büyük bir modelin yüklenmesi bir iki saniye sürer. Baştan yüklemek bu "
|
||||
"bedeli ilk diktede değil bir kez öder; karşılığı, modelin oturduğu "
|
||||
"bellektir.",
|
||||
"An LLM is slower to load than a whisper model and sits in more memory. "
|
||||
"Off means it is loaded on the first cleanup instead.":
|
||||
"Bir LLM, whisper modelinden daha geç yüklenir ve daha çok bellekte "
|
||||
"oturur. Kapalı, ilk temizlemede yüklenmesi demektir.",
|
||||
"A model trained to think will think unless it is told not to, and "
|
||||
"spending 300 tokens of reasoning on a comma is 300 tokens of waiting. "
|
||||
"Off is what cleanup wants.":
|
||||
"Düşünmeye eğitilmiş bir model, aksi söylenmedikçe düşünür; bir virgül "
|
||||
"için 300 token akıl yürütmek 300 token'lık bekleyiştir. Temizleme için "
|
||||
"doğrusu Kapalı.",
|
||||
"whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the build "
|
||||
"it is running was made with one. A build without any of them runs on the "
|
||||
"processor whatever this says.":
|
||||
"whisper.cpp karta CUDA, ROCm ya da Vulkan üzerinden ulaşır; koştuğu "
|
||||
"derleme bunlardan biriyle yapılmışsa. Hiçbiri olmadan derlenmiş bir "
|
||||
"kopya, bu ne derse desin işlemcide çalışır.",
|
||||
"whisper.cpp is not installed. Settings → API and models → Download.":
|
||||
"whisper.cpp kurulu değil. Ayarlar → API ve modeller → İndir.",
|
||||
"llama.cpp is not installed. Settings → API and models → Download.":
|
||||
"llama.cpp kurulu değil. Ayarlar → API ve modeller → İndir.",
|
||||
"No whisper model has been downloaded yet. Settings → API and models → "
|
||||
"Download.":
|
||||
"Henüz whisper modeli indirilmedi. Ayarlar → API ve modeller → İndir.",
|
||||
"No local cleanup model has been downloaded yet. Settings → API and "
|
||||
"models → Download.":
|
||||
"Henüz yerel temizleme modeli indirilmedi. Ayarlar → API ve modeller "
|
||||
"→ İndir.",
|
||||
"Hugging Face did not return a model list.":
|
||||
"Hugging Face model listesi döndürmedi.",
|
||||
"{repo} did not return a file list.": "{repo} dosya listesi döndürmedi.",
|
||||
"{repo} has no downloadable release.":
|
||||
"{repo} deposunun indirilebilir bir sürümü yok.",
|
||||
"{repo} {tag} has no build for this machine.":
|
||||
"{repo} {tag} bu makine için derleme içermiyor.",
|
||||
"{url} answered HTTP {code}.": "{url} HTTP {code} yanıtı verdi.",
|
||||
"Could not reach {url}: {error}": "{url} adresine ulaşılamadı: {error}",
|
||||
"Could not read the answer from {url}: {error}":
|
||||
"{url} yanıtı okunamadı: {error}",
|
||||
"Could not create {path}: {error}": "{path} oluşturulamadı: {error}",
|
||||
"Could not download {name}: HTTP {code}":
|
||||
"{name} indirilemedi: HTTP {code}",
|
||||
"Could not download {name}: {error}": "{name} indirilemedi: {error}",
|
||||
"Could not write {name}: {error}": "{name} yazılamadı: {error}",
|
||||
"Could not unpack {name}: {error}": "{name} açılamadı: {error}",
|
||||
"Could not install {name}: {error}": "{name} kurulamadı: {error}",
|
||||
"Could not start {name}: {error}": "{name} başlatılamadı: {error}",
|
||||
"Could not delete the model: {error}": "Model silinemedi: {error}",
|
||||
"Could not replace {path}: a file in it is still open: {error}":
|
||||
"{path} değiştirilemedi: içindeki bir dosya hâlâ açık: {error}",
|
||||
"{name} did not start: {error}": "{name} başlamadı: {error}",
|
||||
"no output": "çıktı yok",
|
||||
"The download stopped early ({done} of {total}).":
|
||||
"İndirme erken kesildi ({done} / {total}).",
|
||||
"{name} is longer than it said it would be.":
|
||||
"{name} bildirdiğinden daha uzun çıktı.",
|
||||
"{name} does not match its published checksum. Nothing was installed.":
|
||||
"{name} yayımlanan sağlama toplamıyla uyuşmuyor. Hiçbir şey kurulmadı.",
|
||||
"{name} is published without a checksum, so there is no way to tell what "
|
||||
"arrived. Nothing was installed.":
|
||||
"{name} sağlama toplamı olmadan yayımlanmış; gelenin ne olduğu "
|
||||
"doğrulanamaz. Hiçbir şey kurulmadı.",
|
||||
"{name} was not in the download.": "{name} indirilenin içinde yoktu.",
|
||||
"{name} downloaded, but the old file is held open by the running server. "
|
||||
"Stop it and try again.":
|
||||
"{name} indirildi ama eski dosyayı çalışan sunucu açık tutuyor. "
|
||||
"Sunucuyu durdurup yeniden dene.",
|
||||
"The cleanup model spent its whole reply on thinking. Set Thinking to "
|
||||
"“Off”.":
|
||||
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
|
||||
"“Kapalı” yap.",
|
||||
|
||||
# --- this pass's new messages ---------------------------------------
|
||||
"Audio recorder stopped before receiving sound":
|
||||
"Ses kayıt aracı veri alamadan kapandı",
|
||||
"Could not write the recording: {error}": "Kayıt dosyası yazılamadı: {error}",
|
||||
"Copied, but pasting failed: {error}":
|
||||
"Kopyalandı ama yapıştırma başarısız: {error}",
|
||||
"The recording was kept: {path}": "Kayıt saklandı: {path}",
|
||||
"The recording stopped on its own; transcribing what was captured.":
|
||||
"Kayıt kendi kendine durdu; yakalanan kısım yazıya dökülüyor.",
|
||||
"Could not save the settings: {error}": "Ayarlar kaydedilemedi: {error}",
|
||||
}
|
||||
|
||||
+58
-11
@@ -56,6 +56,17 @@ def packaged():
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def windowed_executable(executable=None):
|
||||
"""The windowed executable installed beside this one, or None.
|
||||
|
||||
Beside rather than at a known place, because the setup program lays the two
|
||||
executables into the same directory wherever that directory was put: asking
|
||||
from either of them finds the other without knowing where the install is.
|
||||
"""
|
||||
windowed = pathlib.Path(executable or sys.executable).with_name(WINDOWS_APP)
|
||||
return windowed if windowed.is_file() else None
|
||||
|
||||
|
||||
def target():
|
||||
"""The file a launcher has to name to start this build again.
|
||||
|
||||
@@ -74,8 +85,8 @@ def target():
|
||||
# The windowed executable, whichever of the two is running: the console
|
||||
# one is what the `dikte` command names, and a sign-in that started
|
||||
# that one would open a console window nobody asked for.
|
||||
windowed = executable.with_name(WINDOWS_APP)
|
||||
if windowed.is_file():
|
||||
windowed = windowed_executable(executable)
|
||||
if windowed is not None:
|
||||
return windowed
|
||||
return executable
|
||||
|
||||
@@ -572,24 +583,60 @@ def _run_entry_name():
|
||||
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
|
||||
|
||||
|
||||
def _run_target(value):
|
||||
"""The executable a Run value names, out of the quoting the setup wrote.
|
||||
|
||||
Only the first word matters here: it is the file whose existence says
|
||||
whether the entry still starts anything.
|
||||
"""
|
||||
if value.startswith('"'):
|
||||
closing = value.find('"', 1)
|
||||
return value[1:closing] if closing > 0 else ""
|
||||
return value.split(" ", 1)[0]
|
||||
|
||||
|
||||
def _startup_shortcut():
|
||||
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if not appdata:
|
||||
return None
|
||||
return (pathlib.Path(appdata) / "Microsoft" / "Windows" / "Start Menu"
|
||||
/ "Programs" / "Startup" / "Dikte.lnk")
|
||||
|
||||
|
||||
def _windows_install(app, force=False):
|
||||
"""Point the autostart entry at this build. What changed.
|
||||
|
||||
Only `force`, which is what typing `dikte integrate` means, creates one.
|
||||
The call on every start repairs an entry that is already there and names an
|
||||
executable somewhere else, which is what an installation moved to another
|
||||
drive or reinstalled into another directory leaves behind; somebody who
|
||||
unticked the box in the wizard, or turned it off since, is not asked again
|
||||
by every start.
|
||||
executable that is gone, which is what an installation moved to another
|
||||
drive or reinstalled into another directory leaves behind. An entry naming
|
||||
an executable that still exists is another installation that still works,
|
||||
and is stood aside for the way the Linux half stands aside for another
|
||||
menu entry; somebody who unticked the box in the wizard, or turned it off
|
||||
since, is not asked again by every start either.
|
||||
"""
|
||||
command = f'"{app}"'
|
||||
current = _run_entry()
|
||||
changed = []
|
||||
if force:
|
||||
# install.ps1 -Autostart wrote this for a checkout. The Run value
|
||||
# written below replaces it, and both left in place would be two
|
||||
# Diktes at every sign-in. Only on force: the silent call on every
|
||||
# start has not been asked to move the machine off its checkout.
|
||||
shortcut = _startup_shortcut()
|
||||
if shortcut is not None and shortcut.is_file():
|
||||
shortcut.unlink()
|
||||
changed.append(shortcut)
|
||||
if not current and not force:
|
||||
return []
|
||||
if current == command:
|
||||
return []
|
||||
_write_run_entry(command)
|
||||
return [_run_entry_name()]
|
||||
return changed
|
||||
if current != command:
|
||||
theirs = _run_target(current) if current else ""
|
||||
if not force and theirs and theirs != str(app) and os.path.exists(theirs):
|
||||
return changed
|
||||
_write_run_entry(command)
|
||||
changed.append(_run_entry_name())
|
||||
return changed
|
||||
|
||||
|
||||
def _windows_remove():
|
||||
|
||||
+60
-4
@@ -11,11 +11,14 @@ shortcut may still send.
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from PyQt6.QtCore import QLockFile
|
||||
from PyQt6.QtNetwork import QLocalSocket
|
||||
|
||||
from . import integrate
|
||||
from . import paths
|
||||
|
||||
SERVER_NAME = "dikte-" + (
|
||||
str(os.getuid()) if hasattr(os, "getuid")
|
||||
@@ -54,10 +57,9 @@ def launcher():
|
||||
if not getattr(sys, "frozen", False):
|
||||
return [sys.executable, script_path()]
|
||||
if sys.platform == "win32":
|
||||
windowed = os.path.join(os.path.dirname(sys.executable),
|
||||
integrate.WINDOWS_APP)
|
||||
if os.path.isfile(windowed):
|
||||
return [windowed]
|
||||
windowed = integrate.windowed_executable()
|
||||
if windowed is not None:
|
||||
return [str(windowed)]
|
||||
return [os.environ.get("APPIMAGE") or sys.executable]
|
||||
|
||||
|
||||
@@ -72,6 +74,60 @@ def command_for(verb):
|
||||
return shlex.join(launcher() + ([verb] if verb else []))
|
||||
|
||||
|
||||
def already_serving():
|
||||
"""Whether a running instance answers on this user's name.
|
||||
|
||||
Asked before an instance opens a server of its own, because listen() is
|
||||
not the check: a Windows named pipe takes a second server on the same name
|
||||
rather than refusing it, and everywhere else removeServer() would first
|
||||
take the live socket away from the instance holding it. Either way two
|
||||
whole Diktes then run, and the newer one's sweep() kills the whisper the
|
||||
older one is answering dictations with. The probe is "status" and nothing
|
||||
else: a verb with a side effect here would fire it during the relaunch a
|
||||
slow-to-answer instance provokes, on top of the verb being forwarded.
|
||||
"""
|
||||
return send("status") is not None
|
||||
|
||||
|
||||
def instance_lock():
|
||||
"""This user's one-Dikte lock, taken before anything else is built.
|
||||
|
||||
The probe above has a hole: two copies started in the same moment both ask
|
||||
before either listens, and both come up. A lock file closes it, and
|
||||
QLockFile writes the holder's pid into it, so a lock a killed instance
|
||||
left behind identifies itself as stale and clears. None when the data
|
||||
directory cannot be made, which a start should survive: the probe still
|
||||
stands guard, just without the simultaneous-start case.
|
||||
"""
|
||||
try:
|
||||
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return None
|
||||
lock = QLockFile(str(paths.DATA_DIR / "dikte.lock"))
|
||||
# Never presume a lock is stale by age alone; the pid check is the truth.
|
||||
lock.setStaleLockTime(0)
|
||||
return lock
|
||||
|
||||
|
||||
def respawn(arguments):
|
||||
"""Start this installation again with `arguments`, leaving this process.
|
||||
|
||||
execv everywhere it works the way it says: the new process takes this
|
||||
pid and nothing is left behind. On Windows execv mangles arguments with
|
||||
spaces and leaves the two processes sharing a console, so the replacement
|
||||
is started detached instead and the caller exits on its own.
|
||||
"""
|
||||
args = launcher() + list(arguments)
|
||||
if sys.platform == "win32":
|
||||
# By value where the names are missing, so the Windows half of this is
|
||||
# testable from the suite's other platforms too.
|
||||
detached = (getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200))
|
||||
subprocess.Popen(args, creationflags=detached, close_fds=True)
|
||||
return
|
||||
os.execv(args[0], args)
|
||||
|
||||
|
||||
def send(cmd, wait=False, timeout=0, **args):
|
||||
"""Send one request; the reply, or None when no instance is running.
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The parts of AppKit a dictation needs on macOS and Qt does not reach.
|
||||
|
||||
Two jobs, both about staying out of the user's way.
|
||||
|
||||
The first is keeping the indicator on screen. Qt draws it as a tool window,
|
||||
which on macOS is an NSPanel, and an NSPanel is hidden by the system the moment
|
||||
its application stops being the active one. For a dictation indicator that is
|
||||
exactly backwards: you press the shortcut inside some other program, so Dikte is
|
||||
never the active application, and the one window that has something to say
|
||||
disappears as soon as you look away from it. Three settings AppKit has and Qt
|
||||
does not expose:
|
||||
|
||||
hidesOnDeactivate = NO stay put when another application comes forward
|
||||
collectionBehavior show on whichever desktop is in front, including
|
||||
over a full screen window, and stay out of Cmd+Tab
|
||||
nonactivating panel come to the front without bringing Dikte with it
|
||||
|
||||
The second is putting the front back. Opening the microphone activates Dikte
|
||||
whatever the indicator does, so app.py watches for that and calls activate()
|
||||
here. See _give_the_front_back() there for the measurement.
|
||||
|
||||
Done through the Objective-C runtime rather than a binding, because Dikte has no
|
||||
third party Python packages and this is a handful of messages to three objects.
|
||||
The runtime is loaded in _appkit() rather than at import, the way paste.py loads
|
||||
its frameworks in _macos_api(): that is the one function a test fakes, and it is
|
||||
what lets the tests below run on a machine that has no AppKit at all.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
|
||||
from PyQt6.QtGui import QGuiApplication
|
||||
|
||||
# NSWindowCollectionBehavior, as of the macOS these names come from:
|
||||
CAN_JOIN_ALL_SPACES = 1 << 0
|
||||
IGNORES_CYCLE = 1 << 6 # not a window Cmd+Tab should ever land on
|
||||
FULL_SCREEN_AUXILIARY = 1 << 8
|
||||
BEHAVIOUR = CAN_JOIN_ALL_SPACES | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY
|
||||
|
||||
# NSWindowStyleMaskNonactivatingPanel. Without it, ordering the indicator to
|
||||
# the front brings Dikte to the front with it, and the application the user was
|
||||
# typing in loses focus the moment they start dictating: the Cmd+V at the end
|
||||
# then lands on the indicator instead of their document. Qt has no flag for
|
||||
# this; WA_ShowWithoutActivating governs the show, not what the panel does to
|
||||
# the application afterwards.
|
||||
NONACTIVATING_PANEL = 1 << 7
|
||||
|
||||
_appkit_runtime = None
|
||||
|
||||
|
||||
class _AppKit:
|
||||
"""objc_msgSend under the signatures this file sends it through.
|
||||
|
||||
It has no fixed signature of its own, and calling it through the wrong
|
||||
argument or return types is how a Mac crashes rather than raises, so each
|
||||
one is spelled out once here and used by name below.
|
||||
"""
|
||||
|
||||
def __init__(self, objc):
|
||||
self.objc = objc
|
||||
objc.sel_registerName.restype = ctypes.c_void_p
|
||||
objc.sel_registerName.argtypes = [ctypes.c_char_p]
|
||||
objc.objc_getClass.restype = ctypes.c_void_p
|
||||
objc.objc_getClass.argtypes = [ctypes.c_char_p]
|
||||
self.ask = self._as(ctypes.c_void_p)
|
||||
self.ask_bool = self._as(ctypes.c_bool)
|
||||
self.ask_pid = self._as(ctypes.c_int) # pid_t is an int32
|
||||
self.ask_unsigned = self._as(ctypes.c_ulong) # NSUInteger
|
||||
self.tell_bool = self._as(None, ctypes.c_bool)
|
||||
self.tell_unsigned = self._as(None, ctypes.c_ulong)
|
||||
self.ask_of_class = self._as(ctypes.c_bool, ctypes.c_void_p)
|
||||
self.ask_of_pid = self._as(ctypes.c_void_p, ctypes.c_int)
|
||||
self.ask_with_options = self._as(ctypes.c_bool, ctypes.c_ulong)
|
||||
|
||||
def _as(self, returns, *arguments):
|
||||
return ctypes.cast(self.objc.objc_msgSend, ctypes.CFUNCTYPE(
|
||||
returns, ctypes.c_void_p, ctypes.c_void_p, *arguments))
|
||||
|
||||
def selector(self, name):
|
||||
return self.objc.sel_registerName(name)
|
||||
|
||||
def shared(self, class_name, selector):
|
||||
"""A class's singleton, e.g. +[NSWorkspace sharedWorkspace]."""
|
||||
return ctypes.c_void_p(self.ask(
|
||||
ctypes.c_void_p(self.objc.objc_getClass(class_name)),
|
||||
self.selector(selector)))
|
||||
|
||||
|
||||
def _appkit():
|
||||
"""The Objective-C runtime, loaded the first time something needs it.
|
||||
|
||||
Loaded here rather than at import so that this module can be imported on a
|
||||
machine that has no AppKit: the tests stand on macOS from a Linux machine
|
||||
and back, and this is the one function they replace to do it.
|
||||
"""
|
||||
global _appkit_runtime
|
||||
if _appkit_runtime is None:
|
||||
_appkit_runtime = _AppKit(
|
||||
ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")))
|
||||
return _appkit_runtime
|
||||
|
||||
|
||||
def frontmost_pid():
|
||||
"""Which application is in front, by process id, or None when unasked.
|
||||
|
||||
A process id rather than the object itself: the object would have to be
|
||||
retained to survive the trip, and a number needs nothing looking after it.
|
||||
"""
|
||||
try:
|
||||
api = _appkit()
|
||||
workspace = api.shared(b"NSWorkspace", b"sharedWorkspace")
|
||||
running = ctypes.c_void_p(api.ask(
|
||||
workspace, api.selector(b"frontmostApplication")))
|
||||
if not running:
|
||||
return None
|
||||
return int(api.ask_pid(running, api.selector(b"processIdentifier")))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def activate(pid):
|
||||
"""Put the application with that process id back in front.
|
||||
|
||||
False when it has gone away in the meantime, or when the message could not
|
||||
be sent at all: a dictation is not worth failing over the window behind it.
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
api = _appkit()
|
||||
running = ctypes.c_void_p(api.ask_of_pid(
|
||||
ctypes.c_void_p(api.objc.objc_getClass(b"NSRunningApplication")),
|
||||
api.selector(b"runningApplicationWithProcessIdentifier:"),
|
||||
int(pid)))
|
||||
if not running:
|
||||
return False
|
||||
# activateWithOptions: rather than the deprecated activate, and with no
|
||||
# options: bringing every one of its windows forward is not asked for,
|
||||
# only the application it was before Dikte took the front from it.
|
||||
return bool(api.ask_with_options(
|
||||
running, api.selector(b"activateWithOptions:"), 0))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_frontmost():
|
||||
"""Whether Dikte itself is the application in front.
|
||||
|
||||
By process id rather than -[NSRunningApplication isActive] on our own
|
||||
process, which stays true once the application has ever been activated:
|
||||
measured True with another application plainly in front.
|
||||
"""
|
||||
pid = frontmost_pid()
|
||||
return pid is not None and pid == os.getpid()
|
||||
|
||||
|
||||
def _is_panel(api, window):
|
||||
"""Whether this window is an NSPanel, which is the only kind the
|
||||
nonactivating bit is legal on: setting it on a plain NSWindow raises an
|
||||
Objective-C exception, and an exception through ctypes takes the process
|
||||
down with it."""
|
||||
panel = api.objc.objc_getClass(b"NSPanel")
|
||||
if not panel:
|
||||
return False
|
||||
return bool(api.ask_of_class(window, api.selector(b"isKindOfClass:"),
|
||||
ctypes.c_void_p(panel)))
|
||||
|
||||
|
||||
def keep_on_screen(widget):
|
||||
"""Ask the window behind `widget` to stay while other programs are used.
|
||||
|
||||
Silent when anything is not as expected: an indicator that cannot be made
|
||||
to linger is still an indicator, and a dictation should not fail over the
|
||||
window it is drawn in.
|
||||
"""
|
||||
# Only the Cocoa backend hands out a real NSView. Under the offscreen
|
||||
# platform the tests run on, winId() is a number that means something else
|
||||
# entirely, and sending an Objective-C message to it is how a test run
|
||||
# turns into a crash.
|
||||
if QGuiApplication.platformName() != "cocoa":
|
||||
return False
|
||||
try:
|
||||
api = _appkit()
|
||||
view = ctypes.c_void_p(int(widget.winId()))
|
||||
window = api.ask(view, api.selector(b"window"))
|
||||
if not window:
|
||||
return False
|
||||
window = ctypes.c_void_p(window)
|
||||
api.tell_bool(window, api.selector(b"setHidesOnDeactivate:"), False)
|
||||
api.tell_unsigned(window, api.selector(b"setCollectionBehavior:"),
|
||||
BEHAVIOUR)
|
||||
# Only a panel may carry the nonactivating bit, and only a panel is
|
||||
# asked to: on anything else the message raises, and an Objective-C
|
||||
# exception through ctypes takes the process with it.
|
||||
if _is_panel(api, window):
|
||||
mask = api.ask_unsigned(window, api.selector(b"styleMask"))
|
||||
if not mask & NONACTIVATING_PANEL:
|
||||
api.tell_unsigned(window, api.selector(b"setStyleMask:"),
|
||||
mask | NONACTIVATING_PANEL)
|
||||
return True
|
||||
except (AttributeError, OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
+19
-3
@@ -7,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
||||
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
|
||||
from PyQt6.QtWidgets import QWidget, QApplication
|
||||
|
||||
from . import mac_window
|
||||
|
||||
BARS = 22
|
||||
HEIGHT = 56
|
||||
MIN_WIDTH = 210
|
||||
@@ -40,9 +42,11 @@ class Overlay(QWidget):
|
||||
of covering it, which is what lets a dictation and a command to the agent be
|
||||
under way at the same time and still both be visible."""
|
||||
|
||||
def __init__(self, corner="bottom-left", below=None, dismissable=False):
|
||||
def __init__(self, corner="bottom-left", below=None, dismissable=False,
|
||||
screen_name=""):
|
||||
super().__init__(None)
|
||||
self.corner = corner
|
||||
self.screen_name = screen_name
|
||||
self.below = below
|
||||
# A job that can run for ten minutes should not have to be watched for
|
||||
# ten minutes. Clicking such an indicator puts the progress away; the
|
||||
@@ -202,6 +206,11 @@ class Overlay(QWidget):
|
||||
self._reposition()
|
||||
if not self.isVisible():
|
||||
self.show()
|
||||
if sys.platform == "darwin":
|
||||
# After show(), because the window it works on does not exist until
|
||||
# then, and every time, because a window Qt rebuilt has the setting
|
||||
# again at its default.
|
||||
mac_window.keep_on_screen(self)
|
||||
if self._concealed:
|
||||
self.raise_()
|
||||
self._concealed = False
|
||||
@@ -244,8 +253,15 @@ class Overlay(QWidget):
|
||||
self.resize(width, HEIGHT)
|
||||
|
||||
def _reposition(self):
|
||||
# On a multi-monitor setup, show up where the user actually is.
|
||||
screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen()
|
||||
# The screen the settings name, or, when none is named or it is not
|
||||
# plugged in right now, where the user actually is. Names are connector
|
||||
# names on X11 and model names on macOS, where two identical monitors
|
||||
# can share one; the first then wins.
|
||||
screen = next(
|
||||
(item for item in QApplication.screens() if item.name() == self.screen_name),
|
||||
None,
|
||||
)
|
||||
screen = screen or QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen()
|
||||
area = screen.availableGeometry()
|
||||
left = "left" in self.corner
|
||||
top = "top" in self.corner
|
||||
|
||||
+37
-6
@@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""):
|
||||
def ready():
|
||||
return shutil.which(program) is not None
|
||||
|
||||
def press(shortcut, delay):
|
||||
def press(shortcut, delay, _focus=None):
|
||||
# Nothing here takes the front from the window being dictated into, so
|
||||
# there is nothing to hand back: the process id is a macOS concern.
|
||||
if not ready():
|
||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||
tool=program))
|
||||
@@ -296,11 +298,27 @@ def _ask_for_permission():
|
||||
pass
|
||||
|
||||
|
||||
def _macos_press(shortcut, delay):
|
||||
def _macos_press(shortcut, delay, focus=None):
|
||||
"""Post the key down and up straight into the window system.
|
||||
|
||||
Nothing is typed anywhere until macOS has been told to trust Dikte, and it
|
||||
only asks once, when the paste it was granted for is first tried.
|
||||
|
||||
`focus` is the application that was in front when the recording began. The
|
||||
keys land wherever the window system is pointing, so a Dikte that has ended
|
||||
up in front would swallow its own transcript; when that has happened the
|
||||
front is handed back before pressing. Nothing is taken from anyone else: an
|
||||
application the user went to while the transcription ran is where they want
|
||||
the text now.
|
||||
|
||||
This runs on the transcription's own thread rather than the main one, and
|
||||
the two calls it makes are the kind AppKit documents as answering
|
||||
atomically wherever they are asked from: NSRunningApplication is thread
|
||||
safe by its own header, and the workspace lookup behind it returns a
|
||||
reference rather than anything that has to be held. Stressed with four
|
||||
threads and 32000 lookups against a running main loop without a fault; if
|
||||
one ever does happen, mac_window answers None and the press goes ahead
|
||||
where it would have gone anyway.
|
||||
"""
|
||||
keycode, flags = _macos_keys(shortcut)
|
||||
services, core = _macos_api()
|
||||
@@ -310,6 +328,12 @@ def _macos_press(shortcut, delay):
|
||||
"macOS has not been told to let Dikte press keys. Turn Dikte on "
|
||||
"under System Settings → Privacy & Security → Accessibility."
|
||||
))
|
||||
if focus:
|
||||
# Imported here rather than at the top: it reaches for QtGui, and a
|
||||
# terminal that only wants the clipboard should not pay for that.
|
||||
from . import mac_window
|
||||
if mac_window.is_frontmost():
|
||||
mac_window.activate(focus)
|
||||
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
||||
@@ -464,11 +488,14 @@ class _WinInput(ctypes.Structure):
|
||||
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
|
||||
|
||||
|
||||
def _win_press(shortcut, delay):
|
||||
def _win_press(shortcut, delay, _focus=None):
|
||||
"""Post the presses and releases straight into the input queue.
|
||||
|
||||
No permission stands in front of SendInput the way Accessibility does on
|
||||
macOS: whatever window has focus receives the combination.
|
||||
|
||||
Nothing here takes the front from the window being dictated into, so the
|
||||
remembered process id has nothing to hand back to: it is a macOS concern.
|
||||
"""
|
||||
codes = _win_keys(shortcut)
|
||||
user32, _ = _win_api()
|
||||
@@ -675,7 +702,11 @@ def paste_ready():
|
||||
return desktop().ready()
|
||||
|
||||
|
||||
def press(shortcut="", delay=0.12):
|
||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
|
||||
def press(shortcut="", delay=0.12, focus=None):
|
||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own.
|
||||
|
||||
`focus` is the process the keys are meant for, remembered when the
|
||||
recording started: see the macOS press for what is done with it.
|
||||
"""
|
||||
here = desktop()
|
||||
here.press(shortcut or here.shortcuts[0], delay)
|
||||
here.press(shortcut or here.shortcuts[0], delay, focus)
|
||||
|
||||
+30
-5
@@ -13,10 +13,18 @@ platform as an argument so that a test can stand on the other one.
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# The one other platform constant every subprocess site needs, kept in this
|
||||
# leaf so no caller has to pull the audio stack in for it: console programs
|
||||
# started from a windowless process would otherwise each open a console window
|
||||
# of their own on Windows.
|
||||
NO_WINDOW = (getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if sys.platform == "win32" else 0)
|
||||
|
||||
def _env(var, default):
|
||||
|
||||
def env_path(var, default):
|
||||
"""The directory a variable names, or the one it stands in for."""
|
||||
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
|
||||
|
||||
@@ -34,11 +42,28 @@ def directories(platform=None):
|
||||
support = pathlib.Path.home() / "Library/Application Support/Dikte"
|
||||
return support, support
|
||||
if here == "win32":
|
||||
roaming = _env("APPDATA", "~/AppData/Roaming")
|
||||
local = _env("LOCALAPPDATA", "~/AppData/Local")
|
||||
roaming = env_path("APPDATA", "~/AppData/Roaming")
|
||||
local = env_path("LOCALAPPDATA", "~/AppData/Local")
|
||||
return roaming / "Dikte", local / "Dikte"
|
||||
return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte",
|
||||
_env("XDG_DATA_HOME", "~/.local/share") / "dikte")
|
||||
return (env_path("XDG_CONFIG_HOME", "~/.config") / "dikte",
|
||||
env_path("XDG_DATA_HOME", "~/.local/share") / "dikte")
|
||||
|
||||
|
||||
def cache_dir(platform=None):
|
||||
"""The directory for answers worth keeping but never worth backing up.
|
||||
|
||||
A third place because a cache is neither settings nor data: losing it costs
|
||||
a network request, not a model or a preference, and every system sets aside
|
||||
a directory for exactly that kind of file, one that backups skip and
|
||||
cleanup tools may empty. Storing it with the data would ask a backup to
|
||||
carry files whose whole point is that they can be thrown away.
|
||||
"""
|
||||
here = platform or sys.platform
|
||||
if here == "darwin":
|
||||
return pathlib.Path.home() / "Library/Caches/Dikte"
|
||||
if here == "win32":
|
||||
return env_path("LOCALAPPDATA", "~/AppData/Local") / "Dikte" / "cache"
|
||||
return env_path("XDG_CACHE_HOME", "~/.cache") / "dikte"
|
||||
|
||||
|
||||
CONFIG_DIR, DATA_DIR = directories()
|
||||
|
||||
+608
-84
File diff suppressed because it is too large
Load Diff
+158
@@ -0,0 +1,158 @@
|
||||
"""Whether a newer Dikte has been published, and where to get it.
|
||||
|
||||
GitHub is asked for the newest release, its number is held against the one this
|
||||
build carries, and that is where it stops. Nothing is downloaded and nothing is
|
||||
replaced. The four downloads are installed in four different ways, and three of
|
||||
those belong to the platform rather than to Dikte: a Mac bundle is dragged into
|
||||
Applications and cannot rewrite itself while it is running, the Windows setup
|
||||
is an installer with an uninstall entry of its own, an AppImage is a single
|
||||
file kept wherever its owner keeps it, and a checkout is updated with git. A
|
||||
program that guessed at all four would be wrong on at least one of them, and
|
||||
being wrong there means an installation somebody has to repair by hand. So the
|
||||
answer ends in a browser, on the release page, where the same download that was
|
||||
installed the first time is waiting.
|
||||
|
||||
The clock is kept in a file of its own rather than in the settings. A check
|
||||
runs while the settings window may be open, and a background write into
|
||||
config.json is exactly what would overwrite a setting somebody is in the middle
|
||||
of changing.
|
||||
|
||||
Nothing here imports Qt or the rest of the application: `dikte update` at a
|
||||
terminal and the timer behind the tray icon ask the same three questions of the
|
||||
same module.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import itertools
|
||||
import json
|
||||
import time
|
||||
|
||||
from . import __version__
|
||||
from . import hub
|
||||
from . import paths
|
||||
|
||||
REPO = "yusufipk/dikte"
|
||||
# Where somebody is sent. GitHub redirects this to whatever the newest release
|
||||
# is, so it stays right without anybody writing a number into it.
|
||||
RELEASES_PAGE = f"https://github.com/{REPO}/releases/latest"
|
||||
|
||||
# Once a day. A release happens every few weeks at best, and a question nobody
|
||||
# is waiting on is not one to ask GitHub on every start.
|
||||
INTERVAL = 24 * 3600
|
||||
|
||||
# When the last check was, what it found, and which version has already been
|
||||
# announced. In the data directory rather than the config one: it is not a
|
||||
# setting, nobody edits it, and losing it costs one extra request.
|
||||
STATE_FILE = paths.DATA_DIR / "update.json"
|
||||
|
||||
Release = collections.namedtuple("Release", "version url published")
|
||||
|
||||
|
||||
def _numbers(version):
|
||||
"""(1, 0, 2) for "v1.0.2", "1.0.2" and "1.0.2-dev.abc1234" alike.
|
||||
|
||||
Empty for anything that does not start with a number, which is what a tag
|
||||
naming something other than a version comes back as.
|
||||
"""
|
||||
number = str(version or "").strip().lstrip("vV").split("-")[0].split("+")[0]
|
||||
parts = []
|
||||
for piece in number.split("."):
|
||||
digits = "".join(itertools.takewhile(str.isdigit, piece))
|
||||
if not digits:
|
||||
break
|
||||
parts.append(int(digits))
|
||||
return tuple((parts + [0, 0, 0])[:3]) if parts else ()
|
||||
|
||||
|
||||
def newer(there, here=""):
|
||||
"""Whether the release numbered `there` is one this build has not got.
|
||||
|
||||
Only the numbers are compared, and what follows them is dropped. A build
|
||||
off master carries the released number with its commit after it
|
||||
(1.0.1-dev.abc1234), and that build is ahead of 1.0.1 rather than behind
|
||||
it; read as a version suffix it would be behind, and every nightly would be
|
||||
told to update to the release it was already past.
|
||||
"""
|
||||
theirs = _numbers(there)
|
||||
return bool(theirs) and theirs > _numbers(here or __version__)
|
||||
|
||||
|
||||
def state():
|
||||
"""What the last check wrote down; empty when there has never been one."""
|
||||
try:
|
||||
stored = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return stored if isinstance(stored, dict) else {}
|
||||
|
||||
|
||||
def _store(**changes):
|
||||
stored = state()
|
||||
stored.update(changes)
|
||||
try:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(stored), encoding="utf-8")
|
||||
except OSError:
|
||||
pass # a check that cannot be written down still happened
|
||||
return stored
|
||||
|
||||
|
||||
def due(now=0):
|
||||
"""Whether a day has gone by since the last time anybody asked."""
|
||||
return (now or time.time()) - float(state().get("checked") or 0) >= INTERVAL
|
||||
|
||||
|
||||
def latest(refresh=False):
|
||||
"""The newest published release, asked for outright. Raises HubError."""
|
||||
tag, url, published = hub.newest_release(REPO, refresh=refresh)
|
||||
return Release(tag.lstrip("vV"), url or RELEASES_PAGE, published)
|
||||
|
||||
|
||||
def remember(release):
|
||||
"""Write down that a check has just happened, and what it found."""
|
||||
_store(checked=time.time(), version=release.version, url=release.url,
|
||||
published=release.published)
|
||||
|
||||
|
||||
def pending():
|
||||
"""The newer release the last check found, without asking anybody.
|
||||
|
||||
What the tray icon is built from: the answer has to be there the moment it
|
||||
appears, and a request on the way to the screen is a request nobody has
|
||||
time for.
|
||||
"""
|
||||
stored = state()
|
||||
version = stored.get("version") or ""
|
||||
if not newer(version):
|
||||
return None
|
||||
return Release(version, stored.get("url") or RELEASES_PAGE,
|
||||
stored.get("published") or "")
|
||||
|
||||
|
||||
def check(force=False):
|
||||
"""A newer release, or None when there is nothing to say.
|
||||
|
||||
The scheduled half: it asks only when a day has gone by, and answers from
|
||||
what the last check found in between. `force` is the button in Settings and
|
||||
the command line, which ask whatever the clock says.
|
||||
|
||||
The clock here is the only throttle. Once it has decided to ask, it asks
|
||||
for real rather than reading hub.py's few hours of cache, which is there to
|
||||
keep a settings window from fetching the same model list twice in an
|
||||
evening and would only ever answer this with something it already knew.
|
||||
"""
|
||||
if not force and not due():
|
||||
return pending()
|
||||
release = latest(refresh=True)
|
||||
remember(release)
|
||||
return release if newer(release.version) else None
|
||||
|
||||
|
||||
def announced():
|
||||
"""The version somebody has already been shown a notification about."""
|
||||
return state().get("announced") or ""
|
||||
|
||||
|
||||
def mark_announced(version):
|
||||
"""Said once. A daily check must not be a daily interruption."""
|
||||
_store(announced=version)
|
||||
+3
-1
@@ -17,13 +17,15 @@ import unicodedata
|
||||
|
||||
# Stock phrases the models produce when handed silence. Kept deliberately
|
||||
# narrow: only sentences nobody dictates on purpose in a two-second clip.
|
||||
# Whisper does invent "you" and "bye" too, but people dictate both as whole
|
||||
# answers, so a single word never belongs here.
|
||||
HALLUCINATIONS = {
|
||||
"altyazi mk", "altyazi m k", "altyazi", "altyazilar",
|
||||
"abone olmayi unutmayin", "izlediginiz icin tesekkurler",
|
||||
"izlediginiz icin tesekkur ederim", "izlediginiz icin tesekkur ederiz",
|
||||
"kanalima abone olmayi unutmayin", "altyazi mk altyazi mk",
|
||||
"thanks for watching", "thank you for watching", "thanks for watching!",
|
||||
"please subscribe", "subscribe to my channel", "you", "bye",
|
||||
"please subscribe", "subscribe to my channel",
|
||||
"mbc masr", "sous titres realises par la communaute damara org",
|
||||
"amara org community", "sous titrage st 501",
|
||||
}
|
||||
|
||||
+119
-37
@@ -6,6 +6,7 @@ whatever came of it: an answer to a question, or a sentence saying what was
|
||||
done.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
@@ -45,25 +46,50 @@ class Pipeline(QObject):
|
||||
self.conf = conf
|
||||
self._thread = None
|
||||
self._stop = threading.Event()
|
||||
# Recordings waiting their turn, and whether a thread is working them
|
||||
# off. The flag rather than the thread's own liveness, because a thread
|
||||
# stays alive for a moment after deciding it is done, and a job arriving
|
||||
# in that moment would be left in the queue with nobody coming back.
|
||||
self._jobs = collections.deque()
|
||||
self._draining = False
|
||||
self._jobs_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def busy(self):
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
|
||||
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None,
|
||||
focus=None):
|
||||
"""`paste` overrides the setting for this one run, which is what a
|
||||
dictation asked for from a terminal wants: the text comes back down the
|
||||
socket, and pasting it into whatever had focus is nobody's intention."""
|
||||
if self.busy:
|
||||
return
|
||||
socket, and pasting it into whatever had focus is nobody's intention.
|
||||
|
||||
`focus` is the application that was in front when the recording began,
|
||||
as a process id, and is where the paste is meant to land.
|
||||
|
||||
A run started while one is going waits its turn rather than being
|
||||
dropped: the next dictation can be spoken while the last one is still
|
||||
being cleaned up, and each one is finished, pasted and reported in the
|
||||
order it was spoken."""
|
||||
with self._jobs_lock:
|
||||
self._jobs.append((wav_path, duration, list(rms_values), ask, paste,
|
||||
focus))
|
||||
if self._draining:
|
||||
return
|
||||
self._draining = True
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._work,
|
||||
args=(wav_path, duration, list(rms_values), ask, paste),
|
||||
daemon=True,
|
||||
)
|
||||
self._thread = threading.Thread(target=self._drain, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _drain(self):
|
||||
while True:
|
||||
with self._jobs_lock:
|
||||
if not self._jobs:
|
||||
self._draining = False
|
||||
return
|
||||
job = self._jobs.popleft()
|
||||
self._work(*job)
|
||||
|
||||
def cancel(self):
|
||||
"""Give up on a job already under way.
|
||||
|
||||
@@ -73,7 +99,8 @@ class Pipeline(QObject):
|
||||
"""
|
||||
self._stop.set()
|
||||
|
||||
def _work(self, wav_path, duration, rms_values, ask, paste_override=None):
|
||||
def _work(self, wav_path, duration, rms_values, ask, paste_override=None,
|
||||
focus=None):
|
||||
conf = self.conf
|
||||
started = time.monotonic()
|
||||
raw = ""
|
||||
@@ -107,11 +134,16 @@ class Pipeline(QObject):
|
||||
|
||||
text = raw
|
||||
warning = ""
|
||||
# Remembered rather than re-derived at the history write below: the
|
||||
# ask path runs cleanup under a different setting, and the record
|
||||
# should say what happened, not what one of the two gates implies.
|
||||
cleaned = False
|
||||
# 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…"))
|
||||
cleaned = True
|
||||
try:
|
||||
text = cleanup.run(raw, conf, conf.cleanup_prompt())
|
||||
except api.ApiError as exc:
|
||||
@@ -137,62 +169,112 @@ class Pipeline(QObject):
|
||||
if paste_override is not None:
|
||||
wants_paste = paste_override
|
||||
|
||||
with _paste_lock:
|
||||
previous = (paste.read_clipboard()
|
||||
if conf["restore_clipboard"] and wants_paste else None)
|
||||
try:
|
||||
paste.copy(text)
|
||||
if wants_paste:
|
||||
self.stage.emit(t("Pasting…"))
|
||||
paste.press(conf["paste_shortcut"])
|
||||
finally:
|
||||
if previous is not None:
|
||||
# Let the focused application consume the temporary
|
||||
# transcription before putting every old clipboard type
|
||||
# back. This also runs when key injection fails.
|
||||
time.sleep(0.35)
|
||||
paste.copy_bytes(previous)
|
||||
|
||||
cfg.append_history({
|
||||
# Into the history before the paste is attempted: the record says
|
||||
# what was dictated, not whether a key press landed, and a paste
|
||||
# that fails must not take the transcript down with it.
|
||||
record = {
|
||||
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"duration": round(duration, 1),
|
||||
"elapsed": round(time.monotonic() - started, 1),
|
||||
"model": target.model,
|
||||
"cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "",
|
||||
"cleanup_model": cleanup.model(conf) if cleaned else "",
|
||||
"cleanup_error": warning,
|
||||
"mode": "ask" if ask else "",
|
||||
"question": question,
|
||||
"assistant_model": conf["assistant_model"] if ask else "",
|
||||
"assistant": assistant.provider(conf) if ask else "",
|
||||
"assistant_model": assistant.model(conf) if ask else "",
|
||||
"raw": raw,
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
cfg.append_history(record)
|
||||
try:
|
||||
cfg.trim_history(conf["history_limit"])
|
||||
except OSError as exc:
|
||||
print(f"dikte: could not trim the history: {exc}", file=sys.stderr)
|
||||
|
||||
with _paste_lock:
|
||||
previous = (paste.read_clipboard()
|
||||
if conf["restore_clipboard"] and wants_paste else None)
|
||||
paste.copy(text)
|
||||
if wants_paste:
|
||||
self.stage.emit(t("Pasting…"))
|
||||
try:
|
||||
paste.press(conf["paste_shortcut"], focus=focus)
|
||||
except paste.PasteError as exc:
|
||||
# The transcript is on the clipboard and in the history;
|
||||
# a key press that would not land is a warning, not a
|
||||
# failure, and the old clipboard is NOT put back over
|
||||
# the text the user now has to paste by hand.
|
||||
previous = None
|
||||
warning = "\n".join(x for x in (
|
||||
warning,
|
||||
t("Copied, but pasting failed: {error}", error=exc),
|
||||
) if x)
|
||||
# The row above was written before the paste, so it
|
||||
# has to be told what the paste then did.
|
||||
record = cfg.amend_history(
|
||||
record, cleanup_error=warning) or record
|
||||
if previous is not None:
|
||||
# Let the focused application consume the temporary
|
||||
# transcription before putting every old clipboard type
|
||||
# back.
|
||||
time.sleep(0.35)
|
||||
paste.copy_bytes(previous)
|
||||
|
||||
self.finished.emit(raw, text, warning)
|
||||
|
||||
except assistant.Cancelled:
|
||||
self.cancelled.emit()
|
||||
except (api.ApiError, paste.PasteError, assistant.AssistantError) as exc:
|
||||
print(f"dikte: {exc}", file=sys.stderr)
|
||||
self.failed.emit(str(exc))
|
||||
self.failed.emit(self._keeping(wav_path, str(exc)))
|
||||
except Exception as exc: # never fail silently
|
||||
traceback.print_exc()
|
||||
self.failed.emit(t("Unexpected error: {error}", error=exc))
|
||||
self.failed.emit(self._keeping(wav_path, t("Unexpected error: {error}",
|
||||
error=exc)))
|
||||
finally:
|
||||
self._discard(wav_path)
|
||||
|
||||
def _keeping(self, wav_path, message):
|
||||
"""Put the failed run's audio somewhere a retry can find it.
|
||||
|
||||
A dictation that died on the way to the model is speech the user cannot
|
||||
say again from memory; deleting it because a server was down turns one
|
||||
failure into two. Kept regardless of the keep_audio setting, which is
|
||||
about the runs that succeeded.
|
||||
"""
|
||||
kept = self._keep(wav_path)
|
||||
if not kept:
|
||||
return message
|
||||
return message + "\n" + t("The recording was kept: {path}", path=kept)
|
||||
|
||||
def _keep(self, wav_path):
|
||||
"""Move the WAV into the recordings directory; its new path, or ''."""
|
||||
try:
|
||||
cfg.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
base = time.strftime("%Y%m%d-%H%M%S")
|
||||
# Two runs can finish inside the same second; the first one kept
|
||||
# must not be overwritten by the second.
|
||||
for suffix in ("",) + tuple(f"-{n}" for n in range(1, 100)):
|
||||
target = cfg.RECORDINGS_DIR / f"{base}{suffix}.wav"
|
||||
if not target.exists():
|
||||
shutil.move(wav_path, target)
|
||||
return str(target)
|
||||
return ""
|
||||
except OSError as exc:
|
||||
print(f"dikte: could not keep the audio: {exc}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
def _discard(self, wav_path):
|
||||
if not os.path.exists(wav_path):
|
||||
return
|
||||
if self.conf["keep_audio"]:
|
||||
try:
|
||||
cfg.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(wav_path, cfg.RECORDINGS_DIR / (time.strftime("%Y%m%d-%H%M%S") + ".wav"))
|
||||
if self._keep(wav_path):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
# The move failing is no reason to delete what the user asked to
|
||||
# keep: the temporary file stays where it is, named in the log.
|
||||
print(f"dikte: the audio stays at {wav_path}", file=sys.stderr)
|
||||
return
|
||||
try:
|
||||
os.unlink(wav_path)
|
||||
except OSError:
|
||||
|
||||
+21
-1
@@ -23,9 +23,20 @@ $autostartLink = Join-Path $startup "Dikte.lnk"
|
||||
$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd"
|
||||
|
||||
if ($Uninstall) {
|
||||
foreach ($path in @($shortcut, $autostartLink, $cmdShim)) {
|
||||
foreach ($path in @($shortcut, $autostartLink)) {
|
||||
if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" }
|
||||
}
|
||||
# The packaged install writes the same shim, naming its own dikte-cli.exe.
|
||||
# Only the one naming this checkout is ours to delete; taking the other
|
||||
# would break the `dikte` command of an install this script never made.
|
||||
if (Test-Path $cmdShim) {
|
||||
if ((Get-Content $cmdShim -Raw).Contains($entry)) {
|
||||
Remove-Item $cmdShim -Force
|
||||
Write-Host "removed: $cmdShim"
|
||||
} else {
|
||||
Write-Host "left alone: $cmdShim (it names another install, not this checkout)"
|
||||
}
|
||||
}
|
||||
Write-Host "Dikte's shortcuts are gone. The repository and your settings are not."
|
||||
exit 0
|
||||
}
|
||||
@@ -65,6 +76,15 @@ foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else {
|
||||
Write-Host "shortcut: $path"
|
||||
}
|
||||
|
||||
if ($Autostart) {
|
||||
# The packaged build keeps its sign-in entry in the registry. Left there
|
||||
# beside the shortcut written above, both would start a Dikte at sign-in,
|
||||
# and this install is the one being asked for.
|
||||
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" `
|
||||
-Name "Dikte" -ErrorAction SilentlyContinue
|
||||
Write-Host "autostart: the Startup shortcut replaces any registry Run entry a packaged install left"
|
||||
}
|
||||
|
||||
# --- the dikte command ------------------------------------------------------
|
||||
# The interpreter by its full path rather than by name: the one checked above is
|
||||
# the one the command line should run, whatever a later PATH change puts first.
|
||||
|
||||
+15
-1
@@ -59,6 +59,13 @@ Name: "autostart"; Description: "Start Dikte when I sign in"
|
||||
[Files]
|
||||
Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
||||
|
||||
[InstallDelete]
|
||||
; A checkout's install.ps1 -Autostart is a shortcut in the Startup folder. The
|
||||
; registry entry this setup writes replaces it, and both left in place would be
|
||||
; two Diktes at every sign-in. Only under the autostart task: somebody who
|
||||
; unticked the box has not asked for their checkout's entry to go.
|
||||
Type: files; Name: "{userstartup}\Dikte.lnk"; Tasks: autostart
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe"
|
||||
|
||||
@@ -111,7 +118,14 @@ begin
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
var
|
||||
Shim: AnsiString;
|
||||
begin
|
||||
{ Only the shim this setup wrote, which is the one naming its dikte-cli.exe.
|
||||
install.ps1 writes the same file for a checkout, naming that checkout's
|
||||
Python, and a shim somebody else wrote is not this uninstaller's to take. }
|
||||
if CurUninstallStep = usUninstall then
|
||||
DeleteFile(ShimPath());
|
||||
if LoadStringFromFile(ShimPath(), Shim)
|
||||
and (Pos(ExpandConstant('{app}\dikte-cli.exe'), Shim) > 0) then
|
||||
DeleteFile(ShimPath());
|
||||
end;
|
||||
|
||||
@@ -56,6 +56,18 @@ analysis = Analysis( # noqa: F821
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
# Qt's xcb platform plugin uses libxkbcommon in two halves: the core library
|
||||
# and libxkbcommon-x11, which allocates keymap objects and hands them to the
|
||||
# core half to use and free, so the two must come from the same build. The
|
||||
# build machine has only the core half installed, which had PyInstaller
|
||||
# bundling that one while the other kept coming from the user's system, and a
|
||||
# 22.04-era core freeing what a current x11 half allocated is the startup
|
||||
# crash of issue #57. Ship neither: any desktop that can show a window
|
||||
# carries both, from one build.
|
||||
if not (MACOS or WINDOWS):
|
||||
analysis.binaries = [entry for entry in analysis.binaries
|
||||
if "libxkbcommon" not in entry[0]]
|
||||
|
||||
archive = PYZ(analysis.pure) # noqa: F821
|
||||
|
||||
executable = EXE( # noqa: F821
|
||||
|
||||
@@ -17,6 +17,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
|
||||
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
|
||||
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data")
|
||||
os.environ["XDG_CACHE_HOME"] = os.path.join(_SANDBOX, "cache")
|
||||
# Home goes with them: the shortcut file, the applications directory and every
|
||||
# macOS path start from it rather than from an XDG variable, and a test run is
|
||||
# not allowed to touch the real one.
|
||||
|
||||
+4
-1
@@ -25,6 +25,7 @@ from unittest import mock
|
||||
from dikte import assistant
|
||||
from dikte import config as cfg
|
||||
from dikte import i18n
|
||||
from dikte import update
|
||||
|
||||
# What the application is, rather than what it does: PipeWire, wl-clipboard,
|
||||
# ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces
|
||||
@@ -86,8 +87,10 @@ class DikteTest(unittest.TestCase):
|
||||
MEETINGS_FILE=data_dir / "meetings.jsonl",
|
||||
)
|
||||
# Resolved from cfg.DATA_DIR when assistant was imported, so it needs
|
||||
# moving on its own.
|
||||
# moving on its own. The same goes for where the update check writes
|
||||
# down when it last ran.
|
||||
self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json")
|
||||
self.patch_attr(update, "STATE_FILE", data_dir / "update.json")
|
||||
|
||||
i18n.set_language("en")
|
||||
self.addCleanup(i18n.set_language, "en")
|
||||
|
||||
@@ -53,6 +53,16 @@ class TimestampModel(unittest.TestCase):
|
||||
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||
"whisper-1")
|
||||
|
||||
def test_openrouter_takes_the_file_model_that_was_set(self):
|
||||
self.assertEqual(
|
||||
api.timestamp_model("openrouter", "openai/gpt-4o-transcribe",
|
||||
"openai/whisper-large-v3"),
|
||||
"openai/whisper-large-v3")
|
||||
|
||||
def test_openrouter_with_no_file_model_falls_back_to_whisper(self):
|
||||
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", ""),
|
||||
"openai/whisper-1")
|
||||
|
||||
|
||||
class Explain(DikteTest):
|
||||
def error(self, status):
|
||||
@@ -119,9 +129,22 @@ class ExtractError(unittest.TestCase):
|
||||
body = json.dumps({"error": {"code": 42}})
|
||||
self.assertIn("42", api._extract_error(body))
|
||||
|
||||
def test_an_error_wrapped_in_an_array(self):
|
||||
"""Google's 503 arrives this way, and .get() on a list raises."""
|
||||
body = json.dumps([{"error": {"code": 503,
|
||||
"message": "The model is overloaded."}}])
|
||||
self.assertEqual(api._extract_error(body), "The model is overloaded.")
|
||||
|
||||
def test_a_body_that_is_not_json(self):
|
||||
self.assertEqual(api._extract_error("<html>502</html>"), "<html>502</html>")
|
||||
|
||||
def test_no_shape_at_all_still_comes_back_as_a_string(self):
|
||||
"""It runs while an ApiError is being raised: throwing here would
|
||||
escape the `except ApiError` holding the raw transcript."""
|
||||
for body in ("[]", "[1, 2]", '"a string"', "null", "17"):
|
||||
with self.subTest(body=body):
|
||||
self.assertIsInstance(api._extract_error(body), str)
|
||||
|
||||
def test_a_wall_of_html_is_cut_short(self):
|
||||
self.assertEqual(len(api._extract_error("x" * 5000)), 300)
|
||||
|
||||
@@ -305,6 +328,13 @@ class TranscribeSegments(DikteTest):
|
||||
api.transcribe_segments(OPENROUTER, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
||||
|
||||
def test_openrouter_asks_for_the_file_model_when_one_is_set(self):
|
||||
target = OPENROUTER._replace(file_model="mistralai/voxtral-mini-transcribe")
|
||||
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"],
|
||||
"mistralai/voxtral-mini-transcribe")
|
||||
|
||||
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:
|
||||
@@ -384,6 +414,37 @@ class Cleanup(DikteTest):
|
||||
self.assertEqual(sent_json(calls[0])["reasoning"],
|
||||
{"effort": "high", "exclude": True})
|
||||
|
||||
def test_gemini_takes_openai_s_flat_field_rather_than_the_object(self):
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning="low",
|
||||
provider="gemini", service="Google AI Studio")
|
||||
payload = sent_json(calls[0])
|
||||
self.assertEqual(payload["reasoning_effort"], "low")
|
||||
self.assertNotIn("reasoning", payload)
|
||||
|
||||
def test_off_is_asked_for_as_the_lowest_rung_google_actually_has(self):
|
||||
"""Sending "none" is a 400, and Flash left alone thinks."""
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning="none",
|
||||
provider="gemini", service="Google AI Studio")
|
||||
self.assertEqual(sent_json(calls[0])["reasoning_effort"], "minimal")
|
||||
|
||||
def test_a_rung_google_does_not_have_lands_on_the_nearest_one(self):
|
||||
for asked in ("xhigh", "max"):
|
||||
with self.subTest(asked=asked):
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning=asked,
|
||||
provider="gemini", service="Google AI Studio")
|
||||
self.assertEqual(sent_json(calls[0])["reasoning_effort"], "high")
|
||||
|
||||
def test_gemini_left_on_the_model_s_own_default_is_told_nothing(self):
|
||||
_, calls = self.call(chat_reply("Hello."), provider="gemini",
|
||||
service="Google AI Studio")
|
||||
self.assertNotIn("reasoning_effort", sent_json(calls[0]))
|
||||
|
||||
def test_a_missing_gemini_key_says_google_ai_studio(self):
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup("hello", "", "gemini-3.5-flash-lite", "prompt",
|
||||
provider="gemini", service="Google AI Studio")
|
||||
self.assertIn("Google AI Studio", str(caught.exception))
|
||||
|
||||
def test_a_local_base_url(self):
|
||||
_, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
|
||||
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
|
||||
@@ -513,6 +574,40 @@ class ModelLists(DikteTest):
|
||||
api.openai_models("", api.GROQ_URL, "Groq")
|
||||
self.assertIn("Groq", str(caught.exception))
|
||||
|
||||
def test_gemini_keeps_only_the_models_that_answer_a_chat_request(self):
|
||||
with fake_urlopen({"data": [{"id": "gemini-3.5-flash"},
|
||||
{"id": "text-embedding-004"},
|
||||
{"id": "imagen-4.0"},
|
||||
{"id": "gemini-2.5-flash-lite"}]}) as calls:
|
||||
models = api.gemini_models("AIza-test")
|
||||
self.assertEqual(calls[0].full_url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/models")
|
||||
self.assertEqual(models, ["gemini-2.5-flash-lite", "gemini-3.5-flash"])
|
||||
|
||||
def test_the_long_form_of_an_id_is_shortened_to_what_a_request_wants(self):
|
||||
with fake_urlopen({"data": [{"id": "models/gemini-3.5-flash-lite"}]}):
|
||||
self.assertEqual(api.gemini_models("AIza-test"),
|
||||
["gemini-3.5-flash-lite"])
|
||||
|
||||
def test_a_gemini_id_that_is_not_a_chat_model_is_left_out(self):
|
||||
"""Google names its pictures and its voices `gemini` too."""
|
||||
with fake_urlopen({"data": [{"id": "gemini-3.5-flash"},
|
||||
{"id": "gemini-embedding-001"},
|
||||
{"id": "gemini-2.5-flash-image"},
|
||||
{"id": "gemini-2.5-flash-preview-tts"},
|
||||
{"id": "gemini-2.5-native-audio"}]}):
|
||||
self.assertEqual(api.gemini_models("AIza-test"), ["gemini-3.5-flash"])
|
||||
|
||||
def test_gemini_sends_the_key_as_a_bearer_token(self):
|
||||
with fake_urlopen({"data": []}) as calls:
|
||||
api.gemini_models("AIza-test")
|
||||
self.assertEqual(calls[0].get_header("Authorization"), "Bearer AIza-test")
|
||||
|
||||
def test_a_missing_gemini_key_says_google_ai_studio(self):
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.gemini_models("")
|
||||
self.assertIn("Google AI Studio", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+432
-43
@@ -10,21 +10,28 @@ import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from dikte import assistant
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
||||
from tests.support import (DikteTest, FakeCompleted, fake_urlopen,
|
||||
only_these_tools)
|
||||
|
||||
|
||||
class FakeCli:
|
||||
"""A CLI that prints the given events and exits."""
|
||||
"""A CLI that prints the given events and exits.
|
||||
|
||||
def __init__(self, events=(), code=0, stderr="", noise=()):
|
||||
Its stderr is not modelled: _stream hands the process a temporary file for
|
||||
that, and a mocked Popen leaves the file empty, which is what a quiet CLI
|
||||
writes anyway.
|
||||
"""
|
||||
|
||||
def __init__(self, events=(), code=0, noise=()):
|
||||
lines = list(noise) + [json.dumps(event) for event in events]
|
||||
self.stdout = io.StringIO("\n".join(lines) + "\n")
|
||||
self.stderr = io.StringIO(stderr)
|
||||
self.returncode = code
|
||||
self.killed = False
|
||||
|
||||
@@ -41,6 +48,38 @@ class FakeCli:
|
||||
self.killed = True
|
||||
|
||||
|
||||
class WedgedCli:
|
||||
"""A CLI whose stdout produces nothing, the way a hung process's does.
|
||||
|
||||
Iterating its stdout blocks until the kill arrives, because that is what
|
||||
reading a silent pipe does: only the process ending closes the stream.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.pid = 4242
|
||||
self.returncode = None
|
||||
self.released = threading.Event()
|
||||
self.stdout = self
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
# The 5 second cap is a safety net for the test itself; the kill is
|
||||
# what is supposed to end the wait.
|
||||
self.released.wait(timeout=5)
|
||||
raise StopIteration
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self.returncode
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Provider(DikteTest):
|
||||
def test_the_default(self):
|
||||
self.assertEqual(assistant.provider(self.config()), "claude")
|
||||
@@ -58,15 +97,38 @@ class Provider(DikteTest):
|
||||
def test_what_each_one_runs(self):
|
||||
self.assertEqual(assistant.executable("claude"), "claude")
|
||||
self.assertEqual(assistant.executable("codex"), "codex")
|
||||
self.assertEqual(assistant.executable("agy"), "agy")
|
||||
self.assertEqual(assistant.executable("openrouter"), "")
|
||||
self.assertEqual(assistant.executable("opencode"), "")
|
||||
|
||||
def test_the_model_recorded_is_the_one_that_answered(self):
|
||||
"""The history used to write Claude's setting whoever had answered."""
|
||||
self.assertEqual(assistant.model(self.config()), "sonnet")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="codex")), "codex")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="agy")), "agy")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="agy",
|
||||
assistant_agy_model="gemini-3.1-pro-low")),
|
||||
"gemini-3.1-pro-low")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="openrouter")),
|
||||
"google/gemini-3.5-flash")
|
||||
|
||||
def test_what_each_one_is_called(self):
|
||||
self.assertEqual(assistant.display_name(self.config()), "Claude")
|
||||
self.assertEqual(
|
||||
assistant.display_name(self.config(assistant_provider="codex")), "Codex")
|
||||
self.assertEqual(
|
||||
assistant.display_name(self.config(assistant_provider="openrouter")),
|
||||
"OpenRouter")
|
||||
for name, called in (("codex", "Codex"), ("agy", "Antigravity"),
|
||||
("openrouter", "OpenRouter"),
|
||||
("opencode", "OpenCode Go")):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(
|
||||
assistant.display_name(self.config(assistant_provider=name)),
|
||||
called)
|
||||
|
||||
def test_every_provider_has_a_name_to_be_called_by(self):
|
||||
"""_conclude writes its errors in it, so a gap here is a bare id."""
|
||||
self.assertEqual(set(assistant.SERVICES), set(assistant.PROVIDERS))
|
||||
|
||||
|
||||
class Effort(unittest.TestCase):
|
||||
@@ -74,21 +136,26 @@ class Effort(unittest.TestCase):
|
||||
|
||||
def test_the_scales_cover_the_same_settings(self):
|
||||
self.assertEqual(set(assistant.CLAUDE_EFFORT), set(assistant.CODEX_EFFORT))
|
||||
self.assertEqual(set(assistant.CLAUDE_EFFORT), set(assistant.AGY_EFFORT))
|
||||
|
||||
def test_codex_has_no_rung_above_high(self):
|
||||
self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high")
|
||||
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
|
||||
def test_neither_codex_nor_agy_has_a_rung_above_high(self):
|
||||
for scale in (assistant.CODEX_EFFORT, assistant.AGY_EFFORT):
|
||||
self.assertEqual(scale["xhigh"], "high")
|
||||
self.assertEqual(scale["max"], "high")
|
||||
|
||||
def test_neither_one_asks_for_a_rung_below_low(self):
|
||||
def test_none_of_them_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):
|
||||
# models and "none" on the newer ones, and refuses the wrong word; agy
|
||||
# has three rungs and no word for off at all.
|
||||
for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT,
|
||||
assistant.AGY_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("", ""), "")
|
||||
self.assertEqual(assistant.CODEX_EFFORT.get("", ""), "")
|
||||
for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT,
|
||||
assistant.AGY_EFFORT):
|
||||
self.assertEqual(scale.get("", ""), "")
|
||||
|
||||
|
||||
class Session(DikteTest):
|
||||
@@ -221,19 +288,7 @@ class Denials(DikteTest):
|
||||
self.assertIn("Write", warning)
|
||||
|
||||
|
||||
class SessionMissing(unittest.TestCase):
|
||||
def test_a_session_that_is_gone(self):
|
||||
for text in ("Error: session abc not found",
|
||||
"No conversation with that id",
|
||||
"unknown thread: abc"):
|
||||
with self.subTest(text=text):
|
||||
self.assertTrue(assistant._session_missing(text))
|
||||
|
||||
def test_an_unrelated_failure(self):
|
||||
for text in ("", "network unreachable", "session limit exceeded"):
|
||||
with self.subTest(text=text):
|
||||
self.assertFalse(assistant._session_missing(text))
|
||||
|
||||
class LastLine(unittest.TestCase):
|
||||
def test_the_last_line_is_the_one_worth_showing(self):
|
||||
self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
|
||||
"real error")
|
||||
@@ -249,56 +304,129 @@ class Conclude(DikteTest):
|
||||
|
||||
def test_an_answer_and_its_session(self):
|
||||
answer, warning = assistant._conclude(
|
||||
self.found(answer="done", session="abc"), 0, "", "", "Claude")
|
||||
self.found(answer="done", session="abc"), 0, "", "", "claude")
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(warning, "")
|
||||
self.assertEqual(assistant.read_session("claude", 1800), "abc")
|
||||
|
||||
def test_codex_stores_under_its_own_name(self):
|
||||
assistant._conclude(self.found(answer="done", session="t-1"), 0, "",
|
||||
"", "Codex")
|
||||
self.assertEqual(assistant.read_session("codex", 1800), "t-1")
|
||||
def test_each_one_stores_under_its_own_name(self):
|
||||
for name, session in (("codex", "t-1"), ("agy", "c-9")):
|
||||
with self.subTest(name=name):
|
||||
assistant._conclude(self.found(answer="done", session=session),
|
||||
0, "", "", name)
|
||||
self.assertEqual(assistant.read_session(name, 1800), session)
|
||||
|
||||
def test_the_error_is_written_in_the_provider_s_own_name(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 1, "", "", "agy")
|
||||
self.assertIn("Antigravity", str(caught.exception))
|
||||
|
||||
def test_a_non_zero_exit_with_nothing_to_show_for_it(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 1, "it all went wrong\n", "", "Claude")
|
||||
assistant._conclude(self.found(), 1, "it all went wrong\n", "", "claude")
|
||||
self.assertIn("it all went wrong", str(caught.exception))
|
||||
|
||||
def test_a_session_that_is_gone_is_raised_apart(self):
|
||||
with self.assertRaises(assistant._SessionGone):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
"abc", "Claude")
|
||||
"abc", "claude")
|
||||
|
||||
def test_the_recovery_no_longer_hangs_on_the_words_the_cli_chose(self):
|
||||
# The complaint used to be matched by substring, which a CLI update or
|
||||
# another language broke. A resumed run that died with nothing to show
|
||||
# is now enough on its own.
|
||||
for stderr in ("Oturum bulunamadı", "something else entirely", ""):
|
||||
with self.subTest(stderr=stderr):
|
||||
with self.assertRaises(assistant._SessionGone):
|
||||
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||
|
||||
def test_api_trouble_on_a_resumed_run_is_not_blamed_on_the_session(self):
|
||||
# A fresh session cannot cure a spent quota, a signed-out CLI or a dead
|
||||
# network: the retry would fail the same way after a second wait, and
|
||||
# the user would lose the conversation thread on top.
|
||||
for stderr in ("Rate limit exceeded",
|
||||
"You are not logged in. Please run /login.",
|
||||
"API Error: 401 Unauthorized",
|
||||
"fetch failed: ECONNREFUSED 127.0.0.1"):
|
||||
with self.subTest(stderr=stderr):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||
self.assertIn(stderr, str(caught.exception))
|
||||
|
||||
def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self):
|
||||
with self.assertRaises(assistant.AssistantError):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
"", "Claude")
|
||||
"", "claude")
|
||||
|
||||
def test_an_answer_survives_a_non_zero_exit(self):
|
||||
answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise",
|
||||
"", "Claude")
|
||||
"", "claude")
|
||||
self.assertEqual(answer, "done")
|
||||
|
||||
def test_an_answer_on_a_resumed_session_is_kept_rather_than_retried(self):
|
||||
answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise",
|
||||
"abc", "Claude")
|
||||
self.assertEqual(answer, "done")
|
||||
|
||||
def test_a_reported_failure_with_no_answer(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(failure="the model refused"), 0, "",
|
||||
"", "Claude")
|
||||
"", "claude")
|
||||
self.assertIn("refused", str(caught.exception))
|
||||
|
||||
def test_a_run_that_said_nothing_at_all(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 0, "", "", "Codex")
|
||||
assistant._conclude(self.found(), 0, "", "", "codex")
|
||||
self.assertIn("Codex", str(caught.exception))
|
||||
|
||||
|
||||
class Stream(DikteTest):
|
||||
def test_a_cli_that_floods_stderr_still_finishes(self):
|
||||
# A real subprocess, because the wedge being tested is real plumbing:
|
||||
# with stderr on a pipe nobody drains, 200 KB fills the pipe's buffer,
|
||||
# the child blocks writing it, and the run hangs until the watchdog
|
||||
# timeout. With stderr on a file the run completes at once.
|
||||
script = (
|
||||
"import sys\n"
|
||||
"sys.stderr.write('x' * 200000)\n"
|
||||
"sys.stderr.flush()\n"
|
||||
"print('{\"type\": \"result\", \"result\": \"done\"}')\n"
|
||||
)
|
||||
conf = self.config(assistant_timeout=15)
|
||||
events = []
|
||||
code, stderr = assistant._stream(
|
||||
[sys.executable, "-c", script], conf, events.append, None)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(len(stderr), 200000)
|
||||
self.assertEqual(events[-1]["result"], "done")
|
||||
|
||||
def test_the_watchdog_takes_the_whole_tree_down_on_timeout(self):
|
||||
proc = WedgedCli()
|
||||
|
||||
def killed(target):
|
||||
# What the real kill does, as far as _stream can see: the process
|
||||
# ends, and its closing stream releases the blocked read.
|
||||
target.returncode = 1
|
||||
target.released.set()
|
||||
|
||||
conf = self.config(assistant_timeout=0)
|
||||
with mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||
mock.patch.object(assistant, "kill_tree",
|
||||
side_effect=killed) as kill:
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._stream(["claude"], conf, lambda event: None, None)
|
||||
kill.assert_called_once_with(proc)
|
||||
self.assertIn("did not finish", str(caught.exception))
|
||||
|
||||
|
||||
class AskClaude(DikteTest):
|
||||
def run_ask(self, conf=None, events=None, code=0, stderr="", noise=(),
|
||||
def run_ask(self, conf=None, events=None, code=0, noise=(),
|
||||
session=""):
|
||||
conf = conf or self.config()
|
||||
proc = FakeCli(events or [
|
||||
{"type": "system", "subtype": "init", "session_id": "abc"},
|
||||
{"type": "result", "session_id": "abc", "result": " done "},
|
||||
], code=code, stderr=stderr, noise=noise)
|
||||
], code=code, noise=noise)
|
||||
stages = []
|
||||
with only_these_tools("claude", "codex"), \
|
||||
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||
@@ -472,6 +600,109 @@ class AskCodex(DikteTest):
|
||||
self.assertIn("quota", str(caught.exception))
|
||||
|
||||
|
||||
class AskAgy(DikteTest):
|
||||
"""agy's stream is shaped nothing like the other two: the key is `event`,
|
||||
the answer arrives whole in `result.response`, and the conversation to
|
||||
resume is named in the first line rather than the last."""
|
||||
|
||||
def run_ask(self, conf=None, events=None, session=""):
|
||||
conf = conf or self.config(assistant_provider="agy")
|
||||
proc = FakeCli(events or [
|
||||
{"event": "init", "conversation_id": "c-9", "init": {"cwd": "/home"}},
|
||||
{"event": "result",
|
||||
"result": {"conversation_id": "c-9", "status": "SUCCESS",
|
||||
"response": " done "}},
|
||||
])
|
||||
stages = []
|
||||
with only_these_tools("agy"), \
|
||||
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||
result = assistant._ask_agy("book it", conf, session,
|
||||
stages.append, None)
|
||||
return result, popen.call_args.args[0], stages
|
||||
|
||||
def test_the_answer_comes_back_stripped(self):
|
||||
(answer, warning), _, _ = self.run_ask()
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(warning, "")
|
||||
|
||||
def test_the_instruction_is_kept_apart_from_the_command(self):
|
||||
"""agy takes no system prompt, so the two must not read as one."""
|
||||
conf = self.config(assistant_provider="agy")
|
||||
_, cmd, _ = self.run_ask(conf)
|
||||
body = cmd[cmd.index("-p") + 1]
|
||||
self.assertTrue(body.startswith(conf.assistant_prompt()))
|
||||
self.assertIn("\n\n---\n\n", body)
|
||||
self.assertTrue(body.endswith("book it"))
|
||||
|
||||
def test_a_first_command_starts_a_project_of_its_own(self):
|
||||
"""Without it agy works in whichever project it was last in."""
|
||||
_, cmd, _ = self.run_ask()
|
||||
self.assertIn("--new-project", cmd)
|
||||
self.assertNotIn("--conversation", cmd)
|
||||
|
||||
def test_a_second_command_carries_the_conversation_rather_than_starting_one(self):
|
||||
_, cmd, _ = self.run_ask(session="c-9")
|
||||
self.assertEqual(cmd[cmd.index("--conversation") + 1], "c-9")
|
||||
self.assertNotIn("--new-project", cmd)
|
||||
|
||||
def test_the_conversation_is_kept_under_agy_s_own_name(self):
|
||||
self.run_ask()
|
||||
self.assertEqual(assistant.read_session("agy", 1800), "c-9")
|
||||
|
||||
def test_it_is_not_left_to_give_up_before_the_caller_does(self):
|
||||
conf = self.config(assistant_provider="agy", assistant_timeout=90)
|
||||
_, cmd, _ = self.run_ask(conf)
|
||||
self.assertEqual(cmd[cmd.index("--print-timeout") + 1], "90s")
|
||||
|
||||
def test_no_model_named_means_whatever_agy_is_set_to(self):
|
||||
_, cmd, _ = self.run_ask()
|
||||
self.assertNotIn("--model", cmd)
|
||||
|
||||
def test_a_model_of_your_own(self):
|
||||
_, cmd, _ = self.run_ask(
|
||||
self.config(assistant_provider="agy",
|
||||
assistant_agy_model="gemini-3.1-pro-low"))
|
||||
self.assertEqual(cmd[cmd.index("--model") + 1], "gemini-3.1-pro-low")
|
||||
|
||||
def test_a_tool_is_named_in_the_corner_as_it_starts(self):
|
||||
_, _, stages = self.run_ask(events=[
|
||||
{"event": "step_update",
|
||||
"step_update": {"step_type": "tool", "state": "ACTIVE",
|
||||
"tool_name": "run_command"}},
|
||||
{"event": "step_update",
|
||||
"step_update": {"step_type": "tool", "state": "DONE",
|
||||
"tool_name": "run_command"}},
|
||||
{"event": "result",
|
||||
"result": {"status": "SUCCESS", "response": "done"}},
|
||||
])
|
||||
self.assertEqual(stages, ["Running a command…"])
|
||||
|
||||
def test_the_two_dozen_browser_tools_are_one_line_between_them(self):
|
||||
_, _, stages = self.run_ask(events=[
|
||||
{"event": "step_update",
|
||||
"step_update": {"step_type": "tool", "state": "ACTIVE",
|
||||
"tool_name": "browser_click_element"}},
|
||||
{"event": "result",
|
||||
"result": {"status": "SUCCESS", "response": "done"}},
|
||||
])
|
||||
self.assertEqual(stages, ["Working in the browser…"])
|
||||
|
||||
def test_a_turn_that_did_not_succeed_is_a_failure_rather_than_an_answer(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
self.run_ask(events=[
|
||||
{"event": "result",
|
||||
"result": {"status": "ERROR", "response": "the model refused"}},
|
||||
])
|
||||
self.assertIn("refused", str(caught.exception))
|
||||
|
||||
def test_a_failure_with_nothing_to_say_is_still_named(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
self.run_ask(events=[
|
||||
{"event": "result", "result": {"status": "ERROR"}},
|
||||
])
|
||||
self.assertIn("Antigravity", str(caught.exception))
|
||||
|
||||
|
||||
class AskOpenRouter(DikteTest):
|
||||
def test_a_question_and_an_answer(self):
|
||||
conf = self.config(assistant_provider="openrouter",
|
||||
@@ -507,6 +738,41 @@ class AskOpenRouter(DikteTest):
|
||||
assistant.ask("when is it", conf)
|
||||
|
||||
|
||||
class AskOpenCode(DikteTest):
|
||||
def test_a_question_and_an_answer(self):
|
||||
conf = self.config(assistant_provider="opencode",
|
||||
opencode_api_key="opencode-test-key")
|
||||
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||
answer, warning = assistant.ask("when is it", conf)
|
||||
self.assertEqual(answer, "on Thursday")
|
||||
self.assertEqual(warning, "")
|
||||
|
||||
def test_the_conversation_is_ours_to_keep(self):
|
||||
conf = self.config(assistant_provider="opencode",
|
||||
opencode_api_key="opencode-test-key")
|
||||
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||
assistant.ask("when is it", conf)
|
||||
stored = assistant.read_messages("opencode", 1800)
|
||||
self.assertEqual([row["content"] for row in stored],
|
||||
["when is it", "on Thursday"])
|
||||
|
||||
def test_the_model_and_endpoint_are_opencode_s_own(self):
|
||||
conf = self.config(assistant_provider="opencode",
|
||||
opencode_api_key="opencode-test-key",
|
||||
assistant_opencode_model="glm-5.3")
|
||||
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}) as calls:
|
||||
assistant.ask("when is it", conf)
|
||||
sent = json.loads(calls[0].data.decode("utf-8"))
|
||||
self.assertEqual(sent["model"], "glm-5.3")
|
||||
self.assertIn("https://opencode.ai/zen/go/v1/chat/completions",
|
||||
calls[0].full_url)
|
||||
|
||||
def test_an_api_failure_reads_as_an_assistant_failure(self):
|
||||
conf = self.config(assistant_provider="opencode")
|
||||
with self.assertRaises(assistant.AssistantError):
|
||||
assistant.ask("when is it", conf)
|
||||
|
||||
|
||||
class Ask(DikteTest):
|
||||
def test_a_cli_that_is_not_installed_says_where_to_change_it(self):
|
||||
with only_these_tools(), \
|
||||
@@ -533,6 +799,129 @@ class Ask(DikteTest):
|
||||
self.assertEqual(attempts, ["stale-id", ""])
|
||||
self.assertEqual(assistant.stored_provider(), "")
|
||||
|
||||
def test_a_resumed_run_that_dies_is_retried_without_the_session_flag(self):
|
||||
# All the way through the stream this time: the first run exits 1 with
|
||||
# no answer and whatever stderr it liked, and the recovery must not
|
||||
# depend on those words.
|
||||
conf = self.config()
|
||||
assistant.write_session("claude", "stale-id")
|
||||
procs = iter([
|
||||
FakeCli(code=1),
|
||||
FakeCli(events=[{"type": "result", "result": "done"}]),
|
||||
])
|
||||
cmds = []
|
||||
|
||||
def popen(cmd, **kwargs):
|
||||
cmds.append(cmd)
|
||||
return next(procs)
|
||||
|
||||
with only_these_tools("claude"), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||
answer, _ = assistant.ask("hi", conf)
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(cmds[0][cmds[0].index("--resume") + 1], "stale-id")
|
||||
self.assertNotIn("--resume", cmds[1])
|
||||
|
||||
def test_a_run_that_dies_with_an_answer_in_hand_is_not_retried(self):
|
||||
conf = self.config()
|
||||
assistant.write_session("claude", "stale-id")
|
||||
calls = []
|
||||
|
||||
def popen(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return FakeCli(events=[{"type": "result", "result": "done"}], code=1)
|
||||
|
||||
with only_these_tools("claude"), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||
answer, _ = assistant.ask("hi", conf)
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
class CodexModels(DikteTest):
|
||||
"""The model list read off `codex debug models`."""
|
||||
|
||||
CATALOG = {"models": [
|
||||
{"slug": "gpt-6-mini", "visibility": "list", "priority": 9},
|
||||
{"slug": "gpt-6", "visibility": "list", "priority": 1},
|
||||
{"slug": "codex-auto-review", "visibility": "hide", "priority": 3},
|
||||
]}
|
||||
|
||||
def models(self, reply, code=0):
|
||||
with only_these_tools("codex"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
return_value=FakeCompleted(
|
||||
returncode=code, stdout=reply)) as run:
|
||||
found = assistant.codex_models()
|
||||
self.run_call = run
|
||||
return found
|
||||
|
||||
def test_the_catalog_arrives_best_first_without_the_hidden_ones(self):
|
||||
found = self.models(json.dumps(self.CATALOG))
|
||||
self.assertEqual(found, ["gpt-6", "gpt-6-mini"])
|
||||
self.assertEqual(self.run_call.call_args.args[0],
|
||||
["codex", "debug", "models"])
|
||||
|
||||
def test_a_codex_that_is_not_installed_is_not_run(self):
|
||||
with only_these_tools(), \
|
||||
mock.patch.object(subprocess, "run") as run:
|
||||
self.assertEqual(assistant.codex_models(), [])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_a_codex_too_old_to_have_the_command(self):
|
||||
self.assertEqual(self.models("error: unknown subcommand", code=2), [])
|
||||
|
||||
def test_a_catalog_that_is_not_what_was_expected(self):
|
||||
self.assertEqual(self.models(json.dumps(["gpt-6"])), [])
|
||||
self.assertEqual(self.models(""), [])
|
||||
|
||||
def test_a_codex_that_hangs_is_given_up_on(self):
|
||||
with only_these_tools("codex"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired(
|
||||
["codex"], 30)):
|
||||
self.assertEqual(assistant.codex_models(), [])
|
||||
|
||||
|
||||
class AgyModels(DikteTest):
|
||||
"""The model list read off `agy models`: one id, a tab, a display name."""
|
||||
|
||||
LISTING = ("gemini-4-flash-high\tGemini 4 Flash (High)\n"
|
||||
"gemini-4-flash-low\tGemini 4 Flash (Low)\n"
|
||||
"a line with no tab is not a model\n"
|
||||
"\ta tab with no id in front of it is not one either\n")
|
||||
|
||||
def models(self, reply, code=0):
|
||||
with only_these_tools("agy"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
return_value=FakeCompleted(
|
||||
returncode=code, stdout=reply)) as run:
|
||||
found = assistant.agy_models()
|
||||
self.run_call = run
|
||||
return found
|
||||
|
||||
def test_the_listing_arrives_in_agy_s_own_order(self):
|
||||
found = self.models(self.LISTING)
|
||||
self.assertEqual(found, ["gemini-4-flash-high", "gemini-4-flash-low"])
|
||||
self.assertEqual(self.run_call.call_args.args[0], ["agy", "models"])
|
||||
|
||||
def test_an_agy_that_is_not_installed_is_not_run(self):
|
||||
with only_these_tools(), \
|
||||
mock.patch.object(subprocess, "run") as run:
|
||||
self.assertEqual(assistant.agy_models(), [])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_a_call_that_failed_answers_with_nothing(self):
|
||||
self.assertEqual(self.models("error: not logged in", code=1), [])
|
||||
self.assertEqual(self.models(""), [])
|
||||
|
||||
def test_an_agy_that_hangs_is_given_up_on(self):
|
||||
with only_these_tools("agy"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired(
|
||||
["agy"], 30)):
|
||||
self.assertEqual(assistant.agy_models(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+203
-14
@@ -81,6 +81,14 @@ class ChunkLevels(unittest.TestCase):
|
||||
self.assertEqual(peak, 1.0)
|
||||
self.assertEqual(rms, 1.0)
|
||||
|
||||
def test_the_fast_and_plain_rms_paths_agree(self):
|
||||
"""sumprod is a speedup, not a different sum: on a 3.11 machine the
|
||||
loop must land on the same integers."""
|
||||
chunk = tone(0.1)
|
||||
with mock.patch.object(audio, "sumprod", None):
|
||||
plain = audio.chunk_levels(chunk)
|
||||
self.assertEqual(audio.chunk_levels(chunk), plain)
|
||||
|
||||
|
||||
class StereoLevels(unittest.TestCase):
|
||||
def test_the_channels_are_read_apart(self):
|
||||
@@ -280,6 +288,48 @@ class _StalledStream:
|
||||
self._released.set()
|
||||
|
||||
|
||||
class _DribblingStream:
|
||||
"""A pipe that never fills a whole chunk in one read, the way an unbuffered
|
||||
pipe hands data over under load."""
|
||||
|
||||
def __init__(self, data, piece):
|
||||
self._data = io.BytesIO(data)
|
||||
self._piece = piece
|
||||
|
||||
def read(self, size):
|
||||
return self._data.read(min(size, self._piece))
|
||||
|
||||
|
||||
class _RunSwappingStream:
|
||||
"""A pipe whose recorder moves on mid-read, the way a new recording starts
|
||||
while a stale pump is still draining the old one."""
|
||||
|
||||
def __init__(self, data, recorder, swap_at, new_proc):
|
||||
self._data = io.BytesIO(data)
|
||||
self._recorder = recorder
|
||||
self._swap_at = swap_at
|
||||
self._new_proc = new_proc
|
||||
self.reads = 0
|
||||
|
||||
def read(self, size):
|
||||
if self.reads == self._swap_at:
|
||||
self._recorder._run = object()
|
||||
self._recorder._proc = self._new_proc
|
||||
self.reads += 1
|
||||
return self._data.read(size)
|
||||
|
||||
|
||||
class _SignalCrashingProcess(FakeProcess):
|
||||
"""An ffmpeg that calls being interrupted a failure, the way ffmpeg does."""
|
||||
|
||||
def __init__(self, data, code=255):
|
||||
super().__init__(data)
|
||||
self._code = code
|
||||
|
||||
def poll(self):
|
||||
return None if self._alive else self._code
|
||||
|
||||
|
||||
class _HeldStream:
|
||||
"""A capture that is paused and taken up again partway through, the way a
|
||||
key press lands in the middle of a recording rather than between two."""
|
||||
@@ -307,7 +357,10 @@ class RecordingCommand(OnLinux, DikteTest):
|
||||
super().setUp()
|
||||
# Whether pw-record takes --raw is read off the installed binary, and
|
||||
# what is being tested here is the command rather than the machine the
|
||||
# test is running on. PwRecordRawOption covers the reading itself.
|
||||
# test is running on. PwRecordRawOption covers the reading itself. The
|
||||
# answer is remembered between calls, so it cannot be remembered
|
||||
# between tests.
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
self.enterContext(mock.patch.object(
|
||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||
|
||||
@@ -392,11 +445,30 @@ class PwRecordRawOption(DikteTest):
|
||||
self.assertEqual(["--raw"], self.option(return_value=FakeCompleted()))
|
||||
|
||||
|
||||
class PwRawMemo(OnLinux, DikteTest):
|
||||
"""The --raw probe runs once per process, not once per key press."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
|
||||
def test_the_probe_is_asked_once_and_remembered(self):
|
||||
with only_these_tools("pw-record"), \
|
||||
mock.patch.object(audio, "_pw_record_raw_option",
|
||||
return_value=["--raw"]) as probe:
|
||||
first = audio.recording_command()
|
||||
second = audio.recording_command()
|
||||
probe.assert_called_once_with()
|
||||
self.assertIn("--raw", first)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
class RecorderChain(OnLinux, DikteTest):
|
||||
"""Start to WAV, with pw-record faked out."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
self.enterContext(mock.patch.object(
|
||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||
|
||||
@@ -476,42 +548,121 @@ class RecorderChain(OnLinux, DikteTest):
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("pulseaudio-utils", failures[0])
|
||||
|
||||
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False):
|
||||
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False,
|
||||
alive=False):
|
||||
"""Run the pump in this thread, where a queued signal would need an
|
||||
event loop nobody is running here."""
|
||||
recorder = audio.Recorder()
|
||||
failures = []
|
||||
deaths = []
|
||||
recorder.failed.connect(failures.append)
|
||||
recorder.died.connect(lambda: deaths.append(True))
|
||||
proc = FakeProcess(data)
|
||||
proc.stderr = io.BytesIO(stderr)
|
||||
proc._alive = False
|
||||
proc._alive = alive
|
||||
recorder._proc = proc
|
||||
recorder._max_bytes = 10 ** 9
|
||||
recorder._log = io.BytesIO(stderr)
|
||||
recorder._stopping = stopping
|
||||
recorder._cancelled = cancelled
|
||||
recorder._pump()
|
||||
return failures
|
||||
recorder._run = run = object()
|
||||
recorder._pump(run, proc, proc.stdout, recorder._buffer,
|
||||
recorder._rms, 10 ** 9)
|
||||
return failures, deaths
|
||||
|
||||
def test_a_recorder_that_died_on_its_own_says_so(self):
|
||||
"""parec refused the device, or the sound server went away."""
|
||||
failures = self.pump(stderr=b"connection refused\n")
|
||||
failures, _ = self.pump(stderr=b"connection refused\n")
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("connection refused", failures[0])
|
||||
|
||||
def test_a_death_with_nothing_on_stderr_still_names_the_exit_code(self):
|
||||
failures = self.pump()
|
||||
failures, _ = self.pump()
|
||||
self.assertIn("exit code", failures[0])
|
||||
|
||||
def test_a_death_that_left_no_exit_code_is_not_named_none(self):
|
||||
"""A process nobody managed to reap has no code to show, and "exit
|
||||
code None" would only puzzle the person reading it."""
|
||||
failures, _ = self.pump(alive=True)
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertNotIn("None", failures[0])
|
||||
|
||||
def test_a_recording_we_ended_ourselves_is_not_a_death(self):
|
||||
"""Otherwise a stray keypress produces two errors, and the first one
|
||||
sends the user looking for a broken sound server."""
|
||||
self.assertEqual(self.pump(stopping=True), [])
|
||||
self.assertEqual(self.pump(stopping=True), ([], []))
|
||||
|
||||
def test_a_cancelled_recording_is_not_a_death(self):
|
||||
self.assertEqual(self.pump(cancelled=True), [])
|
||||
self.assertEqual(self.pump(cancelled=True), ([], []))
|
||||
|
||||
def test_a_recorder_that_captured_something_first_is_not_a_death(self):
|
||||
self.assertEqual(self.pump(data=silence(0.5)), [])
|
||||
def test_a_capture_that_ends_mid_recording_dies_rather_than_fails(self):
|
||||
"""Sound had already arrived, so this is not a broken installation:
|
||||
the app is told the recording died and can rescue what there is."""
|
||||
failures, deaths = self.pump(data=silence(0.5))
|
||||
self.assertEqual(failures, [])
|
||||
self.assertEqual(deaths, [True])
|
||||
|
||||
def test_short_pipe_reads_are_gathered_into_whole_chunks(self):
|
||||
"""Every RMS entry must stand for one full chunk, or the silence check
|
||||
weighs a half-filled read as its own stretch of room tone."""
|
||||
half = audio.CHUNK_BYTES // 2
|
||||
data = pcm([1000] * (3 * half // 2)) # three half-chunk reads
|
||||
recorder = audio.Recorder()
|
||||
proc = FakeProcess(b"")
|
||||
proc.stdout = _DribblingStream(data, half)
|
||||
proc._alive = False
|
||||
recorder._proc = proc
|
||||
recorder._log = io.BytesIO(b"")
|
||||
recorder._run = run = object()
|
||||
buffer, rms = bytearray(), []
|
||||
recorder._pump(run, proc, proc.stdout, buffer, rms, 10 ** 9)
|
||||
self.assertEqual(len(buffer), len(data))
|
||||
self.assertEqual(len(rms), 2) # one whole chunk, then the tail
|
||||
|
||||
def test_a_stale_pump_cannot_touch_the_recording_that_replaced_it(self):
|
||||
"""A pump that outlives its join must not meter the next run, stop its
|
||||
process, push audio into its buffer, or speak on its behalf."""
|
||||
recorder = audio.Recorder()
|
||||
levels, failures, deaths = [], [], []
|
||||
recorder.level.connect(levels.append)
|
||||
recorder.failed.connect(failures.append)
|
||||
recorder.died.connect(lambda: deaths.append(True))
|
||||
new_proc = FakeProcess(b"")
|
||||
old_proc = FakeProcess(b"")
|
||||
old_proc.stdout = _RunSwappingStream(tone(0.192), recorder,
|
||||
swap_at=1, new_proc=new_proc)
|
||||
recorder._proc = old_proc
|
||||
recorder._log = io.BytesIO(b"")
|
||||
old_run = object()
|
||||
recorder._run = old_run
|
||||
recorder._buffer = bytearray() # the next recording's buffer
|
||||
old_buffer, old_rms = bytearray(), []
|
||||
recorder._pump(old_run, old_proc, old_proc.stdout, old_buffer, old_rms,
|
||||
2 * audio.CHUNK_BYTES)
|
||||
# Metered once, then the new run took over: the over-length cutoff hit
|
||||
# on the next chunk and had to stand down instead of stopping a
|
||||
# process that was never its own.
|
||||
self.assertEqual(len(levels), 1)
|
||||
self.assertEqual(len(old_buffer), 2 * audio.CHUNK_BYTES)
|
||||
self.assertEqual(new_proc.signals, [])
|
||||
self.assertEqual(old_proc.signals, [])
|
||||
self.assertEqual(recorder._buffer, bytearray())
|
||||
self.assertEqual((failures, deaths), ([], []))
|
||||
|
||||
def test_a_wav_that_cannot_be_written_is_reported_not_raised(self):
|
||||
recorder = audio.Recorder()
|
||||
results, failures = [], []
|
||||
recorder.stopped.connect(lambda *args: results.append(args))
|
||||
recorder.failed.connect(failures.append)
|
||||
proc = FakeProcess(tone(1.0))
|
||||
with only_these_tools("pw-record"), \
|
||||
mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||
mock.patch.object(audio, "write_wav",
|
||||
side_effect=OSError("disk full")):
|
||||
recorder.start()
|
||||
recorder._thread.join(timeout=5)
|
||||
recorder.stop()
|
||||
self.assertEqual(results, [])
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("disk full", failures[0])
|
||||
|
||||
def test_a_short_recording_reports_only_that(self):
|
||||
_, results, failures, _ = self.record(silence(0.1))
|
||||
@@ -722,6 +873,42 @@ class MacMeetingRecorder(OnMacOS, DikteTest):
|
||||
_, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5))
|
||||
self.assertTrue(all(process.signals for process in processes))
|
||||
|
||||
def test_a_stop_we_asked_for_is_not_reported_as_an_ffmpeg_failure(self):
|
||||
"""ffmpeg exits 255 when interrupted, and the interruption was our own
|
||||
stop: a meeting ended at once must say "too short", not "ffmpeg → 255"."""
|
||||
path = str(self.path("meeting.wav"))
|
||||
recorder = audio.MeetingRecorder()
|
||||
failed = []
|
||||
recorder.failed.connect(failed.append)
|
||||
processes = [_SignalCrashingProcess(tone(0.1)),
|
||||
_SignalCrashingProcess(tone(0.1))]
|
||||
with only_these_tools("ffmpeg"), self.devices(), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||
recorder._thread.join(timeout=5)
|
||||
recorder.stop()
|
||||
self.assertEqual(len(failed), 1)
|
||||
self.assertIn("0.3", failed[0])
|
||||
self.assertNotIn("255", failed[0])
|
||||
|
||||
def test_an_ffmpeg_that_died_on_its_own_keeps_its_exit_code(self):
|
||||
"""A process nobody interrupted has a story to tell, and its code is
|
||||
the only lead the user gets."""
|
||||
path = str(self.path("meeting.wav"))
|
||||
recorder = audio.MeetingRecorder()
|
||||
failed = []
|
||||
recorder.failed.connect(failed.append)
|
||||
dead = _SignalCrashingProcess(tone(0.1))
|
||||
dead._alive = False # it fell over before stop() reached it
|
||||
processes = [dead, FakeProcess(tone(0.1))]
|
||||
with only_these_tools("ffmpeg"), self.devices(), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||
recorder._thread.join(timeout=5)
|
||||
recorder.stop()
|
||||
self.assertEqual(len(failed), 1)
|
||||
self.assertIn("255", failed[0])
|
||||
|
||||
def test_a_legacy_numeric_target_fails_before_recording(self):
|
||||
recorder = audio.MeetingRecorder()
|
||||
failed = []
|
||||
@@ -953,9 +1140,11 @@ class WindowsDevices(OnWindows, DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# The listing is remembered between calls, so that a dictation does not
|
||||
# run ffmpeg of its own. It cannot be remembered between tests.
|
||||
# run ffmpeg of its own. It cannot be remembered between tests, and
|
||||
# neither can the pw-record probe's answer.
|
||||
audio._DSHOW_SEEN.clear()
|
||||
self.addCleanup(audio._DSHOW_SEEN.clear)
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
|
||||
@contextlib.contextmanager
|
||||
def listing(self, stderr=None, tools=("ffmpeg",)):
|
||||
|
||||
+170
-18
@@ -1,9 +1,9 @@
|
||||
"""Who cleans the transcript up, and what they are asked.
|
||||
"""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.
|
||||
The CLIs are faked at subprocess.Popen: 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
|
||||
@@ -18,18 +18,26 @@ 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."""
|
||||
def fake_cli(stdout="", code=0, stderr="", last_message=""):
|
||||
"""Stand in for subprocess.Popen.
|
||||
|
||||
_output hands the process a temporary file for each stream, so the fake
|
||||
writes into those, plus the file Codex would have written on its way out.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
def popen(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
kwargs["stdout"].write(stdout.encode("utf-8"))
|
||||
kwargs["stderr"].write(stderr.encode("utf-8"))
|
||||
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)
|
||||
proc = mock.Mock()
|
||||
proc.returncode = code
|
||||
return proc
|
||||
|
||||
return mock.patch.object(subprocess, "run", side_effect=run), calls
|
||||
return mock.patch.object(subprocess, "Popen", side_effect=popen), calls
|
||||
|
||||
|
||||
class Provider(DikteTest):
|
||||
@@ -49,7 +57,10 @@ class Provider(DikteTest):
|
||||
def test_what_each_one_runs(self):
|
||||
self.assertEqual(cleanup.executable("claude"), "claude")
|
||||
self.assertEqual(cleanup.executable("codex"), "codex")
|
||||
self.assertEqual(cleanup.executable("agy"), "agy")
|
||||
self.assertEqual(cleanup.executable("openrouter"), "")
|
||||
self.assertEqual(cleanup.executable("gemini"), "")
|
||||
self.assertEqual(cleanup.executable("opencode"), "")
|
||||
|
||||
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")),
|
||||
@@ -65,6 +76,19 @@ class Provider(DikteTest):
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="codex",
|
||||
cleanup_codex_model="gpt-5.4")), "gpt-5.4")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="gemini")),
|
||||
"gemini-3.5-flash-lite")
|
||||
# Antigravity is left on its own default the way Codex is.
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="agy")), "agy")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="agy",
|
||||
cleanup_agy_model="gemini-3.7-flash-low")),
|
||||
"gemini-3.7-flash-low")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="opencode",
|
||||
cleanup_opencode_model="glm-5.3")), "glm-5.3")
|
||||
|
||||
|
||||
class OpenRouter(DikteTest):
|
||||
@@ -80,12 +104,78 @@ class OpenRouter(DikteTest):
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
conf = self.config(openrouter_api_key="sk-or-test")
|
||||
patcher, calls = fake_run(stdout="never")
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
|
||||
cleanup.run("uh, done", conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class OpenCode(DikteTest):
|
||||
def test_it_is_one_request_with_the_settings_as_they_were(self):
|
||||
conf = self.config(cleanup_provider="opencode",
|
||||
opencode_api_key="opencode-test-key",
|
||||
cleanup_opencode_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", "opencode-test-key", "some/model", "the rules"))
|
||||
self.assertEqual(call.call_args.kwargs["reasoning"], "low")
|
||||
self.assertEqual(call.call_args.kwargs["provider"], "opencode")
|
||||
self.assertEqual(call.call_args.kwargs["service"], "OpenCode Go")
|
||||
self.assertEqual(call.call_args.kwargs["base_url"],
|
||||
"https://opencode.ai/zen/go/v1")
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
conf = self.config(cleanup_provider="opencode",
|
||||
opencode_api_key="opencode-test-key")
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
|
||||
cleanup.run("uh, done", conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class GoogleAiStudio(DikteTest):
|
||||
"""Cleanup over Google's OpenAI-compatible endpoint: one request, no CLI."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = self.config(cleanup_provider="gemini",
|
||||
gemini_api_key="AIza-test")
|
||||
|
||||
def test_it_goes_to_google_with_the_settings_as_they_were(self):
|
||||
self.conf["cleanup_reasoning"] = "none"
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
self.assertEqual(cleanup.run("uh, done", self.conf, "the rules"),
|
||||
"Done.")
|
||||
self.assertEqual(
|
||||
calls[0].full_url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")
|
||||
payload = sent_json(calls[0])
|
||||
self.assertEqual(payload["model"], "gemini-3.5-flash-lite")
|
||||
self.assertEqual(payload["reasoning_effort"], "minimal")
|
||||
self.assertIn("uh, done", payload["messages"][1]["content"])
|
||||
|
||||
def test_the_key_travels_as_a_bearer_token(self):
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(calls[0].get_header("Authorization"), "Bearer AIza-test")
|
||||
|
||||
def test_a_missing_key_names_google_rather_than_openrouter(self):
|
||||
self.conf["gemini_api_key"] = ""
|
||||
with mock.patch.dict(os.environ, {}, clear=True), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertIn("Google AI Studio", str(caught.exception))
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, fake_urlopen(chat_reply("Done.")):
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class ClaudeCode(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -93,7 +183,7 @@ class ClaudeCode(DikteTest):
|
||||
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)
|
||||
patcher, calls = fake_cli(**kwargs)
|
||||
with patcher:
|
||||
answer = cleanup.run(text, self.conf, "the rules")
|
||||
return answer, calls[0]
|
||||
@@ -146,14 +236,18 @@ class ClaudeCode(DikteTest):
|
||||
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)
|
||||
def test_a_run_that_never_ends_is_killed_with_its_whole_tree(self):
|
||||
def popen(cmd, **kwargs):
|
||||
proc = mock.Mock()
|
||||
proc.wait.side_effect = subprocess.TimeoutExpired(cmd, 180)
|
||||
return proc
|
||||
|
||||
with mock.patch.object(subprocess, "run", side_effect=run):
|
||||
with mock.patch.object(subprocess, "Popen", side_effect=popen), \
|
||||
mock.patch.object(cleanup.assistant, "kill_tree") as kill:
|
||||
with self.assertRaises(cleanup.CleanupError) as caught:
|
||||
cleanup.run("uh, book it", self.conf, "the rules")
|
||||
self.assertIn("180", str(caught.exception))
|
||||
kill.assert_called_once()
|
||||
|
||||
|
||||
class Codex(DikteTest):
|
||||
@@ -163,7 +257,7 @@ class Codex(DikteTest):
|
||||
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)
|
||||
patcher, calls = fake_cli(**kwargs)
|
||||
with patcher:
|
||||
answer = cleanup.run(text, self.conf, "the rules")
|
||||
return answer, calls[0]
|
||||
@@ -209,6 +303,63 @@ class Codex(DikteTest):
|
||||
self.run_cleanup(stdout="tokens used 400", last_message="")
|
||||
|
||||
|
||||
class Antigravity(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = self.config(cleanup_provider="agy")
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
|
||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||
patcher, calls = fake_cli(**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(stdout="Book it.\n")
|
||||
self.assertEqual(answer, "Book it.")
|
||||
self.assertEqual(cmd[0], "agy")
|
||||
self.assertEqual(cmd[cmd.index("-p") + 1],
|
||||
"the rules\n\n---\n\n<transcript>\nuh, book it\n</transcript>")
|
||||
|
||||
def test_it_starts_somewhere_of_its_own_and_takes_no_slash_commands(self):
|
||||
"""Without --new-project agy works in whichever project it was last in."""
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertIn("--new-project", cmd)
|
||||
self.assertIn("--disable-slash-commands", cmd)
|
||||
self.assertEqual(cmd[cmd.index("--output-format") + 1], "text")
|
||||
|
||||
def test_it_is_not_left_to_give_up_before_the_caller_does(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--print-timeout") + 1], "180s")
|
||||
|
||||
def test_the_model_is_left_alone_until_one_is_typed_in(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertNotIn("--model", cmd)
|
||||
self.conf["cleanup_agy_model"] = "gemini-3.7-flash-low"
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--model") + 1], "gemini-3.7-flash-low")
|
||||
|
||||
def test_the_thinking_setting_lands_on_the_nearest_rung_agy_has(self):
|
||||
self.conf["cleanup_reasoning"] = "max"
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--effort") + 1], "high")
|
||||
|
||||
def test_no_thinking_setting_means_no_flag(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertNotIn("--effort", cmd)
|
||||
|
||||
def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self):
|
||||
with self.assertRaises(cleanup.CleanupError):
|
||||
self.run_cleanup(stdout=" ")
|
||||
|
||||
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("agy", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -280,7 +431,8 @@ class Here(DikteTest):
|
||||
self.assertIn("out of memory", str(caught.exception))
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
patcher, calls = fake_run(stdout="never")
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, fake_urlopen(chat_reply("Done.")):
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
+107
-1
@@ -10,16 +10,21 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import webbrowser
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
from dikte import audio
|
||||
from dikte import cleanup
|
||||
from dikte import cli
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import hotkey
|
||||
from dikte import hub
|
||||
from dikte import ipc
|
||||
from dikte import paste
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
||||
from dikte import update
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error
|
||||
|
||||
|
||||
class Options:
|
||||
@@ -419,6 +424,78 @@ class Providers(DikteTest):
|
||||
self.assertIn("groq", out)
|
||||
self.assertIn("Groq", out)
|
||||
|
||||
def test_opencode_is_a_choice_and_reports_under_its_own_name(self):
|
||||
parser = cli.build_parser()
|
||||
self.assertEqual(
|
||||
parser.parse_args(["test-key", "opencode"]).which, "opencode")
|
||||
self.write_config({"opencode_api_key": "opencode-test"})
|
||||
with fake_urlopen({"data": [{"id": "deepseek-v4-flash"}]}):
|
||||
code, out, _ = self.run_cmd(cli.cmd_test_key, which="opencode")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("opencode: connection works, 1 models visible", out)
|
||||
|
||||
|
||||
class Updates(DikteTest):
|
||||
"""`dikte update` looks, says what it found, and installs nothing."""
|
||||
|
||||
RELEASE: ClassVar[dict] = {
|
||||
"tag_name": "v9.9.9",
|
||||
"html_url": "https://github.com/yusufipk/dikte/releases/tag/v9.9.9",
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
|
||||
# Nothing here may reach a browser, whatever the answer turns out to be.
|
||||
self.opened = []
|
||||
self.patch_attr(webbrowser, "open", self.opened.append)
|
||||
|
||||
def run_update(self, reply, **values):
|
||||
with fake_urlopen(reply), captured() as (out, err):
|
||||
code = cli.cmd_update(Options(open=False, **values))
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
def test_a_newer_release_is_named_with_its_page(self):
|
||||
code, out, _ = self.run_update(self.RELEASE)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("9.9.9", out)
|
||||
self.assertIn(self.RELEASE["html_url"], out)
|
||||
|
||||
def test_this_build_being_the_newest_is_not_a_failure(self):
|
||||
code, out, _ = self.run_update({"tag_name": f"v{cli.__version__}"})
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("newest", out)
|
||||
|
||||
def test_the_json_answer_says_both_numbers(self):
|
||||
code, out, _ = self.run_update(self.RELEASE, json=True)
|
||||
answer = json.loads(out)
|
||||
self.assertTrue(answer["update"])
|
||||
self.assertEqual(answer["latest"], "9.9.9")
|
||||
self.assertEqual(answer["current"], cli.__version__)
|
||||
|
||||
def test_github_being_unreachable_is_a_failure_with_a_reason(self):
|
||||
with fake_urlopen(url_error("no route to host")), captured() as (_, err):
|
||||
code = cli.cmd_update(Options(open=False))
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("api.github.com", err.getvalue())
|
||||
|
||||
def test_the_browser_is_opened_only_when_asked_and_only_when_there_is_one(self):
|
||||
self.run_update(self.RELEASE)
|
||||
self.assertEqual(self.opened, [])
|
||||
with fake_urlopen({"tag_name": f"v{cli.__version__}"}), captured():
|
||||
cli.cmd_update(Options(open=True))
|
||||
self.assertEqual(self.opened, [])
|
||||
with fake_urlopen(self.RELEASE), captured():
|
||||
cli.cmd_update(Options(open=True))
|
||||
self.assertEqual(self.opened, [self.RELEASE["html_url"]])
|
||||
|
||||
def test_the_answer_is_written_down_for_the_application(self):
|
||||
"""A check at a terminal is a check; the tray must not go and ask the
|
||||
same question an hour later."""
|
||||
self.run_update(self.RELEASE)
|
||||
self.assertEqual(update.state()["version"], "9.9.9")
|
||||
self.assertFalse(update.due())
|
||||
|
||||
|
||||
class Doctor(DikteTest):
|
||||
"""One pass over everything the settings window checks behind its buttons."""
|
||||
@@ -437,6 +514,35 @@ class Doctor(DikteTest):
|
||||
self.assertIn("OpenRouter key, cleaning up on some/model",
|
||||
self.run_doctor(as_json=False, cleanup_model="some/model"))
|
||||
|
||||
def test_cleanup_on_opencode_is_a_question_about_its_own_key(self):
|
||||
reply = self.run_doctor(cleanup_provider="opencode",
|
||||
cleanup_opencode_model="glm-5.3")
|
||||
self.assertEqual(reply["cleanup"]["provider"], "opencode")
|
||||
self.assertEqual(reply["cleanup"]["model"], "glm-5.3")
|
||||
self.assertIn("OpenCode Go key, cleaning up on glm-5.3",
|
||||
self.run_doctor(as_json=False, cleanup_provider="opencode",
|
||||
cleanup_opencode_model="glm-5.3"))
|
||||
|
||||
def test_it_survives_every_provider_cleanup_can_be_set_to(self):
|
||||
"""It used to raise KeyError on the local model, whose executable is ""."""
|
||||
for name in cleanup.PROVIDERS:
|
||||
with self.subTest(provider=name):
|
||||
reply = self.run_doctor(cleanup_provider=name)
|
||||
self.assertEqual(reply["cleanup"]["provider"], name)
|
||||
self.run_doctor(as_json=False, cleanup_provider=name)
|
||||
|
||||
def test_a_provider_with_no_key_to_check_says_so_rather_than_no(self):
|
||||
"""A CLI needs none, so `false` there would read as one gone missing."""
|
||||
self.assertIsNone(self.run_doctor(cleanup_provider="claude")["cleanup"]["key"])
|
||||
self.assertIsNone(self.run_doctor(cleanup_provider="local")["cleanup"]["key"])
|
||||
self.assertIs(self.run_doctor(cleanup_provider="gemini")["cleanup"]["key"],
|
||||
False)
|
||||
|
||||
def test_cleanup_on_google_is_a_question_about_its_own_key(self):
|
||||
line = self.run_doctor(as_json=False, cleanup_provider="gemini",
|
||||
cleanup_gemini_model="gemini-2.5-flash")
|
||||
self.assertIn("Google AI Studio key, cleaning up on gemini-2.5-flash", line)
|
||||
|
||||
def test_it_asks_after_the_programs_this_desktop_actually_uses(self):
|
||||
"""A missing ydotool on a Mac is a red mark with nothing behind it."""
|
||||
with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS):
|
||||
|
||||
@@ -8,7 +8,10 @@ config and now shadows the default.
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -44,6 +47,21 @@ class Loading(DikteTest):
|
||||
conf = cfg.Config()
|
||||
self.assertEqual(conf["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||
|
||||
def test_a_config_that_is_not_json_is_set_aside_as_evidence(self):
|
||||
"""Left in place it would be overwritten by the very next save."""
|
||||
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cfg.CONFIG_FILE.write_text("{not json", encoding="utf-8")
|
||||
broken = cfg.CONFIG_FILE.with_suffix(".json.broken")
|
||||
with mock.patch("builtins.print") as told:
|
||||
conf = cfg.Config()
|
||||
self.assertEqual(broken.read_text(encoding="utf-8"), "{not json")
|
||||
self.assertFalse(cfg.CONFIG_FILE.exists())
|
||||
self.assertIn(str(broken), told.call_args[0][0])
|
||||
conf.save()
|
||||
self.assertEqual(broken.read_text(encoding="utf-8"), "{not json")
|
||||
self.assertEqual(self.read_config_file()["cleanup_model"],
|
||||
cfg.DEFAULTS["cleanup_model"])
|
||||
|
||||
def test_a_config_that_is_json_but_not_an_object(self):
|
||||
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cfg.CONFIG_FILE.write_text("[1, 2]", encoding="utf-8")
|
||||
@@ -113,6 +131,41 @@ class Saving(DikteTest):
|
||||
conf.save()
|
||||
self.assertEqual(i18n.language(), "tr")
|
||||
|
||||
def test_the_settings_hit_the_disk_before_the_swap(self):
|
||||
"""Renaming a file still in the page cache into place makes a power
|
||||
cut a settings wipe, which is what the atomic replace exists to stop."""
|
||||
with mock.patch("os.fsync") as fsync:
|
||||
cfg.Config().save()
|
||||
fsync.assert_called_once()
|
||||
|
||||
def test_a_file_held_briefly_by_a_scanner_does_not_fail_the_save(self):
|
||||
"""Antivirus and sync tools on Windows hold a fresh file for a moment,
|
||||
and the rename over it fails until they let go."""
|
||||
attempts = []
|
||||
real_replace = pathlib.Path.replace
|
||||
|
||||
def flaky(path, target):
|
||||
attempts.append(str(target))
|
||||
if len(attempts) < 3:
|
||||
raise PermissionError("held by a scanner")
|
||||
return real_replace(path, target)
|
||||
|
||||
with mock.patch.object(pathlib.Path, "replace", flaky), \
|
||||
mock.patch("time.sleep"):
|
||||
cfg.Config().save()
|
||||
self.assertEqual(len(attempts), 3)
|
||||
self.assertEqual(self.read_config_file()["language"],
|
||||
cfg.DEFAULTS["language"])
|
||||
|
||||
def test_a_file_held_for_good_still_raises(self):
|
||||
def held(path, target):
|
||||
raise PermissionError("never let go")
|
||||
|
||||
with mock.patch.object(pathlib.Path, "replace", held), \
|
||||
mock.patch("time.sleep"):
|
||||
with self.assertRaises(PermissionError):
|
||||
cfg.Config().save()
|
||||
|
||||
|
||||
class Keys(DikteTest):
|
||||
def test_a_stored_key_is_used(self):
|
||||
@@ -134,6 +187,10 @@ class Keys(DikteTest):
|
||||
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")
|
||||
with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "AIza-env"}):
|
||||
self.assertEqual(cfg.Config().gemini_key(), "AIza-env")
|
||||
with mock.patch.dict(os.environ, {"OPENCODE_API_KEY": "opencode-env"}):
|
||||
self.assertEqual(cfg.Config().opencode_key(), "opencode-env")
|
||||
|
||||
|
||||
class TranscribeTarget(DikteTest):
|
||||
@@ -163,6 +220,19 @@ class TranscribeTarget(DikteTest):
|
||||
self.assertEqual(target.service, "OpenRouter")
|
||||
self.assertEqual(target.api_key, "sk-or-test")
|
||||
self.assertEqual(target.model, "openai/whisper-1")
|
||||
self.assertEqual(target.file_model, "")
|
||||
|
||||
def test_openrouter_carries_its_file_model(self):
|
||||
conf = self.config(transcribe_provider="openrouter",
|
||||
openrouter_api_key="sk-or-test",
|
||||
openrouter_file_model=" openai/whisper-large-v3 ")
|
||||
self.assertEqual(conf.transcribe_target().file_model,
|
||||
"openai/whisper-large-v3")
|
||||
|
||||
def test_only_openrouter_has_a_file_model(self):
|
||||
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test",
|
||||
openrouter_file_model="openai/whisper-large-v3")
|
||||
self.assertEqual(conf.transcribe_target().file_model, "")
|
||||
|
||||
def test_groq_when_it_is_picked(self):
|
||||
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
||||
@@ -350,6 +420,23 @@ class History(DikteTest):
|
||||
cfg.delete_history([])
|
||||
self.assertEqual(len(cfg.read_history()), 1)
|
||||
|
||||
def test_amending_matches_on_content_and_patches_in_place(self):
|
||||
rows = [self.entry("a"), self.entry("b")]
|
||||
for row in rows:
|
||||
cfg.append_history(row)
|
||||
patched = cfg.amend_history(rows[0], cleanup_error="could not paste")
|
||||
self.assertEqual(patched["cleanup_error"], "could not paste")
|
||||
kept = cfg.read_history()
|
||||
self.assertEqual([row["text"] for row in kept], ["a", "b"])
|
||||
self.assertEqual(kept[0]["cleanup_error"], "could not paste")
|
||||
|
||||
def test_amending_a_row_a_trim_took_away_is_a_no_op(self):
|
||||
row = self.entry("gone")
|
||||
cfg.append_history(row)
|
||||
cfg.clear_history()
|
||||
self.assertIsNone(cfg.amend_history(row, cleanup_error="x"))
|
||||
self.assertEqual(cfg.read_history(), [])
|
||||
|
||||
def test_clearing(self):
|
||||
cfg.append_history(self.entry("a"))
|
||||
cfg.clear_history()
|
||||
@@ -358,6 +445,40 @@ class History(DikteTest):
|
||||
def test_clearing_a_history_that_is_not_there(self):
|
||||
cfg.clear_history() # must not raise
|
||||
|
||||
def test_an_append_during_a_trim_is_not_lost(self):
|
||||
"""Trim is read, cut, rewrite; a dictation appended between the read
|
||||
and the rewrite must wait rather than be erased by a rewrite that
|
||||
never saw it. The rewrite is slowed down to hold the race open."""
|
||||
for index in range(10):
|
||||
cfg.append_history(self.entry(str(index)))
|
||||
real_write = cfg._write_history
|
||||
rewriting = threading.Event()
|
||||
|
||||
def slow_write(lines):
|
||||
rewriting.set()
|
||||
time.sleep(0.1)
|
||||
real_write(lines)
|
||||
|
||||
with mock.patch.object(cfg, "_write_history", slow_write):
|
||||
trimmer = threading.Thread(target=cfg.trim_history, args=(3,))
|
||||
trimmer.start()
|
||||
# The trim now holds the lock inside its read-cut-rewrite window.
|
||||
self.assertTrue(rewriting.wait(5))
|
||||
appender = threading.Thread(target=cfg.append_history,
|
||||
args=(self.entry("late"),))
|
||||
appender.start()
|
||||
trimmer.join()
|
||||
appender.join()
|
||||
self.assertEqual([row["text"] for row in cfg.read_history()],
|
||||
["7", "8", "9", "late"])
|
||||
|
||||
def test_the_rewrite_hits_the_disk_before_the_swap(self):
|
||||
for index in range(5):
|
||||
cfg.append_history(self.entry(str(index)))
|
||||
with mock.patch("os.fsync") as fsync:
|
||||
cfg.trim_history(2)
|
||||
fsync.assert_called_once()
|
||||
|
||||
|
||||
class Meetings(DikteTest):
|
||||
def entry(self, base, **changes):
|
||||
@@ -430,6 +551,27 @@ class Meetings(DikteTest):
|
||||
cfg.delete_meetings([])
|
||||
self.assertEqual(len(cfg.read_meetings()), 1)
|
||||
|
||||
def test_the_index_hits_the_disk_before_the_swap(self):
|
||||
with mock.patch("os.fsync") as fsync:
|
||||
cfg.save_meeting(self.entry("a"))
|
||||
fsync.assert_called_once()
|
||||
|
||||
def test_an_index_held_briefly_by_a_scanner_is_still_written(self):
|
||||
real_replace = pathlib.Path.replace
|
||||
attempts = []
|
||||
|
||||
def flaky(path, target):
|
||||
attempts.append(str(target))
|
||||
if len(attempts) < 3:
|
||||
raise PermissionError("held by a scanner")
|
||||
return real_replace(path, target)
|
||||
|
||||
with mock.patch.object(pathlib.Path, "replace", flaky), \
|
||||
mock.patch("time.sleep"):
|
||||
cfg.save_meeting(self.entry("a"))
|
||||
self.assertEqual(len(attempts), 3)
|
||||
self.assertEqual([row["base"] for row in cfg.read_meetings()], ["a"])
|
||||
|
||||
|
||||
class Defaults(unittest.TestCase):
|
||||
"""The table itself, which every command line and settings tab reads."""
|
||||
@@ -449,6 +591,19 @@ class Defaults(unittest.TestCase):
|
||||
def test_the_keys_ship_empty(self):
|
||||
self.assertEqual(cfg.DEFAULTS["openai_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["gemini_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["opencode_api_key"], "")
|
||||
|
||||
def test_google_ai_studio_is_a_cleanup_provider_and_not_a_transcriber(self):
|
||||
"""Its compatible endpoint has no /audio/transcriptions behind it."""
|
||||
self.assertNotIn("gemini", cfg.TRANSCRIBERS)
|
||||
self.assertIn("gemini", cleanup.PROVIDERS)
|
||||
|
||||
def test_opencode_ships_on_its_own_endpoint(self):
|
||||
self.assertEqual(cfg.DEFAULTS["opencode_base_url"],
|
||||
"https://opencode.ai/zen/go/v1")
|
||||
self.assertEqual(cfg.DEFAULTS["cleanup_opencode_model"], "deepseek-v4-flash")
|
||||
self.assertEqual(cfg.DEFAULTS["assistant_opencode_model"], "deepseek-v4-flash")
|
||||
|
||||
def test_every_language_specific_prompt_has_both_languages(self):
|
||||
for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT",
|
||||
|
||||
@@ -235,6 +235,28 @@ class ChunkSeconds(DikteTest):
|
||||
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0)
|
||||
|
||||
|
||||
class Ffmpeg(DikteTest):
|
||||
"""How the converter process is started."""
|
||||
|
||||
def test_its_output_is_read_as_utf8_whatever_the_locale_says(self):
|
||||
"""ffmpeg writes UTF-8; read as the locale codepage its messages
|
||||
mojibake, and a byte the codepage cannot place raises from inside
|
||||
communicate itself."""
|
||||
out = str(self.path("out.wav"))
|
||||
with open(out, "wb") as fh:
|
||||
fh.write(b"\x00")
|
||||
proc = mock.Mock()
|
||||
proc.communicate.return_value = ("", "")
|
||||
proc.returncode = 0
|
||||
proc.poll.return_value = 0
|
||||
with mock.patch.object(ft.subprocess, "Popen", return_value=proc) as popen:
|
||||
ft._ffmpeg(["-i", "in.mp4", out], out)
|
||||
kwargs = popen.call_args.kwargs
|
||||
self.assertTrue(kwargs["text"])
|
||||
self.assertEqual(kwargs["encoding"], "utf-8")
|
||||
self.assertEqual(kwargs["errors"], "replace")
|
||||
|
||||
|
||||
class Chunks(DikteTest):
|
||||
"""What each provider is handed, and in how many pieces."""
|
||||
|
||||
|
||||
+242
-2
@@ -9,6 +9,7 @@ import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import sys
|
||||
import tarfile
|
||||
@@ -179,6 +180,21 @@ class Download(Local):
|
||||
ggml.download(item("m.bin", b"x"), target)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_a_target_held_open_keeps_the_finished_download(self):
|
||||
# Windows refuses to replace a file a running server holds open. The
|
||||
# bytes are complete and verified by then, so the .part must survive
|
||||
# the failure rather than being deleted with everything else.
|
||||
data = b"a finished, verified download"
|
||||
target = self.path("data", "models", "m.bin")
|
||||
with fake_urlopen(body(data)):
|
||||
with mock.patch.object(pathlib.Path, "replace",
|
||||
side_effect=PermissionError(13, "in use")):
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
ggml.download(item("m.bin", data), target)
|
||||
self.assertIn("held open", str(caught.exception))
|
||||
self.assertEqual(target.with_name("m.bin.part").read_bytes(), data)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
|
||||
# --- installing a program -------------------------------------------------
|
||||
|
||||
@@ -275,6 +291,70 @@ class InstallProgram(Local):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty)
|
||||
self.assertIn("whisper-server", str(caught.exception))
|
||||
|
||||
def test_a_failed_update_leaves_the_working_install_alone(self):
|
||||
# The old install used to be deleted before the new bytes had even
|
||||
# arrived, so a bad download left no local server at all.
|
||||
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz",
|
||||
archive=b"not an archive at all")
|
||||
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
self.assertEqual(ggml.installed_version(ggml.WHISPER), "v1.9.1")
|
||||
# And the half-made sibling did not linger either.
|
||||
left = list(self.path("data", "bin", "whisper").glob("*.new"))
|
||||
self.assertEqual(left, [])
|
||||
|
||||
def test_an_update_that_never_downloaded_leaves_the_install_alone(self):
|
||||
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with serving(listing, self.archive):
|
||||
with mock.patch.object(ggml, "download",
|
||||
side_effect=ggml.LocalError("no route")):
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
class StubServer:
|
||||
"""Owns the installed binary, remembers when it was told to stop."""
|
||||
|
||||
def __init__(self):
|
||||
self.program = ggml.WHISPER
|
||||
self.stops = 0
|
||||
self.new_version_was_ready = False
|
||||
|
||||
def settings(self):
|
||||
return {"binary": ""}
|
||||
|
||||
def stop(self):
|
||||
self.stops += 1
|
||||
self.new_version_was_ready = any(
|
||||
(ggml.BIN_DIR / "whisper").glob("*.new"))
|
||||
|
||||
def test_the_running_server_is_stopped_only_for_the_swap(self):
|
||||
# The outage is the swap, not the transfer: the server keeps answering
|
||||
# through a download that can take minutes, and is stopped only once
|
||||
# the replacement is unpacked next door and known to be whole.
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
server = self.StubServer()
|
||||
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
self.assertEqual(server.stops, 1)
|
||||
self.assertTrue(server.new_version_was_ready)
|
||||
|
||||
def test_a_download_that_fails_never_stops_the_server(self):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
server = self.StubServer()
|
||||
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||
with serving(listing, self.archive):
|
||||
with mock.patch.object(ggml, "download",
|
||||
side_effect=ggml.LocalError("no route")):
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertEqual(server.stops, 0)
|
||||
|
||||
|
||||
def test_a_release_without_a_published_checksum_is_refused(self):
|
||||
# GitHub did not always publish one, and whisper.cpp v1.8.0 and older
|
||||
@@ -456,12 +536,14 @@ STAND_IN = textwrap.dedent("""
|
||||
def opt(name, default=""):
|
||||
return args[args.index(name) + 1] if name in args else default
|
||||
|
||||
time.sleep(float(opt("--wait", "0")))
|
||||
|
||||
# After the sleep, so that --wait plus --die is a program that runs for a
|
||||
# while and then crashes, the way a bad model dies mid-load.
|
||||
if "--die" in args:
|
||||
print("could not load model: no such file")
|
||||
sys.exit(2)
|
||||
|
||||
time.sleep(float(opt("--wait", "0")))
|
||||
|
||||
started = time.monotonic()
|
||||
healthy_after = float(opt("--healthy-after", "0"))
|
||||
|
||||
@@ -539,6 +621,14 @@ class Servers(Local):
|
||||
self.assertTrue(server.running)
|
||||
self.assertTrue(second)
|
||||
|
||||
def count_ports(self):
|
||||
"""Record every port handed to a launch, one per attempt."""
|
||||
ports = []
|
||||
real = ggml._free_port
|
||||
self.patch_attr(ggml, "_free_port",
|
||||
lambda: ports.append(real()) or ports[-1])
|
||||
return ports
|
||||
|
||||
def test_a_program_that_dies_reports_what_it_printed(self):
|
||||
server = self.server(extra=["--die"])
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
@@ -546,6 +636,46 @@ class Servers(Local):
|
||||
self.assertIn("no such file", str(caught.exception))
|
||||
self.assertFalse(server.running)
|
||||
|
||||
def test_an_early_death_that_never_listened_is_retried(self):
|
||||
# A child that loses the bind race fails and exits at once, and which
|
||||
# port was lost cannot be told from the log: the shape of the failure,
|
||||
# not its wording, is what earns another port.
|
||||
ports = self.count_ports()
|
||||
server = self.server(extra=["--die"])
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
server.serve()
|
||||
self.assertEqual(len(ports), 3)
|
||||
|
||||
def test_a_late_crash_is_not_retried(self):
|
||||
# A program that ran for a while before dying was not a bind race: it
|
||||
# would die the same way on any port.
|
||||
self.patch_attr(ggml, "EARLY_EXIT_WINDOW", 0.2)
|
||||
ports = self.count_ports()
|
||||
server = self.server(extra=["--wait", "0.5", "--die"])
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
server.serve()
|
||||
self.assertEqual(len(ports), 1)
|
||||
self.assertIn("no such file", str(caught.exception))
|
||||
|
||||
def test_an_open_port_with_a_dead_child_is_not_ready(self):
|
||||
# Another process winning the bind race leaves the port open while our
|
||||
# child exits: the open port alone must not be read as ready.
|
||||
polls = iter([None, 2])
|
||||
proc = mock.Mock()
|
||||
proc.poll = lambda: next(polls)
|
||||
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||
reason, listened = self.server()._wait_ready(proc, 1)
|
||||
self.assertEqual(reason, "exited")
|
||||
self.assertFalse(listened)
|
||||
|
||||
def test_an_open_port_with_a_child_that_outlives_it_a_beat_is_ready(self):
|
||||
proc = mock.Mock()
|
||||
proc.poll = lambda: None
|
||||
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||
reason, listened = self.server()._wait_ready(proc, 1)
|
||||
self.assertEqual(reason, "ready")
|
||||
self.assertTrue(listened)
|
||||
|
||||
def test_a_model_that_is_still_loading_is_not_ready_yet(self):
|
||||
# llama binds its port first and answers /health with 503 until the
|
||||
# model is in memory, so the open port on its own is not the signal.
|
||||
@@ -614,6 +744,72 @@ class Servers(Local):
|
||||
def test_no_pid_file_is_nothing_to_sweep(self):
|
||||
self.assertFalse(self.server().sweep())
|
||||
|
||||
def test_an_unverifiable_pid_is_kept_for_a_later_sweep(self):
|
||||
# Could not be checked is not the same as known stale: dropping the
|
||||
# file here would lose track of a server that may still hold a model.
|
||||
server = self.server()
|
||||
server._remember(4242)
|
||||
with mock.patch.object(server, "_is_ours", return_value=None):
|
||||
self.assertFalse(server.sweep())
|
||||
self.assertTrue(server._pid_file().exists())
|
||||
|
||||
def test_a_pid_known_stale_is_forgotten(self):
|
||||
server = self.server()
|
||||
server._remember(4242)
|
||||
with mock.patch.object(server, "_is_ours", return_value=False):
|
||||
self.assertFalse(server.sweep())
|
||||
self.assertFalse(server._pid_file().exists())
|
||||
|
||||
def test_the_pid_file_goes_once_the_kill_was_attempted(self):
|
||||
server = self.server()
|
||||
server._remember(4242)
|
||||
with mock.patch.object(server, "_is_ours", return_value=True):
|
||||
with mock.patch.object(ggml.os, "kill") as kill:
|
||||
self.assertTrue(server.sweep())
|
||||
kill.assert_called_once_with(4242, signal.SIGTERM)
|
||||
self.assertFalse(server._pid_file().exists())
|
||||
|
||||
def test_forget_leaves_a_pid_file_that_is_no_longer_ours(self):
|
||||
server = self.server()
|
||||
server._remember(111)
|
||||
# A Dikte started after us wrote its own server's pid over ours;
|
||||
# removing the file would hide that server from every future sweep.
|
||||
server._pid_file().write_text("222")
|
||||
server._forget()
|
||||
self.assertEqual(server._pid_file().read_text(), "222")
|
||||
|
||||
def test_forget_removes_the_pid_it_remembered(self):
|
||||
server = self.server()
|
||||
server._remember(111)
|
||||
server._forget()
|
||||
self.assertFalse(server._pid_file().exists())
|
||||
|
||||
def test_stop_waits_out_a_start_in_flight_and_kills_it(self):
|
||||
# stop_all on quit must not slide past a launch that is mid-load: the
|
||||
# child would then survive Dikte with nothing left that knows its pid.
|
||||
children = []
|
||||
real = ggml.subprocess.Popen
|
||||
|
||||
def popen(*args, **kwargs):
|
||||
proc = real(*args, **kwargs)
|
||||
children.append(proc)
|
||||
return proc
|
||||
|
||||
self.patch_attr(ggml.subprocess, "Popen", popen)
|
||||
server = self.server(extra=["--wait", "0.5"])
|
||||
thread = threading.Thread(target=server.serve)
|
||||
thread.start()
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while not children and time.monotonic() < deadline:
|
||||
time.sleep(0.01) # until the launch is truly in flight
|
||||
server.stop()
|
||||
finally:
|
||||
thread.join(timeout=10)
|
||||
self.assertEqual(len(children), 1)
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
self.assertFalse(server.running)
|
||||
|
||||
def test_a_start_that_goes_wrong_takes_its_process_with_it(self):
|
||||
started = []
|
||||
|
||||
@@ -752,6 +948,10 @@ class InstallOnWindows(Local):
|
||||
super().setUp()
|
||||
self.patch_attr(sys, "platform", "win32")
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
# shutil.which cannot be allowed through to the real one: standing on
|
||||
# win32 from another system, Python 3.12's Windows branch of which()
|
||||
# reaches for the nt module that is not there.
|
||||
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||
self.archive = zipball({
|
||||
"Release/whisper-server.exe": b"MZ not really a program",
|
||||
"Release/whisper.dll": b"not really a library",
|
||||
@@ -785,3 +985,43 @@ class InstallOnWindows(Local):
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertIn("this machine", str(caught.exception))
|
||||
|
||||
|
||||
class WindowsOwnership(Local):
|
||||
"""Sweeping on Windows goes by the executable's full path, never its name:
|
||||
the base name alone is anyone's whisper-server.exe, and this answer decides
|
||||
what gets killed."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(sys, "platform", "win32")
|
||||
# See InstallOnWindows: the real which() on win32 wants the nt module.
|
||||
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||
self.made = ggml.Server(ggml.WHISPER, lambda values: [], {"binary": ""})
|
||||
|
||||
def image(self, path):
|
||||
self.patch_attr(ggml, "_win_image_name", lambda pid: path)
|
||||
|
||||
def test_a_binary_under_our_bin_directory_is_ours(self):
|
||||
self.image(str(ggml.BIN_DIR / "whisper" / "v1.9.1" / "whisper-server.exe"))
|
||||
self.assertIs(self.made._is_ours(1234), True)
|
||||
|
||||
def test_the_configured_binary_is_ours_wherever_it_lives(self):
|
||||
mine = self.path("elsewhere", "whisper-server.exe")
|
||||
mine.parent.mkdir(parents=True)
|
||||
mine.write_bytes(b"MZ")
|
||||
mine.chmod(0o755)
|
||||
self.made._settings["binary"] = str(mine)
|
||||
self.image(str(mine.resolve()))
|
||||
self.assertIs(self.made._is_ours(1234), True)
|
||||
|
||||
def test_the_same_name_somewhere_else_is_not_ours(self):
|
||||
self.image(str(self.path("theirs", "whisper-server.exe")))
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
self.assertIs(self.made._is_ours(1234), False)
|
||||
|
||||
def test_a_process_that_cannot_be_read_is_no_verdict(self):
|
||||
# OpenProcess answering nothing covers both "gone" and "not readable
|
||||
# from here", and only one of those makes the pid file safe to drop.
|
||||
self.image("")
|
||||
self.assertIsNone(self.made._is_ours(1234))
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
|
||||
from dikte import hub
|
||||
from dikte import paths
|
||||
from tests.support import DikteTest, fake_urlopen, http_error, url_error
|
||||
|
||||
RELEASE = {
|
||||
@@ -165,6 +166,15 @@ def os_utime(path):
|
||||
os.utime(path, (old, old))
|
||||
|
||||
|
||||
class CacheLocation(DikteTest):
|
||||
"""Resolved at import, like every other path constant."""
|
||||
|
||||
def test_the_cache_lives_in_the_system_cache_directory(self):
|
||||
# One answer for both, the same way ggml and config share DATA_DIR:
|
||||
# hub asked paths once, at import, and kept what it was told.
|
||||
self.assertEqual(hub.CACHE_DIR, paths.cache_dir())
|
||||
|
||||
|
||||
class CacheOnDisk(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
@@ -475,6 +475,17 @@ class Windows(unittest.TestCase):
|
||||
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||
self.app = self.installed / "Dikte.exe"
|
||||
self.app.write_text("")
|
||||
# APPDATA pointed into the sandbox, so that the Startup folder these
|
||||
# tests delete from is never the machine's own.
|
||||
appdata = mock.patch.dict(os.environ,
|
||||
{"APPDATA": str(self.installed / "Roaming")})
|
||||
appdata.start()
|
||||
self.addCleanup(appdata.stop)
|
||||
|
||||
def startup_shortcut(self):
|
||||
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
|
||||
return (self.installed / "Roaming" / "Microsoft" / "Windows"
|
||||
/ "Start Menu" / "Programs" / "Startup" / "Dikte.lnk")
|
||||
|
||||
def _write(self, command):
|
||||
self.value = command
|
||||
@@ -510,6 +521,45 @@ class Windows(unittest.TestCase):
|
||||
self.assertEqual(len(self.install()), 1)
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_an_entry_for_another_working_install_is_left_alone(self):
|
||||
"""The same courtesy the Linux half pays another menu entry: an entry
|
||||
naming an executable that still exists is an installation that still
|
||||
works, and a start of this one has no business redirecting it."""
|
||||
other = self.installed / "Elsewhere" / "Dikte.exe"
|
||||
other.parent.mkdir()
|
||||
other.write_text("")
|
||||
self.value = f'"{other}"'
|
||||
self.assertEqual(self.install(), [])
|
||||
self.assertEqual(self.value, f'"{other}"')
|
||||
|
||||
def test_asking_outright_overrules_a_working_other_install(self):
|
||||
other = self.installed / "Elsewhere" / "Dikte.exe"
|
||||
other.parent.mkdir()
|
||||
other.write_text("")
|
||||
self.value = f'"{other}"'
|
||||
self.assertEqual(self.install(force=True), [integrate._run_entry_name()])
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_typing_it_sweeps_away_a_checkout_startup_shortcut(self):
|
||||
"""install.ps1 -Autostart writes it, the Run value replaces it, and
|
||||
both left in place would be two Diktes at every sign-in."""
|
||||
shortcut = self.startup_shortcut()
|
||||
shortcut.parent.mkdir(parents=True)
|
||||
shortcut.write_text("")
|
||||
changed = self.install(force=True)
|
||||
self.assertIn(shortcut, changed)
|
||||
self.assertFalse(shortcut.exists())
|
||||
|
||||
def test_a_start_leaves_a_checkout_startup_shortcut_alone(self):
|
||||
"""The silent call on every start has not been asked to move the
|
||||
machine off its checkout."""
|
||||
shortcut = self.startup_shortcut()
|
||||
shortcut.parent.mkdir(parents=True)
|
||||
shortcut.write_text("")
|
||||
self.value = f'"{self.app}"'
|
||||
self.assertEqual(self.install(), [])
|
||||
self.assertTrue(shortcut.exists())
|
||||
|
||||
def test_running_it_again_changes_nothing(self):
|
||||
self.install(force=True)
|
||||
self.assertEqual(self.install(), [])
|
||||
@@ -521,6 +571,31 @@ class Windows(unittest.TestCase):
|
||||
self.assertEqual(self.remove(), [])
|
||||
|
||||
|
||||
class WindowedExecutable(unittest.TestCase):
|
||||
"""The windowed executable, looked up beside whichever one is running.
|
||||
|
||||
Beside rather than at a known place: the setup lays both executables into
|
||||
one directory wherever that directory was put, so either can find the
|
||||
other without knowing where the install is.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||
|
||||
def test_found_beside_the_named_executable(self):
|
||||
windowed = self.installed / "Dikte.exe"
|
||||
windowed.write_text("")
|
||||
self.assertEqual(
|
||||
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")),
|
||||
windowed)
|
||||
|
||||
def test_none_when_no_setup_installed_one(self):
|
||||
self.assertIsNone(
|
||||
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")))
|
||||
|
||||
|
||||
class WindowsExecutableNames(unittest.TestCase):
|
||||
"""The two Windows executables, read out of the files that name them.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import unittest
|
||||
from unittest import mock
|
||||
|
||||
from dikte import ipc
|
||||
from tests.support import DikteTest
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
@@ -185,5 +186,74 @@ class Send(unittest.TestCase):
|
||||
self.assertTrue(sock.disconnected)
|
||||
|
||||
|
||||
class AlreadyServing(unittest.TestCase):
|
||||
"""The single-instance check, which listen() cannot be: a Windows pipe
|
||||
takes a second server on the same name rather than refusing it."""
|
||||
|
||||
def probe(self, socket):
|
||||
with mock.patch.object(ipc, "QLocalSocket", return_value=socket):
|
||||
return ipc.already_serving()
|
||||
|
||||
def test_nothing_running_means_go_ahead(self):
|
||||
self.assertFalse(self.probe(FakeSocket(connected=False)))
|
||||
|
||||
def test_an_answer_means_yield(self):
|
||||
self.assertTrue(self.probe(FakeSocket(reply=b'{"ok": true}\n')))
|
||||
|
||||
def test_the_probe_has_no_side_effect(self):
|
||||
"""A probe that opened a window would open it during the relaunch a
|
||||
slow instance provokes, on top of the verb being forwarded."""
|
||||
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||
self.probe(sock)
|
||||
self.assertEqual(sock.written.decode("utf-8").strip(), "status")
|
||||
|
||||
def test_an_instance_too_old_to_answer_still_counts_as_running(self):
|
||||
self.assertTrue(self.probe(FakeSocket(reply=b"")))
|
||||
|
||||
|
||||
class InstanceLock(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# The lock derives its home from paths, which DikteTest's cfg patches
|
||||
# do not cover; without this the test would write into the real one.
|
||||
from dikte import paths
|
||||
self.patch_attr(paths, "DATA_DIR", self.path("data"))
|
||||
|
||||
def test_one_holder_at_a_time(self):
|
||||
first = ipc.instance_lock()
|
||||
self.assertIsNotNone(first)
|
||||
self.assertTrue(first.tryLock(0))
|
||||
second = ipc.instance_lock()
|
||||
self.assertFalse(second.tryLock(0))
|
||||
first.unlock()
|
||||
self.assertTrue(second.tryLock(0))
|
||||
second.unlock()
|
||||
|
||||
def test_the_lock_lives_in_the_data_directory(self):
|
||||
from dikte import paths
|
||||
lock = ipc.instance_lock()
|
||||
self.assertTrue(lock.tryLock(0))
|
||||
self.assertTrue((paths.DATA_DIR / "dikte.lock").exists())
|
||||
lock.unlock()
|
||||
|
||||
|
||||
class Respawn(unittest.TestCase):
|
||||
def test_windows_starts_a_detached_process_and_returns(self):
|
||||
with mock.patch.object(sys, "platform", "win32"), \
|
||||
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
|
||||
mock.patch.object(ipc.subprocess, "Popen") as popen:
|
||||
ipc.respawn(["--gui"])
|
||||
self.assertEqual(popen.call_args.args[0], ["py", "x", "--gui"])
|
||||
self.assertEqual(popen.call_args.kwargs["creationflags"],
|
||||
0x00000008 | 0x00000200)
|
||||
|
||||
def test_everywhere_else_the_process_is_replaced(self):
|
||||
with mock.patch.object(sys, "platform", "linux"), \
|
||||
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
|
||||
mock.patch.object(ipc.os, "execv") as execv:
|
||||
ipc.respawn(["toggle", "--gui"])
|
||||
execv.assert_called_once_with("py", ["py", "x", "toggle", "--gui"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -444,6 +444,45 @@ class MacOS(ClipboardContract, DikteTest):
|
||||
self.assertFalse(paste.paste_ready())
|
||||
|
||||
|
||||
class MacPasteGoesWhereTheDictationStarted(MacOS):
|
||||
"""The keys land in the frontmost window, so the front is what decides
|
||||
where a transcript ends up."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import mac_window
|
||||
self.mac_window = mac_window
|
||||
self.activated = []
|
||||
self.patch_attr(mac_window, "activate", self.activated.append)
|
||||
|
||||
def frontmost(self, dikte_is):
|
||||
self.patch_attr(self.mac_window, "is_frontmost", lambda: dikte_is)
|
||||
|
||||
def test_a_dikte_that_took_the_front_hands_it_back_before_pressing(self):
|
||||
self.frontmost(True)
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
|
||||
|
||||
def test_another_application_in_front_is_where_the_user_went_and_is_left(self):
|
||||
self.frontmost(False)
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_a_run_that_remembered_nobody_asks_nothing(self):
|
||||
self.frontmost(True)
|
||||
paste.press("cmd+v")
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_the_front_is_handed_back_only_once_macos_trusts_dikte(self):
|
||||
"""Pulling the user out of their window and then failing to type would
|
||||
be the worst of both."""
|
||||
self.frontmost(True)
|
||||
self.api.trusted = False
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
class MacClipboardSnapshot(DikteTest):
|
||||
def test_every_native_type_is_restored_and_the_files_are_removed(self):
|
||||
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
|
||||
|
||||
@@ -61,6 +61,34 @@ class Directories(unittest.TestCase):
|
||||
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte"))
|
||||
|
||||
|
||||
class CacheDir(unittest.TestCase):
|
||||
"""The third place: files whose whole point is that they can be lost."""
|
||||
|
||||
def test_linux_follows_xdg(self):
|
||||
with mock.patch.dict(os.environ, {"XDG_CACHE_HOME": "/k"}):
|
||||
self.assertEqual(paths.cache_dir("linux").as_posix(), "/k/dikte")
|
||||
|
||||
def test_linux_without_the_variable_set(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertTrue(paths.cache_dir("linux").as_posix()
|
||||
.endswith("/.cache/dikte"))
|
||||
|
||||
def test_a_mac_caches_under_library_caches(self):
|
||||
"""Where Time Machine already knows not to look."""
|
||||
self.assertTrue(paths.cache_dir("darwin").as_posix()
|
||||
.endswith("/Library/Caches/Dikte"))
|
||||
|
||||
def test_windows_caches_outside_the_roaming_profile(self):
|
||||
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "C:/local"}):
|
||||
self.assertEqual(paths.cache_dir("win32").as_posix(),
|
||||
"C:/local/Dikte/cache")
|
||||
|
||||
def test_windows_without_the_variable_set(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertTrue(paths.cache_dir("win32").as_posix()
|
||||
.endswith("/AppData/Local/Dikte/cache"))
|
||||
|
||||
|
||||
class OnePlace(unittest.TestCase):
|
||||
"""The programs and the models go where everything else goes.
|
||||
|
||||
|
||||
+548
-4
@@ -25,8 +25,14 @@ from dikte import ipc
|
||||
from dikte import overlay as overlay_module
|
||||
from dikte import paste
|
||||
from dikte import settings_ui
|
||||
from dikte import update
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
|
||||
# The harness below replaces this method on the class so that opening a window
|
||||
# in a test never calls anybody; taken here, before any test runs, so the two
|
||||
# tests about what it does when called still have the real one.
|
||||
REAL_LOAD_HOSTED_MODELS = settings_ui.SettingsWindow._load_hosted_models
|
||||
|
||||
# One application for the whole run; Qt allows no second one.
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
|
||||
@@ -41,6 +47,7 @@ CHANGED = {
|
||||
"paste_shortcut": "ctrl+shift+v",
|
||||
"restore_clipboard": True,
|
||||
"overlay_corner": "top-right",
|
||||
"overlay_screen": "DP-1",
|
||||
"max_seconds": 120,
|
||||
"skip_silent": False,
|
||||
"silence_db": -42.0,
|
||||
@@ -49,6 +56,7 @@ CHANGED = {
|
||||
"openai_api_key": "sk-test-key",
|
||||
"groq_api_key": "gsk-test-key",
|
||||
"openrouter_api_key": "sk-or-test-key",
|
||||
"gemini_api_key": "AIza-test-key",
|
||||
"transcribe_provider": "openrouter",
|
||||
"transcribe_model": "whisper-1",
|
||||
"groq_transcribe_model": "whisper-large-v3",
|
||||
@@ -58,6 +66,8 @@ CHANGED = {
|
||||
"cleanup_model": "some/other-model",
|
||||
"cleanup_claude_model": "opus",
|
||||
"cleanup_codex_model": "gpt-5",
|
||||
"cleanup_gemini_model": "gemini-2.5-flash",
|
||||
"cleanup_agy_model": "gemini-3.1-pro-low",
|
||||
"cleanup_reasoning": "high",
|
||||
"local_model": "ggml-small.bin",
|
||||
"local_gpu": False,
|
||||
@@ -77,6 +87,7 @@ CHANGED = {
|
||||
"assistant_codex_model": "gpt-5",
|
||||
"assistant_codex_sandbox": "read-only",
|
||||
"assistant_openrouter_model": "some/agent-model",
|
||||
"assistant_agy_model": "gemini-3.1-pro-low",
|
||||
"assistant_reasoning": "high",
|
||||
"assistant_dir": "/tmp",
|
||||
"assistant_timeout": 600,
|
||||
@@ -103,6 +114,7 @@ CHANGED = {
|
||||
"pause_shortcut": "Meta+P",
|
||||
"evdev_hotkey": True,
|
||||
"history_limit": 50,
|
||||
"update_check": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +143,12 @@ class Settings(DikteTest):
|
||||
"_load_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_transcribe_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_agy_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_hosted_models"))
|
||||
# The local model boxes fetch their own list the moment they are shown,
|
||||
# from a thread, which is nobody's test failing but a real request.
|
||||
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
||||
@@ -157,7 +175,7 @@ class Settings(DikteTest):
|
||||
def test_the_window_opens_with_every_tab_on_it(self):
|
||||
window = self.window(cfg.Config())
|
||||
tabs = window.findChildren(settings_ui.QTabWidget)[0]
|
||||
self.assertEqual(tabs.count(), 9)
|
||||
self.assertEqual(tabs.count(), 10)
|
||||
self.assertEqual(window.windowTitle(), "Dikte Settings")
|
||||
|
||||
def test_no_tab_can_stretch_the_window_past_a_small_screen(self):
|
||||
@@ -238,10 +256,26 @@ class Settings(DikteTest):
|
||||
with self.subTest(key=key):
|
||||
self.assertEqual(stored[key], value)
|
||||
|
||||
def test_only_a_save_that_changed_the_language_says_so(self):
|
||||
# The owner answers language_changed by replacing the window, so a
|
||||
# save that left the language alone must keep quiet.
|
||||
i18n = settings_ui.i18n
|
||||
self.addCleanup(i18n.set_language, i18n.language())
|
||||
window = self.window(cfg.Config())
|
||||
heard = []
|
||||
window.language_changed.connect(lambda: heard.append(True))
|
||||
window._save()
|
||||
self.assertEqual(heard, [])
|
||||
other = "en" if i18n.language() == "tr" else "tr"
|
||||
window._select_data(window.ui_language, other)
|
||||
window._save()
|
||||
self.assertEqual(heard, [True])
|
||||
|
||||
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,
|
||||
"opencode": window.cleanup_opencode_model_row,
|
||||
"claude": window.cleanup_claude_model,
|
||||
"codex": window.cleanup_codex_model}
|
||||
for provider, box in boxes.items():
|
||||
@@ -252,6 +286,128 @@ class Settings(DikteTest):
|
||||
self.assertEqual(shown, [provider])
|
||||
self.assertFalse(box.isHidden())
|
||||
|
||||
def test_codex_answering_refills_both_of_its_boxes(self):
|
||||
"""The list Codex gave replaces the built-in one, in both places, and
|
||||
neither loses what was already picked."""
|
||||
conf = self.config(cleanup_codex_model="my-own-model")
|
||||
window = self.window(conf)
|
||||
window._on_codex_models_loaded(["gpt-6", "gpt-6-mini"])
|
||||
for combo in (window.cleanup_codex_model, window.assistant_codex_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered[1:], ["gpt-6", "gpt-6-mini"])
|
||||
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||
"my-own-model")
|
||||
|
||||
def test_opencode_answering_refills_both_of_its_boxes(self):
|
||||
"""The fetched catalog replaces the built-in list in the cleanup and
|
||||
agent boxes alike, and neither loses what was picked."""
|
||||
conf = self.config(cleanup_opencode_model="my-own-model")
|
||||
window = self.window(conf)
|
||||
window._on_opencode_models_loaded(["glm-9", "kimi-k9"], "")
|
||||
for combo in (window.cleanup_opencode_model,
|
||||
window.assistant_opencode_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered, ["glm-9", "kimi-k9"])
|
||||
self.assertEqual(window.cleanup_opencode_model.currentText(),
|
||||
"my-own-model")
|
||||
|
||||
def test_opencode_s_list_arriving_at_open_leaves_the_other_boxes_alone(self):
|
||||
conf = self.config(cleanup_opencode_model="my-own-model",
|
||||
meeting_model="some/meeting-model")
|
||||
window = self.window(conf)
|
||||
before = [window.meeting_model.itemText(i)
|
||||
for i in range(window.meeting_model.count())]
|
||||
window._on_hosted_models_loaded("opencode", ["glm-5.3", "kimi-k3"])
|
||||
combo = window.cleanup_opencode_model
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered, ["glm-5.3", "kimi-k3"])
|
||||
self.assertEqual(combo.currentText(), "my-own-model")
|
||||
self.assertEqual([window.meeting_model.itemText(i)
|
||||
for i in range(window.meeting_model.count())], before)
|
||||
|
||||
def test_opencode_cleanup_offers_a_fetch_button_of_its_own(self):
|
||||
"""The OpenRouter button leaves the screen with its box, so OpenCode Go
|
||||
carries its own."""
|
||||
window = self.window(cfg.Config())
|
||||
window._select_data(window.cleanup_provider, "opencode")
|
||||
self.assertFalse(window.cleanup_opencode_model_row.isHidden())
|
||||
self.assertTrue(window.cleanup_model_row.isHidden())
|
||||
|
||||
def test_agy_answering_refills_both_of_its_boxes(self):
|
||||
"""The same arrangement as Codex: both boxes, nothing chosen is lost."""
|
||||
conf = self.config(cleanup_agy_model="my-own-model")
|
||||
window = self.window(conf)
|
||||
window._on_agy_models_loaded(["gemini-4-flash-low", "gemini-4-pro-low"])
|
||||
for combo in (window.cleanup_agy_model, window.assistant_agy_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered[1:],
|
||||
["gemini-4-flash-low", "gemini-4-pro-low"])
|
||||
self.assertEqual(window.cleanup_agy_model.currentText(), "my-own-model")
|
||||
|
||||
def test_openrouter_s_list_arriving_at_open_refills_cleanup_and_meetings(self):
|
||||
conf = self.config(cleanup_model="my/own-model")
|
||||
window = self.window(conf)
|
||||
window._on_hosted_models_loaded("openrouter", ["a/one", "b/two"])
|
||||
for combo in (window.cleanup_model, window.meeting_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered, ["a/one", "b/two"])
|
||||
self.assertEqual(window.cleanup_model.currentText(), "my/own-model")
|
||||
|
||||
def test_google_s_list_arriving_at_open_refills_its_own_box_only(self):
|
||||
window = self.window(self.config(cleanup_gemini_model="gemini-x"))
|
||||
before = window.cleanup_model.count()
|
||||
window._on_hosted_models_loaded("gemini", ["gemini-4-flash"])
|
||||
offered = [window.cleanup_gemini_model.itemText(i)
|
||||
for i in range(window.cleanup_gemini_model.count())]
|
||||
self.assertEqual(offered, ["gemini-4-flash"])
|
||||
self.assertEqual(window.cleanup_gemini_model.currentText(), "gemini-x")
|
||||
self.assertEqual(window.cleanup_model.count(), before)
|
||||
|
||||
def test_no_key_no_call_home_at_open(self):
|
||||
"""Opening Settings is not consent to be talked about to two vendors."""
|
||||
window = self.window(self.config())
|
||||
with mock.patch.dict(os.environ, {}, clear=True), \
|
||||
mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||||
REAL_LOAD_HOSTED_MODELS(window)
|
||||
thread.assert_not_called()
|
||||
|
||||
def test_a_key_on_file_is_fetched_with_at_open(self):
|
||||
window = self.window(self.config(openrouter_api_key="sk-or-x",
|
||||
gemini_api_key="AIza-x",
|
||||
opencode_api_key="opencode-x"))
|
||||
with mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||||
REAL_LOAD_HOSTED_MODELS(window)
|
||||
self.assertEqual(thread.call_count, 3)
|
||||
|
||||
def test_the_update_line_names_the_version_that_is_running(self):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertIn(settings_ui.__version__, window.update_status.text())
|
||||
# Nothing to open until a check has found something to open.
|
||||
self.assertTrue(window.update_page.isHidden())
|
||||
|
||||
def test_a_newer_release_puts_the_page_button_on_screen(self):
|
||||
window = self.window(cfg.Config())
|
||||
told = []
|
||||
window.update_found.connect(told.append)
|
||||
release = update.Release("9.9.9", "https://example.invalid/9.9.9", "")
|
||||
window._on_update_checked(release, "")
|
||||
self.assertIn("9.9.9", window.update_status.text())
|
||||
self.assertFalse(window.update_page.isHidden())
|
||||
self.assertEqual(window._release_url, release.url)
|
||||
# And the tray hears about it from here rather than waiting a day.
|
||||
self.assertEqual(told, [release])
|
||||
|
||||
def test_a_check_that_failed_says_why_and_hands_the_button_back(self):
|
||||
window = self.window(cfg.Config())
|
||||
window.update_now.setEnabled(False)
|
||||
window._on_update_checked(None, "api.github.com answered HTTP 403.")
|
||||
self.assertIn("403", window.update_status.text())
|
||||
self.assertTrue(window.update_now.isEnabled())
|
||||
|
||||
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,
|
||||
@@ -348,6 +504,20 @@ class Settings(DikteTest):
|
||||
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||||
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
||||
|
||||
def test_the_file_model_is_saved_and_only_shown_for_openrouter(self):
|
||||
self.write_config({"transcribe_provider": "openrouter",
|
||||
"openrouter_file_model": "openai/whisper-large-v3"})
|
||||
conf = cfg.Config()
|
||||
window = self.window(conf)
|
||||
self.assertEqual(window.file_model.currentText(), "openai/whisper-large-v3")
|
||||
self.assertTrue(window.stt_form.isRowVisible(window.file_model_row))
|
||||
window.file_model.setCurrentText(" deepgram/nova-3 ")
|
||||
window._save()
|
||||
self.assertEqual(conf["openrouter_file_model"], "deepgram/nova-3")
|
||||
window.transcribe_provider.setCurrentIndex(
|
||||
window.transcribe_provider.findData("openai"))
|
||||
self.assertFalse(window.stt_form.isRowVisible(window.file_model_row))
|
||||
|
||||
def test_the_provider_box_offers_every_provider_config_knows(self):
|
||||
window = self.window(cfg.Config())
|
||||
offered = [window.transcribe_provider.itemData(i)
|
||||
@@ -491,6 +661,321 @@ class KdeSettings(Settings):
|
||||
self.assertFalse(window.evdev_enabled.isHidden())
|
||||
|
||||
|
||||
|
||||
|
||||
class FakeAppKit:
|
||||
"""The Objective-C runtime, answering rather than being asked.
|
||||
|
||||
Stands in for what mac_window._appkit() loads, so what the indicator's
|
||||
window would have been sent can be read off `told` on any machine. The
|
||||
selectors come back as the names they were registered under, which is what
|
||||
lets the tests below name the message they mean.
|
||||
"""
|
||||
|
||||
def __init__(self, window=4242, panel=True, mask=0xe):
|
||||
self.objc = self
|
||||
self.window, self.panel, self.mask = window, panel, mask
|
||||
self.told = {}
|
||||
|
||||
def objc_getClass(self, name):
|
||||
return 1 if name == b"NSPanel" else 0
|
||||
|
||||
def selector(self, name):
|
||||
return name.decode()
|
||||
|
||||
def shared(self, _class_name, _selector):
|
||||
return 1
|
||||
|
||||
def ask(self, _to, selector):
|
||||
return self.window if selector == "window" else 0
|
||||
|
||||
def ask_unsigned(self, _to, _selector):
|
||||
return self.mask
|
||||
|
||||
def ask_of_class(self, _to, _selector, _klass):
|
||||
return self.panel
|
||||
|
||||
def tell_bool(self, _to, selector, value):
|
||||
self.told[selector] = value
|
||||
|
||||
tell_unsigned = tell_bool
|
||||
|
||||
|
||||
class MacIndicatorWindow(DikteTest):
|
||||
"""Which of the three AppKit settings the indicator's window is sent.
|
||||
|
||||
The nonactivating bit is the one worth a test of its own: it is legal on an
|
||||
NSPanel and nowhere else, and sending it to a plain NSWindow raises an
|
||||
Objective-C exception, which through ctypes is not something Python can
|
||||
catch. It takes the whole process down. `_is_panel` is the only thing
|
||||
standing between the two, so what it answers has to decide what is sent.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import mac_window
|
||||
self.mac_window = mac_window
|
||||
self.patch_attr(mac_window.QGuiApplication, "platformName",
|
||||
staticmethod(lambda: "cocoa"))
|
||||
|
||||
def told(self, **kwargs):
|
||||
"""What keep_on_screen sends an indicator, against this AppKit."""
|
||||
appkit = FakeAppKit(**kwargs)
|
||||
self.patch_attr(self.mac_window, "_appkit", lambda: appkit)
|
||||
widget = overlay_module.Overlay()
|
||||
self.addCleanup(widget.deleteLater)
|
||||
self.addCleanup(widget.close)
|
||||
self.answered = self.mac_window.keep_on_screen(widget)
|
||||
return appkit.told
|
||||
|
||||
def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self):
|
||||
"""The message that would kill the process. The other two still go."""
|
||||
told = self.told(panel=False)
|
||||
self.assertTrue(self.answered)
|
||||
self.assertNotIn("setStyleMask:", told)
|
||||
self.assertIs(told["setHidesOnDeactivate:"], False)
|
||||
self.assertEqual(told["setCollectionBehavior:"],
|
||||
self.mac_window.BEHAVIOUR)
|
||||
|
||||
def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self):
|
||||
told = self.told(panel=True, mask=0xe)
|
||||
self.assertEqual(told["setStyleMask:"],
|
||||
0xe | self.mac_window.NONACTIVATING_PANEL)
|
||||
|
||||
def test_a_panel_that_already_has_the_bit_is_left_alone(self):
|
||||
"""Every dictation runs this again, and a mask Cocoa did not need is a
|
||||
window it rebuilds underneath the indicator."""
|
||||
told = self.told(panel=True,
|
||||
mask=0xe | self.mac_window.NONACTIVATING_PANEL)
|
||||
self.assertNotIn("setStyleMask:", told)
|
||||
|
||||
def test_a_window_the_view_does_not_have_yet_is_not_messaged(self):
|
||||
self.assertEqual(self.told(window=0), {})
|
||||
self.assertFalse(self.answered)
|
||||
|
||||
def test_nothing_is_sent_anywhere_but_cocoa(self):
|
||||
"""Every other platform hands out a winId that means something else
|
||||
entirely, and messaging it crashes the run."""
|
||||
self.patch_attr(self.mac_window.QGuiApplication, "platformName",
|
||||
staticmethod(lambda: "offscreen"))
|
||||
self.assertEqual(self.told(), {})
|
||||
self.assertFalse(self.answered)
|
||||
|
||||
|
||||
class GivingTheFrontBack(DikteTest):
|
||||
"""Opening the microphone brings Dikte to the front, and the window the
|
||||
user was dictating into goes inactive with the caret in it. Nothing can be
|
||||
asked of the capture session, so the front is put back afterwards.
|
||||
|
||||
The watch is driven by hand here: what matters is what it decides, not how
|
||||
long Qt takes to tick.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import app as dikte_module
|
||||
from dikte import mac_window
|
||||
self.dikte = dikte_module
|
||||
self.activated = []
|
||||
self.patch_attr(mac_window, "activate", self.activate)
|
||||
self.ticks = []
|
||||
outer = self
|
||||
|
||||
class FakeTimer:
|
||||
"""Records what it was asked to do and hands over the tick."""
|
||||
|
||||
def __init__(self, _parent):
|
||||
self.interval = None
|
||||
self.running = False
|
||||
outer.ticks.append(self)
|
||||
|
||||
def setInterval(self, milliseconds):
|
||||
self.interval = milliseconds
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
@property
|
||||
def timeout(self):
|
||||
return self
|
||||
|
||||
def connect(self, slot):
|
||||
self.tick = slot
|
||||
|
||||
self.patch_attr(dikte_module, "QTimer", FakeTimer)
|
||||
|
||||
class BareDikte:
|
||||
"""As much of the application as this one method touches."""
|
||||
|
||||
app = None
|
||||
_front_watch = None
|
||||
_the_front = dikte_module.Dikte._the_front
|
||||
_give_the_front_back = dikte_module.Dikte._give_the_front_back
|
||||
_stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front
|
||||
|
||||
self.bare = BareDikte
|
||||
|
||||
def activate(self, pid):
|
||||
self.activated.append(pid)
|
||||
return True
|
||||
|
||||
def watching(self, was_in_front, dikte_in_front, on=None):
|
||||
from dikte import mac_window
|
||||
self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front)
|
||||
dikte = on if on is not None else self.bare()
|
||||
dikte._give_the_front_back(was_in_front)
|
||||
return self.ticks[-1] if self.ticks else None
|
||||
|
||||
def test_the_front_goes_back_to_whoever_had_it(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertTrue(watch.running) # accepted is not the same as landed
|
||||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False)
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
|
||||
def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertTrue(watch.running)
|
||||
|
||||
def test_an_accepted_restore_that_never_lands_still_times_out(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
with mock.patch.object(self.dikte.time, "monotonic",
|
||||
return_value=self.dikte.time.monotonic() + 60):
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
self.assertEqual(self.activated, [4242])
|
||||
|
||||
def test_a_restore_the_system_refused_is_retried(self):
|
||||
from dikte import mac_window
|
||||
self.patch_attr(mac_window, "activate",
|
||||
lambda pid: self.activated.append(pid) or False)
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242, 4242])
|
||||
self.assertTrue(watch.running)
|
||||
|
||||
def test_a_front_that_was_never_taken_is_left_where_it_is(self):
|
||||
"""The microphone does not always take it, and pulling an application
|
||||
forward that is already there is one flicker for nothing."""
|
||||
watch = self.watching(4242, dikte_in_front=False)
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [])
|
||||
self.assertTrue(watch.running) # still waiting for the moment
|
||||
|
||||
def test_it_gives_up_rather_than_watching_for_ever(self):
|
||||
watch = self.watching(4242, dikte_in_front=False)
|
||||
with mock.patch.object(self.dikte.time, "monotonic",
|
||||
return_value=self.dikte.time.monotonic() + 60):
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_a_dictation_started_in_dikte_itself_watches_nothing(self):
|
||||
"""Settings is a window of ours, and the front is already where it
|
||||
belongs."""
|
||||
self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True))
|
||||
|
||||
def test_a_second_recording_calls_off_the_watch_the_first_one_left(self):
|
||||
"""The older watch remembers where the older recording started, and by
|
||||
now that is the wrong window to be pulling forward."""
|
||||
dikte = self.bare()
|
||||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||||
second = self.watching(1111, dikte_in_front=False, on=dikte)
|
||||
self.assertFalse(first.running)
|
||||
self.assertTrue(second.running)
|
||||
second.tick() # and the survivor is the new one
|
||||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True)
|
||||
second.tick()
|
||||
self.assertEqual(self.activated, [1111])
|
||||
|
||||
def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self):
|
||||
"""Starting the next one from Dikte's own window is not a reason to
|
||||
leave the last one's watch running."""
|
||||
dikte = self.bare()
|
||||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||||
self.watching(os.getpid(), dikte_in_front=False, on=dikte)
|
||||
self.assertFalse(first.running)
|
||||
self.assertIsNone(dikte._front_watch)
|
||||
|
||||
def test_nobody_in_front_is_nobody_to_go_back_to(self):
|
||||
self.assertIsNone(self.watching(None, dikte_in_front=True))
|
||||
|
||||
def mac_window_module(self):
|
||||
from dikte import mac_window
|
||||
return mac_window
|
||||
|
||||
|
||||
class EveryRecordingProtectsTheFront(DikteTest):
|
||||
"""Three ways in, dictation, agent and meeting, and all three open the same
|
||||
avfoundation capture, so all three take the front the same way. What is
|
||||
checked here is the order: the front has to be noted before the microphone
|
||||
is opened, and the watch armed after, or there is nothing to go back to.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import app as dikte_module
|
||||
self.dikte = dikte_module
|
||||
self.order = []
|
||||
|
||||
def app(self, **attributes):
|
||||
"""A Dikte that records the order it does things in, and nothing else.
|
||||
|
||||
The methods under test are called unbound against it, so the stand-ins
|
||||
go on the object rather than on the class.
|
||||
"""
|
||||
dikte = mock.Mock(**attributes)
|
||||
dikte._run_id = 0
|
||||
dikte.conf = {"mic_target": "", "max_seconds": 60,
|
||||
"meeting_mic_target": "", "meeting_system_target": "",
|
||||
"meeting_max_seconds": 60}
|
||||
dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242
|
||||
dikte._give_the_front_back.side_effect = (
|
||||
lambda pid: self.order.append(f"watching {pid}"))
|
||||
dikte._begin_recording = (
|
||||
lambda owner: self.dikte.Dikte._begin_recording(dikte, owner))
|
||||
dikte.recorder.start.side_effect = (
|
||||
lambda *_a: self.order.append("microphone"))
|
||||
dikte.meeting_recorder.start.side_effect = (
|
||||
lambda *_a: self.order.append("microphone"))
|
||||
return dikte
|
||||
|
||||
def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self):
|
||||
dikte = self.app(state=self.dikte.IDLE, recording=False)
|
||||
dikte.overlay.show_recording.side_effect = (
|
||||
lambda *_a: self.order.append("indicator"))
|
||||
self.dikte.Dikte.start(dikte)
|
||||
self.assertEqual(self.order,
|
||||
["noted", "indicator", "microphone", "watching 4242"])
|
||||
|
||||
def test_the_agent_does_the_same(self):
|
||||
dikte = self.app(ask_state=self.dikte.IDLE, recording=False)
|
||||
self.dikte.Dikte.start_ask(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||||
|
||||
def test_a_meeting_does_the_same(self):
|
||||
"""The one most worth protecting: the user is in a call."""
|
||||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||||
dikte.meeting_recorder.active = True
|
||||
self.dikte.Dikte.start_meeting(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||||
|
||||
def test_a_meeting_whose_microphone_never_opened_watches_nothing(self):
|
||||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||||
dikte.meeting_recorder.active = False
|
||||
self.dikte.Dikte.start_meeting(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone"])
|
||||
|
||||
class Overlay(DikteTest):
|
||||
def overlay(self, **kwargs):
|
||||
widget = overlay_module.Overlay(**kwargs)
|
||||
@@ -569,6 +1054,17 @@ class Overlay(DikteTest):
|
||||
widget.show_recording()
|
||||
widget._reposition()
|
||||
|
||||
def test_a_named_screen_is_used_instead_of_the_pointer_screen(self):
|
||||
screen = mock.Mock()
|
||||
screen.name.return_value = "DP-1"
|
||||
screen.availableGeometry.return_value = settings_ui.QRect(1920, 0, 1920, 1080)
|
||||
widget = self.overlay(screen_name="DP-1")
|
||||
with mock.patch.object(QApplication, "screens", return_value=[screen]), \
|
||||
mock.patch.object(QApplication, "screenAt") as screen_at:
|
||||
widget._reposition()
|
||||
screen_at.assert_not_called()
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
|
||||
def test_a_warning_and_an_error_both_show(self):
|
||||
widget = self.overlay()
|
||||
widget.show_warning("cleanup failed")
|
||||
@@ -627,7 +1123,13 @@ class MeetingSources(DikteTest):
|
||||
only_these_tools(), \
|
||||
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_transcribe_models"):
|
||||
"_load_transcribe_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_agy_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_hosted_models"):
|
||||
window = settings_ui.SettingsWindow(cfg.Config())
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
@@ -656,6 +1158,13 @@ class LocalModels(DikteTest):
|
||||
# "nothing can transcribe" question from its real binary and model.
|
||||
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
||||
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||
# And one with Codex on it would ask it for its model list.
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_agy_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_hosted_models"))
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
@@ -692,12 +1201,21 @@ class LocalModels(DikteTest):
|
||||
# Qt's int is C++'s 32-bit one, and a 2.3 GB model is more than fits in
|
||||
# it: the count came out the far side negative, at "-1%".
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box._downloading = True
|
||||
box._report(1_048_576, 2_489_757_856)
|
||||
box._report("model", 1_048_576, 2_489_757_856)
|
||||
_app.processEvents()
|
||||
self.assertIn("2.3 GB", box.status.text())
|
||||
self.assertNotIn("-", box.status.text())
|
||||
|
||||
def test_each_download_reports_into_its_own_label(self):
|
||||
"""The two can run at once; the tag, not a flag read later, says
|
||||
which label the bytes belong to."""
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box._report("program", 10, 100)
|
||||
box._report("model", 20, 100)
|
||||
_app.processEvents()
|
||||
self.assertIn("10", box.program_label.text())
|
||||
self.assertIn("20", box.status.text())
|
||||
|
||||
def test_a_long_model_name_is_not_cut_in_half(self):
|
||||
# The list under a combo box takes the box's width and elides what does
|
||||
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
||||
@@ -727,3 +1245,29 @@ class LocalModels(DikteTest):
|
||||
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))
|
||||
|
||||
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
||||
window = self.window(cfg.Config())
|
||||
rows = {"openrouter": window.cleanup_model_row,
|
||||
"gemini": window.cleanup_gemini_model_row,
|
||||
"claude": window.cleanup_claude_model,
|
||||
"codex": window.cleanup_codex_model,
|
||||
"agy": window.cleanup_agy_model}
|
||||
for chosen, row in rows.items():
|
||||
with self.subTest(provider=chosen):
|
||||
window._select_data(window.cleanup_provider, chosen)
|
||||
for name, other in rows.items():
|
||||
self.assertEqual(window.cleanup_form.isRowVisible(other),
|
||||
name == chosen)
|
||||
|
||||
def test_each_agent_brings_its_own_box_and_no_other(self):
|
||||
window = self.window(cfg.Config())
|
||||
boxes = {"claude": window.claude_box, "codex": window.codex_box,
|
||||
"agy": window.agy_box, "openrouter": window.openrouter_box}
|
||||
for chosen, box in boxes.items():
|
||||
with self.subTest(provider=chosen):
|
||||
window._select_data(window.assistant_provider, chosen)
|
||||
for name, other in boxes.items():
|
||||
# isHidden rather than isVisible: the window itself is never
|
||||
# shown in a test, so nothing in it is ever visible.
|
||||
self.assertEqual(other.isHidden(), name != chosen)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Whether a newer release is one worth telling somebody about.
|
||||
|
||||
Two things carry the weight here. A version is compared by its numbers alone,
|
||||
because a build off master carries the released number with its commit after
|
||||
it and is ahead of that release rather than behind it. And the clock lives in a
|
||||
file, so a day of asking nobody has to survive a restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from dikte import hub
|
||||
from dikte import update
|
||||
from tests.support import DikteTest, fake_urlopen, url_error
|
||||
|
||||
RELEASE = {
|
||||
"tag_name": "v1.4.0",
|
||||
"html_url": "https://github.com/yusufipk/dikte/releases/tag/v1.4.0",
|
||||
"published_at": "2026-08-01T10:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
class Numbers(DikteTest):
|
||||
def test_a_tag_and_a_bare_number_read_the_same(self):
|
||||
self.assertEqual(update._numbers("v1.4.0"), (1, 4, 0))
|
||||
self.assertEqual(update._numbers("1.4.0"), (1, 4, 0))
|
||||
|
||||
def test_a_short_number_is_filled_out(self):
|
||||
self.assertEqual(update._numbers("2"), (2, 0, 0))
|
||||
self.assertEqual(update._numbers("2.1"), (2, 1, 0))
|
||||
|
||||
def test_what_follows_the_number_is_dropped(self):
|
||||
self.assertEqual(update._numbers("1.0.1-dev.abc1234"), (1, 0, 1))
|
||||
self.assertEqual(update._numbers("1.0.1+build7"), (1, 0, 1))
|
||||
|
||||
def test_something_that_is_not_a_version_is_no_version(self):
|
||||
self.assertEqual(update._numbers("latest"), ())
|
||||
self.assertEqual(update._numbers(""), ())
|
||||
self.assertEqual(update._numbers(None), ())
|
||||
|
||||
|
||||
class Newer(DikteTest):
|
||||
def test_a_higher_number_is_newer(self):
|
||||
self.assertTrue(update.newer("1.4.0", "1.3.9"))
|
||||
self.assertTrue(update.newer("v2.0.0", "1.9.9"))
|
||||
|
||||
def test_the_same_number_is_not(self):
|
||||
self.assertFalse(update.newer("1.4.0", "1.4.0"))
|
||||
self.assertFalse(update.newer("1.3.0", "1.4.0"))
|
||||
|
||||
def test_a_build_off_master_is_ahead_of_the_release_it_names(self):
|
||||
"""1.0.1-dev.abc1234 was built after 1.0.1 went out, not before it.
|
||||
Read as a version suffix it would be older, and every nightly would be
|
||||
told to go back to the release it had already passed."""
|
||||
self.assertFalse(update.newer("1.0.1", "1.0.1-dev.abc1234"))
|
||||
self.assertTrue(update.newer("1.0.2", "1.0.1-dev.abc1234"))
|
||||
|
||||
def test_a_tag_that_is_not_a_version_is_never_newer(self):
|
||||
self.assertFalse(update.newer("nightly", "1.0.0"))
|
||||
|
||||
|
||||
class Asking(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
|
||||
self.patch_attr(update, "__version__", "1.0.0")
|
||||
|
||||
def test_the_newest_release_comes_back_with_its_page(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
release = update.latest()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
self.assertEqual(release.url, RELEASE["html_url"])
|
||||
self.assertEqual(
|
||||
calls[0].full_url,
|
||||
"https://api.github.com/repos/yusufipk/dikte/releases/latest")
|
||||
|
||||
def test_a_release_with_no_page_falls_back_to_the_redirect(self):
|
||||
with fake_urlopen({"tag_name": "v1.4.0"}):
|
||||
release = update.latest()
|
||||
self.assertEqual(release.url, update.RELEASES_PAGE)
|
||||
|
||||
def test_a_repository_with_no_release_is_an_error(self):
|
||||
with fake_urlopen({"message": "Not Found"}):
|
||||
with self.assertRaises(hub.HubError):
|
||||
update.latest()
|
||||
|
||||
def test_a_check_answers_with_the_newer_release(self):
|
||||
with fake_urlopen(RELEASE):
|
||||
release = update.check()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
|
||||
def test_a_check_that_finds_nothing_new_answers_with_nothing(self):
|
||||
self.patch_attr(update, "__version__", "1.4.0")
|
||||
with fake_urlopen(RELEASE):
|
||||
self.assertIsNone(update.check())
|
||||
|
||||
def test_a_second_check_the_same_day_asks_nobody(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
update.check()
|
||||
release = update.check()
|
||||
self.assertEqual(len(calls), 1)
|
||||
# And still says what the first one found, since it is still true.
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
|
||||
def test_a_day_later_it_asks_again(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
update.check()
|
||||
update._store(checked=time.time() - update.INTERVAL - 60)
|
||||
update.check()
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_the_button_asks_whatever_the_clock_says(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
update.check()
|
||||
update.check(force=True)
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_a_check_that_cannot_reach_github_says_so(self):
|
||||
with fake_urlopen(url_error("no route to host")):
|
||||
with self.assertRaises(hub.HubError):
|
||||
update.check()
|
||||
|
||||
|
||||
class Remembering(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
|
||||
self.patch_attr(update, "__version__", "1.0.0")
|
||||
|
||||
def test_what_the_last_check_found_survives_a_restart(self):
|
||||
with fake_urlopen(RELEASE):
|
||||
update.check()
|
||||
release = update.pending()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
self.assertEqual(release.url, RELEASE["html_url"])
|
||||
|
||||
def test_nothing_was_ever_checked(self):
|
||||
self.assertIsNone(update.pending())
|
||||
self.assertEqual(update.state(), {})
|
||||
self.assertTrue(update.due())
|
||||
|
||||
def test_a_release_that_is_no_longer_newer_is_not_pending(self):
|
||||
"""The state file outlives the build that wrote it: an update that was
|
||||
found and then installed must not still be waiting afterwards."""
|
||||
update._store(version="1.4.0")
|
||||
self.patch_attr(update, "__version__", "1.4.0")
|
||||
self.assertIsNone(update.pending())
|
||||
|
||||
def test_a_version_is_announced_once(self):
|
||||
self.assertEqual(update.announced(), "")
|
||||
update.mark_announced("1.4.0")
|
||||
self.assertEqual(update.announced(), "1.4.0")
|
||||
|
||||
def test_a_state_file_that_is_rubbish_is_no_state_at_all(self):
|
||||
update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
update.STATE_FILE.write_text("half a {", encoding="utf-8")
|
||||
self.assertEqual(update.state(), {})
|
||||
self.assertIsNone(update.pending())
|
||||
|
||||
def test_a_state_file_that_cannot_be_written_is_not_a_failure(self):
|
||||
self.patch_attr(update, "STATE_FILE",
|
||||
self.path("nope") / "deeper" / "update.json")
|
||||
self.path("nope").write_text("a file where a directory would go")
|
||||
with fake_urlopen(RELEASE):
|
||||
release = update.check()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
|
||||
def test_the_clock_is_kept_out_of_the_settings(self):
|
||||
"""A background check writes while the settings window may be open, and
|
||||
a write into config.json there would undo whatever it holds."""
|
||||
with fake_urlopen(RELEASE):
|
||||
update.check()
|
||||
stored = json.loads(update.STATE_FILE.read_text(encoding="utf-8"))
|
||||
self.assertEqual(stored["version"], "1.4.0")
|
||||
self.assertGreater(stored["checked"], 0)
|
||||
+8
-2
@@ -128,6 +128,12 @@ class Hallucinations(DikteTest):
|
||||
self.assertFalse(vad.looks_like_hallucination("Bugün toplantı var.", 2.0))
|
||||
self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 2.0))
|
||||
|
||||
def test_a_one_word_answer_is_believed(self):
|
||||
# Whisper invents both over silence, but people dictate both as whole
|
||||
# answers, and losing a real answer costs more than passing a fake one.
|
||||
self.assertFalse(vad.looks_like_hallucination("You.", 1.5))
|
||||
self.assertFalse(vad.looks_like_hallucination("Bye.", 1.5))
|
||||
|
||||
def test_an_empty_transcript_counts_as_invented(self):
|
||||
self.assertTrue(vad.looks_like_hallucination(" ", 2.0))
|
||||
self.assertTrue(vad.looks_like_hallucination("...", 2.0))
|
||||
@@ -138,8 +144,8 @@ class Hallucinations(DikteTest):
|
||||
self.assertTrue(vad.looks_like_hallucination(text, 2.0))
|
||||
|
||||
def test_the_boundary_is_the_max_duration(self):
|
||||
self.assertTrue(vad.looks_like_hallucination("you", 6.0))
|
||||
self.assertFalse(vad.looks_like_hallucination("you", 6.1))
|
||||
self.assertTrue(vad.looks_like_hallucination("thanks for watching", 6.0))
|
||||
self.assertFalse(vad.looks_like_hallucination("thanks for watching", 6.1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+102
-13
@@ -8,6 +8,7 @@ afterwards. A pull request that reorders any of it shows up here.
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -30,9 +31,11 @@ class Chain(DikteTest):
|
||||
|
||||
def run_chain(self, ask=False, paste_override=None, duration=2.0,
|
||||
transcript="uh, book it for Thursday",
|
||||
transcribe_error=None,
|
||||
cleaned="Book it for Thursday.",
|
||||
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
||||
clipboard=b"what was there before", paste_error=None):
|
||||
clipboard=b"what was there before", paste_error=None,
|
||||
focus=None):
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
done, failures, stages, cancels = [], [], [], []
|
||||
pipeline.finished.connect(lambda *args: done.append(args))
|
||||
@@ -46,7 +49,10 @@ class Chain(DikteTest):
|
||||
# The chain reports its own failures on stderr, which a test run has no
|
||||
# use for.
|
||||
with contextlib.redirect_stderr(io.StringIO()), \
|
||||
mock.patch.object(api, "transcribe", return_value=transcript) as tr, \
|
||||
mock.patch.object(
|
||||
api, "transcribe",
|
||||
**({"side_effect": transcribe_error} if transcribe_error
|
||||
else {"return_value": transcript})) as tr, \
|
||||
mock.patch.object(api, "cleanup", cleanup), \
|
||||
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
|
||||
mock.patch.object(paste, "copy") as copy, \
|
||||
@@ -60,7 +66,8 @@ class Chain(DikteTest):
|
||||
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
||||
"read_clipboard": read_clipboard}
|
||||
pipeline._work(self.wav, duration,
|
||||
self.rms if rms is None else rms, ask, paste_override)
|
||||
self.rms if rms is None else rms, ask, paste_override,
|
||||
focus)
|
||||
return {"done": done, "failures": failures, "stages": stages,
|
||||
"cancelled": cancels, **calls}
|
||||
|
||||
@@ -72,7 +79,15 @@ class Chain(DikteTest):
|
||||
self.assertEqual(run["done"][0],
|
||||
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
||||
run["copy"].assert_called_once_with("Book it for Thursday.")
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"])
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=None)
|
||||
|
||||
def test_the_paste_is_told_where_the_dictation_started(self):
|
||||
"""Whoever was in front when the recording began is where the keys are
|
||||
meant to go, and the press is the only part that can act on it."""
|
||||
run = self.run_chain(focus=4242)
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=4242)
|
||||
|
||||
def test_the_stages_are_named_as_they_happen(self):
|
||||
run = self.run_chain()
|
||||
@@ -108,11 +123,57 @@ class Chain(DikteTest):
|
||||
run = self.run_chain()
|
||||
run["copy_bytes"].assert_not_called()
|
||||
|
||||
def test_the_clipboard_is_put_back_when_the_keypress_fails(self):
|
||||
def test_a_failed_keypress_leaves_the_transcript_on_the_clipboard(self):
|
||||
"""The press failing is a warning, not a lost dictation: restoring the
|
||||
old clipboard over the text would leave nothing to paste by hand."""
|
||||
self.conf["restore_clipboard"] = True
|
||||
run = self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||
self.assertIn("not trusted", run["failures"][0])
|
||||
run["copy_bytes"].assert_called_once_with(b"what was there before")
|
||||
self.assertEqual(run["failures"], [])
|
||||
raw, text, warning = run["done"][0]
|
||||
self.assertIn("not trusted", warning)
|
||||
run["copy_bytes"].assert_not_called()
|
||||
|
||||
def test_a_failed_keypress_still_reaches_the_history(self):
|
||||
self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||
rows = cfg.read_history()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["text"], "Book it for Thursday.")
|
||||
# The row goes in before the paste is attempted, so the paste failing
|
||||
# has to be written back into it: the record tells the whole truth.
|
||||
self.assertIn("not trusted", rows[0]["cleanup_error"])
|
||||
|
||||
def test_a_failed_transcription_keeps_the_audio(self):
|
||||
"""Speech the user cannot repeat from memory must survive the failure."""
|
||||
run = self.run_chain(transcribe_error=api.ApiError("server down"))
|
||||
self.assertIn("server down", run["failures"][0])
|
||||
self.assertIn("kept", run["failures"][0])
|
||||
kept = list(cfg.RECORDINGS_DIR.glob("*.wav"))
|
||||
self.assertEqual(len(kept), 1)
|
||||
self.assertFalse(os.path.exists(self.wav))
|
||||
|
||||
def test_two_failures_in_one_second_keep_both_recordings(self):
|
||||
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||
self.wav = make_wav(self.path("clip2.wav"), speech(2.0))
|
||||
with mock.patch.object(worker.time, "strftime",
|
||||
return_value="20260820-120000"):
|
||||
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||
self.wav = make_wav(self.path("clip3.wav"), speech(2.0))
|
||||
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||
self.assertEqual(len(list(cfg.RECORDINGS_DIR.glob("*.wav"))), 3)
|
||||
|
||||
def test_the_history_row_says_whether_cleanup_actually_ran(self):
|
||||
"""The ask path cleans under its own setting; the record follows the
|
||||
run, not the dictation gate."""
|
||||
self.conf["cleanup_enabled"] = False
|
||||
self.conf["assistant_cleanup"] = True
|
||||
self.run_chain(ask=True)
|
||||
row = cfg.read_history()[0]
|
||||
self.assertNotEqual(row["cleanup_model"], "")
|
||||
cfg.clear_history()
|
||||
self.conf["cleanup_enabled"] = True
|
||||
self.conf["assistant_cleanup"] = False
|
||||
self.run_chain(ask=True)
|
||||
self.assertEqual(cfg.read_history()[0]["cleanup_model"], "")
|
||||
|
||||
def test_the_transcription_is_told_the_language_and_the_glossary(self):
|
||||
self.conf["language"] = "tr"
|
||||
@@ -278,13 +339,41 @@ class Chain(DikteTest):
|
||||
|
||||
|
||||
class Busy(DikteTest):
|
||||
def test_a_second_run_while_one_is_going_is_ignored(self):
|
||||
def test_a_second_run_while_one_is_going_waits_its_turn(self):
|
||||
"""The microphone is free while a transcript is being cleaned up, so
|
||||
the next dictation can already have been spoken by then. It has to run
|
||||
once the first is done, in the order they were spoken, on one thread."""
|
||||
pipeline = worker.Pipeline(self.config())
|
||||
pipeline._thread = mock.Mock(is_alive=lambda: True)
|
||||
self.assertTrue(pipeline.busy)
|
||||
with mock.patch.object(worker.threading, "Thread") as thread:
|
||||
pipeline.run("/tmp/nope.wav", 1.0)
|
||||
thread.assert_not_called()
|
||||
order = []
|
||||
started, gate = threading.Event(), threading.Event()
|
||||
|
||||
def work(wav_path, *_rest):
|
||||
order.append(wav_path)
|
||||
started.set()
|
||||
if wav_path == "first.wav":
|
||||
gate.wait(5)
|
||||
|
||||
with mock.patch.object(pipeline, "_work", side_effect=work):
|
||||
pipeline.run("first.wav", 1.0)
|
||||
self.assertTrue(started.wait(5))
|
||||
pipeline.run("second.wav", 1.0)
|
||||
# Held, not dropped and not running beside the first.
|
||||
self.assertEqual(order, ["first.wav"])
|
||||
gate.set()
|
||||
pipeline._thread.join(5)
|
||||
self.assertEqual(order, ["first.wav", "second.wav"])
|
||||
|
||||
def test_a_run_arriving_after_the_queue_drained(self):
|
||||
"""The worker thread ends with the queue; the next run brings one."""
|
||||
pipeline = worker.Pipeline(self.config())
|
||||
order = []
|
||||
with mock.patch.object(pipeline, "_work",
|
||||
side_effect=lambda wav, *rest: order.append(wav)):
|
||||
pipeline.run("first.wav", 1.0)
|
||||
pipeline._thread.join(5)
|
||||
pipeline.run("second.wav", 1.0)
|
||||
pipeline._thread.join(5)
|
||||
self.assertEqual(order, ["first.wav", "second.wav"])
|
||||
|
||||
def test_the_chunk_length_matches_the_level_meter(self):
|
||||
"""The silence thresholds are read in seconds, so the two must agree."""
|
||||
|
||||
Reference in New Issue
Block a user