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:
iamdoubz committed 2026-07-07 08:15:04 -05:00
commit e966181ab8
9 files changed
+1207 -72

No files matched your search

+5 -1
View File
@@ -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
+93
View File
@@ -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>
+18 -2
View File
@@ -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
View File
@@ -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 {
+150 -6
View File
@@ -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;