deerflow-code/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
2026-09-07 18:24:55 +08:00

1038 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<AIWritingState> }
// 手动「继续」从检查点重跑当前阶段前,清空该阶段的流式缓冲(避免与重跑的新输出粘连)。
| { 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<AIWritingState> = {};
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<AIWritingState> = (() => {
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<string, unknown>): AIMaterial {
const extra = (m.extra_metadata ?? m.extraMetadata) as Record<string, unknown> | 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<string, unknown>): 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<string, unknown>) {
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<string, unknown>;
return {
title: String(obj.title ?? ''),
sections: Array.isArray(obj.sections) ? obj.sections.map((s) => normalizeOutlineSection(s as Record<string, unknown>)) : [],
};
}
function normalizeReviewIssue(i: Record<string, unknown>): 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<string, unknown>;
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<Record<string, unknown>>).map(normalizeReviewIssue)
: [],
};
}
const REHYDRATE_PAUSE_MESSAGES: Record<CompletedIntervention['pausePoint'], string> = {
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<CompletedIntervention, 'pausePoint' | 'action'> | 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<string, unknown>
: null;
return pkg && Array.isArray(pkg.materials)
? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial)
: [];
}
function draftFromValues(values: AIWritingValues): Record<string, unknown> | null {
return values.current_draft && typeof values.current_draft === 'object'
? values.current_draft as Record<string, unknown>
: 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<string, unknown>
: {};
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<string, unknown>[]).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<string, unknown>[]).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<string, unknown>[]).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<string, unknown>): 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<string, unknown>, sessionId: string): ProgressEvent {
const out: Record<string, unknown> = { ...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<string, unknown>[]).map(normalizeMaterial);
if (out.outline) out.outline = normalizeOutline(out.outline);
if (Array.isArray(out.blocked_sections)) {
out.blockedSections = (out.blocked_sections as Record<string, unknown>[]).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<string, unknown>;
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<string, unknown>[]).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<AIWritingState> {
const pkg = (values.material_package && typeof values.material_package === 'object')
? (values.material_package as Record<string, unknown>) : null;
const materials = pkg && Array.isArray(pkg.materials)
? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [];
const skillSteps = pkg && Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps)
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep) : [];
const draft = (values.current_draft && typeof values.current_draft === 'object')
? (values.current_draft as Record<string, unknown>) : 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<string, unknown>;
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<string, unknown> = { ...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<string, unknown>[]).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<string, unknown>).failedModel ?? ''),
nextModel: String(o.next_model ?? (o as Record<string, unknown>).nextModel ?? ''),
reason: String(o.reason ?? ''),
sectionIndex: typeof o.section_index === 'number'
? (o.section_index as number)
: (typeof (o as Record<string, unknown>).sectionIndex === 'number'
? ((o as Record<string, unknown>).sectionIndex as number)
: undefined),
sectionTitle: typeof o.section_title === 'string'
? (o.section_title as string)
: (typeof (o as Record<string, unknown>).sectionTitle === 'string'
? ((o as Record<string, unknown>).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<string, unknown>;
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<string, unknown>[]) {
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<string, unknown>;
const skillSteps = Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps)
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep)
: undefined;
events.push({
type: 'materials_ready',
sessionId,
message: '',
materials: Array.isArray(pkg.materials) ? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [],
keywords: (pkg.keywords ?? []) as string[],
materialSummary: (pkg.summary as string) ?? '',
...(skillSteps ? { skillSearchSteps: skillSteps } as unknown as Partial<ProgressEvent> : {}),
} 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<string, unknown>;
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<string, unknown>;
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<string, unknown>[]).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<string, unknown>[]).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[];
};