personality: bool API, i18n across the app

- Collapse intent tri-state (respond/rewrite/compose) to `personality: bool` on /generate, /speak, and voicebox.speak. Drop respond entirely; keep compose as a standalone button via /profiles/{id}/compose. Remove /rewrite, /respond, and /speak profile endpoints.
- FloatingGenerateBox: Wand2 persona toggle + Dices compose button appear when the selected profile has a personality. ProfileCard badges Wand2 alongside the effects Sparkles.
- MCP bindings: default_intent column → default_personality: bool. Migration drops the legacy column.
- i18n: en / ja / zh-CN / zh-TW translation files filled out and wired through the capture, server, and profile UI.

```ts
voicebox.speak({
  text: "Deploy complete.",
  profile: "Morgan",
  personality: true, // rewrite through the profile's personality LLM
});
```
This commit is contained in:
Jamie Pine
2026-04-23 17:31:23 -07:00
parent 7c50e189cb
commit abf5dfda8c
41 changed files with 2146 additions and 1193 deletions
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
@@ -81,6 +82,7 @@ export function useAccessibilityPermission() {
* already granted.
*/
export function AccessibilityNotice() {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
const [stillMissing, setStillMissing] = useState(false);
@@ -98,26 +100,23 @@ export function AccessibilityNotice() {
<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 Accessibility permission to enable auto-paste
{t('captures.permissions.accessibility.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
Voicebox needs System Settings Privacy &amp; Security Accessibility
to paste transcriptions into other apps. Your dictation still lands
in the Captures tab without it.
<Trans i18nKey="captures.permissions.accessibility.body" components={{ path: <span /> }} />
</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
{t('captures.permissions.accessibility.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking' : "I've enabled it"}
{checking ? t('captures.permissions.accessibility.rechecking') : t('captures.permissions.accessibility.recheck')}
</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.
{t('captures.permissions.accessibility.stillMissing')}
</p>
)}
</div>
+14 -10
View File
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils/cn';
/**
@@ -15,12 +16,12 @@ export type PillState =
| 'rest'
| 'error';
const PILL_LABELS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'Recording',
transcribing: 'Transcribing',
refining: 'Refining',
speaking: 'Speaking',
completed: 'Done',
const PILL_LABEL_KEYS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'captures.pill.recording',
transcribing: 'captures.pill.transcribing',
refining: 'captures.pill.refining',
speaking: 'captures.pill.speaking',
completed: 'captures.pill.completed',
};
function barModeFor(
@@ -87,10 +88,12 @@ export function CapturePill({
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
if (state === 'error') {
return (
<ErrorPill
message={errorMessage ?? 'Something went wrong'}
message={errorMessage ?? t('captures.pill.errorFallback')}
onDismiss={onDismiss}
className={className}
/>
@@ -98,7 +101,7 @@ export function CapturePill({
}
const visible = state !== 'rest';
const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state];
const labelText = t(state === 'rest' ? PILL_LABEL_KEYS.recording : PILL_LABEL_KEYS[state]);
const barMode = barModeFor(state);
const dot = (
@@ -114,7 +117,7 @@ export function CapturePill({
<button
type="button"
onClick={onStop}
aria-label="Stop recording"
aria-label={t('captures.pill.stopAria')}
className="relative flex h-2 w-2 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{dot}
@@ -161,6 +164,7 @@ function ErrorPill({
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
const handleClick = async () => {
try {
await navigator.clipboard.writeText(message);
@@ -175,7 +179,7 @@ function ErrorPill({
<button
type="button"
onClick={handleClick}
title="Click to copy error"
title={t('captures.pill.errorCopyTooltip')}
className={cn(
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
'bg-black/65 backdrop-blur-md text-red-300',
+67 -104
View File
@@ -20,6 +20,7 @@ import {
Volume2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import { Badge } from '@/components/ui/badge';
@@ -48,25 +49,12 @@ import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSessi
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
const diffMs = Date.now() - then;
const mins = Math.round(diffMs / 60_000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs} hr ago`;
const days = Math.round(hrs / 24);
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return new Date(iso).toLocaleDateString();
}
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000);
@@ -75,20 +63,6 @@ function formatDuration(ms?: number | null): string {
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function snippetOf(capture: CaptureResponse): string {
const source = capture.transcript_refined || capture.transcript_raw || '';
return source.trim() || '(no transcript)';
}
function ChordKeys({ keys }: { keys: string[] }) {
if (keys.length === 0) return null;
return (
@@ -114,8 +88,14 @@ function ChordKeys({ keys }: { keys: string[] }) {
}
function SourceBadge({ source }: { source: CaptureSource }) {
const { t } = useTranslation();
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
const label = source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File';
const label =
source === 'dictation'
? t('captures.source.dictation')
: source === 'recording'
? t('captures.source.recording')
: t('captures.source.file');
return (
<Badge
variant="secondary"
@@ -154,26 +134,18 @@ function FakeWaveform({ seed = 1, className }: { seed?: number; className?: stri
type PlaybackState = 'idle' | 'generating' | 'playing';
function voiceGradient(profileId: string): string {
const gradients = [
'from-blue-400 to-indigo-500',
'from-emerald-400 to-teal-500',
'from-purple-500 to-fuchsia-500',
'from-amber-400 to-rose-500',
'from-rose-400 to-pink-500',
'from-cyan-400 to-sky-500',
];
let hash = 0;
for (let i = 0; i < profileId.length; i++) hash = (hash * 31 + profileId.charCodeAt(i)) | 0;
return gradients[Math.abs(hash) % gradients.length];
}
export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const snippetOf = (capture: CaptureResponse): string => {
const source = capture.transcript_refined || capture.transcript_raw || '';
return source.trim() || t('captures.snippetEmpty');
};
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showRefined, setShowRefined] = useState(true);
@@ -278,14 +250,14 @@ export function CapturesTab() {
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
toast({ title: 'Delete failed', description: err.message, variant: 'destructive' });
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
},
});
const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error('Capture has no transcript yet');
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode;
// Preset profiles (Kokoro etc.) reject the qwen default — honor the
// profile's stored engine preference. Cloned profiles without an
@@ -308,14 +280,14 @@ export function CapturesTab() {
apiClient.getAudioUrl(result.id),
result.id,
voice.id,
`${voice.name} · ${capture.id.slice(0, 8)}`,
t('captures.playerVoiceLabel', { voice: voice.name, captureId: capture.id.slice(0, 8) }),
);
setPlaybackState('playing');
}
},
onError: (err: Error) => {
setPlaybackState('idle');
toast({ title: 'Play-as failed', description: err.message, variant: 'destructive' });
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
},
});
@@ -339,7 +311,7 @@ export function CapturesTab() {
apiClient.getCaptureAudioUrl(selected.id),
`capture-${selected.id}`,
null,
`Capture · ${formatDate(selected.created_at)}`,
t('captures.captureCardLabel', { when: formatAbsoluteDate(selected.created_at) }),
);
};
@@ -350,9 +322,9 @@ export function CapturesTab() {
: selected.transcript_raw;
try {
await navigator.clipboard.writeText(text || '');
toast({ title: 'Transcript copied' });
toast({ title: t('captures.toast.transcriptCopied') });
} catch {
toast({ title: 'Copy failed', variant: 'destructive' });
toast({ title: t('captures.toast.copyFailed'), variant: 'destructive' });
}
};
@@ -361,8 +333,8 @@ export function CapturesTab() {
const target = voice ?? playAsVoice;
if (!target) {
toast({
title: 'No voice profile',
description: 'Create a voice profile before using Play as.',
title: t('captures.toast.noVoice'),
description: t('captures.toast.noVoiceDescription'),
variant: 'destructive',
});
return;
@@ -395,17 +367,17 @@ export function CapturesTab() {
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center mb-3">
<h1 className="text-2xl px-4 font-bold">Captures</h1>
<h1 className="text-2xl px-4 font-bold">{t('captures.title')}</h1>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
Beta
{t('captures.beta')}
</Badge>
</div>
<div className="relative">
<Input
placeholder="Search transcripts..."
placeholder={t('captures.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -427,9 +399,9 @@ export function CapturesTab() {
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>No captures match "{search}"</p>
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>No captures yet.</p>
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
@@ -450,7 +422,7 @@ export function CapturesTab() {
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatRelative(capture.created_at)}
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
@@ -468,7 +440,7 @@ export function CapturesTab() {
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
Refined
{t('captures.transcript.refined')}
</Badge>
)}
</div>
@@ -490,9 +462,10 @@ export function CapturesTab() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span>
Whisper {sttModel.charAt(0).toUpperCase() + sttModel.slice(1)}
<span className="mx-1.5 text-muted-foreground/40">·</span>
Qwen3 · {llmModel}
{t('captures.header.modelSummary', {
stt: sttModel.charAt(0).toUpperCase() + sttModel.slice(1),
llm: llmModel,
})}
</span>
</div>
<div className="flex-1" />
@@ -510,7 +483,7 @@ export function CapturesTab() {
<Button variant="outline" asChild>
<Link to="/settings/captures">
<Settings2 className="mr-2 h-4 w-4" />
Configure
{t('captures.actions.configure')}
</Link>
</Button>
{readiness.allReady && (
@@ -524,7 +497,7 @@ export function CapturesTab() {
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? 'Uploading...' : 'Import'}
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
</Button>
)}
</>
@@ -542,12 +515,12 @@ export function CapturesTab() {
{session.isRecording ? (
<>
<Square className="h-4 w-4 mr-2 fill-current" />
Stop
{t('captures.actions.stop')}
</>
) : (
<>
<Mic className="h-4 w-4 mr-2" />
Dictate
{t('captures.actions.dictate')}
</>
)}
</Button>
@@ -564,7 +537,7 @@ export function CapturesTab() {
>
{/* Meta row */}
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
<span>{formatDate(selected.created_at)}</span>
<span>{formatAbsoluteDate(selected.created_at)}</span>
{selected.language && (
<>
<span className="text-muted-foreground/40">·</span>
@@ -611,7 +584,7 @@ export function CapturesTab() {
)}
>
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
Refined
{t('captures.transcript.refined')}
</button>
<button
type="button"
@@ -624,15 +597,15 @@ export function CapturesTab() {
)}
>
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
Raw
{t('captures.transcript.raw')}
</button>
</div>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{showRefined && selected.transcript_refined
? `Refined with Qwen3 · ${selected.llm_model ?? llmModel}`
? t('captures.transcript.refinedHint', { model: selected.llm_model ?? llmModel })
: selected.stt_model
? `Transcribed with Whisper ${selected.stt_model}`
? t('captures.transcript.rawHint', { model: selected.stt_model })
: null}
</span>
</div>
@@ -665,29 +638,24 @@ export function CapturesTab() {
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15',
)}
>
{playAsVoice && (
<div
className={cn(
'h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(playAsVoice.id),
playbackState === 'playing' && 'animate-pulse',
)}
/>
)}
{playbackState === 'generating' ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Generating
{t('captures.actions.playAsGenerating')}
</>
) : playbackState === 'playing' ? (
<>
<Square className="h-3 w-3 fill-current" />
Stop · {playAsVoice?.name ?? 'Voice'}
{playAsVoice
? t('captures.actions.playAsStop', { name: playAsVoice.name })
: t('captures.actions.playAsStopFallback')}
</>
) : (
<>
<Volume2 className="h-3.5 w-3.5" />
{playAsVoice ? `Play as ${playAsVoice.name}` : 'Play as…'}
{playAsVoice
? t('captures.actions.playAs', { name: playAsVoice.name })
: t('captures.actions.playAsFallback')}
</>
)}
</Button>
@@ -708,21 +676,15 @@ export function CapturesTab() {
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Play transcript as
{t('captures.actions.playAsDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profiles?.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => handlePlayAs(v)}
className="gap-2.5 py-2"
className="py-2"
>
<div
className={cn(
'h-7 w-7 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(v.id),
)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate">
@@ -739,7 +701,7 @@ export function CapturesTab() {
</div>
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
{t('captures.actions.copy')}
</Button>
<Button
variant="outline"
@@ -752,11 +714,13 @@ export function CapturesTab() {
) : (
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
)}
{selected.transcript_refined ? 'Re-refine' : 'Refine'}
{selected.transcript_refined
? t('captures.actions.reRefine')
: t('captures.actions.refine')}
</Button>
<Button variant="outline" size="sm" disabled>
<Send className="h-3.5 w-3.5 mr-1.5" />
Send to
{t('captures.actions.sendTo')}
</Button>
<div className="flex-1" />
<Button
@@ -771,7 +735,7 @@ export function CapturesTab() {
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
Delete
{t('captures.actions.delete')}
</Button>
</div>
</div>
@@ -780,12 +744,12 @@ export function CapturesTab() {
{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>
<p className="text-sm">{t('captures.empty.loading')}</p>
</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">{t('captures.empty.pickOne')}</p>
</div>
) : hotkeyEnabled && !readiness.allReady ? (
<DictationReadinessChecklist readiness={readiness} />
@@ -796,7 +760,7 @@ export function CapturesTab() {
<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
{t('captures.empty.holdToRecord')}
</span>
</div>
) : null}
@@ -804,25 +768,24 @@ export function CapturesTab() {
<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
{t('captures.empty.toggleHandsFree')}
</span>
</div>
) : null}
</div>
<p className="text-sm">
Press the shortcut anywhere on your machine to start your first capture.
{t('captures.empty.pressShortcut')}
</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-sm">{t('captures.empty.none')}</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.
{t('captures.empty.turnOnShortcut')}
</p>
<Button asChild variant="outline" size="sm">
<Link to="/settings/captures">Open Captures settings</Link>
<Link to="/settings/captures">{t('captures.empty.openSettings')}</Link>
</Button>
</div>
)}
@@ -10,6 +10,7 @@ import {
Loader2,
} from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
@@ -74,6 +75,7 @@ function progressPercent(task: ActiveDownloadTask | undefined): number | null {
* "stuck pill" failure mode of pressing the chord with a missing model.
*/
export function DictationReadinessChecklist({ readiness }: { readiness: DictationReadiness }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
@@ -123,13 +125,13 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
const displayName =
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
toast({
title: 'Download started',
description: `${displayName} is downloading. The shortcut will arm itself when it finishes.`,
title: t('captures.readiness.downloadStarted'),
description: t('captures.readiness.downloadStartedDescription', { name: displayName }),
});
},
onError: (err: Error) => {
toast({
title: 'Download failed',
title: t('captures.readiness.downloadFailed'),
description: err.message,
variant: 'destructive',
});
@@ -159,12 +161,14 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
{downloading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{pct != null ? `Downloading… ${pct}%` : 'Downloading…'}
{pct != null
? t('captures.readiness.downloadingPercent', { pct })
: t('captures.readiness.downloading')}
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Download
{t('captures.readiness.downloadButton')}
</>
)}
</Button>
@@ -175,21 +179,23 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
<div className="w-full max-w-md mx-auto space-y-2.5">
<div className="text-center mb-5 space-y-1">
<h2 className="text-base font-semibold text-foreground">
A few things before you can dictate
{t('captures.readiness.title')}
</h2>
<p className="text-xs text-muted-foreground">
The shortcut stays off until everything below is ready.
{t('captures.readiness.subheading')}
</p>
</div>
{readiness.stt && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.stt.display_name} (speech-to-text)`}
title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
description={
readiness.stt.ready
? 'Model downloaded.'
: `Needed to transcribe your audio${sttSize ? ` · ${sttSize}` : ''}.`
? t('captures.readiness.stt.ready')
: sttSize
? t('captures.readiness.stt.missingWithSize', { size: sttSize })
: t('captures.readiness.stt.missing')
}
ready={readiness.stt.ready}
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
@@ -199,11 +205,13 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
{readiness.llm && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.llm.display_name} (refinement)`}
title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
description={
readiness.llm.ready
? 'Model downloaded.'
: `Cleans up the raw transcript before paste${llmSize ? ` · ${llmSize}` : ''}.`
? t('captures.readiness.llm.ready')
: llmSize
? t('captures.readiness.llm.missingWithSize', { size: llmSize })
: t('captures.readiness.llm.missing')
}
ready={readiness.llm.ready}
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
@@ -212,34 +220,34 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
<ChecklistRow
icon={<Keyboard className="h-3.5 w-3.5" />}
title="Input Monitoring permission"
title={t('captures.readiness.inputMonitoring.label')}
description={
readiness.inputMonitoring
? 'macOS allows Voicebox to detect your global shortcut.'
: 'macOS needs to allow Voicebox to detect the global shortcut.'
? t('captures.readiness.inputMonitoring.ready')
: t('captures.readiness.inputMonitoring.missing')
}
ready={readiness.inputMonitoring}
action={
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
{t('captures.readiness.inputMonitoring.openSettings')}
</Button>
}
/>
<ChecklistRow
icon={<Accessibility className="h-3.5 w-3.5" />}
title="Accessibility permission"
title={t('captures.readiness.accessibility.label')}
description={
readiness.accessibility
? 'Voicebox can paste transcriptions into other apps.'
: 'Required so transcriptions can paste into the focused app.'
? t('captures.readiness.accessibility.ready')
: t('captures.readiness.accessibility.missing')
}
ready={readiness.accessibility}
action={
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
{t('captures.readiness.accessibility.openSettings')}
</Button>
}
/>
@@ -1,5 +1,6 @@
import { Keyboard } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -47,6 +48,7 @@ export function ChordPicker({
onSave,
onCancel,
}: ChordPickerProps) {
const { t } = useTranslation();
// Currently held set, peak set captured this session, and "is the user
// mid-chord?". We freeze the peak when they release everything so the
// Save button can read a stable value.
@@ -153,12 +155,12 @@ export function ChordPicker({
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Keyboard className="h-3.5 w-3.5" />
{pressed.size > 0 ? 'Capturing' : 'Press your shortcut'}
{pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
</div>
<div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]">
{displayKeys.length === 0 ? (
<span className="text-sm text-muted-foreground italic">
No keys yet
{t('captures.chord.noKeys')}
</span>
) : (
displayKeys.map((k) => <ChordKey key={k} name={k} />)
@@ -166,8 +168,7 @@ export function ChordPicker({
</div>
{unsupportedAttempt ? (
<p className="text-xs text-destructive">
"{unsupportedAttempt}" isn't supported in chords. Try a modifier
or letter key.
{t('captures.chord.unsupported', { key: unsupportedAttempt })}
</p>
) : null}
</div>
@@ -175,10 +176,10 @@ export function ChordPicker({
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={() => onSave(captured)} disabled={!canSave}>
Save
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
import { Dices, Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
@@ -52,6 +53,21 @@ export function FloatingGenerateBox({
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId);
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
const { toast } = useToast();
const composeMutation = useMutation({
mutationFn: async () => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.composeWithPersonality(selectedProfileId);
},
onError: (err: Error) => {
toast({
title: t('generation.compose.failedTitle'),
description: err.message || t('generation.compose.failedDescription'),
variant: 'destructive',
});
},
});
// Fetch effect presets for the dropdown
const { data: effectPresets } = useQuery({
@@ -175,6 +191,10 @@ export function FloatingGenerateBox({
) {
setSelectedPresetId(null);
}
// Persona toggle only applies when the profile has a personality prompt.
if (selectedProfile && !selectedProfile.personality?.trim()) {
form.setValue('personality', false);
}
}, [selectedProfile, effectPresets, form]);
// Auto-resize textarea based on content (only when expanded)
@@ -331,35 +351,90 @@ export function FloatingGenerateBox({
/>
</motion.div>
<div className="relative shrink-0">
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')}
</span>
</div>
<div className="flex items-start gap-2 shrink-0">
{/* Compose — fills the textarea with a fresh in-character line. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
disabled={composeMutation.isPending || !selectedProfileId}
onClick={async () => {
const result = await composeMutation.mutateAsync();
form.setValue('text', result.text, { shouldDirty: true });
setIsExpanded(true);
}}
className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
aria-label={t('generation.compose.ariaLabel')}
>
{composeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Dices className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{t('generation.compose.tooltip')}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Persona — rewrite input through the profile's personality LLM before TTS. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<FormField
control={form.control}
name="personality"
render={({ field }) => {
const active = !!field.value;
return (
<FormItem className="space-y-0">
<FormControl>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => field.onChange(!active)}
className={cn(
'h-10 w-10 rounded-full transition-all duration-200',
active
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: 'bg-card border border-border hover:bg-background/50',
)}
aria-label={active ? t('generation.persona.ariaLabelActive') : t('generation.persona.ariaLabelInactive')}
aria-pressed={active}
>
<Wand2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')}
</span>
</div>
</FormControl>
</FormItem>
);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
<AnimatePresence>
@@ -369,7 +444,6 @@ export function FloatingGenerateBox({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<div className="group relative">
<Button
@@ -399,6 +473,35 @@ export function FloatingGenerateBox({
</motion.div>
)}
</AnimatePresence>
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')}
</span>
</div>
</div>
</div>
@@ -463,6 +566,7 @@ export function FloatingGenerateBox({
</div>
)}
<FormField
control={form.control}
name="language"
@@ -1,315 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { Loader2, Mic, RefreshCw, Sparkles } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import {
applyEngineSelection,
EngineModelSelector,
getEngineDescription,
} from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
function getEngineSelectValue(engine: string): string {
if (engine === 'qwen') return 'qwen:1.7B';
if (engine === 'qwen_custom_voice') return 'qwen_custom_voice:1.7B';
if (engine === 'tada') return 'tada:1B';
return engine;
}
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { toast } = useToast();
const { form, handleSubmit, isPending } = useGenerationForm();
useEffect(() => {
if (!selectedProfile) {
return;
}
if (selectedProfile.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
const preferredEngine = selectedProfile.default_engine || selectedProfile.preset_engine;
if (preferredEngine) {
applyEngineSelection(form, getEngineSelectValue(preferredEngine));
}
}, [form, selectedProfile]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
// ── Personality-driven text generation ─────────────────────────────
// Compose fills the empty textarea with a fresh in-character line.
// Rewrite restates whatever's in the textarea in the profile's voice.
// Both buttons hide entirely when the selected profile has no
// personality set — nothing to drive the LLM with otherwise.
const personality = selectedProfile?.personality?.trim() || '';
const hasPersonality = personality.length > 0;
const currentText = form.watch('text');
const textHasContent = (currentText || '').trim().length > 0;
const composeMutation = useMutation({
mutationFn: async () => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.composeWithPersonality(selectedProfileId);
},
onSuccess: (result) => {
form.setValue('text', result.text, { shouldDirty: true, shouldValidate: true });
},
onError: (err: Error) => {
toast({
title: 'Compose failed',
description: err.message || 'Could not generate text from this personality.',
variant: 'destructive',
});
},
});
const rewriteMutation = useMutation({
mutationFn: async (text: string) => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.rewriteWithPersonality(selectedProfileId, text);
},
onSuccess: (result) => {
form.setValue('text', result.text, { shouldDirty: true, shouldValidate: true });
},
onError: (err: Error) => {
toast({
title: 'Rewrite failed',
description: err.message || 'Could not rewrite the text in this voice.',
variant: 'destructive',
});
},
});
return (
<Card>
<CardHeader>
<CardTitle>Generate Speech</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Voice Profile</FormLabel>
{selectedProfile ? (
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
<Mic className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{selectedProfile.name}</span>
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
</div>
) : (
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
Click on a profile card above to select a voice profile
</div>
)}
</div>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>Text to Speak</FormLabel>
<FormControl>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder="Enter text... type / for effects like [laugh], [sigh]"
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
/>
) : (
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[150px]"
{...field}
/>
)}
</FormControl>
<FormDescription>
{form.watch('engine') === 'chatterbox_turbo'
? 'Max 5000 characters. Type / to insert sound effects.'
: 'Max 5000 characters'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{hasPersonality && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={
!selectedProfileId ||
textHasContent ||
composeMutation.isPending ||
rewriteMutation.isPending
}
onClick={() => composeMutation.mutate()}
>
{composeMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-2 h-3.5 w-3.5" />
)}
Compose
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={
!selectedProfileId ||
!textHasContent ||
rewriteMutation.isPending ||
composeMutation.isPending
}
onClick={() => rewriteMutation.mutate(currentText || '')}
>
{rewriteMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-2 h-3.5 w-3.5" />
)}
Rewrite in voice
</Button>
<span className="text-xs text-muted-foreground">
Uses the profile's personality.
</span>
</div>
)}
{form.watch('engine') === 'qwen_custom_voice' && (
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion,
pace). Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-4 md:grid-cols-3">
<FormItem>
<FormLabel>Model</FormLabel>
<EngineModelSelector form={form} selectedProfile={selectedProfile} />
<FormDescription>
{getEngineDescription(form.watch('engine') || 'qwen')}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="language"
render={({ field }) => {
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
return (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="seed"
render={({ field }) => (
<FormItem>
<FormLabel>Seed (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Random"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormDescription>For reproducible results</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
'Generate Speech'
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
@@ -62,6 +63,7 @@ export function useInputMonitoringPermission() {
* be noise).
*/
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } =
useInputMonitoringPermission();
const [stillMissing, setStillMissing] = useState(false);
@@ -80,26 +82,23 @@ export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
<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
{t('captures.permissions.inputMonitoring.title')}
</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.
<Trans i18nKey="captures.permissions.inputMonitoring.body" components={{ path: <span /> }} />
</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
{t('captures.permissions.inputMonitoring.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking' : "I've enabled it"}
{checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
</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.
{t('captures.permissions.inputMonitoring.stillMissing')}
</p>
)}
</div>
+103 -90
View File
@@ -1,5 +1,6 @@
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
@@ -50,8 +51,9 @@ function voiceGradient(voiceId: string): string {
}
function ChordPreview({ keys }: { keys: string[] }) {
const { t } = useTranslation();
if (keys.length === 0) {
return <span className="text-xs text-muted-foreground italic">Not set</span>;
return <span className="text-xs text-muted-foreground italic">{t('captures.chord.notSet')}</span>;
}
return (
<div className="flex items-center gap-1">
@@ -132,6 +134,7 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
}
export function CapturesPage() {
const { t } = useTranslation();
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const { toast } = useToast();
@@ -164,13 +167,13 @@ export function CapturesPage() {
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
<SettingSection
title="Dictation"
description="Capture from anywhere on your machine with a global shortcut."
title={t('settings.captures.dictation.title')}
description={t('settings.captures.dictation.description')}
>
<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."
title={t('settings.captures.dictation.globalShortcut.title')}
description={t('settings.captures.dictation.globalShortcut.description')}
htmlFor="hotkeyEnabled"
action={
<Toggle
@@ -195,10 +198,11 @@ export function CapturesPage() {
.filter(Boolean)
.join(' and ');
toast({
title: 'Shortcut on, but not yet armed',
description: `${names} still need${
missingModels.length === 1 ? 's' : ''
} to download. Open the Captures tab to start.`,
title: t('captures.toast.shortcutNotArmed'),
description: t('captures.toast.shortcutNotArmedDescription', {
names,
count: missingModels.length,
}),
});
}}
/>
@@ -208,8 +212,8 @@ export function CapturesPage() {
</div>
<SettingRow
title="Push-to-talk shortcut"
description="Hold these keys anywhere on your system to record. Release to stop and transcribe."
title={t('settings.captures.dictation.pushToTalk.title')}
description={t('settings.captures.dictation.pushToTalk.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={pushToTalkKeys} />
@@ -220,15 +224,15 @@ export function CapturesPage() {
onClick={() => setChordEditor('push')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
Change
{t('settings.captures.dictation.pushToTalk.change')}
</Button>
</div>
}
/>
<SettingRow
title="Toggle shortcut"
description="Press once to start a hands-free recording. Press again to stop. Usually push-to-talk plus Space."
title={t('settings.captures.dictation.toggle.title')}
description={t('settings.captures.dictation.toggle.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={toggleToTalkKeys} />
@@ -239,7 +243,7 @@ export function CapturesPage() {
onClick={() => setChordEditor('toggle')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
Change
{t('settings.captures.dictation.toggle.change')}
</Button>
</div>
}
@@ -247,8 +251,8 @@ export function CapturesPage() {
<ChordPicker
open={chordEditor === 'push'}
title="Set push-to-talk shortcut"
description="Hold the keys you want to use, then release and click Save. The right-hand modifier badge shows whether a key is the left or right variant."
title={t('settings.captures.dictation.chordPicker.pttTitle')}
description={t('settings.captures.dictation.chordPicker.pttDescription')}
initialKeys={pushToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
@@ -259,8 +263,8 @@ export function CapturesPage() {
<ChordPicker
open={chordEditor === 'toggle'}
title="Set toggle shortcut"
description="Hold the keys you want to use, then release and click Save. Pick something distinct from your push-to-talk chord."
title={t('settings.captures.dictation.chordPicker.toggleTitle')}
description={t('settings.captures.dictation.chordPicker.toggleDescription')}
initialKeys={toggleToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
@@ -270,15 +274,15 @@ export function CapturesPage() {
/>
<SettingRow
title="Preview"
description="What appears on screen while you're holding the shortcut."
title={t('settings.captures.dictation.preview.title')}
description={t('settings.captures.dictation.preview.description')}
>
<HotkeyPillPreview enabled={hotkeyEnabled} />
</SettingRow>
<SettingRow
title="Copy transcript to clipboard"
description="The cleaned transcript lands on your clipboard when the capture finishes."
title={t('settings.captures.dictation.copyToClipboard.title')}
description={t('settings.captures.dictation.copyToClipboard.description')}
htmlFor="copyToClipboard"
action={
<Toggle
@@ -292,8 +296,8 @@ export function CapturesPage() {
<div>
<SettingRow
title="Auto-paste into focused text field"
description="If a text input is focused in another app, paste directly into it. Voicebox saves and restores whatever was on your clipboard."
title={t('settings.captures.dictation.autoPaste.title')}
description={t('settings.captures.dictation.autoPaste.description')}
htmlFor="autoPaste"
action={
<Toggle
@@ -309,12 +313,12 @@ export function CapturesPage() {
</SettingSection>
<SettingSection
title="Transcription"
description="Pick which speech-to-text model runs on your captures."
title={t('settings.captures.transcription.title')}
description={t('settings.captures.transcription.description')}
>
<SettingRow
title="Transcription model"
description="Whisper ships with Voicebox and runs entirely on your machine."
title={t('settings.captures.transcription.model.title')}
description={t('settings.captures.transcription.model.description')}
action={
<Select
value={sttModel}
@@ -324,16 +328,20 @@ export function CapturesPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base">Whisper Base · 74M · Fast</SelectItem>
<SelectItem value="small">Whisper Small · 244M · Balanced</SelectItem>
<SelectItem value="base">
{t('settings.captures.transcription.model.base', { tail: t('settings.captures.transcription.model.tail.fast') })}
</SelectItem>
<SelectItem value="small">
{t('settings.captures.transcription.model.small', { tail: t('settings.captures.transcription.model.tail.balanced') })}
</SelectItem>
<SelectItem value="medium">
Whisper Medium · 769M · Higher accuracy
{t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
</SelectItem>
<SelectItem value="large">
Whisper Large · 1.5B · Best accuracy
{t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
</SelectItem>
<SelectItem value="turbo">
Whisper Turbo · Pruned Large v3 · Near-best, fast
{t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
</SelectItem>
</SelectContent>
</Select>
@@ -341,42 +349,42 @@ export function CapturesPage() {
/>
<SettingRow
title="Language"
description="Auto-detect works for most captures. Lock it if you're always speaking the same language."
title={t('settings.captures.transcription.language.title')}
description={t('settings.captures.transcription.language.description')}
action={
<Select value={language} onValueChange={(v) => update({ language: v })}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
<SelectItem value="ja">Japanese</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
<SelectItem value="hi">Hindi</SelectItem>
<SelectItem value="auto">{t('settings.captures.transcription.language.auto')}</SelectItem>
<SelectItem value="en">{t('settings.captures.transcription.language.en')}</SelectItem>
<SelectItem value="es">{t('settings.captures.transcription.language.es')}</SelectItem>
<SelectItem value="fr">{t('settings.captures.transcription.language.fr')}</SelectItem>
<SelectItem value="de">{t('settings.captures.transcription.language.de')}</SelectItem>
<SelectItem value="ja">{t('settings.captures.transcription.language.ja')}</SelectItem>
<SelectItem value="zh">{t('settings.captures.transcription.language.zh')}</SelectItem>
<SelectItem value="hi">{t('settings.captures.transcription.language.hi')}</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Archive audio"
description="Keep the original recording alongside every transcript."
title={t('settings.captures.transcription.archive.title')}
description={t('settings.captures.transcription.archive.description')}
htmlFor="archiveAudio"
action={<Toggle id="archiveAudio" checked={archiveAudio} onCheckedChange={setArchiveAudio} />}
/>
</SettingSection>
<SettingSection
title="Refinement"
description="Optionally run a local LLM over transcripts to clean filler words, punctuation, and self-corrections."
title={t('settings.captures.refinement.title')}
description={t('settings.captures.refinement.description')}
>
<SettingRow
title="Refine transcripts automatically"
description="Runs after every capture. You can still toggle between raw and refined in the Captures tab."
title={t('settings.captures.refinement.auto.title')}
description={t('settings.captures.refinement.auto.description')}
htmlFor="autoRefine"
action={
<Toggle
@@ -388,8 +396,8 @@ export function CapturesPage() {
/>
<SettingRow
title="Refinement model"
description="Larger models are slower but handle subtle self-corrections and technical vocabulary better."
title={t('settings.captures.refinement.model.title')}
description={t('settings.captures.refinement.model.description')}
action={
<Select
value={llmModel}
@@ -400,17 +408,23 @@ export function CapturesPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.6B">Qwen3 · 0.6B · 400 MB · Very fast</SelectItem>
<SelectItem value="1.7B">Qwen3 · 1.7B · 1.1 GB · Fast</SelectItem>
<SelectItem value="4B">Qwen3 · 4B · 2.5 GB · Full quality</SelectItem>
<SelectItem value="0.6B">
{t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
</SelectItem>
<SelectItem value="1.7B">
{t('settings.captures.refinement.model.size17', { tail: t('settings.captures.refinement.model.tail.fast') })}
</SelectItem>
<SelectItem value="4B">
{t('settings.captures.refinement.model.size4', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Smart cleanup"
description="Remove filler words (um, uh, like), restore punctuation, and fix capitalization without rephrasing."
title={t('settings.captures.refinement.smartCleanup.title')}
description={t('settings.captures.refinement.smartCleanup.description')}
htmlFor="smartCleanup"
action={
<Toggle
@@ -423,8 +437,8 @@ export function CapturesPage() {
/>
<SettingRow
title="Remove self-corrections"
description={'When you change your mind mid-sentence ("actually, no...", "wait, I meant..."), drop the retracted part and keep the final intent.'}
title={t('settings.captures.refinement.selfCorrection.title')}
description={t('settings.captures.refinement.selfCorrection.description')}
htmlFor="selfCorrection"
action={
<Toggle
@@ -437,8 +451,8 @@ export function CapturesPage() {
/>
<SettingRow
title="Preserve technical terms"
description="Keep code identifiers, command names, and acronyms exactly as spoken. Turn on when you dictate into a code prompt."
title={t('settings.captures.refinement.preserveTechnical.title')}
description={t('settings.captures.refinement.preserveTechnical.description')}
htmlFor="preserveTechnical"
action={
<Toggle
@@ -452,12 +466,12 @@ export function CapturesPage() {
</SettingSection>
<SettingSection
title="Playback"
description='Default voice for the "Play as" action in the Captures tab.'
title={t('settings.captures.playback.title')}
description={t('settings.captures.playback.description')}
>
<SettingRow
title="Default voice"
description="Used when you click Play as without picking a voice first. You can change it per capture."
title={t('settings.captures.playback.defaultVoice.title')}
description={t('settings.captures.playback.defaultVoice.description')}
action={
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -480,7 +494,9 @@ export function CapturesPage() {
</>
) : (
<span className="truncate text-muted-foreground">
{voices.length === 0 ? 'No cloned voices yet' : 'None selected'}
{voices.length === 0
? t('settings.captures.playback.defaultVoice.noClonedVoices')
: t('settings.captures.playback.defaultVoice.noneSelected')}
</span>
)}
</div>
@@ -489,7 +505,7 @@ export function CapturesPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Cloned voices
{t('settings.captures.playback.defaultVoice.clonedVoices')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{voices.map((v) => (
@@ -522,30 +538,30 @@ export function CapturesPage() {
</SettingSection>
<SettingSection
title="Storage"
description="Captures are saved as paired audio and transcript files in your Voicebox data directory."
title={t('settings.captures.storage.title')}
description={t('settings.captures.storage.description')}
>
<SettingRow
title="Retention"
description="How long to keep captures. Applies to both audio and transcripts."
title={t('settings.captures.storage.retention.title')}
description={t('settings.captures.storage.retention.description')}
action={
<Select value={retention} onValueChange={setRetention}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="forever">Keep forever</SelectItem>
<SelectItem value="90d">90 days</SelectItem>
<SelectItem value="30d">30 days</SelectItem>
<SelectItem value="7d">7 days</SelectItem>
<SelectItem value="forever">{t('settings.captures.storage.retention.forever')}</SelectItem>
<SelectItem value="90d">{t('settings.captures.storage.retention.d90')}</SelectItem>
<SelectItem value="30d">{t('settings.captures.storage.retention.d30')}</SelectItem>
<SelectItem value="7d">{t('settings.captures.storage.retention.d7')}</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Clear all captures"
description="Permanently delete every capture and its audio. This cannot be undone."
title={t('settings.captures.storage.clearAll.title')}
description={t('settings.captures.storage.clearAll.description')}
action={
<Button
variant="outline"
@@ -553,7 +569,7 @@ export function CapturesPage() {
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Clear captures
{t('settings.captures.storage.clearAll.action')}
</Button>
}
/>
@@ -562,41 +578,38 @@ export function CapturesPage() {
<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 Captures</h3>
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Hold a shortcut anywhere on your machine, speak, and Voicebox turns
your voice into text. Replay it in any cloned voice, paste it into
any app, or pipe it into your coding agent.
{t('settings.captures.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">What's different</h3>
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.differencesTitle')}</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Fully local.</span>{' '}
Whisper and the refinement LLM run on your hardware. No cloud,
no accounts, your voice never leaves the machine.
<span className="text-foreground font-medium">{t('settings.captures.sidebar.local.title')}</span>{' '}
{t('settings.captures.sidebar.local.body')}
</span>
</li>
<li className="flex gap-2.5">
<Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
Play as any voice.
{t('settings.captures.sidebar.playAs.title')}
</span>{' '}
Transcripts can be read back in any profile you've cloned.
{t('settings.captures.sidebar.playAs.body')}
</span>
</li>
<li className="flex gap-2.5">
<Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
Cross-platform.
{t('settings.captures.sidebar.crossPlatform.title')}
</span>{' '}
Same shortcut, same flow on macOS, Windows, and Linux.
{t('settings.captures.sidebar.crossPlatform.body')}
</span>
</li>
</ul>
+40 -50
View File
@@ -1,10 +1,12 @@
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
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 { formatDate } from '@/lib/utils/format';
import { SettingRow, SettingSection } from './SettingRow';
/**
@@ -13,6 +15,7 @@ import { SettingRow, SettingSection } from './SettingRow';
* existing Voicebox server; this page is the agent-onboarding surface.
*/
export function MCPPage() {
const { t } = useTranslation();
const serverUrl = useServerStore((s) => s.serverUrl);
const { bindings, upsertAsync, remove } = useMCPBindings();
const { data: profiles } = useProfiles();
@@ -47,12 +50,12 @@ export function MCPPage() {
<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."
title={t('settings.mcp.install.title')}
description={t('settings.mcp.install.description')}
>
<SnippetRow
title="HTTP (recommended)"
description="For clients that speak HTTP MCP — Claude Code, Cursor, Windsurf, VS Code."
title={t('settings.mcp.install.http.title')}
description={t('settings.mcp.install.http.description')}
snippet={JSON.stringify(
{
mcpServers: {
@@ -67,13 +70,13 @@ export function MCPPage() {
)}
/>
<SnippetRow
title="Claude Code one-liner"
description="Registers via the Claude Code CLI."
title={t('settings.mcp.install.claudeCode.title')}
description={t('settings.mcp.install.claudeCode.description')}
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."
title={t('settings.mcp.install.stdio.title')}
description={t('settings.mcp.install.stdio.description')}
snippet={JSON.stringify(
{
mcpServers: {
@@ -91,12 +94,12 @@ export function MCPPage() {
</SettingSection>
<SettingSection
title="Default voice"
description="Used when an agent calls voicebox.speak without a specific profile and has no per-client binding."
title={t('settings.mcp.defaultVoice.title')}
description={t('settings.mcp.defaultVoice.description')}
>
<SettingRow
title="Default playback voice"
description="Shared with the Captures-tab 'Play as voice' dropdown — one default voice for passive playback."
title={t('settings.mcp.defaultVoice.label')}
description={t('settings.mcp.defaultVoice.labelHint')}
action={
<select
value={defaultProfileId}
@@ -107,7 +110,7 @@ export function MCPPage() {
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]"
>
<option value="">(none)</option>
<option value="">{t('settings.mcp.defaultVoice.none')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -119,13 +122,12 @@ export function MCPPage() {
</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)."
title={t('settings.mcp.bindings.title')}
description={t('settings.mcp.bindings.description')}
>
{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>.
<Trans i18nKey="settings.mcp.bindings.empty" components={{ code: <code /> }} />
</p>
) : (
<div className="divide-y divide-border/60">
@@ -142,12 +144,12 @@ export function MCPPage() {
<code className="text-[11px]">{b.client_id}</code>
{' · '}
{b.last_seen_at ? (
<span title={`Last seen ${b.last_seen_at}`}>
<span title={t('settings.mcp.bindings.lastSeenTitle', { when: b.last_seen_at })}>
<Plug className="inline h-3 w-3 text-emerald-500" />{' '}
last seen {formatRelative(b.last_seen_at)}
{t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
</span>
) : (
<span>never connected</span>
<span>{t('settings.mcp.bindings.neverConnected')}</span>
)}
</div>
</div>
@@ -162,7 +164,7 @@ export function MCPPage() {
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]"
>
<option value="">(default)</option>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -173,7 +175,7 @@ export function MCPPage() {
size="icon"
variant="ghost"
onClick={() => remove(b.client_id)}
aria-label={`Remove binding for ${b.client_id}`}
aria-label={t('settings.mcp.bindings.removeAria', { client: b.client_id })}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -183,18 +185,18 @@ export function MCPPage() {
)}
<div className="pt-4 space-y-2">
<div className="text-sm font-medium">Add a binding</div>
<div className="text-sm font-medium">{t('settings.mcp.bindings.add.title')}</div>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<input
type="text"
placeholder="client id (e.g. claude-code)"
placeholder={t('settings.mcp.bindings.add.clientIdPlaceholder')}
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)"
placeholder={t('settings.mcp.bindings.add.labelPlaceholder')}
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
@@ -204,7 +206,7 @@ export function MCPPage() {
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>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -217,7 +219,7 @@ export function MCPPage() {
onClick={handleAdd}
disabled={!newClientId.trim() || adding}
>
Add binding
{t('settings.mcp.bindings.add.action')}
</Button>
</div>
</SettingSection>
@@ -225,39 +227,36 @@ export function MCPPage() {
<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>
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.aboutTitle')}</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.
{t('settings.mcp.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold">Available tools</h3>
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.toolsTitle')}</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>
<div>{t('settings.mcp.sidebar.tools.speak')}</div>
</li>
<li>
<code className="text-accent">voicebox.transcribe</code>
<div>Whisper STT on a clip.</div>
<div>{t('settings.mcp.sidebar.tools.transcribe')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_captures</code>
<div>Recent dictations / recordings.</div>
<div>{t('settings.mcp.sidebar.tools.listCaptures')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_profiles</code>
<div>Available voice profiles.</div>
<div>{t('settings.mcp.sidebar.tools.listProfiles')}</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.
<Trans i18nKey="settings.mcp.sidebar.postSpeak" components={{ code: <code /> }} />
</span>
</div>
</aside>
@@ -274,6 +273,7 @@ function SnippetRow({
description: string;
snippet: string;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
@@ -296,12 +296,12 @@ function SnippetRow({
{copied ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
Copied
{t('settings.mcp.install.copied')}
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
{t('settings.mcp.install.copy')}
</>
)}
</Button>
@@ -312,13 +312,3 @@ function SnippetRow({
</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`;
}
+2 -2
View File
@@ -23,8 +23,8 @@ interface SettingsTab {
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.captures', path: '/settings/captures' },
{ labelKey: 'settings.tabs.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' },
+1 -1
View File
@@ -22,7 +22,7 @@ const tabs: Array<{
}> = [
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
{ id: 'captures', path: '/captures', icon: Captions, label: 'Captures' },
{ id: 'captures', path: '/captures', icon: Captions, labelKey: 'nav.captures' },
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
@@ -1,4 +1,4 @@
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
@@ -126,6 +126,9 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
{profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)}
{profile.personality?.trim() && (
<Wand2 className="h-3.5 w-3.5 text-accent" />
)}
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
@@ -1197,16 +1197,16 @@ export function ProfileForm() {
name="personality"
render={({ field }) => (
<FormItem>
<FormLabel>Personality</FormLabel>
<FormLabel>{t('profileForm.fields.personalityLabel')}</FormLabel>
<FormControl>
<Textarea
placeholder="Optional. Who this voice is and how they talk. E.g. &quot;a grumpy pirate who only speaks in nautical metaphors&quot;. Used by Compose, Rewrite, and the Speak API."
placeholder={t('profileForm.fields.personalityPlaceholder')}
className="min-h-[96px]"
{...field}
/>
</FormControl>
<FormDescription>
Leave blank to hide the Compose and Rewrite buttons on the generate page.
{t('profileForm.fields.personalityHint')}
</FormDescription>
<FormMessage />
</FormItem>