mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Qt's int is C++'s 32-bit one, so the progress of a 2.3 GB download arrived at the other end of the signal as -1805209440, which is the real figure less 2^32. The file was landing correctly; only the line under it was wrong, reading "1.0 MB of -1805209440 B (-1%)".
2018 lines
87 KiB
Python
2018 lines
87 KiB
Python
"""Settings window."""
|
|
|
|
import os
|
|
import shutil
|
|
import threading
|
|
|
|
from PyQt6.QtCore import Qt, QUrl, pyqtSignal
|
|
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
|
|
from PyQt6.QtWidgets import (
|
|
QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
|
QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
|
|
QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
|
|
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
import api
|
|
import assistant
|
|
import audio
|
|
import config as cfg
|
|
import filetranscribe
|
|
import ggml
|
|
import hotkey
|
|
import meeting
|
|
from filetranscribe import FileTranscriber
|
|
from i18n import t
|
|
|
|
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
|
|
LANGUAGES = [
|
|
("Detect automatically", "auto"), ("Turkish", "tr"), ("English", "en"),
|
|
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
|
|
]
|
|
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
|
|
TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"),
|
|
("OpenAI", "openai"), ("OpenRouter", "openrouter")]
|
|
CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"),
|
|
("This machine (llama.cpp)", "local")]
|
|
# Starting points for the model box; "Fetch model list" replaces them with
|
|
# whatever the provider offers today.
|
|
TRANSCRIBE_MODELS = {
|
|
"openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"],
|
|
"openrouter": [
|
|
"openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe",
|
|
"openai/whisper-1", "openai/whisper-large-v3",
|
|
"openai/whisper-large-v3-turbo", "mistralai/voxtral-mini-transcribe",
|
|
"deepgram/nova-3", "google/chirp-3",
|
|
],
|
|
}
|
|
CLEANUP_MODELS = [
|
|
"google/gemini-3.5-flash-lite", "google/gemini-3.1-flash-lite",
|
|
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
|
|
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
|
|
]
|
|
# Minutes are a harder job than cleanup: an hour of talk has to be read whole
|
|
# and turned into decisions, so the starting points are the larger models.
|
|
MEETING_MODELS = [
|
|
"google/gemini-3.5-flash", "google/gemini-3.1-pro-preview",
|
|
"anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5",
|
|
]
|
|
ASSISTANT_PROVIDERS = [
|
|
("Claude Code", "claude"), ("Codex", "codex"), ("OpenRouter", "openrouter"),
|
|
]
|
|
# Aliases resolve to the newest model of that name, so they age better than an
|
|
# id does; a full id can be typed in when a particular one is wanted.
|
|
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"]
|
|
CODEX_MODELS = ["gpt-5.4-codex", "gpt-5.4", "o4-mini"]
|
|
# Starting points only; the box is editable and OpenRouter has hundreds.
|
|
ASSISTANT_OR_MODELS = [
|
|
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
|
|
"x-ai/grok-4.5", "google/gemini-3.1-pro-preview",
|
|
]
|
|
# What Claude Code may do without being able to ask. It cannot ask: there is no
|
|
# window to answer in, so a mode that would have prompted denies instead.
|
|
PERMISSION_MODES = [
|
|
("Decide on its own, with the safety checks on", "auto"),
|
|
("Allow everything", "bypassPermissions"),
|
|
("Only what needs no permission", "manual"),
|
|
]
|
|
# Codex confines the commands it runs instead of asking about them.
|
|
CODEX_SANDBOXES = [
|
|
("Read anything, write in the working directory", "workspace-write"),
|
|
("Read only", "read-only"),
|
|
("No sandbox at all", "danger-full-access"),
|
|
]
|
|
MEETING_STATUS = {
|
|
"recorded": "waiting to be written up",
|
|
"transcribed": "transcript ready, minutes missing",
|
|
"failed": "failed",
|
|
}
|
|
# How hard the cleanup model may think before it answers, in OpenRouter's own
|
|
# effort levels. A model that ignores the field simply answers as it always did.
|
|
REASONING_LEVELS = [
|
|
("Model's own default", ""), ("Off", "none"), ("Minimal", "minimal"),
|
|
("Low", "low"), ("Medium", "medium"), ("High", "high"),
|
|
("Very high", "xhigh"), ("Maximum", "max"),
|
|
]
|
|
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
|
|
# Offered for all three global shortcuts, which keeps them one kind of field
|
|
# rather than three. The boxes stay editable: this is a shortlist of
|
|
# combinations that are usually free, not the set of ones that work.
|
|
SHORTCUTS = [
|
|
"Ctrl+Space", "Ctrl+Alt+Space", "Ctrl+Shift+Space", "Meta+Space",
|
|
"Ctrl+Alt+A", "Ctrl+Alt+D", "Ctrl+Alt+M", "Ctrl+Alt+Q",
|
|
"Meta+A", "Meta+D", "Meta+M",
|
|
"Ctrl+Alt+F1", "Ctrl+Alt+F2", "Ctrl+Alt+F3",
|
|
]
|
|
AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
|
|
"*.mp4 *.mkv *.webm *.mov *.avi")
|
|
|
|
|
|
class LocalModelBox(QGroupBox):
|
|
"""The program, the model, and the two downloads that put them there.
|
|
|
|
One class for whisper.cpp and llama.cpp, because the job is the same one
|
|
twice: say whether the program is here, offer the models somebody publishes,
|
|
fetch the chosen one, and stay usable while a gigabyte arrives. Nothing is
|
|
listed in the source; `repos` and `models` are asked at the moment the box is
|
|
opened, so a model published this morning is in the list this afternoon.
|
|
"""
|
|
|
|
_listed = pyqtSignal(list, str)
|
|
_quants = pyqtSignal(list, str)
|
|
# qint64 rather than int, which is C++'s 32-bit one: a 2.3 GB model is more
|
|
# than fits in it, and the count comes out the far side negative.
|
|
_progress = pyqtSignal("qint64", "qint64")
|
|
_finished = pyqtSignal(str, str)
|
|
_installed = pyqtSignal(str, str)
|
|
|
|
changed = pyqtSignal()
|
|
|
|
def __init__(self, program, title, models, model_path, repos=None, parent=None):
|
|
super().__init__(title, parent)
|
|
self.program = program
|
|
self._models = models # () -> [hub.Item], or (repo) -> [hub.Item]
|
|
self._model_path = model_path # (name) -> Path
|
|
self._repos = repos # None, or () -> [repo id]
|
|
self._downloading = False
|
|
self._pending = False
|
|
self._stop = False
|
|
self._wanted = "" # the model to select once a list arrives
|
|
|
|
form = QFormLayout(self)
|
|
|
|
self.program_label = QLabel("")
|
|
self.program_label.setWordWrap(True)
|
|
self.install_button = QPushButton(t("Download"))
|
|
self.install_button.clicked.connect(self._install_program)
|
|
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
|
self.install_button))
|
|
|
|
if self._repos is not None:
|
|
self.repo = QComboBox()
|
|
self.repo.setEditable(True)
|
|
self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The "
|
|
"list is fetched; any other one can be typed in."))
|
|
self.repo.currentTextChanged.connect(self._repo_changed)
|
|
form.addRow(t("Publisher"), self.repo)
|
|
|
|
self.model = QComboBox()
|
|
self.download_button = QPushButton(t("Download"))
|
|
self.download_button.clicked.connect(self._download)
|
|
self.delete_button = QPushButton(t("Delete"))
|
|
self.delete_button.clicked.connect(self._delete)
|
|
form.addRow(t("Model"), self._side_by_side(self.model,
|
|
self.download_button,
|
|
self.delete_button))
|
|
self.model.currentIndexChanged.connect(self._model_changed)
|
|
|
|
self.status = QLabel("")
|
|
self.status.setWordWrap(True)
|
|
form.addRow(self.status)
|
|
|
|
self._listed.connect(self._on_listed)
|
|
self._quants.connect(self._on_listed)
|
|
self._progress.connect(self._on_progress)
|
|
self._finished.connect(self._on_finished)
|
|
self._installed.connect(self._on_installed)
|
|
|
|
@staticmethod
|
|
def _side_by_side(*widgets):
|
|
layout = QHBoxLayout()
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
for index, widget in enumerate(widgets):
|
|
layout.addWidget(widget, 1 if index == 0 else 0)
|
|
holder = QWidget()
|
|
holder.setLayout(layout)
|
|
return holder
|
|
|
|
# ---- what is here ----------------------------------------------------
|
|
|
|
def selected(self):
|
|
return self.model.currentData() or ""
|
|
|
|
def repository(self):
|
|
return self.repo.currentText().strip() if self._repos is not None else ""
|
|
|
|
def load(self, model, repo=""):
|
|
"""Show what is stored. What else is on offer is asked for on the way up.
|
|
|
|
Nothing is fetched here: building the settings window is not the same as
|
|
opening it, and a list nobody is looking at is not worth a request. What
|
|
is already on this disk is shown straight away either way.
|
|
"""
|
|
self._wanted = model
|
|
self._pending = True
|
|
self._show_program()
|
|
if self._repos is not None:
|
|
self.repo.blockSignals(True)
|
|
self.repo.clear()
|
|
self.repo.addItems(list(ggml.SUGGESTED_LLM))
|
|
self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0])
|
|
self.repo.blockSignals(False)
|
|
self._fill_models([])
|
|
|
|
def showEvent(self, event):
|
|
super().showEvent(event)
|
|
if self._pending:
|
|
self._pending = False
|
|
if self._repos is not None:
|
|
self._fill_repos(self.repository())
|
|
self._fetch_models(self.repository())
|
|
|
|
def _show_program(self):
|
|
path = ggml.program_path(self.program)
|
|
if not path:
|
|
self.program_label.setText(t("Not installed."))
|
|
self.install_button.setVisible(True)
|
|
return
|
|
self.install_button.setVisible(not ggml.installed_program(self.program)
|
|
and not ggml.system_program(self.program))
|
|
if ggml.system_program(self.program):
|
|
# Worth saying which one is running: a distribution package is built
|
|
# for this machine and may reach the graphics card, while the
|
|
# released binaries carry processor backends only.
|
|
self.program_label.setText(t("Installed on the system: {path}", path=path))
|
|
else:
|
|
self.program_label.setText(
|
|
t("Downloaded, version {version}.",
|
|
version=ggml.installed_version(self.program) or "?"))
|
|
|
|
# ---- the lists -------------------------------------------------------
|
|
|
|
def _fill_repos(self, current):
|
|
def work():
|
|
self._listed.emit([("repos", ggml.llm_repos())], "")
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _repo_changed(self):
|
|
if not self._downloading:
|
|
self._fetch_models(self.repository())
|
|
|
|
def _fetch_models(self, repo=""):
|
|
self.status.setText(t("Fetching the model list…"))
|
|
|
|
def work():
|
|
try:
|
|
found = self._models(repo) if self._repos is not None else self._models()
|
|
self._quants.emit([("models", found)], "")
|
|
except ggml.LocalError as exc:
|
|
self._quants.emit([], str(exc))
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _on_listed(self, payload, error):
|
|
if error:
|
|
self.status.setText(error)
|
|
self._refresh_buttons()
|
|
return
|
|
kind, found = payload[0]
|
|
if kind == "repos":
|
|
current = self.repo.currentText()
|
|
self.repo.blockSignals(True)
|
|
self.repo.clear()
|
|
self.repo.addItems(found)
|
|
self.repo.setCurrentText(current)
|
|
self.repo.blockSignals(False)
|
|
return
|
|
self._fill_models(found)
|
|
|
|
def _fill_models(self, items):
|
|
"""One row per model, saying what it weighs and whether it is here."""
|
|
wanted = self._wanted or self.selected()
|
|
here = [name for name in (self._model_path(i.name).name for i in items)]
|
|
self.model.blockSignals(True)
|
|
self.model.clear()
|
|
for item, name in zip(items, here):
|
|
mark = (t("downloaded") if ggml.have_model(self._model_path(item.name))
|
|
else ggml.human_size(item.size))
|
|
self.model.addItem(f"{name} ({mark})", name)
|
|
self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1)
|
|
# A model that was downloaded and then dropped from the list upstream is
|
|
# still on this disk and still works, so it stays on offer.
|
|
for name in self._on_disk():
|
|
if self.model.findData(name) < 0:
|
|
self.model.addItem(f"{name} ({t('downloaded')})", name)
|
|
# And one that is chosen but not here, because the file was deleted from
|
|
# underneath or the settings came from another machine, stays chosen:
|
|
# Save reads this box, and a row missing here would quietly empty the
|
|
# setting rather than showing that the model needs downloading again.
|
|
if wanted and self.model.findData(wanted) < 0:
|
|
self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted)
|
|
index = self.model.findData(wanted)
|
|
self.model.setCurrentIndex(max(index, 0))
|
|
self.model.blockSignals(False)
|
|
self._wanted = ""
|
|
self._model_changed()
|
|
|
|
def _on_disk(self):
|
|
return (ggml.installed_whisper_models() if self.program is ggml.WHISPER
|
|
else ggml.installed_llm_models())
|
|
|
|
# ---- fetching --------------------------------------------------------
|
|
|
|
def _install_program(self):
|
|
self.install_button.setEnabled(False)
|
|
self.program_label.setText(t("Downloading…"))
|
|
|
|
def work():
|
|
try:
|
|
ggml.install_program(self.program, on_progress=self._report)
|
|
self._installed.emit("", "")
|
|
except ggml.LocalError as exc:
|
|
self._installed.emit("", str(exc))
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _on_installed(self, _, error):
|
|
self.install_button.setEnabled(True)
|
|
self._show_program()
|
|
if error:
|
|
self.program_label.setText(error)
|
|
self.changed.emit()
|
|
|
|
def _current_item(self):
|
|
return self.model.currentData(Qt.ItemDataRole.UserRole + 1)
|
|
|
|
def _download(self):
|
|
if self._downloading:
|
|
self._stop = True
|
|
return
|
|
item = self._current_item()
|
|
if item is None:
|
|
return
|
|
self._downloading, self._stop = True, False
|
|
self._refresh_buttons()
|
|
|
|
def work():
|
|
try:
|
|
landed = ggml.download(item, self._model_path(item.name),
|
|
on_progress=self._report,
|
|
should_stop=lambda: self._stop)
|
|
self._finished.emit(item.name if landed else "", "")
|
|
except ggml.LocalError as exc:
|
|
self._finished.emit("", str(exc))
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _report(self, done, total):
|
|
self._progress.emit(done, total)
|
|
|
|
def _on_progress(self, done, total):
|
|
share = f" ({done * 100 // total}%)" if total else ""
|
|
text = t("Downloading: {done} of {total}{share}",
|
|
done=ggml.human_size(done), total=ggml.human_size(total or done),
|
|
share=share)
|
|
if self._downloading:
|
|
self.status.setText(text)
|
|
else:
|
|
self.program_label.setText(text)
|
|
|
|
def _on_finished(self, name, error):
|
|
self._downloading = False
|
|
if error:
|
|
self.status.setText(error)
|
|
elif not name:
|
|
self.status.setText(t("Download stopped."))
|
|
self._fill_models_from_current()
|
|
self.changed.emit()
|
|
|
|
def _fill_models_from_current(self):
|
|
"""Redraw the rows without asking anybody anything again."""
|
|
items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
|
for i in range(self.model.count())]
|
|
self._wanted = self.selected()
|
|
self._fill_models([i for i in items if i is not None])
|
|
|
|
def _delete(self):
|
|
name = self.selected()
|
|
if not name or not ggml.have_model(self._model_path(name)):
|
|
return
|
|
if QMessageBox.question(self, t("Delete model"),
|
|
t("Delete {name} from this machine?", name=name)) \
|
|
!= QMessageBox.StandardButton.Yes:
|
|
return
|
|
try:
|
|
ggml.delete_model(self._model_path(name))
|
|
except ggml.LocalError as exc:
|
|
self.status.setText(str(exc))
|
|
self._fill_models_from_current()
|
|
self.changed.emit()
|
|
|
|
def _model_changed(self):
|
|
self._refresh_buttons()
|
|
self.changed.emit()
|
|
|
|
def _refresh_buttons(self):
|
|
name = self.selected()
|
|
here = bool(name) and ggml.have_model(self._model_path(name))
|
|
self.delete_button.setEnabled(here and not self._downloading)
|
|
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
|
self.download_button.setEnabled(self._downloading or (bool(name) and not here))
|
|
if self._downloading:
|
|
return
|
|
if not name:
|
|
self.status.setText(t("Nothing downloaded yet."))
|
|
elif here:
|
|
self.status.setText(t("Ready: {name}.", name=name))
|
|
else:
|
|
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
|
|
|
|
|
class SettingsWindow(QDialog):
|
|
applied = pyqtSignal()
|
|
|
|
_models_loaded = pyqtSignal(list, str)
|
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
|
_test_done = pyqtSignal(bool, str)
|
|
_or_test_done = pyqtSignal(bool, str)
|
|
|
|
def __init__(self, conf, launch_command, meeting_command=None,
|
|
meetings=None, ask_command=None, parent=None):
|
|
super().__init__(parent)
|
|
self.conf = conf
|
|
self.launch_command = launch_command
|
|
self.meeting_command = meeting_command or launch_command
|
|
self.ask_command = ask_command or launch_command
|
|
self.meetings = meetings
|
|
# Each provider keeps its own transcription model, so switching the
|
|
# provider back and forth never overwrites the other one's.
|
|
self._models = {"openai": "", "openrouter": ""}
|
|
self._shown_provider = ""
|
|
self.transcriber = FileTranscriber(conf, self)
|
|
self.setWindowTitle(t("Dikte Settings"))
|
|
self.resize(680, 640)
|
|
|
|
tabs = self.tabs = QTabWidget(self)
|
|
tabs.addTab(self._general_tab(), t("General"))
|
|
self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models"))
|
|
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
|
|
tabs.addTab(self._assistant_tab(), t("Agent"))
|
|
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
|
tabs.addTab(self._minutes_tab(), t("Minutes"))
|
|
tabs.addTab(self._file_tab(), t("Audio file"))
|
|
tabs.addTab(self._shortcut_tab(), t("Shortcut"))
|
|
tabs.addTab(self._history_tab(), t("History"))
|
|
|
|
# Save keeps the window open, so the window is closed with the titlebar
|
|
# cross (or Escape) instead. A "Cancel" next to it would be a lie: the
|
|
# settings are already on disk by then.
|
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save)
|
|
buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Save"))
|
|
buttons.accepted.connect(self._save)
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.addWidget(tabs)
|
|
layout.addWidget(buttons)
|
|
|
|
self._models_loaded.connect(self._on_models_loaded)
|
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
|
self._test_done.connect(self._on_test_done)
|
|
self._or_test_done.connect(self._on_or_test_done)
|
|
self.transcriber.progress.connect(self._on_file_progress)
|
|
self.transcriber.finished.connect(self._on_file_finished)
|
|
self.transcriber.failed.connect(self._on_file_failed)
|
|
if self.meetings is not None:
|
|
self.meetings.progress.connect(self._on_minutes_progress)
|
|
self.meetings.finished.connect(self._on_minutes_finished)
|
|
self.meetings.failed.connect(self._on_minutes_failed)
|
|
self._load()
|
|
# On a machine where nothing can transcribe yet, this window was opened
|
|
# because of that, so open it on the tab that fixes it.
|
|
if not conf.transcribe_ready():
|
|
self.tabs.setCurrentIndex(self.api_tab_index)
|
|
|
|
# ---- tabs ----------------------------------------------------------
|
|
|
|
def _general_tab(self):
|
|
page = QWidget()
|
|
form = QFormLayout(page)
|
|
|
|
self.ui_language = QComboBox()
|
|
for label, code in UI_LANGUAGES:
|
|
self.ui_language.addItem(t(label), code)
|
|
self.ui_language.setToolTip(
|
|
t("Restart Dikte for the language change to reach every window.")
|
|
)
|
|
form.addRow(t("Interface language"), self.ui_language)
|
|
|
|
self.mic = QComboBox()
|
|
self.mic.addItem(t("Default microphone"), "")
|
|
for name, desc in audio.list_sources():
|
|
self.mic.addItem(desc, name)
|
|
form.addRow(t("Microphone"), self.mic)
|
|
|
|
self.language = QComboBox()
|
|
for label, code in LANGUAGES:
|
|
self.language.addItem(t(label), code)
|
|
form.addRow(t("Speech language"), self.language)
|
|
|
|
self.auto_paste = QCheckBox(t("Paste the text into the focused window"))
|
|
form.addRow("", self.auto_paste)
|
|
|
|
self.paste_shortcut = QComboBox()
|
|
self.paste_shortcut.addItems(PASTE_SHORTCUTS)
|
|
self.paste_shortcut.setToolTip(
|
|
t("Terminals usually want ctrl+shift+v. Change this if pasting does nothing.")
|
|
)
|
|
form.addRow(t("Paste key"), self.paste_shortcut)
|
|
|
|
self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting"))
|
|
form.addRow("", self.restore_clipboard)
|
|
|
|
self.corner = QComboBox()
|
|
for value in CORNERS:
|
|
self.corner.addItem(t(value), value)
|
|
form.addRow(t("Indicator corner"), self.corner)
|
|
|
|
self.max_seconds = QSpinBox()
|
|
self.max_seconds.setRange(10, 3600)
|
|
self.max_seconds.setSuffix(t(" s"))
|
|
form.addRow(t("Longest recording"), self.max_seconds)
|
|
|
|
self.skip_silent = QCheckBox(t("Skip silent recordings (don't call the API)"))
|
|
form.addRow("", self.skip_silent)
|
|
|
|
self.silence_db = QSpinBox()
|
|
self.silence_db.setRange(-80, -20)
|
|
self.silence_db.setSuffix(" dB")
|
|
self.silence_db.setToolTip(t(
|
|
"Speech also has to rise {margin} dB above the recording's own noise "
|
|
"floor, so this absolute floor rarely needs touching. Lower it if quiet "
|
|
"speech gets dropped; raise it if noise still gets through.",
|
|
margin=10,
|
|
))
|
|
form.addRow(t("Silence threshold"), self.silence_db)
|
|
|
|
self.filter_hallucinations = QCheckBox(
|
|
t("Discard stock phrases models invent for near-silent audio")
|
|
)
|
|
self.filter_hallucinations.setToolTip(
|
|
t("Whisper answers silence with things like “Thanks for watching”.")
|
|
)
|
|
form.addRow("", self.filter_hallucinations)
|
|
|
|
self.keep_audio = QCheckBox(t("Keep audio files (~/.local/share/dikte/recordings)"))
|
|
form.addRow("", self.keep_audio)
|
|
return page
|
|
|
|
def _api_tab(self):
|
|
page = QWidget()
|
|
outer = QVBoxLayout(page)
|
|
|
|
# Keys first, then the two jobs, because OpenRouter can now do both of
|
|
# them and a key no longer belongs to a single job.
|
|
keys = QGroupBox(t("Keys"))
|
|
keys_form = QFormLayout(keys)
|
|
self.openai_key = QLineEdit()
|
|
self.openai_key.setEchoMode(QLineEdit.EchoMode.Password)
|
|
self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)"))
|
|
self.test_button = QPushButton(t("Test"))
|
|
self.test_button.clicked.connect(self._test_openai)
|
|
self.test_label = QLabel("")
|
|
self.test_label.setWordWrap(True)
|
|
keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button))
|
|
keys_form.addRow("", self.test_label)
|
|
|
|
self.openrouter_key = QLineEdit()
|
|
self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password)
|
|
self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)"))
|
|
self.or_test_button = QPushButton(t("Test"))
|
|
self.or_test_button.clicked.connect(self._test_openrouter)
|
|
self.or_test_label = QLabel("")
|
|
self.or_test_label.setWordWrap(True)
|
|
keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button))
|
|
keys_form.addRow("", self.or_test_label)
|
|
outer.addWidget(keys)
|
|
|
|
stt = QGroupBox(t("Speech to text"))
|
|
stt_form = QFormLayout(stt)
|
|
self.transcribe_provider = QComboBox()
|
|
for label, value in TRANSCRIBE_PROVIDERS:
|
|
self.transcribe_provider.addItem(t(label), value)
|
|
stt_form.addRow(t("Provider"), self.transcribe_provider)
|
|
|
|
# The hosted providers take any model id that is typed at them; the
|
|
# local one offers what has been published, so the two are separate
|
|
# blocks and only one of them is ever visible.
|
|
self.hosted_stt = QWidget()
|
|
hosted_form = QFormLayout(self.hosted_stt)
|
|
hosted_form.setContentsMargins(0, 0, 0, 0)
|
|
self.transcribe_model = QComboBox()
|
|
self.transcribe_model.setEditable(True)
|
|
self.refresh_transcribe_models = QPushButton(t("Fetch model list"))
|
|
self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models)
|
|
hosted_form.addRow(t("Model"),
|
|
self._row(self.transcribe_model,
|
|
self.refresh_transcribe_models))
|
|
# A spanning row: in the narrow field column a wrapped label gets a
|
|
# height that fits one line, and the rest of the text is cut off.
|
|
self.transcribe_status = QLabel("")
|
|
self.transcribe_status.setWordWrap(True)
|
|
hosted_form.addRow(self.transcribe_status)
|
|
stt_form.addRow(self.hosted_stt)
|
|
|
|
self.local_whisper = LocalModelBox(
|
|
ggml.WHISPER, t("On this machine"),
|
|
ggml.whisper_models, ggml.whisper_model_path)
|
|
stt_form.addRow(self.local_whisper)
|
|
|
|
self.local_gpu = QCheckBox(t("Use the graphics card"))
|
|
self.local_gpu.setToolTip(
|
|
t("whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the "
|
|
"build it is running was made with one. A build without any of them "
|
|
"runs on the processor whatever this says."))
|
|
self.local_preload = QCheckBox(t("Load the model when Dikte starts"))
|
|
self.local_preload.setToolTip(
|
|
t("A large model takes a second or two to load. Loading it up front "
|
|
"spends that once instead of on the first dictation, at the cost of "
|
|
"the memory it sits in."))
|
|
self.local_threads = QSpinBox()
|
|
self.local_threads.setRange(0, 64)
|
|
self.local_threads.setSpecialValueText(t("Automatic"))
|
|
self.local_options = QWidget()
|
|
options_form = QFormLayout(self.local_options)
|
|
options_form.setContentsMargins(0, 0, 0, 0)
|
|
options_form.addRow("", self.local_gpu)
|
|
options_form.addRow("", self.local_preload)
|
|
options_form.addRow(t("Threads"), self.local_threads)
|
|
stt_form.addRow(self.local_options)
|
|
|
|
self.transcribe_provider.currentIndexChanged.connect(self._provider_changed)
|
|
outer.addWidget(stt)
|
|
|
|
orr = QGroupBox(t("Transcript cleanup"))
|
|
orr_form = QFormLayout(orr)
|
|
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
|
|
orr_form.addRow("", self.cleanup_enabled)
|
|
|
|
self.cleanup_provider = QComboBox()
|
|
for label, value in CLEANUP_PROVIDERS:
|
|
self.cleanup_provider.addItem(t(label), value)
|
|
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
|
|
orr_form.addRow(t("Provider"), self.cleanup_provider)
|
|
|
|
self.hosted_cleanup = QWidget()
|
|
cleanup_form = QFormLayout(self.hosted_cleanup)
|
|
cleanup_form.setContentsMargins(0, 0, 0, 0)
|
|
self.cleanup_model = QComboBox()
|
|
self.cleanup_model.setEditable(True)
|
|
self.cleanup_model.addItems(CLEANUP_MODELS)
|
|
self.refresh_models = QPushButton(t("Fetch model list"))
|
|
self.refresh_models.clicked.connect(self._load_models)
|
|
cleanup_form.addRow(t("Model"), self._row(self.cleanup_model,
|
|
self.refresh_models))
|
|
|
|
self.cleanup_reasoning = QComboBox()
|
|
for label, value in REASONING_LEVELS:
|
|
self.cleanup_reasoning.addItem(t(label), value)
|
|
self.cleanup_reasoning.setToolTip(
|
|
t("How long a thinking model may reason before it answers. Cleanup is "
|
|
"a light job, so more thinking mostly costs time and tokens. Models "
|
|
"that cannot think ignore this.")
|
|
)
|
|
cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning)
|
|
|
|
self.models_label = QLabel(t("Runs on OpenRouter."))
|
|
self.models_label.setWordWrap(True)
|
|
cleanup_form.addRow(self.models_label)
|
|
orr_form.addRow(self.hosted_cleanup)
|
|
|
|
self.local_llm = LocalModelBox(
|
|
ggml.LLAMA, t("On this machine"),
|
|
ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos)
|
|
orr_form.addRow(self.local_llm)
|
|
|
|
self.local_llm_gpu = QCheckBox(t("Use the graphics card"))
|
|
self.local_llm_preload = QCheckBox(t("Load the model when Dikte starts"))
|
|
self.local_llm_preload.setToolTip(
|
|
t("An LLM is slower to load than a whisper model and sits in more "
|
|
"memory. Off means it is loaded on the first cleanup instead."))
|
|
self.local_llm_reasoning = QComboBox()
|
|
for label, value in REASONING_LEVELS:
|
|
self.local_llm_reasoning.addItem(t(label), value)
|
|
self.local_llm_reasoning.setToolTip(
|
|
t("A model trained to think will think unless it is told not to, and "
|
|
"spending 300 tokens of reasoning on a comma is 300 tokens of "
|
|
"waiting. Off is what cleanup wants."))
|
|
self.local_llm_options = QWidget()
|
|
llm_form = QFormLayout(self.local_llm_options)
|
|
llm_form.setContentsMargins(0, 0, 0, 0)
|
|
llm_form.addRow("", self.local_llm_gpu)
|
|
llm_form.addRow("", self.local_llm_preload)
|
|
llm_form.addRow(t("Thinking"), self.local_llm_reasoning)
|
|
orr_form.addRow(self.local_llm_options)
|
|
|
|
outer.addWidget(orr)
|
|
outer.addStretch(1)
|
|
return page
|
|
|
|
def _prompt_tab(self):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
|
|
# Two jobs, two sets of rules: dictation is rewritten for reading, an
|
|
# audio file becomes subtitles that have to stay in sync with the voice.
|
|
inner = QTabWidget()
|
|
self.cleanup_prompt = self._prompt_page(
|
|
inner, t("Dictation"),
|
|
t("System instruction given to the cleanup model. This is where you "
|
|
"decide how much it may touch your words."),
|
|
cfg.default_cleanup_prompt,
|
|
)
|
|
self.file_cleanup_prompt = self._prompt_page(
|
|
inner, t("Audio file"),
|
|
t("Used instead when an audio or video file is cleaned up. It is "
|
|
"written for subtitles: lines stay where they are, nothing is "
|
|
"shortened, and misheard words are repaired from the context."),
|
|
cfg.default_file_cleanup_prompt,
|
|
)
|
|
layout.addWidget(inner, 1)
|
|
|
|
hint = QLabel(t("Names and terms you say often (optional). They go to the "
|
|
"transcription model as a hint, and to the cleanup model as a "
|
|
"glossary, so it can repair the ones that still come out wrong."))
|
|
hint.setWordWrap(True)
|
|
layout.addWidget(hint)
|
|
self.transcribe_prompt = QPlainTextEdit()
|
|
self.transcribe_prompt.setMaximumHeight(90)
|
|
layout.addWidget(self.transcribe_prompt)
|
|
return page
|
|
|
|
def _assistant_tab(self):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
intro = QLabel(t(
|
|
"This shortcut records the same way dictation does, but the "
|
|
"transcript is not what gets pasted. It goes to an agent as a "
|
|
"command, and what comes back is pasted instead: the answer to a "
|
|
"question, or a sentence saying what was done. Claude Code and "
|
|
"Codex run as the session you would have opened yourself, with your "
|
|
"skills, your connected services and your account."
|
|
))
|
|
intro.setWordWrap(True)
|
|
layout.addWidget(intro)
|
|
|
|
self.assistant_found = QLabel("")
|
|
self.assistant_found.setWordWrap(True)
|
|
layout.addWidget(self.assistant_found)
|
|
|
|
how = QGroupBox(t("How it runs"))
|
|
how_form = QFormLayout(how)
|
|
self.assistant_shortcut = self._shortcut_box(t("none"))
|
|
install = QPushButton(t("Install as a KDE shortcut"))
|
|
install.clicked.connect(self._install_ask_shortcut)
|
|
remove = QPushButton(t("Remove"))
|
|
remove.clicked.connect(self._remove_ask_shortcut)
|
|
how_form.addRow(t("Shortcut"),
|
|
self._row(self.assistant_shortcut, install, remove))
|
|
self.assistant_shortcut_status = QLabel("")
|
|
self.assistant_shortcut_status.setWordWrap(True)
|
|
how_form.addRow(self.assistant_shortcut_status)
|
|
|
|
self.assistant_provider = QComboBox()
|
|
for label, value in ASSISTANT_PROVIDERS:
|
|
self.assistant_provider.addItem(t(label), value)
|
|
self.assistant_provider.currentIndexChanged.connect(
|
|
self._assistant_provider_changed
|
|
)
|
|
how_form.addRow(t("Runs on"), self.assistant_provider)
|
|
|
|
self.assistant_dir = QLineEdit()
|
|
self.assistant_dir.setPlaceholderText(os.path.expanduser("~"))
|
|
browse = QPushButton(t("Choose…"))
|
|
browse.clicked.connect(self._choose_assistant_dir)
|
|
how_form.addRow(t("Working directory"),
|
|
self._row(self.assistant_dir, browse))
|
|
dir_note = QLabel(t(
|
|
"The directory the command runs in, which decides which project's "
|
|
"instructions and files it can see. Your own skills and services "
|
|
"are there whichever one it is."
|
|
))
|
|
dir_note.setWordWrap(True)
|
|
how_form.addRow(dir_note)
|
|
|
|
# One scale for all three: how hard to think is one thing to want, and
|
|
# each provider is handed the nearest rung it actually has.
|
|
self.assistant_reasoning = QComboBox()
|
|
for label, value in REASONING_LEVELS:
|
|
self.assistant_reasoning.addItem(t(label), value)
|
|
self.assistant_reasoning.setToolTip(t(
|
|
"More thinking is slower, and you are standing in front of the "
|
|
"screen while it happens. Worth it for a job that has to be worked "
|
|
"out rather than looked up."
|
|
))
|
|
how_form.addRow(t("Thinking"), self.assistant_reasoning)
|
|
|
|
self.assistant_timeout = QSpinBox()
|
|
self.assistant_timeout.setRange(15, 3600)
|
|
self.assistant_timeout.setSuffix(t(" s"))
|
|
self.assistant_timeout.setToolTip(t(
|
|
"A command still running after this is given up on. The tray menu "
|
|
"can stop one earlier."
|
|
))
|
|
how_form.addRow(t("Give up after"), self.assistant_timeout)
|
|
layout.addWidget(how)
|
|
|
|
# One box per provider, only the chosen one on screen: they have nothing
|
|
# in common past the model, and three sets of half-relevant fields would
|
|
# be worse than none.
|
|
self.claude_box = QGroupBox(t("Claude Code"))
|
|
claude_form = QFormLayout(self.claude_box)
|
|
self.assistant_model = QComboBox()
|
|
self.assistant_model.setEditable(True)
|
|
self.assistant_model.addItems(ASSISTANT_MODELS)
|
|
self.assistant_model.setToolTip(t(
|
|
"A name like “sonnet” always means the newest model of that line. "
|
|
"Opus thinks harder and answers slower, which is felt here more "
|
|
"than anywhere else: you are standing in front of the screen."
|
|
))
|
|
claude_form.addRow(t("Model"), self.assistant_model)
|
|
self.assistant_permission = QComboBox()
|
|
for label, value in PERMISSION_MODES:
|
|
self.assistant_permission.addItem(t(label), value)
|
|
claude_form.addRow(t("Permissions"), self.assistant_permission)
|
|
layout.addWidget(self.claude_box)
|
|
|
|
self.codex_box = QGroupBox(t("Codex"))
|
|
codex_form = QFormLayout(self.codex_box)
|
|
self.assistant_codex_model = QComboBox()
|
|
self.assistant_codex_model.setEditable(True)
|
|
self.assistant_codex_model.addItem(t("Codex's own default"), "")
|
|
for name in CODEX_MODELS:
|
|
self.assistant_codex_model.addItem(name, name)
|
|
codex_form.addRow(t("Model"), self.assistant_codex_model)
|
|
self.assistant_codex_sandbox = QComboBox()
|
|
for label, value in CODEX_SANDBOXES:
|
|
self.assistant_codex_sandbox.addItem(t(label), value)
|
|
codex_form.addRow(t("Sandbox"), self.assistant_codex_sandbox)
|
|
layout.addWidget(self.codex_box)
|
|
|
|
self.openrouter_box = QGroupBox("OpenRouter")
|
|
or_form = QFormLayout(self.openrouter_box)
|
|
self.assistant_openrouter_model = QComboBox()
|
|
self.assistant_openrouter_model.setEditable(True)
|
|
self.assistant_openrouter_model.addItems(ASSISTANT_OR_MODELS)
|
|
or_form.addRow(t("Model"), self.assistant_openrouter_model)
|
|
or_note = QLabel(t(
|
|
"A plain question and a plain answer, over the OpenRouter key you "
|
|
"already have. It runs no commands, opens no files and reaches none "
|
|
"of your services, so it can tell you what the capital of Peru is "
|
|
"but not what is in your calendar. Working directory and permissions "
|
|
"above mean nothing here."
|
|
))
|
|
or_note.setWordWrap(True)
|
|
or_form.addRow(or_note)
|
|
layout.addWidget(self.openrouter_box)
|
|
|
|
thread = QGroupBox(t("The conversation"))
|
|
thread_form = QFormLayout(thread)
|
|
self.assistant_session_minutes = QSpinBox()
|
|
self.assistant_session_minutes.setRange(0, 1440)
|
|
self.assistant_session_minutes.setSuffix(t(" min"))
|
|
self.assistant_session_minutes.setSpecialValueText(t("every command on its own"))
|
|
thread_form.addRow(t("Carry on for"), self.assistant_session_minutes)
|
|
thread_note = QLabel(t(
|
|
"Commands within this long of each other are one conversation, so "
|
|
"“and move that to Thursday” knows what “that” is. After it, the "
|
|
"next command starts fresh."
|
|
))
|
|
thread_note.setWordWrap(True)
|
|
thread_form.addRow(thread_note)
|
|
reset = QPushButton(t("Start a new conversation now"))
|
|
reset.clicked.connect(self._reset_assistant_session)
|
|
self.assistant_session_status = QLabel("")
|
|
self.assistant_session_status.setWordWrap(True)
|
|
thread_form.addRow(self._row(reset), self.assistant_session_status)
|
|
layout.addWidget(thread)
|
|
|
|
answer = QGroupBox(t("The answer"))
|
|
answer_form = QFormLayout(answer)
|
|
self.assistant_paste = QCheckBox(t("Paste it into the focused window"))
|
|
self.assistant_paste.setToolTip(t(
|
|
"It is copied to the clipboard either way."
|
|
))
|
|
answer_form.addRow("", self.assistant_paste)
|
|
self.assistant_cleanup = QCheckBox(t("Clean the transcript up before sending it"))
|
|
self.assistant_cleanup.setToolTip(t(
|
|
"Off by default: Claude reads through “erm” and “you know” without "
|
|
"help, and cleanup costs an API call and a second or two."
|
|
))
|
|
answer_form.addRow("", self.assistant_cleanup)
|
|
layout.addWidget(answer)
|
|
|
|
prompt_label = QLabel(t(
|
|
"Told to the agent alongside every command, on top of whatever your "
|
|
"own configuration already says."
|
|
))
|
|
prompt_label.setWordWrap(True)
|
|
layout.addWidget(prompt_label)
|
|
self.assistant_prompt = QPlainTextEdit()
|
|
self.assistant_prompt.setMinimumHeight(180)
|
|
layout.addWidget(self.assistant_prompt, 1)
|
|
reset_prompt = QPushButton(t("Reset to default"))
|
|
reset_prompt.clicked.connect(
|
|
lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt())
|
|
)
|
|
layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight)
|
|
|
|
area = QScrollArea()
|
|
area.setWidgetResizable(True)
|
|
area.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
area.setWidget(page)
|
|
return area
|
|
|
|
def _meeting_tab(self):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
intro = QLabel(t(
|
|
"A meeting is recorded from two devices at once: your microphone and "
|
|
"whatever comes out of your speakers. Nothing has to guess who was "
|
|
"speaking, because the two never share a channel."
|
|
))
|
|
intro.setWordWrap(True)
|
|
layout.addWidget(intro)
|
|
|
|
sources = QGroupBox(t("Sound"))
|
|
sources_form = QFormLayout(sources)
|
|
self.meeting_mic = QComboBox()
|
|
self.meeting_mic.addItem(t("Same as dictation"), "")
|
|
for name, desc in audio.list_sources():
|
|
self.meeting_mic.addItem(desc, name)
|
|
sources_form.addRow(t("Microphone"), self.meeting_mic)
|
|
|
|
self.meeting_system = QComboBox()
|
|
self.meeting_system.addItem(t("Current output"), "")
|
|
for name, desc in audio.list_monitors():
|
|
self.meeting_system.addItem(desc, name)
|
|
sources_form.addRow(t("The other participants"), self.meeting_system)
|
|
|
|
note = QLabel(t(
|
|
"Wear headphones if you can. Through speakers your microphone hears "
|
|
"the other side as well, and although a line that lands on both "
|
|
"channels at once is dropped again, the repair is never as clean as "
|
|
"not needing it."
|
|
))
|
|
note.setWordWrap(True)
|
|
sources_form.addRow(note)
|
|
layout.addWidget(sources)
|
|
|
|
people = QGroupBox(t("Who is talking"))
|
|
people_form = QFormLayout(people)
|
|
self.meeting_self_name = QLineEdit()
|
|
self.meeting_self_name.setPlaceholderText(t("Me"))
|
|
people_form.addRow(t("You"), self.meeting_self_name)
|
|
self.meeting_other_name = QLineEdit()
|
|
self.meeting_other_name.setPlaceholderText(t("Other side"))
|
|
people_form.addRow(t("The other end"), self.meeting_other_name)
|
|
self.meeting_participants = QPlainTextEdit()
|
|
self.meeting_participants.setMaximumHeight(70)
|
|
self.meeting_participants.setPlaceholderText(t("One name per line"))
|
|
people_form.addRow(t("Expected"), self.meeting_participants)
|
|
people_note = QLabel(t(
|
|
"Everyone on the far end shares one label: they reach you as a single "
|
|
"mixed signal. The names go to the transcription model so they come "
|
|
"out spelled right, and to the minutes, which may use one for a line "
|
|
"only when the conversation itself makes clear who was speaking."
|
|
))
|
|
people_note.setWordWrap(True)
|
|
people_form.addRow(people_note)
|
|
layout.addWidget(people)
|
|
|
|
models = QGroupBox(t("Minutes"))
|
|
models_form = QFormLayout(models)
|
|
self.meeting_model = QComboBox()
|
|
self.meeting_model.setEditable(True)
|
|
self.meeting_model.addItems(MEETING_MODELS)
|
|
models_form.addRow(t("Model"), self.meeting_model)
|
|
self.meeting_reasoning = QComboBox()
|
|
for label, value in REASONING_LEVELS:
|
|
self.meeting_reasoning.addItem(t(label), value)
|
|
self.meeting_reasoning.setToolTip(t(
|
|
"Unlike cleanup, this one is worth some thinking: it has to hold a "
|
|
"whole meeting in its head and work out what was actually decided."
|
|
))
|
|
models_form.addRow(t("Thinking"), self.meeting_reasoning)
|
|
self.meeting_language = QComboBox()
|
|
self.meeting_language.addItem(t("Same as dictation"), "")
|
|
for label, code in LANGUAGES:
|
|
self.meeting_language.addItem(t(label), code)
|
|
models_form.addRow(t("Speech language"), self.meeting_language)
|
|
self.meeting_cleanup = QCheckBox(t("Clean the transcript up first"))
|
|
self.meeting_cleanup.setToolTip(t(
|
|
"Runs the cleanup model over the transcript before the minutes are "
|
|
"written, keeping the timestamps and the speaker labels."
|
|
))
|
|
models_form.addRow("", self.meeting_cleanup)
|
|
layout.addWidget(models)
|
|
|
|
recording = QGroupBox(t("Recording"))
|
|
recording_form = QFormLayout(recording)
|
|
self.meeting_max_minutes = QSpinBox()
|
|
self.meeting_max_minutes.setRange(5, 600)
|
|
self.meeting_max_minutes.setSuffix(t(" min"))
|
|
recording_form.addRow(t("Longest meeting"), self.meeting_max_minutes)
|
|
self.meeting_keep_audio = QCheckBox(
|
|
t("Keep the recording after the minutes are written")
|
|
)
|
|
self.meeting_keep_audio.setToolTip(t(
|
|
"A run that fails keeps its recording either way, so it can be tried "
|
|
"again from the Minutes tab. This is about the ones that worked."
|
|
))
|
|
recording_form.addRow("", self.meeting_keep_audio)
|
|
|
|
self.meeting_shortcut = self._shortcut_box(t("none"))
|
|
install = QPushButton(t("Install as a KDE shortcut"))
|
|
install.clicked.connect(self._install_meeting_shortcut)
|
|
remove = QPushButton(t("Remove"))
|
|
remove.clicked.connect(self._remove_meeting_shortcut)
|
|
recording_form.addRow(t("Shortcut"),
|
|
self._row(self.meeting_shortcut, install, remove))
|
|
self.meeting_shortcut_status = QLabel("")
|
|
self.meeting_shortcut_status.setWordWrap(True)
|
|
recording_form.addRow(self.meeting_shortcut_status)
|
|
layout.addWidget(recording)
|
|
|
|
prompt_label = QLabel(t("System instruction given to the minutes model."))
|
|
prompt_label.setWordWrap(True)
|
|
layout.addWidget(prompt_label)
|
|
self.meeting_prompt = QPlainTextEdit()
|
|
self.meeting_prompt.setMinimumHeight(200)
|
|
layout.addWidget(self.meeting_prompt, 1)
|
|
reset = QPushButton(t("Reset to default"))
|
|
reset.clicked.connect(
|
|
lambda: self.meeting_prompt.setPlainText(cfg.default_meeting_prompt())
|
|
)
|
|
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
|
|
|
|
# Everything above is more than one screenful; let it scroll rather than
|
|
# squeezing the prompt box down to nothing.
|
|
area = QScrollArea()
|
|
area.setWidgetResizable(True)
|
|
area.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
area.setWidget(page)
|
|
return area
|
|
|
|
def _minutes_tab(self):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
|
|
self.minutes_list = QListWidget()
|
|
self.minutes_list.setWordWrap(True)
|
|
self.minutes_list.setMaximumHeight(170)
|
|
self.minutes_list.currentItemChanged.connect(self._show_minutes)
|
|
layout.addWidget(self.minutes_list)
|
|
|
|
self.minutes_status = QLabel("")
|
|
self.minutes_status.setWordWrap(True)
|
|
layout.addWidget(self.minutes_status)
|
|
|
|
self.minutes_view = QPlainTextEdit()
|
|
self.minutes_view.setReadOnly(True)
|
|
self.minutes_view.setPlaceholderText(t("Pick a meeting to read it."))
|
|
layout.addWidget(self.minutes_view, 1)
|
|
|
|
copy = QPushButton(t("Copy"))
|
|
copy.clicked.connect(
|
|
lambda: QGuiApplication.clipboard().setText(self.minutes_view.toPlainText())
|
|
)
|
|
self.minutes_retry = QPushButton(t("Write it up"))
|
|
self.minutes_retry.clicked.connect(self._retry_minutes)
|
|
self.minutes_retry.setEnabled(False)
|
|
folder = QPushButton(t("Open the folder"))
|
|
folder.clicked.connect(
|
|
lambda: QDesktopServices.openUrl(
|
|
QUrl.fromLocalFile(str(cfg.MEETINGS_DIR))
|
|
)
|
|
)
|
|
delete = QPushButton(t("Delete selected"))
|
|
delete.clicked.connect(self._delete_minutes)
|
|
reload_ = QPushButton(t("Reload"))
|
|
reload_.clicked.connect(self._load_minutes)
|
|
row = QHBoxLayout()
|
|
row.addWidget(copy)
|
|
row.addWidget(self.minutes_retry)
|
|
row.addStretch(1)
|
|
row.addWidget(folder)
|
|
row.addWidget(delete)
|
|
row.addWidget(reload_)
|
|
layout.addLayout(row)
|
|
return page
|
|
|
|
def _file_tab(self):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
intro = QLabel(t("Transcribe an existing audio or video file with the same models."))
|
|
intro.setWordWrap(True)
|
|
layout.addWidget(intro)
|
|
|
|
pick = QPushButton(t("Choose file…"))
|
|
pick.clicked.connect(self._choose_file)
|
|
self.file_label = QLabel(t("No file selected"))
|
|
self.file_label.setWordWrap(True)
|
|
row = QHBoxLayout()
|
|
row.addWidget(pick)
|
|
row.addWidget(self.file_label, 1)
|
|
layout.addLayout(row)
|
|
|
|
self.file_timestamps = QCheckBox(t("Add timestamps"))
|
|
self.file_timestamps.setToolTip(
|
|
t("Prefixes every segment with [mm:ss]. Uses whisper-1 on whichever "
|
|
"provider you picked, the only model that returns segment times.")
|
|
)
|
|
layout.addWidget(self.file_timestamps)
|
|
|
|
self.file_cleanup = QCheckBox(t("Run the cleanup model afterwards"))
|
|
self.file_cleanup.setToolTip(
|
|
t("With its own rules, under Cleanup rules: written for subtitles, so "
|
|
"the lines keep their place and nothing is shortened.")
|
|
)
|
|
layout.addWidget(self.file_cleanup)
|
|
|
|
self.file_run = QPushButton(t("Transcribe"))
|
|
self.file_run.clicked.connect(self._run_file)
|
|
self.file_stop = QPushButton(t("Stop"))
|
|
self.file_stop.clicked.connect(self.transcriber.stop)
|
|
self.file_stop.setEnabled(False)
|
|
run_row = QHBoxLayout()
|
|
run_row.addWidget(self.file_run)
|
|
run_row.addWidget(self.file_stop)
|
|
run_row.addStretch(1)
|
|
layout.addLayout(run_row)
|
|
|
|
self.file_status = QLabel("")
|
|
self.file_status.setWordWrap(True)
|
|
layout.addWidget(self.file_status)
|
|
|
|
self.file_output = QPlainTextEdit()
|
|
self.file_output.setPlaceholderText("…")
|
|
layout.addWidget(self.file_output, 1)
|
|
|
|
copy = QPushButton(t("Copy"))
|
|
copy.clicked.connect(
|
|
lambda: QGuiApplication.clipboard().setText(self.file_output.toPlainText())
|
|
)
|
|
save = QPushButton(t("Save as .txt"))
|
|
save.clicked.connect(self._save_transcript)
|
|
self.file_save_srt = QPushButton(t("Save as .srt"))
|
|
self.file_save_srt.setToolTip(
|
|
t("Subtitles, timed from the segments. Needs the timestamps option.")
|
|
)
|
|
self.file_save_srt.setEnabled(False)
|
|
self.file_save_srt.clicked.connect(self._save_subtitles)
|
|
out_row = QHBoxLayout()
|
|
out_row.addWidget(copy)
|
|
out_row.addWidget(save)
|
|
out_row.addWidget(self.file_save_srt)
|
|
out_row.addStretch(1)
|
|
layout.addLayout(out_row)
|
|
return page
|
|
|
|
def _shortcut_tab(self):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
form = QFormLayout()
|
|
self.shortcut = self._shortcut_box("Ctrl+Space")
|
|
form.addRow(t("Shortcut"), self.shortcut)
|
|
layout.addLayout(form)
|
|
|
|
install = QPushButton(t("Install as a KDE shortcut"))
|
|
install.clicked.connect(self._install_shortcut)
|
|
remove = QPushButton(t("Remove"))
|
|
remove.clicked.connect(self._remove_shortcut)
|
|
row = QHBoxLayout()
|
|
row.addWidget(install)
|
|
row.addWidget(remove)
|
|
row.addStretch(1)
|
|
layout.addLayout(row)
|
|
|
|
self.shortcut_status = QLabel("")
|
|
self.shortcut_status.setWordWrap(True)
|
|
layout.addWidget(self.shortcut_status)
|
|
|
|
self.evdev_enabled = QCheckBox(t(
|
|
"Use the built-in listener (/dev/input), for when the KDE shortcut is "
|
|
"not active yet"
|
|
))
|
|
self.evdev_enabled.setToolTip(t(
|
|
"Works immediately, no session restart. The only difference: the key "
|
|
"combination also reaches the focused application."
|
|
))
|
|
layout.addWidget(self.evdev_enabled)
|
|
|
|
note = QLabel(t(
|
|
"KWin only reads shortcut settings at startup. After 'Install' the "
|
|
"shortcut shows up under System Settings → Shortcuts, but it will not "
|
|
"fire until you log out and back in. Until then, use the built-in listener."
|
|
))
|
|
note.setWordWrap(True)
|
|
layout.addWidget(note)
|
|
layout.addStretch(1)
|
|
return page
|
|
|
|
def _history_tab(self):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
self.history = QListWidget()
|
|
self.history.setWordWrap(True)
|
|
self.history.setSelectionMode(
|
|
QAbstractItemView.SelectionMode.ExtendedSelection
|
|
)
|
|
self.history.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.history.customContextMenuRequested.connect(self._history_menu)
|
|
delete_key = QShortcut(QKeySequence.StandardKey.Delete, self.history)
|
|
delete_key.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
delete_key.activated.connect(self._delete_history)
|
|
layout.addWidget(self.history, 1)
|
|
|
|
self.history_limit = QSpinBox()
|
|
self.history_limit.setRange(0, 10000)
|
|
self.history_limit.setSpecialValueText(t("no limit"))
|
|
self.history_limit.setSuffix(t(" entries"))
|
|
self.history_limit.setToolTip(t(
|
|
"Once the history passes this many entries, the oldest one is dropped "
|
|
"every time a new one arrives. Set it to 0 to keep everything."
|
|
))
|
|
limit_row = QHBoxLayout()
|
|
limit_row.addWidget(QLabel(t("Keep at most")))
|
|
limit_row.addWidget(self.history_limit)
|
|
limit_row.addStretch(1)
|
|
layout.addLayout(limit_row)
|
|
|
|
copy = QPushButton(t("Copy selected to clipboard"))
|
|
copy.clicked.connect(self._copy_history)
|
|
delete = QPushButton(t("Delete selected"))
|
|
delete.clicked.connect(self._delete_history)
|
|
clear = QPushButton(t("Clear history"))
|
|
clear.clicked.connect(self._clear_history)
|
|
reload_ = QPushButton(t("Reload"))
|
|
reload_.clicked.connect(self._load_history)
|
|
row = QHBoxLayout()
|
|
row.addWidget(copy)
|
|
row.addWidget(delete)
|
|
row.addStretch(1)
|
|
row.addWidget(clear)
|
|
row.addWidget(reload_)
|
|
layout.addLayout(row)
|
|
return page
|
|
|
|
@staticmethod
|
|
def _prompt_page(tabs, title, intro, default):
|
|
"""A tab holding one editable prompt, and returns its box."""
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
label = QLabel(intro)
|
|
label.setWordWrap(True)
|
|
layout.addWidget(label)
|
|
box = QPlainTextEdit()
|
|
layout.addWidget(box, 1)
|
|
reset = QPushButton(t("Reset to default"))
|
|
reset.clicked.connect(lambda: box.setPlainText(default()))
|
|
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
|
|
tabs.addTab(page, title)
|
|
return box
|
|
|
|
@staticmethod
|
|
def _shortcut_box(placeholder=""):
|
|
"""The field a global shortcut is typed or picked in."""
|
|
box = QComboBox()
|
|
box.setEditable(True)
|
|
box.addItems(SHORTCUTS)
|
|
box.setCurrentText("")
|
|
if placeholder:
|
|
box.lineEdit().setPlaceholderText(placeholder)
|
|
return box
|
|
|
|
@staticmethod
|
|
def _row(*widgets):
|
|
"""Widgets side by side in one form row; the first one takes the space."""
|
|
layout = QHBoxLayout()
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
for index, widget in enumerate(widgets):
|
|
layout.addWidget(widget, 1 if index == 0 else 0)
|
|
holder = QWidget()
|
|
holder.setLayout(layout)
|
|
return holder
|
|
|
|
# ---- load / save ----------------------------------------------------
|
|
|
|
def _load(self):
|
|
conf = self.conf
|
|
self._select_data(self.ui_language, conf["ui_language"])
|
|
self._select_data(self.mic, conf["mic_target"])
|
|
self._select_data(self.language, conf["language"])
|
|
self.auto_paste.setChecked(conf["auto_paste"])
|
|
self.paste_shortcut.setCurrentText(conf["paste_shortcut"])
|
|
self.restore_clipboard.setChecked(conf["restore_clipboard"])
|
|
self._select_data(self.corner, conf["overlay_corner"])
|
|
self.max_seconds.setValue(conf["max_seconds"])
|
|
self.skip_silent.setChecked(conf["skip_silent"])
|
|
self.silence_db.setValue(int(conf["silence_db"]))
|
|
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
|
self.keep_audio.setChecked(conf["keep_audio"])
|
|
|
|
self.openai_key.setText(conf["openai_api_key"])
|
|
self.openrouter_key.setText(conf["openrouter_api_key"])
|
|
self._models = {"openai": conf["transcribe_model"],
|
|
"openrouter": conf["openrouter_transcribe_model"]}
|
|
self._shown_provider = ""
|
|
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
|
self._provider_changed() # selecting index 0 fires no signal
|
|
self.local_gpu.setChecked(conf["local_gpu"])
|
|
self.local_preload.setChecked(conf["local_preload"])
|
|
self.local_threads.setValue(int(conf["local_threads"]))
|
|
self.local_whisper.load(conf["local_model"])
|
|
|
|
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
|
self._select_data(self.cleanup_provider, conf["cleanup_provider"])
|
|
self._cleanup_provider_changed()
|
|
self.cleanup_model.setCurrentText(conf["cleanup_model"])
|
|
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
|
|
self.local_llm_gpu.setChecked(conf["local_llm_gpu"])
|
|
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
|
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
|
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
|
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
|
|
self.file_cleanup_prompt.setPlainText(
|
|
conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt()
|
|
)
|
|
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
|
|
|
|
self.assistant_shortcut.setCurrentText(conf["assistant_shortcut"])
|
|
self._select_data(self.assistant_provider, conf["assistant_provider"])
|
|
self.assistant_model.setCurrentText(conf["assistant_model"])
|
|
self._select_data(self.assistant_permission, conf["assistant_permission_mode"])
|
|
self.assistant_codex_model.setCurrentText(conf["assistant_codex_model"])
|
|
self._select_data(self.assistant_codex_sandbox, conf["assistant_codex_sandbox"])
|
|
self.assistant_openrouter_model.setCurrentText(conf["assistant_openrouter_model"])
|
|
self._assistant_provider_changed() # selecting index 0 fires no signal
|
|
self._select_data(self.assistant_reasoning, conf["assistant_reasoning"])
|
|
self.assistant_dir.setText(conf["assistant_dir"])
|
|
self.assistant_timeout.setValue(int(conf["assistant_timeout"]))
|
|
self.assistant_session_minutes.setValue(int(conf["assistant_session_minutes"]))
|
|
self.assistant_paste.setChecked(conf["assistant_paste"])
|
|
self.assistant_cleanup.setChecked(conf["assistant_cleanup"])
|
|
self.assistant_prompt.setPlainText(
|
|
conf["assistant_prompt"] or cfg.default_assistant_prompt()
|
|
)
|
|
|
|
self._select_data(self.meeting_mic, conf["meeting_mic_target"])
|
|
self._select_data(self.meeting_system, conf["meeting_system_target"])
|
|
self.meeting_self_name.setText(conf["meeting_self_name"])
|
|
self.meeting_other_name.setText(conf["meeting_other_name"])
|
|
self.meeting_participants.setPlainText(conf["meeting_participants"])
|
|
self.meeting_model.setCurrentText(conf["meeting_model"])
|
|
self._select_data(self.meeting_reasoning, conf["meeting_reasoning"])
|
|
self._select_data(self.meeting_language, conf["meeting_language"])
|
|
self.meeting_cleanup.setChecked(conf["meeting_cleanup"])
|
|
self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60))
|
|
self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"])
|
|
self.meeting_shortcut.setCurrentText(conf["meeting_shortcut"])
|
|
self.meeting_prompt.setPlainText(
|
|
conf["meeting_prompt"] or cfg.default_meeting_prompt()
|
|
)
|
|
|
|
self.file_timestamps.setChecked(conf["file_timestamps"])
|
|
self.file_cleanup.setChecked(conf["file_cleanup"])
|
|
self.file_path = ""
|
|
|
|
self.shortcut.setCurrentText(conf["shortcut"])
|
|
self.evdev_enabled.setChecked(conf["evdev_hotkey"])
|
|
|
|
self.history_limit.setValue(max(0, int(conf["history_limit"])))
|
|
|
|
self._refresh_shortcut_status()
|
|
self._refresh_meeting_shortcut_status()
|
|
self._refresh_ask_shortcut_status()
|
|
self._refresh_assistant_status()
|
|
self._load_history()
|
|
self._load_minutes()
|
|
|
|
def _save(self):
|
|
conf = self.conf
|
|
conf["ui_language"] = self.ui_language.currentData() or "auto"
|
|
conf["mic_target"] = self.mic.currentData() or ""
|
|
conf["language"] = self.language.currentData() or "auto"
|
|
conf["auto_paste"] = self.auto_paste.isChecked()
|
|
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
|
|
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
|
|
conf["overlay_corner"] = self.corner.currentData() or "bottom-left"
|
|
conf["max_seconds"] = self.max_seconds.value()
|
|
conf["skip_silent"] = self.skip_silent.isChecked()
|
|
conf["silence_db"] = float(self.silence_db.value())
|
|
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
|
conf["keep_audio"] = self.keep_audio.isChecked()
|
|
|
|
conf["openai_api_key"] = self.openai_key.text().strip()
|
|
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
|
|
|
|
provider = self.transcribe_provider.currentData() or "local"
|
|
if provider in TRANSCRIBE_MODELS:
|
|
self._models[provider] = self.transcribe_model.currentText().strip()
|
|
conf["transcribe_provider"] = provider
|
|
for key, name in (("openai", "transcribe_model"),
|
|
("openrouter", "openrouter_transcribe_model")):
|
|
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
|
|
conf["local_model"] = self.local_whisper.selected()
|
|
conf["local_gpu"] = self.local_gpu.isChecked()
|
|
conf["local_preload"] = self.local_preload.isChecked()
|
|
conf["local_threads"] = self.local_threads.value()
|
|
|
|
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
|
|
conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter"
|
|
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
|
|
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
|
|
conf["local_llm_model"] = self.local_llm.selected()
|
|
conf["local_llm_repo"] = self.local_llm.repository()
|
|
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
|
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
|
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
|
|
|
# Store an empty prompt when it matches the default, so switching the
|
|
# interface language also switches the prompt language.
|
|
prompt = self.cleanup_prompt.toPlainText().strip()
|
|
conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt
|
|
file_prompt = self.file_cleanup_prompt.toPlainText().strip()
|
|
conf["file_cleanup_prompt"] = ("" if file_prompt == cfg.default_file_cleanup_prompt()
|
|
else file_prompt)
|
|
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
|
|
|
|
conf["assistant_shortcut"] = self.assistant_shortcut.currentText().strip()
|
|
conf["assistant_provider"] = self.assistant_provider.currentData() or "claude"
|
|
conf["assistant_model"] = (self.assistant_model.currentText().strip()
|
|
or cfg.DEFAULTS["assistant_model"])
|
|
conf["assistant_permission_mode"] = (self.assistant_permission.currentData()
|
|
or "auto")
|
|
# The editable box shows a label for "no choice", which must not be
|
|
# stored as if it were a model id.
|
|
codex_model = self.assistant_codex_model.currentText().strip()
|
|
conf["assistant_codex_model"] = (
|
|
"" if codex_model == t("Codex's own default") else codex_model
|
|
)
|
|
conf["assistant_codex_sandbox"] = (self.assistant_codex_sandbox.currentData()
|
|
or "workspace-write")
|
|
conf["assistant_openrouter_model"] = (
|
|
self.assistant_openrouter_model.currentText().strip()
|
|
or cfg.DEFAULTS["assistant_openrouter_model"]
|
|
)
|
|
conf["assistant_reasoning"] = self.assistant_reasoning.currentData() or ""
|
|
conf["assistant_dir"] = self.assistant_dir.text().strip()
|
|
conf["assistant_timeout"] = self.assistant_timeout.value()
|
|
conf["assistant_session_minutes"] = self.assistant_session_minutes.value()
|
|
conf["assistant_paste"] = self.assistant_paste.isChecked()
|
|
conf["assistant_cleanup"] = self.assistant_cleanup.isChecked()
|
|
assistant_prompt = self.assistant_prompt.toPlainText().strip()
|
|
conf["assistant_prompt"] = ("" if assistant_prompt == cfg.default_assistant_prompt()
|
|
else assistant_prompt)
|
|
|
|
conf["meeting_mic_target"] = self.meeting_mic.currentData() or ""
|
|
conf["meeting_system_target"] = self.meeting_system.currentData() or ""
|
|
conf["meeting_self_name"] = self.meeting_self_name.text().strip()
|
|
conf["meeting_other_name"] = self.meeting_other_name.text().strip()
|
|
conf["meeting_participants"] = self.meeting_participants.toPlainText().strip()
|
|
conf["meeting_model"] = (self.meeting_model.currentText().strip()
|
|
or cfg.DEFAULTS["meeting_model"])
|
|
conf["meeting_reasoning"] = self.meeting_reasoning.currentData() or ""
|
|
conf["meeting_language"] = self.meeting_language.currentData() or ""
|
|
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
|
|
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
|
|
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
|
|
conf["meeting_shortcut"] = self.meeting_shortcut.currentText().strip()
|
|
meeting_prompt = self.meeting_prompt.toPlainText().strip()
|
|
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt()
|
|
else meeting_prompt)
|
|
|
|
conf["file_timestamps"] = self.file_timestamps.isChecked()
|
|
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
|
|
|
conf["shortcut"] = self.shortcut.currentText().strip() or "Ctrl+Space"
|
|
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
|
|
conf["history_limit"] = self.history_limit.value()
|
|
conf.save()
|
|
# A lowered limit should bite now, not on the next dictation.
|
|
try:
|
|
cfg.trim_history(conf["history_limit"])
|
|
except OSError as exc:
|
|
print(f"dikte: could not trim the history ({exc})")
|
|
self._load_history() # the trim may just have dropped rows from the list
|
|
self.applied.emit()
|
|
QMessageBox.information(self, t("Dikte Settings"), t("Saved successfully."))
|
|
|
|
@staticmethod
|
|
def _select_data(combo, value):
|
|
index = combo.findData(value)
|
|
combo.setCurrentIndex(index if index >= 0 else 0)
|
|
|
|
# ---- api helpers -----------------------------------------------------
|
|
|
|
def _provider_changed(self):
|
|
"""Swap the model box over to the newly chosen provider's own model."""
|
|
if self._shown_provider in TRANSCRIBE_MODELS:
|
|
self._models[self._shown_provider] = self.transcribe_model.currentText().strip()
|
|
provider = self.transcribe_provider.currentData() or "local"
|
|
self._shown_provider = provider
|
|
local = provider == "local"
|
|
self.hosted_stt.setVisible(not local)
|
|
self.local_whisper.setVisible(local)
|
|
self.local_options.setVisible(local)
|
|
if local:
|
|
return
|
|
self.transcribe_model.clear()
|
|
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
|
self.transcribe_model.setCurrentText(self._models[provider])
|
|
self.transcribe_status.setText("")
|
|
|
|
def _cleanup_provider_changed(self):
|
|
local = (self.cleanup_provider.currentData() or "openrouter") == "local"
|
|
self.hosted_cleanup.setVisible(not local)
|
|
self.local_llm.setVisible(local)
|
|
self.local_llm_options.setVisible(local)
|
|
|
|
def _load_transcribe_models(self):
|
|
"""The model list of whichever provider is selected."""
|
|
provider = self.transcribe_provider.currentData() or "openai"
|
|
self.refresh_transcribe_models.setEnabled(False)
|
|
self.transcribe_status.setText(t("Fetching model list…"))
|
|
openai_key = self.openai_key.text().strip() or self.conf.openai_key()
|
|
openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
|
|
base = self.conf["openai_base_url"]
|
|
|
|
def work():
|
|
try:
|
|
models = (api.openrouter_models(openrouter_key, transcription=True)
|
|
if provider == "openrouter"
|
|
else api.openai_models(openai_key, base))
|
|
self._transcribe_models_loaded.emit(models, "")
|
|
except api.ApiError as exc:
|
|
self._transcribe_models_loaded.emit([], str(exc))
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _on_transcribe_models_loaded(self, models, error):
|
|
self.refresh_transcribe_models.setEnabled(True)
|
|
if error:
|
|
self.transcribe_status.setText(t("Could not fetch the list: {error}", error=error))
|
|
return
|
|
current = self.transcribe_model.currentText()
|
|
self.transcribe_model.clear()
|
|
self.transcribe_model.addItems(models)
|
|
self.transcribe_model.setCurrentText(current)
|
|
self.transcribe_status.setText(t("{count} models loaded.", count=len(models)))
|
|
|
|
def _load_models(self):
|
|
self.refresh_models.setEnabled(False)
|
|
self.models_label.setText(t("Fetching model list…"))
|
|
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
|
|
|
|
def work():
|
|
try:
|
|
self._models_loaded.emit(api.openrouter_models(key), "")
|
|
except api.ApiError as exc:
|
|
self._models_loaded.emit([], str(exc))
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _on_models_loaded(self, models, error):
|
|
self.refresh_models.setEnabled(True)
|
|
if error:
|
|
self.models_label.setText(t("Could not fetch the list: {error}", error=error))
|
|
return
|
|
for combo in (self.cleanup_model, self.meeting_model):
|
|
current = combo.currentText()
|
|
combo.clear()
|
|
combo.addItems(models)
|
|
combo.setCurrentText(current)
|
|
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
|
|
|
def _test_openai(self):
|
|
self.test_button.setEnabled(False)
|
|
self.test_label.setText(t("Trying…"))
|
|
key = self.openai_key.text().strip() or self.conf.openai_key()
|
|
base = self.conf["openai_base_url"]
|
|
|
|
def work():
|
|
try:
|
|
models = api.openai_models(key, base)
|
|
self._test_done.emit(
|
|
True, t("Connection works. {count} audio models visible.", count=len(models))
|
|
)
|
|
except api.ApiError as exc:
|
|
self._test_done.emit(False, str(exc))
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _test_openrouter(self):
|
|
self.or_test_button.setEnabled(False)
|
|
self.or_test_label.setText(t("Trying…"))
|
|
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
|
|
|
|
def work():
|
|
try:
|
|
self._or_test_done.emit(True, api.openrouter_key_status(key))
|
|
except api.ApiError as exc:
|
|
self._or_test_done.emit(False, str(exc))
|
|
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def _on_or_test_done(self, ok, message):
|
|
self.or_test_button.setEnabled(True)
|
|
self.or_test_label.setText(("✓ " if ok else "✗ ") + message)
|
|
|
|
def _on_test_done(self, ok, message):
|
|
self.test_button.setEnabled(True)
|
|
self.test_label.setText(("✓ " if ok else "✗ ") + message)
|
|
|
|
# ---- audio file ------------------------------------------------------
|
|
|
|
def _choose_file(self):
|
|
start = self.conf["file_last_dir"] or os.path.expanduser("~")
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, t("Select an audio file"), start,
|
|
f"{t('Audio and video files')} ({AUDIO_FILTER});;{t('All files')} (*)",
|
|
)
|
|
if not path:
|
|
return
|
|
self.file_path = path
|
|
self.file_label.setText(os.path.basename(path))
|
|
self.conf["file_last_dir"] = os.path.dirname(path)
|
|
|
|
def _run_file(self):
|
|
if not getattr(self, "file_path", "") or self.transcriber.busy:
|
|
return
|
|
self.file_output.clear()
|
|
self.file_segments = []
|
|
self.file_save_srt.setEnabled(False)
|
|
self.file_run.setEnabled(False)
|
|
self.file_stop.setEnabled(True)
|
|
self.transcriber.start(
|
|
self.file_path,
|
|
self.file_timestamps.isChecked(),
|
|
self.file_cleanup.isChecked(),
|
|
)
|
|
|
|
def _on_file_progress(self, message):
|
|
self.file_status.setText(message)
|
|
if message == t("Stopped."):
|
|
self._file_idle()
|
|
|
|
def _on_file_finished(self, text, segments):
|
|
self.file_output.setPlainText(text)
|
|
self.file_segments = segments
|
|
self.file_save_srt.setEnabled(bool(segments))
|
|
self.file_status.setText(t("Done: {chars} characters.", chars=len(text)))
|
|
self._file_idle()
|
|
|
|
def _on_file_failed(self, error):
|
|
self.file_status.setText(t("Failed: {error}", error=error))
|
|
self._file_idle()
|
|
|
|
def _file_idle(self):
|
|
self.file_run.setEnabled(True)
|
|
self.file_stop.setEnabled(False)
|
|
|
|
def _save_transcript(self):
|
|
self._write_transcript(self.file_output.toPlainText(), ".txt",
|
|
f"{t('Text files')} (*.txt)")
|
|
|
|
def _save_subtitles(self):
|
|
srt = filetranscribe.to_srt(self.file_output.toPlainText(),
|
|
getattr(self, "file_segments", []))
|
|
if not srt:
|
|
self.file_status.setText(t("No timestamped lines to turn into subtitles."))
|
|
return
|
|
self._write_transcript(srt, ".srt", f"{t('Subtitle files')} (*.srt)")
|
|
|
|
def _write_transcript(self, text, suffix, file_filter):
|
|
if not text:
|
|
return
|
|
base = os.path.splitext(os.path.basename(getattr(self, "file_path", "")))[0]
|
|
start = os.path.join(self.conf["file_last_dir"] or os.path.expanduser("~"),
|
|
f"{base or 'transcript'}{suffix}")
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, t("Save transcript"), start, file_filter
|
|
)
|
|
if not path:
|
|
return
|
|
if not path.lower().endswith(suffix):
|
|
path += suffix
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
self.file_status.setText(t("Saved: {path}", path=path))
|
|
except OSError as exc:
|
|
self.file_status.setText(t("Failed: {error}", error=exc))
|
|
|
|
# ---- shortcut --------------------------------------------------------
|
|
|
|
def _install_shortcut(self):
|
|
combo = self.shortcut.currentText().strip() or "Ctrl+Space"
|
|
clashes = hotkey.conflicting_shortcuts(combo)
|
|
if clashes:
|
|
answer = QMessageBox.question(
|
|
self, t("Shortcut conflict"),
|
|
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
|
|
shortcut=combo, list="\n".join(clashes[:6])),
|
|
)
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
ok, message = hotkey.install_shortcut(combo, self.launch_command)
|
|
QMessageBox.information(self, t("Shortcut"), message)
|
|
if ok:
|
|
self.conf["shortcut"] = combo
|
|
self.conf.save()
|
|
self._refresh_shortcut_status()
|
|
|
|
def _remove_shortcut(self):
|
|
hotkey.remove_shortcut()
|
|
self._refresh_shortcut_status()
|
|
|
|
def _refresh_shortcut_status(self):
|
|
current = hotkey.shortcut_status()
|
|
self.shortcut_status.setText(
|
|
t("Registered in {desktop}: {shortcut}",
|
|
desktop=hotkey.desktop_name(), shortcut=current) if current
|
|
else t("No global shortcut installed.")
|
|
)
|
|
|
|
def _install_meeting_shortcut(self):
|
|
combo = self.meeting_shortcut.currentText().strip()
|
|
if not combo:
|
|
QMessageBox.information(self, t("Shortcut"),
|
|
t("Type a key combination first."))
|
|
return
|
|
clashes = hotkey.conflicting_shortcuts(combo, hotkey.MEETING_DESKTOP_ID)
|
|
if clashes:
|
|
answer = QMessageBox.question(
|
|
self, t("Shortcut conflict"),
|
|
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
|
|
shortcut=combo, list="\n".join(clashes[:6])),
|
|
)
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
ok, message = hotkey.install_shortcut(
|
|
combo, self.meeting_command, name="Dikte: start/end a meeting recording",
|
|
desktop_id=hotkey.MEETING_DESKTOP_ID,
|
|
)
|
|
QMessageBox.information(self, t("Shortcut"), message)
|
|
if ok:
|
|
self.conf["meeting_shortcut"] = combo
|
|
self.conf.save()
|
|
self._refresh_meeting_shortcut_status()
|
|
|
|
def _remove_meeting_shortcut(self):
|
|
hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID)
|
|
self._refresh_meeting_shortcut_status()
|
|
|
|
def _refresh_meeting_shortcut_status(self):
|
|
current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID)
|
|
self.meeting_shortcut_status.setText(
|
|
t("Registered in {desktop}: {shortcut}",
|
|
desktop=hotkey.desktop_name(), shortcut=current) if current
|
|
else t("No global shortcut installed. The tray menu starts a meeting too.")
|
|
)
|
|
|
|
# ---- Claude ----------------------------------------------------------
|
|
|
|
def _install_ask_shortcut(self):
|
|
combo = self.assistant_shortcut.currentText().strip()
|
|
if not combo:
|
|
QMessageBox.information(self, t("Shortcut"),
|
|
t("Type a key combination first."))
|
|
return
|
|
clashes = hotkey.conflicting_shortcuts(combo, hotkey.ASK_DESKTOP_ID)
|
|
if clashes:
|
|
answer = QMessageBox.question(
|
|
self, t("Shortcut conflict"),
|
|
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
|
|
shortcut=combo, list="\n".join(clashes[:6])),
|
|
)
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
ok, message = hotkey.install_shortcut(
|
|
combo, self.ask_command, name="Dikte: ask Claude Code",
|
|
desktop_id=hotkey.ASK_DESKTOP_ID,
|
|
)
|
|
QMessageBox.information(self, t("Shortcut"), message)
|
|
if ok:
|
|
self.conf["assistant_shortcut"] = combo
|
|
self.conf.save()
|
|
self._refresh_ask_shortcut_status()
|
|
|
|
def _remove_ask_shortcut(self):
|
|
hotkey.remove_shortcut(hotkey.ASK_DESKTOP_ID)
|
|
self._refresh_ask_shortcut_status()
|
|
|
|
def _refresh_ask_shortcut_status(self):
|
|
current = hotkey.shortcut_status(hotkey.ASK_DESKTOP_ID)
|
|
self.assistant_shortcut_status.setText(
|
|
t("Registered in {desktop}: {shortcut}",
|
|
desktop=hotkey.desktop_name(), shortcut=current) if current
|
|
else t("No global shortcut installed. The tray menu asks it too.")
|
|
)
|
|
|
|
def _assistant_provider_changed(self):
|
|
provider = self.assistant_provider.currentData() or "claude"
|
|
self.claude_box.setVisible(provider == "claude")
|
|
self.codex_box.setVisible(provider == "codex")
|
|
self.openrouter_box.setVisible(provider == "openrouter")
|
|
self._refresh_assistant_status()
|
|
|
|
def _refresh_assistant_status(self):
|
|
provider = self.assistant_provider.currentData() or "claude"
|
|
binary = assistant.executable(provider)
|
|
found = shutil.which(binary) if binary else ""
|
|
if not binary:
|
|
self.assistant_found.setText(
|
|
t("Needs no program installed, only the OpenRouter key.")
|
|
)
|
|
elif found:
|
|
self.assistant_found.setText(t("Found: {path}", path=found))
|
|
else:
|
|
self.assistant_found.setText(t(
|
|
"{binary} is not on your PATH, so this cannot run yet. Install "
|
|
"it, or pick another one above.", binary=binary,
|
|
))
|
|
age = assistant.session_age()
|
|
if age is None:
|
|
self.assistant_session_status.setText(t("No conversation going."))
|
|
else:
|
|
self.assistant_session_status.setText(
|
|
t("Last used {minutes} min ago.", minutes=int(age // 60))
|
|
)
|
|
|
|
def _reset_assistant_session(self):
|
|
assistant.clear_session()
|
|
self._refresh_assistant_status()
|
|
|
|
def _choose_assistant_dir(self):
|
|
chosen = QFileDialog.getExistingDirectory(
|
|
self, t("Working directory"),
|
|
self.assistant_dir.text().strip() or os.path.expanduser("~"),
|
|
)
|
|
if chosen:
|
|
self.assistant_dir.setText(chosen)
|
|
|
|
# ---- minutes ---------------------------------------------------------
|
|
|
|
def _load_minutes(self):
|
|
self.minutes_list.clear()
|
|
for row in reversed(cfg.read_meetings()):
|
|
title = row.get("title") or t("Meeting")
|
|
head = f"{row.get('ts', '')} · {meeting.length_label(row.get('duration', 0))}"
|
|
state = MEETING_STATUS.get(row.get("status", ""), "")
|
|
if state:
|
|
head += " · " + t(state)
|
|
item = QListWidgetItem(f"{head}\n{title}")
|
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
|
self.minutes_list.addItem(item)
|
|
if not self.minutes_list.count():
|
|
self.minutes_view.clear()
|
|
self.minutes_retry.setEnabled(False)
|
|
|
|
def _selected_meeting(self):
|
|
item = self.minutes_list.currentItem()
|
|
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
|
|
|
def _show_minutes(self, *_):
|
|
row = self._selected_meeting()
|
|
if not row:
|
|
self.minutes_view.clear()
|
|
self.minutes_retry.setEnabled(False)
|
|
return
|
|
doc_path, _wav = cfg.meeting_paths(row["base"])
|
|
try:
|
|
self.minutes_view.setPlainText(doc_path.read_text(encoding="utf-8"))
|
|
except OSError:
|
|
self.minutes_view.setPlainText(
|
|
row.get("error") or t("Nothing has been written yet.")
|
|
)
|
|
busy = self.meetings is not None and self.meetings.busy
|
|
self.minutes_retry.setEnabled(
|
|
self.meetings is not None and not busy and row.get("status") != "done"
|
|
)
|
|
|
|
def _retry_minutes(self):
|
|
row = self._selected_meeting()
|
|
if not row or self.meetings is None or self.meetings.busy:
|
|
return
|
|
# A row that already has its transcript resumes from there; only a run
|
|
# that never got that far goes back to the audio.
|
|
self.meetings.run(row)
|
|
self.minutes_retry.setEnabled(False)
|
|
self.minutes_status.setText(t("Working…"))
|
|
|
|
def _delete_minutes(self):
|
|
row = self._selected_meeting()
|
|
if not row:
|
|
return
|
|
if self.meetings is not None and self.meetings.running_base == row["base"]:
|
|
QMessageBox.information(self, t("Minutes"),
|
|
t("This one is being written up right now."))
|
|
return
|
|
if not self._confirm(
|
|
t("Delete this meeting, its minutes and its recording?"), t("Minutes")
|
|
):
|
|
return
|
|
try:
|
|
cfg.delete_meetings([row["base"]])
|
|
except OSError as exc:
|
|
QMessageBox.warning(self, t("Minutes"), t("Failed: {error}", error=exc))
|
|
self._load_minutes()
|
|
|
|
def _on_minutes_progress(self, _base, message):
|
|
self.minutes_status.setText(message)
|
|
|
|
def _on_minutes_finished(self, _base, title):
|
|
self.minutes_status.setText(t("Done: {title}", title=title))
|
|
self._load_minutes()
|
|
|
|
def _on_minutes_failed(self, _base, error):
|
|
self.minutes_status.setText(t("Failed: {error}", error=error))
|
|
self._load_minutes()
|
|
|
|
# ---- history ---------------------------------------------------------
|
|
|
|
def _load_history(self):
|
|
self.history.clear()
|
|
for row in reversed(cfg.read_history(self.conf["history_limit"])):
|
|
text = (row.get("text") or "").replace("\n", " ")
|
|
preview = text[:110] + ("…" if len(text) > 110 else "")
|
|
header = t("{ts} ({duration} s)",
|
|
ts=row.get("ts", ""), duration=row.get("duration", 0))
|
|
if row.get("mode") == "ask":
|
|
# The text of an answer says nothing about what was asked, and
|
|
# out of that context half of them read like non sequiturs.
|
|
asked = (row.get("question") or row.get("raw") or "").replace("\n", " ")
|
|
header += t(" · asked Claude: {question}",
|
|
question=asked[:60] + ("…" if len(asked) > 60 else ""))
|
|
item = QListWidgetItem(f"{header}\n{preview}")
|
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
|
self.history.addItem(item)
|
|
|
|
def _selected_rows(self):
|
|
"""Selected entries, newest first, the order they are listed in."""
|
|
items = sorted(self.history.selectedItems(), key=self.history.row)
|
|
return [item.data(Qt.ItemDataRole.UserRole) for item in items]
|
|
|
|
def _copy_history(self):
|
|
rows = self._selected_rows()
|
|
if rows:
|
|
QGuiApplication.clipboard().setText(
|
|
"\n\n".join(row.get("text", "") for row in rows)
|
|
)
|
|
|
|
def _delete_history(self):
|
|
rows = self._selected_rows()
|
|
if not rows:
|
|
return
|
|
# One entry goes without asking; a multi-selection is easy to make by
|
|
# accident, and there is no undo.
|
|
if len(rows) > 1 and not self._confirm(
|
|
t("Delete the {count} selected entries?", count=len(rows))
|
|
):
|
|
return
|
|
self._rewrite_history(lambda: cfg.delete_history(rows))
|
|
|
|
def _clear_history(self):
|
|
if not self.history.count():
|
|
return
|
|
if not self._confirm(t("Delete the whole history? This cannot be undone.")):
|
|
return
|
|
self._rewrite_history(cfg.clear_history)
|
|
|
|
def _confirm(self, question, title=None):
|
|
answer = QMessageBox.question(
|
|
self, title or t("History"), question,
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No,
|
|
)
|
|
return answer == QMessageBox.StandardButton.Yes
|
|
|
|
def _rewrite_history(self, action):
|
|
try:
|
|
action()
|
|
except OSError as exc:
|
|
QMessageBox.warning(self, t("History"), t("Failed: {error}", error=exc))
|
|
self._load_history()
|
|
|
|
def _history_menu(self, pos):
|
|
item = self.history.itemAt(pos)
|
|
if item is not None and not item.isSelected():
|
|
self.history.setCurrentItem(item)
|
|
menu = QMenu(self)
|
|
copy = menu.addAction(t("Copy selected to clipboard"))
|
|
delete = menu.addAction(t("Delete selected"))
|
|
menu.addSeparator()
|
|
clear = menu.addAction(t("Clear history"))
|
|
has_selection = bool(self.history.selectedItems())
|
|
copy.setEnabled(has_selection)
|
|
delete.setEnabled(has_selection)
|
|
clear.setEnabled(self.history.count() > 0)
|
|
chosen = menu.exec(self.history.viewport().mapToGlobal(pos))
|
|
if chosen is copy:
|
|
self._copy_history()
|
|
elif chosen is delete:
|
|
self._delete_history()
|
|
elif chosen is clear:
|
|
self._clear_history()
|