Files
Lumotia/src/lib/pages/SettingsPage.svelte
Jake e436a69839 fix(preferences): use Object.assign mutation to prevent infinite effect loops
Replacing the preferences object (spread reassignment) caused all consumers'
prefs references to go stale — the loop guard read the old value forever.
Svelte 5 shared state pattern requires a stable const object with property
mutations, not reassignment. Also removes duplicate theme sync $effect from
SettingsPage (already handled in +layout.svelte).
2026-04-18 10:18:29 +01:00

1134 lines
49 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { settings, saveSettings, profiles, saveProfiles, templates, saveTemplates, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
import Card from "$lib/components/Card.svelte";
import Toggle from "$lib/components/Toggle.svelte";
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
import ZonePicker from "$lib/components/ZonePicker.svelte";
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
import { clampTextLines } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { Check, ChevronRight } from "lucide-svelte";
const prefs = getPreferences();
let engineStatus = $state("Checking...");
let engineOk = $state(false);
let downloadedModels = $state([]);
let runtimeCapabilities = $state(null);
let downloadingModel = $state("");
let downloadProgress = $state(0);
let unlisten = null;
let outputFolderEl = $state(null);
let outputFolderWidth = $state(0);
// Parakeet state
let parakeetStatus = $state("Checking...");
let parakeetOk = $state(false);
// Audio device picker (Settings → Audio → Microphone)
let audioDevices = $state([]);
let audioDevicesError = $state(null);
async function refreshAudioDevices() {
audioDevicesError = null;
try {
audioDevices = await invoke("list_audio_devices");
} catch (err) {
audioDevicesError = "Could not enumerate audio devices: " + (err?.message || err);
audioDevices = [];
}
}
// --- Custom vocabulary (dictionary) — Day 5 of upgrade plan ---
// Backed by SQLite `dictionary` table via list_dictionary_command etc.
// Terms get injected into the LLM cleanup prompt so the model preserves
// the user's spellings (e.g. medication names, jargon, place names).
let vocabulary = $state([]);
let vocabularyError = $state(null);
let newVocabTerm = $state("");
let newVocabNote = $state("");
async function refreshVocabulary() {
vocabularyError = null;
try {
vocabulary = await invoke("list_dictionary_command");
} catch (err) {
vocabularyError = "Could not load vocabulary: " + (err?.message || err);
}
}
async function addVocabTerm() {
const term = newVocabTerm.trim();
if (!term) return;
try {
await invoke("add_dictionary_entry_command", {
term,
note: newVocabNote.trim() || null,
});
newVocabTerm = "";
newVocabNote = "";
await refreshVocabulary();
} catch (err) {
vocabularyError = "Could not save term: " + (err?.message || err);
}
}
async function deleteVocabTerm(id) {
try {
await invoke("delete_dictionary_entry_command", { id });
await refreshVocabulary();
} catch (err) {
vocabularyError = "Could not delete term: " + (err?.message || err);
}
}
// --- Diagnostics (Settings → About → Generate diagnostic report) ---
// Layer 2 of the diagnostics plan: user previews exactly what would be
// shared, then chooses to copy / save. Nothing leaves the machine
// automatically.
let diagnosticReport = $state("");
let diagnosticReportError = $state(null);
let diagnosticReportSavedTo = $state(null);
let diagnosticReportLoading = $state(false);
async function generateDiagnosticReport() {
diagnosticReportError = null;
diagnosticReportSavedTo = null;
diagnosticReportLoading = true;
try {
diagnosticReport = await invoke("generate_diagnostic_report", {
options: {
includeRecentErrors: true,
includeCrashes: true,
includeSettings: true,
includeLogTail: true,
},
});
} catch (err) {
diagnosticReportError = "Could not generate report: " + (err?.message || err);
} finally {
diagnosticReportLoading = false;
}
}
async function copyDiagnosticReport() {
if (!diagnosticReport) return;
try {
await navigator.clipboard.writeText(diagnosticReport);
diagnosticReportSavedTo = "Copied to clipboard.";
} catch (err) {
diagnosticReportError = "Clipboard copy failed: " + (err?.message || err);
}
}
async function saveDiagnosticReport() {
diagnosticReportError = null;
try {
const path = await invoke("save_diagnostic_report", {
options: {
includeRecentErrors: true,
includeCrashes: true,
includeSettings: true,
includeLogTail: true,
},
});
diagnosticReportSavedTo = "Saved to " + path;
} catch (err) {
diagnosticReportError = "Save failed: " + (err?.message || err);
}
}
let parakeetDownloaded = $state(false);
let parakeetDownloading = $state(false);
let parakeetProgress = $state(0);
let unlistenParakeet = null;
// Profiles & Templates
let showProfiles = $state(false);
let showTemplates = $state(false);
let editingProfile = $state(-1);
let editingTemplate = $state(-1);
let newProfileName = $state("");
let newTemplateName = $state("");
let showNewProfile = $state(false);
let showNewTemplate = $state(false);
// Accordion state — first section open by default
let openSection = $state('transcription');
let settingsTextFont = $derived(pretextFontShorthand(prefs.accessibility, 11));
let settingsPathLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 11));
let outputFolderPreview = $derived.by(() => {
const text = settings.outputFolder || "Default (app data)";
if (!text) return "";
if (outputFolderWidth <= 0) {
return text.length > 80 ? `${text.slice(0, 79)}…` : text;
}
return clampTextLines(
text,
settingsTextFont,
Math.max(120, outputFolderWidth - 24),
settingsPathLineHeight,
2,
).text;
});
function whisperModelId(size) {
const map = {
Tiny: "whisper-tiny-en",
Base: "whisper-base-en",
Small: "whisper-small-en",
Medium: "whisper-medium-en",
};
return map[size] || "whisper-base-en";
}
function selectedModelId() {
return settings.engine === "parakeet"
? "parakeet-ctc-0.6b-int8"
: whisperModelId(settings.modelSize);
}
function engineCapabilities(engineId) {
return runtimeCapabilities?.engines?.find((engine) => engine.id === engineId) || null;
}
function selectedModelCapabilities() {
return engineCapabilities(settings.engine)?.models?.find((model) => model.id === selectedModelId()) || null;
}
function whisperModelCapabilities(size) {
return engineCapabilities("whisper")?.models?.find((model) => model.id === whisperModelId(size)) || null;
}
function currentModelIsEnglishOnly() {
return selectedModelCapabilities()?.languageSupport?.kind === "english-only";
}
function hasGpuAcceleration() {
return (runtimeCapabilities?.accelerators || []).some((accelerator) => accelerator !== "cpu");
}
async function refreshRuntimeCapabilities() {
runtimeCapabilities = await invoke("get_runtime_capabilities");
}
onMount(async () => {
try {
await refreshRuntimeCapabilities();
const loaded = await invoke("check_engine");
engineOk = loaded;
engineStatus = loaded ? "Model loaded" : "No model loaded";
} catch {
engineStatus = "Engine not ready";
}
// Populate audio device list so the Microphone picker is ready when
// the user opens the Audio section.
refreshAudioDevices();
// Pre-load vocabulary too. Both lists are small; pre-fetching avoids
// a flash of "no data" when the section opens.
refreshVocabulary();
try {
downloadedModels = await invoke("list_models");
} catch {}
// Parakeet status
try {
parakeetOk = await invoke("check_parakeet_engine");
parakeetDownloaded = await invoke("check_parakeet_model", { name: "ctc-int8" });
parakeetStatus = parakeetOk ? "Model loaded" : parakeetDownloaded ? "Downloaded (not loaded)" : "Not downloaded";
} catch {
parakeetStatus = "Engine not ready";
}
unlisten = await listen("model-download-progress", (event) => {
downloadProgress = event.payload.percent || event.payload.progress || 0;
});
unlistenParakeet = await listen("parakeet-download-progress", (event) => {
parakeetProgress = event.payload.percent || event.payload.progress || 0;
});
});
onDestroy(() => {
if (unlisten) unlisten();
if (unlistenParakeet) unlistenParakeet();
});
$effect(() => {
if (!outputFolderEl) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
outputFolderWidth = entry.contentRect.width;
}
});
ro.observe(outputFolderEl);
return () => ro.disconnect();
});
// Auto-save on any settings change.
// JSON.stringify subscribes to all properties on the reactive object.
// Safe because `settings` only holds persistent preferences — no ephemeral state.
$effect(() => {
void JSON.stringify(settings);
saveSettings();
});
$effect(() => {
settings.engine;
settings.modelSize;
if (currentModelIsEnglishOnly() && settings.language !== "en") {
settings.language = "en";
}
if (!hasGpuAcceleration() && settings.device !== "auto") {
settings.device = "auto";
}
});
async function downloadModel(size) {
downloadingModel = size;
downloadProgress = 0;
try {
await invoke("download_model", { size });
downloadedModels = await invoke("list_models");
await refreshRuntimeCapabilities();
downloadingModel = "";
} catch (err) {
downloadingModel = "";
engineStatus = typeof err === "string" ? err : "Download failed";
}
}
async function loadSelectedModel() {
const size = settings.modelSize.toLowerCase();
engineStatus = "Loading...";
engineOk = false;
try {
await invoke("load_model", { size });
await refreshRuntimeCapabilities();
engineOk = true;
engineStatus = `${settings.modelSize} model loaded`;
} catch (err) {
engineOk = false;
engineStatus = typeof err === "string" ? err : "Load failed";
}
}
function isModelDownloaded(size) {
const runtimeModel = whisperModelCapabilities(size);
if (runtimeModel) {
return runtimeModel.downloaded;
}
return downloadedModels.some((model) => model.toLowerCase() === size.toLowerCase());
}
function isModelLoaded(size) {
const runtimeModel = whisperModelCapabilities(size);
if (runtimeModel) {
return runtimeModel.loaded;
}
return runtimeCapabilities?.engines?.some(
(engine) => engine.id === "whisper" && engine.loadedModelId === whisperModelId(size),
) || false;
}
const modelDescriptions = {
Tiny: "~75MB · fastest, lower accuracy",
Base: "~150MB · balanced for most use",
Small: "~500MB · noticeably more accurate",
Medium: "~1.5GB · best quality, slower",
};
async function downloadParakeet() {
parakeetDownloading = true;
parakeetProgress = 0;
try {
await invoke("download_parakeet_model", { name: "ctc-int8" });
await refreshRuntimeCapabilities();
parakeetDownloaded = true;
parakeetDownloading = false;
parakeetStatus = "Downloaded (not loaded)";
} catch (err) {
parakeetDownloading = false;
parakeetStatus = typeof err === "string" ? err : "Download failed";
}
}
async function loadParakeet() {
parakeetStatus = "Loading...";
parakeetOk = false;
try {
await invoke("load_parakeet_model", { name: "ctc-int8" });
await refreshRuntimeCapabilities();
parakeetOk = true;
parakeetStatus = "Model loaded";
} catch (err) {
parakeetOk = false;
parakeetStatus = typeof err === "string" ? err : "Load failed";
}
}
// --- Profile management ---
function createProfile() {
if (!newProfileName.trim()) return;
const name = newProfileName.trim();
profiles.push({ name, words: "" });
saveProfiles();
addProfileTaskList(name);
newProfileName = "";
showNewProfile = false;
editingProfile = profiles.length - 1;
}
function deleteProfile(index) {
const name = profiles[index].name;
if (page.activeProfile === name) {
page.activeProfile = "None";
}
removeProfileTaskList(name);
profiles.splice(index, 1);
saveProfiles();
editingProfile = -1;
}
function profileWordCount(words) {
return words ? words.split("\n").filter((w) => w.trim()).length : 0;
}
// --- Template management ---
function createTemplate() {
if (!newTemplateName.trim()) return;
templates.push({ name: newTemplateName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
saveTemplates();
newTemplateName = "";
showNewTemplate = false;
editingTemplate = templates.length - 1;
}
function deleteTemplate(index) {
templates.splice(index, 1);
saveTemplates();
editingTemplate = -1;
}
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Title -->
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-5">Settings</h2>
<div class="px-7 pb-8">
<Card>
<!-- Audio (microphone selection) -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'audio' ? null : 'audio'}
>
<h3 class="font-display text-[18px] italic text-text">Audio</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'audio' ? '' : '+'}</span>
</button>
{#if openSection === 'audio'}
<div class="px-5 pb-5 animate-fade-in">
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Microphone</p>
<div class="flex items-center gap-3">
<select
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
appearance-none cursor-pointer min-w-[280px] max-w-full"
bind:value={settings.microphoneDevice}
onfocus={refreshAudioDevices}
>
<option value="">Auto (recommended) — let Kon pick the working mic</option>
{#each audioDevices as dev}
<option value={dev.name} disabled={dev.is_likely_monitor}>
{dev.name}
{dev.is_default ? " (system default)" : ""}
{dev.is_likely_monitor ? " — speaker monitor, skip" : ""}
</option>
{/each}
</select>
<button
class="px-3 py-2 text-[12px] text-text-secondary border border-border rounded-lg hover:border-accent hover:text-text"
onclick={refreshAudioDevices}
type="button"
>Refresh</button>
</div>
{#if audioDevicesError}
<p class="text-[11px] text-error mt-2">{audioDevicesError}</p>
{:else if audioDevices.length === 0}
<p class="text-[11px] text-text-tertiary mt-2">No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.</p>
{:else}
<p class="text-[11px] text-text-tertiary mt-2">
Auto mode tries the system default first, then any other real input. Speaker-monitor sources (loopback) are skipped because they record system audio rather than the microphone. If dictation is silent, pick the device explicitly here.
</p>
{/if}
</div>
</div>
{/if}
</div>
<!-- Vocabulary (custom dictionary injected into LLM cleanup prompt) -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'vocabulary' ? null : 'vocabulary'}
>
<h3 class="font-display text-[18px] italic text-text">Vocabulary</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'vocabulary' ? '' : '+'}</span>
</button>
{#if openSection === 'vocabulary'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[12px] text-text-tertiary mb-4">
Words and phrases the AI cleanup pass should preserve exactly. Useful for medication names, place names, jargon, names of people in your support network, anything Whisper tends to mishear.
</p>
<div class="flex gap-2 mb-4">
<input
type="text"
placeholder="Term (e.g. methylphenidate, Wren, Tartarus)"
bind:value={newVocabTerm}
onkeydown={(e) => { if (e.key === 'Enter') addVocabTerm(); }}
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
/>
<input
type="text"
placeholder="Note (optional)"
bind:value={newVocabNote}
onkeydown={(e) => { if (e.key === 'Enter') addVocabTerm(); }}
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
/>
<button
type="button"
onclick={addVocabTerm}
disabled={!newVocabTerm.trim()}
class="px-4 py-2 text-[13px] bg-accent text-bg rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent-hover"
>Add</button>
</div>
{#if vocabularyError}
<p class="text-[11px] text-error mb-3">{vocabularyError}</p>
{/if}
{#if vocabulary.length === 0}
<p class="text-[11px] text-text-tertiary italic">No terms yet. Add one above.</p>
{:else}
<ul class="space-y-1">
{#each vocabulary as entry (entry.id)}
<li class="flex items-center gap-3 py-2 px-3 bg-bg-input border border-border rounded-lg">
<div class="flex-1 min-w-0">
<div class="text-[13px] text-text font-medium truncate">{entry.term}</div>
{#if entry.note}
<div class="text-[11px] text-text-tertiary truncate">{entry.note}</div>
{/if}
</div>
<button
type="button"
onclick={() => deleteVocabTerm(entry.id)}
class="text-[12px] text-text-tertiary hover:text-error border border-border hover:border-error rounded px-2 py-1"
aria-label="Delete {entry.term}"
>Remove</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<!-- Transcription -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'transcription' ? null : 'transcription'}
>
<h3 class="font-display text-[18px] italic text-text">Transcription</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'transcription' ? '' : '+'}</span>
</button>
{#if openSection === 'transcription'}
<div class="px-5 pb-5 animate-fade-in">
<!-- Engine selector -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Engine</p>
<SegmentedButton options={["whisper", "parakeet"]} bind:value={settings.engine} />
<p class="text-[11px] text-text-tertiary mt-2">
{settings.engine === "whisper"
? "Whisper with the currently shipped English-only models in this build"
: "Parakeet CTC 0.6B — English-only, fast when the model is installed"}
</p>
</div>
<!-- Format mode -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Format Mode</p>
<SegmentedButton options={["Raw", "Clean", "Smart"]} bind:value={settings.formatMode} />
<p class="text-[11px] text-text-tertiary mt-2">
{settings.formatMode === "Raw" ? "Exact Whisper output, no formatting" :
settings.formatMode === "Clean" ? "Grouped into paragraphs, punctuation tidied" :
"Structured with lists, headings, and sections"}
</p>
</div>
<!-- Engine-specific model management -->
{#if settings.engine === "whisper"}
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Whisper Model</p>
<SegmentedButton options={["Tiny", "Base", "Small", "Medium"]} bind:value={settings.modelSize} />
<p class="text-[11px] text-text-tertiary mt-2">{modelDescriptions[settings.modelSize]}</p>
<div class="flex items-center gap-2 mt-3">
{#if isModelLoaded(settings.modelSize)}
<span class="inline-flex items-center gap-1.5 text-[11px] text-success">
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
Model loaded
</span>
{:else if isModelDownloaded(settings.modelSize)}
<span class="inline-flex items-center gap-1.5 text-[11px] text-success">
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
Downloaded
</span>
<button
class="text-[11px] text-text-tertiary hover:text-accent"
onclick={loadSelectedModel}
>Load model</button>
{:else if downloadingModel === settings.modelSize.toLowerCase()}
<span class="text-[11px] text-warning">{downloadProgress}% downloading...</span>
{:else}
<button
class="text-[11px] text-accent hover:text-accent-hover"
onclick={() => downloadModel(settings.modelSize.toLowerCase())}
>Download {settings.modelSize}</button>
{/if}
</div>
</div>
{:else}
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Parakeet Model</p>
<p class="text-[11px] text-text-tertiary mb-3">Parakeet CTC 0.6B (int8) — ~613MB, near-instant transcription</p>
<div class="flex items-center gap-2">
{#if parakeetOk}
<span class="inline-flex items-center gap-1.5 text-[11px] text-success">
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
Model loaded
</span>
{:else if parakeetDownloaded}
<span class="inline-flex items-center gap-1.5 text-[11px] text-text-secondary">
<span class="w-[6px] h-[6px] rounded-full bg-text-tertiary"></span>
Downloaded
</span>
<button
class="text-[11px] text-text-tertiary hover:text-accent"
onclick={loadParakeet}
>Load model</button>
{:else if parakeetDownloading}
<span class="text-[11px] text-warning">{parakeetProgress}% downloading...</span>
{:else}
<button
class="text-[11px] text-accent hover:text-accent-hover"
onclick={downloadParakeet}
>Download Parakeet</button>
{/if}
</div>
</div>
{/if}
<!-- Compute device -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Compute Device</p>
{#if hasGpuAcceleration()}
<select
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
appearance-none cursor-pointer w-[220px]"
bind:value={settings.device}
>
<option value="auto">Auto</option>
{#if runtimeCapabilities?.accelerators?.includes("cuda")}
<option value="cuda">CUDA (NVIDIA GPU)</option>
{/if}
{#if runtimeCapabilities?.accelerators?.includes("metal")}
<option value="metal">Metal (Apple GPU)</option>
{/if}
{#if runtimeCapabilities?.accelerators?.includes("vulkan")}
<option value="vulkan">Vulkan</option>
{/if}
<option value="cpu">CPU</option>
</select>
<p class="text-[11px] text-text-tertiary mt-2">Only accelerators built into this binary are shown here.</p>
{:else}
<div class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text w-[320px]">
This build is CPU-only. GPU controls appear in GPU-enabled builds.
</div>
{/if}
</div>
<!-- Language -->
<div>
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Language</p>
<div class="flex items-center gap-3">
{#if currentModelIsEnglishOnly()}
<select
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
appearance-none cursor-not-allowed w-[220px]"
bind:value={settings.language}
disabled
>
<option value="en">English only</option>
</select>
{:else}
<select
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
appearance-none cursor-pointer w-[140px]"
bind:value={settings.language}
>
<option value="en">English</option>
<option value="auto">Auto-detect</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Spanish</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="nl">Dutch</option>
<option value="pl">Polish</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="zh">Chinese</option>
</select>
{/if}
{#if settings.language === "en"}
<button
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-[11px] border animate-fade-in
{settings.britishEnglish
? 'bg-accent/10 border-accent/30 text-accent font-medium'
: 'bg-bg-input border-border text-text-tertiary hover:text-text-secondary'}"
onclick={() => { settings.britishEnglish = !settings.britishEnglish; }}
title={settings.britishEnglish ? "British English spelling active" : "Click to enable British English spelling"}
>
{#if settings.britishEnglish}
<Check class="w-3 h-3" strokeWidth={2.5} />
{/if}
British English
</button>
{/if}
</div>
<p class="text-[11px] text-text-tertiary mt-2">
{#if currentModelIsEnglishOnly()}
The selected model only supports English in this build.
{:else}
Auto-detect is only meaningful with multilingual models.
{/if}
</p>
</div>
</div>
{/if}
</div>
<!-- Processing -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'processing' ? null : 'processing'}
>
<h3 class="font-display text-[18px] italic text-text">Processing</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'processing' ? '' : '+'}</span>
</button>
{#if openSection === 'processing'}
<div class="px-5 pb-5 animate-fade-in">
<div class="space-y-0.5">
<Toggle
bind:checked={settings.removeFillers}
label="Remove filler words"
description="Strips um, uh, like, you know from output"
/>
<Toggle
bind:checked={settings.antiHallucination}
label="Anti-hallucination shield"
description="Detects phantom phrases Whisper generates during silence"
/>
</div>
</div>
{/if}
</div>
<!-- AI Assistant -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'ai' ? null : 'ai'}
>
<h3 class="font-display text-[18px] italic text-text">AI Assistant</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'ai' ? '' : '+'}</span>
</button>
{#if openSection === 'ai'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">Local LLM for smart task extraction, transcript cleanup, and formatting. Runs 100% offline.</p>
<div class="bg-bg-input rounded-lg px-3 py-2.5 border border-border-subtle">
<p class="text-[12px] text-text-secondary font-medium mb-1">Coming soon</p>
<p class="text-[11px] text-text-tertiary">AI-powered cleanup and smart extraction are being rebuilt with a faster engine. Task extraction currently uses rule-based matching, which runs automatically after each recording.</p>
</div>
</div>
{/if}
</div>
<!-- Profiles & Templates -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'profiles' ? null : 'profiles'}
>
<h3 class="font-display text-[18px] italic text-text">Profiles & Templates</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'profiles' ? '' : '+'}</span>
</button>
{#if openSection === 'profiles'}
<div class="px-5 pb-5 animate-fade-in">
<!-- Profiles section -->
<button
class="flex items-center gap-2 w-full text-left mb-1"
onclick={() => showProfiles = !showProfiles}
>
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showProfiles ? 'rotate-90' : ''}" />
<h3 class="text-[14px] font-semibold text-text">Profiles</h3>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{profiles.length}
</span>
</button>
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Custom vocabulary to improve transcription accuracy</p>
{#if showProfiles}
<div class="space-y-1.5 animate-fade-in ml-5">
{#each profiles as profile, i}
<div class="group">
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
<span class="text-[12px] text-text flex-1 truncate">{profile.name}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{profileWordCount(profile.words)} words
</span>
<button
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
onclick={() => editingProfile = editingProfile === i ? -1 : i}
>{editingProfile === i ? "Close" : "Edit"}</button>
<button
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
onclick={() => deleteProfile(i)}
>Delete</button>
</div>
{#if editingProfile === i}
<div class="mt-1.5 animate-fade-in">
<textarea
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
text-[12px] text-text resize-none
focus:outline-none focus:border-accent"
placeholder="One word or phrase per line..."
bind:value={profile.words}
oninput={() => saveProfiles()}
data-no-transition
></textarea>
</div>
{/if}
</div>
{/each}
{#if showNewProfile}
<div class="flex items-center gap-2 animate-fade-in">
<input
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Profile name..."
bind:value={newProfileName}
onkeydown={(e) => e.key === "Enter" && createProfile()}
data-no-transition
/>
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createProfile}>Create</button>
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewProfile = false; newProfileName = ""; }}>Cancel</button>
</div>
{:else}
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
{/if}
</div>
{/if}
<div class="my-4 h-px bg-border-subtle"></div>
<!-- Templates section -->
<button
class="flex items-center gap-2 w-full text-left mb-1"
onclick={() => showTemplates = !showTemplates}
>
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showTemplates ? 'rotate-90' : ''}" />
<h3 class="text-[14px] font-semibold text-text">Templates</h3>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{templates.length}
</span>
</button>
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Structured formats for dictation sessions</p>
{#if showTemplates}
<div class="space-y-1.5 animate-fade-in ml-5">
{#each templates as template, i}
<div class="group">
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
<span class="text-[12px] text-text flex-1 truncate">{template.name}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{template.sections.length} sections
</span>
<button
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
onclick={() => editingTemplate = editingTemplate === i ? -1 : i}
>{editingTemplate === i ? "Close" : "Edit"}</button>
<button
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
onclick={() => deleteTemplate(i)}
>Delete</button>
</div>
{#if editingTemplate === i}
<div class="mt-1.5 animate-fade-in">
<textarea
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
text-[12px] text-text resize-none
focus:outline-none focus:border-accent"
placeholder="One section per line..."
value={template.sections.join("\n")}
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
data-no-transition
></textarea>
</div>
{/if}
</div>
{/each}
{#if showNewTemplate}
<div class="flex items-center gap-2 animate-fade-in">
<input
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Template name..."
bind:value={newTemplateName}
onkeydown={(e) => e.key === "Enter" && createTemplate()}
data-no-transition
/>
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createTemplate}>Create</button>
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewTemplate = false; newTemplateName = ""; }}>Cancel</button>
</div>
{:else}
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Output -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'output' ? null : 'output'}
>
<h3 class="font-display text-[18px] italic text-text">Output</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'output' ? '' : '+'}</span>
</button>
{#if openSection === 'output'}
<div class="px-5 pb-5 animate-fade-in">
<div class="space-y-0.5">
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
<Toggle
bind:checked={settings.saveAudio}
label="Save audio recordings"
description="Saves raw audio as .wav files (~2MB per minute). Stored locally."
/>
{#if settings.saveAudio}
<div class="ml-[50px] mt-2 animate-fade-in">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5">Output Folder</p>
<div class="flex items-center gap-2">
<div bind:this={outputFolderEl} class="flex-1 bg-bg-input rounded-lg px-3 py-1.5 border border-border-subtle">
<p class="text-[11px] text-text-secondary whitespace-pre-wrap break-words" style="line-height: {settingsPathLineHeight}px" title={settings.outputFolder || "Default (app data)"}>
{outputFolderPreview}
</p>
</div>
<button
class="text-[11px] text-accent hover:text-accent-hover whitespace-nowrap"
onclick={async () => {
try {
const folder = await open({ directory: true, title: "Select output folder" });
if (folder) { settings.outputFolder = folder; saveSettings(); }
} catch {}
}}
>Change</button>
{#if settings.outputFolder}
<button
class="text-[11px] text-text-tertiary hover:text-text-secondary whitespace-nowrap"
onclick={() => { settings.outputFolder = ""; saveSettings(); }}
>Reset</button>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Hotkey -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'hotkey' ? null : 'hotkey'}
>
<h3 class="font-display text-[18px] italic text-text">Global Hotkey</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'hotkey' ? '' : '+'}</span>
</button>
{#if openSection === 'hotkey'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">Toggle recording from anywhere. Click to change.</p>
<HotkeyRecorder />
</div>
{/if}
</div>
<!-- Appearance -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'appearance' ? null : 'appearance'}
>
<h3 class="font-display text-[18px] italic text-text">Appearance</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'appearance' ? '' : '+'}</span>
</button>
{#if openSection === 'appearance'}
<div class="px-5 pb-5 animate-fade-in">
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Theme</p>
<SegmentedButton options={["Dark", "Light", "System"]} bind:value={settings.theme} />
</div>
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Zone</p>
<ZonePicker />
</div>
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
Font Size <span class="font-normal text-text-secondary ml-1">{settings.fontSize}px</span>
</p>
<input
type="range" min="10" max="24" step="1"
bind:value={settings.fontSize}
class="w-[200px] accent-accent"
data-no-transition
/>
</div>
</div>
{/if}
</div>
<!-- Accessibility -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'accessibility' ? null : 'accessibility'}
>
<h3 class="font-display text-[18px] italic text-text">Accessibility</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'accessibility' ? '' : '+'}</span>
</button>
{#if openSection === 'accessibility'}
<div class="px-5 pb-5 animate-fade-in">
<AccessibilityControls />
</div>
{/if}
</div>
<!-- About -->
<div>
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'about' ? null : 'about'}
>
<h3 class="font-display text-[18px] italic text-text">About</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'about' ? '' : '+'}</span>
</button>
{#if openSection === 'about'}
<div class="px-5 pb-5 animate-fade-in">
<!-- Engine status -->
<div class="flex items-center gap-2 mb-4">
<span class="w-[7px] h-[7px] rounded-full {engineOk ? 'bg-success' : 'bg-warning'}"></span>
<span class="text-[12px] text-text-secondary">{engineStatus}</span>
</div>
<div class="space-y-1.5">
{#each [
"100% offline — all processing on your machine",
"No Python required — compiled Whisper engine",
"No cloud — audio never leaves your computer",
"No accounts — no sign-up, no tracking",
"No telemetry — zero data collection",
] as item}
<div class="flex items-start gap-2">
<Check class="w-3.5 h-3.5 text-success mt-0.5 flex-shrink-0" />
<p class="text-[11px] text-text-secondary">{item}</p>
</div>
{/each}
</div>
<p class="text-[11px] text-text-tertiary mt-5">Kon v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd</p>
<!-- Diagnostic report (privacy posture: local only until you share it) -->
<div class="mt-6 pt-5 border-t border-border-subtle">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Diagnostics</p>
<p class="text-[11px] text-text-tertiary mb-3">
If something goes wrong, generate a diagnostic report to share with the developer. The report contains your settings, recent errors, any crash dumps, and the tail of the log file. Nothing is sent automatically — you preview it first, then choose copy or save.
</p>
<div class="flex gap-2 mb-3">
<button
type="button"
onclick={generateDiagnosticReport}
disabled={diagnosticReportLoading}
class="px-3 py-2 text-[12px] bg-accent text-bg rounded-lg disabled:opacity-50 hover:bg-accent-hover"
>{diagnosticReportLoading ? "Generating…" : "Generate report"}</button>
{#if diagnosticReport}
<button
type="button"
onclick={copyDiagnosticReport}
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
>Copy to clipboard</button>
<button
type="button"
onclick={saveDiagnosticReport}
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
>Save as file</button>
{/if}
</div>
{#if diagnosticReportError}
<p class="text-[11px] text-error mb-2">{diagnosticReportError}</p>
{/if}
{#if diagnosticReportSavedTo}
<p class="text-[11px] text-success mb-2">{diagnosticReportSavedTo}</p>
{/if}
{#if diagnosticReport}
<details class="text-[11px]">
<summary class="cursor-pointer text-text-secondary hover:text-text">Preview report ({diagnosticReport.length} chars)</summary>
<pre class="mt-2 p-3 bg-bg-input border border-border rounded-lg text-[10px] text-text-secondary overflow-auto max-h-[400px] whitespace-pre-wrap font-mono">{diagnosticReport}</pre>
</details>
{/if}
</div>
</div>
{/if}
</div>
</Card>
</div>
</div>