# 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`) ```typescript 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 ```typescript // 启动写作 → 返回 session_id export async function startWritingSession(req: AIWritingRequest): Promise // 订阅 SSE 进度流 → 返回取消订阅函数 export function subscribeToProgress( sessionId: string, onEvent: (event: ProgressEvent) => void, ): () => void // 提交用户干预 → 让 graph 续推 export async function resumeWriting( sessionId: string, payload: UserInterventionPayload, ): Promise ``` **关键实现细节**: - **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` — 历史会话 ```typescript // CRUD listAIWritingSessions() : Promise getAIWritingSession(id) : Promise renameAIWritingSession(id, title) : Promise deleteAIWritingSession(id) : Promise // 时间线持久化(fire-and-forget,永不抛错) saveAIWritingTranscript(id, transcript) : Promise ``` **关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。 ### 3. `api/ai-writing-article-types.ts` — 文章类型管理 | 函数 | 用途 | |---|---| | `listArticleTypes()` | 表单下拉框选项 | | `createArticleType()` | 管理员新增 | | `updateArticleType()` | 管理员编辑 | | `deleteArticleType()` | 管理员删除 | --- ## 六、组件树详解 ### 顶层:`AIWritingPanel`(457 行,最大) 负责: 1. 调用 `useAIWriting()` 拿主状态 2. 调用 `useAIWritingSessions()` 拿历史列表 3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步 4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容 5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧 6. **历史会话回显**:当 `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`,支持 `...` 折叠显示 - **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机 - **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分 ### 历史回显:`AIWritingHistoryView` 只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。 --- ## 七、SSE 事件协议(前后端契约) 后端通过 SSE 推 `event: \ndata: \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 | 非致命告警的标题、详情、原始错误 | --- ## 八、扩展指南:常见开发任务 ### 新增一个干预点 1. **前端类型扩展**: - 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case - 在 `WritingStatus` 加对应的 `awaiting_xxx` - 在 `CompletedIntervention.pausePoint` 加 case 2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑 3. **后端配合**(详见后端开发文档): - `graph.py` 加 `pause_xxx_confirm` 节点 - graph 拓扑加这个节点的边 4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到 ### 新增一个 SSE 事件类型 1. `types/ai-writing.ts`: - `ProgressEventType` 联合类型加新值 - `ProgressEvent` 接口加新字段(如果有载荷) 2. `api/ai-writing.ts`: - `SSE_EVENT_TYPES` 数组加新值(不加会被忽略) - `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize) 3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新 4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染 ### 修改一个流式打字机的行为 每个 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//stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。 ### 查当前 hook 状态 在浏览器 console 里: ```js // 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel // 或在 useAIWriting 里临时加: useEffect(() => { (window as any).__aiwriting = state; }, [state]); // 然后在 console 里 __aiwriting 看 ``` ### 模拟事件 开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。 ### 看后端 transcript ``` GET /api/ai-writing/sessions/ ``` 返回里 `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`,主要类型: ```typescript // 业务实体 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 里注明改动原因。