feat: enhance audio processing and error handling in TTS backend; refactor user dialog form validation
This commit is contained in:
@@ -16,7 +16,6 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const previousAudioUrlRef = useRef<string>('')
|
||||
const playerRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl || audioUrl === previousAudioUrlRef.current) return
|
||||
|
||||
@@ -20,7 +20,6 @@ import { JobDetailDialog } from '@/components/JobDetailDialog'
|
||||
interface HistoryItemProps {
|
||||
job: Job
|
||||
onDelete: (id: number) => void
|
||||
onLoadParams: (job: Job) => void
|
||||
}
|
||||
|
||||
const jobTypeBadgeVariant = {
|
||||
@@ -35,7 +34,7 @@ const jobTypeLabel = {
|
||||
voice_clone: '声音克隆',
|
||||
}
|
||||
|
||||
const HistoryItem = memo(({ job, onDelete, onLoadParams }: HistoryItemProps) => {
|
||||
const HistoryItem = memo(({ job, onDelete }: HistoryItemProps) => {
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false)
|
||||
|
||||
const getLanguageDisplay = (lang: string | undefined) => {
|
||||
|
||||
@@ -5,16 +5,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, FileAudio, RefreshCw } from 'lucide-react'
|
||||
import type { JobType } from '@/types/job'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface HistorySidebarProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onLoadParams: (jobId: number, jobType: JobType) => Promise<void>
|
||||
}
|
||||
|
||||
function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLoadParams'>) {
|
||||
function HistorySidebarContent() {
|
||||
const { jobs, loading, loadingMore, hasMore, loadMore, deleteJob, error, retry } = useHistoryContext()
|
||||
const observerTarget = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -35,14 +32,6 @@ function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLo
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, loadMore])
|
||||
|
||||
const handleLoadParams = async (jobId: number, jobType: JobType) => {
|
||||
try {
|
||||
await onLoadParams(jobId, jobType)
|
||||
} catch (error) {
|
||||
toast.error('加载参数失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b">
|
||||
@@ -79,7 +68,6 @@ function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLo
|
||||
key={job.id}
|
||||
job={job}
|
||||
onDelete={deleteJob}
|
||||
onLoadParams={(job) => handleLoadParams(job.id, job.type)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -96,16 +84,16 @@ function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLo
|
||||
)
|
||||
}
|
||||
|
||||
export function HistorySidebar({ open, onOpenChange, onLoadParams }: HistorySidebarProps) {
|
||||
export function HistorySidebar({ open, onOpenChange }: HistorySidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden lg:block w-[320px] border-r h-full">
|
||||
<HistorySidebarContent onLoadParams={onLoadParams} />
|
||||
<HistorySidebarContent />
|
||||
</aside>
|
||||
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-full sm:max-w-md p-0">
|
||||
<HistorySidebarContent onLoadParams={onLoadParams} />
|
||||
<HistorySidebarContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
|
||||
@@ -15,11 +15,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { PresetSelector } from '@/components/PresetSelector'
|
||||
import { ParamInput } from '@/components/ParamInput'
|
||||
import { PRESET_INSTRUCTS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
|
||||
import type { Language, Speaker } from '@/types/tts'
|
||||
|
||||
@@ -56,7 +54,6 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
const { refresh } = useHistoryContext()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -90,7 +87,6 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
setValue('top_k', params.top_k || 20)
|
||||
setValue('top_p', params.top_p || 0.7)
|
||||
setValue('repetition_penalty', params.repetition_penalty || 1.05)
|
||||
setValue('backend', params.backend || 'local')
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -214,11 +210,11 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
<Dialog open={advancedOpen} onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setTempAdvancedParams({
|
||||
max_new_tokens: watch('max_new_tokens'),
|
||||
temperature: watch('temperature'),
|
||||
top_k: watch('top_k'),
|
||||
top_p: watch('top_p'),
|
||||
repetition_penalty: watch('repetition_penalty')
|
||||
max_new_tokens: watch('max_new_tokens') || 2048,
|
||||
temperature: watch('temperature') || 0.3,
|
||||
top_k: watch('top_k') || 20,
|
||||
top_p: watch('top_p') || 0.7,
|
||||
repetition_penalty: watch('repetition_penalty') || 1.05
|
||||
})
|
||||
}
|
||||
setAdvancedOpen(open)
|
||||
@@ -339,11 +335,11 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTempAdvancedParams({
|
||||
max_new_tokens: watch('max_new_tokens'),
|
||||
temperature: watch('temperature'),
|
||||
top_k: watch('top_k'),
|
||||
top_p: watch('top_p'),
|
||||
repetition_penalty: watch('repetition_penalty')
|
||||
max_new_tokens: watch('max_new_tokens') || 2048,
|
||||
temperature: watch('temperature') || 0.3,
|
||||
top_k: watch('top_k') || 20,
|
||||
top_p: watch('top_p') || 0.7,
|
||||
repetition_penalty: watch('repetition_penalty') || 1.05
|
||||
})
|
||||
setAdvancedOpen(false)
|
||||
}}
|
||||
|
||||
@@ -9,14 +9,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Settings, Globe2, Type, Play, FileText, Mic, Zap, Database, ArrowRight, ArrowLeft } from 'lucide-react'
|
||||
import { Settings, Globe2, Type, Play, FileText, Mic, ArrowRight, ArrowLeft } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconLabel } from '@/components/IconLabel'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { FileUploader } from '@/components/FileUploader'
|
||||
@@ -54,7 +53,6 @@ function VoiceCloneForm() {
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
const { refresh } = useHistoryContext()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -92,6 +90,14 @@ function VoiceCloneForm() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (inputTab === 'record' && PRESET_REF_TEXTS.length > 0) {
|
||||
setValue('ref_text', PRESET_REF_TEXTS[0].text)
|
||||
} else if (inputTab === 'upload') {
|
||||
setValue('ref_text', '')
|
||||
}
|
||||
}, [inputTab])
|
||||
|
||||
const handleNextStep = async () => {
|
||||
// Validate step 1 fields
|
||||
const valid = await trigger(['ref_audio', 'ref_text'])
|
||||
@@ -180,22 +186,31 @@ function VoiceCloneForm() {
|
||||
onSelect={(preset) => setValue('ref_text', preset.text)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="w-full mt-6" onClick={handleNextStep}>
|
||||
下一步
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="record" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">请朗读以下任一段落:</Label>
|
||||
<div className="grid gap-2">
|
||||
{PRESET_REF_TEXTS.map((preset, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-3 border rounded-lg hover:bg-accent cursor-pointer transition-colors text-sm"
|
||||
onClick={() => setValue('ref_text', preset.text)}
|
||||
>
|
||||
<div className="font-medium mb-1">{preset.label}</div>
|
||||
<div className="text-muted-foreground line-clamp-2">{preset.text}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PRESET_REF_TEXTS.map((preset, i) => {
|
||||
const isSelected = watch('ref_text') === preset.text
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-3 border rounded-lg hover:bg-accent cursor-pointer transition-colors text-sm text-center ${
|
||||
isSelected ? 'border-primary bg-primary/10' : ''
|
||||
}`}
|
||||
onClick={() => setValue('ref_text', preset.text)}
|
||||
>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-2">
|
||||
<Label>当前参考文本</Label>
|
||||
@@ -209,28 +224,31 @@ function VoiceCloneForm() {
|
||||
|
||||
{/* Mobile-friendly Bottom Recorder Area */}
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t z-50 md:relative md:border-t-0 md:bg-transparent md:p-0 md:z-0">
|
||||
<Controller
|
||||
name="ref_audio"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AudioRecorder
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{watch('ref_audio') && (
|
||||
<Button type="button" className="w-full" onClick={handleNextStep}>
|
||||
下一步
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{errors.ref_audio && (
|
||||
<p className="text-sm text-destructive mt-2 text-center md:text-left">{errors.ref_audio.message}</p>
|
||||
)}
|
||||
<Controller
|
||||
name="ref_audio"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AudioRecorder
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.ref_audio && (
|
||||
<p className="text-sm text-destructive mt-2 text-center md:text-left">{errors.ref_audio.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Spacer for mobile to prevent content being hidden behind fixed footer */}
|
||||
<div className="h-24 md:hidden" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Button type="button" className="w-full mt-6" onClick={handleNextStep}>
|
||||
下一步
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={step === 2 ? 'block space-y-4' : 'hidden'}>
|
||||
|
||||
@@ -15,11 +15,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { PresetSelector } from '@/components/PresetSelector'
|
||||
import { ParamInput } from '@/components/ParamInput'
|
||||
import { PRESET_VOICE_DESIGNS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
|
||||
import type { Language } from '@/types/tts'
|
||||
|
||||
@@ -54,7 +52,6 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
const { refresh } = useHistoryContext()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -182,11 +179,11 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
||||
<Dialog open={advancedOpen} onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setTempAdvancedParams({
|
||||
max_new_tokens: watch('max_new_tokens'),
|
||||
temperature: watch('temperature'),
|
||||
top_k: watch('top_k'),
|
||||
top_p: watch('top_p'),
|
||||
repetition_penalty: watch('repetition_penalty')
|
||||
max_new_tokens: watch('max_new_tokens') || 2048,
|
||||
temperature: watch('temperature') || 0.3,
|
||||
top_k: watch('top_k') || 20,
|
||||
top_p: watch('top_p') || 0.7,
|
||||
repetition_penalty: watch('repetition_penalty') || 1.05
|
||||
})
|
||||
}
|
||||
setAdvancedOpen(open)
|
||||
@@ -307,11 +304,11 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTempAdvancedParams({
|
||||
max_new_tokens: watch('max_new_tokens'),
|
||||
temperature: watch('temperature'),
|
||||
top_k: watch('top_k'),
|
||||
top_p: watch('top_p'),
|
||||
repetition_penalty: watch('repetition_penalty')
|
||||
max_new_tokens: watch('max_new_tokens') || 2048,
|
||||
temperature: watch('temperature') || 0.3,
|
||||
top_k: watch('top_k') || 20,
|
||||
top_p: watch('top_p') || 0.7,
|
||||
repetition_penalty: watch('repetition_penalty') || 1.05
|
||||
})
|
||||
setAdvancedOpen(false)
|
||||
}}
|
||||
|
||||
@@ -22,23 +22,15 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
const editUserFormSchema = z.object({
|
||||
const userFormSchema = z.object({
|
||||
username: z.string().min(3, '用户名至少3个字符').max(20, '用户名最多20个字符'),
|
||||
email: z.string().email('请输入有效的邮箱地址'),
|
||||
password: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
is_superuser: z.boolean().default(false),
|
||||
is_active: z.boolean(),
|
||||
is_superuser: z.boolean(),
|
||||
})
|
||||
|
||||
const createUserFormSchema = z.object({
|
||||
username: z.string().min(3, '用户名至少3个字符').max(20, '用户名最多20个字符'),
|
||||
email: z.string().email('请输入有效的邮箱地址'),
|
||||
password: z.string().min(8, '密码至少8个字符'),
|
||||
is_active: z.boolean().default(true),
|
||||
is_superuser: z.boolean().default(false),
|
||||
})
|
||||
|
||||
type UserFormValues = z.infer<typeof editUserFormSchema>
|
||||
type UserFormValues = z.infer<typeof userFormSchema>
|
||||
|
||||
interface UserDialogProps {
|
||||
open: boolean
|
||||
@@ -58,7 +50,7 @@ export function UserDialog({
|
||||
const isEditing = !!user
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(isEditing ? editUserFormSchema : createUserFormSchema),
|
||||
resolver: zodResolver(userFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
|
||||
Reference in New Issue
Block a user