mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Transcribe and clean up on this machine, without installing anything first
whisper-server is started on --inference-path /v1/audio/transcriptions, which is exactly the path api.py already builds for the hosted providers, and llama-server answers /chat/completions the way OpenRouter does. So the local half is one more base URL rather than a second code path: worker.py, filetranscribe.py and meeting.py are untouched, and dictation, subtitles and meetings all work here on the first try. Three findings worth naming, none of them in the new code: whisper.cpp cuts segments on tokens, which in Turkish lands inside a word about as often as between two. Pasted raw that gives "akraba değ\niller."; in a subtitle it gives a cue reading "değ". Whisper marks the start of a word with a leading space, so a piece that does not begin with one continues the word above it. A small model will repeat the transcript until the context is full, and every one of those tokens is a second of somebody waiting: measured at 206 seconds, and 25 with a ceiling on the reply. Hosted models are left alone, where the same runaway is rare and a ceiling would cut the minutes short. A server outlives SIGTERM and SIGKILL holding its model in memory. Signals are now turned into an event Qt delivers, since Qt blocks in C where a Python handler never runs, and a pid file lets the next start sweep up what a SIGKILL left behind. The minutes keep their own provider rather than following cleanup's. The two jobs are not the same size: a 4B model here will strip the filler words out of a dictation and will not write up an hour long meeting. The suite runs offline now: a test that reaches the network says so instead of quietly going there.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
# Dikte
|
||||
|
||||
Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter
|
||||
for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the
|
||||
restarts, the missing punctuation), and the result lands in your clipboard and
|
||||
is pasted into whatever window you were typing in.
|
||||
Press `Ctrl+Space`, talk, press again. The recording is transcribed on this
|
||||
machine by default, a model cleans it up (dropping the *uh*s, the restarts, the
|
||||
missing punctuation), and the result lands in your clipboard and is pasted into
|
||||
whatever window you were typing in.
|
||||
|
||||
Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages:
|
||||
just the Python standard library and PyQt6.
|
||||
@@ -39,10 +39,12 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
||||
settings window installs a GNOME or KDE global shortcut.
|
||||
|
||||
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text
|
||||
runs on either one (`gpt-4o-transcribe` by default), cleanup always on
|
||||
OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can
|
||||
cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
|
||||
Speech to text and cleanup each pick a provider in the settings window. Both can
|
||||
run here, on whisper.cpp and llama.cpp: the program and the model are downloaded
|
||||
from that window (checksummed, into `~/.local/share/dikte`), so nothing has to be
|
||||
installed first and nothing leaves the machine. The hosted alternatives want a
|
||||
key: **OpenAI** or **OpenRouter** for speech to text, **OpenRouter** for cleanup
|
||||
(`google/gemini-3.5-flash-lite`), so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
|
||||
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
|
||||
in which case the raw transcript is pasted, and a thinking model's effort can be
|
||||
set next to it.
|
||||
@@ -144,7 +146,9 @@ ipc.py one request and one reply over the local socket
|
||||
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
|
||||
meeting.py channel split, speaker labelling, cleanup, minutes
|
||||
assistant.py running a dictation through Claude Code, Codex or OpenRouter
|
||||
api.py transcription on either provider, OpenRouter cleanup (stdlib only)
|
||||
api.py transcription and cleanup on any provider (stdlib only)
|
||||
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
||||
hub.py what GitHub and Hugging Face have on offer today
|
||||
worker.py transcribe → clean up → clipboard → paste
|
||||
vad.py deciding whether a recording holds speech at all
|
||||
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
||||
|
||||
+14
-9
@@ -1,9 +1,8 @@
|
||||
# Dikte
|
||||
|
||||
`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip
|
||||
yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar,
|
||||
tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye
|
||||
yapıştırılır.
|
||||
`Ctrl+Space`'e bas, konuş, tekrar bas. Ses varsayılan olarak bu makinede yazıya
|
||||
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
|
||||
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır.
|
||||
|
||||
KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok:
|
||||
sadece Python standart kütüphanesi ve PyQt6.
|
||||
@@ -39,10 +38,14 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
||||
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
||||
|
||||
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi
|
||||
yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
||||
temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir
|
||||
OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY` ve
|
||||
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
|
||||
seçer. İkisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program
|
||||
da model de o pencereden indirilir (sha256 doğrulamasıyla,
|
||||
`~/.local/share/dikte` altına), yani önceden hiçbir şey kurman gerekmez ve
|
||||
makineden hiçbir şey çıkmaz. Bulut seçenekleri anahtar ister: yazıya çevirme
|
||||
için **OpenAI** ya da **OpenRouter**, temizleme için **OpenRouter**
|
||||
(`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de
|
||||
yeter. Boş bırakırsan `OPENAI_API_KEY` ve
|
||||
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
|
||||
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
|
||||
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||
@@ -142,7 +145,9 @@ ipc.py yerel sokette bir istek, bir cevap
|
||||
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
|
||||
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
|
||||
assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme
|
||||
api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib)
|
||||
api.py her sağlayıcıda transkript ve temizleme (yalnız stdlib)
|
||||
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
|
||||
hub.py GitHub ve Hugging Face'te bugün ne olduğu
|
||||
worker.py transkript → temizleme → pano → yapıştırma
|
||||
vad.py kayıtta gerçekten konuşma var mı kararı
|
||||
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""OpenAI and OpenRouter calls, stdlib only.
|
||||
"""OpenAI, OpenRouter and this machine, stdlib only.
|
||||
|
||||
Transcription runs on either provider: OpenRouter mirrors OpenAI's
|
||||
/audio/transcriptions endpoint field for field, so one multipart request serves
|
||||
both and only the key, the base URL and the model id change. Cleanup is always
|
||||
OpenRouter.
|
||||
Transcription runs on any of three providers and cleanup on two, and none of
|
||||
them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions
|
||||
endpoint field for field, and ggml.py starts whisper.cpp on that same path, so
|
||||
one multipart request serves all three; llama.cpp answers /chat/completions the
|
||||
way OpenRouter does, so one JSON request serves both. What changes between them
|
||||
is the key, the base URL and the model id.
|
||||
|
||||
The local ones have no key, and their base URL is not known until a server is
|
||||
up, which is the one thing this module has to fill in for them.
|
||||
"""
|
||||
|
||||
import collections
|
||||
@@ -14,6 +19,7 @@ import secrets
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import ggml
|
||||
from i18n import t
|
||||
|
||||
APP_URL = "https://github.com/yusufipk/dikte"
|
||||
@@ -21,14 +27,29 @@ USER_AGENT = f"dikte/1.0 (+{APP_URL})"
|
||||
OPENAI_URL = "https://api.openai.com/v1"
|
||||
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# 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")
|
||||
# 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
|
||||
# time, and a long recording on a machine without a graphics card takes a good
|
||||
# deal of it. Cutting that off would throw the work away for nothing.
|
||||
LOCAL_TIMEOUT = 3600
|
||||
|
||||
# Where a request goes; built by config.Config's *_target() methods. `service`
|
||||
# is the name the user sees in an error, `provider` the one the code branches
|
||||
# on. `reasoning` is only read by cleanup, which is the only job with a model
|
||||
# that might think about anything.
|
||||
Target = collections.namedtuple(
|
||||
"Target", "provider service api_key base_url model reasoning", defaults=("",))
|
||||
|
||||
|
||||
def timestamp_model(provider):
|
||||
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id."""
|
||||
def timestamp_model(provider, model):
|
||||
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id.
|
||||
|
||||
Whisper is what the local server runs whatever the file is called, so there
|
||||
it stays on the model that is already loaded; asking for another one would
|
||||
name a model that server has never heard of.
|
||||
"""
|
||||
if provider == "local":
|
||||
return model
|
||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
||||
|
||||
|
||||
@@ -106,7 +127,11 @@ def _multipart(fields, file_field, file_path):
|
||||
|
||||
|
||||
def _headers(provider, api_key, content_type=None):
|
||||
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
# A server on this machine has nothing to authorise, and sending it a
|
||||
# bearer token would only be a made-up one.
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
if provider == "openrouter":
|
||||
@@ -116,17 +141,44 @@ def _headers(provider, api_key, content_type=None):
|
||||
return headers
|
||||
|
||||
|
||||
def _serving(target, server, timeout):
|
||||
"""A local target with the address of a running server in it.
|
||||
|
||||
The server is started on demand and picks its own port, so this is the first
|
||||
moment its address exists. serve() is idempotent: once it is up this costs
|
||||
nothing.
|
||||
"""
|
||||
try:
|
||||
return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT)
|
||||
except ggml.LocalError as exc:
|
||||
raise ApiError(str(exc)) from None
|
||||
|
||||
|
||||
def _local_failure(target, server, exc):
|
||||
"""A server that died mid-request, explained by its own output.
|
||||
|
||||
Without this the message is that the connection dropped, when the reason for
|
||||
it was printed by the process at the other end.
|
||||
"""
|
||||
detail = server.error()
|
||||
return ApiError(f"{target.service}: {exc}" + (f" ({detail})" if detail else ""),
|
||||
exc.status)
|
||||
|
||||
|
||||
def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||
granularity=None, timeout=300):
|
||||
if not target.api_key:
|
||||
if target.provider == "local":
|
||||
target, timeout = _serving(target, ggml.whisper, timeout)
|
||||
elif not target.api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service=target.service))
|
||||
fields = [("model", target.model), ("response_format", response_format)]
|
||||
if language and language != "auto":
|
||||
fields.append(("language", language))
|
||||
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
||||
# The same words still reach the cleanup model as a glossary.
|
||||
if prompt and target.provider == "openai":
|
||||
# The same words still reach the cleanup model as a glossary. whisper.cpp
|
||||
# takes it as the initial prompt, the way OpenAI does.
|
||||
if prompt and target.provider in ("openai", "local"):
|
||||
fields.append(("prompt", prompt))
|
||||
if granularity:
|
||||
fields.append(("timestamp_granularities[]", granularity))
|
||||
@@ -137,14 +189,58 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||
)
|
||||
except ApiError as exc:
|
||||
if target.provider == "local":
|
||||
raise _local_failure(target, ggml.whisper, exc) from None
|
||||
raise explain(exc, target.service) from None
|
||||
|
||||
|
||||
# Whisper marks the start of a word with a leading space, so a piece of text
|
||||
# that does not begin with one continues the word before it rather than starting
|
||||
# a new one. Both helpers below turn on that.
|
||||
def _continues_a_word(previous, following):
|
||||
return bool(previous) and not previous[-1:].isspace() and not following[:1].isspace()
|
||||
|
||||
|
||||
def _local_text(text):
|
||||
"""whisper.cpp's segments, joined back into the flowing line OpenAI returns.
|
||||
|
||||
Its plain text puts one segment per line, and a segment boundary falls
|
||||
wherever the tokens fell, which in Turkish lands inside a word about as
|
||||
often as between two. Nothing takes the line break's place: whisper's own
|
||||
leading spaces are what separate the words, and a break inside "değ|iller"
|
||||
has nothing on either side of it worth keeping.
|
||||
"""
|
||||
return "".join(text.split("\n"))
|
||||
|
||||
|
||||
def _merge_word_splits(segments):
|
||||
"""Fold a segment that begins mid-word into the one it continues.
|
||||
|
||||
The hosted whisper-1 hands back segments cut on sentences; whisper.cpp cuts
|
||||
them on tokens, and a subtitle cue reading "değ" is not a cue. The times are
|
||||
joined along with the text, so the merged segment still covers the whole
|
||||
word.
|
||||
"""
|
||||
merged = []
|
||||
for seg in segments:
|
||||
text = seg.get("text") or ""
|
||||
if merged and _continues_a_word(merged[-1]["text"], text):
|
||||
merged[-1]["text"] += text
|
||||
merged[-1]["end"] = seg.get("end") or merged[-1]["end"]
|
||||
continue
|
||||
merged.append({"text": text, "start": seg.get("start") or 0.0,
|
||||
"end": seg.get("end") or 0.0})
|
||||
return merged
|
||||
|
||||
|
||||
def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
||||
data = _transcribe_request(
|
||||
target, wav_path, language, prompt, "json", timeout=timeout
|
||||
)
|
||||
text = (data.get("text") or "").strip()
|
||||
text = data.get("text") or ""
|
||||
if target.provider == "local":
|
||||
text = _local_text(text)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ApiError(t("Transcript came back empty."))
|
||||
return text
|
||||
@@ -153,11 +249,13 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
||||
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||
data = _transcribe_request(
|
||||
target._replace(model=timestamp_model(target.provider)),
|
||||
target._replace(model=timestamp_model(target.provider, target.model)),
|
||||
wav_path, language, prompt, "verbose_json",
|
||||
granularity="segment", timeout=timeout,
|
||||
)
|
||||
segments = data.get("segments") or []
|
||||
if target.provider == "local":
|
||||
segments = _merge_word_splits(segments)
|
||||
out = []
|
||||
for seg in segments:
|
||||
text = (seg.get("text") or "").strip()
|
||||
@@ -166,45 +264,91 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
||||
end = float(seg.get("end") or 0.0)
|
||||
out.append((start, max(end, start), text))
|
||||
if not out:
|
||||
text = (data.get("text") or "").strip()
|
||||
text = data.get("text") or ""
|
||||
if target.provider == "local":
|
||||
text = _local_text(text)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ApiError(t("Transcript came back empty."))
|
||||
out = [(0.0, 0.0, text)]
|
||||
return out
|
||||
|
||||
|
||||
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||
base_url=OPENROUTER_URL, timeout=180):
|
||||
if not api_key:
|
||||
def _thinking(target, payload):
|
||||
"""Ask for as much thinking as this provider understands, or for none.
|
||||
|
||||
An empty level means "whatever the model does on its own", so nothing is
|
||||
sent. The two providers mean opposite things by that, which is why the
|
||||
setting is kept per provider: OpenRouter's cleanup models answer straight
|
||||
away, while a local model that was trained to think will think, and cleanup
|
||||
is punctuation rather than a job worth thinking about.
|
||||
"""
|
||||
if not target.reasoning:
|
||||
return
|
||||
if target.provider == "local-llm":
|
||||
# What llama.cpp passes to the chat template. The models that think
|
||||
# read it; the ones that do not ignore it.
|
||||
payload["chat_template_kwargs"] = {
|
||||
"enable_thinking": target.reasoning != "none"}
|
||||
return
|
||||
if target.reasoning != "none":
|
||||
# The thinking itself is never shown, so ask for it to be left out.
|
||||
payload["reasoning"] = {"effort": target.reasoning, "exclude": True}
|
||||
|
||||
|
||||
def _local_ceiling(text):
|
||||
"""How much of a reply is worth waiting for from a model on this machine.
|
||||
|
||||
Cleanup gives back what it was given, near enough, so a reply several times
|
||||
the length of the transcript is a model that has lost the thread rather than
|
||||
one doing the job. A small one will happily repeat the transcript until the
|
||||
context is full, and every one of those tokens is a second of somebody
|
||||
waiting. A hosted model is left alone: there the same runaway is rare, and a
|
||||
ceiling would cut the minutes short instead.
|
||||
"""
|
||||
return max(512, len(text))
|
||||
|
||||
|
||||
def cleanup(target, text, system_prompt, timeout=180):
|
||||
if target.provider == "local-llm":
|
||||
target, timeout = _serving(target, ggml.llm, timeout)
|
||||
elif not target.api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service="OpenRouter"))
|
||||
service=target.service))
|
||||
payload = {
|
||||
"model": model,
|
||||
"model": target.model,
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"<transcript>\n{text}\n</transcript>"},
|
||||
],
|
||||
}
|
||||
# An empty level means "whatever the model does on its own"; anything else is
|
||||
# one of OpenRouter's efforts. The thinking itself is never shown, so ask for
|
||||
# it to be left out of the reply.
|
||||
if reasoning:
|
||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
||||
if target.provider == "local-llm":
|
||||
payload["max_tokens"] = _local_ceiling(text)
|
||||
_thinking(target, payload)
|
||||
try:
|
||||
data = _request(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
f"{target.base_url.rstrip('/')}/chat/completions",
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
_headers("openrouter", api_key, "application/json"),
|
||||
_headers(target.provider, target.api_key, "application/json"),
|
||||
timeout=timeout,
|
||||
)
|
||||
except ApiError as exc:
|
||||
raise explain(exc, "OpenRouter") from None
|
||||
if target.provider == "local-llm":
|
||||
raise _local_failure(target, ggml.llm, exc) from None
|
||||
raise explain(exc, target.service) from None
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise ApiError(_extract_error(json.dumps(data)))
|
||||
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||
message = choices[0].get("message") or {}
|
||||
content = (message.get("content") or "").strip()
|
||||
if not content:
|
||||
# A thinking model can spend the whole reply on the thinking and leave
|
||||
# nothing to paste. Worth naming, because the fix is a setting rather
|
||||
# than a retry: cleanup is not a job that wants thinking.
|
||||
if message.get("reasoning_content") or message.get("reasoning"):
|
||||
raise ApiError(t("The cleanup model spent its whole reply on "
|
||||
"thinking. Set Thinking to “Off”."))
|
||||
raise ApiError(t("The cleanup model returned an empty reply."))
|
||||
return content
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import os
|
||||
import pathlib
|
||||
|
||||
import api
|
||||
import ggml
|
||||
import i18n
|
||||
from i18n import t
|
||||
|
||||
|
||||
def _xdg(var, default):
|
||||
@@ -365,14 +367,46 @@ DEFAULTS = {
|
||||
"openai_base_url": "https://api.openai.com/v1",
|
||||
"openrouter_api_key": "",
|
||||
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
||||
"transcribe_provider": "openai", # openai | openrouter
|
||||
"transcribe_provider": "local", # local | openai | openrouter
|
||||
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
||||
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
||||
"language": "tr",
|
||||
"transcribe_prompt": "",
|
||||
|
||||
# --- whisper.cpp, on this machine ---------------------------------------
|
||||
# The program and the model are both fetched from Settings; empty means
|
||||
# nothing has been downloaded yet, which is what opens Settings on a first
|
||||
# run.
|
||||
# Pointed at the suggestion rather than at nothing, so the settings window
|
||||
# opens with the Download button already on the right model.
|
||||
"local_model": ggml.SUGGESTED_WHISPER,
|
||||
"local_threads": 0, # 0 -> whisper.cpp picks
|
||||
"local_gpu": True,
|
||||
"local_preload": True, # load the model while Dikte starts, rather
|
||||
# than on the first dictation
|
||||
"local_binary": "", # empty -> whichever copy ggml.py finds
|
||||
|
||||
"cleanup_enabled": True,
|
||||
"cleanup_provider": "openrouter", # openrouter | local
|
||||
"cleanup_model": "google/gemini-3.5-flash-lite",
|
||||
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
||||
|
||||
# --- llama.cpp, on this machine -----------------------------------------
|
||||
# Kept apart from the meeting settings on purpose. Cleanup is punctuation
|
||||
# and filler words, which a small model does in a moment; the minutes are a
|
||||
# summary of an hour, which it does not.
|
||||
"local_llm_model": "", # a file name, e.g. gemma-3-4b-it-Q4_K_M.gguf
|
||||
# Where the model list is read from; the settings window offers the
|
||||
# publishers ggml.py knows of and takes any other one that is typed in.
|
||||
"local_llm_repo": ggml.SUGGESTED_LLM[0],
|
||||
"local_llm_threads": 0,
|
||||
"local_llm_gpu": True,
|
||||
"local_llm_context": 8192,
|
||||
"local_llm_binary": "",
|
||||
"local_llm_preload": False, # heavier than whisper, so only when asked
|
||||
# Off rather than empty: a model trained to think will, and 300 tokens of
|
||||
# reasoning about a comma is 300 tokens of waiting.
|
||||
"local_llm_reasoning": "none",
|
||||
"cleanup_prompt": "", # empty -> language-specific default
|
||||
"auto_paste": True,
|
||||
"paste_shortcut": "ctrl+v",
|
||||
@@ -400,6 +434,7 @@ DEFAULTS = {
|
||||
"meeting_language": "", # empty -> the dictation speech language
|
||||
"meeting_max_seconds": 14400, # 4 hours
|
||||
"meeting_cleanup": True,
|
||||
"meeting_provider": "openrouter", # openrouter | local
|
||||
"meeting_model": "google/gemini-3.5-flash",
|
||||
"meeting_reasoning": "",
|
||||
"meeting_prompt": "", # empty -> language-specific default
|
||||
@@ -495,14 +530,84 @@ class Config:
|
||||
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
|
||||
|
||||
def transcribe_target(self):
|
||||
"""Key, endpoint and model for whichever provider does speech to text."""
|
||||
if self["transcribe_provider"] == "openrouter":
|
||||
"""Key, endpoint and model for whichever provider does speech to text.
|
||||
|
||||
The local one leaves its base URL empty on purpose: the server picks a
|
||||
port when it starts, and starting it here would make reading a setting
|
||||
launch a process. api.py fills the address in when it is about to send
|
||||
the request, which is the moment the server is needed anyway.
|
||||
"""
|
||||
provider = self["transcribe_provider"]
|
||||
if provider == "local":
|
||||
return api.Target("local", t("Local whisper"), "", "",
|
||||
self["local_model"])
|
||||
if provider == "openrouter":
|
||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||
self["openrouter_base_url"],
|
||||
self["openrouter_transcribe_model"])
|
||||
return api.Target("openai", "OpenAI", self.openai_key(),
|
||||
self["openai_base_url"], self["transcribe_model"])
|
||||
|
||||
def cleanup_target(self):
|
||||
"""The same, for the model that tidies a transcript up."""
|
||||
if self["cleanup_provider"] == "local":
|
||||
return api.Target("local-llm", t("Local model"), "", "",
|
||||
self["local_llm_model"], self["local_llm_reasoning"])
|
||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||
self["openrouter_base_url"], self["cleanup_model"],
|
||||
self["cleanup_reasoning"])
|
||||
|
||||
def minutes_target(self):
|
||||
"""The same again, for the minutes.
|
||||
|
||||
Its own provider rather than the cleanup one. The two jobs are not the
|
||||
same size: a 4B model on this machine will strip the filler words out of
|
||||
a dictation perfectly well and will not write up an hour long meeting,
|
||||
so choosing it for the first must not quietly choose it for the second.
|
||||
"""
|
||||
if self["meeting_provider"] == "local":
|
||||
return api.Target("local-llm", t("Local model"), "", "",
|
||||
self["local_llm_model"], self["local_llm_reasoning"])
|
||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||
self["openrouter_base_url"], self["meeting_model"],
|
||||
self["meeting_reasoning"])
|
||||
|
||||
def transcribe_ready(self):
|
||||
"""Whether speech to text could run right now, without opening Settings."""
|
||||
if self["transcribe_provider"] == "local":
|
||||
return self.local_whisper_ready()
|
||||
return bool(self.transcribe_target().api_key)
|
||||
|
||||
def local_whisper_ready(self):
|
||||
return bool(ggml.program_path(ggml.WHISPER, self["local_binary"])
|
||||
and self["local_model"]
|
||||
and ggml.have_model(ggml.whisper_model_path(self["local_model"])))
|
||||
|
||||
def local_llm_ready(self):
|
||||
return bool(ggml.program_path(ggml.LLAMA, self["local_llm_binary"])
|
||||
and self["local_llm_model"]
|
||||
and ggml.have_model(ggml.llm_model_path(self["local_llm_model"])))
|
||||
|
||||
def apply_local(self):
|
||||
"""Hand the local settings to the servers, restarting what they change."""
|
||||
ggml.whisper.configure(
|
||||
model=self["local_model"],
|
||||
threads=int(self["local_threads"]),
|
||||
gpu=bool(self["local_gpu"]),
|
||||
binary=self["local_binary"],
|
||||
)
|
||||
ggml.llm.configure(
|
||||
model=self["local_llm_model"],
|
||||
threads=int(self["local_llm_threads"]),
|
||||
gpu=bool(self["local_llm_gpu"]),
|
||||
binary=self["local_llm_binary"],
|
||||
context=int(self["local_llm_context"]),
|
||||
)
|
||||
|
||||
def uses_local_llm(self):
|
||||
"""Whether anything is set to run the local cleanup model."""
|
||||
return "local" in (self["cleanup_provider"], self["meeting_provider"])
|
||||
|
||||
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
||||
subtitles=False):
|
||||
turkish = i18n.language() == "tr"
|
||||
|
||||
@@ -7,16 +7,20 @@ terminal talks to. Every verb it answers is in cli.py, which is also what runs
|
||||
command line says "there is no instance to talk to, so be one".
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
# A Wayland client cannot place a window in a screen corner, so the indicator
|
||||
# is drawn through XWayland.
|
||||
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
|
||||
|
||||
from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402
|
||||
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
|
||||
from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
|
||||
@@ -25,6 +29,7 @@ import assistant # noqa: E402
|
||||
import audio # noqa: E402
|
||||
import cli # noqa: E402
|
||||
import config as cfg # noqa: E402
|
||||
import ggml # noqa: E402
|
||||
import hotkey # noqa: E402
|
||||
import i18n # noqa: E402
|
||||
import ipc # noqa: E402
|
||||
@@ -92,6 +97,9 @@ class Dikte:
|
||||
self.meeting_recorder = audio.MeetingRecorder()
|
||||
self.meetings = MeetingPipeline(self.conf)
|
||||
self.evdev = hotkey.EvdevHotkey()
|
||||
# Before anything of ours is started: a server from a Dikte that was
|
||||
# killed outright is still holding a model in memory.
|
||||
ggml.sweep()
|
||||
|
||||
self.recorder.level.connect(self._on_level)
|
||||
self.recorder.stopped.connect(self._on_recorded)
|
||||
@@ -802,9 +810,44 @@ class Dikte:
|
||||
# Don't drop the object while its own signal is still being delivered.
|
||||
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
|
||||
|
||||
def _apply_local(self):
|
||||
"""Pass the local settings on, and hold the models ready if asked to.
|
||||
|
||||
Loading a model takes a second or two for whisper and longer for an LLM.
|
||||
Doing it while Dikte starts rather than on the first dictation is the
|
||||
whole reason a server is kept alive instead of running the program once
|
||||
per recording; the checkboxes are there for the machine whose memory is
|
||||
wanted elsewhere.
|
||||
"""
|
||||
self.conf.apply_local()
|
||||
wanted = []
|
||||
if self.conf["transcribe_provider"] == "local":
|
||||
if self.conf["local_preload"] and self.conf.local_whisper_ready():
|
||||
wanted.append((ggml.whisper, "whisper"))
|
||||
else:
|
||||
ggml.whisper.stop() # give the memory back when it is not in use
|
||||
if self.conf.uses_local_llm():
|
||||
if self.conf["local_llm_preload"] and self.conf.local_llm_ready():
|
||||
wanted.append((ggml.llm, "llama"))
|
||||
else:
|
||||
ggml.llm.stop()
|
||||
|
||||
def warm():
|
||||
for server, name in wanted:
|
||||
try:
|
||||
server.serve()
|
||||
except ggml.LocalError as exc:
|
||||
# Not worth an indicator: the first dictation raises the
|
||||
# same thing where the user can act on it.
|
||||
print(f"dikte: {name}: {exc}", file=sys.stderr)
|
||||
|
||||
if wanted:
|
||||
threading.Thread(target=warm, daemon=True).start()
|
||||
|
||||
def _apply_settings(self):
|
||||
self.overlay.corner = self.conf["overlay_corner"]
|
||||
self.ask_overlay.corner = self.conf["overlay_corner"]
|
||||
self._apply_local()
|
||||
self._build_tray()
|
||||
self._refresh_tray()
|
||||
if self.conf["evdev_hotkey"]:
|
||||
@@ -834,6 +877,9 @@ class Dikte:
|
||||
self.meeting_recorder.stop()
|
||||
self.overlay.dismiss()
|
||||
self.ask_overlay.dismiss()
|
||||
# Also on the restart path, which replaces the process without ever
|
||||
# reaching atexit and would otherwise leave the models in memory.
|
||||
ggml.stop_all()
|
||||
self.tray.hide()
|
||||
|
||||
|
||||
@@ -872,6 +918,41 @@ def main():
|
||||
return run_app([arg for arg in argv if arg != "--gui"])
|
||||
|
||||
|
||||
def install_signal_handlers(app):
|
||||
"""Quit properly on the signals a session sends, rather than dying where we stand.
|
||||
|
||||
Qt spends its time blocked inside C, and a Python signal handler only runs
|
||||
between bytecodes, so on its own it would not run until the next event
|
||||
arrived, which for an idle tray icon may be never. set_wakeup_fd writes the
|
||||
signal number to a socket instead, and a notifier turns that into an event
|
||||
Qt does deliver.
|
||||
|
||||
Worth the trouble because of what shutdown() does: a logout sends SIGTERM,
|
||||
and without this a whisper.cpp or llama.cpp server outlives the session
|
||||
holding its model in memory. SIGKILL cannot be caught at all, which is what
|
||||
ggml.sweep() is for.
|
||||
|
||||
Returns the objects it made; they have to stay alive to keep working.
|
||||
"""
|
||||
reader, writer = socket.socketpair()
|
||||
reader.setblocking(False)
|
||||
writer.setblocking(False)
|
||||
signal.set_wakeup_fd(writer.fileno())
|
||||
notifier = QSocketNotifier(reader.fileno(), QSocketNotifier.Type.Read)
|
||||
|
||||
def woken():
|
||||
with contextlib.suppress(OSError):
|
||||
reader.recv(64)
|
||||
app.quit() # aboutToQuit runs shutdown()
|
||||
|
||||
notifier.activated.connect(woken)
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
# A handler that does nothing, so that the default action, stopping the
|
||||
# process where it stands, is replaced by the wakeup above.
|
||||
signal.signal(sig, lambda *_: None)
|
||||
return reader, writer, notifier
|
||||
|
||||
|
||||
def run_app(args):
|
||||
command = args[0] if args else ""
|
||||
|
||||
@@ -879,6 +960,12 @@ def run_app(args):
|
||||
app.setApplicationName("Dikte")
|
||||
app.setDesktopFileName("dikte")
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
# Before Dikte is built, because building it is what may start a server, and
|
||||
# a signal arriving in the middle of that would otherwise take the default
|
||||
# action and leave the server behind. A signal this early lands in the
|
||||
# socket and is delivered as soon as the event loop starts. Held in a name
|
||||
# so that the notifier and its socket outlive this function.
|
||||
signal_plumbing = install_signal_handlers(app) # noqa: F841
|
||||
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
print("dikte: no system tray found, running anyway")
|
||||
@@ -927,7 +1014,10 @@ def run_app(args):
|
||||
|
||||
# No key for the chosen transcription provider means nothing can work yet,
|
||||
# so the settings window is the only useful thing to open.
|
||||
if command == "settings" or not dikte.conf.transcribe_target().api_key:
|
||||
# A transcription provider that cannot run yet, whether that is a missing
|
||||
# API key or a model nobody has downloaded, means nothing can work, so the
|
||||
# settings window is the only useful thing to open.
|
||||
if command == "settings" or not dikte.conf.transcribe_ready():
|
||||
dikte.open_settings()
|
||||
elif command == "toggle":
|
||||
QTimer.singleShot(0, dikte.toggle)
|
||||
|
||||
+2
-8
@@ -127,17 +127,11 @@ class FileTranscriber(QObject):
|
||||
def _cleanup(self, text, timestamps):
|
||||
conf = self.conf
|
||||
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
|
||||
target = conf.cleanup_target()
|
||||
out = []
|
||||
for block in split_text(text, timestamps):
|
||||
self._check()
|
||||
out.append(api.cleanup(
|
||||
block,
|
||||
conf.openrouter_key(),
|
||||
conf["cleanup_model"],
|
||||
prompt,
|
||||
reasoning=conf["cleanup_reasoning"],
|
||||
base_url=conf["openrouter_base_url"],
|
||||
))
|
||||
out.append(api.cleanup(target, block, prompt))
|
||||
return ("\n" if timestamps else "\n\n").join(out)
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -116,5 +116,6 @@ fi
|
||||
|
||||
echo
|
||||
ok "Done. Start it with: dikte"
|
||||
say "The settings window opens on first run; add an OpenAI, Groq or OpenRouter key."
|
||||
say "The settings window opens on first run: download a speech model, or add"
|
||||
say "an OpenAI or OpenRouter key instead."
|
||||
echo
|
||||
|
||||
+6
-18
@@ -118,18 +118,12 @@ class MeetingPipeline(QObject):
|
||||
|
||||
self._check()
|
||||
self._say(t("Writing the minutes…"))
|
||||
minutes = api.cleanup(
|
||||
transcript,
|
||||
self.conf.openrouter_key(),
|
||||
self.conf["meeting_model"],
|
||||
self.conf.meeting_prompt(),
|
||||
reasoning=self.conf["meeting_reasoning"],
|
||||
base_url=self.conf["openrouter_base_url"],
|
||||
timeout=600,
|
||||
)
|
||||
writer = self.conf.minutes_target()
|
||||
minutes = api.cleanup(writer, transcript, self.conf.meeting_prompt(),
|
||||
timeout=600)
|
||||
title = self._write(doc_path, minutes, transcript, entry)
|
||||
cfg.update_meeting(base, status="done", error="", title=title,
|
||||
model=self.conf["meeting_model"])
|
||||
model=writer.model)
|
||||
self._discard_audio(wav_path)
|
||||
self.finished.emit(base, title)
|
||||
|
||||
@@ -201,6 +195,7 @@ class MeetingPipeline(QObject):
|
||||
def _cleanup(self, transcript):
|
||||
conf = self.conf
|
||||
prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True)
|
||||
target = conf.cleanup_target()
|
||||
out = []
|
||||
blocks = filetranscribe.split_text(transcript, True)
|
||||
for index, block in enumerate(blocks, start=1):
|
||||
@@ -208,14 +203,7 @@ class MeetingPipeline(QObject):
|
||||
if len(blocks) > 1:
|
||||
self._say(t("Cleaning up {index}/{count}…",
|
||||
index=index, count=len(blocks)))
|
||||
out.append(api.cleanup(
|
||||
block,
|
||||
conf.openrouter_key(),
|
||||
conf["cleanup_model"],
|
||||
prompt,
|
||||
reasoning=conf["cleanup_reasoning"],
|
||||
base_url=conf["openrouter_base_url"],
|
||||
))
|
||||
out.append(api.cleanup(target, block, prompt))
|
||||
return "\n".join(out)
|
||||
|
||||
def _write(self, doc_path, minutes, transcript, entry):
|
||||
|
||||
+438
-14
@@ -18,6 +18,7 @@ import assistant
|
||||
import audio
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import ggml
|
||||
import hotkey
|
||||
import meeting
|
||||
from filetranscribe import FileTranscriber
|
||||
@@ -29,7 +30,10 @@ LANGUAGES = [
|
||||
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
|
||||
]
|
||||
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
|
||||
TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")]
|
||||
TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"),
|
||||
("OpenAI", "openai"), ("OpenRouter", "openrouter")]
|
||||
CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"),
|
||||
("This machine (llama.cpp)", "local")]
|
||||
# Starting points for the model box; "Fetch model list" replaces them with
|
||||
# whatever the provider offers today.
|
||||
TRANSCRIBE_MODELS = {
|
||||
@@ -103,6 +107,316 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
|
||||
"*.mp4 *.mkv *.webm *.mov *.avi")
|
||||
|
||||
|
||||
class LocalModelBox(QGroupBox):
|
||||
"""The program, the model, and the two downloads that put them there.
|
||||
|
||||
One class for whisper.cpp and llama.cpp, because the job is the same one
|
||||
twice: say whether the program is here, offer the models somebody publishes,
|
||||
fetch the chosen one, and stay usable while a gigabyte arrives. Nothing is
|
||||
listed in the source; `repos` and `models` are asked at the moment the box is
|
||||
opened, so a model published this morning is in the list this afternoon.
|
||||
"""
|
||||
|
||||
_listed = pyqtSignal(list, str)
|
||||
_quants = pyqtSignal(list, str)
|
||||
_progress = pyqtSignal(int, int)
|
||||
_finished = pyqtSignal(str, str)
|
||||
_installed = pyqtSignal(str, str)
|
||||
|
||||
changed = pyqtSignal()
|
||||
|
||||
def __init__(self, program, title, models, model_path, repos=None, parent=None):
|
||||
super().__init__(title, parent)
|
||||
self.program = program
|
||||
self._models = models # () -> [hub.Item], or (repo) -> [hub.Item]
|
||||
self._model_path = model_path # (name) -> Path
|
||||
self._repos = repos # None, or () -> [repo id]
|
||||
self._downloading = False
|
||||
self._pending = False
|
||||
self._stop = False
|
||||
self._wanted = "" # the model to select once a list arrives
|
||||
|
||||
form = QFormLayout(self)
|
||||
|
||||
self.program_label = QLabel("")
|
||||
self.program_label.setWordWrap(True)
|
||||
self.install_button = QPushButton(t("Download"))
|
||||
self.install_button.clicked.connect(self._install_program)
|
||||
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
||||
self.install_button))
|
||||
|
||||
if self._repos is not None:
|
||||
self.repo = QComboBox()
|
||||
self.repo.setEditable(True)
|
||||
self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The "
|
||||
"list is fetched; any other one can be typed in."))
|
||||
self.repo.currentTextChanged.connect(self._repo_changed)
|
||||
form.addRow(t("Publisher"), self.repo)
|
||||
|
||||
self.model = QComboBox()
|
||||
self.download_button = QPushButton(t("Download"))
|
||||
self.download_button.clicked.connect(self._download)
|
||||
self.delete_button = QPushButton(t("Delete"))
|
||||
self.delete_button.clicked.connect(self._delete)
|
||||
form.addRow(t("Model"), self._side_by_side(self.model,
|
||||
self.download_button,
|
||||
self.delete_button))
|
||||
self.model.currentIndexChanged.connect(self._model_changed)
|
||||
|
||||
self.status = QLabel("")
|
||||
self.status.setWordWrap(True)
|
||||
form.addRow(self.status)
|
||||
|
||||
self._listed.connect(self._on_listed)
|
||||
self._quants.connect(self._on_listed)
|
||||
self._progress.connect(self._on_progress)
|
||||
self._finished.connect(self._on_finished)
|
||||
self._installed.connect(self._on_installed)
|
||||
|
||||
@staticmethod
|
||||
def _side_by_side(*widgets):
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
for index, widget in enumerate(widgets):
|
||||
layout.addWidget(widget, 1 if index == 0 else 0)
|
||||
holder = QWidget()
|
||||
holder.setLayout(layout)
|
||||
return holder
|
||||
|
||||
# ---- what is here ----------------------------------------------------
|
||||
|
||||
def selected(self):
|
||||
return self.model.currentData() or ""
|
||||
|
||||
def repository(self):
|
||||
return self.repo.currentText().strip() if self._repos is not None else ""
|
||||
|
||||
def load(self, model, repo=""):
|
||||
"""Show what is stored. What else is on offer is asked for on the way up.
|
||||
|
||||
Nothing is fetched here: building the settings window is not the same as
|
||||
opening it, and a list nobody is looking at is not worth a request. What
|
||||
is already on this disk is shown straight away either way.
|
||||
"""
|
||||
self._wanted = model
|
||||
self._pending = True
|
||||
self._show_program()
|
||||
if self._repos is not None:
|
||||
self.repo.blockSignals(True)
|
||||
self.repo.clear()
|
||||
self.repo.addItems(list(ggml.SUGGESTED_LLM))
|
||||
self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0])
|
||||
self.repo.blockSignals(False)
|
||||
self._fill_models([])
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
if self._pending:
|
||||
self._pending = False
|
||||
if self._repos is not None:
|
||||
self._fill_repos(self.repository())
|
||||
self._fetch_models(self.repository())
|
||||
|
||||
def _show_program(self):
|
||||
path = ggml.program_path(self.program)
|
||||
if not path:
|
||||
self.program_label.setText(t("Not installed."))
|
||||
self.install_button.setVisible(True)
|
||||
return
|
||||
self.install_button.setVisible(not ggml.installed_program(self.program)
|
||||
and not ggml.system_program(self.program))
|
||||
if ggml.system_program(self.program):
|
||||
# Worth saying which one is running: a distribution package is built
|
||||
# for this machine and may reach the graphics card, while the
|
||||
# released binaries carry processor backends only.
|
||||
self.program_label.setText(t("Installed on the system: {path}", path=path))
|
||||
else:
|
||||
self.program_label.setText(
|
||||
t("Downloaded, version {version}.",
|
||||
version=ggml.installed_version(self.program) or "?"))
|
||||
|
||||
# ---- the lists -------------------------------------------------------
|
||||
|
||||
def _fill_repos(self, current):
|
||||
def work():
|
||||
self._listed.emit([("repos", ggml.llm_repos())], "")
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _repo_changed(self):
|
||||
if not self._downloading:
|
||||
self._fetch_models(self.repository())
|
||||
|
||||
def _fetch_models(self, repo=""):
|
||||
self.status.setText(t("Fetching the model list…"))
|
||||
|
||||
def work():
|
||||
try:
|
||||
found = self._models(repo) if self._repos is not None else self._models()
|
||||
self._quants.emit([("models", found)], "")
|
||||
except ggml.LocalError as exc:
|
||||
self._quants.emit([], str(exc))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_listed(self, payload, error):
|
||||
if error:
|
||||
self.status.setText(error)
|
||||
self._refresh_buttons()
|
||||
return
|
||||
kind, found = payload[0]
|
||||
if kind == "repos":
|
||||
current = self.repo.currentText()
|
||||
self.repo.blockSignals(True)
|
||||
self.repo.clear()
|
||||
self.repo.addItems(found)
|
||||
self.repo.setCurrentText(current)
|
||||
self.repo.blockSignals(False)
|
||||
return
|
||||
self._fill_models(found)
|
||||
|
||||
def _fill_models(self, items):
|
||||
"""One row per model, saying what it weighs and whether it is here."""
|
||||
wanted = self._wanted or self.selected()
|
||||
here = [name for name in (self._model_path(i.name).name for i in items)]
|
||||
self.model.blockSignals(True)
|
||||
self.model.clear()
|
||||
for item, name in zip(items, here):
|
||||
mark = (t("downloaded") if ggml.have_model(self._model_path(item.name))
|
||||
else ggml.human_size(item.size))
|
||||
self.model.addItem(f"{name} ({mark})", name)
|
||||
self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1)
|
||||
# A model that was downloaded and then dropped from the list upstream is
|
||||
# still on this disk and still works, so it stays on offer.
|
||||
for name in self._on_disk():
|
||||
if self.model.findData(name) < 0:
|
||||
self.model.addItem(f"{name} ({t('downloaded')})", name)
|
||||
# And one that is chosen but not here, because the file was deleted from
|
||||
# underneath or the settings came from another machine, stays chosen:
|
||||
# Save reads this box, and a row missing here would quietly empty the
|
||||
# setting rather than showing that the model needs downloading again.
|
||||
if wanted and self.model.findData(wanted) < 0:
|
||||
self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted)
|
||||
index = self.model.findData(wanted)
|
||||
self.model.setCurrentIndex(max(index, 0))
|
||||
self.model.blockSignals(False)
|
||||
self._wanted = ""
|
||||
self._model_changed()
|
||||
|
||||
def _on_disk(self):
|
||||
return (ggml.installed_whisper_models() if self.program is ggml.WHISPER
|
||||
else ggml.installed_llm_models())
|
||||
|
||||
# ---- fetching --------------------------------------------------------
|
||||
|
||||
def _install_program(self):
|
||||
self.install_button.setEnabled(False)
|
||||
self.program_label.setText(t("Downloading…"))
|
||||
|
||||
def work():
|
||||
try:
|
||||
ggml.install_program(self.program, on_progress=self._report)
|
||||
self._installed.emit("", "")
|
||||
except ggml.LocalError as exc:
|
||||
self._installed.emit("", str(exc))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_installed(self, _, error):
|
||||
self.install_button.setEnabled(True)
|
||||
self._show_program()
|
||||
if error:
|
||||
self.program_label.setText(error)
|
||||
self.changed.emit()
|
||||
|
||||
def _current_item(self):
|
||||
return self.model.currentData(Qt.ItemDataRole.UserRole + 1)
|
||||
|
||||
def _download(self):
|
||||
if self._downloading:
|
||||
self._stop = True
|
||||
return
|
||||
item = self._current_item()
|
||||
if item is None:
|
||||
return
|
||||
self._downloading, self._stop = True, False
|
||||
self._refresh_buttons()
|
||||
|
||||
def work():
|
||||
try:
|
||||
landed = ggml.download(item, self._model_path(item.name),
|
||||
on_progress=self._report,
|
||||
should_stop=lambda: self._stop)
|
||||
self._finished.emit(item.name if landed else "", "")
|
||||
except ggml.LocalError as exc:
|
||||
self._finished.emit("", str(exc))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _report(self, done, total):
|
||||
self._progress.emit(done, total)
|
||||
|
||||
def _on_progress(self, done, total):
|
||||
share = f" ({done * 100 // total}%)" if total else ""
|
||||
text = t("Downloading: {done} of {total}{share}",
|
||||
done=ggml.human_size(done), total=ggml.human_size(total or done),
|
||||
share=share)
|
||||
if self._downloading:
|
||||
self.status.setText(text)
|
||||
else:
|
||||
self.program_label.setText(text)
|
||||
|
||||
def _on_finished(self, name, error):
|
||||
self._downloading = False
|
||||
if error:
|
||||
self.status.setText(error)
|
||||
elif not name:
|
||||
self.status.setText(t("Download stopped."))
|
||||
self._fill_models_from_current()
|
||||
self.changed.emit()
|
||||
|
||||
def _fill_models_from_current(self):
|
||||
"""Redraw the rows without asking anybody anything again."""
|
||||
items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
for i in range(self.model.count())]
|
||||
self._wanted = self.selected()
|
||||
self._fill_models([i for i in items if i is not None])
|
||||
|
||||
def _delete(self):
|
||||
name = self.selected()
|
||||
if not name or not ggml.have_model(self._model_path(name)):
|
||||
return
|
||||
if QMessageBox.question(self, t("Delete model"),
|
||||
t("Delete {name} from this machine?", name=name)) \
|
||||
!= QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
ggml.delete_model(self._model_path(name))
|
||||
except ggml.LocalError as exc:
|
||||
self.status.setText(str(exc))
|
||||
self._fill_models_from_current()
|
||||
self.changed.emit()
|
||||
|
||||
def _model_changed(self):
|
||||
self._refresh_buttons()
|
||||
self.changed.emit()
|
||||
|
||||
def _refresh_buttons(self):
|
||||
name = self.selected()
|
||||
here = bool(name) and ggml.have_model(self._model_path(name))
|
||||
self.delete_button.setEnabled(here and not self._downloading)
|
||||
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
||||
self.download_button.setEnabled(self._downloading or (bool(name) and not here))
|
||||
if self._downloading:
|
||||
return
|
||||
if not name:
|
||||
self.status.setText(t("Nothing downloaded yet."))
|
||||
elif here:
|
||||
self.status.setText(t("Ready: {name}.", name=name))
|
||||
else:
|
||||
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
||||
|
||||
|
||||
class SettingsWindow(QDialog):
|
||||
applied = pyqtSignal()
|
||||
|
||||
@@ -127,9 +441,9 @@ class SettingsWindow(QDialog):
|
||||
self.setWindowTitle(t("Dikte Settings"))
|
||||
self.resize(680, 640)
|
||||
|
||||
tabs = QTabWidget(self)
|
||||
tabs = self.tabs = QTabWidget(self)
|
||||
tabs.addTab(self._general_tab(), t("General"))
|
||||
tabs.addTab(self._api_tab(), t("API and models"))
|
||||
self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models"))
|
||||
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
|
||||
tabs.addTab(self._assistant_tab(), t("Agent"))
|
||||
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
||||
@@ -161,6 +475,10 @@ class SettingsWindow(QDialog):
|
||||
self.meetings.finished.connect(self._on_minutes_finished)
|
||||
self.meetings.failed.connect(self._on_minutes_failed)
|
||||
self._load()
|
||||
# On a machine where nothing can transcribe yet, this window was opened
|
||||
# because of that, so open it on the tab that fixes it.
|
||||
if not conf.transcribe_ready():
|
||||
self.tabs.setCurrentIndex(self.api_tab_index)
|
||||
|
||||
# ---- tabs ----------------------------------------------------------
|
||||
|
||||
@@ -269,20 +587,55 @@ class SettingsWindow(QDialog):
|
||||
stt_form = QFormLayout(stt)
|
||||
self.transcribe_provider = QComboBox()
|
||||
for label, value in TRANSCRIBE_PROVIDERS:
|
||||
self.transcribe_provider.addItem(label, value)
|
||||
self.transcribe_provider.addItem(t(label), value)
|
||||
stt_form.addRow(t("Provider"), self.transcribe_provider)
|
||||
|
||||
# The hosted providers take any model id that is typed at them; the
|
||||
# local one offers what has been published, so the two are separate
|
||||
# blocks and only one of them is ever visible.
|
||||
self.hosted_stt = QWidget()
|
||||
hosted_form = QFormLayout(self.hosted_stt)
|
||||
hosted_form.setContentsMargins(0, 0, 0, 0)
|
||||
self.transcribe_model = QComboBox()
|
||||
self.transcribe_model.setEditable(True)
|
||||
self.refresh_transcribe_models = QPushButton(t("Fetch model list"))
|
||||
self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models)
|
||||
stt_form.addRow(t("Model"),
|
||||
self._row(self.transcribe_model, self.refresh_transcribe_models))
|
||||
hosted_form.addRow(t("Model"),
|
||||
self._row(self.transcribe_model,
|
||||
self.refresh_transcribe_models))
|
||||
# A spanning row: in the narrow field column a wrapped label gets a
|
||||
# height that fits one line, and the rest of the text is cut off.
|
||||
self.transcribe_status = QLabel("")
|
||||
self.transcribe_status.setWordWrap(True)
|
||||
stt_form.addRow(self.transcribe_status)
|
||||
hosted_form.addRow(self.transcribe_status)
|
||||
stt_form.addRow(self.hosted_stt)
|
||||
|
||||
self.local_whisper = LocalModelBox(
|
||||
ggml.WHISPER, t("On this machine"),
|
||||
ggml.whisper_models, ggml.whisper_model_path)
|
||||
stt_form.addRow(self.local_whisper)
|
||||
|
||||
self.local_gpu = QCheckBox(t("Use the graphics card"))
|
||||
self.local_gpu.setToolTip(
|
||||
t("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."))
|
||||
self.local_preload = QCheckBox(t("Load the model when Dikte starts"))
|
||||
self.local_preload.setToolTip(
|
||||
t("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."))
|
||||
self.local_threads = QSpinBox()
|
||||
self.local_threads.setRange(0, 64)
|
||||
self.local_threads.setSpecialValueText(t("Automatic"))
|
||||
self.local_options = QWidget()
|
||||
options_form = QFormLayout(self.local_options)
|
||||
options_form.setContentsMargins(0, 0, 0, 0)
|
||||
options_form.addRow("", self.local_gpu)
|
||||
options_form.addRow("", self.local_preload)
|
||||
options_form.addRow(t("Threads"), self.local_threads)
|
||||
stt_form.addRow(self.local_options)
|
||||
|
||||
self.transcribe_provider.currentIndexChanged.connect(self._provider_changed)
|
||||
outer.addWidget(stt)
|
||||
|
||||
@@ -291,12 +644,22 @@ class SettingsWindow(QDialog):
|
||||
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
|
||||
orr_form.addRow("", self.cleanup_enabled)
|
||||
|
||||
self.cleanup_provider = QComboBox()
|
||||
for label, value in CLEANUP_PROVIDERS:
|
||||
self.cleanup_provider.addItem(t(label), value)
|
||||
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
|
||||
orr_form.addRow(t("Provider"), self.cleanup_provider)
|
||||
|
||||
self.hosted_cleanup = QWidget()
|
||||
cleanup_form = QFormLayout(self.hosted_cleanup)
|
||||
cleanup_form.setContentsMargins(0, 0, 0, 0)
|
||||
self.cleanup_model = QComboBox()
|
||||
self.cleanup_model.setEditable(True)
|
||||
self.cleanup_model.addItems(CLEANUP_MODELS)
|
||||
self.refresh_models = QPushButton(t("Fetch model list"))
|
||||
self.refresh_models.clicked.connect(self._load_models)
|
||||
orr_form.addRow(t("Model"), self._row(self.cleanup_model, self.refresh_models))
|
||||
cleanup_form.addRow(t("Model"), self._row(self.cleanup_model,
|
||||
self.refresh_models))
|
||||
|
||||
self.cleanup_reasoning = QComboBox()
|
||||
for label, value in REASONING_LEVELS:
|
||||
@@ -306,11 +669,38 @@ class SettingsWindow(QDialog):
|
||||
"a light job, so more thinking mostly costs time and tokens. Models "
|
||||
"that cannot think ignore this.")
|
||||
)
|
||||
orr_form.addRow(t("Thinking"), self.cleanup_reasoning)
|
||||
cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning)
|
||||
|
||||
self.models_label = QLabel(t("Runs on OpenRouter."))
|
||||
self.models_label.setWordWrap(True)
|
||||
orr_form.addRow(self.models_label)
|
||||
cleanup_form.addRow(self.models_label)
|
||||
orr_form.addRow(self.hosted_cleanup)
|
||||
|
||||
self.local_llm = LocalModelBox(
|
||||
ggml.LLAMA, t("On this machine"),
|
||||
ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos)
|
||||
orr_form.addRow(self.local_llm)
|
||||
|
||||
self.local_llm_gpu = QCheckBox(t("Use the graphics card"))
|
||||
self.local_llm_preload = QCheckBox(t("Load the model when Dikte starts"))
|
||||
self.local_llm_preload.setToolTip(
|
||||
t("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."))
|
||||
self.local_llm_reasoning = QComboBox()
|
||||
for label, value in REASONING_LEVELS:
|
||||
self.local_llm_reasoning.addItem(t(label), value)
|
||||
self.local_llm_reasoning.setToolTip(
|
||||
t("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."))
|
||||
self.local_llm_options = QWidget()
|
||||
llm_form = QFormLayout(self.local_llm_options)
|
||||
llm_form.setContentsMargins(0, 0, 0, 0)
|
||||
llm_form.addRow("", self.local_llm_gpu)
|
||||
llm_form.addRow("", self.local_llm_preload)
|
||||
llm_form.addRow(t("Thinking"), self.local_llm_reasoning)
|
||||
orr_form.addRow(self.local_llm_options)
|
||||
|
||||
outer.addWidget(orr)
|
||||
outer.addStretch(1)
|
||||
return page
|
||||
@@ -926,9 +1316,20 @@ class SettingsWindow(QDialog):
|
||||
self._shown_provider = ""
|
||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||
self._provider_changed() # selecting index 0 fires no signal
|
||||
self.local_gpu.setChecked(conf["local_gpu"])
|
||||
self.local_preload.setChecked(conf["local_preload"])
|
||||
self.local_threads.setValue(int(conf["local_threads"]))
|
||||
self.local_whisper.load(conf["local_model"])
|
||||
|
||||
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
||||
self._select_data(self.cleanup_provider, conf["cleanup_provider"])
|
||||
self._cleanup_provider_changed()
|
||||
self.cleanup_model.setCurrentText(conf["cleanup_model"])
|
||||
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
|
||||
self.local_llm_gpu.setChecked(conf["local_llm_gpu"])
|
||||
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
||||
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
||||
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
||||
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
|
||||
self.file_cleanup_prompt.setPlainText(
|
||||
conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt()
|
||||
@@ -1003,16 +1404,27 @@ class SettingsWindow(QDialog):
|
||||
conf["openai_api_key"] = self.openai_key.text().strip()
|
||||
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
|
||||
|
||||
provider = self.transcribe_provider.currentData() or "openai"
|
||||
self._models[provider] = self.transcribe_model.currentText().strip()
|
||||
provider = self.transcribe_provider.currentData() or "local"
|
||||
if provider in TRANSCRIBE_MODELS:
|
||||
self._models[provider] = self.transcribe_model.currentText().strip()
|
||||
conf["transcribe_provider"] = provider
|
||||
for key, name in (("openai", "transcribe_model"),
|
||||
("openrouter", "openrouter_transcribe_model")):
|
||||
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
|
||||
conf["local_model"] = self.local_whisper.selected()
|
||||
conf["local_gpu"] = self.local_gpu.isChecked()
|
||||
conf["local_preload"] = self.local_preload.isChecked()
|
||||
conf["local_threads"] = self.local_threads.value()
|
||||
|
||||
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
|
||||
conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter"
|
||||
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
|
||||
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
|
||||
conf["local_llm_model"] = self.local_llm.selected()
|
||||
conf["local_llm_repo"] = self.local_llm.repository()
|
||||
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
||||
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
||||
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
||||
|
||||
# Store an empty prompt when it matches the default, so switching the
|
||||
# interface language also switches the prompt language.
|
||||
@@ -1093,15 +1505,27 @@ class SettingsWindow(QDialog):
|
||||
|
||||
def _provider_changed(self):
|
||||
"""Swap the model box over to the newly chosen provider's own model."""
|
||||
if self._shown_provider:
|
||||
if self._shown_provider in TRANSCRIBE_MODELS:
|
||||
self._models[self._shown_provider] = self.transcribe_model.currentText().strip()
|
||||
provider = self.transcribe_provider.currentData() or "openai"
|
||||
provider = self.transcribe_provider.currentData() or "local"
|
||||
self._shown_provider = provider
|
||||
local = provider == "local"
|
||||
self.hosted_stt.setVisible(not local)
|
||||
self.local_whisper.setVisible(local)
|
||||
self.local_options.setVisible(local)
|
||||
if local:
|
||||
return
|
||||
self.transcribe_model.clear()
|
||||
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
||||
self.transcribe_model.setCurrentText(self._models[provider])
|
||||
self.transcribe_status.setText("")
|
||||
|
||||
def _cleanup_provider_changed(self):
|
||||
local = (self.cleanup_provider.currentData() or "openrouter") == "local"
|
||||
self.hosted_cleanup.setVisible(not local)
|
||||
self.local_llm.setVisible(local)
|
||||
self.local_llm_options.setVisible(local)
|
||||
|
||||
def _load_transcribe_models(self):
|
||||
"""The model list of whichever provider is selected."""
|
||||
provider = self.transcribe_provider.currentData() or "openai"
|
||||
|
||||
@@ -18,6 +18,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import wave
|
||||
from unittest import mock
|
||||
|
||||
@@ -40,6 +41,12 @@ linux_only = unittest.skipUnless(
|
||||
)
|
||||
|
||||
|
||||
def _no_network(*args, **kwargs):
|
||||
raise AssertionError(
|
||||
"a test reached the network; wrap the call in support.fake_urlopen"
|
||||
)
|
||||
|
||||
|
||||
def _no_exec(*args, **kwargs):
|
||||
raise AssertionError(
|
||||
"a test reached os.execv, which would replace the test process with the "
|
||||
@@ -78,6 +85,11 @@ class DikteTest(unittest.TestCase):
|
||||
# with it and hang, so it fails loudly here instead.
|
||||
self.patch_attr(os, "execv", _no_exec)
|
||||
|
||||
# Every way out of here goes through urllib, so closing it is enough to
|
||||
# keep the suite offline. A test that means to answer a request patches
|
||||
# this again through fake_urlopen.
|
||||
self.patch_attr(urllib.request, "urlopen", _no_network)
|
||||
|
||||
# ---- helpers ---------------------------------------------------------
|
||||
|
||||
def path(self, *parts):
|
||||
|
||||
+182
-10
@@ -10,6 +10,7 @@ import os
|
||||
import unittest
|
||||
|
||||
import api
|
||||
import ggml
|
||||
from tests.support import (
|
||||
DikteTest,
|
||||
fake_urlopen,
|
||||
@@ -27,10 +28,18 @@ OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
|
||||
|
||||
class TimestampModel(unittest.TestCase):
|
||||
def test_only_whisper_returns_segment_times(self):
|
||||
self.assertEqual(api.timestamp_model("openai"), "whisper-1")
|
||||
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||
"whisper-1")
|
||||
|
||||
def test_openrouter_namespaces_the_id(self):
|
||||
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
|
||||
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe"),
|
||||
"openai/whisper-1")
|
||||
|
||||
def test_the_local_server_stays_on_the_model_it_loaded(self):
|
||||
# Asking it for whisper-1 would name a model it has never heard of, and
|
||||
# it is running whisper whatever the file is called.
|
||||
self.assertEqual(api.timestamp_model("local", "ggml-base.bin"),
|
||||
"ggml-base.bin")
|
||||
|
||||
|
||||
class Explain(DikteTest):
|
||||
@@ -278,10 +287,15 @@ def chat_reply(content):
|
||||
return {"choices": [{"message": {"content": content}}]}
|
||||
|
||||
|
||||
def openrouter(model="some/model", key="sk-or-test", reasoning="",
|
||||
base_url="https://openrouter.ai/api/v1"):
|
||||
return api.Target("openrouter", "OpenRouter", key, base_url, model, reasoning)
|
||||
|
||||
|
||||
class Cleanup(DikteTest):
|
||||
def call(self, replies, **kwargs):
|
||||
def call(self, replies, target=None, **kwargs):
|
||||
with fake_urlopen(replies) as calls:
|
||||
result = api.cleanup("uh, hello", "sk-or-test", "some/model",
|
||||
result = api.cleanup(target or openrouter(), "uh, hello",
|
||||
"you clean up text", **kwargs)
|
||||
return result, calls
|
||||
|
||||
@@ -311,32 +325,34 @@ class Cleanup(DikteTest):
|
||||
self.assertNotIn("reasoning", sent_json(calls[0]))
|
||||
|
||||
def test_an_effort_is_passed_on_and_the_thinking_left_out(self):
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning="high")
|
||||
_, calls = self.call(chat_reply("Hello."),
|
||||
target=openrouter(reasoning="high"))
|
||||
self.assertEqual(sent_json(calls[0])["reasoning"],
|
||||
{"effort": "high", "exclude": True})
|
||||
|
||||
def test_a_local_base_url(self):
|
||||
_, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
|
||||
_, calls = self.call(chat_reply("Hello."),
|
||||
target=openrouter(base_url="http://localhost:1234/v1"))
|
||||
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
|
||||
|
||||
def test_no_key(self):
|
||||
with self.assertRaises(api.ApiError):
|
||||
api.cleanup("hello", "", "some/model", "prompt")
|
||||
api.cleanup(openrouter(key=""), "hello", "prompt")
|
||||
|
||||
def test_a_reply_with_no_choices_says_why(self):
|
||||
with fake_urlopen({"error": {"message": "model is offline"}}), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
api.cleanup(openrouter(), "hello", "p")
|
||||
self.assertIn("model is offline", str(caught.exception))
|
||||
|
||||
def test_an_empty_answer(self):
|
||||
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
api.cleanup(openrouter(), "hello", "p")
|
||||
|
||||
def test_a_rate_limit_is_explained(self):
|
||||
with fake_urlopen(http_error(429)), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
api.cleanup(openrouter(), "hello", "p")
|
||||
self.assertIn("OpenRouter", str(caught.exception))
|
||||
|
||||
|
||||
@@ -435,3 +451,159 @@ class ModelLists(DikteTest):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class FakeServer:
|
||||
"""A ggml.Server as far as api.py is concerned."""
|
||||
|
||||
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""):
|
||||
self.url = url
|
||||
self.fails = fails
|
||||
self.log = log
|
||||
self.starts = 0
|
||||
|
||||
def serve(self):
|
||||
self.starts += 1
|
||||
if self.fails:
|
||||
raise ggml.LocalError(self.fails)
|
||||
return self.url
|
||||
|
||||
def error(self):
|
||||
return self.log
|
||||
|
||||
|
||||
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
||||
LOCAL_LLM = api.Target("local-llm", "Local model", "", "", "gemma.gguf", "none")
|
||||
|
||||
|
||||
class TranscribeHere(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.wav = str(self.path("clip.wav"))
|
||||
os.makedirs(self.root, exist_ok=True)
|
||||
with open(self.wav, "wb") as fh:
|
||||
fh.write(b"RIFFfake")
|
||||
self.server = FakeServer()
|
||||
self.patch_attr(ggml, "whisper", self.server)
|
||||
|
||||
def test_the_address_comes_from_the_server_it_starts(self):
|
||||
with fake_urlopen({"text": "hello"}) as calls:
|
||||
api.transcribe(LOCAL, self.wav)
|
||||
self.assertEqual(self.server.starts, 1)
|
||||
self.assertEqual(calls[0].full_url,
|
||||
"http://127.0.0.1:9999/v1/audio/transcriptions")
|
||||
|
||||
def test_nothing_local_is_authorised(self):
|
||||
with fake_urlopen({"text": "hello"}) as calls:
|
||||
api.transcribe(LOCAL, self.wav)
|
||||
self.assertNotIn("Authorization", calls[0].headers)
|
||||
|
||||
def test_a_server_that_will_not_start_is_the_error_shown(self):
|
||||
self.patch_attr(ggml, "whisper", FakeServer(fails="no model downloaded"))
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.transcribe(LOCAL, self.wav)
|
||||
self.assertIn("no model downloaded", str(caught.exception))
|
||||
|
||||
def test_a_server_that_dies_mid_request_says_what_it_printed(self):
|
||||
self.patch_attr(ggml, "whisper", FakeServer(log="out of memory"))
|
||||
with fake_urlopen(url_error("connection reset")):
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.transcribe(LOCAL, self.wav)
|
||||
self.assertIn("out of memory", str(caught.exception))
|
||||
|
||||
def test_the_hint_reaches_whisper_as_its_initial_prompt(self):
|
||||
with fake_urlopen({"text": "hi"}) as calls:
|
||||
api.transcribe(LOCAL, self.wav, prompt="Dikte, Paraşüt")
|
||||
self.assertEqual(multipart_fields(calls[0])["prompt"], "Dikte, Paraşüt")
|
||||
|
||||
def test_a_word_broken_over_two_lines_is_put_back_together(self):
|
||||
# whisper.cpp cuts on tokens and writes one segment per line, which in
|
||||
# Turkish lands inside a word about as often as between two.
|
||||
with fake_urlopen({"text": "Onlar akraba değ\niller. Ve\n devamı."}):
|
||||
# The line break inside a word leaves nothing in its place; the
|
||||
# one between two words is where whisper's own leading space is.
|
||||
self.assertEqual(api.transcribe(LOCAL, self.wav),
|
||||
"Onlar akraba değiller. Ve devamı.")
|
||||
|
||||
def test_a_local_timeout_is_not_a_hosted_one(self):
|
||||
# Nothing is being spent but time, and a long file on a machine without
|
||||
# a graphics card takes a good deal of it.
|
||||
with fake_urlopen({"text": "hi"}):
|
||||
api.transcribe(LOCAL, self.wav, timeout=300)
|
||||
self.assertGreaterEqual(api.LOCAL_TIMEOUT, 600)
|
||||
|
||||
def test_segments_that_continue_a_word_are_merged(self):
|
||||
reply = {"segments": [
|
||||
{"start": 0.0, "end": 1.0, "text": " Onlar akraba değ"},
|
||||
{"start": 1.0, "end": 1.4, "text": "iller."},
|
||||
{"start": 2.0, "end": 3.0, "text": " Başka bir cümle."},
|
||||
]}
|
||||
with fake_urlopen(reply):
|
||||
out = api.transcribe_segments(LOCAL, self.wav)
|
||||
self.assertEqual([text for _, _, text in out],
|
||||
["Onlar akraba değiller.", "Başka bir cümle."])
|
||||
self.assertEqual(out[0][1], 1.4) # the merged cue covers the whole word
|
||||
|
||||
def test_the_loaded_model_is_the_one_asked_for_again(self):
|
||||
with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls:
|
||||
api.transcribe_segments(LOCAL, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin")
|
||||
|
||||
|
||||
class CleanupHere(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.server = FakeServer("http://127.0.0.1:8888/v1")
|
||||
self.patch_attr(ggml, "llm", self.server)
|
||||
|
||||
def test_it_goes_to_the_server_it_starts(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
result = api.cleanup(LOCAL_LLM, "uh, hello", "clean it up")
|
||||
self.assertEqual(result, "Hello.")
|
||||
self.assertEqual(calls[0].full_url,
|
||||
"http://127.0.0.1:8888/v1/chat/completions")
|
||||
|
||||
def test_no_key_is_wanted_and_none_is_sent(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertNotIn("Authorization", calls[0].headers)
|
||||
|
||||
def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertEqual(sent_json(calls[0])["chat_template_kwargs"],
|
||||
{"enable_thinking": False})
|
||||
|
||||
def test_the_models_own_default_asks_for_nothing(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM._replace(reasoning=""), "hello", "prompt")
|
||||
self.assertNotIn("chat_template_kwargs", sent_json(calls[0]))
|
||||
|
||||
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertIn("Thinking", str(caught.exception))
|
||||
|
||||
def test_a_reply_longer_than_the_transcript_is_cut_off(self):
|
||||
# A small model will repeat the transcript until the context is full,
|
||||
# and every one of those tokens is a second of somebody waiting.
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "x" * 4000, "prompt")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 4000)
|
||||
|
||||
def test_a_short_dictation_still_gets_room_to_answer(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(LOCAL_LLM, "uh, hi", "prompt")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
||||
|
||||
def test_a_hosted_model_is_left_to_answer_at_length(self):
|
||||
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||
api.cleanup(openrouter(), "uh, hi", "prompt")
|
||||
self.assertNotIn("max_tokens", sent_json(calls[0]))
|
||||
|
||||
def test_a_server_that_will_not_start_is_the_error_shown(self):
|
||||
self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed"))
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||
self.assertIn("llama.cpp", str(caught.exception))
|
||||
|
||||
+84
-3
@@ -13,6 +13,7 @@ from unittest import mock
|
||||
|
||||
import api
|
||||
import config as cfg
|
||||
import ggml
|
||||
import i18n
|
||||
from tests.support import DikteTest
|
||||
|
||||
@@ -127,8 +128,17 @@ class Keys(DikteTest):
|
||||
|
||||
|
||||
class TranscribeTarget(DikteTest):
|
||||
def test_openai_by_default(self):
|
||||
target = self.config(openai_api_key="sk-test").transcribe_target()
|
||||
def test_this_machine_by_default(self):
|
||||
target = cfg.Config().transcribe_target()
|
||||
self.assertEqual(target.provider, "local")
|
||||
self.assertEqual(target.api_key, "")
|
||||
# Empty on purpose: the server picks a port when it starts, and reading
|
||||
# a setting must not be what starts it.
|
||||
self.assertEqual(target.base_url, "")
|
||||
|
||||
def test_openai_when_it_is_picked(self):
|
||||
target = self.config(transcribe_provider="openai",
|
||||
openai_api_key="sk-test").transcribe_target()
|
||||
self.assertEqual(target.provider, "openai")
|
||||
self.assertEqual(target.service, "OpenAI")
|
||||
self.assertEqual(target.api_key, "sk-test")
|
||||
@@ -146,7 +156,8 @@ class TranscribeTarget(DikteTest):
|
||||
self.assertEqual(target.model, "openai/whisper-1")
|
||||
|
||||
def test_a_self_hosted_endpoint(self):
|
||||
conf = self.config(openai_base_url="http://localhost:8080/v1")
|
||||
conf = self.config(transcribe_provider="openai",
|
||||
openai_base_url="http://localhost:8080/v1")
|
||||
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
|
||||
|
||||
|
||||
@@ -425,3 +436,73 @@ class Defaults(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class LocalTargets(DikteTest):
|
||||
def test_cleanup_can_run_here_while_the_minutes_do_not(self):
|
||||
# The two jobs are not the same size: a small model on this machine
|
||||
# strips filler words perfectly well and will not write up an hour.
|
||||
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf")
|
||||
self.assertEqual(conf.cleanup_target().provider, "local-llm")
|
||||
self.assertEqual(conf.minutes_target().provider, "openrouter")
|
||||
self.assertEqual(conf.minutes_target().model, cfg.DEFAULTS["meeting_model"])
|
||||
|
||||
def test_the_minutes_can_run_here_on_their_own(self):
|
||||
conf = self.config(meeting_provider="local", local_llm_model="gemma.gguf")
|
||||
self.assertEqual(conf.minutes_target().model, "gemma.gguf")
|
||||
self.assertEqual(conf.cleanup_target().provider, "openrouter")
|
||||
|
||||
def test_the_local_cleanup_target_carries_the_thinking_level(self):
|
||||
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf",
|
||||
local_llm_reasoning="none")
|
||||
target = conf.cleanup_target()
|
||||
self.assertEqual(target.reasoning, "none")
|
||||
self.assertEqual(target.api_key, "")
|
||||
|
||||
def test_either_of_them_counts_as_using_the_local_model(self):
|
||||
self.assertFalse(cfg.Config().uses_local_llm())
|
||||
self.assertTrue(self.config(cleanup_provider="local").uses_local_llm())
|
||||
self.assertTrue(self.config(meeting_provider="local").uses_local_llm())
|
||||
|
||||
|
||||
class ReadyToRun(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||
|
||||
def install(self, name):
|
||||
path = ggml.whisper_model_path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"model")
|
||||
|
||||
def test_a_missing_program_is_not_ready(self):
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
self.install("ggml-base.bin")
|
||||
conf = self.config(local_model="ggml-base.bin")
|
||||
self.assertFalse(conf.transcribe_ready())
|
||||
|
||||
def test_a_missing_model_is_not_ready_either(self):
|
||||
with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"):
|
||||
conf = self.config(local_model="ggml-base.bin")
|
||||
self.assertFalse(conf.transcribe_ready())
|
||||
|
||||
def test_both_halves_in_place(self):
|
||||
with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"):
|
||||
self.install("ggml-base.bin")
|
||||
conf = self.config(local_model="ggml-base.bin")
|
||||
self.assertTrue(conf.transcribe_ready())
|
||||
|
||||
def test_a_hosted_provider_is_ready_when_it_has_a_key(self):
|
||||
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test")
|
||||
self.assertTrue(conf.transcribe_ready())
|
||||
|
||||
def test_the_settings_reach_the_servers(self):
|
||||
conf = self.config(local_model="ggml-base.bin", local_threads=4,
|
||||
local_gpu=False, local_llm_model="gemma.gguf",
|
||||
local_llm_context=4096)
|
||||
conf.apply_local()
|
||||
self.addCleanup(ggml.whisper.configure, model="", threads=0, gpu=True)
|
||||
self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin")
|
||||
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
||||
self.assertFalse(ggml.whisper.settings()["gpu"])
|
||||
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
||||
|
||||
@@ -197,7 +197,7 @@ class Transcriber(DikteTest):
|
||||
|
||||
def test_cleanup_is_told_it_is_writing_subtitles(self):
|
||||
_, _, _, cleanup_call = self.run_chain(cleanup=True)
|
||||
prompt = cleanup_call.call_args.args[3]
|
||||
prompt = cleanup_call.call_args.args[2]
|
||||
self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True))
|
||||
|
||||
def test_timestamps_come_back_as_segments_and_as_stamped_lines(self):
|
||||
|
||||
@@ -41,8 +41,18 @@ CHANGED = {
|
||||
"transcribe_model": "whisper-1",
|
||||
"openrouter_transcribe_model": "openai/whisper-1",
|
||||
"cleanup_enabled": False,
|
||||
"cleanup_provider": "local",
|
||||
"cleanup_model": "some/other-model",
|
||||
"cleanup_reasoning": "high",
|
||||
"local_model": "ggml-small.bin",
|
||||
"local_gpu": False,
|
||||
"local_preload": False,
|
||||
"local_threads": 6,
|
||||
"local_llm_model": "gemma-3-4b-it-Q4_K_M.gguf",
|
||||
"local_llm_repo": "ggml-org/gemma-4-E2B-it-GGUF",
|
||||
"local_llm_gpu": False,
|
||||
"local_llm_preload": True,
|
||||
"local_llm_reasoning": "low",
|
||||
"cleanup_prompt": "Only fix the punctuation.",
|
||||
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||||
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||||
@@ -259,3 +269,53 @@ class Overlay(DikteTest):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class LocalModels(DikteTest):
|
||||
"""The download boxes, without a network and without either program."""
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf, "dikte toggle")
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
return window
|
||||
|
||||
def test_it_opens_where_the_missing_model_is_fixed(self):
|
||||
# Nothing can transcribe on a fresh install, which is why this window
|
||||
# was opened at all.
|
||||
window = self.window(cfg.Config())
|
||||
self.assertEqual(window.tabs.currentIndex(), window.api_tab_index)
|
||||
|
||||
def test_it_opens_where_it_was_left_when_everything_works(self):
|
||||
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test")
|
||||
self.assertEqual(self.window(conf).tabs.currentIndex(), 0)
|
||||
|
||||
def test_a_model_that_is_not_here_yet_survives_a_save(self):
|
||||
# The box is filled from what is on this disk, so a model that was
|
||||
# deleted from underneath is not in the list. Dropping it on save would
|
||||
# quietly empty the setting instead of asking for the download again.
|
||||
conf = self.config(local_model="ggml-large-v3-turbo-q5_0.bin")
|
||||
with mock.patch.object(QMessageBox, "information"):
|
||||
self.window(conf)._save()
|
||||
self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin")
|
||||
|
||||
def test_nothing_is_fetched_for_a_window_nobody_opened(self):
|
||||
# DikteTest closes the network, so a request would fail the test. The
|
||||
# lists are asked for when the box is shown, not when it is built.
|
||||
window = self.window(cfg.Config())
|
||||
self.assertTrue(window.local_whisper._pending)
|
||||
|
||||
def test_the_hosted_boxes_go_away_when_the_work_happens_here(self):
|
||||
window = self.window(self.config(transcribe_provider="openai"))
|
||||
self.assertTrue(window.hosted_stt.isVisibleTo(window))
|
||||
self.assertFalse(window.local_whisper.isVisibleTo(window))
|
||||
window._select_data(window.transcribe_provider, "local")
|
||||
self.assertFalse(window.hosted_stt.isVisibleTo(window))
|
||||
self.assertTrue(window.local_whisper.isVisibleTo(window))
|
||||
|
||||
def test_the_same_for_cleanup(self):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertTrue(window.hosted_cleanup.isVisibleTo(window))
|
||||
window._select_data(window.cleanup_provider, "local")
|
||||
self.assertTrue(window.local_llm.isVisibleTo(window))
|
||||
self.assertFalse(window.hosted_cleanup.isVisibleTo(window))
|
||||
|
||||
@@ -234,7 +234,7 @@ class Chain(DikteTest):
|
||||
self.assertEqual(row["raw"], "uh, book it for Thursday")
|
||||
self.assertEqual(row["text"], "Book it for Thursday.")
|
||||
self.assertEqual(row["duration"], 2.0)
|
||||
self.assertEqual(row["model"], self.conf["transcribe_model"])
|
||||
self.assertEqual(row["model"], self.conf.transcribe_target().model)
|
||||
self.assertEqual(row["mode"], "")
|
||||
|
||||
def test_a_command_is_recorded_as_one(self):
|
||||
|
||||
@@ -106,20 +106,14 @@ class Pipeline(QObject):
|
||||
|
||||
text = raw
|
||||
warning = ""
|
||||
cleaner = conf.cleanup_target()
|
||||
# Claude reads through “eee” and “hani” without help, so a dictation
|
||||
# on its way there is normally sent as it was heard, one API call and
|
||||
# a second or two lighter.
|
||||
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
|
||||
self.stage.emit(t("Cleaning up…"))
|
||||
try:
|
||||
text = api.cleanup(
|
||||
raw,
|
||||
conf.openrouter_key(),
|
||||
conf["cleanup_model"],
|
||||
conf.cleanup_prompt(),
|
||||
reasoning=conf["cleanup_reasoning"],
|
||||
base_url=conf["openrouter_base_url"],
|
||||
)
|
||||
text = api.cleanup(cleaner, raw, conf.cleanup_prompt())
|
||||
except api.ApiError as exc:
|
||||
# Keep the transcript, but never let the failure pass unseen:
|
||||
# a rejected key would otherwise look like working dictation.
|
||||
@@ -159,7 +153,7 @@ class Pipeline(QObject):
|
||||
"duration": round(duration, 1),
|
||||
"elapsed": round(time.monotonic() - started, 1),
|
||||
"model": target.model,
|
||||
"cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "",
|
||||
"cleanup_model": cleaner.model if conf["cleanup_enabled"] else "",
|
||||
"cleanup_error": warning,
|
||||
"mode": "ask" if ask else "",
|
||||
"question": question,
|
||||
|
||||
Reference in New Issue
Block a user