diff --git a/src/lib/Sidebar.svelte b/src/lib/Sidebar.svelte index b32ac54..6fe6fce 100644 --- a/src/lib/Sidebar.svelte +++ b/src/lib/Sidebar.svelte @@ -1,6 +1,7 @@ + +{#if llmStatus.kind !== "off"} + {#if compact} + + {:else} + + + {#if llmStatus.kind === "error"} + + {:else if llmStatus.kind === "generating"} + + {/if} + {label} + + {/if} +{/if} diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index 079ec65..7c1aeb8 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -5,6 +5,7 @@ import { emit } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; + import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js"; import { profilesStore } from "$lib/stores/profiles.svelte.ts"; import { toasts } from "$lib/stores/toasts.svelte.js"; import Card from "$lib/components/Card.svelte"; @@ -444,14 +445,20 @@ const llmLoaded = await invoke("get_llm_status").catch(() => false); if (!llmLoaded) return text; + // Flip the sidebar LLM status chip to "generating" so the user can + // see the cleanup pass in flight (brief item #31). Always paired + // with markGenerationDone below — success or failure. + markGenerating("Cleaning up"); try { const cleaned = await invoke("cleanup_transcript_text_cmd", { transcript: text, profileId: profilesStore.activeProfileId, }); + markGenerationDone(true); return cleaned?.trim() ? cleaned.trim() : text; } catch (err) { console.warn("LLM cleanup failed, keeping existing transcript", err); + markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null); return text; } } @@ -459,11 +466,14 @@ async function extractTasksForTranscript(text) { const llmLoaded = await invoke("get_llm_status").catch(() => false); if (settings.aiTier === "tasks" && llmLoaded) { + markGenerating("Extracting tasks"); try { const items = await invoke("extract_tasks_from_transcript_cmd", { transcript: text }); + markGenerationDone(true); return items.map((taskText) => ({ text: taskText })); } catch (err) { console.warn("LLM extract_tasks failed, falling back to regex", err); + markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null); } } diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 1019fcf..03fbe3c 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -18,6 +18,10 @@ import { Check, ChevronRight } from "lucide-svelte"; 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(); @@ -515,6 +519,7 @@ await invoke("download_llm_model", { modelId }); llmDownloadingModel = ""; await refreshLlmStatus(); + await refreshGlobalLlmStatus(settings.aiTier); llmStatus = "Download complete"; } catch (err) { llmDownloadingModel = ""; @@ -528,6 +533,7 @@ try { await invoke("load_llm_model", { modelId }); await refreshLlmStatus(); + await refreshGlobalLlmStatus(settings.aiTier); } catch (err) { llmStatus = typeof err === "string" ? err : "LLM load failed"; } @@ -537,6 +543,7 @@ 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"; @@ -548,6 +555,7 @@ 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"; @@ -574,6 +582,7 @@ await unloadLlmModel(); } else { await refreshLlmStatus(); + await refreshGlobalLlmStatus(settings.aiTier); } llmStatus = llmModelDownloaded(modelId) ? "Selected model changed. Load it to enable AI features." @@ -585,6 +594,7 @@ if (openSection === 'ai') { await ensureRecommendedLlmTier(); await refreshLlmStatus(); + await refreshGlobalLlmStatus(settings.aiTier); } } @@ -594,6 +604,7 @@ 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"; diff --git a/src/lib/stores/llmStatus.svelte.ts b/src/lib/stores/llmStatus.svelte.ts new file mode 100644 index 0000000..f8aaa5d --- /dev/null +++ b/src/lib/stores/llmStatus.svelte.ts @@ -0,0 +1,64 @@ +import { invoke } from "@tauri-apps/api/core"; +import { hasTauriRuntime } from "$lib/utils/runtime.js"; + +/// Visible LLM state for the status chip (brief item #31). +/// off — user has settings.aiTier === "off"; chip hidden. +/// warming — model download or load in progress. +/// ready — model loaded, idle. +/// generating — an active cleanup_transcript_text_cmd call is in flight. +/// error — last health check or generation failed. +export type LlmStatusKind = "off" | "warming" | "ready" | "generating" | "error"; + +export interface LlmStatusState { + kind: LlmStatusKind; + /// Optional short phrase for the chip to surface (e.g. "Loading Qwen3 4B"). + detail: string | null; +} + +export const llmStatus = $state({ kind: "off", detail: null }); + +/// Poll `get_llm_status` once. Cheap enough to call on layout mount, on +/// recording start, and on Settings-panel open. Keeps the chip in sync +/// with loads / unloads that happen outside the frontend's observation +/// path (first-run, background reload). The `aiTier` input short-circuits +/// to "off" so we don't show a chip when the user has opted out. +export async function refreshLlmStatus(aiTier: string): Promise { + if (aiTier === "off") { + llmStatus.kind = "off"; + llmStatus.detail = null; + return; + } + if (!hasTauriRuntime()) { + // Running in a pure-browser preview (vite dev without tauri) — leave + // the chip off rather than asserting a loaded state we can't verify. + llmStatus.kind = "off"; + llmStatus.detail = null; + return; + } + try { + const loaded = await invoke("get_llm_status"); + // Don't clobber a "generating" state with "ready" — a parallel + // cleanup call in flight is a truer signal than the load status. + if (llmStatus.kind === "generating") return; + llmStatus.kind = loaded ? "ready" : "warming"; + llmStatus.detail = null; + } catch (err) { + llmStatus.kind = "error"; + llmStatus.detail = typeof err === "string" ? err : (err as Error)?.message ?? "Unknown error"; + } +} + +export function markGenerating(detail: string | null = null): void { + llmStatus.kind = "generating"; + llmStatus.detail = detail; +} + +export function markGenerationDone(success: boolean, detail: string | null = null): void { + if (success) { + llmStatus.kind = "ready"; + llmStatus.detail = null; + } else { + llmStatus.kind = "error"; + llmStatus.detail = detail; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 45f1c30..28f1836 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -22,6 +22,7 @@ import { listen } from "@tauri-apps/api/event"; import { toasts } from "$lib/stores/toasts.svelte.js"; import { initI18n } from "$lib/i18n"; + import { refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js"; import { page as sveltePage } from "$app/stores"; @@ -301,6 +302,12 @@ }) .catch(() => { /* update check failure must not affect the app */ }); + // Seed the LLM status chip (sidebar) with whichever state the + // backend is in right now. The chip also reacts to the $effect + // on settings.aiTier below and to explicit mark-generating + // calls from DictationPage around cleanup_transcript_text_cmd. + refreshLlmStatus(settings.aiTier).catch(() => {}); + // Meeting auto-capture: poll the process list and toast when a match // appears (edge-triggered — no repeat toasts until the app goes away // and comes back). We never start recording from this signal; the