diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index f1cd571d..956c6547 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -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']); + if (presetEngines.has(currentEngine)) { + form.setValue('engine', 'qwen'); + } } // Pre-fill effects from profile defaults if ( diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index e634c38c..b3d5ffee 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -25,9 +25,10 @@ const ENGINE_DISPLAY_NAMES: Record = { 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) { <> = { - 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>(new Map()); + + // Scroll to the selected profile after engine/sort changes + useEffect(() => { + if (!selectedProfileId) return; + let timeoutId: ReturnType | 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]); 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'; + + // 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 (
@@ -56,29 +80,26 @@ export function ProfileList() { - ) : filteredProfiles.length === 0 && isPresetEngine ? ( - - - -

- No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet. -

-

- Create a profile to choose a specific voice before generating. -

- -
-
) : (
- {filteredProfiles.map((profile) => ( -
- + {sortedProfiles.map((profile) => ( +
{ + if (el) cardRefs.current.set(profile.id, el); + else cardRefs.current.delete(profile.id); + }} + > +
))} + {hasUnsupported && ( +
+ + Only supported voice profiles can be selected for the current model. +
+ )}
)}
diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts index 0acdabbf..d6a44bfc 100644 --- a/app/src/lib/hooks/useGenerationForm.ts +++ b/app/src/lib/hooks/useGenerationForm.ts @@ -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(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); @@ -62,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { seed: undefined, modelSize: '1.7B', instruct: '', - engine: 'qwen', + engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen', ...options.defaultValues, }, }); diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index 7bc075c5..706055de 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -4,6 +4,7 @@ import json as _json import logging import tempfile +from datetime import datetime from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile