26 KiB
AI 写作 — 前端开发文档
本文档目的:帮助前端开发者快速理解 AI 写作子系统的代码结构、数据流、状态机和扩展点。
配套文档:
ai-writing-说明.md— 故障排查与运维向解决方案ai-writing-assistant-backend-api.md— 后端 API 详细字段定义ai-writing-assistant-source-map.md— 旧版前端结构索引(已合并到本文档)
一、产品定位
AI 写作是 DeerFlow 的「杂志社协作写作」子产品,由 LangGraph 后端驱动 4 个 AI Agent(素材收集专家 / 作家 / 编辑 / 系统)协作完成一篇文章。用户通过 4 个干预点(素材确认、大纲确认、草稿确认、审核确认)参与决策,UI 实时呈现进度。
UI 入口
- 路由:
/canvas/ai-writing(在WorkspaceRoutes.tsx注册) - 入口页:
src/open-canvas/pages/AIWritingPage.tsx - 双栏布局:左 AI 写作面板(聊天 + 干预),右草稿预览
二、目录结构
frontend-web/src/open-canvas/
├── pages/
│ └── AIWritingPage.tsx # 入口页(双栏 Resizable 布局)
│
├── api/ # ── 后端 API 适配层 ──
│ ├── ai-writing.ts # /start /resume + SSE 订阅(核心)
│ ├── ai-writing-sessions.ts # 历史会话 CRUD + transcript 保存
│ └── ai-writing-article-types.ts # 文章类型 CRUD
│
├── hooks/ # ── 状态管理 ──
│ ├── useAIWriting.ts # 写作主状态机(最重要)
│ ├── useAIWritingSessions.ts # 历史会话列表 hook
│ └── useArticleTypes.ts # 文章类型选项 hook
│
├── components/ai-writing/ # ── UI 组件 ──
│ ├── AIWritingPanel.tsx # 左侧主面板(聊天 + 干预编排)
│ ├── AIWritingDraftPanel.tsx # 右侧草稿预览面板
│ ├── AIWritingPageHeader.tsx # 顶部标题/历史下拉
│ ├── AIWritingForm.tsx # 写作请求表单(首次启动用)
│ │
│ ├── AIWritingTimeline.tsx # 时间线主容器
│ ├── TimelineStep.tsx # 单条进度事件
│ ├── CompletedInterventionCard.tsx # 已完成的干预记录
│ │
│ ├── InterventionCard.tsx # 4 个干预点 + 求助点的卡片(核心)
│ ├── MaterialsCard.tsx # 素材确认
│ ├── MaterialItem.tsx # 单个素材项
│ ├── MaterialsToolbar.tsx # 素材筛选/搜索工具栏
│ ├── OutlineCard.tsx # 大纲确认(含编辑)
│ ├── OutlineSection.tsx # 大纲单章节
│ ├── ReviewCard.tsx # 审核确认
│ ├── IssueList.tsx # 审核意见列表
│ ├── ScoreBar.tsx # 评分进度条
│ │
│ ├── OutlineStreamingView.tsx # 大纲流式打字机
│ ├── DraftStreamingView.tsx # 草稿流式打字机
│ ├── ReviewStreamingView.tsx # 审核流式打字机
│ ├── WritingSkeletonCard.tsx # 加载骨架屏
│ ├── RevisionSummaryCard.tsx # 修改前后对比摘要
│ │
│ ├── AIWritingHistoryDropdown.tsx # 历史会话下拉
│ └── AIWritingHistoryView.tsx # 历史会话只读回显
│
└── types/
└── ai-writing.ts # 全部类型定义(重要参考)
三、数据流总览
┌─────────────────────────────────────────────────────────────────┐
│ AIWritingPanel │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ useAIWriting() 主状态 hook │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ startWriting() ─── POST /api/ai-writing/start │ │ │
│ │ │ ↓ 返回 session_id │ │ │
│ │ │ │ │ │
│ │ │ subscribeToProgress(sessionId) │ │ │
│ │ │ ─── GET /api/ai-writing/{id}/stream (SSE) │ │ │
│ │ │ ─── 持续推 ProgressEvent 给 handleEvent │ │ │
│ │ │ ─── handleEvent reducer → setState │ │ │
│ │ │ │ │ │
│ │ │ submitIntervention() │ │ │
│ │ │ ─── POST /api/ai-writing/{id}/resume │ │ │
│ │ │ ─── graph 续推 → 新事件继续走 SSE │ │ │
│ │ │ │ │ │
│ │ │ saveAIWritingTranscript()(fire-and-forget) │ │ │
│ │ │ ─── PUT /api/ai-writing/sessions/{id}/transcript │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 渲染: │
│ ├ AIWritingForm(idle 状态) │
│ ├ AIWritingTimeline(运行中) │
│ │ ├ TimelineStep × N(progressEvents) │
│ │ └ CompletedInterventionCard × N(已完成的干预) │
│ ├ {Outline,Draft,Review}StreamingView(流式打字机) │
│ ├ InterventionCard(当前 currentPause 存在时弹出) │
│ └ WritingSkeletonCard(运行中无 pause 时的骨架屏) │
└─────────────────────────────────────────────────────────────────┘
四、状态机(useAIWriting.ts)
状态字段(完整定义见 types/ai-writing.ts)
interface AIWritingState {
// —— 核心标识 ——
sessionId: string | null; // 后端返回的会话 id
status: WritingStatus; // 当前阶段(13 种状态之一)
isPaused: boolean; // 是否在干预点等待用户
error: string | null;
// —— 配置 ——
request: AIWritingRequest | null; // 用户提交的写作请求
// —— 累计数据 ——
progressEvents: ProgressEvent[]; // SSE 事件全量历史(供时间线渲染)
materials: AIMaterial[]; // 素材列表(素材就绪后填充)
outline: AIOutline | null; // 大纲(大纲就绪后填充)
currentDraftMarkdown: string; // 草稿正文(草稿就绪后填充)
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
completedInterventions: CompletedIntervention[]; // 已完成的干预历史
// —— 干预 ——
currentPause: ProgressEvent | null; // 当前挂起的 await_user 事件
// —— 流式视图状态 ——
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
outlineStreamingText: string;
outlineThinkingText: string;
researchProgress: string;
streamingReviewItems: StreamingReviewItem[];
revisionSummary: RevisionSummary | null;
pendingRevisionContext: { /* ... */ } | null;
}
状态转移图(写作状态机)
┌──────┐
│ idle │ ←─────────────────────────────────────┐
└──┬───┘ │
│ startWriting() │
↓ │
┌─────────────────┐ │
│ researching │ │
└──┬──────────────┘ │
↓ materials_ready │
┌─────────────────────────────┐ │
│ awaiting_material_confirm │ ← await_user │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_outline │ ← (re_search 路径) │
└──┬──────────────┘ │
↓ outline_ready │
┌─────────────────────────────┐ │
│ awaiting_outline_confirm │ │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_draft │ ← (re_outline 路径) │
└──┬──────────────┘ │
│ │
┌───────┴────────┐ │
↓ ↓ │
awaiting_section_ awaiting_draft_confirm │
help (素材不足) └─┬───┬───┬──────────────┐ │
│ (supplement) │ │ │ to_editor │ │
│ ↑回到 research │ │ ↓ │ │
│ │ │ reviewing │ │
│ │ │ └─┬──────┐ │ │
│ │ │ ↓ ↓ │ │
│ │ │ awaiting_review_confirm │
│ │ │ └─┬────────┐ │
│ │ │ ↓ submit ↓ │
│ │ │ revising done ───────────────┘
│ │ │ │
│ │ │ └─→ writing_draft (循环修订)
│ │ │
│ │ │ user_revise
│ │ └─→ revising
│ │
│ │ finalize
│ └─→ done
│
└──→ (delete/loose) writing_draft 继续
handleEvent reducer(核心)
useAIWriting.ts 里的 handleEvent 是个纯 reducer,根据 SSE 事件类型更新状态:
| 事件类型 | 主要影响的状态字段 |
|---|---|
connected |
仅记录到 progressEvents |
step_start |
切到对应 status(如「编辑」→ reviewing) |
step_done |
仅记录 |
research_progress |
researchProgress(实时检索进度文案) |
materials_ready |
materials、清空 researchProgress |
outline_chunk |
outlineStreamingText / outlineThinkingText(打字机) |
outline_ready |
outline、清空流式文本 |
draft_chunk |
streamingDraftSections(按章节拼接打字机) |
draft_ready |
currentDraftMarkdown / currentDraftTitle |
review_chunk |
streamingReviewItems[i].streamingText(逐条打字机) |
review_item_done |
streamingReviewItems[i] 标记 isDone + 结构化结果 |
review_ready |
reviewResult |
await_user |
currentPause = event,isPaused = true,弹出 InterventionCard |
resumed |
currentPause = null |
notice |
仅记录(非致命告警) |
done |
status = 'done' |
error |
status = 'error',error = event.message |
五、API 层(api/)
1. api/ai-writing.ts — 核心写作 API
// 启动写作 → 返回 session_id
export async function startWritingSession(req: AIWritingRequest): Promise<string>
// 订阅 SSE 进度流 → 返回取消订阅函数
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
): () => void
// 提交用户干预 → 让 graph 续推
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void>
关键实现细节:
- snake_case ↔ camelCase 转换:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(
normalizeMaterial、normalizeOutlineSection...)集中在这一层做转换 - SSE 事件白名单:
SSE_EVENT_TYPES数组列出所有合法事件名,未知事件会被静默忽略 - 取消订阅:
subscribeToProgress返回的函数会调用AbortController.abort()关闭 fetch stream - 重连机制:当前没有内置重连 — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面
- 后端
_recover_session能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑
- 后端
2. api/ai-writing-sessions.ts — 历史会话
// CRUD
listAIWritingSessions() : Promise<AIWritingSessionSummary[]>
getAIWritingSession(id) : Promise<AIWritingSessionSummary>
renameAIWritingSession(id, title) : Promise<AIWritingSessionSummary>
deleteAIWritingSession(id) : Promise<void>
// 时间线持久化(fire-and-forget,永不抛错)
saveAIWritingTranscript(id, transcript) : Promise<boolean>
关键设计:saveAIWritingTranscript 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 console.warn 不影响主流程。这是**「锦上添花」契约**的前端侧。
3. api/ai-writing-article-types.ts — 文章类型管理
| 函数 | 用途 |
|---|---|
listArticleTypes() |
表单下拉框选项 |
createArticleType() |
管理员新增 |
updateArticleType() |
管理员编辑 |
deleteArticleType() |
管理员删除 |
六、组件树详解
顶层:AIWritingPanel(457 行,最大)
负责:
- 调用
useAIWriting()拿主状态 - 调用
useAIWritingSessions()拿历史列表 - 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步
- 自动滚底逻辑:检测用户是否手动上划,决定是否跟随最新内容
- 草稿同步到右侧画布:监听
currentDraftMarkdown变化,调setArtifact更新右侧 - 历史会话回显:当
historySession非空时,渲染AIWritingHistoryView而不是实时面板
干预卡:InterventionCard(663 行,第二大)
按 pausePoint 渲染不同的卡片:
| pausePoint | 卡片 | 用户能做的 |
|---|---|---|
material_confirm |
MaterialsCard |
勾选保留素材 / 加关键词重新搜 |
outline_confirm |
OutlineCard |
直接编辑大纲 / 给反馈让重写 |
draft_confirm |
草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 |
review_confirm |
ReviewCard |
勾选采纳的意见 / 强制通过 |
section_help |
求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 |
时间线:AIWritingTimeline
迭代 progressEvents,每个 step_start / step_done / notice 渲染成一条 TimelineStep。CompletedInterventionCard 穿插显示已完成的干预记录。
流式视图:{Outline,Draft,Review}StreamingView
- OutlineStreamingView:打字机渲染
outlineStreamingText,支持<think>...</think>折叠显示 - DraftStreamingView:按章节流式拼接
streamingDraftSections,每章节独立打字机 - ReviewStreamingView:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分
历史回显:AIWritingHistoryView
只读模式,从 AIWritingSessionSummary 重建 UI。当 transcript 字段有值时,从里面拿到完整 progressEvents,UI 表现跟实时一模一样。transcript 没有时退回到只显示最终草稿。
七、SSE 事件协议(前后端契约)
后端通过 SSE 推 event: <type>\ndata: <json>\n\n 格式,前端用浏览器原生 fetch + ReadableStream 解析(不用 EventSource,因为 EventSource 不能自定义 header)。
解析流程
ReadableStream → TextDecoder
→ 按 "\n\n" 分帧
→ 每帧匹配 /^event: (.+)\ndata: (.+)$/
→ JSON.parse(data)
→ normalize(parsed)(snake → camel)
→ onEvent(normalized)
事件载荷字段(不完全列表)
| 字段 | 出现在哪些事件 | 用途 |
|---|---|---|
type |
全部 | 事件类别 |
sessionId |
全部 | 标识 |
message |
大多数 | 显示给用户的文案 |
agentName |
step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 |
materials |
materials_ready | 素材数组 |
outline |
outline_ready | 大纲对象 |
draftMarkdown / draftTitle |
draft_ready | 草稿内容 |
reviewResult |
review_ready | 完整审核结果 |
pausePoint |
await_user | 哪个干预点 |
pauseContext |
await_user | 干预所需的上下文(如待确认的素材列表) |
blockedSections |
await_user (section_help) | 素材不足的章节列表 |
title / detail / raw |
notice / error | 非致命告警的标题、详情、原始错误 |
八、扩展指南:常见开发任务
新增一个干预点
-
前端类型扩展:
- 在
types/ai-writing.ts的ProgressEvent.pausePoint联合类型里加新 case - 在
WritingStatus加对应的awaiting_xxx - 在
CompletedIntervention.pausePoint加 case
- 在
-
写 InterventionCard 分支:在
InterventionCard.tsx里加新pausePoint分支,做 UI + 提交逻辑 -
后端配合(详见后端开发文档):
graph.py加pause_xxx_confirm节点- graph 拓扑加这个节点的边
-
完成干预后的清理:在
useAIWriting.ts的submitIntervention函数里,确保把这次干预 push 到completedInterventions,让历史能看到
新增一个 SSE 事件类型
-
types/ai-writing.ts:ProgressEventType联合类型加新值ProgressEvent接口加新字段(如果有载荷)
-
api/ai-writing.ts:SSE_EVENT_TYPES数组加新值(不加会被忽略)normalizeEvent函数里加字段转换(如果是 snake_case 需要 normalize)
-
useAIWriting.ts的handleEvent:加新 case 处理状态更新 -
组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染
修改一个流式打字机的行为
每个 streaming view 内部都是独立的:
- 状态在
useAIWriting.ts的streaming*系列字段 - 渲染在
*StreamingView.tsx - 流式 chunk 处理在
handleEvent的outline_chunk/draft_chunk/review_chunk分支
新增草稿到右侧画布的逻辑
AIWritingPanel.tsx 里 useEffect 监听 state.currentDraftMarkdown → 调 setArtifact 更新右侧 CanvasGraphContext。如果需要新的 artifact 类型,看 contexts/CanvasGraphContext.tsx 的 artifact 接口。
九、容错与边界处理
已落实的容错(不会阻断写作)
- ✅
saveAIWritingTranscript失败仅console.warn - ✅ 历史会话列表加载失败仅
console.warn - ✅ 重命名/删除会话失败静默吞掉
- ✅ 后端 SSE 推
notice/error事件时,前端只渲染不中断 - ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧)
已知边界(可能影响 UI 体验)
- ⚠️ SSE 断线无自动重连 — 网络抖动 / 反代超时后用户需要手动刷新
- ⚠️ 多 worker 部署下 SSE 事件丢失(详见
ai-writing-说明.md)—— 前端只能感知到「卡住」,没有重连恢复机制 - ⚠️
outline_chunk/draft_chunk/review_chunk的text/is_thinking字段是 snake_case,前端类型是text/isThinking但 normalize 时是保留 snake_case 直接读(参考useAIWriting.ts的(event as unknown as { text: string }).text),这是历史包袱,未来 normalize 函数应统一处理
十、调试技巧
看 SSE 原始事件
浏览器 DevTools → Network → 找 /api/ai-writing/<id>/stream 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。
查当前 hook 状态
在浏览器 console 里:
// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel
// 或在 useAIWriting 里临时加:
useEffect(() => { (window as any).__aiwriting = state; }, [state]);
// 然后在 console 里 __aiwriting 看
模拟事件
开发期可以注释掉 subscribeToProgress,手动调 handleEvent({...}) 测试 reducer 行为。
看后端 transcript
GET /api/ai-writing/sessions/<id>
返回里 transcript 字段就是前端组装过的完整时间线 JSON。
十一、性能注意点
大数组渲染
progressEvents在长写作里可能上千条 →AIWritingTimeline应该考虑虚拟滚动(当前未实现,会议室会议讨论过)streamingDraftSections章节多时也是潜在瓶颈
transcript PUT 频率
- 当前每次
progressEvents变化都会触发保存(实际上是 useEffect 监听) - 大量 chunk 事件会导致每秒 N 次 PUT,后端 MySQL 写压力大
- 优化建议:在
useAIWriting.ts给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在await_user/done/error关键节点保存
浏览器内存
- progressEvents 不主动清理 → 长会话可能占用几十 MB
- 完成一篇文章后建议
reset()清空状态
十二、相关后端接口(速查)
| 接口 | 用途 | 前端调用方 |
|---|---|---|
POST /api/ai-writing/start |
启动写作 | startWritingSession |
GET /api/ai-writing/{id}/stream |
SSE 进度流 | subscribeToProgress |
POST /api/ai-writing/{id}/resume |
提交干预 | resumeWriting |
GET /api/ai-writing/{id}/state |
查 graph 状态快照(调试用) | 暂未调用 |
GET /api/ai-writing/sessions |
历史列表 | listAIWritingSessions |
GET /api/ai-writing/sessions/{id} |
历史详情 | getAIWritingSession |
PATCH /api/ai-writing/sessions/{id} |
重命名 | renameAIWritingSession |
DELETE /api/ai-writing/sessions/{id} |
删除会话 | deleteAIWritingSession |
PUT /api/ai-writing/sessions/{id}/transcript |
保存时间线(fire-and-forget) | saveAIWritingTranscript |
GET /api/ai-writing/article-types |
文章类型下拉 | listArticleTypes |
POST /api/ai-writing/admin/cleanup |
admin 手动清理 | 暂未在 UI 暴露 |
十三、未来优化方向
| 项目 | 说明 | 工作量 |
|---|---|---|
| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 |
| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 |
| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 |
| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 |
| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 |
| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 |
附录 A:所有类型一览
完整定义在 types/ai-writing.ts,主要类型:
// 业务实体
AIMaterial // 单个素材
AIOutline // 大纲
OutlineSection // 大纲单章节
ReviewIssue // 审核问题
ReviewResult // 完整审核结果
BlockedSection // 素材不足的章节
SectionDecision // 章节求助决策
// 流程
WritingStatus // 13 种状态
ProgressEventType // 17 种事件类型
ProgressEvent // SSE 事件载荷
UserInterventionPayload // 干预提交体
AIWritingRequest // 启动写作的请求
CompletedIntervention // 已完成的干预记录
// 流式视图
StreamingDraftSection
StreamingReviewItem
RevisionSummary
// Hook 状态
AIWritingState // useAIWriting 的完整状态
附录 B:常见踩坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 api/ai-writing.ts 的 normalize 函数里加字段 |
| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 ai-writing-说明.md 多 worker 部分 |
| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 save_transcript |
| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 |
| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | DraftStreamingView 里 markdown parser 设置 |
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。