Merge branch 'worktree-agent-af5a53986b9441608' into feature_chore_bug_005
# Conflicts: # src-tauri/src/llm/mod.rs # src/lib/views/Settings.svelte # src/lib/views/SummaryPanel.svelte
This commit is contained in:
commit
e966181ab8
9 files changed
+1207
-72
No files matched your search
+5
-1
@@ -57,7 +57,7 @@ export interface AudioDeviceInfo {
|
||||
}
|
||||
|
||||
export interface LlmStatus {
|
||||
provider: string; // ollama|custom|off (Phase 10a adds anthropic|openai)
|
||||
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
|
||||
reachable: boolean;
|
||||
isLocal: boolean;
|
||||
models: string[];
|
||||
@@ -287,6 +287,10 @@ export interface AppSettings {
|
||||
low_overhead: boolean;
|
||||
default_record: boolean;
|
||||
consent_acknowledged: boolean;
|
||||
/** One-time "data leaves your device" ack for hosted (non-local) AI
|
||||
* providers — Anthropic today (ADR-0011, T10.3). Independent of
|
||||
* consent_acknowledged (that one's about recording law). */
|
||||
hosted_ai_acknowledged: boolean;
|
||||
sync_enabled: boolean;
|
||||
mcp_enabled: boolean;
|
||||
mcp_transport: string; // http|stdio
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
// One-time third-party "data leaves your device" notice (ADR-0011, T10.3/
|
||||
// M3.3), shown before the first use of any hosted (non-local) AI provider —
|
||||
// Anthropic today, a hosted OpenAI-compatible gateway once wired the same
|
||||
// way. Same shared-copy/shared-acknowledgment pattern as ConsentNotice.svelte
|
||||
// (recording consent, ADR-0009): both gate a single Settings toggle AND a
|
||||
// second use-time trigger point on the same one-time flag.
|
||||
import { Globe } from "@lucide/svelte";
|
||||
let {
|
||||
providerLabel,
|
||||
onAccept,
|
||||
onCancel,
|
||||
}: { providerLabel: string; onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="banner" role="alertdialog" aria-labelledby="hosted-ai-heading">
|
||||
<div class="heading">
|
||||
<Globe size={18} aria-hidden="true" />
|
||||
<strong id="hosted-ai-heading">Before using {providerLabel}</strong>
|
||||
</div>
|
||||
<p>
|
||||
Generating with <strong>{providerLabel}</strong> sends this meeting's transcript to their servers
|
||||
— it leaves this device and is subject to their privacy policy. WhispAssist has no control over data
|
||||
handling once it leaves your device. This is off by default; you're choosing it now.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — continue</button>
|
||||
<button class="ghost" onclick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
max-width: 420px;
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--warning);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.heading strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
p strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid transparent;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
button.ghost {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
}
|
||||
button.ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
low_overhead: false,
|
||||
default_record: false, // recording OFF by default (ADR-0009)
|
||||
consent_acknowledged: false,
|
||||
hosted_ai_acknowledged: false, // hosted-AI "leaves your device" notice (ADR-0011)
|
||||
sync_enabled: false, // sync OFF by default (ADR-0010)
|
||||
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
|
||||
mcp_transport: "http",
|
||||
@@ -251,8 +252,16 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the LLM provider/endpoint/model and refresh status (T5.2). */
|
||||
async setLlmProvider(config: { provider: string; endpoint?: string; model?: string }) {
|
||||
/** Persist the LLM provider/endpoint/model (+ hosted apiKey, ADR-0011) and
|
||||
* refresh status (T5.2/T10.2). The key is only ever sent to the backend
|
||||
* command (which stores it in the OS credential store) — never held here
|
||||
* beyond this call, and never merged into `this.settings`. */
|
||||
async setLlmProvider(config: {
|
||||
provider: string;
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
}) {
|
||||
this.llmSaving = true;
|
||||
// Optimistic local update so the form reflects the change immediately.
|
||||
this.settings = {
|
||||
@@ -270,6 +279,13 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the one-time hosted-AI "leaves your device" acknowledgment
|
||||
* (ADR-0011, T10.3) — same generic patch() every other boolean setting
|
||||
* here uses (see setDefaultRecord below). */
|
||||
acknowledgeHostedAi() {
|
||||
return this.patch({ hosted_ai_acknowledged: true });
|
||||
}
|
||||
|
||||
async setPreferredBackend(backend: AppSettings["preferred_backend"]) {
|
||||
await this.patch({ preferred_backend: backend });
|
||||
await this.loadHardware();
|
||||
|
||||
+130
-13
@@ -6,6 +6,7 @@
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import { calendar } from "../stores/calendar.svelte";
|
||||
import ConsentNotice from "../components/ConsentNotice.svelte";
|
||||
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { api, errorMessage, events } from "../api";
|
||||
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
|
||||
@@ -28,6 +29,9 @@
|
||||
Bot,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
} from "@lucide/svelte";
|
||||
import {
|
||||
OLLAMA_OPTIONS,
|
||||
@@ -47,22 +51,57 @@
|
||||
const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist";
|
||||
let appInfo = $state<{ version: string; commit: string } | null>(null);
|
||||
|
||||
// ---- AI summary provider (T5.2, FR-LLM-1) ----
|
||||
// ---- AI summary provider (T5.2/T10.1/T10.2, FR-LLM-1, ADR-0011) ----
|
||||
let llmProvider = $state(settings.settings.llm_provider);
|
||||
let llmEndpoint = $state(settings.settings.llm_endpoint);
|
||||
let llmModel = $state(settings.settings.llm_model);
|
||||
// Hosted (Anthropic) API key — never read back from the backend (it's
|
||||
// never returned by llm_status/get_settings, FR-SEC-1); this is a
|
||||
// write-only field that's blank unless the user is actively (re)typing
|
||||
// one, and is dropped from memory the moment saveLlm() sends it.
|
||||
let llmApiKey = $state("");
|
||||
let showApiKey = $state(false);
|
||||
$effect(() => {
|
||||
// Re-sync the form when settings (re)load or are saved elsewhere.
|
||||
llmProvider = settings.settings.llm_provider;
|
||||
llmEndpoint = settings.settings.llm_endpoint;
|
||||
llmModel = settings.settings.llm_model;
|
||||
});
|
||||
|
||||
const HOSTED_PROVIDER_LABELS: Record<string, string> = { anthropic: "Anthropic (Claude)" };
|
||||
|
||||
/** True when the *currently selected* provider is hosted (leaves the
|
||||
* device): Anthropic always is; "custom" is hosted only once its endpoint
|
||||
* resolves off-network — mirrors the live warning already shown below. */
|
||||
function isHostedProviderSelection(): boolean {
|
||||
if (llmProvider === "anthropic") return true;
|
||||
if (llmProvider === "custom" && llmEndpoint) return !endpointIsLocalOrLan(llmEndpoint);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Hosted-AI "leaves your device" one-time banner (T10.3/M3.3) ----
|
||||
let showHostedBanner = $state(false);
|
||||
function onSaveLlmClick() {
|
||||
if (isHostedProviderSelection() && !settings.settings.hosted_ai_acknowledged) {
|
||||
showHostedBanner = true;
|
||||
return;
|
||||
}
|
||||
saveLlm();
|
||||
}
|
||||
async function acceptHostedBannerAndSave() {
|
||||
await settings.acknowledgeHostedAi();
|
||||
showHostedBanner = false;
|
||||
await saveLlm();
|
||||
}
|
||||
|
||||
async function saveLlm() {
|
||||
await settings.setLlmProvider({
|
||||
provider: llmProvider,
|
||||
endpoint: llmEndpoint || undefined,
|
||||
endpoint: llmProvider === "anthropic" ? undefined : llmEndpoint || undefined,
|
||||
model: llmModel || undefined,
|
||||
apiKey: llmApiKey.trim() || undefined,
|
||||
});
|
||||
llmApiKey = ""; // never linger in memory once sent
|
||||
}
|
||||
/** Client-side mirror of the backend's loopback+private-LAN check, for a live
|
||||
* "leaves your network" hint while typing. */
|
||||
@@ -1035,8 +1074,10 @@
|
||||
<section>
|
||||
<h3>AI summary provider</h3>
|
||||
<p class="muted">
|
||||
Summaries run on a local LLM. Point this at Ollama on this PC or another machine on your
|
||||
LAN (e.g. <code>192.168.0.x</code>) — both count as local, so nothing leaves your network.
|
||||
Summaries run on a local LLM by default. Point this at Ollama on this PC or another
|
||||
machine on your LAN (e.g. <code>192.168.0.x</code>) — both count as local, so nothing
|
||||
leaves your network. Hosted providers (Anthropic) are optional, off by default, and send
|
||||
the transcript to a third party once you turn one on.
|
||||
</p>
|
||||
<label
|
||||
>Provider
|
||||
@@ -1044,32 +1085,76 @@
|
||||
<option value="off">Off</option>
|
||||
<option value="ollama">Ollama (local / LAN)</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
<option value="anthropic">Anthropic (Claude) — hosted, leaves this device</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if llmProvider !== "off"}
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>Endpoint<input
|
||||
bind:value={llmEndpoint}
|
||||
placeholder="http://192.168.0.42:11434"
|
||||
/></label
|
||||
>
|
||||
<label>Model<input bind:value={llmModel} placeholder="llama3.1" /></label>
|
||||
{#if llmProvider !== "anthropic"}
|
||||
<label class="wide"
|
||||
>Endpoint<input
|
||||
bind:value={llmEndpoint}
|
||||
placeholder="http://192.168.0.42:11434"
|
||||
/></label
|
||||
>
|
||||
<label>Model<input bind:value={llmModel} placeholder="llama3.1" /></label>
|
||||
{:else}
|
||||
<label class="wide"
|
||||
>Model<input bind:value={llmModel} placeholder="claude-3-5-sonnet-latest" /></label
|
||||
>
|
||||
<label class="wide"
|
||||
>API key
|
||||
<div class="key-input">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
autocomplete="off"
|
||||
bind:value={llmApiKey}
|
||||
placeholder={settings.llmStatus?.provider === "anthropic"
|
||||
? "•••••••••••••••• (already set — leave blank to keep it)"
|
||||
: "sk-ant-…"}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
onclick={() => (showApiKey = !showApiKey)}
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{#if showApiKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)}
|
||||
{#if llmProvider === "anthropic"}
|
||||
<div class="banner hosted">
|
||||
<Globe size={14} aria-hidden="true" />
|
||||
Anthropic is a hosted, third-party service — this meeting's transcript leaves your device
|
||||
when you generate a summary.
|
||||
</div>
|
||||
{:else if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)}
|
||||
<div class="banner">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
This endpoint isn't on your machine or LAN — your transcript would leave your network.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}>
|
||||
<button class="primary" onclick={onSaveLlmClick} disabled={settings.llmSaving}>
|
||||
{settings.llmSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="ghost" onclick={() => settings.loadLlmStatus()}>
|
||||
<RefreshCw size={14} aria-hidden="true" /> Test connection
|
||||
</button>
|
||||
</div>
|
||||
{#if showHostedBanner}
|
||||
<HostedAiBanner
|
||||
providerLabel={HOSTED_PROVIDER_LABELS[llmProvider] ?? "this hosted provider"}
|
||||
onAccept={acceptHostedBannerAndSave}
|
||||
onCancel={() => (showHostedBanner = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if settings.llmStatus}
|
||||
{@const s = settings.llmStatus}
|
||||
<div class="status-card">
|
||||
@@ -1719,6 +1804,38 @@
|
||||
margin-top: 0.15rem;
|
||||
color: var(--warning);
|
||||
}
|
||||
/* Informational (not a warning-severity) variant for "this is hosted, by
|
||||
your own choice" notices — Anthropic's fixed endpoint (ADR-0011). */
|
||||
.banner.hosted {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg));
|
||||
}
|
||||
.banner.hosted :global(svg) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.key-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.key-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.icon-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0.35rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
ul.targets,
|
||||
ul.models,
|
||||
ul.events {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
} from "../api";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import TagChip from "../components/TagChip.svelte";
|
||||
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||
import {
|
||||
Tags,
|
||||
Sparkles,
|
||||
@@ -27,6 +28,8 @@
|
||||
FileText,
|
||||
Copy,
|
||||
Check,
|
||||
Cpu,
|
||||
Globe,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
onMount(() => calendar.load());
|
||||
@@ -53,19 +56,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
|
||||
@@ -375,7 +452,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
|
||||
@@ -475,6 +553,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}
|
||||
@@ -494,13 +604,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>
|
||||
@@ -742,6 +852,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