Merge branch 'worktree-agent-af937df502b61b006' into feature_chore_bug_005
# Conflicts: # src-tauri/src/storage/mod.rs
This commit is contained in:
commit
2582d8947f
17 files changed
+1644
-69
No files matched your search
+19
-3
@@ -289,6 +289,10 @@ export interface AppSettings {
|
||||
consent_acknowledged: boolean;
|
||||
sync_enabled: boolean;
|
||||
mcp_enabled: boolean;
|
||||
mcp_transport: string; // http|stdio
|
||||
mcp_port: number;
|
||||
mcp_expose: string; // none|selected|all
|
||||
mcp_expose_recordings: boolean;
|
||||
retention_max_age_days: number | null;
|
||||
retention_max_size_gb: number | null;
|
||||
pst_last_path: string | null;
|
||||
@@ -325,6 +329,15 @@ export interface McpAccessEntry {
|
||||
client: string | null;
|
||||
}
|
||||
|
||||
// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled.
|
||||
export interface McpStatus {
|
||||
enabled: boolean;
|
||||
transport: "http" | "stdio";
|
||||
endpoint: string;
|
||||
tokenSet: boolean;
|
||||
exposeScope: "none" | "selected" | "all";
|
||||
}
|
||||
|
||||
// ---- Commands ----
|
||||
export const api = {
|
||||
// `record` controls audio RETENTION (default false / off — ADR-0009).
|
||||
@@ -455,7 +468,7 @@ export const api = {
|
||||
getFeatureBrief: (id: string) => invoke<FeatureBrief>("get_feature_brief", { id }),
|
||||
setBriefExposed: (id: string, exposed: boolean) =>
|
||||
invoke<void>("set_brief_exposed", { id, exposed }),
|
||||
mcpStatus: () => invoke("mcp_status"),
|
||||
mcpStatus: () => invoke<McpStatus>("mcp_status"),
|
||||
setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) =>
|
||||
invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }),
|
||||
setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) =>
|
||||
@@ -538,8 +551,11 @@ export const events = {
|
||||
onSyncLinked: (
|
||||
cb: (p: { ok: boolean; kind: string; error?: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
|
||||
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("mcp://access", (e) => cb(e.payload as never)),
|
||||
// Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the
|
||||
// snake_case McpAccessEntry rows `mcpAccessLog()` returns).
|
||||
onMcpAccess: (
|
||||
cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("mcp://access", (e) => cb(e.payload as never)),
|
||||
onAgentProgress: (
|
||||
cb: (p: { briefId: string; tool: string; line: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type LlmStatus,
|
||||
type ModelInfo,
|
||||
type PrivacySelfCheck,
|
||||
type McpStatus,
|
||||
type McpAccessEntry,
|
||||
} from "../api";
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -30,6 +32,10 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
consent_acknowledged: false,
|
||||
sync_enabled: false, // sync OFF by default (ADR-0010)
|
||||
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
|
||||
mcp_transport: "http",
|
||||
mcp_port: 4849,
|
||||
mcp_expose: "none", // scope OFF by default (FR-MCP-3)
|
||||
mcp_expose_recordings: false,
|
||||
retention_max_age_days: null, // no cap by default (FR-STORE-2)
|
||||
retention_max_size_gb: null,
|
||||
pst_last_path: null,
|
||||
@@ -58,6 +64,15 @@ class SettingsStore {
|
||||
// Privacy self-check (T7.6, FR-SEC-2).
|
||||
privacy = $state<PrivacySelfCheck | null>(null);
|
||||
|
||||
// MCP server (Phase 10b, ADR-0011).
|
||||
mcpStatus = $state<McpStatus | null>(null);
|
||||
mcpAccessLog = $state<McpAccessEntry[]>([]);
|
||||
mcpSaving = $state(false);
|
||||
/** The freshly-minted token from the last `setMcpEnabled(true)` call —
|
||||
* shown exactly once (it is never re-readable afterwards, same as any
|
||||
* other newly-issued secret). Cleared on disable or when the panel closes. */
|
||||
mcpLastToken = $state<string | null>(null);
|
||||
|
||||
// LLM provider status (T5.2, FR-LLM-1).
|
||||
llmStatus = $state<LlmStatus | null>(null);
|
||||
llmSaving = $state(false);
|
||||
@@ -82,6 +97,16 @@ class SettingsStore {
|
||||
await this.loadModels();
|
||||
await this.loadPrivacy();
|
||||
await this.loadLlmStatus();
|
||||
await this.loadMcpStatus();
|
||||
await this.loadMcpAccessLog();
|
||||
// Live tail of the FR-MCP-5 audit log — every tool read an agent makes
|
||||
// while the panel is open shows up immediately, not just on refresh.
|
||||
await events.onMcpAccess(({ at, tool, meetingId, client }) => {
|
||||
this.mcpAccessLog = [
|
||||
{ at, tool, meeting_id: meetingId ?? null, client: client ?? null },
|
||||
...this.mcpAccessLog,
|
||||
].slice(0, 50);
|
||||
});
|
||||
await events.onHardwareChanged(({ active }) => {
|
||||
if (this.hardware) this.hardware.active = active;
|
||||
});
|
||||
@@ -175,6 +200,49 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
async loadMcpStatus() {
|
||||
try {
|
||||
this.mcpStatus = await api.mcpStatus();
|
||||
} catch {
|
||||
this.mcpStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMcpAccessLog(limit = 50) {
|
||||
try {
|
||||
this.mcpAccessLog = await api.mcpAccessLog(limit);
|
||||
} catch {
|
||||
this.mcpAccessLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable/disable the loopback MCP server (FR-MCP-1/6). On enable, the
|
||||
* returned token is stashed in `mcpLastToken` for the one-time reveal. */
|
||||
async setMcpEnabled(enabled: boolean, transport?: "http" | "stdio", port?: number) {
|
||||
this.mcpSaving = true;
|
||||
try {
|
||||
const res = await api.setMcpEnabled(enabled, transport, port);
|
||||
this.mcpLastToken = enabled ? res.token : null;
|
||||
} catch {
|
||||
this.backendStub = true;
|
||||
} finally {
|
||||
this.mcpSaving = false;
|
||||
}
|
||||
await this.loadMcpStatus();
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
/** Scope control (FR-MCP-3) — takes effect immediately, no restart needed. */
|
||||
async setMcpScope(expose: "none" | "selected" | "all", exposeRecordings?: boolean) {
|
||||
try {
|
||||
await api.setMcpScope(expose, exposeRecordings);
|
||||
} catch {
|
||||
this.backendStub = true;
|
||||
}
|
||||
await this.loadMcpStatus();
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
async loadLlmStatus() {
|
||||
try {
|
||||
this.llmStatus = await api.llmStatus();
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
Info,
|
||||
Bot,
|
||||
Copy,
|
||||
KeyRound,
|
||||
} from "@lucide/svelte";
|
||||
import {
|
||||
OLLAMA_OPTIONS,
|
||||
@@ -37,7 +40,7 @@
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
let section = $state<
|
||||
"recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy" | "about"
|
||||
"recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "mcp" | "privacy" | "about"
|
||||
>("recording");
|
||||
|
||||
// ---- About (version + build commit + source) ----
|
||||
@@ -83,6 +86,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MCP server (Phase 10b, ADR-0011) ----
|
||||
let mcpTransport = $state<"http" | "stdio">(
|
||||
settings.settings.mcp_transport === "stdio" ? "stdio" : "http",
|
||||
);
|
||||
let mcpPort = $state(settings.settings.mcp_port);
|
||||
let mcpExpose = $state<"none" | "selected" | "all">(
|
||||
(settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none",
|
||||
);
|
||||
let mcpExposeRecordings = $state(settings.settings.mcp_expose_recordings);
|
||||
let mcpCopied = $state<"endpoint" | "token" | null>(null);
|
||||
$effect(() => {
|
||||
// Re-sync the form when settings (re)load, same pattern as the LLM form above.
|
||||
mcpTransport = settings.settings.mcp_transport === "stdio" ? "stdio" : "http";
|
||||
mcpPort = settings.settings.mcp_port;
|
||||
mcpExpose = (settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none";
|
||||
mcpExposeRecordings = settings.settings.mcp_expose_recordings;
|
||||
});
|
||||
async function toggleMcpEnabled(enabled: boolean) {
|
||||
await settings.setMcpEnabled(enabled, mcpTransport, mcpPort);
|
||||
}
|
||||
async function saveMcpScope() {
|
||||
await settings.setMcpScope(mcpExpose, mcpExposeRecordings);
|
||||
}
|
||||
async function copyToClipboard(text: string, what: "endpoint" | "token") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
mcpCopied = what;
|
||||
setTimeout(() => {
|
||||
if (mcpCopied === what) mcpCopied = null;
|
||||
}, 2000);
|
||||
} catch {
|
||||
/* clipboard API unavailable — the value is still selectable/copyable by hand */
|
||||
}
|
||||
}
|
||||
function relativeTime(atMs: number): string {
|
||||
const diffSec = Math.round((Date.now() - atMs) / 1000);
|
||||
if (diffSec < 5) return "just now";
|
||||
if (diffSec < 60) return `${diffSec}s ago`;
|
||||
if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`;
|
||||
if (diffSec < 86400) return `${Math.round(diffSec / 3600)}h ago`;
|
||||
return new Date(atMs).toLocaleString();
|
||||
}
|
||||
|
||||
// ---- Advanced Ollama configuration (sparse; only overrides are stored) ----
|
||||
const OPTION_GROUPS: OllamaGroup[] = ["sampling", "repetition", "mirostat", "context"];
|
||||
type OptVal = number | boolean | string[];
|
||||
@@ -489,6 +535,9 @@
|
||||
<button class:active={section === "ai"} onclick={() => (section = "ai")}>
|
||||
<Sparkles size={14} aria-hidden="true" /> AI
|
||||
</button>
|
||||
<button class:active={section === "mcp"} onclick={() => (section = "mcp")}>
|
||||
<Bot size={14} aria-hidden="true" /> MCP server
|
||||
</button>
|
||||
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}>
|
||||
<ShieldCheck size={14} aria-hidden="true" /> Privacy
|
||||
</button>
|
||||
@@ -1191,6 +1240,146 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if section === "mcp"}
|
||||
<section>
|
||||
<h3>MCP server</h3>
|
||||
<div class="banner">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
<span>
|
||||
This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting
|
||||
context on your local machine. Once connected, <strong>that agent</strong> may forward
|
||||
what it reads to its own model provider's cloud — outside WhispAssist's control.
|
||||
WhispAssist itself never sends this data anywhere; the server only listens on this
|
||||
device (<code>127.0.0.1</code>) and every read is logged below.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.mcpStatus?.enabled ?? false}
|
||||
disabled={settings.mcpSaving}
|
||||
onchange={(e) => toggleMcpEnabled((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>Enable the MCP server</span>
|
||||
</label>
|
||||
<p class="muted">
|
||||
Off by default. Loopback-only, token-gated — nothing is reachable from the network.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<label
|
||||
>Transport
|
||||
<select bind:value={mcpTransport} disabled={settings.mcpStatus?.enabled}>
|
||||
<option value="http">Streamable HTTP</option>
|
||||
<option value="stdio">stdio (agent spawns a process)</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if mcpTransport === "http"}
|
||||
<label
|
||||
>Port<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
bind:value={mcpPort}
|
||||
disabled={settings.mcpStatus?.enabled}
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if settings.mcpStatus?.enabled}
|
||||
<p class="muted small">
|
||||
To change transport/port, turn the server off first, then back on.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<h4>Scope</h4>
|
||||
<div class="grid">
|
||||
<label
|
||||
>Expose
|
||||
<select bind:value={mcpExpose} onchange={saveMcpScope}>
|
||||
<option value="none">None — nothing is shared</option>
|
||||
<option value="selected">Selected — only feature briefs you've marked shared</option>
|
||||
<option value="all"
|
||||
>All — meetings, transcripts, action items, and shared briefs</option
|
||||
>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="row small">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={mcpExposeRecordings}
|
||||
disabled={mcpExpose === "none"}
|
||||
onchange={saveMcpScope}
|
||||
/>
|
||||
<span
|
||||
>Also allow meetings with a saved recording (off by default — a recorded meeting's
|
||||
transcript is withheld even in "All" scope until this is on)</span
|
||||
>
|
||||
</label>
|
||||
|
||||
{#if settings.mcpLastToken}
|
||||
<div class="mcp-token" aria-live="polite">
|
||||
<div class="row">
|
||||
<KeyRound size={14} aria-hidden="true" />
|
||||
<strong>New auth token — shown once, copy it now</strong>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
This won't be shown again. It's stored in your OS credential store; if you lose it,
|
||||
turn the server off and back on to mint a new one.
|
||||
</p>
|
||||
<div class="row">
|
||||
<code>{settings.mcpLastToken}</code>
|
||||
<button
|
||||
class="link"
|
||||
onclick={() => copyToClipboard(settings.mcpLastToken ?? "", "token")}
|
||||
>
|
||||
<Copy size={13} aria-hidden="true" />
|
||||
{mcpCopied === "token" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if settings.mcpStatus?.enabled}
|
||||
<div class="row">
|
||||
Endpoint<code>{settings.mcpStatus.endpoint}</code>
|
||||
<button
|
||||
class="link"
|
||||
onclick={() => copyToClipboard(settings.mcpStatus?.endpoint ?? "", "endpoint")}
|
||||
>
|
||||
<Copy size={13} aria-hidden="true" />
|
||||
{mcpCopied === "endpoint" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
Point your agent's MCP client config at this
|
||||
{settings.mcpStatus.transport === "stdio" ? "command" : "URL"}, with the token above as
|
||||
a bearer credential.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<h4>Access log</h4>
|
||||
<p class="muted">Every tool read an agent makes, allowed or denied (FR-MCP-5).</p>
|
||||
{#if settings.mcpAccessLog.length === 0}
|
||||
<p class="muted">No agent has read anything yet.</p>
|
||||
{:else}
|
||||
<ul class="events">
|
||||
{#each settings.mcpAccessLog as entry, i (entry.at + "-" + i)}
|
||||
<li>
|
||||
<code>{entry.tool}</code>
|
||||
{#if entry.meeting_id}<span class="host"
|
||||
>meeting {entry.meeting_id.slice(0, 8)}</span
|
||||
>{/if}
|
||||
{#if entry.client}<span class="badge">{entry.client}</span>{/if}
|
||||
<span class="host">{relativeTime(entry.at)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button class="link" onclick={() => settings.loadMcpAccessLog()}>Refresh</button>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if section === "privacy"}
|
||||
<section>
|
||||
<h3>Privacy</h3>
|
||||
@@ -1210,6 +1399,18 @@
|
||||
{settings.privacy.syncEnabled ? "enabled" : "off"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
MCP server<span class="badge" class:busy={settings.mcpStatus?.enabled}>
|
||||
{settings.mcpStatus?.enabled ? `on · ${settings.mcpStatus.exposeScope}` : "off"}
|
||||
</span>
|
||||
</div>
|
||||
{#if settings.mcpStatus?.enabled}
|
||||
<p class="muted confirm">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
Inbound on loopback only — it adds nothing to the egress list above. A connected agent may
|
||||
still forward what it reads to its own model provider; see the MCP server tab.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<h4>Egress allowlist</h4>
|
||||
{#if settings.privacy.allowlistedHosts.length === 0}
|
||||
@@ -1660,6 +1861,18 @@
|
||||
ul.hosts li {
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
.mcp-token {
|
||||
margin: 0.6rem 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--warning, #d97706);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.mcp-token code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---- AI provider: status + advanced config ---- */
|
||||
.ghost {
|
||||
|
||||
Reference in new issue
Block a user