/** * generationStore — SSE进度 / 图片候选 / 飞轮注入 * image_candidate 无 batch 字段,所以 imageCandidates 平铺存储 */ import { create } from 'zustand'; import { SseConnectionStatus } from '@/lib/sse'; import { TextCandidate, ImageCandidate } from '@/types/index'; // 懒引入 taskStore,避免循环依赖(taskStore 不引 generationStore) import { useTaskStore } from '@/stores/taskStore'; interface FailedBatch { batch: string; // 图片角色名("hook"/"pain"等),对应 BE batch_failed.batch 字段 reason: string; retryable: boolean; } // 生成阶段(驱动阶段进度条,对齐产品包"有阶段进度"体验 #3#4) // idle→分析竞品→生成文案→配图→完成;failed 单列 export type GenStage = 'idle' | 'analyzing' | 'text' | 'image' | 'done' | 'failed'; // 文案产出结果原因(B1)——task_done 透传,区分"完成但没出文案"的真实原因 export type TextOutcomeReason = | 'scoring_unavailable' // 评分服务不可用(apiports+codeproxy都挂) | 'generation_failed' // 文案生成本身失败 | 'quality_filtered' // 生成了但全部未达标 | 'replenishing' // 有产出但不足,后台补充中 | null; // 正常足量 export interface TextOutcome { saved: number; target: number; reason: TextOutcomeReason; } interface GenerationState { // 连接状态 connectionStatus: SseConnectionStatus; reconnectAttempt: number; // 当前生成阶段(#3#4 阶段进度) stage: GenStage; // 文案生成进度 textDone: number; textTotal: number; textCandidates: TextCandidate[]; // 图片生成进度(平铺,不分 batch — 契约无 batch 字段) imageDone: number; imageTotal: number; imageCandidates: ImageCandidate[]; failedBatches: FailedBatch[]; // 任务级致命错误(SSE error事件,生成彻底失败)。区别于图片批次级 failedBatches。 fatalError: { code: number; message: string } | null; // 飞轮注入(飞轮隐形,只显注入提示,无"训练AI"按钮) flywheelInjected: { recentPreference: string; rejectReasons: string[]; signalCount?: number; } | null; // 任务启动时间戳(ms),task_started 事件到达时记录,用于 ETA 推算(A1/A2) startedAt: number | null; // 文案产出结果(B1)——task_done 到达时落定,驱动"完成但没出文案"的明确提示 textOutcome: TextOutcome | null; setConnectionStatus: (s: SseConnectionStatus, attempt?: number) => void; setStage: (stage: GenStage) => void; setStartedAt: (ts: number) => void; setTextOutcome: (outcome: TextOutcome) => void; setTextProgress: (done: number, total: number) => void; addTextCandidate: (candidate: TextCandidate) => void; patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void; patchTextCandidateContent: (candidateId: number, content: string) => void; removeTextCandidate: (candidateId: number) => void; removeImageCandidate: (candidateId: number) => void; setImageProgress: (done: number, total: number) => void; addImageCandidate: (candidate: ImageCandidate) => void; markBatchFailed: (batch: string, reason: string, retryable: boolean) => void; setFatalError: (code: number, message: string) => void; setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[]; signalCount?: number }) => void; hydrateFromTask: (data: { textCandidates: TextCandidate[]; imageCandidates: ImageCandidate[]; }) => void; reset: () => void; } export const useGenerationStore = create((set) => ({ connectionStatus: 'connecting', reconnectAttempt: 0, stage: 'idle', startedAt: null, textOutcome: null, textDone: 0, textTotal: 0, textCandidates: [], imageDone: 0, imageTotal: 0, imageCandidates: [], failedBatches: [], fatalError: null, flywheelInjected: null, setConnectionStatus(s, attempt = 0) { set({ connectionStatus: s, reconnectAttempt: attempt }); }, setStage(stage) { set({ stage }); }, setStartedAt(ts) { set({ startedAt: ts }); }, setTextOutcome(outcome) { set({ textOutcome: outcome }); }, setTextProgress(done, total) { set({ textDone: done, textTotal: total }); }, addTextCandidate(candidate) { set((s) => { const exists = s.textCandidates.some((tc) => tc.candidate_id === candidate.candidate_id); return { textCandidates: exists ? s.textCandidates.map((tc) => tc.candidate_id === candidate.candidate_id ? { ...tc, ...candidate } : tc) : [...s.textCandidates, candidate], }; }); }, patchTextCandidateScore(candidateId, score) { set((s) => ({ textCandidates: s.textCandidates.map((tc) => tc.candidate_id === candidateId ? { ...tc, score } : tc ), })); }, patchTextCandidateContent(candidateId, content) { set((s) => ({ textCandidates: s.textCandidates.map((tc) => tc.candidate_id === candidateId ? { ...tc, content, edited: true } : tc ), })); }, removeTextCandidate(candidateId) { set((s) => ({ textCandidates: s.textCandidates.filter((tc) => tc.candidate_id !== candidateId), })); // 同步清选中态 useTaskStore.getState().deselectText(candidateId); }, removeImageCandidate(candidateId) { set((s) => ({ imageCandidates: s.imageCandidates.filter((ic) => ic.candidate_id !== candidateId), })); useTaskStore.getState().deselectImage(candidateId); }, setImageProgress(done, total) { set({ imageDone: done, imageTotal: total }); }, addImageCandidate(candidate) { set((s) => { const exists = s.imageCandidates.some((ic) => ic.candidate_id === candidate.candidate_id); return { imageCandidates: exists ? s.imageCandidates.map((ic) => ic.candidate_id === candidate.candidate_id ? { ...ic, ...candidate } : ic) : [...s.imageCandidates, candidate], }; }); }, markBatchFailed(batch, reason, retryable) { set((s) => ({ failedBatches: [...s.failedBatches, { batch, reason, retryable }], })); }, setFatalError(code, message) { set({ fatalError: { code, message } }); }, setFlywheelInjected(data) { set({ flywheelInjected: data }); }, // 进入已完成/进行中任务时一次性回填(历史任务 SSE 不重放,必须主动拉接口) // 同时把带 is_selected 标记的候选 id 写进 taskStore,修复跨页选中态丢失(B2) hydrateFromTask({ textCandidates, imageCandidates }) { set({ textCandidates, textDone: textCandidates.length, textTotal: textCandidates.length || 0, imageCandidates, imageDone: imageCandidates.length, imageTotal: imageCandidates.length || 0, }); // 若候选带 is_selected 字段,同步还原 taskStore 选中态 const selectedTexts = textCandidates .filter((c) => (c as TextCandidate & { is_selected?: boolean }).is_selected) .map((c) => c.candidate_id); const selectedImages = imageCandidates .filter((c) => (c as ImageCandidate & { is_selected?: boolean }).is_selected) .map((c) => c.candidate_id); // 方案B(倩倩姐2026-06-26拍板):选中回填只做「首次进页恢复历史」。 // 本地已有选中=用户正在挑(图片/文案选中只存本地、点"去生图/去确认"才提交后端), // 此时 5 秒轮询的后端快照绝不能整体覆盖本地,否则未提交的选中过几秒就被抹掉。 const taskState = useTaskStore.getState(); const localHasSelection = taskState.selectedTextIds.length > 0 || taskState.selectedImageIds.length > 0; if (!localHasSelection && (selectedTexts.length || selectedImages.length)) { taskState.setSelections(selectedTexts, selectedImages); } }, reset() { set({ connectionStatus: 'connecting', reconnectAttempt: 0, stage: 'idle', textDone: 0, textTotal: 0, textCandidates: [], imageDone: 0, imageTotal: 0, imageCandidates: [], failedBatches: [], fatalError: null, flywheelInjected: null, startedAt: null, textOutcome: null, }); }, }));