From abf5dfda8c865c0d19ca3c62b30aef4539a8d5a0 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Thu, 23 Apr 2026 17:31:23 -0700 Subject: [PATCH] personality: bool API, i18n across the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 }); ``` --- CHANGELOG.md | 21 +- README.md | 11 +- .../AccessibilityGate/AccessibilityGate.tsx | 15 +- .../components/CapturePill/CapturePill.tsx | 24 +- .../components/CapturesTab/CapturesTab.tsx | 171 ++++----- .../DictationReadinessChecklist.tsx | 50 ++- .../components/ChordPicker/ChordPicker.tsx | 13 +- .../Generation/FloatingGenerateBox.tsx | 168 ++++++-- .../components/Generation/GenerationForm.tsx | 315 --------------- .../InputMonitoringGate.tsx | 15 +- app/src/components/ServerTab/CapturesPage.tsx | 193 +++++----- app/src/components/ServerTab/MCPPage.tsx | 90 ++--- app/src/components/ServerTab/ServerTab.tsx | 4 +- app/src/components/Sidebar.tsx | 2 +- .../components/VoiceProfiles/ProfileCard.tsx | 5 +- .../components/VoiceProfiles/ProfileForm.tsx | 6 +- app/src/i18n/locales/en/translation.json | 358 +++++++++++++++++- app/src/i18n/locales/ja/translation.json | 358 +++++++++++++++++- app/src/i18n/locales/zh-CN/translation.json | 358 +++++++++++++++++- app/src/i18n/locales/zh-TW/translation.json | 358 +++++++++++++++++- app/src/lib/api/client.ts | 17 +- app/src/lib/api/types.ts | 10 +- app/src/lib/hooks/useGenerationForm.ts | 4 + app/src/lib/utils/format.ts | 10 + backend/database/migrations.py | 25 ++ backend/database/models.py | 21 +- backend/mcp_server/tools.py | 73 ++-- backend/models.py | 67 +--- backend/routes/generations.py | 21 +- backend/routes/mcp_bindings.py | 2 +- backend/routes/profiles.py | 196 +--------- backend/routes/speak.py | 49 ++- backend/services/personality.py | 60 +-- backend/tests/test_personality_samples.py | 96 ++--- docs/content/docs/index.mdx | 4 +- docs/content/docs/overview/captures.mdx | 4 +- docs/content/docs/overview/dictation.mdx | 2 +- docs/content/docs/overview/introduction.mdx | 6 +- docs/content/docs/overview/mcp-server.mdx | 15 +- .../docs/overview/voice-personalities.mdx | 97 +++-- docs/plans/MCP_SERVER.md | 25 +- 41 files changed, 2146 insertions(+), 1193 deletions(-) delete mode 100644 app/src/components/Generation/GenerationForm.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 59334545..38fe4e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ ## [Unreleased] +### Personality — simpler API, better UX + +Voice personalities have been collapsed from a three-mode (`compose` / `rewrite` / `respond`) API down to what they actually needed to be: a compose button and a boolean rewrite toggle. + +- **Generate box UI.** When a profile has a personality set, two controls appear next to the generate button: a shuffle button that drops a fresh in-character line into the textarea, and a wand toggle that runs your input through the personality LLM before TTS. No more intent dropdown. +- **API surface.** `POST /generate` and `POST /speak` accept `personality: bool` in place of the removed `intent` field. `voicebox.speak` (MCP) takes `personality: bool`. The standalone `/profiles/{id}/rewrite`, `/profiles/{id}/respond`, and `/profiles/{id}/speak` endpoints are gone; `/profiles/{id}/compose` remains for the shuffle button. +- **MCP bindings.** `default_intent` has been replaced with `default_personality: bool`. Existing bindings with a non-null `default_intent` are migrated to `false`; re-enable the rewrite path from **Settings → MCP** if you were relying on it. +- **Respond mode is gone.** Letting the personality LLM *reply* to input text was only useful for agent-as-LLM flows nobody was actually building. Agents that want conversational replies can produce the reply themselves and pipe it into `voicebox.speak`. + + ## [0.5.0] - 2026-04-22 **The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. The loop closes in both directions: your voice goes into your computer through a global hotkey, and any agent's voice comes out of your computer through a voice you own. @@ -24,19 +34,18 @@ When enabled, hold a key anywhere on your machine, speak, release — the transc ### Personality — voice profiles that speak for themselves -Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, three new actions appear on the profile, each powered by a bundled Qwen3 LLM running entirely locally: +Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new actions appear next to the generate button, each powered by a bundled Qwen3 LLM running entirely locally: -- **Compose** — generate fresh utterances in the character's voice. Click again for variety. -- **Rewrite** — restate your text in the character's voice while preserving every idea. High-fidelity mode for turning dictation into in-character speech. -- **Respond** — treat your text as a prompt and produce the character's reply. +- **Compose** — drop a fresh in-character line into the textarea. Click again for variety, edit before speaking. +- **Speak in character** — a toggle that rewrites your input text in the character's voice before TTS, preserving every idea. -Temperatures are tuned per mode (compose hot for variety, rewrite cold for fidelity, respond balanced) and the character framing enforces "speech only" output — no narration, no action tags, no meta-commentary. The same LLM doubles as the refinement model, so there's one local LLM in the app, not two. +Temperatures are tuned per mode (compose hot for variety, rewrite cold for fidelity) and the character framing enforces "speech only" output — no narration, no action tags, no meta-commentary. The same LLM doubles as the refinement model, so there's one local LLM in the app, not two. ### Agents — any MCP-aware agent gets a voice Voicebox ships a built-in **Model Context Protocol** server at `http://127.0.0.1:17493/mcp` so Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions — any MCP-aware agent — can call into your local Voicebox install. Four tools ship with dotted names: -- **`voicebox.speak`** — speak text in any voice profile, with optional `intent: compose | rewrite | respond` to run through the profile's personality LLM first +- **`voicebox.speak`** — speak text in any voice profile, with optional `personality: true` to run through the profile's personality LLM first - **`voicebox.transcribe`** — Whisper transcription of a base64 blob or an absolute local path - **`voicebox.list_captures`** — recent captures with their transcripts - **`voicebox.list_profiles`** — available voice profiles (cloned + preset) diff --git a/README.md b/README.md index d3b8029d..e5b8acce 100644 --- a/README.md +++ b/README.md @@ -243,13 +243,12 @@ Also exposed as `POST /speak` for anything that doesn't speak MCP — ACP, A2A, ### Voice Personalities -Attach a free-form personality to any voice profile — who this voice is, how they speak, what they care about. Three new actions appear on the profile, each powered by a bundled Qwen3 LLM running entirely locally. +Attach a free-form personality to any voice profile — who this voice is, how they speak, what they care about. Two actions appear on the generate box when a personality is set, powered by a bundled Qwen3 LLM running entirely locally. -- **Compose** — generate a fresh utterance in the character's voice -- **Rewrite** — restate your text in their voice while preserving every idea -- **Respond** — treat your text as a prompt and produce the character's reply +- **Compose** — a shuffle button that drops a fresh in-character line into the textarea; edit and speak, or click again for a different take +- **Speak in character** — a toggle that routes your input text through the personality LLM to be rewritten in their voice before TTS -Agents can invoke the same modes over MCP by passing `intent: "compose" | "rewrite" | "respond"` to `voicebox.speak`, turning the tool into a text-in → personality-LLM → TTS pipeline. The same LLM backs dictation's refinement step — one LLM in the app, one model cache, one GPU-memory footprint. +Agents can reach the same rewrite path over MCP by passing `personality: true` to `voicebox.speak`, turning the tool into a text-in → personality-LLM → TTS pipeline. The same LLM backs dictation's refinement step — one LLM in the app, one model cache, one GPU-memory footprint. **Local LLM options:** Qwen3 0.6B / 1.7B / 4B, sharing the TTS runtime (MLX on Apple Silicon, PyTorch elsewhere). @@ -348,7 +347,7 @@ Four tools ship: `voicebox.speak`, `voicebox.transcribe`, `voicebox.list_capture await voicebox.speak({ text: "Tests passing. Ready to merge.", profile: "Morgan", // optional — falls back to the per-client binding - intent: "respond", // optional — runs text through the profile's personality LLM first + personality: true, // optional — rewrites text through the profile's personality LLM first }); ``` diff --git a/app/src/components/AccessibilityGate/AccessibilityGate.tsx b/app/src/components/AccessibilityGate/AccessibilityGate.tsx index 50c7f2d5..45307aa7 100644 --- a/app/src/components/AccessibilityGate/AccessibilityGate.tsx +++ b/app/src/components/AccessibilityGate/AccessibilityGate.tsx @@ -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() {

- Grant Accessibility permission to enable auto-paste + {t('captures.permissions.accessibility.title')}

- Voicebox needs System Settings → Privacy & Security → Accessibility - to paste transcriptions into other apps. Your dictation still lands - in the Captures tab without it. + }} />

{stillMissing && !checking && (

- Still not detected. macOS usually requires quitting and reopening - Voicebox after toggling the permission. + {t('captures.permissions.accessibility.stillMissing')}

)}
diff --git a/app/src/components/CapturePill/CapturePill.tsx b/app/src/components/CapturePill/CapturePill.tsx index bf845781..bc413d21 100644 --- a/app/src/components/CapturePill/CapturePill.tsx +++ b/app/src/components/CapturePill/CapturePill.tsx @@ -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, string> = { - recording: 'Recording', - transcribing: 'Transcribing', - refining: 'Refining', - speaking: 'Speaking', - completed: 'Done', +const PILL_LABEL_KEYS: Record, 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 ( @@ -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({ {readiness.allReady && ( @@ -524,7 +497,7 @@ export function CapturesTab() { ) : ( )} - {session.isUploading ? 'Uploading...' : 'Import'} + {session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')} )} @@ -542,12 +515,12 @@ export function CapturesTab() { {session.isRecording ? ( <> - Stop + {t('captures.actions.stop')} ) : ( <> - Dictate + {t('captures.actions.dictate')} )} @@ -564,7 +537,7 @@ export function CapturesTab() { > {/* Meta row */}
- {formatDate(selected.created_at)} + {formatAbsoluteDate(selected.created_at)} {selected.language && ( <> · @@ -611,7 +584,7 @@ export function CapturesTab() { )} > - Refined + {t('captures.transcript.refined')}
{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}
@@ -665,29 +638,24 @@ export function CapturesTab() { 'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15', )} > - {playAsVoice && ( -
- )} {playbackState === 'generating' ? ( <> - Generating… + {t('captures.actions.playAsGenerating')} ) : playbackState === 'playing' ? ( <> - Stop · {playAsVoice?.name ?? 'Voice'} + {playAsVoice + ? t('captures.actions.playAsStop', { name: playAsVoice.name }) + : t('captures.actions.playAsStopFallback')} ) : ( <> - {playAsVoice ? `Play as ${playAsVoice.name}` : 'Play as…'} + {playAsVoice + ? t('captures.actions.playAs', { name: playAsVoice.name }) + : t('captures.actions.playAsFallback')} )} @@ -708,21 +676,15 @@ export function CapturesTab() { - Play transcript as + {t('captures.actions.playAsDropdownLabel')} {profiles?.map((v) => ( handlePlayAs(v)} - className="gap-2.5 py-2" + className="py-2" > -
{v.name}
@@ -739,7 +701,7 @@ export function CapturesTab() {
@@ -780,12 +744,12 @@ export function CapturesTab() { {capturesLoading ? (
-

Loading captures…

+

{t('captures.empty.loading')}

) : captures.length ? (
-

Pick a capture to see the transcript.

+

{t('captures.empty.pickOne')}

) : hotkeyEnabled && !readiness.allReady ? ( @@ -796,7 +760,7 @@ export function CapturesTab() {
- Hold to record + {t('captures.empty.holdToRecord')}
) : null} @@ -804,25 +768,24 @@ export function CapturesTab() {
- Toggle hands-free + {t('captures.empty.toggleHandsFree')}
) : null}

- Press the shortcut anywhere on your machine to start your first capture. + {t('captures.empty.pressShortcut')}

) : (
-

No captures yet.

+

{t('captures.empty.none')}

- Turn on the global shortcut to dictate from anywhere — or click - Dictate above for an in-app capture. + {t('captures.empty.turnOnShortcut')}

)} diff --git a/app/src/components/CapturesTab/DictationReadinessChecklist.tsx b/app/src/components/CapturesTab/DictationReadinessChecklist.tsx index 58f7272a..a26e9655 100644 --- a/app/src/components/CapturesTab/DictationReadinessChecklist.tsx +++ b/app/src/components/CapturesTab/DictationReadinessChecklist.tsx @@ -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 ? ( <> - {pct != null ? `Downloading… ${pct}%` : 'Downloading…'} + {pct != null + ? t('captures.readiness.downloadingPercent', { pct }) + : t('captures.readiness.downloading')} ) : ( <> - Download + {t('captures.readiness.downloadButton')} )} @@ -175,21 +179,23 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio

- A few things before you can dictate + {t('captures.readiness.title')}

- The shortcut stays off until everything below is ready. + {t('captures.readiness.subheading')}

{readiness.stt && ( } - 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 && ( } - 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 } - 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={ } /> } - 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={ } /> diff --git a/app/src/components/ChordPicker/ChordPicker.tsx b/app/src/components/ChordPicker/ChordPicker.tsx index 7b7bc387..933bac2e 100644 --- a/app/src/components/ChordPicker/ChordPicker.tsx +++ b/app/src/components/ChordPicker/ChordPicker.tsx @@ -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({
- {pressed.size > 0 ? 'Capturing…' : 'Press your shortcut'} + {pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
{displayKeys.length === 0 ? ( - No keys yet + {t('captures.chord.noKeys')} ) : ( displayKeys.map((k) => ) @@ -166,8 +168,7 @@ export function ChordPicker({
{unsupportedAttempt ? (

- "{unsupportedAttempt}" isn't supported in chords. Try a modifier - or letter key. + {t('captures.chord.unsupported', { key: unsupportedAttempt })}

) : null}
@@ -175,10 +176,10 @@ export function ChordPicker({ diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index ac64406f..89fdbc7c 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -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({ /> -
-
- - - {isPending - ? t('generation.button.generating') - : !selectedProfileId - ? t('generation.button.selectFirst') - : t('generation.button.generate')} - -
+
+ {/* Compose — fills the textarea with a fresh in-character line. */} + + {selectedProfile?.personality?.trim() && ( + +
+ + + {t('generation.compose.tooltip')} + +
+
+ )} +
+ + {/* Persona — rewrite input through the profile's personality LLM before TTS. */} + + {selectedProfile?.personality?.trim() && ( + + { + const active = !!field.value; + return ( + + +
+ + + {active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')} + +
+
+
+ ); + }} + /> +
+ )} +
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */} @@ -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)]" >
+ + {isPending + ? t('generation.button.generating') + : !selectedProfileId + ? t('generation.button.selectFirst') + : t('generation.button.generate')} + +
@@ -463,6 +566,7 @@ export function FloatingGenerateBox({
)} + 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[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 ( - - - Generate Speech - - -
- -
- Voice Profile - {selectedProfile ? ( -
- - {selectedProfile.name} - {selectedProfile.language} -
- ) : ( -
- Click on a profile card above to select a voice profile -
- )} -
- - ( - - Text to Speak - - {form.watch('engine') === 'chatterbox_turbo' ? ( - - ) : ( -