Previously the bulk import ran new Set(...) on raw trimmed strings before lowercasing, so 'ACME' and 'acme' both survived the first dedupe pass. Neither existed in the store, so both got added — defeating the commit message's claim that pasting the same block twice with different casing is a no-op. Collapse case variants at the initial dedupe step using a lowercase seen-set, keeping the first occurrence's casing as written. Co-authored-by: jars <jakejars@users.noreply.github.com>
1853 lines
79 KiB
Svelte
1853 lines
79 KiB
Svelte
<script lang="ts">
|
||
// @ts-nocheck
|
||
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 { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
|
||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||
import { clampTextLines } from "$lib/utils/textMeasure.js";
|
||
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
|
||
import { Check, ChevronRight } from "lucide-svelte";
|
||
import { _ } from "svelte-i18n";
|
||
import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n";
|
||
|
||
const prefs = getPreferences();
|
||
|
||
let engineStatus = $state("Checking...");
|
||
let engineOk = $state(false);
|
||
let downloadedModels = $state([]);
|
||
let runtimeCapabilities = $state(null);
|
||
let pasteBackends = $state([]);
|
||
let pasteBackendsDescription = $derived.by(() => {
|
||
if (pasteBackends.length === 0) {
|
||
return "Install wtype (Wayland) or xdotool (X11) to enable auto-paste. Focus must already be on the target window.";
|
||
}
|
||
return `Uses ${pasteBackends.join(", ")}. Focus must already be on the target window.`;
|
||
});
|
||
let downloadingModel = $state("");
|
||
let downloadProgress = $state(0);
|
||
let unlisten = null;
|
||
let unlistenLlm = null;
|
||
let outputFolderEl = $state(null);
|
||
let outputFolderWidth = $state(0);
|
||
let llmStatuses = $state({});
|
||
let llmStatus = $state("Checking...");
|
||
let llmDownloadingModel = $state("");
|
||
let llmDownloadProgress = $state(0);
|
||
let llmLoaded = $state(false);
|
||
let systemInfo = $state(null);
|
||
|
||
const LLM_MODELS = [
|
||
{
|
||
id: "qwen3_1_7b",
|
||
label: "Low",
|
||
subtitle: "Qwen3 1.7B",
|
||
fit: "8 GB RAM, CPU-heavy machines",
|
||
size: "~1.1 GB",
|
||
},
|
||
{
|
||
id: "qwen3_4b_instruct_2507",
|
||
label: "Default",
|
||
subtitle: "Qwen3 4B Instruct 2507",
|
||
fit: "16 GB RAM or 8 GB+ VRAM",
|
||
size: "~2.5 GB",
|
||
},
|
||
{
|
||
id: "qwen3_14b",
|
||
label: "High",
|
||
subtitle: "Qwen3 14B",
|
||
fit: "32 GB RAM or 16 GB+ VRAM",
|
||
size: "~10.5 GB",
|
||
},
|
||
];
|
||
|
||
// Parakeet state
|
||
let parakeetStatus = $state("Checking...");
|
||
let parakeetOk = $state(false);
|
||
|
||
// Audio device picker (Settings → Audio → Microphone)
|
||
let audioDevices = $state([]);
|
||
let audioDevicesError = $state(null);
|
||
|
||
// ALSA enumeration leaks raw device strings (hw:, plughw:, front:,
|
||
// sysdefault:, back:, surround:, iec958:, dmix:, usbstream:, plus a
|
||
// bogus "null" device). These are kernel-level aliases — no user
|
||
// needs to pick between "hw:CARD=2,DEV=0" and "plughw:CARD=2,DEV=0".
|
||
//
|
||
// The strategy: keep a small set of well-known sentinel devices
|
||
// (default/pipewire/pulse), then pull a single entry per unique
|
||
// sound card by parsing CARD=X from the sysdefault: alias. That
|
||
// gives us "Microphones" / "C920" / "Generic" as friendly labels
|
||
// while mapping them to a reliable ALSA path.
|
||
const SENTINEL_DEVICES = new Set(["default", "pipewire", "pulse"]);
|
||
|
||
function parseCardName(name) {
|
||
const match = String(name || "").match(/CARD=([^,]+)/);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
function buildVisibleDevices(devices) {
|
||
const out = [];
|
||
const seenCards = new Set();
|
||
|
||
for (const dev of devices) {
|
||
const name = dev?.name || "";
|
||
if (!name || name === "null") continue;
|
||
if (SENTINEL_DEVICES.has(name)) {
|
||
out.push(dev);
|
||
continue;
|
||
}
|
||
if (name.startsWith("sysdefault:CARD=")) {
|
||
const card = parseCardName(name);
|
||
if (card && !seenCards.has(card)) {
|
||
seenCards.add(card);
|
||
out.push(dev);
|
||
}
|
||
}
|
||
// Everything else (hw:, plughw:, front:, dmix:, etc.) is silently
|
||
// dropped — cpal will resolve the sysdefault:CARD= form fine.
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function friendlyLabel(dev) {
|
||
const name = dev?.name || "";
|
||
if (name === "default") return "System default";
|
||
if (name === "pipewire") return "PipeWire";
|
||
if (name === "pulse") return "PulseAudio";
|
||
// Prefer the rich product description from /proc/asound/cards
|
||
// (e.g. "Blue Microphones" for the Yeti). Falls back to the raw
|
||
// CARD=X short name if we couldn't load descriptions.
|
||
const desc = (dev?.description || "").trim();
|
||
if (desc) return desc;
|
||
const card = parseCardName(name);
|
||
if (card) return card;
|
||
return name;
|
||
}
|
||
|
||
let visibleAudioDevices = $derived(buildVisibleDevices(audioDevices));
|
||
|
||
async function refreshAudioDevices() {
|
||
audioDevicesError = null;
|
||
try {
|
||
audioDevices = await invoke("list_audio_devices");
|
||
} catch (err) {
|
||
audioDevicesError = "Could not enumerate audio devices: " + (err?.message || err);
|
||
audioDevices = [];
|
||
}
|
||
}
|
||
|
||
// --- Per-profile vocabulary (Task 15) ---
|
||
// Terms and the profile's initial_prompt travel with the active profile.
|
||
// The Rust side (Task 13) falls back to profile.initial_prompt whenever
|
||
// the invoke payload's initialPrompt is empty.
|
||
let vocabulary = $state([]);
|
||
let vocabularyError = $state(null);
|
||
let newVocabTerm = $state("");
|
||
let newVocabNote = $state("");
|
||
let showBulkVocab = $state(false);
|
||
let bulkVocabText = $state("");
|
||
let bulkVocabBusy = $state(false);
|
||
|
||
// Draft held locally so the textarea doesn't thrash the store on every
|
||
// keystroke. Saved on blur or explicit save.
|
||
let initialPromptDraft = $state("");
|
||
let initialPromptDirty = $state(false);
|
||
let profileRenameDraft = $state("");
|
||
let showNewVocabProfile = $state(false);
|
||
let newVocabProfileName = $state("");
|
||
let newVocabProfilePrompt = $state("");
|
||
|
||
async function refreshVocabulary() {
|
||
vocabularyError = null;
|
||
try {
|
||
const profileId = profilesStore.activeProfileId;
|
||
vocabulary = await profilesStore.listTerms(profileId);
|
||
} catch (err) {
|
||
vocabularyError = "Could not load vocabulary: " + (err?.message || err);
|
||
}
|
||
}
|
||
|
||
async function addVocabTerm() {
|
||
const term = newVocabTerm.trim();
|
||
if (!term) return;
|
||
try {
|
||
await profilesStore.addTerm(
|
||
profilesStore.activeProfileId,
|
||
term,
|
||
newVocabNote.trim(),
|
||
);
|
||
newVocabTerm = "";
|
||
newVocabNote = "";
|
||
await refreshVocabulary();
|
||
} catch (err) {
|
||
vocabularyError = "Could not save term: " + (err?.message || err);
|
||
}
|
||
}
|
||
|
||
async function addBulkVocabTerms() {
|
||
const raw = bulkVocabText;
|
||
// Accept newline-separated OR comma-separated (or mixed) — whichever the
|
||
// user pasted. Trim each entry, drop empties, and dedupe case-insensitively
|
||
// so "ACME" and "acme" in the same paste collapse to a single term
|
||
// (Whisper prompts don't care about case).
|
||
const seen = new Set();
|
||
const candidates = [];
|
||
for (const entry of raw.split(/[\n,]+/)) {
|
||
const trimmed = entry.trim();
|
||
if (!trimmed) continue;
|
||
const key = trimmed.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
candidates.push(trimmed);
|
||
}
|
||
if (candidates.length === 0) {
|
||
vocabularyError = "Nothing to import — paste one term per line or separated by commas.";
|
||
return;
|
||
}
|
||
|
||
// Skip terms the profile already has (case-insensitive — Whisper prompts
|
||
// don't care about case, and duplicates pollute the Terms list).
|
||
const existing = new Set(vocabulary.map((row) => row.term.toLowerCase()));
|
||
const toAdd = candidates.filter((term) => !existing.has(term.toLowerCase()));
|
||
const skipped = candidates.length - toAdd.length;
|
||
|
||
bulkVocabBusy = true;
|
||
vocabularyError = null;
|
||
const failed = [];
|
||
try {
|
||
for (const term of toAdd) {
|
||
try {
|
||
await profilesStore.addTerm(profilesStore.activeProfileId, term, "");
|
||
} catch (err) {
|
||
failed.push({ term, message: err?.message || String(err) });
|
||
}
|
||
}
|
||
} finally {
|
||
bulkVocabBusy = false;
|
||
}
|
||
|
||
await refreshVocabulary();
|
||
bulkVocabText = "";
|
||
showBulkVocab = false;
|
||
|
||
const addedCount = toAdd.length - failed.length;
|
||
const parts = [];
|
||
if (addedCount > 0) parts.push(`Added ${addedCount}`);
|
||
if (skipped > 0) parts.push(`skipped ${skipped} duplicate${skipped === 1 ? "" : "s"}`);
|
||
if (failed.length > 0) parts.push(`${failed.length} failed`);
|
||
if (failed.length > 0) {
|
||
vocabularyError = `Some terms failed: ${failed.map((f) => f.term).join(", ")}`;
|
||
}
|
||
if (parts.length > 0) {
|
||
toasts.info("Vocabulary import", parts.join(" · "));
|
||
}
|
||
}
|
||
|
||
async function deleteVocabTerm(id) {
|
||
try {
|
||
await profilesStore.deleteTerm(id);
|
||
await refreshVocabulary();
|
||
} catch (err) {
|
||
vocabularyError = "Could not delete term: " + (err?.message || err);
|
||
}
|
||
}
|
||
|
||
// Sync the initial-prompt draft + term list when the active profile flips.
|
||
$effect(() => {
|
||
const active = profilesStore.active;
|
||
initialPromptDraft = active?.initialPrompt ?? "";
|
||
initialPromptDirty = false;
|
||
profileRenameDraft = active?.name ?? "";
|
||
// Refresh terms whenever the active profile id changes.
|
||
void profilesStore.activeProfileId;
|
||
refreshVocabulary();
|
||
});
|
||
|
||
function onActiveProfileChange(e) {
|
||
const id = e.target.value;
|
||
profilesStore.setActive(id);
|
||
}
|
||
|
||
async function saveInitialPrompt() {
|
||
if (!initialPromptDirty) return;
|
||
const active = profilesStore.active;
|
||
if (!active) return;
|
||
await profilesStore.update(active.id, active.name, initialPromptDraft);
|
||
initialPromptDirty = false;
|
||
}
|
||
|
||
async function renameActiveProfile() {
|
||
const active = profilesStore.active;
|
||
if (!active) return;
|
||
const name = profileRenameDraft.trim();
|
||
if (!name || name === active.name) {
|
||
profileRenameDraft = active.name;
|
||
return;
|
||
}
|
||
await profilesStore.update(active.id, name, active.initialPrompt);
|
||
}
|
||
|
||
async function createVocabProfile() {
|
||
const name = newVocabProfileName.trim();
|
||
if (!name) return;
|
||
const created = await profilesStore.create(name, newVocabProfilePrompt.trim());
|
||
if (created) {
|
||
profilesStore.setActive(created.id);
|
||
newVocabProfileName = "";
|
||
newVocabProfilePrompt = "";
|
||
showNewVocabProfile = false;
|
||
}
|
||
}
|
||
|
||
async function deleteActiveProfile() {
|
||
const active = profilesStore.active;
|
||
if (!active || active.id === DEFAULT_PROFILE_ID) return;
|
||
await profilesStore.delete(active.id);
|
||
}
|
||
|
||
// --- 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",
|
||
"Distil-S": "whisper-distil-small-en",
|
||
Medium: "whisper-medium-en",
|
||
"Distil-L": "whisper-distil-large-v3",
|
||
};
|
||
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");
|
||
}
|
||
|
||
function selectedLlmModelId() {
|
||
return settings.llmModelId || "qwen3_4b_instruct_2507";
|
||
}
|
||
|
||
function llmModelStatus(modelId) {
|
||
return llmStatuses[modelId] || null;
|
||
}
|
||
|
||
function llmModelDownloaded(modelId) {
|
||
return !!llmModelStatus(modelId)?.downloaded;
|
||
}
|
||
|
||
function llmModelLoaded(modelId) {
|
||
return !!llmModelStatus(modelId)?.loaded;
|
||
}
|
||
|
||
function llmHardwareWarning(modelId) {
|
||
const ramMb = systemInfo?.ram_mb || 0;
|
||
if (modelId === "qwen3_14b" && ramMb < 32768) {
|
||
return "High tier will swap heavily on this machine. Expect slow responses.";
|
||
}
|
||
if (modelId === "qwen3_4b_instruct_2507" && ramMb < 16384 && !hasGpuAcceleration()) {
|
||
return "Default tier is best with 16 GB RAM or a GPU-backed build.";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function llmTierAvailable(modelId) {
|
||
const ramMb = systemInfo?.ram_mb || 0;
|
||
if (modelId === "qwen3_14b") return ramMb >= 32768;
|
||
if (modelId === "qwen3_4b_instruct_2507") return ramMb >= 16384 || hasGpuAcceleration();
|
||
return true;
|
||
}
|
||
|
||
async function ensureRecommendedLlmTier() {
|
||
if (settings.llmModelId) return;
|
||
try {
|
||
settings.llmModelId = await invoke("recommend_llm_tier");
|
||
} catch {
|
||
settings.llmModelId = "qwen3_4b_instruct_2507";
|
||
}
|
||
}
|
||
|
||
async function refreshLlmStatus() {
|
||
const statuses = {};
|
||
for (const model of LLM_MODELS) {
|
||
try {
|
||
statuses[model.id] = await invoke("check_llm_model", { modelId: model.id });
|
||
} catch {}
|
||
}
|
||
llmStatuses = statuses;
|
||
llmLoaded = await invoke("get_llm_status").catch(() => false);
|
||
const selected = llmModelStatus(selectedLlmModelId());
|
||
llmStatus = selected?.loaded
|
||
? `${selected.displayName} loaded`
|
||
: selected?.downloaded
|
||
? `${selected.displayName} downloaded`
|
||
: "No LLM model downloaded";
|
||
}
|
||
|
||
async function downloadSelectedLlmModel() {
|
||
const modelId = selectedLlmModelId();
|
||
llmDownloadingModel = modelId;
|
||
llmDownloadProgress = 0;
|
||
llmStatus = "Downloading...";
|
||
try {
|
||
await invoke("download_llm_model", { modelId });
|
||
llmDownloadingModel = "";
|
||
await refreshLlmStatus();
|
||
llmStatus = "Download complete";
|
||
} catch (err) {
|
||
llmDownloadingModel = "";
|
||
llmStatus = typeof err === "string" ? err : "LLM download failed";
|
||
}
|
||
}
|
||
|
||
async function loadSelectedLlmModel() {
|
||
const modelId = selectedLlmModelId();
|
||
llmStatus = "Loading...";
|
||
try {
|
||
await invoke("load_llm_model", { modelId });
|
||
await refreshLlmStatus();
|
||
} catch (err) {
|
||
llmStatus = typeof err === "string" ? err : "LLM load failed";
|
||
}
|
||
}
|
||
|
||
async function unloadLlmModel() {
|
||
try {
|
||
await invoke("unload_llm_model");
|
||
await refreshLlmStatus();
|
||
llmStatus = "Model unloaded";
|
||
} catch (err) {
|
||
llmStatus = typeof err === "string" ? err : "LLM unload failed";
|
||
}
|
||
}
|
||
|
||
async function deleteSelectedLlmModel() {
|
||
const modelId = selectedLlmModelId();
|
||
try {
|
||
await invoke("delete_llm_model", { modelId });
|
||
await refreshLlmStatus();
|
||
llmStatus = "Downloaded model removed";
|
||
} catch (err) {
|
||
llmStatus = typeof err === "string" ? err : "Delete failed";
|
||
}
|
||
}
|
||
|
||
async function setAiTier(nextTier) {
|
||
settings.aiTier = nextTier;
|
||
if (nextTier === "off") {
|
||
await unloadLlmModel();
|
||
return;
|
||
}
|
||
|
||
if (llmModelDownloaded(selectedLlmModelId())) {
|
||
await loadSelectedLlmModel();
|
||
} else {
|
||
llmStatus = "Download a model to enable AI features.";
|
||
}
|
||
}
|
||
|
||
async function selectLlmModel(modelId) {
|
||
settings.llmModelId = modelId;
|
||
if (llmLoaded) {
|
||
await unloadLlmModel();
|
||
} else {
|
||
await refreshLlmStatus();
|
||
}
|
||
llmStatus = llmModelDownloaded(modelId)
|
||
? "Selected model changed. Load it to enable AI features."
|
||
: "Selected model changed. Download it to enable AI features.";
|
||
}
|
||
|
||
async function toggleAiSection() {
|
||
openSection = openSection === 'ai' ? null : 'ai';
|
||
if (openSection === 'ai') {
|
||
await ensureRecommendedLlmTier();
|
||
await refreshLlmStatus();
|
||
}
|
||
}
|
||
|
||
onMount(async () => {
|
||
try {
|
||
await refreshRuntimeCapabilities();
|
||
systemInfo = await invoke("probe_system").catch(() => null);
|
||
await ensureRecommendedLlmTier();
|
||
await refreshLlmStatus();
|
||
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();
|
||
|
||
// Vocabulary is loaded reactively via the $effect that tracks the
|
||
// active profile id (set once profilesStore.load() resolves in the
|
||
// root layout). No eager fetch needed here.
|
||
|
||
try {
|
||
downloadedModels = await invoke("list_models");
|
||
} catch {}
|
||
|
||
try {
|
||
pasteBackends = (await invoke("detect_paste_backends")) || [];
|
||
} catch {
|
||
pasteBackends = [];
|
||
}
|
||
|
||
// 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;
|
||
});
|
||
|
||
unlistenLlm = await listen("kon:llm-download-progress", (event) => {
|
||
llmDownloadProgress = event.payload.percent || 0;
|
||
llmDownloadingModel = event.payload.modelId || llmDownloadingModel;
|
||
});
|
||
|
||
unlistenParakeet = await listen("parakeet-download-progress", (event) => {
|
||
parakeetProgress = event.payload.percent || event.payload.progress || 0;
|
||
});
|
||
});
|
||
|
||
onDestroy(() => {
|
||
if (unlisten) unlisten();
|
||
if (unlistenLlm) unlistenLlm();
|
||
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 = whisperModelId(settings.modelSize);
|
||
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",
|
||
"Distil-S": "~336MB · small-level accuracy at ~6× the speed",
|
||
Medium: "~1.5GB · best Whisper accuracy, slower",
|
||
"Distil-L": "~1.55GB · near-large accuracy at ~6× the speed",
|
||
};
|
||
|
||
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 visibleAudioDevices as dev}
|
||
<option value={dev.name} disabled={dev.is_likely_monitor}>
|
||
{friendlyLabel(dev)}{#if dev.is_default && dev.name !== "default"} (system default){/if}{#if dev.is_likely_monitor} — speaker monitor, skip{/if}
|
||
</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 visibleAudioDevices.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 — profile-scoped (Task 15) -->
|
||
<!-- Terms and the initial prompt travel with the active profile.
|
||
The default profile can't be deleted, only edited. -->
|
||
<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. Vocabulary is scoped to a profile — switch profiles to swap whole vocabularies.
|
||
</p>
|
||
|
||
<!-- Profile selector -->
|
||
<div class="mb-5">
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Active Profile</p>
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<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-[220px]"
|
||
value={profilesStore.activeProfileId}
|
||
onchange={onActiveProfileChange}
|
||
>
|
||
{#each profilesStore.profiles as profile (profile.id)}
|
||
<option value={profile.id}>{profile.name}</option>
|
||
{/each}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 text-[12px] text-text-secondary border border-border rounded-lg hover:border-accent hover:text-text"
|
||
onclick={() => { showNewVocabProfile = !showNewVocabProfile; }}
|
||
>{showNewVocabProfile ? "Cancel" : "New profile"}</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 text-[12px] text-text-secondary border border-border rounded-lg
|
||
hover:border-error hover:text-error
|
||
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:text-text-secondary"
|
||
disabled={profilesStore.activeProfileId === DEFAULT_PROFILE_ID}
|
||
onclick={deleteActiveProfile}
|
||
title={profilesStore.activeProfileId === DEFAULT_PROFILE_ID ? "The default profile cannot be deleted" : "Delete this profile"}
|
||
>Delete profile</button>
|
||
</div>
|
||
|
||
{#if showNewVocabProfile}
|
||
<div class="mt-3 p-3 border border-border-subtle rounded-lg bg-bg-input animate-fade-in">
|
||
<div class="flex flex-col gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Profile name (e.g. Medical notes, Podcast)"
|
||
bind:value={newVocabProfileName}
|
||
onkeydown={(e) => { if (e.key === 'Enter') createVocabProfile(); }}
|
||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[13px] text-text
|
||
focus:outline-none focus:border-accent"
|
||
/>
|
||
<textarea
|
||
placeholder="Initial prompt (optional) — extra context Whisper primes on before every session in this profile"
|
||
bind:value={newVocabProfilePrompt}
|
||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text resize-none h-[72px]
|
||
focus:outline-none focus:border-accent"
|
||
></textarea>
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onclick={createVocabProfile}
|
||
disabled={!newVocabProfileName.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"
|
||
>Create</button>
|
||
<button
|
||
type="button"
|
||
onclick={() => { showNewVocabProfile = false; newVocabProfileName = ""; newVocabProfilePrompt = ""; }}
|
||
class="px-3 py-2 text-[12px] text-text-secondary"
|
||
>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if profilesStore.active}
|
||
<!-- Rename active profile -->
|
||
<div class="mb-5">
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Profile Name</p>
|
||
<input
|
||
type="text"
|
||
bind:value={profileRenameDraft}
|
||
onblur={renameActiveProfile}
|
||
onkeydown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
||
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text min-w-[280px]
|
||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Initial prompt textarea -->
|
||
<div class="mb-5">
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Initial Prompt</p>
|
||
<p class="text-[11px] text-text-tertiary mb-2">
|
||
Passed to Whisper before every transcription in this profile. Used as a biasing prompt — keep it short and topic-specific (names, domain terms, tone cues).
|
||
</p>
|
||
<textarea
|
||
bind:value={initialPromptDraft}
|
||
oninput={() => { initialPromptDirty = true; }}
|
||
onblur={saveInitialPrompt}
|
||
placeholder="e.g. Meeting between Jake and Wren about CORBEL's Furnished World architecture."
|
||
class="w-full h-[96px] bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px] text-text resize-none
|
||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
|
||
></textarea>
|
||
<div class="flex items-center gap-3 mt-1">
|
||
{#if initialPromptDirty}
|
||
<button
|
||
type="button"
|
||
onclick={saveInitialPrompt}
|
||
class="text-[11px] text-accent hover:text-accent-hover"
|
||
>Save prompt</button>
|
||
<span class="text-[11px] text-text-tertiary">Unsaved changes — saves on blur.</span>
|
||
{:else}
|
||
<span class="text-[11px] text-text-tertiary">Saved.</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Terms -->
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Terms</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>
|
||
|
||
<!-- Bulk import — one term per line or comma-separated. -->
|
||
<div class="mb-4">
|
||
{#if !showBulkVocab}
|
||
<button
|
||
type="button"
|
||
class="text-[11px] text-text-tertiary hover:text-accent underline"
|
||
onclick={() => { showBulkVocab = true; }}
|
||
>Bulk add from a list…</button>
|
||
{:else}
|
||
<div class="p-3 bg-bg-input border border-border-subtle rounded-lg animate-fade-in">
|
||
<textarea
|
||
bind:value={bulkVocabText}
|
||
placeholder="Paste terms — one per line, or separated by commas. Duplicates are skipped automatically."
|
||
rows="4"
|
||
disabled={bulkVocabBusy}
|
||
class="w-full bg-bg border border-border rounded-lg px-3 py-2 text-[13px] text-text font-mono
|
||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
|
||
disabled:opacity-50 resize-y"
|
||
></textarea>
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<button
|
||
type="button"
|
||
onclick={addBulkVocabTerms}
|
||
disabled={bulkVocabBusy || !bulkVocabText.trim()}
|
||
class="px-3 py-1.5 text-[12px] bg-accent text-bg rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent-hover"
|
||
>{bulkVocabBusy ? "Importing…" : "Import"}</button>
|
||
<button
|
||
type="button"
|
||
onclick={() => { showBulkVocab = false; bulkVocabText = ""; }}
|
||
disabled={bulkVocabBusy}
|
||
class="px-3 py-1.5 text-[12px] text-text-tertiary hover:text-text"
|
||
>Cancel</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</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}
|
||
{:else}
|
||
<p class="text-[11px] text-text-tertiary italic">Loading profiles…</p>
|
||
{/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", "Distil-S", "Medium", "Distil-L"]} 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 === whisperModelId(settings.modelSize)}
|
||
<span class="text-[11px] text-warning">{downloadProgress}% downloading...</span>
|
||
{:else}
|
||
<button
|
||
class="text-[11px] text-accent hover:text-accent-hover"
|
||
onclick={() => downloadModel(whisperModelId(settings.modelSize))}
|
||
>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={toggleAiSection}
|
||
>
|
||
<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 transcript cleanup, smart task extraction, and task breakdown. Runs fully offline after the model is downloaded.
|
||
</p>
|
||
|
||
<div class="mb-5">
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Feature Tier</p>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg border text-[12px] transition-colors
|
||
{settings.aiTier === 'off'
|
||
? 'bg-bg-elevated border-accent text-text'
|
||
: 'bg-bg-input border-border text-text-tertiary hover:text-text'}"
|
||
onclick={() => setAiTier("off")}
|
||
>Off</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg border text-[12px] transition-colors
|
||
{settings.aiTier === 'cleanup'
|
||
? 'bg-bg-elevated border-accent text-text'
|
||
: 'bg-bg-input border-border text-text-tertiary hover:text-text'}"
|
||
onclick={() => setAiTier("cleanup")}
|
||
>Cleanup only</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg border text-[12px] transition-colors
|
||
{settings.aiTier === 'tasks'
|
||
? 'bg-bg-elevated border-accent text-text'
|
||
: 'bg-bg-input border-border text-text-tertiary hover:text-text'}"
|
||
onclick={() => setAiTier("tasks")}
|
||
>Cleanup + Tasks</button>
|
||
</div>
|
||
<p class="text-[11px] text-text-tertiary mt-2">
|
||
{settings.aiTier === "off"
|
||
? "No local LLM calls. Kon falls back to the existing rule-based path."
|
||
: settings.aiTier === "cleanup"
|
||
? "Use the local model for transcript cleanup and formatting."
|
||
: "Use the local model for cleanup, task extraction, and task breakdown."}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="mb-5">
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Model Tier</p>
|
||
<div class="space-y-2">
|
||
{#each LLM_MODELS as model}
|
||
<button
|
||
type="button"
|
||
class="w-full text-left rounded-lg border px-3 py-3 transition-colors
|
||
{selectedLlmModelId() === model.id
|
||
? 'border-accent bg-bg-elevated'
|
||
: 'border-border bg-bg-input hover:border-accent/50'}
|
||
{llmTierAvailable(model.id) ? '' : 'opacity-70'}"
|
||
onclick={() => selectLlmModel(model.id)}
|
||
>
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-[12px] font-medium text-text">{model.label}</span>
|
||
<span class="text-[11px] text-text-secondary">{model.subtitle}</span>
|
||
</div>
|
||
<p class="text-[11px] text-text-tertiary mt-1">{model.size} · {model.fit}</p>
|
||
{#if llmHardwareWarning(model.id)}
|
||
<p class="text-[11px] text-warning mt-2">{llmHardwareWarning(model.id)}</p>
|
||
{/if}
|
||
</div>
|
||
<div class="text-right text-[11px]">
|
||
{#if llmModelLoaded(model.id)}
|
||
<span class="text-success">Loaded</span>
|
||
{:else if llmModelDownloaded(model.id)}
|
||
<span class="text-text-secondary">Downloaded</span>
|
||
{:else}
|
||
<span class="text-text-tertiary">Not downloaded</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-bg-input rounded-lg px-3 py-3 border border-border-subtle">
|
||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||
<div>
|
||
<p class="text-[12px] text-text-secondary font-medium">
|
||
{LLM_MODELS.find((model) => model.id === selectedLlmModelId())?.subtitle || "Local model"}
|
||
</p>
|
||
<p class="text-[11px] text-text-tertiary mt-1">{llmStatus}</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
{#if llmDownloadingModel === selectedLlmModelId()}
|
||
<span class="text-[11px] text-warning">{llmDownloadProgress}% downloading…</span>
|
||
{:else if !llmModelDownloaded(selectedLlmModelId())}
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg bg-accent text-bg text-[12px] hover:bg-accent-hover"
|
||
onclick={downloadSelectedLlmModel}
|
||
>Download</button>
|
||
{:else if !llmModelLoaded(selectedLlmModelId())}
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg bg-accent text-bg text-[12px] hover:bg-accent-hover"
|
||
onclick={loadSelectedLlmModel}
|
||
>Load</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg border border-border text-[12px] text-text-secondary hover:text-text"
|
||
onclick={deleteSelectedLlmModel}
|
||
>Delete</button>
|
||
{:else}
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg border border-border text-[12px] text-text-secondary hover:text-text"
|
||
onclick={unloadLlmModel}
|
||
>Unload</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg border border-border text-[12px] text-text-secondary hover:text-text"
|
||
onclick={deleteSelectedLlmModel}
|
||
>Delete</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<p class="text-[11px] text-text-tertiary mt-3">
|
||
Recommended for this machine:
|
||
<span class="text-text">
|
||
{LLM_MODELS.find((model) => model.id === (settings.llmModelId || "qwen3_4b_instruct_2507"))?.subtitle || "Qwen3 4B Instruct 2507"}
|
||
</span>
|
||
{#if systemInfo}
|
||
· {Math.round((systemInfo.ram_mb || 0) / 1024)} GB RAM detected
|
||
{/if}
|
||
</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.autoPaste}
|
||
label="Auto-paste into focused window"
|
||
description={pasteBackendsDescription}
|
||
/>
|
||
<Toggle
|
||
bind:checked={settings.transcriptionPreview}
|
||
label="Floating preview when Kon is unfocused"
|
||
description="Shows a small always-on-top window with the raw transcription as you dictate, then the final formatted text. Only opens when the main window is unfocused or hidden."
|
||
/>
|
||
<Toggle
|
||
bind:checked={settings.meetingAutoCapture}
|
||
label="Remind me when a meeting starts"
|
||
description="Toasts when a matching app appears in the process list. You still hit the hotkey — Kon never records on its own."
|
||
/>
|
||
{#if settings.meetingAutoCapture}
|
||
<div class="ml-[50px] mt-2 mb-1 animate-fade-in">
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5">Apps to watch (comma-separated)</p>
|
||
<input
|
||
type="text"
|
||
class="w-full bg-bg-input border border-border-subtle rounded-lg px-3 py-1.5 text-[12px] text-text"
|
||
value={settings.meetingAutoCaptureApps.join(", ")}
|
||
oninput={(event) => {
|
||
const raw = event.currentTarget.value;
|
||
settings.meetingAutoCaptureApps = raw
|
||
.split(",")
|
||
.map((entry) => entry.trim().toLowerCase())
|
||
.filter((entry) => entry.length > 0);
|
||
}}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
<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 class="mb-2">
|
||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
|
||
{$_("settings.language")}
|
||
</p>
|
||
<div class="inline-flex bg-bg-elevated rounded-[10px] p-[3px] gap-[2px]">
|
||
{#each SUPPORTED_LOCALES as option}
|
||
<button
|
||
class="rounded-lg font-medium px-3.5 py-[6px] text-[12px]
|
||
{$currentLocale === option.code
|
||
? 'bg-accent text-bg shadow-[0_1px_4px_rgba(232,168,124,0.3)]'
|
||
: 'text-text-secondary hover:text-text hover:bg-hover'}"
|
||
style="transition-duration: var(--duration-ui)"
|
||
onclick={() => setLocale(option.code)}
|
||
>{option.label}</button>
|
||
{/each}
|
||
</div>
|
||
<p class="text-[11px] text-text-tertiary mt-2">{$_("settings.languageDescription")}</p>
|
||
</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>
|