SettingsPage is 2 791 LOC with ~95 form controls. The plan called for section-by-section migration with selective Formsnap; the wrapper aliases land Phase 4 made that unnecessary on the per-section level. Because LumotiaCard / LumotiaToggle / LumotiaSettingsGroup / LumotiaStatusPill keep the exact prop API of the underlying components, all that's needed to migrate every existing markup site is repointing the four local import names: Card → $lib/ui/LumotiaCard.svelte Toggle → $lib/ui/LumotiaToggle.svelte SettingsGroup → $lib/ui/LumotiaSettingsGroup.svelte StatusPill → $lib/ui/LumotiaStatusPill.svelte The 90+ <Card>, <Toggle>, <SettingsGroup>, <StatusPill> usages compile unchanged because the local symbols still resolve to compatible components. Existing IA is preserved verbatim — section ordering, SegmentedButton bindings, HotkeyRecorder, ZonePicker, ModelDownloader, and the Phase 3 KI-05 theme bindings all stay in place. Formsnap is intentionally NOT pulled into SettingsPage in v0.2. The form here is a wide tree of independent settings; Superforms + Formsnap would force a heavyweight schema layer for no observable validation win. Per-page gate: npm run check (0/0/5704 files). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2805 lines
122 KiB
Svelte
2805 lines
122 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/ui/LumotiaCard.svelte";
|
||
import Toggle from "$lib/ui/LumotiaToggle.svelte";
|
||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
|
||
import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte";
|
||
import SettingsGroup from "$lib/ui/LumotiaSettingsGroup.svelte";
|
||
import StatusPill from "$lib/ui/LumotiaStatusPill.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, Search, X, Mic, BookOpen, Type, Sparkles, SquareCheck, Clipboard, Sliders } from "lucide-svelte";
|
||
import { formatDuration } from "$lib/utils/time.js";
|
||
import { _ } from "svelte-i18n";
|
||
import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n";
|
||
// Aliased because SettingsPage has its own local refreshLlmStatus
|
||
// that just mutates the page-local llmLoaded bool. The store
|
||
// version drives the sidebar chip (brief item #31).
|
||
import { refreshLlmStatus as refreshGlobalLlmStatus } from "$lib/stores/llmStatus.svelte.js";
|
||
|
||
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);
|
||
// Whisper download. bytes and start time drive the byte counter and the
|
||
// rough remaining-time estimate alongside the percent.
|
||
let downloadBytes = $state(0);
|
||
let downloadTotal = $state(0);
|
||
let downloadStartedAt = $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);
|
||
// LLM download mirrors the whisper telemetry. Bytes + start time give
|
||
// us the same bytes / ETA chip that ships on the whisper row.
|
||
let llmDownloadBytes = $state(0);
|
||
let llmDownloadTotal = $state(0);
|
||
let llmDownloadStartedAt = $state(0);
|
||
let llmLoaded = $state(false);
|
||
let systemInfo = $state(null);
|
||
|
||
// Bytes formatter shared with the model download chips. Mirrors the
|
||
// copy used inside ModelDownloader.svelte; lifted here so we don't have
|
||
// to import a single component just for one helper.
|
||
function formatBytes(bytes: number) {
|
||
if (!bytes || bytes < 0) return "0 B";
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||
return `${(bytes / 1073741824).toFixed(2)} GB`;
|
||
}
|
||
|
||
// Estimated seconds remaining from a percent + the moment the download
|
||
// started. Returns 0 until we have at least 1% of progress so the first
|
||
// tick doesn't read as "300 minutes". Mirrors the FirstRunPage shape
|
||
// but exported here so we can show it on the Whisper and LLM rows.
|
||
function etaSecondsFromPercent(percent: number, startedAt: number): number {
|
||
if (!percent || percent <= 0 || !startedAt) return 0;
|
||
const elapsedSec = (Date.now() - startedAt) / 1000;
|
||
const totalSec = elapsedSec / (percent / 100);
|
||
return Math.max(0, totalSec - elapsedSec);
|
||
}
|
||
|
||
const LLM_MODELS = [
|
||
{
|
||
id: "qwen3_5_2b",
|
||
label: "Minimal",
|
||
subtitle: "Qwen3.5 2B",
|
||
fit: "8 GB RAM, CPU-heavy machines",
|
||
size: "~1.3 GB",
|
||
},
|
||
{
|
||
id: "qwen3_5_4b",
|
||
label: "Standard",
|
||
subtitle: "Qwen3.5 4B",
|
||
fit: "16 GB RAM or 6 GB+ VRAM",
|
||
size: "~2.7 GB",
|
||
},
|
||
{
|
||
id: "qwen3_5_9b",
|
||
label: "High",
|
||
subtitle: "Qwen3.5 9B",
|
||
fit: "32 GB RAM and 12 GB+ VRAM",
|
||
size: "~5.7 GB",
|
||
},
|
||
{
|
||
id: "qwen3_6_27b",
|
||
label: "Maximum",
|
||
subtitle: "Qwen3.6 27B",
|
||
fit: "64 GB RAM and 24 GB+ VRAM",
|
||
size: "~17 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 = String(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 vocabularyErrorDetail = $state("");
|
||
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;
|
||
vocabularyErrorDetail = "";
|
||
try {
|
||
const profileId = profilesStore.activeProfileId;
|
||
vocabulary = await profilesStore.listTerms(profileId);
|
||
} catch (err) {
|
||
vocabularyError = "We couldn't load your custom vocabulary. Your existing transcripts are unaffected.";
|
||
vocabularyErrorDetail = String(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 that term. Your other terms are unaffected.";
|
||
vocabularyErrorDetail = String(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.";
|
||
vocabularyErrorDetail = "";
|
||
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;
|
||
vocabularyErrorDetail = "";
|
||
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 = `${failed.length} term${failed.length === 1 ? "" : "s"} couldn't be saved. Your other terms are unaffected.`;
|
||
vocabularyErrorDetail = failed.map((f) => `${f.term}: ${f.message}`).join("\n");
|
||
}
|
||
if (parts.length > 0) {
|
||
toasts.info("Vocabulary import", parts.join(" · "));
|
||
}
|
||
}
|
||
|
||
async function deleteVocabTerm(id) {
|
||
if (!confirm("Remove this vocabulary term? Future transcripts won't get the custom spelling. This cannot be undone.")) return;
|
||
try {
|
||
await profilesStore.deleteTerm(id);
|
||
await refreshVocabulary();
|
||
} catch (err) {
|
||
vocabularyError = "We couldn't remove that term. Your other terms are unaffected.";
|
||
vocabularyErrorDetail = String(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;
|
||
if (!confirm("Delete the active profile? Its templates, vocabulary, and settings will be removed. This cannot be undone.")) 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 diagnosticReportErrorDetail = $state("");
|
||
let diagnosticReportSavedTo = $state(null);
|
||
let diagnosticReportLoading = $state(false);
|
||
|
||
let diagnosticBundleLoading = $state(false);
|
||
|
||
async function generateDiagnosticReport() {
|
||
diagnosticReportError = null;
|
||
diagnosticReportErrorDetail = "";
|
||
diagnosticReportSavedTo = null;
|
||
diagnosticReportLoading = true;
|
||
try {
|
||
diagnosticReport = await invoke("generate_diagnostic_report", {
|
||
options: {
|
||
includeRecentErrors: true,
|
||
includeCrashes: true,
|
||
includeSettings: true,
|
||
includeLogTail: true,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
diagnosticReportError = "Generating the diagnostic bundle didn't work. Your data is unchanged.";
|
||
diagnosticReportErrorDetail = String(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 = "Copying to clipboard didn't work. Try saving as a file instead.";
|
||
diagnosticReportErrorDetail = String(err?.message ?? err);
|
||
}
|
||
}
|
||
|
||
async function saveDiagnosticReport() {
|
||
diagnosticReportError = null;
|
||
diagnosticReportErrorDetail = "";
|
||
try {
|
||
const path = await invoke("save_diagnostic_report", {
|
||
options: {
|
||
includeRecentErrors: true,
|
||
includeCrashes: true,
|
||
includeSettings: true,
|
||
includeLogTail: true,
|
||
},
|
||
});
|
||
diagnosticReportSavedTo = "Saved to " + path;
|
||
} catch (err) {
|
||
diagnosticReportError = "Saving the report file didn't work. Your data is unchanged.";
|
||
diagnosticReportErrorDetail = String(err?.message ?? err);
|
||
}
|
||
}
|
||
|
||
async function generateDiagnosticBundle() {
|
||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||
const outputPath = await save({
|
||
filters: [{ name: "Zip", extensions: ["zip"] }],
|
||
});
|
||
if (!outputPath) return;
|
||
diagnosticBundleLoading = true;
|
||
try {
|
||
const result = await invoke<{ path: string; bytes: number; included: string[]; excluded: string[] }>(
|
||
"generate_diagnostic_bundle",
|
||
{ outputPath },
|
||
);
|
||
const kb = Math.round(result.bytes / 1024);
|
||
toasts.success(
|
||
"Diagnostic bundle saved",
|
||
`${kb} KB saved. You can attach this to a GitHub issue.`,
|
||
);
|
||
} catch (err) {
|
||
toasts.error(
|
||
"Could not save diagnostic bundle",
|
||
String(err?.message ?? err),
|
||
);
|
||
} finally {
|
||
diagnosticBundleLoading = false;
|
||
}
|
||
}
|
||
|
||
let parakeetDownloaded = $state(false);
|
||
let parakeetDownloading = $state(false);
|
||
let parakeetProgress = $state(0);
|
||
let unlistenParakeet = null;
|
||
|
||
// Profiles & Templates
|
||
let editingProfile = $state(-1);
|
||
let editingTemplate = $state(-1);
|
||
let newProfileName = $state("");
|
||
let newTemplateName = $state("");
|
||
let showNewProfile = $state(false);
|
||
let showNewTemplate = $state(false);
|
||
|
||
// Arm-confirm state for profile deletion. Mirrors HistoryPage's inline
|
||
// confirm pattern (4-second auto-disarm) — first click on Delete arms,
|
||
// second click within the window calls through to the storage layer.
|
||
// Replaces the prior one-click splice-into-localStorage path that
|
||
// silently drifted localStorage and SQLite apart.
|
||
const PROFILE_DELETE_CONFIRM_MS = 4000;
|
||
let deleteProfileArmedIndex = $state(-1);
|
||
let deleteProfileArmTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let deleteProfileBusy = $state(false);
|
||
|
||
// Phase 9c: SettingsGroup native <details> drives disclosure state per
|
||
// group; no more centralised openSection. Transcription stays open by
|
||
// default (matches the prior accordion behaviour where it was the
|
||
// landing section).
|
||
|
||
// Search filter. Empty string = no filter and groups use their default
|
||
// open state. Non-empty string force-opens any SettingsGroup whose
|
||
// title, description, or hand-curated keywords contain the query
|
||
// (case-insensitive). Parent groups pass their children's keywords
|
||
// through so a match on "GPU" inside AI Assistant also opens AI &
|
||
// Processing.
|
||
let settingsSearch = $state("");
|
||
let searchActive = $derived(settingsSearch.trim().length > 0);
|
||
let searchQuery = $derived(settingsSearch.trim().toLowerCase());
|
||
function searchMatches(...terms) {
|
||
if (!searchActive) return false;
|
||
return terms.some((t) => (t || "").toString().toLowerCase().includes(searchQuery));
|
||
}
|
||
|
||
// Quick-settings row at the top of SettingsPage. Surfaces theme, font
|
||
// size, and the global hotkey above the seven progressive-disclosure
|
||
// groups so the most-tweaked controls don't require opening a group.
|
||
// Font-size buckets map onto the existing settings.fontSize range
|
||
// (10-24); Smaller/Default/Larger map onto 12/14/18 to match the
|
||
// existing default of 14. SegmentedButton uses bind:value, so a
|
||
// local mirror state syncs with settings.fontSize via $effect — this
|
||
// also keeps the row in sync if Appearance's fine-grained slider is
|
||
// dragged.
|
||
const FONT_SIZE_BUCKETS = { Smaller: 12, Default: 14, Larger: 18 };
|
||
function bucketFromSize(fs) {
|
||
if ((fs ?? 14) <= 12) return "Smaller";
|
||
if ((fs ?? 14) >= 18) return "Larger";
|
||
return "Default";
|
||
}
|
||
let fontSizeBucket = $state(bucketFromSize(settings.fontSize));
|
||
$effect(() => {
|
||
const next = bucketFromSize(settings.fontSize);
|
||
if (next !== fontSizeBucket) fontSizeBucket = next;
|
||
});
|
||
$effect(() => {
|
||
const target = FONT_SIZE_BUCKETS[fontSizeBucket];
|
||
if (target !== undefined && settings.fontSize !== target) {
|
||
settings.fontSize = target;
|
||
}
|
||
});
|
||
// Hotkey chip jumps to the Global hotkey SettingsGroup. The group
|
||
// sits inside Appearance & System; both must be opened so the
|
||
// recorder is visible after the scroll lands.
|
||
function jumpToGlobalHotkey() {
|
||
const wrapper = document.getElementById("global-hotkey-group");
|
||
if (!wrapper) return;
|
||
const details = wrapper.querySelectorAll("details");
|
||
details.forEach((d) => { (d as HTMLDetailsElement).open = true; });
|
||
wrapper.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
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_5_4b";
|
||
}
|
||
|
||
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_6_27b" && ramMb < 65536) {
|
||
return "Maximum tier needs 64 GB RAM (or a 24 GB GPU) to avoid heavy swap. Expect slow responses on this machine.";
|
||
}
|
||
if (modelId === "qwen3_5_9b" && ramMb < 32768) {
|
||
return "High tier will swap heavily on this machine. Expect slow responses.";
|
||
}
|
||
if (modelId === "qwen3_5_4b" && ramMb < 16384 && !hasGpuAcceleration()) {
|
||
return "Standard 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_6_27b") return ramMb >= 65536;
|
||
if (modelId === "qwen3_5_9b") return ramMb >= 32768;
|
||
if (modelId === "qwen3_5_4b") 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_5_4b";
|
||
}
|
||
}
|
||
|
||
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;
|
||
llmDownloadBytes = 0;
|
||
llmDownloadTotal = 0;
|
||
llmDownloadStartedAt = Date.now();
|
||
llmStatus = "Downloading...";
|
||
try {
|
||
await invoke("download_llm_model", { modelId });
|
||
llmDownloadingModel = "";
|
||
await refreshLlmStatus();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
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,
|
||
// Sequential-GPU guard (brief item A.1 #28).
|
||
concurrent: settings.aiGpuConcurrency === "parallel",
|
||
});
|
||
await refreshLlmStatus();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
} catch (err) {
|
||
llmStatus = typeof err === "string" ? err : "LLM load failed";
|
||
}
|
||
}
|
||
|
||
async function unloadLlmModel() {
|
||
try {
|
||
await invoke("unload_llm_model");
|
||
await refreshLlmStatus();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
llmStatus = "Model unloaded";
|
||
} catch (err) {
|
||
llmStatus = typeof err === "string" ? err : "LLM unload failed";
|
||
}
|
||
}
|
||
|
||
async function deleteSelectedLlmModel() {
|
||
if (!confirm("Delete the selected LLM model file? Lumotia won't be able to use it until you re-download. This cannot be undone.")) return;
|
||
const modelId = selectedLlmModelId();
|
||
try {
|
||
await invoke("delete_llm_model", { modelId });
|
||
await refreshLlmStatus();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
llmStatus = "Downloaded model removed";
|
||
} catch (err) {
|
||
llmStatus = typeof err === "string" ? err : "Delete failed";
|
||
}
|
||
}
|
||
|
||
// Brief item B.1 #27: diagnostic button that classifies LLM setup
|
||
// failures into actionable categories. Result shape comes from the
|
||
// backend — category / ok / message / hint. `llmTestHint` is held
|
||
// separately so we can render it below the one-line status without
|
||
// stomping the existing llmStatus string.
|
||
let llmTestBusy = $state(false);
|
||
let llmTestHint = $state("");
|
||
|
||
async function testSelectedLlmModel() {
|
||
if (llmTestBusy) return;
|
||
const modelId = selectedLlmModelId();
|
||
llmTestBusy = true;
|
||
llmStatus = "Testing...";
|
||
llmTestHint = "";
|
||
try {
|
||
const result = await invoke("test_llm_model", { modelId });
|
||
llmStatus = result?.message ?? "Test complete";
|
||
llmTestHint = result?.hint ?? "";
|
||
// A successful test also means the model was loaded (or was
|
||
// already loaded) — refresh both Settings-local and global
|
||
// status so the sidebar chip and download/load buttons react.
|
||
await refreshLlmStatus();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
} catch (err) {
|
||
llmStatus = typeof err === "string" ? err : "Test failed";
|
||
llmTestHint = "";
|
||
} finally {
|
||
llmTestBusy = false;
|
||
}
|
||
}
|
||
|
||
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();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
}
|
||
llmStatus = llmModelDownloaded(modelId)
|
||
? "Selected model changed. Load it to enable AI features."
|
||
: "Selected model changed. Download it to enable AI features.";
|
||
}
|
||
|
||
// AI section open-handler (Phase 9c restructure). Wired to the AI &
|
||
// Processing SettingsGroup's `onopen`. The status calls are also made
|
||
// in onMount, so re-running them on first open is a cheap correction
|
||
// for cases where the mount-time call raced an unmounted state.
|
||
async function onAiSectionOpen() {
|
||
await ensureRecommendedLlmTier();
|
||
await refreshLlmStatus();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
}
|
||
|
||
// Phase 4 Read Page Aloud. Voices are lazy-loaded on first open so
|
||
// Settings doesn't pay the `say -v ?` / PowerShell cost on every
|
||
// mount. An empty list renders as "System default" only.
|
||
let ttsVoices = $state([]);
|
||
let ttsVoicesLoaded = $state(false);
|
||
let ttsVoicesLoading = $state(false);
|
||
let ttsVoicesError = $state("");
|
||
|
||
async function refreshTtsVoices() {
|
||
if (ttsVoicesLoading) return;
|
||
ttsVoicesLoading = true;
|
||
ttsVoicesError = "";
|
||
try {
|
||
ttsVoices = await invoke("tts_list_voices");
|
||
ttsVoicesLoaded = true;
|
||
} catch (err) {
|
||
ttsVoicesError = String(err);
|
||
} finally {
|
||
ttsVoicesLoading = false;
|
||
}
|
||
}
|
||
|
||
// Wired to the Read aloud SettingsGroup's `onopen`. Idempotent —
|
||
// refreshTtsVoices already short-circuits when ttsVoicesLoading.
|
||
async function onReadAloudOpen() {
|
||
if (!ttsVoicesLoaded) {
|
||
await refreshTtsVoices();
|
||
}
|
||
}
|
||
|
||
async function testReadAloudVoice() {
|
||
try {
|
||
await invoke("tts_speak", {
|
||
text: "This is Lumotia reading aloud.",
|
||
rate: settings.ttsRate,
|
||
voice: settings.ttsVoice ?? null,
|
||
});
|
||
} catch (err) {
|
||
toasts.warn("Could not read aloud", String(err));
|
||
}
|
||
}
|
||
|
||
// Phase 5 rituals. Autostart state mirrors the OS-level entry managed
|
||
// by tauri-plugin-autostart; reading via invoke keeps the toggle
|
||
// honest even if the user has edited their .desktop file manually.
|
||
//
|
||
// Async onChange handler for the Launch-at-Login toggle. Toggle.svelte
|
||
// shows a spinner while the promise is in flight and snaps back to the
|
||
// previous checked value on rejection, so we throw on failure rather
|
||
// than swallow it. The store value is only mutated on success.
|
||
async function setLaunchAtLogin(nextOn: boolean) {
|
||
try {
|
||
const plugin = await import("@tauri-apps/plugin-autostart");
|
||
if (nextOn) {
|
||
await plugin.enable();
|
||
} else {
|
||
await plugin.disable();
|
||
}
|
||
settings.launchAtLogin = nextOn;
|
||
} catch (err) {
|
||
toasts.warn("Could not update autostart", String(err));
|
||
// Re-read to correct the store if we failed halfway, then re-throw
|
||
// so Toggle can snap its checked state back to the previous value.
|
||
try {
|
||
const plugin = await import("@tauri-apps/plugin-autostart");
|
||
settings.launchAtLogin = await plugin.isEnabled();
|
||
} catch { /* best-effort */ }
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function syncAutostartFromOs() {
|
||
try {
|
||
const plugin = await import("@tauri-apps/plugin-autostart");
|
||
settings.launchAtLogin = await plugin.isEnabled();
|
||
} catch { /* best-effort on browser / first load */ }
|
||
}
|
||
|
||
function openWindDown() {
|
||
page.current = "shutdown";
|
||
}
|
||
|
||
// --- Activation log (Privacy section) ---
|
||
interface LumotiaEvent {
|
||
id: number;
|
||
kind: string;
|
||
occurred_at: number;
|
||
payload: string | null;
|
||
}
|
||
|
||
let activationEvents = $state<LumotiaEvent[]>([]);
|
||
let loadingEvents = $state(false);
|
||
let eventsError = $state<string | null>(null);
|
||
|
||
function formatTimestamp(unixSecs: number): string {
|
||
try {
|
||
return new Date(unixSecs * 1000).toLocaleString();
|
||
} catch {
|
||
return String(unixSecs);
|
||
}
|
||
}
|
||
|
||
async function loadActivationEvents() {
|
||
loadingEvents = true;
|
||
eventsError = null;
|
||
try {
|
||
activationEvents = await invoke<LumotiaEvent[]>('list_lumotia_events');
|
||
} catch (err) {
|
||
eventsError = String(err);
|
||
} finally {
|
||
loadingEvents = false;
|
||
}
|
||
}
|
||
|
||
async function confirmAndClear() {
|
||
if (!confirm('Clear all activation events? This cannot be undone.')) return;
|
||
try {
|
||
await invoke('clear_lumotia_events');
|
||
await loadActivationEvents();
|
||
} catch (err) {
|
||
eventsError = String(err);
|
||
}
|
||
}
|
||
|
||
onMount(async () => {
|
||
try {
|
||
await refreshRuntimeCapabilities();
|
||
systemInfo = await invoke("probe_system").catch(() => null);
|
||
await ensureRecommendedLlmTier();
|
||
await refreshLlmStatus();
|
||
await refreshGlobalLlmStatus(settings.aiTier);
|
||
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();
|
||
|
||
// Phase 5: read the live OS-level autostart state so the toggle
|
||
// reflects reality rather than last-saved intent.
|
||
syncAutostartFromOs();
|
||
|
||
// Activation log — loaded eagerly so Privacy section is ready
|
||
loadActivationEvents();
|
||
|
||
// 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;
|
||
// The Rust side sends snake_case keys via DownloadProgress; the
|
||
// older tiny/base ModelDownloader path uses `downloaded`/`total`.
|
||
// Read both so the chip works regardless of which path emitted.
|
||
downloadBytes = event.payload.bytes_downloaded ?? event.payload.downloaded ?? 0;
|
||
downloadTotal = event.payload.total_bytes ?? event.payload.total ?? 0;
|
||
});
|
||
|
||
unlistenLlm = await listen("lumotia:llm-download-progress", (event) => {
|
||
llmDownloadProgress = event.payload.percent || 0;
|
||
llmDownloadingModel = event.payload.modelId || llmDownloadingModel;
|
||
llmDownloadBytes = event.payload.done ?? 0;
|
||
llmDownloadTotal = event.payload.total ?? 0;
|
||
});
|
||
|
||
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();
|
||
if (deleteProfileArmTimer) {
|
||
clearTimeout(deleteProfileArmTimer);
|
||
deleteProfileArmTimer = null;
|
||
}
|
||
});
|
||
|
||
$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;
|
||
downloadBytes = 0;
|
||
downloadTotal = 0;
|
||
downloadStartedAt = Date.now();
|
||
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,
|
||
concurrent: settings.aiGpuConcurrency === "parallel",
|
||
});
|
||
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",
|
||
concurrent: settings.aiGpuConcurrency === "parallel",
|
||
});
|
||
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;
|
||
}
|
||
|
||
// Two-step delete. First call arms (UI swaps to Confirm/Cancel pills);
|
||
// second call inside the window does the work. Routes through the
|
||
// SQLite-backed profilesStore so the canonical profile row + its
|
||
// profile_terms cascade are removed; legacy localStorage entry is only
|
||
// spliced after the storage call succeeds. The storage layer rejects
|
||
// deletion when the profile still has transcripts attached — that
|
||
// rejection surfaces as a toast via profilesStore.delete().
|
||
function armDeleteProfile(index) {
|
||
deleteProfileArmedIndex = index;
|
||
if (deleteProfileArmTimer) clearTimeout(deleteProfileArmTimer);
|
||
deleteProfileArmTimer = setTimeout(() => {
|
||
deleteProfileArmedIndex = -1;
|
||
deleteProfileArmTimer = null;
|
||
}, PROFILE_DELETE_CONFIRM_MS);
|
||
}
|
||
|
||
function disarmDeleteProfile() {
|
||
deleteProfileArmedIndex = -1;
|
||
if (deleteProfileArmTimer) {
|
||
clearTimeout(deleteProfileArmTimer);
|
||
deleteProfileArmTimer = null;
|
||
}
|
||
}
|
||
|
||
async function confirmDeleteProfile(index) {
|
||
if (deleteProfileBusy) return;
|
||
if (index < 0 || index >= profiles.length) {
|
||
disarmDeleteProfile();
|
||
return;
|
||
}
|
||
const name = profiles[index].name;
|
||
deleteProfileBusy = true;
|
||
try {
|
||
// Match the legacy localStorage record to its SQL counterpart by
|
||
// name. The two systems were grown in parallel: localStorage uses
|
||
// name as the natural key, SQL uses a UUID. Until the data model
|
||
// is unified we have to bridge by name.
|
||
const sqlProfile = profilesStore.profiles.find((p) => p.name === name);
|
||
if (sqlProfile && sqlProfile.id !== DEFAULT_PROFILE_ID) {
|
||
const before = profilesStore.profiles.length;
|
||
await profilesStore.delete(sqlProfile.id);
|
||
// profilesStore.delete catches its own errors and emits a toast;
|
||
// we detect failure by checking whether the store actually removed
|
||
// the row. If it did not, abandon the legacy splice so the two
|
||
// sides stay aligned.
|
||
if (profilesStore.profiles.length === before) {
|
||
return;
|
||
}
|
||
}
|
||
if (page.activeProfile === name) {
|
||
page.activeProfile = "None";
|
||
}
|
||
removeProfileTaskList(name);
|
||
profiles.splice(index, 1);
|
||
saveProfiles();
|
||
editingProfile = -1;
|
||
} finally {
|
||
deleteProfileBusy = false;
|
||
disarmDeleteProfile();
|
||
}
|
||
}
|
||
|
||
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 + sticky search. The header stays at the top of the scroll
|
||
container so the search input is reachable from any depth. The
|
||
solid bg-bg matches the page so groups don't bleed under it. -->
|
||
<div class="sticky top-0 z-10 bg-bg pt-6 pb-3 px-7 border-b border-border-subtle">
|
||
<div class="flex items-center justify-between gap-4 mb-3">
|
||
<div class="flex items-baseline gap-3 flex-wrap">
|
||
<h2 class="font-display text-[26px] italic text-text">Settings</h2>
|
||
<!-- Ambient trust signal. The longer About list stays the
|
||
deep-dive; this chip is a one-line summary that's always
|
||
visible while the user moves through the settings tree. -->
|
||
<span
|
||
class="text-[12px] text-text-secondary bg-accent-subtle rounded-full px-2.5 py-1"
|
||
role="status"
|
||
aria-label="Privacy posture: 100% local, no telemetry, no cloud"
|
||
>100% local · no telemetry · no cloud</span>
|
||
</div>
|
||
</div>
|
||
<div class="relative max-w-md">
|
||
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
|
||
<input
|
||
type="search"
|
||
placeholder="Search settings"
|
||
bind:value={settingsSearch}
|
||
class="w-full bg-bg-input border border-border rounded-lg pl-9 pr-9 py-2 text-[13px] text-text placeholder:text-text-tertiary
|
||
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_var(--accent-shadow-focus)]"
|
||
aria-label="Search settings"
|
||
/>
|
||
{#if searchActive}
|
||
<button
|
||
type="button"
|
||
onclick={() => (settingsSearch = "")}
|
||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded text-text-tertiary hover:text-text hover:bg-hover"
|
||
aria-label="Clear search"
|
||
>
|
||
<X size={14} aria-hidden="true" />
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="px-7 pt-5 pb-8">
|
||
<Card>
|
||
<!--
|
||
8-section settings layout (v0.1 release).
|
||
1. Start Here 2. Transcription 3. Models 4. Tasks
|
||
5. Accessibility 6. Privacy 7. Advanced 8. Help
|
||
-->
|
||
|
||
<!-- Quick-settings row. Always visible (not subject to the
|
||
settings search filter). Surfaces theme, font size, and
|
||
the global hotkey above the eight groups. -->
|
||
<div class="grid grid-cols-3 gap-3 px-1 pb-4 border-b border-border-subtle mb-2">
|
||
<div>
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Theme</p>
|
||
<SegmentedButton
|
||
size="small"
|
||
options={["Dark", "Light"]}
|
||
bind:value={
|
||
() => prefs.theme === "light" ? "Light" : "Dark",
|
||
(v) => updatePreferences({ theme: v === "Light" ? "light" : "dark" })
|
||
}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Font size</p>
|
||
<SegmentedButton
|
||
size="small"
|
||
options={["Smaller", "Default", "Larger"]}
|
||
bind:value={fontSizeBucket}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Hotkey</p>
|
||
<button
|
||
type="button"
|
||
onclick={jumpToGlobalHotkey}
|
||
class="px-2.5 py-1 text-[12px] font-medium rounded-lg bg-bg-elevated text-text-secondary border border-border-subtle hover:border-accent hover:text-accent"
|
||
style="transition-duration: var(--duration-ui)"
|
||
aria-label={`Edit global hotkey, currently ${settings.globalHotkey}`}
|
||
>{settings.globalHotkey}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =====================================================
|
||
1. Start Here — microphone, language, engine
|
||
===================================================== -->
|
||
<SettingsGroup
|
||
title="Start Here"
|
||
description="Microphone, language, and engine — the three settings most users change."
|
||
icon={Mic}
|
||
open={searchActive ? searchMatches('Start Here', 'Microphone language model picker mic device engine whisper parakeet') : true}
|
||
>
|
||
<div class="px-1 pb-2 animate-fade-in">
|
||
<!-- Microphone -->
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary 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(214,132,80,0.1)]
|
||
appearance-none cursor-pointer min-w-[280px] max-w-full"
|
||
bind:value={settings.microphoneDevice}
|
||
onfocus={refreshAudioDevices}
|
||
>
|
||
<option value="">Auto (recommended), let Lumotia 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}
|
||
<div class="error-block mt-2">
|
||
<p class="text-[12px] text-danger flex items-center gap-1.5">
|
||
<StatusPill status="needs-review" />
|
||
We couldn't list your audio devices. Plug a microphone in or grant Lumotia microphone permission in your OS settings, then try again.
|
||
</p>
|
||
<details class="mt-1">
|
||
<summary class="text-[11px] text-text-tertiary cursor-pointer select-none">Technical details</summary>
|
||
<pre class="text-[11px] text-text-tertiary mt-1 whitespace-pre-wrap break-all">{audioDevicesError}</pre>
|
||
</details>
|
||
<button
|
||
class="mt-1.5 text-[11px] text-accent underline underline-offset-2 hover:text-accent/80"
|
||
onclick={refreshAudioDevices}
|
||
type="button"
|
||
>Try again</button>
|
||
</div>
|
||
{:else if visibleAudioDevices.length === 0}
|
||
<p class="text-[12px] text-text-secondary mt-2">No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.</p>
|
||
{:else}
|
||
<p class="text-[12px] text-text-secondary mt-2">
|
||
Auto mode tries the system default first, then any other real input. Speaker-monitor sources (loopback) are skipped. If dictation is silent, pick the device explicitly here.
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Language -->
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary 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(214,132,80,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(214,132,80,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-[12px] 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-[12px] text-text-secondary 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>
|
||
|
||
<!-- Engine quick-pick -->
|
||
<div>
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Engine</p>
|
||
<SegmentedButton options={["whisper", "parakeet"]} bind:value={settings.engine} />
|
||
<p class="text-[12px] text-text-secondary 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>
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- =====================================================
|
||
2. Transcription — format mode, cleanup, vocabulary
|
||
===================================================== -->
|
||
<SettingsGroup
|
||
title="Transcription"
|
||
description="Format mode, cleanup, and custom vocabulary."
|
||
icon={Type}
|
||
open={searchActive ? searchMatches('Transcription', 'format mode cleanup vocabulary terms profiles initial prompt', 'whisper parakeet formatting filler smart raw clean timestamps', 'Post-processing remove fillers british anti-hallucination') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<!-- Format mode -->
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Format Mode</p>
|
||
<SegmentedButton options={["Raw", "Clean", "Smart"]} bind:value={settings.formatMode} />
|
||
<p class="text-[12px] text-text-secondary 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>
|
||
|
||
<!-- Post-processing toggles -->
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Post-processing</p>
|
||
<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>
|
||
</div>
|
||
|
||
<!-- Custom vocabulary sub-section (Terms & profiles) -->
|
||
<SettingsGroup
|
||
title="Terms & profiles"
|
||
description="Profile-scoped vocabulary and initial prompt."
|
||
open={searchActive ? searchMatches('Vocabulary', 'Terms profiles initial prompt vocabulary words', 'rename create delete profile') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<p class="text-[12px] text-text-secondary 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-[12px] font-medium text-text-secondary 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(214,132,80,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-danger hover:text-danger
|
||
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-[12px] font-medium text-text-secondary 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(214,132,80,0.1)]"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Initial prompt textarea -->
|
||
<div class="mb-5">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Initial Prompt</p>
|
||
<p class="text-[12px] text-text-secondary 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(214,132,80,0.1)]"
|
||
></textarea>
|
||
<div class="flex items-center gap-3 mt-1">
|
||
{#if initialPromptDirty}
|
||
<button
|
||
type="button"
|
||
onclick={saveInitialPrompt}
|
||
class="text-[12px] text-accent hover:text-accent-hover"
|
||
>Save prompt</button>
|
||
<span class="text-[12px] text-text-secondary">Unsaved changes, saves on blur.</span>
|
||
{:else}
|
||
<span class="text-[12px] text-text-secondary">Saved.</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Terms -->
|
||
<p class="text-[12px] font-medium text-text-secondary 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(214,132,80,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(214,132,80,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 -->
|
||
<div class="mb-4">
|
||
{#if !showBulkVocab}
|
||
<button
|
||
type="button"
|
||
class="text-[12px] text-text-secondary 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(214,132,80,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-secondary hover:text-text"
|
||
>Cancel</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if vocabularyError}
|
||
<div class="error-block mb-3">
|
||
<p class="text-[12px] text-danger flex items-center gap-1.5">
|
||
<StatusPill status="needs-review" />
|
||
{vocabularyError}
|
||
</p>
|
||
{#if vocabularyErrorDetail}
|
||
<details class="mt-1">
|
||
<summary class="text-[11px] text-text-tertiary cursor-pointer select-none">Technical details</summary>
|
||
<pre class="text-[11px] text-text-tertiary mt-1 whitespace-pre-wrap break-all">{vocabularyErrorDetail}</pre>
|
||
</details>
|
||
{/if}
|
||
<button
|
||
class="mt-1.5 text-[11px] text-accent underline underline-offset-2 hover:text-accent/80"
|
||
onclick={refreshVocabulary}
|
||
type="button"
|
||
>Try again</button>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if vocabulary.length === 0}
|
||
<p class="text-[12px] text-text-secondary 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-[12px] text-text-secondary truncate">{entry.note}</div>
|
||
{/if}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onclick={() => deleteVocabTerm(entry.id)}
|
||
class="text-[12px] text-text-secondary hover:text-danger border border-border hover:border-danger rounded px-2 py-1"
|
||
aria-label="Delete {entry.term}"
|
||
>Remove</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{:else}
|
||
<p class="text-[12px] text-text-secondary italic">Loading profiles…</p>
|
||
{/if}
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- Profiles & templates sub-section -->
|
||
<SettingsGroup
|
||
title="Profiles & templates"
|
||
description="Manage legacy profiles and structured templates."
|
||
open={searchActive ? searchMatches('Profiles templates rename create delete', 'Profiles vocabulary words rename create delete', 'Templates structured formats sections create delete') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<SettingsGroup
|
||
title="Profiles"
|
||
description={`${profiles.length} ${profiles.length === 1 ? "profile" : "profiles"} · custom vocabulary to improve transcription accuracy`}
|
||
open={searchActive ? searchMatches('Profiles vocabulary words rename create delete') : false}
|
||
>
|
||
<div class="space-y-1.5 animate-fade-in">
|
||
{#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-[12px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-secondary">
|
||
{profileWordCount(profile.words)} words
|
||
</span>
|
||
<button
|
||
class="text-[12px] text-text-secondary hover:text-accent opacity-0 group-hover:opacity-100"
|
||
onclick={() => editingProfile = editingProfile === i ? -1 : i}
|
||
>{editingProfile === i ? "Close" : "Edit"}</button>
|
||
{#if deleteProfileArmedIndex === i}
|
||
<button
|
||
class="text-[12px] text-danger font-medium hover:underline"
|
||
onclick={() => confirmDeleteProfile(i)}
|
||
disabled={deleteProfileBusy}
|
||
>Confirm</button>
|
||
<button
|
||
class="text-[12px] text-text-tertiary hover:text-text hover:underline"
|
||
onclick={disarmDeleteProfile}
|
||
disabled={deleteProfileBusy}
|
||
>Cancel</button>
|
||
{:else}
|
||
<button
|
||
class="text-[12px] text-text-secondary hover:text-danger opacity-0 group-hover:opacity-100"
|
||
onclick={() => armDeleteProfile(i)}
|
||
>Delete</button>
|
||
{/if}
|
||
</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-[12px] text-accent hover:text-accent-hover" onclick={createProfile}>Create</button>
|
||
<button class="text-[12px] text-text-secondary" 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>
|
||
</SettingsGroup>
|
||
|
||
<SettingsGroup
|
||
title="Templates"
|
||
description={`${templates.length} ${templates.length === 1 ? "template" : "templates"} · structured formats for dictation sessions`}
|
||
open={searchActive ? searchMatches('Templates structured formats sections create delete') : false}
|
||
>
|
||
<div class="space-y-1.5 animate-fade-in">
|
||
{#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-[12px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-secondary">
|
||
{template.sections.length} sections
|
||
</span>
|
||
<button
|
||
class="text-[12px] text-text-secondary hover:text-accent opacity-0 group-hover:opacity-100"
|
||
onclick={() => editingTemplate = editingTemplate === i ? -1 : i}
|
||
>{editingTemplate === i ? "Close" : "Edit"}</button>
|
||
<button
|
||
class="text-[12px] text-text-secondary 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-[12px] text-accent hover:text-accent-hover" onclick={createTemplate}>Create</button>
|
||
<button class="text-[12px] text-text-secondary" 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>
|
||
</SettingsGroup>
|
||
</div>
|
||
</SettingsGroup>
|
||
</SettingsGroup>
|
||
|
||
<!-- =====================================================
|
||
3. Models — download, switch, disk-space readout
|
||
===================================================== -->
|
||
<SettingsGroup
|
||
title="Models"
|
||
description="Download and manage transcription and AI models."
|
||
icon={Sparkles}
|
||
onopen={onAiSectionOpen}
|
||
open={searchActive ? searchMatches('Models', 'download switch whisper parakeet LLM qwen model management disk space prewarm') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<!-- Whisper model management -->
|
||
{#if settings.engine === "whisper"}
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary 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-[12px] text-text-secondary 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-[12px] 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-[12px] text-success">
|
||
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
|
||
Downloaded
|
||
</span>
|
||
<button
|
||
class="text-[12px] text-text-secondary hover:text-accent"
|
||
onclick={loadSelectedModel}
|
||
>Load model</button>
|
||
{:else if downloadingModel === whisperModelId(settings.modelSize)}
|
||
<span class="text-[12px] text-warning">
|
||
{downloadProgress}%
|
||
{#if downloadTotal > 0}
|
||
· {formatBytes(downloadBytes)} / {formatBytes(downloadTotal)}
|
||
{/if}
|
||
{#if downloadProgress > 0 && downloadStartedAt > 0}
|
||
{@const remaining = etaSecondsFromPercent(downloadProgress, downloadStartedAt)}
|
||
{#if remaining > 1}
|
||
· {formatDuration(remaining)} left
|
||
{/if}
|
||
{/if}
|
||
</span>
|
||
{:else}
|
||
<button
|
||
class="text-[12px] text-accent hover:text-accent-hover"
|
||
onclick={() => downloadModel(whisperModelId(settings.modelSize))}
|
||
>Download {settings.modelSize}</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<!-- Parakeet model management -->
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Parakeet Model</p>
|
||
<p class="text-[12px] text-text-secondary 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-[12px] 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-[12px] text-text-secondary">
|
||
<span class="w-[6px] h-[6px] rounded-full bg-text-tertiary"></span>
|
||
Downloaded
|
||
</span>
|
||
<button
|
||
class="text-[12px] text-text-secondary hover:text-accent"
|
||
onclick={loadParakeet}
|
||
>Load model</button>
|
||
{:else if parakeetDownloading}
|
||
<span class="text-[12px] text-warning">{parakeetProgress}% downloading...</span>
|
||
{:else}
|
||
<button
|
||
class="text-[12px] text-accent hover:text-accent-hover"
|
||
onclick={downloadParakeet}
|
||
>Download Parakeet</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- LLM model management -->
|
||
<div class="mb-5">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">AI 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-[12px] text-text-secondary">{model.subtitle}</span>
|
||
</div>
|
||
<p class="text-[12px] text-text-secondary mt-1">{model.size} · {model.fit}</p>
|
||
{#if llmHardwareWarning(model.id)}
|
||
<p class="text-[12px] text-warning mt-2">{llmHardwareWarning(model.id)}</p>
|
||
{/if}
|
||
</div>
|
||
<div class="text-right text-[12px]">
|
||
{#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-[12px] text-text-secondary mt-1">{llmStatus}</p>
|
||
{#if llmTestHint}
|
||
<p class="text-[12px] text-accent mt-1">{llmTestHint}</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
{#if llmDownloadingModel === selectedLlmModelId()}
|
||
<span class="text-[12px] text-warning">
|
||
{llmDownloadProgress}%
|
||
{#if llmDownloadTotal > 0}
|
||
· {formatBytes(llmDownloadBytes)} / {formatBytes(llmDownloadTotal)}
|
||
{/if}
|
||
{#if llmDownloadProgress > 0 && llmDownloadStartedAt > 0}
|
||
{@const remaining = etaSecondsFromPercent(llmDownloadProgress, llmDownloadStartedAt)}
|
||
{#if remaining > 1}
|
||
· {formatDuration(remaining)} left
|
||
{/if}
|
||
{/if}
|
||
</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 disabled:opacity-50"
|
||
onclick={testSelectedLlmModel}
|
||
disabled={llmTestBusy}
|
||
>{llmTestBusy ? "Testing…" : "Test"}</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 disabled:opacity-50"
|
||
onclick={testSelectedLlmModel}
|
||
disabled={llmTestBusy}
|
||
>{llmTestBusy ? "Testing…" : "Test"}</button>
|
||
<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-[12px] text-text-secondary mt-3">
|
||
Recommended for this machine:
|
||
<span class="text-text">
|
||
{LLM_MODELS.find((model) => model.id === (settings.llmModelId || "qwen3_5_4b"))?.subtitle || "Qwen3.5 4B"}
|
||
</span>
|
||
{#if systemInfo}
|
||
· {Math.round((systemInfo.ram_mb || 0) / 1024)} GB RAM detected
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="mt-5">
|
||
<Toggle
|
||
bind:checked={settings.prewarmModelOnStartup}
|
||
label="Prewarm transcription model on startup"
|
||
description="Loads Whisper after the app opens. Faster first capture, but higher idle memory use."
|
||
/>
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- =====================================================
|
||
4. Tasks — WIP limit, energy-aware sequencing
|
||
===================================================== -->
|
||
<SettingsGroup
|
||
title="Tasks"
|
||
description="WIP limit, energy-aware sequencing, and gamification."
|
||
icon={SquareCheck}
|
||
open={searchActive ? searchMatches('Tasks', 'WIP limit energy sequencing gamification sparkline nudges') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<p class="text-[12px] text-text-secondary mb-4">
|
||
How the Tasks page surfaces progress. Always additive.
|
||
</p>
|
||
|
||
<Toggle
|
||
bind:checked={settings.showMomentumSparkline}
|
||
label="Show momentum sparkline"
|
||
description="A tiny chart of the last 7 days' completion counts, shown on the Tasks header. Never counts against you."
|
||
/>
|
||
|
||
<div class="mt-4">
|
||
<Toggle
|
||
bind:checked={settings.nudgesEnabled}
|
||
label="Enable nudges"
|
||
description="Soft-touch notifications when a timer has been ticking while you're away, when a morning triage is waiting, or when a micro-step decomposition has sat untouched for 15 minutes."
|
||
/>
|
||
{#if settings.nudgesEnabled}
|
||
<div class="pl-1 py-1 animate-fade-in">
|
||
<Toggle
|
||
bind:checked={settings.nudgesMuted}
|
||
label="Mute for now"
|
||
description="Stops delivery without forgetting your settings. Flip back off when you want the nudges back."
|
||
/>
|
||
<Toggle
|
||
bind:checked={settings.nudgesSpeakAloud}
|
||
label="Speak nudges aloud"
|
||
description="Also reads the nudge out through your Read-aloud voice."
|
||
/>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- =====================================================
|
||
5. Accessibility — motion, contrast, typography
|
||
===================================================== -->
|
||
<SettingsGroup
|
||
title="Accessibility"
|
||
description="Reduced motion, contrast, typography, and screen-reader hints."
|
||
open={searchActive ? searchMatches('Accessibility Reduced motion contrast typography lexend opendyslexic atkinson bionic dyslexic letter spacing line height') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<AccessibilityControls />
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- =====================================================
|
||
6. Privacy — local-only badge, AI disclosure, activation log
|
||
===================================================== -->
|
||
<SettingsGroup
|
||
title="Privacy"
|
||
description="Local-only data, AI use disclosure, and activation log."
|
||
open={searchActive ? searchMatches('Privacy local telemetry activation log events data') : false}
|
||
>
|
||
<div class="animate-fade-in space-y-5">
|
||
<!-- Local-only trust badge -->
|
||
<div class="flex items-center gap-3">
|
||
<StatusPill status="ready" label="Local-only" />
|
||
<span class="text-[12px] text-text-secondary">All data stays on your machine. No telemetry, no cloud, no accounts.</span>
|
||
</div>
|
||
|
||
<!-- Privacy and AI use disclosure -->
|
||
<div>
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">AI use</p>
|
||
<p class="text-[12px] text-text-secondary">
|
||
The local LLM is used for transcript cleanup and task extraction only — both run fully offline after download. No voice, transcript, or task data is sent to any server.
|
||
See <a href="docs/release/privacy-and-ai-use.md" class="text-accent hover:underline" target="_blank" rel="noopener noreferrer">privacy-and-ai-use.md</a> for details.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Activation log subsection -->
|
||
<div class="pt-4 border-t border-border-subtle">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Activation log</p>
|
||
<p class="text-[12px] text-text-secondary mb-3">
|
||
This log lives on your device only. We never send it anywhere. It records anonymous milestones (first capture, first export, first task extracted) so you can see your own usage shape.
|
||
</p>
|
||
|
||
<label class="flex items-center gap-2 mb-4 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
bind:checked={prefs.recordActivationEvents}
|
||
onchange={() => updatePreferences({ recordActivationEvents: prefs.recordActivationEvents })}
|
||
class="accent-accent"
|
||
/>
|
||
<span class="text-[13px] text-text">Record activation events</span>
|
||
</label>
|
||
|
||
{#if loadingEvents}
|
||
<p class="text-[12px] text-text-secondary">Loading…</p>
|
||
{:else if eventsError}
|
||
<p class="text-[12px] text-danger">Couldn't load activation log.</p>
|
||
{:else if activationEvents.length === 0}
|
||
<p class="text-[12px] text-text-secondary italic">No activation events recorded yet.</p>
|
||
{:else}
|
||
<div class="overflow-x-auto mb-3">
|
||
<table class="w-full text-[12px] text-text-secondary">
|
||
<thead>
|
||
<tr class="text-left border-b border-border-subtle">
|
||
<th class="pb-1 font-medium">Kind</th>
|
||
<th class="pb-1 font-medium pl-4">When</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each activationEvents as ev}
|
||
<tr class="border-b border-border-subtle/50">
|
||
<td class="py-1.5 font-mono">{ev.kind}</td>
|
||
<td class="py-1.5 pl-4">{formatTimestamp(ev.occurred_at)}</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
|
||
<button
|
||
type="button"
|
||
onclick={confirmAndClear}
|
||
class="px-3 py-2 text-[12px] text-text-secondary border border-border rounded-lg hover:border-danger hover:text-danger"
|
||
>Clear activation log</button>
|
||
</div>
|
||
|
||
<!-- Data directory -->
|
||
<div class="pt-4 border-t border-border-subtle">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Data location</p>
|
||
<p class="text-[12px] text-text-secondary">
|
||
All transcripts, models, and settings are stored in your OS app-data directory. No files are written outside that path.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- =====================================================
|
||
7. Advanced — everything else, collapsed by default
|
||
===================================================== -->
|
||
<SettingsGroup
|
||
title="Advanced"
|
||
description="AI tier, rules, rituals, output, hotkey, appearance, and diagnostics."
|
||
icon={Sliders}
|
||
open={searchActive ? searchMatches('Advanced', 'AI tier rules rituals morning triage wind-down autostart launch hotkey appearance theme zone font locale output paste capture TTS read aloud diagnostic', 'Post-processing remove fillers british anti-hallucination', 'AI Assistant Local LLM tier model management qwen llama prewarm', 'cleanup preset prompt email notes code GPU concurrency parallel sequential VRAM', 'If-then rules implementation cleanup intentions', 'Global hotkey toggle recording shortcut', 'Theme zone font size locale dark light', 'Nudges soft notifications hourly', 'Read aloud TTS voice rate', 'Capture export clipboard paste history dictation', 'About Engine status diagnostic report version') : false}
|
||
>
|
||
<!-- AI & Processing subsection -->
|
||
<SettingsGroup
|
||
title="AI & Processing"
|
||
description="Local LLM tier, cleanup post-processing, and rules."
|
||
icon={Sparkles}
|
||
onopen={onAiSectionOpen}
|
||
open={searchActive ? searchMatches('AI Processing', 'Local LLM tier cleanup post-processing rules', 'AI Assistant Local LLM tier model management qwen llama prewarm', 'Advanced cleanup preset prompt email notes code GPU concurrency parallel sequential VRAM', 'If-then rules implementation cleanup intentions') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<!-- Feature tier -->
|
||
<div class="mb-5">
|
||
<p class="text-[12px] font-medium text-text-secondary 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-[12px] text-text-secondary mt-2">
|
||
{settings.aiTier === "off"
|
||
? "No local LLM calls. Lumotia 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>
|
||
|
||
<!-- Cleanup preset -->
|
||
<div class="mb-5">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Cleanup preset</p>
|
||
<SegmentedButton
|
||
options={["default", "email", "notes", "code"]}
|
||
bind:value={settings.llmPromptPreset}
|
||
/>
|
||
<p class="text-[12px] text-text-secondary mt-2">
|
||
{#if settings.llmPromptPreset === "email"}
|
||
Formats output as an email paragraph, tight sentences, no markdown, no auto-added greeting or signoff.
|
||
{:else if settings.llmPromptPreset === "notes"}
|
||
Formats action items as a bullet list led by imperative verbs; keeps prose informational sentences as prose.
|
||
{:else if settings.llmPromptPreset === "code"}
|
||
Preserves technical terms, variable names, file paths, and symbols exactly as spoken. No translation of identifiers.
|
||
{:else}
|
||
No preset, the active profile's prompt governs tone alone.
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- GPU concurrency -->
|
||
<div class="mb-5">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">GPU concurrency</p>
|
||
<SegmentedButton
|
||
options={["parallel", "sequential"]}
|
||
bind:value={settings.aiGpuConcurrency}
|
||
/>
|
||
<p class="text-[12px] text-text-secondary mt-2">
|
||
{#if settings.aiGpuConcurrency === "sequential"}
|
||
On tight-VRAM cards (≤6 GB), loading Whisper + LLM together OOMs. Sequential mode frees the other model before loading; adds a small reload pause between transcribe and cleanup.
|
||
{:else}
|
||
Both models stay resident in GPU memory. Faster transitions, but needs enough VRAM to hold both at once.
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Compute device -->
|
||
<div class="mb-5">
|
||
<p class="text-[12px] font-medium text-text-secondary 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(214,132,80,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-[12px] text-text-secondary 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>
|
||
</div>
|
||
|
||
<SettingsGroup
|
||
title="If-then rules"
|
||
description="Implementation rules for the AI cleanup pass."
|
||
open={searchActive ? searchMatches('If-then rules implementation cleanup intentions') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<ImplementationRulesEditor />
|
||
</div>
|
||
</SettingsGroup>
|
||
</SettingsGroup>
|
||
|
||
<!-- Rituals subsection -->
|
||
<SettingsGroup
|
||
title="Rituals"
|
||
description="Morning triage, wind-down, launch at login."
|
||
open={searchActive ? searchMatches('Rituals morning triage wind-down launch login autostart') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<p class="text-[12px] text-text-secondary mb-4">
|
||
All off by default. Rituals only appear when you ask for them.
|
||
</p>
|
||
|
||
<Toggle
|
||
bind:checked={settings.ritualsMorning}
|
||
label="Morning triage"
|
||
description="On the first launch of the day after your set time, show a gentle pick-three modal drawn from open tasks. Skip any day without penalty."
|
||
/>
|
||
|
||
{#if settings.ritualsMorning}
|
||
<div class="pl-1 py-3 animate-fade-in">
|
||
<label for="triage-time" class="block text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">
|
||
Earliest triage time
|
||
</label>
|
||
<input
|
||
id="triage-time"
|
||
type="time"
|
||
class="bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text focus:border-accent focus:outline-none"
|
||
bind:value={settings.ritualsMorningTime}
|
||
/>
|
||
<p class="text-[12px] text-text-secondary mt-2">
|
||
ADHD sleep inertia is intense for the first 30–45 minutes after waking. Pick a time when you're genuinely ready to decide.
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
|
||
<Toggle
|
||
bind:checked={settings.ritualsEvening}
|
||
label="Evening wind-down"
|
||
description="A reflective page you can open when you want to close the day. Not scheduled, never nagging."
|
||
/>
|
||
|
||
{#if settings.ritualsEvening}
|
||
<div class="pl-1 py-3 animate-fade-in">
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
|
||
onclick={openWindDown}
|
||
>
|
||
Open wind-down now
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="mt-4 pt-4 border-t border-border-subtle">
|
||
<Toggle
|
||
bind:checked={settings.launchAtLogin}
|
||
label="Launch Lumotia at login"
|
||
description="So Lumotia is already there at the start of the day. Uses your OS's standard autostart, no background tricks."
|
||
onChange={setLaunchAtLogin}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- Output & Capture subsection -->
|
||
<SettingsGroup
|
||
title="Output & Capture"
|
||
description="Clipboard, paste, audio capture, and read-aloud."
|
||
icon={Clipboard}
|
||
open={searchActive ? searchMatches('Output Capture', 'Clipboard paste audio capture read-aloud', 'Read aloud TTS voice rate', 'Capture export clipboard paste history dictation drafts sound cues meeting auto-paste auto-copy') : false}
|
||
>
|
||
<SettingsGroup
|
||
title="Read aloud"
|
||
description="System TTS voice for read-aloud features."
|
||
onopen={onReadAloudOpen}
|
||
open={searchActive ? searchMatches('Read aloud TTS voice rate system') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<p class="text-[12px] text-text-secondary mb-4">
|
||
Uses your operating system's built-in voices. No audio leaves the machine.
|
||
</p>
|
||
|
||
<div class="mb-4">
|
||
<label for="tts-voice" class="block text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Voice</label>
|
||
<select
|
||
id="tts-voice"
|
||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
|
||
value={settings.ttsVoice ?? ""}
|
||
onchange={(e) => { settings.ttsVoice = e.currentTarget.value || null; }}
|
||
disabled={ttsVoicesLoading}
|
||
>
|
||
<option value="">System default</option>
|
||
{#each ttsVoices as voice (voice.id)}
|
||
<option value={voice.id}>
|
||
{voice.name}{#if voice.language} · {voice.language}{/if}
|
||
</option>
|
||
{/each}
|
||
</select>
|
||
{#if ttsVoicesError}
|
||
<div class="error-block mt-2">
|
||
<p class="text-[12px] text-danger flex items-center gap-1.5">
|
||
<StatusPill status="needs-review" />
|
||
We couldn't load the text-to-speech voices. The rest of Settings still works.
|
||
</p>
|
||
<details class="mt-1">
|
||
<summary class="text-[11px] text-text-tertiary cursor-pointer select-none">Technical details</summary>
|
||
<pre class="text-[11px] text-text-tertiary mt-1 whitespace-pre-wrap break-all">{ttsVoicesError}</pre>
|
||
</details>
|
||
<button
|
||
class="mt-1.5 text-[11px] text-accent underline underline-offset-2 hover:text-accent/80"
|
||
onclick={refreshTtsVoices}
|
||
type="button"
|
||
>Try again</button>
|
||
</div>
|
||
{:else if ttsVoicesLoaded && ttsVoices.length === 0}
|
||
<p class="text-[12px] text-text-secondary mt-2">
|
||
No additional voices reported by the system synth. Install extra voices through your OS accessibility settings.
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<label for="tts-rate" class="block text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">
|
||
Rate · {settings.ttsRate.toFixed(1)}×
|
||
</label>
|
||
<input
|
||
id="tts-rate"
|
||
type="range"
|
||
min="0.5"
|
||
max="2.0"
|
||
step="0.1"
|
||
class="w-full accent-accent"
|
||
bind:value={settings.ttsRate}
|
||
/>
|
||
<div class="flex justify-between text-[12px] text-text-secondary mt-1">
|
||
<span>Slower</span>
|
||
<span>Normal</span>
|
||
<span>Faster</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
|
||
onclick={testReadAloudVoice}
|
||
>
|
||
Test voice
|
||
</button>
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<SettingsGroup
|
||
title="Capture & export"
|
||
description="Clipboard, paste, history, and dictation drafts."
|
||
open={searchActive ? searchMatches('Capture export clipboard paste history dictation drafts sound cues meeting auto-paste auto-copy') : false}
|
||
>
|
||
<div class="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 Lumotia 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. Lumotia never records on its own."
|
||
/>
|
||
{#if settings.meetingAutoCapture}
|
||
<div class="ml-[50px] mt-2 mb-1 animate-fade-in">
|
||
<p class="text-[12px] font-medium text-text-secondary 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.soundCues}
|
||
label="Sound cues on start / stop / complete"
|
||
description="Short synthesised tones for eyes-off feedback. Synthesised locally via Web Audio, no assets bundled."
|
||
/>
|
||
{#if settings.soundCues}
|
||
<div class="ml-[50px] mt-2 mb-1 animate-fade-in flex items-center gap-3">
|
||
<label for="sound-cue-volume" class="text-[12px] text-text-secondary uppercase tracking-wider">Volume</label>
|
||
<input
|
||
id="sound-cue-volume"
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.05"
|
||
bind:value={settings.soundCueVolume}
|
||
class="w-[160px] accent-accent"
|
||
data-no-transition
|
||
/>
|
||
<span class="text-[12px] text-text-secondary w-[34px]">{Math.round(settings.soundCueVolume * 100)}%</span>
|
||
<button
|
||
class="text-[12px] text-accent hover:text-accent-hover"
|
||
onclick={async () => {
|
||
const mod = await import("$lib/utils/sounds.js");
|
||
mod.playCompleteCue(settings.soundCueVolume, true);
|
||
}}
|
||
>Test</button>
|
||
</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-[12px] font-medium text-text-secondary 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-[12px] 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-[12px] 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-[12px] text-text-secondary hover:text-text-secondary whitespace-nowrap"
|
||
onclick={() => { settings.outputFolder = ""; saveSettings(); }}
|
||
>Reset</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
</SettingsGroup>
|
||
|
||
<!-- Hotkey + Appearance subsection -->
|
||
<div id="global-hotkey-group">
|
||
<SettingsGroup
|
||
title="Global hotkey"
|
||
description="Toggle recording from anywhere."
|
||
open={searchActive ? searchMatches('Global hotkey toggle recording shortcut') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<p class="text-[12px] text-text-secondary mb-4">Toggle recording from anywhere. Click to change.</p>
|
||
<HotkeyRecorder />
|
||
</div>
|
||
</SettingsGroup>
|
||
</div>
|
||
|
||
<SettingsGroup
|
||
title="Appearance"
|
||
description="Theme, zone, font size, locale."
|
||
open={searchActive ? searchMatches('Appearance Theme zone font size locale dark light british cave energy reset') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Theme</p>
|
||
<SegmentedButton
|
||
options={["Dark", "Light", "System"]}
|
||
bind:value={
|
||
() => prefs.theme === "light" ? "Light" : prefs.theme === "system" ? "System" : "Dark",
|
||
(v) => updatePreferences({ theme: v === "Light" ? "light" : v === "System" ? "system" : "dark" })
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Zone</p>
|
||
<ZonePicker />
|
||
</div>
|
||
|
||
<div class="mb-6">
|
||
<p class="text-[12px] font-medium text-text-secondary 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-[12px] font-medium text-text-secondary 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-[var(--shadow-accent-pill)]'
|
||
: '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-[12px] text-text-secondary mt-2">{$_("settings.languageDescription")}</p>
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
|
||
<!-- About & diagnostics subsection -->
|
||
<SettingsGroup
|
||
title="About"
|
||
description="Engine status and diagnostic report."
|
||
open={searchActive ? searchMatches('About Engine status diagnostic report version privacy local telemetry') : false}
|
||
>
|
||
<div class="animate-fade-in">
|
||
<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-[12px] text-text-secondary">{item}</p>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<p class="text-[12px] text-text-secondary mt-5">Lumotia v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd</p>
|
||
|
||
<div class="mt-6 pt-5 border-t border-border-subtle">
|
||
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Diagnostics</p>
|
||
<p class="text-[12px] text-text-secondary 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}
|
||
<div class="error-block mb-2">
|
||
<p class="text-[12px] text-danger flex items-center gap-1.5">
|
||
<StatusPill status="failed-safely" />
|
||
{diagnosticReportError}
|
||
</p>
|
||
{#if diagnosticReportErrorDetail}
|
||
<details class="mt-1">
|
||
<summary class="text-[11px] text-text-tertiary cursor-pointer select-none">Technical details</summary>
|
||
<pre class="text-[11px] text-text-tertiary mt-1 whitespace-pre-wrap break-all">{diagnosticReportErrorDetail}</pre>
|
||
</details>
|
||
{/if}
|
||
<button
|
||
class="mt-1.5 text-[11px] text-accent underline underline-offset-2 hover:text-accent/80"
|
||
onclick={generateDiagnosticReport}
|
||
type="button"
|
||
>Try again</button>
|
||
</div>
|
||
{/if}
|
||
{#if diagnosticReportSavedTo}
|
||
<p class="text-[12px] text-success mb-2">{diagnosticReportSavedTo}</p>
|
||
{/if}
|
||
{#if diagnosticReport}
|
||
<details class="text-[12px]">
|
||
<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-[12px] text-text-secondary overflow-auto max-h-[400px] whitespace-pre-wrap font-mono">{diagnosticReport}</pre>
|
||
</details>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
</SettingsGroup>
|
||
|
||
|
||
<!-- 8. Help — first-run tutorial replay + support links. -->
|
||
<SettingsGroup
|
||
title="Help"
|
||
description="Replay the tutorial or report an issue."
|
||
>
|
||
<div class="space-y-4 py-1">
|
||
<div>
|
||
<p class="text-[13px] text-text-secondary leading-relaxed">
|
||
Need to walk through onboarding again? Replay the first-run tutorial and
|
||
the gate will skip past your existing data.
|
||
</p>
|
||
<button
|
||
class="mt-3 px-3 py-2 text-[12px] bg-accent text-bg rounded-lg hover:bg-accent-hover"
|
||
style="transition-duration: var(--duration-ui)"
|
||
onclick={() => {
|
||
sessionStorage.setItem("lumotia:replay-tutorial", "1");
|
||
page.current = "first-run";
|
||
}}
|
||
>Replay first-run tutorial</button>
|
||
</div>
|
||
|
||
<div class="pt-3 border-t border-border-subtle">
|
||
<button
|
||
type="button"
|
||
onclick={generateDiagnosticBundle}
|
||
disabled={diagnosticBundleLoading}
|
||
class="px-3 py-2 text-[12px] bg-accent text-bg rounded-lg disabled:opacity-50 hover:bg-accent-hover"
|
||
style="transition-duration: var(--duration-ui)"
|
||
>{diagnosticBundleLoading ? "Saving…" : "Generate diagnostic bundle"}</button>
|
||
<p class="mt-2 text-[12px] text-text-secondary leading-relaxed">
|
||
Logs + system info + redacted preferences. Never includes audio or transcript content.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="pt-3 border-t border-border-subtle">
|
||
<p class="text-[12px] text-text-secondary leading-relaxed">
|
||
Known limitations for this release are documented in
|
||
<a
|
||
href="https://github.com/jakeadriansames/lumotia/blob/main/KNOWN-ISSUES.md"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="text-accent hover:underline"
|
||
>KNOWN-ISSUES.md</a>.
|
||
Bugs and feature requests can be filed at
|
||
<a
|
||
href="https://github.com/jakeadriansames/lumotia/issues"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="text-accent hover:underline"
|
||
>github.com/jakeadriansames/lumotia/issues</a>.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</SettingsGroup>
|
||
</Card>
|
||
</div>
|
||
</div>
|