feat(settings): finalise polish — autostart Toggle dedupe, AI Default/Advanced split, vocab consistency, model ETA, privacy badge, group icons
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) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,8 @@
|
|||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
import { clampTextLines } from "$lib/utils/textMeasure.js";
|
import { clampTextLines } from "$lib/utils/textMeasure.js";
|
||||||
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.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 { _ } from "svelte-i18n";
|
||||||
import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n";
|
import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n";
|
||||||
// Aliased because SettingsPage has its own local refreshLlmStatus
|
// Aliased because SettingsPage has its own local refreshLlmStatus
|
||||||
@@ -40,6 +41,11 @@
|
|||||||
});
|
});
|
||||||
let downloadingModel = $state("");
|
let downloadingModel = $state("");
|
||||||
let downloadProgress = $state(0);
|
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 unlisten = null;
|
||||||
let unlistenLlm = null;
|
let unlistenLlm = null;
|
||||||
let outputFolderEl = $state(null);
|
let outputFolderEl = $state(null);
|
||||||
@@ -48,9 +54,36 @@
|
|||||||
let llmStatus = $state("Checking...");
|
let llmStatus = $state("Checking...");
|
||||||
let llmDownloadingModel = $state("");
|
let llmDownloadingModel = $state("");
|
||||||
let llmDownloadProgress = $state(0);
|
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 llmLoaded = $state(false);
|
||||||
let systemInfo = $state(null);
|
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 = [
|
const LLM_MODELS = [
|
||||||
{
|
{
|
||||||
id: "qwen3_5_2b",
|
id: "qwen3_5_2b",
|
||||||
@@ -389,8 +422,6 @@
|
|||||||
let unlistenParakeet = null;
|
let unlistenParakeet = null;
|
||||||
|
|
||||||
// Profiles & Templates
|
// Profiles & Templates
|
||||||
let showProfiles = $state(false);
|
|
||||||
let showTemplates = $state(false);
|
|
||||||
let editingProfile = $state(-1);
|
let editingProfile = $state(-1);
|
||||||
let editingTemplate = $state(-1);
|
let editingTemplate = $state(-1);
|
||||||
let newProfileName = $state("");
|
let newProfileName = $state("");
|
||||||
@@ -543,6 +574,9 @@
|
|||||||
const modelId = selectedLlmModelId();
|
const modelId = selectedLlmModelId();
|
||||||
llmDownloadingModel = modelId;
|
llmDownloadingModel = modelId;
|
||||||
llmDownloadProgress = 0;
|
llmDownloadProgress = 0;
|
||||||
|
llmDownloadBytes = 0;
|
||||||
|
llmDownloadTotal = 0;
|
||||||
|
llmDownloadStartedAt = Date.now();
|
||||||
llmStatus = "Downloading...";
|
llmStatus = "Downloading...";
|
||||||
try {
|
try {
|
||||||
await invoke("download_llm_model", { modelId });
|
await invoke("download_llm_model", { modelId });
|
||||||
@@ -708,10 +742,12 @@
|
|||||||
// Phase 5 rituals. Autostart state mirrors the OS-level entry managed
|
// Phase 5 rituals. Autostart state mirrors the OS-level entry managed
|
||||||
// by tauri-plugin-autostart; reading via invoke keeps the toggle
|
// by tauri-plugin-autostart; reading via invoke keeps the toggle
|
||||||
// honest even if the user has edited their .desktop file manually.
|
// 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) {
|
async function setLaunchAtLogin(nextOn: boolean) {
|
||||||
autostartSyncing = true;
|
|
||||||
try {
|
try {
|
||||||
const plugin = await import("@tauri-apps/plugin-autostart");
|
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||||
if (nextOn) {
|
if (nextOn) {
|
||||||
@@ -722,13 +758,13 @@
|
|||||||
settings.launchAtLogin = nextOn;
|
settings.launchAtLogin = nextOn;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.warn("Could not update autostart", String(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 {
|
try {
|
||||||
const plugin = await import("@tauri-apps/plugin-autostart");
|
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||||
settings.launchAtLogin = await plugin.isEnabled();
|
settings.launchAtLogin = await plugin.isEnabled();
|
||||||
} catch { /* best-effort */ }
|
} catch { /* best-effort */ }
|
||||||
} finally {
|
throw err;
|
||||||
autostartSyncing = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,11 +826,18 @@
|
|||||||
|
|
||||||
unlisten = await listen("model-download-progress", (event) => {
|
unlisten = await listen("model-download-progress", (event) => {
|
||||||
downloadProgress = event.payload.percent || event.payload.progress || 0;
|
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) => {
|
unlistenLlm = await listen("magnotia:llm-download-progress", (event) => {
|
||||||
llmDownloadProgress = event.payload.percent || 0;
|
llmDownloadProgress = event.payload.percent || 0;
|
||||||
llmDownloadingModel = event.payload.modelId || llmDownloadingModel;
|
llmDownloadingModel = event.payload.modelId || llmDownloadingModel;
|
||||||
|
llmDownloadBytes = event.payload.done ?? 0;
|
||||||
|
llmDownloadTotal = event.payload.total ?? 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
unlistenParakeet = await listen("parakeet-download-progress", (event) => {
|
unlistenParakeet = await listen("parakeet-download-progress", (event) => {
|
||||||
@@ -842,6 +885,9 @@
|
|||||||
async function downloadModel(size) {
|
async function downloadModel(size) {
|
||||||
downloadingModel = size;
|
downloadingModel = size;
|
||||||
downloadProgress = 0;
|
downloadProgress = 0;
|
||||||
|
downloadBytes = 0;
|
||||||
|
downloadTotal = 0;
|
||||||
|
downloadStartedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
await invoke("download_model", { size });
|
await invoke("download_model", { size });
|
||||||
downloadedModels = await invoke("list_models");
|
downloadedModels = await invoke("list_models");
|
||||||
@@ -980,7 +1026,15 @@
|
|||||||
solid bg-bg matches the page so groups don't bleed under it. -->
|
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="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-center justify-between gap-4 mb-3">
|
||||||
<h2 class="font-display text-[26px] italic text-text">Settings</h2>
|
<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-[11px] text-text-tertiary bg-accent-subtle rounded-full px-2.5 py-1"
|
||||||
|
>100% local · no telemetry · no cloud</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative max-w-md">
|
<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" />
|
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
|
||||||
@@ -1020,6 +1074,7 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Audio"
|
title="Audio"
|
||||||
description="Microphone input and capture behaviour."
|
description="Microphone input and capture behaviour."
|
||||||
|
icon={Mic}
|
||||||
open={searchActive ? searchMatches('Audio', 'Microphone input capture behaviour', 'mic device gain monitoring rms vad') : false}
|
open={searchActive ? searchMatches('Audio', 'Microphone input capture behaviour', 'mic device gain monitoring rms vad') : false}
|
||||||
>
|
>
|
||||||
<div class="px-1 pb-2 animate-fade-in">
|
<div class="px-1 pb-2 animate-fade-in">
|
||||||
@@ -1069,7 +1124,8 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Vocabulary"
|
title="Vocabulary"
|
||||||
description="Profile-scoped terms, prompts, and templates."
|
description="Profile-scoped terms, prompts, and templates."
|
||||||
open={searchActive ? searchMatches('Vocabulary', 'Profile-scoped terms prompts templates', 'Terms profiles initial prompt', 'Profiles templates rename create delete') : false}
|
icon={BookOpen}
|
||||||
|
open={searchActive ? searchMatches('Vocabulary', 'Profile-scoped terms prompts templates', 'Terms profiles initial prompt', 'Profiles templates rename create delete', 'Profiles vocabulary words rename create delete', 'Templates structured formats sections create delete') : false}
|
||||||
>
|
>
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Terms & profiles"
|
title="Terms & profiles"
|
||||||
@@ -1291,24 +1347,18 @@
|
|||||||
-->
|
-->
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Profiles & templates"
|
title="Profiles & templates"
|
||||||
open={searchActive ? searchMatches('Profiles templates rename create delete') : false}
|
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">
|
<div class="animate-fade-in">
|
||||||
<!-- Profiles section -->
|
<!-- Profiles section. Nested SettingsGroup for parity with
|
||||||
<button
|
the rest of the settings tree; the count badge stays in
|
||||||
class="flex items-center gap-2 w-full text-left mb-1"
|
the title via the description slot. -->
|
||||||
onclick={() => showProfiles = !showProfiles}
|
<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}
|
||||||
>
|
>
|
||||||
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showProfiles ? 'rotate-90' : ''}" />
|
<div class="space-y-1.5 animate-fade-in">
|
||||||
<h3 class="text-[14px] font-semibold text-text">Profiles</h3>
|
|
||||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
|
||||||
{profiles.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Custom vocabulary to improve transcription accuracy</p>
|
|
||||||
|
|
||||||
{#if showProfiles}
|
|
||||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
|
||||||
{#each profiles as profile, i}
|
{#each profiles as profile, i}
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||||
@@ -1327,10 +1377,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if editingProfile === i}
|
{#if editingProfile === i}
|
||||||
<div class="mt-1.5 animate-fade-in">
|
<div class="mt-1.5 animate-fade-in">
|
||||||
<textarea
|
<textarea
|
||||||
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||||
text-[12px] text-text resize-none
|
text-[12px] text-text resize-none
|
||||||
focus:outline-none focus:border-accent"
|
focus:outline-none focus:border-accent"
|
||||||
placeholder="One word or phrase per line..."
|
placeholder="One word or phrase per line..."
|
||||||
bind:value={profile.words}
|
bind:value={profile.words}
|
||||||
oninput={() => saveProfiles()}
|
oninput={() => saveProfiles()}
|
||||||
@@ -1358,25 +1408,15 @@
|
|||||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
|
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</SettingsGroup>
|
||||||
|
|
||||||
<div class="my-4 h-px bg-border-subtle"></div>
|
|
||||||
|
|
||||||
<!-- Templates section -->
|
<!-- Templates section -->
|
||||||
<button
|
<SettingsGroup
|
||||||
class="flex items-center gap-2 w-full text-left mb-1"
|
title="Templates"
|
||||||
onclick={() => showTemplates = !showTemplates}
|
description={`${templates.length} ${templates.length === 1 ? "template" : "templates"} · structured formats for dictation sessions`}
|
||||||
|
open={searchActive ? searchMatches('Templates structured formats sections create delete') : false}
|
||||||
>
|
>
|
||||||
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showTemplates ? 'rotate-90' : ''}" />
|
<div class="space-y-1.5 animate-fade-in">
|
||||||
<h3 class="text-[14px] font-semibold text-text">Templates</h3>
|
|
||||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
|
||||||
{templates.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Structured formats for dictation sessions</p>
|
|
||||||
|
|
||||||
{#if showTemplates}
|
|
||||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
|
||||||
{#each templates as template, i}
|
{#each templates as template, i}
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||||
@@ -1395,10 +1435,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if editingTemplate === i}
|
{#if editingTemplate === i}
|
||||||
<div class="mt-1.5 animate-fade-in">
|
<div class="mt-1.5 animate-fade-in">
|
||||||
<textarea
|
<textarea
|
||||||
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||||
text-[12px] text-text resize-none
|
text-[12px] text-text resize-none
|
||||||
focus:outline-none focus:border-accent"
|
focus:outline-none focus:border-accent"
|
||||||
placeholder="One section per line..."
|
placeholder="One section per line..."
|
||||||
value={template.sections.join("\n")}
|
value={template.sections.join("\n")}
|
||||||
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
|
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
|
||||||
@@ -1426,7 +1466,7 @@
|
|||||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
|
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</SettingsGroup>
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
@@ -1435,6 +1475,7 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Transcription"
|
title="Transcription"
|
||||||
description="Engine selection, models, language, and output formatting."
|
description="Engine selection, models, language, and output formatting."
|
||||||
|
icon={Type}
|
||||||
open={searchActive ? searchMatches('Transcription', 'Engine selection models language output formatting', 'whisper parakeet engine model size british english formatting filler smart raw clean tier timestamps') : true}
|
open={searchActive ? searchMatches('Transcription', 'Engine selection models language output formatting', 'whisper parakeet engine model size british english formatting filler smart raw clean tier timestamps') : true}
|
||||||
>
|
>
|
||||||
<div class="animate-fade-in">
|
<div class="animate-fade-in">
|
||||||
@@ -1484,7 +1525,18 @@
|
|||||||
onclick={loadSelectedModel}
|
onclick={loadSelectedModel}
|
||||||
>Load model</button>
|
>Load model</button>
|
||||||
{:else if downloadingModel === whisperModelId(settings.modelSize)}
|
{:else if downloadingModel === whisperModelId(settings.modelSize)}
|
||||||
<span class="text-[11px] text-warning">{downloadProgress}% downloading...</span>
|
<span class="text-[11px] 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}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="text-[11px] text-accent hover:text-accent-hover"
|
class="text-[11px] text-accent hover:text-accent-hover"
|
||||||
@@ -1625,8 +1677,9 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="AI & Processing"
|
title="AI & Processing"
|
||||||
description="Local LLM tier, cleanup post-processing, and rules."
|
description="Local LLM tier, cleanup post-processing, and rules."
|
||||||
|
icon={Sparkles}
|
||||||
onopen={onAiSectionOpen}
|
onopen={onAiSectionOpen}
|
||||||
open={searchActive ? searchMatches('AI Processing', 'Local LLM tier cleanup post-processing rules', 'Post-processing remove fillers british anti-hallucination', 'AI Assistant Local LLM tier model management qwen llama prewarm gpu concurrency', 'If-then rules implementation cleanup intentions') : false}
|
open={searchActive ? searchMatches('AI Processing', 'Local LLM tier cleanup post-processing rules', 'Post-processing remove fillers british anti-hallucination', '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}
|
||||||
>
|
>
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Post-processing"
|
title="Post-processing"
|
||||||
@@ -1651,7 +1704,7 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="AI Assistant"
|
title="AI Assistant"
|
||||||
description="Local LLM tier and model management."
|
description="Local LLM tier and model management."
|
||||||
open={searchActive ? searchMatches('AI Assistant Local LLM tier model management qwen llama prewarm gpu concurrency cleanup') : false}
|
open={searchActive ? searchMatches('AI Assistant Local LLM tier model management qwen llama prewarm', 'Advanced cleanup preset prompt email notes code GPU concurrency parallel sequential VRAM') : false}
|
||||||
>
|
>
|
||||||
<div class="animate-fade-in">
|
<div class="animate-fade-in">
|
||||||
<p class="text-[11px] text-text-tertiary mb-4">
|
<p class="text-[11px] text-text-tertiary mb-4">
|
||||||
@@ -1748,7 +1801,18 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{#if llmDownloadingModel === selectedLlmModelId()}
|
{#if llmDownloadingModel === selectedLlmModelId()}
|
||||||
<span class="text-[11px] text-warning">{llmDownloadProgress}% downloading…</span>
|
<span class="text-[11px] 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())}
|
{:else if !llmModelDownloaded(selectedLlmModelId())}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1804,48 +1868,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cleanup preset (brief item B.1 #15). Shapes the tone /
|
|
||||||
structure of LLM cleanup output; composes on top of the
|
|
||||||
active profile's initial prompt. -->
|
|
||||||
<div class="mt-5">
|
|
||||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Cleanup preset</p>
|
|
||||||
<SegmentedButton
|
|
||||||
options={["default", "email", "notes", "code"]}
|
|
||||||
bind:value={settings.llmPromptPreset}
|
|
||||||
/>
|
|
||||||
<p class="text-[11px] text-text-tertiary 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>
|
|
||||||
|
|
||||||
<!-- Sequential-GPU guard (brief item A.1 #28). On a tight-
|
|
||||||
VRAM single-GPU system, loading LLM + Whisper together
|
|
||||||
can OOM. Sequential mode unloads one before loading the
|
|
||||||
other; adds reload latency between transcribe and
|
|
||||||
cleanup phases. -->
|
|
||||||
<div class="mt-5">
|
|
||||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">GPU concurrency</p>
|
|
||||||
<SegmentedButton
|
|
||||||
options={["parallel", "sequential"]}
|
|
||||||
bind:value={settings.aiGpuConcurrency}
|
|
||||||
/>
|
|
||||||
<p class="text-[11px] text-text-tertiary 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>
|
|
||||||
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<Toggle
|
<Toggle
|
||||||
bind:checked={settings.prewarmModelOnStartup}
|
bind:checked={settings.prewarmModelOnStartup}
|
||||||
@@ -1853,6 +1875,60 @@
|
|||||||
description="Loads Whisper after the app opens. Faster first capture, but higher idle memory use."
|
description="Loads Whisper after the app opens. Faster first capture, but higher idle memory use."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced tuning. Closed by default so the AI Assistant
|
||||||
|
page leads with the four first-decision controls (tier,
|
||||||
|
model, status, prewarm). Search opens this on hits for
|
||||||
|
GPU concurrency, cleanup preset, parallel/sequential. -->
|
||||||
|
<SettingsGroup
|
||||||
|
title="Advanced"
|
||||||
|
description="Cleanup preset and GPU concurrency."
|
||||||
|
open={searchActive ? searchMatches('Advanced cleanup preset prompt email notes code GPU concurrency parallel sequential VRAM') : false}
|
||||||
|
>
|
||||||
|
<div class="animate-fade-in">
|
||||||
|
<!-- Cleanup preset (brief item B.1 #15). Shapes the tone /
|
||||||
|
structure of LLM cleanup output; composes on top of the
|
||||||
|
active profile's initial prompt. -->
|
||||||
|
<div>
|
||||||
|
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Cleanup preset</p>
|
||||||
|
<SegmentedButton
|
||||||
|
options={["default", "email", "notes", "code"]}
|
||||||
|
bind:value={settings.llmPromptPreset}
|
||||||
|
/>
|
||||||
|
<p class="text-[11px] text-text-tertiary 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>
|
||||||
|
|
||||||
|
<!-- Sequential-GPU guard (brief item A.1 #28). On a tight-
|
||||||
|
VRAM single-GPU system, loading LLM + Whisper together
|
||||||
|
can OOM. Sequential mode unloads one before loading the
|
||||||
|
other; adds reload latency between transcribe and
|
||||||
|
cleanup phases. -->
|
||||||
|
<div class="mt-5">
|
||||||
|
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">GPU concurrency</p>
|
||||||
|
<SegmentedButton
|
||||||
|
options={["parallel", "sequential"]}
|
||||||
|
bind:value={settings.aiGpuConcurrency}
|
||||||
|
/>
|
||||||
|
<p class="text-[11px] text-text-tertiary 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>
|
||||||
|
</div>
|
||||||
|
</SettingsGroup>
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
@@ -1876,6 +1952,7 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Tasks & Rituals"
|
title="Tasks & Rituals"
|
||||||
description="Task surface, gamification, rituals, and nudges."
|
description="Task surface, gamification, rituals, and nudges."
|
||||||
|
icon={SquareCheck}
|
||||||
open={searchActive ? searchMatches('Tasks Rituals', 'Task surface gamification rituals nudges', 'Rituals morning triage wind-down launch login autostart', 'Tasks page gamification visuals header sparkline', 'Nudges soft notifications hourly') : false}
|
open={searchActive ? searchMatches('Tasks Rituals', 'Task surface gamification rituals nudges', 'Rituals morning triage wind-down launch login autostart', 'Tasks page gamification visuals header sparkline', 'Nudges soft notifications hourly') : false}
|
||||||
>
|
>
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
@@ -1930,38 +2007,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-border-subtle">
|
<div class="mt-4 pt-4 border-t border-border-subtle">
|
||||||
<!-- One-way flow: click → OS call → state update. Can't use
|
<!-- Toggle handles the round-trip: it shows a spinner
|
||||||
the standard Toggle because its bind:checked would
|
while the OS call is in flight and snaps the checked
|
||||||
race the autostart invoke and let the UI lie during
|
state back if the call rejects, so the UI never lies
|
||||||
the round-trip. -->
|
about whether autostart is registered. -->
|
||||||
<div class="flex items-start gap-3 py-2.5">
|
<Toggle
|
||||||
<button
|
bind:checked={settings.launchAtLogin}
|
||||||
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
|
label="Launch Magnotia at login"
|
||||||
{settings.launchAtLogin ? 'bg-accent shadow-[0_0_8px_rgba(201,133,85,0.25)]' : 'bg-bg-elevated'}
|
description="So Magnotia is already there at the start of the day. Uses your OS's standard autostart, no background tricks."
|
||||||
active:scale-95 disabled:opacity-60"
|
onChange={setLaunchAtLogin}
|
||||||
style="transition-duration: var(--duration-ui)"
|
/>
|
||||||
onclick={() => setLaunchAtLogin(!settings.launchAtLogin)}
|
|
||||||
disabled={autostartSyncing}
|
|
||||||
role="switch"
|
|
||||||
aria-checked={settings.launchAtLogin}
|
|
||||||
aria-label="Launch Magnotia at login"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
|
|
||||||
{settings.launchAtLogin ? 'translate-x-[16px]' : 'translate-x-0'}"
|
|
||||||
style="transition: transform var(--duration-ui) cubic-bezier(0.16, 1, 0.3, 1)"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-[13px] text-text leading-tight">Launch Magnotia at login</p>
|
|
||||||
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">
|
|
||||||
So Magnotia is already there at the start of the day. Uses your OS's standard autostart, no background tricks.
|
|
||||||
</p>
|
|
||||||
{#if autostartSyncing}
|
|
||||||
<p class="text-[11px] text-text-tertiary mt-1">Updating…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -2028,6 +2083,7 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Output & Capture"
|
title="Output & Capture"
|
||||||
description="Clipboard, paste, audio capture, and read-aloud."
|
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') : false}
|
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') : false}
|
||||||
>
|
>
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
@@ -2210,6 +2266,7 @@
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Appearance & System"
|
title="Appearance & System"
|
||||||
description="Theme, accessibility, hotkey, and About."
|
description="Theme, accessibility, hotkey, and About."
|
||||||
|
icon={Sliders}
|
||||||
open={searchActive ? searchMatches('Appearance System', 'Theme accessibility hotkey About', 'Global hotkey toggle recording shortcut', 'Appearance Theme zone font size locale dark light', 'Accessibility Reduced motion contrast typography lexend opendyslexic atkinson bionic dyslexic', 'About Engine status diagnostic report version') : false}
|
open={searchActive ? searchMatches('Appearance System', 'Theme accessibility hotkey About', 'Global hotkey toggle recording shortcut', 'Appearance Theme zone font size locale dark light', 'Accessibility Reduced motion contrast typography lexend opendyslexic atkinson bionic dyslexic', 'About Engine status diagnostic report version') : false}
|
||||||
>
|
>
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
|
|||||||
Reference in New Issue
Block a user