feat(ui): active-provider indicator + per-use quick switch in SummaryPanel (T10.3/M3.3)

Adds a provider row above Summary (dropdown mirroring Settings' AI
options + a local/hosted badge sourced from llm_status) so it's always
visible which provider a summary/tag generation will actually use.
Generate/Regenerate and the quick switch itself route hosted selections
(Anthropic, or "custom" once its endpoint resolves off-network) through
the same HostedAiBanner one-time acknowledgment gate as Settings — the
generate-click gate is the one that actually matters, since selecting a
provider alone sends nothing; it's kept even though the quick-switch
already pre-empts Anthropic specifically.

Also drops a stale svelte-ignore comment (pre-existing, unrelated to
this change — eslint-plugin-svelte no longer flags that element) that
was failing `eslint .` for this file.
This commit is contained in:
iamdoubz committed 2026-07-07 07:59:57 -05:00
1 parent b986c570f7
commit a211f88ad4
1 file changed
+150 -6
+150 -6
View File
@@ -13,6 +13,7 @@
} from "../api";
import { renderMarkdown } from "../markdown";
import TagChip from "../components/TagChip.svelte";
import HostedAiBanner from "../components/HostedAiBanner.svelte";
import {
Tags,
Sparkles,
@@ -22,6 +23,8 @@
Mic2,
Bell,
UploadCloud,
Cpu,
Globe,
} from "@lucide/svelte";
onMount(() => calendar.load());
@@ -48,19 +51,93 @@
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
let llmStatus = $state<LlmStatus | null>(null);
onMount(async () => {
async function refreshLlmStatus() {
try {
llmStatus = await api.llmStatus();
} catch {
llmStatus = null;
}
});
}
onMount(refreshLlmStatus);
function generateSummary() {
const m = meetings.selected;
if (m) meetings.generateSummary(m.id);
}
// ---- Active provider indicator + per-use quick switch (T10.3/M3.3) ----
// Same four providers Settings' AI section offers; switching here reuses
// the exact same set_llm_provider command (and hence the same "anthropic's
// endpoint is fixed" / "switching away resets a stale hosted endpoint"
// guarantees — see apply_llm_provider_args in commands.rs).
const PROVIDER_LABELS: Record<string, string> = {
off: "Off",
ollama: "Ollama",
custom: "Custom",
anthropic: "Anthropic (Claude)",
};
function isHostedStatus(status: LlmStatus | null): boolean {
return !!status && status.provider !== "off" && !status.isLocal;
}
let switchingProvider = $state(false);
// ---- Hosted-AI "leaves your device" one-time gate (T10.3/M3.3) — shared
// by the quick switch (switching TO Anthropic) and Generate/Regenerate
// (the actual point a transcript would leave the device, so this is the
// gate that matters even if the quick switch's own check is skipped, e.g.
// "custom" resolving to a hosted endpoint that was configured elsewhere).
let showHostedBanner = $state(false);
let hostedBannerLabel = $state("this hosted provider");
let pendingHostedAction = $state<(() => void) | null>(null);
function requireHostedAck(providerLabel: string, action: () => void) {
if (!settings.settings.hosted_ai_acknowledged) {
hostedBannerLabel = providerLabel;
pendingHostedAction = action;
showHostedBanner = true;
return;
}
action();
}
async function acceptHostedBanner() {
await settings.acknowledgeHostedAi();
showHostedBanner = false;
const action = pendingHostedAction;
pendingHostedAction = null;
action?.();
}
function cancelHostedBanner() {
showHostedBanner = false;
pendingHostedAction = null;
}
async function switchProvider(provider: string) {
switchingProvider = true;
try {
await settings.setLlmProvider({ provider });
await refreshLlmStatus();
} finally {
switchingProvider = false;
}
}
function onProviderChange(e: Event) {
const next = (e.target as HTMLSelectElement).value;
if (next === "anthropic") {
// Deterministically hosted — worth confirming before even switching to
// it, not just before the next generate.
requireHostedAck(PROVIDER_LABELS[next], () => void switchProvider(next));
} else {
void switchProvider(next);
}
}
function onGenerateClick() {
const label = llmStatus ? (PROVIDER_LABELS[llmStatus.provider] ?? llmStatus.provider) : "";
if (isHostedStatus(llmStatus)) {
requireHostedAck(label, generateSummary);
} else {
generateSummary();
}
}
// Editable copy so toggling a checkbox doesn't persist until "Save" is
// pressed; recomputes whenever a different meeting (or a freshly generated
// summary) comes in. bind:checked mutates each item in place, which a plain
@@ -256,7 +333,8 @@
{#if meetings.selected?.recorded}
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
{#if audioSrc}
<!-- svelte-ignore a11y_media_has_caption -- a meeting recording has no caption track -->
<!-- a meeting recording has no caption track — no svelte-ignore needed,
the current eslint-plugin-svelte doesn't flag this element. -->
<!-- controlsList/contextmenu: no download affordance — the decrypted audio
must not be savable to disk (would undermine encryption at rest). -->
<audio
@@ -356,6 +434,38 @@
{/if}
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
<div class="provider-row">
<label class="provider-select">
<span class="sr-only">AI provider</span>
<select
value={llmStatus?.provider ?? settings.settings.llm_provider}
onchange={onProviderChange}
disabled={switchingProvider}
>
{#each Object.entries(PROVIDER_LABELS) as [id, label] (id)}
<option value={id}>{label}</option>
{/each}
</select>
</label>
{#if llmStatus && llmStatus.provider !== "off"}
<span class="provider-badge" class:hosted={!llmStatus.isLocal}>
{#if llmStatus.isLocal}<Cpu size={12} aria-hidden="true" />{:else}<Globe
size={12}
aria-hidden="true"
/>{/if}
{llmStatus.isLocal ? "local" : "leaves this device"}
</span>
{/if}
</div>
{#if showHostedBanner}
<HostedAiBanner
providerLabel={hostedBannerLabel}
onAccept={acceptHostedBanner}
onCancel={cancelHostedBanner}
/>
{/if}
{#if !meetings.selected}
<p class="muted">Select a meeting to generate a summary.</p>
{:else if meetings.summarizingId === meetings.selected.id}
@@ -375,13 +485,13 @@
{/each}
</ul>
{/if}
<button class="link" onclick={generateSummary}>Regenerate</button>
<button class="link" onclick={onGenerateClick}>Regenerate</button>
{:else}
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
{#if llmStatus && llmStatus.provider === "off"}
<p class="muted small">No local LLM provider configured — enable one in Settings.</p>
<p class="muted small">No AI provider configured — enable one in Settings.</p>
{:else}
<button class="primary" onclick={generateSummary}>
<button class="primary" onclick={onGenerateClick}>
<Sparkles size={14} aria-hidden="true" />
Generate summary
</button>
@@ -538,6 +648,40 @@
padding-top: 0;
border-top: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.provider-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: -0.3rem 0 0.6rem;
}
.provider-select select {
font-size: 0.78rem;
padding: 0.25rem 0.4rem;
}
.provider-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--muted);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full, 999px);
border: 1px solid var(--border);
}
.provider-badge.hosted {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
button.primary {
display: inline-flex;
align-items: center;