diff --git a/frontend-web/docs/hebing-1.md b/frontend-web/docs/hebing-1.md deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend-web/docs/hebing.md b/frontend-web/docs/hebing.md deleted file mode 100644 index 4bbe4153..00000000 --- a/frontend-web/docs/hebing.md +++ /dev/null @@ -1,140 +0,0 @@ -# App.tsx 路由与页面结构重构需求(首页与业务页分离) - -## 一、需求背景 - -当前 `frontend-web/src/App.tsx` 同时承载了: - -1. 登录/首页相关路由(如 `/login`、`/landing`) -2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`) -3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`) - -这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。 - ---- - -## 二、目标(你提出的核心诉求) - -你希望: - -1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。 -2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。 -3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。 - -> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。 - ---- - -## 三、重构范围 - -### 1)保留在 `App.tsx` 的内容 - -- 全局 Provider:`HashRouter`、`AppProviders` -- 登录/首页路由: - - `/` - - `/login` - - `/login/:username` - - `/landing` -- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一) - -### 2)迁移出 `App.tsx` 的内容 - -将以下与工作台有关的路由从 `App.tsx` 抽离: - -- `/workspace`(重定向) -- `/workspace/chats` -- `/workspace/chats/:thread_id` -- `/workspace/agents` -- `/workspace/agents/new` -- `/workspace/agents/:agent_id/chats`(重定向) -- `/workspace/agents/:agent_id/chats/:thread_id` -- `/workspace/scheduled` -- `/workspace/scheduled/tasks` -- `/workspace/scheduled/runs/:run_id` - -以及配套的包装组件职责: - -- `WorkspaceLayout` -- `ChatRuntime` - ---- - -## 四、建议的新结构(方案) - -## 方案 A(推荐):新增“业务路由容器组件” - -在 `pages` 下新增一个路由容器组件,例如: - -- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`) - -职责: -- 专门维护 `/workspace/*` 相关路由 -- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime` -- 输出一个可被 `App.tsx` 挂载的路由节点 - -同时在 `App.tsx` 里只保留: -- 登录/首页/404 -- 一个 `Route path="/workspace/*" element={} />` - ---- - -## 五、“再建立一个页面放置导航”的落地解释 - -你说的“建立一个页面放置这些导航”,可以有两种理解: - -1. **路由容器页(推荐)**: - - 不强调视觉 UI,重点是集中管理导航与路由跳转规则。 - - 即上面的 `WorkspaceRoutes.tsx`。 - -2. **可见的导航页(可选)**: - - 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。 - - 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。 - -当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。 - ---- - -## 六、实施步骤(开发任务拆解) - -1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。 -2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。 -3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。 -4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。 -5. 校验以下访问路径行为一致: - - `/workspace` 重定向 - - chats/agents/scheduled 各页面可正常进入 - - 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常 - - 404 不受影响 -6. 运行前端并做手工回归。 - ---- - -## 七、验收标准 - -1. `App.tsx` 明显瘦身,主要负责顶层入口路由。 -2. `/workspace/*` 全部由新建组件统一管理。 -3. 功能行为与改造前一致(或符合新定义)。 -4. 代码可读性提升:登录入口与业务入口职责分离。 - ---- - -## 八、注意事项 - -1. 当前项目使用 `HashRouter`,路由写法需保持兼容。 -2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。 -3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。 -4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。 - ---- - -## 九、建议的命名(可直接采用) - -- 新增组件文件:`src/pages/WorkspaceRoutes.tsx` -- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx` - ---- - -## 十、后续可选优化 - -1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。 -2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。 -3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。 diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index c04bce2e..eb924c40 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -1,14 +1,47 @@ # 多智能体圆桌研讨 · 后端开发文档 -本文档面向需要维护或扩展"多智能体圆桌研讨"功能后端的开发者。前端 API 速查请看 `multi-agent-api.md`,此文档关注**实现细节、设计取舍、性能特征**。 +本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`、`multi-agent-step1-api.md`、`multi-agent-step3-api.md`;推荐弹窗见 `recommend-agents-progress.md`;前端实现见 `multi-agent-frontend-dev.md`。本文关注**实现细节、协议帧、设计取舍**。 + +源码内另有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py` 及 `clarification_utils.py`),与本文互为补充。 --- -## 1. 功能定位 +## 1. 功能定位与三步数据流 -把"任务目标 + 用户约束"丢给一个**协调智能体**,由它通过 `agent_orchestration` 技能调度 6 个固定职能的子智能体(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述)协同产出方案,直到协调智能体认为信息足够、停止派活并输出最终结论为止。 +对应前端页面:`/page/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。 -对应前端页面:`/page/roundtable/planning` 的 **Step 2 多智能体研讨**。 +``` +Step 1 任务意图理解 + POST /api/intent/init → 建 thread,绑定内置 roundtable-intent + POST /api/intent/stream (多轮) → 澄清对话,终态 [INTENT_READY] + summary + + ↓ intentReady { objective, constraints, assumptions } + +推荐弹窗(Step 1 → Step 2 之间,可选) + POST /api/recommend/stream → 从候选池选 2–6 个席位,[RECOMMEND_READY] + picks + + ↓ selectedAgents[] + +Step 2 多智能体圆桌研讨 + POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(席位列表由前端传入) + POST /api/multi-agent/run/stream → leader 派活 / special 执行 / 澄清 + + ↓ lastLeaderContent、各席位交付、hasConsensus + +Step 3 最终方案展示 + GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件 + GET /api/threads/{tid}/artifacts/{path} → 预览或下载 + (总控结论、席位文本主要来自 Step 2 前端内存,一般不再调 run/stream) +``` + +| 步骤 | 前端 `currentStep` | 专属 Router | 内置 Agent | +|------|-------------------|-------------|------------| +| 1 | 1 | `intent.py` | `roundtable-intent`(固定 id,复用) | +| 1→2 | 推荐弹窗 | `recommend.py` | `roundtable-recommender`(每请求新建 thread) | +| 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 | +| 3 | 3 | `artifacts.py`(通用) | 无新 agent | + +所有接口需 `Authorization: Bearer `。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:单次请求卡住 40s+)。 --- @@ -17,13 +50,16 @@ ``` offline-backend-20260512/backend/ ├── app/gateway/ -│ ├── app.py # 注册 multi_agent.router +│ ├── app.py # include_router: multi_agent, intent, recommend │ └── routers/ -│ └── multi_agent.py # 本功能唯一新增的 router +│ ├── multi_agent.py # Step 2:init(SSE) + run/stream +│ ├── intent.py # Step 1:init + stream +│ ├── recommend.py # Step 1→2:推荐 stream +│ └── artifacts.py # Step 3:列出/下载 thread 产出 └── .deer-flow/agents/ - ├── roundtable-intelligence/ # 6 个内置子智能体目录 - │ ├── config.yaml # ├── id / name / description - │ └── SOUL.md # └── 角色 prompt + ├── roundtable-intent/ # Step 1 任务理解(SOUL 含完整工作流,可不挂 Skill) + ├── roundtable-recommender/ # 推荐智能体 + ├── roundtable-intelligence/ # 默认可选席位(6 个,前端可替换) ├── roundtable-environment/ ├── roundtable-solution-design/ ├── roundtable-risk-review/ @@ -31,329 +67,371 @@ offline-backend-20260512/backend/ └── roundtable-summary/ ``` -**零代码改动构造内置 agent**:6 个目录通过既有的 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。 +**内置 agent 注册**:各 `roundtable-*` 目录通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次 `/api/agents` 请求时 upsert 进 `agents` 表,`user_id IS NULL` 表示内置。 + +**共享工具逻辑**:`packages/harness/deerflow/tools/builtins/clarification_utils.py` 中的 `resolve_allow_multiple()` 被 `intent.py`、`multi_agent.py` 与 `ClarificationMiddleware` 共用,决定选项卡片默认单选还是多选。 --- ## 3. 接口总览 -路由前缀 `/api/multi-agent`,所有接口需要 `Authorization: Bearer `。 +| 方法 | 路径 | 步骤 | 用途 | +|------|------|------|------| +| POST | `/api/intent/init` | 1 | 创建意图澄清 thread | +| POST | `/api/intent/stream` | 1 | SSE,一轮用户对话 | +| POST | `/api/recommend/stream` | 1→2 | SSE,一次性推荐席位 | +| POST | `/api/multi-agent/init` | 2 | SSE,并行拉起席位 + 协调智能体 | +| POST | `/api/multi-agent/run/stream` | 2 | SSE,leader 或 special 一轮 | +| GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 | +| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出 | -| 方法 | 路径 | 用途 | -|---|---|---| -| POST | `/api/multi-agent/init` | 一次性创建协调智能体 + 6 个子席位线程,返回 `thread_ids` 映射 | -| POST | `/api/multi-agent/run/stream` | SSE 流式驱动一次 leader 或 special 调用 | - -字段细节见 `multi-agent-api.md`,这里不重复。 +字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。 --- -## 4. 内部实现详解 +## 4. Step 1:任务理解(`intent.py`) -### 4.1 `/init` 内部流程 +### 4.1 定位 -``` -[阶段1] 并行准备 6 个子智能体席位 (asyncio.gather) - ├─ GET /api/agents/{id} × 6 → 取 name + description - └─ POST /api/threads × 6 → 建新 thread +轻量包装 Lead Agent 运行时,固定跑内置 `roundtable-intent`。不做 broadcast、不派活;每轮用户消息对应一次 `/stream`。 -[阶段2] 创建协调智能体 - ├─ POST /api/threads → 协调智能体的 thread - └─ POST /api/agents (with model + skills=[agent_orchestration] + soul) - └─ soul 里包含可调度的 6 个 agent_name + description 列表 +### 4.2 `POST /api/intent/init` + +1. `_create_thread()` → LangGraph thread +2. 返回固定 `agent_id: "roundtable-intent"`(**不**每次新建 agent) + +与 Step 2 `/init` 的区别:无协调智能体、无 `agent_names` 列表。 + +### 4.3 `POST /api/intent/stream` + +**上游**:`_run_payload(agent_name=roundtable-intent, excluded_tools=[...])`,硬排除 `agent_orchestration`、`write_file`、`web_search` 等,**仅保留** `ask_clarification` 等澄清能力,防止模型写方案文档。 + +**流式**:透传 LangGraph SSE → 解析 `_parse_last_messages(captured)`。 + +**终态帧判定顺序(重要)**: + +1. **先**扫描全部 AI 消息中的 `[INTENT_READY]\n\`\`\`json {...}\`\`\``(正则 `_INTENT_READY_PATTERN`) + - 命中 → `status: "done"`,`summary` 为解析后的 JSON,`content` 为含标记的整段文本 + - **优先于** `ask_clarification`,避免「已汇总仍被当成待澄清」 + +2. 否则查最后一则带 `tool_calls` 的 AIMessage 是否含 `ask_clarification` + - 命中 → `status: "clarification"`,附带结构化字段供前端选项卡片: + +```json +{ + "status": "clarification", + "content": "<引导语/前言>", + "question": "...", + "clarification_type": "missing_info", + "clarification_context": "...", + "options": ["..."], + "allow_custom": true, + "allow_multiple": false, + "summary": null +} ``` -**协调智能体 SOUL 是动态拼出来的**(`multi_agent.py:216-224`): +`allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定:**默认单选**(点击即发送);仅模型显式传 `allow_multiple: true` 时为多选。 -```python -soul = ( - "#核心定位:\n" - "用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,...\n" - "#调度方法:\n" - "通过使用agent_orchestration技能调用其他智能体干活,...\n" - "#可调度智能体:\n" - f"{descriptions}" # ← 6 个子席位的 name+description -) +3. 否则 → `status: "asking"`(继续对话,无结构化澄清) + +**`summary` 形状**(`done` 时): + +```json +{ + "objective": "一句话目标", + "constraints": ["硬约束 1", "..."], + "assumptions": ["可选假设"] +} ``` -由于 `main_agent_name` 由前端每次新建(`roundtable-coordinator-{timestamp36}`),所以协调智能体本身是一次性的、用完即弃。 +### 4.4 内置 Agent:`roundtable-intent` -### 4.2 `/run/stream` 内部流程 +- 配置:`.deer-flow/agents/roundtable-intent/config.yaml` + `SOUL.md` +- SOUL 内写清:工作流、`ask_clarification` 用法、`[INTENT_READY]` 格式、禁止派活/写文件 +- **可不配置 `skills`**;若配置 Skill,SOUL 要求不得偏离澄清与结束协议 -接收 `agent_type` 后走两条分支: +--- -#### `agent_type = "leader"` (协调智能体) +## 5. 推荐智能体(`recommend.py`,Step 1 → 2) + +### 5.1 定位 + +无状态单次调用:每次请求内部 `_create_thread()`,不暴露 `/init`。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(排除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。 + +### 5.2 `POST /api/recommend/stream` + +**入参校验**: + +- `intent.objective` 必填 +- `candidates` 非空,且至少一个有效 `agent_id` + +**Prompt 拼装**(`_build_prompt`):把 objective/constraints/assumptions 与候选池格式化为一条 user message。 + +**上游**:`roundtable-recommender`,`excluded_tools` 含 `ask_clarification`、`agent_orchestration` 等(纯判断,不调工具)。 + +**终态**: + +- 解析 `[RECOMMEND_READY]\n\`\`\`json {"picks":[{"agent_id","reason"},...]}\`\`\`` +- `_validate_picks()`:只保留候选池内的 id、去重、数量 clamp 到 2–6 +- 有效 picks ≥ 2 → `status: "done"`,`picks` 有值 +- 否则 → `status: "asking"`(前端回退默认席位列表) + +```json +{ + "status": "done", + "content": "<自然语言推荐理由>", + "picks": [{"agent_id": "roundtable-risk-review", "reason": "..."}] +} +``` + +**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤,避免污染推荐理由区。 + +--- + +## 6. Step 2:多智能体研讨(`multi_agent.py`) + +### 6.1 `/init` 内部流程(SSE,非一次性 JSON) + +当前实现为 **流式 init**,便于前端按席位点亮 UI: ``` -1. broadcast「总控收到用户消息」到所有非自己线程 (asyncio.gather) -2. 拼 RunCreateRequest,转发到 POST /api/threads/{coordinator}/runs/stream +asyncio.as_completed(各席位 _prepare_sub_agent) + 每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"} + +协调智能体: + POST /api/threads → coord_thread + POST /api/agents(skills=[agent_orchestration],SOUL 动态拼入可调度列表) + → data: {"event":"coordinator_ready","main_agent_name","thread_id"} + +→ data: {"event":"init_done","main_agent_name","thread_ids":{...}} +失败 → data: {"event":"error","detail":"..."} +``` + +**席位列表**:由前端 `agent_names` 传入(推荐弹窗确认后的 2–6 个 id),不再写死 6 个。 + +**协调智能体 SOUL**(动态拼接)包含:只负责协调汇总、通过 `agent_orchestration` 派活、`agent_name` 必须用英文 id(不要用中文显示名)。 + +### 6.2 `/run/stream` — `agent_type = "leader"` + +``` +1. (可选)pre_broadcast:总控收到用户消息 → 广播到非自己的 thread +2. 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": "" - } +3. 透传 LangGraph 事件 +4. 解析 messages → tool_calls + +分支 A — ask_clarification: + status: "clarification" + + question / options / allow_multiple / clarification_type / clarification_context + (空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环) + +分支 B — 有 agent_orchestration 派活: + 并发 _broadcast 派活消息到相关 thread + status: [["sub_agent_id", "task_text"], ...] + +分支 C — 无 tool_calls: + status: [] // 共识达成,content 为总控最终文本 ``` -#### `agent_type = "special"` (子智能体) +### 6.3 `/run/stream` — `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": "" - } +1. POST /api/threads/{sub}/runs/stream + - excluded_tools: web_search, ask_clarification, present_files, view_image, agent_orchestration +2. 透传 + 解析最后 AI 文本 +3. post_broadcast:子智能体完成交付 → 广播到非自己的 thread +4. status: "<完成提示字符串>" 或业务自定义;content 为交付正文;agent_name 为子 agent id ``` -### 4.3 _broadcast 机制 +### 6.4 `_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)) + # 对每个 target: GET state → append human message → POST state ``` -`_append_thread_message` 对每个目标 thread: +目的:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文。`skip` 通常含发起方与接收方,避免回声。 -``` -GET /api/threads/{id}/state # 拿当前 messages 列表 -state.values.messages.append({"type":"human","content":...}) -POST /api/threads/{id}/state # 写回完整 state -``` +### 6.5 终态帧协议(leader / special / clarification) -这是**为了让每个子线程的对话历史里都能看到全局上下文**(其他席位说了什么、协调智能体怎么派活的)。子智能体下次被调用时,模型自然能基于这份"群消息回放"做判断。 +前端 `streamMultiAgent` 用 `isFinalStatusFrame()` 识别:**同时存在 `status` 与 `content`**。 -**关键不变量**:`skip` 列表通常包含发起方和接收方,避免发起方收到回声、接收方收到重复消息。 +| `status` 类型 | 含义 | +|---------------|------| +| `[["agent","task"],...]` | 派活列表;`[]` = 共识 | +| `"clarification"` | 总控追问,需用户回复后再 leader | +| `"子智能体 X 完成..."` | special 完成(字符串) | +| `"error"` | 异常 | + +与 Step 1 的 `clarification` 帧字段对齐(`question`、`options`、`allow_multiple` 等),便于复用选项卡片组件。 --- -## 5. 6 个内置 agent SOUL 设计要点 +## 7. Step 3:产出文件(`artifacts.py`,通用 Thread API) -每个 `roundtable-*` 的 SOUL.md 遵循统一结构: +Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力。 -``` -# {席位名} +### 7.1 `GET /api/threads/{thread_id}/artifacts` -## 角色定位 -你是"多智能体圆桌研讨"会议的 **{席位}** 席位。会议由"总控协调智能体"统一调度, -你只在被派到任务时发言,不需要主动推动议程。 - -## 核心职责 -- ...具体职责... - -## 输出风格 -- 每次发言以 {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 内部数据形状,迁移成本高。所以后端在流末尾追加一个**协议层的总结帧**: +- 鉴权:`threads:read`,owner 校验 +- 扫描:`users/{user_id}/threads/{thread_id}/user-data/outputs/`(`collect_outputs_listing`) +- 目录不存在 → `files: []`(非 404) ```json { - "status": [["agent_name", "task"], ...] | "<完成提示>", - "content": "<最终 AI 文本>", - "agent_name": "<本轮 agent>" + "thread_id": "...", + "files": [ + { + "path": "mnt/user-data/outputs/report.md", + "name": "report.md", + "size_bytes": 12480, + "mime_type": "text/markdown", + "modified_at": "2026-05-23T14:32:30.123Z" + } + ] } ``` -前端 `streamMultiAgent` helper 通过 `isFinalStatusFrame()` 识别该帧(必须同时有 `status` + `content` 键),与其他 LangGraph 帧区分。 +前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 可对协调 thread 与各席位 thread 分别拉取,合并为下载清单。 + +### 7.2 `GET /api/threads/{thread_id}/artifacts/{path}` + +- 虚拟路径前缀 `mnt/user-data/outputs/...` +- 路径穿越防护;`download=true` 强制附件下载 +- HTML/XHTML/SVG 等 active content 始终按下载处理 + +**总控结论、各席位最终段落**:主要来自 Step 2 已缓存的 `lastLeaderContent`、`step2RoundtableDialogues`;无需为 Step 3 新增聚合接口(可选优化见 `multi-agent-step3-api.md` 中的 `sessions/summary` 草案)。 --- -## 7. 性能特征 +## 8. 内置席位 SOUL 设计要点 -### 7.1 已应用的优化 +每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。 -| ID | 优化 | 收益 | -|---|---|---| -| **A** | `/init` 阶段 1 并行化(`asyncio.gather`) | 阶段 1 从 2-4s 降到 0.3-0.7s | -| **C** | 删除 `/init` 末尾的 intro 广播(SOUL 已含定位) | 阶段 3 整段消失,省 1-2s | +| 席位 | agent_id | emoji | 核心职责 | +|------|----------|-------|----------| +| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险 | +| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 承载力 | +| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 | +| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 | +| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源 | +| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 | -应用前 `/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 解析开销。 +子智能体**不**安装 `agent_orchestration`;仅协调智能体安装,保证单调度源。 --- -## 8. 依赖与扩展点 +## 9. 关键设计决策 -### 8.1 `agent_orchestration` 技能 +### 9.1 HTTP loopback -协调智能体强依赖此技能,SOUL 里硬编码: +三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。 -``` -通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活 -``` +### 9.2 协调智能体每次新建 -技能的实际派活产物是一个形如 `python3 scripts/agent_orchestration.py "agent_name" "task"` 的 shell 调用文本,后端用正则提取: +`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录,需后续 GC 策略。 -```python -_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"') -``` +### 9.3 澄清协议统一 -**若该技能被删除/改名**,leader 流程会拿不到 `tool_calls`,直接走"无派活=共识达成"分支。注意 `skills/` 下必须保留此技能。 +Step 1 与 Step 2 leader 均通过 `ask_clarification` + 终态 `clarification` 帧驱动选项卡片;`[INTENT_READY]` / 派活列表 / `[]` 各自表示阶段结束语义,互不混用。 -### 8.2 模型选择 +### 9.4 选项单选默认 -前端通过 `GET /api/models` 拿到列表,在 `model` 字段传 `Model.name` (与 `config.yaml.models[].name` 对应)。 +`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。 -- `/init` 的 `model` 字段 → 协调智能体的默认模型 (写进 agents 配置) -- `/run/stream` 的 `model` 字段 → 该次具体运行用什么模型 (写进 `context.model_name`) +### 9.5 SSE 末尾状态帧 -前端允许"逐席位覆盖模型",由前端在 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. 圆桌可视化(圆圈环形图)加一个气泡 + 头像 - -**后端代码完全不需要改**。 +LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端与 LangGraph 帧区分解析。 --- -## 9. 故障排查 +## 10. 性能特征 + +| 阶段 | 已做优化 | 典型耗时 | +|------|----------|----------| +| Step 2 `/init` | 席位 `as_completed` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) | +| LLM `/run/stream` | — | 数百 ms~数十秒(主因) | +| Step 1/推荐 | 单 thread,无 broadcast | 与模型相关 | + +仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用(见原 B/D 优化项)。 + +--- + +## 11. 依赖与扩展点 + +### 11.1 `agent_orchestration` 技能 + +协调智能体派活依赖此技能;正则 `_TOOL_CALL_PATTERN` 从 tool 输出提取 `agent_name` 与 task。技能缺失时 leader 易变成「无 tool_calls → 空 status → 前端误判共识」。 + +### 11.2 模型 + +`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型。 + +### 11.3 新增席位 + +1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md` +2. 前端候选池 / 推荐逻辑纳入该 id +3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改** + +### 11.4 新增 Step 1 工具 + +若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 白名单逻辑(当前为黑名单,仅 `ask_clarification` 实际可用)。 + +--- + +## 12. 故障排查 | 现象 | 可能原因 | 排查 | -|---|---|---| -| `/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` 状态码与响应体 | +|------|----------|------| +| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY | +| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple` | +| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 | Network + 后端 `multi_agent` 日志 | +| leader 立即 `status: []` | 未调用 orchestration 技能 | 换模型或加强 SOUL | +| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧 | +| Step 3 无文件 | 子 agent 未写 outputs | `GET .../artifacts` 对子 thread_id 逐个查 | -### 服务端日志关键字 +### 日志关键字 ```bash -# multi_agent 路由的日志 -grep "multi_agent" .runtime/logs/*.log - -# 派活解析失败 +grep "intent stream" .runtime/logs/*.log +grep "multi_agent\|multi-agent" .runtime/logs/*.log +grep "recommend stream" .runtime/logs/*.log grep "dispatch failed" .runtime/logs/*.log - -# 广播失败(通常无害) -grep "update state .* failed" .runtime/logs/*.log ``` --- -## 10. 测试入口 - -后端目前没有针对本功能的单测。建议补: +## 13. 测试建议 ``` -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 +backend/tests/test_intent.py # init、INTENT_READY、clarification 优先级 +backend/tests/test_recommend.py # picks 校验、候选池过滤 +backend/tests/test_multi_agent.py # init SSE 事件、leader/special 终态、clarification ``` -可用 `httpx.MockTransport` 拦截 loopback 请求,不依赖真实 Gateway。 +可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway。 --- -## 11. 路径速查 +## 14. 路径速查 | 资源 | 路径 | -|---|---| -| 路由实现 | `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` | +|------|------| +| Step 1 路由 | `offline-backend-20260512/backend/app/gateway/routers/intent.py` | +| 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` | +| Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` | +| Step 3 列出/下载 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | +| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` | +| 澄清单选/多选 | `packages/harness/deerflow/tools/builtins/clarification_utils.py` | +| 任务理解 Agent | `.deer-flow/agents/roundtable-intent/` | +| 推荐 Agent | `.deer-flow/agents/roundtable-recommender/` | +| 圆桌席位 | `.deer-flow/agents/roundtable-*/` | +| 前端 Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` | +| 前端推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` | +| 前端 Step 2 API | `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` | +| API 速查 | `frontend-web/docs/multi-agent-api.md` | +| Step 1 API 说明 | `frontend-web/docs/multi-agent-step1-api.md` | +| Step 3 API 说明 | `frontend-web/docs/multi-agent-step3-api.md` | diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md new file mode 100644 index 00000000..298536ec --- /dev/null +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -0,0 +1,659 @@ +# 多智能体圆桌研讨 · 前端开发文档 + +本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。 + +> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。 + +--- + +## 0. 速读:你需要先知道的事 + +- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。 +- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。 +- **唯一默认模型**:`DEFAULT_MODEL = "deepseek-chat"`(页面常量,写在 `RoundtablePlanningPage.tsx`)。`useModels()` 只用于 Persona 抽屉的下拉选项。 +- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。 +- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。 +- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。 +- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。 + +--- + +## 1. 功能定位 + +**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」) + +**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx` + +**核心能力**: + +| 步骤 | 用户目标 | 真实后端对接 | +|------|----------|--------------| +| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` | +| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` | +| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 | +| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` | + +**步骤门禁**: + +- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。 +- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。 + +--- + +## 2. 目录结构 + +``` +frontend-web/src/roundtable-planning/ +├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport +├── pages/ +│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行) +├── api/ +│ ├── intent.ts # Step 1:initIntent、streamIntent +│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent +│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl +├── components/ +│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer +│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选) +│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单 +│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览 +│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮 +├── hooks/ +│ └── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随 +├── lib/ +│ └── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐 +├── utils/ +│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName) +└── styles/ + └── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖) +``` + +**路由注册**(`src/pages/WorkspaceRoutes.tsx`): + +```tsx +} /> +``` + +侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。 + +**环境变量**(由 `scripts/start-frontend.sh` 注入): + +- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀 +- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装 + +--- + +## 3. 三步数据流(前端视角) + +``` +[进入 Step 1] + useEffect → runStep1Init() + initIntent(model) → { agent_id, thread_id } + setIntentThreadId、setTimelineEntries + interactiveMessages seed 欢迎语(本地 seed,非 API) + +[用户发送 / 选项卡片提交] + sendIntentMessage(text) + 追加 user 气泡 + agent 占位(streaming + phase) + streamIntent({ onTextDelta, onPhaseChange, signal }) + 终态: + done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗 + clarification → IntentClarificationCard + pendingIntentClarificationMsgId + asking → 继续对话 + error → toast + 红色 (错误) 文案 + +[点击「进入多智能体研讨」且 intentReady] + setIsRecommendDialogOpen(true) + RecommendAgentsDialog + listAgents() → filterRecommendCandidates() + streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange }) + 用户勾选 2–8 个 → onConfirm(agentIds) + handleConfirmAgents + setSelectedAgents(agentIds.map(...)) + step2InitRef.current = false // 复位 Step 2 init 闸门 + setCurrentStep(2) + +[进入 Step 2] + useEffect(step2InitRef 闸门)→ initMultiAgent(SSE) + onSeatReady:右侧席位面板逐个「在线」 + onCoordinatorReady / init_done → threadIds + coordinatorName + runOrchestration(buildInitialIntent(), threadIds, coordinatorName) + while !cancelled && cycles<8: + leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条 + 否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合… + dispatched.length === 0 → hasConsensus = true,可进 Step 3 + +[进入 Step 3] + HighFidelityReport + consensusText ← lastLeaderContent + seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") + Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) + 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) +``` + +--- + +## 4. API 层 + +### 4.1 `api/intent.ts` — Step 1 + +| 函数 | 说明 | +|------|------| +| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` | +| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 | + +**`StreamIntentRequest` 重要字段**: + +- `threadId`:必填,跨多轮对话保持 +- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消) +- `onTextDelta(text, messageId)`:每个 AI 文本增量 +- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling` + +**`StreamIntentResult`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 | +| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) | +| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 | +| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 | + +**SSE 解析要点**: + +- `event: metadata` → phase `connected` +- `event: messages` + `extractAiTextDelta` → `onTextDelta` +- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名) +- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`) + +**Abort 行为**: + +- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef` +- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft` +- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」 + +### 4.2 `api/recommend.ts` — 推荐弹窗 + +| 函数 | 说明 | +|------|------| +| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` | +| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) | +| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 | + +**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见): + +- `roundtable-intent` +- `roundtable-recommender` +- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳) + +**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似): + +- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"` +- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象 +- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示) +- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id) +- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`) + +**终态语义**: + +- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选 +- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集 + +### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件 + +| 函数 | 说明 | +|------|------| +| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` | +| `streamMultiAgent(req)` | leader / special 一轮 | +| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` | +| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL | + +**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段): + +```ts +{ event: "seat_ready", agent_name, thread_id, display_name, description } +{ event: "coordinator_ready", main_agent_name, thread_id } +{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record +{ event: "error", detail } +``` + +**`streamMultiAgent` 终态**(`isFinalStatusFrame`): + +| `status` | 含义 | 前端处理 | +|----------|------|----------| +| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 | +| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 | +| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 | +| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence | +| `"error"` | 失败 | toast + 红色气泡 | + +**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。 + +**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。 + +**调试开关**: + +```js +window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total) +window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream::] +``` + +> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。 + +--- + +## 5. 主页面状态(`RoundtablePlanningPage`) + +### 5.1 步骤与门禁 + +- `currentStep`: `1 | 2 | 3` +- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」 +- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用 + +### 5.2 Step 1 关键 state + +| 状态 | 用途 | +|------|------| +| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 | +| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 | +| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) | +| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) | +| `intentClarificationSelections` | `Map`,多选 / 多消息时各自维护 | +| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) | +| `intentAborterRef` | 当前 stream 的 AbortController | +| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 | +| `step1InitRef` | 闸门:true 即认为已 init 过 | +| `step1InitGenRef` | **单调递增**的代数标记,详见下文 | +| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) | + +**`step1InitGenRef` 的存在原因**: + +React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。 + +**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则): + +- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块 +- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染 +- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐 + +### 5.3 推荐与 Step 2 席位 + +| 状态 | 用途 | +|------|------| +| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) | +| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) | +| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName | +| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` | +| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 | +| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) | +| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 | +| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) | +| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) | +| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 | +| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) | +| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 | +| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 | +| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set | +| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) | +| `agentConfigs` | `Map` Persona 覆盖;下一轮该席位的派活会用此 model | +| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 | + +**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。 + +### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)` + +伪代码: + +``` +isOrchestrating = true +orchestrationCancelRef = false +nextLeaderTask = seedMessage +cycles = 0 +MAX_CYCLES = 8 + +while !cancelled && cycles < MAX_CYCLES: + cycles += 1 + highlight(speakerId=2) // 总控 + leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中") + res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange) + + if res.clarification: + finalize(leaderBubble, content, "等待用户澄清") + setPendingClarification(res.clarification) + break // 等用户回复后由 submitClarification 重启循环 + + finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明") + if res.content: setLastLeaderContent(res.content) + + if res.dispatched.length === 0: + setHasConsensus(true); setConsensusPercentage(100); break + + for [subName, task] of res.dispatched: // **顺序**,不是并行 + meta = agentNameToRole[subName.toLowerCase()] + markSpeakerStreaming(meta.speakerId) + perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL + subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中") + specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter) + finalize(subBubble, specRes.content, "子智能体交付") + markSpeakerIdle(meta.speakerId) + if cancelled: break + + setConsensusPercentage(prev => min(95, prev + 8)) + nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..." + +if cycles >= MAX_CYCLES: + toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环") + +clearAllStreamingSpeakers() +isOrchestrating = false +``` + +要点: + +- **`buildInitialIntent()`**: + - 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语 + - 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字 + - 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活 +- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)` +- **挂起 / 恢复**: + - 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()` + - 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)` +- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread + +### 5.5 Persona 抽屉(Step 2 圆桌可视化点击) + +- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉 +- 抽屉控件: + - **System Prompt**:仅前端记录(后端覆写未开放) + - **Temp**:仅前端记录 + - **推理模型**:选项来自 `useModels()`,默认 `DEFAULT_MODEL` +- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`: + - 把新的 `(seatId, model)` 写入 `agentConfigs` + - 追加一条「⚡ [模型偏好已保存] ...」对话气泡 + - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数 + +### 5.6 草稿持久化(`utils/drafts.ts`) + +- Key:`roundtable-planning:drafts:v1`(`localStorage`) +- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep` +- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字 +- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep` +- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave` +- **加载草稿**:`handleLoadDraft` + - 中断所有 in-flight(`abortAllInFlight + stopIntentStream`) + - **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉 + - `step1InitGenRef` 自增,淘汰可能晚归的旧 init + - `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init + - 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」 + +--- + +## 6. 组件说明 + +### 6.1 `IntentClarificationCard` + +- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent` +- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`) +- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`: + - 仅选项 → `我选择:A、B` + - 仅自定义 → 直接发自定义文本 + - 同时存在 → `我选择:A、B;补充:…` +- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片) + +### 6.2 `RecommendAgentsDialog` + +- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()` +- 阶段 UI(`InternalState.phase`): + - `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error` +- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」 +- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选 +- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state +- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init + +### 6.3 `HighFidelityReport`(Step 3) + +- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset` +- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行) +- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉 +- **文件预览策略**(`shouldPreviewInModal`): + - 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text) + - 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer + - 命中规则: + - mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*` + - 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml` +- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者) +- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`) +- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程 + +### 6.4 `ArtifactPreviewModal` + +- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文 +- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm +- 其它文本类 → `
` 直出
+- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
+- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
+
+### 6.5 共享 UI 模式与辅助
+
+- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
+- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
+  - `connecting` → 「调度中」
+  - `connected` → 「已连接,等待响应」
+  - `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:」
+  - `tool_result` → 「处理工具结果」
+  - `streaming` → leader 时「正在派活说明」、special 时「正在输出」
+  - Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
+- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
+- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
+- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
+- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
+
+---
+
+## 7. 澄清交互(与主聊天对齐)
+
+| 能力 | 实现位置 |
+|------|----------|
+| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
+| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
+| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
+| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
+| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
+| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
+
+后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
+
+**关键差异**:
+
+- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
+- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
+
+---
+
+## 8. 与主聊天的差异
+
+| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
+|------|-----------------------------------|----------|
+| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
+| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
+| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
+| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
+| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
+| 暂停 | LangGraph interrupt | `AbortController.abort()` |
+
+复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
+
+---
+
+## 9. 关键交互清单
+
+| 交互 | 行为 |
+|------|------|
+| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
+| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
+| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
+| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
+| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
+| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
+| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
+| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
+| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
+| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
+| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
+
+---
+
+## 10. 类型与常量速查
+
+```ts
+// Step 1 输出 → Step 2 输入
+interface IntentSummary {
+  objective: string;
+  constraints: string[];
+  assumptions?: string[];
+}
+
+// Step 2 线程映射(init_done.thread_ids)
+type ThreadIdMap = Record;
+
+// 派活
+type LeaderDispatch = [string, string]; // [agent_name, task]
+
+// 当前选中的研讨席位
+type SelectedAgent = { agent_id: string; name: string };
+
+// Step 1 单条澄清问题
+interface ClarificationPayload {
+  question: string;
+  clarificationType: string;
+  context: string;
+  options: unknown[];
+  allowCustom: boolean;
+  allowMultiple?: boolean;
+}
+
+// Step 2 stream 终态
+interface StreamMultiAgentResult {
+  dispatched: LeaderDispatch[];
+  message: string | null;
+  content: string;
+  agentName: string | null;
+  clarification: ClarificationPayload | null;
+}
+```
+
+**核心常量**:
+
+| 常量 | 值 | 位置 |
+|------|----|------|
+| `DEFAULT_MODEL` | `"deepseek-chat"` | RoundtablePlanningPage |
+| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
+| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
+| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
+| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
+| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
+| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
+| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
+| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
+| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
+| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
+
+---
+
+## 11. 故障排查
+
+| 现象 | 可能原因 | 排查 |
+|------|----------|------|
+| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
+| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
+| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
+| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
+| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
+| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
+| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
+| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
+| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
+| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
+| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
+| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
+| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
+| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
+| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
+
+**本地调试开关**:
+
+```js
+window.__MULTI_AGENT_INIT_TIMING__ = true;  // init 各阶段耗时(默认开)
+window.__MULTI_AGENT_DEBUG__       = true;  // stream 帧计数(默认开)
+```
+
+---
+
+## 12. 扩展指南
+
+### 12.1 新增默认研讨席位
+
+1. 后端 `.deer-flow/agents/roundtable-xxx/`
+2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
+3. 无需改 API 层(`agent_names` 由推荐结果传入)
+
+### 12.2 调整 Step 1 澄清轮次上限
+
+逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
+
+### 12.3 调整 Step 2 编排上限
+
+`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
+
+### 12.4 把 special 改回并行
+
+不推荐;若一定要做:
+
+- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
+- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
+- 用更高效的派发结构(如按 id 分桶 `Map` + ref 缓存)改善之后再启用
+
+### 12.5 接入真实 Persona 覆盖
+
+当前 prompt / temp 只在前端记录;要落地需:
+
+- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
+- 前端:`agentConfigs[meta.speakerId]` 一并传入
+
+### 12.6 拆分页面
+
+当前刻意单文件维护三步。若拆分建议:
+
+- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
+- 保持 `api/*` 不变
+- 保持 `roundtable-planning.css` 主题变量
+
+### 12.7 文档协同
+
+`@/roundtable-planning/` 内一切公共 API 都应当:
+
+1. 在 `api/*.ts` 文件级注释里说明 endpoint
+2. 在本页第 4 节 / 第 10 节同步对照
+3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
+
+---
+
+## 13. 路径速查
+
+| 资源 | 路径 |
+|------|------|
+| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
+| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
+| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
+| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
+| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
+| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
+| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
+| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
+| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
+| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
+| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
+| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
+| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
+| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
+| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
+| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
+| API 速查 | `frontend-web/docs/multi-agent-api.md` |
diff --git a/frontend-web/docs/multi-agent-step1-api.md b/frontend-web/docs/multi-agent-step1-api.md
deleted file mode 100644
index 62f36b0c..00000000
--- a/frontend-web/docs/multi-agent-step1-api.md
+++ /dev/null
@@ -1,492 +0,0 @@
-# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现
-
-> Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。
->
-> 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。
-
----
-
-## 1. 目标与边界
-
-### ✅ 要做的事
-- 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位
-- 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream`
-- 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper)
-- 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration`
-
-### ❌ 不做的事
-- 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样)
-- 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可)
-- 不做时间线(timelineEntries)的后端持久化(前端临时状态够用)
-- 不做"评分/置信度"等假指标
-- 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL`
-
----
-
-## 2. 数据流概览
-
-```
-[Step 1 入口]
-    ↓
-前端:POST /api/intent/init
-    ← {agent_id: "roundtable-intent", thread_id: "..."}
-
-用户输入第一句话:"帮我规划一个生产系统安全改造方案"
-    ↓
-前端:POST /api/intent/stream  (SSE)
-    ← (流式 AI 文本) "好的,预算大致是多少?"
-    ← {status: "asking", content: "..."}      ← 智能体反问中,需要用户继续
-    
-用户回:"500 万左右"
-    ↓
-前端再次 POST /api/intent/stream
-    ← (流式) "明白,还有什么时间约束吗?"
-    ← {status: "asking", ...}
-    
-...对话进行几轮 ...
-
-用户:可以了
-    ↓
-前端再 POST /api/intent/stream
-    ← (流式) "好,我理解的任务是: <汇总>"
-    ← {status: "done", content: "<最终汇总文本>", summary: {...}}  ← 信息充分,可进入 step 2
-
-[Step 1 完成 → Step 2 init]
-    前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent()
-```
-
----
-
-## 3. 接口设计
-
-跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。
-
-### 3.1 `POST /api/intent/init`
-
-**请求体**
-```json
-{
-  "model": "deepseek-chat"        // 可选,默认全局 DEFAULT_MODEL
-}
-```
-
-**响应 200**
-```json
-{
-  "status": "success",
-  "agent_id": "roundtable-intent",
-  "thread_id": ""
-}
-```
-
-**行为**:
-1. 鉴权(透传 `Authorization`)
-2. 调上游 `POST /api/threads` 建 1 个 thread
-3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**)
-
-**与 step 2 `/init` 的区别**:
-- 没有"协调智能体"概念,只有 1 个 agent
-- 没有 6 个 sub 席位,只有 1 个 thread
-- 不需要传 `agent_names` 数组
-- `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的)
-
-### 3.2 `POST /api/intent/stream`
-
-**请求体**
-```json
-{
-  "thread_id": "",
-  "message": "用户这一轮要说的话",
-  "model": "deepseek-chat"         // 可选
-}
-```
-
-**响应**:`text/event-stream`,SSE 流式。
-
-**SSE 帧组成**:
-1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量
-2. 流末追加一个**状态帧**(跟 step 2 同样模式):
-
-   ```json
-   {
-     "status": "asking" | "done" | "error",
-     "content": "<本轮 AI 文本>",
-     "summary": {
-       "objective": "...",       // 仅 status=done 时有: 任务目标
-       "constraints": ["...", ...], // 仅 status=done 时有: 约束清单
-       "assumptions": ["...", ...]  // 仅 status=done 时有: 关键假设
-     }
-   }
-   ```
-
-**`status` 取值**:
-- `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框
-- `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent
-- `"error"` —— 上游异常,`content` 里是错误信息
-
-**判定 done/asking 的简单策略**(后端实现):
-- 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧
-- 否则一律是 `asking`
-
-> 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取:
->
-> ```
-> [INTENT_READY]
-> ```json
-> {"objective": "...", "constraints": ["..."], "assumptions": ["..."]}
-> ```
-> ```
->
-> 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。
-
----
-
-## 4. 内置智能体配置
-
-**位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/`
-
-### 4.1 `config.yaml`
-```yaml
-id: roundtable-intent
-name: 任务理解智能体
-description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。
-```
-
-### 4.2 `SOUL.md`
-
-```markdown
-# 任务理解智能体
-
-## 角色定位
-你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
-通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。
-
-## 核心职责
-- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
-- 不要追问已经清楚的事
-- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
-- 信息足够后,立即停止追问,产出结构化汇总
-
-## 信息充分性判定
-当满足以下条件时,认为信息已充分:
-1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
-2. 至少 1 条硬约束(预算/时间/合规/范围之一)
-3. 没有明显的歧义点
-
-## 信息汇总产出
-信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):
-
-```
-[INTENT_READY]
-```json
-{
-  "objective": "用一句话写出用户最终要做的事",
-  "constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
-  "assumptions": ["假设 1", "假设 2", ...]
-}
-```
-```
-
-输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。
-
-## 输出风格
-- 一次回复不超过 3 句话
-- 提问要具体,不要"还有什么补充吗"这种空问
-- 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总)
-
-## 边界
-- 不调用 agent_orchestration(这是 step 2 的协调智能体的事)
-- 不写文件、不做计算、不联网
-- 用户消息歧义大时,优先调 ask_clarification 工具引导
-```
-
-### 4.3 注册方式
-
-跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。
-
----
-
-## 5. 后端实现要点
-
-### 5.1 新建 router 文件
-**位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py`
-
-```python
-"""任务理解智能体接口 - Step 1 的极简包装。
-
-跟 multi_agent.py 同构但简化:
-- 没有 broadcast(单 thread,无需广播)
-- 没有派活逻辑
-- 没有 leader/special 分支,只有一种调用方式
-- 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking
-"""
-
-from fastapi import APIRouter, Body, HTTPException, Request
-from fastapi.responses import StreamingResponse
-import httpx, json, re, asyncio, logging
-
-logger = logging.getLogger(__name__)
-router = APIRouter(prefix="/api/intent", tags=["intent"])
-
-# 复用 multi_agent.py 里已有的工具函数
-from app.gateway.routers.multi_agent import (
-    _loopback_base, _auth_headers,
-    _create_thread, _stream_upstream, _parse_last_messages,
-    _flatten_content, _run_payload,
-)
-
-INTENT_AGENT_ID = "roundtable-intent"
-
-_INTENT_READY_PATTERN = re.compile(
-    r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
-    re.DOTALL,
-)
-
-
-@router.post("/init")
-async def init_intent(request: Request, payload: dict = Body(...)) -> dict:
-    base = _loopback_base(request)
-    headers = _auth_headers(request)
-    async with httpx.AsyncClient(timeout=None) as client:
-        thread_id = await _create_thread(client, base, headers)
-    return {
-        "status": "success",
-        "agent_id": INTENT_AGENT_ID,
-        "thread_id": thread_id,
-    }
-
-
-@router.post("/stream")
-async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
-    thread_id = str(payload.get("thread_id") or "").strip()
-    message = str(payload.get("message") or "").strip()
-    model_name = str(payload.get("model") or "deepseek-chat")
-    if not thread_id or not message:
-        raise HTTPException(status_code=400, detail="thread_id and message required")
-
-    base = _loopback_base(request)
-    headers = _auth_headers(request)
-
-    async def gen():
-        async with httpx.AsyncClient(timeout=None) as client:
-            try:
-                body = _run_payload(
-                    agent_name=INTENT_AGENT_ID,
-                    model_name=model_name,
-                    thread_id=thread_id,
-                    new_message=message,
-                    excluded_tools=["web_search", "present_files",
-                                     "view_image", "agent_orchestration"],
-                    skill_stop_names=[],
-                )
-                captured = []
-                async for line, done in _stream_upstream(client, base, headers, thread_id, body):
-                    if line is not None:
-                        yield (line if line.endswith("\n") else line + "\n")
-                    elif done is not None:
-                        captured = done
-
-                messages = _parse_last_messages(captured)
-                last_ai = {}
-                for m in reversed(messages):
-                    if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
-                        last_ai = m
-                        break
-                content = _flatten_content(last_ai.get("content", ""))
-
-                # 检测 [INTENT_READY] 标记
-                match = _INTENT_READY_PATTERN.search(content)
-                if match:
-                    try:
-                        summary = json.loads(match.group(1))
-                    except json.JSONDecodeError:
-                        summary = None
-                    frame = {"status": "done", "content": content, "summary": summary}
-                else:
-                    frame = {"status": "asking", "content": content}
-                yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
-            except HTTPException as exc:
-                yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n"
-            except Exception as exc:
-                logger.exception("intent stream failure")
-                yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n"
-
-    return StreamingResponse(
-        gen(),
-        media_type="text/event-stream",
-        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
-    )
-```
-
-### 5.2 在 `app/gateway/app.py` 注册
-```python
-from app.gateway.routers import intent
-app.include_router(intent.router)
-```
-
----
-
-## 6. 前端实现要点
-
-### 6.1 新增 API helper
-**位置**:`frontend-web/src/roundtable-planning/api/intent.ts`
-
-```ts
-import { apiFetch } from "@/core/api/fetch-client";
-import { getBackendBaseURL } from "@/core/config";
-
-export interface IntentInitResponse {
-  status: "success";
-  agent_id: string;
-  thread_id: string;
-}
-
-export async function initIntent(model?: string): Promise {
-  const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
-    method: "POST",
-    headers: { "Content-Type": "application/json" },
-    body: JSON.stringify({ model }),
-  });
-  if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`);
-  return res.json();
-}
-
-export interface IntentSummary {
-  objective: string;
-  constraints: string[];
-  assumptions?: string[];
-}
-
-export interface StreamIntentRequest {
-  threadId: string;
-  message: string;
-  model?: string;
-  signal?: AbortSignal;
-  onTextDelta?: (text: string, id: string) => void;
-}
-
-export interface StreamIntentResult {
-  status: "asking" | "done" | "error";
-  content: string;
-  summary: IntentSummary | null;
-}
-
-export async function streamIntent(req: StreamIntentRequest): Promise {
-  // 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
-  // {status, content, summary?} 格式。
-  // ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
-}
-```
-
-### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染
-
-替换 `handleSendMessage`:
-
-```ts
-const [intentThreadId, setIntentThreadId] = useState(null);
-const [intentReady, setIntentReady] = useState(null);
-
-useEffect(() => {
-  if (currentStep === 1 && !intentThreadId) {
-    initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id));
-  }
-}, [currentStep]);
-
-const handleSendMessage = async () => {
-  if (!inputText.trim() || !intentThreadId) return;
-  const userText = inputText.trim();
-  // (push user msg to interactiveMessages 略)
-  setInputText("");
-  
-  // 占位 agent 气泡
-  const agentMsgId = pushAgentPlaceholder("任务理解智能体");
-  
-  const res = await streamIntent({
-    threadId: intentThreadId,
-    message: userText,
-    model: DEFAULT_MODEL,
-    onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta),
-  });
-  
-  finalizeAgentMsg(agentMsgId, res.content);
-  if (res.status === "done" && res.summary) {
-    setIntentReady(res.summary);
-    triggerToast("✅ 任务理解完成,可以进入圆桌研讨");
-  }
-};
-```
-
-### 6.3 "进入 step 2"按钮的联动
-
-- 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`)
-- 改造:只有 `intentReady !== null` 时该按钮才 enabled
-- 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑)
-
-```ts
-const buildInitialIntent = useCallback(() => {
-  if (intentReady) {
-    return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
-  }
-  // fallback: 旧逻辑保留兜底
-  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, intentReady]);
-```
-
----
-
-## 7. 实施清单
-
-| 优先级 | 项 | 文件 |
-|---|---|---|
-| P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` |
-| P0 | 新 router | `backend/app/gateway/routers/intent.py` |
-| P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) |
-| P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
-| P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) |
-| P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 |
-| P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) |
-| P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 |
-
----
-
-## 8. 与 step 2 的衔接
-
-```
-step 1 输出: intentReady.objective + intentReady.constraints
-                              ↓
-step 2 入口: buildInitialIntent() 拼成 leader 的 new_message
-                              ↓
-step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了),
-        除非模型遇到完全意料之外的歧义
-                              ↓
-研讨更聚焦,减少 step2 阶段的 clarification 轮次
-```
-
-副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。
-
----
-
-## 9. 路径速查
-
-| 资源 | 位置 |
-|---|---|
-| Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) |
-| 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
-| 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
-| 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
-| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
-| 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) |
-
----
-
-## 10. 验收清单
-
-- [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id
-- [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout)
-- [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary
-- [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态
-- [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话)
-- [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)
diff --git a/frontend-web/docs/multi-agent-step3-api.md b/frontend-web/docs/multi-agent-step3-api.md
deleted file mode 100644
index b0b76023..00000000
--- a/frontend-web/docs/multi-agent-step3-api.md
+++ /dev/null
@@ -1,264 +0,0 @@
-# 多智能体圆桌 · Step 3 · 接口与对接说明
-
-> Step 3(`/page/roundtable/planning` 的"最终方案展示"页)对应 `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx`。当前页面是产品早期的"展厅效果",绝大多数内容是硬编码 mock。本文档梳理**真正需要的数据**、**用哪些接口**、**前后端怎么对接**。
-
----
-
-## 1. 改造方向(在做什么,不做什么)
-
-### ✅ 保留并对接真实数据
-
-| 区块 | 现状 | 改造后 |
-|---|---|---|
-| 标题栏 + 生成时间 | 硬编码"2026-05-21 11:01:12" | 用本地 `Date.now()` 或 init/最后一次 leader 完成时刻 |
-| "已达成共识"徽章 | 静态显示 | step2 自然收敛(`leaderRes.dispatched.length === 0`)后才显示 |
-| **总控最终结论** | 完全是 mock 文本 | 取最后一次 `leaderRes.content`(已经在 step2 拿到了) |
-| **各席位最终交付(折叠卡片)** | 当前 step3 没渲染 | 取每个 sub 线程的最后一条 AIMessage 文本 |
-| **生成的报告文件清单** | 5 张假"档案" | 真实列出 `outputs/` 目录里的所有产出(markdown / pdf / csv 等) |
-| **文件下载** | 假 toast | 用现有 `GET /api/threads/{tid}/artifacts/{path}` |
-| "重新起草新规划" | 调用 `setCurrentStep(1)` 重置 | **保留**,含义就是回到 step1 重新开始 |
-
-### ❌ 不做(直接删 / 不接口)
-
-| 区块 | 原因 |
-|---|---|
-| 4 张 metric 卡片(设计全要素通过率 99.4% / Tier III+ / 终审共识度 42 轮研商 / 90.3分)| 全是按 `budgetLimit` 编出来的"展厅指标",没有真实数据源 |
-| 三阶段推进路线图(0-24h / 24-60h / 60-72h 三张卡)| 假设业务必然是 72 小时交付,写死场景,不通用 |
-| "采纳理由与最终裁定判词"段 | 整段都是模板话术,没接口能算出"90.3 分总评" |
-| "12 轮极限对抗研商" 数据 | 当前没记录"轮次"语义,临时就用 leader 实际轮数代替即可,不值得为它出接口 |
-| "签发起草成果并投产运行" 按钮 | 无对应业务动作(不是要部署、不是要审批),删掉 |
-| "执行摘要" 段写死的 3 个 bullet | 模板话术,跟实际任务无关 |
-
-最终 step3 页面只保留四块:**头部状态 + 总控结论 + 席位交付列表 + 文件下载清单**,再加底部"重新起草"。
-
----
-
-## 2. 数据来源全景
-
-```
-┌───────────────────────────────────────────────────────────┐
-│ step2 全程结束时,前端手里已经有的本地状态                  │
-├───────────────────────────────────────────────────────────┤
-│ coordinatorName: "roundtable-coordinator-xxx"             │
-│ threadIds:       { [agent_name]: thread_id, ... }         │  ← /init 返回
-│ step2RoundtableDialogues: [...所有气泡, 含每个 sub 最终  │
-│                           交付的 content...]              │
-│ leaderRes.content (最后一次 leader 的 final text)         │
-└───────────────────────────────────────────────────────────┘
-
-后端权威数据(需要时从后端拉):
-- 每个 sub 线程的完整 state    → GET /api/threads/{tid}/state
-- 单个 outputs 文件内容/下载    → GET /api/threads/{tid}/artifacts/{path}
-- outputs 目录文件清单 ❗暂无    → 新增接口(见 §3)
-```
-
-**关键判断**:席位最终交付文本 + 总控结论文本 **已经在 step2 内存里**,无需重新调接口。文件清单是**目前唯一缺失**的数据源。
-
----
-
-## 3. 接口需求
-
-### 3.1 新增(**必需**):列出某 thread 的产出文件
-
-```
-GET /api/multi-agent/sessions/artifacts?thread_id=
-```
-
-或者更通用、放进 artifacts 路由下:
-
-```
-GET /api/threads/{thread_id}/artifacts
-```
-
-**为什么需要**:现有 artifacts endpoint 只能"按已知路径下载文件",没有"列出目录"功能。Step 3 需要知道 sub-agent 写到 `/mnt/user-data/outputs/` 下哪些文件,才能在前端列出可下载清单。
-
-**响应**:
-```json
-{
-  "thread_id": "",
-  "files": [
-    {
-      "path": "mnt/user-data/outputs/方案设计报告_生产系统安全改造技术方案.md",
-      "name": "方案设计报告_生产系统安全改造技术方案.md",
-      "size_bytes": 12480,
-      "mime_type": "text/markdown",
-      "modified_at": "2026-05-23T14:32:30.123Z"
-    },
-    ...
-  ]
-}
-```
-
-**实现建议**:
-- 列 `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` 下的文件
-- 鉴权复用 `@require_permission("threads", "read", owner_check=True)`
-- 路径要做 traversal 保护(虚拟路径仅限 `mnt/user-data/outputs/`)
-
-### 3.2 新增(**可选,体感优化**):一次性聚合 step3 数据
-
-```
-POST /api/multi-agent/sessions/summary
-Body: {
-  "main_agent_name": "roundtable-coordinator-xxx",
-  "thread_ids": { "": "", ... }
-}
-```
-
-**为什么可选**:不加这个接口的话,前端需要为每个席位发一次 `GET /state` + 一次 `GET /artifacts`(共 7-13 次 round-trip)。加了这个接口,**一次返回所有数据**。
-
-**响应**:
-```json
-{
-  "main_agent_name": "roundtable-coordinator-xxx",
-  "coordinator": {
-    "thread_id": "...",
-    "final_text": "...",          // 最后一条 AIMessage content
-    "dispatch_rounds": 3           // leader 派活的轮数(可选展示)
-  },
-  "seats": [
-    {
-      "agent_name": "roundtable-intelligence",
-      "thread_id": "...",
-      "display_name": "情报收集",
-      "final_text": "...",         // 最后一条 AIMessage content
-      "files": [                   // 该席位写到 outputs/ 的文件
-        { "path": "...", "name": "...", "size_bytes": ..., "mime_type": "..." }
-      ]
-    },
-    ...
-  ]
-}
-```
-
-**取舍**:
-- 优点:1 次请求 vs 13 次;体验更顺
-- 缺点:等于把 3.1 + N 次 state 读放到一个 endpoint,实现稍复杂
-- 推荐:**先做 3.1,3.2 暂缓**;前端用现有 endpoint 串起来够用
-
-### 3.3 复用(**无需改动**)
-
-| Endpoint | 用途 |
-|---|---|
-| `GET /api/threads/{tid}/state` | 取席位 / 总控线程的完整状态,提取最后一条 AIMessage content |
-| `GET /api/threads/{tid}/artifacts/{path}` | 点击文件名预览或下载(已有 `?download=true` 支持) |
-
-> Note: 这两个 endpoint 已经在 `app.gateway.routers.threads` / `app.gateway.routers.artifacts` 里实现,前端 `fetch-client` 也有现成调用方式。
-
----
-
-## 4. 前后端对接流程
-
-### 4.1 进入 step3 时
-
-```ts
-// 触发条件:runOrchestration 自然结束(leaderRes.dispatched.length === 0
-// 且无 clarification),用户点"达成共识生成全案成果"按钮 / 自动跳转。
-
-// 前端手里已有:threadIds, coordinatorName, 最后的 leaderRes
-// 1. 总控结论文本 — 已在内存,直接用
-const consensusText = lastLeaderRes.content;
-
-// 2. 各席位交付 — 已在 step2RoundtableDialogues 里,过滤"子智能体交付"的卡片
-const seatDeliveries = step2RoundtableDialogues
-  .filter(d => d.confidence === "子智能体交付")
-  .map(d => ({ sender: d.sender, content: d.content }));
-
-// 3. 文件清单 — 调新接口
-const filesPerSeat = await Promise.all(
-  Object.entries(threadIds)
-    .filter(([name]) => name !== coordinatorName)
-    .map(async ([name, tid]) => ({
-      agentName: name,
-      files: (await fetch(`/api/threads/${tid}/artifacts`).then(r => r.json())).files,
-    })),
-);
-```
-
-### 4.2 用户点击文件时
-
-```ts
-// 在线预览(mime 是 markdown/text/pdf 自动 inline)
-window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}`);
-
-// 强制下载
-window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}?download=true`);
-```
-
-### 4.3 "重新起草新规划" 按钮
-
-```ts
-// 跟现在一样,纯前端:清掉 dialogues + 跳 step1
-setCurrentStep(1);
-setInteractiveMessages(...);  // 已有逻辑
-setStep2RoundtableDialogues([]);
-// 不需要调任何接口
-```
-
----
-
-## 5. 改造前端的具体文件清单
-
-| 文件 | 改动 |
-|---|---|
-| `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` | **大改**。删除四张 metric 卡 / 三阶段路线图 / 论证要件 / 裁定判词;新增结论卡 + 席位折叠列表 + 文件清单 |
-| `frontend-web/src/roundtable-planning/api/multi-agent.ts` | 新增 `listSessionArtifacts(threadId)` helper(封装 §3.1 接口) |
-| `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 把 `threadIds` / `coordinatorName` / `lastLeaderContent` 传进 `HighFidelityReport` 的 props(目前没传) |
-
----
-
-## 6. 改造后端的具体文件清单
-
-| 文件 | 改动 |
-|---|---|
-| `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | 新增 `GET /api/threads/{tid}/artifacts`(不带 path,列目录) |
-| `offline-backend-20260512/backend/CLAUDE.md` | 在 artifacts 路由表里增加新条目说明 |
-| `offline-backend-20260512/backend/tests/test_artifacts_list.py` | 列目录新接口的单测(覆盖:空目录返回 [];traversal 路径返回 403;不存在 thread 返回 404)|
-
----
-
-## 7. 实施优先级
-
-| 优先级 | 项 | 备注 |
-|---|---|---|
-| P0 | 新增 `GET /api/threads/{tid}/artifacts` 列文件接口 | 必需,没它前端无法列文件 |
-| P0 | step3 大改:删 mock 区块、加结论/席位/文件三块 | 主要价值改造 |
-| P1 | 前端 helper `listSessionArtifacts` | 封装 fetch + 类型 |
-| P2 | 单测 `test_artifacts_list.py` | 上线后跟进 |
-| P3 | `POST /api/multi-agent/sessions/summary` 聚合接口 | 13 次→1 次,体感优化,**非必需** |
-
----
-
-## 8. 显式不做的事 ❌
-
-1. **不做"PDF 导出"**:后端没有现成 PDF 渲染管线,要做就是单独的项目。当前页面那个 `导出方案 PDF` 按钮直接删掉,或者保留按钮但点击只触发 `window.print()`(让浏览器自己生成 PDF)。
-2. **不做"安全分享"**:项目里已有 `/api/thread-shares`(per-thread 分享)和 `/api/public/shares`(公开页面),但那是针对单个 thread 的,不是多 thread 会话的。要做"多 thread 整合分享"是新功能,远超 step3 的范围。
-3. **不做"指标雷达图"**(什么 90.3 分总评):没有真实数据源,强行加只会变成下一个 mock。
-4. **不做"签发投产"**:纯展示按钮,不接业务系统,删。
-5. **不做"轮次/对抗次数"显示**:除非前端记一下 `dispatch_count`,否则后端没现成统计。如果真要展示,用现成的"派活了几次"代替"对抗 N 轮"。
-
----
-
-## 9. 验收清单
-
-step3 改造完成后应该能:
-
-- [ ] 看到当前 session 的总控最终结论,markdown 渲染
-- [ ] 看到 6 个席位各自的最终交付内容(默认折叠,点击展开)
-- [ ] 看到所有 sub-agent 写到 outputs 的文件列表,含文件名、大小、类型 icon
-- [ ] 点击文件名能在线预览(markdown / pdf / 图片)
-- [ ] 点击下载按钮能下载(带正确文件名)
-- [ ] 点"重新起草新规划"能回到 step1 且 step2 内存清空
-- [ ] 页面不再包含任何来自 `budgetLimit` 的"假指标"
-
----
-
-## 10. 路径速查
-
-| 资源 | 位置 |
-|---|---|
-| Step 3 组件 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
-| 父页面 (state 持有者) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
-| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
-| 后端 artifacts 路由 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
-| 后端 multi-agent 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
-| 物理 outputs 目录 | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
diff --git a/frontend-web/docs/openCanvas-backend-checklist.md b/frontend-web/docs/openCanvas-backend-checklist.md
deleted file mode 100644
index 31152fbe..00000000
--- a/frontend-web/docs/openCanvas-backend-checklist.md
+++ /dev/null
@@ -1,1031 +0,0 @@
-# Open Canvas 后端实施清单
-
-## 1. 文档目标
-
-本文档是 [openCanvas.md](./openCanvas.md) 的后端落地版清单。
-
-目标是把 Canvas 模式在当前 DeerFlow 后端中的实施工作拆成可以直接排期和执行的任务,覆盖:
-
-- 线程状态扩展
-- `canvas_agent` 设计
-- LangGraph 节点拆分
-- Gateway API 扩展
-- 流式输出契约
-- 模型配置与本地模型支持
-- 结构化 artifact 的持久化
-- 测试与验收顺序
-
-本文档默认前端会新增独立的 Canvas 子系统,并通过现有 LangGraph 线程和 Gateway API 与后端通信。
-
----
-
-## 2. 实施原则
-
-### 2.1 总原则
-
-- 不破坏现有 `lead_agent`
-- 不破坏现有 `artifacts: list[str]` 文件产物体系
-- 优先增量扩展,不做推倒重构
-- 先打通状态和协议,再实现复杂 AI 行为
-- 先支持文本/代码双类型,再逐步扩展快捷动作
-
-### 2.2 推荐路线
-
-推荐采用“新增独立 `canvas_agent`”方案,而不是把所有 Canvas 行为塞进现有 `lead_agent`。
-
-推荐原因:
-
-- 风险更低
-- 图逻辑更清晰
-- 更容易对齐 open-canvas 的状态结构
-- 不影响现有 DeerFlow 普通聊天与文件产物模式
-
-### 2.3 后端目标边界
-
-本次后端实施重点是:
-
-- 新增 Canvas 专用线程状态字段
-- 新增 Canvas Agent 图
-- 新增前端需要的模型能力元数据
-- 支持结构化 artifact 在线程状态中读写
-
-本次不要求:
-
-- 完整复刻 open-canvas 的所有 TS 图结构
-- 完整复刻 Supabase 相关逻辑
-- 把所有模型 provider 完全抽象成统一能力中台
-
----
-
-## 3. 现有后端基础能力盘点
-
-当前 DeerFlow 后端已经具备非常好的基础设施:
-
-- LangGraph 线程与流式交互
-- FastAPI Gateway
-- 模型动态配置
-- 本地线程状态管理
-- Sandbox/工具体系
-- 上传与文件 artifact 访问
-- Memory 与标题生成
-
-因此 Canvas 模式并不是“从 0 开始做 Agent 平台”,而是“在现有平台上新增一类结构化创作模式”。
-
----
-
-## 4. 目标代码落点
-
-建议优先关注以下目录:
-
-```text
-offline-backend-20260512/backend/
-  app/gateway/
-    routers/
-    deps.py
-    app.py
-  packages/harness/deerflow/
-    agents/
-      factory.py
-      thread_state.py
-      lead_agent/
-      middlewares/
-    runtime/
-    config/
-    tools/
-```
-
-建议为 Canvas 新增或扩展以下区域:
-
-```text
-packages/harness/deerflow/
-  agents/
-    canvas_agent/
-      __init__.py
-      agent.py
-      prompt.py
-      state.py
-      nodes/
-        route_user_intent.py
-        generate_artifact.py
-        rewrite_artifact.py
-        update_highlighted_text.py
-        update_highlighted_code.py
-        generate_followup.py
-        quick_actions.py
-        web_search.py
-```
-
-如果不想把节点拆得太细,首版也可以把部分逻辑先收在 `agent.py` 里,但建议至少把 `state.py` 和 `prompt.py` 独立出来。
-
----
-
-## 5. 开发阶段总览
-
-后端建议分为 11 个阶段:
-
-1. 线程状态扩展
-2. 结构化 artifact 协议定义
-3. `canvas_agent` 接入到 Agent 工厂
-4. Canvas 图的最小节点实现
-5. Gateway/线程接口兼容
-6. 流式状态输出适配
-7. 模型能力元数据扩展
-8. 本地模型兼容与配置统一
-9. 快捷动作与局部改写
-10. Web 搜索增强
-11. 测试、回归、验收
-
----
-
-## 6. 阶段一:线程状态扩展
-
-### 6.1 任务目标
-
-让 DeerFlow 的线程状态能原生保存 Canvas 所需的结构化信息。
-
-### 6.2 任务清单
-
-- 扩展 [thread_state.py](../../offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py)
-- 定义 Canvas 相关 TypedDict 或 dataclass 结构
-- 确保状态字段可序列化
-- 不影响现有 `messages/title/artifacts/todos` 逻辑
-
-### 6.3 建议新增字段
-
-在现有 `ThreadState` 上新增:
-
-- `artifact`
-- `highlighted_text`
-- `highlighted_code`
-- `canvas_mode`
-- `quick_action_id`
-- `web_search_enabled`
-- `web_search_results`
-
-### 6.4 建议数据形态
-
-可先采用 JSON-safe dict 结构,而不是在状态层引入过重的复杂对象。
-
-建议:
-
-```python
-class CanvasArtifactText(TypedDict):
-    index: int
-    type: Literal["text"]
-    title: str
-    fullMarkdown: str
-
-class CanvasArtifactCode(TypedDict):
-    index: int
-    type: Literal["code"]
-    title: str
-    language: str
-    code: str
-
-class CanvasArtifact(TypedDict):
-    currentIndex: int
-    contents: list[CanvasArtifactText | CanvasArtifactCode]
-```
-
-### 6.5 与现有 `artifacts` 的关系
-
-必须保留现有:
-
-- `artifacts: list[str]`
-
-新增:
-
-- `artifact: dict | None`
-
-含义区分:
-
-- `artifacts` 表示文件型产物列表
-- `artifact` 表示 Canvas 当前主文档
-
-### 6.6 验收标准
-
-- 旧线程无 `artifact` 时不报错
-- 新线程可写入 `artifact`
-- 线程状态能被 FastAPI 和 LangGraph 正确序列化
-
----
-
-## 7. 阶段二:结构化 Artifact 协议定义
-
-### 7.1 任务目标
-
-统一前后端对 Canvas artifact 的字段约定。
-
-### 7.2 任务清单
-
-- 在后端建立一份明确的 artifact 协议定义
-- 明确字段命名、类型、兼容策略
-- 明确版本管理规则
-
-### 7.3 协议要求
-
-必须支持:
-
-- 文本 artifact
-- 代码 artifact
-- 标题
-- 当前版本索引
-- 历史版本列表
-- 后续支持局部改写
-
-### 7.4 版本规则
-
-- 每次 AI 整体生成,生成 `contents[0]`
-- 每次 AI 重写,追加一个新版本
-- `currentIndex` 指向当前版本
-- 历史版本默认只保留在线程状态中,不额外落库
-
-### 7.5 建议约束
-
-- `index` 从 1 开始
-- `currentIndex` 必须落在 `contents` 范围内
-- `type` 只允许 `"text"` 或 `"code"`
-
-### 7.6 验收标准
-
-- 前后端字段完全一致
-- 状态协议有明确默认值和边界规则
-
----
-
-## 8. 阶段三:接入 `canvas_agent`
-
-### 8.1 任务目标
-
-在现有 Agent 体系中注册一个独立的 Canvas Agent。
-
-### 8.2 任务清单
-
-- 新建 `packages/harness/deerflow/agents/canvas_agent/`
-- 新建 `agent.py`
-- 新建 `prompt.py`
-- 新建 `state.py`
-- 修改 [factory.py](../../offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py)
-
-### 8.3 注册方式建议
-
-建议将 `canvas_agent` 作为一个显式 assistant id 暴露给 LangGraph 运行时。
-
-前端调用时:
-
-- `assistant_id=canvas_agent`
-
-### 8.4 Agent 设计原则
-
-- Canvas Agent 不负责 DeerFlow 全部工具生态
-- 首版专注于:
-  - 生成文档
-  - 重写文档
-  - 局部改写
-  - 跟进回复
-
-### 8.5 工具策略
-
-首版建议:
-
-- 不强依赖 shell/sandbox
-- 不强依赖 present_files
-- 先把生成与改写链路做好
-
-后续可以根据需要接入文件导出能力。
-
-### 8.6 验收标准
-
-- `canvas_agent` 可被识别
-- 前端能以 assistant id 调用它
-- 不影响 `lead_agent`
-
----
-
-## 9. 阶段四:Canvas 状态与图定义
-
-### 9.1 任务目标
-
-为 Canvas Agent 定义清晰、最小但可扩展的状态结构。
-
-### 9.2 任务清单
-
-- 新建 `canvas_agent/state.py`
-- 定义图状态
-- 约束与 `ThreadState` 的映射方式
-
-### 9.3 建议状态字段
-
-- `messages`
-- `_messages` 或等价内部消息
-- `artifact`
-- `highlighted_text`
-- `highlighted_code`
-- `quick_action_id`
-- `web_search_enabled`
-- `web_search_results`
-- `next`
-
-### 9.4 状态设计建议
-
-优先复用 LangGraph 原生消息 reducer。
-
-对结构化字段,尽量采用:
-
-- 可整体替换
-- 易序列化
-- 无 Python 特殊对象依赖
-
-### 9.5 验收标准
-
-- 状态能驱动 Canvas 节点运行
-- 状态可被序列化回线程
-
----
-
-## 10. 阶段五:Canvas 图最小节点闭环
-
-### 10.1 任务目标
-
-先实现最小闭环,不要一开始就追求 open-canvas 全量功能。
-
-### 10.2 首版最小节点
-
-建议至少实现:
-
-- `route_user_intent`
-- `reply_general_input`
-- `generate_artifact`
-- `rewrite_artifact`
-- `generate_followup`
-
-### 10.3 节点职责
-
-#### `route_user_intent`
-
-判断用户当前意图:
-
-- 普通聊天
-- 新建 artifact
-- 重写当前 artifact
-
-#### `reply_general_input`
-
-仅返回普通消息,不更新 artifact。
-
-#### `generate_artifact`
-
-根据用户需求生成第一版 artifact。
-
-#### `rewrite_artifact`
-
-根据当前 artifact 和用户请求生成新版本。
-
-#### `generate_followup`
-
-在 artifact 更新后补一条自然语言消息。
-
-### 10.4 输出要求
-
-必须至少更新:
-
-- `messages`
-- `artifact`
-
-### 10.5 验收标准
-
-- 无 artifact 时能生成新 artifact
-- 有 artifact 时能整体重写
-- 重写后版本数增加
-
----
-
-## 11. 阶段六:Prompt 设计与模型调用封装
-
-### 11.1 任务目标
-
-把 Canvas 模式的 prompt 和模型调用逻辑集中管理。
-
-### 11.2 任务清单
-
-- 在 `canvas_agent/prompt.py` 中集中定义 prompt 常量
-- 在 `canvas_agent/agent.py` 或节点工具模块中封装模型调用
-
-### 11.3 最少需要的 prompt
-
-- 新建 artifact prompt
-- 整体重写 artifact prompt
-- 路由判断 prompt
-- followup prompt
-
-### 11.4 设计建议
-
-提示词必须显式约束:
-
-- 输出完整文档还是局部片段
-- 是否允许修改类型
-- 是否允许修改标题
-- 对文本和代码的输出格式要求
-
-### 11.5 模型调用封装建议
-
-不要在每个节点里重复拼模型配置。
-
-建议提供统一函数:
-
-- `get_canvas_model(...)`
-- `invoke_canvas_model(...)`
-
-### 11.6 验收标准
-
-- prompt 集中管理
-- 节点中模型调用逻辑可复用
-
----
-
-## 12. 阶段七:Gateway 线程接口兼容
-
-### 12.1 任务目标
-
-让前端读取线程状态时能拿到结构化 `artifact`。
-
-### 12.2 任务清单
-
-- 检查 [threads.py](../../offline-backend-20260512/backend/app/gateway/routers/threads.py)
-- 扩展线程 state 响应的 `values`
-- 确保 `artifact` 字段会被安全返回
-
-### 12.3 要求
-
-- `GET /api/threads/{id}`
-- 线程历史
-- 状态读取接口
-
-都要兼容 `artifact`
-
-### 12.4 注意事项
-
-- 历史 checkpoint 可能没有 `artifact`
-- 旧线程必须兼容
-- 结构化字段不能被错误裁剪
-
-### 12.5 验收标准
-
-- 前端取线程时能拿到 `artifact`
-- 刷新页面后 Canvas 内容能恢复
-
----
-
-## 13. 阶段八:流式输出适配
-
-### 13.1 任务目标
-
-让 Canvas 模式的 artifact 能通过 LangGraph 流实时更新。
-
-### 13.2 任务清单
-
-- 检查运行时序列化逻辑
-- 确保 `artifact` 在流事件中可见
-- 明确前端消费 `values` 还是 `updates`
-
-### 13.3 输出建议
-
-首版允许采用较简单策略:
-
-- 每次节点完成后输出完整 `artifact`
-
-不强制首版做精细的 token 级 artifact 局部流。
-
-### 13.4 原因
-
-完整对象覆盖更新:
-
-- 容易实现
-- 容易调试
-- 前后端一致性更好
-
-### 13.5 二期方向
-
-后续再考虑:
-
-- 局部 chunk 更新
-- 文本与代码差异化流式拼接
-
-### 13.6 验收标准
-
-- 前端发送请求时,artifact 能跟随 run 更新
-- 流结束后状态与线程最终值一致
-
----
-
-## 14. 阶段九:模型接口扩展
-
-### 14.1 任务目标
-
-让前端能拿到适合 Canvas 模式的模型能力信息。
-
-### 14.2 任务清单
-
-- 扩展 [models.py](../../offline-backend-20260512/backend/app/gateway/routers/models.py)
-- 扩展模型响应结构
-
-### 14.3 现有字段之外建议新增
-
-- `provider`
-- `supports_vision`
-- `supports_tool_calling`
-- `supports_canvas_generation`
-- `supports_canvas_rewrite`
-- `supports_canvas_code_edit`
-- `supports_streaming_text`
-- `supports_streaming_tool_calls`
-- `is_local`
-- `base_url`
-
-### 14.4 注意事项
-
-不是所有字段都必须从配置文件显式写出。
-
-可以采用:
-
-- 配置直出
-- provider 推断
-- 默认值补齐
-
-### 14.5 验收标准
-
-- 前端模型选择器能获取足够元数据
-- 本地模型和云模型能区分
-
----
-
-## 15. 阶段十:本地模型支持与配置统一
-
-### 15.1 任务目标
-
-支持像 open-canvas 一样自由配置本地模型,但配置真源在 DeerFlow。
-
-### 15.2 原则
-
-- 以 `config.yaml` 为唯一真源
-- 前端不依赖 open-canvas 的 TS 模型表
-- 后端负责把配置转换成前端可消费的能力描述
-
-### 15.3 首批支持范围
-
-推荐先支持:
-
-- Ollama
-- OpenAI-compatible 本地服务
-
-例如:
-
-- LM Studio
-- vLLM
-- LocalAI
-- One API/OpenAI 兼容代理
-
-### 15.4 配置要求
-
-建议模型配置支持以下字段:
-
-- `name`
-- `display_name`
-- `use`
-- `model`
-- `api_key`
-- `base_url`
-- `supports_vision`
-- `supports_thinking`
-- `supports_reasoning_effort`
-- `supports_tool_calling`
-- `is_local`
-
-### 15.5 Canvas 额外能力字段
-
-建议再增加:
-
-- `supports_canvas_generation`
-- `supports_canvas_rewrite`
-- `supports_canvas_code_edit`
-
-### 15.6 验收标准
-
-- 配置本地模型后 `/api/models` 可见
-- `canvas_agent` 可调用本地模型
-- 至少文本生成和整体改写可用
-
----
-
-## 16. 阶段十一:整体重写节点完善
-
-### 16.1 任务目标
-
-让已有 artifact 能被稳定整体重写并产生新版本。
-
-### 16.2 任务清单
-
-- 实现 `rewrite_artifact`
-- 明确文本与代码两种重写路径
-- 维护标题和类型变更规则
-
-### 16.3 规则建议
-
-- 默认保持当前类型
-- 用户明确要求时才允许改类型
-- 默认保持标题
-- 用户明确换主题时可更新标题
-
-### 16.4 结果写入规则
-
-- 新版本追加到 `contents`
-- `currentIndex` 指向新版本
-
-### 16.5 验收标准
-
-- 文本 artifact 整体重写正常
-- 代码 artifact 整体重写正常
-- 历史版本保留
-
----
-
-## 17. 阶段十二:局部改写节点
-
-### 17.1 任务目标
-
-支持文本和代码的局部选区修改。
-
-### 17.2 任务清单
-
-- 新增 `update_highlighted_text.py`
-- 新增 `update_highlighted_code.py`
-- 在状态中消费选区信息
-
-### 17.3 文本局部改写要求
-
-输入:
-
-- 当前 `fullMarkdown`
-- 选区起止
-- 选中原文
-- 用户修改指令
-
-输出:
-
-- 只返回替换片段或返回完整合并后文本
-
-### 17.4 首版建议
-
-首版优先采用“后端返回完整合并后文本”策略。
-
-原因:
-
-- 前端逻辑更轻
-- 状态更一致
-
-### 17.5 代码局部改写要求
-
-输入:
-
-- 当前代码全文
-- 选区代码片段
-- 选区边界
-- 用户修改指令
-
-输出:
-
-- 新的完整代码文本
-
-### 17.6 验收标准
-
-- 文本局部改写成功
-- 代码局部改写成功
-- 未选中区域不被误改
-
----
-
-## 18. 阶段十三:快捷动作节点
-
-### 18.1 任务目标
-
-支持前端动作栏触发不同的重写行为。
-
-### 18.2 任务清单
-
-- 新增 `quick_actions.py`
-- 定义动作 id 到 prompt 的映射
-- 在路由节点中识别 `quick_action_id`
-
-### 18.3 首版动作建议
-
-文本:
-
-- `expand`
-- `shorten`
-- `formalize`
-- `casualize`
-
-代码:
-
-- `add_comments`
-- `add_logs`
-- `fix_bugs`
-
-### 18.4 实现建议
-
-动作不要散落在各节点里硬编码。
-
-建议集中成映射表:
-
-- `action_id -> prompt builder`
-
-### 18.5 验收标准
-
-- 指定动作可触发不同结果
-- 动作执行后生成新版本
-
----
-
-## 19. 阶段十四:Web 搜索增强
-
-### 19.1 任务目标
-
-支持 Canvas 模式在需要时引入搜索结果。
-
-### 19.2 任务清单
-
-- 新增 `web_search.py`
-- 消费 `web_search_enabled`
-- 将搜索结果写入 `web_search_results`
-
-### 19.3 首版策略
-
-首版只做:
-
-- 打开开关后,生成/重写前先搜索
-- 把结果摘要注入 prompt
-
-先不做复杂页面抓取多轮链路。
-
-### 19.4 验收标准
-
-- 开关打开时能引入搜索结果
-- 结果可回传前端展示
-
----
-
-## 20. 阶段十五:线程创建与 metadata 策略
-
-### 20.1 任务目标
-
-让 Canvas 线程在创建和恢复时保留必要上下文。
-
-### 20.2 任务清单
-
-- 约定前端创建 Canvas thread 时写入 metadata
-- 后端在 thread search / get 中保留这些 metadata
-
-### 20.3 推荐 metadata
-
-- `ui_mode: "canvas"`
-- `agent_id: "canvas_agent"`
-- `model_name`
-- `canvas_type` 可选
-
-### 20.4 验收标准
-
-- Canvas thread 与普通 thread 可区分
-- 恢复线程时前端可据 metadata 恢复 UI
-
----
-
-## 21. 阶段十六:Gateway 扩展接口评估
-
-### 21.1 任务目标
-
-判断是否需要新增 Canvas 专用 REST API。
-
-### 21.2 原则
-
-能复用现有线程状态与 LangGraph 流,就不要新增太多新接口。
-
-### 21.3 可以先不新增的接口
-
-- Canvas artifact 专用 CRUD
-- Canvas 快捷动作专用接口
-
-这些都可以先通过:
-
-- 线程 state
-- runs.stream
-
-完成。
-
-### 21.4 可能需要新增的接口
-
-二期如果需要,可增加:
-
-- `GET /api/canvas/models`
-- `POST /api/canvas/export`
-- `POST /api/canvas/convert-to-file`
-
-### 21.5 验收标准
-
-- 首版尽量少增接口
-- 协议尽量复用现有线程与 run 模型
-
----
-
-## 22. 阶段十七:持久化与导出联动
-
-### 22.1 任务目标
-
-让 Canvas 文档能和现有文件 artifact 体系联动。
-
-### 22.2 任务清单
-
-- 规划导出接口
-- 规划 artifact 转文件逻辑
-- 规划从 Canvas 生成 outputs 文件
-
-### 22.3 推荐能力
-
-- 保存为 `.md`
-- 保存为代码文件
-- 将导出文件加入现有 `artifacts: list[str]`
-
-### 22.4 首版建议
-
-这部分不是最早阶段必须做的。
-
-建议等核心 Canvas 生成与重写稳定后再接。
-
-### 22.5 验收标准
-
-- 导出文件能出现在现有 artifact 文件体系中
-
----
-
-## 23. 测试清单
-
-### 23.1 单元测试建议
-
-优先覆盖:
-
-- artifact 协议校验
-- 版本追加逻辑
-- 标题/类型变更规则
-- 文本局部改写拼接逻辑
-- 代码局部改写拼接逻辑
-
-### 23.2 集成测试建议
-
-应覆盖:
-
-- 创建 Canvas thread
-- 首次生成 artifact
-- 整体重写
-- 局部改写
-- 模型切换
-- 本地模型调用
-
-### 23.3 回归测试重点
-
-重点检查现有功能未被破坏:
-
-- 普通 `lead_agent` 对话
-- 文件型 `artifacts`
-- uploads
-- memory
-- scheduled tasks
-
-### 23.4 验收标准
-
-- Canvas 新功能通过
-- 现有 DeerFlow 核心路径无回归
-
----
-
-## 24. 推荐开发顺序
-
-强烈建议按以下顺序推进:
-
-1. 线程状态扩展
-2. artifact 协议定稿
-3. `canvas_agent` 注册
-4. 最小图闭环
-5. 线程读取与流式返回
-6. 模型接口扩展
-7. 整体生成与整体改写
-8. 局部改写
-9. 快捷动作
-10. 本地模型增强
-11. Web 搜索增强
-12. 导出联动
-
-不要一开始就做:
-
-- 快捷动作全集
-- 局部 token 级流式 artifact 更新
-- 复杂导出体系
-
----
-
-## 25. 多人协作拆分建议
-
-### A 组:状态与线程
-
-- `thread_state.py`
-- 线程协议
-- Gateway 线程兼容
-
-### B 组:Canvas Agent 图
-
-- `canvas_agent`
-- prompt
-- route/generate/rewrite/followup
-
-### C 组:模型与配置
-
-- `/api/models`
-- 本地模型配置
-- provider 兼容
-
-### D 组:增强能力
-
-- 局部改写
-- 快捷动作
-- Web 搜索
-
----
-
-## 26. 首版最小可交付范围
-
-后端首版建议只交付这些:
-
-- 扩展线程状态支持 `artifact`
-- 新增 `canvas_agent`
-- 支持文本/代码 artifact 首次生成
-- 支持整体重写
-- 支持版本历史
-- 扩展 `/api/models` 返回 Canvas 能力元数据
-- 支持至少一种本地模型接入
-
-首版先不强制:
-
-- 局部高亮改写
-- 全量快捷动作
-- 搜索增强
-- 导出文件联动
-
----
-
-## 27. 总验收标准
-
-### 27.1 功能层
-
-- 无 artifact 时可生成新文档
-- 有 artifact 时可整体重写
-- 版本历史可保留
-- 前端刷新后可恢复
-
-### 27.2 协议层
-
-- `artifact` 字段稳定
-- 流式与最终线程状态一致
-- 模型能力字段可用于前端判断
-
-### 27.3 兼容层
-
-- 不破坏 `lead_agent`
-- 不破坏现有 `artifacts: list[str]`
-- 不破坏现有 Gateway 核心接口
-
----
-
-## 28. 下一步建议
-
-完成本文档后,建议继续补两份工程文档:
-
-### 28.1 Canvas 状态协议文档
-
-明确:
-
-- `artifact`
-- `highlighted_text`
-- `highlighted_code`
-- `quick_action_id`
-- `web_search_results`
-
-的最终 JSON 格式。
-
-### 28.2 Canvas Agent 节点接口文档
-
-逐节点定义:
-
-- 输入字段
-- 输出字段
-- prompt 责任
-- 错误处理
-
-这样进入实际编码时会更顺畅。
diff --git a/frontend-web/docs/openCanvas-frontend-checklist.md b/frontend-web/docs/openCanvas-frontend-checklist.md
deleted file mode 100644
index 26a47ae8..00000000
--- a/frontend-web/docs/openCanvas-frontend-checklist.md
+++ /dev/null
@@ -1,1373 +0,0 @@
-# Open Canvas 前端实施清单
-
-## 1. 文档目标
-
-本文档是 [openCanvas.md](./openCanvas.md) 的前端落地版清单。
-
-目标是把 Canvas 模式拆成可以直接开发的前端任务,覆盖:
-
-- 路由接入
-- 目录结构
-- 页面布局
-- 状态管理
-- 组件拆分
-- 接口适配
-- 流式更新
-- 版本管理
-- 局部改写
-- 模型选择
-- 验收标准
-
-本文档默认后端会配合新增结构化 `artifact` 字段与 `canvas_agent`。
-
----
-
-## 2. 实施原则
-
-### 2.1 总原则
-
-- 不破坏现有 `workspace` 聊天页面
-- 尽量复用现有基础设施
-- 先做骨架,再接数据,再接智能行为
-- 每一步都能跑、能看、能验收
-
-### 2.2 可复用的现有能力
-
-当前项目前端已有以下基础设施,可尽量复用:
-
-- `react-router-dom` 路由体系
-- `WorkspaceLayout` 工作区布局
-- `@langchain/langgraph-sdk` 客户端
-- `useThreadStream` 的流式线程模式
-- 模型列表接口 `/api/models`
-- 现有认证头注入机制
-- `sonner` 提示
-- `react-resizable-panels` 双栏/拖拽布局
-- `CodeMirror` 代码编辑器
-
-### 2.3 新增能力的边界
-
-Canvas 模式前端是一个“新子系统”,但不建议做成完全独立的第二套应用。
-
-建议方式:
-
-- 复用现有壳层和 API 客户端
-- 新建 `src/open-canvas` 目录承载新功能
-- 将 Canvas 模式接到现有 Page 层路由里
-
----
-
-## 3. 目标目录结构
-
-建议新增如下目录:
-
-```text
-frontend-web/src/open-canvas/
-  api/
-    client.ts
-    models.ts
-    threads.ts
-    runs.ts
-  components/
-    layout/
-      CanvasShell.tsx
-      CanvasHeader.tsx
-      CanvasResizableLayout.tsx
-    chat/
-      CanvasChatPanel.tsx
-      CanvasMessageList.tsx
-      CanvasInputBox.tsx
-      CanvasWelcome.tsx
-    artifact/
-      CanvasArtifactPanel.tsx
-      CanvasArtifactRenderer.tsx
-      CanvasArtifactHeader.tsx
-      CanvasArtifactVersionNav.tsx
-      CanvasEmptyState.tsx
-      TextArtifactRenderer.tsx
-      CodeArtifactRenderer.tsx
-    selectors/
-      CanvasModelSelector.tsx
-      CanvasModeSelector.tsx
-    quick-actions/
-      QuickActionBar.tsx
-      QuickActionButton.tsx
-    web-search/
-      CanvasWebSearchPanel.tsx
-  contexts/
-    CanvasThreadContext.tsx
-    CanvasGraphContext.tsx
-    CanvasUIContext.tsx
-  hooks/
-    useCanvasThread.ts
-    useCanvasStream.ts
-    useCanvasArtifact.ts
-    useCanvasSelection.ts
-    useCanvasQuickActions.ts
-  pages/
-    CanvasHomePage.tsx
-    CanvasThreadPage.tsx
-  routes/
-    OpenCanvasRoutes.tsx
-  types/
-    artifact.ts
-    thread.ts
-    model.ts
-    selection.ts
-    quick-action.ts
-  utils/
-    artifact.ts
-    selection.ts
-    thread.ts
-    stream.ts
-    quick-actions.ts
-  constants/
-    index.ts
-```
-
----
-
-## 4. 开发阶段总览
-
-前端建议分为 10 个实施阶段:
-
-1. 路由与目录骨架
-2. Canvas 页面布局
-3. 类型与状态协议
-4. API 适配层
-5. 线程和流式状态接入
-6. Artifact 渲染与版本管理
-7. 快速开始与空状态
-8. 局部选区改写交互
-9. 快捷动作系统
-10. 模型选择与增强能力接入
-
-建议每个阶段完成后都做一次可视化验收。
-
----
-
-## 5. 阶段一:路由与目录骨架
-
-### 5.1 任务目标
-
-把 Canvas 模式先挂到系统里,确保页面可进入。
-
-### 5.2 任务清单
-
-- 新建 `src/open-canvas/` 目录
-- 新建 `src/open-canvas/routes/OpenCanvasRoutes.tsx`
-- 新建 `src/open-canvas/pages/CanvasHomePage.tsx`
-- 新建 `src/open-canvas/pages/CanvasThreadPage.tsx`
-- 修改 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
-
-### 5.3 路由建议
-
-建议新增:
-
-- `/page/canvas`
-- `/page/canvas/:thread_id`
-
-建议在 `PageLayout` 内部新增:
-
-- `path="canvas/*"`
-
-子路由建议:
-
-- `index -> CanvasHomePage`
-- `:thread_id -> CanvasThreadPage`
-- `new -> 跳转到 CanvasHomePage 或创建临时线程态`
-
-### 5.4 页面行为要求
-
-`CanvasHomePage`:
-
-- 展示 Canvas 欢迎页
-- 展示快速开始入口
-- 可进入新建文本或代码模式
-
-`CanvasThreadPage`:
-
-- 只负责线程级页面装配
-- 挂载所有 Canvas 上下文提供器
-- 渲染主布局组件
-
-### 5.5 验收标准
-
-- 访问 `/page/canvas` 正常
-- 访问 `/page/canvas/xxx` 正常
-- 不影响 `/page/workspace/*`
-- 页面为独立功能入口
-
----
-
-## 6. 阶段二:Canvas 页面布局
-
-### 6.1 任务目标
-
-先搭出稳定的“左聊天右画布”骨架。
-
-### 6.2 任务清单
-
-- 新建 `layout/CanvasShell.tsx`
-- 新建 `layout/CanvasHeader.tsx`
-- 新建 `layout/CanvasResizableLayout.tsx`
-- 新建 `chat/CanvasChatPanel.tsx`
-- 新建 `artifact/CanvasArtifactPanel.tsx`
-- 新建 `artifact/CanvasEmptyState.tsx`
-
-### 6.3 布局要求
-
-推荐布局:
-
-- 顶部:Canvas 标题栏
-- 左侧:聊天面板
-- 右侧:artifact 面板
-- 可选右抽屉:Web 搜索结果
-
-### 6.4 建议复用
-
-可以优先复用:
-
-- `react-resizable-panels`
-- 现有按钮、分隔线、Tabs、Tooltip 等 UI 组件
-
-### 6.5 初始状态要求
-
-无 thread 或 thread 中无 artifact 时:
-
-- 右侧显示空状态
-- 左侧显示欢迎语和快速开始
-
-### 6.6 验收标准
-
-- 双栏布局稳定
-- 可拖拽调整宽度
-- 空状态显示正常
-- 适配桌面优先,移动端先做降级布局
-
----
-
-## 7. 阶段三:类型与状态协议
-
-### 7.1 任务目标
-
-先把前端需要消费和管理的数据类型定下来。
-
-### 7.2 任务清单
-
-- 新建 `types/artifact.ts`
-- 新建 `types/thread.ts`
-- 新建 `types/model.ts`
-- 新建 `types/selection.ts`
-- 新建 `types/quick-action.ts`
-
-### 7.3 必要类型
-
-#### artifact.ts
-
-- `CanvasArtifact`
-- `CanvasArtifactText`
-- `CanvasArtifactCode`
-- `CanvasArtifactContent`
-
-#### selection.ts
-
-- `CanvasTextSelection`
-- `CanvasCodeSelection`
-- `CanvasSelectionState`
-
-#### thread.ts
-
-- `CanvasThreadValues`
-- `CanvasThreadMetadata`
-- `CanvasThread`
-
-### 7.4 建议结构
-
-```ts
-export type CanvasArtifact = {
-  currentIndex: number;
-  contents: CanvasArtifactContent[];
-};
-
-export type CanvasArtifactText = {
-  index: number;
-  type: "text";
-  title: string;
-  fullMarkdown: string;
-};
-
-export type CanvasArtifactCode = {
-  index: number;
-  type: "code";
-  title: string;
-  language: string;
-  code: string;
-};
-```
-
-### 7.5 线程值类型建议
-
-```ts
-export type CanvasThreadValues = {
-  title?: string;
-  messages?: Message[];
-  artifact?: CanvasArtifact;
-  web_search_results?: unknown[];
-  todos?: unknown[];
-};
-```
-
-### 7.6 验收标准
-
-- 所有 Canvas 组件只依赖 `open-canvas/types`
-- 不把类型散落到页面或组件内部
-- 前后端协议字段统一命名
-
----
-
-## 8. 阶段四:前端 Context 设计
-
-### 8.1 任务目标
-
-把 Canvas 的状态拆成职责清晰的三层上下文。
-
-### 8.2 任务清单
-
-- 新建 `CanvasThreadContext.tsx`
-- 新建 `CanvasGraphContext.tsx`
-- 新建 `CanvasUIContext.tsx`
-
-### 8.3 职责划分
-
-#### CanvasThreadContext
-
-负责:
-
-- 当前 thread id
-- thread 基础信息
-- thread 创建/切换
-- 模型名称
-- 模型配置
-
-#### CanvasGraphContext
-
-负责:
-
-- messages
-- artifact
-- runId
-- streaming 状态
-- error 状态
-- 发送消息
-- 停止生成
-- 切换版本
-- 更新 artifact 内容
-
-#### CanvasUIContext
-
-负责:
-
-- 面板折叠状态
-- 是否正在编辑
-- 当前选区
-- Web 搜索面板开关
-- 快捷动作面板开关
-
-### 8.4 验收标准
-
-- 页面组件不直接持有大量跨层 useState
-- 聊天状态和 UI 状态分离
-- 线程切换时可安全重置状态
-
----
-
-## 9. 阶段五:API 适配层
-
-### 9.1 任务目标
-
-为 Canvas 模式建立专用的前端 API 包装层。
-
-### 9.2 任务清单
-
-- 新建 `api/client.ts`
-- 新建 `api/models.ts`
-- 新建 `api/threads.ts`
-- 新建 `api/runs.ts`
-
-### 9.3 设计要求
-
-不要让页面组件直接到处调用现有 `getAPIClient()` 或裸 `fetch`。
-
-建议统一在 `src/open-canvas/api/` 中封装。
-
-### 9.4 需要封装的能力
-
-#### models.ts
-
-- `loadCanvasModels()`
-- 对现有 `/api/models` 结果做标准化
-
-#### threads.ts
-
-- `createCanvasThread()`
-- `getCanvasThread(threadId)`
-- `updateCanvasThreadMetadata(threadId, metadata)`
-- `updateCanvasThreadState(threadId, values)`
-
-#### runs.ts
-
-- `streamCanvasRun(...)`
-- `stopCanvasRun(...)`
-
-### 9.5 与现有 API 的关系
-
-底层可以复用:
-
-- `src/core/api/api-client.ts`
-- `src/core/api/fetch-client.ts`
-
-但上层调用应走 Canvas 专用 API。
-
-### 9.6 验收标准
-
-- 页面层不直接拼接 URL
-- Canvas API 调用集中管理
-- 后续改协议时只动一层
-
----
-
-## 10. 阶段六:线程与流式状态接入
-
-### 10.1 任务目标
-
-让 Canvas 页面能像现有 ChatPage 一样收发消息并接流。
-
-### 10.2 任务清单
-
-- 新建 `hooks/useCanvasThread.ts`
-- 新建 `hooks/useCanvasStream.ts`
-- 新建 `utils/stream.ts`
-
-### 10.3 设计建议
-
-可以参考现有:
-
-- [ChatPage.tsx](../src/pages/ChatPage.tsx)
-- [threads/hooks.ts](../src/core/threads/hooks.ts)
-
-但不要直接把现有 `ChatPage` 复制一份。
-
-### 10.4 `useCanvasThread` 负责
-
-- 当前 thread id 管理
-- 新建/切换 thread
-- 从 URL 参数恢复 thread
-
-### 10.5 `useCanvasStream` 负责
-
-- 发送消息
-- 停止生成
-- 处理流式事件
-- 更新 `messages`
-- 更新 `artifact`
-- 更新 `runId`
-
-### 10.6 流式更新要求
-
-首版可以假设后端在 `values` 或 `updates` 中返回:
-
-- `messages`
-- `artifact`
-
-前端需要:
-
-- 新消息追加
-- artifact 覆盖或增量更新
-- 流结束后保持最终状态
-
-### 10.7 验收标准
-
-- 能发消息
-- 能收到消息
-- 能实时更新 artifact
-- 停止按钮可用
-
----
-
-## 11. 阶段七:Artifact 渲染器
-
-### 11.1 任务目标
-
-实现文本/代码两类 artifact 的基础渲染与切换。
-
-### 11.2 任务清单
-
-- 新建 `artifact/CanvasArtifactRenderer.tsx`
-- 新建 `artifact/CanvasArtifactHeader.tsx`
-- 新建 `artifact/CanvasArtifactVersionNav.tsx`
-- 新建 `artifact/TextArtifactRenderer.tsx`
-- 新建 `artifact/CodeArtifactRenderer.tsx`
-
-### 11.3 组件职责
-
-#### CanvasArtifactRenderer
-
-- 判断当前 artifact 是否存在
-- 判断当前内容是文本还是代码
-- 分发到具体渲染器
-
-#### CanvasArtifactHeader
-
-- 显示标题
-- 显示保存状态
-- 显示版本导航
-
-#### CanvasArtifactVersionNav
-
-- 上一个版本
-- 下一个版本
-- 当前版本号
-
-#### TextArtifactRenderer
-
-- 渲染 markdown 内容
-- 支持编辑模式
-- 支持文本选区
-
-#### CodeArtifactRenderer
-
-- 使用 CodeMirror
-- 支持语言模式切换
-- 支持代码选区
-
-### 11.4 首版策略
-
-文本首版建议:
-
-- 编辑态用 textarea 或现有 markdown 编辑器
-- 展示态用现有 markdown renderer
-
-代码首版建议:
-
-- 直接复用现有 CodeMirror 基础能力
-
-### 11.5 验收标准
-
-- 文本 artifact 可看可编辑
-- 代码 artifact 可看可编辑
-- 版本切换正确
-- 标题与当前版本一致
-
----
-
-## 12. 阶段八:快速开始与空状态
-
-### 12.1 任务目标
-
-让用户在没有任何 artifact 时也能顺畅开始。
-
-### 12.2 任务清单
-
-- 新建 `chat/CanvasWelcome.tsx`
-- 在 `CanvasHomePage` 中增加快速开始面板
-- 在 `CanvasThreadPage` 空状态时提供快速开始
-
-### 12.3 快速开始项建议
-
-- 空白文本
-- 空白代码
-- React 组件模板
-- HTML 页面模板
-- Python 脚本模板
-
-### 12.4 行为要求
-
-点击后:
-
-- 创建本地前端 artifact 草稿
-- 必要时自动创建 thread
-- 自动进入编辑态
-
-### 12.5 验收标准
-
-- 不发消息也能先建立 artifact
-- 新建文本/代码入口可用
-- 初始编辑体验完整
-
----
-
-## 13. 阶段九:Artifact 版本管理
-
-### 13.1 任务目标
-
-让每次 AI 生成或修改都成为一个新版本。
-
-### 13.2 任务清单
-
-- 新建 `hooks/useCanvasArtifact.ts`
-- 新建 `utils/artifact.ts`
-
-### 13.3 必要能力
-
-- 获取当前版本内容
-- 切换版本
-- 更新当前版本内容
-- 追加新版本
-- 判断 artifact 是否为空
-
-### 13.4 推荐工具函数
-
-- `getCurrentArtifactContent(artifact)`
-- `setCurrentArtifactIndex(artifact, index)`
-- `appendArtifactVersion(artifact, content)`
-- `replaceCurrentArtifactContent(artifact, content)`
-- `isTextArtifact(content)`
-- `isCodeArtifact(content)`
-
-### 13.5 注意事项
-
-- 前端切版本不能直接修改原对象引用
-- 每次更新要创建新对象,避免渲染异常
-
-### 13.6 验收标准
-
-- AI 更新后版本数增加
-- 手动切换历史版本稳定
-- 当前版本高亮正确
-
----
-
-## 14. 阶段十:局部选区交互
-
-### 14.1 任务目标
-
-实现局部改写所需的前端选区采集与展示。
-
-### 14.2 任务清单
-
-- 新建 `hooks/useCanvasSelection.ts`
-- 新建 `utils/selection.ts`
-
-### 14.3 文本选区需求
-
-- 获取选中起止位置
-- 获取选中文本
-- 在 UI 上显示“已选中某段内容”
-- 支持清除选区
-
-### 14.4 代码选区需求
-
-- 获取 CodeMirror 选区
-- 保存选中代码内容
-- 保存 start/end 或行列信息
-
-### 14.5 交互建议
-
-可以在选区出现后展示浮层或工具条:
-
-- 改写
-- 精简
-- 扩写
-- 解释
-
-首版先只保留:
-
-- “对所选内容进行修改” 的统一入口
-
-### 14.6 验收标准
-
-- 文本选区可读
-- 代码选区可读
-- 选区状态可清空
-- 切换版本或 thread 时选区自动重置
-
----
-
-## 15. 阶段十一:聊天面板适配
-
-### 15.1 任务目标
-
-实现 Canvas 模式专用聊天面板,不直接塞回现有 ChatBox。
-
-### 15.2 任务清单
-
-- 新建 `chat/CanvasMessageList.tsx`
-- 新建 `chat/CanvasInputBox.tsx`
-- 新建 `chat/CanvasChatPanel.tsx`
-
-### 15.3 设计建议
-
-可以参考现有 ChatPage 的结构,但建议把 Canvas 模式聊天区做成更轻量版本。
-
-### 15.4 必要功能
-
-- 展示消息列表
-- 展示生成中状态
-- 提交输入
-- 停止生成
-- 显示模型选择器
-- 显示 Web 搜索开关
-
-### 15.5 可以复用
-
-- 现有消息渲染组件中的部分能力
-- 现有 InputBox 的一部分交互逻辑
-
-### 15.6 不建议直接复用
-
-- 当前工作区的文件 artifact 面板耦合
-- 当前以文件产物为中心的 ChatBox 结构
-
-### 15.7 验收标准
-
-- Canvas 聊天区独立可用
-- 能与 artifact 面板联动
-- 布局不受现有文件 artifact 影响
-
----
-
-## 16. 阶段十二:模型选择器
-
-### 16.1 任务目标
-
-让 Canvas 模式可以使用 DeerFlow 的动态模型列表。
-
-### 16.2 任务清单
-
-- 新建 `selectors/CanvasModelSelector.tsx`
-- 在 `api/models.ts` 中做标准化
-
-### 16.3 UI 要求
-
-- 显示模型名
-- 显示是否本地模型
-- 显示是否支持视觉/推理/Canvas
-
-### 16.4 状态要求
-
-- 当前 thread 记录 `model_name`
-- 必要时记录 `model_config`
-- 切换 thread 时能恢复模型
-
-### 16.5 首版能力
-
-首版可以只做:
-
-- 下拉选择模型
-- 将模型信息写入 thread metadata 或 run configurable
-
-### 16.6 验收标准
-
-- 模型列表正确展示
-- 切换模型后发消息使用新模型
-- 本地模型与云模型能在 UI 中区分
-
----
-
-## 17. 阶段十三:快捷动作栏
-
-### 17.1 任务目标
-
-实现 artifact 上方或下方的快捷动作入口。
-
-### 17.2 任务清单
-
-- 新建 `quick-actions/QuickActionBar.tsx`
-- 新建 `quick-actions/QuickActionButton.tsx`
-- 新建 `hooks/useCanvasQuickActions.ts`
-- 新建 `utils/quick-actions.ts`
-
-### 17.3 首版动作建议
-
-文本类:
-
-- 扩写
-- 精简
-- 改正式
-- 改口语
-
-代码类:
-
-- 加注释
-- 加日志
-- 修 bug
-
-### 17.4 行为要求
-
-点击动作后:
-
-- 写入 `quick_action_id`
-- 触发一次 Canvas run
-- 生成新的 artifact 版本
-
-### 17.5 验收标准
-
-- 动作栏根据 artifact 类型动态变化
-- 动作点击后有效果
-- 新版本正确生成
-
----
-
-## 18. 阶段十四:Web 搜索面板
-
-### 18.1 任务目标
-
-为后续搜索增强预留 UI 和交互位置。
-
-### 18.2 任务清单
-
-- 新建 `web-search/CanvasWebSearchPanel.tsx`
-- 在 `CanvasUIContext` 中增加开关状态
-
-### 18.3 首版要求
-
-- 只做抽屉面板
-- 可展示结构化搜索结果列表
-- 可开关显示
-
-### 18.4 二期能力
-
-- 支持点击来源预览
-- 支持从结果插入上下文
-
-### 18.5 验收标准
-
-- 面板开关正常
-- 搜索结果区域可承载数据
-
----
-
-## 19. 阶段十五:页面装配
-
-### 19.1 任务目标
-
-将所有子模块整合到页面中。
-
-### 19.2 任务清单
-
-`CanvasHomePage.tsx`:
-
-- 欢迎页
-- 快速开始
-- 最近 Canvas 线程入口
-
-`CanvasThreadPage.tsx`:
-
-- 解析 route params
-- 挂载 `CanvasThreadContext`
-- 挂载 `CanvasGraphContext`
-- 挂载 `CanvasUIContext`
-- 渲染 `CanvasShell`
-
-### 19.3 结构建议
-
-```tsx
-
-  
-    
-      
-    
-  
-
-```
-
-### 19.4 验收标准
-
-- 页面层只负责装配,不承载复杂业务逻辑
-- 子组件依赖上下文而不是层层 props drilling
-
----
-
-## 20. 推荐逐文件实施顺序
-
-建议按这个顺序建文件:
-
-1. `routes/OpenCanvasRoutes.tsx`
-2. `pages/CanvasHomePage.tsx`
-3. `pages/CanvasThreadPage.tsx`
-4. `types/*`
-5. `contexts/CanvasThreadContext.tsx`
-6. `contexts/CanvasUIContext.tsx`
-7. `contexts/CanvasGraphContext.tsx`
-8. `layout/CanvasShell.tsx`
-9. `layout/CanvasResizableLayout.tsx`
-10. `artifact/CanvasEmptyState.tsx`
-11. `artifact/CanvasArtifactRenderer.tsx`
-12. `artifact/TextArtifactRenderer.tsx`
-13. `artifact/CodeArtifactRenderer.tsx`
-14. `chat/CanvasChatPanel.tsx`
-15. `chat/CanvasInputBox.tsx`
-16. `api/*`
-17. `hooks/useCanvasStream.ts`
-18. `hooks/useCanvasSelection.ts`
-19. `quick-actions/*`
-20. `web-search/*`
-
----
-
-## 21. 开发任务拆分建议
-
-如果多人协作,建议这样拆:
-
-### A 组:页面与路由
-
-- 路由接入
-- 页面骨架
-- 主布局
-
-### B 组:artifact 视图
-
-- artifact 类型
-- 渲染器
-- 版本导航
-- 代码/文本编辑器
-
-### C 组:聊天与流式
-
-- thread 管理
-- stream 接入
-- 消息面板
-- 模型切换
-
-### D 组:增强交互
-
-- 选区
-- 快捷动作
-- 搜索面板
-
----
-
-## 22. 每阶段验收清单
-
-### 22.1 阶段一到三
-
-- 路由正常
-- 空页面正常
-- 类型编译通过
-- 目录结构清晰
-
-### 22.2 阶段四到六
-
-- thread 可创建/读取
-- 消息可发送
-- artifact 可展示
-- 流式结果可更新
-
-### 22.3 阶段七到九
-
-- 文本/代码 artifact 渲染正确
-- 版本导航正确
-- 快速开始可用
-- 模型选择可用
-
-### 22.4 阶段十到十四
-
-- 局部选区可采集
-- 快捷动作可触发
-- 搜索面板可显示
-- 各功能互不冲突
-
----
-
-## 23. 风险点与规避方式
-
-### 23.1 不要直接复制现有 ChatPage
-
-原因:
-
-- 现有页面与文件 artifact 耦合较重
-- Canvas 需要结构化 artifact 而不是文件列表
-
-### 23.2 不要把所有状态都放一个 Context
-
-原因:
-
-- 更新频率高
-- 容易导致全树重渲染
-
-### 23.3 不要先做复杂富文本
-
-原因:
-
-- 选区映射复杂
-- 首版容易卡在编辑器实现而不是业务链路
-
-建议先以:
-
-- `fullMarkdown`
-- `textarea/markdown renderer`
-
-作为最小闭环。
-
-### 23.4 不要前端硬编码模型表
-
-原因:
-
-- 后端才是真源
-- 后期本地模型和自定义 provider 会频繁变化
-
----
-
-## 24. 首版最小可交付范围
-
-前端首版建议只交付这些:
-
-- `canvas/*` 路由
-- Canvas 双栏布局
-- 文本/代码 artifact 渲染
-- 模型选择器
-- 流式聊天
-- artifact 版本切换
-- 快速开始
-
-首版先不强制包含:
-
-- 复杂文本局部高亮浮层
-- 复杂快捷动作配置中心
-- 高级 Web 搜索结果联动
-
----
-
-## 25. 下一步建议
-
-完成本文档后,建议继续产出两份更细的执行文件:
-
-### 25.1 前端文件创建顺序清单
-
-按天或按 PR 颗粒度,列出:
-
-- 每一批次新建哪些文件
-- 每一批次修改哪些已有文件
-
-### 25.2 前端组件接口文档
-
-列出关键组件 props:
-
-- `CanvasShell`
-- `CanvasChatPanel`
-- `CanvasArtifactRenderer`
-- `CanvasModelSelector`
-
-这样正式开工时会更顺畅。
----
-
-## 26. 参考源码映射
-
-前端实施清单建议补充对 `langchain-ai/open-canvas` 源码的“映射式参考”,但不建议把源码大段粘贴进本文档。
-
-推荐做法是:
-
-- 记录参考文件路径
-- 说明它解决了什么问题
-- 说明在 DeerFlow 前端里应该落到哪个模块
-- 只摘能力点,不摘整段实现
-
-这样做的原因:
-
-- 文档更清晰
-- 不会把 Next.js 运行时细节误当成可直接迁移代码
-- 后续维护更轻
-
-### 26.1 推荐参考文件总览
-
-以下文件最值得作为前端实现参考:
-
-- `apps/web/src/contexts/GraphContext.tsx`
-- `apps/web/src/components/canvas/canvas.tsx`
-- `apps/web/src/components/artifacts/ArtifactRenderer.tsx`
-- `apps/web/src/components/artifacts/TextRenderer.tsx`
-- `apps/web/src/components/artifacts/CodeRenderer.tsx`
-- `apps/web/src/contexts/ThreadProvider.tsx`
-- `apps/web/src/contexts/AssistantContext.tsx`
-- `apps/web/src/hooks/useRuns.tsx`
-- `apps/web/src/types.ts`
-- `packages/shared/src/types.ts`
-- `packages/shared/src/models.ts`
-- `packages/shared/src/utils/artifacts.ts`
-
-### 26.2 逐文件映射建议
-
-#### `apps/web/src/contexts/GraphContext.tsx`
-
-参考价值:
-
-- 这是 open-canvas 前端最核心的状态管理文件之一
-- 管理消息、artifact、流式状态、保存状态、版本切换、局部更新
-- 处理流中 artifact 更新和 thread 切换
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/contexts/CanvasGraphContext.tsx`
-- `src/open-canvas/hooks/useCanvasStream.ts`
-- `src/open-canvas/utils/stream.ts`
-- `src/open-canvas/utils/artifact.ts`
-
-重点借鉴内容:
-
-- artifact 如何作为线程主状态存在
-- 流式过程中如何同步 messages 与 artifact
-- 切换 thread 时如何恢复状态
-- 如何维护“当前版本”和“已保存状态”
-
-不建议直接照搬的部分:
-
-- 与 Next.js、`nuqs`、open-canvas 内部 hook 耦合的代码
-- 与其后端返回格式强绑定的处理逻辑
-
-#### `apps/web/src/components/canvas/canvas.tsx`
-
-参考价值:
-
-- Open Canvas 的主页面装配组件
-- 管理左聊天右画布布局
-- 定义快速开始逻辑
-- 管理 chat collapsed、artifact 初始创建
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/components/layout/CanvasShell.tsx`
-- `src/open-canvas/components/layout/CanvasResizableLayout.tsx`
-- `src/open-canvas/pages/CanvasThreadPage.tsx`
-
-重点借鉴内容:
-
-- 快速开始时如何创建文本或代码 artifact
-- 左右双栏如何组织
-- 页面级状态和上下文边界怎么划分
-
-不建议直接照搬的部分:
-
-- Next 路由参数逻辑
-- 与 open-canvas ThreadProvider 强耦合的状态恢复代码
-
-#### `apps/web/src/components/artifacts/ArtifactRenderer.tsx`
-
-参考价值:
-
-- 负责 artifact 总渲染分发
-- 根据当前版本内容选择文本或代码渲染器
-- 负责版本导航和选区交互挂钩
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/components/artifact/CanvasArtifactRenderer.tsx`
-- `src/open-canvas/components/artifact/CanvasArtifactHeader.tsx`
-- `src/open-canvas/components/artifact/CanvasArtifactVersionNav.tsx`
-
-重点借鉴内容:
-
-- 当前 artifact 的统一入口组件结构
-- 版本导航和正文渲染如何组合
-- 文本/代码视图如何统一被包装
-
-#### `apps/web/src/components/artifacts/TextRenderer.tsx`
-
-参考价值:
-
-- 负责文本 artifact 的渲染与编辑
-- 处理 markdown 内容、更新、局部交互
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/components/artifact/TextArtifactRenderer.tsx`
-- `src/open-canvas/hooks/useCanvasSelection.ts`
-
-重点借鉴内容:
-
-- 文本 artifact 的编辑态与展示态切换
-- 当前版本内容如何回写到状态
-- 局部选区的挂接点
-
-实现上的取舍建议:
-
-- DeerFlow 首版不要追求和 open-canvas 一样复杂的富文本块级编辑
-- 建议先用 `fullMarkdown` + markdown renderer + 简单编辑态完成闭环
-
-#### `apps/web/src/components/artifacts/CodeRenderer.tsx`
-
-参考价值:
-
-- 负责代码 artifact 的渲染与编辑
-- 与 CodeMirror 集成
-- 管理代码内容更新
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/components/artifact/CodeArtifactRenderer.tsx`
-
-重点借鉴内容:
-
-- 代码 artifact 如何绑定语言
-- 代码编辑结果如何同步回 artifact 状态
-- 局部代码选区的接入位置
-
-#### `apps/web/src/contexts/ThreadProvider.tsx`
-
-参考价值:
-
-- 负责 thread 与模型相关状态
-- 管理 thread 切换和 thread 级配置
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/contexts/CanvasThreadContext.tsx`
-- `src/open-canvas/hooks/useCanvasThread.ts`
-
-重点借鉴内容:
-
-- 当前 thread id 的管理方式
-- thread metadata 与 model state 的关系
-- 页面恢复 thread 时的上下文同步
-
-#### `apps/web/src/contexts/AssistantContext.tsx`
-
-参考价值:
-
-- 管理 assistant 选择
-- 管理与 assistant 相关的 thread 行为
-
-DeerFlow 对应落点:
-
-- DeerFlow 首版可以弱化这部分
-- 如果首版只有 `canvas_agent` 一个固定 assistant,可只保留最小 assistant 绑定能力
-
-重点借鉴内容:
-
-- thread 与 assistant 的耦合方式
-
-#### `apps/web/src/hooks/useRuns.tsx`
-
-参考价值:
-
-- 管理 run 相关行为
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/api/runs.ts`
-- `src/open-canvas/hooks/useCanvasStream.ts`
-
-重点借鉴内容:
-
-- run 行为与页面状态的解耦方式
-
-#### `apps/web/src/types.ts`
-
-参考价值:
-
-- 保存了一部分 artifact 和消息类型定义
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/types/artifact.ts`
-- `src/open-canvas/types/thread.ts`
-
-重点借鉴内容:
-
-- artifact 的前端表达方式
-
-注意:
-
-- 更建议以 `packages/shared/src/types.ts` 为主参考,而不是这个文件
-- 因为 `src/types.ts` 里同时包含了部分历史兼容结构
-
-#### `packages/shared/src/types.ts`
-
-参考价值:
-
-- 这是 open-canvas 中最重要的共享类型来源之一
-- 定义了 `ArtifactV3`、代码/文本内容、选择区和其他共享结构
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/types/artifact.ts`
-- `src/open-canvas/types/selection.ts`
-- 可作为前后端协议设计的主要参考
-
-重点借鉴内容:
-
-- `ArtifactV3` 结构
-- 文本/代码 artifact 类型边界
-- 与版本管理有关的字段组织方式
-
-建议:
-
-- DeerFlow 可以参考其字段设计
-- 但不要机械复制所有字段,保留适合当前项目的最小集
-
-#### `packages/shared/src/models.ts`
-
-参考价值:
-
-- 展示了 open-canvas 如何组织前端模型列表和模型能力
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/types/model.ts`
-- `src/open-canvas/api/models.ts`
-- `src/open-canvas/components/selectors/CanvasModelSelector.tsx`
-
-重点借鉴内容:
-
-- 模型展示结构
-- provider 概念
-- 各模型能力标签的前端呈现思路
-
-注意:
-
-- 不应作为 DeerFlow 的模型配置真源
-- DeerFlow 必须以自己后端 `/api/models` 为真源
-
-#### `packages/shared/src/utils/artifacts.ts`
-
-参考价值:
-
-- 里面有 artifact 类型判断和内容获取工具
-
-DeerFlow 对应落点:
-
-- `src/open-canvas/utils/artifact.ts`
-
-重点借鉴内容:
-
-- 获取当前版本内容
-- 判断当前 artifact 是文本还是代码
-- 为渲染器和快捷动作提供统一入口
-
-### 26.3 可以直接参考但不要直接迁移的部分
-
-以下内容可以借鉴思路,但不建议直接搬代码:
-
-- Next.js 路由和页面结构
-- `src/app/api/*` 目录中的 API Route
-- Supabase SSR 和服务端 session 相关代码
-- `next/navigation` 相关逻辑
-- `middleware.ts`
-
-原因:
-
-- DeerFlow 前端是 `React 19 + Vite + react-router-dom`
-- 这些代码依赖 Next.js 运行时
-
-### 26.4 建议的使用方式
-
-开发时推荐这样使用参考源码:
-
-1. 先按本文档实现 DeerFlow 自己的模块边界
-2. 遇到某个具体功能点时,再去查对应 open-canvas 文件
-3. 提炼实现思路,重写到 DeerFlow 的目录结构中
-4. 不要复制包含 Next.js 运行时依赖的代码
-
-### 26.5 推荐补充动作
-
-如果后面要正式进入实现阶段,建议再单独新增一份源码映射文档,例如:
-
-- `frontend-web/docs/openCanvas-source-map.md`
-
-里面可以更细地记录:
-
-- open-canvas 文件路径
-- DeerFlow 对应目标文件
-- 迁移策略
-- 是否可直接借鉴
-- 是否需要重写
diff --git a/frontend-web/docs/openCanvas-progress.md b/frontend-web/docs/openCanvas-progress.md
deleted file mode 100644
index 5e74c279..00000000
--- a/frontend-web/docs/openCanvas-progress.md
+++ /dev/null
@@ -1,312 +0,0 @@
-# Open Canvas 集成进度文档
-
-> 最后更新:2026-05-16
-> 当前分支:dhk-1
-
----
-
-## 一、整体进度概览
-
-### Canvas 系统(双栏写作/编码工作区)
-
-| 阶段 | 名称 | 状态 |
-|------|------|------|
-| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
-| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
-| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
-| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
-| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
-| 阶段六 | 前后端流式同步 | ✅ 已完成 |
-| 阶段七 | 版本历史导航 | ✅ 已完成 |
-| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
-| 阶段九 | 快捷动作系统 | ✅ 已完成 |
-| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
-
-### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
-
-| 步骤 | 名称 | 状态 |
-|------|------|------|
-| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
-| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
-| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
-| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
-| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
-| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
-| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
-| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
-
-**当前状态:全部功能已完成 🎉**
-
----
-
-## 二、已完成工作详情
-
-### 2.1 前端核心文件结构
-
-```
-frontend-web/src/open-canvas/
-├── api/
-│   ├── client.ts          # LangGraph SDK 客户端实例
-│   ├── models.ts          # 模型列表 API
-│   ├── rewrite.ts         # AI 改写 SSE 流(streamRewrite,parseSse)
-│   ├── runs.ts            # streamCanvasRun(老的直接 API,已停用)
-│   └── threads.ts         # createCanvasThread、getCanvasThread
-├── components/
-│   ├── artifact/
-│   │   ├── CanvasArtifactHeader.tsx      # 右栏顶部:标题、语言、版本导航
-│   │   ├── CanvasArtifactPanel.tsx       # 右栏:流式预览 / 编辑器 / 空状态
-│   │   ├── CanvasArtifactVersionNav.tsx  # prev/next 版本切换
-│   │   ├── CanvasEmptyState.tsx          # 5 种快速开始模板
-│   │   ├── CanvasQuickActionBar.tsx      # 快捷动作按钮栏
-│   │   ├── CodeArtifactRenderer.tsx      # CodeMirror 代码编辑器
-│   │   ├── TextArtifactRenderer.tsx      # Tiptap 文本编辑器 + AI 改写流程
-│   │   ├── WritingAiMenu.tsx             # AI 改写动作菜单(polish/expand/shorten/tone)
-│   │   ├── WritingBubbleMenu.tsx         # 选区气泡菜单(portal 实现)
-│   │   ├── WritingParagraphMenu.tsx      # 段落悬停 AI 改写按钮
-│   │   ├── WritingRewritePreview.tsx     # 改写结果预览面板(含思考过程展开)
-│   │   └── WritingToolbar.tsx            # 格式工具栏(含模型选择器)
-│   ├── chat/
-│   │   └── CanvasChatPanel.tsx           # 左栏:消息列表 + 输入框
-│   ├── layout/
-│   │   ├── CanvasHeader.tsx              # 顶部栏:返回、折叠、流式指示
-│   │   ├── CanvasResizableLayout.tsx     # 左右可拖拽双栏(含 CanvasStreamProvider)
-│   │   └── CanvasShell.tsx               # 顶层壳:加载已有线程状态
-│   └── selectors/
-│       └── CanvasModelSelector.tsx       # 模型下拉选择器
-├── contexts/
-│   ├── CanvasGraphContext.tsx   # artifact、streamingArtifact、isStreaming
-│   ├── CanvasStreamContext.tsx  # 共享 useCanvasThreadStream 实例(sendMessage)
-│   ├── CanvasThreadContext.tsx  # threadId、modelName
-│   └── CanvasUIContext.tsx      # isChatCollapsed、writingSelection
-├── editor/
-│   ├── TiptapEditor.tsx         # EditorContent 包装器
-│   └── TiptapExtensions.ts      # StarterKit + Underline + Placeholder + Markdown
-├── hooks/
-│   ├── useCanvasArtifact.ts     # artifact 初始化工具
-│   ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
-│   ├── useCanvasSelection.ts    # 文字/代码选区状态
-│   ├── useCanvasStream.ts       # 旧直接 API 流(已停用,保留备用)
-│   ├── useCanvasThread.ts       # 线程加载工具
-│   ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
-│   ├── useWritingEditor.ts      # Tiptap editor 实例管理
-│   └── useWritingRewrite.ts     # AI 改写状态机(ThinkTagParser、AbortController)
-├── pages/
-│   ├── CanvasHomePage.tsx       # 欢迎页 + 快速开始
-│   └── CanvasThreadPage.tsx     # Provider 装配层
-├── routes/
-│   └── OpenCanvasRoutes.tsx     # /page/canvas/* 子路由
-├── types/
-│   ├── artifact.ts              # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
-│   ├── model.ts                 # CanvasModel
-│   ├── quick-action.ts          # QuickAction、QuickActionId
-│   ├── selection.ts             # CanvasTextSelection、CanvasCodeSelection
-│   └── thread.ts                # CanvasMessage、CanvasThreadValues
-└── utils/
-    ├── apply-rewrite.ts         # applyReplace、applyInsert(Tiptap 事务)
-    ├── artifact.ts              # getCurrentArtifactContent、setCurrentArtifactIndex 等
-    ├── quick-actions.ts         # buildQuickActionMessage(含 artifact 内容注入)
-    ├── selection.ts             # buildMessageWithSelection
-    ├── stream.ts                # 流式工具
-    └── thread.ts                # 线程工具
-```
-
-### 2.2 后端核心文件结构
-
-```
-offline-backend-20260512/backend/
-├── packages/harness/deerflow/agents/
-│   ├── canvas_agent/
-│   │   ├── __init__.py
-│   │   ├── agent.py      # make_canvas_agent():LangGraph graph 工厂
-│   │   ├── prompt.py     # build_canvas_system_prompt():含 selection_rewrite + quick_actions
-│   │   └── tools.py      # update_artifact_tool:创建/追加 artifact 版本
-│   └── thread_state.py   # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
-└── app/gateway/
-    ├── app.py            # 注册 writing router
-    └── routers/
-        └── writing.py    # POST /api/writing/rewrite(SSE 流式)
-```
-
-**langgraph.json** 注册了两个 graph:
-- `lead_agent` → 主聊天 Agent
-- `canvas_agent` → Canvas 写作 Agent
-
-### 2.3 关键 Context 层级
-
-```
-CanvasThreadPage
-  ├── CanvasThreadProvider      # threadId、modelName
-  │     ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
-  │     │     └── CanvasUIProvider  # isChatCollapsed、writingSelection
-  │     │           └── CanvasShell
-  │     │                 └── CanvasResizableLayout
-  │     │                       └── CanvasStreamProvider  ← 持有 useCanvasThreadStream 单例
-  │     │                             ├── CanvasChatPanel      # 读 useCanvasStreamContext()
-  │     │                             └── CanvasArtifactPanel
-  │     │                                   └── CanvasQuickActionBar  # 读 useCanvasStreamContext()
-```
-
----
-
-## 三、关键架构决策与注意事项
-
-### 3.1 流式架构:单例流(重要)
-
-**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
-
-**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
-
-```
-useCanvasStream(旧,已停用) ← 不再被调用,保留备用
-useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
-```
-
-### 3.2 artifact 内容注入机制(重要)
-
-**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
-
-**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `` 标签嵌入人类消息末尾。
-
-- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `` 时)
-- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
-- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
-
-```typescript
-// utils/quick-actions.ts
-export function buildQuickActionMessage(id, content) {
-  const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
-  return `${instruction}\n\n\n${body}\n`;
-}
-
-// core/messages/utils.ts
-export function stripArtifactTag(content: string): string {
-  return content.replace(/]*>[\s\S]*?<\/artifact>/g, "").trim();
-}
-```
-
-### 3.3 ThinkTagParser(AI 改写思考过程解析)
-
-后端支持两种思考格式:
-- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
-- DeepSeek:把思考过程内联在文本里:`...content`
-
-前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `` 标签。
-
-### 3.4 版本历史:本地切换
-
-`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
-
-### 3.5 Writing Editor 流程(三阶段状态机)
-
-```
-idle → menu → preview → idle
-         ↑              ↓
-     openAiMenu      closeAll
-```
-
-- **idle**:正常编辑
-- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
-- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
-
----
-
-## 四、已解决的技术难题
-
-| 问题 | 解法 |
-|------|------|
-| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
-| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
-| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
-| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
-| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
-| DeepSeek 在文本流里混入 `` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
-| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
-| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
-| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `` 标签,显示时剥离 |
-| `` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
-
----
-
-## 五、当前已知限制(非 bug,设计取舍)
-
-1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
-2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
-3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
-4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
-
----
-
-## 六、快速开始下一次工作
-
-### 启动服务
-
-```bash
-# 后端(从 offline-backend-20260512/backend/)
-make dev       # 启动 Gateway API on port 8001
-
-# 前端(从 frontend-web/)
-pnpm dev       # 启动 Vite on port 5174
-```
-
-### 访问路径
-
-- 主页:`http://localhost:5174`
-- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
-
-### 类型检查
-
-```bash
-cd frontend-web && pnpm typecheck
-# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
-```
-
----
-
-## 七、如需继续开发的潜在方向
-
-| 方向 | 描述 | 优先级 |
-|------|------|------|
-| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
-| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
-| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
-| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
-| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
-| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
-
----
-
-## 八、关键文件速查
-
-| 文件 | 说明 |
-|------|------|
-| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
-| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
-| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
-| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
-| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
-| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
-| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
-| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
-| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
-| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
-| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
-| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
-| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
-| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
-
----
-
-## 九、开始新对话的推荐提示词
-
-```
-我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
-详细进度见:frontend-web/docs/openCanvas-progress.md
-
-当前全部功能已完成,包括:
-- Canvas 双栏布局、canvas_agent、版本历史
-- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
-- 快捷动作(通过 CanvasStreamContext 统一流)
-- artifact 内容注入(消息里嵌入  标签,显示时剥离)
-
-请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
-```
diff --git a/frontend-web/docs/openCanvas.md b/frontend-web/docs/openCanvas.md
deleted file mode 100644
index 6ef38733..00000000
--- a/frontend-web/docs/openCanvas.md
+++ /dev/null
@@ -1,950 +0,0 @@
-# Open Canvas 模式需求拆解文档
-
-## 1. 文档目标
-
-本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
-
-目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
-
-- 前端:基于 `React 19 + Vite + react-router-dom`
-- 后端:基于 `Python + FastAPI + LangGraph`
-- 模型层:支持云模型与本地模型自由配置
-- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
-
-本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
-
----
-
-## 2. 最终目标
-
-### 2.1 用户视角目标
-
-用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
-
-- 左侧是聊天区,右侧是 Canvas 编辑区
-- 可以从空白文本或代码模板快速开始
-- 可以通过聊天让 AI 生成一份结构化 artifact
-- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
-- artifact 支持版本历史切换
-- artifact 支持文本和代码两种主要类型
-- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
-- 支持启用 Web 搜索增强
-- 支持切换模型
-- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
-
-### 2.2 工程视角目标
-
-新增的 Canvas 模式需要满足以下工程原则:
-
-- 不破坏现有 DeerFlow 工作区
-- 不影响现有基于 `artifacts: string[]` 的文件产物功能
-- 可逐阶段上线
-- 前后端状态协议清晰
-- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
-
----
-
-## 3. 范围定义
-
-### 3.1 本次需求包含
-
-- 前端新增 `open-canvas` 专用目录
-- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
-- 前端新增 Canvas 页面、上下文、组件、状态管理
-- 后端新增 Canvas 专用线程状态字段
-- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
-- 模型配置统一为 DeerFlow 后端配置驱动
-- 支持本地大模型配置与模型列表动态下发
-- 支持结构化 artifact 的保存、读取、更新、版本切换
-
-### 3.2 本次需求不包含
-
-- 一次性复刻 open-canvas 全部 UI 细节
-- 完整照搬 Supabase 鉴权体系
-- 直接复用 open-canvas 的 Next.js API Route
-- 直接运行 open-canvas 的 LangGraph.js 后端
-- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
-
-### 3.3 迁移原则
-
-- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
-- 前端尽量复用其交互设计和数据结构
-- 后端以 Python 实现同等能力
-- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
-
----
-
-## 4. 总体设计思路
-
-### 4.1 前端设计思路
-
-在现有 `frontend-web/src` 下新增一个专用目录,例如:
-
-```text
-src/
-  open-canvas/
-    pages/
-    routes/
-    components/
-    contexts/
-    hooks/
-    types/
-    api/
-    utils/
-    constants/
-```
-
-建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
-
-建议路由形式:
-
-- `/canvas`
-- `/canvas/:threadId`
-
-也可以挂在现有页面布局下,例如:
-
-- `/workspace/canvas`
-- `/workspace/canvas/:threadId`
-
-推荐优先采用独立一级功能路由:
-
-- 逻辑更清晰
-- 状态隔离更容易
-- 不容易污染现有 `workspace` 功能
-
-### 4.2 后端设计思路
-
-后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
-
-#### 方案 A:新增独立 Canvas Assistant
-
-- 新增 `canvas_agent`
-- 使用单独的 LangGraph 状态定义
-- 前端调用时通过 `assistant_id=canvas_agent`
-
-优点:
-
-- 与现有 `lead_agent` 隔离
-- 风险更低
-- 更容易对齐 open-canvas 的图结构
-
-缺点:
-
-- 会产生一套新的 Agent 图维护成本
-
-#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
-
-- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
-- Agent 根据模式进入 Canvas 路径
-
-优点:
-
-- 统一入口
-
-缺点:
-
-- 会显著增加现有 Agent 复杂度
-- 容易影响现有聊天和文件产物模式
-
-推荐先做方案 A。
-
-### 4.3 模型配置设计思路
-
-模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
-
-前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
-
-为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
-
-- `name`
-- `display_name`
-- `provider`
-- `supports_thinking`
-- `supports_reasoning_effort`
-- `supports_vision`
-- `supports_tool_calling`
-- `supports_artifact_editing`
-- `supports_code_editing`
-- `temperature_range`
-- `max_tokens_range`
-- `base_url_source` 或 provider 标识
-- `is_local`
-
----
-
-## 5. 与当前项目的关键差异
-
-### 5.1 当前 DeerFlow 现状
-
-当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
-
-- `artifacts: string[]`
-
-这更接近“生成文件产物”的工作模式。
-
-前端当前 artifact 面板也主要是:
-
-- 文件列表
-- 文件详情
-- 文件下载/预览
-
-### 5.2 open-canvas 的关键能力
-
-open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
-
-- 一个 conversation 对应一个主 artifact
-- artifact 具有类型、标题、内容、版本
-- 用户可以选中部分内容让 AI 改写
-- 每次改写都会生成新版本
-
-### 5.3 需要新增的核心能力
-
-为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
-
-- 当前 artifact
-- 当前版本索引
-- 历史版本列表
-- 选区信息
-- 当前编辑模式
-- 快速动作状态
-- Web 搜索结果状态
-
----
-
-## 6. 分阶段实施方案
-
-以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
-
----
-
-## 7. 阶段一:前端路由与目录骨架
-
-### 7.1 目标
-
-在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
-
-### 7.2 需求
-
-- 新增 `src/open-canvas/` 目录
-- 新增 Canvas 页面路由
-- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
-- 提供 Canvas 首页和 Canvas Thread 页
-- 支持空状态展示
-
-### 7.3 建议目录
-
-```text
-src/open-canvas/
-  api/
-    threads.ts
-    runs.ts
-    models.ts
-  components/
-    layout/
-    chat/
-    canvas/
-    artifacts/
-    quick-actions/
-    selectors/
-  contexts/
-    CanvasThreadContext.tsx
-    CanvasGraphContext.tsx
-    CanvasUIContext.tsx
-  hooks/
-    useCanvasThread.ts
-    useCanvasStream.ts
-    useCanvasArtifact.ts
-  pages/
-    CanvasHomePage.tsx
-    CanvasThreadPage.tsx
-  routes/
-    OpenCanvasRoutes.tsx
-  types/
-    artifact.ts
-    thread.ts
-    model.ts
-  utils/
-    artifact.ts
-    selection.ts
-    stream.ts
-  constants/
-    index.ts
-```
-
-### 7.4 路由建议
-
-- 在 `PageRoutes.tsx` 中新增 `canvas/*`
-- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
-
-建议形式:
-
-```text
-/canvas
-/canvas/:threadId
-```
-
-### 7.5 验收标准
-
-- 能访问 Canvas 首页
-- 能访问指定 thread 的 Canvas 页面
-- 不影响现有 `workspace/*` 与 `strategy/*`
-- 页面有独立布局骨架
-
----
-
-## 8. 阶段二:前端 Canvas 布局与交互容器
-
-### 8.1 目标
-
-先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
-
-### 8.2 需求
-
-- 左侧聊天区
-- 右侧画布区
-- 支持面板拖拽或折叠
-- 初始空状态
-- 支持“快速开始”:
-  - 新建空白文本 artifact
-  - 新建代码 artifact
-
-### 8.3 组件拆分建议
-
-- `CanvasShell`
-- `CanvasSidebar`
-- `CanvasChatPanel`
-- `CanvasArtifactPanel`
-- `CanvasEmptyState`
-- `QuickStartActions`
-
-### 8.4 快速开始能力
-
-用户进入空线程时可以选择:
-
-- 开始写文章
-- 开始写代码
-- 从模板开始
-
-首版模板可只做最小集:
-
-- 空白文档
-- HTML 模板
-- React 组件模板
-- Python 脚本模板
-
-### 8.5 验收标准
-
-- 页面可稳定展示双栏布局
-- 快速开始能创建前端本地 artifact 状态
-- 代码和文本可分别进入不同渲染器
-
----
-
-## 9. 阶段三:结构化 Artifact 数据模型
-
-### 9.1 目标
-
-定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
-
-### 9.2 核心要求
-
-需要支持以下能力:
-
-- 文本 artifact
-- 代码 artifact
-- 标题
-- 当前版本索引
-- 版本历史
-- 当前选中版本切换
-- 后续支持局部高亮编辑
-
-### 9.3 建议数据结构
-
-```ts
-type CanvasArtifact = {
-  currentIndex: number;
-  contents: Array;
-};
-
-type CanvasArtifactText = {
-  index: number;
-  type: "text";
-  title: string;
-  fullMarkdown: string;
-};
-
-type CanvasArtifactCode = {
-  index: number;
-  type: "code";
-  title: string;
-  language: string;
-  code: string;
-};
-```
-
-### 9.4 线程状态建议新增字段
-
-前端与后端需共同支持:
-
-- `artifact`
-- `highlighted_text`
-- `highlighted_code`
-- `canvas_mode`
-- `web_search_enabled`
-- `web_search_results`
-- `quick_action_id`
-
-### 9.5 与现有 `artifacts: string[]` 的关系
-
-建议保留现有 `artifacts: string[]` 不动。
-
-新增字段:
-
-- `artifact` 用于 Canvas 主文档
-- `artifacts` 继续用于文件产物
-
-这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
-
-### 9.6 验收标准
-
-- 前端类型定义完成
-- 后端状态字段设计完成
-- 确认与现有线程状态兼容
-
----
-
-## 10. 阶段四:后端线程状态与 API 扩展
-
-### 10.1 目标
-
-让后端线程状态可以安全存储和返回 Canvas Artifact。
-
-### 10.2 需求
-
-- 扩展 Python `ThreadState`
-- 扩展状态序列化逻辑
-- 确保 `/api/threads/*` 返回结构化 `artifact`
-- 确保 LangGraph 流中可带上 `artifact` 更新
-
-### 10.3 后端改造点
-
-建议关注以下模块:
-
-- `packages/harness/deerflow/agents/thread_state.py`
-- 线程 state serializer
-- `app/gateway/routers/threads.py`
-- 流事件适配层
-
-### 10.4 注意事项
-
-- 旧线程没有 `artifact` 字段时必须兼容
-- 状态更新不能破坏现有消息结构
-- 必须确保 JSON 可序列化
-
-### 10.5 验收标准
-
-- 新建线程可写入 `artifact`
-- 读取线程时可返回 `artifact`
-- 不影响现有线程读取
-- 前端能从线程 state 中恢复 Canvas 内容
-
----
-
-## 11. 阶段五:Canvas 专用 Agent 图
-
-### 11.1 目标
-
-实现与 open-canvas 类似的 AI 路由能力。
-
-### 11.2 推荐方案
-
-新增 `canvas_agent`,单独维护图逻辑。
-
-### 11.3 节点拆分建议
-
-首版建议至少拆成这些节点:
-
-- `route_user_intent`
-- `reply_general_input`
-- `generate_artifact`
-- `rewrite_artifact`
-- `update_highlighted_text`
-- `update_highlighted_code`
-- `generate_followup`
-
-二期可加:
-
-- `rewrite_artifact_theme`
-- `rewrite_code_theme`
-- `change_length`
-- `change_reading_level`
-- `translate_artifact`
-- `add_comments`
-- `add_logs`
-- `fix_bugs`
-- `port_language`
-- `web_search_enrich`
-
-### 11.4 路由逻辑要求
-
-系统需要能判断:
-
-- 用户是在闲聊
-- 用户要新生成 artifact
-- 用户要整体重写当前 artifact
-- 用户要局部修改当前 artifact
-- 用户要执行某个快捷动作
-
-### 11.5 状态输入要求
-
-节点需要读取:
-
-- 当前消息历史
-- 当前 artifact
-- 当前选区
-- 当前模型配置
-- Web 搜索开关
-
-### 11.6 输出要求
-
-节点输出必须能更新:
-
-- `messages`
-- `artifact`
-- `web_search_results`
-- `followup`
-
-### 11.7 验收标准
-
-- 能根据输入创建新 artifact
-- 能根据输入整体改写 artifact
-- 能保留版本历史
-- 能在改写后返回自然语言 followup
-
----
-
-## 12. 阶段六:前端流式同步与版本管理
-
-### 12.1 目标
-
-让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
-
-### 12.2 需求
-
-- 复用现有 LangGraph stream client
-- 在 Canvas 模式中解析 `artifact` 更新
-- 支持流式文本更新
-- 支持版本切换
-- 支持断线后恢复线程状态
-
-### 12.3 前端状态层建议
-
-- `CanvasThreadContext`:线程基础信息
-- `CanvasGraphContext`:消息、artifact、streaming 状态
-- `CanvasUIContext`:面板、编辑态、选中态
-
-### 12.4 关键能力
-
-- 当前版本切换
-- 新版本创建后自动定位到最新版本
-- 手动编辑内容后保存
-- 流结束后线程状态持久化
-
-### 12.5 验收标准
-
-- 发送消息时聊天和画布可同步更新
-- 版本数会增加
-- 切换历史版本稳定
-- 刷新页面后线程状态能恢复
-
----
-
-## 13. 阶段七:局部选区改写
-
-### 13.1 目标
-
-实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
-
-### 13.2 文本选区需求
-
-- 支持在 Markdown 文本中选择一段文本
-- 记录选区位置与原始文本
-- 用户发出修改请求后,仅重写该选区
-- 将选区结果合并为新版本 artifact
-
-### 13.3 代码选区需求
-
-- 支持在代码编辑器中选择一段代码
-- 记录 start/end index 或行列信息
-- AI 只返回替换片段
-- 前端或后端负责将新片段拼接回完整代码
-
-### 13.4 实现建议
-
-首版建议:
-
-- 文本:前端记录纯文本字符位置
-- 代码:使用 CodeMirror selection 信息
-
-不建议首版就做复杂块级富文本局部编辑。
-
-### 13.5 风险
-
-- Markdown 渲染态与源码态位置映射复杂
-- 富文本块级位置难维护
-- 流式更新中选区同步容易错位
-
-### 13.6 首版折中方案
-
-首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
-
-### 13.7 验收标准
-
-- 文本局部改写成功
-- 代码局部改写成功
-- 原内容未改动部分保持不变
-- 新版本正确入历史
-
----
-
-## 14. 阶段八:快捷动作系统
-
-### 14.1 目标
-
-实现 open-canvas 风格的快捷动作体系。
-
-### 14.2 首批推荐动作
-
-文本类:
-
-- 扩写
-- 精简
-- 改正式语气
-- 改口语语气
-- 改阅读难度
-- 翻译
-
-代码类:
-
-- 加注释
-- 加日志
-- 修复 bug
-- 优化代码
-- 转换语言
-
-### 14.3 设计方式
-
-快捷动作不要直接写死在 UI,建议配置化:
-
-- `id`
-- `label`
-- `type`
-- `applicable_to`
-- `prompt_template`
-
-### 14.4 后端设计
-
-快捷动作可通过:
-
-- 前端传 `quick_action_id`
-- 后端路由节点根据动作 id 选择 prompt
-
-### 14.5 验收标准
-
-- 文本动作可执行
-- 代码动作可执行
-- 结果进入新版本
-- 不同动作提示词可扩展
-
----
-
-## 15. 阶段九:模型配置统一与本地模型支持
-
-### 15.1 目标
-
-让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
-
-### 15.2 原则
-
-- 后端 `config.yaml` 为真源
-- 前端只展示后端暴露的模型信息
-- 不维护一份脱离后端的前端常量模型表
-
-### 15.3 本地模型支持范围
-
-建议优先支持两类:
-
-- `Ollama`
-- OpenAI-compatible 本地服务
-  - LM Studio
-  - vLLM
-  - LocalAI
-  - one-api 风格代理
-
-### 15.4 后端配置建议
-
-后端模型配置应至少支持:
-
-- `name`
-- `display_name`
-- `use`
-- `model`
-- `api_key`
-- `base_url`
-- `supports_vision`
-- `supports_thinking`
-- `supports_tool_calling`
-- `is_local`
-- `canvas_capabilities`
-
-### 15.5 Canvas 模式额外要求
-
-由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
-
-- `supports_canvas_generation`
-- `supports_canvas_rewrite`
-- `supports_canvas_code_edit`
-- `supports_streaming_text`
-- `supports_streaming_tool_calls`
-
-### 15.6 与 open-canvas 的关系
-
-可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
-
-### 15.7 验收标准
-
-- 前端模型选择器可显示后端返回模型
-- 可切换本地模型
-- Canvas 模式能用本地模型生成和改写
-
----
-
-## 16. 阶段十:Web 搜索增强
-
-### 16.1 目标
-
-实现类似 open-canvas 的可选 Web 搜索增强。
-
-### 16.2 需求
-
-- UI 提供 Web 搜索开关
-- 后端在需要时走搜索节点
-- 搜索结果写入线程状态
-- 结果可在画布旁侧栏展示
-
-### 16.3 首版建议
-
-只做:
-
-- 开关
-- 搜索结果列表
-- 搜索结果用于生成 artifact
-
-暂不做复杂网页抓取编辑链路。
-
-### 16.4 验收标准
-
-- 打开搜索后可返回搜索结果
-- 搜索结果可参与 artifact 生成
-- 结果展示不阻塞主画布
-
----
-
-## 17. 阶段十一:持久化、导出与文件产物联动
-
-### 17.1 目标
-
-让 Canvas 文档与 DeerFlow 原有文件能力协同。
-
-### 17.2 需求
-
-- artifact 持久化在线程 state 中
-- 可手动导出为文件
-- 可生成真实 HTML/Markdown/代码文件到现有 outputs
-- Canvas artifact 与 `artifacts: string[]` 可双向关联
-
-### 17.3 推荐能力
-
-- 导出 Markdown
-- 导出代码文件
-- 另存为 artifact file
-- 从 artifact file 回填到 Canvas
-
-### 17.4 验收标准
-
-- 当前版本可导出
-- 导出文件可在现有文件面板查看
-- 文件型 artifact 和 Canvas artifact 能共存
-
----
-
-## 18. 推荐开发顺序
-
-建议严格按以下顺序推进:
-
-1. 路由和目录骨架
-2. Canvas 页面布局
-3. 前端 artifact 数据模型
-4. 后端线程状态扩展
-5. 新建 Canvas Agent 图
-6. 前后端流式同步
-7. 整体生成与整体改写
-8. 局部选区改写
-9. 快捷动作
-10. 本地模型支持完善
-11. Web 搜索增强
-12. 导出与文件联动
-
-不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
-
----
-
-## 19. 里程碑定义
-
-### M1:页面可进入
-
-- 路由接通
-- 双栏布局完成
-- 本地 mock artifact 可渲染
-
-### M2:后端状态打通
-
-- 线程可保存结构化 artifact
-- 页面刷新后能恢复
-
-### M3:AI 生成可用
-
-- 可新建文本/代码 artifact
-- 可整体改写
-- 可产生版本历史
-
-### M4:核心体验可用
-
-- 选区改写可用
-- 快捷动作可用
-- 模型切换可用
-
-### M5:增强能力完整
-
-- 本地模型支持完善
-- Web 搜索增强
-- 导出和文件联动
-
----
-
-## 20. 风险与难点
-
-### 20.1 最大难点
-
-- 结构化 artifact 状态从无到有
-- 局部选区改写的精确性
-- 流式状态与版本管理的一致性
-- 不破坏现有 DeerFlow 文件型 artifact 体系
-
-### 20.2 技术风险
-
-- 线程 state JSON 结构过大
-- 大模型对局部改写指令遵循不稳定
-- 本地模型 tool calling 与流式能力差异大
-- 文本渲染态与源码态位置映射复杂
-
-### 20.3 规避建议
-
-- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
-- 首版只支持文本和代码两种 artifact 类型
-- 首版先做整体生成和整体改写,再做局部改写
-- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
-
----
-
-## 21. 验收总清单
-
-### 21.1 前端
-
-- 有独立 Canvas 路由
-- 有专用目录和模块边界
-- 双栏布局可用
-- 文本和代码两类 artifact 可渲染
-- 历史版本可切换
-- 选区改写可交互
-- 快捷动作可触发
-- 模型可切换
-
-### 21.2 后端
-
-- 线程状态支持 `artifact`
-- 新增 `canvas_agent` 或等价模式
-- 生成、重写、局部改写可用
-- 流式返回稳定
-- 模型列表接口支持 Canvas 能力元数据
-
-### 21.3 本地模型
-
-- 可配置 Ollama
-- 可配置 OpenAI-compatible 本地服务
-- 前端可见并可切换
-- 至少能完成文本生成和整体改写
-
----
-
-## 22. 推荐首版最小可交付范围
-
-如果要控制风险,建议首版只交付以下内容:
-
-- 新增 `canvas/*` 路由
-- 双栏布局
-- 结构化 `artifact`
-- `canvas_agent`
-- 文本/代码 artifact 新建
-- 整体生成
-- 整体改写
-- 历史版本切换
-- 后端模型动态加载
-- Ollama 或本地 OpenAI-compatible 至少一种可用
-
-先不要把以下内容放进首版强制目标:
-
-- 富文本块级精确编辑
-- 全量快捷动作
-- 复杂 Web 搜索增强
-- 多 artifact 并行管理
-
----
-
-## 23. 后续实施建议
-
-建议下一步基于本文档继续输出两份落地文档:
-
-### 23.1 前端实施清单
-
-逐文件列出:
-
-- 要新建哪些文件
-- 哪些组件可复用
-- 哪些页面要接入路由
-- 哪些状态要从现有 hooks 抽离
-
-### 23.2 后端实施清单
-
-逐模块列出:
-
-- 要改哪些 Python 文件
-- 新增哪些状态字段
-- 新增哪些 Agent 节点
-- 哪些 API 需要扩展
-
----
-
-## 24. 结论
-
-这个需求很大,但完全可拆。
-
-正确做法不是“先抄 UI”,而是按下面的主线推进:
-
-- 先建前端 Canvas 子系统
-- 再建结构化 artifact 状态
-- 再建 Canvas Agent 图
-- 再补局部改写和快捷动作
-- 最后统一本地模型能力
-
-只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
diff --git a/frontend-web/docs/素材专家检索.md b/frontend-web/docs/素材专家检索.md
deleted file mode 100644
index 2d1f89a8..00000000
--- a/frontend-web/docs/素材专家检索.md
+++ /dev/null
@@ -1,243 +0,0 @@
-# 素材专家检索 —— 内网接口对接说明
-
-本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
-涉及的代码文件,以及常见错误的排查方式。
-
----
-
-## 1. 检索来源切换
-
-素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
-
-配置位置:`offline-backend-20260512/backend/config.yaml`
-
-```yaml
-ai_writing:
-  # 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
-  search_provider: internet
-  # 以下两项仅在 search_provider=intranet 时生效
-  intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
-  intranet_knowledge_base: samples
-```
-
-| `search_provider` 取值 | 含义 |
-|---|---|
-| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
-| `intranet`  | 内网知识库检索(调用 `search_docs_es` 接口) |
-| `tavily`    | Tavily 互联网检索 |
-| `demo`      | 内置演示素材,仅用于本地调试 |
-
-> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
-> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
-
----
-
-## 1.5 检索关键词数量
-
-素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
-范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
-
-- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
-- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
-  (`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
-- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
-  规整到 `[1, 10]`,越界回退默认 `6`。
-- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
-
----
-
-## 1.6 素材总量上限
-
-素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
-
-配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
-
-```yaml
-ai_writing:
-  # 素材总量上限,范围 1~100,非法值回退 20
-  max_materials: 20
-```
-
-均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
-
-| `max_materials` | 检索词数 | 每词检索条数 |
-|---|---|---|
-| 20 | 1 | 20 |
-| 20 | 2 | 10 |
-| 20 | 3 | 7(共 21,合并后截断到 20) |
-| 20 | 5 | 4 |
-
-- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
-- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
-- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
-- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
-
-> `config.yaml` 支持热加载,改完一般无需重启后端。
-
----
-
-## 2. 内网接口规格
-
-- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
-- **方法**:`POST`
-- **请求体**:
-
-  ```json
-  {
-    "query": "检索关键词",
-    "knowledge_base_name": "samples",
-    "ts": "yes"
-  }
-  ```
-
-- **响应体**:一个数组,每个元素结构如下:
-
-  ```json
-  [
-    {
-      "page_content": "正文片段",
-      "metadata": {
-        "m_title":  "标题",
-        "content":  "内容",
-        "recUuid":  "原文id",
-        "m_publish":"发布时间",
-        "source1":  "来源"
-      }
-    }
-  ]
-  ```
-
-  我们需要的字段都在 `metadata` 里。
-
-### 字段映射
-
-后端把内网响应映射为统一的 `SearchResult` 结构:
-
-| 内网响应字段              | SearchResult 字段 | 说明 |
-|--------------------------|--------------------|------|
-| `metadata.m_title`        | `title`            | 标题,缺省为「(无标题)」 |
-| `metadata.content`        | `content`          | 内容,截断到 500 字 |
-| `metadata.source1`        | `source`           | 来源,缺省为「内网知识库」 |
-| `metadata.m_publish`      | `published_at`     | 发布时间 |
-| `metadata.recUuid`        | `recUuid`          | 原文 id,用于前端拼接「查看原文」链接 |
-| —                         | `url`              | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
-| —                         | `relevance_score`  | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
-| —                         | `source_type`      | 固定为 `"intranet"` |
-
----
-
-## 3. 对接逻辑与涉及文件
-
-### 后端
-
-| 文件 | 作用 |
-|------|------|
-| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
-| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
-| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
-| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
-| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
-| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
-
-调用链:
-
-```
-ai_writing.py(_make_config 注入 ai_writing_config)
-  → researcher_node
-    → get_search_provider(ai_writing_config)   # 依据 search_provider 选择
-      → IntranetSearchProvider.search()         # POST search_docs_es
-        → SearchResult[]                        # 字段映射
-```
-
-### 前端
-
-| 文件 | 作用 |
-|------|------|
-| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
-| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
-| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
-| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
-
-「查看原文」链接拼接规则:
-
-```
-INTRANET_SOURCE_URL_BASE + recUuid
-= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
-```
-
-- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
-- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
-
----
-
-## 4. 错误处理与排查
-
-### 4.1 内网检索返回空 / 调不通
-
-**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
-下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
-
-`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
-都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
-不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
-以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
-**接口报错不影响写作流程继续**。
-
-> 容错相关代码:
-> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
-> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
-> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
->   `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
-> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
-
-排查步骤:
-
-1. **确认网络可达**:在后端所在机器执行
-   `curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
-2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
-3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
-
-空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
-用户可以选择「重新搜索」或「无数据继续写作」。
-
-### 4.2 字段缺失 / 响应结构变化
-
-`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
-单条数据字段缺失不会导致整批失败。
-
-若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
-若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
-
-### 4.3 「查看原文」按钮不显示
-
-**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
-互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
-
-如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
-
-### 4.4 原文链接打不开 / 地址不对
-
-「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
-若内网原文详情页地址有变,只改这一个常量即可。
-
-### 4.5 检索到的素材全是无关内容
-
-**现象**:素材卡片内容与写作意图完全不沾边。
-
-**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
-提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
-
-`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
-`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
-`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
-
----
-
-## 5. 联调上线步骤
-
-1. 确认后端机器能访问 `10.106.57.180:7863`。
-2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
-3. 后端配置热加载生效(或重启后端)。
-4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
-5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
-6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx
index 63cee2fb..f9581a7a 100644
--- a/frontend-web/src/components/workspace/messages/message-list.tsx
+++ b/frontend-web/src/components/workspace/messages/message-list.tsx
@@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks";
 import { useUpdateSubtask } from "@/core/tasks/context";
 import type { AgentThreadState } from "@/core/threads";
 import { cn } from "@/lib/utils";
+import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
 
 import { ArtifactFileList } from "../artifacts/artifact-file-list";
 import { StreamingIndicator } from "../streaming-indicator";
@@ -102,7 +103,9 @@ export function MessageList({
     [],
   );
 
-  const handleSubmitAllClarifications = useCallback(async () => {
+  const handleSubmitAllClarifications = useCallback(async (
+    selectionOverrides?: Map,
+  ) => {
     if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
       return;
     }
@@ -111,7 +114,7 @@ export function MessageList({
       const id = pendingClarificationIds[index]!;
       const message = messages.find((msg) => msg.id === id);
       const payload = message ? getClarificationPayload(message) : null;
-      const selection = clarificationSelections.get(id) ?? {
+      const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
         selectedIds: [],
         custom: "",
       };
@@ -221,7 +224,12 @@ export function MessageList({
                     }
                     submitting={submittingClarification}
                     canSubmit={hasAnyClarificationAnswer}
-                    onSubmitAll={handleSubmitAllClarifications}
+                    onSubmitAll={(override) => {
+                      const overrides = override
+                        ? new Map([[messageId, override]])
+                        : undefined;
+                      void handleSubmitAllClarifications(overrides);
+                    }}
                   />
                    void;
   submitting: boolean;
   canSubmit: boolean;
-  onSubmitAll: () => void | Promise;
+  onSubmitAll: (override?: ClarificationSelection) => void | Promise;
 }) {
   const payload = getClarificationPayload(message);
 
@@ -624,11 +633,21 @@ function ClarificationCard({
 
   const disabled = !isPending || submitting;
   const multiPending = (pendingCount ?? 0) > 1;
+  const allowMultiple = resolveAllowMultiple(
+    payload.allow_multiple,
+    payload.clarification_type,
+  );
 
-  function toggleOption(optionId: string) {
+  function handleOptionClick(optionId: string) {
     if (disabled) {
       return;
     }
+    if (!allowMultiple) {
+      const next = { selectedIds: [optionId], custom: "" };
+      onSelectionChange(next);
+      void onSubmitAll(next);
+      return;
+    }
     const next = selection.selectedIds.includes(optionId)
       ? selection.selectedIds.filter((id) => id !== optionId)
       : [...selection.selectedIds, optionId];
@@ -666,7 +685,7 @@ function ClarificationCard({
       {options.length > 0 ? (
         
- 可多选(再次点击取消选择) + {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
{options.map((option) => { const optionId = option.id || option.label; @@ -675,10 +694,10 @@ function ClarificationCard({ @@ -720,10 +739,10 @@ function ClarificationCard({
) : null} - {isPending ? ( + {isPending && (allowMultiple || payload.allow_custom !== false) ? ( isLastPending ? (
- {multiPending ? ( + {multiPending && allowMultiple ? ( 将一起提交以上 {pendingCount} 个问题的回答 @@ -735,14 +754,14 @@ function ClarificationCard({ onClick={() => void onSubmitAll()} > - {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"} + {submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
- ) : ( + ) : allowMultiple ? (
选择后等待最后一题统一提交
- ) + ) : null ) : null} ); diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 481b9d00..dabcdb3e 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -1,6 +1,11 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import type { ClarificationPayload } from "./multi-agent"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; + +export type { ClarificationPayload }; + /** * Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single * built-in agent and no per-call coordinator: one /init creates a thread @@ -73,12 +78,17 @@ export interface StreamIntentRequest { } export interface StreamIntentResult { - /** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */ - status: "asking" | "done" | "error"; + /** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */ + status: "asking" | "clarification" | "done" | "error"; /** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */ content: string; /** Populated only when status === "done". */ summary: IntentSummary | null; + /** + * Populated when the intent agent called ask_clarification — render option + * cards + custom input instead of treating the turn as plain text only. + */ + clarification: ClarificationPayload | null; } /** @@ -87,14 +97,25 @@ export interface StreamIntentResult { * is optional. */ function isFinalIntentFrame(value: unknown): value is { - status: "asking" | "done" | "error"; + status: "asking" | "clarification" | "done" | "error"; content?: string; summary?: IntentSummary | null; + question?: string; + clarification_type?: string; + clarification_context?: string; + options?: unknown[]; + allow_custom?: boolean; + allow_multiple?: boolean; } { if (!value || typeof value !== "object") return false; if (!("status" in value) || !("content" in value)) return false; const status = (value as { status: unknown }).status; - return status === "asking" || status === "done" || status === "error"; + return ( + status === "asking" || + status === "clarification" || + status === "done" || + status === "error" + ); } /** @@ -166,7 +187,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise; export interface SeatReadyEvent { @@ -222,6 +224,8 @@ export interface ClarificationPayload { options: unknown[]; /** Whether the user is allowed to type a custom answer. */ allowCustom: boolean; + /** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */ + allowMultiple?: boolean; } export interface StreamMultiAgentResult { @@ -257,6 +261,7 @@ function isFinalStatusFrame(value: unknown): value is { clarification_context?: string; options?: unknown[]; allow_custom?: boolean; + allow_multiple?: boolean; } { if (!value || typeof value !== "object") return false; if (!("status" in value) || !("content" in value)) return false; @@ -447,14 +452,19 @@ export async function streamMultiAgent( // a question (or chip choices) instead of mistaking the run for // a consensus. if (parsed.status === "clarification") { + const clarificationType = + typeof parsed.clarification_type === "string" ? parsed.clarification_type : ""; result.clarification = { question: typeof parsed.question === "string" ? parsed.question : "", - clarificationType: - typeof parsed.clarification_type === "string" ? parsed.clarification_type : "", + clarificationType, context: typeof parsed.clarification_context === "string" ? parsed.clarification_context : "", options: Array.isArray(parsed.options) ? parsed.options : [], allowCustom: parsed.allow_custom !== false, + allowMultiple: resolveAllowMultiple( + typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined, + clarificationType, + ), }; } } diff --git a/frontend-web/src/roundtable-planning/api/recommend.ts b/frontend-web/src/roundtable-planning/api/recommend.ts new file mode 100644 index 00000000..db73b4ed --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/recommend.ts @@ -0,0 +1,303 @@ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; +import type { Agent } from "@/core/agents"; + +import type { IntentSummary } from "./intent"; + +/** + * Agent-recommendation API. Sits between Step 1 (intent clarification) and + * Step 2 (multi-agent roundtable): given the IntentSummary and a candidate + * agent pool, the built-in `roundtable-recommender` picks 2-6 agents most + * relevant to the task and explains why. + * + * One-shot, stateless: no /init, no thread_id, each call spins up a fresh + * backend-managed thread. Use {@link filterRecommendCandidates} to clean the + * `/api/agents` list before passing it as `candidates`. + */ + +/** Single agent pick returned by the recommender. */ +export interface RecommendPick { + agent_id: string; + reason: string; +} + +/** Final status frame the backend appends after the upstream stream finishes. */ +export interface StreamRecommendResult { + /** + * `done` → picks parsed and validated, ready to populate the dialog as + * default-checked selections. + * `asking` → recommender failed to emit a usable [RECOMMEND_READY] block, + * caller should apply its fallback list (typically the 6 roundtable-* seats). + * `error` → upstream / network failure. + */ + status: "done" | "asking" | "error"; + /** Full final AI text (includes the natural-language rationale). */ + content: string; + /** Populated only when status === "done"; null otherwise. */ + picks: RecommendPick[] | null; +} + +/** Phase mirror of streamIntent so the UI can reuse one loading-label mapper. */ +export type RecommendStreamPhase = "connecting" | "connected" | "streaming"; + +export interface StreamRecommendRequest { + /** Step 1 output. `objective` is required by the backend. */ + intent: IntentSummary; + /** Pre-filtered candidate pool (use {@link filterRecommendCandidates}). */ + candidates: RecommendCandidate[]; + model?: string; + signal?: AbortSignal; + /** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */ + onRationaleUpdate?: (rationale: string) => void; + /** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */ + onPicksUpdate?: (picks: RecommendPick[]) => void; + /** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */ + onTextDelta?: (text: string, messageId: string) => void; + /** Fires whenever the inferred SSE phase changes. */ + onPhaseChange?: (phase: RecommendStreamPhase) => void; +} + +/** Shape the backend expects in `candidates[]`. */ +export interface RecommendCandidate { + agent_id: string; + name: string; + description: string; +} + +/** + * Agent ids that must never enter the candidate pool. Centralized here so + * every caller (recommend dialog, future tools) applies the same rule. + * + * - `roundtable-intent` — Step 1 clarification specialist, no role in research. + * - `roundtable-recommender`— the recommender itself, can't recommend itself. + * - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB + * accumulates one per session); meaningless as roundtable participants. + */ +const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-"; +const EXCLUDED_AGENT_IDS = new Set(["roundtable-intent", "roundtable-recommender"]); + +/** + * Drop excluded ids from a raw `GET /api/agents` list and project each entry + * to the slim shape the backend expects. Returns a fresh array; original + * objects are not mutated. + */ +export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] { + const out: RecommendCandidate[] = []; + for (const a of agents) { + if (!a || !a.id) continue; + if (EXCLUDED_AGENT_IDS.has(a.id)) continue; + if (a.id.startsWith(EXCLUDED_AGENT_ID_PREFIX)) continue; + out.push({ + agent_id: a.id, + name: a.name ?? a.id, + description: a.description ?? "", + }); + } + return out; +} + +function isFinalRecommendFrame(value: unknown): value is { + status: "done" | "asking" | "error"; + content?: string; + picks?: RecommendPick[] | null; +} { + if (!value || typeof value !== "object") return false; + if (!("status" in value) || !("content" in value)) return false; + const status = (value as { status: unknown }).status; + return status === "done" || status === "asking" || status === "error"; +} + +const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"; + +/** Match one complete pick object inside a partially streamed JSON block. */ +const PICK_OBJECT_PATTERN = + /\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g; + +function unescapeJsonString(raw: string): string { + try { + return JSON.parse(`"${raw}"`) as string; + } catch { + return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } +} + +/** + * Split accumulated recommender output into visible rationale text and any + * pick objects that have fully arrived in the JSON section. + */ +export function parseRecommendStreamContent(raw: string): { + rationale: string; + picks: RecommendPick[]; +} { + const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER); + let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw; + rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd(); + + const jsonSection = + markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : ""; + + const picks: RecommendPick[] = []; + const seen = new Set(); + PICK_OBJECT_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) { + const agent_id = unescapeJsonString(match[1]).trim(); + const reason = unescapeJsonString(match[2]).trim(); + if (!agent_id || seen.has(agent_id)) continue; + seen.add(agent_id); + picks.push({ agent_id, reason }); + } + + return { rationale, picks }; +} + +/** + * Extract an AI text delta from one LangGraph `messages-tuple` frame. Same + * shape as the helper in intent.ts; duplicated rather than imported to keep + * each helper independently readable. + */ +function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { + if (!Array.isArray(parsed) || parsed.length < 1) return null; + + if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") { + const meta = parsed[1] as Record; + const node = String(meta.langgraph_node ?? ""); + if (node.includes("TitleMiddleware")) return null; + const tags = meta.tags; + if (Array.isArray(tags) && tags.includes("middleware:title")) return null; + } + + const chunk = parsed[0]; + if (!chunk || typeof chunk !== "object") return null; + const obj = chunk as Record; + const type = String(obj.type ?? "").toLowerCase(); + if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null; + const content = obj.content; + let text = ""; + if (typeof content === "string") { + text = content; + } else if (Array.isArray(content)) { + for (const item of content) { + if (typeof item === "string") text += item; + else if (item && typeof item === "object") { + const block = item as Record; + const blockText = block.text; + if (typeof blockText === "string") text += blockText; + } + } + } + if (!text) return null; + const id = typeof obj.id === "string" ? obj.id : ""; + return { text, id }; +} + +/** + * Open an SSE connection to `/api/recommend/stream` and resolve once the + * upstream stream finishes. Each call is one recommendation round — the + * recommender is stateless, so calling twice in a row produces two + * independent runs. + */ +export async function streamRecommend(req: StreamRecommendRequest): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/recommend/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + intent: req.intent, + candidates: req.candidates, + model: req.model, + }), + signal: req.signal, + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ""); + throw new Error(`recommend/stream failed (${res.status}): ${text}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastEvent = ""; + + let currentPhase: RecommendStreamPhase = "connecting"; + req.onPhaseChange?.("connecting"); + const setPhase = (next: RecommendStreamPhase) => { + if (next === currentPhase) return; + currentPhase = next; + req.onPhaseChange?.(next); + }; + + const result: StreamRecommendResult = { status: "asking", content: "", picks: null }; + let rawContent = ""; + let lastRationale = ""; + let lastPickCount = 0; + + 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) continue; + if (line.startsWith("event:")) { + lastEvent = line.slice(6).trim(); + if (lastEvent === "metadata" && currentPhase === "connecting") { + setPhase("connected"); + } + continue; + } + 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 (isFinalRecommendFrame(parsed)) { + result.status = parsed.status; + if (typeof parsed.content === "string") result.content = parsed.content; + if (Array.isArray(parsed.picks)) { + result.picks = parsed.picks as RecommendPick[]; + } + continue; + } + + if (lastEvent === "messages") { + const delta = extractAiTextDelta(parsed); + if (delta) { + setPhase("streaming"); + req.onTextDelta?.(delta.text, delta.id); + rawContent += delta.text; + const { rationale, picks } = parseRecommendStreamContent(rawContent); + if (rationale !== lastRationale) { + lastRationale = rationale; + req.onRationaleUpdate?.(rationale); + } + if (picks.length > lastPickCount) { + lastPickCount = picks.length; + req.onPicksUpdate?.(picks); + } + } + } + } + } + + if (result.content) { + const { rationale, picks } = parseRecommendStreamContent(result.content); + if (rationale !== lastRationale) { + req.onRationaleUpdate?.(rationale); + } + if (picks.length > lastPickCount) { + req.onPicksUpdate?.(picks); + } + } + + return result; +} diff --git a/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx new file mode 100644 index 00000000..d30fbc5d --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx @@ -0,0 +1,217 @@ +import { Check } from "lucide-react"; +import { useMemo } from "react"; + +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; + +export type IntentClarificationSelection = { + selectedIds: string[]; + custom: string; +}; + +type ClarificationOption = { + id: string; + label: string; + description?: string; +}; + +function normalizeOptions(raw: unknown[]): ClarificationOption[] { + return raw + .map((opt, index) => { + if (typeof opt === "string") { + const label = opt.trim(); + return label ? { id: `option-${index}`, label } : null; + } + if (opt && typeof opt === "object" && "label" in opt) { + const obj = opt as { id?: unknown; label?: unknown; description?: unknown }; + const label = String(obj.label ?? "").trim(); + if (!label) return null; + const id = String(obj.id ?? label).trim() || `option-${index}`; + const description = + typeof obj.description === "string" && obj.description.trim() + ? obj.description.trim() + : undefined; + return { id, label, description }; + } + return null; + }) + .filter((opt): opt is ClarificationOption => opt !== null); +} + +export function formatIntentClarificationAnswer( + payload: ClarificationPayload, + selection: IntentClarificationSelection, +): string | null { + const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []); + const selectedLabels = selection.selectedIds + .map((sid) => options.find((option) => option.id === sid)?.label) + .filter((label): label is string => Boolean(label && label.trim())); + const trimmedCustom = selection.custom.trim(); + + if (selectedLabels.length > 0 && trimmedCustom) { + return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`; + } + if (selectedLabels.length > 0) { + return `我选择:${selectedLabels.join("、")}`; + } + if (trimmedCustom) { + return trimmedCustom; + } + return null; +} + +interface IntentClarificationCardProps { + payload: ClarificationPayload; + fallbackContent?: string; + selection: IntentClarificationSelection; + onSelectionChange: (next: IntentClarificationSelection) => void; + disabled?: boolean; + submitting?: boolean; + /** Multi-select: submit current selection. Single-select: also used for custom text. */ + onSubmit: (overrideSelection?: IntentClarificationSelection) => void; +} + +export function IntentClarificationCard({ + payload, + fallbackContent, + selection, + onSelectionChange, + disabled = false, + submitting = false, + onSubmit, +}: IntentClarificationCardProps) { + const options = useMemo( + () => normalizeOptions(Array.isArray(payload.options) ? payload.options : []), + [payload.options], + ); + + const allowMultiple = resolveAllowMultiple( + payload.allowMultiple, + payload.clarificationType, + ); + + const canSubmit = useMemo(() => { + return selection.selectedIds.length > 0 || selection.custom.trim().length > 0; + }, [selection.custom, selection.selectedIds]); + + const showSubmitButton = allowMultiple || payload.allowCustom !== false; + + function handleOptionClick(optionId: string) { + if (disabled || submitting) return; + if (!allowMultiple) { + onSubmit({ selectedIds: [optionId], custom: "" }); + return; + } + const next = selection.selectedIds.includes(optionId) + ? selection.selectedIds.filter((id) => id !== optionId) + : [...selection.selectedIds, optionId]; + onSelectionChange({ ...selection, selectedIds: next }); + } + + function updateCustom(value: string) { + if (disabled || submitting) return; + onSelectionChange({ ...selection, custom: value }); + } + + return ( +
+
+
+ + 需要你的确认 + + {payload.clarificationType ? ( + + {payload.clarificationType} + + ) : null} +
+ {payload.context ? ( +

{payload.context}

+ ) : null} +
+ {payload.question || fallbackContent} +
+
+ + {options.length > 0 ? ( +
+
+ {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"} +
+ {options.map((option) => { + const isSelected = selection.selectedIds.includes(option.id); + return ( + + ); + })} +
+ ) : null} + + {payload.allowCustom !== false ? ( +
+