mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3ae8d70b | ||
|
|
93944f6c14 | ||
|
|
2af5ec671c | ||
|
|
825f089fe9 | ||
|
|
5a4ae8c315 | ||
|
|
3e3cb21bc3 | ||
|
|
70bc4c16fa | ||
|
|
b13b08fc38 | ||
|
|
e282e6b0cf |
@@ -161,7 +161,8 @@ running.
|
||||
- **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
|
||||
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
|
||||
and graphics can take is marked. The graphics card is
|
||||
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:
|
||||
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
|
||||
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
|
||||
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
|
||||
ü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
|
||||
# build off master rather than off a tag appends the commit to it, so that a
|
||||
# bug report from someone running "latest" names a commit.
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.2.0"
|
||||
|
||||
+72
-11
@@ -387,12 +387,17 @@ def _transcribe_request(target, audio_path, language, prompt, response_format,
|
||||
if granularity:
|
||||
fields.append(("timestamp_granularities[]", granularity))
|
||||
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:
|
||||
return _request(
|
||||
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||
aborter=aborter,
|
||||
)
|
||||
with held:
|
||||
return _request(
|
||||
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||
aborter=aborter,
|
||||
)
|
||||
except ApiError as exc:
|
||||
if target.provider == "local":
|
||||
raise local_failure(target.service, ggml.whisper, exc) from None
|
||||
@@ -518,22 +523,65 @@ def _thinking(payload, provider, reasoning):
|
||||
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.
|
||||
|
||||
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.
|
||||
waiting, with only the hour-long local timeout underneath. A hosted model is
|
||||
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="",
|
||||
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":
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service=service))
|
||||
@@ -546,7 +594,8 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||
],
|
||||
}
|
||||
if provider == "local-llm":
|
||||
payload["max_tokens"] = local_ceiling(text)
|
||||
payload["max_tokens"] = local_ceiling(text, reasoning, context,
|
||||
system_prompt)
|
||||
_thinking(payload, provider, reasoning)
|
||||
try:
|
||||
data = _request(
|
||||
@@ -570,6 +619,13 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||
raise ApiError(t("The cleanup model spent its whole reply on "
|
||||
"thinking. Set Thinking to \u201cOff\u201d."))
|
||||
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
|
||||
|
||||
|
||||
@@ -605,6 +661,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="",
|
||||
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||
if not content:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -289,6 +289,11 @@ class Dikte:
|
||||
self.update_action.triggered.connect(self.open_release_page)
|
||||
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.triggered.connect(self.open_settings)
|
||||
self.menu.addAction(self.settings_action)
|
||||
@@ -303,6 +308,10 @@ class Dikte:
|
||||
self.menu.addAction(self.quit_action)
|
||||
|
||||
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.activated.connect(self._tray_clicked)
|
||||
self._refresh_update()
|
||||
@@ -398,6 +407,21 @@ class Dikte:
|
||||
)
|
||||
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
|
||||
# say, since dictation is the one being waited on in front of a screen.
|
||||
if self.state == IDLE and self.ask_state != IDLE:
|
||||
@@ -1209,6 +1233,18 @@ class Dikte:
|
||||
QDesktopServices.openUrl(
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
def open_settings(self):
|
||||
|
||||
+14
-7
@@ -115,13 +115,20 @@ def _local(text, conf, system_prompt, timeout, aborter=None):
|
||||
"""
|
||||
service = t("Local model")
|
||||
try:
|
||||
return api.cleanup(
|
||||
text, "", conf["local_llm_model"], system_prompt,
|
||||
reasoning=conf["local_llm_reasoning"],
|
||||
base_url=api.serving(ggml.llm),
|
||||
timeout=max(timeout, api.LOCAL_TIMEOUT),
|
||||
provider="local-llm", service=service, aborter=aborter,
|
||||
)
|
||||
# Held for the length of the request so that the idle unload does not
|
||||
# take the model away from a block still being cleaned up.
|
||||
with ggml.llm.busy():
|
||||
return api.cleanup(
|
||||
text, "", conf["local_llm_model"], system_prompt,
|
||||
reasoning=conf["local_llm_reasoning"],
|
||||
base_url=api.serving(ggml.llm),
|
||||
timeout=max(timeout, api.LOCAL_TIMEOUT),
|
||||
provider="local-llm", service=service, 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:
|
||||
# A server that died mid-request would otherwise report only that the
|
||||
# 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
|
||||
# reasoning about a comma is 300 tokens of waiting.
|
||||
"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
|
||||
"auto_paste": True,
|
||||
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
||||
@@ -710,6 +719,14 @@ class Config:
|
||||
binary=self["local_llm_binary"],
|
||||
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):
|
||||
"""Whether anything is set to run the local cleanup model."""
|
||||
|
||||
+128
-2
@@ -26,6 +26,7 @@ interface already knows how to show.
|
||||
|
||||
import atexit
|
||||
import collections
|
||||
import contextlib
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
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
|
||||
# another port" and "would fail the same way again" is drawn on time.
|
||||
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
|
||||
|
||||
# `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
|
||||
# removes a file some other Dikte wrote after us.
|
||||
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 --------------------------------------------------------
|
||||
|
||||
@@ -1133,6 +1147,23 @@ class Server:
|
||||
with self._lock:
|
||||
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):
|
||||
"""What a running server would have to be restarted for."""
|
||||
return json.dumps(self._settings, sort_keys=True, default=str)
|
||||
@@ -1172,13 +1203,83 @@ class Server:
|
||||
proc, port, log = self._launch(settings)
|
||||
with self._lock:
|
||||
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()
|
||||
|
||||
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:
|
||||
up = self._proc is not None and self._proc.poll() is None
|
||||
return (f"http://{HOST}:{self._port}/v1"
|
||||
if up and self._key == self._settings_key() else "")
|
||||
if not (up and self._key == self._settings_key()):
|
||||
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):
|
||||
args = self._build(settings) # raises LocalError when unusable
|
||||
@@ -1285,6 +1386,31 @@ class Server:
|
||||
except subprocess.TimeoutExpired:
|
||||
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):
|
||||
# Taking _starting means a stop cannot slide past a launch in flight:
|
||||
# serve() finishes registering its child first, and the child is then
|
||||
|
||||
@@ -782,6 +782,26 @@ TR = {
|
||||
"On this machine": "Bu makinede",
|
||||
"Use the graphics card": "Ekran kartını kullan",
|
||||
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
||||
"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 model": "Yerel model",
|
||||
"Not installed.": "Kurulu değil.",
|
||||
@@ -793,6 +813,7 @@ TR = {
|
||||
"İndirildi, sürüm {version}. Vulkan sürümü yoktu, bu sürüm işlemcide çalışıyor.",
|
||||
"Fetching the model list…": "Model listesi çekiliyor…",
|
||||
"Downloading…": "İndiriliyor…",
|
||||
"Starting the download…": "İndirme başlatılıyor…",
|
||||
"Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}",
|
||||
"Download stopped.": "İndirme durduruldu.",
|
||||
"Ready: {name}.": "Hazır: {name}.",
|
||||
@@ -958,6 +979,10 @@ TR = {
|
||||
"“Off”.":
|
||||
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
|
||||
"“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 ---------------------------------------
|
||||
"Audio recorder stopped before receiving sound":
|
||||
|
||||
@@ -717,12 +717,21 @@ class LocalModelBox(QGroupBox):
|
||||
def _download(self):
|
||||
if self._downloading:
|
||||
self._stop = True
|
||||
# The flag is only read between blocks, and the wait for the server
|
||||
# to answer is not between blocks: a click during it changes
|
||||
# nothing on screen for as long as the connection takes.
|
||||
self.status.setText(t("Stopping…"))
|
||||
return
|
||||
item = self._current_item()
|
||||
if item is None:
|
||||
return
|
||||
self._downloading, self._stop = True, False
|
||||
self._refresh_buttons()
|
||||
# Opening the connection can take ten or twenty seconds, and the first
|
||||
# byte counts are what the line below would otherwise wait for. Left
|
||||
# saying "not downloaded yet" beside a button that now reads Stop, a
|
||||
# download that started looks like a click that did not register.
|
||||
self.status.setText(t("Starting the download…"))
|
||||
|
||||
def work():
|
||||
try:
|
||||
@@ -1316,9 +1325,47 @@ class SettingsWindow(QDialog):
|
||||
orr_form.addRow(self.local_llm_options)
|
||||
|
||||
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)
|
||||
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):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
@@ -2085,6 +2132,9 @@ class SettingsWindow(QDialog):
|
||||
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.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
|
||||
# language switch the boxes still hold the old language's default, and
|
||||
# comparing against the new one would store that text as a custom
|
||||
@@ -2216,6 +2266,8 @@ class SettingsWindow(QDialog):
|
||||
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 ""
|
||||
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
|
||||
# loaded with, or today's (a Reset click in a session that switched
|
||||
@@ -2358,6 +2410,7 @@ class SettingsWindow(QDialog):
|
||||
self.stt_form.setRowVisible(self.transcribe_status, not local)
|
||||
self.stt_form.setRowVisible(self.local_whisper, local)
|
||||
self.stt_form.setRowVisible(self.local_options, local)
|
||||
self._refresh_local_box()
|
||||
if local:
|
||||
return
|
||||
self.transcribe_model.clear()
|
||||
@@ -2866,6 +2919,7 @@ class SettingsWindow(QDialog):
|
||||
provider != "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
||||
self._refresh_local_box()
|
||||
binary = cleanup.executable(provider)
|
||||
found = shutil.which(binary) if binary else ""
|
||||
if provider == "local":
|
||||
|
||||
+47
-1
@@ -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.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
@@ -463,6 +464,29 @@ class Cleanup(DikteTest):
|
||||
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||
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):
|
||||
with fake_urlopen(http_error(429)), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
@@ -471,6 +495,14 @@ class Cleanup(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):
|
||||
history = [{"role": "user", "content": "book it"},
|
||||
{"role": "assistant", "content": "done"}]
|
||||
@@ -616,11 +648,14 @@ if __name__ == "__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=""):
|
||||
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log="",
|
||||
context=8192):
|
||||
self.url = url
|
||||
self.fails = fails
|
||||
self.log = log
|
||||
self.starts = 0
|
||||
self.held = 0
|
||||
self.context = context
|
||||
|
||||
def serve(self):
|
||||
self.starts += 1
|
||||
@@ -628,9 +663,20 @@ class FakeServer:
|
||||
raise ggml.LocalError(self.fails)
|
||||
return self.url
|
||||
|
||||
@contextlib.contextmanager
|
||||
def busy(self):
|
||||
self.held += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.held -= 1
|
||||
|
||||
def error(self):
|
||||
return self.log
|
||||
|
||||
def settings(self):
|
||||
return {"context": self.context}
|
||||
|
||||
|
||||
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
||||
|
||||
|
||||
@@ -411,6 +411,52 @@ class Here(DikteTest):
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
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):
|
||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||
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.assertFalse(ggml.whisper.settings()["gpu"])
|
||||
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):
|
||||
super().setUp()
|
||||
self.path("data").mkdir(parents=True, exist_ok=True)
|
||||
@@ -806,6 +808,8 @@ class Servers(Local):
|
||||
self.addCleanup(made.stop)
|
||||
return made
|
||||
|
||||
|
||||
class Servers(ServerCase):
|
||||
def test_a_started_server_hands_back_its_address(self):
|
||||
server = self.server()
|
||||
url = server.serve()
|
||||
@@ -1037,6 +1041,122 @@ class Servers(Local):
|
||||
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):
|
||||
"""What the two command lines say, since neither program is here to say it."""
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ CHANGED = {
|
||||
"local_llm_gpu": False,
|
||||
"local_llm_preload": True,
|
||||
"local_llm_reasoning": "low",
|
||||
"local_idle_unload": False,
|
||||
"local_idle_minutes": 45,
|
||||
"cleanup_prompt": "Only fix the punctuation.",
|
||||
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||||
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||||
@@ -1369,6 +1371,28 @@ class LocalModels(DikteTest):
|
||||
self.assertIn("10", box.program_label.text())
|
||||
self.assertIn("20", box.status.text())
|
||||
|
||||
def test_a_download_says_something_before_the_first_byte(self):
|
||||
# Opening the connection takes ten or twenty seconds, and the byte
|
||||
# counts only start after it. The line underneath still read "has not
|
||||
# been downloaded yet" beside a button that now said Stop, so a
|
||||
# download that had started looked like a click that had not landed.
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||||
box.repo.blockSignals(True)
|
||||
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||||
box.repo.blockSignals(False)
|
||||
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||||
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||||
with mock.patch.object(settings_ui.threading, "Thread"):
|
||||
box._download()
|
||||
self.assertIn("Starting", box.status.text())
|
||||
# And the same again for the stop, which is read between blocks and so
|
||||
# not read at all while the connection is still being opened.
|
||||
with mock.patch.object(settings_ui.threading, "Thread"):
|
||||
box._download()
|
||||
self.assertTrue(box._stop)
|
||||
self.assertIn("Stopping", box.status.text())
|
||||
|
||||
def test_a_long_model_name_is_not_cut_in_half(self):
|
||||
# The list under a combo box takes the box's width and elides what does
|
||||
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
||||
@@ -1712,6 +1736,26 @@ class LocalModels(DikteTest):
|
||||
# Its own thinking box, because the two default to opposite things.
|
||||
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):
|
||||
window = self.window(cfg.Config())
|
||||
rows = {"openrouter": window.cleanup_model_row,
|
||||
|
||||
Reference in New Issue
Block a user