` 直出 +- 头部「下载」按钮单独走 `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 ? ( ) : null} - {isPending ? ( + {isPending && (allowMultiple || payload.allow_custom !== false) ? ( isLastPending ? (- 可多选(再次点击取消选择) + {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}{options.map((option) => { const optionId = option.id || option.label; @@ -675,10 +694,10 @@ function ClarificationCard({ @@ -720,10 +739,10 @@ function ClarificationCard({- {multiPending ? ( + {multiPending && allowMultiple ? ( 将一起提交以上 {pendingCount} 个问题的回答 @@ -735,14 +754,14 @@ function ClarificationCard({ onClick={() => void onSubmitAll()} >- ) : ( + ) : allowMultiple ? (- {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"} + {submitting ? "发送中…" : multiPending && 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 ( + ++ ); +} diff --git a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx new file mode 100644 index 00000000..a096824a --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx @@ -0,0 +1,614 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { listAgents, type Agent } from "@/core/agents"; +import { cn } from "@/lib/utils"; + +import type { IntentSummary } from "../api/intent"; +import { + filterRecommendCandidates, + parseRecommendStreamContent, + streamRecommend, + type RecommendCandidate, + type RecommendPick, + type RecommendStreamPhase, +} from "../api/recommend"; + +/** + * Picks-selection dialog that sits between Step 1 (intent) and Step 2 + * (roundtable). Opens with a streaming recommendation from + * `roundtable-recommender`, defaults its picks to checked, and lets the user + * trim or extend the selection from the rest of the agent pool before + * confirming. + * + * Selection bounds (2 ≤ N ≤ 8) are enforced on the confirm button — the + * backend also clamps but UI feedback is faster here. + */ + +export interface RecommendAgentsDialogProps { + open: boolean; + intent: IntentSummary | null; + /** Falls back to the recommender's own default if omitted. */ + model?: string; + onCancel: () => void; + /** Called with the user's final selection of agent ids (in display order). */ + onConfirm: (agentIds: string[]) => void; +} + +const MIN_SELECTED = 2; +const MAX_SELECTED = 8; + +/** + * Default selection when the recommender fails or returns < 2 valid picks. + * Intersected with the live candidate pool at runtime so missing built-ins + * don't break the dialog. + */ +const FALLBACK_AGENT_IDS = [ + "roundtable-intelligence", + "roundtable-environment", + "roundtable-solution-design", + "roundtable-risk-review", + "roundtable-execution-plan", + "roundtable-summary", +]; + +type DialogPhase = + | "idle" + | "loading_agents" + | "recommending" + | "ready" + | "fallback" + | "error"; + +interface InternalState { + phase: DialogPhase; + /** Streaming + final rationale text from the recommender. */ + rationale: string; + /** Streaming SSE phase from the recommender helper. */ + streamPhase: RecommendStreamPhase | null; + /** Pre-filtered candidate pool fetched from `/api/agents`. */ + candidates: RecommendCandidate[]; + /** Reason-by-agent_id, only for picks returned by the recommender. */ + reasons: Record++ + {options.length > 0 ? ( ++ + 需要你的确认 + + {payload.clarificationType ? ( + + {payload.clarificationType} + + ) : null} ++ {payload.context ? ( +{payload.context}
+ ) : null} ++ {payload.question || fallbackContent} ++++ ) : null} + + {payload.allowCustom !== false ? ( ++ {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"} ++ {options.map((option) => { + const isSelected = selection.selectedIds.includes(option.id); + return ( + + ); + })} +++ ) : null} + + {showSubmitButton ? ( ++ ++ ) : null} +; + /** Agent ids the recommender returned (in order). */ + recommendedIds: string[]; + /** User-visible warning ("推荐失败,已使用默认席位") for the fallback case. */ + warning: string | null; + /** Latest error message; only shown when `phase === "error"`. */ + errorMessage: string | null; +} + +const INITIAL_STATE: InternalState = { + phase: "idle", + rationale: "", + streamPhase: null, + candidates: [], + reasons: {}, + recommendedIds: [], + warning: null, + errorMessage: null, +}; + +/** + * Resolve which candidate ids should be checked by default given a set of + * recommended ids. The result preserves recommendation order and silently + * drops ids that aren't in the candidate pool. + */ +function intersectInOrder(preferred: string[], pool: RecommendCandidate[]): string[] { + const poolIds = new Set(pool.map((c) => c.agent_id)); + const seen = new Set (); + const out: string[] = []; + for (const id of preferred) { + if (!poolIds.has(id) || seen.has(id)) continue; + out.push(id); + seen.add(id); + } + return out; +} + +export function RecommendAgentsDialog({ + open, + intent, + model, + onCancel, + onConfirm, +}: RecommendAgentsDialogProps) { + const [state, setState] = useState (INITIAL_STATE); + const [selectedIds, setSelectedIds] = useState >(new Set()); + + // Latest stream abort controller, so closing or re-running cancels the + // in-flight request instead of leaking it. + const abortRef = useRef (null); + // Generation token guards against stale completion handlers writing into + // state after a re-run (e.g. user clicks "重新推荐" mid-stream). + const generationRef = useRef(0); + + const cancelInflight = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + }, []); + + /** + * The full pipeline: fetch agents → run recommendation → seed selection. + * Wrapped so the user can re-trigger it via the "重新推荐" button. + */ + const runRecommendation = useCallback(async () => { + if (!intent || !intent.objective) { + setState({ + ...INITIAL_STATE, + phase: "error", + errorMessage: "缺少任务目标,无法启动推荐", + }); + return; + } + + cancelInflight(); + const myGen = ++generationRef.current; + const controller = new AbortController(); + abortRef.current = controller; + + setState({ ...INITIAL_STATE, phase: "loading_agents" }); + setSelectedIds(new Set()); + + let agents: Agent[]; + try { + agents = await listAgents(); + } catch (err) { + if (myGen !== generationRef.current) return; + setState({ + ...INITIAL_STATE, + phase: "error", + errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + if (myGen !== generationRef.current) return; + + const candidates = filterRecommendCandidates(agents); + if (candidates.length === 0) { + setState({ + ...INITIAL_STATE, + phase: "error", + errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体", + }); + return; + } + + setState((prev) => ({ + ...prev, + phase: "recommending", + candidates, + rationale: "", + streamPhase: "connecting", + })); + + try { + const result = await streamRecommend({ + intent, + candidates, + model, + signal: controller.signal, + onRationaleUpdate: (rationale) => { + if (myGen !== generationRef.current) return; + setState((prev) => ({ ...prev, rationale })); + }, + onPicksUpdate: (picks) => { + if (myGen !== generationRef.current) return; + const reasons: Record = {}; + const recommendedIds: string[] = []; + for (const p of picks) { + reasons[p.agent_id] = p.reason; + recommendedIds.push(p.agent_id); + } + const seed = intersectInOrder(recommendedIds, candidates); + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of seed) next.add(id); + return next; + }); + setState((prev) => ({ + ...prev, + recommendedIds, + reasons, + })); + }, + onPhaseChange: (phase) => { + if (myGen !== generationRef.current) return; + setState((prev) => ({ ...prev, streamPhase: phase })); + }, + }); + if (myGen !== generationRef.current) return; + + const reasons: Record = {}; + const recommendedIds: string[] = []; + if (result.picks) { + for (const p of result.picks) { + reasons[p.agent_id] = p.reason; + recommendedIds.push(p.agent_id); + } + } + + if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) { + const seed = intersectInOrder(recommendedIds, candidates); + setSelectedIds(new Set(seed)); + const { rationale } = parseRecommendStreamContent(result.content || ""); + setState((prev) => ({ + ...prev, + phase: "ready", + rationale: rationale || prev.rationale, + recommendedIds, + reasons, + streamPhase: null, + warning: null, + })); + return; + } + + // Fallback: status === "asking" or picks too few. Use the 6 built-in + // roundtable seats, intersected against the live pool. + const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates); + const seed = fallback.length >= MIN_SELECTED + ? fallback + : candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id); + setSelectedIds(new Set(seed)); + const { rationale } = parseRecommendStreamContent(result.content || ""); + setState((prev) => ({ + ...prev, + phase: "fallback", + rationale: rationale || prev.rationale, + recommendedIds: seed, + reasons, + streamPhase: null, + warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。", + })); + } catch (err) { + if (myGen !== generationRef.current) return; + // AbortError fires when the user closes the dialog mid-stream — no + // state update needed because the dialog is going away. + if (err instanceof DOMException && err.name === "AbortError") return; + setState((prev) => ({ + ...prev, + phase: "error", + streamPhase: null, + errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`, + })); + } + }, [intent, model, cancelInflight]); + + // Trigger the pipeline whenever the dialog opens. Closing it aborts any + // in-flight stream so the next open starts clean. + useEffect(() => { + if (!open) { + cancelInflight(); + return; + } + void runRecommendation(); + return () => { + cancelInflight(); + }; + }, [open, runRecommendation, cancelInflight]); + + const handleToggle = useCallback((agentId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + return next; + }); + }, []); + + // Split candidates into "recommended" and "other" — order within each + // group matches the recommender's pick order / the API's list order so the + // dialog is deterministic between renders. + const { recommendedCards, otherCards } = useMemo(() => { + const recSet = new Set(state.recommendedIds); + const byId = new Map(state.candidates.map((c) => [c.agent_id, c])); + const recommended: RecommendCandidate[] = []; + for (const id of state.recommendedIds) { + const c = byId.get(id); + if (c) recommended.push(c); + } + const others = state.candidates.filter((c) => !recSet.has(c.agent_id)); + return { recommendedCards: recommended, otherCards: others }; + }, [state.candidates, state.recommendedIds]); + + const selectedCount = selectedIds.size; + const canConfirm = + (state.phase === "ready" || state.phase === "fallback") && + selectedCount >= MIN_SELECTED && + selectedCount <= MAX_SELECTED; + + const selectionHint = (() => { + if (selectedCount < MIN_SELECTED) { + return `至少再选 ${MIN_SELECTED - selectedCount} 个`; + } + if (selectedCount > MAX_SELECTED) { + return `已超出上限 ${selectedCount - MAX_SELECTED} 个`; + } + return `已选 ${selectedCount} / ${MAX_SELECTED}`; + })(); + + const handleConfirm = useCallback(() => { + if (!canConfirm) return; + // Order: recommended-and-selected first (in recommender order), then + // user-added picks (in pool order). Keeps the leader's "可调度智能体" + // list visually stable. + const ordered: string[] = []; + for (const c of recommendedCards) { + if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id); + } + for (const c of otherCards) { + if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id); + } + onConfirm(ordered); + }, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (!next) onCancel(); + }, + [onCancel], + ); + + return ( + + ); +} + +function RationaleBlock({ state }: { state: InternalState }) { + if (state.phase === "loading_agents") { + return ( + ++ ); + } + if (state.phase === "recommending") { + const label = + state.streamPhase === "connecting" + ? "调度推荐智能体..." + : state.streamPhase === "connected" + ? "已连接,等待响应..." + : "正在生成推荐..."; + return ( ++ 正在加载候选智能体... + ++ ); + } + if (state.phase === "ready" || state.phase === "fallback") { + if (!state.rationale) { + return++ {state.rationale ? ( ++ {label} + + {state.rationale} +
+ ) : ( +正在分析任务与候选智能体...
+ )} +推荐完成。; + } + return ( ++ {state.rationale} +
+ ); + } + if (state.phase === "error") { + return推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。; + } + return null; +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +++ ); +} + +interface AgentCardProps { + candidate: RecommendCandidate; + reason: string | undefined; + recommended: boolean; + checked: boolean; + onToggle: () => void; + /** Card is still being filled in from the SSE stream. */ + streaming?: boolean; +} + +function AgentCard({ + candidate, + reason, + recommended, + checked, + onToggle, + streaming = false, +}: AgentCardProps) { + return ( + + ); +} diff --git a/frontend-web/src/roundtable-planning/index.ts b/frontend-web/src/roundtable-planning/index.ts index 020bea8d..b7e6b294 100644 --- a/frontend-web/src/roundtable-planning/index.ts +++ b/frontend-web/src/roundtable-planning/index.ts @@ -1,2 +1,10 @@ +/** + * 圆桌规划(Roundtable Planning)模块入口。 + * + * 详细架构 / API 字段速查见 `frontend-web/docs/multi-agent-frontend-dev.md`。 + * 这里只暴露两个外部使用的组件: + * - `RoundtablePlanningPage`:三步合一的主页面(任务理解 → 多智能体研讨 → 方案展示)。 + * - `HighFidelityReport`:Step 3 报告视图,可单独嵌入其它面板(如未来的「方案对比」)。 + */ export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage"; export { HighFidelityReport } from "./components/HighFidelityReport"; diff --git a/frontend-web/src/roundtable-planning/lib/clarification.ts b/frontend-web/src/roundtable-planning/lib/clarification.ts new file mode 100644 index 00000000..93fb3ee9 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/clarification.ts @@ -0,0 +1,13 @@ +/** + * Resolve whether option cards should allow multi-select. + * Default is single-select (click to send); only multi when explicitly requested. + */ +export function resolveAllowMultiple( + allowMultiple: boolean | undefined, + _clarificationType?: string | undefined, +): boolean { + if (typeof allowMultiple === "boolean") { + return allowMultiple; + } + return false; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 6c0ed51e..abecde29 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1,4 +1,38 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +/** + * 圆桌规划主页面(三步合一)。 + * + * 这是一个约 3 600 行的单文件页面,通过 `currentStep ∈ {1,2,3}` 在同一容器内切换 + * 三步 UI。架构 / 状态机 / SSE 终态语义请先读 `frontend-web/docs/multi-agent-frontend-dev.md`, + * 这里只补充阅读源码时最容易忽略的几条规则: + * + * 1) **不要并行派活**:编排循环 `runOrchestration` 内 `for (const [subName, task] of dispatched)` + * 是**顺序**而非并行——历史上并行触发 N 路 SSE,每路 token 级 setState 整表会卡顿;保持顺序。 + * + * 2) **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅增量)→ + * `finalizeDialogue`(最终文本 + confidence + `streaming=false`)。三个函数都按 `id` + * 匹配,绝不要用数组下标。 + * + * 3) **Step 1 init 防 StrictMode 双 mount**:用单调代数 `step1InitGenRef` 而不是闭包 cancelled + * 标志。任何 setState 完成前都要校验 `myGen === step1InitGenRef.current`。同样模式见 + * `RecommendAgentsDialog` 的 `generationRef`。 + * + * 4) **离开 Step 2 不销毁线程**:`step2InitRef`/`threadIds` 保留,允许用户回到 Step 2 继续看 + * 完整讨论。只有「新建任务 / 加载草稿 / 推荐确认」会复位 `step2InitRef`。 + * + * 5) **草稿自动保存只在前进切换时触发**(1→2、2→3),后退不保存,避免覆盖 `furthestStep`。 + * + * 6) **顶栏门禁**:Step 2 需 `intentReady`;Step 3 需 `hasConsensus`(leader.dispatched===[])。 + * + * 7) **Persona 抽屉**改的 model 不会立即重启编排,下一轮该席位被派活时由 `runOrchestration` + * 读 `agentConfigs[meta.speakerId]?.model` 传入 special。prompt / temp 当前**仅前端记录**。 + * + * 8) **常量**:`DEFAULT_MODEL`、`MAX_CYCLES=8`、`COMMANDER_SEAT_ID=1`、`INTENT_READY_RE`。 + * + * 调试开关(默认开启): + * window.__MULTI_AGENT_INIT_TIMING__ // /api/multi-agent/init 各阶段耗时 + * window.__MULTI_AGENT_DEBUG__ // streamMultiAgent 帧计数 + lastEvent + */ +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport"; @@ -21,6 +55,12 @@ import { type IntentStreamPhase, type IntentSummary, } from "@/roundtable-planning/api/intent"; +import { + IntentClarificationCard, + formatIntentClarificationAnswer, + type IntentClarificationSelection, +} from "@/roundtable-planning/components/IntentClarificationCard"; +import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog"; import { deleteDraft, deriveDraftName, @@ -159,42 +199,111 @@ type RoleEntry = { status: string; }; -const ROUNDTABLE_ROLE_TEMPLATES: ReadonlyArray+ {title} ++{children}+> = [ - { id: 1, name: "指挥员 / 主持人", type: "human" }, - { id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent" }, - { id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent" }, - { id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent" }, - { id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent" }, - { id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent" }, - { id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent" }, - { id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent" }, -]; +/** Stable seat id reserved for the human commander row. Agent seats start at 2. */ +const COMMANDER_SEAT_ID = 1; -function makeRoles(defaultStatus: string, overrides: Record = {}): RoleEntry[] { - return ROUNDTABLE_ROLE_TEMPLATES.map((t) => ({ +/** One entry per agent the user picked for this round of Step 2. */ +type SelectedAgent = { agent_id: string; name: string }; + +/** + * Display metadata for the 6 built-in roundtable seats — kept so the + * familiar emoji/color/display name survives when the user keeps the + * defaults. Custom agents fall through to {@link FALLBACK_AVATAR_TYPES}. + */ +const BUILTIN_AGENT_META: Record = { + "roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" }, + "roundtable-environment": { avatarType: "emerald", displayName: "环境评估" }, + "roundtable-solution-design": { avatarType: "human", displayName: "方案设计" }, + "roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" }, + "roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" }, + "roundtable-summary": { avatarType: "blue", displayName: "总结陈述" }, +}; + +/** + * Visual rotation for non-built-in agents. Each entry must be a key + * recognized by {@link getAvatar} — anything else falls back to the plain + * `RobotAvatar`. + */ +const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const; + +function getAvatarTypeFor(agentId: string, index: number): string { + return ( + BUILTIN_AGENT_META[agentId]?.avatarType ?? + FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length] + ); +} + +function getDisplayNameFor(agent: SelectedAgent): string { + return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name; +} + +/** + * Build the seat-template list shown in the right-rail role panel. + * The commander always sits at id=1; agents take id=2..N+1, in the order + * the user picked them so the panel mirrors the recommend dialog. + */ +function buildRoleTemplates( + agents: SelectedAgent[], +): ReadonlyArray > { + const out: Omit [] = [ + { id: COMMANDER_SEAT_ID, name: "指挥员 / 主持人", type: "human" }, + ]; + agents.forEach((a, i) => { + out.push({ + id: i + 2, + name: getDisplayNameFor(a), + agentId: a.agent_id, + type: "agent", + }); + }); + return out; +} + +function makeRolesFromTemplates( + templates: ReadonlyArray >, + defaultStatus: string, + overrides: Record = {}, +): RoleEntry[] { + return templates.map((t) => ({ ...t, status: overrides[t.id] ?? defaultStatus, })); } -// Subset of role templates that map to real backend agents (excludes the -// human commander seat) — used as the `agent_names` payload on init. -const ROUNDTABLE_AGENT_IDS = Array.from( - new Set( - ROUNDTABLE_ROLE_TEMPLATES.filter((r) => r.agentId).map((r) => r.agentId as string), - ), -); +/** + * Map `agent_name` (lowercase) → bubble meta. Used while streaming output so + * the right speaker bubble lights up and the dialogue card picks the right + * avatar. Speaker ids stay in sync with {@link buildRoleTemplates} (agents + * are id 2..N+1, indexed by selection order). + */ +function buildAgentNameToRole( + agents: SelectedAgent[], +): Record { + const out: Record = {}; + agents.forEach((a, i) => { + out[a.agent_id] = { + speakerId: i + 2, + avatarType: getAvatarTypeFor(a.agent_id, i), + displayName: getDisplayNameFor(a), + }; + }); + return out; +} -// agent_name (lowercase) → bubble meta. Used when streaming output, so we can -// light up the right speaker bubble and pick an avatar for the dialogue card. -const AGENT_NAME_TO_ROLE: Record = { - "roundtable-summary": { speakerId: 2, avatarType: "blue", displayName: "总结陈述" }, - "roundtable-intelligence": { speakerId: 3, avatarType: "purple", displayName: "情报收集" }, - "roundtable-environment": { speakerId: 4, avatarType: "emerald", displayName: "环境评估" }, - "roundtable-solution-design": { speakerId: 5, avatarType: "human", displayName: "方案设计" }, - "roundtable-risk-review": { speakerId: 6, avatarType: "amber", displayName: "风险审查" }, - "roundtable-execution-plan": { speakerId: 7, avatarType: "pink", displayName: "执行规划" }, -}; +/** + * Default Step-2 roster when the user hasn't picked anything yet (e.g. the + * recommend dialog was skipped or hasn't run). Matches the original + * hand-coded 6 roundtable seats so the page renders identically until the + * recommend flow overrides it. + */ +const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [ + { agent_id: "roundtable-summary", name: "总结陈述" }, + { agent_id: "roundtable-intelligence", name: "情报收集" }, + { agent_id: "roundtable-environment", name: "环境评估" }, + { agent_id: "roundtable-solution-design", name: "方案设计" }, + { agent_id: "roundtable-risk-review", name: "风险审查" }, + { agent_id: "roundtable-execution-plan", name: "执行规划" }, +]; function nowTime() { return new Date().toLocaleTimeString("zh-CN", { hour12: false }); @@ -334,24 +443,33 @@ export default function RoundtablePlanningPage() { } ]); - // Roles in roundtable state (updates as we switch steps!) - const [roles, setRoles] = useState ([ - { id: 1, name: "指挥员 / 主持人", type: "human", status: "等待激活" }, - { id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待激活" }, - { id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent", status: "等待激活" }, - { id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent", status: "等待激活" }, - { id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent", status: "等待激活" }, - { id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent", status: "等待激活" }, - { id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent", status: "等待激活" }, - { id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待陈述" }, - ]); + // The user's chosen Step-2 participants. Defaults to the 6 built-in + // roundtable seats so behavior is unchanged before the recommend dialog + // overrides it. Step 6 will replace this via the dialog's onConfirm. + const [selectedAgents, setSelectedAgents] = useState (DEFAULT_SELECTED_AGENTS); + + // Derived: template list for the seat panel + dispatch-time name lookup + + // the `agent_names` payload for /api/multi-agent/init. All three regenerate + // together whenever the selection changes, so seat ids stay in sync. + const roleTemplates = useMemo(() => buildRoleTemplates(selectedAgents), [selectedAgents]); + const agentNameToRole = useMemo(() => buildAgentNameToRole(selectedAgents), [selectedAgents]); + const roundtableAgentIds = useMemo(() => selectedAgents.map((a) => a.agent_id), [selectedAgents]); + + // Roles in roundtable state (updates as we switch steps!). Seeded from + // `roleTemplates`'s initial value (selectedAgents starts at the default + // roster), so the panel renders identically to the previous hand-coded list. + const [roles, setRoles] = useState (() => + makeRolesFromTemplates(buildRoleTemplates(DEFAULT_SELECTED_AGENTS), "等待激活"), + ); + + // Dialog: recommend agents before entering Step 2. + const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState (false); // Step 2 dialogue list — populated by the real orchestration loop once the // user enters the roundtable. Starts empty so we never flash mock content. const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState ([]); // Inputs for Step 2 conversation simulation controls - const [roundTableSpeed, setRoundTableSpeed] = useState ("正常"); const [isRoundTableRunning, setIsRoundTableRunning] = useState (true); const [customAgentInput, setCustomAgentInput] = useState (""); // Whether the roundtable visualizer + constraint console panel is collapsed. @@ -367,6 +485,10 @@ export default function RoundtablePlanningPage() { const [intentReady, setIntentReady] = useState (null); const [intentInitError, setIntentInitError] = useState (null); const [isIntentSending, setIsIntentSending] = useState (false); + const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState (null); + const [intentClarificationSelections, setIntentClarificationSelections] = useState< + Map + >(new Map()); const step1InitRef = useRef (false); // Monotonic counter incremented every time Step 1 init starts. The in-flight // async checks this on completion and bails if a newer init has begun (e.g. @@ -376,6 +498,7 @@ export default function RoundtablePlanningPage() { // early-returned because the ref was already set; net result: the async // landed but its setState got skipped, leaving intentThreadId null forever). const step1InitGenRef = useRef (0); + const intentAborterRef = useRef (null); // Multi-agent orchestration state (lives only for one step-2 visit). const [threadIds, setThreadIds] = useState (null); @@ -427,6 +550,17 @@ export default function RoundtablePlanningPage() { activeAbortersRef.current.clear(); }, []); + const stopIntentStream = useCallback(() => { + if (intentAborterRef.current) { + try { + intentAborterRef.current.abort(); + } catch { + /* noop */ + } + intentAborterRef.current = null; + } + }, []); + // Toast auto-dismiss logic useEffect(() => { if (showToast) { @@ -435,6 +569,11 @@ export default function RoundtablePlanningPage() { } }, [showToast]); + useEffect(() => { + if (currentStep === 1) return; + stopIntentStream(); + }, [currentStep, stopIntentStream]); + // Auto-follow the streamed conversations: track scroll position, stop // following when the user scrolls up, expose a `回到底部` button that // re-engages auto-follow. Same hook for both steps. @@ -442,6 +581,22 @@ export default function RoundtablePlanningPage() { step1ScrollRef, [interactiveMessages], ); + + // Sync intentReady when [INTENT_READY] appears in chat but the stream frame + // missed it (e.g. clarification returned before summary was parsed). + useEffect(() => { + if (intentReady) return; + for (let i = interactiveMessages.length - 1; i >= 0; i--) { + const msg = interactiveMessages[i]; + if (msg.sender === "user") continue; + const { summary } = splitIntentReadyBlock(msg.content ?? ""); + if (summary) { + setIntentReady(summary); + setPendingIntentClarificationMsgId(null); + break; + } + } + }, [interactiveMessages, intentReady]); const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll( step2ScrollRef, [step2RoundtableDialogues], @@ -737,9 +892,13 @@ export default function RoundtablePlanningPage() { // parallel meant N concurrent SSE streams each triggering a full-list // setState on every token delta, which made the browser stutter badly // when the leader 派活给多个席位。改为顺序后,同一时刻只有一个 SSE 在跑。 + // + // ⚠️ 不要改回 Promise.all 并行 —— 详见 multi-agent-frontend-dev.md §5.4 + // 与 §12.4。如果未来要并行,先把 step2RoundtableDialogues 换成 Map + // 或基于 ref 的局部更新,再放开 N 路 SSE。 for (const [subName, task] of leaderRes.dispatched) { if (orchestrationCancelRef.current) break; - const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()]; + const meta = agentNameToRole[subName.toLowerCase()]; if (meta) markSpeakerStreaming(meta.speakerId); const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined; @@ -806,7 +965,23 @@ export default function RoundtablePlanningPage() { clearAllStreamingSpeakers(); setIsOrchestrating(false); } - }, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs]); + }, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]); + + // Called by RecommendAgentsDialog onConfirm: update the participant roster, + // reset step-2 init so the new coordinator is created for the new roster, + // then navigate to Step 2. + const handleConfirmAgents = useCallback((agentIds: string[]) => { + setSelectedAgents( + agentIds.map((id) => ({ + agent_id: id, + name: BUILTIN_AGENT_META[id]?.displayName ?? id, + })), + ); + step2InitRef.current = false; + setIsRecommendDialogOpen(false); + setCurrentStep(2); + setShowToast("成功确认!已切换至第二阶段圆桌研讨系统"); + }, []); // Public stop handler used by the pause/stop button and the step-2 teardown. // Sets the cancel flag and aborts every in-flight stream so the loop can @@ -849,7 +1024,7 @@ export default function RoundtablePlanningPage() { const coord = newCoordinatorName(); try { const initRes = await initMultiAgent({ - agentNames: ROUNDTABLE_AGENT_IDS, + agentNames: roundtableAgentIds, mainAgentName: coord, model: DEFAULT_MODEL, // As each sub-agent's thread comes online, flip every role that @@ -918,7 +1093,11 @@ export default function RoundtablePlanningPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRoundTableRunning]); - // Adjust budget constraint dynamically + // Budget slider — UI demo only. + // 这是一段**未对接后端**的演示逻辑:滑动预算条只会本地 setBudgetLimit 并 1.5 秒后追加一条 + // 模拟智能体口吻的气泡(根据档位选话术)。真实研讨里预算属于约束条件,应由 Step 1 的 + // intent.summary.constraints 承载、由总控派活时透传给 sub-agent,而不是这条单滑块。 + // 重写此功能前请同步考虑:1) 是否真的需要可调约束;2) 若需要,改成走 runOrchestration 注入新 prompt。 const handleBudgetChange = (newVal: number) => { setBudgetLimit(newVal); triggerToast(`💰 研商经费调优约束变更为 ${newVal} 万!各大智能体启动资源重算...`); @@ -961,6 +1140,10 @@ export default function RoundtablePlanningPage() { // Save per-seat agent config overrides. We persist locally only — the next // time the orchestration loop dispatches to this seat it will read the // chosen model out of `agentConfigs` and pass it on the streaming call. + // + // 目前 prompt / temp 两个字段**仅前端保留**,后端尚未开放 per-agent override 接口。 + // 当后端开放时,应在 streamMultiAgent 的 special 调用上同时传 prompt_override / temperature, + // 而不是改这里的本地存储结构(后者会被 useState 重置)。 const handleSaveAgentAndRecalculate = (id: number) => { const role = roles.find(r => r.id === id); const roleName = role?.name || "专家智能体"; @@ -1009,6 +1192,8 @@ export default function RoundtablePlanningPage() { setIntentReady(null); setIntentInitError(null); setIntentThreadId(null); + setPendingIntentClarificationMsgId(null); + setIntentClarificationSelections(new Map()); setTimelineEntries([]); setInteractiveMessages([ { @@ -1049,6 +1234,7 @@ export default function RoundtablePlanningPage() { // Stop anything in flight before swapping state out. orchestrationCancelRef.current = true; abortAllInFlight(); + stopIntentStream(); setIsOrchestrating(false); setIsInitializing(false); setPendingClarification(null); @@ -1078,7 +1264,7 @@ export default function RoundtablePlanningPage() { // doesn't change and the effect wouldn't re-fire. runStep1Init(); triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求"); - }, [abortAllInFlight, runStep1Init]); + }, [abortAllInFlight, runStep1Init, stopIntentStream]); // ── Draft helpers ────────────────────────────────────────────────────── // Build a fresh snapshot of the current in-memory session. Called by every @@ -1184,6 +1370,7 @@ export default function RoundtablePlanningPage() { // Cancel anything in-flight before swapping state out from under it. orchestrationCancelRef.current = true; abortAllInFlight(); + stopIntentStream(); setIsOrchestrating(false); setIsInitializing(false); setPendingClarification(null); @@ -1224,7 +1411,7 @@ export default function RoundtablePlanningPage() { setCurrentStep(draft.furthestStep); triggerToast(`📂 已加载草稿:${draft.name}`); }, - [drafts, abortAllInFlight], + [drafts, abortAllInFlight, stopIntentStream], ); const handleDeleteDraft = useCallback( @@ -1277,10 +1464,11 @@ export default function RoundtablePlanningPage() { // Chat sender logic for Step 1 interaction — drives the real intent agent // via /api/intent/stream. The agent streams its reply into a placeholder // bubble; if its final status is "done" we capture the structured summary - // for the Step 2 handoff. - const handleSendMessage = async () => { - const userText = inputText.trim(); - if (!userText) return; + // for the Step 2 handoff. When status is "clarification", render option + // cards like the main chat ClarificationCard. + const sendIntentMessage = async (userText: string) => { + const trimmed = userText.trim(); + if (!trimmed) return; if (!intentThreadId) { triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候"); return; @@ -1290,33 +1478,31 @@ export default function RoundtablePlanningPage() { return; } + setPendingIntentClarificationMsgId(null); + const userMsgTime = nowTime(); const userMsgId = Date.now() + Math.random(); const userMsg = { id: userMsgId, sender: "user", senderName: "用户", - content: userText, + content: trimmed, time: userMsgTime, }; setInteractiveMessages(prev => [...prev, userMsg]); setInputText(""); setCharactersLeft(0); - // Right-side timeline gets a short summary too. setTimelineEntries(prev => [ ...prev, { id: prev.length + 1, time: userMsgTime, title: "用户实时反馈", - desc: userText.length > 25 ? userText.substring(0, 25) + "..." : userText, + desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed, }, ]); - // Placeholder agent bubble — text deltas accumulate into it. `phase` - // tracks the inferred SSE stage so the "in flight" caption can flip from - // "调度中" → "已连接,等待响应" → "正在思考问题" as the run progresses. const agentMsgId = Date.now() + Math.random() + 0.01; setInteractiveMessages(prev => [ ...prev, @@ -1331,9 +1517,6 @@ export default function RoundtablePlanningPage() { } as any, ]); - // Map an inferred phase + (optional) tool name to a human-readable label - // shown next to the spinner. Tool-call phases highlight ask_clarification - // explicitly since that's the only tool the intent agent normally uses. const intentPhaseLabel = (phase: IntentStreamPhase, toolName?: string): string => { switch (phase) { case "connecting": @@ -1351,11 +1534,14 @@ export default function RoundtablePlanningPage() { }; setIsIntentSending(true); + const aborter = new AbortController(); + intentAborterRef.current = aborter; try { const res = await streamIntent({ threadId: intentThreadId, - message: userText, + message: trimmed, model: DEFAULT_MODEL, + signal: aborter.signal, onTextDelta: (delta) => { setInteractiveMessages(prev => prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)), @@ -1368,47 +1554,105 @@ export default function RoundtablePlanningPage() { ); }, }); - // Finalize the bubble with the authoritative final text from the - // status frame (covers cases where deltas didn't accumulate cleanly). + const parsedFromContent = splitIntentReadyBlock(res.content || "").summary; + const resolvedSummary = res.summary ?? parsedFromContent; + setInteractiveMessages(prev => prev.map(m => m.id === agentMsgId - ? { ...m, content: res.content || m.content || "(无文本输出)", streaming: false } as any + ? { + ...m, + content: res.content || m.content || "(无文本输出)", + streaming: false, + clarification: resolvedSummary ? undefined : res.clarification ?? undefined, + } as any : m, ), ); - if (res.status === "done" && res.summary) { - setIntentReady(res.summary); + + if (resolvedSummary) { + setIntentReady(resolvedSummary); + setPendingIntentClarificationMsgId(null); setTimelineEntries(prev => [ ...prev, { id: prev.length + 1, time: nowTime(), title: "任务意图已明确", - desc: res.summary!.objective.length > 25 - ? res.summary!.objective.substring(0, 25) + "..." - : res.summary!.objective, + desc: resolvedSummary.objective.length > 25 + ? resolvedSummary.objective.substring(0, 25) + "..." + : resolvedSummary.objective, }, ]); triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨"); + } else if (res.status === "clarification" && res.clarification) { + setPendingIntentClarificationMsgId(agentMsgId); + setIntentClarificationSelections(prev => { + const next = new Map(prev); + next.set(agentMsgId, { selectedIds: [], custom: "" }); + return next; + }); } else if (res.status === "error") { triggerToast(`❌ 智能体调用失败: ${res.content}`); } } catch (err) { - console.error("intent stream failed:", err); - setInteractiveMessages(prev => - prev.map(m => - m.id === agentMsgId - ? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any - : m, - ), - ); - triggerToast(`❌ 发送失败: ${(err as Error).message}`); + if ((err as Error).name === "AbortError" || aborter.signal.aborted) { + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { + ...m, + content: (m.content ?? "").trim() + ? `${m.content ?? ""}\n\n(已暂停)` + : "(已暂停)", + streaming: false, + } as any + : m, + ), + ); + triggerToast("⏸️ 已暂停任务理解回复"); + } else { + console.error("intent stream failed:", err); + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any + : m, + ), + ); + triggerToast(`❌ 发送失败: ${(err as Error).message}`); + } } finally { + if (intentAborterRef.current === aborter) { + intentAborterRef.current = null; + } setIsIntentSending(false); } }; + const handleSendMessage = () => { + void sendIntentMessage(inputText); + }; + + const submitIntentClarification = ( + msgId: number, + payload: ClarificationPayload, + overrideSelection?: IntentClarificationSelection, + ) => { + const selection = + overrideSelection ?? + intentClarificationSelections.get(msgId) ?? { + selectedIds: [], + custom: "", + }; + const answer = formatIntentClarificationAnswer(payload, selection); + if (!answer) { + triggerToast("请先选择选项或输入回答"); + return; + } + void sendIntentMessage(answer); + }; + // Skip step logic const handleSkipQuestion = () => { const systemSkipMsg = { @@ -1473,11 +1717,11 @@ export default function RoundtablePlanningPage() { // seat to 在线 — assume all seats are online so the panel doesn't get // stuck at "正在上线". const baseStatus = loadedFromDraftRef.current ? "在线" : "正在上线"; - setRoles(makeRoles(baseStatus, { 1: "在线" })); + setRoles(makeRolesFromTemplates(roleTemplates, baseStatus, { 1: "在线" })); } else { - setRoles(makeRoles("已完成")); + setRoles(makeRolesFromTemplates(roleTemplates, "已完成")); } - }, [currentStep, intentThreadId, intentInitError]); + }, [currentStep, intentThreadId, intentInitError, roleTemplates]); // Inject user input to the roundtable room — sends it as a new leader call. const handleInjectToRoundtable = () => { @@ -1544,6 +1788,14 @@ export default function RoundtablePlanningPage() { > {/* Toast Notice Banner */} + setIsRecommendDialogOpen(false)} + onConfirm={handleConfirmAgents} + /> + {showToast && ( @@ -2009,16 +2261,10 @@ export default function RoundtablePlanningPage() { }`} > - {/* Avatar chooser based on role.id */} {role.type === "human" ? () : ( - role.id === 2 ? : - role.id === 3 ? : - role.id === 4 ? : - role.id === 6 ? : - role.id === 7 ? : - + getAvatar(role.agentId ? (agentNameToRole[role.agentId]?.avatarType ?? "blue") : "blue", "h-7 w-7") )} @@ -2146,10 +2392,7 @@ export default function RoundtablePlanningPage() {