mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-17 22:00:40 -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:
@@ -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 & 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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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. "a grumpy pirate who only speaks in nautical metaphors". 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>
|
||||
|
||||
Reference in New Issue
Block a user