1038 lines
44 KiB
TypeScript
1038 lines
44 KiB
TypeScript
/**
|
||
* 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[];
|
||
};
|