接口修改
This commit is contained in:
1 parent
edb3238f3e
commit
a85f1571a0
17 files changed
+906
-10648
No files matched your search
@@ -1,916 +0,0 @@
|
||||
# AI 写作 — 后端开发文档
|
||||
|
||||
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
|
||||
>
|
||||
> **配套文档**:
|
||||
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
|
||||
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
|
||||
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、产品定位与技术架构
|
||||
|
||||
### 产品定位
|
||||
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
|
||||
|
||||
### 技术栈
|
||||
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
|
||||
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
|
||||
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
|
||||
- **业务数据**:MySQL `ai_writing_sessions` 表
|
||||
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
|
||||
|
||||
### 与主 Agent 的关系
|
||||
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── app/gateway/
|
||||
│ ├── routers/
|
||||
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
|
||||
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
|
||||
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
|
||||
│
|
||||
└── packages/harness/deerflow/
|
||||
├── agents/ai_writing/
|
||||
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
|
||||
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
|
||||
│ ├── state.py # AIWritingState TypedDict(121 行)
|
||||
│ │
|
||||
│ ├── nodes/ # ── Graph 节点 ──
|
||||
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
|
||||
│ │ ├── intent_parser.py # 节点 1:意图解析
|
||||
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
|
||||
│ │ ├── writer_outline.py # 节点 3:大纲规划
|
||||
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
|
||||
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
|
||||
│ │
|
||||
│ ├── prompts/ # ── Prompt 模板 ──
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── researcher_prompts.py
|
||||
│ │ ├── writer_prompts.py
|
||||
│ │ └── editor_prompts.py
|
||||
│ │
|
||||
│ └── search/ # ── 素材检索 provider ──
|
||||
│ ├── base.py # SearchResult / SearchProvider 接口
|
||||
│ ├── duckduckgo_search.py
|
||||
│ ├── internet_search.py # 互联网检索(默认)
|
||||
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
|
||||
│ └── demo_search.py # 离线 demo 数据
|
||||
│
|
||||
└── persistence/ai_writing_sessions/
|
||||
├── __init__.py # AIWritingSessionRepository 工厂
|
||||
├── model.py # ORM:AIWritingSessionRow
|
||||
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Graph 拓扑(最重要)
|
||||
|
||||
### StateGraph 节点 + 边
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
|
||||
└────────┬────────┘
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ researcher │ ← (re_search 回到这里)
|
||||
└────────┬────────┘
|
||||
↓ (after_researcher: ⼟材 ready)
|
||||
┌──────────────────────┐
|
||||
│ pause_material │ ⚙️ PAUSE-1:素材确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ re_search
|
||||
↓ └→ researcher
|
||||
┌─────────────────┐
|
||||
│ writer_outline │ ← (re_outline 回到这里)
|
||||
└────────┬────────┘
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ re_outline
|
||||
↓ └→ writer_outline
|
||||
┌─────────────────┐
|
||||
│ writer_draft │ ← (素材补充后回到这里)
|
||||
└─┬───────────────┘
|
||||
↓ (after_writer_draft)
|
||||
┌──────┴────────────────┐
|
||||
↓ ↓
|
||||
┌──────────────────┐ ┌────────────────────┐
|
||||
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
|
||||
│ help(素材不足)│ │ │
|
||||
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
|
||||
│ supplement │ del/ │ │ │ to_editor
|
||||
│↓ │ loose │ │ user_revise
|
||||
research writer_ │ final ↓
|
||||
draft │ ize writer_draft
|
||||
│↓
|
||||
done
|
||||
│
|
||||
(to_editor)│
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ editor │ 3 维度并行流式审核
|
||||
└────────┬────────┘
|
||||
↓ (after_review)
|
||||
┌──────────────────────┐
|
||||
│ pause_review │ ⚙️ PAUSE-4:审核确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ revise
|
||||
│↓ └→ writer_draft
|
||||
done
|
||||
```
|
||||
|
||||
### 节点定义位置一览
|
||||
|
||||
| 节点名 | 实现文件 | 类型 | 流式? |
|
||||
|---|---|---|---|
|
||||
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
|
||||
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
|
||||
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
|
||||
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
|
||||
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
|
||||
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
|
||||
### 条件路由函数(graph.py)
|
||||
|
||||
| 函数 | 在哪个节点之后 | 路由依据 |
|
||||
|---|---|---|
|
||||
| `after_researcher` | researcher | 是否找到素材 |
|
||||
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
|
||||
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
|
||||
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
|
||||
| `after_section_help_pause` | pause_section_help | status |
|
||||
| `after_draft_pause` | pause_draft | done / revising / reviewing |
|
||||
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
|
||||
| `after_review_pause` | pause_review | done / writing_draft |
|
||||
|
||||
---
|
||||
|
||||
## 四、State 定义(state.py)
|
||||
|
||||
```python
|
||||
class AIWritingState(TypedDict):
|
||||
# —— 写作配置(启动时填充)——
|
||||
user_intent: str
|
||||
article_type: str
|
||||
target_audience: str
|
||||
word_count_target: int
|
||||
keyword_count: int # 1-10,默认 6
|
||||
writing_mode: str # "strict" | "loose"
|
||||
|
||||
# —— Agent 产出 ——
|
||||
material_package: Optional[MaterialPackage] # 素材收集专家
|
||||
current_outline: Optional[Outline] # 作家
|
||||
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
|
||||
current_draft: Optional[DraftArticle] # 最新草稿
|
||||
review_result: Optional[ReviewResult] # 编辑
|
||||
|
||||
# —— 用户干预 ——
|
||||
last_user_intervention: Optional[UserInterventionPayload]
|
||||
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
|
||||
|
||||
# —— 流程控制 ——
|
||||
revision_count: int
|
||||
max_revisions: int # 默认 3
|
||||
status: WritingStatus
|
||||
|
||||
# —— SSE 推送队列 ——
|
||||
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
|
||||
```
|
||||
|
||||
### State 字段的特殊语义
|
||||
|
||||
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
|
||||
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
|
||||
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
|
||||
|
||||
---
|
||||
|
||||
## 五、路由层(app/gateway/routers/ai_writing.py)
|
||||
|
||||
### 14 个接口一览
|
||||
|
||||
| HTTP | 路径 | 用途 | 鉴权 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
|
||||
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
|
||||
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
|
||||
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
|
||||
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
|
||||
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
|
||||
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
|
||||
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
|
||||
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
|
||||
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
|
||||
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
|
||||
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
|
||||
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
|
||||
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
|
||||
|
||||
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
|
||||
|
||||
```python
|
||||
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
|
||||
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
|
||||
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件(供重连重投)
|
||||
_session_model_names: dict[str, str] # 用户选的模型
|
||||
_session_interventions: dict[str, list] # 累计干预历史
|
||||
_session_repos: dict[str, repo] # DB 仓库引用
|
||||
_session_user_ids: dict[str, str] # 用户 id
|
||||
_session_article_types: dict[str, list] # 动态文章类型列表
|
||||
_session_error_flags: dict[str, bool]
|
||||
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
|
||||
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
|
||||
```
|
||||
|
||||
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
|
||||
|
||||
### `_stream_graph_segment`(graph 推进函数)
|
||||
|
||||
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
|
||||
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
|
||||
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
|
||||
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
|
||||
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
|
||||
5. 任何异常 → 推 `error` 事件 → 关闭 queue
|
||||
6. finally 段:更新 DB 最终状态、清理所有模块字典
|
||||
|
||||
### SSE 生成器 `_sse_generator`
|
||||
|
||||
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
|
||||
|
||||
**关键逻辑**:
|
||||
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
|
||||
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
|
||||
- 收到 None 哨兵 → 关闭流
|
||||
|
||||
### `_recover_session`(跨 worker 恢复)
|
||||
|
||||
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
|
||||
1. 从 checkpointer 读出 state snapshot
|
||||
2. 重建 `_event_queues[sid]` 队列
|
||||
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
|
||||
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
|
||||
|
||||
返回三种状态:
|
||||
- `"interrupted"` — 图停在干预点,已重建队列
|
||||
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
|
||||
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
|
||||
|
||||
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
|
||||
|
||||
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
|
||||
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
|
||||
|
||||
---
|
||||
|
||||
## 六、Graph 与持久化(graph.py)
|
||||
|
||||
### Lazy 单例 + 自动降级
|
||||
|
||||
```python
|
||||
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
|
||||
_async_checkpointer_singleton = None # checkpointer 单例
|
||||
|
||||
async def get_ai_writing_graph():
|
||||
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
|
||||
global AI_WRITING_GRAPH
|
||||
if AI_WRITING_GRAPH is not None:
|
||||
return AI_WRITING_GRAPH
|
||||
checkpointer = await _get_async_checkpointer()
|
||||
AI_WRITING_GRAPH = build_ai_writing_graph(checkpointer=checkpointer)
|
||||
return AI_WRITING_GRAPH
|
||||
```
|
||||
|
||||
### Checkpointer 优先级(_get_async_checkpointer)
|
||||
|
||||
```
|
||||
1. config.yaml 的 checkpointer 段
|
||||
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
|
||||
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
|
||||
└── type: memory → InMemorySaver
|
||||
|
||||
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
|
||||
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
|
||||
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
|
||||
```
|
||||
|
||||
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
|
||||
|
||||
### Shutdown
|
||||
|
||||
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
|
||||
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
|
||||
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
|
||||
|
||||
调用顺序(详见 `app/gateway/app.py` lifespan):
|
||||
1. 停清理任务
|
||||
2. `wait_for_inflight_graph_tasks(30s)`
|
||||
3. `shutdown_ai_writing_graph()`
|
||||
|
||||
---
|
||||
|
||||
## 七、节点详解(nodes/)
|
||||
|
||||
### `nodes/_utils.py`(公共工具)
|
||||
|
||||
| 函数 / 类 | 用途 |
|
||||
|---|---|
|
||||
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
|
||||
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
|
||||
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
|
||||
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
|
||||
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
|
||||
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
|
||||
| `count_words(text)` | 中文 + 英文混合计字数 |
|
||||
|
||||
### `nodes/intent_parser.py` — 意图解析
|
||||
|
||||
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
|
||||
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
|
||||
- 输出:更新 state 的对应字段,推 `step_done` 事件
|
||||
|
||||
### `nodes/researcher.py` — 素材检索
|
||||
|
||||
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
|
||||
- 用 `search/*` 的 provider 并行检索
|
||||
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
|
||||
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
|
||||
|
||||
### `nodes/writer_outline.py` — 大纲规划
|
||||
|
||||
- 输入:`user_intent` + `material_package`
|
||||
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
|
||||
- 解析最终 JSON → `current_outline`,推 `outline_ready`
|
||||
|
||||
### `nodes/writer_draft.py` — 草稿生成(最复杂)
|
||||
|
||||
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
|
||||
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
|
||||
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
|
||||
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
|
||||
|
||||
### `nodes/editor.py` — 编辑审核(3 维度并行)
|
||||
|
||||
- 3 个维度(**fact / logic / language**)并行调 LLM
|
||||
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
|
||||
- 三个都完成后聚合成 `review_result`,推 `review_ready`
|
||||
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
|
||||
|
||||
---
|
||||
|
||||
## 八、持久化(persistence/ai_writing_sessions/)
|
||||
|
||||
### `model.py` — ORM
|
||||
|
||||
```python
|
||||
class AIWritingSessionRow(Base):
|
||||
__tablename__ = "ai_writing_sessions"
|
||||
id: Mapped[str] # = session_id(uuid)
|
||||
user_id: Mapped[str | None] # NULL = no-auth 模式
|
||||
title: Mapped[str] # 从 user_intent 截取前 50 字
|
||||
user_intent: Mapped[str | None]
|
||||
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
|
||||
draft_title: Mapped[str | None]
|
||||
draft_markdown: Mapped[str | None]
|
||||
completed_interventions: Mapped[str | None] # JSON 字符串
|
||||
review_result: Mapped[str | None] # JSON 字符串
|
||||
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
|
||||
created_at / updated_at: BeijingDateTime
|
||||
```
|
||||
|
||||
### `sql.py` — Repository
|
||||
|
||||
| 方法 | 用途 |
|
||||
|---|---|
|
||||
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
|
||||
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
|
||||
| `list(user_id, limit)` | 列出当前用户的会话 |
|
||||
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
|
||||
| `delete(session_id, user_id)` | 删除(带权限校验) |
|
||||
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
|
||||
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
|
||||
|
||||
### Alembic 迁移
|
||||
|
||||
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
|
||||
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
|
||||
|
||||
---
|
||||
|
||||
## 九、定期清理(ai_writing_cleanup.py)
|
||||
|
||||
### 设计目标
|
||||
- 删超过 `retention_days`(默认 7)的旧会话
|
||||
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
|
||||
- **绝对不删正在写的会话**(活跃保护)
|
||||
|
||||
### 关键函数
|
||||
|
||||
```python
|
||||
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
|
||||
```
|
||||
|
||||
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
|
||||
|
||||
执行流程:
|
||||
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
|
||||
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
|
||||
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
|
||||
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
|
||||
5. 循环直到本批 < batch_size
|
||||
|
||||
### Cron 调度
|
||||
|
||||
`cleanup_scheduler_loop()` 常驻后台任务:
|
||||
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
|
||||
- `asyncio.sleep` 到下次触发时间
|
||||
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
|
||||
|
||||
### Admin 手动触发
|
||||
|
||||
`POST /api/ai-writing/admin/cleanup`:
|
||||
- 鉴权:`system_role == "admin"`
|
||||
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
|
||||
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
|
||||
|
||||
---
|
||||
|
||||
## 十、配置(config.yaml)
|
||||
|
||||
```yaml
|
||||
# Checkpointer:graph 状态持久化
|
||||
checkpointer:
|
||||
type: sqlite # sqlite | postgres | memory
|
||||
connection_string: .deer-flow/data/checkpoints.db
|
||||
|
||||
# AI 写作子系统
|
||||
ai_writing:
|
||||
# ── 清理调度 ─────────────────────────────────────────────
|
||||
cleanup:
|
||||
enabled: true
|
||||
retention_days: 7
|
||||
cron: "0 3 * * *" # 每天凌晨 3 点
|
||||
delete_only_finished: false
|
||||
batch_size: 500
|
||||
|
||||
# ── 素材收集专家(researcher)专用配置 ──────────────────
|
||||
# 这两个模型名必须出现在顶层 models[] 段里;留空 / 不配则回退当前写作主模型。
|
||||
# 拆出来是为了让「检索词生成 / 素材排序」这种结构化抽取任务可以走更轻量、
|
||||
# 不带思考输出的小模型,省钱省时间,也避开写作主模型的思考干扰。
|
||||
keyword_model: qwen3-4b-instruct # 检索词生成(EXTRACT_KEYWORDS_*)
|
||||
rank_model: qwen3-4b-instruct # 素材排序筛选(RANK_MATERIALS_*)
|
||||
|
||||
# 素材总量上限:合并去重后保留多少条素材进入大纲 / 草稿阶段
|
||||
# 取值范围 [1, 100],非法值或缺省回退 20。
|
||||
# 每个检索词分到的检索条数 = ceil(max_materials / keyword_count),
|
||||
# 所以这个数越大,每个关键词向 provider 拉的条数越多,整体耗时也越长。
|
||||
max_materials: 20
|
||||
|
||||
# ── 素材检索 provider ───────────────────────────────────
|
||||
# internet(默认,DuckDuckGo) / intranet(内网 ES) / tavily / demo
|
||||
search_provider: internet
|
||||
|
||||
# internet 模式:DuckDuckGo 区域代码(cn-zh / us-en / wt-wt 等)
|
||||
ddg_region: "cn-zh"
|
||||
|
||||
# intranet 模式:对接内网知识库 ES 检索接口
|
||||
# 接口契约(POST):
|
||||
# payload = {"query": "...", "knowledge_base_name": "<intranet_knowledge_base>", "ts": "yes"}
|
||||
# response = [{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}]
|
||||
# 实现见 packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples # 默认 samples,按部署的实际知识库名改
|
||||
```
|
||||
|
||||
### 配置项与代码的对应关系
|
||||
|
||||
| 配置键 | 默认值 | 取值范围 / 说明 | 读取位置 |
|
||||
|---|---|---|---|
|
||||
| `ai_writing.keyword_model` | 空(回退写作主模型) | 必须是 `models[]` 段里已声明的模型名 | `nodes/researcher.py` → `llm_json(..., model_name=keyword_model)` |
|
||||
| `ai_writing.rank_model` | 空(回退写作主模型) | 同上 | `nodes/researcher.py` → 排序分支 `llm_json(..., model_name=rank_model)` |
|
||||
| `ai_writing.max_materials` | `20` | `[1, 100]`,非法值回退 20;`_resolve_max_materials` 钳制 | `nodes/researcher.py:_DEFAULT_MAX_MATERIALS` |
|
||||
| `ai_writing.search_provider` | `"internet"` | `internet` / `intranet` / `tavily` / `demo` | `search/__init__.py:get_search_provider` |
|
||||
| `ai_writing.ddg_region` | `"cn-zh"` | DuckDuckGo 区域代码,仅 `internet` 模式生效 | 同上 |
|
||||
| `ai_writing.intranet_search_url` | `http://10.106.57.180:7863/knowledge_base/search_docs_es` | 仅 `intranet` 模式生效,POST JSON 接口 | `search/intranet_stub.py:_DEFAULT_ENDPOINT` |
|
||||
| `ai_writing.intranet_knowledge_base` | `"samples"` | 仅 `intranet` 模式生效,知识库 ID/名称 | 同上 |
|
||||
|
||||
### 配置如何传到节点
|
||||
|
||||
```
|
||||
config.yaml
|
||||
└─ ai_writing: { ... }
|
||||
│
|
||||
▼ _ai_writing_config() 取出整个 dict
|
||||
routers/ai_writing.py:_make_config()
|
||||
│ 注入到 config.configurable.ai_writing_config
|
||||
▼
|
||||
nodes/researcher.py
|
||||
cfg = config.get("configurable", {})
|
||||
ai_writing_config = cfg.get("ai_writing_config", {})
|
||||
keyword_model = ai_writing_config.get("keyword_model")
|
||||
rank_model = ai_writing_config.get("rank_model")
|
||||
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
|
||||
provider = get_search_provider(ai_writing_config) # ← search_provider 等也从这里读
|
||||
```
|
||||
|
||||
### 切换内网检索的最小步骤
|
||||
|
||||
1. `config.yaml` 改 `ai_writing.search_provider: intranet`
|
||||
2. 填 `intranet_search_url` 和 `intranet_knowledge_base`(部署侧给)
|
||||
3. 内网 ES 接口若返回结构异常 / 网络抖动,researcher 节点会收集 `provider.errors` + `provider.raw_responses`,作为非致命 `notice` 事件推前端时间线(折叠显示原始响应体),**不会中断写作流程**
|
||||
4. 健康检查:可通过 `IntranetSearchProvider.health_check()` 单测,或直接发一笔 `/start` 看 `research_progress` 是否报错
|
||||
|
||||
### 备注
|
||||
|
||||
- `keyword_model` / `rank_model` 只影响素材收集专家的 LLM 调用,**不影响**大纲、草稿、编辑审核三个节点(它们各自走自己的主模型)。
|
||||
- `max_materials` 是「合并去重后的总量上限」,不是每个关键词的检索条数;每个词的检索数会按 `ceil(max_materials / keyword_count)` 均分。`keyword_count` 由前端启动写作时传入(1~10,默认 6)。
|
||||
- `intranet` 模式不带 fallback;如果内网 ES 全挂,researcher 拿到空结果会直接进入「未检索到素材,可重新搜索或在无素材的情况下继续写作」分支。如果想让内网失败时退回互联网检索,需要在 `get_search_provider` 里手动用 `_FallbackSearchProvider(IntranetSearchProvider(...), DuckDuckGoSearchProvider(...))` 包一层。
|
||||
|
||||
ai_writing:
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||
|
||||
---
|
||||
|
||||
## 十一、SSE 事件协议(后端 → 前端契约)
|
||||
|
||||
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
|
||||
|
||||
### 推出事件的位置
|
||||
|
||||
| 事件 | 推出位置 |
|
||||
|---|---|
|
||||
| `connected` | `_sse_generator` 入口 |
|
||||
| `step_start` / `step_done` | 节点内部主动 emit |
|
||||
| `research_progress` | `researcher_node` 内的 `_emit` |
|
||||
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
|
||||
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
|
||||
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
|
||||
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
|
||||
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
|
||||
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
|
||||
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
|
||||
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
|
||||
| `resumed` | pause 节点 return 时塞进 `progress_events` |
|
||||
| `notice` | 节点内手动推(非致命告警) |
|
||||
| `done` | `_stream_graph_segment` 正常结束 |
|
||||
| `error` | `_stream_graph_segment` except 分支 |
|
||||
|
||||
### 节点如何流式推 chunk
|
||||
|
||||
通过 `config.configurable.sse_queue`:
|
||||
|
||||
```python
|
||||
async def writer_outline_node(state, config):
|
||||
sse_queue = config.get("configurable", {}).get("sse_queue")
|
||||
sse_session_id = config.get("configurable", {}).get("sse_session_id")
|
||||
|
||||
async for chunk in llm.astream(prompt):
|
||||
if sse_queue:
|
||||
await sse_queue.put(("outline_chunk", {
|
||||
"session_id": sse_session_id,
|
||||
"text": chunk.content,
|
||||
"is_thinking": False,
|
||||
}))
|
||||
|
||||
return {"current_outline": parsed, ...}
|
||||
```
|
||||
|
||||
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
|
||||
|
||||
---
|
||||
|
||||
## 十二、扩展指南:常见开发任务
|
||||
|
||||
### 任务 1:新增一个 Graph 节点
|
||||
|
||||
**例**:在 `editor` 之后加一个「术语统一」节点。
|
||||
|
||||
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
|
||||
```python
|
||||
async def terminology_node(state, config):
|
||||
# ... 读 current_draft 改术语
|
||||
return {
|
||||
"current_draft": new_draft,
|
||||
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
|
||||
}
|
||||
```
|
||||
|
||||
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
|
||||
```python
|
||||
from .nodes.terminology import terminology_node
|
||||
g.add_node("terminology", terminology_node)
|
||||
# 改 editor 后的边
|
||||
g.add_edge("editor", "terminology")
|
||||
g.add_edge("terminology", "pause_review")
|
||||
```
|
||||
|
||||
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
|
||||
|
||||
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
|
||||
|
||||
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
|
||||
|
||||
### 任务 2:新增一个用户干预点
|
||||
|
||||
1. **写 pause 节点**:在 `graph.py` 顶部加:
|
||||
```python
|
||||
async def pause_xxx(state):
|
||||
user_input = interrupt({
|
||||
"pause_point": "xxx_confirm",
|
||||
"message": "...",
|
||||
"context_field": state["..."],
|
||||
})
|
||||
action = user_input.get("action", "confirm")
|
||||
if action == "confirm":
|
||||
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
|
||||
# ... 其他分支
|
||||
```
|
||||
|
||||
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
|
||||
```python
|
||||
class ResumeRequest(BaseModel):
|
||||
# ...
|
||||
new_field: Optional[str] = None
|
||||
```
|
||||
|
||||
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
|
||||
```python
|
||||
intervention = {
|
||||
# ...
|
||||
"newField": body.new_field,
|
||||
}
|
||||
```
|
||||
|
||||
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
|
||||
|
||||
5. **前端配合**:见前端文档
|
||||
|
||||
### 任务 3:新增一种素材检索 provider
|
||||
|
||||
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
|
||||
```python
|
||||
class MyProvider(SearchProvider):
|
||||
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
|
||||
...
|
||||
```
|
||||
|
||||
2. 配置 `config.yaml`:
|
||||
```yaml
|
||||
ai_writing:
|
||||
search:
|
||||
provider: my_pkg.search:MyProvider
|
||||
# provider 特定的配置...
|
||||
```
|
||||
|
||||
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
|
||||
|
||||
### 任务 4:修改 LLM Prompt
|
||||
|
||||
所有 prompt 集中在 `agents/ai_writing/prompts/`:
|
||||
- `researcher_prompts.py` — 检索专家
|
||||
- `writer_prompts.py` — 大纲 + 草稿
|
||||
- `editor_prompts.py` — 审核
|
||||
|
||||
改 prompt 后**强烈建议**:
|
||||
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
|
||||
2. 跑现有测试确认拓扑兼容
|
||||
|
||||
### 任务 5:扩展持久化字段
|
||||
|
||||
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
|
||||
|
||||
1. `model.py` 加列:
|
||||
```python
|
||||
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
```
|
||||
|
||||
2. **必须**新建 alembic 迁移:
|
||||
```bash
|
||||
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
|
||||
```
|
||||
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
|
||||
|
||||
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
|
||||
|
||||
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
|
||||
|
||||
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
|
||||
|
||||
---
|
||||
|
||||
## 十三、容错策略(关键设计原则)
|
||||
|
||||
### 「写作流程能完整继续」契约
|
||||
|
||||
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
|
||||
|
||||
### 落实位置
|
||||
|
||||
| 失败点 | 兜底位置 | 行为 |
|
||||
|---|---|---|
|
||||
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
|
||||
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
|
||||
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
|
||||
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
|
||||
| DB `update(review)` 失败 | 同上 | 同上 |
|
||||
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
|
||||
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
|
||||
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
|
||||
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
|
||||
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
|
||||
|
||||
### 容器优雅关闭
|
||||
|
||||
lifespan shutdown 顺序:
|
||||
1. 停 cleanup scheduler
|
||||
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
|
||||
3. 超时未结束的 task `cancel()` + 2s ack 等待
|
||||
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
|
||||
|
||||
---
|
||||
|
||||
## 十四、测试覆盖
|
||||
|
||||
| 测试文件 | 数量 | 覆盖范围 |
|
||||
|---|---|---|
|
||||
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
|
||||
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
|
||||
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
|
||||
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
|
||||
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
|
||||
|
||||
跑全部:
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
|
||||
```
|
||||
|
||||
期望 **51 个测试全过**。
|
||||
|
||||
---
|
||||
|
||||
## 十五、关键调试 / 排查命令
|
||||
|
||||
### 看 graph 当前状态
|
||||
|
||||
```python
|
||||
# 进 python shell
|
||||
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
|
||||
graph = await get_ai_writing_graph()
|
||||
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
|
||||
print(snap.values, snap.next, snap.tasks)
|
||||
```
|
||||
|
||||
### 直接读 sqlite checkpointer
|
||||
|
||||
```bash
|
||||
sqlite3 /app/.deer-flow/data/checkpoints.db
|
||||
.tables # 看表(checkpoints / checkpoint_writes 等)
|
||||
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
|
||||
WHERE thread_id = '<session_id>' LIMIT 5;
|
||||
```
|
||||
|
||||
### 查 MySQL 业务行
|
||||
|
||||
```sql
|
||||
SELECT id, status, draft_title, updated_at,
|
||||
JSON_LENGTH(transcript) AS transcript_size
|
||||
FROM ai_writing_sessions
|
||||
WHERE id = '<session_id>';
|
||||
```
|
||||
|
||||
### 查内存中的活跃会话(调试时手动加)
|
||||
|
||||
```python
|
||||
# 临时在路由加一个 admin 接口
|
||||
@router.get("/admin/inflight")
|
||||
async def admin_inflight():
|
||||
return {
|
||||
"event_queues": list(_event_queues.keys()),
|
||||
"inflight_tasks": len(_inflight_graph_tasks),
|
||||
"pending_pause": list(_session_pending_pause.keys()),
|
||||
}
|
||||
```
|
||||
|
||||
### 日志关键字
|
||||
|
||||
```bash
|
||||
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
|
||||
```
|
||||
|
||||
期望看到(启动时):
|
||||
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
|
||||
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
|
||||
|
||||
警告:
|
||||
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
|
||||
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
|
||||
|
||||
---
|
||||
|
||||
## 十六、性能与限制
|
||||
|
||||
### 单 session 资源占用
|
||||
- 内存:~50KB(state + progress_events 累计)
|
||||
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
|
||||
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
|
||||
|
||||
### 并发上限
|
||||
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
|
||||
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
|
||||
|
||||
### 长会话风险
|
||||
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
|
||||
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
|
||||
|
||||
### 优化方向
|
||||
- progress_events 节流(聚合 chunk 事件)
|
||||
- transcript 增量保存(diff 而非全量覆盖)
|
||||
- 节点级 LLM 调用加 retry(tenacity)
|
||||
|
||||
---
|
||||
|
||||
## 十七、与主 Agent 的关系
|
||||
|
||||
| 维度 | 主 Agent | AI 写作 |
|
||||
|---|---|---|
|
||||
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
|
||||
| Middleware | 18 层中间件 | 无(直接节点链) |
|
||||
| State | `ThreadState` | `AIWritingState` |
|
||||
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
|
||||
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
|
||||
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
|
||||
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
|
||||
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
|
||||
|
||||
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
|
||||
|
||||
---
|
||||
|
||||
## 十八、未来 RFC 方向
|
||||
|
||||
| 主题 | 描述 |
|
||||
|---|---|
|
||||
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
|
||||
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
|
||||
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
|
||||
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
|
||||
| 多模态素材 | 支持图片素材输入和图文混合输出 |
|
||||
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:相关文件速查
|
||||
|
||||
| 文件 | 行数 | 用途 |
|
||||
|---|---|---|
|
||||
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
|
||||
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
|
||||
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
|
||||
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
|
||||
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
|
||||
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
|
||||
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
|
||||
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
|
||||
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
|
||||
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
|
||||
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
|
||||
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:常见踩坑
|
||||
|
||||
| 现象 | 根因 | 解决 |
|
||||
|---|---|---|
|
||||
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
|
||||
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
|
||||
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
|
||||
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
|
||||
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
|
||||
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
|
||||
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
|
||||
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
|
||||
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
|
||||
|
||||
---
|
||||
|
||||
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
|
||||
@@ -1,726 +0,0 @@
|
||||
# AI 写作助手后端接口与 Prompt 协议文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
|
||||
|
||||
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮后端职责
|
||||
|
||||
后端负责:
|
||||
|
||||
- 接收当前线程信息
|
||||
- 接收用户选区文本
|
||||
- 接收改写动作
|
||||
- 选取必要上下文
|
||||
- 调用模型生成候选改写结果
|
||||
- 返回纯净候选文本
|
||||
|
||||
后端本轮不负责:
|
||||
|
||||
- 直接编辑 Tiptap JSON
|
||||
- 直接操作前端编辑器选区
|
||||
- 直接替换前端文档内容
|
||||
- 做复杂富文本 patch
|
||||
- 做 tracked changes
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
### 3.1 纯文本协议优先
|
||||
|
||||
首版只让后端处理:
|
||||
|
||||
- 选区文本
|
||||
- 前后文文本
|
||||
- 动作类型
|
||||
- 风格参数
|
||||
|
||||
不让后端直接理解完整富文本 JSON。
|
||||
|
||||
### 3.2 前端负责应用,后端负责生成
|
||||
|
||||
- 前端负责选区获取
|
||||
- 前端负责候选结果展示
|
||||
- 前端负责替换 / 插入
|
||||
- 后端只负责“给出候选文本”
|
||||
|
||||
### 3.3 结果必须可直接落文档
|
||||
|
||||
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
|
||||
|
||||
- “当然可以,以下是润色版本:”
|
||||
- “我帮你改写如下:”
|
||||
- “下面是优化后的内容:”
|
||||
|
||||
这类内容必须禁止。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐接口设计
|
||||
|
||||
## 4.1 主接口
|
||||
|
||||
推荐新增统一接口:
|
||||
|
||||
```text
|
||||
POST /api/writing/rewrite
|
||||
```
|
||||
|
||||
由 `action` 决定具体重写类型。
|
||||
|
||||
## 4.2 为什么推荐统一接口
|
||||
|
||||
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
|
||||
|
||||
- 前端判断分支增多
|
||||
- 埋点与异常处理分散
|
||||
- 后续新增动作不方便扩展
|
||||
|
||||
统一接口更适合本轮需求。
|
||||
|
||||
---
|
||||
|
||||
## 5. 请求体定义
|
||||
|
||||
建议请求体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"threadId": "string",
|
||||
"documentId": "string",
|
||||
"selection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"action": "polish",
|
||||
"tone": "professional",
|
||||
"surroundingContext": {
|
||||
"before": "选区前文",
|
||||
"after": "选区后文"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 字段说明
|
||||
|
||||
### 6.1 `threadId`
|
||||
|
||||
用途:
|
||||
|
||||
- 关联当前 DeerFlow 线程
|
||||
- 必要时取线程上下文或会话元数据
|
||||
|
||||
### 6.2 `documentId`
|
||||
|
||||
用途:
|
||||
|
||||
- 标识当前右侧文档
|
||||
- 首版可选
|
||||
- 如果当前系统还没有独立文档 ID,可先允许为空
|
||||
|
||||
### 6.3 `selection.from` / `selection.to`
|
||||
|
||||
用途:
|
||||
|
||||
- 帮助前后端联调排查
|
||||
- 首版后端通常不需要真正使用偏移量
|
||||
- 建议保留,便于日志定位
|
||||
|
||||
### 6.4 `selection.text`
|
||||
|
||||
用途:
|
||||
|
||||
- 这是本轮最核心输入
|
||||
- 后端实际重写的主文本
|
||||
|
||||
### 6.5 `action`
|
||||
|
||||
允许值:
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone`
|
||||
|
||||
### 6.6 `tone`
|
||||
|
||||
仅当 `action = tone` 时使用。
|
||||
|
||||
建议允许值:
|
||||
|
||||
- `professional`
|
||||
- `casual`
|
||||
- `readable`
|
||||
- `academic`
|
||||
- `expressive`
|
||||
|
||||
### 6.7 `surroundingContext.before`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区前文
|
||||
- 帮助模型理解语气、主语、叙事连续性
|
||||
|
||||
建议:
|
||||
|
||||
- 控制长度
|
||||
- 不要塞太多无关上下文
|
||||
|
||||
### 6.8 `surroundingContext.after`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区后文
|
||||
- 帮助模型在扩写、改语气时与后续内容衔接
|
||||
|
||||
---
|
||||
|
||||
## 7. 返回体定义
|
||||
|
||||
建议返回体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": "AI 改写后的候选文本",
|
||||
"originalSelection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"metadata": {
|
||||
"action": "polish",
|
||||
"tone": null,
|
||||
"model": "your-model-name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 返回字段要求
|
||||
|
||||
## 8.1 `output`
|
||||
|
||||
必须:
|
||||
|
||||
- 只包含候选正文
|
||||
- 尽量不夹带解释语
|
||||
- 能直接被前端替换或插入
|
||||
|
||||
## 8.2 `originalSelection`
|
||||
|
||||
作用:
|
||||
|
||||
- 前端可以做校验
|
||||
- 联调时方便确认是针对哪段文本返回的
|
||||
|
||||
## 8.3 `metadata`
|
||||
|
||||
作用:
|
||||
|
||||
- 便于埋点
|
||||
- 便于日志记录
|
||||
- 便于问题追踪
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误响应规范
|
||||
|
||||
建议统一错误结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "WRITING_REWRITE_FAILED",
|
||||
"message": "rewrite failed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议错误码:
|
||||
|
||||
- `WRITING_REWRITE_FAILED`
|
||||
- `WRITING_INVALID_ACTION`
|
||||
- `WRITING_INVALID_TONE`
|
||||
- `WRITING_EMPTY_SELECTION`
|
||||
- `WRITING_MODEL_UNAVAILABLE`
|
||||
- `WRITING_CONTEXT_TOO_LARGE`
|
||||
|
||||
---
|
||||
|
||||
## 10. 动作语义定义
|
||||
|
||||
## 10.1 `polish`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持原意
|
||||
- 改善表达
|
||||
- 提升通顺度、自然度和可读性
|
||||
|
||||
不应:
|
||||
|
||||
- 引入新事实
|
||||
- 改写成完全不同内容
|
||||
|
||||
## 10.2 `expand`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持主旨
|
||||
- 扩充细节
|
||||
- 增强画面感、解释性或完整度
|
||||
|
||||
不应:
|
||||
|
||||
- 无限制发散
|
||||
- 添加明显脱离主题的新内容
|
||||
|
||||
## 10.3 `shorten`
|
||||
|
||||
目标:
|
||||
|
||||
- 压缩篇幅
|
||||
- 保留关键信息
|
||||
- 减少重复表达
|
||||
|
||||
不应:
|
||||
|
||||
- 丢失关键论点
|
||||
- 压缩到语义残缺
|
||||
|
||||
## 10.4 `tone`
|
||||
|
||||
目标:
|
||||
|
||||
- 不改变原意
|
||||
- 调整语言风格
|
||||
|
||||
不应:
|
||||
|
||||
- 改写事实
|
||||
- 改写逻辑结构到完全不同
|
||||
|
||||
---
|
||||
|
||||
## 11. Prompt 设计原则
|
||||
|
||||
## 11.1 通用系统约束
|
||||
|
||||
所有改写类 prompt 都应包含以下约束:
|
||||
|
||||
- 直接输出最终可用正文
|
||||
- 不输出标题,除非原文本身就是标题改写
|
||||
- 不输出解释
|
||||
- 不输出分析过程
|
||||
- 不输出“以下是结果”之类前缀
|
||||
- 保持与上下文语气一致
|
||||
|
||||
## 11.2 上下文使用原则
|
||||
|
||||
前后文只用于理解,不要求模型重写前后文。
|
||||
|
||||
明确告诉模型:
|
||||
|
||||
- 只改写选中文本
|
||||
- 前后文仅用于参考衔接
|
||||
|
||||
## 11.3 长度控制原则
|
||||
|
||||
- `polish`:长度与原文接近
|
||||
- `expand`:长度可明显增加,但不要失控
|
||||
- `shorten`:长度明显缩短,但要保留关键信息
|
||||
- `tone`:长度尽量与原文接近
|
||||
|
||||
---
|
||||
|
||||
## 12. Prompt 模板建议
|
||||
|
||||
## 12.1 润色模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
|
||||
|
||||
要求:
|
||||
1. 保留原文事实和核心意思。
|
||||
2. 不要添加原文没有的新事实。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出润色后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.2 扩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
|
||||
|
||||
要求:
|
||||
1. 保持原文主旨不变。
|
||||
2. 增加内容时要自然,不要跑题。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出扩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.3 缩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
|
||||
|
||||
要求:
|
||||
1. 不要丢掉核心信息。
|
||||
2. 去掉重复、冗余和拖沓表达。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出缩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.4 改语气模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
|
||||
|
||||
要求:
|
||||
1. 保持原文信息不变。
|
||||
2. 只调整表达风格,不改变事实和逻辑。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出改写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Tone 映射建议
|
||||
|
||||
建议前端传标准值,后端映射中文标签:
|
||||
|
||||
- `professional` -> 专业、正式
|
||||
- `casual` -> 口语化、轻松自然
|
||||
- `readable` -> 更易读、更清晰
|
||||
- `academic` -> 学术、严谨
|
||||
- `expressive` -> 更有文采、更有表现力
|
||||
|
||||
---
|
||||
|
||||
## 14. 上下文截取策略
|
||||
|
||||
## 14.1 推荐策略
|
||||
|
||||
前端传:
|
||||
|
||||
- 选区前固定长度文本
|
||||
- 选区后固定长度文本
|
||||
|
||||
例如:
|
||||
|
||||
- 前文 200 到 500 字
|
||||
- 后文 200 到 500 字
|
||||
|
||||
### 原因
|
||||
|
||||
- 足够帮助模型理解文气和衔接
|
||||
- 不会让请求太重
|
||||
|
||||
## 14.2 不建议
|
||||
|
||||
- 把整篇长文全量塞进去
|
||||
- 把整个线程消息都塞进改写接口
|
||||
|
||||
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
|
||||
|
||||
---
|
||||
|
||||
## 15. LangGraph 接入建议
|
||||
|
||||
## 15.1 推荐接入方式
|
||||
|
||||
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
|
||||
|
||||
建议二选一:
|
||||
|
||||
### 方案 A
|
||||
|
||||
新增专用 router:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
内部调用现有模型层 / agent 层。
|
||||
|
||||
### 方案 B
|
||||
|
||||
新增专用 graph node 或 tool:
|
||||
|
||||
- `rewrite_selection`
|
||||
|
||||
再由 router 调用该能力。
|
||||
|
||||
## 15.2 更推荐方案
|
||||
|
||||
推荐:
|
||||
|
||||
- 对外暴露统一 router
|
||||
- 内部走已有模型封装或 LangGraph 节点
|
||||
|
||||
这样前端接口更稳定,后续后端内部重构不会影响前端。
|
||||
|
||||
---
|
||||
|
||||
## 16. 后端模块拆解
|
||||
|
||||
## 16.1 模块 A:接口层
|
||||
|
||||
负责:
|
||||
|
||||
- 请求校验
|
||||
- 错误响应
|
||||
- 参数标准化
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一 rewrite API
|
||||
- action / tone 校验
|
||||
- 空选区校验
|
||||
|
||||
## 16.2 模块 B:上下文拼装层
|
||||
|
||||
负责:
|
||||
|
||||
- 拼装 before / selection / after
|
||||
- 必要时提取线程相关信息
|
||||
|
||||
需要完成:
|
||||
|
||||
- 上下文长度控制
|
||||
- 清洗空白和异常字符
|
||||
|
||||
## 16.3 模块 C:Prompt 层
|
||||
|
||||
负责:
|
||||
|
||||
- 选择模板
|
||||
- 填充 action 和 tone
|
||||
|
||||
需要完成:
|
||||
|
||||
- 4 类动作模板
|
||||
- tone 映射
|
||||
|
||||
## 16.4 模块 D:模型调用层
|
||||
|
||||
负责:
|
||||
|
||||
- 走现有模型配置体系
|
||||
- 执行大模型请求
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一调用入口
|
||||
- 模型不可用错误处理
|
||||
|
||||
## 16.5 模块 E:结果清洗层
|
||||
|
||||
负责:
|
||||
|
||||
- 去掉解释性前缀
|
||||
- 去掉多余包裹
|
||||
- 标准化返回
|
||||
|
||||
需要完成:
|
||||
|
||||
- 输出清洗
|
||||
- metadata 填充
|
||||
|
||||
---
|
||||
|
||||
## 17. 返回结果清洗建议
|
||||
|
||||
即使 prompt 已经约束,模型仍可能输出:
|
||||
|
||||
- “以下是润色后的内容:”
|
||||
- “当然,这里是改写结果:”
|
||||
- “优化如下:”
|
||||
|
||||
建议后端加一层轻量清洗:
|
||||
|
||||
- 去掉常见说明性前缀
|
||||
- 去掉包裹性 markdown 代码块
|
||||
- 去掉多余首尾空白
|
||||
|
||||
注意:
|
||||
|
||||
- 清洗必须保守
|
||||
- 不要误删正文
|
||||
|
||||
---
|
||||
|
||||
## 18. 日志与观测建议
|
||||
|
||||
建议记录:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `selection_length`
|
||||
- `before_length`
|
||||
- `after_length`
|
||||
- `model_name`
|
||||
- `latency_ms`
|
||||
- `success / fail`
|
||||
|
||||
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
|
||||
|
||||
---
|
||||
|
||||
## 19. 后端测试清单
|
||||
|
||||
## 19.1 单元测试
|
||||
|
||||
- action 校验
|
||||
- tone 校验
|
||||
- 空选区校验
|
||||
- prompt 选择逻辑
|
||||
- 输出清洗逻辑
|
||||
|
||||
## 19.2 集成测试
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone-professional`
|
||||
- `tone-casual`
|
||||
- `tone-readable`
|
||||
- `tone-academic`
|
||||
- `tone-expressive`
|
||||
|
||||
## 19.3 边界测试
|
||||
|
||||
- 选区过短
|
||||
- 选区过长
|
||||
- 前后文为空
|
||||
- 多段文本带换行
|
||||
- 模型超时
|
||||
- 模型不可用
|
||||
|
||||
---
|
||||
|
||||
## 20. 联调约束
|
||||
|
||||
## 20.1 前后端必须统一
|
||||
|
||||
- `action` 枚举值
|
||||
- `tone` 枚举值
|
||||
- 错误码
|
||||
- 返回体字段名
|
||||
|
||||
## 20.2 联调重点
|
||||
|
||||
- 多段落文本中换行保留是否正确
|
||||
- AI 返回内容是否适合直接落文档
|
||||
- 超时和失败时前端是否能优雅重试
|
||||
|
||||
## 20.3 联调建议顺序
|
||||
|
||||
1. 先 mock 返回固定文本
|
||||
2. 再接真实模型
|
||||
3. 先验证单段落
|
||||
4. 再验证多段落
|
||||
5. 最后验证大段文本
|
||||
|
||||
---
|
||||
|
||||
## 21. 优先级建议
|
||||
|
||||
### P0
|
||||
|
||||
- 统一 rewrite API
|
||||
- 4 类动作支持
|
||||
- 输出纯净正文
|
||||
- 错误结构标准化
|
||||
|
||||
### P1
|
||||
|
||||
- 输出清洗增强
|
||||
- 埋点与日志
|
||||
- 上下文截断策略优化
|
||||
|
||||
### P2
|
||||
|
||||
- 更细粒度风格控制
|
||||
- 结果质量二次校验
|
||||
|
||||
---
|
||||
|
||||
## 22. 完成定义
|
||||
|
||||
后端满足以下条件即可视为本轮完成:
|
||||
|
||||
- 前端能通过统一接口发起改写
|
||||
- 4 类动作均可稳定返回候选文本
|
||||
- 返回文本基本可直接用于替换或插入
|
||||
- 多段落文本能稳定处理
|
||||
- 错误结构规范统一
|
||||
|
||||
---
|
||||
|
||||
## 23. 结论
|
||||
|
||||
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
|
||||
|
||||
最稳妥的路线是:
|
||||
|
||||
- 统一接口
|
||||
- 统一动作枚举
|
||||
- 统一 prompt 模板
|
||||
- 统一错误结构
|
||||
- 统一输出约束
|
||||
|
||||
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
|
||||
@@ -1,703 +0,0 @@
|
||||
# AI 写作助手前端开发任务清单
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
|
||||
|
||||
目标是让前端团队按模块并行推进,最终完成:
|
||||
|
||||
- 保留左侧聊天区
|
||||
- 右侧升级为 `Tiptap` 写作编辑器
|
||||
- 支持选区浮层
|
||||
- 支持润色 / 扩写 / 缩写 / 改语气
|
||||
- 支持替换 / 插入
|
||||
- 支持标题 / 列表 / 引用 / 代码块工具条
|
||||
- UI 参考豆包 + Novel + open-canvas
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮前端改造范围
|
||||
|
||||
### 2.1 保留不动
|
||||
|
||||
- 左侧聊天主链路
|
||||
- 现有线程消息流
|
||||
- 现有模型选择器
|
||||
- 现有输入框的 `writing_mode` 总入口
|
||||
|
||||
### 2.2 重点改造
|
||||
|
||||
- 右侧 Artifact 面板承载方式
|
||||
- 新增独立写作工作区
|
||||
- 富文本编辑器内核
|
||||
- 选区浮层
|
||||
- AI 改写结果预览
|
||||
- 文档级工具条
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐目录结构
|
||||
|
||||
建议新增独立模块目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas-writing/
|
||||
routes/
|
||||
WritingRoutes.tsx
|
||||
pages/
|
||||
WritingWorkspacePage.tsx
|
||||
components/
|
||||
WritingShell.tsx
|
||||
WritingHeader.tsx
|
||||
WritingToolbar.tsx
|
||||
WritingEditorPane.tsx
|
||||
WritingBubbleMenu.tsx
|
||||
WritingAiMenu.tsx
|
||||
WritingRewritePreview.tsx
|
||||
WritingStatusBar.tsx
|
||||
WritingEmptyState.tsx
|
||||
editor/
|
||||
TiptapEditor.tsx
|
||||
TiptapExtensions.ts
|
||||
commands.ts
|
||||
selection.ts
|
||||
context/
|
||||
WritingEditorContext.tsx
|
||||
hooks/
|
||||
useWritingEditor.ts
|
||||
useWritingSelection.ts
|
||||
useWritingRewrite.ts
|
||||
useWritingToolbar.ts
|
||||
api/
|
||||
rewrite.ts
|
||||
documents.ts
|
||||
types/
|
||||
document.ts
|
||||
rewrite.ts
|
||||
utils/
|
||||
apply-rewrite.ts
|
||||
markdown.ts
|
||||
editor-style.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 现有代码落点
|
||||
|
||||
## 4.1 需要接入的现有文件
|
||||
|
||||
- `frontend-web/src/pages/PageRoutes.tsx`
|
||||
- 增加新写作路由入口
|
||||
|
||||
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
- 当前双栏布局参考来源
|
||||
- 后续决定是否复用布局容器,或在新页面中重建双栏
|
||||
|
||||
- `frontend-web/src/components/workspace/input-box.tsx`
|
||||
- 保留 `writing_mode`
|
||||
- 后续可在切换写作模式时跳转到新写作页面
|
||||
|
||||
- `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
- 仅保留给旧 Artifact 逻辑
|
||||
- 不要继续承载写作编辑器状态
|
||||
|
||||
## 4.2 不建议继续沿用的方式
|
||||
|
||||
- 不要把编辑器主状态存成 `string[] artifacts`
|
||||
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
|
||||
- 不要让 AI 改写结果先回聊天,再由用户手动复制
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务分阶段拆解
|
||||
|
||||
## 阶段 A:路由与页面壳
|
||||
|
||||
### 目标
|
||||
|
||||
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRoutes.tsx`
|
||||
- 新增 `WritingWorkspacePage.tsx`
|
||||
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
|
||||
- 确认新页面进入方式
|
||||
- 确定是:
|
||||
- 通过左侧聊天上下文进入
|
||||
- 通过 `writing_mode` 入口进入
|
||||
- 或二者都支持
|
||||
|
||||
### 交付物
|
||||
|
||||
- 新写作页面能被路由访问
|
||||
- 页面有基础左右布局和空态
|
||||
|
||||
### 验收
|
||||
|
||||
- 打开 `page/writing` 能进入工作区
|
||||
- 页面不报错
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:编辑器底座
|
||||
|
||||
### 目标
|
||||
|
||||
把右侧正式替换成富文本编辑器。
|
||||
|
||||
### 任务
|
||||
|
||||
- 安装 Tiptap 依赖
|
||||
- 建立 `TiptapEditor.tsx`
|
||||
- 建立 `TiptapExtensions.ts`
|
||||
- 配置节点:
|
||||
- paragraph
|
||||
- heading
|
||||
- bulletList
|
||||
- orderedList
|
||||
- listItem
|
||||
- blockquote
|
||||
- codeBlock
|
||||
- hardBreak
|
||||
- 配置 marks:
|
||||
- bold
|
||||
- italic
|
||||
- underline
|
||||
- strike
|
||||
- code
|
||||
- 建立编辑器初始化逻辑
|
||||
- 建立编辑器销毁逻辑
|
||||
- 定义最小文档状态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 可编辑正文区域
|
||||
- 初始文档可加载
|
||||
- 内容可读可写
|
||||
|
||||
### 验收
|
||||
|
||||
- 可以直接输入正文
|
||||
- 可以换行、加标题、切换列表
|
||||
- 不出现内容错位和明显闪烁
|
||||
|
||||
---
|
||||
|
||||
## 阶段 C:主工具栏
|
||||
|
||||
### 目标
|
||||
|
||||
右侧具备正式写作工具栏。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingToolbar.tsx`
|
||||
- 新增 `useWritingToolbar.ts`
|
||||
- 实现按钮:
|
||||
- 正文
|
||||
- H1
|
||||
- H2
|
||||
- H3
|
||||
- 有序列表
|
||||
- 无序列表
|
||||
- 引用
|
||||
- 代码块
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 下划线
|
||||
- 删除线
|
||||
- 处理 active 状态
|
||||
- 处理不可用状态
|
||||
- 处理光标块状态联动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 主工具栏可用
|
||||
|
||||
### 验收
|
||||
|
||||
- 工具条按钮能正确反映当前块状态
|
||||
- 操作后编辑器内容正确变化
|
||||
|
||||
---
|
||||
|
||||
## 阶段 D:选区状态与浮层
|
||||
|
||||
### 目标
|
||||
|
||||
实现豆包式选区浮层基础能力。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `useWritingSelection.ts`
|
||||
- 新增 `selection.ts`
|
||||
- 新增 `WritingBubbleMenu.tsx`
|
||||
- 基于 Tiptap 监听 selection 变化
|
||||
- 过滤掉空选区
|
||||
- 处理多段连续选区
|
||||
- 浮层按钮:
|
||||
- AI 改写
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 标题
|
||||
- 复制
|
||||
- 处理滚动定位
|
||||
- 处理窗口尺寸变化
|
||||
|
||||
### 交付物
|
||||
|
||||
- 选中内容后出现浮层
|
||||
|
||||
### 验收
|
||||
|
||||
- 单段可触发
|
||||
- 多段可触发
|
||||
- 浮层位置稳定
|
||||
|
||||
---
|
||||
|
||||
## 阶段 E:AI 改写菜单
|
||||
|
||||
### 目标
|
||||
|
||||
把选区浮层接到 AI 改写工作流。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingAiMenu.tsx`
|
||||
- 新增 `useWritingRewrite.ts`
|
||||
- 新增 `api/rewrite.ts`
|
||||
- 实现一级动作:
|
||||
- 润色
|
||||
- 扩写
|
||||
- 缩写
|
||||
- 改语气
|
||||
- 实现改语气二级菜单:
|
||||
- 更专业
|
||||
- 更口语化
|
||||
- 更易读
|
||||
- 更学术
|
||||
- 更有文采
|
||||
- 封装请求入参
|
||||
- 增加 loading 态
|
||||
- 增加 error 态
|
||||
- 增加 retry 入口
|
||||
|
||||
### 交付物
|
||||
|
||||
- AI 改写菜单和请求链路
|
||||
|
||||
### 验收
|
||||
|
||||
- 4 个动作都能发请求
|
||||
- 改语气的二级菜单可正常展开
|
||||
- 请求失败时有反馈
|
||||
|
||||
---
|
||||
|
||||
## 阶段 F:候选结果预览
|
||||
|
||||
### 目标
|
||||
|
||||
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRewritePreview.tsx`
|
||||
- 新增 `apply-rewrite.ts`
|
||||
- 显示原文与候选内容
|
||||
- 提供按钮:
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
- 用 transaction 应用结果
|
||||
- 处理应用成功后的焦点与滚动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 改写结果预览卡片或面板
|
||||
|
||||
### 验收
|
||||
|
||||
- 替换准确
|
||||
- 插入准确
|
||||
- 取消后文档保持原样
|
||||
|
||||
---
|
||||
|
||||
## 阶段 G:文档状态与容器体验
|
||||
|
||||
### 目标
|
||||
|
||||
补齐正式产品的壳层体验。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingShell.tsx`
|
||||
- 新增 `WritingHeader.tsx`
|
||||
- 新增 `WritingStatusBar.tsx`
|
||||
- 新增 `WritingEmptyState.tsx`
|
||||
- 补充文档标题区域
|
||||
- 补充文档状态提示
|
||||
- 补充空态和加载态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 页面完整壳层
|
||||
|
||||
### 验收
|
||||
|
||||
- 页面不是裸编辑器
|
||||
- 有完整的文档工作区感
|
||||
|
||||
---
|
||||
|
||||
## 阶段 H:视觉打磨
|
||||
|
||||
### 目标
|
||||
|
||||
让整体观感接近成熟产品。
|
||||
|
||||
### 任务
|
||||
|
||||
- 调整右侧阅读宽度
|
||||
- 调整字体大小和行高
|
||||
- 调整段距和标题层级
|
||||
- 调整工具栏阴影、边框、圆角
|
||||
- 调整浮层动效
|
||||
- 调整 AI 预览卡片质感
|
||||
- 优化深浅色模式兼容
|
||||
|
||||
### 交付物
|
||||
|
||||
- 完整视觉优化
|
||||
|
||||
### 验收
|
||||
|
||||
- 明显优于当前版本
|
||||
- 有豆包 / Novel 风格的编辑器质感
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件级开发清单
|
||||
|
||||
## 6.1 `WritingWorkspacePage.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面级容器
|
||||
- 获取线程 / 文档上下文
|
||||
- 组织左右布局
|
||||
|
||||
必须完成:
|
||||
|
||||
- 页面初始化
|
||||
- 与左侧聊天协同
|
||||
- 右侧编辑区挂载
|
||||
|
||||
## 6.2 `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 写作工作区总壳层
|
||||
- 布局标题区、工具栏、正文区、状态区
|
||||
|
||||
必须完成:
|
||||
|
||||
- 自适应布局
|
||||
- 桌面端优先
|
||||
- 窄屏降级
|
||||
|
||||
## 6.3 `TiptapEditor.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 包装 Tiptap EditorContent
|
||||
- 负责 editor 实例初始化
|
||||
|
||||
必须完成:
|
||||
|
||||
- `onUpdate`
|
||||
- `onSelectionUpdate`
|
||||
- 内容同步
|
||||
|
||||
## 6.4 `WritingToolbar.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 顶部主工具栏
|
||||
|
||||
必须完成:
|
||||
|
||||
- 所有基础格式化命令
|
||||
- 当前状态高亮
|
||||
|
||||
## 6.5 `WritingBubbleMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 选区浮层
|
||||
|
||||
必须完成:
|
||||
|
||||
- 跟随选区
|
||||
- 基础快捷操作
|
||||
- AI 改写入口
|
||||
|
||||
## 6.6 `WritingAiMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- AI 改写动作菜单
|
||||
|
||||
必须完成:
|
||||
|
||||
- 一级菜单
|
||||
- 改语气二级菜单
|
||||
- 动作触发
|
||||
|
||||
## 6.7 `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 展示候选内容
|
||||
|
||||
必须完成:
|
||||
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
|
||||
---
|
||||
|
||||
## 7. Hook 级开发清单
|
||||
|
||||
## 7.1 `useWritingEditor.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- editor 生命周期
|
||||
- 初始内容装载
|
||||
- 内容变更同步
|
||||
|
||||
## 7.2 `useWritingSelection.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 当前选区抽取
|
||||
- 选区文本缓存
|
||||
- 多段落纯文本拼接
|
||||
|
||||
## 7.3 `useWritingRewrite.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 改写请求
|
||||
- loading / success / error
|
||||
- 结果缓存
|
||||
- 重试
|
||||
|
||||
## 7.4 `useWritingToolbar.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- toolbar active 状态
|
||||
- editor command 映射
|
||||
|
||||
---
|
||||
|
||||
## 8. API 对接清单
|
||||
|
||||
前端需向后端约定以下接口能力:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
前端请求内容至少包括:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `selection.from`
|
||||
- `selection.to`
|
||||
- `selection.text`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `surroundingContext.before`
|
||||
- `surroundingContext.after`
|
||||
|
||||
前端需处理的返回字段:
|
||||
|
||||
- `output`
|
||||
- `originalSelection`
|
||||
- `metadata`
|
||||
|
||||
---
|
||||
|
||||
## 9. 状态管理建议
|
||||
|
||||
建议新增独立 Context:
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
建议持有:
|
||||
|
||||
- 当前文档内容
|
||||
- 当前 editor 实例
|
||||
- 当前选区
|
||||
- 当前改写请求状态
|
||||
- 当前候选结果
|
||||
- 当前 UI 面板状态
|
||||
|
||||
不建议继续复用旧 Artifact Context 的原因:
|
||||
|
||||
- 旧 Context 关注文件列表和文件路径
|
||||
- 新编辑器关注选区、结构、候选结果、应用动作
|
||||
- 关注点完全不同
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端联调检查点
|
||||
|
||||
## 10.1 与后端联调前
|
||||
|
||||
- 先用 mock 数据打通浮层和预览
|
||||
- 先验证替换 / 插入逻辑
|
||||
|
||||
## 10.2 与后端联调时
|
||||
|
||||
- 检查多段落选区拼接结果
|
||||
- 检查 AI 返回内容是否带说明性前缀
|
||||
- 检查替换后位置是否准确
|
||||
|
||||
## 10.3 联调后
|
||||
|
||||
- 验证大段文本性能
|
||||
- 验证撤销 / 重做
|
||||
- 验证快速连续触发是否会状态错乱
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端测试清单
|
||||
|
||||
## 11.1 功能测试
|
||||
|
||||
- 输入正文
|
||||
- 插入标题
|
||||
- 切换列表
|
||||
- 插入引用
|
||||
- 插入代码块
|
||||
- 加粗 / 斜体 / 下划线 / 删除线
|
||||
- 选区浮层显示
|
||||
- 选区浮层隐藏
|
||||
- AI 改写菜单打开
|
||||
- 改语气二级菜单打开
|
||||
- 改写结果替换
|
||||
- 改写结果插入
|
||||
- 改写结果取消
|
||||
|
||||
## 11.2 场景测试
|
||||
|
||||
- 单段短文本润色
|
||||
- 多段文本润色
|
||||
- 多段文本扩写
|
||||
- 多段文本缩写
|
||||
- 多段文本改语气
|
||||
- 连续两次改写同一段
|
||||
- 快速切换线程后重新进入
|
||||
|
||||
## 11.3 回归测试
|
||||
|
||||
- 左侧聊天是否正常
|
||||
- 模型切换是否正常
|
||||
- `writing_mode` 是否正常
|
||||
- 原有 artifact 模块是否仍可使用
|
||||
|
||||
---
|
||||
|
||||
## 12. 任务优先级
|
||||
|
||||
### P0
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
- 选区浮层
|
||||
- 改写菜单
|
||||
- 替换 / 插入
|
||||
|
||||
### P1
|
||||
|
||||
- 页面头部与状态
|
||||
- 优雅 loading / error / retry
|
||||
- 多段落稳定性
|
||||
- UI 打磨
|
||||
|
||||
### P2
|
||||
|
||||
- 空态优化
|
||||
- 动效优化
|
||||
- 快捷键支持
|
||||
|
||||
---
|
||||
|
||||
## 13. 建议分工
|
||||
|
||||
### 前端 1 号
|
||||
|
||||
负责:
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
|
||||
### 前端 2 号
|
||||
|
||||
负责:
|
||||
|
||||
- 选区状态
|
||||
- BubbleMenu
|
||||
- AI 菜单
|
||||
|
||||
### 前端 3 号
|
||||
|
||||
负责:
|
||||
|
||||
- 候选结果预览
|
||||
- 替换 / 插入
|
||||
- 状态栏与页面体验
|
||||
|
||||
### 联调负责人
|
||||
|
||||
负责:
|
||||
|
||||
- 前后端请求协议
|
||||
- 错误态规范
|
||||
- 多段落测试和回归
|
||||
|
||||
---
|
||||
|
||||
## 14. 前端完成定义
|
||||
|
||||
满足以下条件可视为前端完成:
|
||||
|
||||
- 右侧能作为正式编辑器独立使用
|
||||
- 选区后可直接发起 AI 改写
|
||||
- 改写后可替换或插入
|
||||
- 视觉上明显优于当前版本
|
||||
- 左侧聊天和现有系统不被破坏
|
||||
|
||||
---
|
||||
|
||||
## 15. 下一步建议
|
||||
|
||||
前端团队可以按以下顺序开工:
|
||||
|
||||
1. 先起页面和路由
|
||||
2. 再接 Tiptap 与主工具栏
|
||||
3. 再做选区浮层
|
||||
4. 再接 AI 菜单和结果预览
|
||||
5. 最后做视觉打磨
|
||||
|
||||
这个顺序风险最小,也最容易在中途验收。
|
||||
@@ -1,457 +0,0 @@
|
||||
# AI 写作助手源码与实现映射表
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把以下几类信息对应起来:
|
||||
|
||||
- 豆包 AI 写作助手里要借鉴的交互
|
||||
- Novel / open-canvas / Tiptap 中可参考的实现思路
|
||||
- DeerFlow 当前项目里已经存在的落点
|
||||
- 本次新建模块应该落到哪里
|
||||
|
||||
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 使用方式
|
||||
|
||||
开发时建议按以下顺序看:
|
||||
|
||||
1. 先看“目标能力”
|
||||
2. 再看“参考来源”
|
||||
3. 再看“DeerFlow 当前落点”
|
||||
4. 最后看“本次目标文件”
|
||||
|
||||
这样可以避免出现两种常见问题:
|
||||
|
||||
- 直接照搬外部项目结构,结果和你们现有架构冲突
|
||||
- 只看自己项目,不知道成熟产品交互应该怎么落
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体映射
|
||||
|
||||
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
|
||||
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
|
||||
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
|
||||
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
|
||||
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
|
||||
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
|
||||
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
|
||||
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
|
||||
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
|
||||
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 豆包功能到 DeerFlow 的映射
|
||||
|
||||
## 4.1 滑词 / 选区浮层
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 选中文本后出现轻量浮层
|
||||
- 浮层里有:
|
||||
- AI 改写
|
||||
- 文本样式
|
||||
- 复制
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 右侧还没有正式的选区级工具层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 的 `BubbleMenu`
|
||||
- 再叠加自定义菜单逻辑
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.2 多段落改写
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 不是只改单句
|
||||
- 能对连续几段一起润色、扩写、缩写、改语气
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 现有右侧编辑区没有正式的多段落选区改写模型
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 前端提取连续选区纯文本
|
||||
- 前端附带前后文
|
||||
- 后端返回候选纯文本
|
||||
- 前端决定替换或插入
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
|
||||
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.3 替换 / 插入
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 改写结果不是直接覆盖原文
|
||||
- 用户可选择:
|
||||
- 替换
|
||||
- 插入
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前没有候选文本应用层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 建立候选结果预览区
|
||||
- 基于 Tiptap transaction 应用文本
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.4 标题 / 列表 / 引用 / 代码块工具条
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 右侧本身就是正式文档编辑器
|
||||
- 可以快速切换文档结构
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前右侧偏预览,不是正式写作编辑器
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 节点和命令
|
||||
- 在主工具栏中集中承载
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. Novel 参考映射
|
||||
|
||||
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
|
||||
|
||||
- 编辑器主容器的产品感
|
||||
- 工具栏和空态的组织方式
|
||||
- 正文阅读宽度、排版、留白
|
||||
|
||||
## 5.2 对应到 DeerFlow 的实现位置
|
||||
|
||||
- 页面壳:`WritingShell.tsx`
|
||||
- 顶部文档区:`WritingHeader.tsx`
|
||||
- 编辑容器:`TiptapEditor.tsx`
|
||||
- 工具栏:`WritingToolbar.tsx`
|
||||
- 空态:`WritingEmptyState.tsx`
|
||||
|
||||
## 5.3 不建议直接照搬的部分
|
||||
|
||||
- Next.js 页面结构
|
||||
- 依赖其服务端 AI 调用方式
|
||||
- 与 Vercel AI SDK 深绑定的部分
|
||||
|
||||
---
|
||||
|
||||
## 6. open-canvas 参考映射
|
||||
|
||||
## 6.1 主要借鉴对象
|
||||
|
||||
open-canvas 这次最值得借鉴的不是页面外观,而是:
|
||||
|
||||
- AI 工作流组织方式
|
||||
- 改写结果应用节奏
|
||||
- 编辑器和聊天协作的模式
|
||||
|
||||
## 6.2 借鉴重点
|
||||
|
||||
### 借鉴 1:独立工作区思想
|
||||
|
||||
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
|
||||
|
||||
### 借鉴 2:画布状态独立维护
|
||||
|
||||
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
|
||||
|
||||
### 借鉴 3:AI 输出先预览,再应用
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 7. Tiptap 参考映射
|
||||
|
||||
## 7.1 Tiptap 在本项目中的职责
|
||||
|
||||
- 作为右侧正式富文本编辑器内核
|
||||
- 提供结构节点与 marks
|
||||
- 提供 BubbleMenu 能力
|
||||
- 提供 transaction 应用能力
|
||||
|
||||
## 7.2 建议直接参考的实现点
|
||||
|
||||
- `EditorContent` 的封装模式
|
||||
- `BubbleMenu` 的接入方式
|
||||
- heading / list / blockquote / codeBlock 扩展配置
|
||||
- `editor.chain().focus()` 命令调用模式
|
||||
- selection 变化监听
|
||||
|
||||
## 7.3 DeerFlow 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8. DeerFlow 现有文件到新模块的映射
|
||||
|
||||
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 汇总页面路由
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 挂入新的写作助手路由
|
||||
|
||||
### 后续动作
|
||||
|
||||
- 引入 `WritingRoutes`
|
||||
- 增加 `page/writing/*`
|
||||
|
||||
---
|
||||
|
||||
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 左聊天 + 右 Artifact 双栏布局
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 提供双栏工作区组织思路
|
||||
- 可复用布局比例和 panel 交互经验
|
||||
|
||||
### 不建议直接复用的部分
|
||||
|
||||
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 管理文件型 artifact 列表、选中项和开关状态
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 仅作为旧 Artifact 模块保留
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
### 原因
|
||||
|
||||
- 写作编辑器需要的不是文件路径状态
|
||||
- 而是文档状态、选区状态、候选结果状态、工具层状态
|
||||
|
||||
---
|
||||
|
||||
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 聊天输入区
|
||||
- 已有 `writing_mode` 入口
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 保留为用户进入写作模式的现有入口
|
||||
|
||||
### 本次改造建议
|
||||
|
||||
- 写作模式打开时,跳转或唤起新的写作工作区
|
||||
- 不再只停留在“生成 Markdown 文件”这个层面
|
||||
|
||||
---
|
||||
|
||||
## 9. 新模块间的依赖关系
|
||||
|
||||
## 9.1 页面层
|
||||
|
||||
- `WritingRoutes.tsx`
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面路由
|
||||
- 双栏或工作区布局
|
||||
- 上下文装配
|
||||
|
||||
## 9.2 编辑器层
|
||||
|
||||
- `TiptapEditor.tsx`
|
||||
- `TiptapExtensions.ts`
|
||||
- `commands.ts`
|
||||
- `selection.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 编辑器内核
|
||||
- 节点和 marks
|
||||
- 基础命令
|
||||
- 选区抽取
|
||||
|
||||
## 9.3 交互层
|
||||
|
||||
- `WritingToolbar.tsx`
|
||||
- `WritingBubbleMenu.tsx`
|
||||
- `WritingAiMenu.tsx`
|
||||
- `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 工具栏
|
||||
- 浮层
|
||||
- AI 菜单
|
||||
- 候选内容应用
|
||||
|
||||
## 9.4 状态层
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
- `useWritingEditor.ts`
|
||||
- `useWritingSelection.ts`
|
||||
- `useWritingRewrite.ts`
|
||||
- `useWritingToolbar.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 主状态
|
||||
- 选区状态
|
||||
- 改写状态
|
||||
- 工具栏状态
|
||||
|
||||
## 9.5 API 层
|
||||
|
||||
- `api/rewrite.ts`
|
||||
- `api/documents.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 对接后端写作改写接口
|
||||
- 对接文档加载与保存接口
|
||||
|
||||
---
|
||||
|
||||
## 10. 能力到文件的精确映射
|
||||
|
||||
| 能力 | 新文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
|
||||
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
|
||||
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
|
||||
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
|
||||
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
|
||||
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
|
||||
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
|
||||
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
|
||||
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
|
||||
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
|
||||
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
|
||||
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
|
||||
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发时的参考顺序
|
||||
|
||||
建议开发顺序如下:
|
||||
|
||||
1. 先看 `PageRoutes.tsx`,确定路由接法
|
||||
2. 再看 `chat-box.tsx`,理解当前左右布局
|
||||
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
|
||||
4. 再接 `TiptapEditor.tsx`
|
||||
5. 再做 `WritingToolbar.tsx`
|
||||
6. 再做 `WritingBubbleMenu.tsx`
|
||||
7. 再做 `WritingAiMenu.tsx`
|
||||
8. 最后做 `WritingRewritePreview.tsx`
|
||||
|
||||
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
|
||||
|
||||
---
|
||||
|
||||
## 12. 明确哪些是“参考”,哪些是“目标”
|
||||
|
||||
### 12.1 参考
|
||||
|
||||
- 豆包:交互体验参考
|
||||
- Novel:页面和编辑器质感参考
|
||||
- open-canvas:AI 工作流参考
|
||||
- Tiptap:编辑器技术实现参考
|
||||
|
||||
### 12.2 目标
|
||||
|
||||
目标不是复刻任何一个外部项目,而是:
|
||||
|
||||
- 在 DeerFlow 现有架构中
|
||||
- 用 React + Vite + 现有线程体系
|
||||
- 做出一个更成熟的 AI 写作编辑器模式
|
||||
|
||||
---
|
||||
|
||||
## 13. 结论
|
||||
|
||||
这份映射表的核心作用,是避免团队在开发时迷路。
|
||||
|
||||
真正推荐的姿势是:
|
||||
|
||||
- 用豆包定义目标体验
|
||||
- 用 Novel 定义页面质感
|
||||
- 用 open-canvas 定义 AI 工作流
|
||||
- 用 Tiptap 作为编辑器内核
|
||||
- 用 DeerFlow 现有路由、线程、模型体系做宿主
|
||||
|
||||
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,645 +0,0 @@
|
||||
# AI 写作后端新路线设计草案
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为“弱模型多 Agent 写作工作流”建设一条独立后端路线,满足以下要求:
|
||||
|
||||
- 不影响现有同事正在开发的功能
|
||||
- 不强耦合现有 DeerFlow 通用对话主链路
|
||||
- 可独立部署、独立调试、独立回滚
|
||||
- 支持多阶段写作编排、人工介入、失败重试、流式事件输出
|
||||
|
||||
---
|
||||
|
||||
## 2. 推荐建设方式
|
||||
|
||||
优先建议:
|
||||
|
||||
- 在 `offline-backend-20260512` 下新建独立应用或独立模块
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
如果当前后端没有 `apps/` 结构,也可以采用:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
ai-writing-orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
重点不是目录名字,而是一定要把“多 Agent 写作编排”与现有通用聊天能力隔离开。
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术路线建议
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 编排框架:`LangGraph`
|
||||
- 模型调用:接你们内网模型网关
|
||||
- 检索层:接内网知识库 / 文档库 / 搜索服务
|
||||
- 状态存储:先用数据库或 Redis + DB,后续再演进
|
||||
- 事件输出:SSE 或流式 HTTP
|
||||
|
||||
推荐原因:
|
||||
|
||||
- `LangGraph` 适合状态机和多节点回退
|
||||
- 对弱模型最关键的是“强流程控制”,不是“自由 Agent 对话”
|
||||
- 后续加人工确认节点和 checkpoint 会更自然
|
||||
|
||||
---
|
||||
|
||||
## 4. 分层设计
|
||||
|
||||
建议按四层拆分。
|
||||
|
||||
## 4.1 API 层
|
||||
|
||||
职责:
|
||||
|
||||
- 接收前端请求
|
||||
- 鉴权
|
||||
- 参数校验
|
||||
- 返回 workflow 信息
|
||||
- 提供事件流出口
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
api/
|
||||
routes/
|
||||
workflows.py
|
||||
workflow_events.py
|
||||
schemas/
|
||||
workflow_request.py
|
||||
workflow_response.py
|
||||
```
|
||||
|
||||
## 4.2 Application 层
|
||||
|
||||
职责:
|
||||
|
||||
- 组织用例
|
||||
- 调度 workflow service
|
||||
- 管理人工确认动作
|
||||
- 处理重试和终止逻辑
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
application/
|
||||
services/
|
||||
workflow_service.py
|
||||
workflow_event_service.py
|
||||
human_action_service.py
|
||||
use_cases/
|
||||
create_workflow.py
|
||||
start_workflow.py
|
||||
confirm_outline.py
|
||||
rewrite_section.py
|
||||
approve_final.py
|
||||
```
|
||||
|
||||
## 4.3 Domain 层
|
||||
|
||||
职责:
|
||||
|
||||
- 定义工作流状态机
|
||||
- 定义 Agent 输入输出协议
|
||||
- 定义 artifact 与评分模型
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 4.4 Infrastructure 层
|
||||
|
||||
职责:
|
||||
|
||||
- 访问模型网关
|
||||
- 访问检索服务
|
||||
- 访问数据库
|
||||
- 发送事件
|
||||
- 缓存与日志
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
llm/
|
||||
model_client.py
|
||||
retrieval/
|
||||
intranet_search_client.py
|
||||
knowledge_base_client.py
|
||||
persistence/
|
||||
workflow_repository.py
|
||||
artifact_repository.py
|
||||
streaming/
|
||||
sse_publisher.py
|
||||
logging/
|
||||
workflow_logger.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 工作流最小闭环
|
||||
|
||||
第一版建议只做如下状态机:
|
||||
|
||||
1. `collect_materials`
|
||||
2. `generate_outline`
|
||||
3. `await_outline_confirmation`
|
||||
4. `write_draft`
|
||||
5. `review_draft`
|
||||
6. `rewrite_if_needed`
|
||||
7. `complete`
|
||||
|
||||
终止规则:
|
||||
|
||||
- 编辑评分达标则完成
|
||||
- 否则进入重写
|
||||
- 最多重写 2 次
|
||||
- 超过次数则标记 `human_intervention_required`
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent 模块设计
|
||||
|
||||
## 6.1 Researcher
|
||||
|
||||
输入:
|
||||
|
||||
- 主题
|
||||
- 用户要求
|
||||
- 检索范围
|
||||
- 可用资料源
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
程序职责:
|
||||
|
||||
- 先做检索
|
||||
- 再把检索结果裁剪后送给模型
|
||||
- 输出必须结构化
|
||||
|
||||
## 6.2 Outliner
|
||||
|
||||
输入:
|
||||
|
||||
- 用户要求
|
||||
- 研究结果
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
约束:
|
||||
|
||||
- 提纲优先,不写正文
|
||||
|
||||
## 6.3 Writer
|
||||
|
||||
输入:
|
||||
|
||||
- 已确认提纲
|
||||
- 素材卡片
|
||||
- 风格要求
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
建议:
|
||||
|
||||
- 按章节分段生成,不要一次长文直出
|
||||
|
||||
## 6.4 Editor
|
||||
|
||||
输入:
|
||||
|
||||
- 初稿
|
||||
- 提纲
|
||||
- 素材
|
||||
- 用户要求
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
规则:
|
||||
|
||||
- 先查事实,再查结构,再查文风
|
||||
|
||||
## 6.5 Supervisor
|
||||
|
||||
输入:
|
||||
|
||||
- 各阶段结果
|
||||
- 重试计数
|
||||
- 评分结果
|
||||
|
||||
输出:
|
||||
|
||||
- 下一跳节点
|
||||
- 是否需要人工确认
|
||||
- 是否结束
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt 组织建议
|
||||
|
||||
建议为每个 Agent 分开管理 prompt,不要写成一个超级 prompt。
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 每个 Agent 一套职责
|
||||
- 每个 Agent 一套输出 schema
|
||||
- Prompt 版本可管理
|
||||
- 后续支持 A/B 调参
|
||||
|
||||
---
|
||||
|
||||
## 8. 弱模型适配策略
|
||||
|
||||
## 8.1 先检索后生成
|
||||
|
||||
不要让模型自己“想资料”,程序先检索、裁剪、去重后再喂给模型。
|
||||
|
||||
## 8.2 短上下文任务化
|
||||
|
||||
每个节点只给弱模型一小段明确任务:
|
||||
|
||||
- 研究归纳
|
||||
- 提纲整理
|
||||
- 单章节写作
|
||||
- 单章节审稿
|
||||
|
||||
## 8.3 结构化输出
|
||||
|
||||
优先 JSON 或 Pydantic schema,不要放任自然语言自由返回。
|
||||
|
||||
## 8.4 章节级重写
|
||||
|
||||
不要整篇回炉。编辑指出第 2 节有问题,就只重写第 2 节。
|
||||
|
||||
## 8.5 程序接管质量门槛
|
||||
|
||||
例如:
|
||||
|
||||
- 资料引用数不足,不进入下一步
|
||||
- 提纲章节数过少,直接重生
|
||||
- 编辑评分低于阈值,自动重写
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据模型建议
|
||||
|
||||
## 9.1 Workflow
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `status`
|
||||
- `current_stage`
|
||||
- `user_id`
|
||||
- `thread_id`
|
||||
- `input_payload`
|
||||
- `current_artifact_id`
|
||||
- `retry_count`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
## 9.2 Workflow Artifact
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `artifact_type`
|
||||
- `stage`
|
||||
- `version`
|
||||
- `title`
|
||||
- `content`
|
||||
- `metadata`
|
||||
|
||||
## 9.3 Workflow Event
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `event_type`
|
||||
- `stage`
|
||||
- `agent_name`
|
||||
- `payload`
|
||||
- `created_at`
|
||||
|
||||
## 9.4 Human Action
|
||||
|
||||
建议字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `action_type`
|
||||
- `payload`
|
||||
- `operator`
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## 10. API 草案
|
||||
|
||||
## 10.1 创建任务
|
||||
|
||||
`POST /api/ai-writing/workflows`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "主题",
|
||||
"articleType": "行业分析",
|
||||
"audience": "管理层",
|
||||
"tone": "专业克制",
|
||||
"targetLength": 3000,
|
||||
"constraints": ["避免口语化"],
|
||||
"referenceScope": ["内网知识库", "项目复盘文档"]
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "wf_xxx",
|
||||
"status": "idle"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.2 启动任务
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/start`
|
||||
|
||||
## 10.3 查询任务
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}`
|
||||
|
||||
## 10.4 订阅事件
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}/events`
|
||||
|
||||
返回事件流示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stage_changed",
|
||||
"stage": "generating_outline",
|
||||
"message": "正在生成提纲"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.5 确认提纲
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
|
||||
## 10.6 指定章节重写
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
|
||||
## 10.7 确认终稿
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
---
|
||||
|
||||
## 11. LangGraph 实现建议
|
||||
|
||||
## 11.1 推荐 graph 节点
|
||||
|
||||
- `load_input`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
## 11.2 推荐 state
|
||||
|
||||
建议 state 字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `input_payload`
|
||||
- `materials`
|
||||
- `outline`
|
||||
- `outline_confirmed`
|
||||
- `draft_sections`
|
||||
- `merged_draft`
|
||||
- `review_result`
|
||||
- `rewrite_targets`
|
||||
- `final_article`
|
||||
- `retry_count`
|
||||
- `events`
|
||||
|
||||
## 11.3 条件边
|
||||
|
||||
建议条件边逻辑:
|
||||
|
||||
- 提纲未确认 -> 等待用户
|
||||
- 编辑通过 -> 完成
|
||||
- 编辑不通过且可重试 -> 重写
|
||||
- 编辑不通过且超限 -> 人工介入
|
||||
|
||||
---
|
||||
|
||||
## 12. 可观测性要求
|
||||
|
||||
第一版就建议带上以下能力:
|
||||
|
||||
- 每个 workflow 的事件日志
|
||||
- 每个 Agent 的输入输出摘要
|
||||
- 每次重试原因
|
||||
- 每个阶段耗时
|
||||
- 每次模型调用 token 与错误日志
|
||||
|
||||
后续排问题会非常依赖这些数据。
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试建议
|
||||
|
||||
## 13.1 单元测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- stage 流转
|
||||
- retry policy
|
||||
- quality policy
|
||||
- 事件构造
|
||||
|
||||
## 13.2 集成测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- 创建任务到完成的主链路
|
||||
- 提纲确认链路
|
||||
- 审稿失败后重写链路
|
||||
- 人工介入链路
|
||||
|
||||
## 13.3 Prompt 回归集
|
||||
|
||||
建议建立 10-20 个固定写作样例,做回归:
|
||||
|
||||
- 短文
|
||||
- 长文
|
||||
- 事实密集型文章
|
||||
- 宣传型文章
|
||||
- 分析型文章
|
||||
|
||||
---
|
||||
|
||||
## 14. 开发顺序建议
|
||||
|
||||
## Step 1:先做 domain 与 API 契约
|
||||
|
||||
先定:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- human action payload
|
||||
|
||||
## Step 2:先做 fake orchestrator
|
||||
|
||||
先不接真实模型,只模拟事件流,把前后端联调跑通。
|
||||
|
||||
## Step 3:接 Researcher + Outliner
|
||||
|
||||
优先把“研究 + 提纲”做出来,因为这是弱模型提升最大的环节。
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再接初稿和审稿重写。
|
||||
|
||||
## Step 5:接可观测性和缓存
|
||||
|
||||
最后再补生产化能力。
|
||||
|
||||
---
|
||||
|
||||
## 15. 风险控制建议
|
||||
|
||||
### 风险 1:直接复用现有线程流,导致协议污染
|
||||
|
||||
建议:
|
||||
|
||||
- workflow 用独立 route 和独立 event schema
|
||||
|
||||
### 风险 2:写作结果与右侧 artifact 模型不兼容
|
||||
|
||||
建议:
|
||||
|
||||
- 后端统一输出标准 markdown artifact
|
||||
|
||||
### 风险 3:弱模型频繁输出不合法 JSON
|
||||
|
||||
建议:
|
||||
|
||||
- 每个节点加 schema repair
|
||||
- 加轻量重试
|
||||
- 尽量减少复杂嵌套输出
|
||||
|
||||
### 风险 4:研究资料太多导致上下文爆炸
|
||||
|
||||
建议:
|
||||
|
||||
- 检索后先裁剪、摘要、分桶
|
||||
|
||||
---
|
||||
|
||||
## 16. 最终建议
|
||||
|
||||
后端第一版最重要的是把“状态机、事件流、人工确认、章节重写”这四件事做对,而不是一开始就追求很多花哨 Agent。
|
||||
|
||||
建议 MVP 只保留:
|
||||
|
||||
1. Researcher
|
||||
2. Outliner
|
||||
3. Writer
|
||||
4. Editor
|
||||
5. Supervisor
|
||||
|
||||
当这条链路稳定后,再继续扩展 fact-checker、style editor、compliance reviewer。
|
||||
@@ -1,806 +0,0 @@
|
||||
# AI 写作执行方案
|
||||
|
||||
## 1. 总体原则
|
||||
|
||||
本项目的最终落地原则:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制第二套页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,不侵入现有主链路
|
||||
3. 编排框架采用 `LangGraph`
|
||||
4. 写作工作流采用 `Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
5. Agent 间输出统一采用 `JSON Schema / Pydantic`
|
||||
6. 前后端通过 `SSE` 传递工作流事件
|
||||
7. 第一版只做最小闭环,不做过多花哨扩展
|
||||
|
||||
一句话说明:
|
||||
|
||||
用独立的新后端路线承载多 Agent 写作工作流,前端继续复用 `open-canvas`,实现上采用 `LangGraph + A/B/C Agent + SSE` 的最小闭环。
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目边界
|
||||
|
||||
## 2.1 前端边界
|
||||
|
||||
继续使用当前页面:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
前端做的事情:
|
||||
|
||||
- 左侧增加“写作工作流模式”
|
||||
- 展示多 Agent 执行进度与中间产物
|
||||
- 提供提纲确认和指定章节重写
|
||||
- 将初稿、终稿同步到右侧 artifact
|
||||
|
||||
前端不做的事情:
|
||||
|
||||
- 不重写右侧编辑器
|
||||
- 不重写现有普通 chat 流程
|
||||
- 不把 workflow 状态散落在多个旧 context 里
|
||||
|
||||
## 2.2 后端边界
|
||||
|
||||
后端采用独立新路线:
|
||||
|
||||
推荐目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
后端做的事情:
|
||||
|
||||
- 实现写作 workflow 状态机
|
||||
- 实现 A/B/C Agent 与 Supervisor
|
||||
- 接内网模型与内网检索
|
||||
- 输出标准事件流和文章 artifact
|
||||
|
||||
后端不做的事情:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把工作流逻辑塞到现有通用 thread 协议里
|
||||
|
||||
## 2.3 资料搜集边界
|
||||
|
||||
第一版处于互联网开发阶段,因此研究员的资料搜集先走互联网检索,不接内网检索服务。
|
||||
|
||||
第一版要求:
|
||||
|
||||
- 研究员可以调用互联网搜索能力收集公开资料
|
||||
- 研究结果必须保留来源 URL、标题、摘要、抓取时间
|
||||
- 检索层必须抽象成统一接口,不能把互联网搜索逻辑写死在 Agent prompt 或 graph 节点里
|
||||
|
||||
第二版预留:
|
||||
|
||||
- 后续替换或并行接入内网搜索接口
|
||||
- 后续接入内部知识库、文档库、FAQ、项目复盘库
|
||||
- 前端和工作流层不因检索源变化而改协议
|
||||
|
||||
统一设计要求:
|
||||
|
||||
- `Researcher` 只依赖“检索接口”,不依赖具体是互联网还是内网
|
||||
- `writing_graph` 只读取标准化后的素材结果
|
||||
- API、事件流、artifact 结构不区分互联网资料和内网资料
|
||||
|
||||
推荐检索抽象:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
```
|
||||
|
||||
建议接口:
|
||||
|
||||
```python
|
||||
class RetrievalProvider(Protocol):
|
||||
def search(self, query: str, top_k: int) -> list[RetrievedDocument]: ...
|
||||
|
||||
class RetrievedDocument(BaseModel):
|
||||
title: str
|
||||
url: str | None
|
||||
source_type: str
|
||||
snippet: str
|
||||
published_at: str | None = None
|
||||
fetched_at: str
|
||||
reliability: str | None = None
|
||||
```
|
||||
|
||||
第一版路由策略:
|
||||
|
||||
- 默认 `internet`
|
||||
- 配置项预留 `intranet`、`hybrid`
|
||||
|
||||
例如:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
```
|
||||
|
||||
这样第一版先跑公网资料,后续切换内网时,只替换 `retriever_router` 和对应 provider,不改 workflow 主逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一版产品目标
|
||||
|
||||
用户在 `open-canvas` 中选择“写作工作流模式”后,可以完成以下流程:
|
||||
|
||||
1. 输入主题、体裁、读者、风格、篇幅
|
||||
2. 研究员搜集资料并输出素材卡片
|
||||
3. 提纲 Agent 生成提纲
|
||||
4. 用户确认提纲
|
||||
5. Writer 生成初稿
|
||||
6. Editor 给出问题清单与评分
|
||||
7. 不达标则重写一次或两次
|
||||
8. 终稿写入右侧 artifact
|
||||
|
||||
第一版成功标准:
|
||||
|
||||
- 不是“生成得像最强模型一样好”
|
||||
- 而是“明显优于单轮直出,并且流程可控、可回退、可人工介入”
|
||||
|
||||
## 3.1 第一版研究员目标
|
||||
|
||||
第一版 `Researcher` 的核心职责,不是“写文章”,而是“给 Writer 准备可信、可引用、可筛选的素材”。
|
||||
|
||||
第一版输入:
|
||||
|
||||
- 用户主题
|
||||
- 用户写作要求
|
||||
- 检索源配置,第一版默认 `internet`
|
||||
|
||||
第一版输出至少包含:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
每条素材最少要带:
|
||||
|
||||
- 来源标题
|
||||
- 来源 URL
|
||||
- 摘要
|
||||
- 可信度标签
|
||||
- 被引用的原始片段或整理后的事实点
|
||||
|
||||
第一版 Researcher 的程序流程建议:
|
||||
|
||||
1. 根据主题自动生成 3-5 个检索 query
|
||||
2. 调用互联网检索接口拿回候选文档
|
||||
3. 做去重、裁剪、初筛
|
||||
4. 让模型将结果整理成结构化素材卡片
|
||||
5. 将结构化结果返回给 Outliner 和 Writer
|
||||
|
||||
第一版先不做:
|
||||
|
||||
- 深度网页 crawling
|
||||
- 多轮自动追问式深研究
|
||||
- 内网知识库接入
|
||||
- 复杂引用格式生成
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终工作流
|
||||
|
||||
## 4.1 Agent 角色
|
||||
|
||||
### `Researcher`
|
||||
|
||||
职责:
|
||||
|
||||
- 从配置的检索源检索信息
|
||||
- 产出结构化素材卡片
|
||||
- 标注来源、可信度、风险点
|
||||
|
||||
第一版说明:
|
||||
|
||||
- 默认走互联网公开资料检索
|
||||
- 必须预留后续切换到内网检索源的接口
|
||||
- 检索实现放在 `infrastructure/retrieval/`,不要写死在 prompt 中
|
||||
|
||||
第一版检索源字段建议:
|
||||
|
||||
- `source_type = "internet"`
|
||||
- 后续预留 `source_type = "intranet" | "hybrid"`
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
### `Outliner`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户要求和研究结果生成提纲
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
### `Writer`
|
||||
|
||||
职责:
|
||||
|
||||
- 基于提纲和素材写初稿
|
||||
- 按章节写,不一次生成整篇长文
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
### `Editor`
|
||||
|
||||
职责:
|
||||
|
||||
- 检查事实、结构、文风
|
||||
- 给出评分与重写建议
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
### `Supervisor`
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程调度
|
||||
- 负责通过 / 重写 / 人工介入判断
|
||||
|
||||
## 4.2 状态机
|
||||
|
||||
第一版统一采用:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
## 4.3 流程闭环
|
||||
|
||||
最小闭环固定为:
|
||||
|
||||
1. `Research`
|
||||
2. `Outline`
|
||||
3. `HumanConfirmOutline`
|
||||
4. `Draft`
|
||||
5. `Review`
|
||||
6. `RewriteIfNeeded`
|
||||
7. `Complete`
|
||||
|
||||
重试规则:
|
||||
|
||||
- 最多重写 2 次
|
||||
- 达标则完成
|
||||
- 超限则进入人工介入
|
||||
|
||||
## 4.4 LangGraph 推荐节点
|
||||
|
||||
第一版推荐节点:
|
||||
|
||||
- `load_input`
|
||||
- `build_search_queries`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
其中和资料搜集直接相关的节点说明:
|
||||
|
||||
### `build_search_queries`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户主题和目标文章类型,生成 3-5 个检索 query
|
||||
|
||||
### `retrieve_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 调用检索 provider 获取候选文档
|
||||
- 第一版默认走 `internet_search_client`
|
||||
- 后续可切到 `intranet_search_client`
|
||||
|
||||
### `normalize_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 做去重
|
||||
- 做摘要裁剪
|
||||
- 做来源字段统一
|
||||
- 输出标准 `RetrievedDocument[]`
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端最终做法
|
||||
|
||||
## 5.1 目录建议
|
||||
|
||||
在现有 `open-canvas` 下新增:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
CanvasWorkflowPanel.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
```
|
||||
|
||||
## 5.2 左侧改造策略
|
||||
|
||||
`CanvasChatPanel` 只承担模式切换与容器职责:
|
||||
|
||||
- `chat` 模式:沿用现有普通聊天
|
||||
- `writing_pipeline` 模式:渲染 `CanvasWorkflowPanel`
|
||||
|
||||
新面板负责:
|
||||
|
||||
- 写作任务表单
|
||||
- 进度时间线
|
||||
- 事件列表
|
||||
- 中间产物抽屉
|
||||
- 提纲确认卡片
|
||||
|
||||
## 5.3 右侧衔接策略
|
||||
|
||||
继续复用现有 `CanvasArtifactPanel`。
|
||||
|
||||
同步规则:
|
||||
|
||||
- 提纲:可选同步到右侧预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖当前草稿
|
||||
- 终稿:作为最终 artifact
|
||||
|
||||
## 5.4 前端状态设计
|
||||
|
||||
新增:
|
||||
|
||||
- `workflowMode`
|
||||
- `workflowRunId`
|
||||
- `workflowStage`
|
||||
- `workflowStatus`
|
||||
- `workflowEvents`
|
||||
- `workflowArtifacts`
|
||||
- `workflowPendingAction`
|
||||
|
||||
注意:
|
||||
|
||||
- workflow 状态放进新的 `WritingWorkflowContext`
|
||||
- 不塞进现有 `CanvasGraphContext`
|
||||
|
||||
## 5.5 前端显示的资料信息
|
||||
|
||||
第一版左侧至少要能展示研究员产出的资料摘要:
|
||||
|
||||
- 来源标题
|
||||
- 来源域名或 URL
|
||||
- 资料摘要
|
||||
- 可信度标签
|
||||
- 是否被提纲或正文引用
|
||||
|
||||
建议在 `WritingWorkflowArtifactDrawer.tsx` 中增加“素材卡片”视图。
|
||||
|
||||
注意:
|
||||
|
||||
- 第一版显示公开互联网来源即可
|
||||
- 后续接内网时,这个展示层不变,只是来源字段从 URL 变成内网文档标识或内部链接
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端最终做法
|
||||
|
||||
## 6.1 分层
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
## 6.2 核心模块
|
||||
|
||||
建议核心文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 6.3 检索层实现要求
|
||||
|
||||
第一版检索层必须独立成基础设施模块,不允许:
|
||||
|
||||
- 在 Agent prompt 中直接拼接“去网上找资料”
|
||||
- 在 graph 节点中散落请求第三方搜索的代码
|
||||
- 把互联网检索和内网检索混在一个无抽象的函数里
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
material_normalizer.py
|
||||
```
|
||||
|
||||
建议职责:
|
||||
|
||||
- `internet_search_client.py`
|
||||
- 第一版实际使用
|
||||
- 对接互联网搜索 API 或爬取网关
|
||||
|
||||
- `intranet_search_client.py`
|
||||
- 第一版只保留接口和空实现或 mock
|
||||
- 后续接企业内部接口
|
||||
|
||||
- `retriever_router.py`
|
||||
- 根据配置选择 provider
|
||||
|
||||
- `material_normalizer.py`
|
||||
- 统一清洗和标准化结果
|
||||
|
||||
## 6.4 Prompt 管理
|
||||
|
||||
统一独立存放:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
## 6.5 输出约束
|
||||
|
||||
每个 Agent 都必须:
|
||||
|
||||
- 优先输出 JSON / Pydantic schema
|
||||
- 失败时允许轻量 repair 和一次重试
|
||||
- 避免自由散文式输出
|
||||
|
||||
## 6.6 Researcher 的实现细则
|
||||
|
||||
Researcher 推荐由两部分组成:
|
||||
|
||||
1. 程序化检索
|
||||
2. 模型化整理
|
||||
|
||||
推荐伪流程:
|
||||
|
||||
```python
|
||||
def run_research(topic: str, article_type: str, provider: RetrievalProvider):
|
||||
queries = build_queries(topic, article_type)
|
||||
raw_docs = []
|
||||
for query in queries:
|
||||
raw_docs.extend(provider.search(query=query, top_k=5))
|
||||
docs = normalize_materials(raw_docs)
|
||||
docs = dedupe_materials(docs)
|
||||
docs = rank_materials(docs)
|
||||
return llm_extract_material_cards(docs)
|
||||
```
|
||||
|
||||
第一版建议:
|
||||
|
||||
- 先把 query 生成逻辑做简单
|
||||
- `top_k` 先控制在 5-10
|
||||
- 先做轻量去重和摘要裁剪
|
||||
- 不做很重的网页全文抓取
|
||||
|
||||
---
|
||||
|
||||
## 7. 前后端协议
|
||||
|
||||
## 7.1 API
|
||||
|
||||
第一版统一提供:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
## 7.2 SSE 事件
|
||||
|
||||
统一事件类型:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
第一版前端不需要消费复杂嵌套事件,只要先把这 9 类打通。
|
||||
|
||||
## 7.3 配置项补充
|
||||
|
||||
建议增加以下配置项:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
enable_content_fetch: false
|
||||
|
||||
workflow:
|
||||
max_revisions: 2
|
||||
|
||||
models:
|
||||
researcher: your-default-model
|
||||
outliner: your-default-model
|
||||
writer: your-default-model
|
||||
editor: your-default-model
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 第一版 `provider=internet`
|
||||
- 第二版可切成 `intranet` 或 `hybrid`
|
||||
|
||||
---
|
||||
|
||||
## 8. 必须做 / 延后做 / 不建议现在做
|
||||
|
||||
## 8.1 必须做
|
||||
|
||||
### 架构
|
||||
|
||||
- 独立后端路线 `ai-writing-orchestrator`
|
||||
- 前端独立 `workflow/` 目录
|
||||
- LangGraph 状态机
|
||||
- `Researcher / Outliner / Writer / Editor / Supervisor`
|
||||
- 独立检索抽象层,第一版先落互联网 provider
|
||||
|
||||
### 协议
|
||||
|
||||
- 标准 workflow 状态枚举
|
||||
- 标准 SSE 事件枚举
|
||||
- 标准 artifact 结构
|
||||
- JSON / Pydantic 输出约束
|
||||
|
||||
### 功能
|
||||
|
||||
- 启动写作任务
|
||||
- 研究阶段
|
||||
- 提纲阶段
|
||||
- 提纲确认
|
||||
- 初稿生成
|
||||
- 审稿与最多 2 次重写
|
||||
- 终稿同步到右侧
|
||||
- 素材卡片展示
|
||||
- 来源 URL、标题、摘要、可信度落库或挂在 artifact metadata
|
||||
|
||||
### UI
|
||||
|
||||
- 模式切换
|
||||
- 时间线
|
||||
- 事件列表
|
||||
- 提纲确认卡片
|
||||
- 右侧终稿显示
|
||||
- 左侧素材卡片视图
|
||||
|
||||
## 8.2 延后做
|
||||
|
||||
- 章节级精准重写
|
||||
- 初稿多版本树
|
||||
- FactChecker
|
||||
- StyleEditor
|
||||
- ComplianceReviewer
|
||||
- 自动评分看板
|
||||
- 素材引用可视化高亮
|
||||
- 更复杂的人机协作节点
|
||||
- 内网知识库正式接入
|
||||
- 混合检索 `hybrid`
|
||||
- 网页全文抓取与深摘要
|
||||
|
||||
## 8.3 不建议现在做
|
||||
|
||||
- 直接接入现有 DeerFlow 主 agent 包
|
||||
- 同时试 MetaGPT、CrewAI、AutoGen 多套底座
|
||||
- 一开始就做 5 个以上 Agent
|
||||
- 一开始就做很复杂的文章版本管理
|
||||
- 一开始就把评分阈值和 prompt 写死得非常细
|
||||
|
||||
## 9. 最终开发顺序
|
||||
|
||||
## Step 1:先定契约
|
||||
|
||||
先一起拍板:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- confirm / rewrite 的 payload
|
||||
- retrieval provider 接口
|
||||
- RetrievedDocument 结构
|
||||
|
||||
## Step 2:先做 fake workflow
|
||||
|
||||
后端先不接真实模型,只吐 mock SSE 事件,前端把左侧工作台跑通。
|
||||
|
||||
## Step 3:先接互联网版 Researcher + Outliner
|
||||
|
||||
优先打磨“研究 + 提纲”,这是弱模型提升最大的部分。
|
||||
|
||||
这一阶段要求:
|
||||
|
||||
- `Researcher` 先走互联网检索
|
||||
- `retriever_router` 可配置
|
||||
- `intranet_search_client` 先留接口空壳
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再补初稿与审稿重写闭环。
|
||||
|
||||
## Step 5:接右侧 artifact
|
||||
|
||||
把初稿和终稿稳定写入右侧。
|
||||
|
||||
## Step 6:补生产化
|
||||
|
||||
- 缓存
|
||||
- 日志
|
||||
- 错误回放
|
||||
- 配置化 prompt
|
||||
|
||||
## 10. 推荐任务拆分
|
||||
|
||||
## 前端
|
||||
|
||||
- `CanvasChatPanel` 模式切换
|
||||
- `workflow/` 目录与类型
|
||||
- 时间线 / 事件列表 / 确认卡片
|
||||
- SSE 订阅 hook
|
||||
- 右侧 artifact 映射
|
||||
- 素材卡片列表展示
|
||||
- 来源字段展示规范
|
||||
|
||||
## 后端
|
||||
|
||||
- workflow API
|
||||
- LangGraph graph
|
||||
- 5 个 Agent 模块
|
||||
- Prompt 目录
|
||||
- SSE 输出
|
||||
- 仓储与日志
|
||||
- `RetrievalProvider` 抽象
|
||||
- `internet_search_client`
|
||||
- `intranet_search_client` 预留接口
|
||||
- `material_normalizer`
|
||||
|
||||
## Prompt / 算法
|
||||
|
||||
- Researcher prompt
|
||||
- Outliner prompt
|
||||
- Writer prompt
|
||||
- Editor prompt
|
||||
- JSON schema
|
||||
- 失败样本回归
|
||||
|
||||
## 联调
|
||||
|
||||
- mock SSE
|
||||
- 真模型接入
|
||||
- artifact 同步校验
|
||||
- 提纲确认链路验证
|
||||
- 互联网资料来源字段联调
|
||||
- 素材卡片到提纲引用链路联调
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发过程中的约束
|
||||
|
||||
开发过程中统一采用下面这套口径:
|
||||
|
||||
- 产品形态:当前 `open-canvas` 页面上的“写作工作流模式”
|
||||
- 技术底座:`LangGraph`
|
||||
- 核心模式:`Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
- 后端方式:独立 `ai-writing-orchestrator`
|
||||
- 检索方式:第一版 `internet`,接口预留 `intranet`
|
||||
- 通信方式:`SSE`
|
||||
- 输出方式:`JSON Schema / Pydantic`
|
||||
- 第一版目标:先做最小闭环,不追求角色数量和复杂度
|
||||
|
||||
给开发团队的统一口径:
|
||||
|
||||
“我们在 `open-canvas` 上开发写作工作流模式,后端单开 `ai-writing-orchestrator`,第一版先用互联网资料检索,保留内网检索接入口,用 LangGraph 编排 Researcher、Outliner、Writer、Editor、Supervisor,把研究、提纲、初稿、审稿重写这条最小闭环做稳。”
|
||||
@@ -1,538 +0,0 @@
|
||||
# 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<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` — 历史会话
|
||||
|
||||
```typescript
|
||||
// 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 行,最大)
|
||||
|
||||
负责:
|
||||
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`,支持 `<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 | 非致命告警的标题、详情、原始错误 |
|
||||
|
||||
---
|
||||
|
||||
## 八、扩展指南:常见开发任务
|
||||
|
||||
### 新增一个干预点
|
||||
|
||||
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/<id>/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/<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`,主要类型:
|
||||
|
||||
```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 里注明改动原因。
|
||||
@@ -1,472 +0,0 @@
|
||||
# AI 写作前端改造清单
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不新增第二套页面的前提下,基于现有 `open-canvas` 页面,把左侧从“单轮聊天输入框”升级为“多 Agent 写作工作台”,右侧继续沿用当前 `artifact` 编辑与流式预览能力。
|
||||
|
||||
当前核心文件:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
---
|
||||
|
||||
## 2. 改造原则
|
||||
|
||||
- 不改右侧主编辑器交互心智
|
||||
- 不复制新页面
|
||||
- 不破坏现有普通 chat 模式
|
||||
- 新增“写作工作流模式”时尽量以增量状态字段和新组件完成
|
||||
- 所有 workflow 能力都应允许逐步灰度
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐前端结构
|
||||
|
||||
建议在 `frontend-web/src/open-canvas` 下新增以下目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
utils/
|
||||
workflow-event.ts
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `open-canvas` 原有聊天、artifact、editor 目录不重构
|
||||
- 以 `workflow/` 作为新能力边界,减少对既有文件的侵入
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态设计
|
||||
|
||||
## 4.1 新增的核心状态
|
||||
|
||||
建议新增:
|
||||
|
||||
- `workflowMode: "chat" | "writing_pipeline"`
|
||||
- `workflowRunId: string | null`
|
||||
- `workflowStage`
|
||||
- `workflowStatus: "idle" | "running" | "waiting_user" | "completed" | "failed"`
|
||||
- `workflowEvents: WritingWorkflowEvent[]`
|
||||
- `workflowArtifacts: WritingWorkflowArtifact[]`
|
||||
- `workflowPendingAction: WritingPendingAction | null`
|
||||
|
||||
## 4.2 推荐类型定义
|
||||
|
||||
建议在 `workflow/types/workflow.ts` 中先定义:
|
||||
|
||||
```ts
|
||||
export type WritingWorkflowStage =
|
||||
| "idle"
|
||||
| "collecting_materials"
|
||||
| "generating_outline"
|
||||
| "await_outline_confirmation"
|
||||
| "writing_draft"
|
||||
| "reviewing_draft"
|
||||
| "rewriting_draft"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type WritingWorkflowEventType =
|
||||
| "workflow_started"
|
||||
| "stage_changed"
|
||||
| "agent_started"
|
||||
| "agent_output_chunk"
|
||||
| "artifact_generated"
|
||||
| "human_confirmation_required"
|
||||
| "retry_scheduled"
|
||||
| "workflow_completed"
|
||||
| "workflow_failed";
|
||||
```
|
||||
|
||||
第一版把类型先收紧,后续再扩展。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件拆分建议
|
||||
|
||||
## 5.1 左侧主面板改造
|
||||
|
||||
当前 `CanvasChatPanel` 建议只承担“模式路由”和布局容器职责,不继续无限堆逻辑。
|
||||
|
||||
建议改造为:
|
||||
|
||||
- `chat` 模式继续渲染现有消息列表 + 输入框
|
||||
- `writing_pipeline` 模式渲染新的工作流视图
|
||||
|
||||
建议拆分出:
|
||||
|
||||
- `CanvasChatConversation.tsx`
|
||||
- `CanvasChatInputBox.tsx`
|
||||
- `CanvasWorkflowPanel.tsx`
|
||||
|
||||
这样后续不会把 `CanvasChatPanel.tsx` 变成一个过大的组件。
|
||||
|
||||
## 5.2 新增组件职责
|
||||
|
||||
### `WritingWorkflowEntryForm.tsx`
|
||||
|
||||
负责采集:
|
||||
|
||||
- 主题
|
||||
- 文章类型
|
||||
- 目标读者
|
||||
- 语气风格
|
||||
- 目标篇幅
|
||||
- 是否需要引用资料
|
||||
- 禁写项
|
||||
- 补充说明
|
||||
|
||||
### `WritingWorkflowTimeline.tsx`
|
||||
|
||||
负责显示阶段:
|
||||
|
||||
- 研究中
|
||||
- 提纲生成中
|
||||
- 等待确认提纲
|
||||
- 正在写初稿
|
||||
- 正在审稿
|
||||
- 正在重写
|
||||
- 已完成
|
||||
|
||||
### `WritingWorkflowEventList.tsx`
|
||||
|
||||
负责显示细粒度事件:
|
||||
|
||||
- 哪个 Agent 开始执行
|
||||
- 当前在处理哪一步
|
||||
- 当前产生了什么中间结果
|
||||
- 是否重试
|
||||
|
||||
### `WritingWorkflowArtifactDrawer.tsx`
|
||||
|
||||
负责展示中间产物:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑意见
|
||||
- 重写稿
|
||||
|
||||
### `WritingWorkflowConfirmCard.tsx`
|
||||
|
||||
负责展示用户确认点:
|
||||
|
||||
- 确认提纲
|
||||
- 驳回提纲并附带修改意见
|
||||
- 继续写稿
|
||||
- 指定重写章节
|
||||
|
||||
---
|
||||
|
||||
## 6. Context 与 Hook 改造建议
|
||||
|
||||
## 6.1 `CanvasGraphContext`
|
||||
|
||||
当前它只维护:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `streamingArtifact`
|
||||
- `isStreaming`
|
||||
|
||||
建议不要把 workflow 状态直接塞进现有 `CanvasGraphContext`,否则会让语义过重。
|
||||
|
||||
推荐做法:
|
||||
|
||||
- 保留 `CanvasGraphContext` 继续服务右侧 artifact 和通用流式状态
|
||||
- 新增 `WritingWorkflowContext`
|
||||
|
||||
## 6.2 `WritingWorkflowContext`
|
||||
|
||||
建议承载:
|
||||
|
||||
- 当前 workflow 运行状态
|
||||
- 中间产物列表
|
||||
- 待确认操作
|
||||
- workflow 事件缓存
|
||||
- 启动 / 取消 / 确认 / 重写等 action
|
||||
|
||||
## 6.3 `useCanvasThreadStream`
|
||||
|
||||
当前这个 hook 已经负责:
|
||||
|
||||
- 创建 thread
|
||||
- 提交消息
|
||||
- 接收 thread stream
|
||||
- 将 `update_artifact` 同步到右侧
|
||||
|
||||
建议不要把“多 Agent workflow 状态机”也硬塞进去。
|
||||
|
||||
推荐方案:
|
||||
|
||||
- 保留 `useCanvasThreadStream` 给普通 chat 或兼容旧能力使用
|
||||
- 新增 `useWritingWorkflow`
|
||||
- 新增 `useWritingWorkflowEvents`
|
||||
|
||||
分工如下:
|
||||
|
||||
- `useCanvasThreadStream`:普通对话流
|
||||
- `useWritingWorkflow`:创建 workflow、提交表单、触发确认动作
|
||||
- `useWritingWorkflowEvents`:订阅 workflow 事件流
|
||||
|
||||
---
|
||||
|
||||
## 7. API 接入建议
|
||||
|
||||
## 7.1 前端 API 文件
|
||||
|
||||
新增:
|
||||
|
||||
- `workflow/api/writing-workflow.ts`
|
||||
|
||||
建议暴露方法:
|
||||
|
||||
- `createWorkflow(payload)`
|
||||
- `startWorkflow(id)`
|
||||
- `getWorkflow(id)`
|
||||
- `subscribeWorkflowEvents(id, handlers)`
|
||||
- `confirmOutline(id, payload)`
|
||||
- `rewriteSection(id, payload)`
|
||||
- `approveFinal(id)`
|
||||
|
||||
## 7.2 事件流形式
|
||||
|
||||
优先级建议:
|
||||
|
||||
1. `SSE`
|
||||
2. 流式 HTTP chunk
|
||||
3. 轮询兜底
|
||||
|
||||
理由:
|
||||
|
||||
- 事件驱动比整段消息流更适合 workflow UI
|
||||
- 可以更方便映射到时间线和事件列表
|
||||
|
||||
---
|
||||
|
||||
## 8. 与右侧 Artifact 的衔接
|
||||
|
||||
## 8.1 现有优势
|
||||
|
||||
当前 `CanvasArtifactPanel` 已具备:
|
||||
|
||||
- 流式展示内容
|
||||
- 最终 artifact 渲染
|
||||
- 右侧编辑体验成熟
|
||||
|
||||
这意味着多 Agent 工作流不需要再做第二套文章渲染,只要把最终阶段结果推成 artifact 即可。
|
||||
|
||||
## 8.2 建议接法
|
||||
|
||||
工作流各阶段结果按两类处理:
|
||||
|
||||
- 中间产物:进入左侧 `workflowArtifacts`
|
||||
- 可阅读正文:同步一份到右侧 `artifact`
|
||||
|
||||
推荐映射:
|
||||
|
||||
- 提纲:可选同步到右侧,供用户预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖或新增版本到右侧
|
||||
- 终稿:作为最终右侧主版本
|
||||
|
||||
## 8.3 版本策略
|
||||
|
||||
建议右侧版本保留:
|
||||
|
||||
- `outline`
|
||||
- `draft_v1`
|
||||
- `draft_v2`
|
||||
- `final`
|
||||
|
||||
如果现有 artifact 数据结构不方便承载完整版本树,第一版可先保留在 workflowArtifacts 中,右侧只展示当前激活版本。
|
||||
|
||||
---
|
||||
|
||||
## 9. 页面交互流程
|
||||
|
||||
## 9.1 用户首次进入
|
||||
|
||||
- 默认仍是现有 chat 模式
|
||||
- 在左侧增加一个显眼切换入口:`普通对话` / `写作工作流`
|
||||
|
||||
## 9.2 启动写作工作流
|
||||
|
||||
- 用户填写写作任务表单
|
||||
- 点击“开始生成”
|
||||
- 左侧切换到时间线 + 事件流视图
|
||||
- 右侧进入 loading 或素材 / 提纲预览态
|
||||
|
||||
## 9.3 等待提纲确认
|
||||
|
||||
- 时间线停在 `await_outline_confirmation`
|
||||
- 左侧出现确认卡片
|
||||
- 用户可执行:
|
||||
- 通过提纲
|
||||
- 要求调整提纲
|
||||
- 追加要求
|
||||
|
||||
## 9.4 生成初稿与审稿
|
||||
|
||||
- 左侧显示 Agent 事件
|
||||
- 右侧展示初稿内容
|
||||
- 审稿意见在左侧展示,同时高亮需要重写的章节
|
||||
|
||||
## 9.5 完成
|
||||
|
||||
- 左侧显示“终稿已完成”
|
||||
- 右侧进入可继续局部 AI 编辑状态
|
||||
|
||||
---
|
||||
|
||||
## 10. 分步开发清单
|
||||
|
||||
## Step 1:类型与假数据
|
||||
|
||||
目标:
|
||||
|
||||
- 先把 workflow 的类型、mock 数据、组件骨架搭好
|
||||
|
||||
任务:
|
||||
|
||||
- 新建 `workflow/types/workflow.ts`
|
||||
- 新建 `workflow/components/*`
|
||||
- 用本地 mock 事件驱动时间线和事件流
|
||||
|
||||
验收:
|
||||
|
||||
- 不接后端也能在页面中演示完整流程
|
||||
|
||||
## Step 2:模式切换
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 中接入 `chat / writing_pipeline` 双模式
|
||||
|
||||
任务:
|
||||
|
||||
- 拆出 `CanvasWorkflowPanel`
|
||||
- 增加模式切换 UI
|
||||
- 保证旧 chat 不回归
|
||||
|
||||
验收:
|
||||
|
||||
- 现有功能不受影响
|
||||
- 新模式可进入表单页
|
||||
|
||||
## Step 3:接入 workflow API
|
||||
|
||||
目标:
|
||||
|
||||
- 从 mock 切到真实 API
|
||||
|
||||
任务:
|
||||
|
||||
- 接 `create/start/get/events`
|
||||
- 实现基础错误态和重试态
|
||||
|
||||
验收:
|
||||
|
||||
- 前后端可联调跑一条主链路
|
||||
|
||||
## Step 4:接入确认节点
|
||||
|
||||
目标:
|
||||
|
||||
- 支持提纲确认和指定章节重写
|
||||
|
||||
任务:
|
||||
|
||||
- 做确认卡片
|
||||
- 做动作回传
|
||||
|
||||
验收:
|
||||
|
||||
- 用户能在流程中介入
|
||||
|
||||
## Step 5:打通右侧版本
|
||||
|
||||
目标:
|
||||
|
||||
- 让 workflow 产物稳定进入右侧编辑器
|
||||
|
||||
任务:
|
||||
|
||||
- 约定 artifact 映射
|
||||
- 接入版本切换或当前版本覆盖
|
||||
|
||||
验收:
|
||||
|
||||
- 终稿稳定显示在右侧
|
||||
|
||||
---
|
||||
|
||||
## 11. 建议修改点位
|
||||
|
||||
优先涉及文件:
|
||||
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
建议新增,不建议重写原有:
|
||||
|
||||
- `open-canvas/workflow/api/writing-workflow.ts`
|
||||
- `open-canvas/workflow/contexts/WritingWorkflowContext.tsx`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflow.ts`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflowEvents.ts`
|
||||
- `open-canvas/workflow/components/*`
|
||||
|
||||
---
|
||||
|
||||
## 12. 联调约定
|
||||
|
||||
前后端需要尽早统一:
|
||||
|
||||
- workflow id 命名
|
||||
- stage 枚举
|
||||
- event type 枚举
|
||||
- artifact type 枚举
|
||||
- user action payload
|
||||
- 错误码与可恢复动作
|
||||
|
||||
建议先由前端与后端共同确认一份 `workflow.ts` 契约文档,再开工。
|
||||
|
||||
---
|
||||
|
||||
## 13. 最终建议
|
||||
|
||||
前端最重要的不是“把 Agent 结果展示出来”,而是把“工作流过程可视化并允许人工介入”做清楚。
|
||||
|
||||
第一版建议先保证三件事:
|
||||
|
||||
1. 左侧能看见阶段进展
|
||||
2. 用户能确认提纲
|
||||
3. 右侧能稳定收到初稿和终稿
|
||||
|
||||
只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。
|
||||
@@ -1,718 +0,0 @@
|
||||
# AI 写作弱模型多 Agent 编排需求文档
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `open-canvas` 页面右侧的成品展示与编辑体验已经比较成熟,但左侧模型直接生成文章的质量还不稳定。对于内网部署的弱性能大模型,单轮直出通常会出现以下问题:
|
||||
|
||||
- 结构松散,长文容易跑题
|
||||
- 素材不足,内容空泛
|
||||
- 事实依据弱,容易幻觉
|
||||
- 润色能力有限,语言风格不统一
|
||||
- 一次生成失败后缺少可控的纠偏机制
|
||||
|
||||
因此本次需求的核心目标不是继续优化“单个模型一次写完”,而是在内网用代码编排一个“小型杂志社工作流”,让弱模型通过多角色分工协作,产出明显优于单模型直出的文章质量。
|
||||
|
||||
建议的角色分工:
|
||||
|
||||
- `Agent A 研究员`:负责搜集内网素材、归纳事实、提取观点
|
||||
- `Agent B 作家`:基于选题、素材和结构写初稿
|
||||
- `Agent C 严苛编辑`:负责挑错、质检、润色、打回重写
|
||||
- `Supervisor / Orchestrator`:负责流程调度、状态记录、重试与终止判断
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次建设边界
|
||||
|
||||
### 2.1 前端边界
|
||||
|
||||
前端继续使用现有 `open-canvas` 页面,不新开第二套编辑器页面。
|
||||
|
||||
当前相关入口:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
前端本期只做以下增强:
|
||||
|
||||
- 在左侧聊天区增加“写作工作流模式”
|
||||
- 让用户能看到多 Agent 阶段进度,而不是只看到最终回答
|
||||
- 支持结构化输入:主题、体裁、目标读者、篇幅、语气、参考资料、禁写项
|
||||
- 支持查看中间产物:提纲、素材卡片、初稿、编辑意见、终稿
|
||||
- 支持人工介入:确认提纲、确认初稿、接受编辑建议、手动重跑某一步
|
||||
|
||||
### 2.2 后端边界
|
||||
|
||||
后端必须单开新路线,不影响其他同事当前功能开发。
|
||||
|
||||
建议原则:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把多 Agent 逻辑硬塞进当前通用对话链路
|
||||
- 以独立服务或独立模块形式建设
|
||||
- 通过清晰 API 与当前前端页面对接
|
||||
|
||||
推荐落地位置:
|
||||
|
||||
- 基于仓库中的 `offline-backend-20260512` 单独开一条 `ai-writing-orchestrator` 路线
|
||||
- 或在后端新建独立目录,例如 `offline-backend-20260512/apps/ai_writing_orchestrator`
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么多 Agent 对弱模型更合适
|
||||
|
||||
弱模型最怕“一个 prompt 做完所有事”,因为这要求模型同时完成:
|
||||
|
||||
- 选题理解
|
||||
- 信息检索
|
||||
- 信息筛选
|
||||
- 结构规划
|
||||
- 长文写作
|
||||
- 自我校验
|
||||
- 风格润色
|
||||
|
||||
把这些任务拆开后,每个 Agent 只处理一个更短、更清晰、更容易被弱模型完成的子任务,通常会带来以下收益:
|
||||
|
||||
- 降低单次上下文复杂度
|
||||
- 降低一次性长输出失败率
|
||||
- 把“写得不好”变成“某一步做得不好”,便于重试
|
||||
- 可以在关键节点插入规则校验与人工确认
|
||||
- 可以用程序补足模型短板,而不是完全依赖模型智力
|
||||
|
||||
这套方案的本质不是“让模型变聪明”,而是“让系统更会组织模型工作”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 可借鉴的开源项目
|
||||
|
||||
以下项目适合借鉴,但不建议整套照搬。建议以“借鉴状态机、角色协作、阶段拆分、重试与审稿机制”为主。
|
||||
|
||||
### 4.1 第一优先级:LangGraph
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 天然适合把写作流程建模成状态图
|
||||
- 支持多步骤、条件分支、重试、人工介入
|
||||
- 便于把“研究 -> 提纲 -> 初稿 -> 评审 -> 重写”固化成可控流程
|
||||
- 对“弱模型 + 强流程控制”很友好
|
||||
|
||||
可借鉴点:
|
||||
|
||||
- `StateGraph` 的状态驱动编排
|
||||
- 每一步单独定义输入、输出、失败处理
|
||||
- 可视化地表达“通过 / 打回 / 重试 / 终止”
|
||||
|
||||
参考:
|
||||
|
||||
- [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph)
|
||||
- [langgraph supervisor pattern](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
|
||||
### 4.2 第二优先级:STORM
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 它本身就是“研究 + 提纲 + 成文 + 打磨”的长文系统
|
||||
- 对“先研究后写作”的思路非常契合
|
||||
- 很适合借鉴其“问题驱动研究”和“分阶段生成”的思想
|
||||
|
||||
根据其 GitHub README,STORM 将长文生成拆成两个大阶段:先研究并产出提纲,再基于提纲与资料写长文;其引擎进一步拆成知识整理、提纲生成、正文生成、文章润色四个模块。
|
||||
|
||||
这和我们的目标高度一致,尤其适合借鉴:
|
||||
|
||||
- 提纲先行
|
||||
- 素材引用先行
|
||||
- 分模块写作
|
||||
- 后置润色
|
||||
|
||||
参考:
|
||||
|
||||
- [stanford-oval/storm](https://github.com/stanford-oval/storm)
|
||||
|
||||
### 4.3 第三优先级:Deep Research Agent
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 明确支持本地模型与云模型混用
|
||||
- 已经包含研究、写作、质量校验、缓存、重试等能力
|
||||
- 很适合借鉴“研究阶段如何约束弱模型”
|
||||
|
||||
根据其 README,这个项目采用四个专职 Agent,并且带有可信度评分、质量验证、重试逻辑、缓存和 checkpoint。
|
||||
|
||||
特别适合借鉴:
|
||||
|
||||
- 素材缓存
|
||||
- 分阶段质量评分
|
||||
- 节点失败重试
|
||||
- 研究结果结构化存储
|
||||
|
||||
参考:
|
||||
|
||||
- [tarun7r/deep-research-agent](https://github.com/tarun7r/deep-research-agent)
|
||||
|
||||
### 4.4 可选借鉴:CrewAI
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 角色扮演表达自然
|
||||
- 写“研究员 / 作家 / 编辑”这种协作关系很直观
|
||||
- Flow + Crew 的组织方式适合做 demo 和快速验证
|
||||
|
||||
不建议作为首选底座的原因:
|
||||
|
||||
- 更偏“Agent 角色编排体验”
|
||||
- 若要做企业内网稳定落地,状态管理和流程可控性通常不如 LangGraph 路线清晰
|
||||
|
||||
适合借鉴:
|
||||
|
||||
- 角色定义写法
|
||||
- Task 模板
|
||||
- 多角色协作 prompt 结构
|
||||
|
||||
参考:
|
||||
|
||||
- [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI)
|
||||
|
||||
### 4.5 不建议新项目优先采用:AutoGen
|
||||
|
||||
原因:
|
||||
|
||||
- 截至最近公开信息,`microsoft/autogen` 已进入 maintenance mode
|
||||
- 官方建议新项目优先采用 `Microsoft Agent Framework`
|
||||
|
||||
如果要借鉴,只建议借鉴早期多 Agent 对话模式,不建议作为本项目主框架。
|
||||
|
||||
参考:
|
||||
|
||||
- [microsoft/autogen](https://github.com/microsoft/autogen)
|
||||
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework)
|
||||
|
||||
### 4.6 结论
|
||||
|
||||
综合本项目需求,建议技术选型优先级如下:
|
||||
|
||||
1. `LangGraph` 作为主编排框架
|
||||
2. `STORM` 借鉴研究-提纲-成文-润色的模块划分
|
||||
3. `Deep Research Agent` 借鉴缓存、评分、重试、可观测性
|
||||
4. `CrewAI` 借鉴角色 prompt 设计与任务模板
|
||||
|
||||
---
|
||||
|
||||
## 5. 目标产品形态
|
||||
|
||||
用户在当前 `open-canvas` 页面中,不再只是“向模型提问写文章”,而是启动一个可视化的写作流水线。
|
||||
|
||||
理想交互流程:
|
||||
|
||||
1. 用户输入主题、用途、风格、读者、篇幅要求
|
||||
2. 系统先进入“研究阶段”
|
||||
3. 系统给出资料卡片与候选提纲
|
||||
4. 用户确认提纲或调整
|
||||
5. 系统生成初稿
|
||||
6. 严苛编辑给出问题清单
|
||||
7. 系统自动重写问题段落
|
||||
8. 系统输出终稿到右侧编辑区
|
||||
9. 用户可继续局部改写、扩写、精简
|
||||
|
||||
核心体验目标:
|
||||
|
||||
- 左侧像“编辑部工作台”
|
||||
- 右侧像“成文编辑器”
|
||||
- 用户知道系统现在进行到哪一步
|
||||
- 用户可以在关键节点接管,而不是只能等待最终结果
|
||||
|
||||
---
|
||||
|
||||
## 6. 多 Agent 流程设计
|
||||
|
||||
## 6.1 建议的最小可用流程 MVP
|
||||
|
||||
第一版不要一上来做很复杂,建议先做 4 步闭环:
|
||||
|
||||
1. `Research`
|
||||
- 输入:主题、用户要求、内网资料源
|
||||
- 输出:素材卡片列表、可引用事实列表、风险点列表
|
||||
|
||||
2. `Outline`
|
||||
- 输入:用户要求 + 素材卡片
|
||||
- 输出:结构化提纲
|
||||
|
||||
3. `Draft`
|
||||
- 输入:提纲 + 素材卡片
|
||||
- 输出:初稿
|
||||
|
||||
4. `ReviewAndRewrite`
|
||||
- 输入:初稿 + 用户要求 + 素材卡片
|
||||
- 输出:问题清单、改写稿、终稿
|
||||
|
||||
MVP 终止条件:
|
||||
|
||||
- 编辑评分达标则结束
|
||||
- 不达标最多重写 2 次
|
||||
- 超过次数则返回“需要人工介入”
|
||||
|
||||
## 6.2 第二阶段增强流程
|
||||
|
||||
在 MVP 跑通后,再增加:
|
||||
|
||||
- `Planner`:根据文章类型动态规划流程
|
||||
- `SectionWriter`:按章节分别写作,避免长文崩坏
|
||||
- `FactChecker`:逐段核对素材来源
|
||||
- `StyleEditor`:统一语气、标题风格、句式节奏
|
||||
- `ComplianceReviewer`:内网场景下做敏感词、保密、术语规范检查
|
||||
|
||||
## 6.3 建议的状态机
|
||||
|
||||
建议将流程状态固定为:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
这样前端和后端都可以围绕统一状态通信。
|
||||
|
||||
---
|
||||
|
||||
## 7. Agent 角色定义建议
|
||||
|
||||
## 7.1 Agent A 研究员
|
||||
|
||||
职责:
|
||||
|
||||
- 搜集内网知识库、文档库、FAQ、历史稿件中的相关信息
|
||||
- 输出结构化素材,而不是长篇自然语言
|
||||
- 对每条素材标注来源、可信度、适用角度
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `facts[]`
|
||||
- `quotes[]`
|
||||
- `examples[]`
|
||||
- `risks[]`
|
||||
- `source_refs[]`
|
||||
|
||||
约束建议:
|
||||
|
||||
- 禁止直接生成整篇文章
|
||||
- 不做风格化表达
|
||||
- 每条结论必须可回溯到来源
|
||||
|
||||
## 7.2 Agent B 作家
|
||||
|
||||
职责:
|
||||
|
||||
- 严格基于提纲和素材写稿
|
||||
- 按章节写,而不是一次写完整篇
|
||||
- 对每段附带素材引用 ID,便于后续审核
|
||||
|
||||
约束建议:
|
||||
|
||||
- 无素材不发挥
|
||||
- 每段只服务一个小节目标
|
||||
- 段落长度控制
|
||||
- 输出结构化草稿数据,而不仅是纯文本
|
||||
|
||||
## 7.3 Agent C 严苛编辑
|
||||
|
||||
职责:
|
||||
|
||||
- 检查逻辑跳跃、素材不足、事实不稳、表述空泛、风格不统一
|
||||
- 输出明确的“问题清单”和“改写指令”
|
||||
- 必要时打回指定章节,而不是整篇推翻
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `issues[]`
|
||||
- `severity`
|
||||
- `rewrite_instructions[]`
|
||||
- `accept/reject`
|
||||
|
||||
编辑原则建议:
|
||||
|
||||
- 优先挑事实问题,再挑结构问题,最后才是文风问题
|
||||
- 问题描述必须可执行,不能只说“写得不够好”
|
||||
|
||||
## 7.4 Supervisor
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程启动、状态流转、失败重试、记录日志
|
||||
- 判断是否需要回到上一步
|
||||
- 控制最大重试次数
|
||||
- 对外提供统一 API 与流式事件
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端需求
|
||||
|
||||
## 8.1 保持当前页面结构不变
|
||||
|
||||
继续沿用当前左右布局:
|
||||
|
||||
- 左侧:聊天 + 流程进度 + 中间产物
|
||||
- 右侧:最终文章编辑与局部 AI 改写
|
||||
|
||||
不建议再复制一套新页面,以免造成维护分叉。
|
||||
|
||||
## 8.2 左侧新增 UI 模块
|
||||
|
||||
建议在 `CanvasChatPanel` 基础上新增:
|
||||
|
||||
- 写作任务表单
|
||||
- 工作流阶段时间线
|
||||
- Agent 执行日志面板
|
||||
- 中间产物抽屉
|
||||
- 人工确认卡片
|
||||
|
||||
建议新增的前端概念:
|
||||
|
||||
- `workflowMode`: `chat | writing_pipeline`
|
||||
- `workflowStage`
|
||||
- `workflowRunId`
|
||||
- `workflowEvents[]`
|
||||
- `workflowArtifacts[]`
|
||||
|
||||
## 8.3 需要展示的中间产物
|
||||
|
||||
至少支持展示:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑问题单
|
||||
- 重写结果
|
||||
- 最终定稿
|
||||
|
||||
这些内容建议不仅显示在消息区,也保存到右侧可选版本中,便于回看。
|
||||
|
||||
## 8.4 人工介入节点
|
||||
|
||||
至少提供两个确认点:
|
||||
|
||||
- 提纲确认
|
||||
- 初稿确认或编辑后重跑
|
||||
|
||||
后续可扩展:
|
||||
|
||||
- 指定重写某个章节
|
||||
- 锁定某段不允许再改
|
||||
- 替换某条素材后重新生成
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端需求
|
||||
|
||||
## 9.1 架构原则
|
||||
|
||||
后端新路线必须满足:
|
||||
|
||||
- 与现有通用对话链路解耦
|
||||
- 与现有 Agent 运行时解耦
|
||||
- 独立维护 Prompt、状态机、数据结构
|
||||
- 可以单独部署、单独回滚、单独压测
|
||||
|
||||
## 9.2 推荐模块划分
|
||||
|
||||
建议后端拆分为以下模块:
|
||||
|
||||
- `workflow_orchestrator`
|
||||
- `agent_registry`
|
||||
- `material_retriever`
|
||||
- `outline_generator`
|
||||
- `draft_writer`
|
||||
- `editor_reviewer`
|
||||
- `rewrite_engine`
|
||||
- `workflow_store`
|
||||
- `stream_event_adapter`
|
||||
|
||||
## 9.3 API 建议
|
||||
|
||||
建议最小 API:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- 创建写作任务
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- 启动流程
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- 查询当前状态
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- 获取事件流或轮询事件
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- 用户确认提纲
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- 指定章节重写
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
- 确认终稿
|
||||
|
||||
## 9.4 流式事件建议
|
||||
|
||||
为了与当前 `CanvasStreamContext` 风格兼容,建议后端输出结构化事件流,例如:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
前端只要订阅事件,就能把左侧工作台做出来。
|
||||
|
||||
---
|
||||
|
||||
## 10. 弱模型优化策略
|
||||
|
||||
这是本项目成功的关键,不能只靠“多 Agent”这四个字。
|
||||
|
||||
## 10.1 强约束输出格式
|
||||
|
||||
每个 Agent 尽量输出 JSON 或结构化对象,不要自由发挥。
|
||||
|
||||
原因:
|
||||
|
||||
- 便于程序消费
|
||||
- 便于后续 Agent 接续
|
||||
- 便于失败重试
|
||||
- 便于质量评估
|
||||
|
||||
## 10.2 长文拆成章节生成
|
||||
|
||||
不要让弱模型一次生成整篇长文,建议:
|
||||
|
||||
- 先出提纲
|
||||
- 再按章节逐段写
|
||||
- 最后统一润色
|
||||
|
||||
## 10.3 把检索和写作分离
|
||||
|
||||
研究员只负责找资料,作家只负责组织表达。不要让写作 Agent 同时去“边搜边编”。
|
||||
|
||||
## 10.4 程序兜底而不是模型兜底
|
||||
|
||||
程序需要承担:
|
||||
|
||||
- 去重
|
||||
- 引用格式化
|
||||
- 章节拼接
|
||||
- 长度控制
|
||||
- 评分阈值判断
|
||||
- 重试路由
|
||||
|
||||
## 10.5 小步快跑、失败可回退
|
||||
|
||||
每步都要能独立失败、独立重试、独立回放。
|
||||
|
||||
## 10.6 引入可量化评分
|
||||
|
||||
编辑 Agent 输出不能只是一段文字,建议给出评分维度:
|
||||
|
||||
- 相关性
|
||||
- 事实性
|
||||
- 结构完整度
|
||||
- 语言流畅度
|
||||
- 风格一致性
|
||||
|
||||
系统据此决定是否进入下一步。
|
||||
|
||||
---
|
||||
|
||||
## 11. 分阶段实施计划
|
||||
|
||||
## Phase 1:需求冻结与技术验证
|
||||
|
||||
目标:
|
||||
|
||||
- 确认文章类型范围
|
||||
- 确认内网资料来源
|
||||
- 选定主框架
|
||||
- 跑通最小多 Agent demo
|
||||
|
||||
交付物:
|
||||
|
||||
- 技术选型说明
|
||||
- 角色与状态机设计
|
||||
- 1 个主题的端到端 demo
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型可完成“研究 -> 提纲 -> 初稿 -> 编辑 -> 重写”闭环
|
||||
|
||||
## Phase 2:后端新路线搭建
|
||||
|
||||
目标:
|
||||
|
||||
- 建立独立 orchestrator
|
||||
- 建立 workflow state store
|
||||
- 建立事件流输出
|
||||
- 建立可配置 prompt 模板
|
||||
|
||||
交付物:
|
||||
|
||||
- 独立服务目录
|
||||
- 基础 API
|
||||
- 状态机运行骨架
|
||||
|
||||
验收标准:
|
||||
|
||||
- 不依赖现有主流程改造即可独立跑通
|
||||
|
||||
## Phase 3:前端接入当前页面
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 接入工作流模式
|
||||
- 增加阶段展示与人工确认
|
||||
- 中间产物可视化
|
||||
|
||||
交付物:
|
||||
|
||||
- 左侧工作流时间线
|
||||
- 中间产物展示面板
|
||||
- 流程控制按钮
|
||||
|
||||
验收标准:
|
||||
|
||||
- 用户可以在当前页面完成一次全流程写作
|
||||
|
||||
## Phase 4:质量优化
|
||||
|
||||
目标:
|
||||
|
||||
- 强化素材检索质量
|
||||
- 提高提纲质量
|
||||
- 引入章节级重写
|
||||
- 引入评分与回退机制
|
||||
|
||||
交付物:
|
||||
|
||||
- 质量评估规则
|
||||
- 重试策略
|
||||
- Prompt 调优包
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型效果明显优于单轮直出
|
||||
|
||||
## Phase 5:生产化
|
||||
|
||||
目标:
|
||||
|
||||
- 加缓存
|
||||
- 加日志
|
||||
- 加监控
|
||||
- 加失败告警
|
||||
- 加审计记录
|
||||
|
||||
交付物:
|
||||
|
||||
- 可观测性方案
|
||||
- 运行日志与回放
|
||||
- 配置中心或 prompt 管理机制
|
||||
|
||||
验收标准:
|
||||
|
||||
- 可长期维护并可定位问题
|
||||
|
||||
---
|
||||
|
||||
## 12. 团队协作与隔离方案
|
||||
|
||||
为了不影响其他同事,建议采用以下协作原则:
|
||||
|
||||
- 新后端目录独立开发
|
||||
- 新接口使用独立路由前缀
|
||||
- 前端只在 `open-canvas` 内增量开发
|
||||
- 不修改现有公共线程协议,必要时增加新事件类型
|
||||
- Prompt、Agent 配置、评分规则独立存放
|
||||
|
||||
推荐分工:
|
||||
|
||||
- 前端 1 人:负责左侧工作流 UI 与右侧版本接入
|
||||
- 后端 1-2 人:负责 orchestrator、Agent 执行、事件流
|
||||
- 算法/Prompt 1 人:负责角色 prompt、评分规则、失败样本调优
|
||||
|
||||
---
|
||||
|
||||
## 13. 风险与规避
|
||||
|
||||
### 风险 1:Agent 太多,成本和耗时暴涨
|
||||
|
||||
规避:
|
||||
|
||||
- MVP 只保留 3 个业务 Agent + 1 个 Supervisor
|
||||
- 长文按章节写,失败只重写局部
|
||||
|
||||
### 风险 2:弱模型在自由对话场景中失控
|
||||
|
||||
规避:
|
||||
|
||||
- 全流程结构化
|
||||
- 每步只做一件事
|
||||
- 强约束输入输出格式
|
||||
|
||||
### 风险 3:内网检索质量不足,导致垃圾进垃圾出
|
||||
|
||||
规避:
|
||||
|
||||
- 先把资料源治理好
|
||||
- 对素材做来源、可信度、去重处理
|
||||
|
||||
### 风险 4:前后端协议频繁变化影响联调
|
||||
|
||||
规避:
|
||||
|
||||
- 先定义事件模型和 artifact 模型
|
||||
- 先 mock 数据,再并行开发
|
||||
|
||||
### 风险 5:影响现有功能稳定性
|
||||
|
||||
规避:
|
||||
|
||||
- 新路线、新接口、新状态机
|
||||
- 不侵入现有主链路
|
||||
|
||||
---
|
||||
|
||||
## 14. 推荐结论
|
||||
|
||||
本项目推荐的总体策略:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制新页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,避免影响现有同事
|
||||
3. 主框架优先采用 `LangGraph`
|
||||
4. 流程设计借鉴 `STORM`
|
||||
5. 质量控制借鉴 `Deep Research Agent`
|
||||
6. 角色设计与任务模板可参考 `CrewAI`
|
||||
7. 第一版只做 4 步 MVP:研究、提纲、初稿、编辑重写
|
||||
|
||||
一句话总结:
|
||||
|
||||
不是继续“调一个更强的 prompt”,而是把弱模型放进一个可控的编辑部流水线,让系统帮它把复杂写作拆开完成。
|
||||
|
||||
---
|
||||
|
||||
## 15. 参考链接
|
||||
|
||||
- [LangGraph](https://github.com/langchain-ai/langgraph)
|
||||
- [LangGraph Supervisor](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
- [STORM](https://github.com/stanford-oval/storm)
|
||||
- [Deep Research Agent](https://github.com/tarun7r/deep-research-agent)
|
||||
- [CrewAI](https://github.com/crewAIInc/crewAI)
|
||||
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
|
||||
- [AutoGen](https://github.com/microsoft/autogen)
|
||||
@@ -1,501 +0,0 @@
|
||||
# AI 写作 — 迁移到 LangGraph Server 的分步实现方案
|
||||
|
||||
> **文档目的**:彻底解决 AI 写作在多 worker 部署下"SSE 时断时续 / resume 偶发 404"的根因,参照主聊天(`/workspace/chats/new`)的实现,把 AI 写作的 graph 注册成 LangGraph Server 上的一个 assistant,让 Server 接管线程、checkpoint、SSE、interrupt/resume 的全部协议。
|
||||
>
|
||||
> **配套文档**:[ai-writing-说明.md](./ai-writing-说明.md) 里描述了为什么会出问题、当前已落地的兜底方案,以及方案 B/C/F 三条"绕过"路径。本文档是其中"长期方案"的具体实现路线 —— 不是绕过多 worker,而是从架构层面让它**天然不受 worker 数影响**。
|
||||
>
|
||||
> **更新日期**:2026-05-20
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么走这条路
|
||||
|
||||
主聊天和 AI 写作功能形态几乎一致(多步流式、可中断/恢复、有持久化),但**主聊天完全不受多 worker 影响**。差别在于:
|
||||
|
||||
| 维度 | 主聊天(现状) | AI 写作(现状) | AI 写作(迁移后) |
|
||||
|---|---|---|---|
|
||||
| 前端流接入 | `useStream` (SDK) | `fetch` + 自写 `EventSource` | `useStream` (SDK) |
|
||||
| 后端运行时 | **LangGraph Server**(独立进程,端口 2024 / 经 nginx 走 `/api/langgraph`) | 嵌在 FastAPI Gateway 路由里直接跑 | **LangGraph Server**(同主聊天) |
|
||||
| 线程状态 | Server 自带持久化 checkpointer | 自管 sqlite + 一堆模块级 dict | Server 自带持久化 checkpointer |
|
||||
| SSE 通道 | Server ↔ SDK 协议层 | 自写 `asyncio.Queue` per session_id | Server ↔ SDK 协议层 |
|
||||
| 多 worker | 任何 worker 都能查 Server | per-worker 内存 dict 隔离 | 任何 worker 都能查 Server |
|
||||
| Interrupt/Resume | Server 协议(`Command(resume=...)`)| 自写 `_session_pending_pause` + `/resume` 路由 | Server 协议 |
|
||||
|
||||
**核心收益**:迁完后 `_event_queues`、`_session_pending_pause`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_article_types`、`_session_error_flags`、`_session_pending_pause`、`_inflight_graph_tasks`、`_recover_session()`、自管 sqlite checkpointer 这一整套"per-process 内存状态"全部可以删掉 —— 不是"绕过"问题,是**让问题消失**。
|
||||
|
||||
**代价**:节点要改造(流式 chunk 改走 LangGraph 原生 `StreamWriter`/state update)、前端干预卡的交互要重写、业务表写入路径要从"路由里写"挪到"节点里写"。约 **6 人日**核心改造 + 50% buffer,建议按 **9 人日**排期。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状盘点:要删 / 要留 / 要改
|
||||
|
||||
### 2.1 要删的(迁完即可移除)
|
||||
|
||||
| 位置 | 内容 | 备注 |
|
||||
|---|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | `POST /start`、`GET /{id}/stream`、`POST /{id}/resume`、`GET /{id}/state` | 由 LangGraph Server 的 `runs.stream / threads.create / runs.wait` 替代 |
|
||||
| 同上 | `_event_queues`、`_session_pending_pause`、`_session_model_names`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_error_flags`、`_session_article_types` 这 8 个模块级 dict | 状态全部交给 LangGraph Server |
|
||||
| 同上 | `_recover_session()`、`_inflight_graph_tasks`、`_spawn_graph_task()`、`wait_for_inflight_graph_tasks()` | 跨 worker 恢复 / drain 都由 Server 内建机制处理 |
|
||||
| 同上 | `_make_config()` 里注入 `sse_queue` / `sse_session_id` 的逻辑 | 流式输出改走 LangGraph 原生通道 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()`、`AI_WRITING_GRAPH` 单例、`_async_checkpointer_singleton` | checkpoint 由 langgraph.json 的全局 checkpointer 接管 |
|
||||
| `app/gateway/ai_writing_cleanup.py` | "联动清理 sqlite checkpointer thread" 那一段 | 改成调 `langgraph_sdk` 的 `threads.delete()` |
|
||||
| `offline-backend-20260512/config.yaml` | `checkpointer:` 段里 AI 写作专用的 sqlite 路径(如果跟主 checkpointer 不同) | 共用主配置 |
|
||||
|
||||
### 2.2 要保留的
|
||||
|
||||
| 位置 | 内容 | 理由 |
|
||||
|---|---|---|
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/` | MySQL 业务表(标题、最终 draft、completed_interventions、transcript、article_type、status…) | 列表页 / 历史回显 / 全文搜索都依赖业务表,**不能让 LangGraph checkpoint 取代它** —— checkpoint 是流程状态,业务表是产品形态 |
|
||||
| `GET/PATCH/DELETE /api/ai-writing/sessions[/{id}]` | 列表 / 重命名 / 删除 | 只查业务表,不碰 graph |
|
||||
| `PUT /api/ai-writing/sessions/{id}/transcript` | 时间线持久化 | 维持锦上添花的回显方案 |
|
||||
| `GET/POST/PATCH/DELETE /api/ai-writing/article-types[/{id}]` | 文章类型 CRUD | 纯业务路由,跟 graph 无关 |
|
||||
| `POST /api/ai-writing/admin/cleanup` | admin 清理入口 | 路由保留,内部实现改 |
|
||||
| 4 个 interrupt 暂停点 + 5 个节点 + 全部 state 字段 | 业务逻辑零改动 | `pause_material_confirm` / `pause_outline_confirm` / `pause_section_help` / `pause_draft_confirm` / `pause_review_confirm` 用的就是 LangGraph 原生 `interrupt()`,直接吃迁移红利 |
|
||||
|
||||
### 2.3 要改的
|
||||
|
||||
| 位置 | 改造点 |
|
||||
|---|---|
|
||||
| `graph.py` | 删自管 checkpointer,导出 `make_ai_writing_graph(config) → CompiledStateGraph`,由 Server 注入 checkpointer |
|
||||
| `langgraph.json` | `graphs` 里增加 `"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"` |
|
||||
| 各 node(`researcher.py` / `writer_outline.py` / `writer_draft.py` / `editor.py`) | 流式输出从 `sse_queue.put_nowait(...)` 改成 LangGraph 原生 `get_stream_writer()` 推 custom 事件 |
|
||||
| 各 node | DB 写入(草稿、审核结果、status)从「路由里调 repo」改成「节点里通过 `RunnableConfig` 拿 `thread_id` 调 repo」 |
|
||||
| 各 node | "创建 session 行"从 `/start` 路由里挪到 `intent_parser_node`(或前端 `onCreated` 回调里调一个轻量级 upsert API) |
|
||||
| `langgraph_auth.py` | 给 `ai_writing` assistant 加授权规则(保证只能访问自己的 thread) |
|
||||
| 前端 `frontend-web/src/open-canvas/` | 完全弃用现有 `api/ai-writing.ts` 的 `fetch + EventSource`,新写 `useAIWritingStream` hook(基于 `@langchain/langgraph-sdk/react` 的 `useStream`) |
|
||||
| 前端 4 个干预卡 | 提交回调从 `POST /resume` 改为 `thread.submit(new Command({ resume: payload }))` |
|
||||
| 前端"启动写作"按钮 | 从 `POST /start` + 拿 session_id + 建 EventSource 改成 `thread.submit({...args})` 触发 `onCreated` 回调 |
|
||||
| `tests/test_ai_writing_session_recovery.py` | 整体删除 —— 跨 worker 恢复由 LangGraph Server 接管,不再是 AI 写作自己的事 |
|
||||
| 新增 `tests/test_ai_writing_assistant.py` | 通过 LangGraph SDK in-process 跑一遍 graph,覆盖 interrupt / resume / custom stream / state 持久化 |
|
||||
|
||||
---
|
||||
|
||||
## 三、分步实施
|
||||
|
||||
### 阶段 0:可行性预演(0.5 d)
|
||||
|
||||
**目标**:在不动现有代码的前提下,验证"把 ai_writing graph 注册到 LangGraph Server 并通过 SDK 跑通"这条路完全可行。
|
||||
|
||||
1. 临时在 `langgraph.json` 的 `graphs` 里加一条 `"ai_writing_probe": "deerflow.agents.ai_writing.graph:build_ai_writing_graph"`(直接复用现有 `build_ai_writing_graph`,不传 checkpointer 让 Server 注入)。
|
||||
2. 起 `make dev`,确认 LangGraph Server 把 `ai_writing_probe` 列入 assistant 列表。
|
||||
3. 用 Python SDK 客户端(`langgraph_sdk.get_client()`):
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
client = get_client(url="http://localhost:8001/api/langgraph")
|
||||
thread = await client.threads.create()
|
||||
async for chunk in client.runs.stream(
|
||||
thread["thread_id"], "ai_writing_probe",
|
||||
input={"user_intent": "测试", "article_type": "analysis", ...},
|
||||
stream_mode=["values", "messages-tuple", "custom"],
|
||||
): print(chunk)
|
||||
# 跑到 pause_material_confirm 应该收到 interrupt 事件
|
||||
await client.runs.stream(thread["thread_id"], "ai_writing_probe",
|
||||
command={"resume": {"action": "confirm", "approvedMaterialIds": [...]}})
|
||||
```
|
||||
4. 验证:interrupt 能正确暂停、`Command(resume=...)` 能续跑、state 能持久化到 Server checkpoint。
|
||||
5. 跑通后**回滚 langgraph.json** —— 这一步只是"摸路"。
|
||||
|
||||
**通过条件**:4 个 pause 点全部能用 SDK 跑通 interrupt + resume。如果某个 pause 点的 payload 结构跟 SDK 协议不兼容(例如用了非 JSON 可序列化的对象),阶段 0 就要解决,否则会在阶段 2 翻车。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1:graph 注册为 assistant(0.5 d)
|
||||
|
||||
**改动文件**:`packages/harness/deerflow/agents/ai_writing/graph.py`、`offline-backend-20260512/backend/langgraph.json`
|
||||
|
||||
1. `graph.py` 顶部新增:
|
||||
```python
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
def make_ai_writing_graph(config: RunnableConfig | None = None):
|
||||
"""LangGraph Server 入口:不接收 checkpointer,由 Server 注入。"""
|
||||
g = StateGraph(AIWritingState)
|
||||
# ... 跟现在 build_ai_writing_graph 完全一样的节点 / 边定义 ...
|
||||
return g.compile() # 注意:不传 checkpointer
|
||||
```
|
||||
2. 旧的 `build_ai_writing_graph(checkpointer=None)`、`get_ai_writing_graph()`、`_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()` 全部**先标 `@deprecated` 暂留** —— 阶段 7 再删,避免阶段 2/3 出错时无法快速回退。
|
||||
3. `langgraph.json`:
|
||||
```json
|
||||
{
|
||||
"graphs": {
|
||||
"lead_agent": "deerflow.agents:make_lead_agent",
|
||||
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent",
|
||||
"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"
|
||||
}
|
||||
}
|
||||
```
|
||||
4. `checkpointer.path` 已经指向 `make_checkpointer`(看 `langgraph.json:15-17`),AI 写作直接共享主 checkpointer 即可,**不要**重新搞一套。
|
||||
|
||||
**通过条件**:启动 LangGraph Server 后 `GET /api/langgraph/assistants` 能列出 `ai_writing`,state schema 字段齐全。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2:节点改造 —— 流式输出走原生通道(1.5 d)
|
||||
|
||||
**核心问题**:现在的 `writer_draft_node` / `writer_outline_node` / `researcher_node` 通过 `config["configurable"]["sse_queue"]` 把 LLM token chunk 推给前端。LangGraph Server 不会再注入 `sse_queue`,必须改用原生流通道。
|
||||
|
||||
**选型**:用 `langgraph.config.get_stream_writer()` 推 `custom` 事件(保留现有"非聊天消息、自定义协议"的形态)。
|
||||
|
||||
> 不选 `messages-tuple` 模式的原因:草稿生成不是聊天消息流,而是大段 markdown;硬塞成 `AIMessage` 会让前端渲染逻辑变得很别扭。custom 通道改动最小、契合度最高。
|
||||
|
||||
**改造模板**(每个节点都按这个模板改):
|
||||
```python
|
||||
# 旧:
|
||||
from .nodes._utils import push_sse_event
|
||||
async def writer_draft_node(state, config):
|
||||
sse_queue = config["configurable"].get("sse_queue")
|
||||
sse_queue.put_nowait({"event": "progress", "data": {...}})
|
||||
async for chunk in llm.astream(...):
|
||||
sse_queue.put_nowait({"event": "draft_chunk", "data": {"text": chunk}})
|
||||
|
||||
# 新:
|
||||
from langgraph.config import get_stream_writer
|
||||
async def writer_draft_node(state, config):
|
||||
writer = get_stream_writer() # 注意:必须在 async 函数体内调,不能模块级缓存
|
||||
writer({"type": "progress", "agent": "writer", "message": "开始写作"})
|
||||
async for chunk in llm.astream(...):
|
||||
writer({"type": "draft_chunk", "text": chunk})
|
||||
```
|
||||
|
||||
**业务表写入改造**:现在 `writer_draft_node` 收尾时通过 `config["configurable"]["sse_session_id"]` + `_session_repos[sid]` 调 `repo.save_draft()`。新方案:
|
||||
```python
|
||||
async def writer_draft_node(state, config):
|
||||
thread_id = config["configurable"]["thread_id"] # LangGraph Server 注入
|
||||
# 从 thread metadata / state 里拿到 user_id(在阶段 4 写入)
|
||||
user_id = state.get("_user_id") or config["configurable"].get("user_id")
|
||||
repo = get_ai_writing_session_store() # 进程内全局,无 per-session 字典
|
||||
try:
|
||||
await repo.save_draft(thread_id, user_id, state["current_draft"])
|
||||
except Exception:
|
||||
logger.exception("save_draft failed (non-fatal)")
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- `get_stream_writer()` 在子图 / 工具函数里会拿到当前 graph 的 writer,**不要**在模块 import 时缓存。
|
||||
- 节点要兼容"测试场景下没有 LangGraph runtime":包一层 `try: writer = get_stream_writer() except ...`,单测里 writer 是 no-op。
|
||||
- progress_events 字段保留写入 state(现在节点都已经 return `{"progress_events": [...]}`),这样 history / 回放仍然能在 state 里看到完整轨迹;custom stream 只是为了**实时推送**。
|
||||
|
||||
**通过条件**:手动跑一次 `make dev`,用 SDK 客户端从前一阶段验证脚本流式拿到 `draft_chunk` 事件。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3:前端接入 LangGraph SDK(1 d)
|
||||
|
||||
**核心改动**:`frontend-web/src/open-canvas/api/ai-writing.ts` 整文件作废,改写 `frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`。
|
||||
|
||||
**新 hook 骨架**(参考 `frontend-web/src/core/threads/hooks.ts:207` 现有主聊天写法):
|
||||
```ts
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { Command } from "@langchain/langgraph-sdk";
|
||||
import { getAPIClient } from "@/core/api/api-client";
|
||||
|
||||
interface AIWritingValues {
|
||||
user_intent: string;
|
||||
article_type: string;
|
||||
status: string;
|
||||
material_package?: MaterialPackage;
|
||||
current_outline?: Outline;
|
||||
current_draft?: DraftArticle;
|
||||
review_result?: ReviewResult;
|
||||
progress_events: any[];
|
||||
}
|
||||
|
||||
export function useAIWritingStream(sessionId: string | "new") {
|
||||
const stream = useStream<AIWritingValues, { CustomEventType: AIWritingCustomEvent }>({
|
||||
client: getAPIClient(),
|
||||
assistantId: "ai_writing",
|
||||
threadId: sessionId === "new" ? undefined : sessionId,
|
||||
reconnectOnMount: true,
|
||||
onCreated(meta) {
|
||||
// 后端会用同一个 thread_id 做 ai_writing_sessions.id;通知 UI 写入 URL
|
||||
navigateTo(`/workspace/ai-writing/${meta.thread_id}`);
|
||||
},
|
||||
onCustomEvent(event) {
|
||||
// 节点 push 来的 draft_chunk / progress / notice
|
||||
pushUIEvent(event);
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
```
|
||||
|
||||
**4 个干预卡的提交逻辑**(旧 → 新):
|
||||
```ts
|
||||
// 旧(自写 fetch):
|
||||
await fetch(`/api/ai-writing/${sessionId}/resume`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "confirm", approvedMaterialIds: [...] }),
|
||||
});
|
||||
|
||||
// 新(SDK 原生 Command):
|
||||
stream.submit(undefined, {
|
||||
command: { resume: { action: "confirm", approvedMaterialIds: [...] } },
|
||||
});
|
||||
```
|
||||
|
||||
**启动写作**:
|
||||
```ts
|
||||
// 旧:POST /start → 返回 session_id → 建 EventSource
|
||||
// 新:
|
||||
stream.submit({
|
||||
user_intent, article_type, target_audience, word_count_target, keyword_count, writing_mode,
|
||||
}, {
|
||||
config: { configurable: { model_name } },
|
||||
metadata: { feature: "ai_writing", article_type }, // 走业务表筛选
|
||||
});
|
||||
```
|
||||
|
||||
**列表页 / 历史回显**:仍然走 `GET /api/ai-writing/sessions` 业务 API,**无改动**。点进某条历史记录时:
|
||||
- 拿到 `sessionId` (== LangGraph `thread_id`)
|
||||
- `useAIWritingStream(sessionId)` 自动调 `client.threads.getState()` 拉最新 state,渲染最后一次草稿 / 干预点 / interrupt 等待
|
||||
- 已结束的会话 (`status == "done"`) 直接读业务表的 `draft_markdown` 字段,**不挂 SDK stream**(省 Server 资源)
|
||||
|
||||
**通过条件**:手动跑完一个完整流程(启动 → 4 次干预 → 拿到草稿),过程中刷新页面 / 切 worker,进度都不丢。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4:业务表写入解耦(0.5 d)
|
||||
|
||||
**问题**:现在 `/start` 路由先 INSERT 一行 `ai_writing_sessions(id=session_id, ...)`,再启动 graph。迁移后前端用 `stream.submit(...)` 直接触发 LangGraph 建 thread + 跑 graph,业务表行没人建 → 列表页查不到。
|
||||
|
||||
**两个方案二选一**:
|
||||
|
||||
**方案 4A(推荐)**:节点里建业务表行
|
||||
- `intent_parser_node` 开头:`await repo.upsert_session(thread_id, user_id, user_intent, article_type, status="researching")`
|
||||
- 优点:单一数据源(节点),不依赖前端做"双写"。
|
||||
- 缺点:用户从前端点"启动" → 看到列表里出现这条 → 之间有 200~500ms 延迟(intent_parser 跑完才入库),需要前端 onCreated 后乐观插入一条临时行。
|
||||
|
||||
**方案 4B**:前端 onCreated 时调一个轻量 upsert API
|
||||
- 新增 `POST /api/ai-writing/sessions/{thread_id}/init`:只插业务表行,不碰 graph。
|
||||
- 前端 `onCreated(meta)` 回调里立刻 fire-and-forget 调它。
|
||||
- 优点:列表瞬间出现新行。
|
||||
- 缺点:前端要多调一次 API,且失败要兜底;多了一条"双方都能建行"的路径。
|
||||
|
||||
**推荐 4A** —— 单一数据源比 UI 响应更重要,200ms 延迟用 loading skeleton 即可。
|
||||
|
||||
**用户身份传递**:节点要拿到 `user_id` 写入业务表。两条路:
|
||||
- LangGraph Server 已有 `langgraph_auth.py` 注入 auth context,在节点里 `config["configurable"].get("langgraph_auth_user")` 取(具体字段名以现有实现为准)。
|
||||
- 或者前端 `submit()` 时 metadata 里带上 `user_id`,节点从 `config["configurable"]["metadata"]` 拿。
|
||||
|
||||
**通过条件**:从启动到列表显示新行,延迟 < 1s;多用户场景下不会出现"A 用户的 thread 落到 B 的业务表行"。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 5:transcript 持久化(0.2 d)
|
||||
|
||||
**当前**:前端 `PUT /api/ai-writing/sessions/{id}/transcript` 全量覆盖 `ai_writing_sessions.transcript` 列。
|
||||
|
||||
**改造选择**:
|
||||
- **维持现状**(推荐):transcript 是前端组装的时间线 blob,让前端继续 fire-and-forget PUT 即可,跟迁移正交。
|
||||
- 或者完全弃用 transcript 列,直接从 `client.threads.getHistory()` + `getState()` 还原 —— 工作量大且需要重写回显逻辑,**不推荐第一版做**。
|
||||
|
||||
**通过条件**:原 PUT 接口照常工作;新建会话也能落库。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 6:admin 清理改造(0.3 d)
|
||||
|
||||
**改动文件**:`app/gateway/ai_writing_cleanup.py`
|
||||
|
||||
```python
|
||||
# 旧:
|
||||
saver = get_ai_writing_checkpointer()
|
||||
await saver.adelete_thread(sid) # 自管 sqlite
|
||||
|
||||
# 新:
|
||||
from langgraph_sdk import get_client
|
||||
lg_client = get_client(url=internal_langgraph_url())
|
||||
await lg_client.threads.delete(sid)
|
||||
```
|
||||
|
||||
**活跃保护**:旧逻辑是看 `sid in _event_queues`。新逻辑:
|
||||
```python
|
||||
state = await lg_client.threads.get_state(sid)
|
||||
is_active = (
|
||||
state.get("next") # graph 还有未执行的节点
|
||||
or state.get("tasks") # 当前有 pending task
|
||||
or state.get("metadata", {}).get("interrupted") # 卡在 interrupt 上
|
||||
)
|
||||
if is_active:
|
||||
skipped_active += 1
|
||||
continue
|
||||
```
|
||||
|
||||
**通过条件**:admin cleanup 接口照常返回 `deleted_db / deleted_checkpoints / skipped_active`,且能正确跳过仍在 interrupt 等待用户的会话。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 7:删冗余代码(0.3 d)
|
||||
|
||||
按 §2.1 列表逐项删除。建议**单独开一个 PR**,跟阶段 1~6 解耦:
|
||||
- 前 6 个 PR 全部上线、灰度验证至少 1 周
|
||||
- 确认旧代码路径再无流量后,再走清理 PR
|
||||
- 这样万一灰度阶段需要回滚,旧路径还在
|
||||
|
||||
**通过条件**:`make test` 全绿,`grep -r "_event_queues\|_session_pending_pause\|_recover_session" app/ packages/` 零命中。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 8:测试 + 灰度(1 d)
|
||||
|
||||
**新增测试**:
|
||||
- `tests/test_ai_writing_assistant.py` —— 用 `langgraph_sdk` 客户端打 LangGraph Server,覆盖:
|
||||
- 完整流程:start → 4 个 interrupt → resume → done
|
||||
- state 持久化:跑到 pause_outline 后重建客户端,能 `getState` 看到 outline
|
||||
- custom stream:能收到 `draft_chunk` 等事件
|
||||
- 错误兜底:节点抛错时 status 落 error、业务表 status 同步
|
||||
- 保留 `tests/test_portable_long_text.py` / `tests/test_ai_writing_transcript_endpoint.py` / `tests/test_ai_writing_session_repo.py` —— 这几个跟业务表强相关,不受迁移影响。
|
||||
|
||||
**删除测试**:
|
||||
- `tests/test_ai_writing_session_recovery.py` —— 跨 worker 恢复改由 LangGraph Server 提供,不再是 AI 写作自己的能力。
|
||||
- `tests/test_ai_writing_cleanup.py` 里关于"drain + checkpointer 降级 + SSE 序列化"的几个用例 —— 这些机制已删,对应用例失效。
|
||||
|
||||
**灰度策略**:
|
||||
- **前端开关**:`VITE_AI_WRITING_BACKEND` = `legacy | langgraph`,默认 `legacy`
|
||||
- `legacy` → 走老的 `fetch + EventSource + /resume`
|
||||
- `langgraph` → 走新 `useAIWritingStream`
|
||||
- **后端**:旧路由(`/start /stream /resume /state`)保留两个发版周期,逐步下线
|
||||
- **数据兼容**:业务表 schema 完全不变,两条路径都用同一张表
|
||||
- **回滚成本**:改回 `VITE_AI_WRITING_BACKEND=legacy` 重新部署即可,无 DB migration
|
||||
|
||||
**通过条件**:
|
||||
- 灰度环境跑 100+ 写作流程,对比新旧两条路径的成功率、SSE 中断率、resume 成功率
|
||||
- 新路径在 worker=4 部署下 SSE 中断率 = 0(旧路径基线约 5~15%)
|
||||
|
||||
---
|
||||
|
||||
## 四、关键设计决策与权衡
|
||||
|
||||
### 4.1 为什么不让 LangGraph checkpoint 替代业务表?
|
||||
|
||||
LangGraph checkpoint 存的是"图执行状态",业务表存的是"产品形态"。两者职责不同:
|
||||
- **会话列表筛选/排序**:业务表 `WHERE user_id = ? AND status = ? ORDER BY updated_at` —— LangGraph checkpoint 没法这样查(它是 `(thread_id, checkpoint_ns)` 主键)
|
||||
- **全文搜索 draft**:业务表 `LIKE '%关键词%'` —— checkpoint 是序列化 blob
|
||||
- **transcript 自定义 UI 协议**:前端组装的时间线 JSON,跟 graph state 不一一对应
|
||||
|
||||
**结论**:两者**并存**,`thread_id` 作为外键。这是主聊天的做法(`threads_meta` + LangGraph checkpoint),AI 写作迁完后也是同样的形态。
|
||||
|
||||
### 4.2 为什么 graph 节点里直接调 repo,不发到外面消费?
|
||||
|
||||
考虑过"节点只 emit custom event,路由层订阅事件后写业务表" —— 但这又把状态分散回路由层了。
|
||||
|
||||
直接在节点里调 `await repo.save_draft(...)`:
|
||||
- 节点拿得到 `thread_id`(== session_id == business id),主键一致
|
||||
- `repo` 是单例(DB 连接池),不依赖 per-session 字典
|
||||
- 写入失败包 try/except 即可(草稿落 state 比落 DB 更关键,DB 失败不阻断流程)
|
||||
|
||||
### 4.3 为什么用 custom stream 而不是 messages-tuple?
|
||||
|
||||
writer_draft 输出是大段 markdown,强行包成 `AIMessage` 后:
|
||||
- 前端要把它从消息列表里拎出来当草稿渲染(混杂)
|
||||
- 大纲 / 审核结果是结构化 JSON,更不像聊天消息
|
||||
- progress_events / notice / draft_chunk / outline_chunk 几种事件类型,全塞 message 通道得发明 metadata convention
|
||||
|
||||
custom stream 是为这种"非聊天但需要流式 push"场景设计的,匹配度更高。
|
||||
|
||||
### 4.4 在 Server 进程里跑 graph,资源够用吗?
|
||||
|
||||
LangGraph Server 是独立 Python 进程,跟 Gateway 共享同一台机器但不共享内存。新增一个 assistant 的常驻开销 ~10 MB(graph 编译后的 schema 缓存),每个并发会话峰值 ~50 MB(LLM client + state)。
|
||||
|
||||
实测建议:在阶段 0 之后跑一次 10 个并发的压测,观察 Server RSS 增量。若一台机器 4 GB 内存,理论支撑 60~80 并发 AI 写作,足够 50 人规模团队使用。
|
||||
|
||||
---
|
||||
|
||||
## 五、风险清单
|
||||
|
||||
| # | 风险 | 缓解 |
|
||||
|---|---|---|
|
||||
| R1 | LangGraph Server 升级版本时 `interrupt()` / `Command(resume=...)` 协议有 breaking change | 在 `pyproject.toml` 里 pin `langgraph` minor 版本;升级前先在阶段 0 那个 probe 跑一遍冒烟 |
|
||||
| R2 | 前端 `useStream` 的 `reconnectOnMount` 行为跟自写 EventSource 不完全一致,刷新页面后某些情况下不会自动续接 SSE | 阶段 3 手动测刷新场景;如不达预期,配合 `client.threads.getState()` 拉一次 state 兜底 |
|
||||
| R3 | 节点里 `get_stream_writer()` 在单测里没有 LangGraph runtime → 抛错 | 节点 wrap `try: writer = get_stream_writer() except: writer = lambda x: None` |
|
||||
| R4 | 多用户场景下 `langgraph_auth.py` 没对 ai_writing assistant 做隔离,可能跨用户拿到别人的 thread state | 阶段 4 一并配置 auth;新增专项测试 |
|
||||
| R5 | `client.threads.delete()` 是异步删,admin 清理脚本调完后立即查 state 还能短暂查到 → `skipped_active` 误判 | 删之前先 lock thread 或调用 `client.runs.cancel()` 强制收敛 |
|
||||
| R6 | LangGraph Server 自带 checkpoint 仍然是 sqlite(`langgraph.json` 里配的),在容器重启时跟 AI 写作原有的 `.deer-flow/data/checkpoints.db` 是同一个文件 → schema 共用 | 阶段 1 之前先确认主 checkpointer 配置路径,避免双开 sqlite 文件锁竞争 |
|
||||
| R7 | 现有用户在迁移窗口期发起的会话,灰度切到新路径后无法续跑(state 在旧 sqlite,新路径查不到) | 迁移前 1 小时禁止新会话;或写一个一次性脚本把旧 checkpointer 的 thread 复制到主 checkpointer |
|
||||
|
||||
---
|
||||
|
||||
## 六、工时与里程碑
|
||||
|
||||
| 阶段 | 工时 | 可独立交付 |
|
||||
|---|---|---|
|
||||
| 阶段 0:可行性预演 | 0.5 d | ✅ |
|
||||
| 阶段 1:graph 注册 | 0.5 d | ❌(后端只生效,前端没接) |
|
||||
| 阶段 2:节点改造 | 1.5 d | ❌ |
|
||||
| 阶段 3:前端接入 | 1 d | ✅(带开关上灰度) |
|
||||
| 阶段 4:业务表写入解耦 | 0.5 d | ❌(跟阶段 3 合并发) |
|
||||
| 阶段 5:transcript | 0.2 d | ✅ |
|
||||
| 阶段 6:admin 清理改造 | 0.3 d | ✅ |
|
||||
| 阶段 7:删冗余 | 0.3 d | ✅(独立 PR,灰度后再发) |
|
||||
| 阶段 8:测试 + 灰度 | 1 d | ✅ |
|
||||
| **核心工时** | **5.8 d** | |
|
||||
| **+ 50% buffer** | **8.7 d ≈ 9 d** | |
|
||||
|
||||
**建议排期**:
|
||||
- 第 1 周:阶段 0 + 1 + 2(后端完整、本地能跑通)
|
||||
- 第 2 周:阶段 3 + 4 + 5 + 6(前端 + 业务集成)
|
||||
- 第 3 周:阶段 8(测试 + 灰度)
|
||||
- 灰度 1~2 周后:阶段 7(删冗余)
|
||||
|
||||
---
|
||||
|
||||
## 七、回滚预案
|
||||
|
||||
每个阶段都设计为可独立回滚:
|
||||
|
||||
| 场景 | 回滚动作 |
|
||||
|---|---|
|
||||
| 阶段 3 上线后发现 SSE 不稳 | 前端开关 `VITE_AI_WRITING_BACKEND=legacy`,重新部署,0 dataloss |
|
||||
| 阶段 7 删冗余代码后发现新路径有问题 | 从 git revert 删除 commit,恢复旧路由代码;业务表/checkpoint 不受影响 |
|
||||
| 阶段 1 注册 assistant 后 LangGraph Server 起不来 | 从 `langgraph.json` 移除 `ai_writing` 那一行,重启 Server,回到迁移前状态 |
|
||||
|
||||
**数据兼容承诺**:
|
||||
- 业务表 schema 完全不变
|
||||
- `thread_id` (== `ai_writing_sessions.id`) 含义保持一致
|
||||
- transcript 列含义不变
|
||||
|
||||
**最坏回滚**:把 `VITE_AI_WRITING_BACKEND` 改回 `legacy`、`langgraph.json` 删 `ai_writing` 那行,回到迁移前的"方案 C/F/B 路径"。已经走完的会话留在新 checkpoint 里成"孤儿",但业务表里的草稿和审核结果**完整无损**。
|
||||
|
||||
---
|
||||
|
||||
## 八、迁移成功后能享受的"免费红利"
|
||||
|
||||
1. **完整 history API**:`client.threads.getHistory(thread_id)` 拿到每一步 checkpoint,做"回到某一步重试"非常便宜
|
||||
2. **time travel**:用户能从任意 checkpoint 分叉重写
|
||||
3. **跟主聊天共享基础设施**:升级 LangGraph 版本时一并升级,不用维护两套 SSE / checkpoint 协议
|
||||
4. **天然支持水平扩容**:哪天上 K8s 多副本部署,AI 写作跟主聊天一样开箱即用
|
||||
5. **天然支持 IM 渠道接入**:`app/channels/` 那套 Feishu / Slack 实现是 `langgraph-sdk` 的,迁完后 AI 写作也能在 IM 里用,零额外代码
|
||||
|
||||
---
|
||||
|
||||
## 九、相关文件索引(迁移前后参照)
|
||||
|
||||
### 现状(迁前)
|
||||
- `app/gateway/routers/ai_writing.py:148-176` —— 8 个模块级 dict + drain task
|
||||
- `packages/harness/deerflow/agents/ai_writing/graph.py:370-538` —— 自管 checkpointer
|
||||
- `frontend-web/src/open-canvas/api/ai-writing.ts` —— 自写 fetch + EventSource
|
||||
|
||||
### 参照(主聊天迁后形态)
|
||||
- `frontend-web/src/core/threads/hooks.ts:207` —— `useStream` 用法
|
||||
- `frontend-web/src/core/api/api-client.ts` —— LangGraph SDK Client 初始化
|
||||
- `offline-backend-20260512/backend/langgraph.json` —— assistant 注册位置
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/` —— graph 工厂参考写法
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py` —— 共享 checkpointer 入口
|
||||
|
||||
### 目标(迁后)
|
||||
- 新增:`packages/harness/deerflow/agents/ai_writing/graph.py:make_ai_writing_graph`
|
||||
- 新增:`frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`
|
||||
- 删除:`app/gateway/routers/ai_writing.py` 中的 `/start /stream /resume /state` 路由 + 8 个 dict
|
||||
- 删除:`frontend-web/src/open-canvas/api/ai-writing.ts` 中的 `startWriting / streamSession / resume` 全部 fetch 调用
|
||||
- 简化:`app/gateway/ai_writing_cleanup.py`(改用 SDK client 删 thread)
|
||||
|
||||
---
|
||||
|
||||
## 十、决策摘要(给 PM / 维护者)
|
||||
|
||||
> **TL;DR**
|
||||
> 1. 主聊天不受多 worker 影响,是因为它把状态托管给了独立的 LangGraph Server 进程,前端直接通过 SDK 跟 Server 通信。AI 写作要做同样的事。
|
||||
> 2. 改造范围:后端 graph 注册 + 节点流通道改造 + 前端 hook 换 SDK + 业务表写入解耦。**核心约 6 人日,含 buffer 9 人日**。
|
||||
> 3. 业务表、节点逻辑、4 个干预点、UI 形态全部保留,**用户感知层面零变化**。
|
||||
> 4. 改造完一次性获得"刷新不丢进度、多 worker 不裂、跟主聊天共享基础设施、IM 渠道免费支持"等长期红利。
|
||||
> 5. 建议跟"方案 C"并行:先按 [ai-writing-说明.md](./ai-writing-说明.md) 上方案 C 应急止血,本方案作为长期改造按周排期。两者不冲突,方案 C 上线即受益,本方案完工后可下线方案 C 的双进程配置。
|
||||
@@ -1,787 +0,0 @@
|
||||
# AI 写作 — 故障排查与解决方案说明
|
||||
|
||||
> **本文档目的**:完整记录 AI 写作子系统在内网 Docker 部署后出现的问题、根因、已落地的修复、剩余风险、以及未来的解决方案路径。
|
||||
>
|
||||
> **适用读者**:维护 AI 写作功能的前端/后端开发者、内网部署运维。
|
||||
>
|
||||
> **更新日期**:2026-05-20
|
||||
|
||||
---
|
||||
|
||||
## 一、问题背景
|
||||
|
||||
### 起因
|
||||
内网 Docker 部署后,AI 写作出现两类报错:
|
||||
|
||||
| 接口 | 报错 | 频率 |
|
||||
|---|---|---|
|
||||
| `PUT /api/ai-writing/sessions/{id}/transcript` | HTTP 500 | 经常 |
|
||||
| `POST /api/ai-writing/{id}/resume` | HTTP 4xx(用户记忆为 400,实际可能是 404) | 经常,且没有 Docker 重启时也发生 |
|
||||
|
||||
### 这两个接口的作用
|
||||
|
||||
#### 1. `PUT /sessions/{id}/transcript` — 对话时间线持久化
|
||||
- **职责**:保存前端组装的完整对话时间线(progressEvents + completedInterventions)到 MySQL
|
||||
- **调用时机**:前端在写作过程中**反复 PUT**(事件累计、干预完成、草稿生成等节点全量覆盖)
|
||||
- **存到哪**:MySQL 的 `ai_writing_sessions.transcript` 列(LONGTEXT)
|
||||
- **用途**:用户**关闭页面后再回来 / 刷新 / 看历史会话**时,从 DB 拉回这段时间线,UI 上回显出「跟实时一样的对话历史」
|
||||
- **定位**:**纯锦上添花** — 失败了用户当下写作完全不受影响,只是历史回显会缺失/不完整
|
||||
|
||||
#### 2. `POST /{id}/resume` — 写作流程的核心控制接口
|
||||
- **职责**:提交用户在干预点的决策,让 LangGraph 从挂起的 interrupt 点继续推进
|
||||
- **调用时机**:用户在 4 个 interrupt 点(素材确认 / 大纲确认 / 草稿确认 / 审核确认)做出决定后
|
||||
- **传什么**:用户的决策内容(确认的素材 id、修改后的大纲、用户的修订意见、章节求助决策…)
|
||||
- **定位**:**流程主干** — 它失败,用户就**真的卡死**在干预点
|
||||
|
||||
---
|
||||
|
||||
## 二、根因分析
|
||||
|
||||
### 根因 1:`transcript` 列容量不足 + 路由层无兜底
|
||||
- MySQL `TEXT` 列上限 64 KB
|
||||
- 用户写作过程中累计 `progressEvents`(含每个流式 chunk)+ `completedInterventions` 后,JSON blob 经常超过 64 KB
|
||||
- `session.commit()` 抛 `DataError: Data too long for column 'transcript'`
|
||||
- 原路由没有 try/except,异常直接冒泡成 HTTP 500
|
||||
|
||||
### 根因 2:内存中的会话状态在多 worker 部署下隔离
|
||||
后端用了大量模块级 dict 维护会话状态:
|
||||
|
||||
```python
|
||||
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
|
||||
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件
|
||||
_session_model_names: dict[str, str] # 用户选的模型
|
||||
_session_interventions: dict[str, list] # 累计干预历史
|
||||
_session_repos: dict[str, repo] # DB 仓库引用
|
||||
_session_user_ids: dict[str, str] # 用户 id
|
||||
_session_article_types: dict[str, list] # 文章类型列表
|
||||
```
|
||||
|
||||
**uvicorn / gunicorn 多 worker 是多进程共享 socket**,OS 内核决定哪个 worker 接到请求,应用层和 nginx 都管不着。所以:
|
||||
|
||||
```
|
||||
T0: POST /start → worker A 创建 _event_queues[sid] = QueueA
|
||||
graph 推进任务跑在 worker A
|
||||
T1: GET /stream → 落 worker B,没有 QueueA 的引用
|
||||
→ 触发 _recover_session,从 sqlite 重建 QueueB
|
||||
→ SSE 推 QueueB 但事件都在 QueueA 里
|
||||
T2: POST /resume → 落 worker C,同样无法看到 A/B 的状态
|
||||
```
|
||||
|
||||
**结果**:SSE 时断时续、resume 偶发 404「会话状态已丢失」。
|
||||
|
||||
### 根因 3:Graph 用了内存 checkpointer
|
||||
原代码:
|
||||
```python
|
||||
# ai_writing/graph.py
|
||||
checkpointer = MemorySaver()
|
||||
```
|
||||
进程内的 `MemorySaver` 重启即丢,**容器重启或单 worker 进程被回收**后所有进行中会话都拿不到。
|
||||
|
||||
### 根因 4:路径配错
|
||||
`config.yaml` 里 sqlite 路径写成了 Windows 绝对路径 `D:\cursor-work\...`,Linux 容器里根本无法解析。
|
||||
|
||||
---
|
||||
|
||||
## 三、已落地的修复(已合入 dhk-1 分支)
|
||||
|
||||
### 修复 1:`transcript` 列改 LONGTEXT + 路由兜底
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/harness/deerflow/persistence/types.py` | 新增 `PortableLongText` 类型(MySQL→LONGTEXT,SQLite/PG→TEXT) |
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/model.py` | `transcript` 列改成 `PortableLongText` |
|
||||
| `packages/harness/deerflow/persistence/migrations/versions/20260519_02_ai_writing_transcript_longtext.py` | Alembic 迁移:MySQL 上 `ALTER ... type_=LONGTEXT()` |
|
||||
| `app/gateway/routers/ai_writing.py` | `save_transcript` 整段 try/except 包裹,**任何异常都返回 `{success: false, reason}`,永远不抛 5xx** |
|
||||
|
||||
**结果**:transcript 500 永远不会再发生。前端已经做了 fire-and-forget 容错,失败只 `console.warn`,写作流程不阻断。
|
||||
|
||||
### 修复 2:Checkpointer 改为持久化 SQLite
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `MemorySaver()` → 懒加载 `AsyncSqliteSaver` |
|
||||
| 同上 | 新增 `get_ai_writing_graph()` / `get_ai_writing_checkpointer()` / `shutdown_ai_writing_graph()` |
|
||||
| `offline-backend-20260512/config.yaml` | sqlite 路径改成相对路径 `.deer-flow/data/checkpoints.db` |
|
||||
|
||||
**关键容错**:sqlite 初始化失败(磁盘满 / 权限 / aiosqlite 未装 / 损坏)会**自动降级 InMemorySaver**,写作流程能跑完,但跨重启不可恢复。
|
||||
|
||||
### 修复 3:跨 worker / 进程重启会话恢复
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `_recover_session(session_id, request)` |
|
||||
|
||||
当 worker 内存里没有该 session 时,从 sqlite checkpointer 读快照,重建 `_event_queues`、重新注入 `await_user` 事件。
|
||||
|
||||
**效果**:worker B 收到的 resume 请求,能从 sqlite 找到 worker A 写下的 checkpoint,**resume 接口本身能跑通**(但 SSE 事件流仍隔离 — 见下文剩余风险)。
|
||||
|
||||
### 修复 4:多层失败兜底,保证写作流程能继续
|
||||
|
||||
| 改动 | 作用 |
|
||||
|---|---|
|
||||
| `_sse(event, data)` 加 `default=str` + notice 帧降级 | JSON 序列化失败不会让 SSE 断开 |
|
||||
| 所有 DB 写入(草稿/审核/干预/status)已包 try/except | DB 异常吞掉,graph 继续推 |
|
||||
| 文章类型加载失败 → 退回 `[]` | intent_parser 仍能跑 |
|
||||
| `/start` 加 503 兜底 | graph 构建失败时给前端可读错误而不是 500 |
|
||||
|
||||
### 修复 5:定期清理 + admin 触发接口
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/ai_writing_cleanup.py` | 新文件:cron 调度 + 联动清理(MySQL 行 + sqlite checkpointer thread) |
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/sql.py` | 新增 `list_older_than()` / `delete_by_ids()` |
|
||||
| `app/gateway/app.py` | lifespan 启动/停止清理任务 |
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `POST /api/ai-writing/admin/cleanup` 手动触发接口(admin only) |
|
||||
| `config.yaml` | 新增 `ai_writing.cleanup` 段(默认每天凌晨 3 点跑,保留 7 天) |
|
||||
|
||||
**关键安全机制**:清理永远跳过 `_event_queues` 里仍在跑的会话(**活跃保护**),即使 admin 手动传 `retention_days: 0` 也不会秒杀正在写作的用户。
|
||||
|
||||
### 修复 6:优雅关闭 — In-flight Drain
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `_inflight_graph_tasks` + `_spawn_graph_task()` + `wait_for_inflight_graph_tasks(timeout)` |
|
||||
| `app/gateway/app.py` | lifespan shutdown 顺序:取消清理 → **等 in-flight 写作 task 收尾(30s 超时)** → 关 checkpointer connection |
|
||||
|
||||
容器收到 SIGTERM 时,给正在跑的写作 30 秒窗口跑到下一个 interrupt / END,已生成的 checkpoint 都安全落盘。
|
||||
|
||||
### 修复 7:多 worker 配置陷阱检测
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/app.py` | lifespan startup 检测 `WEB_CONCURRENCY > 1` 或 `/proc` 里同名 uvicorn 进程数 > 1 → 打 ERROR 日志 |
|
||||
|
||||
下次启动容器看 docker logs 第一屏就能看到强提醒。
|
||||
|
||||
---
|
||||
|
||||
## 四、当前状态评估
|
||||
|
||||
### transcript 500
|
||||
✅ **彻底修复**,跟 worker 数无关。LONGTEXT 列 + 路由兜底双保险。
|
||||
|
||||
### resume 报错
|
||||
⚠️ **大幅缓解**:
|
||||
- 接口本身(在 worker > 1 下)能跑通
|
||||
- sqlite 共享让 worker B 也能恢复 worker A 创建的 session
|
||||
|
||||
❌ **未彻底解决**:
|
||||
- SSE 事件流仍 per-worker 隔离
|
||||
- 用户可能看到「进度时有时无、需要刷新页面才能看到草稿」
|
||||
- 但**草稿、审核结果**都落到 MySQL,刷新 `/sessions/{id}` 总能看到最终成品
|
||||
|
||||
### 「写作流程能不能跑完」
|
||||
✅ **能**。最坏情况下:
|
||||
- 用户感受:进度断断续续,需要刷新几次
|
||||
- 数据保证:草稿和审核都落 MySQL,**写出来的内容不会丢**
|
||||
|
||||
---
|
||||
|
||||
## 五、剩余风险
|
||||
|
||||
### 风险 A:多 worker 下 SSE 事件流隔离 ⚠️ 最重要
|
||||
|
||||
**症状**:用户 SSE 长连接绑在 worker B,但 graph 推进任务跑在 worker A,事件 put 进 A 的 queue → SSE 收不到。
|
||||
|
||||
**为什么不能用 `--workers 1` 解决**:业务团队反馈其他业务功能需要多 worker。
|
||||
|
||||
**解决方案见第六章**。
|
||||
|
||||
### 风险 B:容器优雅关闭时 in-flight 写作进度
|
||||
|
||||
**当前缓解**:30 秒 drain timeout 让 graph 跑到下一个 checkpoint。
|
||||
|
||||
**完美方案**(如有需求):无法 100% 防护,只能尽量缩短「未落 checkpoint」窗口。
|
||||
|
||||
### 风险 C:节点级别 LLM 抽风
|
||||
|
||||
某个写作节点调用 LLM 时网络抖动 / 限流 → astream 抛错 → session 结束。
|
||||
|
||||
**当前缓解**:astream level 已 try/except,推 error 事件。用户从 checkpoint 重试。
|
||||
|
||||
**改进方向**(不在本次修复范围):
|
||||
- 给节点加 `tenacity.retry` 重试装饰器
|
||||
- 节点抛错时把已生成的部分塞进 progress_events
|
||||
|
||||
### 风险 D:跨 worker 恢复时降级丢失
|
||||
|
||||
worker B 从 sqlite 恢复 worker A 的 session 时,以下数据回退到默认:
|
||||
|
||||
| 数据 | 默认值 | 影响 |
|
||||
|---|---|---|
|
||||
| `_session_model_names` | `""` | 退回默认模型 |
|
||||
| `_session_interventions` | `[]` | 本次 resume 之前的干预历史从内存里丢(MySQL 里有副本,但内存累计不全) |
|
||||
| `_session_article_types` | `[]` | 没影响(graph 早过了 intent_parser) |
|
||||
|
||||
**改进方向**:恢复时从 MySQL 反向填充内存元数据(约 20 行代码)。
|
||||
|
||||
---
|
||||
|
||||
## 六、Worker > 1 限制下的解决方案路径
|
||||
|
||||
### 三条路径对比
|
||||
|
||||
| 方案 | 是否引入新依赖 | 代码改动 | 运维改动 | 适用场景 |
|
||||
|---|---|---|---|---|
|
||||
| **B. Redis Pub/Sub 共享状态** | ⚠️ 需 Redis | ~150 行 | docker-compose 加 redis | 已有 Redis 或愿意上 |
|
||||
| **C. AI 写作独立单 worker 进程** | 无 | ~30 行 | docker-compose 加一个容器 / 进程 + nginx 转发 | 不想上 Redis |
|
||||
| **F. 多容器单 worker + nginx sticky** | 无 | ~5 行(nginx config) | 把 1 个多 worker 容器拆成 N 个单 worker 容器 | 已经在做容器编排 |
|
||||
|
||||
### 方案 B:Redis Pub/Sub(最彻底)
|
||||
|
||||
**改造点**:
|
||||
|
||||
```
|
||||
之前(per-worker memory dict) 之后(Redis 共享)
|
||||
───────────────────────── ─────────────────────
|
||||
_event_queues[sid] = asyncio.Queue() → Redis Pub/Sub channel "ai_writing:events:{sid}"
|
||||
_session_pending_pause → Redis Hash "ai_writing:pause"
|
||||
_session_model_names → Redis Hash "ai_writing:meta:model"
|
||||
_session_interventions → Redis Hash + JSON
|
||||
_session_repos / _session_user_ids → Redis Hash
|
||||
```
|
||||
|
||||
**关键设计**:
|
||||
1. `/start` 在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel
|
||||
2. `/stream` 在 worker B:subscribe Redis channel,把消息转 SSE 推前端
|
||||
3. `/resume` 在 worker C:用 Redis 分布式锁保证「同一 session 同时只有一个 worker 驱动 graph」
|
||||
|
||||
**优点**:根治。任何 worker 数、任何部署形态都能跑。
|
||||
|
||||
**缺点**:
|
||||
- 引入 Redis 依赖(运维负担)
|
||||
- 代码改动较大(约 150 行)
|
||||
- 需要处理 Redis 断连 / 分布式锁释放等边缘情况
|
||||
|
||||
### 方案 C:AI 写作独立服务(次推荐)
|
||||
|
||||
**做法**:在同一个容器里多起一个 uvicorn 进程,专门处理 `/api/ai-writing/*`,**显式 `--workers 1`**。
|
||||
|
||||
```bash
|
||||
# 主 gateway(多 worker,处理其他业务)
|
||||
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 4
|
||||
|
||||
# AI 写作专用单 worker
|
||||
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8002 --workers 1
|
||||
```
|
||||
|
||||
nginx 配置:
|
||||
```nginx
|
||||
location /api/ai-writing/ {
|
||||
proxy_pass http://localhost:8002;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8001;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 零代码改动
|
||||
- 不引入新依赖
|
||||
- AI 写作彻底单 worker,问题根除
|
||||
|
||||
**缺点**:
|
||||
- 容器内多一个进程
|
||||
- 内存占用 +1 份 Python runtime(约 200 MB)
|
||||
|
||||
### 方案 F:多容器单 worker + nginx sticky
|
||||
|
||||
**做法**:把当前 1 个 4 worker 容器,拆成 4 个 1 worker 容器,nginx 用 `ip_hash` 做 sticky。
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
gateway-1: { ports: ["8001:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-2: { ports: ["8002:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-3: { ports: ["8003:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-4: { ports: ["8004:8001"], command: ["...", "--workers", "1"] }
|
||||
|
||||
nginx:
|
||||
# upstream 配 ip_hash,让同一个客户端粘到同一个容器
|
||||
```
|
||||
|
||||
```nginx
|
||||
upstream gateway_pool {
|
||||
ip_hash;
|
||||
server gateway-1:8001;
|
||||
server gateway-2:8001;
|
||||
server gateway-3:8001;
|
||||
server gateway-4:8001;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:零代码改动,标准做法
|
||||
|
||||
**缺点**:
|
||||
- 改动 docker-compose
|
||||
- 客户端 IP 不均匀时负载不均衡(但 AI 写作低并发场景影响小)
|
||||
|
||||
### 推荐顺序
|
||||
|
||||
1. **方案 C(AI 写作独立单 worker 进程)** — 改动最小、风险最低、立刻可上
|
||||
2. **方案 F(多容器 sticky)** — 如果你们已经在做多容器编排
|
||||
3. **方案 B(Redis)** — 长期方案,等真正需要水平扩容时再上
|
||||
|
||||
---
|
||||
|
||||
## 六-A、方案 C 在 Docker 容器内的详细实施指南
|
||||
|
||||
> **目标**:在不引入新依赖、不动业务代码、不拆容器的前提下,让 AI 写作走专门的单 worker 进程,其它业务继续走多 worker。
|
||||
>
|
||||
> **基础设施**:本项目镜像基于 `python:3.12-slim-bookworm`,用 `uv` 管理依赖,Dockerfile 位于 `offline-backend-20260512/backend/Dockerfile`,默认入口是
|
||||
>
|
||||
> ```
|
||||
> CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
> ```
|
||||
|
||||
### 总览:单容器双 uvicorn 进程
|
||||
|
||||
```
|
||||
┌─────────────── nginx (容器外或前置) ───────────────┐
|
||||
│ │
|
||||
/api/ai-writing/ ──┼──► container:8002 (uvicorn --workers 1) ───► AI 写作专用
|
||||
其余 /api/* ──┼──► container:8001 (uvicorn --workers N) ───► 其他业务
|
||||
└────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ (两个进程跑在同一个容器里,共享 .deer-flow 卷
|
||||
│ /同一个 MySQL /同一个 sqlite checkpoints.db)
|
||||
```
|
||||
|
||||
两进程都用 **同一个 `app.gateway.app:app`**,区别只在:
|
||||
- `8002` 单 worker,专门接 `/api/ai-writing/*`
|
||||
- `8001` 多 worker,接其他所有路由
|
||||
- 容器内用 `supervisord` 同时托管这两个 uvicorn
|
||||
|
||||
### 步骤 1:在镜像里加 supervisord
|
||||
|
||||
编辑 `offline-backend-20260512/backend/Dockerfile`,给 **runtime stage**(第 67 行 `FROM python:3.12-slim-bookworm` 之后)补一段:
|
||||
|
||||
```dockerfile
|
||||
# 安装 supervisord(约 4 MB,纯 Python,无需编译)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
如果内网无法访问 debian 源,可用现有的 `APT_MIRROR` build-arg:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg APT_MIRROR=mirrors.aliyun.com \
|
||||
-f offline-backend-20260512/backend/Dockerfile \
|
||||
-t deerflow-gateway:dual .
|
||||
```
|
||||
|
||||
### 步骤 2:写 supervisord 配置
|
||||
|
||||
新建 `offline-backend-20260512/backend/deploy/supervisord.conf`:
|
||||
|
||||
```ini
|
||||
[supervisord]
|
||||
nodaemon=true ; 容器主进程,必须前台
|
||||
user=root
|
||||
logfile=/dev/null ; 让子进程日志直接走 stdout/stderr,由 docker logs 接管
|
||||
logfile_maxbytes=0
|
||||
pidfile=/tmp/supervisord.pid
|
||||
|
||||
; ─── 主 Gateway:多 worker,处理除 ai-writing 以外的所有业务 ───
|
||||
[program:gateway-main]
|
||||
command=uv run --no-sync uvicorn app.gateway.app:app
|
||||
--host 0.0.0.0 --port 8001
|
||||
--workers %(ENV_GATEWAY_MAIN_WORKERS)s
|
||||
--proxy-headers --forwarded-allow-ips=*
|
||||
directory=/app/backend
|
||||
environment=PYTHONPATH=".",AI_WRITING_DEDICATED_PORT="8002"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=35 ; 给 lifespan 的 30s drain 留点余量
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; ─── AI 写作专用:单 worker,处理 /api/ai-writing/* ───
|
||||
[program:gateway-aiwriting]
|
||||
command=uv run --no-sync uvicorn app.gateway.app:app
|
||||
--host 0.0.0.0 --port 8002
|
||||
--workers 1
|
||||
--proxy-headers --forwarded-allow-ips=*
|
||||
directory=/app/backend
|
||||
environment=PYTHONPATH=".",DEER_FLOW_ROLE="ai-writing"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=35
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
```
|
||||
|
||||
**几个关键点**:
|
||||
- `nodaemon=true` 让 supervisord 自己变成 PID 1,docker stop 发的 SIGTERM 才能正确传给它,它再传给两个 uvicorn 子进程
|
||||
- `stopasgroup=true` + `killasgroup=true` 确保 uvicorn 多 worker 模式下所有子进程一起退
|
||||
- `stopwaitsecs=35` 必须大于 lifespan 的 30s drain 超时,否则 in-flight 写作会被 SIGKILL
|
||||
- `%(ENV_GATEWAY_MAIN_WORKERS)s` 让主进程 worker 数走环境变量,下面会传
|
||||
|
||||
把这个配置文件 COPY 进镜像(在 Dockerfile runtime stage `COPY --from=builder /app/backend ./backend` 之后加):
|
||||
|
||||
```dockerfile
|
||||
COPY backend/deploy/supervisord.conf /etc/supervisor/conf.d/deerflow.conf
|
||||
```
|
||||
|
||||
### 步骤 3:把 Dockerfile 入口换成 supervisord
|
||||
|
||||
把 Dockerfile **runtime stage 最后一行** 改成:
|
||||
|
||||
```dockerfile
|
||||
# 之前
|
||||
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
|
||||
# 之后
|
||||
EXPOSE 8001 8002 2024
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/deerflow.conf"]
|
||||
```
|
||||
|
||||
> **`dev` stage 不用改**:开发环境本来就是单 worker、reload 模式,方案 C 是为生产准备的,dev stage 维持原 CMD。
|
||||
|
||||
### 步骤 4:暴露端口 / 挂卷 / 环境变量
|
||||
|
||||
启动容器时:
|
||||
|
||||
```bash
|
||||
docker run -d --name deerflow-gateway \
|
||||
-p 8001:8001 \
|
||||
-p 8002:8002 \
|
||||
-e GATEWAY_MAIN_WORKERS=4 \
|
||||
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
|
||||
-v /your/host/path/.deer-flow:/app/.deer-flow \
|
||||
-v /your/host/path/config.yaml:/app/config.yaml:ro \
|
||||
-v /your/host/path/extensions_config.json:/app/extensions_config.json:ro \
|
||||
deerflow-gateway:dual
|
||||
```
|
||||
|
||||
**两个进程必须共享 `.deer-flow` 卷** —— 因为 `checkpoints.db` 在里面(`offline-backend-20260512/config.yaml` 里配的是相对路径 `.deer-flow/data/checkpoints.db`)。两个进程跑在同一个容器内,天然共享文件系统,**这一步不用做任何额外配置**,只要确认数据卷挂出去就行。
|
||||
|
||||
> ⚠️ 即便 8002 是单 worker,仍然推荐把 checkpointer 持久化到磁盘,因为:① 容器重启会重建进程;② 如果哪天临时改回多容器/多 worker,数据不丢;③ 跟主 gateway 共享 checkpoint,未来想做读副本也方便。
|
||||
|
||||
### 步骤 5:nginx 路由
|
||||
|
||||
如果你前面已经有 nginx(容器外或者另一个容器),把 AI 写作的路由独立出来:
|
||||
|
||||
```nginx
|
||||
upstream gateway_main { server gateway-host:8001; }
|
||||
upstream gateway_aiwriting { server gateway-host:8002; }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name deerflow.example.com;
|
||||
|
||||
# SSE 长连接必须关 buffering
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# AI 写作 → 单 worker 后端
|
||||
location /api/ai-writing/ {
|
||||
proxy_pass http://gateway_aiwriting;
|
||||
proxy_read_timeout 3600s; # graph 跑得久,给足时间
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 其它一切 API → 多 worker 后端
|
||||
location /api/ {
|
||||
proxy_pass http://gateway_main;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 前端静态资源不变
|
||||
location / {
|
||||
proxy_pass http://frontend:5174;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`proxy_buffering off` 是必须的** —— 不关 buffering,nginx 会把 SSE 流缓冲到响应结束才一次性吐给前端,等于把流式写作变成「等几分钟啥都看不到 → 一下吐一整篇」。
|
||||
|
||||
如果前端是 Vite 直连后端(开发态没走 nginx),就只需要把 `frontend-web` 里 AI 写作相关的 `fetch`/`EventSource` 改成 `:8002`,但更推荐 **始终走 nginx**,对前端透明。
|
||||
|
||||
### 步骤 6:如果你用 docker-compose
|
||||
|
||||
完整 compose 示例:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gateway:
|
||||
image: deerflow-gateway:dual
|
||||
container_name: deerflow-gateway
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GATEWAY_MAIN_WORKERS: "4"
|
||||
DEER_FLOW_CONFIG_PATH: /app/config.yaml
|
||||
ports:
|
||||
- "8001:8001"
|
||||
- "8002:8002" # 如果有外层 nginx,且 nginx 跟 gateway 同网段,可去掉这行只走内网
|
||||
volumes:
|
||||
- ./data/deer-flow:/app/.deer-flow
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./extensions_config.json:/app/extensions_config.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://localhost:8001/health && curl -fsS http://localhost:8002/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
stop_grace_period: 45s # 给 supervisord 的 35s + 余量,让两路 drain 跑完
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
depends_on: [gateway]
|
||||
ports: ["80:80"]
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
```
|
||||
|
||||
`stop_grace_period: 45s` 这一行很关键 —— docker-compose 默认只给 10s,到点直接 SIGKILL,in-flight 写作会全部丢。设到 45s 等于 supervisord 的 35s + 10s 余量。
|
||||
|
||||
### 步骤 7:验证(按这个清单逐项过)
|
||||
|
||||
```bash
|
||||
# 1) 两个进程都起来了
|
||||
docker exec deerflow-gateway sh -c 'ps -ef | grep -E "uvicorn|supervisord" | grep -v grep'
|
||||
# 期望看到:
|
||||
# 1 root supervisord -c /etc/supervisor/conf.d/deerflow.conf
|
||||
# N root uvicorn app.gateway.app:app --port 8001 --workers 4
|
||||
# M root uvicorn app.gateway.app:app --port 8002 --workers 1
|
||||
# (8001 那一行会有 N 个 worker 子进程,8002 那一行只有 1 个)
|
||||
|
||||
# 2) 8002 只有 1 个 worker(这是方案 C 的命脉)
|
||||
docker exec deerflow-gateway sh -c 'pgrep -f "port 8002" | wc -l'
|
||||
# 期望 = 2(1 个 master + 1 个 worker);如果 > 2,单 worker 没生效
|
||||
|
||||
# 3) 8001 不再警告多 worker
|
||||
docker logs deerflow-gateway 2>&1 | grep -i 'WEB_CONCURRENCY'
|
||||
# 期望:没有 ERROR 警告(因为 8001 走的是其他业务,8002 走的是单 worker)
|
||||
|
||||
# 4) 8002 进程能看到 ai_writing 持久化日志
|
||||
docker logs deerflow-gateway 2>&1 | grep 'AsyncSqliteSaver'
|
||||
# 期望至少 1 行:ai_writing: 使用 AsyncSqliteSaver (...)
|
||||
|
||||
# 5) /api/ai-writing/* 走 8002,其他走 8001
|
||||
curl -sI http://deerflow.example.com/api/ai-writing/health # 看 nginx access log 落到 gateway_aiwriting
|
||||
curl -sI http://deerflow.example.com/api/models # 看落到 gateway_main
|
||||
|
||||
# 6) 真跑一次 AI 写作,确认全程没有「resume 404 / 进度断流」
|
||||
```
|
||||
|
||||
### 步骤 8:监控两个进程的健康
|
||||
|
||||
supervisord 自带 `unix_http_server` + `supervisorctl`,但对容器化场景更直接的是看 docker logs:
|
||||
|
||||
```bash
|
||||
# 实时看两个进程的日志
|
||||
docker logs -f deerflow-gateway 2>&1 | grep -E '(gateway-main|gateway-aiwriting)'
|
||||
|
||||
# 进程级状态
|
||||
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf status
|
||||
# 期望:
|
||||
# gateway-main RUNNING pid 7, uptime 0:05:12
|
||||
# gateway-aiwriting RUNNING pid 18, uptime 0:05:12
|
||||
|
||||
# 单独重启 AI 写作进程(不影响主业务,前端只是 AI 写作页几秒不可用)
|
||||
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf restart gateway-aiwriting
|
||||
```
|
||||
|
||||
### 资源开销(实测预估)
|
||||
|
||||
| 项 | 单 worker 镜像 | 双进程镜像 | 增量 |
|
||||
|---|---|---|---|
|
||||
| 镜像大小 | 基线 | +约 5 MB | supervisord 依赖 |
|
||||
| 启动时 RSS | ~250 MB | ~450 MB | 多一份 Python runtime + 模型客户端预加载 |
|
||||
| 稳态 RSS | 视业务而定 | +约 200 MB | 第二个 worker 的常驻内存 |
|
||||
| CPU | 不变 | 不变 | 单 worker 进程空闲基本不占 CPU |
|
||||
|
||||
**结论**:内存额外占用约 200 MB,对 4 GB 以上的部署机基本无压力。
|
||||
|
||||
### 常见坑位
|
||||
|
||||
1. **`stop_grace_period` 没改大 → 重启时 in-flight 写作被秒杀**
|
||||
表现:每次 `docker compose restart` 都有用户报告「写到一半没了」。修复见步骤 6。
|
||||
|
||||
2. **8002 没暴露 / nginx 没改 → 所有请求还是落 8001**
|
||||
表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。
|
||||
|
||||
3. **两路用了不同的 `config.yaml`**
|
||||
表现:8001 跟 8002 行为不一致。务必让两个进程**只读同一个文件**(在 compose 里只挂一次卷)。
|
||||
|
||||
4. **`.deer-flow` 卷没挂 → 8002 重启后会话全丢**
|
||||
表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的 `-v` 必须有。
|
||||
|
||||
5. **AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸**
|
||||
表现:`gateway-aiwriting` 状态在 RUNNING/EXITED/BACKOFF 间反复。
|
||||
修复:先 `supervisorctl tail gateway-aiwriting stderr` 看真正原因(多半是 sqlite 权限或配置文件路径),再修,**别盲目调 `autorestart=false`**。
|
||||
|
||||
6. **想做 K8s 部署但还想用方案 C**
|
||||
不要把两个进程塞一个 Pod,应该直接拆成两个 Deployment、共享 PVC(checkpoints.db)。这种情况下方案 C 已经变形成方案 F 的子集,建议直接走方案 F。
|
||||
|
||||
### 回滚方案
|
||||
|
||||
如果上线后发现问题,回滚只需要 1 步:
|
||||
|
||||
```bash
|
||||
# 把 Dockerfile 最后一行换回原来的 CMD,重新 build + 重启
|
||||
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
```
|
||||
|
||||
数据完全兼容(checkpoints.db、MySQL 表结构都跟单进程模式一致),**不会丢任何用户数据**,只是回到「resume 偶发 404、SSE 断断续续」的旧体验。
|
||||
|
||||
---
|
||||
|
||||
## 七、运维操作清单
|
||||
|
||||
### 部署前验证
|
||||
|
||||
```bash
|
||||
# 1) 确认 .deer-flow 数据卷挂出来了(持久化的命脉)
|
||||
docker exec <gateway> ls -la /app/.deer-flow/data/
|
||||
# 应该能看到 checkpoints.db 文件(首次启动后会自动创建)
|
||||
|
||||
# 2) 确认 alembic 迁移跑完了
|
||||
docker exec <gateway> sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current"
|
||||
# 应该看到包含 20260519_02 的 head
|
||||
|
||||
# 3) 确认 transcript 列已扩容
|
||||
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||||
"SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
|
||||
# Type 列应该是 longtext
|
||||
|
||||
# 4) 查 worker 数
|
||||
docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
|
||||
# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告
|
||||
```
|
||||
|
||||
### 启动时关注的日志
|
||||
|
||||
```bash
|
||||
docker logs <gateway> 2>&1 | head -100 | grep -iE 'ai_writing|checkpointer|worker'
|
||||
```
|
||||
|
||||
期望看到:
|
||||
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
|
||||
- `ai_writing_cleanup: 已启动 cron=...` — 清理任务起来了
|
||||
|
||||
警告:
|
||||
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 配置警告
|
||||
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 初始化失败,需立刻排查
|
||||
|
||||
### 用户报告「会话丢失」时的排查
|
||||
|
||||
```bash
|
||||
# 1) 查 sqlite 里这个 session 的快照
|
||||
docker exec <gateway> sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \
|
||||
\"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='<session_id>' LIMIT 5;\""
|
||||
|
||||
# 2) 查 MySQL 里这条会话
|
||||
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||||
"SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='<session_id>';"
|
||||
|
||||
# 3) 看 gateway 日志里这个 session 的活动轨迹
|
||||
docker logs <gateway> 2>&1 | grep '<session_id>' | tail -50
|
||||
|
||||
# 4) 看是否有 recover_session 调用
|
||||
docker logs <gateway> 2>&1 | grep 'ai_writing: recovered' | tail -20
|
||||
|
||||
# 5) 看是否有节点异常
|
||||
docker logs <gateway> 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20
|
||||
```
|
||||
|
||||
### 手动触发清理(admin only)
|
||||
|
||||
```bash
|
||||
# 用 admin 账号登录后,取 cookie,调清理接口
|
||||
curl -X POST 'http://<gateway>/api/ai-writing/admin/cleanup' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Cookie: <admin session cookie>' \
|
||||
-d '{"retention_days": 7}'
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"deleted_db": 12,
|
||||
"deleted_checkpoints": 12,
|
||||
"skipped_active": 1,
|
||||
"elapsed_seconds": 0.234,
|
||||
"params": { "retention_days": 7, "only_finished": false, "batch_size": 500 }
|
||||
}
|
||||
```
|
||||
|
||||
`skipped_active` > 0 表示有正在跑的会话被活跃保护拦下来,不会被误删。
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文件索引
|
||||
|
||||
### 后端
|
||||
- `offline-backend-20260512/backend/app/gateway/routers/ai_writing.py` — 路由 + 内存状态字典 + drain
|
||||
- `offline-backend-20260512/backend/app/gateway/ai_writing_cleanup.py` — 定期清理
|
||||
- `offline-backend-20260512/backend/app/gateway/app.py` — lifespan / 多 worker 检测
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py` — Graph 定义 + 持久化 checkpointer
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/` — MySQL repo
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py` — `PortableLongText`
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_02_*.py` — LONGTEXT 迁移
|
||||
- `offline-backend-20260512/config.yaml` — `checkpointer:` + `ai_writing.cleanup:` 配置
|
||||
|
||||
### 前端
|
||||
- `frontend-web/src/open-canvas/pages/AIWritingPage.tsx` — 页面顶部有快速排查注释
|
||||
- `frontend-web/src/open-canvas/api/ai-writing-sessions.ts` — transcript 保存(已做 fire-and-forget 容错)
|
||||
- `frontend-web/src/open-canvas/api/ai-writing.ts` — resume / stream
|
||||
|
||||
### 测试
|
||||
- `tests/test_ai_writing_cleanup.py` — 清理 + drain + checkpointer 降级 + SSE 序列化(21 个测试)
|
||||
- `tests/test_ai_writing_session_recovery.py` — 跨 worker 会话恢复(8 个测试)
|
||||
- `tests/test_ai_writing_session_repo.py` — Repo(7 个测试)
|
||||
- `tests/test_ai_writing_transcript_endpoint.py` — transcript 兜底(5 个测试)
|
||||
- `tests/test_portable_long_text.py` — LONGTEXT 列类型(5 个测试)
|
||||
|
||||
总测试:**51 个,全过**。
|
||||
|
||||
---
|
||||
|
||||
## 九、变更历史
|
||||
|
||||
| 日期 | Commit | 内容 |
|
||||
|---|---|---|
|
||||
| 2026-05-19 | c3d0c82 | 报错兜底(transcript LONGTEXT 迁移 + 路由 try/except + session 恢复) |
|
||||
| 2026-05-20 | (本次) | checkpointer 持久化 + 定期清理 + admin 接口 + 多层兜底 + drain + 多 worker 检测 |
|
||||
|
||||
---
|
||||
|
||||
## 十、决策摘要(给未来的你/同事)
|
||||
|
||||
> **如果你现在第一次接手这个问题**,按这个清单往下走:
|
||||
>
|
||||
> 1. ✅ **transcript 500 不用管** — 已彻底修复
|
||||
> 2. ⚠️ **resume 报错的根因是多 worker** — sqlite 持久化已减轻,但 SSE 事件流仍隔离
|
||||
> 3. ⚠️ **写作能跑完,但用户体验断断续续** — 草稿数据安全,刷新就能看到
|
||||
> 4. 🎯 **要彻底解决,从方案 C / F / B 选一个**(建议从 C 开始)
|
||||
> 5. 🚨 **务必确认 `.deer-flow` 数据卷挂出来了** — 否则 sqlite 持久化白做
|
||||
> 6. 📊 **关注的日志关键字**:`ai_writing:`、`checkpointer`、`AsyncSqliteSaver`、`WEB_CONCURRENCY`
|
||||
|
||||
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,359 @@
|
||||
# 多智能体圆桌研讨 · 后端开发文档
|
||||
|
||||
本文档面向需要维护或扩展"多智能体圆桌研讨"功能后端的开发者。前端 API 速查请看 `multi-agent-api.md`,此文档关注**实现细节、设计取舍、性能特征**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定位
|
||||
|
||||
把"任务目标 + 用户约束"丢给一个**协调智能体**,由它通过 `agent_orchestration` 技能调度 6 个固定职能的子智能体(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述)协同产出方案,直到协调智能体认为信息足够、停止派活并输出最终结论为止。
|
||||
|
||||
对应前端页面:`/page/roundtable/planning` 的 **Step 2 多智能体研讨**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 涉及的后端文件
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── app/gateway/
|
||||
│ ├── app.py # 注册 multi_agent.router
|
||||
│ └── routers/
|
||||
│ └── multi_agent.py # 本功能唯一新增的 router
|
||||
└── .deer-flow/agents/
|
||||
├── roundtable-intelligence/ # 6 个内置子智能体目录
|
||||
│ ├── config.yaml # ├── id / name / description
|
||||
│ └── SOUL.md # └── 角色 prompt
|
||||
├── roundtable-environment/
|
||||
├── roundtable-solution-design/
|
||||
├── roundtable-risk-review/
|
||||
├── roundtable-execution-plan/
|
||||
└── roundtable-summary/
|
||||
```
|
||||
|
||||
**零代码改动构造内置 agent**:6 个目录通过既有的 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口总览
|
||||
|
||||
路由前缀 `/api/multi-agent`,所有接口需要 `Authorization: Bearer <JWT>`。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| POST | `/api/multi-agent/init` | 一次性创建协调智能体 + 6 个子席位线程,返回 `thread_ids` 映射 |
|
||||
| POST | `/api/multi-agent/run/stream` | SSE 流式驱动一次 leader 或 special 调用 |
|
||||
|
||||
字段细节见 `multi-agent-api.md`,这里不重复。
|
||||
|
||||
---
|
||||
|
||||
## 4. 内部实现详解
|
||||
|
||||
### 4.1 `/init` 内部流程
|
||||
|
||||
```
|
||||
[阶段1] 并行准备 6 个子智能体席位 (asyncio.gather)
|
||||
├─ GET /api/agents/{id} × 6 → 取 name + description
|
||||
└─ POST /api/threads × 6 → 建新 thread
|
||||
|
||||
[阶段2] 创建协调智能体
|
||||
├─ POST /api/threads → 协调智能体的 thread
|
||||
└─ POST /api/agents (with model + skills=[agent_orchestration] + soul)
|
||||
└─ soul 里包含可调度的 6 个 agent_name + description 列表
|
||||
```
|
||||
|
||||
**协调智能体 SOUL 是动态拼出来的**(`multi_agent.py:216-224`):
|
||||
|
||||
```python
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,...\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,...\n"
|
||||
"#可调度智能体:\n"
|
||||
f"{descriptions}" # ← 6 个子席位的 name+description
|
||||
)
|
||||
```
|
||||
|
||||
由于 `main_agent_name` 由前端每次新建(`roundtable-coordinator-{timestamp36}`),所以协调智能体本身是一次性的、用完即弃。
|
||||
|
||||
### 4.2 `/run/stream` 内部流程
|
||||
|
||||
接收 `agent_type` 后走两条分支:
|
||||
|
||||
#### `agent_type = "leader"` (协调智能体)
|
||||
|
||||
```
|
||||
1. broadcast「总控收到用户消息」到所有非自己线程 (asyncio.gather)
|
||||
2. 拼 RunCreateRequest,转发到 POST /api/threads/{coordinator}/runs/stream
|
||||
- excluded_tools: ["web_search"]
|
||||
- context.model_name = 用户传的 model
|
||||
3. 流式透传 LangGraph 事件给前端
|
||||
4. 流结束后从最后一帧 values 取最末一条 message
|
||||
5. 解析 message.tool_calls (agent_orchestration 技能调用):
|
||||
- 用 _extract_name_task() 提取 [agent_name, task_text]
|
||||
- 对每个 [agent_name, task]:
|
||||
broadcast「总控给 X 派活,内容为: ...」到所有非派活双方
|
||||
6. 末尾追加状态帧:
|
||||
{
|
||||
"status": [["agent_name", "task"], ...], // 空数组 = 共识达成
|
||||
"content": "<leader 最终文本>",
|
||||
"agent_name": "<coordinator>"
|
||||
}
|
||||
```
|
||||
|
||||
#### `agent_type = "special"` (子智能体)
|
||||
|
||||
```
|
||||
1. 拼 RunCreateRequest,转发到 POST /api/threads/{sub_agent}/runs/stream
|
||||
- excluded_tools: ["web_search","ask_clarification","present_files","view_image"]
|
||||
2. 流式透传 LangGraph 事件
|
||||
3. 流结束后从最后一帧 values 取交付内容 (content)
|
||||
4. broadcast「子智能体 X 完成 Y 工作,交付内容为: ...」到所有非自己线程
|
||||
5. 末尾追加状态帧:
|
||||
{
|
||||
"status": "子智能体 X 完成 Y 工作",
|
||||
"content": "<交付内容>",
|
||||
"agent_name": "<sub agent>"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 _broadcast 机制
|
||||
|
||||
```python
|
||||
async def _broadcast(client, base, headers, message, agent_threads, skip):
|
||||
targets = [tid for name, tid in agent_threads.items() if name not in skip]
|
||||
await asyncio.gather(*(_append_thread_message(...) for tid in targets))
|
||||
```
|
||||
|
||||
`_append_thread_message` 对每个目标 thread:
|
||||
|
||||
```
|
||||
GET /api/threads/{id}/state # 拿当前 messages 列表
|
||||
state.values.messages.append({"type":"human","content":...})
|
||||
POST /api/threads/{id}/state # 写回完整 state
|
||||
```
|
||||
|
||||
这是**为了让每个子线程的对话历史里都能看到全局上下文**(其他席位说了什么、协调智能体怎么派活的)。子智能体下次被调用时,模型自然能基于这份"群消息回放"做判断。
|
||||
|
||||
**关键不变量**:`skip` 列表通常包含发起方和接收方,避免发起方收到回声、接收方收到重复消息。
|
||||
|
||||
---
|
||||
|
||||
## 5. 6 个内置 agent SOUL 设计要点
|
||||
|
||||
每个 `roundtable-*` 的 SOUL.md 遵循统一结构:
|
||||
|
||||
```
|
||||
# {席位名}
|
||||
|
||||
## 角色定位
|
||||
你是"多智能体圆桌研讨"会议的 **{席位}** 席位。会议由"总控协调智能体"统一调度,
|
||||
你只在被派到任务时发言,不需要主动推动议程。
|
||||
|
||||
## 核心职责
|
||||
- ...具体职责...
|
||||
|
||||
## 输出风格
|
||||
- 每次发言以 {emoji} 开头
|
||||
- 第一句直接给结论
|
||||
- 不超过 4 句
|
||||
|
||||
## 边界
|
||||
- 只产出 {本席位输出}
|
||||
- 不替其他席位做 {其他席位输出}
|
||||
- 任务结束后停下,等待总控派下一个任务
|
||||
|
||||
## 示例输出
|
||||
> ...
|
||||
```
|
||||
|
||||
| 席位 | agent_id | emoji | 核心职责 |
|
||||
|---|---|---|---|
|
||||
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险预警 |
|
||||
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 量化承载力 |
|
||||
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
|
||||
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
|
||||
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源排布 |
|
||||
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 + 盖印 |
|
||||
|
||||
**为什么不让子智能体自己调度**:所有 SOUL 都明文写了"只在被点到时发言,由总控协调智能体派活"。子智能体不装 `agent_orchestration` 技能。只有协调智能体装。这是单调度源,避免多智能体相互抢调度权造成的死循环。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键设计决策
|
||||
|
||||
### 6.1 为什么用 HTTP loopback 而不是内部函数调用
|
||||
|
||||
`multi_agent.py` 调用 `/api/threads`、`/api/agents`、`/api/threads/.../runs/stream` 都是用 `httpx` 走 `http://localhost:8001/...` 自身循环回来,而不是直接 `from app.gateway.services import ...`。
|
||||
|
||||
**好处**:
|
||||
- 自动复用 Gateway 的鉴权 + CSRF + permission 中间件,不用再手写
|
||||
- 复用既有路由的请求验证(Pydantic)和错误响应
|
||||
- `Authorization` 头透传,被调端自然知道是哪个用户在调
|
||||
|
||||
**代价**: 每次调用都有一次额外的 TCP+JWT 解析开销(~10-50ms)。但因为 init 已经做了并行优化(A)、broadcast 已经省掉(C),目前总体耗时是可接受的。
|
||||
|
||||
如未来需要进一步优化,可改为直接调用 `app/gateway/services.py` 里的内部函数,绕过 HTTP 一层。
|
||||
|
||||
### 6.2 为什么协调智能体每次新建
|
||||
|
||||
前端在每次进入 step 2 时生成 `roundtable-coordinator-{Date.now().toString(36)}`,作为 `main_agent_name`。
|
||||
|
||||
**好处**:
|
||||
- 不同会话/不同用户/同一用户不同任务之间完全隔离
|
||||
- 协调智能体的对话历史就是本次研讨的全部上下文,不会被上次任务污染
|
||||
- SOUL 不会被"上次的可调度列表"覆盖(虽然现在 6 个席位是固定的)
|
||||
|
||||
**代价**: 每次都要在 `agents` 表插一行,`.deer-flow/agents/<id>/` 下生成新目录。长期会累积大量协调智能体目录,**未来需要清理脚本**(标记 description 以 "你是总控控制协调智能体" 开头的 agent 自动 GC)。
|
||||
|
||||
### 6.3 子智能体席位为什么固定 6 个
|
||||
|
||||
- 圆桌的 6 个角色定位是产品级强约束(情报/环境/方案/风险/执行/总结)
|
||||
- 写在后端 SOUL.md 一处维护,跟其他内置 agent 同等地位
|
||||
- 前端只需要把 6 个 hyphen-case 名作为 `agent_names` 传过来即可
|
||||
|
||||
如果后续要支持"用户自选席位",在前端加 mapping 面板让用户从 `GET /api/agents` 里挑,不动后端。
|
||||
|
||||
### 6.4 为什么 SSE 末尾还要追加一个 `{status:...}` 帧
|
||||
|
||||
LangGraph 原生 SSE 里没有"调度结果"这种语义,前端只能从最后一帧 values 里去翻 tool_calls。如果由前端解析,前后端协议会绑死 LangGraph 内部数据形状,迁移成本高。所以后端在流末尾追加一个**协议层的总结帧**:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": [["agent_name", "task"], ...] | "<完成提示>",
|
||||
"content": "<最终 AI 文本>",
|
||||
"agent_name": "<本轮 agent>"
|
||||
}
|
||||
```
|
||||
|
||||
前端 `streamMultiAgent` helper 通过 `isFinalStatusFrame()` 识别该帧(必须同时有 `status` + `content` 键),与其他 LangGraph 帧区分。
|
||||
|
||||
---
|
||||
|
||||
## 7. 性能特征
|
||||
|
||||
### 7.1 已应用的优化
|
||||
|
||||
| ID | 优化 | 收益 |
|
||||
|---|---|---|
|
||||
| **A** | `/init` 阶段 1 并行化(`asyncio.gather`) | 阶段 1 从 2-4s 降到 0.3-0.7s |
|
||||
| **C** | 删除 `/init` 末尾的 intro 广播(SOUL 已含定位) | 阶段 3 整段消失,省 1-2s |
|
||||
|
||||
应用前 `/init` 耗时 **3-8s**,应用后 **0.5-1.2s**。
|
||||
|
||||
### 7.2 仍然存在的开销点
|
||||
|
||||
| 调用 | 单次耗时 | 主因 |
|
||||
|---|---|---|
|
||||
| `GET /api/agents/{id}` | 50-300ms | 每次触发 `_sync_legacy_agents()` 扫整个 `.deer-flow/agents/` 目录(目前 30+ 项) |
|
||||
| `POST /api/threads` | 100-200ms | LangGraph thread 表插入 + 默认 state 初始化 |
|
||||
| `POST /api/agents` | 100-300ms | 文件系统写入 SOUL.md + config.yaml + agents 表 |
|
||||
| `GET/POST /state` | 50-150ms × 2 | checkpointer 读写 |
|
||||
| `POST /runs/stream` 启动 | 50-100ms | RunManager + StreamBridge 初始化 |
|
||||
| LLM 推理 | **数百毫秒~数十秒** | 取决于 model + 上下文长度 |
|
||||
|
||||
**LLM 推理才是绝对大头**,基础设施开销在十几个百分点以内。
|
||||
|
||||
### 7.3 未来可探索
|
||||
|
||||
- **B**(已识别未做): broadcast 跳过 GET-state,直接 POST 新建态。但 POST 需要构造完整的 state shape,改造成本不小。
|
||||
- **D**(已识别未做): loopback HTTP → 内部函数调用。代码改动大,但能省掉 ~20-30 次 HTTP 解析开销。
|
||||
|
||||
---
|
||||
|
||||
## 8. 依赖与扩展点
|
||||
|
||||
### 8.1 `agent_orchestration` 技能
|
||||
|
||||
协调智能体强依赖此技能,SOUL 里硬编码:
|
||||
|
||||
```
|
||||
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
|
||||
```
|
||||
|
||||
技能的实际派活产物是一个形如 `python3 scripts/agent_orchestration.py "agent_name" "task"` 的 shell 调用文本,后端用正则提取:
|
||||
|
||||
```python
|
||||
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
|
||||
```
|
||||
|
||||
**若该技能被删除/改名**,leader 流程会拿不到 `tool_calls`,直接走"无派活=共识达成"分支。注意 `skills/` 下必须保留此技能。
|
||||
|
||||
### 8.2 模型选择
|
||||
|
||||
前端通过 `GET /api/models` 拿到列表,在 `model` 字段传 `Model.name` (与 `config.yaml.models[].name` 对应)。
|
||||
|
||||
- `/init` 的 `model` 字段 → 协调智能体的默认模型 (写进 agents 配置)
|
||||
- `/run/stream` 的 `model` 字段 → 该次具体运行用什么模型 (写进 `context.model_name`)
|
||||
|
||||
前端允许"逐席位覆盖模型",由前端在 special 调用时按 `agentConfigs[seat].model` 传入。后端不感知"席位"概念,只看到 `model` 字段。
|
||||
|
||||
### 8.3 自定义新席位
|
||||
|
||||
如果要加第 7 个席位(比如"成本测算"):
|
||||
|
||||
1. `.deer-flow/agents/roundtable-cost/` 加 `config.yaml` + `SOUL.md`
|
||||
2. 前端 `RoundtablePlanningPage.tsx` 的 `ROUNDTABLE_ROLE_TEMPLATES` + `AGENT_NAME_TO_ROLE` 加映射
|
||||
3. 圆桌可视化(圆圈环形图)加一个气泡 + 头像
|
||||
|
||||
**后端代码完全不需要改**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查 |
|
||||
|---|---|---|
|
||||
| `/init` 500 "create coordinator agent failed" | `agent_orchestration` 技能不存在 / config.yaml 写入失败 | 看后端日志 `multi_agent` logger;检查 `skills/` 目录 |
|
||||
| `/run/stream` 502 "upstream returned HTTP 4xx" | JWT 过期 / 转发到上游时鉴权挂了 | 前端检查 Authorization 头是否带上 |
|
||||
| leader 永远 `status: []` 立即结束 | 协调智能体没产生 `tool_calls`,直接给文本回答 | 协调智能体 SOUL 不够强、或模型不支持工具调用,换 model |
|
||||
| special 内容为空 | 子智能体被 `excluded_tools` 限制到无可用工具 / 模型无文本输出 | 看上游 `runs/stream` 的 values 帧 messages.last.content |
|
||||
| 进入 step 2 卡住转圈 | `/init` 超时或失败 | F12 Network 看 `/api/multi-agent/init` 状态码与响应体 |
|
||||
|
||||
### 服务端日志关键字
|
||||
|
||||
```bash
|
||||
# multi_agent 路由的日志
|
||||
grep "multi_agent" .runtime/logs/*.log
|
||||
|
||||
# 派活解析失败
|
||||
grep "dispatch failed" .runtime/logs/*.log
|
||||
|
||||
# 广播失败(通常无害)
|
||||
grep "update state .* failed" .runtime/logs/*.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试入口
|
||||
|
||||
后端目前没有针对本功能的单测。建议补:
|
||||
|
||||
```
|
||||
backend/tests/test_multi_agent.py
|
||||
- test_init_creates_threads_for_all_subagents
|
||||
- test_init_creates_coordinator_with_orchestration_skill
|
||||
- test_init_returns_400_when_agent_names_empty
|
||||
- test_stream_leader_emits_status_frame_with_content
|
||||
- test_stream_special_emits_status_frame_with_content
|
||||
- test_extract_name_task_parses_orchestration_skill_output
|
||||
- test_broadcast_skips_listed_agents
|
||||
```
|
||||
|
||||
可用 `httpx.MockTransport` 拦截 loopback 请求,不依赖真实 Gateway。
|
||||
|
||||
---
|
||||
|
||||
## 11. 路径速查
|
||||
|
||||
| 资源 | 路径 |
|
||||
|---|---|
|
||||
| 路由实现 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
|
||||
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py`(`include_router(multi_agent.router)`) |
|
||||
| 6 个内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` |
|
||||
| `agent_orchestration` 技能定义 | `skills/public/agent_orchestration/`(若存在) |
|
||||
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||
| 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| 前端 API 速查 | `frontend-web/docs/multi-agent-api.md` |
|
||||
@@ -0,0 +1,162 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
export interface InitMultiAgentRequest {
|
||||
agentNames: string[];
|
||||
mainAgentName: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface InitMultiAgentResponse {
|
||||
status: "success";
|
||||
main_agent_name: string;
|
||||
thread_ids: ThreadIdMap;
|
||||
}
|
||||
|
||||
export async function initMultiAgent(
|
||||
payload: InitMultiAgentRequest,
|
||||
): Promise<InitMultiAgentResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
agent_names: payload.agentNames,
|
||||
main_agent_name: payload.mainAgentName,
|
||||
model: payload.model,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as InitMultiAgentResponse;
|
||||
}
|
||||
|
||||
export type StreamAgentType = "leader" | "special";
|
||||
|
||||
export type LeaderDispatch = [string, string];
|
||||
|
||||
export interface StreamMultiAgentRequest {
|
||||
agentType: StreamAgentType;
|
||||
dAgentThreadId: ThreadIdMap;
|
||||
agentName: string;
|
||||
newMessage: string;
|
||||
model?: string;
|
||||
skillStopNames?: string[];
|
||||
signal?: AbortSignal;
|
||||
/** Fires for every non-status data frame the upstream emits. */
|
||||
onUpstreamFrame?: (frame: unknown) => void;
|
||||
}
|
||||
|
||||
export interface StreamMultiAgentResult {
|
||||
/** For leader runs: dispatched [agentName, task] tuples. Empty = consensus. */
|
||||
dispatched: LeaderDispatch[];
|
||||
/** For special runs: the completion status string. */
|
||||
message: string | null;
|
||||
/** Final assistant text emitted by the underlying agent. */
|
||||
content: string;
|
||||
/** Echoed agent_name of the agent that produced this run. */
|
||||
agentName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the final status frame our backend appends after the LangGraph
|
||||
* stream. Required keys are `status` (array for leader, string for special)
|
||||
* and `content` (final assistant text); `agent_name` is also expected.
|
||||
*/
|
||||
function isFinalStatusFrame(value: unknown): value is {
|
||||
status: LeaderDispatch[] | string;
|
||||
content?: string;
|
||||
agent_name?: string;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
if (typeof status === "string") return true;
|
||||
if (Array.isArray(status)) {
|
||||
return status.every(
|
||||
(item) =>
|
||||
Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
|
||||
* stream completes. The final frame (`{status: ...}`) is interpreted; every
|
||||
* other `data:` frame is forwarded to `onUpstreamFrame` so the caller can do
|
||||
* its own rendering (we don't try to mimic LangGraph SDK semantics here).
|
||||
*/
|
||||
export async function streamMultiAgent(
|
||||
req: StreamMultiAgentRequest,
|
||||
): Promise<StreamMultiAgentResult> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
agent_type: req.agentType,
|
||||
d_agent_thread_id: req.dAgentThreadId,
|
||||
agent_name: req.agentName,
|
||||
new_message: req.newMessage,
|
||||
model: req.model,
|
||||
skill_stop_names: req.skillStopNames ?? [],
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
const result: StreamMultiAgentResult = { dispatched: [], message: null, content: "", agentName: null };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFinalStatusFrame(parsed)) {
|
||||
if (Array.isArray(parsed.status)) {
|
||||
result.dispatched = parsed.status;
|
||||
} else {
|
||||
result.message = parsed.status;
|
||||
}
|
||||
if (typeof parsed.content === "string") {
|
||||
result.content = parsed.content;
|
||||
}
|
||||
if (typeof parsed.agent_name === "string") {
|
||||
result.agentName = parsed.agent_name;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
req.onUpstreamFrame?.(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
initMultiAgent,
|
||||
streamMultiAgent,
|
||||
type LeaderDispatch,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
Shield,
|
||||
MessageSquare,
|
||||
@@ -115,56 +122,69 @@ function getAvatar(type: string, size: string = "h-8 w-8") {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Extended Multi-Agent dialogue simulation rounds pool ---
|
||||
const simulatedRoundPool = [
|
||||
{
|
||||
sender: "情报收集",
|
||||
avatarType: "purple",
|
||||
senderRole: "intelligence",
|
||||
confidence: "高可信",
|
||||
content: "📢 [情报追加] 检索到华东通信中枢类似物理断线案例,当时曾因极限负载冷切短接导致恢复延期 15%。已向执行规划端推送自恢复重组逻辑。"
|
||||
},
|
||||
{
|
||||
sender: "环境评估",
|
||||
avatarType: "emerald",
|
||||
senderRole: "environment",
|
||||
confidence: "动态沙盒 98%",
|
||||
content: "🌡️ [热失稳警报] 预测在 T+36h 至 T+40h(高负载测试节点),物理降温柜将执行自清洁维护。我已通知方案设计端调低该时段的瞬时极限功率。"
|
||||
},
|
||||
{
|
||||
sender: "方案设计",
|
||||
avatarType: "human",
|
||||
senderRole: "design",
|
||||
confidence: "模型调整完成",
|
||||
content: "📐 [方案调优] 收到环境和情报侧指标!已成功将主极速方案的测试镜像烘焙时间错开该高温窗口,挪前至 T+24h。完美规避第 48 小时的高温警戒区!"
|
||||
},
|
||||
{
|
||||
sender: "风险审查",
|
||||
avatarType: "amber",
|
||||
senderRole: "risk",
|
||||
confidence: "合规检测绿码",
|
||||
content: "🛡️ [安全审查通过] 沙盒深度测析:方案 A 全栈静态脚本和隔离拓扑完全符合特级安全审计底线。安全系数上升 8.3%,物理漏损判定绿码!"
|
||||
},
|
||||
{
|
||||
sender: "执行规划",
|
||||
avatarType: "pink",
|
||||
senderRole: "execution",
|
||||
confidence: "自习惯里程碑",
|
||||
content: "📅 [甘特图重排就绪] 72 小时全交付链路已锁定:T+12h 架构冻结,T+24h 镜像热部署规避,T+36h 集成通过,T+72h 整体可落地投产。全程算力承载平滑。"
|
||||
},
|
||||
{
|
||||
sender: "总结陈述",
|
||||
avatarType: "blue",
|
||||
senderRole: "coordinator",
|
||||
confidence: "全案收敛盖印",
|
||||
content: "🏆 [全案决策固化] 研讨论证完美收敛!主极速构建案(310 万)携手备隔离冷库案(152.5 万),共耗资 462.5 万元,各决策节点已正式盖印交割!"
|
||||
}
|
||||
// --- Multi-agent backend wiring ---
|
||||
const DEFAULT_MODEL = "deepseek-chat";
|
||||
|
||||
type RoleEntry = {
|
||||
id: number;
|
||||
name: string;
|
||||
agentId?: string;
|
||||
type: "human" | "agent";
|
||||
status: string;
|
||||
};
|
||||
|
||||
const ROUNDTABLE_ROLE_TEMPLATES: ReadonlyArray<Omit<RoleEntry, "status">> = [
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human" },
|
||||
{ id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent" },
|
||||
{ id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent" },
|
||||
{ id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent" },
|
||||
{ id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent" },
|
||||
{ id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent" },
|
||||
{ id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent" },
|
||||
{ id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent" },
|
||||
];
|
||||
|
||||
function makeRoles(defaultStatus: string, overrides: Record<number, string> = {}): RoleEntry[] {
|
||||
return ROUNDTABLE_ROLE_TEMPLATES.map((t) => ({
|
||||
...t,
|
||||
status: overrides[t.id] ?? defaultStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
// Subset of role templates that map to real backend agents (excludes the
|
||||
// human commander seat) — used as the `agent_names` payload on init.
|
||||
const ROUNDTABLE_AGENT_IDS = Array.from(
|
||||
new Set(
|
||||
ROUNDTABLE_ROLE_TEMPLATES.filter((r) => r.agentId).map((r) => r.agentId as string),
|
||||
),
|
||||
);
|
||||
|
||||
// agent_name (lowercase) → bubble meta. Used when streaming output, so we can
|
||||
// light up the right speaker bubble and pick an avatar for the dialogue card.
|
||||
const AGENT_NAME_TO_ROLE: Record<string, { speakerId: number; avatarType: string; displayName: string }> = {
|
||||
"roundtable-summary": { speakerId: 2, avatarType: "blue", displayName: "总结陈述" },
|
||||
"roundtable-intelligence": { speakerId: 3, avatarType: "purple", displayName: "情报收集" },
|
||||
"roundtable-environment": { speakerId: 4, avatarType: "emerald", displayName: "环境评估" },
|
||||
"roundtable-solution-design": { speakerId: 5, avatarType: "human", displayName: "方案设计" },
|
||||
"roundtable-risk-review": { speakerId: 6, avatarType: "amber", displayName: "风险审查" },
|
||||
"roundtable-execution-plan": { speakerId: 7, avatarType: "pink", displayName: "执行规划" },
|
||||
};
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function newCoordinatorName() {
|
||||
return `roundtable-coordinator-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
export default function RoundtablePlanningPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
// Backend-supplied model list (used by the per-seat persona drawer).
|
||||
const { models: availableModels, isLoading: modelsLoading } = useModels();
|
||||
|
||||
// Navigation active tab
|
||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
@@ -180,17 +200,15 @@ export default function RoundtablePlanningPage() {
|
||||
const [consensusPercentage, setConsensusPercentage] = useState<number>(89.4);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
|
||||
const [activeSpeakerId, setActiveSpeakerId] = useState<number | null>(null);
|
||||
const [agentThinkingStatus, setAgentThinkingStatus] = useState<string | null>(null);
|
||||
const [simRoundIndex, setSimRoundIndex] = useState<number>(0);
|
||||
|
||||
// Custom presets for agent model, weight, temperature and prompt editors
|
||||
const [agentConfigs, setAgentConfigs] = useState<{ [key: number]: { model: string; weight: number; prompt: string; temp: number } }>({
|
||||
2: { model: "Gemini 2.5 Flash", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
|
||||
3: { model: "Gemini 2.5 Flash", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
|
||||
4: { model: "Gemini 2.5 Pro", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
|
||||
5: { model: "Claude 3.5 Sonnet", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
|
||||
6: { model: "Gemini 2.5 Pro", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
|
||||
7: { model: "Gemini 2.5 Flash", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
|
||||
2: { model: DEFAULT_MODEL, weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
|
||||
3: { model: DEFAULT_MODEL, weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
|
||||
4: { model: DEFAULT_MODEL, weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
|
||||
5: { model: DEFAULT_MODEL, weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
|
||||
6: { model: DEFAULT_MODEL, weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
|
||||
7: { model: DEFAULT_MODEL, weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
|
||||
});
|
||||
|
||||
// Message lists state representation to mimic image items & enable actual conversations
|
||||
@@ -254,74 +272,35 @@ export default function RoundtablePlanningPage() {
|
||||
]);
|
||||
|
||||
// Roles in roundtable state (updates as we switch steps!)
|
||||
const [roles, setRoles] = useState([
|
||||
const [roles, setRoles] = useState<RoleEntry[]>([
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human", status: "等待激活" },
|
||||
{ id: 2, name: "总结陈述", type: "agent", status: "等待激活" },
|
||||
{ id: 3, name: "情报收集", type: "agent", status: "等待激活" },
|
||||
{ id: 4, name: "环境评估", type: "agent", status: "等待激活" },
|
||||
{ id: 5, name: "方案设计", type: "human", status: "等待激活" },
|
||||
{ id: 6, name: "风险审查", type: "agent", status: "等待激活" },
|
||||
{ id: 7, name: "执行规划", type: "agent", status: "等待激活" },
|
||||
{ id: 8, name: "总结陈述", type: "agent", status: "等待陈述" },
|
||||
{ id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待激活" },
|
||||
{ id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent", status: "等待激活" },
|
||||
{ id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent", status: "等待激活" },
|
||||
{ id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent", status: "等待激活" },
|
||||
{ id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent", status: "等待激活" },
|
||||
{ id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent", status: "等待激活" },
|
||||
{ id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待陈述" },
|
||||
]);
|
||||
|
||||
// Step 2 Simulated Discussion Log
|
||||
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<any[]>([
|
||||
{
|
||||
id: 1,
|
||||
sender: "情报收集",
|
||||
avatarType: "purple",
|
||||
content: "📢 经全网高价值情报与历史同类备案检索,类似“72小时极速部署”在电力与通信中枢系统常面临软硬件适配断层风险,建议采用已验证的云原生容灾中继器架构模型。",
|
||||
time: "11:02:18",
|
||||
confidence: "高可信"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: "环境评估",
|
||||
avatarType: "emerald",
|
||||
content: "🌍 主服务器托管物理环境测试就绪。在极端并发与资源受限下,机柜最高算力分配可承受 1.8x 的弹性波动。72小时周期内由于降温机组轮休,需规避第48-52小时的高温警戒区。",
|
||||
time: "11:03:05",
|
||||
confidence: "精确度96%"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sender: "指挥员 / 主持人",
|
||||
avatarType: "man",
|
||||
content: "🎤 研讨团队注意,用户要求500万预算硬约束,且首要目标是提升执行效率和保障100%合规。方案设计端应当对核心敏捷路径进行显式标注。",
|
||||
time: "11:04:12",
|
||||
confidence: "主席裁决"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
sender: "方案设计",
|
||||
avatarType: "man",
|
||||
content: "📐 已完成主备双核心方案建模。主极速构建方案基于K3s轻量容器,可在36小时内提前部署,并由情报端微模型预烘焙;备用方案配置纯物理备份,确保全栈零公网隔离。",
|
||||
time: "11:05:40",
|
||||
confidence: "主责输出"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
sender: "风险审查",
|
||||
avatarType: "amber",
|
||||
content: "⚠️ 指出主极速方案漏洞:由于预算硬性限制500万,自动化测试外包采购与安全审计费用需压缩至60万之内。否则会触碰预算预警线,建议在第三轮调度中裁剪旁路遥测组件。",
|
||||
time: "11:06:55",
|
||||
confidence: "黄色警戒"
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
sender: "执行规划",
|
||||
avatarType: "pink",
|
||||
content: "📅 72小时里程碑已自适应重排:T+12h 架构冻结;T+36h 核心集成验证;T+48h 风险切片模拟与极速演练;T+60h 预算与合规静态查验;T+72h 整体安全投产。",
|
||||
time: "11:08:10",
|
||||
confidence: "自动化规划器"
|
||||
}
|
||||
]);
|
||||
// Step 2 dialogue list — populated by the real orchestration loop once the
|
||||
// user enters the roundtable. Starts empty so we never flash mock content.
|
||||
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<any[]>([]);
|
||||
|
||||
// Inputs for Step 2 conversation simulation controls
|
||||
const [roundTableSpeed, setRoundTableSpeed] = useState<string>("正常");
|
||||
const [isRoundTableRunning, setIsRoundTableRunning] = useState<boolean>(true);
|
||||
const [customAgentInput, setCustomAgentInput] = useState<string>("");
|
||||
|
||||
// Multi-agent orchestration state (lives only for one step-2 visit).
|
||||
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
|
||||
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
|
||||
const [isInitializing, setIsInitializing] = useState<boolean>(false);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const [isOrchestrating, setIsOrchestrating] = useState<boolean>(false);
|
||||
const orchestrationCancelRef = useRef<boolean>(false);
|
||||
const step2InitRef = useRef<boolean>(false);
|
||||
|
||||
// Toast auto-dismiss logic
|
||||
useEffect(() => {
|
||||
if (showToast) {
|
||||
@@ -350,84 +329,221 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
}, [step2RoundtableDialogues]);
|
||||
|
||||
// Roundtable Dynamic Simulation Timer Hook
|
||||
useEffect(() => {
|
||||
if (currentStep !== 2 || !isRoundTableRunning) return;
|
||||
// Push one dialogue card; uses Date.now()+rand to avoid React key collisions.
|
||||
const appendDialogue = useCallback((card: {
|
||||
sender: string;
|
||||
avatarType: string;
|
||||
content: string;
|
||||
confidence?: string;
|
||||
}) => {
|
||||
setStep2RoundtableDialogues(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now() + Math.random(),
|
||||
sender: card.sender,
|
||||
avatarType: card.avatarType,
|
||||
content: card.content,
|
||||
time: nowTime(),
|
||||
confidence: card.confidence ?? "实时输出",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
let intervalTime = 5000;
|
||||
if (roundTableSpeed === "慢速") intervalTime = 8000;
|
||||
else if (roundTableSpeed === "极速") intervalTime = 2500;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (simRoundIndex < simulatedRoundPool.length) {
|
||||
const nextMessageInfo = simulatedRoundPool[simRoundIndex];
|
||||
|
||||
// Determine correct Agent ID to highlight and activate
|
||||
let speakerId = 3; // default: Purple / 情报收集
|
||||
if (nextMessageInfo.senderRole === "environment") speakerId = 4;
|
||||
else if (nextMessageInfo.senderRole === "design") speakerId = 5;
|
||||
else if (nextMessageInfo.senderRole === "risk") speakerId = 6;
|
||||
else if (nextMessageInfo.senderRole === "execution") speakerId = 7;
|
||||
else if (nextMessageInfo.senderRole === "coordinator") speakerId = 2;
|
||||
|
||||
// Visual highlights speaker on roundtable diagram
|
||||
setActiveSpeakerId(speakerId);
|
||||
|
||||
// Update active role and highlight typing indicator
|
||||
setRoles(prevRoles => prevRoles.map(item => {
|
||||
if (item.id === speakerId) {
|
||||
return { ...item, status: "研讨中" };
|
||||
} else if (item.status === "研讨中") {
|
||||
return { ...item, status: "在线" };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
|
||||
setAgentThinkingStatus(`🤖 【${nextMessageInfo.sender}】正在结合最新人设约束深度推算,生成论证要点...`);
|
||||
|
||||
const delayTimeout = setTimeout(() => {
|
||||
const dialogueTime = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
const newDialogue = {
|
||||
id: Date.now(),
|
||||
sender: nextMessageInfo.sender,
|
||||
avatarType: nextMessageInfo.avatarType,
|
||||
content: nextMessageInfo.content,
|
||||
time: dialogueTime,
|
||||
confidence: nextMessageInfo.confidence
|
||||
};
|
||||
|
||||
setStep2RoundtableDialogues(prev => [...prev, newDialogue]);
|
||||
setSimRoundIndex(prevIndex => prevIndex + 1);
|
||||
setAgentThinkingStatus(null);
|
||||
|
||||
// Smoothly increment consensus percentage up to 100%
|
||||
setConsensusPercentage(prev => {
|
||||
const stepInc = (100 - 89.4) / simulatedRoundPool.length;
|
||||
const updated = parseFloat((prev + stepInc).toFixed(1));
|
||||
return updated >= 100 ? 100 : updated;
|
||||
});
|
||||
|
||||
}, roundTableSpeed === "极速" ? 800 : 1800);
|
||||
|
||||
return () => clearTimeout(delayTimeout);
|
||||
} else {
|
||||
// Dialogue fully completed & stabilized
|
||||
setActiveSpeakerId(null);
|
||||
setRoles(prevRoles => prevRoles.map(item => {
|
||||
if (item.type === "agent" && item.status !== "已完成") {
|
||||
return { ...item, status: "已完成" };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
if (consensusPercentage < 100) {
|
||||
setConsensusPercentage(100);
|
||||
triggerToast("🎉 圆桌研讨论证会圆满落幕!已取得 100% 专家组共识,提请渲染成果方案");
|
||||
// Highlight a speaker bubble + flip the role's status label.
|
||||
const highlightSpeaker = useCallback((speakerId: number | null) => {
|
||||
setActiveSpeakerId(speakerId);
|
||||
setRoles(prev =>
|
||||
prev.map(item => {
|
||||
if (speakerId !== null && item.id === speakerId) {
|
||||
return { ...item, status: "研讨中" };
|
||||
}
|
||||
}
|
||||
}, intervalTime);
|
||||
if (item.status === "研讨中") {
|
||||
return { ...item, status: "在线" };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentStep, isRoundTableRunning, roundTableSpeed, simRoundIndex, consensusPercentage]);
|
||||
// Pull the consolidated user intent out of step-1 history. Falls back to a
|
||||
// generic prompt if the user never spoke.
|
||||
const buildInitialIntent = useCallback(() => {
|
||||
const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
|
||||
const userIntent = lastUserMessage?.content || "用户尚未给出明确意图";
|
||||
const constraints = `预算硬约束:${budgetLimit} 万元`;
|
||||
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||
}, [interactiveMessages, budgetLimit]);
|
||||
|
||||
// Run one full orchestration step: 1) leader call → 2) dispatch specials in
|
||||
// parallel → 3) recurse with a synthesis prompt until leader stops dispatching.
|
||||
const runOrchestration = useCallback(async (
|
||||
seedMessage: string,
|
||||
ids: ThreadIdMap,
|
||||
coord: string,
|
||||
) => {
|
||||
if (isOrchestrating) return;
|
||||
setIsOrchestrating(true);
|
||||
orchestrationCancelRef.current = false;
|
||||
|
||||
let nextLeaderTask = seedMessage;
|
||||
let safetyCounter = 0;
|
||||
const MAX_CYCLES = 8;
|
||||
|
||||
try {
|
||||
while (!orchestrationCancelRef.current && safetyCounter < MAX_CYCLES) {
|
||||
safetyCounter += 1;
|
||||
highlightSpeaker(2);
|
||||
appendDialogue({
|
||||
sender: "总控协调",
|
||||
avatarType: "blue",
|
||||
content: `🧭 [总控派活] 正在分析当前进展并决定下一步:\n${nextLeaderTask.slice(0, 140)}${nextLeaderTask.length > 140 ? "..." : ""}`,
|
||||
confidence: "调度中",
|
||||
});
|
||||
|
||||
const leaderRes = await streamMultiAgent({
|
||||
agentType: "leader",
|
||||
dAgentThreadId: ids,
|
||||
agentName: coord,
|
||||
newMessage: nextLeaderTask,
|
||||
model: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
if (orchestrationCancelRef.current) break;
|
||||
|
||||
if (leaderRes.content) {
|
||||
appendDialogue({
|
||||
sender: "总结陈述",
|
||||
avatarType: "blue",
|
||||
content: leaderRes.content,
|
||||
confidence: leaderRes.dispatched.length === 0 ? "全案盖印" : "派活说明",
|
||||
});
|
||||
}
|
||||
|
||||
if (leaderRes.dispatched.length === 0) {
|
||||
highlightSpeaker(null);
|
||||
setConsensusPercentage(100);
|
||||
triggerToast("🎉 圆桌研讨论证会圆满落幕!已取得 100% 专家组共识");
|
||||
break;
|
||||
}
|
||||
|
||||
// Dispatch all sub-agents in parallel; per-seat model preference
|
||||
// overrides the default if the user picked one in the drawer.
|
||||
await Promise.allSettled(
|
||||
leaderRes.dispatched.map(async ([subName, task]) => {
|
||||
if (orchestrationCancelRef.current) return;
|
||||
const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()];
|
||||
if (meta) highlightSpeaker(meta.speakerId);
|
||||
const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
|
||||
|
||||
const specRes = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
dAgentThreadId: ids,
|
||||
agentName: subName,
|
||||
newMessage: task,
|
||||
model: perSeatModel || DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
if (orchestrationCancelRef.current) return;
|
||||
appendDialogue({
|
||||
sender: meta?.displayName ?? subName,
|
||||
avatarType: meta?.avatarType ?? "blue",
|
||||
content: specRes.content || `(无文本输出)`,
|
||||
confidence: "子智能体交付",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (orchestrationCancelRef.current) break;
|
||||
|
||||
// Nudge consensus up after each completed round.
|
||||
setConsensusPercentage(prev => Math.min(95, parseFloat((prev + 8).toFixed(1))));
|
||||
|
||||
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
|
||||
}
|
||||
|
||||
if (safetyCounter >= MAX_CYCLES) {
|
||||
triggerToast("⏱️ 已达到最大研讨轮次,自动停止以避免循环");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("orchestration error:", err);
|
||||
triggerToast(`❌ 研讨异常: ${(err as Error).message}`);
|
||||
} finally {
|
||||
highlightSpeaker(null);
|
||||
setIsOrchestrating(false);
|
||||
}
|
||||
}, [appendDialogue, highlightSpeaker, isOrchestrating, agentConfigs]);
|
||||
|
||||
// On first entry to step 2: init the coordinator + sub-agent threads, then
|
||||
// fire the first leader cycle. Leaving step 2 resets so re-entering builds
|
||||
// a fresh coordinator (per spec).
|
||||
useEffect(() => {
|
||||
if (currentStep !== 2) {
|
||||
// tear down: cancel any in-flight orchestration; reset bookkeeping.
|
||||
orchestrationCancelRef.current = true;
|
||||
step2InitRef.current = false;
|
||||
setIsOrchestrating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (step2InitRef.current) return;
|
||||
step2InitRef.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setIsInitializing(true);
|
||||
setInitError(null);
|
||||
setStep2RoundtableDialogues([]);
|
||||
setConsensusPercentage(0);
|
||||
const coord = newCoordinatorName();
|
||||
try {
|
||||
const initRes = await initMultiAgent({
|
||||
agentNames: ROUNDTABLE_AGENT_IDS,
|
||||
mainAgentName: coord,
|
||||
model: DEFAULT_MODEL,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setThreadIds(initRes.thread_ids);
|
||||
setCoordinatorName(initRes.main_agent_name);
|
||||
appendDialogue({
|
||||
sender: "系统",
|
||||
avatarType: "blue",
|
||||
content: `✅ 圆桌已就绪:协调智能体 ${initRes.main_agent_name} 已上线,${Object.keys(initRes.thread_ids).length - 1} 个席位已分配独立线程。`,
|
||||
confidence: "系统就绪",
|
||||
});
|
||||
triggerToast("✅ 多智能体圆桌初始化完成");
|
||||
await runOrchestration(buildInitialIntent(), initRes.thread_ids, initRes.main_agent_name);
|
||||
} catch (err) {
|
||||
console.error("init multi-agent failed:", err);
|
||||
if (cancelled) return;
|
||||
setInitError((err as Error).message);
|
||||
triggerToast(`❌ 初始化失败: ${(err as Error).message}`);
|
||||
} finally {
|
||||
if (!cancelled) setIsInitializing(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
orchestrationCancelRef.current = true;
|
||||
};
|
||||
// We intentionally re-init only on `currentStep` transition.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep]);
|
||||
|
||||
// Pause/resume button — pausing cancels in-flight stream, resuming kicks off
|
||||
// another leader cycle to continue the conversation.
|
||||
useEffect(() => {
|
||||
if (currentStep !== 2) return;
|
||||
if (!isRoundTableRunning) {
|
||||
orchestrationCancelRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (isOrchestrating || isInitializing || !threadIds || !coordinatorName) return;
|
||||
// resumed and idle: nudge with a continuation prompt
|
||||
runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", threadIds, coordinatorName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRoundTableRunning, currentStep]);
|
||||
|
||||
// Adjust budget constraint dynamically
|
||||
const handleBudgetChange = (newVal: number) => {
|
||||
@@ -469,39 +585,32 @@ export default function RoundtablePlanningPage() {
|
||||
return () => clearTimeout(tid);
|
||||
};
|
||||
|
||||
// Save Agent config overrides and trigger recalculation
|
||||
// Save per-seat agent config overrides. We persist locally only — the next
|
||||
// time the orchestration loop dispatches to this seat it will read the
|
||||
// chosen model out of `agentConfigs` and pass it on the streaming call.
|
||||
const handleSaveAgentAndRecalculate = (id: number) => {
|
||||
const roleName = roles.find(r => r.id === id)?.name || "专家智能体";
|
||||
triggerToast(`⚙️ 已部署“${roleName}”个性化参数偏好!正在同步大模型提示词防线...`);
|
||||
const role = roles.find(r => r.id === id);
|
||||
const roleName = role?.name || "专家智能体";
|
||||
const cfg = agentConfigs[id];
|
||||
const targetModel = cfg?.model || DEFAULT_MODEL;
|
||||
|
||||
setTimeout(() => {
|
||||
const respTime = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
const currentConfig = agentConfigs[id];
|
||||
const modelShort = currentConfig?.model || "Gemini 2.5 Flash";
|
||||
const promptSnippet = currentConfig?.prompt.length > 22
|
||||
? currentConfig.prompt.substring(0, 22) + "..."
|
||||
: currentConfig?.prompt || "";
|
||||
const avatarColor =
|
||||
id === 2 ? "blue"
|
||||
: id === 3 ? "purple"
|
||||
: id === 4 ? "emerald"
|
||||
: id === 5 ? "human"
|
||||
: id === 6 ? "amber"
|
||||
: id === 7 ? "pink"
|
||||
: "blue";
|
||||
|
||||
let avatarColor = "blue";
|
||||
if (id === 2) avatarColor = "blue";
|
||||
else if (id === 3) avatarColor = "purple";
|
||||
else if (id === 4) avatarColor = "emerald";
|
||||
else if (id === 5) avatarColor = "human";
|
||||
else if (id === 6) avatarColor = "amber";
|
||||
else if (id === 7) avatarColor = "pink";
|
||||
|
||||
const recomputedResponse = {
|
||||
id: Date.now() + 20,
|
||||
sender: `${roleName} (${modelShort})`,
|
||||
avatarType: avatarColor,
|
||||
confidence: `个性化权重 ${currentConfig?.weight || 90}%`,
|
||||
content: `⚡ [提示词覆写激活] 独立节点已部署为温度 ${currentConfig?.temp} 指示。结合重新加载的 Persona 指令“${promptSnippet}”,我立即优化了博弈运算函数,当前可用预算重分配函数已广播给其他专家。`,
|
||||
time: respTime
|
||||
};
|
||||
|
||||
setStep2RoundtableDialogues(prev => [...prev, recomputedResponse]);
|
||||
setSelectedAgentId(null); // Close drawer
|
||||
}, 1200);
|
||||
appendDialogue({
|
||||
sender: `${roleName} (${targetModel})`,
|
||||
avatarType: avatarColor,
|
||||
content: `⚡ [模型偏好已保存] 本席位在下一轮研讨中将使用 ${targetModel} 进行推理。系统提示词与温度(${cfg?.temp ?? 0.2})当前仅在前端记录,等后端开放覆写后再同步生效。`,
|
||||
confidence: "席位偏好",
|
||||
});
|
||||
setSelectedAgentId(null);
|
||||
triggerToast(`⚙️ "${roleName}"已切换至 ${targetModel},下轮生效`);
|
||||
};
|
||||
|
||||
// Text area counter update
|
||||
@@ -590,73 +699,38 @@ export default function RoundtablePlanningPage() {
|
||||
// Step 2 role statuses transition when entering roundtable!
|
||||
useEffect(() => {
|
||||
if (currentStep === 1) {
|
||||
setRoles([
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human", status: "等待激活" },
|
||||
{ id: 2, name: "总结陈述", type: "agent", status: "在线" },
|
||||
{ id: 3, name: "情报收集", type: "agent", status: "等待激活" },
|
||||
{ id: 4, name: "环境评估", type: "agent", status: "等待激活" },
|
||||
{ id: 5, name: "方案设计", type: "human", status: "等待激活" },
|
||||
{ id: 6, name: "风险审查", type: "agent", status: "等待激活" },
|
||||
{ id: 7, name: "执行规划", type: "agent", status: "等待激活" },
|
||||
{ id: 8, name: "总结陈述", type: "agent", status: "等待陈述" },
|
||||
]);
|
||||
setRoles(makeRoles("等待激活", { 2: "在线" }));
|
||||
} else if (currentStep === 2) {
|
||||
setRoles([
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human", status: "在线" },
|
||||
{ id: 2, name: "总结陈述", type: "agent", status: "在线" },
|
||||
{ id: 3, name: "情报收集", type: "agent", status: "在线" },
|
||||
{ id: 4, name: "环境评估", type: "agent", status: "在线" },
|
||||
{ id: 5, name: "方案设计", type: "human", status: "在线" },
|
||||
{ id: 6, name: "风险审查", type: "agent", status: "在线" },
|
||||
{ id: 7, name: "执行规划", type: "agent", status: "在线" },
|
||||
{ id: 8, name: "总结陈述", type: "agent", status: "等待陈述" },
|
||||
]);
|
||||
setRoles(makeRoles("在线", { 8: "等待陈述" }));
|
||||
} else {
|
||||
setRoles([
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human", status: "已完成" },
|
||||
{ id: 2, name: "总结陈述", type: "agent", status: "已完成" },
|
||||
{ id: 3, name: "情报收集", type: "agent", status: "已完成" },
|
||||
{ id: 4, name: "环境评估", type: "agent", status: "已完成" },
|
||||
{ id: 5, name: "方案设计", type: "human", status: "已完成" },
|
||||
{ id: 6, name: "风险审查", type: "agent", status: "已完成" },
|
||||
{ id: 7, name: "执行规划", type: "agent", status: "已完成" },
|
||||
{ id: 8, name: "总结陈述", type: "agent", status: "已完成" },
|
||||
]);
|
||||
setRoles(makeRoles("已完成"));
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Inject user input to the roundtable room
|
||||
// Inject user input to the roundtable room — sends it as a new leader call.
|
||||
const handleInjectToRoundtable = () => {
|
||||
if (!customAgentInput.trim()) return;
|
||||
if (!threadIds || !coordinatorName) {
|
||||
triggerToast("⚠️ 圆桌尚未就绪,请稍候");
|
||||
return;
|
||||
}
|
||||
if (isOrchestrating) {
|
||||
triggerToast("⏳ 当前轮次仍在进行中,请等待或暂停后再发送");
|
||||
return;
|
||||
}
|
||||
|
||||
const responseTime = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
const userInjectedMessage = {
|
||||
id: Date.now(),
|
||||
const userText = customAgentInput.trim();
|
||||
setCustomAgentInput("");
|
||||
|
||||
appendDialogue({
|
||||
sender: "用户 (插话干预)",
|
||||
avatarType: "human",
|
||||
content: `💡 (人工指令介入) ${customAgentInput}`,
|
||||
time: responseTime,
|
||||
confidence: "最高指令"
|
||||
};
|
||||
content: `💡 (人工指令介入) ${userText}`,
|
||||
confidence: "最高指令",
|
||||
});
|
||||
triggerToast("您的人工纠偏指令已同步至圆桌节点!");
|
||||
|
||||
setStep2RoundtableDialogues(prev => [...prev, userInjectedMessage]);
|
||||
setCustomAgentInput("");
|
||||
triggerToast("您的人工纠偏指令已同步至圆桌节点!");
|
||||
|
||||
// Automatically trigger a response from the designated Agent
|
||||
setTimeout(() => {
|
||||
const respTime = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
const agentReplyLine = {
|
||||
id: Date.now() + 5,
|
||||
sender: "总结陈述智能体 (Gemini 2.5 Pro)",
|
||||
avatarType: "blue",
|
||||
content: `🤝 收到统筹人工指令!我已将您的“${userInjectedMessage.content.substring(13, 28)}...”微指令分解并广播映射至情报组、设计组和安全审查端,开始实时博弈重收敛计算。`,
|
||||
time: respTime,
|
||||
confidence: "全局统筹"
|
||||
};
|
||||
setStep2RoundtableDialogues(prev => [...prev, agentReplyLine]);
|
||||
triggerToast("研筹团队成功接收指令!");
|
||||
}, 1250);
|
||||
void runOrchestration(`用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`, threadIds, coordinatorName);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1650,8 +1724,8 @@ export default function RoundtablePlanningPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">推理本源模型</label>
|
||||
<select
|
||||
value={agentConfigs[selectedAgentId]?.model || "Gemini 2.5 Flash"}
|
||||
<select
|
||||
value={agentConfigs[selectedAgentId]?.model || DEFAULT_MODEL}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setAgentConfigs(prev => ({
|
||||
@@ -1663,9 +1737,18 @@ export default function RoundtablePlanningPage() {
|
||||
}));
|
||||
}}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs text-slate-700 font-bold focus:outline-none"
|
||||
disabled={modelsLoading}
|
||||
>
|
||||
<option value="Gemini 2.5 Pro">Gemini 2.5 Pro (深度分析型)</option>
|
||||
<option value="Gemini 2.5 Flash">Gemini 2.5 Flash (疾速自适应型)</option>
|
||||
{availableModels.length === 0 && (
|
||||
<option value={DEFAULT_MODEL}>{DEFAULT_MODEL}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -205,13 +205,21 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> dict[
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
agent_threads: dict[str, str] = {}
|
||||
descriptions: list[str] = []
|
||||
for sub_id in agent_names:
|
||||
# Optimization A: fetch every sub-agent's metadata and create its
|
||||
# thread concurrently. With 6 seats this turns ~12 sequential
|
||||
# loopback round-trips into one wave of 12 parallel ones.
|
||||
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str]:
|
||||
info = await _get_agent(client, base, headers, sub_id)
|
||||
sub_name = str(info["name"]).lower()
|
||||
agent_threads[sub_name] = await _create_thread(client, base, headers)
|
||||
descriptions.append(f"agent_name:{sub_name},{info.get('description', '')}")
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return sub_name, thread_id, info.get("description", "") or ""
|
||||
|
||||
prepared = await asyncio.gather(*(_prepare_sub_agent(sid) for sid in agent_names))
|
||||
|
||||
agent_threads: dict[str, str] = {sub_name: thread_id for sub_name, thread_id, _ in prepared}
|
||||
descriptions: list[str] = [
|
||||
f"agent_name:{sub_name},{description}" for sub_name, _, description in prepared
|
||||
]
|
||||
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
@@ -237,13 +245,10 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> dict[
|
||||
if not ok and isinstance(result, str) and "exists" not in result:
|
||||
raise HTTPException(status_code=500, detail=f"create coordinator agent failed: {result}")
|
||||
|
||||
intro = (
|
||||
"你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,"
|
||||
"总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活\n"
|
||||
"注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,"
|
||||
"你只关心于完成总控控制协调智能体分配的活即可"
|
||||
)
|
||||
await _broadcast(client, base, headers, intro, agent_threads, [main_agent_name])
|
||||
# Optimization C: the sub-agent SOULs already declare "你只在被点到时
|
||||
# 发言,由总控协调智能体派活", so the old intro broadcast (one human
|
||||
# message per thread, 6 × GET-state + 6 × POST-state) is redundant.
|
||||
# Skipping it removes the slowest stage of init entirely.
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -330,9 +335,10 @@ async def _leader_run(
|
||||
captured = done
|
||||
|
||||
last_msg = _parse_last_message(captured)
|
||||
leader_text = _flatten_content(last_msg.get("content", ""))
|
||||
tool_calls = last_msg.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
yield {"status": []}
|
||||
yield {"status": [], "content": leader_text, "agent_name": agent_name}
|
||||
return
|
||||
|
||||
dispatched: list[list[str]] = []
|
||||
@@ -352,7 +358,7 @@ async def _leader_run(
|
||||
dispatched.append([sub_name, task_text])
|
||||
except Exception as exc:
|
||||
logger.warning("dispatch failed: %s", exc)
|
||||
yield {"status": dispatched}
|
||||
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
|
||||
|
||||
|
||||
async def _special_run(
|
||||
@@ -392,7 +398,11 @@ async def _special_run(
|
||||
agent_threads,
|
||||
[agent_name],
|
||||
)
|
||||
yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
|
||||
yield {
|
||||
"status": f"子智能体{agent_name}完成{new_message}工作",
|
||||
"content": content,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/run/stream")
|
||||
|
||||
Reference in new issue
Block a user