feat(mcp): local MCP server exposes voicebox.* tools to AI agents

Mounts FastMCP at /mcp (Streamable HTTP) so Claude Code, Cursor,
Windsurf, and the VS Code MCP extensions can call voicebox.speak,
voicebox.transcribe, voicebox.list_captures, and voicebox.list_profiles
against the running Voicebox server.

Backend
- new backend/mcp_server package (tools, middleware, profile resolve,
  pub/sub events); named mcp_server to avoid shadowing the installed mcp
  PyPI package FastMCP imports internally
- app.py migrated from @app.on_event to lifespan= so FastMCP's session
  manager cohabits with Voicebox's startup/shutdown
- new MCPClientBinding table + /mcp/bindings CRUD; ClientIdMiddleware
  reads X-Voicebox-Client-Id into a ContextVar and stamps last_seen_at
- profile resolution precedence: explicit -> per-client binding ->
  capture_settings.default_playback_voice_id
- POST /speak REST wrapper for non-MCP callers (shell, ACP, A2A)
- GET /events/speak SSE broadcasts speak-start / speak-end so the pill
  surfaces agent-initiated speech
- backend/mcp_shim proxy (plain httpx) for stdio-only MCP clients
- PyInstaller spec updates + new --shim build target (~18 MB)

Frontend
- Settings -> MCP page with HTTP / stdio / claude-mcp-add copy snippets,
  default voice picker, per-client bindings table, connection status
- useMCPBindings, useSpeakEvents hooks
- CapturePill gains 'speaking' state; DictateWindow subscribes to SSE
  and emits dictate:show so the Rust side surfaces the pill window

Native
- tauri.conf.json externalBin now includes voicebox-mcp
- show_dictate_window helper + dictate:show listener in main.rs
- (also in this commit: InputMonitoringGate UX, hotkey_monitor tweaks,
  landing footer/navbar updates, new overview docs for captures /
  dictation / mcp-server / voice-personalities)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-22 22:05:30 -07:00
co-authored by Claude Opus 4.7
parent 87c582ad54
commit 0cef2c9fe1
52 changed files with 4094 additions and 336 deletions
@@ -10,6 +10,7 @@ export type PillState =
| 'recording'
| 'transcribing'
| 'refining'
| 'speaking'
| 'completed'
| 'rest'
| 'error';
@@ -18,13 +19,14 @@ const PILL_LABELS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'Recording',
transcribing: 'Transcribing',
refining: 'Refining',
speaking: 'Speaking',
completed: 'Done',
};
function barModeFor(
state: Exclude<PillState, 'error'>,
): 'generating' | 'playing' | 'idle' {
if (state === 'recording') return 'playing';
if (state === 'recording' || state === 'speaking') return 'playing';
if (state === 'completed' || state === 'rest') return 'idle';
return 'generating';
}
+75 -22
View File
@@ -46,6 +46,7 @@ import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
@@ -86,6 +87,30 @@ function snippetOf(capture: CaptureResponse): string {
return source.trim() || '(no transcript)';
}
function ChordKeys({ keys }: { keys: string[] }) {
if (keys.length === 0) return null;
return (
<div className="flex items-center gap-1">
{keys.map((k) => {
const side = modifierSideHint(k);
return (
<span
key={k}
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
>
{displayLabelForKey(k)}
{side ? (
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
})}
</div>
);
}
function SourceBadge({ source }: { source: CaptureSource }) {
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
const label = source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File';
@@ -160,6 +185,9 @@ export function CapturesTab() {
const { settings: captureSettings } = useCaptureSettings();
const sttModel = captureSettings?.stt_model ?? 'turbo';
const llmModel = captureSettings?.llm_model ?? '0.6B';
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
const session = useCaptureRecordingSession({
onCaptureCreated: (capture) => setSelectedId(capture.id),
@@ -394,17 +422,11 @@ export function CapturesTab() {
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground space-y-3">
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>No captures match "{search}"</p>
) : (
<>
<p>No captures yet.</p>
<Button variant="outline" size="sm" onClick={handleUploadClick}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Import audio
</Button>
</>
<p>No captures yet.</p>
)}
</div>
) : (
@@ -744,22 +766,53 @@ export function CapturesTab() {
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground pt-20">
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
{capturesLoading ? (
{capturesLoading ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">Loading captures</p>
) : captures.length ? (
</div>
) : captures.length ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">Pick a capture to see the transcript.</p>
) : (
<>
<p className="text-sm">No captures yet.</p>
<Button variant="outline" size="sm" onClick={handleUploadClick}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Import audio
</Button>
</>
)}
</div>
</div>
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
<div className="max-w-sm mx-auto text-center space-y-5">
<div className="space-y-2">
{pushToTalkKeys.length ? (
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={pushToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Hold to record
</span>
</div>
) : null}
{toggleToTalkKeys.length ? (
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={toggleToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Toggle hands-free
</span>
</div>
) : null}
</div>
<p className="text-sm">
Press the shortcut anywhere on your machine to start your first capture.
</p>
</div>
) : (
<div className="max-w-sm mx-auto text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">No captures yet.</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Turn on the global shortcut to dictate from anywhere or click
Dictate above for an in-app capture.
</p>
<Button asChild variant="outline" size="sm">
<Link to="/settings/captures">Open Captures settings</Link>
</Button>
</div>
)}
</div>
)}
</div>
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import type { FocusSnapshot } from '@/lib/api/types';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { useSpeakEvents } from '@/lib/hooks/useSpeakEvents';
/**
* Floating dictate surface shown in a separate transparent Tauri window.
@@ -82,27 +83,47 @@ export function DictateWindow() {
};
}, []);
// When the pill cycle ends, tell Rust to tuck the window away. The Rust
// side is responsible for the hide + park-off-screen + click-through
// combo because calling hide() directly from JS has been unreliable for
// transparent always-on-top windows on macOS. Showing is the reverse —
// the HotkeyMonitor restores position, clicks, and visibility when a
// chord next fires.
// Subscribe to agent-initiated speak events so the pill surfaces while
// voicebox.speak (MCP) or POST /speak is producing audio. We ask Rust
// to show the pill window by emitting `dictate:show` — the existing
// `dictate:hide` happens on cycle end below.
const speaking = useSpeakEvents();
const prevSpeakingIdRef = useRef<string | null>(null);
useEffect(() => {
if (session.pillState === 'hidden') {
const id = speaking?.generationId ?? null;
if (id && id !== prevSpeakingIdRef.current) {
emit('dictate:show').catch(() => {});
}
prevSpeakingIdRef.current = id;
}, [speaking?.generationId]);
// Compose the effective pill state: speak events override the capture
// session when both would render, because agent speech is always
// category-mattering (the user can't hear two pills). Elapsed time
// restarts for speaking so the timer reflects playback length.
const isSpeaking = Boolean(speaking);
const effectiveState = isSpeaking ? 'speaking' : session.pillState;
const effectiveElapsed = isSpeaking ? speaking!.elapsedMs : session.pillElapsedMs;
// When the pill cycle ends (no capture AND no speak), tell Rust to tuck
// the window away. Rust owns the hide + park-off-screen + click-through
// combo because calling hide() directly from JS has been unreliable for
// transparent always-on-top windows on macOS.
useEffect(() => {
if (effectiveState === 'hidden') {
emit('dictate:hide').catch(() => {});
}
}, [session.pillState]);
}, [effectiveState]);
return (
<div
className="h-screen w-screen flex items-center justify-center px-3"
style={{ background: 'transparent' }}
>
{session.pillState !== 'hidden' ? (
{effectiveState !== 'hidden' ? (
<CapturePill
state={session.pillState}
elapsedMs={session.pillElapsedMs}
state={effectiveState}
elapsedMs={effectiveElapsed}
errorMessage={session.errorMessage}
onDismiss={session.dismissError}
onStop={session.isRecording ? session.stopRecording : undefined}
@@ -0,0 +1,109 @@
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Tracks macOS Input Monitoring permission state. Without it, `rdev::listen`
* sees no key events and the chord engine never fires — but neither does
* anything error-out visibly, so we surface an inline prompt next to the
* hotkey toggle instead of leaving the user wondering why the shortcut is
* dead.
*
* Re-checked on mount and on window focus (cheap way to pick up the user
* flipping the toggle in System Settings and alt-tabbing back).
*/
export function useInputMonitoringPermission() {
const platform = usePlatform();
const [needsPermission, setNeedsPermission] = useState(false);
const [checking, setChecking] = useState(false);
const recheck = useCallback(async (): Promise<boolean> => {
if (!platform.metadata.isTauri) return true;
setChecking(true);
try {
const trusted = await invoke<boolean>('check_input_monitoring_permission');
setNeedsPermission(!trusted);
return trusted;
} catch (err) {
console.warn('[input-monitoring] check failed:', err);
return false;
} finally {
setChecking(false);
}
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
recheck();
const onFocus = () => {
recheck();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [platform.metadata.isTauri, recheck]);
const openSettings = useCallback(async () => {
try {
await invoke('open_input_monitoring_settings');
} catch (err) {
console.warn('[input-monitoring] open settings failed:', err);
}
}, []);
return { needsPermission, checking, recheck, openSettings };
}
/**
* Inline notice rendered under the global-shortcut toggle when the user has
* opted in but macOS Input Monitoring is not granted. Returns null when the
* permission is present (or when the toggle is off and the notice would just
* be noise).
*/
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
const { needsPermission, checking, recheck, openSettings } =
useInputMonitoringPermission();
const [stillMissing, setStillMissing] = useState(false);
const handleRecheck = useCallback(async () => {
setStillMissing(false);
const trusted = await recheck();
if (!trusted) setStillMissing(true);
}, [recheck]);
if (!enabled || !needsPermission) return null;
return (
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
Grant Input Monitoring to enable the global shortcut
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
Voicebox needs System Settings Privacy &amp; Security Input
Monitoring to detect your dictation chord. The toggle is on, but
macOS is blocking key events until you allow it.
</p>
<div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking…' : "I've enabled it"}
</Button>
</div>
{stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
Still not detected. macOS usually requires quitting and reopening
Voicebox after toggling the permission.
</p>
)}
</div>
</div>
</div>
);
}
+17 -9
View File
@@ -1,6 +1,7 @@
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
import { Button } from '@/components/ui/button';
@@ -140,13 +141,13 @@ export function CapturesPage() {
const preserveTechnical = settings?.preserve_technical ?? true;
const allowAutoPaste = settings?.allow_auto_paste ?? true;
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
const hotkeyEnabled = settings?.hotkey_enabled ?? false;
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? ['MetaRight', 'AltGr'];
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? ['MetaRight', 'AltGr', 'Space'];
// Mock-only settings — not yet wired to a backend. Keep local so the UI
// still responds while Phase 7 (hotkey / clipboard / paste) catches up.
const [archiveAudio, setArchiveAudio] = useState(true);
const [hotkeyEnabled, setHotkeyEnabled] = useState(true);
const [copyToClipboard, setCopyToClipboard] = useState(true);
const [retention, setRetention] = useState('forever');
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
@@ -162,14 +163,21 @@ export function CapturesPage() {
title="Dictation"
description="Capture from anywhere on your machine with a global shortcut."
>
<SettingRow
title="Global shortcut"
description="Hold the shortcut to record. Release to transcribe. Requires an Accessibility permission the first time you enable it."
htmlFor="hotkeyEnabled"
action={
<Toggle id="hotkeyEnabled" checked={hotkeyEnabled} onCheckedChange={setHotkeyEnabled} />
}
/>
<div>
<SettingRow
title="Global shortcut"
description="Hold the shortcut to record from anywhere on your machine. Release to transcribe. macOS will ask for Input Monitoring permission the first time you turn this on."
htmlFor="hotkeyEnabled"
action={
<Toggle
id="hotkeyEnabled"
checked={hotkeyEnabled}
onCheckedChange={(v) => update({ hotkey_enabled: v })}
/>
}
/>
<InputMonitoringNotice enabled={hotkeyEnabled} />
</div>
<SettingRow
title="Push-to-talk shortcut"
+324
View File
@@ -0,0 +1,324 @@
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useServerStore } from '@/stores/serverStore';
import { SettingRow, SettingSection } from './SettingRow';
/**
* Settings → MCP — configure per-agent voice binding and show copy-paste
* install snippets for major MCP clients. Backend runs at /mcp on the
* existing Voicebox server; this page is the agent-onboarding surface.
*/
export function MCPPage() {
const serverUrl = useServerStore((s) => s.serverUrl);
const { bindings, upsertAsync, remove } = useMCPBindings();
const { data: profiles } = useProfiles();
const { settings: captureSettings, update: updateCapture } = useCaptureSettings();
const defaultProfileId = captureSettings?.default_playback_voice_id ?? '';
const mcpUrl = `${serverUrl}/mcp`;
const [newClientId, setNewClientId] = useState('');
const [newLabel, setNewLabel] = useState('');
const [newProfileId, setNewProfileId] = useState('');
const [adding, setAdding] = useState(false);
const handleAdd = async () => {
if (!newClientId.trim()) return;
setAdding(true);
try {
await upsertAsync({
client_id: newClientId.trim(),
label: newLabel.trim() || null,
profile_id: newProfileId || null,
});
setNewClientId('');
setNewLabel('');
setNewProfileId('');
} finally {
setAdding(false);
}
};
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
<SettingSection
title="Install into your agent"
description="Voicebox exposes a local MCP server whenever the app is open. Paste one of these snippets into your agent's MCP config."
>
<SnippetRow
title="HTTP (recommended)"
description="For clients that speak HTTP MCP — Claude Code, Cursor, Windsurf, VS Code."
snippet={JSON.stringify(
{
mcpServers: {
voicebox: {
url: mcpUrl,
headers: { 'X-Voicebox-Client-Id': 'claude-code' },
},
},
},
null,
2,
)}
/>
<SnippetRow
title="Claude Code one-liner"
description="Registers via the Claude Code CLI."
snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`}
/>
<SnippetRow
title="Stdio (fallback)"
description="For clients that only spawn stdio processes. The shim binary ships with the app."
snippet={JSON.stringify(
{
mcpServers: {
voicebox: {
command:
'/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp',
env: { VOICEBOX_CLIENT_ID: 'claude-code' },
},
},
},
null,
2,
)}
/>
</SettingSection>
<SettingSection
title="Default voice"
description="Used when an agent calls voicebox.speak without a specific profile and has no per-client binding."
>
<SettingRow
title="Default playback voice"
description="Shared with the Captures-tab 'Play as voice' dropdown — one default voice for passive playback."
action={
<select
value={defaultProfileId}
onChange={(e) =>
updateCapture({
default_playback_voice_id: e.target.value || null,
})
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]"
>
<option value="">(none)</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
}
/>
</SettingSection>
<SettingSection
title="Per-agent voice"
description="Bind specific agents to specific voices so you can tell who's speaking without looking. The agent identifies itself by the X-Voicebox-Client-Id header (or VOICEBOX_CLIENT_ID env for stdio)."
>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 italic">
No bindings yet. Add one below, then configure your MCP client to
send the matching <code>X-Voicebox-Client-Id</code>.
</p>
) : (
<div className="divide-y divide-border/60">
{bindings.map((b) => (
<div
key={b.client_id}
className="py-3 grid grid-cols-[1fr_auto_auto] gap-4 items-center"
>
<div className="min-w-0">
<div className="font-medium text-sm truncate">
{b.label || b.client_id}
</div>
<div className="text-xs text-muted-foreground truncate">
<code className="text-[11px]">{b.client_id}</code>
{' · '}
{b.last_seen_at ? (
<span title={`Last seen ${b.last_seen_at}`}>
<Plug className="inline h-3 w-3 text-emerald-500" />{' '}
last seen {formatRelative(b.last_seen_at)}
</span>
) : (
<span>never connected</span>
)}
</div>
</div>
<select
value={b.profile_id ?? ''}
onChange={(e) =>
upsertAsync({
client_id: b.client_id,
label: b.label,
profile_id: e.target.value || null,
})
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]"
>
<option value="">(default)</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
size="icon"
variant="ghost"
onClick={() => remove(b.client_id)}
aria-label={`Remove binding for ${b.client_id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="pt-4 space-y-2">
<div className="text-sm font-medium">Add a binding</div>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<input
type="text"
placeholder="client id (e.g. claude-code)"
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<input
type="text"
placeholder="label (optional)"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<select
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
className="h-9 px-2 rounded-md border bg-background text-sm min-w-[140px]"
>
<option value="">(default)</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<Button
size="sm"
onClick={handleAdd}
disabled={!newClientId.trim() || adding}
>
Add binding
</Button>
</div>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">About MCP</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Model Context Protocol lets your AI coding agent Claude Code,
Cursor, Windsurf call Voicebox tools. Speak in a cloned voice,
transcribe audio, browse captures.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold">Available tools</h3>
<ul className="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<li>
<code className="text-accent">voicebox.speak</code>
<div>Speak text in a voice profile.</div>
</li>
<li>
<code className="text-accent">voicebox.transcribe</code>
<div>Whisper STT on a clip.</div>
</li>
<li>
<code className="text-accent">voicebox.list_captures</code>
<div>Recent dictations / recordings.</div>
</li>
<li>
<code className="text-accent">voicebox.list_profiles</code>
<div>Available voice profiles.</div>
</li>
</ul>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waypoints className="h-3.5 w-3.5 text-accent" />
<span>
Also exposed as <code>POST /speak</code> for shell scripts, ACP,
A2A.
</span>
</div>
</aside>
</div>
);
}
function SnippetRow({
title,
description,
snippet,
}: {
title: string;
description: string;
snippet: string;
}) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore; user can still select-and-copy the pre content
}
};
return (
<div className="py-3 space-y-2">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">{title}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<Button size="sm" variant="outline" onClick={copy}>
{copied ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
Copied
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</>
)}
</Button>
</div>
<pre className="text-[11px] font-mono p-3 rounded-md bg-muted/50 overflow-x-auto whitespace-pre-wrap break-all">
{snippet}
</pre>
</div>
);
}
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
const now = Date.now();
const diff = Math.max(0, now - then);
if (diff < 60_000) return 'just now';
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} min ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} h ago`;
return `${Math.floor(diff / 86400_000)} d ago`;
}
@@ -12,6 +12,7 @@ interface SettingsTab {
| '/settings'
| '/settings/generation'
| '/settings/captures'
| '/settings/mcp'
| '/settings/gpu'
| '/settings/logs'
| '/settings/changelog'
@@ -23,6 +24,7 @@ const tabs: SettingsTab[] = [
{ labelKey: 'settings.tabs.general', path: '/settings' },
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
{ label: 'Captures', path: '/settings/captures' },
{ label: 'MCP', path: '/settings/mcp' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
+24
View File
@@ -45,6 +45,9 @@ import type {
CaptureSource,
GenerationSettings,
GenerationSettingsUpdate,
MCPClientBinding,
MCPClientBindingListResponse,
MCPClientBindingUpsert,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -503,6 +506,27 @@ class ApiClient {
});
}
// MCP bindings — per-MCP-client voice/engine/intent mapping.
async listMCPBindings(): Promise<MCPClientBindingListResponse> {
return this.request<MCPClientBindingListResponse>('/mcp/bindings');
}
async upsertMCPBinding(
data: MCPClientBindingUpsert,
): Promise<MCPClientBinding> {
return this.request<MCPClientBinding>('/mcp/bindings', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteMCPBinding(clientId: string): Promise<{ deleted: string }> {
return this.request<{ deleted: string }>(
`/mcp/bindings/${encodeURIComponent(clientId)}`,
{ method: 'DELETE' },
);
}
// Model Management
async getModelStatus(): Promise<ModelStatusListResponse> {
return this.request<ModelStatusListResponse>('/models/status');
+28
View File
@@ -208,6 +208,9 @@ export interface CaptureSettings {
preserve_technical: boolean;
allow_auto_paste: boolean;
default_playback_voice_id: string | null;
/** Whether the global keyboard hotkey is armed. Off by default — turning
* this on triggers the macOS Input Monitoring TCC prompt. */
hotkey_enabled: boolean;
/** rdev::Key variant names. Defaults: ["MetaRight","AltGr"]. */
chord_push_to_talk_keys: string[];
/** rdev::Key variant names. Defaults: ["MetaRight","AltGr","Space"]. */
@@ -465,3 +468,28 @@ export interface ApplyEffectsRequest {
label?: string;
set_as_default?: boolean;
}
/* ─── MCP ─────────────────────────────────────────────────────────────── */
export interface MCPClientBinding {
client_id: string;
label: string | null;
profile_id: string | null;
default_engine: string | null;
default_intent: 'respond' | 'rewrite' | 'compose' | null;
last_seen_at: string | null;
created_at: string;
updated_at: string;
}
export interface MCPClientBindingUpsert {
client_id: string;
label?: string | null;
profile_id?: string | null;
default_engine?: string | null;
default_intent?: 'respond' | 'rewrite' | 'compose' | null;
}
export interface MCPClientBindingListResponse {
items: MCPClientBinding[];
}
+22 -13
View File
@@ -4,32 +4,41 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Push the user's saved chord into the running Rust `HotkeyMonitor`.
* The monitor boots with hard-coded right-hand defaults; this hook
* replaces them as soon as capture_settings resolves and re-applies on
* every subsequent change so chord edits land without a restart.
* Spawn (or quiet) the global hotkey monitor based on the saved
* `capture_settings.hotkey_enabled` flag, and keep its bindings in sync with
* the user's chord choices.
*
* Call once from the main app shell — multiple call sites would just
* fire redundant invokes, since the chord engine swap is the same value
* either way.
* Boot sequence:
* - hotkey_enabled = false → call `disable_hotkey` (no-op if monitor was
* never spawned). Crucially, we do *not* call `enable_hotkey`, so the
* macOS Input Monitoring TCC prompt is never triggered for users who
* haven't opted in.
* - hotkey_enabled = true → call `enable_hotkey` with the saved chords.
* This is the call that creates the CGEventTap and triggers the TCC
* prompt on first opt-in.
*
* Call once from the main app shell.
*/
export function useChordSync() {
const platform = usePlatform();
const { settings } = useCaptureSettings();
const enabled = settings?.hotkey_enabled;
const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_to_talk_keys;
useEffect(() => {
if (!platform.metadata.isTauri) return;
if (!pushKeys || !toggleKeys) return;
invoke('update_chord_bindings', {
pushToTalk: pushKeys,
toggleToTalk: toggleKeys,
}).catch((err) => {
console.warn('[chord-sync] failed to update bindings:', err);
if (enabled === undefined || !pushKeys || !toggleKeys) return;
const command = enabled ? 'enable_hotkey' : 'disable_hotkey';
const args = enabled
? { pushToTalk: pushKeys, toggleToTalk: toggleKeys }
: {};
invoke(command, args).catch((err) => {
console.warn(`[chord-sync] ${command} failed:`, err);
});
}, [
platform.metadata.isTauri,
enabled,
// Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch.
pushKeys?.join(','),
+60
View File
@@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type {
MCPClientBindingListResponse,
MCPClientBindingUpsert,
} from '@/lib/api/types';
const MCP_BINDINGS_KEY = ['settings', 'mcp', 'bindings'] as const;
/** Manage per-MCP-client voice bindings (Claude Code → Morgan, etc.). */
export function useMCPBindings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: MCP_BINDINGS_KEY,
queryFn: () => apiClient.listMCPBindings(),
// Keep fresh while the Settings page is open — the ``last_seen_at``
// timestamp is useful for confirming an install works, and we want it
// to tick forward when a client connects.
refetchInterval: 10_000,
});
const upsertMutation = useMutation({
mutationFn: (data: MCPClientBindingUpsert) =>
apiClient.upsertMCPBinding(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: MCP_BINDINGS_KEY });
},
});
const deleteMutation = useMutation({
mutationFn: (clientId: string) => apiClient.deleteMCPBinding(clientId),
onMutate: async (clientId) => {
await queryClient.cancelQueries({ queryKey: MCP_BINDINGS_KEY });
const prev =
queryClient.getQueryData<MCPClientBindingListResponse>(MCP_BINDINGS_KEY);
if (prev) {
queryClient.setQueryData<MCPClientBindingListResponse>(
MCP_BINDINGS_KEY,
{ items: prev.items.filter((b) => b.client_id !== clientId) },
);
}
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) queryClient.setQueryData(MCP_BINDINGS_KEY, ctx.prev);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: MCP_BINDINGS_KEY });
},
});
return {
bindings: query.data?.items ?? [],
isLoading: query.isLoading,
upsert: upsertMutation.mutate,
upsertAsync: upsertMutation.mutateAsync,
remove: deleteMutation.mutate,
};
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react';
import { useServerStore } from '@/stores/serverStore';
/** Payload for a speak-start SSE event broadcast by the backend. */
export interface ActiveSpeak {
generationId: string;
profileName: string;
source: 'mcp' | 'rest' | string;
clientId: string | null;
startedAt: number;
elapsedMs: number;
}
/**
* Subscribes to `/events/speak` and reports whichever agent-initiated
* speak is currently producing audio. Returns ``null`` when nothing is
* speaking.
*
* Multiple concurrent speaks are rare (the model can only really do one
* at a time) and we don't bother stacking them — newest wins, the old
* one's speak-end will clear when it fires.
*/
export function useSpeakEvents(): ActiveSpeak | null {
const [active, setActive] = useState<ActiveSpeak | null>(null);
const activeRef = useRef<ActiveSpeak | null>(null);
activeRef.current = active;
// Keep a live timer so the pill's elapsed label advances smoothly
// without re-opening the SSE stream.
const [, setTick] = useState(0);
useEffect(() => {
if (!active) return;
const iv = window.setInterval(() => setTick((t) => t + 1), 250);
return () => window.clearInterval(iv);
}, [active]);
useEffect(() => {
const baseUrl = useServerStore.getState().serverUrl;
if (!baseUrl) return;
let cancelled = false;
let source: EventSource | null = null;
const connect = () => {
if (cancelled) return;
source = new EventSource(`${baseUrl}/events/speak`);
source.addEventListener('speak-start', (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
const now = Date.now();
setActive({
generationId: String(data.generation_id ?? ''),
profileName: String(data.profile_name ?? ''),
source: String(data.source ?? 'mcp'),
clientId: data.client_id ?? null,
startedAt: now,
elapsedMs: 0,
});
} catch {
// malformed payload — ignore, don't crash the stream
}
});
source.addEventListener('speak-end', (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
const endedId = String(data.generation_id ?? '');
// Only clear if this end matches the currently-active id; late
// ends from previous sessions are ignored.
if (activeRef.current?.generationId === endedId) setActive(null);
} catch {
// ignore
}
});
source.onerror = () => {
// EventSource auto-reconnects, but if the browser gives up we
// manually retry with backoff.
source?.close();
if (!cancelled) window.setTimeout(connect, 2000);
};
};
connect();
return () => {
cancelled = true;
source?.close();
};
}, []);
if (!active) return null;
return { ...active, elapsedMs: Date.now() - active.startedAt };
}
+8
View File
@@ -17,6 +17,7 @@ import { GeneralPage } from '@/components/ServerTab/GeneralPage';
import { GenerationPage } from '@/components/ServerTab/GenerationPage';
import { GpuPage } from '@/components/ServerTab/GpuPage';
import { LogsPage } from '@/components/ServerTab/LogsPage';
import { MCPPage } from '@/components/ServerTab/MCPPage';
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
@@ -159,6 +160,12 @@ const settingsCapturesRoute = createRoute({
component: CapturesPage,
});
const settingsMCPRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/mcp',
component: MCPPage,
});
const settingsGpuRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/gpu',
@@ -204,6 +211,7 @@ const routeTree = rootRoute.addChildren([
settingsGeneralRoute,
settingsGenerationRoute,
settingsCapturesRoute,
settingsMCPRoute,
settingsGpuRoute,
settingsLogsRoute,
settingsChangelogRoute,