mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-17 22:00:40 -07:00
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:
co-authored by
Claude Opus 4.7
parent
87c582ad54
commit
0cef2c9fe1
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user