Feature chore bug 005 #19
@@ -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,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 +533,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 +1238,141 @@
|
||||
</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 +1392,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 +1854,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