feat: gray out unsupported profiles instead of filtering, auto-switch engine on selection

- Show all voice profiles with unsupported ones grayed out (opacity) instead of hidden
- Clicking a grayed-out profile selects it and auto-switches the engine to a compatible one
- Sort supported profiles first, with info tip about compatibility at the bottom
- Scroll to selected profile after engine/sort changes with safe margin
- Fix engine desync on tab navigation by initializing form engine from store

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-16 02:56:45 -07:00
co-authored by Claude Opus 4.6
parent 2bfe400457
commit 48cd1f369a
5 changed files with 69 additions and 46 deletions
@@ -125,22 +125,22 @@ export function FloatingGenerateBox({
}, [watchedEngine, setSelectedEngine]); }, [watchedEngine, setSelectedEngine]);
// Sync generation form language, engine, and effects with selected profile // Sync generation form language, engine, and effects with selected profile
type EngineValue = 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro' | 'qwen_custom_voice';
useEffect(() => { useEffect(() => {
if (selectedProfile?.language) { if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode); form.setValue('language', selectedProfile.language as LanguageCode);
} }
// Auto-switch engine if profile has a default // Auto-switch engine to match the profile
if (selectedProfile?.default_engine) { const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
form.setValue( if (engine) {
'engine', form.setValue('engine', engine as EngineValue);
selectedProfile.default_engine as } else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
| 'qwen' // Cloned/designed profile with no default — ensure a compatible (non-preset) engine
| 'luxtts' const currentEngine = form.getValues('engine');
| 'chatterbox' const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
| 'chatterbox_turbo' if (presetEngines.has(currentEngine)) {
| 'tada' form.setValue('engine', 'qwen');
| 'kokoro', }
);
} }
// Pre-fill effects from profile defaults // Pre-fill effects from profile defaults
if ( if (
@@ -25,9 +25,10 @@ const ENGINE_DISPLAY_NAMES: Record<string, string> = {
interface ProfileCardProps { interface ProfileCardProps {
profile: VoiceProfileResponse; profile: VoiceProfileResponse;
disabled?: boolean;
} }
export function ProfileCard({ profile }: ProfileCardProps) { export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile(); const deleteProfile = useDeleteProfile();
@@ -80,8 +81,9 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<> <>
<Card <Card
className={cn( className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]', 'cursor-pointer transition-all flex flex-col h-[162px]',
isSelected && 'ring-2 ring-accent shadow-md', disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
)} )}
onClick={handleSelect} onClick={handleSelect}
tabIndex={0} tabIndex={0}
@@ -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 { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useProfiles } from '@/lib/hooks/useProfiles'; import { useProfiles } from '@/lib/hooks/useProfiles';
@@ -9,16 +10,28 @@ import { ProfileForm } from './ProfileForm';
/** Engines that use preset (built-in) voices instead of cloned profiles. */ /** Engines that use preset (built-in) voices instead of cloned profiles. */
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']); 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() { export function ProfileList() {
const { data: profiles, isLoading, error } = useProfiles(); const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedEngine = useUIStore((state) => state.selectedEngine); 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;
// Wait a frame for the DOM to update after re-sort
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' });
// Clean up after scroll completes
setTimeout(() => { el.style.scrollMarginTop = ''; }, 500);
});
}, [selectedProfileId, selectedEngine]);
if (isLoading) { if (isLoading) {
return null; return null;
@@ -35,10 +48,18 @@ export function ProfileList() {
const allProfiles = profiles || []; const allProfiles = profiles || [];
const isPresetEngine = PRESET_ENGINES.has(selectedEngine); const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
// Filter profiles based on selected engine /** Whether a profile is supported by the currently selected engine. */
const filteredProfiles = isPresetEngine const isSupported = (p: (typeof allProfiles)[number]) =>
? allProfiles.filter((p) => p.voice_type === 'preset' && p.preset_engine === selectedEngine) isPresetEngine
: allProfiles.filter((p) => p.voice_type !== 'preset'); ? 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 ( return (
<div className="flex flex-col"> <div className="flex flex-col">
@@ -56,29 +77,26 @@ export function ProfileList() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </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]"> <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) => ( {sortedProfiles.map((profile) => (
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink"> <div
<ProfileCard profile={profile} /> 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> </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>
)} )}
</div> </div>
+3 -1
View File
@@ -10,6 +10,7 @@ import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({ const generationSchema = z.object({
text: z.string().min(1, '').max(50000), text: z.string().min(1, '').max(50000),
@@ -45,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const maxChunkChars = useServerStore((state) => state.maxChunkChars); const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs); const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio); const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null); const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -62,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined, seed: undefined,
modelSize: '1.7B', modelSize: '1.7B',
instruct: '', instruct: '',
engine: 'qwen', engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
...options.defaultValues, ...options.defaultValues,
}, },
}); });
+1
View File
@@ -4,6 +4,7 @@ import io
import json as _json import json as _json
import logging import logging
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile