From ec09f8ffdb02581e9c7fe7e6fb12874a9d27dbbc Mon Sep 17 00:00:00 2001 From: Jake Date: Thu, 7 May 2026 10:19:26 +0100 Subject: [PATCH] =?UTF-8?q?feat(settings):=20finalise=20polish=20=E2=80=94?= =?UTF-8?q?=20autostart=20Toggle=20dedupe,=20AI=20Default/Advanced=20split?= =?UTF-8?q?,=20vocab=20consistency,=20model=20ETA,=20privacy=20badge,=20gr?= =?UTF-8?q?oup=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six changes, one coherent polish pass on SettingsPage: 1. Autostart toggle dedupe. The 27-line custom button that mirrored Toggle's markup to dodge a bind:checked race is gone. Toggle's new async onChange + snap-back covers the OS round-trip; setLaunchAtLogin re-throws on failure so Toggle reverts checked. autostartSyncing state retired. 2. AI Assistant Default/Advanced split. Cleanup preset and GPU concurrency moved into a nested SettingsGroup title="Advanced", closed by default. The Assistant page now leads with four first-decision controls (tier, model picker, status, prewarm) and parks the tuning knobs behind a disclosure. Search keywords cascade from the Advanced group up to AI Assistant up to AI & Processing. 3. Vocabulary disclosure consistency. The hand-rolled Profiles and Templates sub-panels (with their own ChevronRight buttons + showProfiles/showTemplates state) are now nested SettingsGroups. The count badges live in the description slot ("3 profiles · custom vocabulary..."). showProfiles, showTemplates, and the ChevronRight import retired. 4. Model-download ETA in Settings. Both whisper and LLM download chips now show "{percent}% · {bytes} / {total} · {duration} left", reusing formatDuration from utils/time.ts. New formatBytes + etaSecondsFromPercent helpers in SettingsPage; bytes pulled from the existing emit payload (DownloadProgress snake_case for whisper, done/total for the LLM event). FirstRunPage already does the timestamp-based ETA; we mirror its shape. 5. Privacy badge in sticky header. Single chip "100% local · no telemetry · no cloud" added next to the Settings heading using bg-accent-subtle + text-text-tertiary. The longer About list stays put as the deep-dive. 6. SettingsGroup icons on the seven top-level groups. Mic, BookOpen, Type, Sparkles, SquareCheck, Clipboard, Sliders — paired with the existing literal text titles per the icon-with-label brand rule. Inner nested groups stay icon-free. Verification: npm run check returns 0 errors, 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/pages/SettingsPage.svelte | 315 ++++++++++++++++++------------ 1 file changed, 186 insertions(+), 129 deletions(-) diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 7a8ca99..25f316a 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -17,7 +17,8 @@ import { toasts } from "$lib/stores/toasts.svelte.js"; import { clampTextLines } from "$lib/utils/textMeasure.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; - import { Check, ChevronRight, Search, X } from "lucide-svelte"; + 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 @@ -40,6 +41,11 @@ }); 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); @@ -48,9 +54,36 @@ 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", @@ -389,8 +422,6 @@ let unlistenParakeet = null; // Profiles & Templates - let showProfiles = $state(false); - let showTemplates = $state(false); let editingProfile = $state(-1); let editingTemplate = $state(-1); let newProfileName = $state(""); @@ -543,6 +574,9 @@ const modelId = selectedLlmModelId(); llmDownloadingModel = modelId; llmDownloadProgress = 0; + llmDownloadBytes = 0; + llmDownloadTotal = 0; + llmDownloadStartedAt = Date.now(); llmStatus = "Downloading..."; try { await invoke("download_llm_model", { modelId }); @@ -708,10 +742,12 @@ // 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. - let autostartSyncing = $state(false); - + // + // 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) { - autostartSyncing = true; try { const plugin = await import("@tauri-apps/plugin-autostart"); if (nextOn) { @@ -722,13 +758,13 @@ settings.launchAtLogin = nextOn; } catch (err) { toasts.warn("Could not update autostart", String(err)); - // Re-read to correct the UI if we failed halfway. + // 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 */ } - } finally { - autostartSyncing = false; + throw err; } } @@ -790,11 +826,18 @@ 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("magnotia: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) => { @@ -842,6 +885,9 @@ 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"); @@ -980,7 +1026,15 @@ solid bg-bg matches the page so groups don't bleed under it. -->
-

Settings

+
+

Settings

+ + 100% local · no telemetry · no cloud +