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:
1 parent
b986c570f7
commit
a211f88ad4
1 file changed
+150
-6
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user