/** * AI 写作前端 hook —— LangGraph Server 路径(阶段 3)。 * * 对外 API 与老 hook ``useAIWriting`` 完全一致:返回 * ``{ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }``, * 所以 ``AIWritingPanel`` 只需要按 env 开关切 import,不用改任何组件。 * * 底层差别(参见 ai-writing-优化.md §阶段 3): * - 老 hook 走 ``POST /start`` + ``GET /{id}/stream`` + ``POST /{id}/resume`` 自写 SSE * - 本 hook 用 ``@langchain/langgraph-sdk/react`` 的 ``useStream``,assistant=``ai_writing``, * thread 和 SSE 协议都交给 LangGraph Server 管。``resume`` 改成 ``Command(resume=...)``。 * * 事件翻译层: * - LangGraph ``custom`` 通道(节点 ``get_stream_writer`` 推) → * ``ProgressEvent`` { type=draft_chunk/outline_chunk/research_progress/review_chunk/review_item_done } * - LangGraph ``updates`` 通道(节点 return 的 state delta) → 合成 * ``materials_ready`` / ``outline_ready`` / ``draft_ready`` / ``review_ready`` / * ``notice`` / ``step_*`` (后端节点把这些塞进 ``progress_events`` 数组里 return) * - ``stream.interrupt`` → ``await_user`` 事件 * * 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码, * 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。 */ import { countArticleWords } from '../utils/wordCount'; import type { AIWritingRequest, AIWritingState, AIMaterial, AIOutline, BlockedSection, CompletedIntervention, ParsedIntent, ProgressEvent, ProgressEventType, ReviewIssue, ReviewResult, SkillSearchStep, StreamingReviewItem, UserInterventionPayload, WritingStatus, } from '../types/ai-writing'; const INITIAL_STATE: AIWritingState = { sessionId: null, status: 'idle', request: null, isPaused: false, progressEvents: [], materials: [], outline: null, currentDraftMarkdown: '', currentDraftTitle: '', reviewResult: null, sampleStyleProfile: null, revisionCount: 0, currentPause: null, completedInterventions: [], error: null, streamingDraftTitle: '', streamingDraftSections: [], outlineStreamingText: '', outlineThinkingText: '', sampleAnalysisStreamingText: '', sampleAnalysisThinkingText: '', intentStreamingText: '', intentThinkingText: '', parsedIntent: null, researchProgress: '', keywordStreamingText: '', keywordThinkingText: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '', skillSearchPending: false, streamingReviewItems: [], revisionSummary: null, pendingRevisionContext: null, isRevising: false, }; export { INITIAL_STATE }; // reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理 // 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。 export type Action = | { type: 'handle'; event: ProgressEvent } | { type: 'handle_batch'; events: ProgressEvent[] } | { type: 'start'; request: AIWritingRequest } | { type: 'set_session'; sessionId: string } | { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext']; materialSource?: AIWritingRequest['materialSource'] } | { type: 'pause' } | { type: 'resume_ui' } // Restore history: rebuild state from the graph checkpoint values snapshot. | { type: 'rehydrate'; snapshot: Partial } // 手动「继续」从检查点重跑当前阶段前,清空该阶段的流式缓冲(避免与重跑的新输出粘连)。 | { type: 'clear_streaming' } | { type: 'reset' } | { type: 'set_error'; message: string }; export function reducer(prev: AIWritingState, action: Action): AIWritingState { switch (action.type) { case 'handle': return handleEvent(prev, action.event); case 'handle_batch': return action.events.reduce(handleEvent, prev); case 'start': return { ...INITIAL_STATE, status: action.request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching', request: action.request, }; case 'set_session': return { ...prev, sessionId: action.sessionId }; case 'intervention_submitted': { const ns = action.nextStatus; // 点击瞬间标记技能检索 pending,让 SkillStepsCard 占位卡立即挂载。 // 通用检索现在统一走技能编排:素材确认卡上任何回到 researching 的动作 // (重新搜索 / 按输入追加检索)都会产生 skill_search_step 流。 const enablingSkillSearch = action.record.action === 'enable_skill_search' || (ns === 'researching' && action.record.pausePoint === 'material_confirm'); return { ...prev, currentPause: null, status: ns, skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending, ...(ns === 'researching' ? { researchProgress: '', keywordStreamingText: '', keywordThinkingText: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '', } : {}), ...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}), ...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}), ...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}), // 进入「修改」:右侧画布恢复初始空占位(isRevising),不再显示旧稿/流式重写 ...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '', isRevising: true } : {}), ...(action.materialSource && prev.request ? { request: { ...prev.request, materialSource: action.materialSource } } : {}), completedInterventions: [...prev.completedInterventions, action.record], pendingRevisionContext: action.pendingRevisionContext, }; } case 'pause': return { ...prev, isPaused: true }; case 'resume_ui': return { ...prev, isPaused: false }; case 'rehydrate': // Restore history: layer checkpoint snapshot over INITIAL_STATE. return { ...INITIAL_STATE, ...action.snapshot, isPaused: false }; case 'clear_streaming': // 「继续」重跑当前阶段:清掉各阶段流式缓冲,重跑的输出从干净状态累积。 return { ...prev, isPaused: false, sampleAnalysisStreamingText: '', sampleAnalysisThinkingText: '', intentStreamingText: '', intentThinkingText: '', outlineStreamingText: '', outlineThinkingText: '', streamingDraftSections: [], streamingDraftTitle: '', streamingReviewItems: [], keywordStreamingText: '', keywordThinkingText: '', skillSelectStreamingText: '', skillSelectThinkingText: '', }; case 'reset': return INITIAL_STATE; case 'set_error': return { ...prev, status: 'error', error: action.message }; default: return prev; } } // 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。 // 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题), // 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。 export function assembleStreamingDraftMarkdown( sections: AIWritingState['streamingDraftSections'], title: string, ): string { const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex); const body = ordered .map((s) => { const heading = s.sectionTitle ? `## ${s.sectionTitle}` : ''; const text = s.text ?? ''; return heading ? `${heading}\n\n${text}` : text; }) .filter((part) => part.trim().length > 0) .join('\n\n'); const head = title ? `# ${title}\n\n` : ''; return `${head}${body}`; } // 与老 hook 行为对齐的事件 reducer —— 直接从 useAIWriting.ts 移植,删掉 setArtifact 副作用 // (副作用单独提到 hook 顶层 useEffect 里处理)。 function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState { const base: AIWritingState = { ...prev, progressEvents: [...prev.progressEvents, event], }; switch (event.type) { case 'step_start': if (event.agentName === '编辑' && prev.status !== 'reviewing') { return { ...base, status: 'reviewing' }; } return base; case 'step_done': return base; case 'sample_chunk': { const text = (event as unknown as { text: string }).text ?? ''; const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; return { ...prev, sampleAnalysisStreamingText: isThinking ? prev.sampleAnalysisStreamingText : prev.sampleAnalysisStreamingText + text, sampleAnalysisThinkingText: isThinking ? prev.sampleAnalysisThinkingText + text : prev.sampleAnalysisThinkingText, }; } case 'intent_chunk': { const text = (event as unknown as { text: string }).text ?? ''; const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; return { ...prev, intentStreamingText: isThinking ? prev.intentStreamingText : prev.intentStreamingText + text, intentThinkingText: isThinking ? prev.intentThinkingText + text : prev.intentThinkingText, }; } case 'intent_ready': { const ev = event as unknown as { articleType?: string; article_type?: string; targetAudience?: string; target_audience?: string; wordCountTarget?: number; word_count_target?: number; refinedIntent?: string; refined_intent?: string; }; const parsed: ParsedIntent = { articleType: ev.articleType ?? ev.article_type ?? '', targetAudience: ev.targetAudience ?? ev.target_audience ?? '', wordCountTarget: Number(ev.wordCountTarget ?? ev.word_count_target ?? 800), refinedIntent: ev.refinedIntent ?? ev.refined_intent ?? '', }; return { ...base, parsedIntent: parsed, intentStreamingText: '', intentThinkingText: '', }; } case 'research_progress': return { ...base, researchProgress: event.message ?? '' }; case 'keyword_chunk': { const text = (event as unknown as { text: string }).text ?? ''; const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; const visibleText = text || (prev.keywordStreamingText ? '' : ' '); return { ...prev, status: 'researching', keywordStreamingText: isThinking ? prev.keywordStreamingText : prev.keywordStreamingText + visibleText, keywordThinkingText: isThinking ? prev.keywordThinkingText + text : prev.keywordThinkingText, }; } case 'sample_profile_ready': return { ...base, sampleStyleProfile: event.sampleStyleProfile ?? null, sampleAnalysisStreamingText: '', sampleAnalysisThinkingText: '', status: 'researching', }; case 'resumed': return { ...base, currentPause: null }; case 'skill_search_step': { const step = event.skillStep; if (!step) return base; // select_skills 步骤到达 = LLM 选择完成,清空流式打字缓冲 const clearStreaming = step.type === 'select_skills' ? { skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}; return { ...base, skillSearchSteps: [...prev.skillSearchSteps, step], ...clearStreaming }; } case 'skill_select_chunk': { const text = (event as unknown as { text: string }).text ?? ''; const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; return { ...prev, skillSelectStreamingText: isThinking ? prev.skillSelectStreamingText : prev.skillSelectStreamingText + text, skillSelectThinkingText: isThinking ? prev.skillSelectThinkingText + text : prev.skillSelectThinkingText, }; } case 'materials_ready': { // 后端把 pkg.skill_search_steps 一并塞回来时,覆盖前端累积(保证刷新页面回放也对) const incomingSteps = (event as unknown as { skillSearchSteps?: SkillSearchStep[] }).skillSearchSteps; return { ...base, materials: (event.materials ?? []) as AIMaterial[], researchProgress: '', keywordStreamingText: '', keywordThinkingText: '', ...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}), }; } case 'outline_chunk': { const text = (event as unknown as { text: string }).text ?? ''; const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; return { ...prev, status: 'writing_outline', outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + text, outlineThinkingText: isThinking ? prev.outlineThinkingText + text : prev.outlineThinkingText, }; } case 'outline_ready': return { ...base, outline: (event.outline ?? null) as AIOutline | null, outlineStreamingText: '', outlineThinkingText: '', }; case 'draft_chunk': { const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0; const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? ''; const text = (event as unknown as { text: string }).text ?? ''; const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false; const sections = prev.streamingDraftSections.map((s) => ({ ...s })); const existing = sections.find((s) => s.sectionIndex === idx); if (existing) { if (isThinking) existing.thinkingText += text; else existing.text += text; existing.isDone = done; } else { sections.push({ sectionIndex: idx, sectionTitle: title, text: isThinking ? '' : text, thinkingText: isThinking ? text : '', isDone: done, }); } const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || ''; // 流式阶段只累积章节片段;右侧 Markdown 由面板按需拼装,避免每 chunk 改 currentDraftMarkdown // 触发 DraftSync / 全树重渲染导致更新风暴。 return { ...prev, status: 'writing_draft', streamingDraftSections: sections, streamingDraftTitle: streamingTitle, currentDraftTitle: streamingTitle || prev.currentDraftTitle, }; } case 'review_chunk': { const category = (event as unknown as { category: StreamingReviewItem['category'] }).category; const text = (event as unknown as { text: string }).text ?? ''; const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; const isNewCycle = prev.status !== 'reviewing'; const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it })); let item = items.find((it) => it.category === category); if (!item) { item = { category, isDone: false, streamingText: '', thinkingText: '', score: 0, issues: [] }; items.push(item); } if (isThinking) item.thinkingText += text; else item.streamingText += text; return { ...prev, status: 'reviewing', streamingReviewItems: items }; } case 'review_item_done': { const category = event.reviewItemCategory ?? 'fact'; const items = prev.streamingReviewItems.map((it) => ({ ...it })); const item = items.find((it) => it.category === category); if (item) { item.isDone = true; item.score = event.reviewItemScore ?? 80; item.issues = event.reviewItemIssues ?? []; } else { items.push({ category, isDone: true, streamingText: '', thinkingText: '', score: event.reviewItemScore ?? 80, issues: event.reviewItemIssues ?? [], }); } return { ...prev, status: 'reviewing', streamingReviewItems: items }; } case 'draft_ready': { const newMarkdown = event.draftMarkdown ?? ''; const newWordCount = countArticleWords(newMarkdown); const summary = prev.pendingRevisionContext ? { revisedIssues: prev.pendingRevisionContext.submittedIssues, skippedIssues: prev.pendingRevisionContext.skippedIssues, oldWordCount: prev.pendingRevisionContext.oldWordCount, newWordCount, } : null; return { ...base, currentDraftMarkdown: newMarkdown, currentDraftTitle: event.draftTitle ?? '', revisionCount: event.revisionCount ?? 0, streamingDraftSections: [], streamingDraftTitle: '', revisionSummary: summary, pendingRevisionContext: null, // 修改完成:解除右侧空占位,展示新成稿 isRevising: false, }; } case 'review_ready': return { ...base, reviewResult: (event.reviewResult ?? null) as ReviewResult | null }; case 'await_user': { const pp = event.pausePoint; if (prev.currentPause?.pausePoint === pp) return prev; const pauseStatus = pp ? (`awaiting_${pp}` as AIWritingState['status']) : prev.status; const extra: Partial = {}; if (pp === 'material_confirm' && event.materials?.length) extra.materials = event.materials; if (pp === 'outline_confirm' && event.outline) extra.outline = event.outline; // 进入素材确认(material_confirm)= 技能检索(如果在进行)已经完成,清掉 pending 占位 const clearPending = pp === 'material_confirm' ? { skillSearchPending: false } : {}; return { ...base, ...extra, ...clearPending, status: pauseStatus, currentPause: event }; } case 'done': return { ...base, status: 'done', currentPause: null }; case 'error': { const msg = event.detail ? `${event.title ?? '出错了'}:${event.detail}` : (event.message ?? '未知错误'); return { ...base, status: 'error', error: msg }; } case 'notice': return base; case 'model_switch': { // 模型自动容错:丢弃当前阶段已累积的流式缓冲,避免上一个失败模型的半截输出 // 与下一个模型的输出粘在一起;时间线展示侧由 TimelineStep 按 amber 渲染(同 notice)。 const stage = event.modelSwitch?.stage; const sectionIndex = event.modelSwitch?.sectionIndex; const resetByStage: Partial = (() => { switch (stage) { case 'intent': return { intentStreamingText: '', intentThinkingText: '' }; case 'outline': return { outlineStreamingText: '', outlineThinkingText: '' }; case 'draft': { // 只清掉失败那一节的流式片段,已写好的章节保持不动 if (typeof sectionIndex === 'number') { return { streamingDraftSections: prev.streamingDraftSections.filter( (s) => s.sectionIndex !== sectionIndex, ), }; } return { streamingDraftSections: [], streamingDraftTitle: '' }; } case 'review': return { streamingReviewItems: [] }; default: return {}; } })(); return { ...base, ...resetByStage }; } default: return base; } } // ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ────── export function normalizeMaterial(m: Record): AIMaterial { const extra = (m.extra_metadata ?? m.extraMetadata) as Record | undefined; return { id: String(m.id ?? ''), title: String(m.title ?? ''), content: String(m.content ?? ''), source: String(m.source ?? ''), publishedAt: String(m.published_at ?? m.publishedAt ?? ''), relevanceScore: Number(m.relevance_score ?? m.relevanceScore ?? 0), sourceType: (m.source_type ?? m.sourceType ?? 'internet') as AIMaterial['sourceType'], url: m.url as string | undefined, recUuid: (m.recUuid ?? m.rec_uuid) as string | undefined, extraMetadata: extra, }; } function normalizeSkillStep(s: Record): SkillSearchStep { return { type: (s.type as SkillSearchStep['type']) ?? 'invoke_skill', message: String(s.message ?? ''), skillNames: (s.skill_names ?? s.skillNames) as string[] | undefined, skillName: (s.skill_name ?? s.skillName) as string | undefined, reason: s.reason as string | undefined, matchedCount: (s.matched_count ?? s.matchedCount) as number | undefined, }; } function normalizeOutlineSection(s: Record) { return { sectionTitle: String(s.section_title ?? s.sectionTitle ?? ''), keyPoints: (s.key_points ?? s.keyPoints ?? []) as string[], materialIds: (s.material_ids ?? s.materialIds ?? []) as string[], }; } export function normalizeOutline(o: unknown): AIOutline | null { if (!o || typeof o !== 'object') return null; const obj = o as Record; return { title: String(obj.title ?? ''), sections: Array.isArray(obj.sections) ? obj.sections.map((s) => normalizeOutlineSection(s as Record)) : [], }; } function normalizeReviewIssue(i: Record): ReviewIssue { return { category: (i.category as ReviewIssue['category']) ?? 'fact', severity: (i.severity as ReviewIssue['severity']) ?? 'minor', location: String(i.location ?? ''), issue: String(i.issue ?? ''), suggestion: String(i.suggestion ?? ''), }; } export function normalizeReviewResult(r: unknown): ReviewResult | null { if (!r || typeof r !== 'object') return null; const o = r as Record; return { verdict: (o.verdict as 'pass' | 'reject') ?? 'pass', factScore: Number(o.fact_score ?? o.factScore ?? 0), logicScore: Number(o.logic_score ?? o.logicScore ?? 0), languageScore: Number(o.language_score ?? o.languageScore ?? 0), overallScore: Number(o.overall_score ?? o.overallScore ?? 0), passThreshold: Number(o.pass_threshold ?? o.passThreshold ?? 75), revisionNotes: Array.isArray(o.revision_notes ?? o.revisionNotes) ? ((o.revision_notes ?? o.revisionNotes) as Array>).map(normalizeReviewIssue) : [], }; } const REHYDRATE_PAUSE_MESSAGES: Record = { material_confirm: '素材收集专家已完成素材搜集,请确认素材后继续。', outline_confirm: '作家已完成大纲规划,请确认或编辑大纲后继续。', draft_confirm: '草稿已完成,请确认或给出修改意见。', review_confirm: '编辑认为文章需要修改,请决定下一步操作。', section_help: '部分章节缺少足够素材支撑写作,请决定如何处理。', }; function normalizePausePoint(value: unknown): CompletedIntervention['pausePoint'] | null { const pausePoint = String(value ?? ''); switch (pausePoint) { case 'material_confirm': case 'outline_confirm': case 'draft_confirm': case 'review_confirm': case 'section_help': return pausePoint; default: return null; } } function classifyResumedMessage(message: string): Pick | null { const m = message || ''; if (m.includes('确认素材')) return { pausePoint: 'material_confirm', action: 'confirm' }; if (m.includes('匹配技能检索') || m.includes('重新检索') || m.includes('重新搜索')) { return { pausePoint: 'material_confirm', action: 're_search' }; } if (m.includes('采用你提供的大纲')) return { pausePoint: 'material_confirm', action: 'confirm' }; if (m.includes('确认大纲')) return { pausePoint: 'outline_confirm', action: 'confirm' }; if (m.includes('重新规划大纲')) return { pausePoint: 'outline_confirm', action: 're_outline' }; if (m.includes('提交编辑审核')) return { pausePoint: 'draft_confirm', action: 'to_editor' }; if (m.includes('强制定稿')) return { pausePoint: 'review_confirm', action: 'force_finalize' }; if (m.includes('确认定稿')) return { pausePoint: 'draft_confirm', action: 'finalize' }; if (m.includes('按用户意见重写')) return { pausePoint: 'draft_confirm', action: 'user_revise' }; if (m.includes('按编辑意见重写')) return { pausePoint: 'review_confirm', action: 'accept_review' }; if (m.includes('补充检索素材')) return { pausePoint: 'section_help', action: 'supplement' }; if (m.includes('继续写作') || m.includes('按你的选择继续写作')) { return { pausePoint: 'section_help', action: 'loose' }; } return null; } function materialsFromValues(values: AIWritingValues): AIMaterial[] { const pkg = values.material_package && typeof values.material_package === 'object' ? values.material_package as Record : null; return pkg && Array.isArray(pkg.materials) ? (pkg.materials as Record[]).map(normalizeMaterial) : []; } function draftFromValues(values: AIWritingValues): Record | null { return values.current_draft && typeof values.current_draft === 'object' ? values.current_draft as Record : null; } export function buildCompletedInterventionFromCheckpointPause( values: AIWritingValues, pausePointValue: unknown, actionValue: unknown, ): CompletedIntervention | null { const pausePoint = normalizePausePoint(pausePointValue); if (!pausePoint) return null; const action = String(actionValue || 'confirm'); const record: CompletedIntervention = { pausePoint, action }; if (pausePoint === 'material_confirm') { record.materials = materialsFromValues(values); } else if (pausePoint === 'outline_confirm') { record.outline = normalizeOutline(values.current_outline) ?? undefined; } else if (pausePoint === 'draft_confirm') { const draft = draftFromValues(values); record.draftTitle = String(draft?.title ?? ''); const wordCount = Number(draft?.word_count ?? draft?.wordCount ?? 0); record.draftWordCount = wordCount > 0 ? wordCount : countArticleWords(String(draft?.full_markdown ?? draft?.fullMarkdown ?? '')); record.reviewResult = normalizeReviewResult(values.review_result) ?? undefined; } else if (pausePoint === 'review_confirm') { record.reviewResult = normalizeReviewResult(values.review_result) ?? undefined; } return record; } export function buildAwaitUserEventFromCheckpointPause( values: AIWritingValues, sessionId: string, pausePointValue: unknown, interruptValue?: unknown, ): ProgressEvent | null { const pausePoint = normalizePausePoint(pausePointValue); if (!pausePoint) return null; const intr = interruptValue && typeof interruptValue === 'object' ? interruptValue as Record : {}; const draft = draftFromValues(values); const event: ProgressEvent = { type: 'await_user', sessionId, message: String(intr.message ?? REHYDRATE_PAUSE_MESSAGES[pausePoint]), pausePoint, }; if (pausePoint === 'material_confirm') { event.materials = Array.isArray(intr.materials) ? (intr.materials as Record[]).map(normalizeMaterial) : materialsFromValues(values); } else if (pausePoint === 'outline_confirm') { event.outline = normalizeOutline(intr.outline ?? values.current_outline) ?? undefined; } else if (pausePoint === 'draft_confirm') { event.draftTitle = String(intr.draft_title ?? draft?.title ?? ''); event.revisionCount = Number(intr.revision_count ?? draft?.revision_count ?? draft?.revisionCount ?? 0); event.reviewResult = normalizeReviewResult(intr.review_result ?? values.review_result) ?? undefined; } else if (pausePoint === 'review_confirm') { event.reviewResult = normalizeReviewResult(intr.review_result ?? values.review_result) ?? undefined; } else if (pausePoint === 'section_help' && Array.isArray(intr.blocked_sections)) { event.blockedSections = (intr.blocked_sections as Record[]).map(normalizeBlockedSection); } return event; } function buildRehydrateTimeline(values: AIWritingValues, sessionId: string): { progressEvents: ProgressEvent[]; completedInterventions: CompletedIntervention[]; } { const rawEvents = Array.isArray(values.progress_events) ? (values.progress_events as Record[]).map((e) => normalizeProgressDict(e, sessionId)) : []; const progressEvents: ProgressEvent[] = []; const completedInterventions: CompletedIntervention[] = []; for (const event of rawEvents) { if (event.type === 'resumed') { const classified = classifyResumedMessage(event.message); if (classified) { const pauseEvent = buildAwaitUserEventFromCheckpointPause(values, sessionId, classified.pausePoint); const record = buildCompletedInterventionFromCheckpointPause( values, classified.pausePoint, classified.action, ); if (pauseEvent) progressEvents.push(pauseEvent); if (record) completedInterventions.push(record); } } progressEvents.push(event); } return { progressEvents, completedInterventions }; } function normalizeBlockedSection(b: Record): BlockedSection { return { sectionTitle: String(b.section_title ?? b.sectionTitle ?? ''), reason: String(b.reason ?? ''), }; } /** 把后端节点 ``progress_events`` 数组里的一条 dict 翻译成前端 ProgressEvent。 * * 幂等:对已是 camelCase 的前端事件再跑一遍基本不变(snake_case 键不存在即跳过), * 所以历史回显时可无脑对**整条** transcript 跑一遍——既归一后台存的 raw 事件,又 * 不破坏前端实时存的事件。 */ export function normalizeProgressDict(parsed: Record, sessionId: string): ProgressEvent { const out: Record = { ...parsed, sessionId }; if ('agent_name' in out) out.agentName = out.agent_name; if ('pause_point' in out) out.pausePoint = out.pause_point; if (Array.isArray(out.materials)) out.materials = (out.materials as Record[]).map(normalizeMaterial); if (out.outline) out.outline = normalizeOutline(out.outline); if (Array.isArray(out.blocked_sections)) { out.blockedSections = (out.blocked_sections as Record[]).map(normalizeBlockedSection); } // draft_ready: 节点 emit 的字段是 draft_markdown / draft_title if ('draft_markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.draft_markdown; if ('draft_title' in out && !out.draftTitle) out.draftTitle = out.draft_title; if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown; if ('title' in out && !out.draftTitle) out.draftTitle = out.title; if ('revision_count' in out) out.revisionCount = out.revision_count; if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result); if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult); if (out.sample_style_profile && !out.sampleStyleProfile) { const p = out.sample_style_profile as Record; out.sampleStyleProfile = { summary: p.summary as string | undefined, articleTypeGuess: (p.article_type_guess ?? p.articleTypeGuess) as string | undefined, tone: p.tone as string | undefined, structure: (p.structure as string[] | undefined) ?? [], languageFeatures: (p.language_features ?? p.languageFeatures ?? []) as string[], sentenceStyle: (p.sentence_style ?? p.sentenceStyle) as string | undefined, openingStyle: (p.opening_style ?? p.openingStyle) as string | undefined, endingStyle: (p.ending_style ?? p.endingStyle) as string | undefined, imitationGuidance: (p.imitation_guidance ?? p.imitationGuidance ?? []) as string[], avoidCopying: (p.avoid_copying ?? p.avoidCopying ?? []) as string[], sampleOutline: (p.sample_outline ?? p.sampleOutline ?? []) as string[], }; } if ('category' in out) out.reviewItemCategory = out.category; if ('score' in out) out.reviewItemScore = out.score; if ('issues' in out && Array.isArray(out.issues)) { out.reviewItemIssues = (out.issues as Record[]).map(normalizeReviewIssue); } // model_switch(updates 通道里的 progress_events 也可能带):折成 modelSwitch 子对象 if (out.type === 'model_switch' && !out.modelSwitch) { out.modelSwitch = { stage: (out.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent', failedModel: String(out.failed_model ?? out.failedModel ?? ''), nextModel: String(out.next_model ?? out.nextModel ?? ''), reason: String(out.reason ?? ''), sectionIndex: typeof out.section_index === 'number' ? (out.section_index as number) : (typeof out.sectionIndex === 'number' ? (out.sectionIndex as number) : undefined), sectionTitle: typeof out.section_title === 'string' ? (out.section_title as string) : (typeof out.sectionTitle === 'string' ? (out.sectionTitle as string) : undefined), }; } if (!out.message) out.message = ''; return out as unknown as ProgressEvent; } /** Restore history: rebuild a frontend state snapshot from graph checkpoint values. * * ``status`` 后端与前端共用同一套字面量(state.py WritingStatus ↔ types/ai-writing.ts),可直接 * 透传;timeline 由累积的 ``progress_events``(operator.add 全量)重放,素材/大纲/草稿/审核读各自 * 快照字段。pause 点(currentPause)由 ``stream.interrupt`` 的既有 effect / host 另行设置。 */ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: string): Partial { const pkg = (values.material_package && typeof values.material_package === 'object') ? (values.material_package as Record) : null; const materials = pkg && Array.isArray(pkg.materials) ? (pkg.materials as Record[]).map(normalizeMaterial) : []; const skillSteps = pkg && Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps) ? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record[]).map(normalizeSkillStep) : []; const draft = (values.current_draft && typeof values.current_draft === 'object') ? (values.current_draft as Record) : null; const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId); const sampleProfileEvent = values.sample_style_profile ? normalizeProgressDict({ type: 'sample_profile_ready', sample_style_profile: values.sample_style_profile }, sessionId) : null; return { sessionId, status: (values.status as AIWritingState['status']) || 'researching', materials, skillSearchSteps: skillSteps, outline: normalizeOutline(values.current_outline), currentDraftMarkdown: String(draft?.full_markdown ?? draft?.fullMarkdown ?? ''), currentDraftTitle: String(draft?.title ?? ''), revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0), reviewResult: normalizeReviewResult(values.review_result), sampleStyleProfile: sampleProfileEvent?.sampleStyleProfile ?? null, progressEvents, completedInterventions, }; } /** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */ export function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null { if (!raw || typeof raw !== 'object') return null; const o = raw as Record; const type = String(o.type ?? ''); // 后端 _save_draft_to_repo 等 helper 不走 custom 通道,type 必为节点 emit 的 chunk 类型 if (!type) return null; // 技能检索步骤:后端 _emit_skill_step 以 `writer({"type": "skill_search_step", **step})` // 发出,但 **step 里也有 type(invoke_skill / list_skills / skill_result / select_skills / // match_skills / skill_error / fallback / stop_early),会**覆盖**掉外层的 "skill_search_step"。 // 所以 custom 事件实际 type 是这些内层值。这里按内层 step 类型识别,**实时**归一成 // skill_search_step 事件(与素材包快照里的 step 同格式),让技能调用过程一步步进时间线, // 而不是被这里的白名单丢弃、只在节点结束的快照里一次性出现。 const SKILL_STEP_TYPES = [ 'list_skills', 'match_skills', 'select_skills', 'invoke_skill', 'skill_result', 'skill_error', 'fallback', 'stop_early', ]; if (SKILL_STEP_TYPES.includes(type)) { return { type: 'skill_search_step', sessionId, message: String(o.message ?? ''), agentName: (o.agent_name ?? o.agentName) as string | undefined, skillStep: normalizeSkillStep(o), } as unknown as ProgressEvent; } const allowed: ProgressEventType[] = [ 'intent_chunk', 'intent_ready', 'sample_chunk', 'sample_profile_ready', 'research_progress', 'keyword_chunk', 'outline_chunk', 'draft_chunk', 'review_chunk', 'review_item_done', 'skill_search_step', 'skill_select_chunk', 'model_switch', ]; if (!allowed.includes(type as ProgressEventType)) return null; if (type === 'sample_profile_ready') return normalizeProgressDict(o, sessionId); // 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues) const merged: Record = { ...o, sessionId, message: o.message ?? '' }; // draft_chunk / outline_chunk 用 camelCase 字段名(reducer 期望) if ('section_index' in merged) merged.sectionIndex = merged.section_index; if ('section_title' in merged) merged.sectionTitle = merged.section_title; if ('is_section_done' in merged) merged.isSectionDone = merged.is_section_done; if ('category' in merged) merged.reviewItemCategory = merged.category; if ('score' in merged) merged.reviewItemScore = merged.score; if (Array.isArray(merged.issues)) { merged.reviewItemIssues = (merged.issues as Record[]).map(normalizeReviewIssue); } if ('agent_name' in merged && !merged.agentName) merged.agentName = merged.agent_name; // skill_search_step:把字段折成 skillStep 子对象,方便 reducer / UI 取用 if (type === 'skill_search_step') { merged.skillStep = normalizeSkillStep(o); } // model_switch:把 snake_case 字段折成 modelSwitch 子对象(reducer + TimelineStep 取用) if (type === 'model_switch') { merged.modelSwitch = { stage: (o.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent', failedModel: String(o.failed_model ?? (o as Record).failedModel ?? ''), nextModel: String(o.next_model ?? (o as Record).nextModel ?? ''), reason: String(o.reason ?? ''), sectionIndex: typeof o.section_index === 'number' ? (o.section_index as number) : (typeof (o as Record).sectionIndex === 'number' ? ((o as Record).sectionIndex as number) : undefined), sectionTitle: typeof o.section_title === 'string' ? (o.section_title as string) : (typeof (o as Record).sectionTitle === 'string' ? ((o as Record).sectionTitle as string) : undefined), }; } return merged as unknown as ProgressEvent; } /** updates 通道里的节点输出 → 一组 ProgressEvent(progress_events + *_ready 合成)。 */ export function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] { if (!nodeOutput || typeof nodeOutput !== 'object') return []; const out = nodeOutput as Record; const events: ProgressEvent[] = []; // 节点 return 的 progress_events 数组(step_start / step_done / notice / draft_ready / outline_ready / review_ready / resumed / error) if (Array.isArray(out.progress_events)) { for (const e of out.progress_events as Record[]) { events.push(normalizeProgressDict(e, sessionId)); } } // ``material_package`` 出现 → materials_ready(节点 progress_events 里不带) if (out.material_package && typeof out.material_package === 'object') { const pkg = out.material_package as Record; const skillSteps = Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps) ? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record[]).map(normalizeSkillStep) : undefined; events.push({ type: 'materials_ready', sessionId, message: '', materials: Array.isArray(pkg.materials) ? (pkg.materials as Record[]).map(normalizeMaterial) : [], keywords: (pkg.keywords ?? []) as string[], materialSummary: (pkg.summary as string) ?? '', ...(skillSteps ? { skillSearchSteps: skillSteps } as unknown as Partial : {}), } as ProgressEvent); } // ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条 if (out.current_outline && !events.some((e) => e.type === 'outline_ready')) { const outline = normalizeOutline(out.current_outline); if (outline) events.push({ type: 'outline_ready', sessionId, message: '', outline }); } // ``review_result`` 出现且 progress_events 里没 review_ready → 合成一条(带 reviewResult 字段) if (out.review_result) { const reviewResult = normalizeReviewResult(out.review_result); if (reviewResult) { // 如果 progress_events 里已有 review_ready(节点 emit 的)但字段不全,补上 reviewResult const existing = events.find((e) => e.type === 'review_ready'); if (existing) { existing.reviewResult = reviewResult; } else { events.push({ type: 'review_ready', sessionId, message: '', reviewResult }); } } } // ``current_draft`` 出现 → 补全 draft_ready 的 draftMarkdown / draftTitle / revisionCount if (out.current_draft && typeof out.current_draft === 'object') { const d = out.current_draft as Record; const draftReady = events.find((e) => e.type === 'draft_ready'); const ev: ProgressEvent = draftReady ?? { type: 'draft_ready', sessionId, message: '' }; if (!ev.draftMarkdown) ev.draftMarkdown = (d.full_markdown ?? d.fullMarkdown ?? '') as string; if (!ev.draftTitle) ev.draftTitle = (d.title ?? '') as string; if (ev.revisionCount == null) ev.revisionCount = (d.revision_count ?? d.revisionCount ?? 0) as number; if (!draftReady) events.push(ev); } return events; } /** ``stream.interrupt.value`` → await_user ProgressEvent。 */ export function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null { if (!value || typeof value !== 'object') return null; const v = value as Record; return { type: 'await_user', sessionId, message: String(v.message ?? ''), pausePoint: v.pause_point as ProgressEvent['pausePoint'], materials: Array.isArray(v.materials) ? (v.materials as Record[]).map(normalizeMaterial) : undefined, keywords: v.keywords as string[] | undefined, materialSummary: v.summary as string | undefined, outline: v.outline ? (normalizeOutline(v.outline) as AIOutline) : undefined, reviewResult: v.review_result ? (normalizeReviewResult(v.review_result) as ReviewResult) : undefined, draftTitle: v.draft_title as string | undefined, revisionCount: v.revision_count as number | undefined, blockedSections: Array.isArray(v.blocked_sections) ? (v.blocked_sections as Record[]).map(normalizeBlockedSection) : undefined, }; } export type AIWritingValues = { user_intent?: string; user_outline_raw?: string; writing_mode_type?: string; sample_text?: string; sample_filename?: string; sample_style_profile?: unknown; imitate_strength?: string; preserve_sample_structure?: boolean; article_type?: string; /** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */ article_type_detail?: string; /** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */ add_references?: boolean; /** 不收集素材,直接用通用知识写作(researcher 节点据此短路跳过检索)。 */ skip_research?: boolean; model_name?: string; status?: string; revision_count?: number; max_revisions?: number; blocked_sections?: unknown[]; material_package?: unknown; current_outline?: unknown; current_draft?: unknown; review_result?: unknown; progress_events?: unknown[]; };