feat(sidebar B.1 #31): visible LLM status chip with live state
The LLM runtime has been quiet since it shipped in Phase 3 — users had no surface-level signal that cleanup was loaded, warming, or actively generating. Settings has verbose status text internally, but a dictation-flow user never opens Settings during a run. New: a shared $state store drives a small chip in the sidebar that reflects the true LLM state in ≤500 ms of any transition (brief item #31 acceptance). Five states: off → hidden (user has aiTier === "off") warming → model download or first load in flight; amber pulse ready → loaded + idle; green dot generating → cleanup_transcript_text_cmd or extract_tasks_from_transcript_cmd in flight; accent pulse with Sparkles icon error → last operation failed; red dot with AlertTriangle The store exposes three calls: refreshLlmStatus(aiTier) (polls the backend), markGenerating(detail) / markGenerationDone(success). DictationPage wraps its cleanup + extract calls in mark-generating pairs. SettingsPage's LLM load / unload / delete / download paths also refresh the global store so Settings-initiated transitions surface in the sidebar immediately. The chip collapses to a dot-only compact form when the sidebar is collapsed. No backend changes — everything wires onto the existing `get_llm_status` Tauri command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page, settings, tasks, saveSettings } from "$lib/stores/page.svelte.js";
|
import { page, settings, tasks, saveSettings } from "$lib/stores/page.svelte.js";
|
||||||
import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
|
import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
|
||||||
|
import LlmStatusChip from "$lib/components/LlmStatusChip.svelte";
|
||||||
|
|
||||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||||
|
|
||||||
@@ -53,6 +54,17 @@
|
|||||||
|
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<p class="text-[10px] text-text-tertiary px-5 mt-1.5 tracking-[0.12em] uppercase">Think out loud</p>
|
<p class="text-[10px] text-text-tertiary px-5 mt-1.5 tracking-[0.12em] uppercase">Think out loud</p>
|
||||||
|
<!-- LLM status chip — hidden when aiTier === off, otherwise reflects
|
||||||
|
disconnected / warming / ready / generating / error state live. -->
|
||||||
|
<div class="px-5 mt-2">
|
||||||
|
<LlmStatusChip />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Collapsed: compact dot-only chip so the sidebar still surfaces
|
||||||
|
the state without eating horizontal space. -->
|
||||||
|
<div class="mt-2 flex justify-center">
|
||||||
|
<LlmStatusChip compact />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Separator -->
|
<!-- Separator -->
|
||||||
|
|||||||
60
src/lib/components/LlmStatusChip.svelte
Normal file
60
src/lib/components/LlmStatusChip.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
|
import { Sparkles, AlertTriangle } from "lucide-svelte";
|
||||||
|
import { llmStatus } from "$lib/stores/llmStatus.svelte.js";
|
||||||
|
|
||||||
|
let { compact = false } = $props<{ compact?: boolean }>();
|
||||||
|
|
||||||
|
let label = $derived.by(() => {
|
||||||
|
switch (llmStatus.kind) {
|
||||||
|
case "warming":
|
||||||
|
return llmStatus.detail ?? "AI warming";
|
||||||
|
case "ready":
|
||||||
|
return "AI ready";
|
||||||
|
case "generating":
|
||||||
|
return llmStatus.detail ?? "Cleaning…";
|
||||||
|
case "error":
|
||||||
|
return "AI error";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let dotClass = $derived.by(() => {
|
||||||
|
switch (llmStatus.kind) {
|
||||||
|
case "warming":
|
||||||
|
return "bg-warning animate-pulse";
|
||||||
|
case "ready":
|
||||||
|
return "bg-success";
|
||||||
|
case "generating":
|
||||||
|
return "bg-accent animate-pulse";
|
||||||
|
case "error":
|
||||||
|
return "bg-danger";
|
||||||
|
default:
|
||||||
|
return "bg-text-tertiary";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if llmStatus.kind !== "off"}
|
||||||
|
{#if compact}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center w-[10px] h-[10px] rounded-full {dotClass}"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
></span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 px-2 py-[3px] rounded-full bg-bg-elevated border border-border-subtle text-[10px] text-text-secondary whitespace-nowrap"
|
||||||
|
title={llmStatus.detail ?? label}
|
||||||
|
>
|
||||||
|
<span class="inline-block w-[6px] h-[6px] rounded-full {dotClass}"></span>
|
||||||
|
{#if llmStatus.kind === "error"}
|
||||||
|
<AlertTriangle size={10} class="text-danger" />
|
||||||
|
{:else if llmStatus.kind === "generating"}
|
||||||
|
<Sparkles size={10} class="text-accent" />
|
||||||
|
{/if}
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { emit } from "@tauri-apps/api/event";
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
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 { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
@@ -444,14 +445,20 @@
|
|||||||
const llmLoaded = await invoke("get_llm_status").catch(() => false);
|
const llmLoaded = await invoke("get_llm_status").catch(() => false);
|
||||||
if (!llmLoaded) return text;
|
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 {
|
try {
|
||||||
const cleaned = await invoke("cleanup_transcript_text_cmd", {
|
const cleaned = await invoke("cleanup_transcript_text_cmd", {
|
||||||
transcript: text,
|
transcript: text,
|
||||||
profileId: profilesStore.activeProfileId,
|
profileId: profilesStore.activeProfileId,
|
||||||
});
|
});
|
||||||
|
markGenerationDone(true);
|
||||||
return cleaned?.trim() ? cleaned.trim() : text;
|
return cleaned?.trim() ? cleaned.trim() : text;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("LLM cleanup failed, keeping existing transcript", err);
|
console.warn("LLM cleanup failed, keeping existing transcript", err);
|
||||||
|
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,11 +466,14 @@
|
|||||||
async function extractTasksForTranscript(text) {
|
async function extractTasksForTranscript(text) {
|
||||||
const llmLoaded = await invoke("get_llm_status").catch(() => false);
|
const llmLoaded = await invoke("get_llm_status").catch(() => false);
|
||||||
if (settings.aiTier === "tasks" && llmLoaded) {
|
if (settings.aiTier === "tasks" && llmLoaded) {
|
||||||
|
markGenerating("Extracting tasks");
|
||||||
try {
|
try {
|
||||||
const items = await invoke("extract_tasks_from_transcript_cmd", { transcript: text });
|
const items = await invoke("extract_tasks_from_transcript_cmd", { transcript: text });
|
||||||
|
markGenerationDone(true);
|
||||||
return items.map((taskText) => ({ text: taskText }));
|
return items.map((taskText) => ({ text: taskText }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("LLM extract_tasks failed, falling back to regex", err);
|
console.warn("LLM extract_tasks failed, falling back to regex", err);
|
||||||
|
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
import { Check, ChevronRight } from "lucide-svelte";
|
import { Check, ChevronRight } from "lucide-svelte";
|
||||||
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
|
||||||
|
// 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();
|
const prefs = getPreferences();
|
||||||
|
|
||||||
@@ -515,6 +519,7 @@
|
|||||||
await invoke("download_llm_model", { modelId });
|
await invoke("download_llm_model", { modelId });
|
||||||
llmDownloadingModel = "";
|
llmDownloadingModel = "";
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier);
|
||||||
llmStatus = "Download complete";
|
llmStatus = "Download complete";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmDownloadingModel = "";
|
llmDownloadingModel = "";
|
||||||
@@ -528,6 +533,7 @@
|
|||||||
try {
|
try {
|
||||||
await invoke("load_llm_model", { modelId });
|
await invoke("load_llm_model", { modelId });
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus = typeof err === "string" ? err : "LLM load failed";
|
llmStatus = typeof err === "string" ? err : "LLM load failed";
|
||||||
}
|
}
|
||||||
@@ -537,6 +543,7 @@
|
|||||||
try {
|
try {
|
||||||
await invoke("unload_llm_model");
|
await invoke("unload_llm_model");
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier);
|
||||||
llmStatus = "Model unloaded";
|
llmStatus = "Model unloaded";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus = typeof err === "string" ? err : "LLM unload failed";
|
llmStatus = typeof err === "string" ? err : "LLM unload failed";
|
||||||
@@ -548,6 +555,7 @@
|
|||||||
try {
|
try {
|
||||||
await invoke("delete_llm_model", { modelId });
|
await invoke("delete_llm_model", { modelId });
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier);
|
||||||
llmStatus = "Downloaded model removed";
|
llmStatus = "Downloaded model removed";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus = typeof err === "string" ? err : "Delete failed";
|
llmStatus = typeof err === "string" ? err : "Delete failed";
|
||||||
@@ -574,6 +582,7 @@
|
|||||||
await unloadLlmModel();
|
await unloadLlmModel();
|
||||||
} else {
|
} else {
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier);
|
||||||
}
|
}
|
||||||
llmStatus = llmModelDownloaded(modelId)
|
llmStatus = llmModelDownloaded(modelId)
|
||||||
? "Selected model changed. Load it to enable AI features."
|
? "Selected model changed. Load it to enable AI features."
|
||||||
@@ -585,6 +594,7 @@
|
|||||||
if (openSection === 'ai') {
|
if (openSection === 'ai') {
|
||||||
await ensureRecommendedLlmTier();
|
await ensureRecommendedLlmTier();
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +604,7 @@
|
|||||||
systemInfo = await invoke("probe_system").catch(() => null);
|
systemInfo = await invoke("probe_system").catch(() => null);
|
||||||
await ensureRecommendedLlmTier();
|
await ensureRecommendedLlmTier();
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier);
|
||||||
const loaded = await invoke("check_engine");
|
const loaded = await invoke("check_engine");
|
||||||
engineOk = loaded;
|
engineOk = loaded;
|
||||||
engineStatus = loaded ? "Model loaded" : "No model loaded";
|
engineStatus = loaded ? "Model loaded" : "No model loaded";
|
||||||
|
|||||||
64
src/lib/stores/llmStatus.svelte.ts
Normal file
64
src/lib/stores/llmStatus.svelte.ts
Normal file
@@ -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<LlmStatusState>({ 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<void> {
|
||||||
|
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<boolean>("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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
import { initI18n } from "$lib/i18n";
|
import { initI18n } from "$lib/i18n";
|
||||||
|
import { refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
|
||||||
|
|
||||||
import { page as sveltePage } from "$app/stores";
|
import { page as sveltePage } from "$app/stores";
|
||||||
|
|
||||||
@@ -301,6 +302,12 @@
|
|||||||
})
|
})
|
||||||
.catch(() => { /* update check failure must not affect the app */ });
|
.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
|
// Meeting auto-capture: poll the process list and toast when a match
|
||||||
// appears (edge-triggered — no repeat toasts until the app goes away
|
// appears (edge-triggered — no repeat toasts until the app goes away
|
||||||
// and comes back). We never start recording from this signal; the
|
// and comes back). We never start recording from this signal; the
|
||||||
|
|||||||
Reference in New Issue
Block a user