feat: Enhance API interactions and improve job handling with new request validation and error management

This commit is contained in:
2026-03-06 12:03:41 +08:00
parent 3844e825cd
commit a93754f449
15 changed files with 204 additions and 74 deletions

View File

@@ -47,15 +47,15 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
const PRESET_INSTRUCTS = useMemo(() => tConstants('presetInstructs', { returnObjects: true }) as Array<{ label: string; instruct: string; text: string }>, [tConstants])
const formSchema = z.object({
text: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.text') })).max(5000, tErrors('validation.maxLength', { field: tErrors('fieldNames.text'), max: 5000 })),
text: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.text') })).max(1000, tErrors('validation.maxLength', { field: tErrors('fieldNames.text'), max: 1000 })),
language: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.language') })),
speaker: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.speaker') })),
instruct: z.string().optional(),
max_new_tokens: z.number().min(1).max(10000).optional(),
temperature: z.number().min(0).max(2).optional(),
max_new_tokens: z.number().min(128).max(4096).optional(),
temperature: z.number().min(0.1).max(2).optional(),
top_k: z.number().min(1).max(100).optional(),
top_p: z.number().min(0).max(1).optional(),
repetition_penalty: z.number().min(0).max(2).optional(),
repetition_penalty: z.number().min(1).max(2).optional(),
})
const [languages, setLanguages] = useState<Language[]>([])
const [unifiedSpeakers, setUnifiedSpeakers] = useState<UnifiedSpeakerItem[]>([])
@@ -395,8 +395,8 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
<Input
id="dialog-max_new_tokens"
type="number"
min={1}
max={10000}
min={128}
max={4096}
value={tempAdvancedParams.max_new_tokens}
onChange={(e) => setTempAdvancedParams({
...tempAdvancedParams,
@@ -414,7 +414,7 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
<Input
id="dialog-temperature"
type="number"
min={0}
min={0.1}
max={2}
step={0.1}
value={tempAdvancedParams.temperature}

View File

@@ -49,17 +49,17 @@ function VoiceCloneForm() {
const PRESET_REF_TEXTS = useMemo(() => tConstants('presetRefTexts', { returnObjects: true }) as Array<{ label: string; text: string }>, [tConstants])
const formSchema = z.object({
text: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.text') })).max(5000, tErrors('validation.maxLength', { field: tErrors('fieldNames.text'), max: 5000 })),
text: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.text') })).max(1000, tErrors('validation.maxLength', { field: tErrors('fieldNames.text'), max: 1000 })),
language: z.string().optional(),
ref_audio: z.instanceof(File, { message: tErrors('validation.required', { field: tErrors('fieldNames.reference_audio') }) }),
ref_text: z.string().optional(),
use_cache: z.boolean().optional(),
x_vector_only_mode: z.boolean().optional(),
max_new_tokens: z.number().min(1).max(10000).optional(),
temperature: z.number().min(0).max(2).optional(),
max_new_tokens: z.number().min(128).max(4096).optional(),
temperature: z.number().min(0.1).max(2).optional(),
top_k: z.number().min(1).max(100).optional(),
top_p: z.number().min(0).max(1).optional(),
repetition_penalty: z.number().min(0).max(2).optional(),
repetition_penalty: z.number().min(1).max(2).optional(),
})
const [languages, setLanguages] = useState<Language[]>([])
const [isLoading, setIsLoading] = useState(false)
@@ -358,8 +358,8 @@ function VoiceCloneForm() {
<Input
id="dialog-max_new_tokens"
type="number"
min={1}
max={10000}
min={128}
max={4096}
value={tempAdvancedParams.max_new_tokens}
onChange={(e) => setTempAdvancedParams({
...tempAdvancedParams,

View File

@@ -46,14 +46,14 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
const PRESET_VOICE_DESIGNS = useMemo(() => tConstants('presetVoiceDesigns', { returnObjects: true }) as Array<{ label: string; instruct: string; text: string }>, [tConstants])
const formSchema = z.object({
text: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.text') })).max(5000, tErrors('validation.maxLength', { field: tErrors('fieldNames.text'), max: 5000 })),
text: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.text') })).max(1000, tErrors('validation.maxLength', { field: tErrors('fieldNames.text'), max: 1000 })),
language: z.string().min(1, tErrors('validation.required', { field: tErrors('fieldNames.language') })),
instruct: z.string().min(10, tErrors('validation.minLength', { field: tErrors('fieldNames.instruct'), min: 10 })).max(500, tErrors('validation.maxLength', { field: tErrors('fieldNames.instruct'), max: 500 })),
max_new_tokens: z.number().min(1).max(10000).optional(),
temperature: z.number().min(0).max(2).optional(),
max_new_tokens: z.number().min(128).max(4096).optional(),
temperature: z.number().min(0.1).max(2).optional(),
top_k: z.number().min(1).max(100).optional(),
top_p: z.number().min(0).max(1).optional(),
repetition_penalty: z.number().min(0).max(2).optional(),
repetition_penalty: z.number().min(1).max(2).optional(),
})
const [languages, setLanguages] = useState<Language[]>([])
const [isLoading, setIsLoading] = useState(false)
@@ -310,8 +310,8 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
<Input
id="dialog-max_new_tokens"
type="number"
min={1}
max={10000}
min={128}
max={4096}
value={tempAdvancedParams.max_new_tokens}
onChange={(e) => setTempAdvancedParams({
...tempAdvancedParams,
@@ -329,7 +329,7 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
<Input
id="dialog-temperature"
type="number"
min={0}
min={0.1}
max={2}
step={0.1}
value={tempAdvancedParams.temperature}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from 'react'
import { toast } from 'sonner'
import { jobApi } from '@/lib/api'
import type { Job, JobStatus } from '@/types/job'
@@ -25,13 +25,27 @@ export function JobProvider({ children }: { children: ReactNode }) {
const [elapsedTime, setElapsedTime] = useState(0)
const { refresh: historyRefresh } = useHistoryContext()
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const clearIntervals = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
pollIntervalRef.current = null
}
if (timeIntervalRef.current) {
clearInterval(timeIntervalRef.current)
timeIntervalRef.current = null
}
}, [])
const stopJob = useCallback(() => {
clearIntervals()
setCurrentJob(null)
setStatus(null)
setError(null)
setElapsedTime(0)
}, [])
}, [clearIntervals])
const resetJob = useCallback(() => {
setError(null)
@@ -45,15 +59,13 @@ export function JobProvider({ children }: { children: ReactNode }) {
}, [])
const startJob = useCallback((jobId: number) => {
clearIntervals()
// Reset state for new job
setCurrentJob(null)
setStatus('pending')
setError(null)
setElapsedTime(0)
let pollInterval: ReturnType<typeof setInterval> | null = null
let timeInterval: ReturnType<typeof setInterval> | null = null
const poll = async () => {
try {
const job = await jobApi.getJob(jobId)
@@ -61,15 +73,13 @@ export function JobProvider({ children }: { children: ReactNode }) {
setStatus(job.status)
if (job.status === 'completed') {
if (pollInterval) clearInterval(pollInterval)
if (timeInterval) clearInterval(timeInterval)
clearIntervals()
toast.success('任务完成!')
try {
historyRefresh()
} catch {}
} else if (job.status === 'failed') {
if (pollInterval) clearInterval(pollInterval)
if (timeInterval) clearInterval(timeInterval)
clearIntervals()
setError(job.error_message || '任务失败')
toast.error(job.error_message || '任务失败')
try {
@@ -77,8 +87,7 @@ export function JobProvider({ children }: { children: ReactNode }) {
} catch {}
}
} catch (error: any) {
if (pollInterval) clearInterval(pollInterval)
if (timeInterval) clearInterval(timeInterval)
clearIntervals()
const message = error.response?.data?.detail || '获取任务状态失败'
setError(message)
toast.error(message)
@@ -86,16 +95,17 @@ export function JobProvider({ children }: { children: ReactNode }) {
}
poll()
pollInterval = setInterval(poll, POLL_INTERVAL)
timeInterval = setInterval(() => {
pollIntervalRef.current = setInterval(poll, POLL_INTERVAL)
timeIntervalRef.current = setInterval(() => {
setElapsedTime((prev) => prev + 1)
}, 1000)
}, [historyRefresh, clearIntervals])
useEffect(() => {
return () => {
if (pollInterval) clearInterval(pollInterval)
if (timeInterval) clearInterval(timeInterval)
clearIntervals()
}
}, [historyRefresh])
}, [clearIntervals])
const value = useMemo(
() => ({

View File

@@ -13,9 +13,22 @@ const apiClient = axios.create({
},
})
const isTrustedApiRequest = (url?: string, baseURL?: string): boolean => {
if (!url) return false
if (url.startsWith('/')) return true
try {
const resolvedUrl = new URL(url, baseURL || window.location.origin)
const apiOrigin = baseURL ? new URL(baseURL, window.location.origin).origin : window.location.origin
return resolvedUrl.origin === apiOrigin
} catch {
return false
}
}
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
if (token && isTrustedApiRequest(config.url, config.baseURL || import.meta.env.VITE_API_URL)) {
config.headers.Authorization = `Bearer ${token}`
}
return config
@@ -346,11 +359,23 @@ export const jobApi = {
getAudioUrl: (id: number, audioPath?: string): string => {
if (audioPath) {
if (audioPath.startsWith('http')) {
const apiBase = import.meta.env.VITE_API_URL
if (apiBase) {
try {
const audioOrigin = new URL(audioPath).origin
const apiOrigin = new URL(apiBase, window.location.origin).origin
if (audioOrigin !== apiOrigin) {
return API_ENDPOINTS.JOBS.AUDIO(id)
}
} catch {
return API_ENDPOINTS.JOBS.AUDIO(id)
}
}
if (audioPath.includes('localhost') || audioPath.includes('127.0.0.1')) {
const url = new URL(audioPath)
return url.pathname
}
return audioPath
return API_ENDPOINTS.JOBS.AUDIO(id)
} else {
return audioPath.startsWith('/') ? audioPath : `/${audioPath}`
}