mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85622aefb | ||
|
|
4b3ae8d70b | ||
|
|
93944f6c14 | ||
|
|
2af5ec671c | ||
|
|
825f089fe9 | ||
|
|
5a4ae8c315 | ||
|
|
22d2a40341 | ||
|
|
44db26c459 | ||
|
|
3e3cb21bc3 | ||
|
|
70bc4c16fa | ||
|
|
b13b08fc38 |
@@ -161,7 +161,8 @@ running.
|
|||||||
- **It all runs on this machine by default.** Speech to text on whisper.cpp and
|
- **It all runs on this machine by default.** Speech to text on whisper.cpp and
|
||||||
cleanup on llama.cpp, neither installed beforehand: the settings window fetches
|
cleanup on llama.cpp, neither installed beforehand: the settings window fetches
|
||||||
the program and the model, verifies the sha256 and refuses a download published
|
the program and the model, verifies the sha256 and refuses a download published
|
||||||
without one, then keeps a server alive while you dictate. The model list is
|
without one, then keeps a server alive while you dictate and hands the memory
|
||||||
|
back once it has sat unused for ten minutes. The model list is
|
||||||
grouped by model rather than by file size, and the row this machine's memory
|
grouped by model rather than by file size, and the row this machine's memory
|
||||||
and graphics can take is marked. The graphics card is
|
and graphics can take is marked. The graphics card is
|
||||||
reached through CUDA, ROCm or Vulkan where the build allows. No key, no
|
reached through CUDA, ROCm or Vulkan where the build allows. No key, no
|
||||||
|
|||||||
+2
-1
@@ -158,7 +158,8 @@ olmasını ister.
|
|||||||
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
|
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
|
||||||
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
|
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
|
||||||
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
|
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
|
||||||
ayakta tutar. Model listesi dosya boyutuna değil modele göre gruplanır ve bu
|
ayakta tutar ve on dakika kullanılmayan modelin belleğini geri verir. Model
|
||||||
|
listesi dosya boyutuna değil modele göre gruplanır ve bu
|
||||||
makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme
|
makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme
|
||||||
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
|
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
|
||||||
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
|
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
|
||||||
|
|||||||
+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
|
# 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
|
# 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.
|
# bug report from someone running "latest" names a commit.
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.2.0"
|
||||||
|
|||||||
+189
-19
@@ -384,15 +384,20 @@ def _transcribe_request(target, audio_path, language, prompt, response_format,
|
|||||||
# takes it as the initial prompt, the way OpenAI does.
|
# takes it as the initial prompt, the way OpenAI does.
|
||||||
if prompt and target.provider != "openrouter":
|
if prompt and target.provider != "openrouter":
|
||||||
fields.append(("prompt", prompt))
|
fields.append(("prompt", prompt))
|
||||||
if granularity:
|
for level in granularity or ():
|
||||||
fields.append(("timestamp_granularities[]", granularity))
|
fields.append(("timestamp_granularities[]", level))
|
||||||
body, ctype = _multipart(fields, "file", audio_path)
|
body, ctype = _multipart(fields, "file", audio_path)
|
||||||
|
# An hour of meeting takes the local server a while, and the idle unload has
|
||||||
|
# to count that as the model being used rather than as nobody wanting it.
|
||||||
|
held = (ggml.whisper.busy() if target.provider == "local"
|
||||||
|
else contextlib.nullcontext())
|
||||||
try:
|
try:
|
||||||
return _request(
|
with held:
|
||||||
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
return _request(
|
||||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
||||||
aborter=aborter,
|
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||||
)
|
aborter=aborter,
|
||||||
|
)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
if target.provider == "local":
|
if target.provider == "local":
|
||||||
raise local_failure(target.service, ggml.whisper, exc) from None
|
raise local_failure(target.service, ggml.whisper, exc) from None
|
||||||
@@ -438,6 +443,96 @@ def _merge_word_splits(segments):
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# A cue built here is one a reader has time for: about two lines of subtitle,
|
||||||
|
# and no longer on screen than a sentence takes to say. Neither is a hard rule
|
||||||
|
# for a sentence that ends early, only the point past which one is broken.
|
||||||
|
MAX_CUE_SECONDS = 7.0
|
||||||
|
MAX_CUE_CHARS = 84
|
||||||
|
# The other end of it: a cue nobody can read because it was gone before they
|
||||||
|
# looked. A full stop this early in a cue is not the end of anything worth
|
||||||
|
# breaking on, which is what "1." and "Dr." are, and a cue that ends up short
|
||||||
|
# anyway is held on screen until the next one needs the space.
|
||||||
|
MIN_CUE_SECONDS = 1.2
|
||||||
|
# No whisper segment is longer than the window it was heard in, so a segment
|
||||||
|
# that runs past this came from a model that is not marking segments at all.
|
||||||
|
WHISPER_WINDOW = 30.0
|
||||||
|
SENTENCE_END = ".!?…"
|
||||||
|
|
||||||
|
|
||||||
|
def _too_coarse(segments):
|
||||||
|
"""Whether these segments are too long to be cues, or are not there at all.
|
||||||
|
|
||||||
|
Not every model behind /audio/transcriptions marks segments the way whisper
|
||||||
|
does. Some fill the field with one entry per paragraph, or with a single one
|
||||||
|
covering the whole file, which turns a fourteen minute video into three
|
||||||
|
subtitles. Word times are what those models do give, and cues built from
|
||||||
|
them are better than what the segments would have been.
|
||||||
|
"""
|
||||||
|
if not segments:
|
||||||
|
return True
|
||||||
|
return any(float(seg.get("end") or 0.0) - float(seg.get("start") or 0.0)
|
||||||
|
> WHISPER_WINDOW for seg in segments)
|
||||||
|
|
||||||
|
|
||||||
|
def cues_from_words(words):
|
||||||
|
"""[(start, end, text)] cut out of word times, where segments were no use.
|
||||||
|
|
||||||
|
A cue ends where a sentence does, and failing that wherever it has grown too
|
||||||
|
long to read or too long to leave up. Nothing is ever cut between two words:
|
||||||
|
the times that arrive are per word, and so are the ones that leave.
|
||||||
|
"""
|
||||||
|
cues = []
|
||||||
|
start = end = 0.0
|
||||||
|
current = []
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
nonlocal current
|
||||||
|
if current:
|
||||||
|
cues.append((start, max(end, start), " ".join(current)))
|
||||||
|
current = []
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
text = (word.get("word") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
at = float(word.get("start") or 0.0)
|
||||||
|
until = float(word.get("end") or at)
|
||||||
|
if current:
|
||||||
|
grown = len(" ".join(current)) + 1 + len(text)
|
||||||
|
if grown > MAX_CUE_CHARS or until - start > MAX_CUE_SECONDS:
|
||||||
|
flush()
|
||||||
|
if not current:
|
||||||
|
start = at
|
||||||
|
current.append(text)
|
||||||
|
end = until
|
||||||
|
# A sentence can end inside the punctuation that closes a quote. What
|
||||||
|
# is too short to have been a sentence is a list marker or a shortened
|
||||||
|
# word, and the cue goes on rather than ending on it.
|
||||||
|
if (end - start >= MIN_CUE_SECONDS
|
||||||
|
and text.rstrip("\"')]»”’").endswith(tuple(SENTENCE_END))):
|
||||||
|
flush()
|
||||||
|
flush()
|
||||||
|
return _held(cues)
|
||||||
|
|
||||||
|
|
||||||
|
def _held(cues):
|
||||||
|
"""Keep a cue that is still too short on screen, without covering the next.
|
||||||
|
|
||||||
|
A one word sentence is a fifth of a second of audio and so a fifth of a
|
||||||
|
second of subtitle, which is a flicker. It stays up until the cue after it
|
||||||
|
starts, or for as long as it takes to read, whichever comes first.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for index, (start, end, text) in enumerate(cues):
|
||||||
|
if end - start < MIN_CUE_SECONDS:
|
||||||
|
room = start + MIN_CUE_SECONDS
|
||||||
|
if index + 1 < len(cues):
|
||||||
|
room = min(room, cues[index + 1][0])
|
||||||
|
end = max(end, room)
|
||||||
|
out.append((start, end, text))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=None):
|
def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=None):
|
||||||
data = _transcribe_request(
|
data = _transcribe_request(
|
||||||
target, audio_path, language, prompt, "json", timeout=timeout, aborter=aborter
|
target, audio_path, language, prompt, "json", timeout=timeout, aborter=aborter
|
||||||
@@ -454,15 +549,34 @@ def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=
|
|||||||
def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
|
def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
|
||||||
aborter=None):
|
aborter=None):
|
||||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||||
data = _transcribe_request(
|
target = target._replace(model=timestamp_model(target.provider, target.model,
|
||||||
target._replace(model=timestamp_model(target.provider, target.model,
|
target.file_model))
|
||||||
target.file_model)),
|
ask = dict(language=language, prompt=prompt, response_format="verbose_json",
|
||||||
audio_path, language, prompt, "verbose_json",
|
timeout=timeout, aborter=aborter)
|
||||||
granularity="segment", timeout=timeout, aborter=aborter,
|
# Word times are the way out of a model that does not mark segments, and
|
||||||
)
|
# whisper.cpp is not one of those, so the local server is only ever asked
|
||||||
|
# for what it has always been asked for. A hosted model that refuses the
|
||||||
|
# field says so with a 400, and the request it used to answer is still
|
||||||
|
# there to fall back on rather than losing the run over a field it did not
|
||||||
|
# need in the first place.
|
||||||
|
if target.provider == "local":
|
||||||
|
data = _transcribe_request(target, audio_path, granularity=("segment",), **ask)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = _transcribe_request(target, audio_path,
|
||||||
|
granularity=("segment", "word"), **ask)
|
||||||
|
except ApiError as exc:
|
||||||
|
if exc.status != 400:
|
||||||
|
raise
|
||||||
|
data = _transcribe_request(target, audio_path,
|
||||||
|
granularity=("segment",), **ask)
|
||||||
segments = data.get("segments") or []
|
segments = data.get("segments") or []
|
||||||
if target.provider == "local":
|
if target.provider == "local":
|
||||||
segments = _merge_word_splits(segments)
|
segments = _merge_word_splits(segments)
|
||||||
|
if _too_coarse(segments):
|
||||||
|
cues = cues_from_words(data.get("words") or [])
|
||||||
|
if cues:
|
||||||
|
return cues
|
||||||
out = []
|
out = []
|
||||||
for seg in segments:
|
for seg in segments:
|
||||||
text = (seg.get("text") or "").strip()
|
text = (seg.get("text") or "").strip()
|
||||||
@@ -518,22 +632,65 @@ def _thinking(payload, provider, reasoning):
|
|||||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
||||||
|
|
||||||
|
|
||||||
def local_ceiling(text):
|
# Room for the thinking on this machine, one budget per rung of the settings
|
||||||
|
# ladder. llama.cpp counts the thinking towards max_tokens along with the answer
|
||||||
|
# it precedes, so a ceiling sized for the answer alone leaves a model that
|
||||||
|
# thinks nothing to answer with. The rungs double, starting where a small model
|
||||||
|
# lands when it barely thinks at all: cleanup is punctuation, and locally every
|
||||||
|
# one of these tokens is also a second of somebody standing in front of the
|
||||||
|
# screen, so the low rungs are the ones meant to be used.
|
||||||
|
THINKING_ROOM = {
|
||||||
|
"minimal": 256, "low": 512, "medium": 1024,
|
||||||
|
"high": 2048, "xhigh": 4096, "max": 8192,
|
||||||
|
}
|
||||||
|
# An empty setting leaves it to the model, and the templates that can think
|
||||||
|
# think by default. Room for a middling amount of it, since there is no way to
|
||||||
|
# ask which kind of model this is.
|
||||||
|
DEFAULT_THINKING_ROOM = THINKING_ROOM["medium"]
|
||||||
|
|
||||||
|
|
||||||
|
def local_ceiling(text, reasoning="", context=0, prompt=""):
|
||||||
"""How much of a reply is worth waiting for from a model on this machine.
|
"""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
|
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
|
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
|
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
|
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
|
waiting, with only the hour-long local timeout underneath. A hosted model is
|
||||||
ceiling would cut the minutes short instead.
|
left alone: there the same runaway is rare, and a ceiling would cut the
|
||||||
|
minutes short instead.
|
||||||
|
|
||||||
|
The answer's share is the transcript's length in characters spent as a
|
||||||
|
budget in tokens, so what it really allows is two to four times the
|
||||||
|
transcript depending on how well the language tokenises. Turkish sits at the
|
||||||
|
tight end of that and still has room to spare for a reply that is meant to
|
||||||
|
come back the same length it went in.
|
||||||
|
|
||||||
|
Thinking is added on top of that share rather than taken out of it. Sharing
|
||||||
|
one budget is what makes turning thinking up quietly cost the answer, and on
|
||||||
|
a short dictation the 512 floor is the whole budget, so the answer is what
|
||||||
|
goes missing first.
|
||||||
|
|
||||||
|
`context` is what the server was started with, and the whole of it is the
|
||||||
|
real limit whatever is asked for here: a ceiling above it is not a ceiling,
|
||||||
|
because the runaway it exists to stop would run to the end of the context
|
||||||
|
instead. So the ceiling is held below what the prompt leaves. Two characters
|
||||||
|
to the token is under any tokeniser's rate for natural language, Turkish
|
||||||
|
included, which makes the reserve an over-estimate rather than a promise of
|
||||||
|
room that is not there.
|
||||||
"""
|
"""
|
||||||
return max(512, len(text))
|
answer = max(512, len(text))
|
||||||
|
if reasoning != "none":
|
||||||
|
answer += THINKING_ROOM.get(reasoning, DEFAULT_THINKING_ROOM)
|
||||||
|
context = int(context or 0)
|
||||||
|
if not context:
|
||||||
|
return answer
|
||||||
|
return max(256, min(answer, context - (len(prompt) + len(text)) // 2))
|
||||||
|
|
||||||
|
|
||||||
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||||
base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
|
base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
|
||||||
service="OpenRouter", aborter=None):
|
service="OpenRouter", aborter=None, context=0):
|
||||||
if not api_key and provider != "local-llm":
|
if not api_key and provider != "local-llm":
|
||||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||||
service=service))
|
service=service))
|
||||||
@@ -546,7 +703,8 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
if provider == "local-llm":
|
if provider == "local-llm":
|
||||||
payload["max_tokens"] = local_ceiling(text)
|
payload["max_tokens"] = local_ceiling(text, reasoning, context,
|
||||||
|
system_prompt)
|
||||||
_thinking(payload, provider, reasoning)
|
_thinking(payload, provider, reasoning)
|
||||||
try:
|
try:
|
||||||
data = _request(
|
data = _request(
|
||||||
@@ -570,6 +728,13 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
|
|||||||
raise ApiError(t("The cleanup model spent its whole reply on "
|
raise ApiError(t("The cleanup model spent its whole reply on "
|
||||||
"thinking. Set Thinking to \u201cOff\u201d."))
|
"thinking. Set Thinking to \u201cOff\u201d."))
|
||||||
raise ApiError(t("The cleanup model returned an empty reply."))
|
raise ApiError(t("The cleanup model returned an empty reply."))
|
||||||
|
if choices[0].get("finish_reason") == "length":
|
||||||
|
# Cut off at somebody's ceiling: ours locally, the provider's otherwise.
|
||||||
|
# What came back is a sentence that stops mid-word, and cleanup is meant
|
||||||
|
# to hand back the whole dictation, so the half is refused rather than
|
||||||
|
# returned. The callers keep the transcript they started with, which is
|
||||||
|
# the better of the two.
|
||||||
|
raise ApiError(t("The cleanup model was cut off before it finished."))
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
@@ -605,6 +770,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="",
|
|||||||
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
raise ApiError(t("The model returned an empty reply."))
|
raise ApiError(t("The model returned an empty reply."))
|
||||||
|
if choices[0].get("finish_reason") == "length":
|
||||||
|
# An answer that stops mid-sentence reads like a whole one once it has
|
||||||
|
# been pasted, so it is refused here for the same reason cleanup refuses
|
||||||
|
# a half transcript.
|
||||||
|
raise ApiError(t("The model was cut off before it finished."))
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ class Dikte:
|
|||||||
self.update_action.triggered.connect(self.open_release_page)
|
self.update_action.triggered.connect(self.open_release_page)
|
||||||
self.menu.addAction(self.update_action)
|
self.menu.addAction(self.update_action)
|
||||||
|
|
||||||
|
# Named in _refresh_tray, which is where the loaded models are known.
|
||||||
|
self.unload_action = QAction("", self.menu)
|
||||||
|
self.unload_action.triggered.connect(self.unload_models)
|
||||||
|
self.menu.addAction(self.unload_action)
|
||||||
|
|
||||||
self.settings_action = QAction(t("Settings…"), self.menu)
|
self.settings_action = QAction(t("Settings…"), self.menu)
|
||||||
self.settings_action.triggered.connect(self.open_settings)
|
self.settings_action.triggered.connect(self.open_settings)
|
||||||
self.menu.addAction(self.settings_action)
|
self.menu.addAction(self.settings_action)
|
||||||
@@ -303,6 +308,10 @@ class Dikte:
|
|||||||
self.menu.addAction(self.quit_action)
|
self.menu.addAction(self.quit_action)
|
||||||
|
|
||||||
self.tray.setContextMenu(self.menu)
|
self.tray.setContextMenu(self.menu)
|
||||||
|
# A model unloads itself in the background, so what the unload row says
|
||||||
|
# goes stale between state changes. Refreshed as the menu opens, which
|
||||||
|
# is the only moment anybody reads it.
|
||||||
|
self.menu.aboutToShow.connect(self._refresh_tray)
|
||||||
self.tray.setToolTip(t("Dikte: ready"))
|
self.tray.setToolTip(t("Dikte: ready"))
|
||||||
self.tray.activated.connect(self._tray_clicked)
|
self.tray.activated.connect(self._tray_clicked)
|
||||||
self._refresh_update()
|
self._refresh_update()
|
||||||
@@ -398,6 +407,21 @@ class Dikte:
|
|||||||
)
|
)
|
||||||
self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
|
self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
|
||||||
|
|
||||||
|
# A local model holds its memory whether or not anything is using it, so
|
||||||
|
# the menu says which of the two are loaded and offers to give it back.
|
||||||
|
# Hidden on a machine that runs neither: there is nothing to unload and
|
||||||
|
# nothing to report.
|
||||||
|
loaded = [server for server in (ggml.whisper, ggml.llm) if server.running]
|
||||||
|
self.unload_action.setVisible(
|
||||||
|
self.conf["transcribe_provider"] == "local" or self.conf.uses_local_llm()
|
||||||
|
)
|
||||||
|
self.unload_action.setText(
|
||||||
|
t("Unload the models") if len(loaded) > 1
|
||||||
|
else t("Unload the model") if loaded
|
||||||
|
else t("No model loaded")
|
||||||
|
)
|
||||||
|
self.unload_action.setEnabled(bool(loaded))
|
||||||
|
|
||||||
# The agent speaks through the icon only when dictation has nothing to
|
# The agent speaks through the icon only when dictation has nothing to
|
||||||
# say, since dictation is the one being waited on in front of a screen.
|
# say, since dictation is the one being waited on in front of a screen.
|
||||||
if self.state == IDLE and self.ask_state != IDLE:
|
if self.state == IDLE and self.ask_state != IDLE:
|
||||||
@@ -1209,6 +1233,18 @@ class Dikte:
|
|||||||
QDesktopServices.openUrl(
|
QDesktopServices.openUrl(
|
||||||
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
||||||
|
|
||||||
|
def unload_models(self):
|
||||||
|
"""Give the memory back now rather than when the idle window closes."""
|
||||||
|
held = [server for server in (ggml.whisper, ggml.llm)
|
||||||
|
if not server.unload()]
|
||||||
|
self._refresh_tray()
|
||||||
|
if held:
|
||||||
|
self.tray.showMessage(
|
||||||
|
"Dikte",
|
||||||
|
t("A model is loading or answering right now. Try again in a "
|
||||||
|
"moment."),
|
||||||
|
QSystemTrayIcon.MessageIcon.Information, 5000)
|
||||||
|
|
||||||
# ---- settings ---------------------------------------------------------
|
# ---- settings ---------------------------------------------------------
|
||||||
|
|
||||||
def open_settings(self):
|
def open_settings(self):
|
||||||
|
|||||||
+14
-7
@@ -115,13 +115,20 @@ def _local(text, conf, system_prompt, timeout, aborter=None):
|
|||||||
"""
|
"""
|
||||||
service = t("Local model")
|
service = t("Local model")
|
||||||
try:
|
try:
|
||||||
return api.cleanup(
|
# Held for the length of the request so that the idle unload does not
|
||||||
text, "", conf["local_llm_model"], system_prompt,
|
# take the model away from a block still being cleaned up.
|
||||||
reasoning=conf["local_llm_reasoning"],
|
with ggml.llm.busy():
|
||||||
base_url=api.serving(ggml.llm),
|
return api.cleanup(
|
||||||
timeout=max(timeout, api.LOCAL_TIMEOUT),
|
text, "", conf["local_llm_model"], system_prompt,
|
||||||
provider="local-llm", service=service, aborter=aborter,
|
reasoning=conf["local_llm_reasoning"],
|
||||||
)
|
base_url=api.serving(ggml.llm),
|
||||||
|
timeout=max(timeout, api.LOCAL_TIMEOUT),
|
||||||
|
provider="local-llm", service=service, aborter=aborter,
|
||||||
|
# The ceiling is only a ceiling while it sits under what the
|
||||||
|
# server was started with; above that the context is what stops
|
||||||
|
# the reply.
|
||||||
|
context=ggml.llm.settings()["context"],
|
||||||
|
)
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
# A server that died mid-request would otherwise report only that the
|
# A server that died mid-request would otherwise report only that the
|
||||||
# connection dropped, when the reason is in its own output.
|
# connection dropped, when the reason is in its own output.
|
||||||
|
|||||||
@@ -442,6 +442,15 @@ DEFAULTS = {
|
|||||||
# Off rather than empty: a model trained to think will, and 300 tokens of
|
# Off rather than empty: a model trained to think will, and 300 tokens of
|
||||||
# reasoning about a comma is 300 tokens of waiting.
|
# reasoning about a comma is 300 tokens of waiting.
|
||||||
"local_llm_reasoning": "none",
|
"local_llm_reasoning": "none",
|
||||||
|
|
||||||
|
# --- what happens to both of them when nothing is using them -------------
|
||||||
|
# One pair for the two servers rather than a pair each: what is being
|
||||||
|
# decided is whether a machine keeps gigabytes tied up between dictations,
|
||||||
|
# and nobody wants that answered one model at a time. On by default because
|
||||||
|
# a reload costs seconds and the memory costs the rest of the desktop.
|
||||||
|
"local_idle_unload": True,
|
||||||
|
"local_idle_minutes": 10,
|
||||||
|
|
||||||
"cleanup_prompt": "", # empty -> language-specific default
|
"cleanup_prompt": "", # empty -> language-specific default
|
||||||
"auto_paste": True,
|
"auto_paste": True,
|
||||||
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
||||||
@@ -710,6 +719,14 @@ class Config:
|
|||||||
binary=self["local_llm_binary"],
|
binary=self["local_llm_binary"],
|
||||||
context=int(self["local_llm_context"]),
|
context=int(self["local_llm_context"]),
|
||||||
)
|
)
|
||||||
|
ggml.whisper.set_idle(self.idle_seconds())
|
||||||
|
ggml.llm.set_idle(self.idle_seconds())
|
||||||
|
|
||||||
|
def idle_seconds(self):
|
||||||
|
"""How long a loaded model may sit unused. 0 means it is kept."""
|
||||||
|
if not self["local_idle_unload"]:
|
||||||
|
return 0
|
||||||
|
return max(1, int(self["local_idle_minutes"])) * 60
|
||||||
|
|
||||||
def uses_local_llm(self):
|
def uses_local_llm(self):
|
||||||
"""Whether anything is set to run the local cleanup model."""
|
"""Whether anything is set to run the local cleanup model."""
|
||||||
|
|||||||
+11
-2
@@ -283,11 +283,20 @@ def to_srt(text, segments):
|
|||||||
hours, minutes, secs = (int(g or 0) for g in match.groups())
|
hours, minutes, secs = (int(g or 0) for g in match.groups())
|
||||||
cues.append([hours * 3600 + minutes * 60 + secs, None, body])
|
cues.append([hours * 3600 + minutes * 60 + secs, None, body])
|
||||||
|
|
||||||
|
# Several cues can share a whole second, so a second holds every segment
|
||||||
|
# that began in it and they are handed out in the order they were spoken.
|
||||||
timing = {}
|
timing = {}
|
||||||
for start, end, _ in segments:
|
for start, end, _ in segments:
|
||||||
timing.setdefault(int(start), (start, end))
|
timing.setdefault(int(start), []).append((start, end))
|
||||||
for cue in cues:
|
for cue in cues:
|
||||||
cue[0], cue[1] = timing.get(cue[0], (float(cue[0]), 0.0))
|
found = timing.get(cue[0])
|
||||||
|
if found:
|
||||||
|
# The last one stays, so a second with more lines than it has
|
||||||
|
# timings hands the last of them out again rather than falling back
|
||||||
|
# to the bare second, which would run backwards from the line above.
|
||||||
|
cue[0], cue[1] = found.pop(0) if len(found) > 1 else found[0]
|
||||||
|
else:
|
||||||
|
cue[0], cue[1] = float(cue[0]), 0.0
|
||||||
for index, cue in enumerate(cues):
|
for index, cue in enumerate(cues):
|
||||||
following = cues[index + 1][0] if index + 1 < len(cues) else 0.0
|
following = cues[index + 1][0] if index + 1 < len(cues) else 0.0
|
||||||
if following > cue[0]:
|
if following > cue[0]:
|
||||||
|
|||||||
+128
-2
@@ -26,6 +26,7 @@ interface already knows how to show.
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import collections
|
import collections
|
||||||
|
import contextlib
|
||||||
import ctypes
|
import ctypes
|
||||||
import ctypes.util
|
import ctypes.util
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -68,6 +69,10 @@ STARTUP_TIMEOUT = 180.0
|
|||||||
# to load takes longer than this to be read in first. The line between "worth
|
# 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.
|
# another port" and "would fail the same way again" is drawn on time.
|
||||||
EARLY_EXIT_WINDOW = 5.0
|
EARLY_EXIT_WINDOW = 5.0
|
||||||
|
# How often the watcher looks at a model it has been asked to unload when idle.
|
||||||
|
# Short next to any window worth setting, so the memory goes back within seconds
|
||||||
|
# of the window closing rather than a minute after it.
|
||||||
|
IDLE_CHECK_SECONDS = 5.0
|
||||||
DOWNLOAD_CHUNK = 1 << 20
|
DOWNLOAD_CHUNK = 1 << 20
|
||||||
|
|
||||||
# `health` is the path that answers only once the model is in memory. whisper
|
# `health` is the path that answers only once the model is in memory. whisper
|
||||||
@@ -1116,6 +1121,15 @@ class Server:
|
|||||||
# The pid this instance last wrote to its pid file, so _forget never
|
# The pid this instance last wrote to its pid file, so _forget never
|
||||||
# removes a file some other Dikte wrote after us.
|
# removes a file some other Dikte wrote after us.
|
||||||
self._pid = 0
|
self._pid = 0
|
||||||
|
# The idle unload. `_idle` is the window in seconds, zero meaning the
|
||||||
|
# model stays loaded until something else stops it; `_used` is when the
|
||||||
|
# address was last handed out or a request last finished; `_busy` counts
|
||||||
|
# the requests still in flight. The count is there because a file being
|
||||||
|
# transcribed is one address lookup and then minutes of work, which to a
|
||||||
|
# clock started at the lookup looks exactly like a model nobody wants.
|
||||||
|
self._idle = 0.0
|
||||||
|
self._used = 0.0
|
||||||
|
self._busy = 0
|
||||||
|
|
||||||
# ---- settings --------------------------------------------------------
|
# ---- settings --------------------------------------------------------
|
||||||
|
|
||||||
@@ -1133,6 +1147,23 @@ class Server:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self._settings)
|
return dict(self._settings)
|
||||||
|
|
||||||
|
def set_idle(self, seconds):
|
||||||
|
"""How long a loaded model may sit unused before the memory goes back.
|
||||||
|
|
||||||
|
Deliberately not one of the settings above: those describe the server
|
||||||
|
that is running, and changing one has to restart it. This describes how
|
||||||
|
long to keep it, which the server it is applied to never needs to know.
|
||||||
|
Zero keeps the model until something else stops it.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._idle = max(0.0, float(seconds))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def idle(self):
|
||||||
|
"""The window `set_idle` was last given, in seconds."""
|
||||||
|
with self._lock:
|
||||||
|
return self._idle
|
||||||
|
|
||||||
def _settings_key(self):
|
def _settings_key(self):
|
||||||
"""What a running server would have to be restarted for."""
|
"""What a running server would have to be restarted for."""
|
||||||
return json.dumps(self._settings, sort_keys=True, default=str)
|
return json.dumps(self._settings, sort_keys=True, default=str)
|
||||||
@@ -1172,13 +1203,83 @@ class Server:
|
|||||||
proc, port, log = self._launch(settings)
|
proc, port, log = self._launch(settings)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._proc, self._port, self._log, self._key = proc, port, log, key
|
self._proc, self._port, self._log, self._key = proc, port, log, key
|
||||||
|
# Only the clock. The count is not this launch's to reset: a
|
||||||
|
# caller that took a hold and then asked for the address, which
|
||||||
|
# is what the local cleanup does, would have it wiped here and
|
||||||
|
# spend the whole request unprotected.
|
||||||
|
self._used = time.monotonic()
|
||||||
|
threading.Thread(target=self._watch, args=(proc,), daemon=True).start()
|
||||||
return self.base_url()
|
return self.base_url()
|
||||||
|
|
||||||
def _current_url(self):
|
def _current_url(self):
|
||||||
|
"""The address of a server running the current settings, or "".
|
||||||
|
|
||||||
|
Asking counts as using it. Everything that asks is about to send a
|
||||||
|
request, and the idle watcher reads the same clock, so the stamp has to
|
||||||
|
be set here rather than where the answer comes back.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
up = self._proc is not None and self._proc.poll() is None
|
up = self._proc is not None and self._proc.poll() is None
|
||||||
return (f"http://{HOST}:{self._port}/v1"
|
if not (up and self._key == self._settings_key()):
|
||||||
if up and self._key == self._settings_key() else "")
|
return ""
|
||||||
|
self._used = time.monotonic()
|
||||||
|
return f"http://{HOST}:{self._port}/v1"
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def busy(self):
|
||||||
|
"""Hold the model for the length of one request.
|
||||||
|
|
||||||
|
A dictation is over a second after the address was handed out, but a
|
||||||
|
file is minutes of it, and an hour of meeting is longer still. Without
|
||||||
|
the count the watcher would unload the model out from under the request
|
||||||
|
that started it.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._busy += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
# Nothing else moves the count, so every hold that was taken is
|
||||||
|
# given back here and it stays balanced across a restart. A hold
|
||||||
|
# outliving the server it was taken against only keeps the next
|
||||||
|
# one loaded a moment longer, which is the safe way round.
|
||||||
|
self._busy -= 1
|
||||||
|
self._used = time.monotonic()
|
||||||
|
|
||||||
|
def _idle_now(self, proc):
|
||||||
|
"""Whether `proc` is still ours and has been sitting unused long enough."""
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is not proc or not self._idle or self._busy:
|
||||||
|
return False
|
||||||
|
return time.monotonic() - self._used >= self._idle
|
||||||
|
|
||||||
|
def _watch(self, proc):
|
||||||
|
"""Give the memory back when nothing has asked anything for a while.
|
||||||
|
|
||||||
|
One thread per launch, holding the process it was started for, so that a
|
||||||
|
server stopped and started again is watched by the new thread alone and
|
||||||
|
this one leaves on the first pass that finds its own process gone.
|
||||||
|
|
||||||
|
Started whatever the window is, zero included: turning the unload on in
|
||||||
|
Settings has to reach a model that is already loaded, and a thread that
|
||||||
|
wakes every few seconds to read one number is cheaper than the machinery
|
||||||
|
for starting one later.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
time.sleep(IDLE_CHECK_SECONDS)
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is not proc:
|
||||||
|
return # stopped, or replaced by a later launch
|
||||||
|
if not self._idle_now(proc):
|
||||||
|
continue
|
||||||
|
with self._starting:
|
||||||
|
# Asked once more under the lock a start has to take. An address
|
||||||
|
# handed out while this thread waited its turn stamps _used, and
|
||||||
|
# the request behind it must not arrive at a server killed here.
|
||||||
|
if self._idle_now(proc):
|
||||||
|
self._stop_now()
|
||||||
|
return
|
||||||
|
|
||||||
def _launch(self, settings):
|
def _launch(self, settings):
|
||||||
args = self._build(settings) # raises LocalError when unusable
|
args = self._build(settings) # raises LocalError when unusable
|
||||||
@@ -1285,6 +1386,31 @@ class Server:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def unload(self):
|
||||||
|
"""Stop the server unless it is in the middle of something.
|
||||||
|
|
||||||
|
The same rule the idle watcher goes by, taken by hand from the menu, and
|
||||||
|
it says no for the same reason: the memory is worth having back, but not
|
||||||
|
at the price of the dictation waiting on it. A model still being read in
|
||||||
|
counts as in the middle of something too, and that is why the lock is
|
||||||
|
asked for rather than waited on: this runs on the interface's own
|
||||||
|
thread, and a start holds _starting for as long as the load takes, which
|
||||||
|
for a large model on a cold cache is most of a minute. True when nothing
|
||||||
|
is loaded any more, either way.
|
||||||
|
"""
|
||||||
|
if not self._starting.acquire(blocking=False):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is None:
|
||||||
|
return True
|
||||||
|
if self._busy:
|
||||||
|
return False
|
||||||
|
self._stop_now()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
self._starting.release()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
# Taking _starting means a stop cannot slide past a launch in flight:
|
# Taking _starting means a stop cannot slide past a launch in flight:
|
||||||
# serve() finishes registering its child first, and the child is then
|
# serve() finishes registering its child first, and the child is then
|
||||||
|
|||||||
@@ -782,6 +782,26 @@ TR = {
|
|||||||
"On this machine": "Bu makinede",
|
"On this machine": "Bu makinede",
|
||||||
"Use the graphics card": "Ekran kartını kullan",
|
"Use the graphics card": "Ekran kartını kullan",
|
||||||
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
||||||
|
"Models on this machine": "Bu makinedeki modeller",
|
||||||
|
"Unload a model that is sitting unused": "Kullanılmayan modeli bellekten çıkar",
|
||||||
|
"A loaded model holds its memory whether anything is using it or "
|
||||||
|
"not: over a gigabyte for whisper, several for an LLM. Unloading "
|
||||||
|
"gives that back to the rest of the desktop, and the next "
|
||||||
|
"dictation loads it again at the cost of the seconds that takes.":
|
||||||
|
"Yüklü bir model, kullanılsa da kullanılmasa da belleği tutar: whisper "
|
||||||
|
"için bir gigabaytın üzerinde, bir LLM için birkaç gigabayt. Bellekten "
|
||||||
|
"çıkarmak bunu masaüstünün geri kalanına iade eder, sonraki dikte de "
|
||||||
|
"modeli birkaç saniye bekleyerek yeniden yükler.",
|
||||||
|
" minute": " dakika",
|
||||||
|
" minutes": " dakika",
|
||||||
|
"After": "Şu kadar sonra",
|
||||||
|
"Unload the model": "Modeli bellekten çıkar",
|
||||||
|
"Unload the models": "Modelleri bellekten çıkar",
|
||||||
|
"No model loaded": "Yüklü model yok",
|
||||||
|
"A model is loading or answering right now. Try again in a "
|
||||||
|
"moment.":
|
||||||
|
"Bir model şu anda yükleniyor ya da cevap veriyor. Az sonra tekrar "
|
||||||
|
"deneyin.",
|
||||||
"Local whisper": "Yerel whisper",
|
"Local whisper": "Yerel whisper",
|
||||||
"Local model": "Yerel model",
|
"Local model": "Yerel model",
|
||||||
"Not installed.": "Kurulu değil.",
|
"Not installed.": "Kurulu değil.",
|
||||||
@@ -959,6 +979,10 @@ TR = {
|
|||||||
"“Off”.":
|
"“Off”.":
|
||||||
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
|
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
|
||||||
"“Kapalı” yap.",
|
"“Kapalı” yap.",
|
||||||
|
"The cleanup model was cut off before it finished.":
|
||||||
|
"Temizleme modeli bitiremeden kesildi.",
|
||||||
|
"The model was cut off before it finished.":
|
||||||
|
"Model bitiremeden kesildi.",
|
||||||
|
|
||||||
# --- this pass's new messages ---------------------------------------
|
# --- this pass's new messages ---------------------------------------
|
||||||
"Audio recorder stopped before receiving sound":
|
"Audio recorder stopped before receiving sound":
|
||||||
|
|||||||
+66
-5
@@ -181,18 +181,34 @@ class WrappedLabel(QLabel):
|
|||||||
super().setText(text)
|
super().setText(text)
|
||||||
self._fit()
|
self._fit()
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
# Text set while the window was still being built was measured against
|
||||||
|
# nothing; this is the first moment the width means anything.
|
||||||
|
super().showEvent(event)
|
||||||
|
self._fit()
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
self._fit()
|
self._fit()
|
||||||
|
|
||||||
def _fit(self):
|
def _fit(self):
|
||||||
|
# A label the layout has not placed yet is a handful of pixels wide,
|
||||||
|
# and wrapping a sentence against that width invents a hundred lines.
|
||||||
|
# The minimum set from it does not stay a minimum either: QLabel folds
|
||||||
|
# it into its own cached size hints and clears that cache only when the
|
||||||
|
# text changes, so the row stands thousands of pixels tall and carries
|
||||||
|
# the model box and everything under it off the bottom of the window
|
||||||
|
# until another publisher is picked. Nothing to measure against yet
|
||||||
|
# means nothing to claim yet, and the show and resize above come back
|
||||||
|
# for it.
|
||||||
|
if not self.isVisible() or self.width() <= 0:
|
||||||
|
return
|
||||||
# Measured off the font rather than asked of the label, whose own answer
|
# Measured off the font rather than asked of the label, whose own answer
|
||||||
# is floored by the minimum set here a moment ago and so only ever grows.
|
# is floored by the minimum set here a moment ago and so only ever grows.
|
||||||
if self.width() > 0:
|
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
||||||
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
box = QRect(0, 0, self.width(), 0)
|
||||||
box = QRect(0, 0, self.width(), 0)
|
self.setMinimumHeight(
|
||||||
self.setMinimumHeight(
|
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
|
||||||
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
|
|
||||||
|
|
||||||
|
|
||||||
class WheelGuard(QObject):
|
class WheelGuard(QObject):
|
||||||
@@ -1309,9 +1325,47 @@ class SettingsWindow(QDialog):
|
|||||||
orr_form.addRow(self.local_llm_options)
|
orr_form.addRow(self.local_llm_options)
|
||||||
|
|
||||||
outer.addWidget(orr)
|
outer.addWidget(orr)
|
||||||
|
|
||||||
|
# One box for both servers rather than a row inside each: what is being
|
||||||
|
# decided is whether this machine keeps gigabytes tied up between
|
||||||
|
# dictations, and that is not a question anybody wants to answer once
|
||||||
|
# per model.
|
||||||
|
self.local_box = QGroupBox(t("Models on this machine"))
|
||||||
|
local_form = QFormLayout(self.local_box)
|
||||||
|
self.local_idle_unload = QCheckBox(t("Unload a model that is sitting unused"))
|
||||||
|
self.local_idle_unload.setToolTip(
|
||||||
|
t("A loaded model holds its memory whether anything is using it or "
|
||||||
|
"not: over a gigabyte for whisper, several for an LLM. Unloading "
|
||||||
|
"gives that back to the rest of the desktop, and the next "
|
||||||
|
"dictation loads it again at the cost of the seconds that takes."))
|
||||||
|
self.local_idle_minutes = QSpinBox()
|
||||||
|
self.local_idle_minutes.setRange(1, 720)
|
||||||
|
self.local_idle_minutes.valueChanged.connect(self._idle_suffix)
|
||||||
|
self._idle_suffix(self.local_idle_minutes.value())
|
||||||
|
self.local_idle_unload.toggled.connect(self.local_idle_minutes.setEnabled)
|
||||||
|
local_form.addRow("", self.local_idle_unload)
|
||||||
|
local_form.addRow(t("After"), self.local_idle_minutes)
|
||||||
|
outer.addWidget(self.local_box)
|
||||||
|
|
||||||
outer.addStretch(1)
|
outer.addStretch(1)
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
def _idle_suffix(self, minutes):
|
||||||
|
"""The spin box's own noun, since its lowest value is one of them.
|
||||||
|
|
||||||
|
Turkish is handed both and translates them the same: a number there is
|
||||||
|
followed by the singular however many it counts.
|
||||||
|
"""
|
||||||
|
self.local_idle_minutes.setSuffix(
|
||||||
|
t(" minute") if minutes == 1 else t(" minutes"))
|
||||||
|
|
||||||
|
def _refresh_local_box(self):
|
||||||
|
"""The idle unload is only on screen when something here runs locally."""
|
||||||
|
self.local_box.setVisible(
|
||||||
|
(self.transcribe_provider.currentData() or "local") == "local"
|
||||||
|
or (self.cleanup_provider.currentData() or "openrouter") == "local"
|
||||||
|
)
|
||||||
|
|
||||||
def _prompt_tab(self):
|
def _prompt_tab(self):
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
layout = QVBoxLayout(page)
|
layout = QVBoxLayout(page)
|
||||||
@@ -2078,6 +2132,9 @@ class SettingsWindow(QDialog):
|
|||||||
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
||||||
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
||||||
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
||||||
|
self.local_idle_unload.setChecked(conf["local_idle_unload"])
|
||||||
|
self.local_idle_minutes.setValue(int(conf["local_idle_minutes"]))
|
||||||
|
self.local_idle_minutes.setEnabled(conf["local_idle_unload"])
|
||||||
# The defaults as they read NOW, kept for the save comparison: after a
|
# The defaults as they read NOW, kept for the save comparison: after a
|
||||||
# language switch the boxes still hold the old language's default, and
|
# language switch the boxes still hold the old language's default, and
|
||||||
# comparing against the new one would store that text as a custom
|
# comparing against the new one would store that text as a custom
|
||||||
@@ -2209,6 +2266,8 @@ class SettingsWindow(QDialog):
|
|||||||
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
||||||
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
||||||
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
||||||
|
conf["local_idle_unload"] = self.local_idle_unload.isChecked()
|
||||||
|
conf["local_idle_minutes"] = self.local_idle_minutes.value()
|
||||||
|
|
||||||
# Store an empty prompt when it matches a default: the one it was
|
# Store an empty prompt when it matches a default: the one it was
|
||||||
# loaded with, or today's (a Reset click in a session that switched
|
# loaded with, or today's (a Reset click in a session that switched
|
||||||
@@ -2351,6 +2410,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.stt_form.setRowVisible(self.transcribe_status, not local)
|
self.stt_form.setRowVisible(self.transcribe_status, not local)
|
||||||
self.stt_form.setRowVisible(self.local_whisper, local)
|
self.stt_form.setRowVisible(self.local_whisper, local)
|
||||||
self.stt_form.setRowVisible(self.local_options, local)
|
self.stt_form.setRowVisible(self.local_options, local)
|
||||||
|
self._refresh_local_box()
|
||||||
if local:
|
if local:
|
||||||
return
|
return
|
||||||
self.transcribe_model.clear()
|
self.transcribe_model.clear()
|
||||||
@@ -2859,6 +2919,7 @@ class SettingsWindow(QDialog):
|
|||||||
provider != "local")
|
provider != "local")
|
||||||
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
||||||
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
||||||
|
self._refresh_local_box()
|
||||||
binary = cleanup.executable(provider)
|
binary = cleanup.executable(provider)
|
||||||
found = shutil.which(binary) if binary else ""
|
found = shutil.which(binary) if binary else ""
|
||||||
if provider == "local":
|
if provider == "local":
|
||||||
|
|||||||
+121
-2
@@ -9,6 +9,7 @@ is blocked on, and a faked urlopen has no socket to cut, so those tests talk to
|
|||||||
a server of their own on the loopback interface.
|
a server of their own on the loopback interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -321,7 +322,12 @@ class TranscribeSegments(DikteTest):
|
|||||||
fields = multipart_fields(calls[0])
|
fields = multipart_fields(calls[0])
|
||||||
self.assertEqual(fields["model"], "whisper-1")
|
self.assertEqual(fields["model"], "whisper-1")
|
||||||
self.assertEqual(fields["response_format"], "verbose_json")
|
self.assertEqual(fields["response_format"], "verbose_json")
|
||||||
self.assertEqual(fields["timestamp_granularities[]"], "segment")
|
# Both are asked for: whisper answers with segments, and a model that
|
||||||
|
# does not mark them still answers with word times.
|
||||||
|
body = calls[0].data.decode("utf-8", "replace")
|
||||||
|
for level in ("segment", "word"):
|
||||||
|
self.assertIn(
|
||||||
|
f'name="timestamp_granularities[]"\r\n\r\n{level}\r\n', body)
|
||||||
|
|
||||||
def test_openrouter_uses_the_namespaced_id(self):
|
def test_openrouter_uses_the_namespaced_id(self):
|
||||||
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||||
@@ -362,6 +368,74 @@ class TranscribeSegments(DikteTest):
|
|||||||
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||||
[(5.0, 5.0, "hi")])
|
[(5.0, 5.0, "hi")])
|
||||||
|
|
||||||
|
def test_a_long_sentence_is_broken_where_it_gets_too_long_to_read(self):
|
||||||
|
words = [{"word": "word", "start": i * 0.2, "end": i * 0.2 + 0.2}
|
||||||
|
for i in range(60)]
|
||||||
|
cues = api.cues_from_words(words)
|
||||||
|
self.assertGreater(len(cues), 1)
|
||||||
|
for start, end, text in cues:
|
||||||
|
self.assertLessEqual(len(text), api.MAX_CUE_CHARS)
|
||||||
|
self.assertLessEqual(end - start, api.MAX_CUE_SECONDS + 0.2)
|
||||||
|
|
||||||
|
def test_a_pause_between_short_sentences_does_not_join_them(self):
|
||||||
|
cues = api.cues_from_words([
|
||||||
|
{"word": "Yes.", "start": 0.0, "end": 0.3},
|
||||||
|
{"word": "No.", "start": 9.0, "end": 9.3},
|
||||||
|
])
|
||||||
|
self.assertEqual([(start, text) for start, _, text in cues],
|
||||||
|
[(0.0, "Yes."), (9.0, "No.")])
|
||||||
|
|
||||||
|
def test_a_cue_too_short_to_read_is_held_until_the_next_one(self):
|
||||||
|
cues = api.cues_from_words([
|
||||||
|
{"word": "Yes.", "start": 0.0, "end": 0.3},
|
||||||
|
{"word": "No.", "start": 9.0, "end": 9.3},
|
||||||
|
])
|
||||||
|
# The first has the room for it, the last has nothing after it to wait for.
|
||||||
|
self.assertEqual(cues[0][1], api.MIN_CUE_SECONDS)
|
||||||
|
self.assertEqual(cues[1][1], 9.0 + api.MIN_CUE_SECONDS)
|
||||||
|
|
||||||
|
def test_a_list_marker_does_not_end_a_cue_on_its_own(self):
|
||||||
|
cues = api.cues_from_words([
|
||||||
|
{"word": "1.", "start": 0.0, "end": 0.2},
|
||||||
|
{"word": "Antivirus.", "start": 0.4, "end": 1.6},
|
||||||
|
])
|
||||||
|
self.assertEqual([text for _, _, text in cues], ["1. Antivirus."])
|
||||||
|
|
||||||
|
def test_a_sentence_ending_inside_a_quote_still_ends_the_cue(self):
|
||||||
|
cues = api.cues_from_words([
|
||||||
|
{"word": '"Stop', "start": 0.0, "end": 1.0},
|
||||||
|
{"word": 'there."', "start": 1.1, "end": 2.0},
|
||||||
|
{"word": "Then", "start": 2.2, "end": 2.6},
|
||||||
|
])
|
||||||
|
self.assertEqual([text for _, _, text in cues],
|
||||||
|
['"Stop there."', "Then"])
|
||||||
|
|
||||||
|
def test_word_times_take_over_from_segments_too_long_to_read(self):
|
||||||
|
# What a model that does not mark segments answers with: one entry for
|
||||||
|
# the whole file, and the real timing in the words beside it.
|
||||||
|
reply = {
|
||||||
|
"text": "One. Two.",
|
||||||
|
"segments": [{"start": 0, "end": 60, "text": "One. Two."}],
|
||||||
|
"words": [
|
||||||
|
{"word": "One.", "start": 0.1, "end": 1.5},
|
||||||
|
{"word": "Two.", "start": 1.7, "end": 3.0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with fake_urlopen(reply):
|
||||||
|
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||||
|
[(0.1, 1.5, "One."), (1.7, 3.0, "Two.")])
|
||||||
|
|
||||||
|
def test_whisper_segments_are_left_alone_when_words_come_too(self):
|
||||||
|
reply = {
|
||||||
|
"text": "hi there",
|
||||||
|
"segments": [{"start": 0, "end": 2, "text": "hi there"}],
|
||||||
|
"words": [{"word": "hi", "start": 0.0, "end": 0.5},
|
||||||
|
{"word": "there", "start": 0.5, "end": 2.0}],
|
||||||
|
}
|
||||||
|
with fake_urlopen(reply):
|
||||||
|
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||||
|
[(0.0, 2.0, "hi there")])
|
||||||
|
|
||||||
def test_a_model_that_returned_no_segments_still_gives_its_text(self):
|
def test_a_model_that_returned_no_segments_still_gives_its_text(self):
|
||||||
with fake_urlopen(self.reply([], text="the whole thing")):
|
with fake_urlopen(self.reply([], text="the whole thing")):
|
||||||
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||||
@@ -463,6 +537,29 @@ class Cleanup(DikteTest):
|
|||||||
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||||
api.cleanup("hello", "k", "m", "p")
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
|
||||||
|
def test_a_reply_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
|
||||||
|
# Half a sentence looks like a cleaned-up transcript and is not one. The
|
||||||
|
# caller keeps what it was given, which is the whole dictation.
|
||||||
|
reply = {"choices": [{"message": {"content": "Hello, and then the"},
|
||||||
|
"finish_reason": "length"}]}
|
||||||
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
self.assertIn("cut off", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_reply_that_stopped_on_its_own_is_kept(self):
|
||||||
|
reply = {"choices": [{"message": {"content": "Hello."},
|
||||||
|
"finish_reason": "stop"}]}
|
||||||
|
with fake_urlopen(reply):
|
||||||
|
self.assertEqual(api.cleanup("hello", "k", "m", "p"), "Hello.")
|
||||||
|
|
||||||
|
def test_all_thinking_is_named_before_the_ceiling_it_was_cut_at(self):
|
||||||
|
"""Both are true at once, and only one of them says what to change."""
|
||||||
|
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"},
|
||||||
|
"finish_reason": "length"}]}
|
||||||
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
self.assertIn("Thinking", str(caught.exception))
|
||||||
|
|
||||||
def test_a_rate_limit_is_explained(self):
|
def test_a_rate_limit_is_explained(self):
|
||||||
with fake_urlopen(http_error(429)), \
|
with fake_urlopen(http_error(429)), \
|
||||||
self.assertRaises(api.ApiError) as caught:
|
self.assertRaises(api.ApiError) as caught:
|
||||||
@@ -471,6 +568,14 @@ class Cleanup(DikteTest):
|
|||||||
|
|
||||||
|
|
||||||
class Chat(DikteTest):
|
class Chat(DikteTest):
|
||||||
|
def test_an_answer_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
|
||||||
|
# Half an answer reads like a whole one once it is on the screen.
|
||||||
|
reply = {"choices": [{"message": {"content": "Booked it for the"},
|
||||||
|
"finish_reason": "length"}]}
|
||||||
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.chat([{"role": "user", "content": "book it"}], "k", "m", "p")
|
||||||
|
self.assertIn("cut off", str(caught.exception))
|
||||||
|
|
||||||
def test_the_history_is_sent_after_the_system_prompt(self):
|
def test_the_history_is_sent_after_the_system_prompt(self):
|
||||||
history = [{"role": "user", "content": "book it"},
|
history = [{"role": "user", "content": "book it"},
|
||||||
{"role": "assistant", "content": "done"}]
|
{"role": "assistant", "content": "done"}]
|
||||||
@@ -616,11 +721,14 @@ if __name__ == "__main__":
|
|||||||
class FakeServer:
|
class FakeServer:
|
||||||
"""A ggml.Server as far as api.py is concerned."""
|
"""A ggml.Server as far as api.py is concerned."""
|
||||||
|
|
||||||
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""):
|
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log="",
|
||||||
|
context=8192):
|
||||||
self.url = url
|
self.url = url
|
||||||
self.fails = fails
|
self.fails = fails
|
||||||
self.log = log
|
self.log = log
|
||||||
self.starts = 0
|
self.starts = 0
|
||||||
|
self.held = 0
|
||||||
|
self.context = context
|
||||||
|
|
||||||
def serve(self):
|
def serve(self):
|
||||||
self.starts += 1
|
self.starts += 1
|
||||||
@@ -628,9 +736,20 @@ class FakeServer:
|
|||||||
raise ggml.LocalError(self.fails)
|
raise ggml.LocalError(self.fails)
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def busy(self):
|
||||||
|
self.held += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.held -= 1
|
||||||
|
|
||||||
def error(self):
|
def error(self):
|
||||||
return self.log
|
return self.log
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return {"context": self.context}
|
||||||
|
|
||||||
|
|
||||||
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
||||||
|
|
||||||
|
|||||||
@@ -411,6 +411,52 @@ class Here(DikteTest):
|
|||||||
cleanup.run("uh, done", self.conf, "the rules")
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
||||||
|
|
||||||
|
def test_thinking_is_given_room_of_its_own_rather_than_the_answer_s(self):
|
||||||
|
# llama.cpp counts the thinking towards the same ceiling, so a rung that
|
||||||
|
# took its budget out of the answer would leave a short dictation with
|
||||||
|
# nothing to reply with. On a context roomy enough that the clamp the
|
||||||
|
# top rung would otherwise meet is not what is being measured.
|
||||||
|
self.patch_attr(ggml, "llm", FakeServer(context=32768))
|
||||||
|
for rung, room in api.THINKING_ROOM.items():
|
||||||
|
with self.subTest(rung=rung):
|
||||||
|
self.conf["local_llm_reasoning"] = rung
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"], 512 + room)
|
||||||
|
|
||||||
|
def test_each_rung_of_the_ladder_thinks_longer_than_the_one_below(self):
|
||||||
|
rungs = [api.THINKING_ROOM[name] for name in
|
||||||
|
("minimal", "low", "medium", "high", "xhigh", "max")]
|
||||||
|
self.assertEqual(rungs, sorted(rungs))
|
||||||
|
self.assertEqual(len(set(rungs)), len(rungs))
|
||||||
|
|
||||||
|
def test_the_models_own_default_is_given_room_to_think_in_too(self):
|
||||||
|
# Nothing is sent, so a template that thinks will think, and the ceiling
|
||||||
|
# has to survive that as well.
|
||||||
|
self.conf["local_llm_reasoning"] = ""
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"],
|
||||||
|
512 + api.DEFAULT_THINKING_ROOM)
|
||||||
|
|
||||||
|
def test_the_ceiling_stays_under_the_context_the_server_was_started_with(self):
|
||||||
|
# Above the context there is no ceiling at all: the runaway would run to
|
||||||
|
# the end of the context instead of stopping where this says.
|
||||||
|
self.patch_attr(ggml, "llm", FakeServer(context=2048))
|
||||||
|
self.conf["local_llm_reasoning"] = "max"
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
|
self.assertLess(sent_json(calls[0])["max_tokens"], 2048)
|
||||||
|
|
||||||
|
def test_the_prompt_keeps_its_share_of_a_small_context(self):
|
||||||
|
self.patch_attr(ggml, "llm", FakeServer(context=2048))
|
||||||
|
self.conf["local_llm_reasoning"] = "max"
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("x" * 2000, self.conf, "the rules")
|
||||||
|
# 2048 less half the characters of prompt and transcript together.
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"],
|
||||||
|
2048 - (len("the rules") + 2000) // 2)
|
||||||
|
|
||||||
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
||||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
|||||||
@@ -689,3 +689,24 @@ class ReadyToRun(DikteTest):
|
|||||||
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
||||||
self.assertFalse(ggml.whisper.settings()["gpu"])
|
self.assertFalse(ggml.whisper.settings()["gpu"])
|
||||||
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
||||||
|
|
||||||
|
def test_the_idle_window_is_in_seconds(self):
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=15)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 900)
|
||||||
|
|
||||||
|
def test_an_unchecked_box_keeps_the_model(self):
|
||||||
|
conf = self.config(local_idle_unload=False, local_idle_minutes=15)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 0)
|
||||||
|
|
||||||
|
def test_a_window_of_no_minutes_is_still_a_window(self):
|
||||||
|
"""The spin box will not go below one; a config edited by hand can."""
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=0)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 60)
|
||||||
|
|
||||||
|
def test_both_servers_are_told_the_window(self):
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=3)
|
||||||
|
self.addCleanup(ggml.llm.set_idle, 0)
|
||||||
|
self.addCleanup(ggml.whisper.set_idle, 0)
|
||||||
|
conf.apply_local()
|
||||||
|
self.assertEqual(ggml.whisper.idle, 180)
|
||||||
|
self.assertEqual(ggml.llm.idle, 180)
|
||||||
|
|||||||
+121
-1
@@ -783,7 +783,9 @@ STAND_IN = textwrap.dedent("""
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
class Servers(Local):
|
class ServerCase(Local):
|
||||||
|
"""The stand-in server and the fixture around it, with no tests of its own."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.path("data").mkdir(parents=True, exist_ok=True)
|
self.path("data").mkdir(parents=True, exist_ok=True)
|
||||||
@@ -806,6 +808,8 @@ class Servers(Local):
|
|||||||
self.addCleanup(made.stop)
|
self.addCleanup(made.stop)
|
||||||
return made
|
return made
|
||||||
|
|
||||||
|
|
||||||
|
class Servers(ServerCase):
|
||||||
def test_a_started_server_hands_back_its_address(self):
|
def test_a_started_server_hands_back_its_address(self):
|
||||||
server = self.server()
|
server = self.server()
|
||||||
url = server.serve()
|
url = server.serve()
|
||||||
@@ -1037,6 +1041,122 @@ class Servers(Local):
|
|||||||
self.assertFalse(server.sweep()) # and the pid file went with it
|
self.assertFalse(server.sweep()) # and the pid file went with it
|
||||||
|
|
||||||
|
|
||||||
|
class IdleUnload(ServerCase):
|
||||||
|
"""Giving the memory back when nothing has asked anything for a while."""
|
||||||
|
|
||||||
|
IDLE = 0.3
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# The real check runs every five seconds against a window of minutes.
|
||||||
|
# Both are scaled down here; what is being tested is the decision, and
|
||||||
|
# nothing in it reads the clock in units of its own.
|
||||||
|
self.patch_attr(ggml, "IDLE_CHECK_SECONDS", 0.05)
|
||||||
|
|
||||||
|
def idle_server(self, seconds=None, **settings):
|
||||||
|
server = self.server(**settings)
|
||||||
|
server.set_idle(self.IDLE if seconds is None else seconds)
|
||||||
|
return server
|
||||||
|
|
||||||
|
def wait_for(self, predicate, timeout=5.0):
|
||||||
|
"""True as soon as `predicate` holds, False once the wait runs out."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.02)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_a_model_nobody_is_using_is_unloaded(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_the_default_is_to_keep_it(self):
|
||||||
|
"""A server nobody set a window on stays until something stops it."""
|
||||||
|
server = self.server()
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_a_window_of_zero_keeps_it_too(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_a_request_in_flight_holds_the_model(self):
|
||||||
|
"""A file is one address lookup and then minutes of work: the clock
|
||||||
|
alone would call that idle and unload it mid-transcription."""
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
with server.busy():
|
||||||
|
self.assertFalse(
|
||||||
|
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_asking_for_the_address_puts_the_window_back(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
first = server.serve()
|
||||||
|
for _ in range(4):
|
||||||
|
time.sleep(self.IDLE / 2)
|
||||||
|
self.assertEqual(server.serve(), first) # never restarted
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
|
||||||
|
def test_the_next_request_loads_it_again(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
first = server.serve()
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
second = server.serve()
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
self.assertNotEqual(second, first) # a new process, a new port
|
||||||
|
|
||||||
|
def test_the_watcher_of_a_stopped_server_does_not_touch_the_next_one(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
server.stop()
|
||||||
|
server.set_idle(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_unloading_by_hand_does_not_wait_for_the_window(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertTrue(server.unload())
|
||||||
|
self.assertFalse(server.running)
|
||||||
|
|
||||||
|
def test_a_hold_taken_before_the_start_survives_it(self):
|
||||||
|
"""The local cleanup takes the hold and only then asks for the address,
|
||||||
|
so the start it triggers must not be what drops the hold."""
|
||||||
|
server = self.idle_server()
|
||||||
|
with server.busy():
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(
|
||||||
|
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_unloading_is_refused_while_the_model_is_still_loading(self):
|
||||||
|
"""It runs on the interface's thread, and a start holds its lock for as
|
||||||
|
long as the load takes: waiting there would freeze the whole window."""
|
||||||
|
server = self.idle_server(0, extra=["--wait", "0.6"])
|
||||||
|
thread = threading.Thread(target=server.serve)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
began = time.monotonic()
|
||||||
|
self.assertFalse(server.unload())
|
||||||
|
self.assertLess(time.monotonic() - began, 0.2)
|
||||||
|
finally:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
def test_unloading_is_refused_while_a_request_is_in_flight(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
with server.busy():
|
||||||
|
self.assertFalse(server.unload())
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
|
||||||
|
def test_unloading_nothing_is_not_a_refusal(self):
|
||||||
|
self.assertTrue(self.server().unload())
|
||||||
|
|
||||||
|
|
||||||
class Arguments(Local):
|
class Arguments(Local):
|
||||||
"""What the two command lines say, since neither program is here to say it."""
|
"""What the two command lines say, since neither program is here to say it."""
|
||||||
|
|
||||||
|
|||||||
+56
-1
@@ -14,7 +14,7 @@ import unittest
|
|||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from PyQt6.QtCore import QPoint, QPointF, Qt
|
from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
|
||||||
from PyQt6.QtGui import QWheelEvent
|
from PyQt6.QtGui import QWheelEvent
|
||||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
@@ -83,6 +83,8 @@ CHANGED = {
|
|||||||
"local_llm_gpu": False,
|
"local_llm_gpu": False,
|
||||||
"local_llm_preload": True,
|
"local_llm_preload": True,
|
||||||
"local_llm_reasoning": "low",
|
"local_llm_reasoning": "low",
|
||||||
|
"local_idle_unload": False,
|
||||||
|
"local_idle_minutes": 45,
|
||||||
"cleanup_prompt": "Only fix the punctuation.",
|
"cleanup_prompt": "Only fix the punctuation.",
|
||||||
"file_cleanup_prompt": "Keep the stamps where they are.",
|
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||||||
"transcribe_prompt": "Paraşüt, OpenFrame",
|
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||||||
@@ -244,6 +246,39 @@ class Settings(DikteTest):
|
|||||||
label.resize(2000, line)
|
label.resize(2000, line)
|
||||||
self.assertLessEqual(label.minimumHeight(), line)
|
self.assertLessEqual(label.minimumHeight(), line)
|
||||||
|
|
||||||
|
def test_a_label_written_before_the_layout_places_it_claims_nothing(self):
|
||||||
|
# The publisher note is written while the settings window is still
|
||||||
|
# being built, when the label is a handful of pixels wide. Wrapped
|
||||||
|
# against that width the sentence became a hundred lines, and the
|
||||||
|
# minimum taken from it did not stay a minimum: QLabel folds it into
|
||||||
|
# its own cached size hints and clears that cache only when the text
|
||||||
|
# changes. The group box stood thousands of pixels tall, with the
|
||||||
|
# model box and everything under it off the bottom of the window,
|
||||||
|
# until another publisher was picked.
|
||||||
|
label = settings_ui.WrappedLabel()
|
||||||
|
self.addCleanup(label.deleteLater)
|
||||||
|
line = label.fontMetrics().height()
|
||||||
|
label.resize(8, line)
|
||||||
|
label.setText("Google Gemma 4, the small one. The default: nothing "
|
||||||
|
"else this size follows an instruction as closely, and "
|
||||||
|
"cleanup is all instruction.")
|
||||||
|
self.assertEqual(label.minimumHeight(), 0)
|
||||||
|
# Placed and shown, which is the first width worth measuring against.
|
||||||
|
# The room the wrapping needs is claimed then, and it is the lines the
|
||||||
|
# sentence takes at this width rather than at the last one. Counted
|
||||||
|
# off the font rather than written down here, because how many lines
|
||||||
|
# 400 pixels hold is a different answer on every machine.
|
||||||
|
label.resize(400, line)
|
||||||
|
label.show()
|
||||||
|
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
||||||
|
needed = label.fontMetrics().boundingRect(
|
||||||
|
QRect(0, 0, 400, 0), wrap, label.text()).height()
|
||||||
|
self.assertGreater(needed, line) # or the sentence never wrapped
|
||||||
|
self.assertEqual(label.minimumHeight(), needed)
|
||||||
|
# And the label's own hints are the wrapping at this width too, not
|
||||||
|
# the hundred lines the eight pixel one asked for.
|
||||||
|
self.assertLessEqual(label.sizeHint().height(), 3 * needed)
|
||||||
|
|
||||||
def test_saving_without_touching_anything_changes_nothing(self):
|
def test_saving_without_touching_anything_changes_nothing(self):
|
||||||
"""Every widget has to load what is stored, or Save writes its default
|
"""Every widget has to load what is stored, or Save writes its default
|
||||||
over it. This says so for the whole table at once."""
|
over it. This says so for the whole table at once."""
|
||||||
@@ -1701,6 +1736,26 @@ class LocalModels(DikteTest):
|
|||||||
# Its own thinking box, because the two default to opposite things.
|
# Its own thinking box, because the two default to opposite things.
|
||||||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
||||||
|
|
||||||
|
def test_the_idle_unload_is_offered_to_whoever_runs_a_model_here(self):
|
||||||
|
for transcriber, cleaner in (("local", "openrouter"),
|
||||||
|
("openai", "local"),
|
||||||
|
("local", "local")):
|
||||||
|
with self.subTest(transcriber=transcriber, cleaner=cleaner):
|
||||||
|
window = self.window(self.config(transcribe_provider=transcriber,
|
||||||
|
cleanup_provider=cleaner))
|
||||||
|
self.assertTrue(window.local_box.isVisibleTo(window))
|
||||||
|
|
||||||
|
def test_a_machine_that_runs_neither_is_not_asked_about_memory(self):
|
||||||
|
window = self.window(self.config(transcribe_provider="openai",
|
||||||
|
cleanup_provider="openrouter"))
|
||||||
|
self.assertFalse(window.local_box.isVisibleTo(window))
|
||||||
|
|
||||||
|
def test_the_minutes_follow_the_checkbox(self):
|
||||||
|
window = self.window(self.config(local_idle_unload=False))
|
||||||
|
self.assertFalse(window.local_idle_minutes.isEnabled())
|
||||||
|
window.local_idle_unload.setChecked(True)
|
||||||
|
self.assertTrue(window.local_idle_minutes.isEnabled())
|
||||||
|
|
||||||
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
rows = {"openrouter": window.cleanup_model_row,
|
rows = {"openrouter": window.cleanup_model_row,
|
||||||
|
|||||||
Reference in New Issue
Block a user