mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user