From a85f1571a05c1fc4213294343c8c8b1550b15473 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Sat, 23 May 2026 10:49:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/AI_WRITING_BACKEND_DEV_ZH.md | 916 -------- .../docs/ai-writing-assistant-backend-api.md | 726 ------- ...ai-writing-assistant-frontend-checklist.md | 703 ------ .../docs/ai-writing-assistant-source-map.md | 457 ---- .../docs/ai-writing-assistant-upgrade.md | 1191 ----------- ...-writing-backend-orchestrator-blueprint.md | 645 ------ .../docs/ai-writing-final-execution-plan.md | 806 ------- frontend-web/docs/ai-writing-frontend-dev.md | 538 ----- .../ai-writing-frontend-refactor-checklist.md | 472 ---- .../ai-writing-weak-model-multi-agent-prd.md | 718 ------- frontend-web/docs/ai-writing-优化.md | 501 ----- frontend-web/docs/ai-writing-说明.md | 787 ------- frontend-web/docs/aixiezuo.md | 1896 ----------------- frontend-web/docs/multi-agent-backend-dev.md | 359 ++++ .../roundtable-planning/api/multi-agent.ts | 162 ++ .../pages/RoundtablePlanningPage.tsx | 637 +++--- .../app/gateway/routers/multi_agent.py | 40 +- 17 files changed, 906 insertions(+), 10648 deletions(-) delete mode 100644 frontend-web/docs/AI_WRITING_BACKEND_DEV_ZH.md delete mode 100644 frontend-web/docs/ai-writing-assistant-backend-api.md delete mode 100644 frontend-web/docs/ai-writing-assistant-frontend-checklist.md delete mode 100644 frontend-web/docs/ai-writing-assistant-source-map.md delete mode 100644 frontend-web/docs/ai-writing-assistant-upgrade.md delete mode 100644 frontend-web/docs/ai-writing-backend-orchestrator-blueprint.md delete mode 100644 frontend-web/docs/ai-writing-final-execution-plan.md delete mode 100644 frontend-web/docs/ai-writing-frontend-dev.md delete mode 100644 frontend-web/docs/ai-writing-frontend-refactor-checklist.md delete mode 100644 frontend-web/docs/ai-writing-weak-model-multi-agent-prd.md delete mode 100644 frontend-web/docs/ai-writing-优化.md delete mode 100644 frontend-web/docs/ai-writing-说明.md delete mode 100644 frontend-web/docs/aixiezuo.md create mode 100644 frontend-web/docs/multi-agent-backend-dev.md create mode 100644 frontend-web/src/roundtable-planning/api/multi-agent.ts diff --git a/frontend-web/docs/AI_WRITING_BACKEND_DEV_ZH.md b/frontend-web/docs/AI_WRITING_BACKEND_DEV_ZH.md deleted file mode 100644 index 296507f8..00000000 --- a/frontend-web/docs/AI_WRITING_BACKEND_DEV_ZH.md +++ /dev/null @@ -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` | 流式解析 `...` 标签(思考 / 正文分流) | -| `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": "", "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: \ndata: \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": ""}}) -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 = '' LIMIT 5; -``` - -### 查 MySQL 业务行 - -```sql -SELECT id, status, draft_title, updated_at, - JSON_LENGTH(transcript) AS transcript_size - FROM ai_writing_sessions - WHERE 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 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` 在 `` 标签边界处理 | 看 `_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 里注明改动原因。 diff --git a/frontend-web/docs/ai-writing-assistant-backend-api.md b/frontend-web/docs/ai-writing-assistant-backend-api.md deleted file mode 100644 index 4f8e00c8..00000000 --- a/frontend-web/docs/ai-writing-assistant-backend-api.md +++ /dev/null @@ -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 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。 diff --git a/frontend-web/docs/ai-writing-assistant-frontend-checklist.md b/frontend-web/docs/ai-writing-assistant-frontend-checklist.md deleted file mode 100644 index 3fd885ac..00000000 --- a/frontend-web/docs/ai-writing-assistant-frontend-checklist.md +++ /dev/null @@ -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. 最后做视觉打磨 - -这个顺序风险最小,也最容易在中途验收。 diff --git a/frontend-web/docs/ai-writing-assistant-source-map.md b/frontend-web/docs/ai-writing-assistant-source-map.md deleted file mode 100644 index 0dbbd7f6..00000000 --- a/frontend-web/docs/ai-writing-assistant-source-map.md +++ /dev/null @@ -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 现有路由、线程、模型体系做宿主 - -这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。 diff --git a/frontend-web/docs/ai-writing-assistant-upgrade.md b/frontend-web/docs/ai-writing-assistant-upgrade.md deleted file mode 100644 index a8ffcf1d..00000000 --- a/frontend-web/docs/ai-writing-assistant-upgrade.md +++ /dev/null @@ -1,1191 +0,0 @@ -# AI 写作助手右侧编辑器升级需求文档 - -## 1. 文档目标 - -本文档用于指导 DeerFlow 当前 AI 写作助手的第二阶段升级。 - -本次升级不推翻现有整体架构,不重做左侧聊天,不替换 LangGraph 后端,而是在现有 DeerFlow 基础上,把右侧编辑区升级为更接近豆包 AI 写作助手、Novel、open-canvas 风格的 AI 原生写作编辑器。 - -本次需求聚焦四类核心能力: - -- 选区浮层 -- 润色 / 扩写 / 缩写 / 改语气 -- 替换 / 插入 -- 标题 / 列表 / 引用 / 代码块工具条 - -本次交付目标不是“补几个按钮”,而是把右侧从“结果预览面板”升级成“可直接写、可选区改、可连续编辑、可 AI 辅助重写”的正式写作工作区。 - ---- - -## 2. 总体结论 - -### 2.1 保留不动的部分 - -- 保留现有左侧聊天区 -- 保留现有线程体系 -- 保留现有 LangGraph 后端 -- 保留现有模型配置与模型选择体系 -- 保留现有消息流与线程上下文 -- 保留现有“写作模式”总入口 - -### 2.2 本次替换和新增的部分 - -- 将右侧简易 Artifact 文本查看 / 编辑区升级为 `Tiptap` 富文本编辑器 -- 在右侧新增面向写作场景的 AI 选区改写工作流 -- 为右侧补齐写作工具栏、选区浮层、结果预览、替换 / 插入动作 -- 将当前“生成后查看”的体验改造成“生成、选中、局部重写、继续编辑”的闭环 - -### 2.3 技术选型结论 - -本次默认采用 `Tiptap` 作为右侧编辑器内核。 - -原因: - -- 更适合做选区级 AI 操作 -- 更适合做浮动菜单和块结构格式工具栏 -- 更适合多段落选区与结构化文本编辑 -- 更适合后续扩展 slash menu、批注、修订记录、评论等能力 -- 与 Novel 和 open-canvas 的交互借鉴路径更贴近 - -`Plate` 作为 UI 参考和交互参考保留,不作为本轮主内核。 - ---- - -## 3. 参考产品与参考源码 - -### 3.1 交互参考 - -- 豆包 AI 写作助手 -- Novel -- open-canvas - -### 3.2 借鉴重点 - -- 豆包:选区浮层、AI 改写入口、替换 / 插入、轻量化写作工具条、页面留白和排版气质 -- Novel:文档编辑器产品感、工具条组织、编辑容器、页面视觉层级 -- open-canvas:AI 改写流程、候选内容应用流程、编辑器与聊天协同思路 - -### 3.3 明确不直接照搬的部分 - -- 不直接复制 open-canvas 的 Next.js 页面结构 -- 不直接复制 Novel 的服务端实现 -- 不直接引入整套 Plate 生态替代现有编辑区 - ---- - -## 4. 当前系统现状 - -结合当前代码结构,和本需求直接相关的现状如下: - -### 4.1 已有接入点 - -- `frontend-web/src/pages/PageRoutes.tsx` - - 当前已引入 `@/open-canvas/routes/OpenCanvasRoutes` - - 说明项目已经有独立 Canvas 路由接入能力 - -- `frontend-web/src/components/workspace/chats/chat-box.tsx` - - 当前左聊天 + 右 Artifact 面板是双栏布局 - - 当前右侧仍然偏 Artifact 文件查看 / 编辑 - -- `frontend-web/src/components/workspace/artifacts/context.tsx` - - 当前 Artifact 上下文仍以 `string[]` 文件路径为主 - - 不适合直接承载“选区级写作编辑器状态” - -- `frontend-web/src/components/workspace/input-box.tsx` - - 已经存在 `writing_mode` - - 可以作为本次写作助手模式切换的入口保留 - -### 4.2 当前不足 - -- 右侧编辑区更像文件预览器,不像完整写作编辑器 -- 缺少选区工具条 -- 缺少多段落改写能力 -- 缺少 AI 改写候选应用工作流 -- 缺少标题 / 列表 / 引用 / 代码块等正式文档工具 -- 页面视觉上仍偏“工程面板”,产品完成度不足 - ---- - -## 5. 本次需求范围 - -### 5.1 本次必须实现 - -- 基于 `Tiptap` 的右侧富文本编辑器 -- 选区浮层 -- AI 改写动作菜单 -- 润色 / 扩写 / 缩写 / 改语气 -- 改语气二级选项 -- 改写结果预览 -- 替换 / 插入 -- 标题 / 列表 / 引用 / 代码块工具条 -- UI 美化与交互统一 -- 多段落选区改写 - -### 5.2 本次不做 - -- 多人协作 -- 评论系统 -- 修订模式 -- 文档历史对比 -- 图片、表格、附件编辑器 -- 复杂导入导出 -- 全量 Notion 式块拖拽系统 -- slash command - -### 5.3 下阶段可扩展 - -- slash command -- 改写历史 -- 修订轨迹 -- 边写边建议 -- 文风模板 -- 批注与审阅 - ---- - -## 6. 产品目标 - -升级完成后,用户应获得以下体验: - -- 在左侧继续正常聊天 -- 在右侧像正式文档编辑器一样直接书写和改写 -- 选中一句、一段、多段内容后,能立即触发 AI 改写 -- AI 返回结果后,用户能决定“替换原文”还是“插入到后面” -- 页面视觉上明显从“开发态”升级为“产品态” -- 对长文写作、改稿、润色、扩写更友好 - ---- - -## 7. 页面与交互结构 - -### 7.1 整体布局 - -继续保留双栏布局: - -- 左侧:聊天区 -- 右侧:AI 写作编辑器 - -### 7.2 右侧编辑器结构 - -建议右侧拆分为以下区域: - -1. 文档头部区 - - 文档标题 - - 文档状态 - - 可能的保存提示 - -2. 主工具栏 - - 正文 - - 一级标题 - - 二级标题 - - 三级标题 - - 有序列表 - - 无序列表 - - 引用 - - 代码块 - - 加粗 - - 斜体 - - 下划线 - - 删除线 - -3. 编辑正文区 - - 基于 Tiptap 的可编辑正文 - -4. 选区浮层 - - AI 改写 - - 加粗 - - 斜体 - - 标题 - - 复制 - -5. AI 改写动作菜单 - - 润色 - - 扩写 - - 缩写 - - 改语气 - -6. 改写结果预览区 - - 预览文本 - - 替换 - - 插入 - - 重试 - - 取消 - -### 7.3 目标交互闭环 - -1. 用户在左侧发起写作任务 -2. 右侧生成初稿或打开写作画布 -3. 用户在右侧直接编辑 -4. 用户选中一句或多段 -5. 弹出浮层 -6. 点击 `AI 改写` -7. 选择 `润色 / 扩写 / 缩写 / 改语气` -8. 看到候选结果 -9. 选择 `替换` 或 `插入` -10. 继续写、继续改、继续和左侧聊天协同 - ---- - -## 8. 核心能力详细定义 - -## 8.1 能力一:选区浮层 - -### 目标 - -用户在右侧选中文本后,出现跟随选区的浮层工具条。 - -### 首版必须包含的按钮 - -- AI 改写 -- 加粗 -- 斜体 -- 标题 -- 复制 - -### 行为要求 - -- 只有在有效文本选区存在时显示 -- 选区取消后自动消失 -- 滚动时位置跟随更新 -- 只对右侧编辑器正文区域生效 -- 对跨多个段落的纯文本选区生效 - -### 首版限制 - -- 首版只处理文本选区 -- 不处理跨复杂嵌套节点的高级结构变更 - -### 验收标准 - -- 选中单段文本时能稳定出现 -- 选中连续多段文本时能稳定出现 -- 不遮挡正文主要阅读区域 - ---- - -## 8.2 能力二:AI 改写动作 - -### 目标 - -用户选中内容后,可快速对该内容发起 AI 重写。 - -### 一级动作 - -- 润色 -- 扩写 -- 缩写 -- 改语气 - -### 改语气二级选项 - -- 更专业 -- 更口语化 -- 更易读 -- 更学术 -- 更有文采 - -### 行为要求 - -- 改写动作基于当前选区触发 -- 支持单段落与多段落 -- 支持带少量前后文一起发给后端 -- 改写期间有明显 loading 态 -- 改写失败要有失败提示和重试入口 - -### 提示词策略 - -首版不开放自定义 prompt,只提供预设动作。 - -### 验收标准 - -- 四类一级动作可正常使用 -- 改语气二级菜单可正常展开和触发 -- 多段落改写不会破坏原文结构边界 - ---- - -## 8.3 能力三:替换 / 插入 - -### 目标 - -AI 返回候选内容后,不直接覆盖原文,而是先让用户决策如何应用。 - -### 两种应用方式 - -- 替换:用候选结果替换当前选区 -- 插入:把候选结果插入到选区后面,保留原文 - -### 行为要求 - -- AI 返回结果后先展示预览 -- 默认不自动落文档 -- 支持重试 -- 支持取消 -- 应用动作完成后,编辑器内容与光标位置要合理 - -### 首版简化 - -- 不做复杂 diff 对比 -- 先做“原文 + 候选文”的简单预览卡片 - -### 验收标准 - -- 替换位置准确 -- 插入位置准确 -- 应用后编辑器不丢焦点或出现异常闪动 - ---- - -## 8.4 能力四:主工具栏 - -### 目标 - -让右侧不再只是 AI 输出区,而是正式写作编辑器。 - -### 首版必须支持的结构 - -- 正文 -- 一级标题 -- 二级标题 -- 三级标题 -- 有序列表 -- 无序列表 -- 引用 -- 代码块 - -### 首版必须支持的行内样式 - -- 加粗 -- 斜体 -- 下划线 -- 删除线 - -### 行为要求 - -- 工具条按钮有选中态 -- 光标所在块能正确反映当前结构 -- 对选区和当前块的作用逻辑明确 -- 工具栏与选区浮层不打架 - -### 验收标准 - -- 标题和正文可互相切换 -- 列表、引用、代码块可稳定切换 -- 行内样式不影响 AI 选区改写链路 - ---- - -## 9. 编辑器数据模型 - -### 9.1 前端主状态 - -建议前端编辑器使用 Tiptap JSON 作为内部主状态。 - -建议定义: - -```ts -type WritingDocumentState = { - documentId?: string; - title: string; - markdown: string; - editorJson?: Record; - updatedAt?: string; -}; -``` - -### 9.2 选区状态 - -```ts -type WritingSelectionState = { - from: number; - to: number; - text: string; - isCollapsed: boolean; - blockTypes?: string[]; -}; -``` - -### 9.3 改写动作 - -```ts -type RewriteAction = - | "polish" - | "expand" - | "shorten" - | "tone"; -``` - -### 9.4 改语气风格 - -```ts -type RewriteTone = - | "professional" - | "casual" - | "readable" - | "academic" - | "expressive"; -``` - -### 9.5 改写请求 - -```ts -type RewriteRequest = { - threadId: string; - documentId?: string; - selection: { - from: number; - to: number; - text: string; - }; - action: RewriteAction; - tone?: RewriteTone; - mode?: "preview"; - surroundingContext?: { - before?: string; - after?: string; - }; -}; -``` - -### 9.6 改写响应 - -```ts -type RewriteResponse = { - output: string; - originalSelection: { - from: number; - to: number; - text: string; - }; - metadata?: { - action: RewriteAction; - tone?: RewriteTone; - model?: string; - }; -}; -``` - ---- - -## 10. 与现有 DeerFlow 代码结构的对应关系 - -## 10.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 - - 新写作画布建议单独维护自己的 context - -## 10.2 不建议继续复用的方式 - -- 不建议把 Tiptap 状态直接塞进现有 `string[] artifacts` -- 不建议继续把右侧主体验建立在“文件详情页”上 -- 不建议把 AI 改写做成单纯聊天消息回传后人工复制 - ---- - -## 11. 前端目录建议 - -建议在 `frontend-web/src` 下新增独立模块目录: - -```text -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 - 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/ - markdown.ts - apply-rewrite.ts - editor-style.ts -``` - ---- - -## 12. 路由与页面接入要求 - -### 12.1 路由策略 - -建议在现有 `PageRoutes` 体系下增加独立写作工作区页面,而不是塞进已有普通 artifact 面板。 - -### 12.2 接入方式 - -建议新增: - -- `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` -- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` - -然后在: - -- `frontend-web/src/pages/PageRoutes.tsx` - -中引入新的写作助手路由,例如: - -- `page/writing/*` -- 或合并到现有 `page/canvas/*` 下的子路由 - -### 12.3 路由设计建议 - -推荐: - -- `page/writing` - - 新建写作工作区 - -- `page/writing/:threadId` - - 进入某个线程对应写作工作区 - -如果你们已经有 `open-canvas` 路由,则建议不要把“豆包式写作编辑器”塞进原实验性画布页面里,而是作为独立子模式接入,避免后续页面职责混乱。 - ---- - -## 13. 前端实施拆解 - -## 13.1 模块 A:编辑器内核接入 - -### 目标 - -让右侧拥有正式可编辑文档能力。 - -### 任务 - -- 安装 Tiptap 基础依赖 -- 接入 `StarterKit` -- 接入 `Underline` -- 配置支持: - - paragraph - - heading - - bulletList - - orderedList - - blockquote - - codeBlock - - bold - - italic - - underline - - strike - - code -- 建立受控 / 半受控编辑器状态 -- 实现 Markdown 与 Tiptap JSON 的最小同步策略 - -### 输出物 - -- `TiptapEditor.tsx` -- `TiptapExtensions.ts` -- 基础编辑器容器样式 - -### 依赖关系 - -- 无前置依赖,可先开工 - -### 验收标准 - -- 右侧可以像普通文档编辑器一样输入和编辑内容 - -## 13.2 模块 B:主工具栏 - -### 目标 - -补齐写作编辑基础能力。 - -### 任务 - -- 实现标题切换 -- 实现列表切换 -- 实现引用切换 -- 实现代码块切换 -- 实现加粗 / 斜体 / 下划线 / 删除线 -- 工具按钮高亮与禁用态 -- 光标所在块状态识别 - -### 输出物 - -- `WritingToolbar.tsx` -- `useWritingToolbar.ts` - -### 依赖关系 - -- 依赖模块 A - -### 验收标准 - -- 常用写作结构和行内样式均可稳定切换 - -## 13.3 模块 C:选区浮层 - -### 目标 - -选中文本后出现轻量化操作浮层。 - -### 任务 - -- 监听 Tiptap selection -- 接入 BubbleMenu -- 处理显示 / 隐藏逻辑 -- 布局 AI 改写、加粗、斜体、标题、复制 -- 处理滚动与定位稳定性 - -### 输出物 - -- `WritingBubbleMenu.tsx` -- `useWritingSelection.ts` -- `selection.ts` - -### 依赖关系 - -- 依赖模块 A - -### 验收标准 - -- 选区浮层在单段和多段文本上都稳定可用 - -## 13.4 模块 D:AI 改写菜单 - -### 目标 - -把 AI 改写入口真正挂进选区工作流。 - -### 任务 - -- 实现 `AI 改写` 一级入口 -- 实现动作菜单 -- 实现改语气二级菜单 -- 封装改写请求 API -- 建立 loading / success / error 状态管理 - -### 输出物 - -- `WritingAiMenu.tsx` -- `useWritingRewrite.ts` -- `api/rewrite.ts` - -### 依赖关系 - -- 依赖模块 C -- 与后端接口联调 - -### 验收标准 - -- 四类改写动作都能从选区直接触发 - -## 13.5 模块 E:候选结果预览与应用 - -### 目标 - -将 AI 改写从“直接灌入文本”升级为“预览后应用”。 - -### 任务 - -- 设计候选结果展示卡 -- 展示原文选区与候选结果 -- 提供: - - 替换 - - 插入 - - 重试 - - 取消 -- 将候选内容通过 Tiptap transaction 应用到文档 - -### 输出物 - -- `WritingRewritePreview.tsx` -- `apply-rewrite.ts` - -### 依赖关系 - -- 依赖模块 D - -### 验收标准 - -- 替换和插入都能准确写回编辑器 - -## 13.6 模块 F:页面美化 - -### 目标 - -把页面从“工程面板”升级为“成熟产品页面”。 - -### 任务 - -- 调整双栏比例和留白 -- 优化右侧容器背景、阴影、边框、圆角 -- 优化标题层级、正文字号、行高、段距 -- 优化浮层和工具条视觉层级 -- 优化 loading、hover、active 动效 -- 对移动端和窄屏做降级策略 - -### 输出物 - -- `WritingShell.tsx` -- `WritingHeader.tsx` -- `WritingStatusBar.tsx` -- 相关样式变量 - -### 依赖关系 - -- 可与其他模块并行推进 - -### 验收标准 - -- 整体视觉明显接近正式写作产品 - ---- - -## 14. 后端实施拆解 - -## 14.1 总原则 - -后端保留现有 LangGraph 和线程架构,本轮只增加“选区改写型接口 / 节点 / 工具”,不重构整套后端。 - -## 14.2 后端最小职责 - -后端负责: - -- 接收选区文本 -- 接收动作类型 -- 补足必要上下文 -- 调用模型完成改写 -- 返回候选文本 - -后端首版不负责: - -- 直接操作 Tiptap JSON -- 直接做文档 patch -- 直接做复杂结构变换 - -## 14.3 推荐新增能力 - -- `rewrite_selection` -- `expand_selection` -- `shorten_selection` -- `change_tone` - -### 统一接口建议 - -更推荐做成一个统一接口: - -```text -POST /api/writing/rewrite -``` - -由请求体中的 `action` 和 `tone` 控制行为。 - -### 请求建议 - -- `threadId` -- `documentId` -- `selection.text` -- `selection.from` -- `selection.to` -- `action` -- `tone` -- `surroundingContext.before` -- `surroundingContext.after` - -### 返回建议 - -- `output` -- `metadata.action` -- `metadata.tone` -- `metadata.model` - -## 14.4 后端提示词策略 - -首版建议固定模板: - -- 润色:保持原意,提高表达质量 -- 扩写:在不偏离主题的前提下扩展细节 -- 缩写:压缩表达但保留关键信息 -- 改语气:保持事实不变,调整语言风格 - -统一要求: - -- 尽量不要引入原文没有的新事实 -- 不输出解释性前缀 -- 直接返回可落文档的正文 - -## 14.5 后端验收标准 - -- 单段落改写稳定 -- 多段落改写稳定 -- 返回结果不夹带解释性废话 -- 失败时能返回可识别错误 - ---- - -## 15. 前后端协议约束 - -### 15.1 首版协议原则 - -- 前端负责编辑器结构和最终写回 -- 后端负责文本重写 -- 前后端暂时通过“纯文本选区 + 上下文”交互 - -### 15.2 为什么不直接传富文本 JSON - -因为首版最大的目标是稳定闭环,而不是复杂结构编辑。 - -如果一开始就让后端理解完整 Tiptap JSON,会显著增加: - -- 协议复杂度 -- 结构错位风险 -- 测试成本 -- 联调成本 - -因此首版推荐: - -- 编辑器内部状态:Tiptap JSON -- 改写协议:纯文本 + 前后文 -- 最终应用:前端 transaction - ---- - -## 16. 分阶段实施计划 - -## 阶段 1:编辑器底座 - -### 目标 - -让右侧具备正式写作编辑能力。 - -### 包含内容 - -- Tiptap 接入 -- 标题 / 列表 / 引用 / 代码块 -- 行内样式 -- 初步页面容器 - -### 验收 - -- 右侧能独立承担写作编辑 - -## 阶段 2:选区浮层 - -### 目标 - -把“选中文本后操作”打通。 - -### 包含内容 - -- BubbleMenu -- AI 改写入口 -- 基础样式按钮 - -### 验收 - -- 单段和多段选区都可稳定触发 - -## 阶段 3:AI 改写 - -### 目标 - -把改写请求与后端串起来。 - -### 包含内容 - -- 润色 -- 扩写 -- 缩写 -- 改语气 - -### 验收 - -- 四类动作都能返回结果 - -## 阶段 4:替换 / 插入 - -### 目标 - -把候选结果稳定应用到文档。 - -### 包含内容 - -- 结果预览 -- 替换 -- 插入 -- 重试 -- 取消 - -### 验收 - -- 改写结果可控落文档 - -## 阶段 5:UI 打磨 - -### 目标 - -让右侧从“能用”升级到“成熟产品感”。 - -### 包含内容 - -- 容器和留白 -- 工具栏设计 -- 浮层风格 -- 字体和排版 -- 动效 - -### 验收 - -- 视觉上明显接近豆包 / Novel 风格 - ---- - -## 17. 可并行分工建议 - -## 17.1 前端 A 组:编辑器底座 - -负责: - -- Tiptap 接入 -- 主工具栏 -- 结构节点与 marks - -对应阶段: - -- 阶段 1 - -## 17.2 前端 B 组:选区交互 - -负责: - -- 选区监听 -- BubbleMenu -- AI 改写入口 - -对应阶段: - -- 阶段 2 - -## 17.3 前端 C 组:候选结果与体验层 - -负责: - -- 改写结果预览 -- 替换 / 插入 -- loading / error / retry -- UI 打磨 - -对应阶段: - -- 阶段 4 -- 阶段 5 - -## 17.4 后端 D 组:改写服务 - -负责: - -- 统一改写接口 -- LangGraph 节点或工具 -- 提示词 -- 上下文拼接 - -对应阶段: - -- 阶段 3 - -## 17.5 联调负责人 - -负责: - -- 协议定稿 -- 错误码和异常约定 -- 多段落选区联调 -- 光标位置 / 替换位置问题兜底 - ---- - -## 18. UI 设计要求 - -### 18.1 风格关键词 - -- 轻 -- 干净 -- 编辑器优先 -- AI 能力明显但不喧宾夺主 -- 有留白 -- 有产品感 - -### 18.2 页面视觉要求 - -- 右侧阅读宽度适中,不可过宽 -- 主文内容器有明确层级 -- 正文行高偏舒展 -- 标题有清晰层级 -- 工具栏不应过厚重 -- 浮层必须轻、准、快 - -### 18.3 动效要求 - -- 浮层出现和消失有轻微过渡 -- AI 改写请求有反馈 -- 替换 / 插入确认有轻量状态切换 - -### 18.4 参考视觉方向 - -- 布局参考 Novel -- 选区和改写交互参考豆包 -- AI 工作流节奏参考 open-canvas - ---- - -## 19. 风险与规避方案 - -## 19.1 风险一:现有 Artifact 架构不适合承载写作编辑器 - -### 规避 - -- 不强塞到 `string[] artifacts` -- 新建独立写作模块和上下文 - -## 19.2 风险二:富文本与 Markdown 同步复杂 - -### 规避 - -- 首版以 Tiptap 内部状态为准 -- 对外只做最小 Markdown 输出 - -## 19.3 风险三:多段落改写后替换位置错乱 - -### 规避 - -- 前端记录稳定选区范围 -- 候选结果应用时使用 Tiptap transaction -- 首版优先支持连续纯文本段落 - -## 19.4 风险四:AI 返回啰嗦解释文本 - -### 规避 - -- 后端提示词严格约束只返回正文 -- 前端必要时做轻量清洗 - -## 19.5 风险五:UI 做成“功能堆砌” - -### 规避 - -- 首版只上最核心 4 类能力 -- 不把太多低频按钮塞进第一层工具栏 - ---- - -## 20. 验收标准 - -## 20.1 功能验收 - -- 右侧可直接编辑文档 -- 支持标题 / 列表 / 引用 / 代码块 -- 选中内容后有浮层 -- 支持润色 / 扩写 / 缩写 / 改语气 -- 支持替换 / 插入 -- 支持多段落选区改写 - -## 20.2 交互验收 - -- 浮层位置稳定 -- AI 改写链路顺畅 -- 替换 / 插入后位置正确 -- 不影响左侧聊天流 - -## 20.3 体验验收 - -- 右侧明显比当前版本更成熟 -- 页面更适合长文阅读和改稿 -- 整体交互接近豆包 AI 写作助手 - ---- - -## 21. 首版最小可交付范围 - -如果需要控制风险,建议首版只交付: - -- Tiptap 接入 -- 主工具栏 -- 选区浮层 -- 润色 / 扩写 / 缩写 / 改语气 -- 替换 / 插入 -- 基础 UI 美化 - -以下能力不要强行塞进首版: - -- 自定义提示词 -- slash command -- 修订模式 -- 评论系统 -- 复杂 diff 对比 -- 文档历史版本 - ---- - -## 22. 实施优先级 - -### P0 - -- Tiptap 接入 -- 主工具栏 -- 选区浮层 -- AI 改写接口 -- 替换 / 插入 - -### P1 - -- 页面整体美化 -- 改语气二级菜单 -- 多段落改写稳定性 -- 错误态 / 重试态 - -### P2 - -- 文档状态提示 -- 轻量空态 -- 细节动画 -- 交互易用性优化 - ---- - -## 23. 结论 - -这次升级最合理的路线,不是继续在现有右侧 Artifact 详情页上小修小补,而是正式引入一层写作编辑器能力。 - -最稳妥的做法是: - -- 保留左侧聊天和现有 LangGraph 后端 -- 在右侧引入 `Tiptap` -- 先把四个核心能力做深做稳 -- UI 借鉴豆包、Novel、open-canvas 的成熟交互 - -只要按照本文档分阶段推进,这个需求虽然大,但完全可以拆成多组并行、逐步验收、持续落地的工程任务,而不是一次性大爆炸改造。 diff --git a/frontend-web/docs/ai-writing-backend-orchestrator-blueprint.md b/frontend-web/docs/ai-writing-backend-orchestrator-blueprint.md deleted file mode 100644 index d44e3f78..00000000 --- a/frontend-web/docs/ai-writing-backend-orchestrator-blueprint.md +++ /dev/null @@ -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。 diff --git a/frontend-web/docs/ai-writing-final-execution-plan.md b/frontend-web/docs/ai-writing-final-execution-plan.md deleted file mode 100644 index cd8805cd..00000000 --- a/frontend-web/docs/ai-writing-final-execution-plan.md +++ /dev/null @@ -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,把研究、提纲、初稿、审稿重写这条最小闭环做稳。” diff --git a/frontend-web/docs/ai-writing-frontend-dev.md b/frontend-web/docs/ai-writing-frontend-dev.md deleted file mode 100644 index 42c32076..00000000 --- a/frontend-web/docs/ai-writing-frontend-dev.md +++ /dev/null @@ -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 - -// 订阅 SSE 进度流 → 返回取消订阅函数 -export function subscribeToProgress( - sessionId: string, - onEvent: (event: ProgressEvent) => void, -): () => void - -// 提交用户干预 → 让 graph 续推 -export async function resumeWriting( - sessionId: string, - payload: UserInterventionPayload, -): Promise -``` - -**关键实现细节**: -- **snake_case ↔ camelCase 转换**:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(`normalizeMaterial`、`normalizeOutlineSection`...)集中在这一层做转换 -- **SSE 事件白名单**:`SSE_EVENT_TYPES` 数组列出所有合法事件名,未知事件会被静默忽略 -- **取消订阅**:`subscribeToProgress` 返回的函数会调用 `AbortController.abort()` 关闭 fetch stream -- **重连机制**:当前**没有内置重连** — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面 - - 后端 `_recover_session` 能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑 - -### 2. `api/ai-writing-sessions.ts` — 历史会话 - -```typescript -// CRUD -listAIWritingSessions() : Promise -getAIWritingSession(id) : Promise -renameAIWritingSession(id, title) : Promise -deleteAIWritingSession(id) : Promise - -// 时间线持久化(fire-and-forget,永不抛错) -saveAIWritingTranscript(id, transcript) : Promise -``` - -**关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。 - -### 3. `api/ai-writing-article-types.ts` — 文章类型管理 - -| 函数 | 用途 | -|---|---| -| `listArticleTypes()` | 表单下拉框选项 | -| `createArticleType()` | 管理员新增 | -| `updateArticleType()` | 管理员编辑 | -| `deleteArticleType()` | 管理员删除 | - ---- - -## 六、组件树详解 - -### 顶层:`AIWritingPanel`(457 行,最大) - -负责: -1. 调用 `useAIWriting()` 拿主状态 -2. 调用 `useAIWritingSessions()` 拿历史列表 -3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步 -4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容 -5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧 -6. **历史会话回显**:当 `historySession` 非空时,渲染 `AIWritingHistoryView` 而不是实时面板 - -### 干预卡:`InterventionCard`(663 行,第二大) - -按 `pausePoint` 渲染**不同的卡片**: - -| pausePoint | 卡片 | 用户能做的 | -|---|---|---| -| `material_confirm` | `MaterialsCard` | 勾选保留素材 / 加关键词重新搜 | -| `outline_confirm` | `OutlineCard` | 直接编辑大纲 / 给反馈让重写 | -| `draft_confirm` | 草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 | -| `review_confirm` | `ReviewCard` | 勾选采纳的意见 / 强制通过 | -| `section_help` | 求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 | - -### 时间线:`AIWritingTimeline` - -迭代 `progressEvents`,每个 `step_start` / `step_done` / `notice` 渲染成一条 `TimelineStep`。`CompletedInterventionCard` 穿插显示已完成的干预记录。 - -### 流式视图:`{Outline,Draft,Review}StreamingView` - -- **OutlineStreamingView**:打字机渲染 `outlineStreamingText`,支持 `...` 折叠显示 -- **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机 -- **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分 - -### 历史回显:`AIWritingHistoryView` - -只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。 - ---- - -## 七、SSE 事件协议(前后端契约) - -后端通过 SSE 推 `event: \ndata: \n\n` 格式,前端用浏览器原生 `fetch + ReadableStream` 解析(不用 `EventSource`,因为 EventSource 不能自定义 header)。 - -### 解析流程 - -``` -ReadableStream → TextDecoder - → 按 "\n\n" 分帧 - → 每帧匹配 /^event: (.+)\ndata: (.+)$/ - → JSON.parse(data) - → normalize(parsed)(snake → camel) - → onEvent(normalized) -``` - -### 事件载荷字段(不完全列表) - -| 字段 | 出现在哪些事件 | 用途 | -|---|---|---| -| `type` | 全部 | 事件类别 | -| `sessionId` | 全部 | 标识 | -| `message` | 大多数 | 显示给用户的文案 | -| `agentName` | step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 | -| `materials` | materials_ready | 素材数组 | -| `outline` | outline_ready | 大纲对象 | -| `draftMarkdown` / `draftTitle` | draft_ready | 草稿内容 | -| `reviewResult` | review_ready | 完整审核结果 | -| `pausePoint` | await_user | 哪个干预点 | -| `pauseContext` | await_user | 干预所需的上下文(如待确认的素材列表) | -| `blockedSections` | await_user (section_help) | 素材不足的章节列表 | -| `title` / `detail` / `raw` | notice / error | 非致命告警的标题、详情、原始错误 | - ---- - -## 八、扩展指南:常见开发任务 - -### 新增一个干预点 - -1. **前端类型扩展**: - - 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case - - 在 `WritingStatus` 加对应的 `awaiting_xxx` - - 在 `CompletedIntervention.pausePoint` 加 case - -2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑 - -3. **后端配合**(详见后端开发文档): - - `graph.py` 加 `pause_xxx_confirm` 节点 - - graph 拓扑加这个节点的边 - -4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到 - -### 新增一个 SSE 事件类型 - -1. `types/ai-writing.ts`: - - `ProgressEventType` 联合类型加新值 - - `ProgressEvent` 接口加新字段(如果有载荷) - -2. `api/ai-writing.ts`: - - `SSE_EVENT_TYPES` 数组加新值(不加会被忽略) - - `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize) - -3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新 - -4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染 - -### 修改一个流式打字机的行为 - -每个 streaming view 内部都是独立的: -- 状态在 `useAIWriting.ts` 的 `streaming*` 系列字段 -- 渲染在 `*StreamingView.tsx` -- 流式 chunk 处理在 `handleEvent` 的 `outline_chunk` / `draft_chunk` / `review_chunk` 分支 - -### 新增草稿到右侧画布的逻辑 - -`AIWritingPanel.tsx` 里 `useEffect` 监听 `state.currentDraftMarkdown` → 调 `setArtifact` 更新右侧 `CanvasGraphContext`。如果需要新的 artifact 类型,看 `contexts/CanvasGraphContext.tsx` 的 artifact 接口。 - ---- - -## 九、容错与边界处理 - -### 已落实的容错(不会阻断写作) -- ✅ `saveAIWritingTranscript` 失败仅 `console.warn` -- ✅ 历史会话列表加载失败仅 `console.warn` -- ✅ 重命名/删除会话失败静默吞掉 -- ✅ 后端 SSE 推 `notice` / `error` 事件时,前端只渲染不中断 -- ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧) - -### 已知边界(可能影响 UI 体验) -- ⚠️ **SSE 断线无自动重连** — 网络抖动 / 反代超时后用户需要手动刷新 -- ⚠️ **多 worker 部署下 SSE 事件丢失**(详见 `ai-writing-说明.md`)—— 前端只能感知到「卡住」,没有重连恢复机制 -- ⚠️ `outline_chunk` / `draft_chunk` / `review_chunk` 的 `text` / `is_thinking` 字段是 snake_case,前端类型是 `text` / `isThinking` 但 normalize 时是**保留 snake_case 直接读**(参考 `useAIWriting.ts` 的 `(event as unknown as { text: string }).text`),这是历史包袱,未来 normalize 函数应统一处理 - ---- - -## 十、调试技巧 - -### 看 SSE 原始事件 -浏览器 DevTools → Network → 找 `/api/ai-writing//stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。 - -### 查当前 hook 状态 -在浏览器 console 里: -```js -// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel -// 或在 useAIWriting 里临时加: -useEffect(() => { (window as any).__aiwriting = state; }, [state]); -// 然后在 console 里 __aiwriting 看 -``` - -### 模拟事件 -开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。 - -### 看后端 transcript -``` -GET /api/ai-writing/sessions/ -``` -返回里 `transcript` 字段就是前端组装过的完整时间线 JSON。 - ---- - -## 十一、性能注意点 - -### 大数组渲染 -- `progressEvents` 在长写作里可能上千条 → `AIWritingTimeline` 应该考虑虚拟滚动(当前未实现,会议室会议讨论过) -- `streamingDraftSections` 章节多时也是潜在瓶颈 - -### transcript PUT 频率 -- 当前每次 `progressEvents` 变化都会触发保存(实际上是 useEffect 监听) -- 大量 chunk 事件会导致**每秒 N 次 PUT**,后端 MySQL 写压力大 -- **优化建议**:在 `useAIWriting.ts` 给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在 `await_user` / `done` / `error` 关键节点保存 - -### 浏览器内存 -- progressEvents 不主动清理 → 长会话可能占用几十 MB -- 完成一篇文章后建议 `reset()` 清空状态 - ---- - -## 十二、相关后端接口(速查) - -| 接口 | 用途 | 前端调用方 | -|---|---|---| -| `POST /api/ai-writing/start` | 启动写作 | `startWritingSession` | -| `GET /api/ai-writing/{id}/stream` | SSE 进度流 | `subscribeToProgress` | -| `POST /api/ai-writing/{id}/resume` | 提交干预 | `resumeWriting` | -| `GET /api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 暂未调用 | -| `GET /api/ai-writing/sessions` | 历史列表 | `listAIWritingSessions` | -| `GET /api/ai-writing/sessions/{id}` | 历史详情 | `getAIWritingSession` | -| `PATCH /api/ai-writing/sessions/{id}` | 重命名 | `renameAIWritingSession` | -| `DELETE /api/ai-writing/sessions/{id}` | 删除会话 | `deleteAIWritingSession` | -| `PUT /api/ai-writing/sessions/{id}/transcript` | 保存时间线(fire-and-forget) | `saveAIWritingTranscript` | -| `GET /api/ai-writing/article-types` | 文章类型下拉 | `listArticleTypes` | -| `POST /api/ai-writing/admin/cleanup` | admin 手动清理 | 暂未在 UI 暴露 | - ---- - -## 十三、未来优化方向 - -| 项目 | 说明 | 工作量 | -|---|---|---| -| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 | -| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 | -| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 | -| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 | -| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 | -| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 | - ---- - -## 附录 A:所有类型一览 - -完整定义在 `types/ai-writing.ts`,主要类型: - -```typescript -// 业务实体 -AIMaterial // 单个素材 -AIOutline // 大纲 -OutlineSection // 大纲单章节 -ReviewIssue // 审核问题 -ReviewResult // 完整审核结果 -BlockedSection // 素材不足的章节 -SectionDecision // 章节求助决策 - -// 流程 -WritingStatus // 13 种状态 -ProgressEventType // 17 种事件类型 -ProgressEvent // SSE 事件载荷 -UserInterventionPayload // 干预提交体 -AIWritingRequest // 启动写作的请求 -CompletedIntervention // 已完成的干预记录 - -// 流式视图 -StreamingDraftSection -StreamingReviewItem -RevisionSummary - -// Hook 状态 -AIWritingState // useAIWriting 的完整状态 -``` - ---- - -## 附录 B:常见踩坑 - -| 现象 | 原因 | 解决 | -|---|---|---| -| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 `api/ai-writing.ts` 的 normalize 函数里加字段 | -| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 `ai-writing-说明.md` 多 worker 部分 | -| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 `save_transcript` | -| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 | -| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | `DraftStreamingView` 里 markdown parser 设置 | - ---- - -如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。 diff --git a/frontend-web/docs/ai-writing-frontend-refactor-checklist.md b/frontend-web/docs/ai-writing-frontend-refactor-checklist.md deleted file mode 100644 index b7119431..00000000 --- a/frontend-web/docs/ai-writing-frontend-refactor-checklist.md +++ /dev/null @@ -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. 右侧能稳定收到初稿和终稿 - -只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。 diff --git a/frontend-web/docs/ai-writing-weak-model-multi-agent-prd.md b/frontend-web/docs/ai-writing-weak-model-multi-agent-prd.md deleted file mode 100644 index e2293119..00000000 --- a/frontend-web/docs/ai-writing-weak-model-multi-agent-prd.md +++ /dev/null @@ -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) diff --git a/frontend-web/docs/ai-writing-优化.md b/frontend-web/docs/ai-writing-优化.md deleted file mode 100644 index 358389b1..00000000 --- a/frontend-web/docs/ai-writing-优化.md +++ /dev/null @@ -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({ - 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 的双进程配置。 diff --git a/frontend-web/docs/ai-writing-说明.md b/frontend-web/docs/ai-writing-说明.md deleted file mode 100644 index be4e57ad..00000000 --- a/frontend-web/docs/ai-writing-说明.md +++ /dev/null @@ -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 ls -la /app/.deer-flow/data/ -# 应该能看到 checkpoints.db 文件(首次启动后会自动创建) - -# 2) 确认 alembic 迁移跑完了 -docker exec sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current" -# 应该看到包含 20260519_02 的 head - -# 3) 确认 transcript 列已扩容 -docker exec mysql -uroot -p deerflow -e \ - "SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';" -# Type 列应该是 longtext - -# 4) 查 worker 数 -docker exec ps -ef | grep -E 'uvicorn|gunicorn' -# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告 -``` - -### 启动时关注的日志 - -```bash -docker logs 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 sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \ - \"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='' LIMIT 5;\"" - -# 2) 查 MySQL 里这条会话 -docker exec mysql -uroot -p deerflow -e \ - "SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='';" - -# 3) 看 gateway 日志里这个 session 的活动轨迹 -docker logs 2>&1 | grep '' | tail -50 - -# 4) 看是否有 recover_session 调用 -docker logs 2>&1 | grep 'ai_writing: recovered' | tail -20 - -# 5) 看是否有节点异常 -docker logs 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20 -``` - -### 手动触发清理(admin only) - -```bash -# 用 admin 账号登录后,取 cookie,调清理接口 -curl -X POST 'http:///api/ai-writing/admin/cleanup' \ - -H 'Content-Type: application/json' \ - -H '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` - -如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。 diff --git a/frontend-web/docs/aixiezuo.md b/frontend-web/docs/aixiezuo.md deleted file mode 100644 index 52048f12..00000000 --- a/frontend-web/docs/aixiezuo.md +++ /dev/null @@ -1,1896 +0,0 @@ -# AI 智能写作 —— 杂志社多 Agent 协作系统(含用户干预 & 详细进度) - -> **目标**:通过角色扮演 + 流程编排,让性能较弱的本地大模型产出高质量文章; -> 同时在每个关键节点暂停等待用户确认,确保最终结果符合预期。 - ---- - -## 目录 - -1. [整体架构](#1-整体架构) -2. [用户干预节点设计(核心)](#2-用户干预节点设计核心) -3. [开源参考项目](#3-开源参考项目) -4. [弱模型增强策略](#4-弱模型增强策略) -5. [三个 Agent 的角色设计](#5-三个-agent-的角色设计) -6. [数据结构定义](#6-数据结构定义) -7. [后端实现(分步)](#7-后端实现分步) -8. [前端实现(分步)](#8-前端实现分步) -9. [内网接口预留设计](#9-内网接口预留设计) -10. [LangGraph 状态机流转图](#10-langgraph-状态机流转图) -11. [提示词模板(弱模型专用)](#11-提示词模板弱模型专用) -12. [SSE 协议 & 干预协议设计](#12-sse-协议--干预协议设计) -13. [前端 UI 组件详细设计](#13-前端-ui-组件详细设计) -14. [文件目录结构](#14-文件目录结构) -15. [分步开发检查清单](#15-分步开发检查清单) - ---- - -## 1. 整体架构 - -``` -┌──────────────────────────────────────────────────────────────────────┐ -│ 前端(现有页面) │ -│ frontend-web/src/open-canvas/ │ -│ │ -│ ┌───────────────────────────┐ ┌──────────────────────────────────┐ │ -│ │ AI写作左侧面板 │ │ CanvasArtifactPanel(右侧) │ │ -│ │ AIWritingPanel.tsx │ │ (右侧保持不变,实时展示文章草稿) │ │ -│ │ ┌─────────────────────┐ │ │ │ │ -│ │ │ 1. 写作意图输入区 │ │ │ ┌────────────────────────────┐ │ │ -│ │ ├─────────────────────┤ │ │ │ 实时文章草稿(Markdown) │ │ │ -│ │ │ 2. 进度时间线 │ │ │ │ 每次修改都保留版本历史 │ │ │ -│ │ │ ┌───────────────┐ │ │ │ └────────────────────────────┘ │ │ -│ │ │ │ ✅ 研究院完成 │ │ │ │ │ │ -│ │ │ │ 素材卡片列表▼ │ │ │ │ │ │ -│ │ │ │ [可收起展开] │ │ │ │ │ │ -│ │ │ ├───────────────┤ │ │ │ │ │ -│ │ │ │ ⏸ 等待确认 │ │ │ │ │ │ -│ │ │ │ [用户干预卡片]│ │ │ │ │ │ -│ │ │ ├───────────────┤ │ │ │ │ │ -│ │ │ │ ✅ 大纲已生成 │ │ │ │ │ │ -│ │ │ │ 大纲预览▼ │ │ │ │ │ │ -│ │ │ │ [可收起展开] │ │ │ │ │ │ -│ │ │ ├───────────────┤ │ │ │ │ │ -│ │ │ │ ⏸ 等待确认 │ │ │ │ │ │ -│ │ │ └───────────────┘ │ │ │ │ │ -│ └───────────────────────────┘ └──────────────────────────────────┘ │ -└───────────────────────────────┬──────────────────────────────────────┘ - │ SSE 流式推送(进度/干预/草稿) - │ REST 干预响应(用户决策提交) - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 后端新路由(独立,URL 前缀 /api/ai-writing/) │ -│ app/gateway/routers/ai_writing.py │ -│ 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 ← 查询当前流程状态 │ -└───────────────────────────────┬──────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ AI Writing LangGraph Graph(独立于原有 Graph) │ -│ packages/harness/deerflow/agents/ai_writing/graph.py │ -│ │ -│ intent_parser → researcher → [PAUSE:材料确认] → writer(outline) │ -│ → [PAUSE:大纲确认] → writer(draft) → editor │ -│ → [PAUSE:终稿确认] → END │ -│ ↑__________________________|(打回重写,最多3次) │ -└──────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 2. 用户干预节点设计(核心) - -整个流程共有 **4 个用户干预点**,每个干预点都会暂停流程,向前端推送 `await_user` 事件,等待用户操作后再恢复。 - -### 干预点总览 - -``` -流程步骤 干预点 用户可执行的操作 -───────────────────────── ────── ─────────────────────────────────────── -① 研究院搜集素材完成 PAUSE-1 确认素材 / 补充关键词重搜 / 手动删除素材 -② 作家生成大纲完成 PAUSE-2 确认大纲 / 编辑大纲内容 / 要求重新规划 -③ 作家完成初稿(每次修改) PAUSE-3 确认定稿 / 给出修改意见 / 直接编辑文本 -④ 编辑审核完成(打回时) PAUSE-4 采纳编辑意见 / 忽略编辑意见强制定稿 -``` - -### PAUSE-1:素材确认 - -**触发**:研究院搜集完素材后暂停 - -前端展示: -- 素材卡片列表(默认收起,点击展开) -- 每张卡片:标题、来源、时间、相关度色条、正文摘要(可展开) -- 每张卡片有勾选框,支持删除不相关素材 -- "补充关键词"输入框,可追加搜索 - -用户操作选项: -``` -[确认素材,开始写大纲] ← 主操作,使用当前勾选素材继续 -[重新搜索] ← 清空素材重新来 -[补充搜索: ___________] ← 追加关键词,合并到现有素材 -``` - -### PAUSE-2:大纲确认 - -**触发**:作家生成大纲后暂停 - -前端展示: -- 大纲树形视图(默认展开) -- 每个章节可直接点击编辑标题和要点 -- 可拖拽调整章节顺序(可选) - -用户操作选项: -``` -[确认大纲,开始写作] ← 使用当前大纲(含用户修改)继续 -[重新规划大纲] ← 清空大纲重新生成,可附加意见: ___ -``` - -### PAUSE-3:草稿确认 - -**触发**:每次作家完成草稿后暂停(包括修改稿) - -前端展示: -- 右侧实时显示完整草稿 -- 左侧显示"编辑打分 + 问题清单"(如已经过编辑) -- 版本切换导航(第1稿 / 第2稿 …) - -用户操作选项: -``` -[确认定稿] ← 以此为最终文章 -[给出修改意见: ____________] ← 用户自己写修改意见,绕过编辑流程直接指导重写 -[让编辑继续审核] ← 走编辑流程(默认) -``` - -### PAUSE-4:编辑审核结果确认(仅在打回时) - -**触发**:编辑判断"打回"且未达到最大循环次数 - -前端展示: -- 编辑评分 + 分项得分(事实/逻辑/语言) -- 问题清单(按严重程度排序) -- 每个问题条目:位置 + 问题描述 + 修改建议 - -用户操作选项: -``` -[采纳编辑意见,让作家重写] ← 走编辑反馈重写 -[强制定稿(忽略编辑意见)] ← 用当前草稿结束 -[补充修改指令: ____________] ← 用户自己的意见覆盖编辑意见 -``` - ---- - -## 3. 开源参考项目 - -### Stanford STORM(最核心参考) -- **项目**:https://github.com/stanford-oval/storm -- **借鉴**:Researcher + Writer + Editor 完整协作流程;素材 → 大纲 → 分段写作的两步写法;短 prompt + JSON 输出模式(弱模型友好) - -### GPT-Researcher -- **项目**:https://github.com/assafelovic/gpt-researcher -- **借鉴**:统一搜索接口抽象(`context/retriever.py`);素材字段定义(标题/来源/时间/相关度) - -### CrewAI -- **项目**:https://github.com/crewAIInc/crewAI -- **借鉴**:`role + goal + backstory` 三段式角色锁定 Prompt;`expected_output` 强制 JSON 输出;任务链传参方式 - -### LangGraph Human-in-the-loop(项目已有,重点参考) -- **文档**:https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/ -- **借鉴**:`interrupt()` 实现流程暂停等待用户输入;`Command(resume=...)` 恢复流程;`MemorySaver` checkpointing 持久化状态 - ---- - -## 4. 弱模型增强策略 - -| 策略 | 实现方式 | 效果 | -|------|----------|------| -| **任务最小化** | 每次 LLM 调用只做一件事,禁止混合任务 | 减少跑偏概率 | -| **角色锁定** | System Prompt 开头:「你是X,你只做Y,不做其他」 | 防止角色混淆 | -| **强制 JSON** | `response_format=json_object` + Prompt 末尾提供 JSON 模板 | 输出可解析,有 fallback | -| **上下文压缩** | 传给下游的素材截断到 200 字摘要,不传原文 | 防止 context 溢出 | -| **两步写作** | 先写大纲(JSON),确认后再逐段写正文 | 防止弱模型一次生成长文失控 | -| **分项审查** | 编辑三项独立打分(事实/逻辑/语言),不混在一起 | 每次调用任务极简 | -| **Few-Shot 示例** | 每个 Agent 的 Prompt 内置 1 个期望输出的完整 JSON 示例 | 模型直接模仿,输出更稳定 | -| **迭代上限保护** | 编辑打回最多 3 次,超出强制定稿 | 防死循环 | -| **用户干预兜底** | 每个关键节点人工确认 | 彻底避免方向跑偏 | - ---- - -## 5. 三个 Agent 的角色设计 - -### Agent A —— 研究院(Researcher) - -``` -角色:专业情报研究员 -单次调用任务:① 只拆关键词 ② 只排序素材(两次独立调用) -输入:用户写作意图 -输出:MaterialPackage(结构化素材包) -工具:web_search(当前) / intranet_search(预留) -``` - -**两步拆分(弱模型关键)**: -- Step 1:`extract_keywords(intent)` → `{"keywords": [...]}`(仅此一件事) -- Step 2:对每个关键词独立搜索(并发),合并去重,截断到 500 字 - -**输出字段**: -```json -{ - "keywords": ["关键词1", "关键词2"], - "materials": [ - { - "id": "m001", - "title": "文章标题", - "content": "正文摘要(最多500字)", - "source": "来源域名或系统名称", - "published_at": "2024-01-15", - "relevance_score": 0.92, - "source_type": "internet", - "url": "https://...", - "recUuid": "", - } - ], - "summary": "共搜索到X条素材,精选Y条" -} -``` - -### Agent B —— 作家(Writer) - -``` -角色:专业内容创作者 -Phase 1:write_outline → 输出大纲 JSON(暂停等用户确认) -Phase 2:write_draft → 按段落逐段写正文(逐段 SSE 推送) -输入:MaterialPackage + 用户意图 + 确认后的大纲 - (修改稿时额外附加:编辑意见 or 用户修改意见) -输出:DraftArticle(含 Markdown 全文) -``` - -### Agent C —— 编辑(Editor) - -``` -角色:资深主编,标准极高 -任务:三项独立审查 → 综合评分 → 通过/打回决定 - 每项审查是独立的 LLM 调用(弱模型关键) -输入:DraftArticle + MaterialPackage(用于核实事实) -输出:ReviewResult(评分 + 分项问题清单) -``` - -**三次独立 LLM 调用**: -1. `review_facts(draft, materials)` → `{"score": 85, "issues": [...]}` -2. `review_logic(draft)` → `{"score": 80, "issues": [...]}` -3. `review_language(draft)` → `{"score": 90, "issues": [...]}` - ---- - -## 6. 数据结构定义 - -### 6.1 后端 LangGraph State(Python) - -**文件**:`packages/harness/deerflow/agents/ai_writing/state.py` - -```python -from typing import TypedDict, List, Optional, Annotated, Literal -import operator - -# ── 素材 ────────────────────────────────────────────────────────────── -class Material(TypedDict): - id: str - title: str - content: str # 截断到 500 字 - source: str - published_at: str - relevance_score: float - source_type: str # "internet" | "intranet" - url: str - recUuid: str # 知识库ID - -class MaterialPackage(TypedDict): - keywords: List[str] - materials: List[Material] - summary: str - -# ── 大纲 ────────────────────────────────────────────────────────────── -class OutlineSection(TypedDict): - section_title: str - key_points: List[str] - material_ids: List[str] - -class Outline(TypedDict): - title: str - sections: List[OutlineSection] - -# ── 草稿 ────────────────────────────────────────────────────────────── -class DraftArticle(TypedDict): - title: str - outline: Outline - full_markdown: str - word_count: int - revision_count: int - -# ── 编辑审核 ────────────────────────────────────────────────────────── -class ReviewIssue(TypedDict): - category: str # "fact" | "logic" | "language" - severity: str # "major" | "minor" - location: str - issue: str - suggestion: str - -class ReviewResult(TypedDict): - verdict: str # "pass" | "reject" - fact_score: int - logic_score: int - language_score: int - overall_score: int - pass_threshold: int - revision_notes: List[ReviewIssue] - -# ── 用户干预 ────────────────────────────────────────────────────────── -class UserInterventionPayload(TypedDict): - """用户在干预点提交的数据""" - action: str # 见各干预点定义 - # PAUSE-1 素材确认 - approved_material_ids: List[str] # 用户勾选保留的素材 ID - extra_keywords: List[str] # 补充搜索关键词 - # PAUSE-2 大纲确认 - edited_outline: Optional[Outline] # 用户编辑后的大纲(None 表示未修改) - outline_feedback: str # 要求重新规划的意见 - # PAUSE-3 草稿确认 - user_revision_notes: str # 用户自己的修改意见 - # PAUSE-4 编辑审核确认 - override_verdict: bool # True = 强制定稿,False = 采纳编辑意见 - -# ── 主状态 ──────────────────────────────────────────────────────────── -WritingStatus = Literal[ - "researching", "awaiting_material_confirm", - "writing_outline", "awaiting_outline_confirm", - "writing_draft", "awaiting_draft_confirm", - "reviewing", "awaiting_review_confirm", - "revising", "done", "error" -] - -class AIWritingState(TypedDict): - # 写作配置 - user_intent: str - article_type: str - target_audience: str - word_count_target: int - - # Agent A 输出 - material_package: Optional[MaterialPackage] - - # Agent B 输出 - current_outline: Optional[Outline] - drafts: Annotated[List[DraftArticle], operator.add] - current_draft: Optional[DraftArticle] - - # Agent C 输出 - review_result: Optional[ReviewResult] - - # 用户干预数据(每个干预点会覆盖) - last_user_intervention: Optional[UserInterventionPayload] - - # 流程控制 - revision_count: int - max_revisions: int # 默认 3 - status: WritingStatus - - # SSE 进度推送队列(每个节点追加) - progress_events: Annotated[List[dict], operator.add] -``` - -### 6.2 前端类型(TypeScript) - -**文件**:`frontend-web/src/open-canvas/types/ai-writing.ts` - -```typescript -// ── 素材 ───────────────────────────────────────────────────────────── -export interface AIMaterial { - id: string; - title: string; - content: string; - source: string; - publishedAt: string; - relevanceScore: number; - sourceType: 'internet' | 'intranet'; - url?: string; - docId?: string; - department?: string; - classification?: string; -} - -// ── 大纲 ───────────────────────────────────────────────────────────── -export interface OutlineSection { - sectionTitle: string; - keyPoints: string[]; - materialIds: string[]; -} - -export interface AIOutline { - title: string; - sections: OutlineSection[]; -} - -// ── 编辑审核 ────────────────────────────────────────────────────────── -export interface ReviewIssue { - category: 'fact' | 'logic' | 'language'; - severity: 'major' | 'minor'; - location: string; - issue: string; - suggestion: string; -} - -export interface ReviewResult { - verdict: 'pass' | 'reject'; - factScore: number; - logicScore: number; - languageScore: number; - overallScore: number; - passThreshold: number; - revisionNotes: ReviewIssue[]; -} - -// ── 写作状态 ────────────────────────────────────────────────────────── -export type WritingStatus = - | 'idle' - | 'researching' - | 'awaiting_material_confirm' - | 'writing_outline' - | 'awaiting_outline_confirm' - | 'writing_draft' - | 'awaiting_draft_confirm' - | 'reviewing' - | 'awaiting_review_confirm' - | 'revising' - | 'done' - | 'error'; - -// ── SSE 进度事件 ────────────────────────────────────────────────────── -export type ProgressEventType = - | 'step_start' // Agent 开始执行某步骤 - | 'step_done' // Agent 完成某步骤 - | 'materials_ready' // 素材搜集完毕 - | 'outline_ready' // 大纲生成完毕 - | 'draft_chunk' // 草稿文字流式片段(逐段) - | 'draft_ready' // 草稿完整版本 - | 'review_ready' // 编辑审核完毕 - | 'await_user' // 暂停,等待用户干预 - | 'resumed' // 用户干预已提交,流程恢复 - | 'done' // 全部完成 - | 'error'; // 出错 - -export interface ProgressEvent { - type: ProgressEventType; - sessionId: string; - agentName?: string; // '研究院' | '作家' | '编辑' | '系统' - message: string; - detail?: string; - // type===materials_ready 时 - materials?: AIMaterial[]; - keywords?: string[]; - materialSummary?: string; - // type===outline_ready 时 - outline?: AIOutline; - // type===draft_chunk / draft_ready 时 - draftMarkdown?: string; - draftTitle?: string; - revisionCount?: number; - // type===review_ready 时 - reviewResult?: ReviewResult; - // type===await_user 时 - pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm'; - pauseContext?: Record; // 干预点所需的上下文数据 -} - -// ── 用户干预提交体 ──────────────────────────────────────────────────── -export interface UserInterventionPayload { - action: string; - // PAUSE-1 - approvedMaterialIds?: string[]; - extraKeywords?: string[]; - // PAUSE-2 - editedOutline?: AIOutline; - outlineFeedback?: string; - // PAUSE-3 - userRevisionNotes?: string; - // PAUSE-4 - overrideVerdict?: boolean; -} - -// ── 写作请求 ────────────────────────────────────────────────────────── -export interface AIWritingRequest { - userIntent: string; - articleType: 'news' | 'analysis' | 'feature' | 'report'; - targetAudience: string; - wordCountTarget: number; - extraContext?: string; -} - -// ── Hook 返回值 ─────────────────────────────────────────────────────── -export interface AIWritingState { - sessionId: string | null; - status: WritingStatus; - progressEvents: ProgressEvent[]; - materials: AIMaterial[]; - outline: AIOutline | null; - currentDraftMarkdown: string; - currentDraftTitle: string; - reviewResult: ReviewResult | null; - revisionCount: number; - currentPause: ProgressEvent | null; // 当前干预点事件(null 表示不在暂停状态) - error: string | null; -} -``` - ---- - -## 7. 后端实现(分步) - -### 步骤 7.1:目录结构 - -``` -packages/harness/deerflow/agents/ai_writing/ -├── __init__.py -├── state.py -├── graph.py -├── nodes/ -│ ├── __init__.py -│ ├── intent_parser.py -│ ├── researcher.py -│ ├── writer_outline.py # 只写大纲(独立节点) -│ ├── writer_draft.py # 只写正文(独立节点) -│ └── editor.py -├── search/ -│ ├── __init__.py -│ ├── base.py # 抽象接口(内网预留) -│ ├── internet_search.py -│ └── intranet_stub.py -└── prompts/ - ├── researcher_prompts.py - ├── writer_prompts.py - └── editor_prompts.py - -app/gateway/routers/ -└── ai_writing.py # 新建,不修改其他路由文件 -``` - -### 步骤 7.2:搜索接口抽象(内网预留) - -**文件**:`packages/harness/deerflow/agents/ai_writing/search/base.py` - -```python -from abc import ABC, abstractmethod -from typing import List -from dataclasses import dataclass, field - -@dataclass -class SearchResult: - """统一搜索结果格式 —— 内外网共用,对接内网只需映射到此结构""" - title: str - content: str - source: str - published_at: str - url: str = "" - relevance_score: float = 0.0 - source_type: str = "internet" # "internet" | "intranet" - # ── 内网扩展字段(按需填充)────────────────────────────────────── - recUuid: str = "" # 知识库 ID - extra_metadata: dict = field(default_factory=dict) - -class BaseSearchProvider(ABC): - """ - 搜索提供者抽象基类 - - 对接内网步骤: - 1. 继承此类,创建 IntranetSearchProvider - 2. 实现 search() —— 调用内网 API,将响应映射到 SearchResult - 3. 在 config.yaml 中设置 ai_writing.search_provider: intranet - 4. 无需修改任何 Agent 代码 - """ - - @abstractmethod - async def search( - self, - query: str, - max_results: int = 5, - date_from: str = "", - date_to: str = "", - ) -> List[SearchResult]: ... - - @abstractmethod - async def health_check(self) -> bool: ... -``` - -**内网接口桩**:`packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` - -```python -from typing import List -from .base import BaseSearchProvider, SearchResult - -class IntranetSearchProvider(BaseSearchProvider): - """ - 内网搜索接口适配器 - ═══════════════════════════════════════════════════════════ - 内网系统需提供 HTTP 接口,推荐格式: - - POST /api/search - Content-Type: application/json - Authorization: Bearer {api_key} (若需认证) - - 请求体: - { - "query": "搜索关键词", - "page_size": 5, - "date_from": "2024-01-01", (可选) - "date_to": "2024-12-31" (可选) - } - - 响应体: - { - "total": 100, - "results": [ - { - "title": "文档标题", - "content": "摘要(建议500字以内)", - "source": "来源系统名称", - "published_at": "2024-01-15", - "recUuid": "DOC-2024-001", - "url": "intranet://...", (可选) - "relevance_score": 0.92 (可选,无则传0) - } - ] - } - - 字段名称不同时,在 config.yaml 的 intranet_field_map 中配置映射。 - ═══════════════════════════════════════════════════════════ - """ - - def __init__(self, base_url: str, api_key: str = "", timeout: int = 10, - field_map: dict = None): - self.base_url = base_url - self.api_key = api_key - self.timeout = timeout - # 字段映射:内网字段名 → 标准字段名 - self.field_map = field_map or {} - - def _map_field(self, item: dict, standard_field: str) -> str: - """将内网字段名映射到标准字段名""" - intranet_field = self.field_map.get(standard_field, standard_field) - return item.get(intranet_field, item.get(standard_field, "")) - - async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]: - # ── TODO:对接内网时,取消以下注释并调整 ────────────────────── - # import httpx - # headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} - # async with httpx.AsyncClient(timeout=self.timeout) as client: - # resp = await client.post( - # f"{self.base_url}/api/search", - # json={"query": query, "page_size": max_results}, - # headers=headers, - # ) - # resp.raise_for_status() - # items = resp.json().get("results", []) - # return [ - # SearchResult( - # title=self._map_field(it, "title"), - # content=self._map_field(it, "content")[:500], - # source=self._map_field(it, "source"), - # published_at=self._map_field(it, "published_at"), - # relevance_score=float(it.get( - # self.field_map.get("relevance_score", "relevance_score"), 0)), - # recUuid=self._map_field(it, "recUuid"), - # source_type="intranet", - # ) - # for it in items - # ] - # ───────────────────────────────────────────────────────────── - return [] # 未配置时返回空,不影响互联网模式 - - async def health_check(self) -> bool: - return False -``` - -**搜索工厂**:`packages/harness/deerflow/agents/ai_writing/search/__init__.py` - -```python -from .base import BaseSearchProvider, SearchResult -from .internet_search import InternetSearchProvider -from .intranet_stub import IntranetSearchProvider - -def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider: - """ - config.yaml 中配置: - ai_writing: - search_provider: internet # 或 intranet - intranet_search_url: http://... - intranet_search_api_key: "" - intranet_field_map: - content: "abstract" - source: "system_name" - """ - provider = ai_writing_config.get("search_provider", "internet") - if provider == "intranet": - return IntranetSearchProvider( - base_url=ai_writing_config.get("intranet_search_url", ""), - api_key=ai_writing_config.get("intranet_search_api_key", ""), - field_map=ai_writing_config.get("intranet_field_map", {}), - ) - return InternetSearchProvider() -``` - -### 步骤 7.3:LangGraph Graph 与 Interrupt 机制 - -**文件**:`packages/harness/deerflow/agents/ai_writing/graph.py` - -```python -import asyncio -from langgraph.graph import StateGraph, END -from langgraph.checkpoint.memory import MemorySaver -from langgraph.types import interrupt, Command -from .state import AIWritingState -from .nodes.intent_parser import intent_parser_node -from .nodes.researcher import researcher_node -from .nodes.writer_outline import writer_outline_node -from .nodes.writer_draft import writer_draft_node -from .nodes.editor import editor_node - -# ── 干预节点:暂停并等待用户输入 ───────────────────────────────────────── - -async def pause_material_confirm(state: AIWritingState) -> dict: - """PAUSE-1:素材确认干预点""" - user_input = interrupt({ - "pause_point": "material_confirm", - "message": "研究院已完成素材搜集,请确认素材后继续。", - "materials": state["material_package"]["materials"], - "keywords": state["material_package"]["keywords"], - "summary": state["material_package"]["summary"], - }) - # 用户提交后,user_input 是 UserInterventionPayload - # 根据用户操作过滤素材 - action = user_input.get("action", "confirm") - if action == "confirm": - approved_ids = set(user_input.get("approvedMaterialIds", [ - m["id"] for m in state["material_package"]["materials"] - ])) - filtered_materials = [ - m for m in state["material_package"]["materials"] - if m["id"] in approved_ids - ] - updated_pkg = {**state["material_package"], "materials": filtered_materials} - return { - "material_package": updated_pkg, - "last_user_intervention": user_input, - "status": "writing_outline", - "progress_events": [{"type": "resumed", "message": "用户已确认素材,开始规划大纲"}] - } - # action == "re_search":重新搜索(返回 researcher 节点,通过路由控制) - extra_keywords = user_input.get("extraKeywords", []) - return { - "last_user_intervention": {**user_input, "_extra_keywords": extra_keywords}, - "status": "researching", - "progress_events": [{"type": "resumed", "message": "重新搜索中..."}] - } - -async def pause_outline_confirm(state: AIWritingState) -> dict: - """PAUSE-2:大纲确认干预点""" - user_input = interrupt({ - "pause_point": "outline_confirm", - "message": "作家已完成大纲规划,请确认或编辑大纲后继续。", - "outline": state["current_outline"], - }) - action = user_input.get("action", "confirm") - if action == "confirm": - edited_outline = user_input.get("editedOutline") or state["current_outline"] - return { - "current_outline": edited_outline, - "last_user_intervention": user_input, - "status": "writing_draft", - "progress_events": [{"type": "resumed", "message": "用户已确认大纲,开始写作"}] - } - # action == "re_outline" - feedback = user_input.get("outlineFeedback", "") - return { - "last_user_intervention": {**user_input, "_feedback": feedback}, - "status": "writing_outline", - "progress_events": [{"type": "resumed", "message": f"重新规划大纲(意见:{feedback[:50]})"}] - } - -async def pause_draft_confirm(state: AIWritingState) -> dict: - """PAUSE-3:草稿确认干预点""" - user_input = interrupt({ - "pause_point": "draft_confirm", - "message": "草稿已完成,请确认或给出修改意见。", - "draft_title": state["current_draft"]["title"], - "revision_count": state["current_draft"]["revision_count"], - "review_result": state.get("review_result"), - }) - action = user_input.get("action", "to_editor") - if action == "finalize": - return { - "last_user_intervention": user_input, - "status": "done", - "progress_events": [{"type": "resumed", "message": "用户确认定稿"}] - } - if action == "user_revise": - notes = user_input.get("userRevisionNotes", "") - return { - "last_user_intervention": {**user_input, "_user_notes": notes}, - "revision_count": state.get("revision_count", 0) + 1, - "status": "revising", - "progress_events": [{"type": "resumed", "message": f"按用户意见重写({notes[:50]})"}] - } - # action == "to_editor":走编辑流程 - return { - "last_user_intervention": user_input, - "status": "reviewing", - "progress_events": [{"type": "resumed", "message": "提交编辑审核"}] - } - -async def pause_review_confirm(state: AIWritingState) -> dict: - """PAUSE-4:编辑打回确认(仅 verdict==reject 时触发)""" - user_input = interrupt({ - "pause_point": "review_confirm", - "message": "编辑认为文章需要修改,请决定下一步操作。", - "review_result": state["review_result"], - "revision_count": state.get("revision_count", 0), - "max_revisions": state.get("max_revisions", 3), - }) - if user_input.get("overrideVerdict"): - return { - "last_user_intervention": user_input, - "status": "done", - "progress_events": [{"type": "resumed", "message": "用户强制定稿"}] - } - # 采纳编辑意见 or 附加用户意见 - extra_notes = user_input.get("userRevisionNotes", "") - return { - "last_user_intervention": {**user_input, "_extra_notes": extra_notes}, - "revision_count": state.get("revision_count", 0) + 1, - "status": "revising", - "progress_events": [{"type": "resumed", "message": "按编辑意见重写"}] - } - -# ── 条件路由 ────────────────────────────────────────────────────────── - -def after_material_pause(state: AIWritingState) -> str: - if state["status"] == "researching": - return "researcher" # 重新搜索 - return "writer_outline" - -def after_outline_pause(state: AIWritingState) -> str: - if state["status"] == "writing_outline": - return "writer_outline" # 重新规划 - return "writer_draft" - -def after_draft_pause(state: AIWritingState) -> str: - status = state["status"] - if status == "done": - return "end" - if status == "revising": - return "writer_draft" # 按用户意见重写 - return "editor" # 走编辑流程 - -def after_review(state: AIWritingState) -> str: - review = state.get("review_result", {}) - if review.get("verdict") == "pass": - return "pause_draft_confirm" # 编辑通过 → 草稿确认 - rev_count = state.get("revision_count", 0) - max_rev = state.get("max_revisions", 3) - if rev_count >= max_rev: - return "pause_draft_confirm" # 超出次数强制到草稿确认 - return "pause_review_confirm" # 编辑打回 → 审核确认 - -def after_review_pause(state: AIWritingState) -> str: - if state["status"] == "done": - return "end" - return "writer_draft" # 重写 - -# ── 组装 Graph ──────────────────────────────────────────────────────── - -def build_ai_writing_graph(): - graph = StateGraph(AIWritingState) - - # 节点 - graph.add_node("intent_parser", intent_parser_node) - graph.add_node("researcher", researcher_node) - graph.add_node("pause_material", pause_material_confirm) # PAUSE-1 - graph.add_node("writer_outline", writer_outline_node) - graph.add_node("pause_outline", pause_outline_confirm) # PAUSE-2 - graph.add_node("writer_draft", writer_draft_node) - graph.add_node("pause_draft", pause_draft_confirm) # PAUSE-3 - graph.add_node("editor", editor_node) - graph.add_node("pause_review", pause_review_confirm) # PAUSE-4 - - # 边 - graph.set_entry_point("intent_parser") - graph.add_edge("intent_parser", "researcher") - graph.add_edge("researcher", "pause_material") - - graph.add_conditional_edges("pause_material", after_material_pause, - {"researcher": "researcher", "writer_outline": "writer_outline"}) - - graph.add_edge("writer_outline", "pause_outline") - graph.add_conditional_edges("pause_outline", after_outline_pause, - {"writer_outline": "writer_outline", "writer_draft": "writer_draft"}) - - graph.add_edge("writer_draft", "pause_draft") - graph.add_conditional_edges("pause_draft", after_draft_pause, - {"end": END, "writer_draft": "writer_draft", "editor": "editor"}) - - graph.add_conditional_edges("editor", after_review, - {"pause_draft_confirm": "pause_draft", - "pause_review_confirm": "pause_review"}) - - graph.add_conditional_edges("pause_review", after_review_pause, - {"end": END, "writer_draft": "writer_draft"}) - - # 使用 MemorySaver 支持 interrupt/resume(生产环境换成 PostgresSaver) - checkpointer = MemorySaver() - return graph.compile(checkpointer=checkpointer, interrupt_before=None) - -AI_WRITING_GRAPH = build_ai_writing_graph() -``` - -### 步骤 7.4:后端 HTTP 路由 - -**文件**:`app/gateway/routers/ai_writing.py` - -```python -import asyncio -import json -import uuid -from typing import AsyncGenerator, Optional -from fastapi import APIRouter, HTTPException -from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field -from langgraph.types import Command -from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH - -router = APIRouter() - -# ── 请求/响应模型 ───────────────────────────────────────────────────── - -class StartWritingRequest(BaseModel): - user_intent: str - article_type: str = "analysis" - target_audience: str = "普通读者" - word_count_target: int = 800 - model_name: str = "" - -class StartWritingResponse(BaseModel): - session_id: str - message: str = "写作流程已启动" - -class ResumeRequest(BaseModel): - action: str - approved_material_ids: list[str] = Field(default_factory=list) - extra_keywords: list[str] = Field(default_factory=list) - edited_outline: Optional[dict] = None - outline_feedback: str = "" - user_revision_notes: str = "" - override_verdict: bool = False - -# ── 工具函数 ────────────────────────────────────────────────────────── - -def sse(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - -def _make_thread_config(session_id: str, model_name: str = ""): - from deerflow.models.factory import get_default_llm - llm = get_default_llm(model_name=model_name or None) - return { - "configurable": { - "thread_id": session_id, - "llm": llm, - "ai_writing_config": {}, # 从全局 config 读取 search_provider 等 - } - } - -# ── 路由 ────────────────────────────────────────────────────────────── - -@router.post("/start", response_model=StartWritingResponse) -async def start_writing(body: StartWritingRequest): - """启动写作流程,返回 session_id(后续用此 ID 监听 SSE 和提交干预)""" - session_id = str(uuid.uuid4()) - initial_state = { - "user_intent": body.user_intent, - "article_type": body.article_type, - "target_audience": body.target_audience, - "word_count_target": body.word_count_target, - "revision_count": 0, - "max_revisions": 3, - "status": "researching", - "drafts": [], - "progress_events": [], - } - config = _make_thread_config(session_id, body.model_name) - - # 异步启动 Graph(不等待完成,通过 SSE 推送进度) - asyncio.create_task(_run_graph(session_id, initial_state, config)) - return StartWritingResponse(session_id=session_id) - - -@router.get("/{session_id}/stream") -async def stream_progress(session_id: str): - """SSE 流:推送进度事件、干预请求、草稿内容""" - return StreamingResponse( - _sse_generator(session_id), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - -@router.post("/{session_id}/resume") -async def resume_writing(session_id: str, body: ResumeRequest): - """用户提交干预决策,恢复暂停的流程""" - config = _make_thread_config(session_id) - intervention = { - "action": body.action, - "approvedMaterialIds": body.approved_material_ids, - "extraKeywords": body.extra_keywords, - "editedOutline": body.edited_outline, - "outlineFeedback": body.outline_feedback, - "userRevisionNotes": body.user_revision_notes, - "overrideVerdict": body.override_verdict, - } - # LangGraph Command(resume=...) 恢复被 interrupt() 暂停的流程 - asyncio.create_task( - AI_WRITING_GRAPH.ainvoke(Command(resume=intervention), config=config) - ) - return {"message": "已提交,流程恢复中"} - - -@router.get("/{session_id}/state") -async def get_state(session_id: str): - """查询当前流程状态快照""" - config = _make_thread_config(session_id) - state = await AI_WRITING_GRAPH.aget_state(config) - if not state: - raise HTTPException(status_code=404, detail="Session not found") - return state.values - - -# ── 内部辅助 ────────────────────────────────────────────────────────── - -# 每个 session 的事件队列 -_event_queues: dict[str, asyncio.Queue] = {} - -async def _run_graph(session_id: str, initial_state: dict, config: dict): - """后台运行 Graph,将节点输出推入事件队列""" - queue = asyncio.Queue() - _event_queues[session_id] = queue - - try: - async for chunk in AI_WRITING_GRAPH.astream( - initial_state, config=config, stream_mode="updates" - ): - node_name = list(chunk.keys())[0] - node_output = chunk[node_name] - - # 推送 progress_events - for evt in node_output.get("progress_events", []): - await queue.put(("progress", evt)) - - # 素材就绪 - if pkg := node_output.get("material_package"): - await queue.put(("materials_ready", { - "keywords": pkg["keywords"], - "materials": pkg["materials"], - "summary": pkg["summary"], - })) - - # 大纲就绪 - if outline := node_output.get("current_outline"): - await queue.put(("outline_ready", {"outline": outline})) - - # 草稿就绪 - if draft := node_output.get("current_draft"): - await queue.put(("draft_ready", { - "title": draft["title"], - "markdown": draft["full_markdown"], - "revision_count": draft["revision_count"], - })) - - # 编辑审核就绪 - if review := node_output.get("review_result"): - await queue.put(("review_ready", {"review_result": review})) - - # interrupt() 触发 → 推送 await_user 事件 - # LangGraph 在 interrupt 时会抛出特殊异常, - # 通过 stream_mode="updates" 时以 "__interrupt__" 节点出现 - if node_name == "__interrupt__": - interrupt_data = node_output.get("value", {}) - await queue.put(("await_user", interrupt_data)) - - await queue.put(("done", {"message": "写作完成"})) - - except Exception as e: - await queue.put(("error", {"message": str(e)})) - finally: - await queue.put(None) # 哨兵,关闭流 - - -async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]: - """从队列消费事件,推送为 SSE""" - # 等待队列创建(最多 5 秒) - for _ in range(50): - if session_id in _event_queues: - break - await asyncio.sleep(0.1) - else: - yield sse("error", {"message": "Session not found"}) - return - - queue = _event_queues[session_id] - yield sse("connected", {"session_id": session_id}) - - while True: - item = await queue.get() - if item is None: - break - event_type, data = item - yield sse(event_type, {"session_id": session_id, **data}) - await asyncio.sleep(0) - - _event_queues.pop(session_id, None) -``` - -**注册路由**(在 `app/gateway/app.py` 末尾追加,不修改已有代码): - -```python -from app.gateway.routers import ai_writing -app.include_router(ai_writing.router, prefix="/api/ai-writing", tags=["ai-writing"]) -``` - ---- - -## 8. 前端实现(分步) - -### 步骤 8.1:API 客户端 - -**文件**:`frontend-web/src/open-canvas/api/ai-writing.ts` - -```typescript -import type { AIWritingRequest, ProgressEvent, UserInterventionPayload } from '../types/ai-writing'; - -const BASE = '/api/ai-writing'; - -/** 启动写作流程,返回 session_id */ -export async function startWritingSession(req: AIWritingRequest): Promise { - const resp = await fetch(`${BASE}/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_intent: req.userIntent, - article_type: req.articleType, - target_audience: req.targetAudience, - word_count_target: req.wordCountTarget, - }), - }); - if (!resp.ok) throw new Error(`启动失败: ${resp.status}`); - const data = await resp.json(); - return data.session_id; -} - -/** 监听 SSE 进度流 */ -export function subscribeToProgress( - sessionId: string, - onEvent: (event: ProgressEvent) => void, - signal?: AbortSignal, -): () => void { - const es = new EventSource(`${BASE}/${sessionId}/stream`); - - const eventTypes = [ - 'progress', 'materials_ready', 'outline_ready', - 'draft_ready', 'review_ready', 'await_user', - 'resumed', 'done', 'error', - ] as const; - - eventTypes.forEach((type) => { - es.addEventListener(type, (e: MessageEvent) => { - try { - const data = JSON.parse(e.data); - onEvent({ type, sessionId, ...data } as ProgressEvent); - } catch { - // 忽略 - } - }); - }); - - signal?.addEventListener('abort', () => es.close()); - return () => es.close(); -} - -/** 提交用户干预决策 */ -export async function resumeWriting( - sessionId: string, - payload: UserInterventionPayload, -): Promise { - const resp = await fetch(`${BASE}/${sessionId}/resume`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: payload.action, - approved_material_ids: payload.approvedMaterialIds ?? [], - extra_keywords: payload.extraKeywords ?? [], - edited_outline: payload.editedOutline ?? null, - outline_feedback: payload.outlineFeedback ?? '', - user_revision_notes: payload.userRevisionNotes ?? '', - override_verdict: payload.overrideVerdict ?? false, - }), - }); - if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`); -} -``` - -### 步骤 8.2:核心 Hook - -**文件**:`frontend-web/src/open-canvas/hooks/useAIWriting.ts` - -```typescript -import { useState, useCallback, useRef, useEffect } from 'react'; -import { startWritingSession, subscribeToProgress, resumeWriting } from '../api/ai-writing'; -import type { - AIWritingRequest, AIWritingState, ProgressEvent, - UserInterventionPayload, AIMaterial, AIOutline, ReviewResult, -} from '../types/ai-writing'; -import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; - -const INITIAL_STATE: AIWritingState = { - sessionId: null, - status: 'idle', - progressEvents: [], - materials: [], - outline: null, - currentDraftMarkdown: '', - currentDraftTitle: '', - reviewResult: null, - revisionCount: 0, - currentPause: null, - error: null, -}; - -export function useAIWriting() { - const { setArtifact } = useCanvasGraphContext(); - const [state, setState] = useState(INITIAL_STATE); - const abortRef = useRef(null); - const unsubscribeRef = useRef<(() => void) | null>(null); - - const handleEvent = useCallback((event: ProgressEvent) => { - setState((prev) => { - const base = { ...prev, progressEvents: [...prev.progressEvents, event] }; - - switch (event.type) { - case 'materials_ready': - return { ...base, materials: event.materials ?? [], status: 'awaiting_material_confirm', currentPause: event }; - - case 'outline_ready': - return { ...base, outline: event.outline ?? null, status: 'awaiting_outline_confirm', currentPause: event }; - - case 'draft_ready': - return { - ...base, - currentDraftMarkdown: event.draftMarkdown ?? '', - currentDraftTitle: event.draftTitle ?? '', - revisionCount: event.revisionCount ?? 0, - status: 'awaiting_draft_confirm', - currentPause: event, - }; - - case 'review_ready': - return { ...base, reviewResult: event.reviewResult ?? null }; - - case 'await_user': - return { ...base, status: `awaiting_${event.pausePoint ?? 'unknown'}` as AIWritingState['status'], currentPause: event }; - - case 'resumed': - return { ...base, currentPause: null }; - - case 'done': - return { ...base, status: 'done', currentPause: null }; - - case 'error': - return { ...base, status: 'error', error: event.message ?? '未知错误' }; - - default: - return base; - } - }); - - // 草稿实时同步到右侧 Artifact 面板 - if (event.type === 'draft_ready' && event.draftMarkdown) { - setArtifact({ - currentIndex: 0, - contents: [{ - index: 0, - type: 'text', - title: event.draftTitle ?? '草稿', - fullMarkdown: event.draftMarkdown, - }], - }); - } - }, [setArtifact]); - - const startWriting = useCallback(async (request: AIWritingRequest) => { - if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return; - - abortRef.current?.abort(); - abortRef.current = new AbortController(); - setState({ ...INITIAL_STATE, status: 'researching' }); - - try { - const sessionId = await startWritingSession(request); - setState((prev) => ({ ...prev, sessionId })); - const unsubscribe = subscribeToProgress(sessionId, handleEvent, abortRef.current.signal); - unsubscribeRef.current = unsubscribe; - } catch (err) { - setState((prev) => ({ ...prev, status: 'error', error: String(err) })); - } - }, [state.status, handleEvent]); - - const submitIntervention = useCallback(async (payload: UserInterventionPayload) => { - if (!state.sessionId) return; - try { - await resumeWriting(state.sessionId, payload); - setState((prev) => ({ ...prev, currentPause: null })); - } catch (err) { - setState((prev) => ({ ...prev, error: String(err) })); - } - }, [state.sessionId]); - - const stopWriting = useCallback(() => { - abortRef.current?.abort(); - unsubscribeRef.current?.(); - setState((prev) => ({ ...prev, status: 'idle' })); - }, []); - - const reset = useCallback(() => { - abortRef.current?.abort(); - unsubscribeRef.current?.(); - setState(INITIAL_STATE); - }, []); - - useEffect(() => () => { abortRef.current?.abort(); }, []); - - return { state, startWriting, submitIntervention, stopWriting, reset }; -} -``` - ---- - -## 9. 内网接口预留设计 - -### 9.1 配置切换(config.yaml) - -```yaml -ai_writing: - search_provider: internet # ← 切换为 intranet 即启用内网模式 - intranet_search_url: "" # 内网搜索服务地址 - intranet_search_api_key: "" # 认证 Token(若需要) - intranet_search_timeout: 10 - intranet_field_map: # 内网字段名映射(与标准字段不同时填写) - title: "title" - content: "abstract" # 示例:内网叫 abstract - source: "system_name" # 示例:内网叫 system_name - published_at: "create_time" - relevance_score: "score" - recUuid: "id" -``` - -### 9.2 内网素材标准字段 - -| 标准字段 | 类型 | 必须 | 说明 | -|----------|------|:----:|------| -| `title` | string | ✅ | 文章/文档标题 | -| `content` | string | ✅ | 正文摘要,500 字以内 | -| `source` | string | ✅ | 来源系统名(如"研发知识库") | -| `published_at` | string | ✅ | 发布日期 YYYY-MM-DD | -| `relevance_score` | float 0-1 | ⬜ | 无则传 0 | -| `recUuid` | string | ⬜ | 知识库文档 ID | - -### 9.3 对接内网步骤清单 - -- [ ] 确认内网搜索 API 地址和认证方式 -- [ ] 在 `config.yaml` 的 `intranet_field_map` 中配置字段映射 -- [ ] 取消 `intranet_stub.py` 中的注释代码,调整字段映射 -- [ ] 将 `config.yaml` 中 `search_provider` 改为 `intranet` -- [ ] 调用 `GET /api/ai-writing/health` 验证连通性 -- [ ] 验证内网素材卡片在前端正常展示(包括 `department`、`classification` 字段) - ---- - -## 10. LangGraph 状态机流转图 - -``` -START - │ - ▼ -intent_parser ──── 意图解析(结构化写作配置) - │ - ▼ -researcher ──────── Agent A:关键词拆解 → 并发搜索 → 去重排序 - │ - ▼ -pause_material ──── ⏸ PAUSE-1:推送 await_user 事件,等待用户确认素材 - │ (interrupt) - ├── action=confirm ──────────────────────────▶ writer_outline - └── action=re_search ──────────────────────────▶ researcher(重搜) - -writer_outline ─── Agent B Phase1:写大纲(JSON) - │ - ▼ -pause_outline ───── ⏸ PAUSE-2:推送 await_user 事件,等待用户确认大纲 - │ (interrupt) - ├── action=confirm(含用户可能的编辑)─────────▶ writer_draft - └── action=re_outline ─────────────────────────▶ writer_outline(重规划) - -writer_draft ────── Agent B Phase2:按大纲逐段写正文 - │ - ▼ -pause_draft ─────── ⏸ PAUSE-3:推送 await_user 事件,等待用户确认草稿 - │ (interrupt) - ├── action=finalize ────────────────────────────▶ END(用户直接定稿) - ├── action=user_revise ─────────────────────────▶ writer_draft(按用户意见重写) - └── action=to_editor ────────────────────────────▶ editor - -editor ─────────── Agent C:三项独立审查,综合评分 - │ - ├── verdict=pass ────────────────────────────────▶ pause_draft(草稿确认) - ├── verdict=reject & count < max ───────────────▶ pause_review(PAUSE-4) - └── verdict=reject & count >= max ──────────────▶ pause_draft(强制到草稿确认) - -pause_review ────── ⏸ PAUSE-4:推送编辑意见,等待用户决策 - │ (interrupt) - ├── override_verdict=true ──────────────────────▶ END(强制定稿) - └── override_verdict=false ─────────────────────▶ writer_draft(按编辑意见重写) - -END -``` - ---- - -## 11. 提示词模板(弱模型专用) - -以下是针对弱模型优化的三段式提示词框架(借鉴 CrewAI 的 role+goal+backstory 结构): - -### 研究院 System Prompt - -``` -你是一名专业情报研究员(研究院)。 -你的目标:为写作任务搜集高质量素材。 -你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。 - -你只能做以下事情: -1. 从写作意图中提取搜索关键词 -2. 对搜索结果按相关度排序 -你不能写文章,不能评价内容好坏,只做信息收集。 - -所有输出必须是合法的 JSON 格式,参考示例: -{"keywords": ["关键词1", "关键词2", "关键词3"]} -``` - -### 作家 System Prompt(大纲阶段) - -``` -你是一名专业内容创作者(作家)。 -你的目标:根据素材和意图,规划清晰的文章大纲。 -你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。 - -你只能做以下事情: -1. 分析素材,提取核心观点 -2. 规划文章章节结构和每节要点 -你不能写正文,只写大纲。 - -所有输出必须是合法的 JSON,严格参考以下示例格式: -{ - "title": "文章标题", - "sections": [ - { - "section_title": "第一章标题", - "key_points": ["要点一", "要点二"], - "material_ids": ["m001"] - } - ] -} -``` - -### 作家 System Prompt(写作阶段) - -``` -你是一名专业内容创作者(作家)。 -你的目标:根据大纲和素材,写出一段高质量的文章内容。 -你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。 - -你只能做以下事情: -1. 根据给定的章节要点和素材写这一段内容 -2. 观点必须来自素材,不编造数据 -你不能修改大纲,不能写其他章节,只写当前这一段。 - -直接返回段落文字,不要 JSON,不要标题,不要解释。 -``` - -### 编辑 System Prompt - -``` -你是一名资深主编(编辑),标准极高,要求严格。 -你的目标:审查文章质量,给出客观评分和具体修改意见。 -你的工作背景:你在一家权威媒体担任主编十五年,见过各种质量的文章。 - -你只能做以下事情: -1. 从 [fact/logic/language] 某一个维度审查文章 -2. 给出该维度的评分(0-100)和具体问题列表 -你不能改写文章,不能评价其他维度,只做当前指定维度的审查。 - -所有输出必须是合法的 JSON,严格参考以下示例: -{ - "score": 82, - "issues": [ - { - "location": "第二段", - "issue": "数据来源不明确", - "suggestion": "引用素材 m001 中的具体数据,并注明来源" - } - ] -} -``` - ---- - -## 12. SSE 协议 & 干预协议设计 - -### SSE 事件类型 - -| event | 触发时机 | data 关键字段 | -|-------|----------|---------------| -| `connected` | 连接建立 | `session_id` | -| `step_start` | Agent 开始某步骤 | `agent_name`, `message` | -| `step_done` | Agent 完成某步骤 | `agent_name`, `message` | -| `materials_ready` | 研究院搜索完成 | `keywords`, `materials[]`, `summary` | -| `outline_ready` | 大纲生成完成 | `outline{title, sections[]}` | -| `draft_ready` | 草稿完成 | `title`, `markdown`, `revision_count` | -| `review_ready` | 编辑审核完成 | `review_result{verdict, scores, notes[]}` | -| `await_user` | 流程暂停,等待干预 | `pause_point`, 干预所需上下文数据 | -| `resumed` | 用户干预已提交,流程恢复 | `message` | -| `done` | 全部完成 | `message` | -| `error` | 出错 | `message` | - -### 干预 API - -``` -POST /api/ai-writing/{session_id}/resume -Content-Type: application/json - -{ - "action": "confirm", // 操作类型(见各干预点定义) - "approved_material_ids": [...], // PAUSE-1 - "extra_keywords": [...], // PAUSE-1 - "edited_outline": {...}, // PAUSE-2(null=不修改) - "outline_feedback": "", // PAUSE-2(重规划时的意见) - "user_revision_notes": "", // PAUSE-3 或 PAUSE-4 - "override_verdict": false // PAUSE-4 -} -``` - -**各干预点的 action 值**: - -| 干预点 | action 值 | 说明 | -|--------|-----------|------| -| PAUSE-1 | `confirm` | 确认当前素材继续 | -| PAUSE-1 | `re_search` | 重新搜索(可附 extra_keywords) | -| PAUSE-2 | `confirm` | 确认大纲(可附 edited_outline) | -| PAUSE-2 | `re_outline` | 重新规划(可附 outline_feedback) | -| PAUSE-3 | `finalize` | 直接定稿 | -| PAUSE-3 | `user_revise` | 按用户意见重写(附 user_revision_notes) | -| PAUSE-3 | `to_editor` | 提交编辑审核 | -| PAUSE-4 | `accept_review` | 采纳编辑意见重写 | -| PAUSE-4 | `force_finalize` | 强制定稿(override_verdict=true) | -| PAUSE-4 | `custom_revise` | 附加用户意见重写(user_revision_notes) | - ---- - -## 13. 前端 UI 组件详细设计 - -### 13.1 组件树 - -``` -AIWritingPanel.tsx (左侧面板根组件) -├── AIWritingForm.tsx (写作意图输入表单) -├── AIWritingTimeline.tsx (进度时间线,纵向排列) -│ ├── TimelineStep.tsx (单个步骤卡片) -│ │ ├── StepHeader.tsx (Agent名 + 状态图标 + 时间) -│ │ └── StepContent.tsx (步骤详情,可收起展开) -│ │ -│ ├── MaterialsCard.tsx (素材卡片区,PAUSE-1 触发展示) -│ │ ├── MaterialItem.tsx (单条素材卡片,默认收起) -│ │ │ ├── 标题 + 来源 + 时间 -│ │ │ ├── 相关度色条 -│ │ │ └── 正文摘要(可展开) -│ │ └── MaterialsToolbar.tsx (全选/全取消/补充搜索输入) -│ │ -│ ├── OutlineCard.tsx (大纲展示卡,PAUSE-2 触发展示) -│ │ ├── OutlineSection.tsx (可编辑的章节行) -│ │ └── OutlineToolbar.tsx (确认/重规划按钮) -│ │ -│ ├── ReviewCard.tsx (编辑审核结果,PAUSE-4 触发展示) -│ │ ├── ScoreBar.tsx (三项评分进度条) -│ │ └── IssueList.tsx (问题清单,按严重程度分组) -│ │ -│ └── InterventionCard.tsx (用户干预操作区,每个 PAUSE 点渲染) -│ ├── 操作按钮组 -│ └── 补充意见输入框 -│ -└── AIWritingStatus.tsx (底部状态栏:当前 Agent + 进度) -``` - -### 13.2 MaterialItem 组件规格 - -``` -┌─────────────────────────────────────────────────────────┐ -│ ☑ [标题(最多两行截断)] ▶ │ ← 点击展开 -│ 来源网站 · 2024-01-15 · ████████░░ 82% │ -└─────────────────────────────────────────────────────────┘ - -展开后: -┌─────────────────────────────────────────────────────────┐ -│ ☑ [标题] ▼ │ -│ 来源网站 · 2024-01-15 · ████████░░ 82% │ -│ ───────────────────────────────────────────────── │ -│ [正文摘要,最多 200 字,超出显示"...展开"] │ -│ │ -│ [查看原文 →](若有 url) │ -└─────────────────────────────────────────────────────────┘ -``` - -**相关度色条颜色规则**: -- `>= 0.8`:绿色(高相关) -- `0.5 ~ 0.8`:橙色(中相关) -- `< 0.5`:红色(低相关,建议取消勾选) - -### 13.3 OutlineCard 组件规格 - -``` -┌──────────────────────────────────────────────────────────┐ -│ 大纲(可编辑) [全部展开▼] │ -├──────────────────────────────────────────────────────────┤ -│ 文章标题:[可内联编辑] │ -│ │ -│ ▶ 第一部分:引言与背景(点击展开要点) │ -│ ▶ 第二部分:核心分析 │ -│ ▶ 第三部分:结论与建议 │ -│ │ -│ (展开后) │ -│ ▼ 第二部分:核心分析 │ -│ • 要点一(可编辑) │ -│ • 要点二(可编辑) │ -│ + 添加要点 │ -│ 引用素材:m001 m002(Tag 样式) │ -├──────────────────────────────────────────────────────────┤ -│ [确认大纲,开始写作] [重新规划大纲...] │ -└──────────────────────────────────────────────────────────┘ -``` - -### 13.4 InterventionCard 组件规格 - -``` -┌──────────────────────────────────────────────────────────┐ -│ ⏸ 需要您的确认才能继续 │ -│ ─────────────────────────────────────────────────────── │ -│ [PAUSE-1 素材确认时] │ -│ 已搜集到 8 条素材(已为您默认全选) │ -│ 您可以取消勾选不相关的素材,或补充关键词 │ -│ │ -│ 补充关键词:[___________________] [+追加搜索] │ -│ │ -│ [✓ 确认素材,开始规划大纲] [↺ 重新搜索] │ -└──────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────┐ -│ ⏸ 需要您的确认才能继续 │ -│ ─────────────────────────────────────────────────────── │ -│ [PAUSE-3 草稿确认时] │ -│ 初稿已完成(共 856 字) │ -│ 编辑评分:81分(通过线 80 分) │ -│ │ -│ 您的修改意见(可选): │ -│ [___________________________________] │ -│ │ -│ [✓ 确认定稿] [✎ 按我的意见修改] [→ 让编辑审核] │ -└──────────────────────────────────────────────────────────┘ -``` - -### 13.5 ReviewCard 组件规格 - -``` -┌──────────────────────────────────────────────────────────┐ -│ 编辑审核结果(第 1 次修改) 综合评分:78 / 100 │ -│ │ -│ 事实准确性 ████████░░ 82 (通过) │ -│ 逻辑结构 ██████░░░░ 72 (需改进) │ -│ 语言文字 █████████░ 88 (优秀) │ -│ │ -│ ── 修改意见 ────────────────────────────────────────── │ -│ │ -│ [major] 逻辑 · 第三段:论点跳跃,缺少过渡 │ -│ → 建议:在第三段开头增加连接上下文的过渡句 │ -│ │ -│ [minor] 事实 · 第二段:引用数据未标注来源 │ -│ → 建议:补充"据素材 m002 显示" │ -│ │ -│ 您的补充意见(可选): │ -│ [___________________________________] │ -│ │ -│ [↺ 采纳编辑意见,让作家重写] [✓ 忽略编辑,强制定稿] │ -└──────────────────────────────────────────────────────────┘ -``` - -### 13.6 进度时间线整体效果 - -``` -左侧面板时间线(纵向) - -○ ── 研究院 ← 完成(绿色圆点) -│ 搜集到 8 条素材 -│ ┌───────────────────────────┐ -│ │ 素材列表(默认收起) ▶ │ ← 点击展开素材卡片 -│ └───────────────────────────┘ -│ ┌──────────────────────────────────────────────┐ -│ │ ⏸ 需要您确认素材,才能继续 │ -│ │ [✓ 确认] [↺ 重新搜索] │ -│ └──────────────────────────────────────────────┘ - -○ ── 作家(大纲) ← 完成(绿色圆点) -│ 已规划 3 个章节 -│ ┌───────────────────────────┐ -│ │ 大纲预览(默认收起) ▶ │ -│ └───────────────────────────┘ -│ ┌──────────────────────────────────────────────┐ -│ │ ⏸ 请确认大纲后继续写作 │ -│ │ [✓ 确认] [✎ 编辑大纲] [↺ 重规划] │ -│ └──────────────────────────────────────────────┘ - -◌ ── 作家(写作) ← 进行中(动画圆点) -│ 正在写作第二章... - -◌ ── 编辑 ← 等待中(灰色圆点) - -◌ ── 定稿 ← 等待中 -``` - ---- - -## 14. 文件目录结构 - -``` -# 后端新增(不修改任何已有文件) -offline-backend-20260512/backend/ -├── app/gateway/routers/ -│ └── ai_writing.py ← 新建 -└── packages/harness/deerflow/agents/ - └── ai_writing/ ← 新建目录 - ├── __init__.py - ├── state.py - ├── graph.py - ├── nodes/ - │ ├── __init__.py - │ ├── intent_parser.py - │ ├── researcher.py - │ ├── writer_outline.py - │ ├── writer_draft.py - │ └── editor.py - ├── search/ - │ ├── __init__.py - │ ├── base.py ← 抽象接口(内网预留) - │ ├── internet_search.py - │ └── intranet_stub.py ← 内网桩 - └── prompts/ - ├── researcher_prompts.py - ├── writer_prompts.py - └── editor_prompts.py - -# 前端新增(不修改已有组件) -frontend-web/src/open-canvas/ -├── api/ -│ └── ai-writing.ts ← 新建 -├── types/ -│ └── ai-writing.ts ← 新建 -├── hooks/ -│ └── useAIWriting.ts ← 新建 -└── components/ - └── ai-writing/ ← 新建目录 - ├── AIWritingPanel.tsx ← 左侧面板根组件 - ├── AIWritingForm.tsx ← 写作意图输入表单 - ├── AIWritingTimeline.tsx ← 进度时间线 - ├── TimelineStep.tsx ← 单步骤卡片 - ├── MaterialsCard.tsx ← 素材卡片区 - ├── MaterialItem.tsx ← 单条素材 - ├── MaterialsToolbar.tsx ← 素材操作工具栏 - ├── OutlineCard.tsx ← 大纲展示与编辑 - ├── OutlineSection.tsx ← 单章节行 - ├── ReviewCard.tsx ← 编辑审核结果 - ├── ScoreBar.tsx ← 评分进度条 - ├── IssueList.tsx ← 问题清单 - └── InterventionCard.tsx ← 用户干预操作区 -``` - ---- - -## 15. 分步开发检查清单 - -### 阶段一:后端骨架(2 天) - -- [ ] 创建 `ai_writing/` 目录结构(所有 `__init__.py`) -- [ ] 实现 `state.py`(完整 AIWritingState 定义) -- [ ] 实现 `search/base.py`(抽象接口) -- [ ] 实现 `search/internet_search.py`(接入项目已有搜索工具) -- [ ] 实现 `search/intranet_stub.py`(桩,返回空列表) -- [ ] 实现 `nodes/intent_parser.py`(意图解析 → 结构化配置) -- [ ] 实现 `nodes/researcher.py`(Agent A,两步:关键词+搜索) -- [ ] 实现 `nodes/writer_outline.py`(Agent B Phase1,只写大纲) -- [ ] 实现 `nodes/writer_draft.py`(Agent B Phase2,逐段写正文) -- [ ] 实现 `nodes/editor.py`(Agent C,三次独立调用) -- [ ] 实现 `graph.py`(含 4 个 interrupt 干预节点) -- [ ] 实现 `app/gateway/routers/ai_writing.py`(start + stream + resume + state) -- [ ] 在 `app.py` 注册路由(追加一行) -- [ ] `curl` 测试 `/api/ai-writing/start` 和 `/api/ai-writing/{id}/stream` - -### 阶段二:后端干预验证(1 天) - -- [ ] 测试 PAUSE-1(素材确认):验证 `interrupt()` 暂停 + `resume` 恢复 -- [ ] 测试 PAUSE-2(大纲确认):验证编辑大纲后继续写作 -- [ ] 测试 PAUSE-3(草稿确认):验证三种 action(定稿/重写/编辑) -- [ ] 测试 PAUSE-4(审核确认):验证打回循环和强制定稿 -- [ ] 测试最大循环上限(revision_count >= max_revisions) - -### 阶段三:前端进度展示(2 天) - -- [ ] 创建 `types/ai-writing.ts`(完整类型定义) -- [ ] 创建 `api/ai-writing.ts`(start + SSE subscribe + resume) -- [ ] 创建 `hooks/useAIWriting.ts`(状态管理 + 事件处理) -- [ ] 创建 `AIWritingTimeline.tsx` + `TimelineStep.tsx`(时间线骨架) -- [ ] 创建 `ScoreBar.tsx`(评分进度条) -- [ ] 在 `CanvasResizableLayout.tsx` 引入 `AIWritingPanel`(新增 import,不修改现有逻辑) - -### 阶段四:素材卡片(1 天) - -- [ ] 创建 `MaterialItem.tsx`(收起/展开,相关度色条,勾选框) -- [ ] 创建 `MaterialsCard.tsx`(素材列表,默认收起,显示素材数量) -- [ ] 创建 `MaterialsToolbar.tsx`(全选/取消,补充关键词输入) -- [ ] 创建 `InterventionCard.tsx` for PAUSE-1(确认/重搜按钮) -- [ ] 联调测试:素材 SSE 事件 → 素材列表渲染 → 用户确认 → resume 请求 - -### 阶段五:大纲交互(1 天) - -- [ ] 创建 `OutlineSection.tsx`(可内联编辑章节标题和要点) -- [ ] 创建 `OutlineCard.tsx`(大纲树形展示,默认收起) -- [ ] 创建 `InterventionCard.tsx` for PAUSE-2(确认/重规划按钮 + 反馈输入) -- [ ] 测试:编辑大纲后提交 → 后端收到 `edited_outline` → 按修改后大纲写作 - -### 阶段六:草稿确认与编辑审核(1 天) - -- [ ] 创建 `IssueList.tsx`(问题清单,按 major/minor 分组) -- [ ] 创建 `ReviewCard.tsx`(评分 + 问题清单) -- [ ] 创建 `InterventionCard.tsx` for PAUSE-3 和 PAUSE-4 -- [ ] 测试完整流程(搜索 → 大纲确认 → 写作 → 编辑 → 打回 → 重写 → 定稿) - -### 阶段七:弱模型调优(2 天) - -- [ ] 为每个 Agent 添加 Few-Shot JSON 示例 -- [ ] 添加完善的 JSON 解析容错(try/except + fallback 策略) -- [ ] 调整各 Agent 的 prompt,确保弱模型返回合法 JSON -- [ ] 调整 `pass_threshold`(从 80 开始,根据实际测试调整) -- [ ] 测试素材截断逻辑(确保不超过 token 限制) - -### 阶段八:内网对接(未来) - -- [ ] 确认内网搜索 API 文档和认证方式 -- [ ] 在 `config.yaml` 配置 `intranet_field_map` 字段映射 -- [ ] 取消 `intranet_stub.py` 中的注释代码并调整 -- [ ] 切换 `search_provider: intranet` 测试连通性 -- [ ] 验证内网素材卡片(包含 `department`、`classification` 字段)正常展示 - ---- - -> **架构原则**:后端新路由用 `/api/ai-writing/` 前缀完全隔离;前端新建独立组件目录, -> 通过在 `CanvasResizableLayout.tsx` 中新增导入接入,不修改任何已有组件。 -> 参考:Stanford STORM 多 Agent 写作流程、LangGraph HITL interrupt 机制、 -> CrewAI 角色锁定 Prompt 设计、GPT-Researcher 搜索接口抽象。 diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md new file mode 100644 index 00000000..c04bce2e --- /dev/null +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -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 `。 + +| 方法 | 路径 | 用途 | +|---|---|---| +| 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": "", + "agent_name": "" + } +``` + +#### `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": "" + } +``` + +### 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//` 下生成新目录。长期会累积大量协调智能体目录,**未来需要清理脚本**(标记 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` | diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts new file mode 100644 index 00000000..3dfab674 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -0,0 +1,162 @@ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +export type ThreadIdMap = Record; + +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 { + 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 { + 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; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index cc356e7b..bea95ecd 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -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> = [ + { 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 = {}): 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 = { + "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(1); const [inputText, setInputText] = useState(""); @@ -180,17 +200,15 @@ export default function RoundtablePlanningPage() { const [consensusPercentage, setConsensusPercentage] = useState(89.4); const [selectedAgentId, setSelectedAgentId] = useState(null); const [activeSpeakerId, setActiveSpeakerId] = useState(null); - const [agentThinkingStatus, setAgentThinkingStatus] = useState(null); - const [simRoundIndex, setSimRoundIndex] = useState(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([ { 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([ - { - 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([]); // Inputs for Step 2 conversation simulation controls const [roundTableSpeed, setRoundTableSpeed] = useState("正常"); const [isRoundTableRunning, setIsRoundTableRunning] = useState(true); const [customAgentInput, setCustomAgentInput] = useState(""); + // Multi-agent orchestration state (lives only for one step-2 visit). + const [threadIds, setThreadIds] = useState(null); + const [coordinatorName, setCoordinatorName] = useState(null); + const [isInitializing, setIsInitializing] = useState(false); + const [initError, setInitError] = useState(null); + const [isOrchestrating, setIsOrchestrating] = useState(false); + const orchestrationCancelRef = useRef(false); + const step2InitRef = useRef(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() {
-
diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index e0dabe0e..940aca3c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -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")