import { useState, useCallback, useRef, useEffect } from 'react'; import { generateAnimation, getJobStatus, cancelJob, modifyAnimation } from '../../../infrastructure/api/api'; import { loadPrompts } from '../../prompts/hooks/usePrompts'; import type { GenerateRequest, GenerateResponse, JobResult, JobStageHistoryEntry, ProcessingStage, ModifyRequest } from '../../../shared/types/api'; import { useI18n } from '../../../shared/i18n/index'; import { localizeApiMessage } from '../../../shared/i18n/runtime'; type GenerationStatus = 'idle' | 'processing' | 'cancelling' | 'completed' | 'error'; interface UseGenerationReturn { status: GenerationStatus; result: JobResult | null; error: string | null; jobId: string | null; stage: ProcessingStage; stageHistory: JobStageHistoryEntry[]; submittedAt: string | null; generate: (request: GenerateRequest) => Promise; renderWithCode: (request: GenerateRequest & { code: string }) => Promise; modifyWithAI: (request: ModifyRequest) => Promise; reset: () => void; cancel: () => void; cancelAndReset: () => void; } interface PersistedActiveJob { jobId: string; } const POLL_INTERVAL = 1000; const MAX_TRANSIENT_POLL_ERRORS = 5; const ACTIVE_JOB_STORAGE_KEY = 'manimcat_active_job'; function readPersistedActiveJob(): PersistedActiveJob | null { const raw = sessionStorage.getItem(ACTIVE_JOB_STORAGE_KEY); if (!raw) { return null; } try { const parsed = JSON.parse(raw) as PersistedActiveJob; if (!parsed.jobId) { return null; } return { jobId: parsed.jobId, }; } catch { return null; } } export function useGeneration(): UseGenerationReturn { const { t, locale } = useI18n(); const [status, setStatus] = useState('idle'); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [jobId, setJobId] = useState(null); const [stage, setStage] = useState('queued'); const [stageHistory, setStageHistory] = useState([]); const [submittedAt, setSubmittedAt] = useState(null); const pollCountRef = useRef(0); const pollIntervalRef = useRef(null); const abortControllerRef = useRef(null); const transientPollErrorCountRef = useRef(0); const latestRevisionRef = useRef(0); const pollInFlightRef = useRef(false); const pollSessionRef = useRef(0); const clearPolling = useCallback(() => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } }, []); const persistActiveJob = useCallback((nextJobId: string) => { sessionStorage.setItem(ACTIVE_JOB_STORAGE_KEY, JSON.stringify({ jobId: nextJobId, })); }, []); const clearActiveJob = useCallback(() => { sessionStorage.removeItem(ACTIVE_JOB_STORAGE_KEY); }, []); const syncTransientStage = useCallback((nextJobId: string, nextStage: ProcessingStage, nextSubmittedAt: string | null) => { setStage(nextStage); setSubmittedAt(nextSubmittedAt); persistActiveJob(nextJobId); }, [persistActiveJob]); const startPolling = useCallback((nextJobId: string, initialStage: ProcessingStage, initialSubmittedAt: string | null) => { clearPolling(); const pollSession = ++pollSessionRef.current; pollCountRef.current = 0; transientPollErrorCountRef.current = 0; latestRevisionRef.current = 0; pollInFlightRef.current = false; setJobId(nextJobId); setStatus('processing'); setError(null); setResult(null); setStageHistory([]); syncTransientStage(nextJobId, initialStage, initialSubmittedAt); pollIntervalRef.current = window.setInterval(async () => { if (pollInFlightRef.current) { return; } pollInFlightRef.current = true; pollCountRef.current += 1; try { const data = await getJobStatus(nextJobId, abortControllerRef.current?.signal); if (pollSession !== pollSessionRef.current) { return; } transientPollErrorCountRef.current = 0; // Terminal results outlive the progress record, whose revision may reset to zero. // Accept the current polling session's terminal state before ordering live progress. if (data.status === 'completed') { clearPolling(); clearActiveJob(); setStatus('completed'); setResult(data); setStageHistory(data.stage_history ?? []); setSubmittedAt(data.submitted_at ?? initialSubmittedAt); return; } if (data.status === 'failed') { clearPolling(); clearActiveJob(); setStatus('error'); setStageHistory(data.stage_history ?? []); setSubmittedAt(data.submitted_at ?? initialSubmittedAt); if (data.cancel_reason) { setError(t('generation.cancelled', { reason: data.cancel_reason })); } else { setError(data.error ? localizeApiMessage(data.error) : t('generation.failed')); } return; } if (typeof data.revision === 'number') { if (data.revision < latestRevisionRef.current) { return; } latestRevisionRef.current = data.revision; } const nextSubmittedAt = data.submitted_at ?? initialSubmittedAt; if (Array.isArray(data.stage_history)) { setStageHistory(data.stage_history); } if (data.stage) { syncTransientStage(nextJobId, data.stage, nextSubmittedAt); } else { setSubmittedAt(nextSubmittedAt); persistActiveJob(nextJobId); } } catch (err) { if (pollSession !== pollSessionRef.current) { return; } if (err instanceof Error && err.name === 'AbortError') { return; } if (err instanceof Error && (err.message.includes('ECONNREFUSED') || err.message.includes('Failed to fetch'))) { transientPollErrorCountRef.current += 1; if (transientPollErrorCountRef.current <= MAX_TRANSIENT_POLL_ERRORS) { console.warn('Backend fetch failed, retry polling', { attempt: transientPollErrorCountRef.current, jobId: nextJobId, error: err.message, }); } return; } console.error('轮询错误:', err); if ( err instanceof Error && ( err.message.includes('未找到任务') || err.message.includes('失效') || err.message.includes('Job not found') || err.message.includes('expired') ) ) { clearPolling(); clearActiveJob(); setStatus('error'); setSubmittedAt(null); setError(t('generation.jobExpired')); return; } clearPolling(); setStatus('error'); setSubmittedAt(null); setError(err instanceof Error ? localizeApiMessage(err.message) : t('api.jobStatusFailed')); } finally { if (pollSession === pollSessionRef.current) { pollInFlightRef.current = false; } } }, POLL_INTERVAL); }, [clearActiveJob, clearPolling, persistActiveJob, syncTransientStage, t]); useEffect(() => { abortControllerRef.current = new AbortController(); const persisted = readPersistedActiveJob(); if (persisted) { startPolling(persisted.jobId, 'queued', null); } return () => { clearPolling(); abortControllerRef.current?.abort(); }; }, [clearPolling, startPolling]); const submitGeneration = useCallback(async ( request: GenerateRequest | ModifyRequest, executor: (payload: GenerateRequest | ModifyRequest, signal: AbortSignal) => Promise, initialStage: ProcessingStage, fallbackMessage: string, ) => { clearPolling(); pollSessionRef.current += 1; pollInFlightRef.current = false; setStatus('processing'); setError(null); setResult(null); setStage(initialStage); setStageHistory([]); pollCountRef.current = 0; latestRevisionRef.current = 0; abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); try { const promptOverrides = loadPrompts(locale); const response = await executor( { ...request, promptOverrides }, abortControllerRef.current.signal, ); startPolling(response.jobId, initialStage, response.submittedAt ?? null); } catch (err) { if (err instanceof Error && err.name === 'AbortError') { return; } clearActiveJob(); setStatus('error'); setSubmittedAt(null); setError(err instanceof Error ? err.message : fallbackMessage); } }, [clearActiveJob, clearPolling, locale, startPolling]); const generate = useCallback(async (request: GenerateRequest) => { await submitGeneration( request, (payload, signal) => generateAnimation(payload as GenerateRequest, signal), 'queued', t('generation.requestFailed'), ); }, [submitGeneration, t]); const renderWithCode = useCallback(async (request: GenerateRequest & { code: string }) => { await submitGeneration( request, (payload, signal) => generateAnimation(payload as GenerateRequest, signal), 'rendering', t('generation.rerenderFailed'), ); }, [submitGeneration, t]); const modifyWithAI = useCallback(async (request: ModifyRequest) => { await submitGeneration( request, (payload, signal) => modifyAnimation(payload as ModifyRequest, signal), 'generating', t('generation.modifyFailed'), ); }, [submitGeneration, t]); const reset = useCallback(() => { clearPolling(); pollSessionRef.current += 1; pollInFlightRef.current = false; abortControllerRef.current?.abort(); clearActiveJob(); setStatus('idle'); setError(null); setResult(null); setJobId(null); setStage('queued'); setStageHistory([]); setSubmittedAt(null); latestRevisionRef.current = 0; }, [clearActiveJob, clearPolling]); const runCancel = useCallback((resetAfterCancel: boolean) => { if (!jobId) { if (resetAfterCancel) { reset(); } return; } clearPolling(); pollSessionRef.current += 1; pollInFlightRef.current = false; abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); setStatus('cancelling'); setError(null); void (async () => { try { await cancelJob(jobId); clearActiveJob(); setResult(null); setJobId(null); setStage('queued'); setStageHistory([]); setSubmittedAt(null); if (resetAfterCancel) { setStatus('idle'); setError(null); } else { setStatus('error'); setError(t('generation.cancelled', { reason: 'Cancelled by client' })); } } catch (err) { console.warn(t('generation.cancelFailed'), err); startPolling(jobId, stage, submittedAt); } })(); }, [clearActiveJob, clearPolling, jobId, reset, stage, startPolling, submittedAt, t]); const cancel = useCallback(() => { runCancel(false); }, [runCancel]); const cancelAndReset = useCallback(() => { runCancel(true); }, [runCancel]); return { status, result, error, jobId, stage, stageHistory, submittedAt, generate, renderWithCode, modifyWithAI, reset, cancel, cancelAndReset, }; }