'use client'; /** * useSse — SSE 客户端 React hook * 接收 SSE 事件,分派到 generationStore * event_seq 去重 + 断线重连UI * 五维分:SSE 推 total,五维详情通过 GET /tasks/{id} 补全 */ import { useEffect, useRef } from 'react'; import { createSseClient, SseClient, SseConnectionStatus } from '@/lib/sse'; import { SseEvent } from '@/types/sse'; import { useGenerationStore } from '@/stores/generationStore'; import { TextCandidate, ImageCandidate } from '@/types/index'; import { api } from '@/lib/api'; export function useSse(taskId: number | null) { const clientRef = useRef(null); const store = useGenerationStore(); useEffect(() => { if (!taskId) return; store.reset(); // reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。 // 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成 // reset 把 hydrate 数据清空的竞态(SPA 跳转进来会看到空白)。 let cancelled = false; api.get<{ text_candidates?: TextCandidate[]; image_candidates?: ImageCandidate[]; }>(`/api/v1/tasks/${taskId}`).then((task) => { if (cancelled) return; const texts = task.text_candidates ?? []; const images = task.image_candidates ?? []; if (texts.length || images.length) { store.hydrateFromTask({ textCandidates: texts, imageCandidates: images }); } }).catch(() => { /* 进行中任务靠 SSE 填充 */ }); const client = createSseClient(taskId, { onEvent: (event: SseEvent) => handleEvent(event, store, taskId), onStatusChange: (status: SseConnectionStatus, attempt?: number) => { store.setConnectionStatus(status, attempt); }, }); clientRef.current = client; return () => { cancelled = true; client.close(); clientRef.current = null; }; }, [taskId]); return { connectionStatus: store.connectionStatus, reconnectAttempt: store.reconnectAttempt, }; } function handleEvent( event: SseEvent, store: ReturnType, taskId: number, ) { switch (event.type) { case 'task_started': store.setTextProgress(0, event.data.total_text); store.setImageProgress(0, event.data.total_image); break; case 'text_progress': store.setTextProgress(event.data.done, event.data.total); break; case 'text_candidate': { // SSE 只推 total 分;先用 total 占位,再异步补全五维分 const totalScore = typeof event.data.score === 'number' ? event.data.score : 0; const tc: TextCandidate = { candidate_id: event.data.candidate_id, angle_label: event.data.angle_label, content: event.data.content, source: 'ai', score: { title: 0, emotion: 0, selling: 0, keyword: 0, compliance: 0, total: totalScore }, banned_word_status: 'pass', }; store.addTextCandidate(tc); // 异步补全五维分(不阻塞 SSE) api.get<{ text_candidates?: Array<{ candidate_id: number; score?: TextCandidate['score'] }> }>( `/api/v1/tasks/${taskId}` ).then((task) => { const found = task.text_candidates?.find((c) => c.candidate_id === tc.candidate_id); if (found?.score) store.patchTextCandidateScore(tc.candidate_id, found.score); }).catch(() => { /* 五维分补全失败不影响主流程 */ }); break; } case 'image_progress': store.setImageProgress(event.data.done, event.data.total); break; case 'image_candidate': { const ic: ImageCandidate = { candidate_id: event.data.candidate_id, strategy: event.data.strategy, url: event.data.url, role: event.data.role as ImageCandidate['role'], }; store.addImageCandidate(ic); break; } case 'batch_failed': store.markBatchFailed(event.data.batch, event.data.reason, event.data.retryable); break; case 'flywheel_injected': store.setFlywheelInjected({ recentPreference: event.data.recent_preference, rejectReasons: event.data.reject_reasons, }); break; case 'error': case 'heartbeat': case 'analyze_done': case 'task_done': break; } }