mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge master, and let one function pick the archive
master grew _pick_asset() while this branch grew a second answer to the same question inside install_program(). Two functions deciding which archive this machine wants is one too many, so the managed build is asked for at the top of the picker: Dikte's own Vulkan whisper-server first where the machine is one it is built for, then upstream's newest, then the nightly pointer, then the newest release that carries a build. install_program() is back to master's three lines and reads which of the two landed off the asset name.
This commit is contained in:
+22
-10
@@ -48,22 +48,33 @@ LOCAL_TIMEOUT = 3600
|
|||||||
|
|
||||||
# Where a transcription request goes; built by config.Config.transcribe_target().
|
# Where a transcription request goes; built by config.Config.transcribe_target().
|
||||||
# `service` is the name the user sees in an error, `provider` the one the code
|
# `service` is the name the user sees in an error, `provider` the one the code
|
||||||
# branches on.
|
# branches on. `file_model` is what a timestamped run asks for instead of
|
||||||
Target = collections.namedtuple("Target", "provider service api_key base_url model")
|
# `model`, where the two differ; empty means the provider's own whisper.
|
||||||
|
Target = collections.namedtuple(
|
||||||
|
"Target", "provider service api_key base_url model file_model",
|
||||||
|
defaults=[""])
|
||||||
|
|
||||||
|
# What answers with segment times on OpenRouter when nothing else was chosen.
|
||||||
|
OPENROUTER_FILE_MODEL = "openai/whisper-1"
|
||||||
|
|
||||||
|
|
||||||
def timestamp_model(provider, selected=""):
|
def timestamp_model(provider, selected="", file_model=""):
|
||||||
"""Which model answers with segment times.
|
"""Which model answers with segment times.
|
||||||
|
|
||||||
OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything
|
OpenAI keeps them to whisper-1. Everything Groq transcribes with is a
|
||||||
Groq transcribes with is a whisper, so the model already chosen does it and
|
whisper, so the model already chosen does it and the fallback is only for a
|
||||||
the fallback is only for a provider left on its default. So is everything the
|
provider left on its default. So is everything the local server runs,
|
||||||
local server runs, whatever the file is called, and there asking for another
|
whatever the file is called, and there asking for another model would name
|
||||||
model would name one it has never heard of.
|
one it has never heard of. OpenRouter fronts several models that do times
|
||||||
|
and several that do not, and a request to the wrong one gets a transcript
|
||||||
|
with no segments in it, so the one to use is a setting of its own
|
||||||
|
(`file_model`) and whisper-1 is only where that setting is left empty.
|
||||||
"""
|
"""
|
||||||
if provider in ("groq", "local"):
|
if provider in ("groq", "local"):
|
||||||
return selected or "whisper-large-v3-turbo"
|
return selected or "whisper-large-v3-turbo"
|
||||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
if provider == "openrouter":
|
||||||
|
return file_model or OPENROUTER_FILE_MODEL
|
||||||
|
return "whisper-1"
|
||||||
|
|
||||||
|
|
||||||
# What a gateway in front of the model answers of its own accord: the request
|
# What a gateway in front of the model answers of its own accord: the request
|
||||||
@@ -444,7 +455,8 @@ 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(
|
data = _transcribe_request(
|
||||||
target._replace(model=timestamp_model(target.provider, target.model)),
|
target._replace(model=timestamp_model(target.provider, target.model,
|
||||||
|
target.file_model)),
|
||||||
audio_path, language, prompt, "verbose_json",
|
audio_path, language, prompt, "verbose_json",
|
||||||
granularity="segment", timeout=timeout, aborter=aborter,
|
granularity="segment", timeout=timeout, aborter=aborter,
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-1
@@ -397,6 +397,9 @@ DEFAULTS = {
|
|||||||
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
||||||
"groq_transcribe_model": "whisper-large-v3-turbo",
|
"groq_transcribe_model": "whisper-large-v3-turbo",
|
||||||
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
||||||
|
# What a timestamped run (subtitles) asks OpenRouter for: not every model
|
||||||
|
# there returns segment times. Empty -> openai/whisper-1.
|
||||||
|
"openrouter_file_model": "",
|
||||||
"language": "tr",
|
"language": "tr",
|
||||||
"transcribe_prompt": "",
|
"transcribe_prompt": "",
|
||||||
|
|
||||||
@@ -669,8 +672,9 @@ class Config:
|
|||||||
# to land on rather than reading it from there.
|
# to land on rather than reading it from there.
|
||||||
name = "openai"
|
name = "openai"
|
||||||
who = TRANSCRIBERS[name]
|
who = TRANSCRIBERS[name]
|
||||||
|
file_model = self["openrouter_file_model"] if name == "openrouter" else ""
|
||||||
return api.Target(name, who.service, self.api_key(who.key),
|
return api.Target(name, who.service, self.api_key(who.key),
|
||||||
self[who.url], self[who.model])
|
self[who.url], self[who.model], file_model.strip())
|
||||||
|
|
||||||
def transcribe_ready(self):
|
def transcribe_ready(self):
|
||||||
"""Whether speech to text could run right now, without opening Settings."""
|
"""Whether speech to text could run right now, without opening Settings."""
|
||||||
|
|||||||
+101
-34
@@ -90,6 +90,10 @@ MANAGED_WHISPER_SHA256 = (
|
|||||||
WHISPER_MODELS_REPO = "ggerganov/whisper.cpp"
|
WHISPER_MODELS_REPO = "ggerganov/whisper.cpp"
|
||||||
LLM_AUTHOR = "ggml-org"
|
LLM_AUTHOR = "ggml-org"
|
||||||
|
|
||||||
|
# The file llama.cpp attaches to its version releases in place of the binaries:
|
||||||
|
# a line naming the nightly tag those are published under.
|
||||||
|
NIGHTLY_TAG = "nightly-tag.txt"
|
||||||
|
|
||||||
# What the whisper repository holds besides models: Core ML encoders for Apple
|
# What the whisper repository holds besides models: Core ML encoders for Apple
|
||||||
# hardware and the odd loose file.
|
# hardware and the odd loose file.
|
||||||
WHISPER_PREFIX = "ggml-"
|
WHISPER_PREFIX = "ggml-"
|
||||||
@@ -295,6 +299,91 @@ def _managed_whisper(program, tag=""):
|
|||||||
and _has_vulkan())
|
and _has_vulkan())
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_asset(refresh=False):
|
||||||
|
"""The Vulkan whisper-server Dikte builds itself, or None.
|
||||||
|
|
||||||
|
Taken only when the archive's digest is the reviewed one. Anything else,
|
||||||
|
a release that is not there yet, a GitHub that cannot be reached, a file
|
||||||
|
that is not the reviewed bytes, leaves upstream's processor build as the
|
||||||
|
answer, and the install record says which of the two landed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_, assets = hub.release(DIKTE_REPO, MANAGED_WHISPER_RELEASE,
|
||||||
|
refresh=refresh)
|
||||||
|
except hub.HubError:
|
||||||
|
return None
|
||||||
|
return next((a for a in assets
|
||||||
|
if a.name.endswith(MANAGED_WHISPER_VULKAN)
|
||||||
|
and a.sha256 == MANAGED_WHISPER_SHA256), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_asset(program, assets):
|
||||||
|
"""The archive this machine wants out of one release's files, or None."""
|
||||||
|
for ending in _wanted_assets(program):
|
||||||
|
item = next((a for a in assets if a.name.endswith(ending)), None)
|
||||||
|
if item:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_asset(program, tag="", refresh=False):
|
||||||
|
"""(tag, Item) for the release archive to install. Item is None when there
|
||||||
|
is none for this machine.
|
||||||
|
|
||||||
|
Dikte's own Vulkan whisper-server comes before upstream's where this
|
||||||
|
machine is one it is built for, because whisper.cpp publishes no Vulkan
|
||||||
|
archive for Linux at all.
|
||||||
|
|
||||||
|
A named tag is taken as given. For the newest, what GitHub answers is not
|
||||||
|
always where the builds are: llama.cpp's latest release is a version marker
|
||||||
|
carrying a single nightly-tag.txt, which names the tag the archives are
|
||||||
|
actually attached to, and those are prereleases that "latest" never points
|
||||||
|
at. The pointer is followed when it is there, and when it is not, the newest
|
||||||
|
release that does carry a build for this machine is taken instead.
|
||||||
|
"""
|
||||||
|
if _managed_whisper(program, tag):
|
||||||
|
item = _managed_asset(refresh=refresh)
|
||||||
|
if item:
|
||||||
|
return MANAGED_WHISPER_VERSION, item
|
||||||
|
named = bool(tag) and tag != "latest"
|
||||||
|
missing = None
|
||||||
|
try:
|
||||||
|
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh)
|
||||||
|
except hub.HubError as exc:
|
||||||
|
# A release carrying no files at all is the case the search below exists
|
||||||
|
# for, not a reason to stop before it: the build for this machine may be
|
||||||
|
# attached to a prerelease that "latest" never points at. The failure is
|
||||||
|
# kept rather than dropped, because an unreachable GitHub arrives here
|
||||||
|
# the same way and that one is the message the caller wants.
|
||||||
|
if named:
|
||||||
|
raise
|
||||||
|
missing, assets = exc, []
|
||||||
|
item = _matching_asset(program, assets)
|
||||||
|
if item or named:
|
||||||
|
return tag, item
|
||||||
|
# Best effort from here on: a machine this project publishes nothing for is
|
||||||
|
# not a failed lookup, and the caller's message about that is the useful
|
||||||
|
# one. Whatever goes wrong while looking further leaves it standing.
|
||||||
|
try:
|
||||||
|
pointer = next((a for a in assets if a.name == NIGHTLY_TAG), None)
|
||||||
|
if pointer:
|
||||||
|
nightly = hub.text(pointer.url).strip()
|
||||||
|
if nightly:
|
||||||
|
found, assets = hub.release(program.repo, nightly, refresh=refresh)
|
||||||
|
item = _matching_asset(program, assets)
|
||||||
|
if item:
|
||||||
|
return found, item
|
||||||
|
for found, assets in hub.releases(program.repo, refresh=refresh):
|
||||||
|
item = _matching_asset(program, assets)
|
||||||
|
if item:
|
||||||
|
return found, item
|
||||||
|
except hub.HubError:
|
||||||
|
pass
|
||||||
|
if missing is not None:
|
||||||
|
raise missing
|
||||||
|
return tag, None
|
||||||
|
|
||||||
|
|
||||||
def _install_record(program):
|
def _install_record(program):
|
||||||
return BIN_DIR / program.name / "installed.json"
|
return BIN_DIR / program.name / "installed.json"
|
||||||
|
|
||||||
@@ -403,41 +492,17 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
refresh=False):
|
refresh=False):
|
||||||
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
|
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
|
||||||
|
|
||||||
Ordinary builds follow the program's newest release. The Linux Vulkan build
|
`tag` is empty for whatever the project released last, which is the point:
|
||||||
comes from Dikte's pinned dependency release instead.
|
a version pinned in Dikte's source would mean a release of Dikte every time
|
||||||
|
whisper.cpp has one. The Linux Vulkan whisper-server is the exception, and
|
||||||
|
_pick_asset says why.
|
||||||
"""
|
"""
|
||||||
repo = program.repo
|
|
||||||
release_tag = tag or "latest"
|
|
||||||
managed = _managed_whisper(program, tag)
|
managed = _managed_whisper(program, tag)
|
||||||
item, vulkan = None, False
|
try:
|
||||||
if managed:
|
tag, item = _pick_asset(program, tag, refresh=refresh)
|
||||||
repo = DIKTE_REPO
|
except hub.HubError as exc:
|
||||||
release_tag = MANAGED_WHISPER_RELEASE
|
raise LocalError(str(exc)) from exc
|
||||||
try:
|
|
||||||
tag, assets = hub.release(repo, release_tag, refresh=refresh)
|
|
||||||
except hub.HubError:
|
|
||||||
# Older Dikte releases have no managed server. The upstream CPU
|
|
||||||
# build remains the usable answer there and during API failures.
|
|
||||||
assets = []
|
|
||||||
item = next((a for a in assets
|
|
||||||
if a.name.endswith(MANAGED_WHISPER_VULKAN)
|
|
||||||
and a.sha256 == MANAGED_WHISPER_SHA256), None)
|
|
||||||
vulkan = item is not None
|
|
||||||
if item:
|
|
||||||
tag = MANAGED_WHISPER_VERSION
|
|
||||||
|
|
||||||
if item is None:
|
|
||||||
repo = program.repo
|
|
||||||
wanted = _wanted_assets(program)
|
|
||||||
try:
|
|
||||||
tag, assets = hub.release(repo, "latest" if managed else release_tag,
|
|
||||||
refresh=refresh)
|
|
||||||
except hub.HubError as exc:
|
|
||||||
raise LocalError(str(exc)) from exc
|
|
||||||
for ending in wanted:
|
|
||||||
item = next((a for a in assets if a.name.endswith(ending)), None)
|
|
||||||
if item:
|
|
||||||
break
|
|
||||||
if item is None:
|
if item is None:
|
||||||
# Nothing to download and nothing to install for you: whisper.cpp
|
# Nothing to download and nothing to install for you: whisper.cpp
|
||||||
# publishes no macOS binary, and Homebrew's whisper-cpp is configured
|
# publishes no macOS binary, and Homebrew's whisper-cpp is configured
|
||||||
@@ -452,7 +517,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
"or transcribe in the cloud. See the README."
|
"or transcribe in the cloud. See the README."
|
||||||
))
|
))
|
||||||
raise LocalError(t("{repo} {tag} has no build for this machine.",
|
raise LocalError(t("{repo} {tag} has no build for this machine.",
|
||||||
repo=repo, tag=tag))
|
repo=program.repo, tag=tag))
|
||||||
|
|
||||||
into = BIN_DIR / program.name / tag
|
into = BIN_DIR / program.name / tag
|
||||||
fresh = into.with_name(tag + ".new")
|
fresh = into.with_name(tag + ".new")
|
||||||
@@ -506,7 +571,9 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
# Which of the two builds this machine ended up with. Only written
|
# Which of the two builds this machine ended up with. Only written
|
||||||
# where both were on offer, so an install that never had the
|
# where both were on offer, so an install that never had the
|
||||||
# choice is not made to look like a fallback.
|
# choice is not made to look like a fallback.
|
||||||
record["backend"] = "vulkan" if vulkan else "processor"
|
record["backend"] = (
|
||||||
|
"vulkan" if item.name.endswith(MANAGED_WHISPER_VULKAN)
|
||||||
|
else "processor")
|
||||||
_install_record(program).write_text(json.dumps(record), encoding="utf-8")
|
_install_record(program).write_text(json.dumps(record), encoding="utf-8")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise LocalError(t("Could not install {name}: {error}",
|
raise LocalError(t("Could not install {name}: {error}",
|
||||||
|
|||||||
+48
-4
@@ -121,6 +121,13 @@ def _digest(value):
|
|||||||
return value.split(":", 1)[1] if value.startswith("sha256:") else value
|
return value.split(":", 1)[1] if value.startswith("sha256:") else value
|
||||||
|
|
||||||
|
|
||||||
|
def _assets(data):
|
||||||
|
return [Item(a.get("name") or "", a.get("browser_download_url") or "",
|
||||||
|
int(a.get("size") or 0), _digest(a.get("digest")))
|
||||||
|
for a in (data.get("assets") or [])
|
||||||
|
if a.get("browser_download_url")]
|
||||||
|
|
||||||
|
|
||||||
def release(repo, tag="latest", refresh=False):
|
def release(repo, tag="latest", refresh=False):
|
||||||
"""(tag, [Item]) for one GitHub release, newest when no tag is given."""
|
"""(tag, [Item]) for one GitHub release, newest when no tag is given."""
|
||||||
where = "latest" if tag in ("", "latest") else f"tags/{tag}"
|
where = "latest" if tag in ("", "latest") else f"tags/{tag}"
|
||||||
@@ -128,10 +135,47 @@ def release(repo, tag="latest", refresh=False):
|
|||||||
f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh)
|
f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh)
|
||||||
if not isinstance(data, dict) or not data.get("assets"):
|
if not isinstance(data, dict) or not data.get("assets"):
|
||||||
raise HubError(t("{repo} has no downloadable release.", repo=repo))
|
raise HubError(t("{repo} has no downloadable release.", repo=repo))
|
||||||
assets = [Item(a.get("name") or "", a.get("browser_download_url") or "",
|
return data.get("tag_name") or tag, _assets(data)
|
||||||
int(a.get("size") or 0), _digest(a.get("digest")))
|
|
||||||
for a in data["assets"] if a.get("browser_download_url")]
|
|
||||||
return data.get("tag_name") or tag, assets
|
def releases(repo, limit=20, refresh=False):
|
||||||
|
"""[(tag, [Item])] for the recent releases, newest first, with their files.
|
||||||
|
|
||||||
|
"latest" is one release and this is the list behind it, prereleases
|
||||||
|
included: a project that attaches its builds to a prerelease is invisible
|
||||||
|
to release() above, and its newest usable build is in here.
|
||||||
|
"""
|
||||||
|
data = _fetch(f"gh-list-{repo}-{limit}",
|
||||||
|
f"{GITHUB_API}/repos/{repo}/releases?per_page={limit}",
|
||||||
|
refresh=refresh)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise HubError(t("{repo} has no downloadable release.", repo=repo))
|
||||||
|
out = []
|
||||||
|
for entry in data:
|
||||||
|
tag, items = entry.get("tag_name") or "", _assets(entry)
|
||||||
|
if tag and items:
|
||||||
|
out.append((tag, items))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def text(url, limit=4096, timeout=20):
|
||||||
|
"""A small text file from a release, as a string.
|
||||||
|
|
||||||
|
Not cached and not checksummed, because what it carries is a pointer: a few
|
||||||
|
bytes naming the release the actual archives are attached to, read once on
|
||||||
|
the way to a download that is checked in full.
|
||||||
|
"""
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.read(limit).decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
exc.close()
|
||||||
|
raise HubError(t("{url} answered HTTP {code}.",
|
||||||
|
url=urllib.parse.urlsplit(url).netloc, code=exc.code)) from exc
|
||||||
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||||
|
raise HubError(t("Could not reach {url}: {error}",
|
||||||
|
url=urllib.parse.urlsplit(url).netloc, error=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def newest_release(repo, refresh=False):
|
def newest_release(repo, refresh=False):
|
||||||
|
|||||||
@@ -228,6 +228,11 @@ TR = {
|
|||||||
"Transcript cleanup": "Transkripti temizleme",
|
"Transcript cleanup": "Transkripti temizleme",
|
||||||
"API key": "API anahtarı",
|
"API key": "API anahtarı",
|
||||||
"Model": "Model",
|
"Model": "Model",
|
||||||
|
"Audio file model": "Ses dosyası modeli",
|
||||||
|
"The model a timestamped audio file (subtitles) is sent to. Not every model on "
|
||||||
|
"OpenRouter returns segment times; empty means openai/whisper-1.":
|
||||||
|
"Zaman damgalı bir ses dosyasının (altyazı) gönderildiği model. OpenRouter'daki her "
|
||||||
|
"model segment zamanı döndürmez; boşsa openai/whisper-1 kullanılır.",
|
||||||
"Provider": "Sağlayıcı",
|
"Provider": "Sağlayıcı",
|
||||||
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
|
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
|
||||||
"gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
|
"gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
|
||||||
@@ -791,6 +796,12 @@ TR = {
|
|||||||
"Ready: {name}.": "Hazır: {name}.",
|
"Ready: {name}.": "Hazır: {name}.",
|
||||||
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
||||||
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
|
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
|
||||||
|
"{name} is here, but the program above is not. Download it first.":
|
||||||
|
"{name} burada, ama yukarıdaki program değil. Önce onu indirin.",
|
||||||
|
"{name} is not on this machine and this publisher does not offer it. "
|
||||||
|
"Choose another model, or another publisher.":
|
||||||
|
"{name} bu makinede yok ve bu yayıncı da sunmuyor. Başka bir model, "
|
||||||
|
"ya da başka bir yayıncı seçin.",
|
||||||
"downloaded": "indirildi",
|
"downloaded": "indirildi",
|
||||||
"not downloaded": "indirilmedi",
|
"not downloaded": "indirilmedi",
|
||||||
"Delete model": "Modeli sil",
|
"Delete model": "Modeli sil",
|
||||||
|
|||||||
+81
-8
@@ -6,7 +6,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
|
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal
|
||||||
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
|
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
||||||
@@ -247,6 +247,13 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._pending = False
|
self._pending = False
|
||||||
self._stop = False
|
self._stop = False
|
||||||
self._wanted = "" # the model to select once a list arrives
|
self._wanted = "" # the model to select once a list arrives
|
||||||
|
self._chosen_in = "" # the publisher the selected model is from
|
||||||
|
# Typing or arrowing through the publisher box changes its text a
|
||||||
|
# character at a time, and each of those would otherwise be a request.
|
||||||
|
self._later = QTimer(self)
|
||||||
|
self._later.setSingleShot(True)
|
||||||
|
self._later.setInterval(400)
|
||||||
|
self._later.timeout.connect(self._later_fetch)
|
||||||
|
|
||||||
form = QFormLayout(self)
|
form = QFormLayout(self)
|
||||||
|
|
||||||
@@ -328,6 +335,8 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._wanted = model
|
self._wanted = model
|
||||||
self._pending = True
|
self._pending = True
|
||||||
self._show_program()
|
self._show_program()
|
||||||
|
self._chosen_in = repo or (ggml.SUGGESTED_LLM[0] if self._repos is not None
|
||||||
|
else "")
|
||||||
if self._repos is not None:
|
if self._repos is not None:
|
||||||
self.repo.blockSignals(True)
|
self.repo.blockSignals(True)
|
||||||
self.repo.clear()
|
self.repo.clear()
|
||||||
@@ -382,11 +391,18 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
def _fill_repos(self, current):
|
def _fill_repos(self, current):
|
||||||
def work():
|
def work():
|
||||||
self._listed.emit([("repos", ggml.llm_repos())], "")
|
self._listed.emit([("repos", ggml.llm_repos(), "")], "")
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _repo_changed(self):
|
def _repo_changed(self):
|
||||||
|
if not self._downloading:
|
||||||
|
self._later.start()
|
||||||
|
|
||||||
|
def _later_fetch(self):
|
||||||
|
# A download that started inside the wait was not there to be seen when
|
||||||
|
# the timer went off, and rebuilding the rows underneath one is exactly
|
||||||
|
# what the guard above is for.
|
||||||
if not self._downloading:
|
if not self._downloading:
|
||||||
self._fetch_models(self.repository())
|
self._fetch_models(self.repository())
|
||||||
|
|
||||||
@@ -396,18 +412,29 @@ class LocalModelBox(QGroupBox):
|
|||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
found = self._models(repo) if self._repos is not None else self._models()
|
found = self._models(repo) if self._repos is not None else self._models()
|
||||||
self._listed.emit([("models", found)], "")
|
self._listed.emit([("models", found, repo)], "")
|
||||||
except ggml.LocalError as exc:
|
except ggml.LocalError as exc:
|
||||||
self._listed.emit([], str(exc))
|
self._listed.emit([("models", [], repo)], str(exc))
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _on_listed(self, payload, error):
|
def _on_listed(self, payload, error):
|
||||||
|
kind, found, repo = payload[0] if payload else ("repos", [], "")
|
||||||
|
# A publisher changed while its predecessor's list was still on the way
|
||||||
|
# would otherwise be answered with the wrong models, whichever request
|
||||||
|
# happened to come back last.
|
||||||
|
if kind == "models" and repo != self.repository():
|
||||||
|
return
|
||||||
if error:
|
if error:
|
||||||
|
# The list is the publisher's, so a failed one leaves the box no
|
||||||
|
# longer showing this publisher's models: emptying it is what keeps
|
||||||
|
# the two boxes saying the same thing. The message goes on after,
|
||||||
|
# because filling the box writes a status of its own.
|
||||||
|
if kind == "models":
|
||||||
|
self._fill_models([])
|
||||||
|
self._refresh_buttons()
|
||||||
self.status.setText(error)
|
self.status.setText(error)
|
||||||
self._refresh_buttons()
|
|
||||||
return
|
return
|
||||||
kind, found = payload[0]
|
|
||||||
if kind == "repos":
|
if kind == "repos":
|
||||||
current = self.repo.currentText()
|
current = self.repo.currentText()
|
||||||
self.repo.blockSignals(True)
|
self.repo.blockSignals(True)
|
||||||
@@ -421,7 +448,12 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
def _fill_models(self, items):
|
def _fill_models(self, items):
|
||||||
"""One row per model, saying what it weighs and whether it is here."""
|
"""One row per model, saying what it weighs and whether it is here."""
|
||||||
wanted = self._wanted or self.selected()
|
# The selection is only worth carrying over within the publisher it was
|
||||||
|
# made in. Carried across one, a model this repository does not publish
|
||||||
|
# would be added back as "not downloaded" and selected again, and
|
||||||
|
# changing the publisher would leave the model box looking untouched.
|
||||||
|
same = self._repos is None or self.repository() == self._chosen_in
|
||||||
|
wanted = self._wanted or (self.selected() if same else "")
|
||||||
here = [name for name in (self._model_path(i.name).name for i in items)]
|
here = [name for name in (self._model_path(i.name).name for i in items)]
|
||||||
self.model.blockSignals(True)
|
self.model.blockSignals(True)
|
||||||
self.model.clear()
|
self.model.clear()
|
||||||
@@ -446,6 +478,7 @@ class LocalModelBox(QGroupBox):
|
|||||||
self.model.blockSignals(False)
|
self.model.blockSignals(False)
|
||||||
self._fit_popup(self.model)
|
self._fit_popup(self.model)
|
||||||
self._wanted = ""
|
self._wanted = ""
|
||||||
|
self._chosen_in = self.repository()
|
||||||
self._model_changed()
|
self._model_changed()
|
||||||
|
|
||||||
def _on_disk(self):
|
def _on_disk(self):
|
||||||
@@ -474,6 +507,9 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._show_program()
|
self._show_program()
|
||||||
if error:
|
if error:
|
||||||
self.program_label.setText(error)
|
self.program_label.setText(error)
|
||||||
|
# The model line says whether the program is here, so installing one
|
||||||
|
# changes what it should read.
|
||||||
|
self._refresh_buttons()
|
||||||
self.changed.emit()
|
self.changed.emit()
|
||||||
|
|
||||||
def _current_item(self):
|
def _current_item(self):
|
||||||
@@ -560,15 +596,30 @@ class LocalModelBox(QGroupBox):
|
|||||||
def _refresh_buttons(self):
|
def _refresh_buttons(self):
|
||||||
name = self.selected()
|
name = self.selected()
|
||||||
here = bool(name) and ggml.have_model(self._model_path(name))
|
here = bool(name) and ggml.have_model(self._model_path(name))
|
||||||
|
# A row carries what it takes to fetch it. The ones that do not are the
|
||||||
|
# models found on this disk and the one the settings name but the list
|
||||||
|
# does not offer: there is nothing to press Download for on those, and
|
||||||
|
# a button that can only do nothing is worse than one that is out.
|
||||||
|
item = self._current_item()
|
||||||
self.delete_button.setEnabled(here and not self._downloading)
|
self.delete_button.setEnabled(here and not self._downloading)
|
||||||
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
||||||
self.download_button.setEnabled(self._downloading or (bool(name) and not here))
|
self.download_button.setEnabled(self._downloading or (item is not None
|
||||||
|
and not here))
|
||||||
if self._downloading:
|
if self._downloading:
|
||||||
return
|
return
|
||||||
if not name:
|
if not name:
|
||||||
self.status.setText(t("Nothing downloaded yet."))
|
self.status.setText(t("Nothing downloaded yet."))
|
||||||
|
elif here and not ggml.program_path(self.program):
|
||||||
|
# The model alone runs nothing, and "Ready" over a missing program
|
||||||
|
# reads as though it does.
|
||||||
|
self.status.setText(t("{name} is here, but the program above is "
|
||||||
|
"not. Download it first.", name=name))
|
||||||
elif here:
|
elif here:
|
||||||
self.status.setText(t("Ready: {name}.", name=name))
|
self.status.setText(t("Ready: {name}.", name=name))
|
||||||
|
elif item is None:
|
||||||
|
self.status.setText(t("{name} is not on this machine and this "
|
||||||
|
"publisher does not offer it. Choose another "
|
||||||
|
"model, or another publisher.", name=name))
|
||||||
else:
|
else:
|
||||||
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
||||||
|
|
||||||
@@ -879,6 +930,15 @@ class SettingsWindow(QDialog):
|
|||||||
self.transcribe_model_row = self._row(self.transcribe_model,
|
self.transcribe_model_row = self._row(self.transcribe_model,
|
||||||
self.refresh_transcribe_models)
|
self.refresh_transcribe_models)
|
||||||
stt_form.addRow(t("Model"), self.transcribe_model_row)
|
stt_form.addRow(t("Model"), self.transcribe_model_row)
|
||||||
|
# OpenRouter only: which of its models a timestamped run asks for.
|
||||||
|
self.file_model = QComboBox()
|
||||||
|
self.file_model.setEditable(True)
|
||||||
|
self.file_model.lineEdit().setPlaceholderText(api.OPENROUTER_FILE_MODEL)
|
||||||
|
self.file_model.setToolTip(
|
||||||
|
t("The model a timestamped audio file (subtitles) is sent to. Not every model "
|
||||||
|
"on OpenRouter returns segment times; empty means openai/whisper-1."))
|
||||||
|
self.file_model_row = self._row(self.file_model)
|
||||||
|
stt_form.addRow(t("Audio file model"), self.file_model_row)
|
||||||
# A spanning row: in the narrow field column a wrapped label gets a
|
# A spanning row: in the narrow field column a wrapped label gets a
|
||||||
# height that fits one line, and the rest of the text is cut off.
|
# height that fits one line, and the rest of the text is cut off.
|
||||||
self.transcribe_status = QLabel("")
|
self.transcribe_status = QLabel("")
|
||||||
@@ -1762,6 +1822,7 @@ class SettingsWindow(QDialog):
|
|||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||||
self._provider_changed() # selecting index 0 fires no signal
|
self._provider_changed() # selecting index 0 fires no signal
|
||||||
|
self.file_model.setCurrentText(conf["openrouter_file_model"])
|
||||||
self.local_gpu.setChecked(conf["local_gpu"])
|
self.local_gpu.setChecked(conf["local_gpu"])
|
||||||
self.local_preload.setChecked(conf["local_preload"])
|
self.local_preload.setChecked(conf["local_preload"])
|
||||||
self.local_threads.setValue(int(conf["local_threads"]))
|
self.local_threads.setValue(int(conf["local_threads"]))
|
||||||
@@ -1879,6 +1940,7 @@ class SettingsWindow(QDialog):
|
|||||||
for name, who in cfg.TRANSCRIBERS.items():
|
for name, who in cfg.TRANSCRIBERS.items():
|
||||||
conf[who.key] = self._key_fields[name].text().strip()
|
conf[who.key] = self._key_fields[name].text().strip()
|
||||||
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
||||||
|
conf["openrouter_file_model"] = self.file_model.currentText().strip()
|
||||||
conf["gemini_api_key"] = self.gemini_key.text().strip()
|
conf["gemini_api_key"] = self.gemini_key.text().strip()
|
||||||
conf["opencode_api_key"] = self.opencode_key.text().strip()
|
conf["opencode_api_key"] = self.opencode_key.text().strip()
|
||||||
conf["local_model"] = self.local_whisper.selected()
|
conf["local_model"] = self.local_whisper.selected()
|
||||||
@@ -2052,6 +2114,7 @@ class SettingsWindow(QDialog):
|
|||||||
self._shown_provider = provider
|
self._shown_provider = provider
|
||||||
local = provider == "local"
|
local = provider == "local"
|
||||||
self.stt_form.setRowVisible(self.transcribe_model_row, not local)
|
self.stt_form.setRowVisible(self.transcribe_model_row, not local)
|
||||||
|
self.stt_form.setRowVisible(self.file_model_row, provider == "openrouter")
|
||||||
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)
|
||||||
@@ -2060,8 +2123,16 @@ class SettingsWindow(QDialog):
|
|||||||
self.transcribe_model.clear()
|
self.transcribe_model.clear()
|
||||||
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
||||||
self.transcribe_model.setCurrentText(self._models[provider])
|
self.transcribe_model.setCurrentText(self._models[provider])
|
||||||
|
if provider == "openrouter":
|
||||||
|
self._fill_file_models(TRANSCRIBE_MODELS[provider])
|
||||||
self.transcribe_status.setText("")
|
self.transcribe_status.setText("")
|
||||||
|
|
||||||
|
def _fill_file_models(self, models):
|
||||||
|
current = self.file_model.currentText()
|
||||||
|
self.file_model.clear()
|
||||||
|
self.file_model.addItems(models)
|
||||||
|
self.file_model.setCurrentText(current)
|
||||||
|
|
||||||
def _load_transcribe_models(self):
|
def _load_transcribe_models(self):
|
||||||
"""The model list of whichever provider is selected."""
|
"""The model list of whichever provider is selected."""
|
||||||
provider = self.transcribe_provider.currentData() or "openai"
|
provider = self.transcribe_provider.currentData() or "openai"
|
||||||
@@ -2090,6 +2161,8 @@ class SettingsWindow(QDialog):
|
|||||||
self.transcribe_model.clear()
|
self.transcribe_model.clear()
|
||||||
self.transcribe_model.addItems(models)
|
self.transcribe_model.addItems(models)
|
||||||
self.transcribe_model.setCurrentText(current)
|
self.transcribe_model.setCurrentText(current)
|
||||||
|
if self._shown_provider == "openrouter":
|
||||||
|
self._fill_file_models(models)
|
||||||
self.transcribe_status.setText(t("{count} models loaded.", count=len(models)))
|
self.transcribe_status.setText(t("{count} models loaded.", count=len(models)))
|
||||||
|
|
||||||
def _load_models(self):
|
def _load_models(self):
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ class TimestampModel(unittest.TestCase):
|
|||||||
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||||
"whisper-1")
|
"whisper-1")
|
||||||
|
|
||||||
|
def test_openrouter_takes_the_file_model_that_was_set(self):
|
||||||
|
self.assertEqual(
|
||||||
|
api.timestamp_model("openrouter", "openai/gpt-4o-transcribe",
|
||||||
|
"openai/whisper-large-v3"),
|
||||||
|
"openai/whisper-large-v3")
|
||||||
|
|
||||||
|
def test_openrouter_with_no_file_model_falls_back_to_whisper(self):
|
||||||
|
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", ""),
|
||||||
|
"openai/whisper-1")
|
||||||
|
|
||||||
|
|
||||||
class Explain(DikteTest):
|
class Explain(DikteTest):
|
||||||
def error(self, status):
|
def error(self, status):
|
||||||
@@ -318,6 +328,13 @@ class TranscribeSegments(DikteTest):
|
|||||||
api.transcribe_segments(OPENROUTER, self.wav)
|
api.transcribe_segments(OPENROUTER, self.wav)
|
||||||
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
||||||
|
|
||||||
|
def test_openrouter_asks_for_the_file_model_when_one_is_set(self):
|
||||||
|
target = OPENROUTER._replace(file_model="mistralai/voxtral-mini-transcribe")
|
||||||
|
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||||
|
api.transcribe_segments(target, self.wav)
|
||||||
|
self.assertEqual(multipart_fields(calls[0])["model"],
|
||||||
|
"mistralai/voxtral-mini-transcribe")
|
||||||
|
|
||||||
def test_groq_stays_on_the_model_it_was_given(self):
|
def test_groq_stays_on_the_model_it_was_given(self):
|
||||||
target = GROQ._replace(model="whisper-large-v3")
|
target = GROQ._replace(model="whisper-large-v3")
|
||||||
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:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
@@ -668,7 +669,11 @@ class WithoutAnInstance(DikteTest):
|
|||||||
def run_verb(self, argv):
|
def run_verb(self, argv):
|
||||||
# launch_gui replaces this process with the application, so it never
|
# launch_gui replaces this process with the application, so it never
|
||||||
# comes back in real use and must not be allowed to here.
|
# comes back in real use and must not be allowed to here.
|
||||||
|
# `ask` with no text reads what was piped in, and the runner's own
|
||||||
|
# stdin is not that: under pytest it is an object that refuses to be
|
||||||
|
# read at all.
|
||||||
with mock.patch.object(ipc, "send", return_value=None), \
|
with mock.patch.object(ipc, "send", return_value=None), \
|
||||||
|
mock.patch.object(sys, "stdin", io.StringIO()), \
|
||||||
mock.patch.object(cli, "launch_gui") as launch, \
|
mock.patch.object(cli, "launch_gui") as launch, \
|
||||||
captured() as (out, err):
|
captured() as (out, err):
|
||||||
code = cli.run(argv)
|
code = cli.run(argv)
|
||||||
|
|||||||
@@ -220,6 +220,19 @@ class TranscribeTarget(DikteTest):
|
|||||||
self.assertEqual(target.service, "OpenRouter")
|
self.assertEqual(target.service, "OpenRouter")
|
||||||
self.assertEqual(target.api_key, "sk-or-test")
|
self.assertEqual(target.api_key, "sk-or-test")
|
||||||
self.assertEqual(target.model, "openai/whisper-1")
|
self.assertEqual(target.model, "openai/whisper-1")
|
||||||
|
self.assertEqual(target.file_model, "")
|
||||||
|
|
||||||
|
def test_openrouter_carries_its_file_model(self):
|
||||||
|
conf = self.config(transcribe_provider="openrouter",
|
||||||
|
openrouter_api_key="sk-or-test",
|
||||||
|
openrouter_file_model=" openai/whisper-large-v3 ")
|
||||||
|
self.assertEqual(conf.transcribe_target().file_model,
|
||||||
|
"openai/whisper-large-v3")
|
||||||
|
|
||||||
|
def test_only_openrouter_has_a_file_model(self):
|
||||||
|
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test",
|
||||||
|
openrouter_file_model="openai/whisper-large-v3")
|
||||||
|
self.assertEqual(conf.transcribe_target().file_model, "")
|
||||||
|
|
||||||
def test_groq_when_it_is_picked(self):
|
def test_groq_when_it_is_picked(self):
|
||||||
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
||||||
|
|||||||
@@ -240,6 +240,44 @@ class InstallProgram(Local):
|
|||||||
"whisper-bin-ubuntu-x64.tar.gz")
|
"whisper-bin-ubuntu-x64.tar.gz")
|
||||||
self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz"))
|
self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz"))
|
||||||
|
|
||||||
|
def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self):
|
||||||
|
"""llama.cpp's latest release carries a tag name, not the binaries."""
|
||||||
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
|
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||||
|
marker = self.release(ggml.NIGHTLY_TAG)
|
||||||
|
nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"),
|
||||||
|
tag_name="b10809")
|
||||||
|
|
||||||
|
def opener(request, timeout=None):
|
||||||
|
url = request.full_url
|
||||||
|
if url.endswith("/releases/latest"):
|
||||||
|
return json_body(marker)
|
||||||
|
if url.endswith("/releases/tags/b10809"):
|
||||||
|
return json_body(nightly)
|
||||||
|
if url.endswith(ggml.NIGHTLY_TAG):
|
||||||
|
return body(b"b10809\n")
|
||||||
|
return body(self.archive)
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=opener):
|
||||||
|
tag, found = ggml._pick_asset(ggml.LLAMA)
|
||||||
|
self.assertEqual(tag, "b10809")
|
||||||
|
self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz")
|
||||||
|
|
||||||
|
def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self):
|
||||||
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
|
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||||
|
marker = self.release("source.zip")
|
||||||
|
listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"),
|
||||||
|
dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")]
|
||||||
|
|
||||||
|
def opener(request, timeout=None):
|
||||||
|
url = request.full_url
|
||||||
|
return json_body(listing if "per_page" in url else marker)
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=opener):
|
||||||
|
tag, found = ggml._pick_asset(ggml.LLAMA)
|
||||||
|
self.assertEqual(tag, "b1")
|
||||||
|
self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz")
|
||||||
def test_linux_x64_with_vulkan_takes_diktes_accelerated_build(self):
|
def test_linux_x64_with_vulkan_takes_diktes_accelerated_build(self):
|
||||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||||
|
|||||||
+5
-2
@@ -42,9 +42,12 @@ class Directories(unittest.TestCase):
|
|||||||
|
|
||||||
def test_a_mac_does_not_read_the_xdg_variables(self):
|
def test_a_mac_does_not_read_the_xdg_variables(self):
|
||||||
"""A Mac with them set from some other tool still stores in one place."""
|
"""A Mac with them set from some other tool still stores in one place."""
|
||||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
|
# Something no temporary directory can be called: the home this runs
|
||||||
|
# under is a mkdtemp path, and a two-letter needle matched the "/c" in
|
||||||
|
# somebody's TMPDIR rather than the variable being read.
|
||||||
|
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/xdg-elsewhere"}):
|
||||||
config_dir, _ = paths.directories("darwin")
|
config_dir, _ = paths.directories("darwin")
|
||||||
self.assertNotIn("/c", config_dir.as_posix())
|
self.assertNotIn("xdg-elsewhere", config_dir.as_posix())
|
||||||
|
|
||||||
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
|
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
|
||||||
"""Settings roam with the account; several gigabytes must not."""
|
"""Settings roam with the account; several gigabytes must not."""
|
||||||
|
|||||||
+85
-1
@@ -9,6 +9,7 @@ next time anybody presses Save. That is the failure this catches.
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -22,12 +23,13 @@ from dikte import cleanup
|
|||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
from dikte import ggml
|
from dikte import ggml
|
||||||
from dikte import hotkey
|
from dikte import hotkey
|
||||||
from dikte.i18n import t
|
from dikte import hub
|
||||||
from dikte import ipc
|
from dikte import ipc
|
||||||
from dikte import overlay as overlay_module
|
from dikte import overlay as overlay_module
|
||||||
from dikte import paste
|
from dikte import paste
|
||||||
from dikte import settings_ui
|
from dikte import settings_ui
|
||||||
from dikte import update
|
from dikte import update
|
||||||
|
from dikte.i18n import t
|
||||||
from tests.support import DikteTest, only_these_tools
|
from tests.support import DikteTest, only_these_tools
|
||||||
|
|
||||||
# The harness below replaces this method on the class so that opening a window
|
# The harness below replaces this method on the class so that opening a window
|
||||||
@@ -506,6 +508,20 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||||||
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
||||||
|
|
||||||
|
def test_the_file_model_is_saved_and_only_shown_for_openrouter(self):
|
||||||
|
self.write_config({"transcribe_provider": "openrouter",
|
||||||
|
"openrouter_file_model": "openai/whisper-large-v3"})
|
||||||
|
conf = cfg.Config()
|
||||||
|
window = self.window(conf)
|
||||||
|
self.assertEqual(window.file_model.currentText(), "openai/whisper-large-v3")
|
||||||
|
self.assertTrue(window.stt_form.isRowVisible(window.file_model_row))
|
||||||
|
window.file_model.setCurrentText(" deepgram/nova-3 ")
|
||||||
|
window._save()
|
||||||
|
self.assertEqual(conf["openrouter_file_model"], "deepgram/nova-3")
|
||||||
|
window.transcribe_provider.setCurrentIndex(
|
||||||
|
window.transcribe_provider.findData("openai"))
|
||||||
|
self.assertFalse(window.stt_form.isRowVisible(window.file_model_row))
|
||||||
|
|
||||||
def test_the_provider_box_offers_every_provider_config_knows(self):
|
def test_the_provider_box_offers_every_provider_config_knows(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
offered = [window.transcribe_provider.itemData(i)
|
offered = [window.transcribe_provider.itemData(i)
|
||||||
@@ -1216,6 +1232,74 @@ class LocalModels(DikteTest):
|
|||||||
for row in range(box.repo.count()))
|
for row in range(box.repo.count()))
|
||||||
self.assertGreaterEqual(view.minimumWidth(), widest)
|
self.assertGreaterEqual(view.minimumWidth(), widest)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _item(name, size=1 << 20):
|
||||||
|
return hub.Item(name, f"https://example.invalid/{name}", size, "")
|
||||||
|
|
||||||
|
def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self):
|
||||||
|
# The model the settings name is not in the list any more, so its row
|
||||||
|
# was rebuilt from the name alone and carries no file to fetch. The
|
||||||
|
# button stayed lit and the press did nothing at all.
|
||||||
|
box = self.window(self.config(local_llm_model="gone.gguf")).local_llm
|
||||||
|
box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
self.assertEqual(box.selected(), "gone.gguf")
|
||||||
|
self.assertFalse(box.download_button.isEnabled())
|
||||||
|
self.assertIn("gone.gguf", box.status.text())
|
||||||
|
self.assertIn("publisher", box.status.text())
|
||||||
|
|
||||||
|
def test_a_model_without_its_program_does_not_say_it_is_ready(self):
|
||||||
|
# The model runs on the program above it, and "Ready" over a missing
|
||||||
|
# one is what had people asking why nothing transcribed.
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
path = ggml.whisper_model_path("ggml-small.bin")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"not really a model")
|
||||||
|
box.load("ggml-small.bin")
|
||||||
|
self.assertFalse(ggml.program_path(ggml.WHISPER))
|
||||||
|
self.assertNotIn("Ready", box.status.text())
|
||||||
|
self.assertIn("program", box.status.text())
|
||||||
|
|
||||||
|
def test_changing_the_publisher_changes_the_model(self):
|
||||||
|
# The model chosen under the old publisher is not published by the new
|
||||||
|
# one. Carried over, it was added back as "not downloaded" and selected
|
||||||
|
# again, and the box looked as though the change had not taken.
|
||||||
|
box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf",
|
||||||
|
local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm
|
||||||
|
box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-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")], "")
|
||||||
|
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||||||
|
self.assertEqual(box.model.count(), 1)
|
||||||
|
|
||||||
|
def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self):
|
||||||
|
# Every change starts its own request, and they do not come back in the
|
||||||
|
# order they went out.
|
||||||
|
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")], "")
|
||||||
|
box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")],
|
||||||
|
"ggml-org/gemma-3-4b-it-GGUF")], "")
|
||||||
|
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||||||
|
|
||||||
|
def test_the_publisher_box_is_not_asked_on_every_keystroke(self):
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
with mock.patch.object(box, "_fetch_models") as fetch:
|
||||||
|
for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"):
|
||||||
|
box.repo.setCurrentText(text)
|
||||||
|
fetch.assert_not_called()
|
||||||
|
box._later.setInterval(0)
|
||||||
|
box._later.start()
|
||||||
|
_app.processEvents()
|
||||||
|
time.sleep(0.05)
|
||||||
|
_app.processEvents()
|
||||||
|
self.assertEqual(fetch.call_count, 1)
|
||||||
def test_a_processor_build_where_the_vulkan_one_belongs_says_so(self):
|
def test_a_processor_build_where_the_vulkan_one_belongs_says_so(self):
|
||||||
# The Vulkan whisper-server is published by hand, and until it is
|
# The Vulkan whisper-server is published by hand, and until it is
|
||||||
# there the download lands upstream's processor build. Said nowhere,
|
# there the download lands upstream's processor build. Said nowhere,
|
||||||
|
|||||||
Reference in New Issue
Block a user