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
+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>
)}