-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat: Qwen CustomVoice engine + profile compatibility UX #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -125,22 +125,22 @@ export function FloatingGenerateBox({ | |
| }, [watchedEngine, setSelectedEngine]); | ||
|
|
||
| // Sync generation form language, engine, and effects with selected profile | ||
| type EngineValue = 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro' | 'qwen_custom_voice'; | ||
| useEffect(() => { | ||
| if (selectedProfile?.language) { | ||
| form.setValue('language', selectedProfile.language as LanguageCode); | ||
| } | ||
| // Auto-switch engine if profile has a default | ||
| if (selectedProfile?.default_engine) { | ||
| form.setValue( | ||
| 'engine', | ||
| selectedProfile.default_engine as | ||
| | 'qwen' | ||
| | 'luxtts' | ||
| | 'chatterbox' | ||
| | 'chatterbox_turbo' | ||
| | 'tada' | ||
| | 'kokoro', | ||
| ); | ||
| // Auto-switch engine to match the profile | ||
| const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine; | ||
| if (engine) { | ||
| form.setValue('engine', engine as EngineValue); | ||
| } else if (selectedProfile && selectedProfile.voice_type !== 'preset') { | ||
| // Cloned/designed profile with no default — ensure a compatible (non-preset) engine | ||
| const currentEngine = form.getValues('engine'); | ||
| const presetEngines = new Set(['kokoro', 'qwen_custom_voice']); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated preset engine set across componentsLow Severity The set of preset engines ( Additional Locations (1) |
||
| if (presetEngines.has(currentEngine)) { | ||
| form.setValue('engine', 'qwen'); | ||
| } | ||
| } | ||
| // Pre-fill effects from profile defaults | ||
| if ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,9 +25,10 @@ const ENGINE_DISPLAY_NAMES: Record<string, string> = { | |
|
|
||
| interface ProfileCardProps { | ||
| profile: VoiceProfileResponse; | ||
| disabled?: boolean; | ||
| } | ||
|
|
||
| export function ProfileCard({ profile }: ProfileCardProps) { | ||
| export function ProfileCard({ profile, disabled }: ProfileCardProps) { | ||
| const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); | ||
|
|
||
| const deleteProfile = useDeleteProfile(); | ||
|
|
@@ -40,6 +41,12 @@ export function ProfileCard({ profile }: ProfileCardProps) { | |
| const isSelected = selectedProfileId === profile.id; | ||
|
|
||
| const handleSelect = () => { | ||
| // If disabled but already selected, bounce the selection to re-trigger engine auto-switch | ||
| if (disabled && isSelected) { | ||
| setSelectedProfileId(null); | ||
| setTimeout(() => setSelectedProfileId(profile.id), 0); | ||
| return; | ||
| } | ||
| setSelectedProfileId(isSelected ? null : profile.id); | ||
| }; | ||
|
|
||
|
|
@@ -80,8 +87,9 @@ export function ProfileCard({ profile }: ProfileCardProps) { | |
| <> | ||
| <Card | ||
| className={cn( | ||
| 'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]', | ||
| isSelected && 'ring-2 ring-accent shadow-md', | ||
| 'cursor-pointer transition-all flex flex-col h-[162px]', | ||
| disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md', | ||
| isSelected && !disabled && 'ring-2 ring-accent shadow-md', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Disabled+selected card deselects instead of re-engagingMedium Severity When a profile is both Additional Locations (1) |
||
| )} | ||
| onClick={handleSelect} | ||
| tabIndex={0} | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,5 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Mic, Music, Sparkles } from 'lucide-react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Info, Mic, Sparkles } from 'lucide-react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useRef } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Button } from '@/components/ui/button'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Card, CardContent } from '@/components/ui/card'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useProfiles } from '@/lib/hooks/useProfiles'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -9,16 +10,31 @@ import { ProfileForm } from './ProfileForm'; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Engines that use preset (built-in) voices instead of cloned profiles. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Human-readable engine names for empty state messages. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ENGINE_NAMES: Record<string, string> = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kokoro: 'Kokoro', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| qwen_custom_voice: 'Qwen CustomVoice', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function ProfileList() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { data: profiles, isLoading, error } = useProfiles(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const selectedEngine = useUIStore((state) => state.selectedEngine); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const selectedProfileId = useUIStore((state) => state.selectedProfileId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Scroll to the selected profile after engine/sort changes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!selectedProfileId) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let timeoutId: ReturnType<typeof setTimeout> | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rafId = requestAnimationFrame(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const el = cardRefs.current.get(selectedProfileId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!el) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Temporarily apply scroll-margin so it doesn't land flush at the top | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| el.style.scrollMarginTop = '180px'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeoutId = setTimeout(() => { el.style.scrollMarginTop = ''; }, 500); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cancelAnimationFrame(rafId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (timeoutId) clearTimeout(timeoutId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [selectedProfileId, selectedEngine]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+21
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add cleanup for deferred scroll operations. Lines 24-33 schedule Proposed fix useEffect(() => {
if (!selectedProfileId) return;
- // Wait a frame for the DOM to update after re-sort
- requestAnimationFrame(() => {
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
+ const rafId = requestAnimationFrame(() => {
const el = cardRefs.current.get(selectedProfileId);
if (!el) return;
@@
- setTimeout(() => { el.style.scrollMarginTop = ''; }, 500);
+ timeoutId = setTimeout(() => {
+ el.style.scrollMarginTop = '';
+ }, 500);
});
+ return () => {
+ cancelAnimationFrame(rafId);
+ if (timeoutId) clearTimeout(timeoutId);
+ };
}, [selectedProfileId, selectedEngine]);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isLoading) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -35,10 +51,18 @@ export function ProfileList() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const allProfiles = profiles || []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isPresetEngine = PRESET_ENGINES.has(selectedEngine); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Filter profiles based on selected engine | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const filteredProfiles = isPresetEngine | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? allProfiles.filter((p) => p.voice_type === 'preset' && p.preset_engine === selectedEngine) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : allProfiles.filter((p) => p.voice_type !== 'preset'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Whether a profile is supported by the currently selected engine. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isSupported = (p: (typeof allProfiles)[number]) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isPresetEngine | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? p.voice_type === 'preset' && p.preset_engine === selectedEngine | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : p.voice_type !== 'preset'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+55
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Lines 52-55 currently treat every non-preset profile as supported whenever a non-preset engine is selected. Profiles with a specific Proposed fix const isSupported = (p: (typeof allProfiles)[number]) =>
- isPresetEngine
- ? p.voice_type === 'preset' && p.preset_engine === selectedEngine
- : p.voice_type !== 'preset';
+ isPresetEngine
+ ? p.voice_type === 'preset' && p.preset_engine === selectedEngine
+ : p.voice_type !== 'preset' &&
+ (!p.default_engine || p.default_engine === selectedEngine);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Sort so supported profiles come first | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sortedProfiles = [...allProfiles].sort( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hasUnsupported = sortedProfiles.some((p) => !isSupported(p)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex flex-col"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -56,29 +80,26 @@ export function ProfileList() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </CardContent> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </Card> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) : filteredProfiles.length === 0 && isPresetEngine ? ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Card> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <CardContent className="flex flex-col items-center justify-center py-12"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Music className="h-12 w-12 text-muted-foreground mb-4" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className="text-muted-foreground mb-2"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className="text-sm text-muted-foreground mb-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Create a profile to choose a specific voice before generating. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Button onClick={() => setDialogOpen(true)}> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Sparkles className="mr-2 h-4 w-4" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Create {ENGINE_NAMES[selectedEngine] ?? selectedEngine} Voice | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </CardContent> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </Card> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {filteredProfiles.map((profile) => ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <ProfileCard profile={profile} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {sortedProfiles.map((profile) => ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key={profile.id} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className="shrink-0 w-[200px] lg:w-auto lg:shrink" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ref={(el) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (el) cardRefs.current.set(profile.id, el); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else cardRefs.current.delete(profile.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <ProfileCard profile={profile} disabled={!isSupported(profile)} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {hasUnsupported && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Info className="h-3.5 w-3.5 shrink-0" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <span>Only supported voice profiles can be selected for the current model.</span> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import { useGeneration } from '@/lib/hooks/useGeneration'; | |
| import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; | ||
| import { useGenerationStore } from '@/stores/generationStore'; | ||
| import { useServerStore } from '@/stores/serverStore'; | ||
| import { useUIStore } from '@/stores/uiStore'; | ||
|
|
||
| const generationSchema = z.object({ | ||
| text: z.string().min(1, '').max(50000), | ||
|
|
@@ -45,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { | |
| const maxChunkChars = useServerStore((state) => state.maxChunkChars); | ||
| const crossfadeMs = useServerStore((state) => state.crossfadeMs); | ||
| const normalizeAudio = useServerStore((state) => state.normalizeAudio); | ||
| const selectedEngine = useUIStore((state) => state.selectedEngine); | ||
| const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null); | ||
| const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null); | ||
|
|
||
|
|
@@ -62,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { | |
| seed: undefined, | ||
| modelSize: '1.7B', | ||
| instruct: '', | ||
| engine: 'qwen', | ||
| engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify selectedEngine is not strongly constrained at store boundary
fd -i 'uiStore.ts' --exec sed -n '1,220p' {}
rg -n --type=ts --type=tsx -C2 '\bsetSelectedEngine\s*\('Repository: jamiepine/voicebox Length of output: 2481 🏁 Script executed: # Check useGenerationForm.ts around line 67 to confirm the code
fd -type f -name 'useGenerationForm.ts' -exec sed -n '40,75p' {} +Repository: jamiepine/voicebox Length of output: 232 🏁 Script executed: # Find GenerationFormValues type definition
rg -n 'type GenerationFormValues|interface GenerationFormValues' -A 15 --type tsRepository: jamiepine/voicebox Length of output: 1512 🏁 Script executed: # Find all usages of setSelectedEngine to see what values are being set
rg -n 'setSelectedEngine' --type ts -B 2 -A 2Repository: jamiepine/voicebox Length of output: 1912 🏁 Script executed: # Find generationSchema definition
rg -n 'generationSchema\s*=' -A 20 --type tsRepository: jamiepine/voicebox Length of output: 1493 🏁 Script executed: # Read FloatingGenerateBox around setSelectedEngine usage to see watchedEngine source
sed -n '110,135p' app/src/components/Generation/FloatingGenerateBox.tsxRepository: jamiepine/voicebox Length of output: 1060 🏁 Script executed: # Read useGenerationForm.ts around line 67
sed -n '60,75p' app/src/lib/hooks/useGenerationForm.tsRepository: jamiepine/voicebox Length of output: 477 Guard Line 67 force-casts a plain Proposed fix+const ALLOWED_ENGINES = new Set<NonNullable<GenerationFormValues['engine']>>([
+ 'qwen',
+ 'qwen_custom_voice',
+ 'luxtts',
+ 'chatterbox',
+ 'chatterbox_turbo',
+ 'tada',
+ 'kokoro',
+]);
+
+const initialEngine: NonNullable<GenerationFormValues['engine']> = ALLOWED_ENGINES.has(
+ selectedEngine as NonNullable<GenerationFormValues['engine']>,
+)
+ ? (selectedEngine as NonNullable<GenerationFormValues['engine']>)
+ : 'qwen';
+
const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema),
defaultValues: {
@@
- engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
+ engine: initialEngine,
...options.defaultValues,
},
});🤖 Prompt for AI Agents |
||
| ...options.defaultValues, | ||
| }, | ||
| }); | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 224
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 232
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 232
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 237
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 4299
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 1512
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 5975
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 1016
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 1346
🏁 Script executed:
Repository: jamiepine/voicebox
Length of output: 541
Add runtime validation for backend engine values before setting form state.
Lines 134–136 cast backend engine strings directly to
EngineValuewithout checking validity. If the backend returns an unexpected value,form.setValue('engine', ...)accepts it and the form silently becomes invalid.The form schema defines engine as a Zod enum (
generationSchema), butform.setValue()doesn't trigger validation—only form submission does. This creates a window where the form state contains an invalid engine without error signaling.Add a guard to validate against known engines:
Suggested fix
Also consider extracting the engine enum to a shared constant to avoid duplication across
FloatingGenerateBox.tsx,generationSchema, and other components likeEngineModelSelector.tsx.🤖 Prompt for AI Agents