mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 06:10:43 -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' },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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(','),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user