` Persona 覆盖;下一轮该席位的派活会用此 model |
+| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
+
+**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。
+
+### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
+
+伪代码:
+
+```
+isOrchestrating = true
+orchestrationCancelRef = false
+nextLeaderTask = seedMessage
+cycles = 0
+MAX_CYCLES = 8
+
+while !cancelled && cycles < MAX_CYCLES:
+ cycles += 1
+ highlight(speakerId=2) // 总控
+ leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
+ res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
+
+ if res.clarification:
+ finalize(leaderBubble, content, "等待用户澄清")
+ setPendingClarification(res.clarification)
+ break // 等用户回复后由 submitClarification 重启循环
+
+ finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
+ if res.content: setLastLeaderContent(res.content)
+
+ if res.dispatched.length === 0:
+ setHasConsensus(true); setConsensusPercentage(100); break
+
+ for [subName, task] of res.dispatched: // **顺序**,不是并行
+ meta = agentNameToRole[subName.toLowerCase()]
+ markSpeakerStreaming(meta.speakerId)
+ perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
+ subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
+ specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
+ finalize(subBubble, specRes.content, "子智能体交付")
+ markSpeakerIdle(meta.speakerId)
+ if cancelled: break
+
+ setConsensusPercentage(prev => min(95, prev + 8))
+ nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
+
+if cycles >= MAX_CYCLES:
+ toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
+
+clearAllStreamingSpeakers()
+isOrchestrating = false
+```
+
+要点:
+
+- **`buildInitialIntent()`**:
+ - 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
+ - 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
+ - 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
+- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
+- **挂起 / 恢复**:
+ - 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
+ - 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
+- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
+
+### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
+
+- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
+- 抽屉控件:
+ - **System Prompt**:仅前端记录(后端覆写未开放)
+ - **Temp**:仅前端记录
+ - **推理模型**:选项来自 `useModels()`,默认 `DEFAULT_MODEL`
+- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
+ - 把新的 `(seatId, model)` 写入 `agentConfigs`
+ - 追加一条「⚡ [模型偏好已保存] ...」对话气泡
+ - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数
+
+### 5.6 草稿持久化(`utils/drafts.ts`)
+
+- Key:`roundtable-planning:drafts:v1`(`localStorage`)
+- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
+- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字
+- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep`
+- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave`
+- **加载草稿**:`handleLoadDraft`
+ - 中断所有 in-flight(`abortAllInFlight + stopIntentStream`)
+ - **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉
+ - `step1InitGenRef` 自增,淘汰可能晚归的旧 init
+ - `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init
+ - 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」
+
+---
+
+## 6. 组件说明
+
+### 6.1 `IntentClarificationCard`
+
+- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`
+- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
+- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
+ - 仅选项 → `我选择:A、B`
+ - 仅自定义 → 直接发自定义文本
+ - 同时存在 → `我选择:A、B;补充:…`
+- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片)
+
+### 6.2 `RecommendAgentsDialog`
+
+- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
+- 阶段 UI(`InternalState.phase`):
+ - `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
+- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
+- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
+- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
+- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
+
+### 6.3 `HighFidelityReport`(Step 3)
+
+- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
+- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
+- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
+- **文件预览策略**(`shouldPreviewInModal`):
+ - 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
+ - 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
+ - 命中规则:
+ - mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
+ - 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
+- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
+- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
+- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
+
+### 6.4 `ArtifactPreviewModal`
+
+- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
+- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
+- 其它文本类 → `` 直出
+- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
+- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
+
+### 6.5 共享 UI 模式与辅助
+
+- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
+- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
+ - `connecting` → 「调度中」
+ - `connected` → 「已连接,等待响应」
+ - `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:」
+ - `tool_result` → 「处理工具结果」
+ - `streaming` → leader 时「正在派活说明」、special 时「正在输出」
+ - Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
+- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
+- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
+- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
+- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
+
+---
+
+## 7. 澄清交互(与主聊天对齐)
+
+| 能力 | 实现位置 |
+|------|----------|
+| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
+| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
+| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
+| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
+| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
+| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
+
+后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
+
+**关键差异**:
+
+- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
+- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
+
+---
+
+## 8. 与主聊天的差异
+
+| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
+|------|-----------------------------------|----------|
+| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
+| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
+| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
+| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
+| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
+| 暂停 | LangGraph interrupt | `AbortController.abort()` |
+
+复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
+
+---
+
+## 9. 关键交互清单
+
+| 交互 | 行为 |
+|------|------|
+| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
+| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
+| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
+| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
+| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
+| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
+| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
+| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
+| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
+| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
+| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
+
+---
+
+## 10. 类型与常量速查
+
+```ts
+// Step 1 输出 → Step 2 输入
+interface IntentSummary {
+ objective: string;
+ constraints: string[];
+ assumptions?: string[];
+}
+
+// Step 2 线程映射(init_done.thread_ids)
+type ThreadIdMap = Record;
+
+// 派活
+type LeaderDispatch = [string, string]; // [agent_name, task]
+
+// 当前选中的研讨席位
+type SelectedAgent = { agent_id: string; name: string };
+
+// Step 1 单条澄清问题
+interface ClarificationPayload {
+ question: string;
+ clarificationType: string;
+ context: string;
+ options: unknown[];
+ allowCustom: boolean;
+ allowMultiple?: boolean;
+}
+
+// Step 2 stream 终态
+interface StreamMultiAgentResult {
+ dispatched: LeaderDispatch[];
+ message: string | null;
+ content: string;
+ agentName: string | null;
+ clarification: ClarificationPayload | null;
+}
+```
+
+**核心常量**:
+
+| 常量 | 值 | 位置 |
+|------|----|------|
+| `DEFAULT_MODEL` | `"deepseek-chat"` | RoundtablePlanningPage |
+| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
+| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
+| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
+| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
+| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
+| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
+| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
+| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
+| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
+| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
+
+---
+
+## 11. 故障排查
+
+| 现象 | 可能原因 | 排查 |
+|------|----------|------|
+| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
+| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
+| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
+| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
+| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
+| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
+| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
+| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
+| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
+| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
+| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
+| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
+| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
+| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
+| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
+
+**本地调试开关**:
+
+```js
+window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
+window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
+```
+
+---
+
+## 12. 扩展指南
+
+### 12.1 新增默认研讨席位
+
+1. 后端 `.deer-flow/agents/roundtable-xxx/`
+2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
+3. 无需改 API 层(`agent_names` 由推荐结果传入)
+
+### 12.2 调整 Step 1 澄清轮次上限
+
+逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
+
+### 12.3 调整 Step 2 编排上限
+
+`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
+
+### 12.4 把 special 改回并行
+
+不推荐;若一定要做:
+
+- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
+- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
+- 用更高效的派发结构(如按 id 分桶 `Map` + ref 缓存)改善之后再启用
+
+### 12.5 接入真实 Persona 覆盖
+
+当前 prompt / temp 只在前端记录;要落地需:
+
+- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
+- 前端:`agentConfigs[meta.speakerId]` 一并传入
+
+### 12.6 拆分页面
+
+当前刻意单文件维护三步。若拆分建议:
+
+- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
+- 保持 `api/*` 不变
+- 保持 `roundtable-planning.css` 主题变量
+
+### 12.7 文档协同
+
+`@/roundtable-planning/` 内一切公共 API 都应当:
+
+1. 在 `api/*.ts` 文件级注释里说明 endpoint
+2. 在本页第 4 节 / 第 10 节同步对照
+3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
+
+---
+
+## 13. 路径速查
+
+| 资源 | 路径 |
+|------|------|
+| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
+| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
+| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
+| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
+| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
+| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
+| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
+| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
+| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
+| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
+| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
+| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
+| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
+| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
+| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
+| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
+| API 速查 | `frontend-web/docs/multi-agent-api.md` |
diff --git a/frontend-web/docs/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/浅红色ui修改.md b/frontend-web/docs/浅红色ui修改.md
new file mode 100644
index 00000000..f2cdb310
--- /dev/null
+++ b/frontend-web/docs/浅红色ui修改.md
@@ -0,0 +1,310 @@
+# 浅色红色 UI 规范对齐 — 实施计划
+
+> 依据:`H:\页面设计\UI设计规范\浅色红色设计规范-前端用.pdf`(共 104 页)
+> 范围:**仅对齐 Light 模式(红色主题)**,**不改动图表部分**
+
+---
+
+## ⚠️ 重要约束(必读)
+
+### 1. 主题切换必须保留
+项目使用 `next-themes` 实现 Light/Dark 切换:
+
+- `:root` → Light 模式(红色品牌,对应本规范)
+- `.dark` → Dark 模式(蓝色品牌,**不在本次范围内**)
+- 切换组件入口:`src/components/theme-provider.tsx`
+
+**所有改动只能动 `:root` 块和 light-only 选择器,禁止动 `.dark` 块和 `dark:` 前缀样式。**
+
+### 2. 不动图表
+本次跳过 `chart-1` ~ `chart-5` 颜色变量和所有图表组件。
+
+### 3. 同一 token 双模式都用时如何改
+若变量同时被 light/dark 使用(如 `--radius`、按钮 `h-*`、`--primary-foreground`),改动后需在 Dark 模式下回归检查,确保没有破坏。
+
+---
+
+## 改动总目标对照
+
+| 类别 | UI 规范 | 现状 | 差异 |
+|------|--------|------|------|
+| 主品牌红 | `#F5222D` | `#d92121` (`oklch(0.52 0.19 27)`) | 偏差 |
+| 深色品牌 | `#CF1322` / `#A8071A` | 未使用 | 缺失 |
+| 圆角-按钮 | 2px | 12px (`--radius: 0.75rem`) | 严重偏大 |
+| 圆角-卡片 | 4px | 12px | 严重偏大 |
+| 按钮高度(正常)| 32px | 36px (`h-9`) | 偏大 4px |
+| 按钮高度(小)| 24px | 32px (`h-8`) | 偏大 8px |
+| 输入框高度 | 32px | 36px (`h-9`) | 偏大 4px |
+| 中文字体 | NotoSansSChinese | 系统字体 | 未引入 |
+| 数字字体 | D-DIN | 系统字体 | 未引入 |
+| 间距体系 | 8px 基准(4/8/12/16/24/32/40/56)| Tailwind 默认 | 基本一致 |
+
+---
+
+## 分步实施计划
+
+### Step 1 — 颜色变量对齐(Light 模式)
+
+**文件**:`src/styles/globals.css`
+**只动 `:root` 块(第 242-277 行),保持 `.dark` 块原样**
+
+#### 1.1 主色映射
+
+| 角色 | 规范值 | 用途 |
+|------|--------|------|
+| 主色 | `#F5222D` | `--primary`、`--ring`、`--sidebar-primary` |
+| 主色 hover | `#CF1322` | 按钮 hover 状态(新增辅助变量) |
+| 主色 active | `#A8071A` | 按钮按下状态 |
+| 主色淡 1 | `#FEEDED` | `--accent`(极淡红,背景) |
+| 主色淡 2 | `#FDDADC` | hover bg、tag 背景 |
+| 主色淡 3 | `#FCB5B9` | 浅边框、selected 态 |
+| 危险色 | `#F5222D` | `--destructive` |
+
+#### 1.2 新增工程变量(建议加在 `:root` 内)
+
+```css
+/* 红色主题阶梯色(规范色板)*/
+--brand-50: #FEFAFA;
+--brand-100: #FEF6F6;
+--brand-200: #FEEDED;
+--brand-300: #FDDADC;
+--brand-400: #FCB5B9;
+--brand-500: #FA9196;
+--brand-600: #F86C73;
+--brand-700: #F74750;
+--brand-800: #F5222D; /* ← 主色 */
+--brand-900: #CF1322; /* ← hover */
+--brand-950: #A8071A; /* ← active */
+
+/* 辅色(规范)*/
+--accent-orange: #FF9900;
+--accent-green: #2FD69A;
+--accent-cyan: #45C3F3;
+```
+
+#### 1.3 `--workspace-brand` 同步
+
+`:root` 内:`--workspace-brand: #F5222D;`(从 `#d92121` 改)
+`.dark` 内:**不动**(保持 `#3b82f6` 蓝色)
+
+#### 1.4 验收
+- 切到 Light 模式:所有按钮、链接、选中态使用规范红
+- 切到 Dark 模式:依然是原蓝色品牌(无变化)
+
+---
+
+### Step 2 — 圆角规范化
+
+**文件**:`src/styles/globals.css`(`--radius` 是全局变量,light/dark 共用)
+
+#### 2.1 改动方案(推荐)
+
+按规范:按钮 2px、卡片/页面框 4px。但当前许多组件依赖 `rounded-md` / `rounded-lg`,全局把 `--radius` 改成 4px 会引起视觉跳变。
+
+**采用细粒度策略:**
+
+```css
+:root {
+ --radius: 0.25rem; /* 4px → 卡片/页面框 */
+}
+
+@theme inline {
+ --radius-sm: 2px; /* 按钮、tag */
+ --radius-md: calc(var(--radius) - 1px); /* 3px → 输入框 */
+ --radius-lg: var(--radius); /* 4px → 卡片 */
+ --radius-xl: calc(var(--radius) + 4px); /* 8px → 弹窗 */
+ --radius-2xl: calc(var(--radius) + 8px); /* 12px → 大卡片 */
+}
+```
+
+#### 2.2 例外保留(设计感样式)
+以下组件**圆角不改**,保持设计稿原貌:
+- `.workspace-user-bubble`(聊天气泡,圆角 `1.6rem 1.6rem 0.5rem 1.6rem`)
+- `.workspace-assistant-card`(圆角 `1.75rem`)
+- `.gemini-gradient-border`(圆角 `1.5rem`)
+
+#### 2.3 验收
+- 普通按钮变为近直角(2px)
+- 卡片维持温和圆角(4px)
+- 聊天气泡、AI 卡片不变形
+
+---
+
+### Step 3 — 按钮组件尺寸对齐
+
+**文件**:`src/components/ui/button.tsx`
+
+#### 3.1 改 size 变体(规范:小 24px / 正常 32px)
+
+| size | 规范 | 当前 | 改后 |
+|------|------|------|------|
+| `sm` | 24px | h-8 (32px) | `h-6 px-2 text-xs` |
+| `default` | 32px | h-9 (36px) | `h-8 px-3 text-sm` |
+| `lg` | — | h-10 (40px) | 保留 `h-10`(用于 Banner/登录场景)|
+
+#### 3.2 圆角
+- 默认按钮:`rounded-sm`(2px)
+
+#### 3.3 主按钮配色
+- bg:`bg-primary`
+- hover:`hover:bg-[var(--brand-900)]`(深红 `#CF1322`)
+- active:`active:bg-[var(--brand-950)]`(更深 `#A8071A`)
+- disabled:`disabled:opacity-50`(保留)
+
+#### 3.4 次要按钮 / 文本按钮
+- 次要按钮(`outline` variant):边框 `border-primary`,文字 `text-primary`
+- 文本按钮(`ghost` variant):仅文字色 `text-primary`,hover 背景 `--brand-200`
+
+#### 3.5 验收
+检查所有调用 `` 的页面:
+- 工具栏按钮(之前 h-9 → 现在 h-8)
+- 弹窗确认/取消(之前 h-9 → 现在 h-8)
+- Landing 主 CTA 按钮(保留 `lg`)
+
+---
+
+### Step 4 — 输入框尺寸对齐
+
+#### 4.1 `src/components/ui/input.tsx`
+- `h-9` → `h-8`(32px)
+- `rounded-md`(沿用 3px)
+- 边框:`border-input`(变量已是浅灰,不变)
+- focus:`focus-visible:ring-[2px] focus-visible:ring-primary/35`
+
+#### 4.2 `src/components/ui/textarea.tsx`
+- 最小高度建议 `min-h-16`
+- 圆角 `rounded-md`
+
+#### 4.3 `src/components/ui/select.tsx`
+- `size: default` → `h-8`
+- `size: sm` → `h-6`
+
+#### 4.4 验收
+- 搜索框、表单输入、下拉框高度一致(32px)
+- 与按钮对齐时高度一致
+
+---
+
+### Step 5 — 字体引入
+
+#### 5.1 引入字体文件
+在 `frontend-web/public/fonts/` 下放置:
+- `NotoSansSC-Regular.woff2` / `Medium.woff2` / `Bold.woff2`
+- `D-DIN-Regular.woff2` / `Bold.woff2`
+
+> 若需在线方式,可用 Google Fonts CDN:
+> `https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap`
+
+#### 5.2 `globals.css` 顶部添加 `@font-face`
+
+```css
+@font-face {
+ font-family: 'Noto Sans SC';
+ src: url('/fonts/NotoSansSC-Regular.woff2') format('woff2');
+ font-weight: 400;
+ font-display: swap;
+}
+/* Medium / Bold 同上 */
+
+@font-face {
+ font-family: 'D-DIN';
+ src: url('/fonts/D-DIN-Regular.woff2') format('woff2');
+ font-weight: 400;
+ font-display: swap;
+}
+```
+
+#### 5.3 修改 `--font-sans`(globals.css 第 74 行)
+
+```css
+--font-sans:
+ 'Noto Sans SC', ui-sans-serif, system-ui, sans-serif,
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+
+--font-numeric: 'D-DIN', 'Noto Sans SC', sans-serif;
+```
+
+#### 5.4 数字字体工具类
+新增 `.font-numeric { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }`
+
+仪表盘、表格中重要数字加 `className="font-numeric"`。
+
+#### 5.5 验收
+- 中文文本回退到 Noto Sans SC
+- Dark 模式同样生效(字体是全局变量)
+
+---
+
+### Step 6 — Badge / 状态标签色对齐
+
+**文件**:`src/components/ui/badge.tsx`
+
+规范状态色(参考 PDF 第 67 页):
+
+| 状态 | 颜色定位 | 推荐 token |
+|------|---------|-----------|
+| 未开始 | 中性灰 | `bg-muted text-muted-foreground` |
+| 等待中 | 蓝/青 | `bg-blue-50 text-blue-600`(保留) |
+| 进行中 | 主红 | `bg-[var(--brand-200)] text-primary` |
+| 已完成 | 绿 | `bg-emerald-50 text-emerald-600` |
+| 已失败 | 红/橙 | `bg-red-50 text-red-600` |
+| 待更新 | 黄/橙 | `bg-amber-50 text-amber-700` |
+
+需要新增 `variant`:`waiting`、`processing`、`success`、`failed`、`pending`。
+
+圆角统一 `rounded-sm`,padding `px-2 py-0.5`,字号 `text-xs`。
+
+---
+
+### Step 7 — 全局间距 & 阴影规范化
+
+#### 7.1 间距
+规范基于 8px 网格(4 / 8 / 12 / 16 / 24 / 32 / 40 / 56)。Tailwind 默认 4px 步进(`p-1`=4, `p-2`=8...)已兼容。
+
+**只需 review**:检查页面是否有 `p-2.5`、`gap-1.5` 等非 8 的倍数,统一改成 8 的倍数。
+
+#### 7.2 阴影
+规范阴影仅用于"标题+操作"和"卡片新增"两类。其余卡片保持平。
+
+新增工具类(可选):
+```css
+.shadow-card { box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06); }
+.shadow-card-hover { box-shadow: 0 4px 12px 0 rgba(0,0,0,0.08); }
+```
+
+---
+
+### Step 8 — 验收清单
+
+每步完成后检查:
+
+- [ ] **Light 模式**视觉对齐规范(参考 PDF 关键页:5、7、10、36、66)
+- [ ] **Dark 模式**完全无变化(切换主题验证)
+- [ ] 首页 / Workspace / 聊天 / 设置弹窗 → 按钮和输入框高度统一
+- [ ] 主题切换按钮点击 → 颜色平滑切换,无错位/闪烁
+- [ ] `/landing` 强制 Dark 模式仍正常
+- [ ] 控制台无 CSS 变量未定义警告
+
+---
+
+## 执行顺序建议
+
+```
+Step 1 (颜色变量) ─┐
+ ├→ Step 3 (按钮) ─┐
+Step 2 (圆角) ─┘ ├→ Step 6 (Badge) ─→ Step 7 (间距/阴影) ─→ Step 8 (验收)
+ │
+ Step 4 (输入框) ──┤
+ Step 5 (字体) ────┘
+```
+
+并行可行:Step 1 + Step 2 完成后,Step 3/4/5 可同时推进。
+
+---
+
+## 风险提示
+
+1. **圆角从 12px → 4px 是大幅度视觉变化**,建议先在分支上做、配截图比对再合并
+2. 字体文件体积较大(NotoSansSC 全字符集 ~10MB),建议子集化或仅引入需要的字重
+3. `--radius-sm` 改后可能影响 dropdown menu / popover 等小组件外观,需逐个检查
+4. Dark 模式下的红色 `--workspace-brand` 已是蓝色,**不要把蓝色也改成 #F5222D**
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/index.html b/frontend-web/index.html
index e1d8ce8a..f6fe9779 100644
--- a/frontend-web/index.html
+++ b/frontend-web/index.html
@@ -1,7 +1,18 @@
-
+
+
CM 助手
diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx
index 23f7257b..1f7554d8 100644
--- a/frontend-web/src/App.tsx
+++ b/frontend-web/src/App.tsx
@@ -25,7 +25,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
return (
diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx
index 945d2878..ff9b4045 100644
--- a/frontend-web/src/components/page-sidebar.tsx
+++ b/frontend-web/src/components/page-sidebar.tsx
@@ -72,6 +72,12 @@ const menuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
+ {
+ id: "qa-roundtable",
+ label: "圆桌规划",
+ icon: Bot,
+ path: "/page/roundtable/planning",
+ },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
@@ -99,6 +105,12 @@ const menuItems: MenuItem[] = [
icon: List,
path: "/page/strategy/light-app/notes",
},
+ {
+ id: "light-app-note-qa",
+ label: "笔记问答",
+ icon: MessageSquareCode,
+ path: "/page/strategy/light-app/note-qa",
+ },
],
},
{ id: "light-app-square", label: "应用广场", icon: Layout },
diff --git a/frontend-web/src/components/ui/sidebar.tsx b/frontend-web/src/components/ui/sidebar.tsx
index f6c5e3e4..f544abf3 100644
--- a/frontend-web/src/components/ui/sidebar.tsx
+++ b/frontend-web/src/components/ui/sidebar.tsx
@@ -397,53 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
-const SidebarGroupLabel = React.forwardRef<
- HTMLDivElement,
- React.ComponentProps<"div"> & { asChild?: boolean }
->(({ className, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "div";
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
- className,
- )}
- {...props}
- />
- );
-});
-
-SidebarGroupLabel.displayName = "SidebarGroupLabel";
+const SidebarGroupLabel = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "div";
-const SidebarGroupAction = React.forwardRef<
- HTMLButtonElement,
- React.ComponentProps<"button"> & { asChild?: boolean }
->(({ className, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "group-data-[collapsible=icon]:hidden",
- className,
- )}
- {...props}
- />
- );
-});
-
-SidebarGroupAction.displayName = "SidebarGroupAction";
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+
+SidebarGroupLabel.displayName = "SidebarGroupLabel";
+
+const SidebarGroupAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+
+SidebarGroupAction.displayName = "SidebarGroupAction";
function SidebarGroupContent({
className,
@@ -503,99 +503,99 @@ const sidebarMenuButtonVariants = cva(
},
);
-const SidebarMenuButton = React.forwardRef<
- HTMLButtonElement,
- React.ComponentProps<"button"> & {
- asChild?: boolean;
- isActive?: boolean;
- tooltip?: string | React.ComponentProps;
- } & VariantProps
->(
- (
- {
- asChild = false,
- isActive = false,
- variant = "default",
- size = "default",
- tooltip,
- className,
- ...props
- },
- ref,
- ) => {
- const Comp = asChild ? Slot : "button";
- const { isMobile, state } = useSidebar();
-
- const button = (
-
- );
-
- if (!tooltip) {
- return button;
- }
-
- if (typeof tooltip === "string") {
- tooltip = {
- children: tooltip,
- };
- }
-
- return (
-
- {button}
-
-
- );
- },
-);
-
-SidebarMenuButton.displayName = "SidebarMenuButton";
+const SidebarMenuButton = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+ } & VariantProps
+>(
+ (
+ {
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+ },
+ ref,
+ ) => {
+ const Comp = asChild ? Slot : "button";
+ const { isMobile, state } = useSidebar();
-const SidebarMenuAction = React.forwardRef<
- HTMLButtonElement,
- React.ComponentProps<"button"> & {
- asChild?: boolean;
- showOnHover?: boolean;
- }
->(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
- showOnHover &&
- "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
- className,
- )}
- {...props}
- />
- );
-});
-
-SidebarMenuAction.displayName = "SidebarMenuAction";
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+ },
+);
+
+SidebarMenuButton.displayName = "SidebarMenuButton";
+
+const SidebarMenuAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+ }
+>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+
+SidebarMenuAction.displayName = "SidebarMenuAction";
function SidebarMenuBadge({
className,
@@ -686,37 +686,37 @@ function SidebarMenuSubItem({
);
}
-const SidebarMenuSubButton = React.forwardRef<
- HTMLAnchorElement,
- React.ComponentProps<"a"> & {
- asChild?: boolean;
- size?: "sm" | "md";
- isActive?: boolean;
- }
->(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
- const Comp = asChild ? Slot : "a";
-
- return (
- svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
- "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
- size === "sm" && "text-xs",
- size === "md" && "text-sm",
- "group-data-[collapsible=icon]:hidden",
- className,
- )}
- {...props}
- />
- );
-});
-
-SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
+const SidebarMenuSubButton = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentProps<"a"> & {
+ asChild?: boolean;
+ size?: "sm" | "md";
+ isActive?: boolean;
+ }
+>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+ svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+
+SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx
index 049e7047..f5e5b635 100644
--- a/frontend-web/src/components/workspace/messages/message-group.tsx
+++ b/frontend-web/src/components/workspace/messages/message-group.tsx
@@ -526,7 +526,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record;
- result?: string;
+ result?: string | Record;
}
type CoTStep = CoTReasoningStep | CoTToolCallStep;
@@ -558,12 +558,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
};
const toolCallId = tool_call.id;
if (toolCallId) {
- const toolCallResult = findToolCallResult(toolCallId, messages);
- if (toolCallResult) {
- try {
- const json = JSON.parse(toolCallResult);
- step.result = json;
- } catch {
+ const toolCallResult = findToolCallResult(
+ toolCallId,
+ messages,
+ tool_call.name,
+ );
+ if (toolCallResult !== undefined) {
+ if (typeof toolCallResult === "string") {
+ try {
+ step.result = JSON.parse(toolCallResult) as Record<
+ string,
+ unknown
+ >;
+ } catch {
+ step.result = toolCallResult;
+ }
+ } else {
step.result = toolCallResult;
}
}
diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx
index 1624d7c7..b0819af5 100644
--- a/frontend-web/src/components/workspace/messages/message-list.tsx
+++ b/frontend-web/src/components/workspace/messages/message-list.tsx
@@ -31,6 +31,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";
@@ -115,7 +116,9 @@ export function MessageList({
[],
);
- const handleSubmitAllClarifications = useCallback(async () => {
+ const handleSubmitAllClarifications = useCallback(async (
+ selectionOverrides?: Map,
+ ) => {
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
return;
}
@@ -124,7 +127,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: "",
};
@@ -244,7 +247,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);
@@ -665,11 +674,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];
@@ -707,7 +726,7 @@ function ClarificationCard({
{options.length > 0 ? (
- 可多选(再次点击取消选择)
+ {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
{options.map((option) => {
const optionId = option.id || option.label;
@@ -716,10 +735,10 @@ function ClarificationCard({
toggleOption(optionId)}
+ onClick={() => handleOptionClick(optionId)}
>
{option.label}
@@ -736,7 +755,7 @@ function ClarificationCard({
) : null}
- {isSelected ? (
+ {isSelected && allowMultiple ? (
) : null}
@@ -761,10 +780,10 @@ function ClarificationCard({
) : null}
- {isPending ? (
+ {isPending && (allowMultiple || payload.allow_custom !== false) ? (
isLastPending ? (
- {multiPending ? (
+ {multiPending && allowMultiple ? (
将一起提交以上 {pendingCount} 个问题的回答
@@ -776,14 +795,14 @@ function ClarificationCard({
onClick={() => void onSubmitAll()}
>
- {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
+ {submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
- ) : (
+ ) : allowMultiple ? (
选择后等待最后一题统一提交
- )
+ ) : null
) : null}
);
diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx
index 85cfe223..c0ed0ed1 100644
--- a/frontend-web/src/components/workspace/recent-chat-list.tsx
+++ b/frontend-web/src/components/workspace/recent-chat-list.tsx
@@ -1,6 +1,7 @@
"use client";
import {
+ ChevronDown,
Download,
FileJson,
FileText,
@@ -63,6 +64,15 @@ export function RecentChatList() {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
+ const [collapsed, setCollapsed] = useState(
+ () => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
+ );
+
+ const toggleCollapsed = () => {
+ const next = !collapsed;
+ setCollapsed(next);
+ localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
+ };
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
useParams<{
thread_id: string;
@@ -161,12 +171,24 @@ export function RecentChatList() {
return (
<>
-
- {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
- ? t.sidebar.recentChats
- : t.sidebar.demoChats}
+
+
+ {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
+ ? t.sidebar.recentChats
+ : t.sidebar.demoChats}
+
+
-
+
{visibleThreads.map((thread) => {
diff --git a/frontend-web/src/components/workspace/recent-writing-list.tsx b/frontend-web/src/components/workspace/recent-writing-list.tsx
new file mode 100644
index 00000000..11be8295
--- /dev/null
+++ b/frontend-web/src/components/workspace/recent-writing-list.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { ChevronDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuItem,
+ SidebarMenuButton,
+} from "@/components/ui/sidebar";
+import {
+ formatRelative,
+ STATUS_LABELS,
+} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
+import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
+import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
+
+const AI_WRITING_PATH = "/page/workspace/ai-writing";
+
+function openSession(
+ session: AIWritingSessionSummary,
+ isOnPage: boolean,
+ router: ReturnType
,
+) {
+ if (isOnPage) {
+ // 已在 AI 写作页面,直接通过自定义事件通知面板
+ window.dispatchEvent(
+ new CustomEvent("ai-writing:open-session", { detail: session }),
+ );
+ } else {
+ // 不在该页面,把 session 存入 sessionStorage 后跳转
+ sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
+ router.push(AI_WRITING_PATH);
+ }
+}
+
+export function RecentWritingList() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const { sessions, loading, refresh } = useAIWritingSessions();
+ const [collapsed, setCollapsed] = useState(
+ () => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
+ );
+
+ const toggleCollapsed = () => {
+ const next = !collapsed;
+ setCollapsed(next);
+ localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
+ };
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ if (!loading && sessions.length === 0) return null;
+
+ const isOnPage = pathname === AI_WRITING_PATH;
+
+ return (
+
+
+ 最近写作
+
+
+
+
+
+ {loading && sessions.length === 0 ? (
+
加载中…
+ ) : (
+ sessions.slice(0, 10).map((session) => {
+ const { label, color } = STATUS_LABELS[session.status] ?? {
+ label: session.status,
+ color: "text-muted-foreground",
+ };
+ return (
+
+ openSession(session, isOnPage, router)}
+ >
+
+
+ {session.title}
+
+
+
+ {label}
+
+ ·
+
+ {formatRelative(session.updated_at)}
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
index 9a853cbe..1a35e372 100644
--- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
+++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
@@ -1,9 +1,10 @@
"use client";
-import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react";
-import { useTheme } from "next-themes";
+import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
+import { useTheme } from "@/contexts/ThemeContext";
+
import {
Select,
SelectContent,
@@ -27,18 +28,12 @@ const languageOptions: { value: Locale; label: string }[] = [
export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n();
- const { theme, setTheme, systemTheme } = useTheme();
- const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
+ const { theme, setTheme } = useTheme();
+ const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo(
() => [
- {
- id: "system",
- label: t.settings.appearance.system,
- description: t.settings.appearance.systemDescription,
- icon: MonitorSmartphoneIcon,
- },
{
id: "light",
label: t.settings.appearance.light,
@@ -57,8 +52,6 @@ export function AppearanceSettingsPage() {
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
- t.settings.appearance.system,
- t.settings.appearance.systemDescription,
],
);
@@ -68,7 +61,7 @@ export function AppearanceSettingsPage() {
title={t.settings.appearance.themeTitle}
description={t.settings.appearance.themeDescription}
>
-
+
{themeOptions.map((option) => (
setTheme(value)}
/>
))}
@@ -159,19 +151,16 @@ function ThemePreviewCard({
description,
active,
mode,
- systemTheme,
onSelect,
}: {
icon: ComponentType>;
label: string;
description: string;
active: boolean;
- mode: "system" | "light" | "dark";
- systemTheme?: string;
- onSelect: (mode: "system" | "light" | "dark") => void;
+ mode: "light" | "dark";
+ onSelect: (mode: "light" | "dark") => void;
}) {
- const previewMode =
- mode === "system" ? (systemTheme === "dark" ? "dark" : "light") : mode;
+ const previewMode = mode;
return (
+
+
+
+
+ 多智能体规划
+
+
+
{isSidebarOpen && }
+ {isSidebarOpen && }
{!compactChrome && (
diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx
index a4042ae7..55cda911 100644
--- a/frontend-web/src/contexts/ThemeContext.tsx
+++ b/frontend-web/src/contexts/ThemeContext.tsx
@@ -1,4 +1,11 @@
-import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
+import React, {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react';
+import { useTheme as useNextTheme } from 'next-themes';
// 主题类型
export type ThemeType = 'dark' | 'light';
@@ -75,12 +82,24 @@ const themes: Record = {
},
};
+const STORAGE_KEY = 'strategy-theme';
+
+function readPersistedTheme(): ThemeType {
+ if (typeof window === 'undefined') {
+ return 'dark';
+ }
+ const saved = localStorage.getItem(STORAGE_KEY);
+ return saved === 'light' ? 'light' : 'dark';
+}
+
// 主题上下文
interface ThemeContextType {
theme: ThemeType;
themeConfig: ThemeConfig;
toggleTheme: () => void;
setTheme: (theme: ThemeType) => void;
+ /** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
+ mounted: boolean;
}
const ThemeContext = createContext(undefined);
@@ -91,34 +110,52 @@ interface ThemeProviderProps {
}
export const ThemeProvider: React.FC = ({ children }) => {
- const [theme, setThemeState] = useState(() => {
- // 从 localStorage 读取保存的主题
- const saved = localStorage.getItem('strategy-theme');
- return (saved as ThemeType) || 'dark';
- });
+ const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
+ const [mounted, setMounted] = useState(false);
- // 切换主题
- const toggleTheme = () => {
- setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
- };
-
- // 设置特定主题
- const setTheme = (newTheme: ThemeType) => {
- setThemeState(newTheme);
- };
-
- // 保存到 localStorage
useEffect(() => {
- localStorage.setItem('strategy-theme', theme);
- // 更新文档类名
- document.documentElement.classList.remove('dark', 'light');
- document.documentElement.classList.add(theme);
- }, [theme]);
+ setMounted(true);
+ }, []);
+
+ // 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
+ useEffect(() => {
+ if (!mounted) return;
+ const current = localStorage.getItem(STORAGE_KEY);
+ const legacy = localStorage.getItem('theme');
+
+ if (current !== 'light' && current !== 'dark') {
+ const fallback =
+ legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
+ setNextTheme(fallback);
+ } else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
+ setNextTheme(legacy);
+ }
+
+ if (legacy) {
+ localStorage.removeItem('theme');
+ }
+ }, [mounted, setNextTheme]);
+
+ const theme: ThemeType = mounted
+ ? resolvedTheme === 'light'
+ ? 'light'
+ : 'dark'
+ : readPersistedTheme();
+
+ const toggleTheme = () => {
+ setNextTheme(theme === 'dark' ? 'light' : 'dark');
+ };
+
+ const setTheme = (newTheme: ThemeType) => {
+ setNextTheme(newTheme);
+ };
const themeConfig = themes[theme];
return (
-
+
{children}
);
diff --git a/frontend-web/src/core/messages/search-results.ts b/frontend-web/src/core/messages/search-results.ts
index 5da47164..7871d423 100644
--- a/frontend-web/src/core/messages/search-results.ts
+++ b/frontend-web/src/core/messages/search-results.ts
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const layers = collectNestedRecords(record);
+ const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
+
let title =
pickFromLayers(layers, TITLE_ALIASES) ??
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
+ if (!title && indexLabel) {
+ title = `结果 ${indexLabel}`;
+ }
let content =
pickFromLayers(layers, CONTENT_ALIASES) ??
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
title = pickShortTitleFallback(layers, reserved);
}
- if (!title && !content && !url) {
+ // 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
+ if (!title && !content && !url && !indexLabel) {
return null;
}
diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts
index 6929f0a7..f13c5747 100644
--- a/frontend-web/src/core/messages/utils.ts
+++ b/frontend-web/src/core/messages/utils.ts
@@ -1,5 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
+import { hasSearchResultPayload } from "@/core/messages/search-results";
+
interface GenericMessageGroup {
type: T;
id: string | undefined;
@@ -141,21 +143,63 @@ export function groupMessages(
}
export function extractTextFromMessage(message: Message) {
- if (typeof message.content === "string") {
- return (
- splitInlineReasoningFromAIMessage(message)?.content ??
- message.content.trim()
- );
+ const payload = extractToolResultPayload(message);
+ if (typeof payload === "string") {
+ return payload;
}
- if (Array.isArray(message.content)) {
- return message.content
- .map((content) => (content.type === "text" ? content.text : ""))
- .join("\n")
- .trim();
+ if (payload && typeof payload === "object") {
+ try {
+ return JSON.stringify(payload);
+ } catch {
+ return "";
+ }
}
return "";
}
+/**
+ * Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
+ *(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
+ */
+export function extractToolResultPayload(
+ message: Message,
+): string | Record | undefined {
+ const { content } = message;
+
+ if (typeof content === "string") {
+ const trimmed = content.trim();
+ return trimmed || undefined;
+ }
+
+ if (content && typeof content === "object" && !Array.isArray(content)) {
+ return content as Record;
+ }
+
+ if (Array.isArray(content)) {
+ const textParts: string[] = [];
+ for (const part of content) {
+ if (!part || typeof part !== "object") {
+ continue;
+ }
+ const block = part as Record;
+ if (block.type === "text" && typeof block.text === "string") {
+ textParts.push(block.text);
+ continue;
+ }
+ if (block.type === "json" && block.json && typeof block.json === "object") {
+ return block.json as Record;
+ }
+ if (hasSearchResultPayload(block)) {
+ return block;
+ }
+ }
+ const joined = textParts.join("\n").trim();
+ return joined || undefined;
+ }
+
+ return undefined;
+}
+
const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) {
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
return false;
}
-export function findToolCallResult(toolCallId: string, messages: Message[]) {
+export function findToolCallResult(
+ toolCallId: string,
+ messages: Message[],
+ toolName?: string,
+): string | Record | undefined {
for (const message of messages) {
- if (message.type === "tool" && message.tool_call_id === toolCallId) {
- const content = extractTextFromMessage(message);
- if (content) {
- return content;
+ if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
+ continue;
+ }
+ const payload = extractToolResultPayload(message);
+ if (payload !== undefined) {
+ return payload;
+ }
+ }
+
+ // updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
+ if (toolName) {
+ for (const message of messages) {
+ if (message.type !== "tool" || message.name !== toolName) {
+ continue;
+ }
+ if (message.tool_call_id && message.tool_call_id !== toolCallId) {
+ continue;
+ }
+ const payload = extractToolResultPayload(message);
+ if (payload !== undefined && hasSearchResultPayload(payload)) {
+ return payload;
}
}
}
+
return undefined;
}
diff --git a/frontend-web/src/open-canvas/api/writing-export.ts b/frontend-web/src/open-canvas/api/writing-export.ts
new file mode 100644
index 00000000..57367122
--- /dev/null
+++ b/frontend-web/src/open-canvas/api/writing-export.ts
@@ -0,0 +1,69 @@
+import { apiFetch } from "@/core/api/fetch-client";
+import { getBackendBaseURL } from "@/core/config";
+
+function filenameFromContentDisposition(header: string | null): string | null {
+ if (!header) return null;
+ const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
+ if (star) {
+ const raw = star[1].replace(/["']/g, "").trim();
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ return raw;
+ }
+ }
+ const plain = /filename="?([^";\n]+)"?/i.exec(header);
+ return plain ? plain[1].trim() : null;
+}
+
+function sanitizeFallbackFilename(title: string | undefined): string {
+ const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
+ return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
+}
+
+async function downloadBlobResponse(response: Response, fallbackFilename: string) {
+ if (!response.ok) {
+ const error = await response
+ .json()
+ .catch(() => ({ detail: "导出 Word 失败" }));
+ const detail = error.detail;
+ if (typeof detail === "string") throw new Error(detail);
+ throw new Error("导出 Word 失败");
+ }
+
+ const blob = await response.blob();
+ const filename =
+ filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
+ fallbackFilename;
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.rel = "noopener";
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
+}
+
+/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
+export async function downloadAIWritingDocx(options: {
+ title?: string;
+ draftMarkdown: string;
+}): Promise {
+ const draftMarkdown = options.draftMarkdown.trim();
+ if (!draftMarkdown) {
+ throw new Error("文档内容为空");
+ }
+
+ const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: options.title?.trim() || undefined,
+ draft_markdown: draftMarkdown,
+ }),
+ });
+
+ await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
+}
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
index 11b7dd23..129b0bea 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
+ /** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
+ hideAdvancedFields?: boolean;
}
interface ModelOption {
@@ -31,7 +33,14 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
-export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
+const DEFAULT_KEYWORD_COUNT = 6;
+const DEFAULT_AUDIENCE = '普通读者';
+
+export function AIWritingForm({
+ onSubmit,
+ disabled,
+ hideAdvancedFields = false,
+}: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
- const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6));
+ const keywordCount = hideAdvancedFields
+ ? DEFAULT_KEYWORD_COUNT
+ : Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({
userIntent: intent.trim(),
articleType,
- targetAudience: audience.trim() || '普通读者',
+ targetAudience: hideAdvancedFields
+ ? DEFAULT_AUDIENCE
+ : audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount,
keywordCount,
- writingMode: strictMode ? 'strict' : 'loose',
+ writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined,
});
}
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
-
-
检索关键词数量(1~10)
-
setKeywordCountInput(e.target.value)}
- onBlur={handleKeywordCountBlur}
- disabled={disabled}
- />
-
- 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
-
-
+ {!hideAdvancedFields && (
+ <>
+
+
检索关键词数量(1~10)
+
setKeywordCountInput(e.target.value)}
+ onBlur={handleKeywordCountBlur}
+ disabled={disabled}
+ />
+
+ 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
+
+
-
-
setStrictMode(e.target.checked)}
- disabled={disabled}
- className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
- />
-
- 严格依据素材写作
-
- 勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
-
-
-
+
+
setStrictMode(e.target.checked)}
+ disabled={disabled}
+ className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
+ />
+
+ 严格依据素材写作
+
+ 勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
+
+
+
-
- 目标读者
- setAudience(e.target.value)}
- disabled={disabled}
- />
-
+
+ 目标读者
+ setAudience(e.target.value)}
+ disabled={disabled}
+ />
+
+ >
+ )}
{models.length > 0 && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx
index 6d4c925e..5ba2fd7c 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
-const STATUS_LABELS: Record
= {
+export const STATUS_LABELS: Record = {
in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record = {
error: { label: '错误', color: 'text-destructive' },
};
-function formatRelative(isoStr: string): string {
+export function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
-interface SessionItemProps {
+export interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
@@ -33,7 +33,7 @@ interface SessionItemProps {
onRename: (title: string) => void;
}
-function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
+export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef(null);
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
index de5a4eec..c9b5ba9f 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record = {
error: '出错了',
};
-export function AIWritingPanel() {
+export function AIWritingPanel({
+ hideAdvancedFields = false,
+}: {
+ hideAdvancedFields?: boolean;
+} = {}) {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
reset();
}, [reset]);
+ // ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
+ // 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
+ const handleSelectHistoryRef = useRef(handleSelectHistory);
+ useEffect(() => {
+ handleSelectHistoryRef.current = handleSelectHistory;
+ }, [handleSelectHistory]);
+
+ // 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
+ useEffect(() => {
+ const raw = sessionStorage.getItem('ai-writing:pending-session');
+ if (!raw) return;
+ sessionStorage.removeItem('ai-writing:pending-session');
+ try {
+ const session = JSON.parse(raw) as AIWritingSessionSummary;
+ handleSelectHistoryRef.current(session);
+ } catch { /* ignore */ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // 仅挂载时执行一次
+
+ // 路径2:已在本页面时,监听侧边栏触发的自定义事件
+ useEffect(() => {
+ function onOpenSession(e: Event) {
+ const session = (e as CustomEvent).detail;
+ if (session) handleSelectHistoryRef.current(session);
+ }
+ window.addEventListener('ai-writing:open-session', onOpenSession);
+ return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
+ }, []);
+
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
{/* 未开始时显示表单 */}
{isIdle && (
-
+
)}
{/* 意图解析:流式 → 结果卡片承接。
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
)}
{state.parsedIntent && !state.intentStreamingText && (
-
+
)}
{/* 运行中或完成后显示时间线 */}
@@ -484,20 +524,26 @@ export function AIWritingPanel() {
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
-
- {state.request.targetAudience}
-
+ {!hideAdvancedFields && (
+
+ {state.request.targetAudience}
+
+ )}
{state.request.wordCountTarget} 字
-
- {state.request.keywordCount} 个
-
-
-
- {state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
-
-
+ {!hideAdvancedFields && (
+ <>
+
+ {state.request.keywordCount} 个
+
+
+
+ {state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
+
+
+ >
+ )}
{state.request.modelName && (
{state.request.modelName}
diff --git a/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx
index 316cb59d..1d69ff2f 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
+ hideTargetAudience?: boolean;
}
/**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/
-export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
+export function IntentResultCard({
+ intent,
+ articleTypes,
+ hideTargetAudience = false,
+}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
|
-
|
+ {!hideTargetAudience && (
+
|
+ )}
|
{intent.refinedIntent && (
|
diff --git a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx
index 1c9202d9..6d77ff86 100644
--- a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx
+++ b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx
@@ -25,7 +25,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
-import { exportMarkdownToDocx } from "../../utils/export-docx";
+import { downloadAIWritingDocx } from "../../api/writing-export";
interface ToolbarButtonProps {
onClick: () => void;
@@ -82,9 +82,12 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
if (!canExport) return;
setExporting(true);
try {
- await exportMarkdownToDocx(markdown ?? "", title);
+ await downloadAIWritingDocx({
+ title,
+ draftMarkdown: markdown ?? "",
+ });
} catch (err) {
- console.error("[export-docx] failed", err);
+ console.error("[writing-export] failed", err);
toast.error("导出 Word 失败,请稍后重试");
} finally {
setExporting(false);
diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
index 99c91440..954c89d0 100644
--- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
+++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
-export default function AIWritingPage() {
+export default function AIWritingPage({
+ hideAdvancedFields = false,
+}: {
+ hideAdvancedFields?: boolean;
+} = {}) {
const layoutRef = useRef
(null);
return (
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
diff --git a/frontend-web/src/pages/LightAppNoteQAPage.tsx b/frontend-web/src/pages/LightAppNoteQAPage.tsx
new file mode 100644
index 00000000..6ef3f83c
--- /dev/null
+++ b/frontend-web/src/pages/LightAppNoteQAPage.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useState } from "react";
+import { BookOpen, Plus, Send } from "lucide-react";
+
+import { useTheme } from "@/contexts/ThemeContext";
+import { cn } from "@/lib/utils";
+
+export default function LightAppNoteQAPage() {
+ const { theme } = useTheme();
+ const isDark = theme === "dark";
+ const [question, setQuestion] = useState("");
+
+ const handleSubmit = () => {
+ if (!question.trim()) return;
+ setQuestion("");
+ };
+
+ return (
+
+ {/* 背景光晕 */}
+
+
+ {/* 主内容 */}
+
+
+
+ {/* 笔记图标 */}
+
+
+
+
+ {/* 笔记标题 */}
+
+ 笔记01
+
+
+ {/* 新增来源按钮 */}
+
+
+ 新增来源
+
+
+ {/* 问答输入框 */}
+
+
+
setQuestion(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }}
+ placeholder="开始问答"
+ className={cn(
+ "min-w-0 flex-1 bg-transparent text-sm outline-none",
+ isDark
+ ? "text-slate-200 placeholder:text-slate-500"
+ : "text-slate-700 placeholder:text-slate-400",
+ )}
+ />
+ {question.trim() && (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx
index 4ae216da..036d15d1 100644
--- a/frontend-web/src/pages/PageRoutes.tsx
+++ b/frontend-web/src/pages/PageRoutes.tsx
@@ -8,6 +8,7 @@ import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
+import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -43,6 +44,7 @@ function PageLayout() {
/>
} />
} />
+ } />
diff --git a/frontend-web/src/pages/RoundtableRoutes.tsx b/frontend-web/src/pages/RoundtableRoutes.tsx
new file mode 100644
index 00000000..cfc39f4d
--- /dev/null
+++ b/frontend-web/src/pages/RoundtableRoutes.tsx
@@ -0,0 +1,16 @@
+import { Navigate, Route, Routes } from "react-router-dom";
+
+import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
+import "@/roundtable-planning/styles/roundtable-planning.css";
+
+export default function RoundtableRoutes() {
+ return (
+
+
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx
index 9fb4ffc3..1fe204e5 100644
--- a/frontend-web/src/pages/StrategyRoutes.tsx
+++ b/frontend-web/src/pages/StrategyRoutes.tsx
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
+import LightAppNoteQAPage from "./LightAppNoteQAPage";
export default function StrategyRoutes() {
return (
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
{/* 轻应用 */}
} />
} />
+ } />
{/* 对话 */}
} />
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx
index 784d5ff0..a721f0da 100644
--- a/frontend-web/src/pages/WorkspaceRoutes.tsx
+++ b/frontend-web/src/pages/WorkspaceRoutes.tsx
@@ -33,6 +33,8 @@ import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
+import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
+import "@/roundtable-planning/styles/roundtable-planning.css";
export function WorkspaceLayout({
children,
@@ -183,7 +185,19 @@ export default function WorkspaceRoutes({
path="ai-writing"
element={
-
+
+
+ }
+ />
+ }
+ />
+
+
}
/>
diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts
new file mode 100644
index 00000000..dabcdb3e
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/api/intent.ts
@@ -0,0 +1,292 @@
+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
+ * tied to the built-in `roundtable-intent` agent, and each /stream POST is
+ * one user-turn of the clarification dialogue. The terminal status frame
+ * carries either `asking` (more turns needed) or `done` (structured summary
+ * available — hand off to Step 2).
+ */
+
+export interface IntentInitResponse {
+ status: "success";
+ agent_id: string;
+ thread_id: string;
+}
+
+/**
+ * Create a fresh thread for an intent-clarification session.
+ * The backend returns the built-in agent id so the frontend doesn't hardcode it.
+ */
+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) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`init intent failed (${res.status}): ${text}`);
+ }
+ return (await res.json()) as IntentInitResponse;
+}
+
+/**
+ * Structured task summary the intent agent emits when it judges the user's
+ * task is fully specified. The frontend feeds this into Step 2's initial
+ * leader prompt.
+ */
+export interface IntentSummary {
+ /** One-line statement of what the user wants accomplished. */
+ objective: string;
+ /** Hard constraints (budget / time / compliance / scope). At least 1 entry. */
+ constraints: string[];
+ /** Optional assumptions the agent is making on the user's behalf. */
+ assumptions?: string[];
+}
+
+/**
+ * Same phases as streamMultiAgent so the UI can reuse one phase-label mapper.
+ * `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`;
+ * `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or
+ * similar tool call (which usually means the run is about to interrupt and
+ * the user-visible text won't grow further this turn).
+ */
+export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
+
+export interface StreamIntentRequest {
+ threadId: string;
+ message: string;
+ model?: string;
+ signal?: AbortSignal;
+ /** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
+ onTextDelta?: (text: string, messageId: string) => void;
+ /**
+ * Fires whenever the inferred SSE phase changes. The UI uses this to flip
+ * the placeholder bubble's caption from "调度中" → "已连接,等待响应" →
+ * "正在输出" so users see what's happening during the inevitable LLM
+ * first-token wait.
+ */
+ onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
+}
+
+export interface StreamIntentResult {
+ /** "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;
+}
+
+/**
+ * Recognise the terminal status frame the backend appends after the upstream
+ * LangGraph stream finishes. Must carry `status` and `content`; `summary`
+ * is optional.
+ */
+function isFinalIntentFrame(value: unknown): value is {
+ 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 === "clarification" ||
+ status === "done" ||
+ status === "error"
+ );
+}
+
+/**
+ * Reuse the same delta-extraction shape as multi-agent.ts so AI text streams
+ * smoothly into the frontend bubble. Returns null for tool / value / tool
+ * result frames.
+ */
+function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
+ if (!Array.isArray(parsed) || parsed.length < 1) 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/intent/stream and resolve when the upstream
+ * stream completes. Each call is one user turn; reuse the same threadId
+ * across calls to keep the dialogue history.
+ */
+export async function streamIntent(req: StreamIntentRequest): Promise {
+ const res = await apiFetch(`${getBackendBaseURL()}/api/intent/stream`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ thread_id: req.threadId,
+ message: req.message,
+ model: req.model,
+ }),
+ signal: req.signal,
+ });
+
+ if (!res.ok || !res.body) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`intent/stream failed (${res.status}): ${text}`);
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let lastEvent = "";
+
+ // Single source of truth for the inferred SSE phase. The helper only fires
+ // the callback when the phase actually changes (or when a tool name is
+ // provided, since the same `tool_calling` phase may surface a new tool).
+ let currentPhase: IntentStreamPhase = "connecting";
+ req.onPhaseChange?.("connecting");
+ const setPhase = (next: IntentStreamPhase, toolName?: string) => {
+ if (next === currentPhase && !toolName) return;
+ currentPhase = next;
+ req.onPhaseChange?.(next, toolName);
+ };
+
+ const result: StreamIntentResult = {
+ status: "asking",
+ content: "",
+ summary: null,
+ clarification: null,
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const rawLine of lines) {
+ const line = rawLine.trimEnd();
+ if (!line) continue;
+ if (line.startsWith("event:")) {
+ lastEvent = line.slice(6).trim();
+ // First `event: metadata` = the upstream LangGraph run was accepted.
+ // Flip out of "connecting" so the bubble label changes from
+ // "调度中" → "已连接,等待响应".
+ 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 (isFinalIntentFrame(parsed)) {
+ result.status = parsed.status;
+ if (typeof parsed.content === "string") result.content = parsed.content;
+ if (parsed.summary && typeof parsed.summary === "object") {
+ result.summary = parsed.summary as IntentSummary;
+ }
+ if (parsed.status === "clarification") {
+ const clarificationType =
+ typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
+ result.clarification = {
+ question: typeof parsed.question === "string" ? parsed.question : "",
+ 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,
+ ),
+ };
+ }
+ continue;
+ }
+
+ if (lastEvent === "messages") {
+ // Phase inference from LangGraph messages-tuple frames. Tool calls
+ // (typically ask_clarification) flip phase to tool_calling; AI text
+ // chunks flip to streaming. Everything else leaves phase alone.
+ if (Array.isArray(parsed) && parsed.length > 0) {
+ const chunk = parsed[0];
+ if (chunk && typeof chunk === "object") {
+ const chunkObj = chunk as Record;
+ const chunkType = String(chunkObj.type ?? "").toLowerCase();
+ const toolCalls = chunkObj.tool_calls;
+ const toolCallChunks = chunkObj.tool_call_chunks;
+ const content = chunkObj.content;
+ const hasToolCall =
+ (Array.isArray(toolCalls) && toolCalls.length > 0) ||
+ (Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
+ if (hasToolCall) {
+ const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
+ ? (toolCalls[0] as Record)
+ : Array.isArray(toolCallChunks) && toolCallChunks.length > 0
+ ? (toolCallChunks[0] as Record)
+ : null;
+ const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
+ setPhase("tool_calling", toolName);
+ } else if (
+ (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
+ ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
+ ) {
+ setPhase("streaming");
+ }
+ }
+ }
+
+ const delta = extractAiTextDelta(parsed);
+ if (delta) req.onTextDelta?.(delta.text, delta.id);
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts
new file mode 100644
index 00000000..3d61d6d7
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts
@@ -0,0 +1,568 @@
+import { apiFetch } from "@/core/api/fetch-client";
+import { getBackendBaseURL } from "@/core/config";
+
+import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
+
+export type ThreadIdMap = Record;
+
+export interface SeatReadyEvent {
+ agentName: string;
+ threadId: string;
+ displayName: string;
+ description: string;
+}
+
+export interface CoordinatorReadyEvent {
+ mainAgentName: string;
+ threadId: string;
+}
+
+export interface InitMultiAgentRequest {
+ agentNames: string[];
+ mainAgentName: string;
+ model?: string;
+ /** Fires the moment each sub-agent's thread is ready (real completion order). */
+ onSeatReady?: (event: SeatReadyEvent) => void;
+ /** Fires when the coordinator's thread + agent have been created. */
+ onCoordinatorReady?: (event: CoordinatorReadyEvent) => void;
+ signal?: AbortSignal;
+}
+
+export interface InitMultiAgentResponse {
+ status: "success";
+ main_agent_name: string;
+ thread_ids: ThreadIdMap;
+}
+
+/**
+ * Open the /api/multi-agent/init SSE stream. Fires per-seat / coordinator
+ * callbacks as events arrive so the UI can light up roles individually, and
+ * resolves with the final {main_agent_name, thread_ids} once init_done lands.
+ * If the server emits an "error" frame mid-stream, the promise rejects.
+ */
+export async function initMultiAgent(
+ payload: InitMultiAgentRequest,
+): Promise {
+ // Timing telemetry for /api/multi-agent/init — enabled by default so we can
+ // see where time goes when users report "Step 2 init feels slow". Disable
+ // with `window.__MULTI_AGENT_INIT_TIMING__ = false` in devtools.
+ const timingEnabled =
+ typeof window === "undefined"
+ ? false
+ : (window as unknown as { __MULTI_AGENT_INIT_TIMING__?: boolean }).__MULTI_AGENT_INIT_TIMING__ !== false;
+ const t0 = performance.now();
+ let tFirstByte = 0;
+ let tCoord = 0;
+ let seatCount = 0;
+ const tlog = (label: string) => {
+ if (!timingEnabled) return;
+ const elapsed = (performance.now() - t0).toFixed(0);
+ console.debug(`[init-timing] +${elapsed}ms ${label}`);
+ };
+ tlog("fetch.start");
+
+ const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ agent_names: payload.agentNames,
+ main_agent_name: payload.mainAgentName,
+ model: payload.model,
+ }),
+ signal: payload.signal,
+ });
+ tlog(`fetch.headers status=${res.status}`);
+ if (!res.ok || !res.body) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`init multi-agent failed (${res.status}): ${text}`);
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let mainAgentName = "";
+ let threadIds: ThreadIdMap | null = null;
+ let aborted = false;
+ let errorDetail: string | null = null;
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (tFirstByte === 0) {
+ tFirstByte = performance.now();
+ tlog(`first-byte (TTFB-after-headers=${(tFirstByte - t0).toFixed(0)}ms)`);
+ }
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const rawLine of lines) {
+ const line = rawLine.trimEnd();
+ if (!line.startsWith("data:")) continue;
+ const data = line.slice(5).trim();
+ if (!data) continue;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ continue;
+ }
+ if (!parsed || typeof parsed !== "object") continue;
+ const obj = parsed as Record;
+ const event = typeof obj.event === "string" ? obj.event : "";
+ switch (event) {
+ case "seat_ready": {
+ seatCount += 1;
+ tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`);
+ payload.onSeatReady?.({
+ agentName: String(obj.agent_name ?? ""),
+ threadId: String(obj.thread_id ?? ""),
+ displayName: String(obj.display_name ?? ""),
+ description: String(obj.description ?? ""),
+ });
+ break;
+ }
+ case "coordinator_ready": {
+ tCoord = performance.now();
+ tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`);
+ payload.onCoordinatorReady?.({
+ mainAgentName: String(obj.main_agent_name ?? ""),
+ threadId: String(obj.thread_id ?? ""),
+ });
+ break;
+ }
+ case "init_done": {
+ tlog(`init_done seats=${seatCount}`);
+ mainAgentName = String(obj.main_agent_name ?? "");
+ threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
+ break;
+ }
+ case "error": {
+ errorDetail = String(obj.detail ?? "unknown init error");
+ break;
+ }
+ default:
+ // Unknown event — ignore for forward-compat.
+ break;
+ }
+ }
+ }
+ } catch (err) {
+ aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
+ if (!aborted) throw err;
+ }
+
+ tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
+
+ if (errorDetail) {
+ throw new Error(`init multi-agent failed: ${errorDetail}`);
+ }
+ if (aborted) {
+ throw new DOMException("init aborted", "AbortError");
+ }
+ if (!threadIds || !mainAgentName) {
+ throw new Error("init multi-agent stream ended without init_done");
+ }
+ return {
+ status: "success",
+ main_agent_name: mainAgentName,
+ thread_ids: threadIds,
+ };
+}
+
+export type StreamAgentType = "leader" | "special";
+
+export type LeaderDispatch = [string, string];
+
+/**
+ * High-level phases the SSE stream goes through. Used by the UI to show
+ * "what the backend is doing" while the user waits — most of the time before
+ * a first token is upstream/LLM latency, which feels like a stall otherwise.
+ *
+ * - `connecting` — fetch sent, no frames yet
+ * - `connected` — got the `event: metadata` frame (run_id assigned)
+ * - `streaming` — first AI text chunk arrived; tokens flowing
+ * - `tool_calling` — model emitted a tool_call chunk (e.g. agent_orchestration,
+ * present_files); usually means the run is about to interrupt
+ * - `tool_result` — a tool result message came back; brief, may go back to streaming
+ */
+export type StreamPhase = "connecting" | "connected" | "streaming" | "tool_calling" | "tool_result";
+
+export interface StreamMultiAgentRequest {
+ agentType: StreamAgentType;
+ dAgentThreadId: ThreadIdMap;
+ agentName: string;
+ newMessage: string;
+ model?: string;
+ skillStopNames?: string[];
+ signal?: AbortSignal;
+ /** Fires for every non-status data frame the upstream emits. */
+ onUpstreamFrame?: (frame: unknown) => void;
+ /**
+ * Fires for every AI text chunk parsed out of LangGraph messages-tuple frames.
+ * `text` is the incremental delta (concatenate to rebuild the message);
+ * `messageId` is stable per AI message so callers can group deltas.
+ */
+ onTextDelta?: (text: string, messageId: string) => void;
+ /**
+ * Fires whenever the inferred SSE phase changes. The UI can use this to
+ * show progressive status messages during the inevitable first-token wait.
+ * `name` is the tool name when phase is `tool_calling` (or empty otherwise).
+ */
+ onPhaseChange?: (phase: StreamPhase, name?: string) => void;
+}
+
+export interface ClarificationPayload {
+ /** Raw question text from the model's ask_clarification args. */
+ question: string;
+ /** Optional category hint from the model (e.g. "missing_info"). */
+ clarificationType: string;
+ /** Free-text reason the model needs clarification. */
+ context: string;
+ /** Suggested answer chips (may be empty). */
+ 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 {
+ /** For leader runs: dispatched [agentName, task] tuples. Empty = consensus. */
+ dispatched: LeaderDispatch[];
+ /** For special runs: the completion status string. */
+ message: string | null;
+ /** Final assistant text emitted by the underlying agent. */
+ content: string;
+ /** Echoed agent_name of the agent that produced this run. */
+ agentName: string | null;
+ /**
+ * Populated when the leader called ask_clarification — the run ended without
+ * dispatching, and the model is asking the user a question instead. When
+ * present, callers should render `content` (or `clarification.question` as
+ * a fallback) as a question and collect a user reply rather than treating
+ * an empty `dispatched` as consensus.
+ */
+ clarification: ClarificationPayload | null;
+}
+
+/**
+ * Identify the final status frame our backend appends after the LangGraph
+ * stream. Required keys are `status` (array for leader, string for special)
+ * and `content` (final assistant text); `agent_name` is also expected.
+ */
+function isFinalStatusFrame(value: unknown): value is {
+ status: LeaderDispatch[] | string;
+ content?: string;
+ agent_name?: string;
+ 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;
+ if (typeof status === "string") return true;
+ if (Array.isArray(status)) {
+ return status.every(
+ (item) =>
+ Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string",
+ );
+ }
+ return false;
+}
+
+/**
+ * Extract an AI text delta from a LangGraph `messages-tuple` data frame.
+ * Frames look like `[chunk, metadata]`; an AI text chunk carries `type` in
+ * {"ai","AIMessageChunk"} and a `content` that is either a string or a list of
+ * structured content blocks. Returns null when the frame is not an AI text
+ * chunk (e.g., tool calls, tool results, value snapshots).
+ */
+function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
+ if (!Array.isArray(parsed) || parsed.length < 1) return null;
+ const chunk = parsed[0];
+ if (!chunk || typeof chunk !== "object") return null;
+ const obj = chunk as Record;
+ const type = String(obj.type ?? "").toLowerCase();
+ // Accept any AI-shaped message chunk LangChain might emit across versions:
+ // "ai" (older), "AIMessageChunk" (newer streaming), or plain "AIMessage".
+ 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/multi-agent/run/stream and resolve once the
+ * stream completes. The final frame (`{status: ...}`) is interpreted; every
+ * other `data:` frame is forwarded to `onUpstreamFrame` so the caller can do
+ * its own rendering (we don't try to mimic LangGraph SDK semantics here).
+ */
+export async function streamMultiAgent(
+ req: StreamMultiAgentRequest,
+): Promise {
+ const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ agent_type: req.agentType,
+ d_agent_thread_id: req.dAgentThreadId,
+ agent_name: req.agentName,
+ new_message: req.newMessage,
+ model: req.model,
+ skill_stop_names: req.skillStopNames ?? [],
+ }),
+ signal: req.signal,
+ });
+
+ if (!res.ok || !res.body) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`run/stream failed (${res.status}): ${text}`);
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ // Track the last `event:` line so logging can show which event a `data:`
+ // belongs to. The frontend doesn't actually dispatch by event name — every
+ // data frame goes through the same parser — but the label is invaluable
+ // for diagnosing why a sub-agent stream "looks empty".
+ let lastEvent = "";
+ let frameSeq = 0;
+
+ const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
+ const debugEnabled =
+ typeof window !== "undefined" &&
+ (window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
+
+ const result: StreamMultiAgentResult = {
+ dispatched: [],
+ message: null,
+ content: "",
+ agentName: null,
+ clarification: null,
+ };
+
+ // Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
+ // value actually changes (the reader loop sees many frames per phase).
+ let currentPhase: StreamPhase = "connecting";
+ req.onPhaseChange?.("connecting");
+ const setPhase = (next: StreamPhase, name?: string) => {
+ if (next === currentPhase && !name) return;
+ currentPhase = next;
+ req.onPhaseChange?.(next, name);
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const chunk = decoder.decode(value, { stream: true });
+ buffer += chunk;
+
+ 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();
+ // First metadata frame = run accepted by upstream. Move phase out
+ // of "connecting" so the UI can switch from "调度中" to "已连接".
+ 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 {
+ if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
+ continue;
+ }
+
+ frameSeq += 1;
+ if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
+
+ // Phase inference from LangGraph messages-tuple frames. We only flip
+ // to tool_calling/tool_result on chunks that clearly indicate them;
+ // everything else with non-empty AI text means we're streaming.
+ if (lastEvent === "messages" && Array.isArray(parsed) && parsed.length > 0) {
+ const chunk = parsed[0];
+ if (chunk && typeof chunk === "object") {
+ const chunkObj = chunk as Record;
+ const chunkType = String(chunkObj.type ?? "").toLowerCase();
+ const toolCalls = chunkObj.tool_calls;
+ const toolCallChunks = chunkObj.tool_call_chunks;
+ const content = chunkObj.content;
+ const hasToolCall =
+ (Array.isArray(toolCalls) && toolCalls.length > 0) ||
+ (Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
+ if (chunkType === "tool") {
+ setPhase("tool_result");
+ } else if (hasToolCall) {
+ // Pick the most informative name we can find in either field.
+ const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
+ ? (toolCalls[0] as Record)
+ : Array.isArray(toolCallChunks) && toolCallChunks.length > 0
+ ? (toolCallChunks[0] as Record)
+ : null;
+ const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
+ setPhase("tool_calling", toolName);
+ } else if (
+ (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
+ ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
+ ) {
+ setPhase("streaming");
+ }
+ }
+ }
+
+ if (isFinalStatusFrame(parsed)) {
+ if (Array.isArray(parsed.status)) {
+ result.dispatched = parsed.status;
+ } else {
+ result.message = parsed.status;
+ // "clarification" status carries the leader's ask_clarification
+ // payload. Extract the structured fields so the page can render
+ // 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,
+ 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,
+ ),
+ };
+ }
+ }
+ if (typeof parsed.content === "string") {
+ result.content = parsed.content;
+ }
+ if (typeof parsed.agent_name === "string") {
+ result.agentName = parsed.agent_name;
+ }
+ continue;
+ }
+
+ const delta = extractAiTextDelta(parsed);
+ if (delta) {
+ if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
+ req.onTextDelta?.(delta.text, delta.id);
+ }
+
+ req.onUpstreamFrame?.(parsed);
+ }
+ }
+
+ if (debugEnabled) {
+ console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
+ }
+ return result;
+}
+
+export interface SessionArtifactFile {
+ /** Virtual path under the sandbox, e.g. `mnt/user-data/outputs/foo.md`. */
+ path: string;
+ /** Bare filename (basename of `path`). */
+ name: string;
+ sizeBytes: number;
+ /** MIME type guessed from the extension, or null when unknown. */
+ mimeType: string | null;
+ /** ISO 8601 UTC timestamp of last modification. */
+ modifiedAt: string;
+}
+
+export interface SessionArtifactsResponse {
+ threadId: string;
+ files: SessionArtifactFile[];
+}
+
+/**
+ * List every file written to `/mnt/user-data/outputs/` for a thread. Used by
+ * the Step 3 final-report view to surface a thread's produced artifacts so the
+ * user can preview/download them via {@link buildArtifactDownloadUrl}.
+ *
+ * Returns an empty `files` array when the thread produced no artifacts (the
+ * backend treats a missing outputs directory as empty, not 404).
+ */
+export async function listSessionArtifacts(
+ threadId: string,
+ options: { signal?: AbortSignal } = {},
+): Promise {
+ const res = await apiFetch(
+ `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
+ { method: "GET", signal: options.signal },
+ );
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`list artifacts failed (${res.status}): ${text}`);
+ }
+ const json = (await res.json()) as {
+ thread_id: string;
+ files: Array<{
+ path: string;
+ name: string;
+ size_bytes: number;
+ mime_type: string | null;
+ modified_at: string;
+ }>;
+ };
+ return {
+ threadId: json.thread_id,
+ files: (json.files ?? []).map((f) => ({
+ path: f.path,
+ name: f.name,
+ sizeBytes: f.size_bytes,
+ mimeType: f.mime_type,
+ modifiedAt: f.modified_at,
+ })),
+ };
+}
+
+/**
+ * Build a URL that previews (inline) or downloads (attachment) an artifact
+ * file. The `path` must be a virtual path returned by
+ * {@link listSessionArtifacts} (e.g. `mnt/user-data/outputs/foo.md`).
+ */
+export function buildArtifactDownloadUrl(
+ threadId: string,
+ virtualPath: string,
+ options: { download?: boolean } = {},
+): string {
+ const segments = virtualPath.split("/").map((s) => encodeURIComponent(s)).join("/");
+ const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
+ return options.download ? `${base}?download=true` : base;
+}
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/ArtifactPreviewModal.tsx b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx
new file mode 100644
index 00000000..2b5cdc95
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx
@@ -0,0 +1,184 @@
+import React, { useCallback, useEffect, useState } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { Download, FileText, Loader2, X } from "lucide-react";
+
+import { apiFetch } from "@/core/api/fetch-client";
+import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
+
+interface ArtifactPreviewModalProps {
+ /** The thread that owns the artifact. Both fetch and download URLs use it. */
+ threadId: string;
+ /** Virtual path under the sandbox (e.g. `mnt/user-data/outputs/foo.md`). */
+ path: string;
+ /** Bare filename — shown in the modal header. */
+ name: string;
+ /** Mime type from the listing; drives the renderer choice. */
+ mimeType: string | null;
+ onClose: () => void;
+}
+
+const MARKDOWN_COMPONENTS = {
+ p: ({ children }: any) => {children}
,
+ ul: ({ children }: any) => ,
+ ol: ({ children }: any) => {children} ,
+ li: ({ children }: any) => {children} ,
+ h1: ({ children }: any) => {children} ,
+ h2: ({ children }: any) => {children} ,
+ h3: ({ children }: any) => {children} ,
+ h4: ({ children }: any) => {children} ,
+ strong: ({ children }: any) => {children} ,
+ em: ({ children }: any) => {children} ,
+ code: ({ children, className }: any) => {
+ const isBlock = /language-/.test(className ?? "");
+ if (isBlock) {
+ return (
+
+ {children}
+
+ );
+ }
+ return {children};
+ },
+ blockquote: ({ children }: any) => (
+ {children}
+ ),
+ a: ({ children, href }: any) => (
+
+ {children}
+
+ ),
+ table: ({ children }: any) => ,
+ th: ({ children }: any) => (
+ {children}
+ ),
+ td: ({ children }: any) => {children} ,
+ hr: () => ,
+};
+
+function isMarkdown(mimeType: string | null, name: string): boolean {
+ if (mimeType === "text/markdown") return true;
+ return /\.(md|markdown)$/i.test(name);
+}
+
+export const ArtifactPreviewModal: React.FC = ({
+ threadId,
+ path,
+ name,
+ mimeType,
+ onClose,
+}) => {
+ const [content, setContent] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Esc closes the modal. Attached once per mount.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ // Fetch the artifact body via the same URL the original download link used.
+ // The GET endpoint already returns text/* as decoded UTF-8, so res.text()
+ // gives us a renderable string directly.
+ useEffect(() => {
+ let cancelled = false;
+ const ctrl = new AbortController();
+ setLoading(true);
+ setError(null);
+ setContent(null);
+
+ (async () => {
+ try {
+ const url = buildArtifactDownloadUrl(threadId, path);
+ const res = await apiFetch(url, { method: "GET", signal: ctrl.signal });
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}`);
+ }
+ const text = await res.text();
+ if (!cancelled) setContent(text);
+ } catch (err) {
+ if (cancelled || (err as Error).name === "AbortError") return;
+ setError((err as Error).message);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ ctrl.abort();
+ };
+ }, [threadId, path]);
+
+ const handleDownload = useCallback(() => {
+ if (typeof window === "undefined") return;
+ const url = buildArtifactDownloadUrl(threadId, path, { download: true });
+ window.open(url, "_blank", "noopener,noreferrer");
+ }, [threadId, path]);
+
+ const renderAsMarkdown = isMarkdown(mimeType, name);
+
+ return (
+ {
+ // Only close on outer-overlay click — clicks inside the modal panel
+ // bubble up here too, so check the target.
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
+ {/* Header */}
+
+
+
+ {name}
+
+
+
+
+ 下载
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {loading ? (
+
+
+ 正在加载文件内容…
+
+ ) : error ? (
+
+ 加载失败:{error}
+
+ ) : content === null ? null : renderAsMarkdown ? (
+
+ {content}
+
+ ) : (
+
{content}
+ )}
+
+
+
+ );
+};
diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx
new file mode 100644
index 00000000..6c658f21
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx
@@ -0,0 +1,477 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import {
+ CheckCircle,
+ ChevronDown,
+ ChevronRight,
+ Download,
+ Eye,
+ FileText,
+ Loader2,
+ Printer,
+ RefreshCw,
+} from "lucide-react";
+
+import {
+ buildArtifactDownloadUrl,
+ listSessionArtifacts,
+ type SessionArtifactFile,
+ type ThreadIdMap,
+} from "@/roundtable-planning/api/multi-agent";
+import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
+
+/**
+ * Text-ish files we render in an in-app preview modal. Other types (pdf,
+ * images, html) keep the original window.open behaviour so the browser's
+ * native viewer handles them.
+ */
+function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
+ const mime = file.mimeType ?? "";
+ if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") {
+ return true;
+ }
+ if (mime.startsWith("text/")) return true;
+ return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name);
+}
+
+interface SeatDelivery {
+ /** Display name of the seat (e.g. "情报收集"). */
+ sender: string;
+ /** Avatar color hint inherited from the dialogue card. Unused for layout right now. */
+ avatarType: string;
+ /** The seat's final markdown delivery from Step 2. */
+ content: string;
+}
+
+interface HighFidelityReportProps {
+ threadIds: ThreadIdMap | null;
+ coordinatorName: string | null;
+ /** The most recent leader-agent text — rendered as the final consensus. */
+ consensusText: string;
+ /** True once leader.dispatched === [] was observed (natural convergence). */
+ hasConsensus: boolean;
+ /** Each sub-agent's final delivery, filtered out of step2 dialogues. */
+ seatDeliveries: SeatDelivery[];
+ /** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
+ seatNameMap: Record;
+ /** Resets state and navigates back to Step 1. */
+ onReset: () => void;
+}
+
+interface SeatArtifacts {
+ agentName: string;
+ displayName: string;
+ threadId: string;
+ files: SessionArtifactFile[];
+ loading: boolean;
+ error: string | null;
+}
+
+function formatBytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+function formatGeneratedAt(d: Date): string {
+ const pad = (n: number) => n.toString().padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
+const markdownComponents = {
+ p: ({ children }: any) => {children}
,
+ ul: ({ children }: any) => ,
+ ol: ({ children }: any) => {children} ,
+ li: ({ children }: any) => {children} ,
+ h1: ({ children }: any) => {children} ,
+ h2: ({ children }: any) => {children} ,
+ h3: ({ children }: any) => {children} ,
+ h4: ({ children }: any) => {children} ,
+ strong: ({ children }: any) => {children} ,
+ em: ({ children }: any) => {children} ,
+ code: ({ children, className }: any) => {
+ const isBlock = /language-/.test(className ?? "");
+ if (isBlock) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+ {children}
+ );
+ },
+ blockquote: ({ children }: any) => (
+ {children}
+ ),
+ a: ({ children, href }: any) => (
+
+ {children}
+
+ ),
+ table: ({ children }: any) => ,
+ th: ({ children }: any) => (
+ {children}
+ ),
+ td: ({ children }: any) => {children} ,
+ hr: () => ,
+};
+
+export const HighFidelityReport: React.FC = ({
+ threadIds,
+ coordinatorName,
+ consensusText,
+ hasConsensus,
+ seatDeliveries,
+ seatNameMap,
+ onReset,
+}) => {
+ // Build the per-seat list (excludes the coordinator). useMemo so the file
+ // fetch effect doesn't re-fire on every parent render.
+ const seatThreadEntries = useMemo(() => {
+ if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
+ return Object.entries(threadIds)
+ .filter(([name]) => !coordinatorName || name !== coordinatorName)
+ .map(([agentName, threadId]) => ({
+ agentName,
+ threadId,
+ displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
+ }));
+ }, [threadIds, coordinatorName, seatNameMap]);
+
+ // Map sender display name → its delivery content for the accordion rows.
+ // Built once per seatDeliveries change; later entries with the same sender
+ // win so the *latest* delivery wins.
+ const deliveryByDisplay = useMemo(() => {
+ const m = new Map();
+ for (const d of seatDeliveries) m.set(d.sender, d.content);
+ return m;
+ }, [seatDeliveries]);
+
+ const [artifactsBySeat, setArtifactsBySeat] = useState([]);
+ const [expandedSeats, setExpandedSeats] = useState>(new Set());
+ const [reloadKey, setReloadKey] = useState(0);
+ // Active preview target — non-null means the modal is open. We carry the
+ // owning threadId so the modal can fetch + build download URLs without
+ // needing a second prop drill.
+ const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null);
+ const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
+
+ // Fetch artifact listings for every seat thread in parallel. Re-runs when
+ // the user clicks "刷新文件清单" (reloadKey ticks) or when the thread set
+ // changes (rare — only on a true re-init).
+ useEffect(() => {
+ if (seatThreadEntries.length === 0) {
+ setArtifactsBySeat([]);
+ return;
+ }
+
+ const initial: SeatArtifacts[] = seatThreadEntries.map((s) => ({
+ ...s,
+ files: [],
+ loading: true,
+ error: null,
+ }));
+ setArtifactsBySeat(initial);
+
+ const abort = new AbortController();
+ let cancelled = false;
+
+ (async () => {
+ const results = await Promise.all(
+ seatThreadEntries.map(async (seat) => {
+ try {
+ const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
+ return { seat, files: res.files, error: null as string | null };
+ } catch (err) {
+ if ((err as Error).name === "AbortError") {
+ return { seat, files: [] as SessionArtifactFile[], error: null };
+ }
+ return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
+ }
+ }),
+ );
+ if (cancelled) return;
+ setArtifactsBySeat(
+ results.map(({ seat, files, error }) => ({
+ ...seat,
+ files,
+ loading: false,
+ error,
+ })),
+ );
+ })();
+
+ return () => {
+ cancelled = true;
+ abort.abort();
+ };
+ }, [seatThreadEntries, reloadKey]);
+
+ const totalFileCount = useMemo(
+ () => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
+ [artifactsBySeat],
+ );
+
+ const toggleSeat = useCallback((key: string) => {
+ setExpandedSeats((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }, []);
+
+ const handlePreview = useCallback(
+ (threadId: string, file: SessionArtifactFile) => {
+ // Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open
+ // in our in-app modal so the user can read the content without leaving
+ // the planning page. Binary or active content (pdf / png / html) still
+ // hands off to the browser's native viewer via the original tab.
+ if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
+ setPreviewing({ threadId, file });
+ return;
+ }
+ if (typeof window !== "undefined") {
+ const url = buildArtifactDownloadUrl(threadId, file.path);
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+ },
+ [],
+ );
+
+ const handleDownload = useCallback(
+ (threadId: string, file: SessionArtifactFile) => {
+ const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
+ if (typeof window !== "undefined") {
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+ },
+ [],
+ );
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
最终方案展示
+
+
+ {hasConsensus && (
+
+ ● 已达成共识
+
+ )}
+
+ 生成时间:{generatedAt}
+
+
+
+
+
+
{
+ if (typeof window !== "undefined") window.print();
+ }}
+ className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
+ >
+
+ 打印 / 另存 PDF
+
+
setReloadKey((k) => k + 1)}
+ className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
+ >
+
+ 刷新文件清单
+
+
+
+
+
+ {/* Section 1: Coordinator consensus conclusion */}
+
+
+
+ 总控最终结论
+
+ {consensusText ? (
+
+
+ {consensusText}
+
+
+ ) : (
+
+ 暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
+
+ )}
+
+
+ {/* Section 2: Per-seat deliveries (collapsible) */}
+
+
+
+ 各席位最终交付
+
+ {seatDeliveries.length}
+
+
+
+ {seatDeliveries.length === 0 ? (
+
+ 本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
+
+ ) : (
+
+ {seatDeliveries.map((seat, idx) => {
+ const key = `${seat.sender}-${idx}`;
+ const expanded = expandedSeats.has(key);
+ return (
+
+
toggleSeat(key)}
+ className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
+ >
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+ {seat.sender}
+
+
+ {seat.content.length} 字
+
+
+ {expanded && (
+
+
+ {seat.content || "(空)"}
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {/* Section 3: Generated files per seat */}
+
+
+
+ 生成的报告文件
+
+ {totalFileCount}
+
+
+
+ {artifactsBySeat.length === 0 ? (
+ 尚未初始化席位线程,无法列出文件。
+ ) : (
+
+ {artifactsBySeat.map((seat) => (
+
+
+ {seat.displayName}
+
+ {seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
+
+
+
+ {seat.loading ? (
+
+
+ 正在查询 outputs 目录…
+
+ ) : seat.error ? (
+
+ 读取失败:{seat.error}
+
+ ) : seat.files.length === 0 ? (
+
+ 该席位未产生输出文件
+
+ ) : (
+
+ {seat.files.map((f) => (
+
+
+
+
+ {f.name}
+
+
+
+
+ {formatBytes(f.sizeBytes)}
+
+ handlePreview(seat.threadId, f)}
+ className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
+ title="预览"
+ >
+
+
+ handleDownload(seat.threadId, f)}
+ className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
+ title="下载"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* Footer */}
+
+
+ 如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
+
+
+ 重新起草新规划
+
+
+
+
+ {previewing && (
+
setPreviewing(null)}
+ />
+ )}
+
+ );
+};
diff --git a/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx
new file mode 100644
index 00000000..d30fbc5d
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx
@@ -0,0 +1,217 @@
+import { Check } from "lucide-react";
+import { useMemo } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
+import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
+
+export type IntentClarificationSelection = {
+ selectedIds: string[];
+ custom: string;
+};
+
+type ClarificationOption = {
+ id: string;
+ label: string;
+ description?: string;
+};
+
+function normalizeOptions(raw: unknown[]): ClarificationOption[] {
+ return raw
+ .map((opt, index) => {
+ if (typeof opt === "string") {
+ const label = opt.trim();
+ return label ? { id: `option-${index}`, label } : null;
+ }
+ if (opt && typeof opt === "object" && "label" in opt) {
+ const obj = opt as { id?: unknown; label?: unknown; description?: unknown };
+ const label = String(obj.label ?? "").trim();
+ if (!label) return null;
+ const id = String(obj.id ?? label).trim() || `option-${index}`;
+ const description =
+ typeof obj.description === "string" && obj.description.trim()
+ ? obj.description.trim()
+ : undefined;
+ return { id, label, description };
+ }
+ return null;
+ })
+ .filter((opt): opt is ClarificationOption => opt !== null);
+}
+
+export function formatIntentClarificationAnswer(
+ payload: ClarificationPayload,
+ selection: IntentClarificationSelection,
+): string | null {
+ const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []);
+ const selectedLabels = selection.selectedIds
+ .map((sid) => options.find((option) => option.id === sid)?.label)
+ .filter((label): label is string => Boolean(label && label.trim()));
+ const trimmedCustom = selection.custom.trim();
+
+ if (selectedLabels.length > 0 && trimmedCustom) {
+ return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
+ }
+ if (selectedLabels.length > 0) {
+ return `我选择:${selectedLabels.join("、")}`;
+ }
+ if (trimmedCustom) {
+ return trimmedCustom;
+ }
+ return null;
+}
+
+interface IntentClarificationCardProps {
+ payload: ClarificationPayload;
+ fallbackContent?: string;
+ selection: IntentClarificationSelection;
+ onSelectionChange: (next: IntentClarificationSelection) => void;
+ disabled?: boolean;
+ submitting?: boolean;
+ /** Multi-select: submit current selection. Single-select: also used for custom text. */
+ onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
+}
+
+export function IntentClarificationCard({
+ payload,
+ fallbackContent,
+ selection,
+ onSelectionChange,
+ disabled = false,
+ submitting = false,
+ onSubmit,
+}: IntentClarificationCardProps) {
+ const options = useMemo(
+ () => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
+ [payload.options],
+ );
+
+ const allowMultiple = resolveAllowMultiple(
+ payload.allowMultiple,
+ payload.clarificationType,
+ );
+
+ const canSubmit = useMemo(() => {
+ return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
+ }, [selection.custom, selection.selectedIds]);
+
+ const showSubmitButton = allowMultiple || payload.allowCustom !== false;
+
+ function handleOptionClick(optionId: string) {
+ if (disabled || submitting) return;
+ if (!allowMultiple) {
+ onSubmit({ selectedIds: [optionId], custom: "" });
+ return;
+ }
+ const next = selection.selectedIds.includes(optionId)
+ ? selection.selectedIds.filter((id) => id !== optionId)
+ : [...selection.selectedIds, optionId];
+ onSelectionChange({ ...selection, selectedIds: next });
+ }
+
+ function updateCustom(value: string) {
+ if (disabled || submitting) return;
+ onSelectionChange({ ...selection, custom: value });
+ }
+
+ return (
+
+
+
+
+ 需要你的确认
+
+ {payload.clarificationType ? (
+
+ {payload.clarificationType}
+
+ ) : null}
+
+ {payload.context ? (
+
{payload.context}
+ ) : null}
+
+ {payload.question || fallbackContent}
+
+
+
+ {options.length > 0 ? (
+
+
+ {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
+
+ {options.map((option) => {
+ const isSelected = selection.selectedIds.includes(option.id);
+ return (
+
handleOptionClick(option.id)}
+ className={cn(
+ "flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
+ isSelected && allowMultiple
+ ? "border-primary bg-primary text-white shadow-sm"
+ : "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
+ !allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
+ (disabled || submitting) && "cursor-not-allowed opacity-60",
+ )}
+ >
+
+ {option.label}
+ {option.description ? (
+
+ {option.description}
+
+ ) : null}
+
+ {isSelected && allowMultiple ? (
+
+ ) : null}
+
+ );
+ })}
+
+ ) : null}
+
+ {payload.allowCustom !== false ? (
+
+
+ ) : null}
+
+ {showSubmitButton ? (
+
+ onSubmit()}
+ className="h-8 rounded-xl px-4 text-xs font-bold"
+ >
+ {submitting ? "发送中…" : "发送回答"}
+
+
+ ) : null}
+
+ );
+}
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;
+ /** 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 (
+
+
+
+
+
+ 选择参会智能体
+
+
+ 根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
+
+
+
+ {/* Rationale + status block */}
+
+
+
+
+ {/* Selection area: scrollable, takes remaining height */}
+
+ {state.phase === "error" ? (
+
+ {state.errorMessage ?? "未知错误"}
+
+ ) : state.phase === "recommending" ? (
+ <>
+ {recommendedCards.length > 0 ? (
+
+ {recommendedCards.map((c) => (
+ handleToggle(c.agent_id)}
+ streaming
+ />
+ ))}
+
+ ) : (
+
+
+ 等待推荐结果...
+
+ )}
+ >
+ ) : (state.phase === "ready" || state.phase === "fallback") ? (
+ <>
+ {state.warning && (
+
+ {state.warning}
+
+ )}
+
+ {recommendedCards.length > 0 && (
+
+ {recommendedCards.map((c) => (
+ handleToggle(c.agent_id)}
+ />
+ ))}
+
+ )}
+
+ {otherCards.length > 0 && (
+
+ {otherCards.map((c) => (
+ handleToggle(c.agent_id)}
+ />
+ ))}
+
+ )}
+ >
+ ) : null}
+
+
+
+
+ MAX_SELECTED
+ ? "text-destructive"
+ : "text-muted-foreground",
+ )}
+ >
+ {selectionHint}
+
+ {(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
+ void runRecommendation()}
+ className="h-7 px-2 text-xs"
+ >
+
+ 重新推荐
+
+ )}
+
+
+
+ 取消
+
+
+ 进入研讨
+
+
+
+
+
+ );
+}
+
+function RationaleBlock({ state }: { state: InternalState }) {
+ if (state.phase === "loading_agents") {
+ return (
+
+
+ 正在加载候选智能体...
+
+ );
+ }
+ if (state.phase === "recommending") {
+ const label =
+ state.streamPhase === "connecting"
+ ? "调度推荐智能体..."
+ : state.streamPhase === "connected"
+ ? "已连接,等待响应..."
+ : "正在生成推荐...";
+ return (
+
+
+
+ {label}
+
+ {state.rationale ? (
+
+ {state.rationale}
+
+ ) : (
+
正在分析任务与候选智能体...
+ )}
+
+ );
+ }
+ if (state.phase === "ready" || state.phase === "fallback") {
+ if (!state.rationale) {
+ return 推荐完成。
;
+ }
+ return (
+
+ {state.rationale}
+
+ );
+ }
+ if (state.phase === "error") {
+ return 推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。
;
+ }
+ return null;
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ );
+}
+
+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 (
+
+ {/* Visual check box — simulated, since the project ships no Checkbox primitive */}
+
+ {checked && }
+
+
+
+
{candidate.name || candidate.agent_id}
+ {recommended && (
+
+ 推荐
+
+ )}
+
+ {candidate.description && (
+
+ {candidate.description}
+
+ )}
+ {reason ? (
+
+ 推荐理由: {reason}
+
+ ) : streaming ? (
+
正在生成推荐理由...
+ ) : null}
+
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx b/frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx
new file mode 100644
index 00000000..df8e88fe
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx
@@ -0,0 +1,35 @@
+import React from "react";
+import { ChevronDown } from "lucide-react";
+
+interface ScrollToBottomButtonProps {
+ onClick: () => void;
+ /** Label shown next to the icon. Defaults to "回到底部". */
+ label?: string;
+ /** Tailwind classes appended to the default positioning. */
+ className?: string;
+}
+
+/**
+ * Pill-shaped floating button shown when the user has scrolled up away from
+ * the bottom of a streamed conversation. Click → smooth-scroll back to the
+ * bottom and resume auto-follow.
+ *
+ * Positioning is `absolute` — render inside a `relative` wrapper so it
+ * floats over the scroll container's lower-center without scrolling along
+ * with content.
+ */
+export const ScrollToBottomButton: React.FC = ({
+ onClick,
+ label = "回到底部",
+ className = "",
+}) => (
+
+
+ {label}
+
+);
diff --git a/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts
new file mode 100644
index 00000000..86c996c9
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts
@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useRef, useState, type DependencyList, type RefObject } from "react";
+
+/**
+ * Auto-scroll a scrollable container while the user is at (or near) the
+ * bottom; stop following as soon as the user scrolls up, and resume when
+ * the user clicks the floating "回到底部" button (which calls `scrollToBottom`).
+ *
+ * Why a custom hook: the page has two streamed-message lists (Step 1 chat,
+ * Step 2 roundtable) with identical scroll-follow semantics, and reading
+ * `scrollHeight` after every state change inside the component would mean
+ * duplicating the scroll listener / auto-scroll / pause logic per list.
+ */
+export function useAutoScroll(
+ ref: RefObject,
+ deps: DependencyList,
+ options: {
+ /** How close to the bottom (px) still counts as "at bottom". */
+ threshold?: number;
+ } = {},
+): {
+ /** True when the user is at the bottom — drives the button's visibility. */
+ atBottom: boolean;
+ /** Force-scroll to the bottom and resume auto-follow. */
+ scrollToBottom: () => void;
+} {
+ const threshold = options.threshold ?? 32;
+ // Ref mirrors state so the deps-effect can read the latest value without
+ // re-subscribing when atBottom flips. State is for the button's render.
+ const atBottomRef = useRef(true);
+ const [atBottom, setAtBottom] = useState(true);
+
+ // Track scroll position. The handler runs for BOTH user-initiated scrolls
+ // and programmatic `scrollTo` calls — after a programmatic scroll to the
+ // bottom, the resulting onScroll naturally leaves atBottom=true, so the
+ // auto-follow effect can keep firing on subsequent deps changes.
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const onScroll = () => {
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ const isAtBottom = distanceFromBottom <= threshold;
+ if (atBottomRef.current !== isAtBottom) {
+ atBottomRef.current = isAtBottom;
+ setAtBottom(isAtBottom);
+ }
+ };
+ el.addEventListener("scroll", onScroll, { passive: true });
+ // Initial measurement so we don't start in a stale state if the container
+ // mounted with content already overflowing.
+ onScroll();
+ return () => el.removeEventListener("scroll", onScroll);
+ }, [ref, threshold]);
+
+ // Auto-scroll on deps change — but only if the user hasn't scrolled away.
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ if (!atBottomRef.current) return;
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+
+ const scrollToBottom = useCallback(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ // Optimistically mark as at-bottom so the next deps change resumes
+ // auto-follow without waiting for the smooth-scroll's onScroll to land.
+ atBottomRef.current = true;
+ setAtBottom(true);
+ }, [ref]);
+
+ return { atBottom, scrollToBottom };
+}
diff --git a/frontend-web/src/roundtable-planning/index.ts b/frontend-web/src/roundtable-planning/index.ts
new file mode 100644
index 00000000..b7e6b294
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/index.ts
@@ -0,0 +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
new file mode 100644
index 00000000..abecde29
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -0,0 +1,3674 @@
+/**
+ * 圆桌规划主页面(三步合一)。
+ *
+ * 这是一个约 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";
+import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
+import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
+import { useTheme } from "@/contexts/ThemeContext";
+import { cn } from "@/lib/utils";
+import { useModels } from "@/core/models/hooks";
+import {
+ initMultiAgent,
+ streamMultiAgent,
+ type ClarificationPayload,
+ type LeaderDispatch,
+ type StreamPhase,
+ type ThreadIdMap,
+} from "@/roundtable-planning/api/multi-agent";
+import {
+ initIntent,
+ streamIntent,
+ 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,
+ listDrafts,
+ renameDraft,
+ upsertDraft,
+ type DraftRecord,
+} from "@/roundtable-planning/utils/drafts";
+import {
+ Shield,
+ MessageSquare,
+ Clipboard,
+ Users,
+ Activity,
+ CheckCircle,
+ HelpCircle,
+ Smile,
+ Send,
+ Repeat,
+ X,
+ Save,
+ Clock,
+ Play,
+ Pause,
+ ChevronRight,
+ ChevronDown,
+ Settings,
+ Download,
+ Share2,
+ Check,
+ Sparkles,
+ AlertTriangle,
+ Layers,
+ ArrowRight,
+ TrendingUp,
+ Cpu,
+ CornerDownRight,
+ Star,
+ ShieldCheck,
+ Loader2,
+ History,
+ Trash2,
+ Pencil,
+ Upload,
+} from "lucide-react";
+
+// --- Custom Crisp SVGs to Match High Fidelity Avatars ---
+const Man1Avatar = ({ size = "h-8 w-8" }: { size?: string }) => (
+
+
+
+ {/* Background sky/gradient */}
+
+ {/* Dark Suit Jacket */}
+
+ {/* White Shirt collar */}
+
+ {/* Dark Tie */}
+
+ {/* Face & Neck */}
+
+
+ {/* Eyes and Eyebrows */}
+
+
+
+
+ {/* Hair (Black neat professional crop) */}
+
+
+
+
+
+);
+
+const RobotAvatar = ({ bg = "bg-primary/10", headColor = "#f5222d", size = "h-8 w-8" }: { bg?: string, headColor?: string, size?: string }) => (
+
+
+ {/* Robot Rounded Head */}
+
+ {/* Black screen mask */}
+
+ {/* Glowing cute round blue eyes */}
+
+
+ {/* Mouth curve */}
+
+ {/* Ears/Side screws */}
+
+
+ {/* Single Cute Antenna */}
+
+
+
+
+);
+
+// High quality abstract avatars for other intelligence roles
+const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
+
+);
+const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ;
+const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ;
+const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ;
+const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ;
+
+// --- Helper component to get avatar by string type ---
+function getAvatar(type: string, size: string = "h-8 w-8") {
+ switch (type) {
+ case "man":
+ case "human":
+ return ;
+ case "blue":
+ return ;
+ case "purple":
+ return ;
+ case "emerald":
+ return ;
+ case "amber":
+ return ;
+ case "pink":
+ return ;
+ default:
+ return ;
+ }
+}
+
+// --- Multi-agent backend wiring ---
+const DEFAULT_MODEL = "deepseek-chat";
+
+type RoleEntry = {
+ id: number;
+ name: string;
+ agentId?: string;
+ type: "human" | "agent";
+ status: string;
+};
+
+/** Stable seat id reserved for the human commander row. Agent seats start at 2. */
+const COMMANDER_SEAT_ID = 1;
+
+/** 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,
+ }));
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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 });
+}
+
+function newCoordinatorName() {
+ return `roundtable-coordinator-${Date.now().toString(36)}`;
+}
+
+// Matches the `[INTENT_READY]\n```json {...}```` handoff signal the intent
+// agent emits when it judges the task fully specified. Same regex shape as
+// the backend's parser; mirrored here so the Step 1 chat can render a nice
+// card instead of the raw JSON block. Returns the parsed payload + the
+// pre/post text so the bubble can show the agent's prose, the structured
+// card, and any closing remarks separately.
+const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
+
+function splitIntentReadyBlock(text: string): {
+ before: string;
+ summary: IntentSummary | null;
+ after: string;
+} {
+ if (!text) return { before: text, summary: null, after: "" };
+ const m = INTENT_READY_RE.exec(text);
+ if (!m) return { before: text, summary: null, after: "" };
+ let summary: IntentSummary | null = null;
+ try {
+ const parsed = JSON.parse(m[1]);
+ if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") {
+ summary = parsed as IntentSummary;
+ }
+ } catch {
+ // Malformed JSON: leave the raw block in `before` so the user still sees it.
+ return { before: text, summary: null, after: "" };
+ }
+ return {
+ before: text.slice(0, m.index).trimEnd(),
+ summary,
+ after: text.slice(m.index + m[0].length).trimStart(),
+ };
+}
+
+export default function RoundtablePlanningPage() {
+ const { theme } = useTheme();
+ const isDark = theme === "dark";
+
+ // Backend-supplied model list (used by the per-seat persona drawer).
+ const { models: availableModels, isLoading: modelsLoading } = useModels();
+
+ // Navigation active tab
+ const [currentStep, setCurrentStep] = useState(1);
+ const [inputText, setInputText] = useState("");
+ const [charactersLeft, setCharactersLeft] = useState(0);
+ const [showToast, setShowToast] = useState(null);
+
+ // Scroll Container References
+ const step1ScrollRef = useRef(null);
+ const step2ScrollRef = useRef(null);
+
+ // Dynamic state variables for roundtable simulation
+ const [budgetLimit, setBudgetLimit] = useState(500);
+ const [consensusPercentage, setConsensusPercentage] = useState(89.4);
+ const [selectedAgentId, setSelectedAgentId] = useState(null);
+ const [activeSpeakerId, setActiveSpeakerId] = useState(null);
+ // Multiple speakers can stream in parallel (one bubble per sub-agent). The Set
+ // tracks every currently-streaming speakerId so the avatar animation lights up
+ // for each of them concurrently instead of only the most recently set one.
+ const [activeSpeakerSet, setActiveSpeakerSet] = useState>(new Set());
+
+ // Custom presets for agent model, weight, temperature and prompt editors
+ const [agentConfigs, setAgentConfigs] = useState<{ [key: number]: { model: string; weight: number; prompt: string; temp: number } }>({
+ 2: { model: DEFAULT_MODEL, weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
+ 3: { model: DEFAULT_MODEL, weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
+ 4: { model: DEFAULT_MODEL, weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
+ 5: { model: DEFAULT_MODEL, weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
+ 6: { model: DEFAULT_MODEL, weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
+ 7: { model: DEFAULT_MODEL, weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
+ });
+
+ // Message lists state representation to mimic image items & enable actual conversations
+ const [interactiveMessages, setInteractiveMessages] = useState([
+ {
+ id: 1,
+ sender: "agent",
+ senderName: "任务理解智能体",
+ content: "主方案需覆盖哪些关键目标?请列出1~3个核心目标。",
+ time: "10:34:10",
+ },
+ {
+ id: 2,
+ sender: "user",
+ senderName: "用户",
+ content: "提升执行效率、降低成本、确保安全合规。",
+ time: "10:34:35",
+ },
+ {
+ id: 3,
+ sender: "agent",
+ senderName: "任务理解智能体",
+ content: "是否存在必须规避的风险或限制条件?",
+ time: "10:34:48",
+ },
+ {
+ id: 4,
+ sender: "user",
+ senderName: "用户",
+ content: "避免重大安全事故,避免舆情风险,合规为先。",
+ time: "10:35:02",
+ }
+ ]);
+
+ // Timeline entries representing Clarification Logs
+ const [timelineEntries, setTimelineEntries] = useState([
+ {
+ id: 1,
+ time: "10:32:15",
+ title: "用户提出任务",
+ desc: "请帮我围绕某复杂任务制定规划方案。"
+ },
+ {
+ id: 2,
+ time: "10:32:22",
+ title: "智能体追问",
+ desc: "请明确目标、时间要求与资源限制。"
+ },
+ {
+ id: 3,
+ time: "10:33:02",
+ title: "用户补充信息",
+ desc: "72小时内完成,可用预算500万,人员30人以内。"
+ },
+ {
+ id: 4,
+ time: "10:33:36",
+ title: "用户确认偏好",
+ desc: "是,请保留主方案并给出备选方案。"
+ }
+ ]);
+
+ // 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 [isRoundTableRunning, setIsRoundTableRunning] = useState(true);
+ const [customAgentInput, setCustomAgentInput] = useState("");
+ // Whether the roundtable visualizer + constraint console panel is collapsed.
+ // Collapsing gives the conversation log more vertical space.
+ const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] = useState(false);
+
+ // Step 1 intent-clarification state (lives only for one step-1 visit).
+ // The thread is created lazily on first entry; intentReady is the
+ // structured summary returned by the intent agent when it judges the
+ // task is fully specified — Step 2's initial leader prompt is built
+ // from this.
+ const [intentThreadId, setIntentThreadId] = useState(null);
+ 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.
+ // after "重新起草"), avoiding a stale fetch overwriting fresh state. This
+ // also replaces a closure-based `cancelled` flag that broke under React 18
+ // StrictMode's double-invoke (cleanup set cancelled=true; the second mount
+ // 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);
+ const [coordinatorName, setCoordinatorName] = useState(null);
+ const [isInitializing, setIsInitializing] = useState(false);
+ const [initError, setInitError] = useState(null);
+ const [isOrchestrating, setIsOrchestrating] = useState(false);
+ // Coordinator's final text from the most recent leader cycle — used by
+ // Step 3 to render the consensus conclusion. Refreshed every leader call so
+ // it always reflects the latest reasoning, even mid-discussion.
+ const [lastLeaderContent, setLastLeaderContent] = useState("");
+ // True once the leader returned with an empty dispatch list (i.e. consensus
+ // reached). Drives the "已达成共识" badge in Step 3.
+ const [hasConsensus, setHasConsensus] = useState(false);
+
+ // ── Draft persistence ─────────────────────────────────────────────────
+ // Drafts are full session snapshots saved to localStorage. `currentDraftId`
+ // is non-null when the in-memory session is bound to a stored draft, so
+ // subsequent saves update the same record instead of forking copies.
+ const [drafts, setDrafts] = useState(() => listDrafts());
+ const [currentDraftId, setCurrentDraftId] = useState(null);
+ const [isHistoryOpen, setIsHistoryOpen] = useState(false);
+ const [renamingDraftId, setRenamingDraftId] = useState(null);
+ const [renameDraftValue, setRenameDraftValue] = useState("");
+ // True while the current session was just hydrated from a draft. Both step1
+ // and step2 init effects bail out when this is set so their reset/init
+ // logic doesn't clobber the restored state.
+ const loadedFromDraftRef = useRef(false);
+ // Previous step value so an effect can detect "moved forward" transitions
+ // (1→2, 2→3) and auto-save without manually wiring every nav button.
+ const prevStepRef = useRef(1);
+ const historyDropdownRef = useRef(null);
+ // When the coordinator calls ask_clarification, the leader run ends with
+ // this payload populated; the page renders a question card and only
+ // resumes orchestration once the user replies.
+ const [pendingClarification, setPendingClarification] = useState(null);
+ const [clarificationDraft, setClarificationDraft] = useState("");
+ const orchestrationCancelRef = useRef(false);
+ const step2InitRef = useRef(false);
+ // Tracks every AbortController active in this Step 2 visit so the "停止" /
+ // "挂起协商" button can abort all in-flight stream fetches at once.
+ const activeAbortersRef = useRef>(new Set());
+
+ // Abort every in-flight streaming fetch. Safe to call multiple times.
+ const abortAllInFlight = useCallback(() => {
+ activeAbortersRef.current.forEach(ctrl => {
+ try { ctrl.abort(); } catch { /* noop */ }
+ });
+ 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) {
+ const timer = setTimeout(() => setShowToast(null), 3500);
+ return () => clearTimeout(timer);
+ }
+ }, [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.
+ const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } = useAutoScroll(
+ 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],
+ );
+
+ // Push one dialogue card; uses Date.now()+rand to avoid React key collisions.
+ const appendDialogue = useCallback((card: {
+ sender: string;
+ avatarType: string;
+ content: string;
+ confidence?: string;
+ }) => {
+ setStep2RoundtableDialogues(prev => [
+ ...prev,
+ {
+ id: Date.now() + Math.random(),
+ sender: card.sender,
+ avatarType: card.avatarType,
+ content: card.content,
+ time: nowTime(),
+ confidence: card.confidence ?? "实时输出",
+ },
+ ]);
+ }, []);
+
+ // Translate an inferred SSE phase into a human-readable confidence label
+ // for the dialogue bubble. We keep the bubble's existing in-progress
+ // confidence ("调度中" / "正在输出…") when phase doesn't add information,
+ // and switch to a specific label when something specific is happening.
+ const phaseLabel = useCallback((phase: StreamPhase, agentType: "leader" | "special", toolName?: string): string => {
+ switch (phase) {
+ case "connecting":
+ return "调度中";
+ case "connected":
+ return "已连接,等待响应";
+ case "tool_calling":
+ if (toolName === "agent_orchestration") return "正在派活";
+ if (toolName === "ask_clarification") return "正在提问";
+ if (toolName === "present_files") return "正在交付文件";
+ return toolName ? `调用工具:${toolName}` : "调用工具";
+ case "tool_result":
+ return "处理工具结果";
+ case "streaming":
+ return agentType === "leader" ? "正在派活说明" : "正在输出";
+ default:
+ return agentType === "leader" ? "调度中" : "正在输出…";
+ }
+ }, []);
+
+ // Update only the confidence field of a streaming card by id. Used by the
+ // phase callback so we can change the label without touching content.
+ const setDialogueConfidence = useCallback((id: number, confidence: string) => {
+ setStep2RoundtableDialogues(prev =>
+ prev.map(d => (d.id === id ? { ...d, confidence } : d)),
+ );
+ }, []);
+
+ // Streaming variant: append a placeholder card and return its id so subsequent
+ // text deltas can be appended in-place via appendToDialogue.
+ const appendStreamingDialogue = useCallback((card: {
+ sender: string;
+ avatarType: string;
+ initialContent?: string;
+ confidence?: string;
+ }): number => {
+ const id = Date.now() + Math.random();
+ setStep2RoundtableDialogues(prev => [
+ ...prev,
+ {
+ id,
+ sender: card.sender,
+ avatarType: card.avatarType,
+ content: card.initialContent ?? "",
+ time: nowTime(),
+ confidence: card.confidence ?? "正在输出…",
+ streaming: true,
+ },
+ ]);
+ return id;
+ }, []);
+
+ // Append an AI text delta to an existing streaming card (matched by id).
+ const appendToDialogue = useCallback((id: number, deltaText: string) => {
+ if (!deltaText) return;
+ setStep2RoundtableDialogues(prev =>
+ prev.map(d => (d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d)),
+ );
+ }, []);
+
+ // Finalize a streaming card with the official content + final confidence label.
+ // If the streamed content already matches, we still overwrite with the final
+ // text (some upstream paths emit trailing whitespace differently).
+ const finalizeDialogue = useCallback((
+ id: number,
+ finalContent: string,
+ confidence?: string,
+ ) => {
+ setStep2RoundtableDialogues(prev =>
+ prev.map(d => {
+ if (d.id !== id) return d;
+ const next = finalContent && finalContent.trim().length > 0 ? finalContent : (d.content || "(无文本输出)");
+ return {
+ ...d,
+ content: next,
+ confidence: confidence ?? d.confidence,
+ streaming: false,
+ };
+ }),
+ );
+ }, []);
+
+ // Highlight a speaker bubble + flip the role's status label. Single-speaker
+ // mode (used by the leader cycle and the legacy code paths).
+ const highlightSpeaker = useCallback((speakerId: number | null) => {
+ setActiveSpeakerId(speakerId);
+ setRoles(prev =>
+ prev.map(item => {
+ if (speakerId !== null && item.id === speakerId) {
+ return { ...item, status: "研讨中" };
+ }
+ if (item.status === "研讨中") {
+ return { ...item, status: "在线" };
+ }
+ return item;
+ }),
+ );
+ }, []);
+
+ // Add a speaker to the concurrent-streaming set so its avatar lights up
+ // while a streaming sub-agent run is in flight. Pair with markSpeakerIdle.
+ const markSpeakerStreaming = useCallback((speakerId: number) => {
+ setActiveSpeakerSet(prev => {
+ if (prev.has(speakerId)) return prev;
+ const next = new Set(prev);
+ next.add(speakerId);
+ return next;
+ });
+ setRoles(prev =>
+ prev.map(item => (item.id === speakerId ? { ...item, status: "研讨中" } : item)),
+ );
+ }, []);
+
+ const markSpeakerIdle = useCallback((speakerId: number) => {
+ setActiveSpeakerSet(prev => {
+ if (!prev.has(speakerId)) return prev;
+ const next = new Set(prev);
+ next.delete(speakerId);
+ return next;
+ });
+ setRoles(prev =>
+ prev.map(item => (item.id === speakerId && item.status === "研讨中" ? { ...item, status: "在线" } : item)),
+ );
+ }, []);
+
+ const clearAllStreamingSpeakers = useCallback(() => {
+ setActiveSpeakerSet(new Set());
+ setRoles(prev =>
+ prev.map(item => (item.status === "研讨中" ? { ...item, status: "在线" } : item)),
+ );
+ }, []);
+
+ // Build the Step 2 leader's initial prompt. Prefer the intent agent's
+ // structured summary when available — it already encodes objective +
+ // constraints + assumptions. Fall back to the user's raw Step 1 turns when
+ // the user skipped clarification or jumped to Step 2 via the header tab,
+ // **without** injecting any hard-coded budget/time/headcount: those numbers
+ // (e.g. "预算 500 万") would otherwise pollute the leader prompt and make
+ // the discussion drift away from the user's actual task.
+ const buildInitialIntent = useCallback(() => {
+ if (intentReady) {
+ const parts: string[] = [intentReady.objective];
+ if (intentReady.constraints?.length) {
+ parts.push("");
+ parts.push("约束条件:");
+ for (const c of intentReady.constraints) parts.push(`- ${c}`);
+ }
+ if (intentReady.assumptions?.length) {
+ parts.push("");
+ parts.push("关键假设:");
+ for (const a of intentReady.assumptions) parts.push(`- ${a}`);
+ }
+ parts.push("");
+ parts.push("用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。");
+ return parts.join("\n");
+ }
+ // Fallback: build the prompt from whatever the user actually said in
+ // Step 1 (in chronological order) so the leader sees the same context
+ // the human would. No mock numbers.
+ const userTurns = interactiveMessages
+ .filter((m) => (m as { sender?: string }).sender === "user")
+ .map((m) => (m as { content?: string }).content?.trim())
+ .filter((s): s is string => !!s);
+ if (userTurns.length === 0) {
+ return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
+ }
+ const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
+ return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
+ }, [interactiveMessages, intentReady]);
+
+ // Run one full orchestration step: 1) leader call → 2) dispatch specials in
+ // parallel → 3) recurse with a synthesis prompt until leader stops dispatching.
+ const runOrchestration = useCallback(async (
+ seedMessage: string,
+ ids: ThreadIdMap,
+ coord: string,
+ ) => {
+ if (isOrchestrating) return;
+ setIsOrchestrating(true);
+ orchestrationCancelRef.current = false;
+
+ let nextLeaderTask = seedMessage;
+ let safetyCounter = 0;
+ const MAX_CYCLES = 8;
+
+ try {
+ while (!orchestrationCancelRef.current && safetyCounter < MAX_CYCLES) {
+ safetyCounter += 1;
+ highlightSpeaker(2);
+
+ // Leader bubble — created BEFORE the call so users see "调度中" the
+ // moment the request is in flight, then phase changes flip the
+ // confidence label so the user sees what the backend is actually
+ // doing during the ~1-2s first-token wait.
+ const leaderBubbleId = appendStreamingDialogue({
+ sender: "总控协调",
+ avatarType: "blue",
+ initialContent: "",
+ confidence: "调度中",
+ });
+
+ // Track this leader call's aborter so the stop button can kill it.
+ const leaderAborter = new AbortController();
+ activeAbortersRef.current.add(leaderAborter);
+ let leaderRes;
+ try {
+ leaderRes = await streamMultiAgent({
+ agentType: "leader",
+ dAgentThreadId: ids,
+ agentName: coord,
+ newMessage: nextLeaderTask,
+ model: DEFAULT_MODEL,
+ signal: leaderAborter.signal,
+ onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
+ onPhaseChange: (phase, toolName) => {
+ setDialogueConfidence(leaderBubbleId, phaseLabel(phase, "leader", toolName));
+ },
+ });
+ } catch (err) {
+ activeAbortersRef.current.delete(leaderAborter);
+ if (leaderAborter.signal.aborted || (err as Error).name === "AbortError") {
+ finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
+ break;
+ }
+ throw err;
+ }
+ activeAbortersRef.current.delete(leaderAborter);
+
+ if (orchestrationCancelRef.current) break;
+
+ // Clarification branch — leader asked the user a question. Stop the
+ // auto loop, surface the question card, and wait for the user to
+ // reply before another leader cycle.
+ if (leaderRes.clarification) {
+ finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
+ highlightSpeaker(null);
+ setPendingClarification(leaderRes.clarification);
+ setClarificationDraft("");
+ triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
+ break;
+ }
+
+ finalizeDialogue(
+ leaderBubbleId,
+ leaderRes.content,
+ leaderRes.dispatched.length === 0 ? "全案盖印" : "派活说明",
+ );
+
+ // Capture the latest leader text so Step 3 can show the most recent
+ // synthesis even if the user navigates over mid-discussion.
+ if (leaderRes.content) {
+ setLastLeaderContent(leaderRes.content);
+ }
+
+ if (leaderRes.dispatched.length === 0) {
+ highlightSpeaker(null);
+ setConsensusPercentage(100);
+ setHasConsensus(true);
+ triggerToast("🎉 圆桌研讨论证会圆满落幕!已取得 100% 专家组共识");
+ break;
+ }
+
+ // Dispatch sub-agents sequentially (one at a time). Running them in
+ // 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 = agentNameToRole[subName.toLowerCase()];
+ if (meta) markSpeakerStreaming(meta.speakerId);
+ const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
+
+ const subBubbleId = appendStreamingDialogue({
+ sender: meta?.displayName ?? subName,
+ avatarType: meta?.avatarType ?? "blue",
+ initialContent: "",
+ confidence: "调度中",
+ });
+
+ const subAborter = new AbortController();
+ activeAbortersRef.current.add(subAborter);
+ let aborted = false;
+ try {
+ const specRes = await streamMultiAgent({
+ agentType: "special",
+ dAgentThreadId: ids,
+ agentName: subName,
+ newMessage: task,
+ model: perSeatModel || DEFAULT_MODEL,
+ signal: subAborter.signal,
+ onTextDelta: (text) => appendToDialogue(subBubbleId, text),
+ onPhaseChange: (phase, toolName) => {
+ setDialogueConfidence(subBubbleId, phaseLabel(phase, "special", toolName));
+ },
+ });
+ if (orchestrationCancelRef.current) {
+ finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
+ aborted = true;
+ } else {
+ finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
+ }
+ } catch (err) {
+ if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
+ finalizeDialogue(subBubbleId, "(已停止)", "已停止");
+ aborted = true;
+ } else {
+ console.error(`[special:${subName}] error:`, err);
+ finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
+ }
+ } finally {
+ activeAbortersRef.current.delete(subAborter);
+ if (meta) markSpeakerIdle(meta.speakerId);
+ }
+ if (aborted) break;
+ }
+
+ if (orchestrationCancelRef.current) break;
+
+ // Nudge consensus up after each completed round.
+ setConsensusPercentage(prev => Math.min(95, parseFloat((prev + 8).toFixed(1))));
+
+ nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
+ }
+
+ if (safetyCounter >= MAX_CYCLES) {
+ triggerToast("⏱️ 已达到最大研讨轮次,自动停止以避免循环");
+ }
+ } catch (err) {
+ console.error("orchestration error:", err);
+ triggerToast(`❌ 研讨异常: ${(err as Error).message}`);
+ } finally {
+ highlightSpeaker(null);
+ clearAllStreamingSpeakers();
+ setIsOrchestrating(false);
+ }
+ }, [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
+ // unwind immediately (rather than waiting for the current LLM call to finish).
+ const stopOrchestration = useCallback(() => {
+ orchestrationCancelRef.current = true;
+ abortAllInFlight();
+ }, [abortAllInFlight]);
+
+ // On first entry to step 2: init the coordinator + sub-agent threads, then
+ // fire the first leader cycle. Leaving step 2 resets so re-entering builds
+ // a fresh coordinator (per spec).
+ useEffect(() => {
+ if (currentStep !== 2) {
+ // Leaving Step 2: stop in-flight orchestration so backend streams
+ // don't keep running in the background, but *do not* reset
+ // step2InitRef. The user's threadIds/dialogues stay in memory so
+ // returning to Step 2 replays the saved state instead of calling
+ // /api/multi-agent/init a second time and wiping the discussion.
+ orchestrationCancelRef.current = true;
+ abortAllInFlight();
+ setIsOrchestrating(false);
+ setPendingClarification(null);
+ setClarificationDraft("");
+ return;
+ }
+
+ if (step2InitRef.current) return;
+ step2InitRef.current = true;
+
+ let cancelled = false;
+
+ (async () => {
+ setIsInitializing(true);
+ setInitError(null);
+ setStep2RoundtableDialogues([]);
+ setConsensusPercentage(0);
+ setLastLeaderContent("");
+ setHasConsensus(false);
+ const coord = newCoordinatorName();
+ try {
+ const initRes = await initMultiAgent({
+ agentNames: roundtableAgentIds,
+ mainAgentName: coord,
+ model: DEFAULT_MODEL,
+ // As each sub-agent's thread comes online, flip every role that
+ // shares the agentId to "在线". Some seats reuse the same agent
+ // (e.g. seat 8 reuses roundtable-summary) so we match on agentId
+ // rather than a single id.
+ onSeatReady: ({ agentName }) => {
+ if (cancelled) return;
+ const id = agentName.toLowerCase();
+ setRoles(prev =>
+ prev.map(r => (r.agentId === id ? { ...r, status: "在线" } : r)),
+ );
+ },
+ // Coordinator readiness is a no-op for the role list (the
+ // coordinator isn't rendered as a seat), but we keep the hook in
+ // case we want to drop a "系统" toast later.
+ onCoordinatorReady: () => {
+ if (cancelled) return;
+ },
+ });
+ if (cancelled) return;
+ setThreadIds(initRes.thread_ids);
+ setCoordinatorName(initRes.main_agent_name);
+ appendDialogue({
+ sender: "系统",
+ avatarType: "blue",
+ content: `✅ 圆桌已就绪:协调智能体 ${initRes.main_agent_name} 已上线,${Object.keys(initRes.thread_ids).length - 1} 个席位已分配独立线程。`,
+ confidence: "系统就绪",
+ });
+ triggerToast("✅ 多智能体圆桌初始化完成");
+ await runOrchestration(buildInitialIntent(), initRes.thread_ids, initRes.main_agent_name);
+ } catch (err) {
+ console.error("init multi-agent failed:", err);
+ if (cancelled) return;
+ setInitError((err as Error).message);
+ triggerToast(`❌ 初始化失败: ${(err as Error).message}`);
+ } finally {
+ if (!cancelled) setIsInitializing(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ orchestrationCancelRef.current = true;
+ };
+ // We intentionally re-init only on `currentStep` transition.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentStep]);
+
+ // Pause/resume button — pausing aborts every in-flight stream so the run
+ // halts immediately rather than waiting for the current LLM call to finish.
+ // Resuming kicks off another leader cycle.
+ //
+ // Deps intentionally exclude `currentStep`: we don't want re-entering Step 2
+ // (e.g. coming back from Step 3 to inspect the discussion) to auto-fire a
+ // new orchestration round when isRoundTableRunning hasn't actually toggled.
+ useEffect(() => {
+ if (currentStep !== 2) return;
+ if (!isRoundTableRunning) {
+ stopOrchestration();
+ return;
+ }
+ if (isOrchestrating || isInitializing || !threadIds || !coordinatorName) return;
+ // resumed and idle: nudge with a continuation prompt
+ runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", threadIds, coordinatorName);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isRoundTableRunning]);
+
+ // 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} 万!各大智能体启动资源重算...`);
+
+ const tid = setTimeout(() => {
+ const respTime = new Date().toLocaleTimeString("zh-CN", { hour12: false });
+ let agentOpinion = "";
+ let speakerType = "amber";
+ let senderName = "风险审查智能体";
+
+ if (newVal < 400) {
+ agentOpinion = `⚠️ [预算警报] 经费压缩至 ${newVal} 万元,自动触发紧急安全妥协防御策略!我们将不得不裁剪旁路冷备份服务器配置,降低静态演练深度,算力容灾等级重下降至 Tier I (低安全水位)。`;
+ speakerType = "amber";
+ senderName = "风险审查智能体";
+ } else if (newVal > 540) {
+ agentOpinion = `🔋 [方案容量上升] 预算上调至充足的 ${newVal} 万元,资装弹性充裕!我已为 A 套极速构建增加了 4 组外部超大异地区容灾节点支持,灾备冗余度成功突破 Tier III+ 指标,高防高内聚!`;
+ speakerType = "purple";
+ senderName = "情报收集与适配";
+ } else {
+ agentOpinion = `⚡ [资源调配优化] 总体经费设定为 ${newVal} 万元,空间适中。我们重新规划了轻量容器 K3s 集成开销(约 ${Math.round(newVal * 0.45)} 万)和安全审计费比,总体博弈过程保持稳定态收敛。`;
+ speakerType = "pink";
+ senderName = "执行规划智能体";
+ }
+
+ const adaptiveBudgetReplier = {
+ id: Date.now() + 10,
+ sender: senderName,
+ avatarType: speakerType,
+ confidence: "资源级自适应重算",
+ content: agentOpinion,
+ time: respTime
+ };
+
+ setStep2RoundtableDialogues(prev => [...prev, adaptiveBudgetReplier]);
+ }, 1500);
+
+ return () => clearTimeout(tid);
+ };
+
+ // 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 || "专家智能体";
+ const cfg = agentConfigs[id];
+ const targetModel = cfg?.model || DEFAULT_MODEL;
+
+ const avatarColor =
+ id === 2 ? "blue"
+ : id === 3 ? "purple"
+ : id === 4 ? "emerald"
+ : id === 5 ? "human"
+ : id === 6 ? "amber"
+ : id === 7 ? "pink"
+ : "blue";
+
+ appendDialogue({
+ sender: `${roleName} (${targetModel})`,
+ avatarType: avatarColor,
+ content: `⚡ [模型偏好已保存] 本席位在下一轮研讨中将使用 ${targetModel} 进行推理。系统提示词与温度(${cfg?.temp ?? 0.2})当前仅在前端记录,等后端开放覆写后再同步生效。`,
+ confidence: "席位偏好",
+ });
+ setSelectedAgentId(null);
+ triggerToast(`⚙️ "${roleName}"已切换至 ${targetModel},下轮生效`);
+ };
+
+ // Text area counter update
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const val = e.target.value;
+ setInputText(val);
+ setCharactersLeft(val.length);
+ };
+
+ // Toast helper
+ const triggerToast = (msg: string) => {
+ setShowToast(msg);
+ };
+
+ // Reset Step 1 to its blank slate (welcome message + empty timeline) and
+ // kick off /api/intent/init to obtain a fresh intent thread. Sets
+ // step1InitRef so the Step 1 useEffect skips its own init pass after we've
+ // already done the work here. Wrapped so both the page's initial-mount
+ // effect and `handleStartNewTask` can call the exact same code path.
+ const runStep1Init = useCallback(() => {
+ step1InitRef.current = true;
+ const myGen = ++step1InitGenRef.current;
+ setIntentReady(null);
+ setIntentInitError(null);
+ setIntentThreadId(null);
+ setPendingIntentClarificationMsgId(null);
+ setIntentClarificationSelections(new Map());
+ setTimelineEntries([]);
+ setInteractiveMessages([
+ {
+ id: 1,
+ sender: "agent",
+ senderName: "任务理解智能体",
+ content: "你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?",
+ time: nowTime(),
+ },
+ ]);
+ (async () => {
+ try {
+ const res = await initIntent(DEFAULT_MODEL);
+ if (step1InitGenRef.current !== myGen) return;
+ setIntentThreadId(res.thread_id);
+ setTimelineEntries([
+ {
+ id: 1,
+ time: nowTime(),
+ title: "任务理解智能体已就绪",
+ desc: `agent_id=${res.agent_id}`,
+ },
+ ]);
+ } catch (err) {
+ if (step1InitGenRef.current !== myGen) return;
+ console.error("init intent failed:", err);
+ setIntentInitError((err as Error).message);
+ }
+ })();
+ }, []);
+
+ // Wipe every piece of the current session and re-init Step 1 from scratch.
+ // Shared by the header "新建任务" button and Step 3's "重新起草新规划" link.
+ // We call runStep1Init synchronously so the UI clears immediately — relying
+ // on the Step 1 effect alone fails when the user is already on Step 1
+ // (setCurrentStep(1) doesn't re-fire effects with unchanged deps).
+ const handleStartNewTask = useCallback(() => {
+ // Stop anything in flight before swapping state out.
+ orchestrationCancelRef.current = true;
+ abortAllInFlight();
+ stopIntentStream();
+ setIsOrchestrating(false);
+ setIsInitializing(false);
+ setPendingClarification(null);
+ setClarificationDraft("");
+
+ // Detach from any loaded draft. `runStep1Init` below sets
+ // step1InitRef = true after seeding fresh state, so we don't reset it
+ // here; step2InitRef is reset so the next Step 2 entry re-creates the
+ // coordinator + thread map from scratch.
+ loadedFromDraftRef.current = false;
+ step2InitRef.current = false;
+ setCurrentDraftId(null);
+
+ // Clear Step 2 state (Step 1 state is reset inside runStep1Init).
+ setThreadIds(null);
+ setCoordinatorName(null);
+ setInitError(null);
+ setStep2RoundtableDialogues([]);
+ setLastLeaderContent("");
+ setHasConsensus(false);
+ setConsensusPercentage(0);
+
+ setCurrentStep(1);
+ // Seed a fresh intent thread + welcome bubble immediately. Calling
+ // runStep1Init directly (instead of relying on the effect) covers the
+ // "user clicked 新建任务 while already on Step 1" case where currentStep
+ // doesn't change and the effect wouldn't re-fire.
+ runStep1Init();
+ triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
+ }, [abortAllInFlight, runStep1Init, stopIntentStream]);
+
+ // ── Draft helpers ──────────────────────────────────────────────────────
+ // Build a fresh snapshot of the current in-memory session. Called by every
+ // save path (manual button, auto step-transition save) so the persisted
+ // shape is defined in one place.
+ const buildDraftPayload = useCallback(
+ (
+ furthestStep: 1 | 2 | 3,
+ idOverride?: string | null,
+ ): Parameters[0] => {
+ const resolvedId = idOverride ?? currentDraftId;
+ return {
+ ...(resolvedId ? { id: resolvedId } : {}),
+ name: deriveDraftName({ intentReady, interactiveMessages }),
+ furthestStep,
+ step1: {
+ intentThreadId,
+ intentReady,
+ interactiveMessages,
+ timelineEntries,
+ },
+ step2: {
+ threadIds,
+ coordinatorName,
+ step2RoundtableDialogues,
+ lastLeaderContent,
+ hasConsensus,
+ consensusPercentage,
+ budgetLimit,
+ },
+ };
+ },
+ [
+ currentDraftId,
+ intentReady,
+ interactiveMessages,
+ timelineEntries,
+ intentThreadId,
+ threadIds,
+ coordinatorName,
+ step2RoundtableDialogues,
+ lastLeaderContent,
+ hasConsensus,
+ consensusPercentage,
+ budgetLimit,
+ ],
+ );
+
+ // Persist whatever step the session is currently at. Returns the saved
+ // record so callers can pick up the freshly-assigned id on first save.
+ const saveDraft = useCallback(
+ (furthestStep: 1 | 2 | 3): DraftRecord | null => {
+ // Skip empty sessions: if the user hasn't said anything to the intent
+ // agent and hasn't entered Step 2, there's nothing meaningful to keep.
+ const hasUserMessage = interactiveMessages.some(
+ (m) => (m as { sender?: string }).sender === "user",
+ );
+ if (!hasUserMessage && step2RoundtableDialogues.length === 0) return null;
+
+ const saved = upsertDraft(buildDraftPayload(furthestStep));
+ setCurrentDraftId(saved.id);
+ setDrafts(listDrafts());
+ return saved;
+ },
+ [buildDraftPayload, interactiveMessages, step2RoundtableDialogues.length],
+ );
+
+ // Manual-save handler shared by the header button and the Step 1 inline
+ // button. Toasts so the user gets visible feedback.
+ const handleManualSave = useCallback(() => {
+ const furthest = (currentStep as 1 | 2 | 3) ?? 1;
+ const saved = saveDraft(furthest);
+ if (saved) {
+ triggerToast(`💾 已保存为草稿:${saved.name}`);
+ } else {
+ triggerToast("当前会话还没有可保存的内容");
+ }
+ }, [saveDraft, currentStep]);
+
+ // Auto-save whenever the user advances to a later step (1→2, 2→3). Skipped
+ // on backward navigation so re-entering an earlier step doesn't downgrade
+ // the saved `furthestStep` and lose later progress.
+ useEffect(() => {
+ const prev = prevStepRef.current;
+ prevStepRef.current = currentStep;
+ if (currentStep > prev) {
+ saveDraft(currentStep as 1 | 2 | 3);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentStep]);
+
+ // Restore a previously-saved session. Sets the loaded-from-draft latch so
+ // both step init effects bail out and the role panel uses 在线 statuses.
+ const handleLoadDraft = useCallback(
+ (id: string) => {
+ const draft = drafts.find((d) => d.id === id) ?? null;
+ if (!draft) {
+ triggerToast("⚠️ 该草稿已不存在");
+ setDrafts(listDrafts());
+ return;
+ }
+
+ // Cancel anything in-flight before swapping state out from under it.
+ orchestrationCancelRef.current = true;
+ abortAllInFlight();
+ stopIntentStream();
+ setIsOrchestrating(false);
+ setIsInitializing(false);
+ setPendingClarification(null);
+ setClarificationDraft("");
+
+ // Latch loaded mode FIRST so the upcoming setCurrentStep doesn't
+ // trigger the step1/step2 cleanup logic that would reset refs.
+ loadedFromDraftRef.current = true;
+
+ // Step 1
+ setIntentThreadId(draft.step1.intentThreadId);
+ setIntentReady(draft.step1.intentReady);
+ setIntentInitError(null);
+ setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages);
+ setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries);
+ step1InitRef.current = true;
+ // Bump the generation so any /api/intent/init still in flight from
+ // before the load doesn't land late and overwrite the restored
+ // intentThreadId.
+ step1InitGenRef.current += 1;
+
+ // Step 2
+ setThreadIds(draft.step2.threadIds);
+ setCoordinatorName(draft.step2.coordinatorName);
+ setStep2RoundtableDialogues(draft.step2.step2RoundtableDialogues as typeof step2RoundtableDialogues);
+ setLastLeaderContent(draft.step2.lastLeaderContent);
+ setHasConsensus(draft.step2.hasConsensus);
+ setConsensusPercentage(draft.step2.consensusPercentage);
+ setBudgetLimit(draft.step2.budgetLimit);
+ // Only mark step2 as "initialized" when the draft was actually past
+ // Step 1 — otherwise entering step 2 should run init normally and
+ // create fresh coordinator threads.
+ step2InitRef.current = draft.furthestStep >= 2;
+
+ setCurrentDraftId(draft.id);
+ setIsHistoryOpen(false);
+ prevStepRef.current = draft.furthestStep;
+ setCurrentStep(draft.furthestStep);
+ triggerToast(`📂 已加载草稿:${draft.name}`);
+ },
+ [drafts, abortAllInFlight, stopIntentStream],
+ );
+
+ const handleDeleteDraft = useCallback(
+ (id: string) => {
+ if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) {
+ return;
+ }
+ deleteDraft(id);
+ setDrafts(listDrafts());
+ if (currentDraftId === id) setCurrentDraftId(null);
+ if (renamingDraftId === id) setRenamingDraftId(null);
+ triggerToast("🗑 已删除历史记录");
+ },
+ [currentDraftId, renamingDraftId],
+ );
+
+ const handleStartRename = useCallback((draft: DraftRecord) => {
+ setRenamingDraftId(draft.id);
+ setRenameDraftValue(draft.name);
+ }, []);
+
+ const handleCommitRename = useCallback(
+ (id: string) => {
+ const updated = renameDraft(id, renameDraftValue);
+ if (updated) {
+ setDrafts(listDrafts());
+ triggerToast("✏️ 已重命名");
+ }
+ setRenamingDraftId(null);
+ setRenameDraftValue("");
+ },
+ [renameDraftValue],
+ );
+
+ // Click-outside closes the history dropdown so it doesn't shadow other
+ // header controls. Re-attached only while the panel is open to avoid an
+ // always-on listener.
+ useEffect(() => {
+ if (!isHistoryOpen) return;
+ const handler = (e: MouseEvent) => {
+ if (!historyDropdownRef.current) return;
+ if (e.target instanceof Node && !historyDropdownRef.current.contains(e.target)) {
+ setIsHistoryOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [isHistoryOpen]);
+
+ // 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. 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;
+ }
+ if (isIntentSending) {
+ triggerToast("⏳ 智能体正在回复,请稍候");
+ return;
+ }
+
+ setPendingIntentClarificationMsgId(null);
+
+ const userMsgTime = nowTime();
+ const userMsgId = Date.now() + Math.random();
+ const userMsg = {
+ id: userMsgId,
+ sender: "user",
+ senderName: "用户",
+ content: trimmed,
+ time: userMsgTime,
+ };
+ setInteractiveMessages(prev => [...prev, userMsg]);
+ setInputText("");
+ setCharactersLeft(0);
+
+ setTimelineEntries(prev => [
+ ...prev,
+ {
+ id: prev.length + 1,
+ time: userMsgTime,
+ title: "用户实时反馈",
+ desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
+ },
+ ]);
+
+ const agentMsgId = Date.now() + Math.random() + 0.01;
+ setInteractiveMessages(prev => [
+ ...prev,
+ {
+ id: agentMsgId,
+ sender: "agent",
+ senderName: "任务理解智能体",
+ content: "",
+ time: nowTime(),
+ streaming: true,
+ phase: "调度中",
+ } as any,
+ ]);
+
+ const intentPhaseLabel = (phase: IntentStreamPhase, toolName?: string): string => {
+ switch (phase) {
+ case "connecting":
+ return "调度中";
+ case "connected":
+ return "已连接,等待响应";
+ case "tool_calling":
+ if (toolName === "ask_clarification") return "正在生成追问";
+ return toolName ? `调用工具:${toolName}` : "调用工具";
+ case "streaming":
+ return "正在思考问题";
+ default:
+ return "正在思考";
+ }
+ };
+
+ setIsIntentSending(true);
+ const aborter = new AbortController();
+ intentAborterRef.current = aborter;
+ try {
+ const res = await streamIntent({
+ threadId: intentThreadId,
+ message: trimmed,
+ model: DEFAULT_MODEL,
+ signal: aborter.signal,
+ onTextDelta: (delta) => {
+ setInteractiveMessages(prev =>
+ prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)),
+ );
+ },
+ onPhaseChange: (phase, toolName) => {
+ const label = intentPhaseLabel(phase, toolName);
+ setInteractiveMessages(prev =>
+ prev.map(m => (m.id === agentMsgId ? ({ ...m, phase: label } as any) : m)),
+ );
+ },
+ });
+ 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,
+ clarification: resolvedSummary ? undefined : res.clarification ?? undefined,
+ } as any
+ : m,
+ ),
+ );
+
+ if (resolvedSummary) {
+ setIntentReady(resolvedSummary);
+ setPendingIntentClarificationMsgId(null);
+ setTimelineEntries(prev => [
+ ...prev,
+ {
+ id: prev.length + 1,
+ time: nowTime(),
+ title: "任务意图已明确",
+ 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) {
+ 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 = {
+ id: interactiveMessages.length + 1,
+ sender: "agent",
+ senderName: "系统提示",
+ content: "⚠️ 您跳过了当前追问,系统已使用深度预测模型填充默认最稳健配置。",
+ time: new Date().toLocaleTimeString("zh-CN", { hour12: false }),
+ };
+ setInteractiveMessages(prev => [...prev, systemSkipMsg]);
+ triggerToast("已跳过,自动填充最优配置");
+ };
+
+ // Agent follow up question automatic clicker
+ const handleAgentFollowUp = () => {
+ const automatedFollowupMsg = {
+ id: interactiveMessages.length + 1,
+ sender: "agent",
+ senderName: "任务理解智能体",
+ content: "🔎 为了使方案时间边界更加安全:请问72小时内是否允许团队进行全仿真机房集成演练?这需要额外调拨3% of 预算冗余,但安全系数能提升12%。",
+ time: new Date().toLocaleTimeString("zh-CN", { hour12: false }),
+ };
+ setInteractiveMessages(prev => [...prev, automatedFollowupMsg]);
+ triggerToast("追问生成就绪!");
+ };
+
+ // Step 1 init effect: create a fresh intent-agent thread the first time
+ // the user lands on step 1 (and on every re-entry, since clearing thread
+ // history is desirable here — each new planning session starts clean).
+ //
+ // Note on React 18 StrictMode: the effect runs twice in dev (mount → cleanup
+ // → mount). Staleness is tracked through `step1InitGenRef` rather than a
+ // closure flag: the second mount short-circuits on `step1InitRef.current`
+ // and the original async still lands its setState, so the panel actually
+ // flips to "在线" instead of being stuck at "正在上线".
+ useEffect(() => {
+ if (currentStep !== 1) return;
+ if (step1InitRef.current) return;
+ runStep1Init();
+ }, [currentStep, runStep1Init]);
+
+ // Role panel transitions:
+ // - Step 1 shows ONLY the commander + the task-intent agent (2 entries).
+ // The intent agent flips to 在线 once /api/intent/init returns; if it
+ // errors we mark 异常 so the user knows why send is disabled.
+ // - Step 2 lights up all 7 seats (each flipped 在线 as init SSE reports).
+ // - Step 3 marks the whole roster as 已完成.
+ useEffect(() => {
+ if (currentStep === 1) {
+ setRoles([
+ { id: 1, name: "指挥员 / 主持人", type: "human", status: "在线" },
+ {
+ id: 100,
+ name: "任务理解",
+ agentId: "roundtable-intent",
+ type: "agent",
+ status: intentInitError ? "异常" : intentThreadId ? "在线" : "正在上线",
+ },
+ ]);
+ } else if (currentStep === 2) {
+ // Draft-loaded sessions skip the SSE init that would normally flip each
+ // seat to 在线 — assume all seats are online so the panel doesn't get
+ // stuck at "正在上线".
+ const baseStatus = loadedFromDraftRef.current ? "在线" : "正在上线";
+ setRoles(makeRolesFromTemplates(roleTemplates, baseStatus, { 1: "在线" }));
+ } else {
+ setRoles(makeRolesFromTemplates(roleTemplates, "已完成"));
+ }
+ }, [currentStep, intentThreadId, intentInitError, roleTemplates]);
+
+ // Inject user input to the roundtable room — sends it as a new leader call.
+ const handleInjectToRoundtable = () => {
+ if (!customAgentInput.trim()) return;
+ if (!threadIds || !coordinatorName) {
+ triggerToast("⚠️ 圆桌尚未就绪,请稍候");
+ return;
+ }
+ if (isOrchestrating) {
+ triggerToast("⏳ 当前轮次仍在进行中,请等待或暂停后再发送");
+ return;
+ }
+
+ const userText = customAgentInput.trim();
+ setCustomAgentInput("");
+
+ appendDialogue({
+ sender: "用户 (插话干预)",
+ avatarType: "human",
+ content: `💡 (人工指令介入) ${userText}`,
+ confidence: "最高指令",
+ });
+ triggerToast("您的人工纠偏指令已同步至圆桌节点!");
+
+ void runOrchestration(`用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`, threadIds, coordinatorName);
+ };
+
+ // Submit the user's answer to the coordinator's clarification question and
+ // resume orchestration. The reply is fed back as the next leader prompt on
+ // the same coordinator thread (no re-init needed).
+ const submitClarification = (rawAnswer: string) => {
+ const answer = rawAnswer.trim();
+ if (!answer) return;
+ if (!threadIds || !coordinatorName) {
+ triggerToast("⚠️ 圆桌尚未就绪,无法回复澄清");
+ return;
+ }
+ if (isOrchestrating) {
+ triggerToast("⏳ 当前轮次仍在进行中,请稍候");
+ return;
+ }
+ const question = pendingClarification?.question || "(澄清问题)";
+ appendDialogue({
+ sender: "用户 (澄清回复)",
+ avatarType: "human",
+ content: `🗣 回复总控提问:${answer}`,
+ confidence: "澄清回复",
+ });
+ setPendingClarification(null);
+ setClarificationDraft("");
+ void runOrchestration(
+ `用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
+ threadIds,
+ coordinatorName,
+ );
+ };
+
+ return (
+
+
+ {/* Toast Notice Banner */}
+
setIsRecommendDialogOpen(false)}
+ onConfirm={handleConfirmAgents}
+ />
+
+ {showToast && (
+
+
+ {showToast}
+
+ )}
+
+ {/* --- HEADER --- */}
+
+ {/* Live Workflow Stepper */}
+
+ {/* Step 1 */}
+ setCurrentStep(1)}
+ className={`flex items-center gap-2 px-4 py-1.5 rounded-xl text-xs font-semibold tracking-wide transition-all relative ${
+ currentStep === 1
+ ? "bg-white text-primary shadow-sm font-bold scale-[1.02] dark:bg-background"
+ : "text-slate-500 hover:text-slate-800 dark:text-muted-foreground dark:hover:text-foreground"
+ }`}
+ >
+ 1
+ 任务意图理解
+ {currentStep === 1 && (
+
+ )}
+
+
+
+
+ {/* Step 2 — locked until intent-clarification is done. */}
+ {(() => {
+ const step2Locked = !intentReady;
+ const step2Active = currentStep === 2;
+ return (
+ {
+ if (step2Locked) {
+ triggerToast("⚠️ 请先在第一步完成任务理解,确认[INTENT_READY]信号后再进入");
+ return;
+ }
+ setCurrentStep(2);
+ }}
+ disabled={step2Locked}
+ aria-disabled={step2Locked}
+ title={step2Locked ? "请先完成第一步任务理解" : undefined}
+ className={`flex items-center gap-2 px-4 py-1.5 rounded-xl text-xs font-semibold tracking-wide transition-all relative ${
+ step2Active
+ ? "bg-white text-primary shadow-sm font-bold scale-[1.02] dark:bg-background"
+ : step2Locked
+ ? "text-slate-300 cursor-not-allowed dark:text-slate-600"
+ : "text-slate-500 hover:text-slate-800 dark:text-muted-foreground dark:hover:text-foreground"
+ }`}
+ >
+ 2
+ 多智能体研讨
+ {step2Locked && 🔒 }
+ {step2Active && (
+
+ )}
+
+ );
+ })()}
+
+
+
+ {/* Step 3 — locked until the roundtable reaches consensus. */}
+ {(() => {
+ const step3Locked = !hasConsensus;
+ const step3Active = currentStep === 3;
+ return (
+ {
+ if (step3Locked) {
+ triggerToast(intentReady
+ ? "⚠️ 请先在第二步达成共识(总控自然收敛)再进入"
+ : "⚠️ 请先依次完成第一步和第二步");
+ return;
+ }
+ setCurrentStep(3);
+ }}
+ disabled={step3Locked}
+ aria-disabled={step3Locked}
+ title={step3Locked ? "请先在第二步达成共识" : undefined}
+ className={`flex items-center gap-2 px-4 py-1.5 rounded-xl text-xs font-semibold tracking-wide transition-all relative ${
+ step3Active
+ ? "bg-white text-primary shadow-sm font-bold scale-[1.02] dark:bg-background"
+ : step3Locked
+ ? "text-slate-300 cursor-not-allowed dark:text-slate-600"
+ : "text-slate-500 hover:text-slate-800 dark:text-muted-foreground dark:hover:text-foreground"
+ }`}
+ >
+ 3
+ 方案结果展示
+ {step3Locked && 🔒 }
+ {step3Active && (
+
+ )}
+
+ );
+ })()}
+
+
+ {/* Right Actions */}
+
+
+
+ 新建任务
+
+
+
{
+ setCurrentStep(1);
+ triggerToast("已切换至第一阶段:人机问答意图澄清");
+ }}
+ className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
+ >
+
+
+
+ 开始澄清
+
+
+
{
+ // Toggle Commander Role
+ setRoles(prev =>
+ prev.map(r => r.id === 1 ? { ...r, status: r.status === "在线" ? "等待激活" : "在线" } : r)
+ );
+ triggerToast("指挥专家状态变更!人工介入切换");
+ }}
+ className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
+ >
+
+ 人工介入
+
+
+
+
+ {/* History dropdown trigger + panel (anchored to this button). */}
+
+
setIsHistoryOpen((open) => !open)}
+ className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
+ >
+
+ 历史记录
+
+ {drafts.length}
+
+
+
+ {isHistoryOpen && (
+
+
+ 历史草稿
+ {drafts.length} 条
+
+ {drafts.length === 0 ? (
+
+ 暂无历史。点击「保存草稿」或走完一步会自动保存到这里。
+
+ ) : (
+
+ {drafts.map((d) => {
+ const isCurrent = d.id === currentDraftId;
+ const isRenaming = d.id === renamingDraftId;
+ return (
+
+
+
+ {isRenaming ? (
+ setRenameDraftValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCommitRename(d.id);
+ if (e.key === "Escape") {
+ setRenamingDraftId(null);
+ setRenameDraftValue("");
+ }
+ }}
+ onBlur={() => handleCommitRename(d.id)}
+ className="w-full text-[11.5px] font-semibold text-slate-800 border border-slate-300 rounded px-2 py-1 outline-none focus:border-primary dark:bg-background dark:text-foreground"
+ />
+ ) : (
+ handleLoadDraft(d.id)}
+ className="block w-full text-left"
+ title="点击加载"
+ >
+
+ {d.name}
+
+
+ Step {d.furthestStep} · {new Date(d.updatedAt).toLocaleString("zh-CN", { hour12: false })}
+
+
+ )}
+
+
+ {!isRenaming && (
+ <>
+
handleLoadDraft(d.id)}
+ className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
+ title="加载"
+ >
+
+
+
handleStartRename(d)}
+ className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
+ title="重命名"
+ >
+
+
+
handleDeleteDraft(d.id)}
+ className="p-1 text-slate-500 hover:text-rose-600 hover:bg-rose-50 rounded transition"
+ title="删除"
+ >
+
+
+ >
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
+
+
+ {/* Settings gear */}
+
triggerToast("⚙️ 系统管理面板拉取中(暂不开放外部审计)")}
+ className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
+ >
+
+
+
+
+
+ {/* --- MAIN CORE --- */}
+
+
+ {/* ========================================================
+ LEFT COLUMN (Task Overview, Roles, Human Participation)
+ ======================================================== */}
+
+
+ {/* ========================================================
+ MIDDLE COLUMN (Scrollable Screen Views Based on Steps)
+ ======================================================== */}
+
+
+ {/* STEP 1 SCREEN VIEW */}
+ {currentStep === 1 && (
+
+
+ {/* Dialogue Header */}
+
+
+
+
+ 任务意图理解 (人机问答)
+
+
+ {/* Real turn counter — counts user messages in the live
+ interactiveMessages list. "X 轮" instead of "X/3" because
+ we don't bound the number of clarification turns. */}
+
+ 问答轮次:{interactiveMessages.filter(m => m.sender === "user").length} 轮
+
+
+
+ {/* Chat list viewport */}
+
+
+ {/* Step 1 dialogue is rendered by `interactiveMessages.map()`
+ inside the 问答交互区 sub-block below. The static demo
+ bubbles that previously lived here have been removed. */}
+
+ {/* --- INTENT SUMMARY BOX --- emerald (ready) / slate (waiting) ---
+ Rendered from `intentReady` when the agent has emitted a
+ structured summary; otherwise shows a placeholder with
+ a disabled "进入下一步" button so the user knows what they
+ need to wait for. */}
+ {intentReady ? (
+
+
+
+
+
+ 已形成明确任务意图
+
+
+
setIsRecommendDialogOpen(true)}
+ className="bg-primary hover:bg-[var(--brand-900)] text-white font-bold text-xs px-4 py-2 rounded-xl transition shadow-[0_2px_8px_rgba(245,34,45,0.2)] active:scale-95 shrink-0"
+ >
+ 用户确认,进入多智能体研讨
+
+
+
+
+
+
任务目标
+
{intentReady.objective}
+
+
+
硬约束
+
+ {intentReady.constraints.map((c, i) => (
+ {c}
+ ))}
+
+
+ {intentReady.assumptions && intentReady.assumptions.length > 0 && (
+
+
关键假设
+
+ {intentReady.assumptions.map((a, i) => (
+ {a}
+ ))}
+
+
+ )}
+
+
+ ) : (
+
+
+
+
+
+ 等待任务理解智能体澄清完成...
+
+
+
+ 用户确认,进入多智能体研讨
+
+
+ {intentInitError && (
+
+ 智能体初始化失败: {intentInitError}
+
+ )}
+
+ )}
+
+ {/* --- Q&A INTERACTION AREA (问答交互区) --- */}
+
+
+ {/* Inside header banner */}
+
+
+
+
+ 问答交互区
+
+
+
持续沟通可有效提供细节决策
+
+
+ {/* Content dialogue lines inside interaction */}
+
+
+ {interactiveMessages.map((msg) => {
+ const isStreaming = (msg as { streaming?: boolean }).streaming === true;
+ const hasContent = (msg.content ?? "").length > 0;
+ const isAgent = msg.sender !== "user";
+ const clarification = (msg as { clarification?: ClarificationPayload }).clarification;
+ const parts = isAgent
+ ? splitIntentReadyBlock(msg.content ?? "")
+ : { before: msg.content ?? "", summary: null, after: "" };
+ const isPendingClarification =
+ isAgent &&
+ clarification &&
+ !intentReady &&
+ !parts.summary &&
+ pendingIntentClarificationMsgId === msg.id;
+ const clarificationSelection =
+ intentClarificationSelections.get(msg.id) ?? {
+ selectedIds: [],
+ custom: "",
+ };
+ const introText =
+ clarification &&
+ parts.before.trim() === (clarification.question || "").trim()
+ ? ""
+ : parts.before;
+ return (
+
+
+ {msg.sender === "user" ? (
+
+ ) : (
+
+ )}
+
+
+ {msg.senderName}
+
+ {/* Pre-text spinner: shown only when streaming AND no content has arrived.
+ Caption reflects the inferred SSE phase so users see what the
+ backend is doing during the first-token wait. */}
+ {isStreaming && !hasContent ? (
+
+
+ {(msg as { phase?: string }).phase ?? "正在连接智能体..."}
+
+ ) : isAgent ? (
+
+ {introText && (
+
{children}
,
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children} ,
+ li: ({ children }) => {children} ,
+ h1: ({ children }) => {children} ,
+ h2: ({ children }) => {children} ,
+ h3: ({ children }) => {children} ,
+ h4: ({ children }) => {children} ,
+ strong: ({ children }) => {children} ,
+ em: ({ children }) => {children} ,
+ code: ({ children, className }) => {
+ const isBlock = /language-/.test(className ?? "");
+ if (isBlock) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ a: ({ children, href }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {introText}
+
+ )}
+ {parts.summary && (
+
+
+
+ 任务意图已明确
+
+
+
核心目标
+
{parts.summary.objective}
+
+ {parts.summary.constraints?.length > 0 && (
+
+
约束条件
+
+ {parts.summary.constraints.map((c, i) => (
+ {c}
+ ))}
+
+
+ )}
+ {parts.summary.assumptions && parts.summary.assumptions.length > 0 && (
+
+
关键假设
+
+ {parts.summary.assumptions.map((a, i) => (
+ {a}
+ ))}
+
+
+ )}
+
+ )}
+ {parts.after && (
+
{children}
,
+ }}>
+ {parts.after}
+
+ )}
+ {isStreaming && (
+
+ )}
+ {isPendingClarification && clarification && (
+
{
+ setIntentClarificationSelections(prev => {
+ const map = new Map(prev);
+ map.set(msg.id, next);
+ return map;
+ });
+ }}
+ disabled={!isPendingClarification}
+ submitting={isIntentSending && isPendingClarification}
+ onSubmit={(override) =>
+ submitIntentClarification(msg.id, clarification, override)
+ }
+ />
+ )}
+
+ ) : (
+
+ {msg.content}
+
+ )}
+
+
+
{msg.time}
+
+ );
+ })}
+
+ {!step1AtBottom &&
}
+
+
+
+
+
+
+ {/* Botton Input Drawer Area (Ask/Reply Box) */}
+
+
+ {/* Custom text-area with nice borders */}
+
+
+ {/* Sub row action buttons */}
+
+
+ {/* Left row buttons */}
+
+
+ {isIntentSending ? : }
+ {isIntentSending ? "发送中" : "发送"}
+
+
+ {isIntentSending && (
+
stopIntentStream()}
+ className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition border border-amber-200/40 shadow-sm active:scale-95"
+ >
+
+ 暂停
+
+ )}
+
+
+
+ 继续追问
+
+
+
+
+ 跳过此项
+
+
+
{
+ if (!intentReady) {
+ triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
+ return;
+ }
+ setIsRecommendDialogOpen(true);
+ }}
+ disabled={!intentReady}
+ title={intentReady ? undefined : "请先完成任务意图澄清"}
+ className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${
+ intentReady
+ ? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
+ : "bg-emerald-600/40 text-white cursor-not-allowed"
+ }`}
+ >
+
+ 进入多智能体研讨 >
+
+
+
+ {/* Right side buttons */}
+
+
+ 保存草稿
+
+
+
+
+
+
+
+ )}
+
+ {/* STEP 2 SCREEN VIEW: MULTI-AGENT ROUNDTABLE STUDY */}
+ {currentStep === 2 && (
+
+
+ {/* Headline block */}
+
+
+
+
+
+ 多智能体圆桌会商中心
+
+
分布式领域专家实时动态博弈与纳什协商收敛中
+
+
+
+ {/* Suspend / resume button visibility:
+ - Show "挂起协商" only while a run is actually in flight
+ (isOrchestrating + still nominally running). When the
+ discussion finishes naturally there's nothing to
+ suspend, so the button hides; it'll come back the
+ next time orchestration starts (user "干预研审" or
+ clarification reply).
+ - Show "恢复会商" any time the user has explicitly paused
+ (!isRoundTableRunning) regardless of orchestration
+ state, so they can always recover from a manual pause. */}
+ {isRoundTableRunning && isOrchestrating ? (
+
{ setIsRoundTableRunning(false); triggerToast("⏸️ 会商模拟暂挂!智能体挂起..."); }}
+ className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
+ >
+
+ 挂起协商
+
+ ) : !isRoundTableRunning ? (
+
{ setIsRoundTableRunning(true); triggerToast("▶️ 恢复大模型并行博弈与推理决策"); }}
+ className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
+ >
+
+ 恢复会商
+
+ ) : null}
+
+
+
+ {/* Collapsed handle: when the upper panel is hidden, show a thin
+ bar at the top of the conversation area so the user can bring
+ it back. */}
+ {isUpperPanelCollapsed && (
+
+ setIsUpperPanelCollapsed(false)}
+ className="flex items-center gap-1.5 text-[11px] font-bold text-slate-500 hover:text-primary hover:bg-slate-50 px-3 py-1 rounded-lg transition cursor-pointer"
+ title="展开圆桌控制台"
+ >
+
+ 展开圆桌控制台
+
+
+ )}
+
+ {/* Split layout: Roundtable visualizer and Interactive Controllers */}
+ {!isUpperPanelCollapsed && (
+
+ {/* Close button — collapses this whole upper panel so the
+ conversation log takes the freed vertical space. */}
+
setIsUpperPanelCollapsed(true)}
+ className="absolute top-3 right-4 flex items-center gap-1 text-[10px] font-bold text-slate-400 hover:text-primary hover:bg-slate-100 px-2 py-1 rounded-lg transition cursor-pointer z-10"
+ title="收起圆桌控制台"
+ >
+
+ 收起
+
+
+
+ {/* Left part: Roundtable circle diagram */}
+
+
+
+ {/* Ring animations */}
+
+
+
+ {/* Center Core */}
+
+
+ 验
+
+
共识指数
+
{consensusPercentage}%
+
+
+ {/* Clickable Roundtable Agent Bubbles */}
+ {/* Role 3: 情报收集 (Top) */}
+
setSelectedAgentId(3)}
+ className={`absolute -top-3.5 left-1/2 -translate-x-1/2 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 3 || (activeSpeakerId === 3 || activeSpeakerSet.has(3)) ? 'ring-4 ring-purple-400 scale-[1.12]' : 'border-slate-200'}`}
+ title="点击配置 情报收集 智能体人设与LLM"
+ >
+
+ 情报收集
+ {((activeSpeakerId === 3 || activeSpeakerSet.has(3)) || selectedAgentId === 3) && (
+
+
+
+
+ )}
+
+
+ {/* Role 4: 环境评估 (Top Right) */}
+
setSelectedAgentId(4)}
+ className={`absolute top-5 -right-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 4 || (activeSpeakerId === 4 || activeSpeakerSet.has(4)) ? 'ring-4 ring-emerald-400 scale-[1.12]' : 'border-slate-200'}`}
+ title="点击配置 环境评估 智能体人设与LLM"
+ >
+
+ 环境评估
+ {((activeSpeakerId === 4 || activeSpeakerSet.has(4)) || selectedAgentId === 4) && (
+
+
+
+
+ )}
+
+
+ {/* Human Participant: 方案设计 (Bottom Right) */}
+
setSelectedAgentId(5)}
+ className={`absolute bottom-5 -right-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 5 || (activeSpeakerId === 5 || activeSpeakerSet.has(5)) ? 'ring-4 ring-primary/50 scale-[1.12]' : 'border-slate-200'}`}
+ title="点击查看 方案设计 角色参数"
+ >
+
+ 方案设计
+ {((activeSpeakerId === 5 || activeSpeakerSet.has(5)) || selectedAgentId === 5) && (
+
+
+
+
+ )}
+
+
+ {/* Role 6: 风险审查 (Bottom) */}
+
setSelectedAgentId(6)}
+ className={`absolute -bottom-3.5 left-1/2 -translate-x-1/2 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 6 || (activeSpeakerId === 6 || activeSpeakerSet.has(6)) ? 'ring-4 ring-amber-400 scale-[1.12]' : 'border-slate-200'}`}
+ title="点击配置 风险审查 智能体人设与LLM"
+ >
+
+ 风险审查
+ {((activeSpeakerId === 6 || activeSpeakerSet.has(6)) || selectedAgentId === 6) && (
+
+
+
+
+ )}
+
+
+ {/* Role 7: 执行规划 (Bottom Left) */}
+
setSelectedAgentId(7)}
+ className={`absolute bottom-5 -left-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 7 || (activeSpeakerId === 7 || activeSpeakerSet.has(7)) ? 'ring-4 ring-pink-400 scale-[1.12]' : 'border-slate-200'}`}
+ title="点击配置 执行规划 智能体人设与LLM"
+ >
+
+ 执行规划
+ {((activeSpeakerId === 7 || activeSpeakerSet.has(7)) || selectedAgentId === 7) && (
+
+
+
+
+ )}
+
+
+ {/* Role 2: 总结陈述 (Top Left) */}
+
setSelectedAgentId(2)}
+ className={`absolute top-5 -left-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 2 || (activeSpeakerId === 2 || activeSpeakerSet.has(2)) ? 'ring-4 ring-primary/50 scale-[1.12]' : 'border-slate-200'}`}
+ title="点击配置 总结陈述 主持人"
+ >
+
+ 总结陈述
+ {((activeSpeakerId === 2 || activeSpeakerSet.has(2)) || selectedAgentId === 2) && (
+
+
+
+
+ )}
+
+
+
+
+
+ 点击上方智能体球泡,可覆写参数人设
+
+
+
+ {/* Right part: Interactive Constraints Panel */}
+
+
+
+
+
+ 博弈边界约束调控台
+
+
+
+ 动态重算激活
+
+
+
+ {/* Budget constraint slider */}
+
+
+ 🛡️ 特别任务预算硬性指示 (万)
+
+ {budgetLimit} 万元人民币
+
+
+
+ 300万
+ handleBudgetChange(parseInt(e.target.value))}
+ className="flex-1 h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-primary"
+ />
+ 600万
+
+
+ 修改该参数代表对圆桌派发最新大选资金分配。专家体检测至边界收紧,将触发博弈重校准进行经费和冗余重算。
+
+
+
+ {/* State feedback indicators */}
+
+
+ 专家组决策模型
+
+ NAS-NASH (平衡态已收敛)
+
+
+
+ 方案最大灾备评级
+
+ {budgetLimit >= 540 ? "Tier III+ (无懈可击灾备)" : budgetLimit > 400 ? "Tier II (优秀推荐防灾)" : "Tier I (基础安全限额)"}
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Selected Agent Persona Config Drawer / Drawer dialog box
+ ────────────────────────────────────────────────────────
+ 点击圆桌可视化里的某个智能体头像后展开,允许覆写该席位的:
+ - System Prompt (本地记录,后端覆写未开放)
+ - 推理模型 (下一轮派活时 runOrchestration 会读 agentConfigs[id].model)
+ - Temperature (本地记录,后端覆写未开放)
+ 保存按钮 = handleSaveAgentAndRecalculate,不会立即重启编排,只更新
+ agentConfigs;真正生效在下一次该席位被 leader 派活时。 */}
+ {selectedAgentId && (
+
+
setSelectedAgentId(null)}
+ className="absolute top-3.5 right-3.5 text-slate-400 hover:text-slate-650 hover:bg-slate-100 p-1.5 rounded-full transition cursor-pointer"
+ title="关闭 Override"
+ >
+
+
+
+
+
+
+ {getAvatar(
+ selectedAgentId === 2 ? "blue" :
+ selectedAgentId === 3 ? "purple" :
+ selectedAgentId === 4 ? "emerald" :
+ selectedAgentId === 5 ? "human" :
+ selectedAgentId === 6 ? "amber" :
+ selectedAgentId === 7 ? "pink" : "blue",
+ "h-10 w-10"
+ )}
+
+
+
+ {roles.find(r => r.id === selectedAgentId)?.name} 人设专有策略调制
+
+
+ 系统就绪
+
+
+
+ 配置此席位智能体的运行大语言模型及系统 System Prompt 指令
+
+
+
+
+
+
+ 该席位系统提示词指令 override (System Instruction)
+
+
+
+
+
+
+
+ 推理本源模型
+ {
+ const val = e.target.value;
+ setAgentConfigs(prev => ({
+ ...prev,
+ [selectedAgentId]: {
+ ...prev[selectedAgentId],
+ model: val
+ }
+ }));
+ }}
+ className="w-full bg-white border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs text-slate-700 font-bold focus:outline-none"
+ disabled={modelsLoading}
+ >
+ {availableModels.length === 0 && (
+ {DEFAULT_MODEL}
+ )}
+ {availableModels.map((m) => (
+
+ {m.display_name && m.display_name !== m.name
+ ? `${m.display_name} (${m.name})`
+ : m.name}
+
+ ))}
+
+
+
+
+
+ 随机创造性倾向 (Temp)
+ {agentConfigs[selectedAgentId]?.temp || 0.2}
+
+
{
+ const val = parseFloat(e.target.value);
+ setAgentConfigs(prev => ({
+ ...prev,
+ [selectedAgentId]: {
+ ...prev[selectedAgentId],
+ temp: val
+ }
+ }));
+ }}
+ className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-primary"
+ />
+
+
+
+
handleSaveAgentAndRecalculate(selectedAgentId)}
+ className="w-full bg-primary hover:bg-[var(--brand-900)] text-white text-[10.5px] font-black py-2 rounded-xl transition shadow active:scale-95 cursor-pointer flex items-center justify-center gap-1"
+ >
+
+ 参数覆盖并重启动算研讨
+
+
+
+
+ )}
+
+ {/* Conversation Log inside Step 2 */}
+
+
+ {step2RoundtableDialogues.map((msg) => (
+
+ {getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
+
+
+
+ {msg.sender}
+
+ {msg.confidence}
+
+
+
{msg.time}
+
+
+ {/* Pre-streaming hint — when the bubble is created but
+ no AI token has arrived yet, show a small spinner
+ + the current phase label so the user can see the
+ backend is doing something (调度中 → 已连接 →
+ 正在派活/调用工具 → 流式输出). */}
+ {msg.streaming && !msg.content && (
+
+
+ {msg.confidence}
+
+ )}
+
{children}
,
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children} ,
+ li: ({ children }) => {children} ,
+ h1: ({ children }) => {children} ,
+ h2: ({ children }) => {children} ,
+ h3: ({ children }) => {children} ,
+ h4: ({ children }) => {children} ,
+ strong: ({ children }) => {children} ,
+ em: ({ children }) => {children} ,
+ code: ({ children, className }) => {
+ const isBlock = /language-/.test(className ?? "");
+ if (isBlock) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ a: ({ children, href }) => (
+
+ {children}
+
+ ),
+ table: ({ children }) => (
+
+ ),
+ th: ({ children }) => (
+
+ {children}
+
+ ),
+ td: ({ children }) => (
+ {children}
+ ),
+ hr: () => ,
+ }}
+ >
+ {msg.content ?? ""}
+
+ {msg.streaming && (
+
+ )}
+
+
+
+ ))}
+
+ {!step2AtBottom &&
}
+
+
+ {/* Clarification card — shown only when the coordinator asked
+ the user a question via ask_clarification. The auto-loop is
+ paused; replying resumes orchestration on the same threads. */}
+ {pendingClarification && (
+
+
+
+
+
+
+ 总控协调需要澄清
+
+ {pendingClarification.clarificationType && (
+
+ {pendingClarification.clarificationType}
+
+ )}
+
+ {pendingClarification.context && (
+
+ {pendingClarification.context}
+
+ )}
+
+ {pendingClarification.question}
+
+ {Array.isArray(pendingClarification.options) &&
+ pendingClarification.options.length > 0 && (
+
+ {pendingClarification.options.map((opt, idx) => {
+ const label =
+ typeof opt === "string"
+ ? opt
+ : opt && typeof opt === "object" && "label" in (opt as object)
+ ? String((opt as { label: unknown }).label ?? "")
+ : String(opt ?? "");
+ if (!label) return null;
+ return (
+ submitClarification(label)}
+ className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
+ >
+ {label}
+
+ );
+ })}
+
+ )}
+ {pendingClarification.allowCustom !== false && (
+
+ setClarificationDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") submitClarification(clarificationDraft);
+ }}
+ placeholder="输入你的回答…"
+ className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
+ />
+ submitClarification(clarificationDraft)}
+ disabled={!clarificationDraft.trim()}
+ className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
+ >
+ 回复
+
+
+ )}
+
+
+
+ )}
+
+ {/* Console control / Custom injection bar */}
+
+
setCustomAgentInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleInjectToRoundtable();
+ }}
+ placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
+ className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
+ />
+
+
+ 干预研审
+
+
+
+
+
{
+ if (!hasConsensus) {
+ triggerToast("⚠️ 圆桌尚未自然收敛,请等待总控派活全部完成");
+ return;
+ }
+ setCurrentStep(3);
+ triggerToast("🎉 多角色智能体博弈共识达成,最终交付全套路线成果文档及方案图!");
+ }}
+ disabled={!hasConsensus}
+ aria-disabled={!hasConsensus}
+ title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
+ className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
+ hasConsensus
+ ? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
+ : "bg-emerald-600/40 text-white cursor-not-allowed"
+ }`}
+ >
+
+ 达成共识生成全案成果 >
+
+
+
+
+ )}
+
+ {/* STEP 3 SCREEN VIEW: COMPREHENSIVE OUTPUT DASHBOARD */}
+ {currentStep === 3 && (
+ d.confidence === "子智能体交付")
+ .map((d) => ({
+ sender: String(d.sender ?? ""),
+ avatarType: String(d.avatarType ?? "blue"),
+ content: String(d.content ?? ""),
+ }))}
+ seatNameMap={Object.fromEntries(
+ Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
+ )}
+ onReset={handleStartNewTask}
+ />
+ )}
+
+
+
+ {/* ========================================================
+ RIGHT COLUMN (Current Intent Summary, Timeline/Logs)
+ ======================================================== */}
+
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/styles/roundtable-planning.css b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css
new file mode 100644
index 00000000..f48040c2
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css
@@ -0,0 +1,217 @@
+@import "tailwindcss";
+
+/* 圆桌规划 — 浅色红色主题(对齐 docs/浅红色ui修改.md) */
+.roundtable-planning {
+ --rt-primary: var(--workspace-brand, #f5222d);
+ --rt-primary-hover: var(--brand-900, #cf1322);
+ --rt-primary-active: var(--brand-950, #a8071a);
+ --rt-primary-soft: var(--brand-200, #feeded);
+ --rt-page-bg: #fef6f6;
+}
+
+.roundtable-planning ::-webkit-scrollbar-thumb {
+ background: #fcb5b9;
+}
+
+.roundtable-planning ::-webkit-scrollbar-thumb:hover {
+ background: #fa9196;
+}
+
+.roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb {
+ background: #fddadc;
+}
+
+/* 深色模式:跟随全局 ThemeContext(html.dark)或组件 .roundtable-planning--dark */
+.dark .roundtable-planning,
+.roundtable-planning--dark {
+ --rt-page-bg: var(--background);
+ --rt-primary: var(--primary);
+ --rt-primary-hover: var(--primary);
+ --rt-primary-soft: var(--accent);
+ color: var(--foreground);
+ background-color: var(--background);
+}
+
+.dark .roundtable-planning ::-webkit-scrollbar-thumb,
+.roundtable-planning--dark ::-webkit-scrollbar-thumb {
+ background: color-mix(in oklch, var(--primary) 45%, var(--border));
+}
+
+.dark .roundtable-planning ::-webkit-scrollbar-thumb:hover,
+.roundtable-planning--dark ::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in oklch, var(--primary) 65%, var(--border));
+}
+
+.dark .roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb,
+.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
+ background: color-mix(in oklch, var(--primary) 35%, transparent);
+}
+
+/* 页面底与浅色专用背景 */
+.dark .roundtable-planning.bg-\[\#fef6f6\],
+.roundtable-planning--dark.bg-\[\#fef6f6\] {
+ background-color: var(--background) !important;
+}
+
+/* 表面:白底卡片 → card */
+.dark .roundtable-planning .bg-white,
+.roundtable-planning--dark .bg-white {
+ background-color: var(--card) !important;
+}
+
+.dark .roundtable-planning .bg-slate-50,
+.dark .roundtable-planning .bg-slate-50\/20,
+.dark .roundtable-planning .bg-slate-50\/30,
+.roundtable-planning--dark .bg-slate-50,
+.roundtable-planning--dark .bg-slate-50\/20,
+.roundtable-planning--dark .bg-slate-50\/30 {
+ background-color: color-mix(in oklch, var(--muted) 80%, transparent) !important;
+}
+
+.dark .roundtable-planning .from-slate-50,
+.roundtable-planning--dark .from-slate-50 {
+ --tw-gradient-from: var(--muted) !important;
+}
+
+.dark .roundtable-planning .to-white,
+.roundtable-planning--dark .to-white {
+ --tw-gradient-to: var(--card) !important;
+}
+
+/* 品牌浅红底 → accent */
+.dark .roundtable-planning [class*="brand-200"],
+.roundtable-planning--dark [class*="brand-200"] {
+ background-color: color-mix(in oklch, var(--accent) 55%, transparent) !important;
+}
+
+.dark .roundtable-planning .border-red-100,
+.dark .roundtable-planning .border-red-100\/50,
+.dark .roundtable-planning .border-red-200,
+.dark .roundtable-planning .border-red-200\/50,
+.dark .roundtable-planning .border-red-200\/85,
+.roundtable-planning--dark .border-red-100,
+.roundtable-planning--dark .border-red-100\/50,
+.roundtable-planning--dark .border-red-200,
+.roundtable-planning--dark .border-red-200\/50,
+.roundtable-planning--dark .border-red-200\/85 {
+ border-color: var(--border) !important;
+}
+
+.dark .roundtable-planning .border-slate-100,
+.dark .roundtable-planning .border-slate-200,
+.dark .roundtable-planning .border-slate-200\/50,
+.dark .roundtable-planning .border-\[\#e2e8f0\],
+.roundtable-planning--dark .border-slate-100,
+.roundtable-planning--dark .border-slate-200,
+.roundtable-planning--dark .border-slate-200\/50,
+.roundtable-planning--dark .border-\[\#e2e8f0\] {
+ border-color: var(--border) !important;
+}
+
+/* 文字 */
+.dark .roundtable-planning .text-slate-900,
+.dark .roundtable-planning .text-slate-800,
+.dark .roundtable-planning .text-slate-700,
+.dark .roundtable-planning .text-\[\#334155\],
+.roundtable-planning--dark .text-slate-900,
+.roundtable-planning--dark .text-slate-800,
+.roundtable-planning--dark .text-slate-700,
+.roundtable-planning--dark .text-\[\#334155\] {
+ color: var(--foreground) !important;
+}
+
+.dark .roundtable-planning .text-slate-600,
+.dark .roundtable-planning .text-slate-500,
+.dark .roundtable-planning .text-slate-400,
+.roundtable-planning--dark .text-slate-600,
+.roundtable-planning--dark .text-slate-500,
+.roundtable-planning--dark .text-slate-400 {
+ color: var(--muted-foreground) !important;
+}
+
+.dark .roundtable-planning .hover\:text-slate-800:hover,
+.roundtable-planning--dark .hover\:text-slate-800:hover {
+ color: var(--foreground) !important;
+}
+
+.dark .roundtable-planning .hover\:bg-slate-50:hover,
+.roundtable-planning--dark .hover\:bg-slate-50:hover {
+ background-color: var(--accent) !important;
+}
+
+/* 顶栏 */
+.dark .roundtable-planning > header,
+.roundtable-planning--dark > header {
+ background-color: var(--card) !important;
+ border-color: var(--border) !important;
+ box-shadow: none !important;
+}
+
+/* 步骤条容器 */
+.dark .roundtable-planning header .rounded-2xl.border,
+.roundtable-planning--dark header .rounded-2xl.border {
+ background-color: var(--muted) !important;
+ border-color: var(--border) !important;
+}
+
+/* 次要按钮(白底) */
+.dark .roundtable-planning header button.bg-white,
+.roundtable-planning--dark header button.bg-white {
+ background-color: var(--secondary) !important;
+ color: var(--foreground) !important;
+}
+
+/* 输入区 focus ring */
+.dark .roundtable-planning .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within,
+.roundtable-planning--dark .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within {
+ --tw-ring-color: color-mix(in oklch, var(--primary) 25%, transparent) !important;
+}
+
+/* 渐变横幅 */
+.dark .roundtable-planning .from-\[var\(--brand-200\)\],
+.roundtable-planning--dark .from-\[var\(--brand-200\)\] {
+ --tw-gradient-from: color-mix(in oklch, var(--accent) 40%, transparent) !important;
+}
+
+.dark .roundtable-planning .border-primary,
+.roundtable-planning--dark .border-2.border-primary {
+ border-color: color-mix(in oklch, var(--primary) 50%, var(--border)) !important;
+}
+
+/* 分隔线 */
+.dark .roundtable-planning .bg-slate-200,
+.roundtable-planning--dark .bg-slate-200 {
+ background-color: var(--border) !important;
+}
+
+/* Custom elegant scrollbar styling */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #cbd5e1;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: #e2e8f0;
+}
+
+.custom-scrollbar-inner::-webkit-scrollbar-thumb {
+ background: #fddadc;
+}
+
+.dark .custom-scrollbar-inner::-webkit-scrollbar-thumb,
+.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
+ background: color-mix(in oklch, var(--primary) 35%, var(--border));
+}
diff --git a/frontend-web/src/roundtable-planning/utils/drafts.ts b/frontend-web/src/roundtable-planning/utils/drafts.ts
new file mode 100644
index 00000000..ce0b97c2
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/utils/drafts.ts
@@ -0,0 +1,195 @@
+/**
+ * Local-only draft storage for /page/roundtable/planning.
+ *
+ * One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
+ * roundtable). Persisted in `localStorage` so refresh / browser-restart keeps
+ * it; cross-device sync is out of scope (would need a backend). The Step 2
+ * back-end LangGraph threads are reused on load, so list/preview/download of
+ * artifacts in Step 3 continue to work as long as the user hasn't deleted the
+ * threads server-side.
+ */
+
+import type { IntentSummary } from "@/roundtable-planning/api/intent";
+import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
+
+const STORAGE_KEY = "roundtable-planning:drafts:v1";
+
+/**
+ * Snapshot of every piece of UI state needed to resume a session. We capture
+ * shapes loosely (`unknown[]`) for the chat / role lists because their inline
+ * types in the page component are wide and would force a refactor we don't
+ * want as part of the persistence feature.
+ */
+export interface DraftStep1Snapshot {
+ intentThreadId: string | null;
+ intentReady: IntentSummary | null;
+ interactiveMessages: unknown[];
+ timelineEntries: unknown[];
+}
+
+export interface DraftStep2Snapshot {
+ threadIds: ThreadIdMap | null;
+ coordinatorName: string | null;
+ step2RoundtableDialogues: unknown[];
+ lastLeaderContent: string;
+ hasConsensus: boolean;
+ consensusPercentage: number;
+ budgetLimit: number;
+}
+
+export interface DraftRecord {
+ id: string;
+ /** Auto-derived task summary (objective → first user message → timestamp). */
+ name: string;
+ createdAt: string;
+ updatedAt: string;
+ /** Furthest step the user had reached when this snapshot was taken. */
+ furthestStep: 1 | 2 | 3;
+ step1: DraftStep1Snapshot;
+ step2: DraftStep2Snapshot;
+}
+
+function isBrowser(): boolean {
+ return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
+}
+
+function readAll(): DraftRecord[] {
+ if (!isBrowser()) return [];
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed as DraftRecord[];
+ } catch (err) {
+ console.warn("[drafts] failed to parse localStorage payload:", err);
+ return [];
+ }
+}
+
+function writeAll(records: DraftRecord[]): void {
+ if (!isBrowser()) return;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
+ } catch (err) {
+ // Quota errors are the realistic failure mode — surface but don't throw,
+ // since the user can keep working with their in-memory state.
+ console.warn("[drafts] failed to write localStorage payload:", err);
+ }
+}
+
+function newId(): string {
+ if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") {
+ return window.crypto.randomUUID();
+ }
+ return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+/** List drafts newest first. */
+export function listDrafts(): DraftRecord[] {
+ return readAll()
+ .slice()
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
+}
+
+export function getDraft(id: string): DraftRecord | null {
+ return readAll().find((d) => d.id === id) ?? null;
+}
+
+/**
+ * Create-or-update a draft. If `id` is given and exists, that record is
+ * overwritten (updatedAt refreshed). Otherwise a new record is inserted.
+ * Returns the (re-)saved record so callers can read back the new id.
+ */
+export function upsertDraft(
+ payload: Omit & { id?: string },
+): DraftRecord {
+ const records = readAll();
+ const now = new Date().toISOString();
+
+ if (payload.id) {
+ const idx = records.findIndex((d) => d.id === payload.id);
+ if (idx >= 0) {
+ const existing = records[idx];
+ const updated: DraftRecord = {
+ ...existing,
+ name: payload.name,
+ furthestStep: payload.furthestStep,
+ step1: payload.step1,
+ step2: payload.step2,
+ updatedAt: now,
+ };
+ records[idx] = updated;
+ writeAll(records);
+ return updated;
+ }
+ }
+
+ const created: DraftRecord = {
+ id: payload.id ?? newId(),
+ name: payload.name,
+ furthestStep: payload.furthestStep,
+ step1: payload.step1,
+ step2: payload.step2,
+ createdAt: now,
+ updatedAt: now,
+ };
+ records.push(created);
+ writeAll(records);
+ return created;
+}
+
+export function deleteDraft(id: string): void {
+ const records = readAll();
+ const next = records.filter((d) => d.id !== id);
+ if (next.length !== records.length) writeAll(next);
+}
+
+/**
+ * Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the
+ * updated record so callers can refresh local UI state.
+ */
+export function renameDraft(id: string, name: string): DraftRecord | null {
+ const trimmed = name.trim();
+ if (!trimmed) return null;
+ const records = readAll();
+ const idx = records.findIndex((d) => d.id === id);
+ if (idx < 0) return null;
+ const updated: DraftRecord = {
+ ...records[idx],
+ name: trimmed,
+ updatedAt: new Date().toISOString(),
+ };
+ records[idx] = updated;
+ writeAll(records);
+ return updated;
+}
+
+/**
+ * Derive a short task-summary name from whatever the user has supplied so far.
+ * Preference order: intent agent's structured objective → first user message
+ * in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so
+ * the dropdown rows stay scannable.
+ */
+export function deriveDraftName(args: {
+ intentReady?: IntentSummary | null;
+ interactiveMessages?: unknown[];
+}): string {
+ const MAX = 40;
+ const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
+
+ const objective = args.intentReady?.objective?.trim();
+ if (objective) return truncate(objective);
+
+ if (Array.isArray(args.interactiveMessages)) {
+ for (const m of args.interactiveMessages) {
+ if (!m || typeof m !== "object") continue;
+ const obj = m as Record;
+ if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
+ return truncate(obj.content.trim());
+ }
+ }
+ }
+
+ return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
+}
diff --git a/frontend-web/src/strategy-components/Header.tsx b/frontend-web/src/strategy-components/Header.tsx
index c446a297..c9ece655 100644
--- a/frontend-web/src/strategy-components/Header.tsx
+++ b/frontend-web/src/strategy-components/Header.tsx
@@ -1,12 +1,24 @@
-import React, { useState } from 'react';
-import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
-import { NotificationDialog } from '@/components/workspace/notification-dialog';
-import { useTheme } from '../contexts/ThemeContext';
-import { useUnreadCount } from '@/core/notifications';
+import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
+import { useState } from "react";
-const Header: React.FC = () => {
+import { NotificationDialog } from "@/components/workspace/notification-dialog";
+import { Button } from "@/components/ui/button";
+import { useUnreadCount } from "@/core/notifications";
+import { cn } from "@/lib/utils";
+
+import { useTheme } from "../contexts/ThemeContext";
+
+const iconButtonClass = (isDark: boolean) =>
+ cn(
+ "size-8 shrink-0 rounded-sm",
+ isDark
+ ? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
+ : "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
+ );
+
+const Header = () => {
const { theme, toggleTheme } = useTheme();
- const isDark = theme === 'dark';
+ const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
@@ -16,72 +28,110 @@ const Header: React.FC = () => {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
-
- {/* 左侧 - 搜索框 */}
-
+
+
+ {isDark ? (
+
+ ) : (
+
+ )}
+
- {/* 右侧 - 用户信息和通知 */}
-
- {/* 主题切换按钮 */}
-
- {isDark ? : }
-
+
setNotificationOpen(true)}
+ className={cn(iconButtonClass(isDark), "relative")}
+ title="通知"
+ >
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 99 ? "99+" : unreadCount}
+
+ )}
+
- {/* 通知 */}
-
setNotificationOpen(true)}
- className={`relative p-2 rounded-lg transition-colors ${
- isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
- }`}
- title="通知"
- >
-
- {unreadCount > 0 && (
-
+
+
+
- {unreadCount > 99 ? '99+' : unreadCount}
-
- )}
-
-
- {/* 分隔线 */}
-
-
- {/* 用户信息 */}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
>
);
};
diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css
index e21e851a..3e08cac3 100644
--- a/frontend-web/src/styles/globals.css
+++ b/frontend-web/src/styles/globals.css
@@ -1,813 +1,821 @@
-@import "tailwindcss";
-@import "tw-animate-css";
-
-@source "../node_modules/streamdown/dist/index.js";
-
-/* Heading */
-@source inline("text-{xs,sm,base,lg,xl,2xl,3xl,4xl,5xl,6xl}");
-@source inline("font-{normal,medium,semibold,bold,extrabold}");
-@source inline("leading-{none,tight,snug,normal,relaxed,loose}");
-
-/* Spacing */
-@source inline("m{t,b,l,r,x,y}-{0,1,2,3,4,5,6,8,10,12,16,20,24}");
-@source inline("p{t,b,l,r,x,y}-{0,1,2,3,4,5,6,8,10,12,16,20,24}");
-@source inline("space-{x,y}-{1,2,3,4,5,6,8}");
-
-/* List */
-@source inline("list-{disc,decimal,none,inside,outside}");
-
-/* Text */
-@source inline("text-{left,center,right,justify}");
-@source inline("text-{slate,gray,zinc,neutral,stone}-{50,100,200,300,400,500,600,700,800,900,950}");
-@source inline("italic");
-@source inline("underline");
-@source inline("line-through");
-
-/* Code */
-@source inline("font-mono");
-@source inline("bg-{slate,gray,zinc,muted}-{50,100,200}");
-@source inline("rounded{,-sm,-md,-lg,-xl}");
-@source inline("border{,-2,-4}");
-@source inline("border-{slate,gray,zinc,border}-{200,300}");
-
-/* Blockquote */
-@source inline("border-l-{2,4}");
-@source inline("border-l-{slate,gray,primary}-{300,400,500}");
-
-/* Link */
-@source inline("text-{blue,primary,accent}-{500,600,700}");
-@source inline("hover:text-{blue,primary,accent}-{600,700,800}");
-@source inline("hover:underline");
-
-/* Table */
-@source inline("border-collapse");
-@source inline("table-auto");
-@source inline("w-full");
-@source inline("divide-{x,y}");
-@source inline("divide-{slate,gray,border}-{200,300}");
-
-/* Image */
-@source inline("max-w-{xs,sm,md,lg,xl,2xl,full}");
-@source inline("h-auto");
-@source inline("object-cover");
-
-/* Horizontal Rule */
-@source inline("border-t");
-@source inline("border-{slate,gray,border}-{200,300}");
-
-/* General */
-@source inline("block");
-@source inline("inline");
-@source inline("inline-block");
-@source inline("break-words");
-@source inline("overflow-{auto,hidden,x-auto}");
-@source inline("whitespace-pre-wrap");
-
-/* Shadcn Colors */
-@source inline("text-{foreground,muted-foreground,primary,secondary,accent}");
-@source inline("bg-{background,muted,primary,secondary,accent}");
-@source inline("border-{border,input}");
-
-@custom-variant dark (&:is(.dark *));
-
-@theme {
- --font-sans:
- ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
- "Segoe UI Symbol", "Noto Color Emoji";
-
- --animate-fade-in: fade-in 1.1s;
- @keyframes fade-in {
- 0% {
- opacity: 0;
- }
- 100% {
- opacity: 1;
- }
- }
-
- --animate-fade-in-up: fade-in-up 0.15s ease-in-out forwards;
- @keyframes fade-in-up {
- 0% {
- opacity: 0;
- transform: translateY(1rem) scale(1.2);
- }
- 100% {
- opacity: 1;
- }
- }
-
- --animate-bouncing: bouncing 0.5s infinite alternate;
- @keyframes bouncing {
- to {
- opacity: 0.1;
- transform: translateY(-8px);
- }
- }
-
- --animate-skeleton-entrance: skeleton-entrance 0.35s ease-out forwards;
- @keyframes skeleton-entrance {
- 0% {
- opacity: 0;
- transform: scaleX(0);
- }
- 100% {
- opacity: 1;
- transform: scaleX(1);
- }
- }
-
- --animate-suggestion-in: suggestion-in 0.2s ease-out forwards;
- @keyframes suggestion-in {
- 0% {
- opacity: 0;
- transform: translateY(-1.25rem);
- }
- 100% {
- opacity: 1;
- transform: translateY(0);
- }
- }
-
- --animate-wave: wave 0.6s ease-in-out 2;
- @keyframes wave {
- 0%,
- 100% {
- transform: rotate(0deg);
- }
- 25% {
- transform: rotate(20deg);
- }
- 50% {
- transform: rotate(0deg);
- }
- 75% {
- transform: rotate(20deg);
- }
- }
-
- --animate-breathe-stop: breathe-stop 1.8s ease-in-out infinite;
- @keyframes breathe-stop {
- 0%,
- 100% {
- transform: scale(1);
- box-shadow:
- 0 0 0 0 rgba(217, 33, 33, 0.14),
- 0 8px 24px -14px rgba(217, 33, 33, 0.35);
- }
- 50% {
- transform: scale(1.06);
- box-shadow:
- 0 0 0 10px rgba(217, 33, 33, 0),
- 0 14px 32px -16px rgba(217, 33, 33, 0.45);
- }
- }
-}
-
-@theme inline {
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
- --radius-2xl: calc(var(--radius) + 8px);
- --radius-3xl: calc(var(--radius) + 12px);
- --radius-4xl: calc(var(--radius) + 16px);
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
- --color-destructive: var(--destructive);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
- --color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
- --animate-aurora: aurora 8s ease-in-out infinite alternate;
- @keyframes aurora {
- 0% {
- background-position: 0% 50%;
- transform: rotate(-5deg) scale(0.9);
- }
- 25% {
- background-position: 50% 100%;
- transform: rotate(5deg) scale(1.1);
- }
- 50% {
- background-position: 100% 50%;
- transform: rotate(-3deg) scale(0.95);
- }
- 75% {
- background-position: 50% 0%;
- transform: rotate(3deg) scale(1.05);
- }
- 100% {
- background-position: 0% 50%;
- transform: rotate(-5deg) scale(0.9);
- }
- }
- --animate-shine: shine var(--duration) infinite linear;
- @keyframes shine {
- 0% {
- background-position: 0% 0%;
- }
- 50% {
- background-position: 100% 100%;
- }
- to {
- background-position: 0% 0%;
- }
- }
-}
-
-:root {
- --workspace-brand: #d92121;
- --workspace-brand-foreground: #ffffff;
- --radius: 0.75rem;
- --background: oklch(1 0 0);
- --foreground: oklch(0.22 0.02 25);
- --card: oklch(1 0.002 95);
- --card-foreground: oklch(0.22 0.02 25);
- --popover: oklch(1 0.002 95);
- --popover-foreground: oklch(0.22 0.02 25);
- --primary: oklch(0.52 0.19 27);
- --primary-foreground: oklch(0.99 0 0);
- --secondary: oklch(0.955 0.008 95);
- --secondary-foreground: oklch(0.32 0.04 25);
- --muted: oklch(0.965 0.006 95);
- --muted-foreground: oklch(0.48 0.02 25);
- --accent: oklch(0.96 0.04 25);
- --accent-foreground: oklch(0.35 0.12 25);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.9 0.008 95);
- --input: oklch(0.9 0.01 95);
- --ring: oklch(0.52 0.19 27 / 0.35);
- --chart-1: oklch(0.646 0.222 41.116);
- --chart-2: oklch(0.6 0.118 184.704);
- --chart-3: oklch(0.398 0.07 227.392);
- --chart-4: oklch(0.828 0.189 84.429);
- --chart-5: oklch(0.769 0.188 70.08);
- --sidebar: oklch(0.995 0.003 95);
- --sidebar-foreground: oklch(0.28 0.02 25);
- --sidebar-primary: oklch(0.52 0.19 27);
- --sidebar-primary-foreground: oklch(0.99 0 0);
- --sidebar-accent: oklch(0.978 0.018 70);
- --sidebar-accent-foreground: oklch(0.32 0.05 32);
- --sidebar-border: oklch(0.92 0.01 85);
- --sidebar-ring: oklch(0.52 0.19 27 / 0.4);
-}
-
-.dark {
- --workspace-brand: #3b82f6;
- --workspace-brand-foreground: #ffffff;
- --background: oklch(0.208 0.042 264);
- --foreground: oklch(0.96 0.005 240);
- --card: oklch(0.265 0.04 260);
- --card-foreground: oklch(0.96 0.005 240);
- --popover: oklch(0.245 0.04 262);
- --popover-foreground: oklch(0.96 0.005 240);
- --primary: oklch(0.623 0.214 259);
- --primary-foreground: oklch(0.99 0 0);
- --secondary: oklch(0.32 0.038 260);
- --secondary-foreground: oklch(0.96 0.005 240);
- --muted: oklch(0.30 0.035 260);
- --muted-foreground: oklch(0.68 0.02 240);
- --accent: oklch(0.35 0.06 260);
- --accent-foreground: oklch(0.96 0.005 240);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 12%);
- --input: oklch(1 0 0 / 14%);
- --ring: oklch(0.623 0.214 259 / 0.45);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
- --sidebar: oklch(0.185 0.04 265);
- --sidebar-foreground: oklch(0.94 0.005 240);
- --sidebar-primary: oklch(0.623 0.214 259);
- --sidebar-primary-foreground: oklch(0.99 0 0);
- --sidebar-accent: oklch(0.30 0.055 260);
- --sidebar-accent-foreground: oklch(0.96 0.005 240);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.623 0.214 259 / 0.45);
- font-weight: 300;
-}
-
-@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
- }
-
- [data-sidebar="menu-button"][data-active="true"] {
- box-shadow: inset 3px 0 0 var(--primary);
- font-weight: 600;
- }
-
- [data-sidebar="menu-button"][data-active="true"] svg {
- color: var(--primary);
- }
-
- .container-md {
- width: 100%;
- @media (width >= 40rem) {
- max-width: 40rem;
- }
- @media (width >= 48rem) {
- max-width: 48rem;
- }
- @media (width >= 64rem) {
- max-width: 64rem;
- }
- @media (width >= 80rem) {
- max-width: 80rem;
- }
- }
-
- .ambilight {
- pointer-events: none;
- opacity: 0;
- transition: opacity 1s ease-in-out;
- }
-
- .ambilight:before,
- .ambilight:after {
- content: "";
- pointer-events: none;
- position: absolute;
- left: 0;
- top: 0;
- background: linear-gradient(
- 45deg,
- #fb0094,
- #0000ff,
- #00ff00,
- #ffff00,
- #ff0000,
- #fb0094,
- #0000ff,
- #00ff00,
- #ffff00,
- #ff0000
- );
- background-size: 400%;
- width: 100%;
- height: 100%;
- border-radius: 10px;
- z-index: -1;
- animation: ambilight 40s ease-in-out infinite;
- }
-
- .ambilight.enabled {
- opacity: 1;
- }
-
- .dark .ambilight:before,
- .dark .ambilight:after {
- opacity: 0.85;
- }
-
- @keyframes ambilight {
- 0% {
- background-position: 0 0;
- }
- 50% {
- background-position: 400% 0;
- }
- 100% {
- background-position: 0 0;
- }
- }
-
- .ambilight:after {
- filter: blur(60px);
- }
-
- .golden-text {
- background: linear-gradient(135deg, #d19e1d 0%, #e9c665 50%, #e3a812 100%);
- -webkit-background-clip: text;
- background-clip: text;
- -webkit-text-fill-color: transparent;
- text-fill-color: transparent;
- }
-
- *::-webkit-scrollbar {
- width: 10px;
- height: 10px;
- }
-
- *::-webkit-scrollbar-track {
- background: transparent;
- }
-
- *::-webkit-scrollbar-thumb {
- border: 2px solid transparent;
- border-radius: 999px;
- background: linear-gradient(180deg, rgb(241 89 89 / 0.82), rgb(214 36 36 / 0.9))
- padding-box;
- }
-
- .workspace-shell {
- background: #ffffff;
- }
-
- .dark .workspace-shell {
- background: var(--background);
- }
-
- .workspace-panel {
- border: 1px solid #f0d7d0;
- background: #ffffff;
- box-shadow:
- 0 32px 72px -46px rgb(98 63 42 / 0.35),
- 0 12px 28px -22px rgb(170 91 75 / 0.24);
- }
-
- .dark .workspace-panel {
- border: 1px solid oklch(1 0 0 / 10%);
- background: var(--card);
- box-shadow: none;
- }
-
- .workspace-user-bubble {
- max-width: min(82%, 42rem);
- border: 1px solid rgb(255 255 255 / 0.14);
- border-radius: 1.6rem 1.6rem 0.5rem 1.6rem;
- background: linear-gradient(135deg, #ff5b67 0%, #ef334d 58%, #df213e 100%);
- color: white;
- box-shadow:
- 0 22px 40px -28px rgb(215 39 55 / 0.72),
- 0 10px 20px -16px rgb(155 18 34 / 0.48);
- }
-
- .workspace-user-bubble a,
- .workspace-user-bubble code {
- color: inherit;
- }
-
- .workspace-user-bubble code {
- background: rgb(255 255 255 / 0.18);
- border-radius: 0.5rem;
- padding: 0.1rem 0.35rem;
- }
-
- .workspace-assistant-card {
- position: relative;
- width: 100%;
- border: 1px solid #f1d9d3;
- border-radius: 1.75rem;
- background: rgb(255 255 255 / 0.94);
- box-shadow:
- 0 28px 48px -42px rgb(120 74 55 / 0.38),
- 0 6px 18px -14px rgb(165 93 73 / 0.2);
- }
-
- .workspace-assistant-card::before {
- content: "";
- position: absolute;
- top: 1rem;
- left: 1rem;
- width: 0.45rem;
- height: calc(100% - 2rem);
- border-radius: 999px;
- background: linear-gradient(180deg, rgb(255 110 110 / 0.92), rgb(233 45 45 / 0.15));
- opacity: 0.88;
- }
-
- .workspace-assistant-card > * {
- position: relative;
- z-index: 1;
- }
-
- .workspace-assistant-card .prose,
- .workspace-assistant-card [data-state] {
- width: 100%;
- }
-
- .workspace-assistant-card .my-3 {
- margin-block: 0.25rem;
- }
-
- .workspace-chat-toolbar {
- border: 1px solid rgb(255 255 255 / 0.22);
- background: rgb(255 255 255 / 0.14);
- color: white;
- backdrop-filter: blur(18px);
- }
-
- @keyframes sandbox-preview-enter {
- 0% {
- opacity: 0;
- transform: translateY(14px) scale(0.985);
- }
- 100% {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
- }
-
- @keyframes sandbox-preview-glow {
- 0%,
- 100% {
- box-shadow:
- 0 0 0 0 rgb(227 39 39 / 0),
- 0 8px 20px -16px rgb(227 39 39 / 0.1);
- }
- 50% {
- box-shadow:
- 0 0 0 8px rgb(227 39 39 / 0.08),
- 0 18px 34px -18px rgb(227 39 39 / 0.3);
- }
- }
-
- @keyframes sandbox-preview-badge {
- 0%,
- 100% {
- transform: translateY(0);
- opacity: 0.88;
- }
- 50% {
- transform: translateY(-1px);
- opacity: 1;
- }
- }
-
- @keyframes sandbox-scroll-button-in {
- 0% {
- opacity: 0;
- transform: translateY(-45%) scale(0.96);
- }
- 100% {
- opacity: 1;
- transform: translateY(-50%) scale(1);
- }
- }
-}
-
-/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
-.tiptap-writing-editor .ProseMirror {
- outline: none;
- min-height: 320px;
- caret-color: var(--primary);
- padding-bottom: 6rem; /* ensure last paragraph isn't flush against the bottom */
-}
-
-.tiptap-writing-editor .ProseMirror p {
- margin-bottom: 1rem;
- line-height: 1.8;
- font-size: 0.9375rem; /* 15px */
- color: var(--foreground);
- text-indent: 2em; /* 段落首行缩进 */
-}
-
-.tiptap-writing-editor .ProseMirror h1 {
- font-size: 1.625rem;
- font-weight: 700;
- letter-spacing: -0.025em;
- margin-top: 2rem;
- margin-bottom: 0.75rem;
- padding-bottom: 0.5rem;
- border-bottom: 1px solid var(--border);
- color: var(--foreground);
- line-height: 1.25;
-}
-
-.tiptap-writing-editor .ProseMirror h1:first-child {
- margin-top: 0;
-}
-
-.tiptap-writing-editor .ProseMirror h2 {
- font-size: 1.25rem;
- font-weight: 600;
- letter-spacing: -0.015em;
- margin-top: 1.75rem;
- margin-bottom: 0.625rem;
- color: var(--foreground);
- line-height: 1.35;
-}
-
-.tiptap-writing-editor .ProseMirror h3 {
- font-size: 1.0625rem;
- font-weight: 600;
- letter-spacing: -0.01em;
- margin-top: 1.25rem;
- margin-bottom: 0.5rem;
- color: var(--foreground);
- line-height: 1.4;
-}
-
-.tiptap-writing-editor .ProseMirror h2:first-child,
-.tiptap-writing-editor .ProseMirror h3:first-child {
- margin-top: 0;
-}
-
-.tiptap-writing-editor .ProseMirror strong {
- font-weight: 600;
-}
-
-.tiptap-writing-editor .ProseMirror em {
- font-style: italic;
-}
-
-.tiptap-writing-editor .ProseMirror s {
- text-decoration: line-through;
- opacity: 0.6;
-}
-
-.tiptap-writing-editor .ProseMirror ul {
- list-style-type: disc;
- margin-left: 1.5rem;
- margin-bottom: 1rem;
- font-size: 0.9375rem;
- color: var(--foreground);
-}
-
-.tiptap-writing-editor .ProseMirror ol {
- list-style-type: decimal;
- margin-left: 1.5rem;
- margin-bottom: 1rem;
- font-size: 0.9375rem;
- color: var(--foreground);
-}
-
-.tiptap-writing-editor .ProseMirror li {
- line-height: 1.8;
- margin-bottom: 0.25rem;
-}
-
-.tiptap-writing-editor .ProseMirror li > p {
- margin-bottom: 0.25rem;
- text-indent: 0; /* 列表项内段落不缩进 */
-}
-
-.tiptap-writing-editor .ProseMirror blockquote {
- border-left: 3px solid var(--border);
- padding-left: 1rem;
- margin: 1.25rem 0;
- font-style: italic;
- font-size: 0.9375rem;
- color: var(--muted-foreground);
-}
-
-.tiptap-writing-editor .ProseMirror pre {
- background: var(--muted);
- border-radius: 0.5rem;
- padding: 1rem 1.25rem;
- margin: 1rem 0 1.25rem;
- overflow-x: auto;
- border: 1px solid var(--border);
-}
-
-.tiptap-writing-editor .ProseMirror pre code {
- background: transparent;
- padding: 0;
- font-size: 0.8125rem; /* 13px */
- font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
- color: var(--foreground);
- line-height: 1.7;
-}
-
-.tiptap-writing-editor .ProseMirror code {
- background: var(--muted);
- padding: 0.15rem 0.4rem;
- border-radius: 0.25rem;
- font-size: 0.8125rem;
- font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
- color: var(--foreground);
- border: 1px solid var(--border);
-}
-
-.tiptap-writing-editor .ProseMirror hr {
- border: none;
- border-top: 1px solid var(--border);
- margin: 2rem 0;
-}
-
-.tiptap-writing-editor .ProseMirror a {
- color: var(--primary);
- text-decoration: underline;
- text-underline-offset: 3px;
- text-decoration-thickness: 1px;
- transition: opacity 0.15s;
-}
-
-.tiptap-writing-editor .ProseMirror a:hover {
- opacity: 0.75;
-}
-
-/* Placeholder */
-.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child {
- text-indent: 0; /* 占位文案不缩进 */
-}
-
-.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child::before {
- content: attr(data-placeholder);
- color: var(--muted-foreground);
- pointer-events: none;
- float: left;
- height: 0;
- opacity: 0.6;
-}
-
-/* Selection highlight — use color-mix to avoid the background+opacity mismatch */
-.tiptap-writing-editor .ProseMirror ::selection {
- background: color-mix(in srgb, #f87171 35%, transparent);
-}
-
-/* AI rewrite — highlight the selected range while the menu / preview is open */
-.ai-rewrite-highlight {
- background: color-mix(in srgb, #fbbf24 30%, transparent);
- border-radius: 2px;
- transition: background 0.2s ease;
-}
-.dark .ai-rewrite-highlight {
- background: color-mix(in srgb, #fbbf24 18%, transparent);
-}
-
-:root {
- --container-width-xs: calc(var(--spacing) * 72);
- --container-width-sm: calc(var(--spacing) * 144);
- --container-width-md: calc(var(--spacing) * 204);
- --container-width-lg: calc(var(--spacing) * 256);
-}
-
-/* ── Landing 态输入框 Gemini 风格:旋转彩色描边 + 多色柔光阴影 ───────── */
-/* 关键:::before 转动的彩色锥形渐变 + ::after 内嵌白板遮罩中央,只在 1.5px 边缘可见模糊彩光 */
-.gemini-shadow {
- box-shadow:
- 0 0 20px rgba(66, 133, 244, 0.10),
- 0 0 40px rgba(219, 68, 55, 0.05),
- 0 0 60px rgba(244, 180, 0, 0.05),
- 0 0 80px rgba(15, 157, 88, 0.05);
-}
-.dark .gemini-shadow {
- box-shadow:
- 0 0 24px rgba(66, 133, 244, 0.20),
- 0 0 48px rgba(219, 68, 55, 0.10),
- 0 0 72px rgba(244, 180, 0, 0.10),
- 0 0 96px rgba(15, 157, 88, 0.10);
-}
-
-.gemini-gradient-border {
- position: relative;
- background: var(--background);
- border-radius: 1.5rem;
- z-index: 0;
- overflow: hidden;
-}
-.gemini-gradient-border::before {
- content: '';
- position: absolute;
- width: 200%;
- height: 200%;
- top: -50%;
- left: -50%;
- background: conic-gradient(
- from 0deg,
- #4285F4 0deg,
- #DB4437 90deg,
- #F4B400 180deg,
- #0F9D58 270deg,
- #4285F4 360deg
- );
- z-index: -2;
- animation: gemini-rotate 4s linear infinite;
- filter: blur(10px);
- pointer-events: none;
-}
-.gemini-gradient-border::after {
- content: '';
- position: absolute;
- inset: 2.5px;
- background: var(--background);
- border-radius: calc(1.5rem - 2px);
- z-index: -1;
-}
-
-@keyframes gemini-rotate {
- to {
- transform: rotate(360deg);
- }
-}
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../node_modules/streamdown/dist/index.js";
+
+/* Heading */
+@source inline("text-{xs,sm,base,lg,xl,2xl,3xl,4xl,5xl,6xl}");
+@source inline("font-{normal,medium,semibold,bold,extrabold}");
+@source inline("leading-{none,tight,snug,normal,relaxed,loose}");
+
+/* Spacing */
+@source inline("m{t,b,l,r,x,y}-{0,1,2,3,4,5,6,8,10,12,16,20,24}");
+@source inline("p{t,b,l,r,x,y}-{0,1,2,3,4,5,6,8,10,12,16,20,24}");
+@source inline("space-{x,y}-{1,2,3,4,5,6,8}");
+
+/* List */
+@source inline("list-{disc,decimal,none,inside,outside}");
+
+/* Text */
+@source inline("text-{left,center,right,justify}");
+@source inline("text-{slate,gray,zinc,neutral,stone}-{50,100,200,300,400,500,600,700,800,900,950}");
+@source inline("italic");
+@source inline("underline");
+@source inline("line-through");
+
+/* Code */
+@source inline("font-mono");
+@source inline("bg-{slate,gray,zinc,muted}-{50,100,200}");
+@source inline("rounded{,-sm,-md,-lg,-xl}");
+@source inline("border{,-2,-4}");
+@source inline("border-{slate,gray,zinc,border}-{200,300}");
+
+/* Blockquote */
+@source inline("border-l-{2,4}");
+@source inline("border-l-{slate,gray,primary}-{300,400,500}");
+
+/* Link */
+@source inline("text-{blue,primary,accent}-{500,600,700}");
+@source inline("hover:text-{blue,primary,accent}-{600,700,800}");
+@source inline("hover:underline");
+
+/* Table */
+@source inline("border-collapse");
+@source inline("table-auto");
+@source inline("w-full");
+@source inline("divide-{x,y}");
+@source inline("divide-{slate,gray,border}-{200,300}");
+
+/* Image */
+@source inline("max-w-{xs,sm,md,lg,xl,2xl,full}");
+@source inline("h-auto");
+@source inline("object-cover");
+
+/* Horizontal Rule */
+@source inline("border-t");
+@source inline("border-{slate,gray,border}-{200,300}");
+
+/* General */
+@source inline("block");
+@source inline("inline");
+@source inline("inline-block");
+@source inline("break-words");
+@source inline("overflow-{auto,hidden,x-auto}");
+@source inline("whitespace-pre-wrap");
+
+/* Shadcn Colors */
+@source inline("text-{foreground,muted-foreground,primary,secondary,accent}");
+@source inline("bg-{background,muted,primary,secondary,accent}");
+@source inline("border-{border,input}");
+
+@custom-variant dark (&:is(.dark *));
+
+@theme {
+ --font-sans:
+ ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
+
+ --animate-fade-in: fade-in 1.1s;
+ @keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ --animate-fade-in-up: fade-in-up 0.15s ease-in-out forwards;
+ @keyframes fade-in-up {
+ 0% {
+ opacity: 0;
+ transform: translateY(1rem) scale(1.2);
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ --animate-bouncing: bouncing 0.5s infinite alternate;
+ @keyframes bouncing {
+ to {
+ opacity: 0.1;
+ transform: translateY(-8px);
+ }
+ }
+
+ --animate-skeleton-entrance: skeleton-entrance 0.35s ease-out forwards;
+ @keyframes skeleton-entrance {
+ 0% {
+ opacity: 0;
+ transform: scaleX(0);
+ }
+ 100% {
+ opacity: 1;
+ transform: scaleX(1);
+ }
+ }
+
+ --animate-suggestion-in: suggestion-in 0.2s ease-out forwards;
+ @keyframes suggestion-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(-1.25rem);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ --animate-wave: wave 0.6s ease-in-out 2;
+ @keyframes wave {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(20deg);
+ }
+ 50% {
+ transform: rotate(0deg);
+ }
+ 75% {
+ transform: rotate(20deg);
+ }
+ }
+
+ --animate-breathe-stop: breathe-stop 1.8s ease-in-out infinite;
+ @keyframes breathe-stop {
+ 0%,
+ 100% {
+ transform: scale(1);
+ box-shadow:
+ 0 0 0 0 rgba(217, 33, 33, 0.14),
+ 0 8px 24px -14px rgba(217, 33, 33, 0.35);
+ }
+ 50% {
+ transform: scale(1.06);
+ box-shadow:
+ 0 0 0 10px rgba(217, 33, 33, 0),
+ 0 14px 32px -16px rgba(217, 33, 33, 0.45);
+ }
+ }
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --animate-aurora: aurora 8s ease-in-out infinite alternate;
+ @keyframes aurora {
+ 0% {
+ background-position: 0% 50%;
+ transform: rotate(-5deg) scale(0.9);
+ }
+ 25% {
+ background-position: 50% 100%;
+ transform: rotate(5deg) scale(1.1);
+ }
+ 50% {
+ background-position: 100% 50%;
+ transform: rotate(-3deg) scale(0.95);
+ }
+ 75% {
+ background-position: 50% 0%;
+ transform: rotate(3deg) scale(1.05);
+ }
+ 100% {
+ background-position: 0% 50%;
+ transform: rotate(-5deg) scale(0.9);
+ }
+ }
+ --animate-shine: shine var(--duration) infinite linear;
+ @keyframes shine {
+ 0% {
+ background-position: 0% 0%;
+ }
+ 50% {
+ background-position: 100% 100%;
+ }
+ to {
+ background-position: 0% 0%;
+ }
+ }
+}
+
+:root {
+ --workspace-brand: #f5222d;
+ --workspace-brand-foreground: #ffffff;
+ --brand-200: #feeded;
+ --brand-900: #cf1322;
+ --brand-950: #a8071a;
+ --radius: 0.75rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.22 0.02 25);
+ --card: oklch(1 0.002 95);
+ --card-foreground: oklch(0.22 0.02 25);
+ --popover: oklch(1 0.002 95);
+ --popover-foreground: oklch(0.22 0.02 25);
+ --primary: oklch(0.52 0.19 27);
+ --primary-foreground: oklch(0.99 0 0);
+ --secondary: oklch(0.955 0.008 95);
+ --secondary-foreground: oklch(0.32 0.04 25);
+ --muted: oklch(0.965 0.006 95);
+ --muted-foreground: oklch(0.48 0.02 25);
+ --accent: oklch(0.96 0.04 25);
+ --accent-foreground: oklch(0.35 0.12 25);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.9 0.008 95);
+ --input: oklch(0.9 0.01 95);
+ --ring: oklch(0.52 0.19 27 / 0.35);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.995 0.003 95);
+ --sidebar-foreground: oklch(0.28 0.02 25);
+ --sidebar-primary: oklch(0.52 0.19 27);
+ --sidebar-primary-foreground: oklch(0.99 0 0);
+ --sidebar-accent: oklch(0.978 0.018 70);
+ --sidebar-accent-foreground: oklch(0.32 0.05 32);
+ --sidebar-border: oklch(0.92 0.01 85);
+ --sidebar-ring: oklch(0.52 0.19 27 / 0.4);
+}
+
+.dark {
+ --workspace-brand: #3b82f6;
+ --workspace-brand-foreground: #ffffff;
+ --background: oklch(0.208 0.042 264);
+ --foreground: oklch(0.96 0.005 240);
+ --card: oklch(0.265 0.04 260);
+ --card-foreground: oklch(0.96 0.005 240);
+ --popover: oklch(0.245 0.04 262);
+ --popover-foreground: oklch(0.96 0.005 240);
+ --primary: oklch(0.623 0.214 259);
+ --primary-foreground: oklch(0.99 0 0);
+ --secondary: oklch(0.32 0.038 260);
+ --secondary-foreground: oklch(0.96 0.005 240);
+ --muted: oklch(0.30 0.035 260);
+ --muted-foreground: oklch(0.68 0.02 240);
+ --accent: oklch(0.35 0.06 260);
+ --accent-foreground: oklch(0.96 0.005 240);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 12%);
+ --input: oklch(1 0 0 / 14%);
+ --ring: oklch(0.623 0.214 259 / 0.45);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.185 0.04 265);
+ --sidebar-foreground: oklch(0.94 0.005 240);
+ --sidebar-primary: oklch(0.623 0.214 259);
+ --sidebar-primary-foreground: oklch(0.99 0 0);
+ --sidebar-accent: oklch(0.30 0.055 260);
+ --sidebar-accent-foreground: oklch(0.96 0.005 240);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.623 0.214 259 / 0.45);
+ font-weight: 300;
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+
+ [data-sidebar="menu-button"][data-active="true"] {
+ box-shadow: inset 3px 0 0 var(--primary);
+ font-weight: 600;
+ }
+
+ [data-sidebar="menu-button"][data-active="true"] svg {
+ color: var(--primary);
+ }
+
+ .container-md {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ }
+
+ .ambilight {
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 1s ease-in-out;
+ }
+
+ .ambilight:before,
+ .ambilight:after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ left: 0;
+ top: 0;
+ background: linear-gradient(
+ 45deg,
+ #fb0094,
+ #0000ff,
+ #00ff00,
+ #ffff00,
+ #ff0000,
+ #fb0094,
+ #0000ff,
+ #00ff00,
+ #ffff00,
+ #ff0000
+ );
+ background-size: 400%;
+ width: 100%;
+ height: 100%;
+ border-radius: 10px;
+ z-index: -1;
+ animation: ambilight 40s ease-in-out infinite;
+ }
+
+ .ambilight.enabled {
+ opacity: 1;
+ }
+
+ .dark .ambilight:before,
+ .dark .ambilight:after {
+ opacity: 0.85;
+ }
+
+ @keyframes ambilight {
+ 0% {
+ background-position: 0 0;
+ }
+ 50% {
+ background-position: 400% 0;
+ }
+ 100% {
+ background-position: 0 0;
+ }
+ }
+
+ .ambilight:after {
+ filter: blur(60px);
+ }
+
+ .golden-text {
+ background: linear-gradient(135deg, #d19e1d 0%, #e9c665 50%, #e3a812 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-fill-color: transparent;
+ }
+
+ *::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+
+ *::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ *::-webkit-scrollbar-thumb {
+ border: 2px solid transparent;
+ border-radius: 999px;
+ background: linear-gradient(180deg, rgb(241 89 89 / 0.82), rgb(214 36 36 / 0.9))
+ padding-box;
+ }
+
+ .workspace-shell {
+ background: #ffffff;
+ }
+
+ .dark .workspace-shell {
+ background: var(--background);
+ }
+
+ .workspace-panel {
+ border: 1px solid #f0d7d0;
+ background: #ffffff;
+ box-shadow:
+ 0 32px 72px -46px rgb(98 63 42 / 0.35),
+ 0 12px 28px -22px rgb(170 91 75 / 0.24);
+ }
+
+ .dark .workspace-panel {
+ border: 1px solid oklch(1 0 0 / 10%);
+ background: var(--card);
+ box-shadow: none;
+ }
+
+ .workspace-user-bubble {
+ max-width: min(82%, 42rem);
+ border: 1px solid rgb(255 255 255 / 0.14);
+ border-radius: 1.6rem 1.6rem 0.5rem 1.6rem;
+ background: linear-gradient(135deg, #ff5b67 0%, #ef334d 58%, #df213e 100%);
+ color: white;
+ box-shadow:
+ 0 22px 40px -28px rgb(215 39 55 / 0.72),
+ 0 10px 20px -16px rgb(155 18 34 / 0.48);
+ }
+
+ .workspace-user-bubble a,
+ .workspace-user-bubble code {
+ color: inherit;
+ }
+
+ .workspace-user-bubble code {
+ background: rgb(255 255 255 / 0.18);
+ border-radius: 0.5rem;
+ padding: 0.1rem 0.35rem;
+ }
+
+ .workspace-assistant-card {
+ position: relative;
+ width: 100%;
+ border: 1px solid #f1d9d3;
+ border-radius: 1.75rem;
+ background: rgb(255 255 255 / 0.94);
+ box-shadow:
+ 0 28px 48px -42px rgb(120 74 55 / 0.38),
+ 0 6px 18px -14px rgb(165 93 73 / 0.2);
+ }
+
+ .workspace-assistant-card::before {
+ content: "";
+ position: absolute;
+ top: 1rem;
+ left: 1rem;
+ width: 0.45rem;
+ height: calc(100% - 2rem);
+ border-radius: 999px;
+ background: linear-gradient(180deg, rgb(255 110 110 / 0.92), rgb(233 45 45 / 0.15));
+ opacity: 0.88;
+ }
+
+ .workspace-assistant-card > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ .workspace-assistant-card .prose,
+ .workspace-assistant-card [data-state] {
+ width: 100%;
+ }
+
+ .workspace-assistant-card .my-3 {
+ margin-block: 0.25rem;
+ }
+
+ .workspace-chat-toolbar {
+ border: 1px solid rgb(255 255 255 / 0.22);
+ background: rgb(255 255 255 / 0.14);
+ color: white;
+ backdrop-filter: blur(18px);
+ }
+
+ @keyframes sandbox-preview-enter {
+ 0% {
+ opacity: 0;
+ transform: translateY(14px) scale(0.985);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ }
+
+ @keyframes sandbox-preview-glow {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 0 0 rgb(227 39 39 / 0),
+ 0 8px 20px -16px rgb(227 39 39 / 0.1);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 8px rgb(227 39 39 / 0.08),
+ 0 18px 34px -18px rgb(227 39 39 / 0.3);
+ }
+ }
+
+ @keyframes sandbox-preview-badge {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ opacity: 0.88;
+ }
+ 50% {
+ transform: translateY(-1px);
+ opacity: 1;
+ }
+ }
+
+ @keyframes sandbox-scroll-button-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(-45%) scale(0.96);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(-50%) scale(1);
+ }
+ }
+}
+
+/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
+.tiptap-writing-editor .ProseMirror {
+ outline: none;
+ min-height: 320px;
+ caret-color: var(--primary);
+ padding-bottom: 6rem; /* ensure last paragraph isn't flush against the bottom */
+}
+
+.tiptap-writing-editor .ProseMirror p {
+ margin-bottom: 1rem;
+ line-height: 1.8;
+ font-size: 0.9375rem; /* 15px */
+ color: var(--foreground);
+ text-indent: 2em; /* 段落首行缩进 */
+}
+
+.tiptap-writing-editor .ProseMirror h1 {
+ font-size: 1.625rem;
+ font-weight: 700;
+ letter-spacing: -0.025em;
+ margin-top: 2rem;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border);
+ color: var(--foreground);
+ line-height: 1.25;
+}
+
+.tiptap-writing-editor .ProseMirror h1:first-child {
+ margin-top: 0;
+}
+
+.tiptap-writing-editor .ProseMirror h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ margin-top: 1.75rem;
+ margin-bottom: 0.625rem;
+ color: var(--foreground);
+ line-height: 1.35;
+}
+
+.tiptap-writing-editor .ProseMirror h3 {
+ font-size: 1.0625rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin-top: 1.25rem;
+ margin-bottom: 0.5rem;
+ color: var(--foreground);
+ line-height: 1.4;
+}
+
+.tiptap-writing-editor .ProseMirror h2:first-child,
+.tiptap-writing-editor .ProseMirror h3:first-child {
+ margin-top: 0;
+}
+
+.tiptap-writing-editor .ProseMirror strong {
+ font-weight: 600;
+}
+
+.tiptap-writing-editor .ProseMirror em {
+ font-style: italic;
+}
+
+.tiptap-writing-editor .ProseMirror s {
+ text-decoration: line-through;
+ opacity: 0.6;
+}
+
+.tiptap-writing-editor .ProseMirror ul {
+ list-style-type: disc;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ font-size: 0.9375rem;
+ color: var(--foreground);
+}
+
+.tiptap-writing-editor .ProseMirror ol {
+ list-style-type: decimal;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ font-size: 0.9375rem;
+ color: var(--foreground);
+}
+
+.tiptap-writing-editor .ProseMirror li {
+ line-height: 1.8;
+ margin-bottom: 0.25rem;
+}
+
+.tiptap-writing-editor .ProseMirror li > p {
+ margin-bottom: 0.25rem;
+ text-indent: 0; /* 列表项内段落不缩进 */
+}
+
+.tiptap-writing-editor .ProseMirror blockquote {
+ border-left: 3px solid var(--border);
+ padding-left: 1rem;
+ margin: 1.25rem 0;
+ font-style: italic;
+ font-size: 0.9375rem;
+ color: var(--muted-foreground);
+}
+
+.tiptap-writing-editor .ProseMirror pre {
+ background: var(--muted);
+ border-radius: 0.5rem;
+ padding: 1rem 1.25rem;
+ margin: 1rem 0 1.25rem;
+ overflow-x: auto;
+ border: 1px solid var(--border);
+}
+
+.tiptap-writing-editor .ProseMirror pre code {
+ background: transparent;
+ padding: 0;
+ font-size: 0.8125rem; /* 13px */
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
+ color: var(--foreground);
+ line-height: 1.7;
+}
+
+.tiptap-writing-editor .ProseMirror code {
+ background: var(--muted);
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ font-size: 0.8125rem;
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
+ color: var(--foreground);
+ border: 1px solid var(--border);
+}
+
+.tiptap-writing-editor .ProseMirror hr {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 2rem 0;
+}
+
+.tiptap-writing-editor .ProseMirror a {
+ color: var(--primary);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ text-decoration-thickness: 1px;
+ transition: opacity 0.15s;
+}
+
+.tiptap-writing-editor .ProseMirror a:hover {
+ opacity: 0.75;
+}
+
+/* Placeholder */
+.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child {
+ text-indent: 0; /* 占位文案不缩进 */
+}
+
+.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child::before {
+ content: attr(data-placeholder);
+ color: var(--muted-foreground);
+ pointer-events: none;
+ float: left;
+ height: 0;
+ opacity: 0.6;
+}
+
+/* Selection highlight — use color-mix to avoid the background+opacity mismatch */
+.tiptap-writing-editor .ProseMirror ::selection {
+ background: color-mix(in srgb, #f87171 35%, transparent);
+}
+
+/* AI rewrite — highlight the selected range while the menu / preview is open */
+.ai-rewrite-highlight {
+ background: color-mix(in srgb, #fbbf24 30%, transparent);
+ border-radius: 2px;
+ transition: background 0.2s ease;
+}
+.dark .ai-rewrite-highlight {
+ background: color-mix(in srgb, #fbbf24 18%, transparent);
+}
+
+:root {
+ --container-width-xs: calc(var(--spacing) * 72);
+ --container-width-sm: calc(var(--spacing) * 144);
+ --container-width-md: calc(var(--spacing) * 204);
+ --container-width-lg: calc(var(--spacing) * 256);
+}
+
+/* ── Landing 态输入框 Gemini 风格:旋转彩色描边 + 多色柔光阴影 ───────── */
+/* 关键:::before 转动的彩色锥形渐变 + ::after 内嵌白板遮罩中央,只在 1.5px 边缘可见模糊彩光 */
+.gemini-shadow {
+ box-shadow:
+ 0 0 20px rgba(66, 133, 244, 0.10),
+ 0 0 40px rgba(219, 68, 55, 0.05),
+ 0 0 60px rgba(244, 180, 0, 0.05),
+ 0 0 80px rgba(15, 157, 88, 0.05);
+}
+.dark .gemini-shadow {
+ box-shadow:
+ 0 0 24px rgba(66, 133, 244, 0.20),
+ 0 0 48px rgba(219, 68, 55, 0.10),
+ 0 0 72px rgba(244, 180, 0, 0.10),
+ 0 0 96px rgba(15, 157, 88, 0.10);
+}
+
+.gemini-gradient-border {
+ position: relative;
+ background: var(--background);
+ border-radius: 1.5rem;
+ z-index: 0;
+ overflow: hidden;
+}
+.gemini-gradient-border::before {
+ content: '';
+ position: absolute;
+ width: 200%;
+ height: 200%;
+ top: -50%;
+ left: -50%;
+ background: conic-gradient(
+ from 0deg,
+ #4285F4 0deg,
+ #DB4437 90deg,
+ #F4B400 180deg,
+ #0F9D58 270deg,
+ #4285F4 360deg
+ );
+ z-index: -2;
+ animation: gemini-rotate 4s linear infinite;
+ filter: blur(10px);
+ pointer-events: none;
+}
+.gemini-gradient-border::after {
+ content: '';
+ position: absolute;
+ inset: 2.5px;
+ background: var(--background);
+ border-radius: calc(1.5rem - 2px);
+ z-index: -1;
+}
+
+@keyframes gemini-rotate {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+* {
+ border-color: var(--border);
+ outline-color: var(--ring);
+}
\ No newline at end of file
diff --git a/frontend-web/vite.config.ts b/frontend-web/vite.config.ts
index 8d6e4335..d49aad30 100644
--- a/frontend-web/vite.config.ts
+++ b/frontend-web/vite.config.ts
@@ -3,11 +3,12 @@ import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
+import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
- plugins: [react()],
+ plugins: [sourceIdentifierPlugin(), react()],
resolve: {
alias: [
{
diff --git a/newpython/OUT-v1.py b/newpython/OUT-v1.py
new file mode 100644
index 00000000..f11ae88e
--- /dev/null
+++ b/newpython/OUT-v1.py
@@ -0,0 +1,483 @@
+"""
+测试流式接口的完整脚本
+API流程:
+1. 登录获取token
+2. 创建thread(可选,也可以使用无状态接口)
+3. 调用流式接口
+
+两种方式:
+- 方式1: POST /api/runs/stream (无状态,自动创建thread)
+- 方式2: POST /api/threads/{thread_id}/runs/stream (需要先创建thread)
+"""
+import requests
+import json
+import asyncio
+import httpx # 异步 HTTP 客户端
+# ============ 配置区域 ============
+BASE_URL = "http://127.0.0.1:8001"
+LOGIN_URL = f"{BASE_URL}/api/v1/auth/login/username"
+THREADS_URL = f"{BASE_URL}/api/threads"
+RUNS_STREAM_URL = f"{BASE_URL}/api/runs/stream" # 无状态流式接口
+
+# ============ 登录获取token ============
+def get_token(username: str) -> str:
+ """登录获取token"""
+ response = requests.post(
+ LOGIN_URL,
+ json={"username": username},
+ headers={"Content-Type": "application/json"}
+ )
+ # print(f"登录状态码: {response.status_code}")
+ data = response.json()
+ # print(f"登录响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
+
+ # 根据实际返回结构调整
+ token = data.get("token") or data.get("access_token") or data.get("data", {}).get("token")
+ return token
+# ============ 方式2: 先创建thread,再调用流式接口 ============
+def create_thread(token: str) -> str:
+ """创建thread,返回thread_id"""
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+
+ response = requests.post(
+ THREADS_URL,
+ json={}, # 空body,使用默认值
+ headers=headers
+ )
+
+ print(f"创建thread状态码: {response.status_code}")
+ data = response.json()
+ print(f"创建thread响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
+
+ return data.get("thread_id")
+def get_agent_name_description(agent_name="Taiwan-intelligence-gathering-agent"):
+ url = f"{BASE_URL}/api/agents/{agent_name}"
+ token=get_token("ji")
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ response = requests.get(
+ url,
+ headers=headers,
+ )
+ print(response.json())
+ return response.json()["name"].lower(),"agent_name:"+response.json()["name"].lower()+","+response.json()["description"]
+
+# ============ 创建智能体 ============
+def create_agent(
+ name: str,
+ description: str = "",
+ model: str | None = None,
+ tool_groups: list[str] | None = None,
+ skills: list[str] | None = None,
+ soul: str = "",
+):
+ """
+ 创建一个新的智能体
+
+ Args:
+ name: 智能体名称(必须是 hyphen-case,如 my-agent)
+ description: 智能体描述
+ model: 可选的模型覆盖(如 gpt-4, claude-3-opus 等)
+ tool_groups: 工具组白名单(如 ["web", "file"])
+ skills: 技能白名单(如 ["skill1", "skill2"])
+ soul: SOUL.md 内容(智能体人格/身份描述)
+
+ Returns:
+ 创建结果
+ """
+ TOKEN =get_token("ji")
+ url = f"{BASE_URL}/api/agents"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {TOKEN}"
+ }
+
+ payload = {
+ "name": name,
+ "description": description,
+ "soul": soul
+ }
+
+ # 添加可选字段
+ if model is not None:
+ payload["model"] = model
+ if tool_groups is not None:
+ payload["tool_groups"] = tool_groups
+ if skills is not None:
+ payload["skills"] = skills
+
+ print(f"请求: POST {url}")
+ print(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
+
+ response = requests.post(url, json=payload, headers=headers)
+
+ print(f"状态码: {response.status_code}")
+
+ if response.status_code == 201:
+ result = response.json()
+ print(f"✓ 智能体创建成功!")
+ print(f"响应: {json.dumps(result, indent=2, ensure_ascii=False)}")
+ return result
+ else:
+ print(f"✗ 创建失败: {response.text}")
+ return f"✗ 创建失败: {response.text}"
+
+def create_main_agent(zhihui_name,agent_names):
+ d={}
+ token=get_token("ji")
+ l=[]
+ for i in agent_names:
+ # print(i)
+ name,description=get_agent_name_description(i)
+ thread_id_tmp=create_thread(token)
+ d[name]=thread_id_tmp
+ l.append(description)
+ name="你是总控控制协调智能体,负责协调其他智能体干活"
+ miaoshu=f"""#核心定位:
+ 用途:跟用用户的任务目标,调用其他智能体干活,只负责协调汇总,当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
+ #调度方法:
+ 通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
+ #可调度智能体:
+ {l}"""
+ thread_id_tmp=create_thread(token)
+ engname=zhihui_name
+ d[engname]=thread_id_tmp
+ tmp_skills=["agent_orchestration"]
+ tmpres=create_agent(
+ name=engname,
+ description=name,
+ model="glm-5", # 指定模型
+ skills=tmp_skills, # 关联技能
+ soul=miaoshu)
+ # print("创建智能体响应:",tmpres)
+ if "失败" in tmpres and "exists" not in tmpres:
+ return tmpres
+ print(f"创建协调智能体成功,可以协调列表:{l}")
+ return d
+import re
+def extract_name_task(text="python3 scripts/agent_orchestration.py \"Us-intelligence-gathering-agent\" \"收集和分析美国人民对特朗普的看法,包括支持率和反对率、不同群体的观点差异、主要支持理由和反对理由等\""):
+ try:
+ pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
+ match = re.search(pattern, text)
+ if match:
+ agent_name = match.group(1)
+ task_content = match.group(2)
+ return agent_name,task_content
+ except:
+ return "",""
+def update_agent_message(thread_id,new_content):
+ token=get_token("ji")
+ url = f"{BASE_URL}/api/threads/{thread_id}/state"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ tmp = requests.get(
+ url,
+ headers=headers
+ ).json()
+ # print(tmp)
+ if "messages" in tmp["values"]:
+ tmp["values"]["messages"].append({"type": "human", "content": new_content})
+ else:
+ tmp["values"]["messages"]=[]
+ tmp["values"]["messages"].append({"type": "human", "content": new_content})
+ url = f"{BASE_URL}/api/threads/{thread_id}/state"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ response = requests.post(
+ url,
+ json=tmp,
+ headers=headers,
+ stream=True
+ )
+ del tmp
+ print(f"线程{thread_id}更新状态码: {response.status_code}")
+def Broadcast_message(message,d_agent_thread_id,pingbi_agent_name):
+ for key in d_agent_thread_id:
+ if key not in pingbi_agent_name:
+ update_agent_message(d_agent_thread_id[key],message)
+ print("广播消息成功:",key,message)
+null=None
+true=True
+false=False
+async def leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
+ """使用 httpx 异步请求的 leader_agent"""
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, "总控智能体收到用户消息:" + new_message, d_agent_thread_id, [agent_name])
+ thread_id = d_agent_thread_id[agent_name]
+ l = []
+ token = get_token("ji")
+ url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ payload = {
+ "input": {
+ "messages": [
+ {
+ "type": "human",
+ "content": [
+ {
+ "type": "text",
+ "text": new_message
+ }
+ ],
+ "additional_kwargs": {}
+ }
+ ]
+ },
+ "config": {
+ "recursion_limit": 1000
+ },
+ "context": {
+ "agent_name": agent_name,
+ "model_name": "deepseek-chat",
+ "mode": "pro",
+ "reasoning_effort": "medium",
+ "thinking_enabled": true,
+ "is_plan_mode": false,
+ "subagent_enabled": false,
+ "thread_id": thread_id
+ },
+ "stream_mode": [
+ "messages-tuple",
+ "values",
+ ],
+ "stream_subgraphs": true,
+ "stream_resumable": true,
+ "assistant_id": "lead_agent",
+ "on_disconnect": "continue",
+ "excluded_tools": ["web_search"],
+ "skill_stop_names": skill_stop_names,
+ }
+
+ async with httpx.AsyncClient(timeout=None) as client:
+ async with client.stream("POST", url, json=payload, headers=headers) as response:
+ print(f"状态码: {response.status_code}")
+ if response.status_code != 200:
+ body = await response.aread()
+ raise RuntimeError(
+ f"upstream {url} returned HTTP {response.status_code}: "
+ f"{body.decode('utf-8', 'replace')[:1000]}"
+ )
+ async for line in response.aiter_lines():
+ if line:
+ yield line
+ if line[:5] == "data:":
+ print(line)
+ l.append(line)
+ if len(l) < 2:
+ raise RuntimeError(
+ f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
+ f"Check the server log on {url} for the real failure."
+ )
+
+ null = None
+ tmpres = eval(l[-2][5:])
+ tmpres = tmpres["messages"][-1]
+ tmp_task = []
+ if tmpres.get("tool_calls", []) == []:
+ yield {"status": []}
+ else:
+ for tool_call in tmpres.get("tool_calls", []):
+ try:
+ agentname, task_text = extract_name_task(str(tool_call["args"]))
+ print("agentname", agentname)
+ print("task_text:", task_text)
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, f"总控智能体给{agentname}派活,内容为:" + task_text, d_agent_thread_id, [agent_name, agentname])
+ tmp_task.append([agentname, task_text])
+ except:
+ pass
+ yield {"status": tmp_task}
+
+
+async def special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
+ """使用 httpx 异步请求的 special_agent"""
+ thread_id = d_agent_thread_id[agent_name]
+ l = []
+ token = get_token("ji")
+ url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ payload = {
+ "input": {
+ "messages": [
+ {
+ "type": "human",
+ "content": [
+ {
+ "type": "text",
+ "text": new_message
+ }
+ ],
+ "additional_kwargs": {}
+ }
+ ]
+ },
+ "config": {
+ "recursion_limit": 1000
+ },
+ "context": {
+ "agent_name": agent_name,
+ "model_name": "deepseek-chat",
+ "mode": "pro",
+ "reasoning_effort": "medium",
+ "thinking_enabled": true,
+ "is_plan_mode": false,
+ "subagent_enabled": false,
+ "thread_id": thread_id
+ },
+ "stream_mode": [
+ "messages-tuple",
+ "values",
+ ],
+ "stream_subgraphs": true,
+ "stream_resumable": true,
+ "assistant_id": "lead_agent",
+ "on_disconnect": "continue",
+ "excluded_tools": ["web_search", "ask_clarification", "present_files", "view_image"],
+ "skill_stop_names": skill_stop_names,
+ }
+
+ async with httpx.AsyncClient(timeout=None) as client:
+ async with client.stream("POST", url, json=payload, headers=headers) as response:
+ print(f"状态码: {response.status_code}")
+ if response.status_code != 200:
+ body = await response.aread()
+ raise RuntimeError(
+ f"upstream {url} returned HTTP {response.status_code}: "
+ f"{body.decode('utf-8', 'replace')[:1000]}"
+ )
+ async for line in response.aiter_lines():
+ if line:
+ yield line
+ if line[:5] == "data:":
+ print(line)
+ l.append(line)
+ if len(l) < 2:
+ raise RuntimeError(
+ f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
+ f"Check the server log on {url} for the real failure."
+ )
+
+ null = None
+ tmpres = eval(l[-2][5:])
+ tmpres = tmpres["messages"][-1]
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + tmpres["content"], d_agent_thread_id, [agent_name])
+ yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
+
+async def agent_init(agent_names, main_agent_name):
+ """异步的 agent_init,同步函数调用使用 to_thread"""
+ agent_names = [i.lower() for i in agent_names]
+ # 使用 to_thread 异步运行同步的 create_main_agent
+ tmpd = await asyncio.to_thread(create_main_agent, main_agent_name, agent_names)
+ message = """
+你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活
+注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,你只关心于完成总控控制协调智能体分配的活即可
+"""
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, message, tmpd, [main_agent_name])
+ return main_agent_name, tmpd
+
+
+from fastapi import FastAPI, HTTPException,Body
+from fastapi.responses import StreamingResponse
+from fastapi.middleware.cors import CORSMiddleware
+import asyncio
+import uvicorn
+
+app = FastAPI()
+ # CORS 配置
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+import time
+@app.post("/generate_mul_agent")
+async def generate_stream(payload: dict = Body(...)):
+ """
+ 根据 payload 中的 agent_type 字段判断调用 leader_agent 还是 special_agent
+
+ payload 结构示例:
+ {
+ "agent_type": "leader", # 或 "special"
+ "d_agent_thread_id": {...},
+ "agent_name": "xxx",
+ "new_message": "xxx",
+ "skill_stop_names": [...]
+ }
+ """
+ # 从 payload 中提取参数
+ agent_type = payload.get("agent_type", "leader") # 默认为 leader
+ d_agent_thread_id = payload.get("d_agent_thread_id", {})
+ agent_name = payload.get("agent_name", "")
+ new_message = payload.get("new_message", "")
+ skill_stop_names = payload.get("skill_stop_names", [])
+
+ # 流式响应生成器
+ async def stream_generator():
+ # 根据 agent_type 选择调用的函数
+ if agent_type == "leader":
+ async_generator = leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
+ else: # special
+ async_generator = special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
+
+ # 异步迭代生成器
+ async for chunk in async_generator:
+ if isinstance(chunk, dict):
+ yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
+ else:
+ yield f"{chunk}"
+
+ # 使用 SSE 流式响应
+ return StreamingResponse(
+ stream_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "Connection": "keep-alive",
+ }
+ )
+
+
+@app.post("/agent_init")
+async def init_agents(payload: dict = Body(...)):
+ """
+ 初始化智能体接口
+ payload 结构示例:
+ {
+ "agent_names": ["agent1", "agent2"],
+ "main_agent_name": "main-agent"
+ }
+ """
+ agent_names = payload.get("agent_names", [])
+ main_agent_name = payload.get("main_agent_name", "main-agent")
+
+ # 调用异步的 agent_init
+ name, thread_id_dict = await agent_init(agent_names, main_agent_name)
+
+ return {
+ "status": "success",
+ "main_agent_name": name,
+ "thread_ids": thread_id_dict
+ }
+if __name__ == "__main__":
+ uvicorn.run("testapi:app", host="0.0.0.0", port=8095, workers=1,limit_concurrency=1000) # 多进程提升并发能力
\ No newline at end of file
diff --git a/newpython/OUT.py b/newpython/OUT.py
new file mode 100644
index 00000000..f11ae88e
--- /dev/null
+++ b/newpython/OUT.py
@@ -0,0 +1,483 @@
+"""
+测试流式接口的完整脚本
+API流程:
+1. 登录获取token
+2. 创建thread(可选,也可以使用无状态接口)
+3. 调用流式接口
+
+两种方式:
+- 方式1: POST /api/runs/stream (无状态,自动创建thread)
+- 方式2: POST /api/threads/{thread_id}/runs/stream (需要先创建thread)
+"""
+import requests
+import json
+import asyncio
+import httpx # 异步 HTTP 客户端
+# ============ 配置区域 ============
+BASE_URL = "http://127.0.0.1:8001"
+LOGIN_URL = f"{BASE_URL}/api/v1/auth/login/username"
+THREADS_URL = f"{BASE_URL}/api/threads"
+RUNS_STREAM_URL = f"{BASE_URL}/api/runs/stream" # 无状态流式接口
+
+# ============ 登录获取token ============
+def get_token(username: str) -> str:
+ """登录获取token"""
+ response = requests.post(
+ LOGIN_URL,
+ json={"username": username},
+ headers={"Content-Type": "application/json"}
+ )
+ # print(f"登录状态码: {response.status_code}")
+ data = response.json()
+ # print(f"登录响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
+
+ # 根据实际返回结构调整
+ token = data.get("token") or data.get("access_token") or data.get("data", {}).get("token")
+ return token
+# ============ 方式2: 先创建thread,再调用流式接口 ============
+def create_thread(token: str) -> str:
+ """创建thread,返回thread_id"""
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+
+ response = requests.post(
+ THREADS_URL,
+ json={}, # 空body,使用默认值
+ headers=headers
+ )
+
+ print(f"创建thread状态码: {response.status_code}")
+ data = response.json()
+ print(f"创建thread响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
+
+ return data.get("thread_id")
+def get_agent_name_description(agent_name="Taiwan-intelligence-gathering-agent"):
+ url = f"{BASE_URL}/api/agents/{agent_name}"
+ token=get_token("ji")
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ response = requests.get(
+ url,
+ headers=headers,
+ )
+ print(response.json())
+ return response.json()["name"].lower(),"agent_name:"+response.json()["name"].lower()+","+response.json()["description"]
+
+# ============ 创建智能体 ============
+def create_agent(
+ name: str,
+ description: str = "",
+ model: str | None = None,
+ tool_groups: list[str] | None = None,
+ skills: list[str] | None = None,
+ soul: str = "",
+):
+ """
+ 创建一个新的智能体
+
+ Args:
+ name: 智能体名称(必须是 hyphen-case,如 my-agent)
+ description: 智能体描述
+ model: 可选的模型覆盖(如 gpt-4, claude-3-opus 等)
+ tool_groups: 工具组白名单(如 ["web", "file"])
+ skills: 技能白名单(如 ["skill1", "skill2"])
+ soul: SOUL.md 内容(智能体人格/身份描述)
+
+ Returns:
+ 创建结果
+ """
+ TOKEN =get_token("ji")
+ url = f"{BASE_URL}/api/agents"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {TOKEN}"
+ }
+
+ payload = {
+ "name": name,
+ "description": description,
+ "soul": soul
+ }
+
+ # 添加可选字段
+ if model is not None:
+ payload["model"] = model
+ if tool_groups is not None:
+ payload["tool_groups"] = tool_groups
+ if skills is not None:
+ payload["skills"] = skills
+
+ print(f"请求: POST {url}")
+ print(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
+
+ response = requests.post(url, json=payload, headers=headers)
+
+ print(f"状态码: {response.status_code}")
+
+ if response.status_code == 201:
+ result = response.json()
+ print(f"✓ 智能体创建成功!")
+ print(f"响应: {json.dumps(result, indent=2, ensure_ascii=False)}")
+ return result
+ else:
+ print(f"✗ 创建失败: {response.text}")
+ return f"✗ 创建失败: {response.text}"
+
+def create_main_agent(zhihui_name,agent_names):
+ d={}
+ token=get_token("ji")
+ l=[]
+ for i in agent_names:
+ # print(i)
+ name,description=get_agent_name_description(i)
+ thread_id_tmp=create_thread(token)
+ d[name]=thread_id_tmp
+ l.append(description)
+ name="你是总控控制协调智能体,负责协调其他智能体干活"
+ miaoshu=f"""#核心定位:
+ 用途:跟用用户的任务目标,调用其他智能体干活,只负责协调汇总,当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
+ #调度方法:
+ 通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
+ #可调度智能体:
+ {l}"""
+ thread_id_tmp=create_thread(token)
+ engname=zhihui_name
+ d[engname]=thread_id_tmp
+ tmp_skills=["agent_orchestration"]
+ tmpres=create_agent(
+ name=engname,
+ description=name,
+ model="glm-5", # 指定模型
+ skills=tmp_skills, # 关联技能
+ soul=miaoshu)
+ # print("创建智能体响应:",tmpres)
+ if "失败" in tmpres and "exists" not in tmpres:
+ return tmpres
+ print(f"创建协调智能体成功,可以协调列表:{l}")
+ return d
+import re
+def extract_name_task(text="python3 scripts/agent_orchestration.py \"Us-intelligence-gathering-agent\" \"收集和分析美国人民对特朗普的看法,包括支持率和反对率、不同群体的观点差异、主要支持理由和反对理由等\""):
+ try:
+ pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
+ match = re.search(pattern, text)
+ if match:
+ agent_name = match.group(1)
+ task_content = match.group(2)
+ return agent_name,task_content
+ except:
+ return "",""
+def update_agent_message(thread_id,new_content):
+ token=get_token("ji")
+ url = f"{BASE_URL}/api/threads/{thread_id}/state"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ tmp = requests.get(
+ url,
+ headers=headers
+ ).json()
+ # print(tmp)
+ if "messages" in tmp["values"]:
+ tmp["values"]["messages"].append({"type": "human", "content": new_content})
+ else:
+ tmp["values"]["messages"]=[]
+ tmp["values"]["messages"].append({"type": "human", "content": new_content})
+ url = f"{BASE_URL}/api/threads/{thread_id}/state"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ response = requests.post(
+ url,
+ json=tmp,
+ headers=headers,
+ stream=True
+ )
+ del tmp
+ print(f"线程{thread_id}更新状态码: {response.status_code}")
+def Broadcast_message(message,d_agent_thread_id,pingbi_agent_name):
+ for key in d_agent_thread_id:
+ if key not in pingbi_agent_name:
+ update_agent_message(d_agent_thread_id[key],message)
+ print("广播消息成功:",key,message)
+null=None
+true=True
+false=False
+async def leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
+ """使用 httpx 异步请求的 leader_agent"""
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, "总控智能体收到用户消息:" + new_message, d_agent_thread_id, [agent_name])
+ thread_id = d_agent_thread_id[agent_name]
+ l = []
+ token = get_token("ji")
+ url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ payload = {
+ "input": {
+ "messages": [
+ {
+ "type": "human",
+ "content": [
+ {
+ "type": "text",
+ "text": new_message
+ }
+ ],
+ "additional_kwargs": {}
+ }
+ ]
+ },
+ "config": {
+ "recursion_limit": 1000
+ },
+ "context": {
+ "agent_name": agent_name,
+ "model_name": "deepseek-chat",
+ "mode": "pro",
+ "reasoning_effort": "medium",
+ "thinking_enabled": true,
+ "is_plan_mode": false,
+ "subagent_enabled": false,
+ "thread_id": thread_id
+ },
+ "stream_mode": [
+ "messages-tuple",
+ "values",
+ ],
+ "stream_subgraphs": true,
+ "stream_resumable": true,
+ "assistant_id": "lead_agent",
+ "on_disconnect": "continue",
+ "excluded_tools": ["web_search"],
+ "skill_stop_names": skill_stop_names,
+ }
+
+ async with httpx.AsyncClient(timeout=None) as client:
+ async with client.stream("POST", url, json=payload, headers=headers) as response:
+ print(f"状态码: {response.status_code}")
+ if response.status_code != 200:
+ body = await response.aread()
+ raise RuntimeError(
+ f"upstream {url} returned HTTP {response.status_code}: "
+ f"{body.decode('utf-8', 'replace')[:1000]}"
+ )
+ async for line in response.aiter_lines():
+ if line:
+ yield line
+ if line[:5] == "data:":
+ print(line)
+ l.append(line)
+ if len(l) < 2:
+ raise RuntimeError(
+ f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
+ f"Check the server log on {url} for the real failure."
+ )
+
+ null = None
+ tmpres = eval(l[-2][5:])
+ tmpres = tmpres["messages"][-1]
+ tmp_task = []
+ if tmpres.get("tool_calls", []) == []:
+ yield {"status": []}
+ else:
+ for tool_call in tmpres.get("tool_calls", []):
+ try:
+ agentname, task_text = extract_name_task(str(tool_call["args"]))
+ print("agentname", agentname)
+ print("task_text:", task_text)
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, f"总控智能体给{agentname}派活,内容为:" + task_text, d_agent_thread_id, [agent_name, agentname])
+ tmp_task.append([agentname, task_text])
+ except:
+ pass
+ yield {"status": tmp_task}
+
+
+async def special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
+ """使用 httpx 异步请求的 special_agent"""
+ thread_id = d_agent_thread_id[agent_name]
+ l = []
+ token = get_token("ji")
+ url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}"
+ }
+ payload = {
+ "input": {
+ "messages": [
+ {
+ "type": "human",
+ "content": [
+ {
+ "type": "text",
+ "text": new_message
+ }
+ ],
+ "additional_kwargs": {}
+ }
+ ]
+ },
+ "config": {
+ "recursion_limit": 1000
+ },
+ "context": {
+ "agent_name": agent_name,
+ "model_name": "deepseek-chat",
+ "mode": "pro",
+ "reasoning_effort": "medium",
+ "thinking_enabled": true,
+ "is_plan_mode": false,
+ "subagent_enabled": false,
+ "thread_id": thread_id
+ },
+ "stream_mode": [
+ "messages-tuple",
+ "values",
+ ],
+ "stream_subgraphs": true,
+ "stream_resumable": true,
+ "assistant_id": "lead_agent",
+ "on_disconnect": "continue",
+ "excluded_tools": ["web_search", "ask_clarification", "present_files", "view_image"],
+ "skill_stop_names": skill_stop_names,
+ }
+
+ async with httpx.AsyncClient(timeout=None) as client:
+ async with client.stream("POST", url, json=payload, headers=headers) as response:
+ print(f"状态码: {response.status_code}")
+ if response.status_code != 200:
+ body = await response.aread()
+ raise RuntimeError(
+ f"upstream {url} returned HTTP {response.status_code}: "
+ f"{body.decode('utf-8', 'replace')[:1000]}"
+ )
+ async for line in response.aiter_lines():
+ if line:
+ yield line
+ if line[:5] == "data:":
+ print(line)
+ l.append(line)
+ if len(l) < 2:
+ raise RuntimeError(
+ f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
+ f"Check the server log on {url} for the real failure."
+ )
+
+ null = None
+ tmpres = eval(l[-2][5:])
+ tmpres = tmpres["messages"][-1]
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + tmpres["content"], d_agent_thread_id, [agent_name])
+ yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
+
+async def agent_init(agent_names, main_agent_name):
+ """异步的 agent_init,同步函数调用使用 to_thread"""
+ agent_names = [i.lower() for i in agent_names]
+ # 使用 to_thread 异步运行同步的 create_main_agent
+ tmpd = await asyncio.to_thread(create_main_agent, main_agent_name, agent_names)
+ message = """
+你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活
+注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,你只关心于完成总控控制协调智能体分配的活即可
+"""
+ # 使用 to_thread 异步运行同步的 Broadcast_message
+ await asyncio.to_thread(Broadcast_message, message, tmpd, [main_agent_name])
+ return main_agent_name, tmpd
+
+
+from fastapi import FastAPI, HTTPException,Body
+from fastapi.responses import StreamingResponse
+from fastapi.middleware.cors import CORSMiddleware
+import asyncio
+import uvicorn
+
+app = FastAPI()
+ # CORS 配置
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+import time
+@app.post("/generate_mul_agent")
+async def generate_stream(payload: dict = Body(...)):
+ """
+ 根据 payload 中的 agent_type 字段判断调用 leader_agent 还是 special_agent
+
+ payload 结构示例:
+ {
+ "agent_type": "leader", # 或 "special"
+ "d_agent_thread_id": {...},
+ "agent_name": "xxx",
+ "new_message": "xxx",
+ "skill_stop_names": [...]
+ }
+ """
+ # 从 payload 中提取参数
+ agent_type = payload.get("agent_type", "leader") # 默认为 leader
+ d_agent_thread_id = payload.get("d_agent_thread_id", {})
+ agent_name = payload.get("agent_name", "")
+ new_message = payload.get("new_message", "")
+ skill_stop_names = payload.get("skill_stop_names", [])
+
+ # 流式响应生成器
+ async def stream_generator():
+ # 根据 agent_type 选择调用的函数
+ if agent_type == "leader":
+ async_generator = leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
+ else: # special
+ async_generator = special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
+
+ # 异步迭代生成器
+ async for chunk in async_generator:
+ if isinstance(chunk, dict):
+ yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
+ else:
+ yield f"{chunk}"
+
+ # 使用 SSE 流式响应
+ return StreamingResponse(
+ stream_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "Connection": "keep-alive",
+ }
+ )
+
+
+@app.post("/agent_init")
+async def init_agents(payload: dict = Body(...)):
+ """
+ 初始化智能体接口
+ payload 结构示例:
+ {
+ "agent_names": ["agent1", "agent2"],
+ "main_agent_name": "main-agent"
+ }
+ """
+ agent_names = payload.get("agent_names", [])
+ main_agent_name = payload.get("main_agent_name", "main-agent")
+
+ # 调用异步的 agent_init
+ name, thread_id_dict = await agent_init(agent_names, main_agent_name)
+
+ return {
+ "status": "success",
+ "main_agent_name": name,
+ "thread_ids": thread_id_dict
+ }
+if __name__ == "__main__":
+ uvicorn.run("testapi:app", host="0.0.0.0", port=8095, workers=1,limit_concurrency=1000) # 多进程提升并发能力
\ No newline at end of file
diff --git a/newpython/__pycache__/OUT.cpython-310.pyc b/newpython/__pycache__/OUT.cpython-310.pyc
new file mode 100644
index 00000000..0f8e4dc7
Binary files /dev/null and b/newpython/__pycache__/OUT.cpython-310.pyc differ
diff --git a/newpython/__pycache__/demo_call.cpython-310.pyc b/newpython/__pycache__/demo_call.cpython-310.pyc
new file mode 100644
index 00000000..701e5343
Binary files /dev/null and b/newpython/__pycache__/demo_call.cpython-310.pyc differ
diff --git a/newpython/demo_call.py b/newpython/demo_call.py
new file mode 100644
index 00000000..c4df49e6
--- /dev/null
+++ b/newpython/demo_call.py
@@ -0,0 +1,132 @@
+"""
+OUT.py 的调用链 demo —— 跑通 agent_init → leader 派活 → 各 special agent 干活 这一整套流程。
+
+前置条件:
+ 1. DeerFlow 后端在 http://127.0.0.1:8001 在跑
+ 2. OUT.py 已经 `python -m uvicorn OUT:app --host 0.0.0.0 --port 8095` 起好
+ 3. 下面 SUB_AGENT_NAMES 里的 agent 已经在 DeerFlow 里创建好(/api/agents 能查到)
+
+直接 `python demo_call.py` 即可。
+"""
+import asyncio
+import json
+
+import httpx
+
+# ============ 改这里 ============
+OUT_BASE = "http://127.0.0.1:8095"
+
+# 已经在 DeerFlow 里建好的子 agent 名字 (会被 OUT.py 强制 lowercase)
+SUB_AGENT_NAMES = [
+ "us-intelligence-gathering-agent",
+ "taiwan-intelligence-gathering-agent",
+]
+
+# 总控 agent 名字 —— OUT.py 会现场创建它,所以填一个新名字即可 (hyphen-case)
+MAIN_AGENT_NAME = "demo-coordinator"
+
+# 用户提问 —— 总控会基于此派活
+USER_QUESTION = "对比中美两国 2024 年在 AI 监管政策上的差异"
+
+# leader 阶段命中即停的 skill 名字单 (OUT.py 用 agent_orchestration 作为派活信号)
+SKILL_STOP_NAMES = ["agent_orchestration"]
+# ================================
+
+
+async def call_agent_init() -> tuple[str, dict[str, str]]:
+ """步骤 1: 初始化 —— 拿回 thread_ids 字典 {agent_name → thread_id}."""
+ print(">>> [1/3] POST /agent_init")
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(
+ f"{OUT_BASE}/agent_init",
+ json={
+ "agent_names": SUB_AGENT_NAMES,
+ "main_agent_name": MAIN_AGENT_NAME,
+ },
+ )
+ r.raise_for_status()
+ body = r.json()
+ main_name = body["main_agent_name"]
+ thread_ids = body["thread_ids"]
+ print(f" main_agent_name = {main_name}")
+ print(f" thread_ids = {json.dumps(thread_ids, ensure_ascii=False, indent=2)}")
+ return main_name, thread_ids
+
+
+async def stream_generate(payload: dict) -> object | None:
+ """通用 SSE 消费:边收边打印,最后返回 OUT.py 自己 yield 的 {'status': ...} 里的 status 字段."""
+ final_status = None
+ async with httpx.AsyncClient(timeout=None) as client:
+ async with client.stream("POST", f"{OUT_BASE}/generate_mul_agent", json=payload) as resp:
+ print(f" HTTP {resp.status_code}")
+ if resp.status_code != 200:
+ err = await resp.aread()
+ raise RuntimeError(f"OUT.py /generate_mul_agent failed: {err.decode('utf-8', 'replace')[:500]}")
+
+ async for line in resp.aiter_lines():
+ if not line:
+ continue
+ # OUT.py 把 DeerFlow 的原始 SSE 流直接转发,自己最后会再 yield 一行
+ # `data: {"status": ...}\n\n`。两边都以 `data:` 开头,我们都尝试 JSON parse
+ # 一下,能解析成 {"status": ...} 的就是 OUT.py 的终态。
+ if line.startswith("data:"):
+ body_text = line[5:].strip()
+ try:
+ obj = json.loads(body_text)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(obj, dict) and "status" in obj:
+ final_status = obj["status"]
+ # 想看上游 DeerFlow 的完整流,把下面打开即可
+ # else:
+ # print(" raw:", line[:200])
+ return final_status
+
+
+async def call_leader(main_agent_name: str, thread_ids: dict[str, str]) -> list[list[str]]:
+ """步骤 2: leader 阶段 —— 总控读完用户问题,产出派活清单."""
+ print(f"\n>>> [2/3] POST /generate_mul_agent (leader, agent={main_agent_name})")
+ status = await stream_generate(
+ {
+ "agent_type": "leader",
+ "d_agent_thread_id": thread_ids,
+ "agent_name": main_agent_name,
+ "new_message": USER_QUESTION,
+ "skill_stop_names": SKILL_STOP_NAMES,
+ }
+ )
+ dispatch = status or []
+ print(f" 派活清单 = {json.dumps(dispatch, ensure_ascii=False, indent=2)}")
+ return dispatch
+
+
+async def call_specials(thread_ids: dict[str, str], dispatch: list[list[str]]) -> None:
+ """步骤 3: 把派活清单里每一项交给对应子 agent 跑."""
+ if not dispatch:
+ print("\n>>> [3/3] 总控没派出任何任务,直接结束。")
+ return
+ for idx, item in enumerate(dispatch, 1):
+ agent_name, task_text = item[0], item[1]
+ print(f"\n>>> [3/3] ({idx}/{len(dispatch)}) POST /generate_mul_agent (special, agent={agent_name})")
+ print(f" task = {task_text[:120]}{'...' if len(task_text) > 120 else ''}")
+ result = await stream_generate(
+ {
+ "agent_type": "special",
+ "d_agent_thread_id": thread_ids,
+ "agent_name": agent_name,
+ "new_message": task_text,
+ "skill_stop_names": [],
+ }
+ )
+ print(f" 完成: {result}")
+
+
+async def main() -> None:
+ main_agent_name, thread_ids = await call_agent_init()
+ dispatch = await call_leader(main_agent_name, thread_ids)
+ await call_specials(thread_ids, dispatch)
+ print("\n✅ demo 跑完")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index 3d32f61b..fba742f5 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -1,10 +1,10 @@
-import asyncio
-import logging
-import os
-import queue
-import sys
-import threading
-from collections.abc import AsyncGenerator
+import asyncio
+import logging
+import os
+import queue
+import sys
+import threading
+from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dotenv import load_dotenv
@@ -32,12 +32,15 @@ from app.gateway.routers import (
auth,
channels,
feedback,
+ intent,
llm_metrics,
mcp,
memory,
models,
+ multi_agent,
notifications,
public_scheduled_tasks,
+ recommend,
recommended_questions,
runs,
scheduled_tasks,
@@ -247,7 +250,7 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
async def _run_db_migrations() -> None:
"""Run Alembic migrations at startup so schema changes are applied automatically."""
- from pathlib import Path
+ from pathlib import Path
from alembic import command
from alembic.config import Config
@@ -260,51 +263,51 @@ async def _run_db_migrations() -> None:
logger.info("Persistence backend=memory — skipping Alembic migrations")
return
- if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
- logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
- return
-
- db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
- migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
- result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
-
- def _run_sync() -> None:
- try:
- cfg = Config()
- cfg.set_main_option("script_location", migrations_dir)
- cfg.set_main_option("sqlalchemy.url", db_url)
- command.upgrade(cfg, "head")
- except BaseException as exc:
- result_queue.put(exc)
- else:
- result_queue.put(None)
-
- try:
- try:
- timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
- except ValueError:
- timeout_s = 60.0
-
- worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
- worker.start()
- try:
- deadline = asyncio.get_running_loop().time() + timeout_s
- while result_queue.empty():
- if asyncio.get_running_loop().time() >= deadline:
- raise asyncio.TimeoutError
- await asyncio.sleep(0.2)
- result = result_queue.get_nowait()
- except asyncio.TimeoutError:
- logger.error(
- "Database migration did not finish within %.0fs; continuing startup on create_all schema. "
- "Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
- "to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
- timeout_s,
- )
- return
- if result is not None:
- raise result
- logger.info("Database migrations applied (head)")
+ if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
+ logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
+ return
+
+ db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
+ migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
+ result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
+
+ def _run_sync() -> None:
+ try:
+ cfg = Config()
+ cfg.set_main_option("script_location", migrations_dir)
+ cfg.set_main_option("sqlalchemy.url", db_url)
+ command.upgrade(cfg, "head")
+ except BaseException as exc:
+ result_queue.put(exc)
+ else:
+ result_queue.put(None)
+
+ try:
+ try:
+ timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
+ except ValueError:
+ timeout_s = 60.0
+
+ worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
+ worker.start()
+ try:
+ deadline = asyncio.get_running_loop().time() + timeout_s
+ while result_queue.empty():
+ if asyncio.get_running_loop().time() >= deadline:
+ raise asyncio.TimeoutError
+ await asyncio.sleep(0.2)
+ result = result_queue.get_nowait()
+ except asyncio.TimeoutError:
+ logger.error(
+ "Database migration did not finish within %.0fs; continuing startup on create_all schema. "
+ "Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
+ "to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
+ timeout_s,
+ )
+ return
+ if result is not None:
+ raise result
+ logger.info("Database migrations applied (head)")
except Exception:
logger.exception("Database migration failed — schema may be out of date")
@@ -860,6 +863,14 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
+ # --- 圆桌规划三步流(前端 /page/workspace/roundtable/planning)---
+ # 实现见 app/gateway/routers/{multi_agent,intent,recommend}.py;
+ # 文档见 frontend-web/docs/multi-agent-backend-dev.md
+ app.include_router(multi_agent.router) # Step 2:init(SSE) + run/stream(leader|special)
+ app.include_router(intent.router) # Step 1:/api/intent/init + /stream
+ app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
+ # Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
+
@app.get("/health", tags=["health"])
async def health_check() -> dict:
"""Health check endpoint.
diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py
index 83bc7bd1..800c4364 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/agents.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py
@@ -60,6 +60,10 @@ class AgentsListResponse(BaseModel):
class AgentCreateRequest(BaseModel):
"""Request body for creating a custom agent."""
+ id: str | None = Field(
+ default=None,
+ description="Optional explicit storage id. Must match the storage id pattern. If omitted, a UUID is generated.",
+ )
name: str = Field(..., description="Display name; Chinese and duplicate names are allowed")
description: str = Field(default="", description="Agent description")
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
@@ -411,13 +415,19 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
_require_agents_api_enabled()
name = _validate_display_name(request.name)
- agent_id = uuid4().hex
+ store = get_agent_store(http_request)
+
+ if request.id:
+ agent_id = _validate_storage_id(request.id)
+ if await store.get_any(agent_id) is not None:
+ raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
+ else:
+ agent_id = uuid4().hex
agent_dir = get_paths().agent_dir(agent_id)
try:
- store = get_agent_store(http_request)
if agent_dir.exists():
- raise HTTPException(status_code=409, detail=f"Generated agent id '{agent_id}' already exists")
+ raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
_write_agent_config(
agent_id,
diff --git a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py
index 4ee0ef86..164b768b 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py
@@ -1,6 +1,7 @@
import logging
import mimetypes
import zipfile
+from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote
@@ -10,6 +11,8 @@ from pydantic import BaseModel
from app.gateway.authz import require_permission
from app.gateway.path_utils import resolve_thread_virtual_path
+from deerflow.config.paths import get_paths
+from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -94,6 +97,68 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
return None
+def collect_outputs_listing(outputs_dir: Path) -> list[dict]:
+ """Walk an outputs directory and return artifact listing entries.
+
+ Each entry has ``path`` (virtual ``mnt/user-data/outputs/...``), ``name``,
+ ``size_bytes``, ``mime_type``, and ``modified_at`` (ISO 8601 UTC). Symlinks
+ that escape the outputs root are dropped to keep the listing scoped to the
+ thread. The result is sorted by ``modified_at`` descending (newest first).
+ """
+ if not outputs_dir.exists() or not outputs_dir.is_dir():
+ return []
+
+ outputs_root = outputs_dir.resolve()
+ files: list[dict] = []
+ for entry in outputs_root.rglob("*"):
+ if not entry.is_file():
+ continue
+ try:
+ resolved = entry.resolve()
+ resolved.relative_to(outputs_root)
+ except (OSError, ValueError):
+ continue
+ relative = resolved.relative_to(outputs_root).as_posix()
+ mime_type, _ = mimetypes.guess_type(resolved.name)
+ stat = resolved.stat()
+ files.append(
+ {
+ "path": f"mnt/user-data/outputs/{relative}",
+ "name": resolved.name,
+ "size_bytes": stat.st_size,
+ "mime_type": mime_type,
+ "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
+ }
+ )
+
+ files.sort(key=lambda f: f["modified_at"], reverse=True)
+ return files
+
+
+@router.get(
+ "/threads/{thread_id}/artifacts",
+ summary="List Artifact Files",
+ description="List all files under the thread's outputs directory (/mnt/user-data/outputs/). Returns an empty list if the thread has no outputs.",
+)
+@require_permission("threads", "read", owner_check=True)
+async def list_artifacts(thread_id: str, request: Request) -> dict:
+ """列出 thread 在 ``outputs/`` 下生成的产物文件。
+
+ 圆桌 Step 3(``HighFidelityReport``)对每个**子席位** thread_id 调用本接口,
+ 展示各智能体写入沙箱 ``mnt/user-data/outputs/`` 的文件;协调智能体 thread
+ 通常无独立交付文件。
+
+ 遍历路径::
+
+ .deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/
+
+ 返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
+ 目录不存在时 ``files`` 为空数组(非 404)。
+ """
+ outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())
+ return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
+
+
@router.get(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Get Artifact File",
diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py
new file mode 100644
index 00000000..becb393f
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py
@@ -0,0 +1,284 @@
+"""圆桌规划 · 第一步「任务理解」Gateway 路由。
+
+前端页面:``/page/workspace/roundtable/planning`` Step 1
+前端模块:``frontend-web/src/roundtable-planning/api/intent.ts``
+
+职责
+----
+在独立 thread 上运行内置智能体 ``roundtable-intent``(SOUL 见
+``.deer-flow/agents/roundtable-intent/SOUL.md``),与用户进行 2–5 轮澄清,
+最终输出结构化意图 ``{objective, constraints, assumptions}``,供 Step 2
+总控协调智能体作为初始 prompt。
+
+与 ``multi_agent.py`` 的关系
+---------------------------
+- **复用**同一套 loopback 工具:``_stream_upstream``、``_run_payload``、
+ ``_parse_last_messages`` 等,保证 SSE 透传 + 末尾状态帧的解析逻辑一致。
+- **不复用** ``/init`` 的协调智能体创建逻辑:intent 是长期注册的内置 agent,
+ 只需 ``POST /api/intent/init`` 创建 thread 即可。
+
+SSE 协议(与前端 ``streamIntent`` 对齐)
+--------------------------------------
+1. 透传 LangGraph ``/api/threads/{id}/runs/stream`` 的全部 ``data:`` 行
+ (含 ``messages-tuple`` 增量,供 UI 流式打字)。
+2. 流结束后追加**一条** JSON 状态帧(``data: {...}\\n\\n``),字段:
+
+ ``status`` — 终态枚举:
+ - ``asking``:本轮无 ``ask_clarification``,模型仍在追问或闲聊;
+ - ``clarification``:本轮以澄清工具结束,需用户选选项/自定义输入;
+ - ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
+ - ``error``:上游或网关异常。
+
+ ``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。
+
+ 仅 ``status == "done"`` 时带 ``summary``;澄清时还带
+ ``question``、``clarification_type``、``clarification_context``、
+ ``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
+
+终态判定优先级(实现细节,勿随意调整)
+------------------------------------
+1. **``[INTENT_READY]`` 优先**:从最新消息向前扫描 AIMessage,正则匹配
+ SOUL 规定的 ``[INTENT_READY]\\n```json {...} `````;即使最后一条消息仍带
+ 过期的 ``ask_clarification`` tool_call,也返回 ``done``(避免前端卡在澄清 UI)。
+2. 否则看最后一条带 ``tool_calls`` 的 AIMessage 是否含 ``ask_clarification``。
+3. 否则 ``asking``。
+
+工具白名单
+----------
+``excluded_tools`` 屏蔽除 ``ask_clarification`` 外的一切工具,防止模型偏离
+澄清任务去写文件、派活等(SOUL 文字约束不可靠,硬排除更稳)。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+from typing import Any, AsyncIterator
+
+import httpx
+from fastapi import APIRouter, Body, HTTPException, Request
+from fastapi.responses import StreamingResponse
+
+# Reuse the building blocks already in multi_agent.py — keeps both routers
+# identical in connection handling, payload shape, and error mapping.
+from app.gateway.routers.multi_agent import (
+ _auth_headers,
+ _create_thread,
+ _find_dispatch_message,
+ _find_trailing_ai_text,
+ _flatten_content,
+ _loopback_base,
+ _parse_last_messages,
+ _run_payload,
+ _stream_upstream,
+)
+from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/intent", tags=["intent"])
+
+# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
+INTENT_AGENT_ID = "roundtable-intent"
+
+# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
+# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
+_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[str, Any]:
+ """创建任务理解会话用的空 thread。
+
+ 请求体:``{ "model": "deepseek-chat" }``(可选,当前 init 未把 model 写入 thread,
+ 实际模型在 ``/stream`` 的每次 run 里指定)。
+
+ 响应::
+
+ { "status": "success", "agent_id": "roundtable-intent", "thread_id": "" }
+
+ 与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位
+ thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。
+ """
+ base = _loopback_base(request)
+ headers = _auth_headers(request)
+ # trust_env=False: this client only talks to the same-process gateway at
+ # 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide
+ # on the dev machine) makes httpx route loopback calls through an upstream
+ # proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per
+ # request before falling back. Symptom in logs: `headers_in=42.160s`.
+ async with httpx.AsyncClient(timeout=None, trust_env=False) 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:
+ """Drive one turn of the intent dialogue via SSE.
+
+ Body: ``{thread_id, message, model?}``. Streams upstream LangGraph frames
+ through transparently, then appends one status frame:
+
+ {
+ "status": "asking" | "clarification" | "done" | "error",
+ "content": "",
+ "summary": {...} | null, // only when status == "done"
+ // when status == "clarification":
+ "question", "clarification_type", "clarification_context",
+ "options", "allow_custom", "allow_multiple"
+ }
+ """
+ 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:
+ raise HTTPException(status_code=400, detail="thread_id is required")
+ if not message:
+ raise HTTPException(status_code=400, detail="message is required")
+
+ base = _loopback_base(request)
+ headers = _auth_headers(request)
+
+ async def stream_generator() -> AsyncIterator[str]:
+ # trust_env=False: same-process loopback to 127.0.0.1 — honoring
+ # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
+ # causes httpx to route loopback calls through an unreachable upstream
+ # proxy and wait ~40 seconds for timeout before falling back.
+ async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
+ try:
+ body = _run_payload(
+ agent_name=INTENT_AGENT_ID,
+ model_name=model_name,
+ thread_id=thread_id,
+ new_message=message,
+ # Intent agent only ever needs ask_clarification. Block
+ # everything else so the model can't drift into solutioning
+ # (e.g. saving a design doc with write_file) — the model
+ # tends to ignore SOUL prose, hard exclusion is reliable.
+ # 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
+ excluded_tools=[
+ "web_search",
+ "present_files",
+ "view_image",
+ "agent_orchestration",
+ "bash",
+ "ls",
+ "read_file",
+ "write_file",
+ "str_replace",
+ "memory",
+ "hindsight_recall",
+ "hindsight_reflect",
+ "hindsight_retain",
+ "task",
+ "write_todos",
+ ],
+ skill_stop_names=[],
+ )
+
+ # captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
+ captured: list[str] = []
+ 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)
+
+ # --- 终态分支 1:结构化意图已就绪(最高优先级)---
+ # 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
+ # 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
+ # 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
+ summary: dict[str, Any] | None = None
+ ready_text: str = ""
+ for msg in reversed(messages):
+ if not isinstance(msg, dict):
+ continue
+ if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
+ continue
+ msg_text = _flatten_content(msg.get("content", ""))
+ m = _INTENT_READY_PATTERN.search(msg_text)
+ if m:
+ try:
+ summary = json.loads(m.group(1))
+ except json.JSONDecodeError as exc:
+ logger.warning("intent summary JSON parse failed: %s", exc)
+ summary = None
+ ready_text = msg_text
+ break
+ if summary and ready_text:
+ final_frame = {
+ "status": "done",
+ "content": ready_text,
+ "summary": summary,
+ }
+ yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
+ return
+
+ # --- 终态分支 2:本轮以 ask_clarification 结束 ---
+ # _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
+ # (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
+ dispatch_msg = _find_dispatch_message(messages)
+ content = _flatten_content(dispatch_msg.get("content", ""))
+ tool_calls = dispatch_msg.get("tool_calls") or []
+
+ clarification_call = next(
+ (tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
+ None,
+ )
+ if clarification_call is not None:
+ cargs = clarification_call.get("args") or {}
+ question = str(cargs.get("question") or "").strip()
+ # ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
+ # 比 tool_call 旁的短 preamble 更适合作为气泡正文。
+ follow_up = _find_trailing_ai_text(messages, exclude_text=content)
+ display_content = follow_up or content or question
+ final_frame: dict[str, Any] = {
+ "status": "clarification",
+ "content": display_content,
+ "question": question,
+ "clarification_type": str(cargs.get("clarification_type") or ""),
+ "clarification_context": str(cargs.get("context") or ""),
+ "options": cargs.get("options") or [],
+ "allow_custom": bool(cargs.get("allow_custom", True)),
+ "allow_multiple": resolve_allow_multiple(cargs),
+ "summary": None,
+ }
+ yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
+ return
+
+ # --- 终态分支 3:普通 AI 回复,继续多轮对话 ---
+ final_frame: dict[str, Any] = {
+ "status": "asking",
+ "content": content,
+ "summary": None,
+ }
+ yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
+
+ except HTTPException as exc:
+ logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
+ err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None}
+ yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
+ except Exception as exc:
+ logger.exception("intent stream unexpected failure")
+ err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None}
+ yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
+
+ return StreamingResponse(
+ stream_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "Connection": "keep-alive",
+ },
+ )
diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
new file mode 100644
index 00000000..f09b8305
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
@@ -0,0 +1,917 @@
+"""圆桌规划 · Step 2「多智能体研讨」Gateway 路由。
+
+前端页面:``RoundtablePlanningPage`` Step 2
+前端模块:``frontend-web/src/roundtable-planning/api/multi-agent.ts``
+
+本模块把原 ``newpython/OUT.py`` 演示编排进 FastAPI Gateway,通过 **HTTP loopback**
+(``127.0.0.1`` + 调用方 ``Authorization``)复用既有 ``/api/threads``、
+``/api/agents``、``/api/threads/{id}/runs/stream``,不再硬编码 token。
+
+对外接口
+--------
+1. ``POST /api/multi-agent/init``(SSE)
+ - 为每个研讨席位(``agent_names``)并行创建 thread,按完成顺序推送
+ ``seat_ready``;
+ - 动态创建**当次会话**的总控协调智能体(``main_agent_name``,如
+ ``roundtable-coordinator-xxx``),写入席位列表 SOUL + ``agent_orchestration`` skill;
+ - 结束帧 ``init_done`` 携带 ``thread_ids: { agent_name: thread_id, ... }``,
+ 含协调智能体自身 thread。
+
+2. ``POST /api/multi-agent/run/stream``(SSE)
+ - ``agent_type=leader``:总控一轮——可能派活、可能澄清、可能共识(空 dispatch);
+ - ``agent_type=special``:单席位子智能体执行一轮交付。
+
+跨席位「广播」语义
+------------------
+通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加
+**只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到
+总控派活/用户干预/子 agent 完成摘要。前端编排循环仍靠显式多次 ``/run/stream``
+驱动各 agent 真正推理。
+
+Leader 终态帧(前端 ``isFinalStatusFrame``)
+-------------------------------------------
+- ``status: [[agent_name, task], ...]`` — 派活列表,前端并行 special run;
+- ``status: []`` — 无 tool_calls,视为共识,可进 Step 3;
+- ``status: "clarification"`` — 总控 ``ask_clarification``,前端暂停编排展示澄清条;
+- ``status: "子智能体 X 完成..."`` — special 完成(字符串状态);
+- ``status: "error"`` — 失败。
+
+共享导出
+--------
+``intent.py`` / ``recommend.py`` 从此模块 import 底层流式与消息解析函数,
+勿在 intent/recommend 中重复实现 loopback 逻辑。
+
+相关文档:``frontend-web/docs/multi-agent-backend-dev.md``
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+import time
+from typing import Any, AsyncIterator
+
+import httpx
+from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
+from fastapi import APIRouter, Body, HTTPException, Request
+from fastapi.responses import StreamingResponse
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
+
+
+# ---------------------------------------------------------------------------
+# Loopback 辅助函数(Gateway → Gateway,同进程)
+# ---------------------------------------------------------------------------
+
+
+def _loopback_base(request: Request) -> str:
+ """当前请求的 scheme+host,用于 httpx 打回本机 Gateway。"""
+ return f"{request.url.scheme}://{request.url.netloc}"
+
+
+def _auth_headers(request: Request) -> dict[str, str]:
+ token = request.headers.get("authorization")
+ if not token:
+ raise HTTPException(status_code=401, detail="Missing Authorization header")
+ return {"Authorization": token, "Content-Type": "application/json"}
+
+
+async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
+ """``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
+ resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
+ resp.raise_for_status()
+ return resp.json()["thread_id"]
+
+
+async def _get_agent(client: httpx.AsyncClient, base: str, headers: dict[str, str], agent_id: str) -> dict[str, Any]:
+ resp = await client.get(f"{base}/api/agents/{agent_id}", headers=headers)
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def _create_agent(
+ client: httpx.AsyncClient,
+ base: str,
+ headers: dict[str, str],
+ *,
+ name: str,
+ description: str,
+ model: str | None,
+ skills: list[str] | None,
+ soul: str,
+ agent_id: str | None = None,
+) -> tuple[bool, dict[str, Any] | str]:
+ payload: dict[str, Any] = {"name": name, "description": description, "soul": soul}
+ if agent_id is not None:
+ payload["id"] = agent_id
+ if model is not None:
+ payload["model"] = model
+ if skills is not None:
+ payload["skills"] = skills
+ resp = await client.post(f"{base}/api/agents", json=payload, headers=headers)
+ if resp.status_code == 201:
+ return True, resp.json()
+ return False, resp.text
+
+
+async def _append_thread_message(
+ client: httpx.AsyncClient,
+ base: str,
+ headers: dict[str, str],
+ thread_id: str,
+ content: str,
+) -> None:
+ """向指定 thread 的 checkpoint 追加一条 human 消息(广播上下文,不触发 LLM run)。
+
+ 失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
+ 前端对该席位 thread 发起的 ``/run/stream``。
+ """
+ state_url = f"{base}/api/threads/{thread_id}/state"
+ get_resp = await client.get(state_url, headers=headers)
+ if get_resp.status_code != 200:
+ logger.warning("get state %s failed: %s %s", thread_id, get_resp.status_code, get_resp.text[:200])
+ return
+ state = get_resp.json()
+ values = state.setdefault("values", {})
+ messages = values.setdefault("messages", [])
+ if not isinstance(messages, list):
+ messages = []
+ values["messages"] = messages
+ messages.append({"type": "human", "content": content})
+ post_resp = await client.post(state_url, json=state, headers=headers)
+ if post_resp.status_code >= 400:
+ logger.warning("update state %s failed: %s %s", thread_id, post_resp.status_code, post_resp.text[:200])
+
+
+async def _broadcast(
+ client: httpx.AsyncClient,
+ base: str,
+ headers: dict[str, str],
+ message: str,
+ agent_threads: dict[str, str],
+ skip: list[str],
+) -> None:
+ """并行向 ``agent_threads`` 中除 ``skip`` 外的所有 thread 追加同一条 human 消息。"""
+ targets = [tid for name, tid in agent_threads.items() if name not in skip]
+ if not targets:
+ return
+ await asyncio.gather(
+ *(_append_thread_message(client, base, headers, tid, message) for tid in targets),
+ return_exceptions=True,
+ )
+
+
+_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
+
+# Mirrors the upstream agent-id validator in the LangGraph runtime. Reject early
+# so callers get a clear 400 here instead of a stream that aborts with HTTP 422
+# from the loopback /api/threads/.../runs/stream endpoint.
+_AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
+
+
+def _validate_agent_id(value: str, field: str) -> str:
+ if not _AGENT_ID_PATTERN.match(value):
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"{field} '{value}' is not a valid agent id. "
+ f"Must match ^[A-Za-z0-9_-]+$ (use the hyphen-case id, not the display name)."
+ ),
+ )
+ return value
+
+
+def _extract_dispatch(tool_call: dict[str, Any]) -> tuple[str, str]:
+ """Return ``(agent_name, task)`` parsed from a leader tool call.
+
+ Two formats are accepted:
+
+ 1. **Structured** — ``{"name": "agent_orchestration", "args": {"agent_name": ..., "task": ...}}``.
+ This is what most LLMs emit when told to "use the agent_orchestration skill",
+ and the form the roundtable coordinator's SOUL prompt elicits.
+
+ 2. **Legacy bash argv** — ``{"name": "bash", "args": {"command": '... .py "X" "Y" ...'}}``.
+ Kept as a fallback so older skills that dispatch through bash still work.
+ """
+ name = (tool_call.get("name") or "").lower()
+ args = tool_call.get("args")
+
+ # Format 1: structured agent_orchestration call.
+ if name == "agent_orchestration" and isinstance(args, dict):
+ agent_name = str(args.get("agent_name") or "").strip()
+ task = str(args.get("task") or "").strip()
+ if agent_name and task:
+ return _strip_display_suffix(agent_name), task
+
+ # Format 2: legacy bash + .py "X" "Y".
+ match = _TOOL_CALL_PATTERN.search(str(args))
+ if match:
+ return _strip_display_suffix(match.group(1)), match.group(2)
+
+ return "", ""
+
+
+def _strip_display_suffix(agent_name: str) -> str:
+ """Tolerate models that paste the human-readable suffix into agent_name.
+
+ The coordinator SOUL lists each seat as ``agent_name: roundtable-x(中文):...``
+ so weaker models sometimes echo the entire ``roundtable-x(中文)`` label
+ back as the dispatch target. Strip everything from the first non-id
+ character onward so we recover the bare hyphen-case id; if what's left
+ still violates the id pattern we hand it back untouched and let the
+ upstream validator reject it.
+ """
+ head = re.split(r"[^A-Za-z0-9_-]", agent_name, maxsplit=1)[0]
+ return head or agent_name
+
+
+def _flatten_content(content: Any) -> str:
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts: list[str] = []
+ for item in content:
+ if isinstance(item, dict):
+ parts.append(str(item.get("text", "")))
+ else:
+ parts.append(str(item))
+ return "".join(parts)
+ return str(content)
+
+
+def _run_payload(
+ *,
+ agent_name: str,
+ model_name: str,
+ thread_id: str,
+ new_message: str,
+ excluded_tools: list[str],
+ skill_stop_names: list[str],
+) -> dict[str, Any]:
+ """构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
+
+ - ``context.agent_name``:本次 run 使用的 agent(内置 id 或 init 创建的 coordinator id)。
+ - ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
+ - ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
+ 调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
+ """
+ return {
+ "input": {
+ "messages": [
+ {
+ "type": "human",
+ "content": [{"type": "text", "text": new_message}],
+ "additional_kwargs": {},
+ }
+ ]
+ },
+ "config": {"recursion_limit": 1000},
+ "context": {
+ "agent_name": agent_name,
+ "model_name": model_name,
+ "mode": "pro",
+ "reasoning_effort": "medium",
+ "thinking_enabled": True,
+ "is_plan_mode": False,
+ "subagent_enabled": False,
+ "thread_id": thread_id,
+ },
+ "stream_mode": ["messages-tuple", "values"],
+ "stream_subgraphs": True,
+ "stream_resumable": True,
+ "assistant_id": "lead_agent",
+ "on_disconnect": "continue",
+ "excluded_tools": excluded_tools,
+ "skill_stop_names": skill_stop_names,
+ }
+
+
+# ---------------------------------------------------------------------------
+# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
+# ---------------------------------------------------------------------------
+
+
+@router.post("/init")
+async def init_multi_agent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
+ """流式初始化圆桌 Step 2 所需的 thread 映射与协调智能体。
+
+ 请求体::
+
+ {
+ "agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
+ "main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
+ "model": "deepseek-chat" // 可选,写入 coordinator agent
+ }
+
+ SSE 事件(每条 ``data: {...}\\n\\n``)::
+
+ seat_ready — 某一席位 thread 已创建(``asyncio.as_completed`` 顺序,先完先发)
+ coordinator_ready — 总控 agent + thread 已就绪
+ init_done — ``thread_ids`` 全量映射,前端存 ``threadIds`` 并开始编排
+ error — 失败详情(同步校验失败则直接 HTTP 4xx,不进 SSE)
+
+ 协调智能体 SOUL 在运行时根据各席位 ``GET /api/agents/{id}`` 的 name/description 拼接,
+ 强调 ``agent_name`` 必须用英文 id(``roundtable-intelligence``),禁止带中文括号后缀。
+ """
+ agent_names_in = payload.get("agent_names") or []
+ agent_names: list[str] = [str(name).lower() for name in agent_names_in]
+ main_agent_name: str = str(payload.get("main_agent_name") or "main-agent").lower()
+ model: str | None = payload.get("model")
+
+ if not agent_names:
+ raise HTTPException(status_code=400, detail="agent_names is required")
+
+ for name in agent_names:
+ _validate_agent_id(name, "agent_names entry")
+ _validate_agent_id(main_agent_name, "main_agent_name")
+
+ base = _loopback_base(request)
+ headers = _auth_headers(request)
+
+ def _frame(payload: dict[str, Any]) -> str:
+ return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
+
+ async def stream_generator() -> AsyncIterator[str]:
+ # trust_env=False: same-process loopback to 127.0.0.1 — honoring
+ # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
+ # causes httpx to route loopback calls through an unreachable upstream
+ # proxy and wait ~40 seconds for timeout before falling back.
+ async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
+ try:
+ # Per-seat prepare task — same body as before, but we no longer
+ # await them with asyncio.gather. Instead, asyncio.as_completed
+ # surfaces them in REAL completion order so we can emit a
+ # seat_ready frame the moment each finishes.
+ async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
+ info = await _get_agent(client, base, headers, sub_id)
+ display_name = str(info.get("name") or sub_id)
+ description = info.get("description", "") or ""
+ thread_id = await _create_thread(client, base, headers)
+ return sub_id.lower(), thread_id, display_name, description
+
+ tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
+ prepared: list[tuple[str, str, str, str]] = []
+ try:
+ for fut in asyncio.as_completed(tasks):
+ sid, thread_id, display_name, description = await fut
+ prepared.append((sid, thread_id, display_name, description))
+ yield _frame({
+ "event": "seat_ready",
+ "agent_name": sid,
+ "thread_id": thread_id,
+ "display_name": display_name,
+ "description": description,
+ })
+ except Exception:
+ # Cancel any still-pending sibling so we don't leak tasks
+ # past the AsyncClient close.
+ for t in tasks:
+ if not t.done():
+ t.cancel()
+ raise
+
+ agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
+ descriptions: list[str] = [
+ f"- agent_name: {sid}({display_name}):{description}"
+ for sid, _, display_name, description in prepared
+ ]
+
+ # 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
+ soul = (
+ "#核心定位:\n"
+ "用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
+ "当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
+ "#调度方法:\n"
+ "通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
+ "注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),"
+ "不要把括号里的中文显示名拼进 agent_name 里。\n"
+ "#可调度智能体:\n"
+ + "\n".join(descriptions)
+ )
+
+ coord_thread = await _create_thread(client, base, headers)
+ agent_threads[main_agent_name] = coord_thread
+ ok, result = await _create_agent(
+ client,
+ base,
+ headers,
+ name=main_agent_name,
+ description="你是总控控制协调智能体,负责协调其他智能体干活",
+ model=model,
+ skills=["agent_orchestration"],
+ soul=soul,
+ agent_id=main_agent_name,
+ )
+ if not ok and isinstance(result, str) and "exists" not in result:
+ yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
+ return
+
+ yield _frame({
+ "event": "coordinator_ready",
+ "main_agent_name": main_agent_name,
+ "thread_id": coord_thread,
+ })
+ yield _frame({
+ "event": "init_done",
+ "main_agent_name": main_agent_name,
+ "thread_ids": agent_threads,
+ })
+ except HTTPException as exc:
+ logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
+ yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
+ except Exception as exc:
+ logger.exception("multi-agent init unexpected failure")
+ yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
+
+ return StreamingResponse(
+ stream_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "Connection": "keep-alive",
+ },
+ )
+
+
+# ---------------------------------------------------------------------------
+# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
+# ---------------------------------------------------------------------------
+
+
+async def _stream_upstream(
+ client: httpx.AsyncClient,
+ base: str,
+ headers: dict[str, str],
+ thread_id: str,
+ body: dict[str, Any],
+) -> AsyncIterator[tuple[str | None, list[str] | None]]:
+ """消费 ``/api/threads/{thread_id}/runs/stream``,逐行 yield 原始 SSE。
+
+ 产出元组:
+ - ``(line, None)`` — 可原样转发给浏览器(含 ``event:`` / ``data:`` 行);
+ - ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表,
+ 供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。
+
+ 日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。
+ """
+
+ url = f"{base}/api/threads/{thread_id}/runs/stream"
+ captured: list[str] = []
+ t_send = time.perf_counter()
+ async with client.stream("POST", url, json=body, headers=headers) as resp:
+ t_headers = time.perf_counter()
+ if resp.status_code != 200:
+ body_text = (await resp.aread()).decode("utf-8", "replace")[:1000]
+ raise HTTPException(
+ status_code=502,
+ detail=f"upstream {url} returned HTTP {resp.status_code}: {body_text}",
+ )
+ agent_for_log = (body.get("context") or {}).get("agent_name") or thread_id
+ logger.info(
+ "[upstream-timing] %s headers_in=%.3fs",
+ agent_for_log,
+ t_headers - t_send,
+ )
+ first_line = True
+ async for line in resp.aiter_lines():
+ if not line:
+ continue
+ if first_line:
+ first_line = False
+ logger.info(
+ "[upstream-timing] %s first_line=%.3fs (LangGraph runtime startup + LLM first byte)",
+ agent_for_log,
+ time.perf_counter() - t_headers,
+ )
+ yield line, None
+ if line.startswith("data:"):
+ captured.append(line)
+ yield None, captured
+
+
+def _parse_last_messages(captured: list[str]) -> list[dict[str, Any]]:
+ """Return the full ``messages`` list from the most recent ``values`` snapshot.
+
+ The upstream sends the final ``values`` frame second-to-last (the very last
+ line is the ``end`` event with ``null``). We tolerate trailing non-JSON
+ frames (e.g. ``data: null``) by scanning backwards for the first frame that
+ successfully parses as a state object with a ``messages`` key.
+ """
+ if not captured:
+ raise HTTPException(status_code=502, detail="upstream produced no data lines")
+
+ for raw_line in reversed(captured):
+ raw = raw_line[5:].strip() if raw_line.startswith("data:") else raw_line.strip()
+ if not raw or raw == "null":
+ continue
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(parsed, dict) and "messages" in parsed:
+ messages = parsed.get("messages") or []
+ if isinstance(messages, list):
+ return messages
+
+ raise HTTPException(status_code=502, detail="no parseable values frame in upstream stream")
+
+
+def _find_trailing_ai_text(messages: list[dict[str, Any]], *, exclude_text: str = "") -> str:
+ """Return the text content of the latest AIMessage with no tool_calls.
+
+ Used by the clarification branch: ClarificationMiddleware ends the run
+ after emitting the structured ToolMessage, but the model often produces a
+ richer human-readable follow-up AIMessage right before the interrupt.
+ Prefer that text over the brief preamble that accompanied the
+ ask_clarification tool_call. When the only trailing AIMessage matches the
+ preamble (``exclude_text``), return empty so the caller can fall back to
+ the question itself.
+ """
+ for msg in reversed(messages):
+ if not isinstance(msg, dict):
+ continue
+ if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
+ continue
+ if msg.get("tool_calls"):
+ continue
+ text = _flatten_content(msg.get("content", "")).strip()
+ if not text:
+ continue
+ if exclude_text and text == exclude_text.strip():
+ continue
+ return text
+ return ""
+
+
+def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
+ """Locate the latest AIMessage that carries dispatch tool calls.
+
+ The very last message can be a skill-stop ToolMessage injected by
+ ``SkillStopMiddleware`` when it intercepts the dispatch, or a clarification
+ ToolMessage. Either way, the actionable tool_calls live on the AIMessage
+ *before* it, so we walk backwards until we hit one with ``tool_calls``.
+ Returns the latest AIMessage when none of them carry tool_calls so the
+ caller can still extract its text content.
+ """
+ last_ai: dict[str, Any] = {}
+ for msg in reversed(messages):
+ if not isinstance(msg, dict):
+ continue
+ if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
+ if not last_ai:
+ last_ai = msg
+ if msg.get("tool_calls"):
+ return msg
+ return last_ai
+
+
+async def _leader_run(
+ client: httpx.AsyncClient,
+ base: str,
+ headers: dict[str, str],
+ agent_threads: dict[str, str],
+ agent_name: str,
+ new_message: str,
+ skill_stop_names: list[str],
+ model_name: str,
+) -> AsyncIterator[str | dict[str, Any]]:
+ """总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
+
+ 时序要点:
+ 1. **预广播**(与 LLM 并行):把用户/综合消息写入其它席位 thread,skip 总控自身;
+ 2. 跑总控 thread 的 stream,yield 每一行原始 SSE;
+ 3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表;
+ 4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
+
+ ``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
+ """
+ t0 = time.perf_counter()
+
+ # 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
+ pre_broadcast_task = asyncio.create_task(
+ _broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
+ )
+ thread_id = agent_threads[agent_name]
+ effective_stop_names = list({"agent_orchestration", *skill_stop_names})
+ body = _run_payload(
+ agent_name=agent_name,
+ model_name=model_name,
+ thread_id=thread_id,
+ new_message=new_message,
+ excluded_tools=["web_search"],
+ skill_stop_names=effective_stop_names,
+ )
+ t_payload_built = time.perf_counter()
+ logger.info(
+ "[leader-timing] %s payload_built=%.3fs (broadcast running in background)",
+ agent_name,
+ t_payload_built - t0,
+ )
+
+ captured: list[str] = []
+ first_line_ts: float | None = None
+ line_count = 0
+ async for line, done in _stream_upstream(client, base, headers, thread_id, body):
+ if line is not None:
+ if first_line_ts is None:
+ first_line_ts = time.perf_counter()
+ logger.info(
+ "[leader-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
+ agent_name,
+ first_line_ts - t_payload_built,
+ first_line_ts - t0,
+ )
+ line_count += 1
+ yield line
+ elif done is not None:
+ captured = done
+ t_stream_done = time.perf_counter()
+ logger.info(
+ "[leader-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
+ agent_name,
+ t_stream_done - (first_line_ts or t_payload_built),
+ line_count,
+ t_stream_done - t0,
+ )
+
+ messages = _parse_last_messages(captured)
+ # Pre-broadcast must finish before we yield the final status frame on any
+ # branch — the seats that get dispatched next rely on having seen the
+ # original user message. The upstream LLM stream usually takes several
+ # seconds, so the broadcast has typically already completed and this
+ # await is effectively free.
+ t_before_broadcast_await = time.perf_counter()
+ try:
+ await pre_broadcast_task
+ except Exception as exc:
+ logger.warning("pre-broadcast failed: %s", exc)
+ t_after_broadcast_await = time.perf_counter()
+ logger.info(
+ "[leader-timing] %s pre_broadcast_await=%.3fs (>0 means broadcast was still in flight after LLM finished)",
+ agent_name,
+ t_after_broadcast_await - t_before_broadcast_await,
+ )
+
+ dispatch_msg = _find_dispatch_message(messages)
+ leader_text = _flatten_content(dispatch_msg.get("content", ""))
+ tool_calls = dispatch_msg.get("tool_calls") or []
+
+ # 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
+ # 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
+ clarification_call = next(
+ (tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
+ None,
+ )
+ if clarification_call is not None:
+ cargs = clarification_call.get("args") or {}
+ question = str(cargs.get("question") or "").strip()
+ follow_up = _find_trailing_ai_text(messages, exclude_text=leader_text)
+ yield {
+ "status": "clarification",
+ "content": follow_up or question or leader_text,
+ "question": question,
+ "clarification_type": str(cargs.get("clarification_type") or ""),
+ "clarification_context": str(cargs.get("context") or ""),
+ "options": cargs.get("options") or [],
+ "allow_custom": bool(cargs.get("allow_custom", True)),
+ "allow_multiple": resolve_allow_multiple(cargs),
+ "agent_name": agent_name,
+ }
+ return
+
+ if not tool_calls:
+ # 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
+ yield {"status": [], "content": leader_text, "agent_name": agent_name}
+ return
+
+ # 派活广播:告知各席位「总控给 X 派了什么活」,与即将发起的 special run 配合。
+ # sets and we don't care about ordering, so serializing them just
+ # multiplied latency before the frontend could start a sub-agent run.
+ dispatched: list[list[str]] = []
+ dispatch_broadcasts: list[asyncio.Task[None]] = []
+ for tool_call in tool_calls:
+ try:
+ sub_name, task_text = _extract_dispatch(tool_call)
+ if not sub_name:
+ continue
+ dispatch_broadcasts.append(
+ asyncio.create_task(
+ _broadcast(
+ client,
+ base,
+ headers,
+ f"总控智能体给{sub_name}派活,内容为:" + task_text,
+ agent_threads,
+ [agent_name, sub_name],
+ )
+ )
+ )
+ dispatched.append([sub_name, task_text])
+ except Exception as exc:
+ logger.warning("dispatch failed: %s", exc)
+ if dispatch_broadcasts:
+ t_before_dispatch = time.perf_counter()
+ await asyncio.gather(*dispatch_broadcasts, return_exceptions=True)
+ logger.info(
+ "[leader-timing] %s dispatch_broadcasts_await=%.3fs count=%d (gates how soon the next sub agent can start)",
+ agent_name,
+ time.perf_counter() - t_before_dispatch,
+ len(dispatch_broadcasts),
+ )
+ yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
+
+
+async def _special_run(
+ client: httpx.AsyncClient,
+ base: str,
+ headers: dict[str, str],
+ agent_threads: dict[str, str],
+ agent_name: str,
+ new_message: str,
+ skill_stop_names: list[str],
+ model_name: str,
+) -> AsyncIterator[str | dict[str, Any]]:
+ """子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
+
+ - ``excluded_tools`` 禁用派活/澄清等,避免席位抢总控活;
+ - 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文;
+ - **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要,
+ 同时尽量让前端先收到结果关闭 loading。
+ """
+ t0 = time.perf_counter()
+ thread_id = agent_threads[agent_name]
+ body = _run_payload(
+ agent_name=agent_name,
+ model_name=model_name,
+ thread_id=thread_id,
+ new_message=new_message,
+ excluded_tools=["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"],
+ skill_stop_names=skill_stop_names,
+ )
+ t_payload_built = time.perf_counter()
+ logger.info(
+ "[special-timing] %s payload_built=%.3fs",
+ agent_name,
+ t_payload_built - t0,
+ )
+
+ captured: list[str] = []
+ first_line_ts: float | None = None
+ line_count = 0
+ async for line, done in _stream_upstream(client, base, headers, thread_id, body):
+ if line is not None:
+ if first_line_ts is None:
+ first_line_ts = time.perf_counter()
+ logger.info(
+ "[special-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
+ agent_name,
+ first_line_ts - t_payload_built,
+ first_line_ts - t0,
+ )
+ line_count += 1
+ yield line
+ elif done is not None:
+ captured = done
+ t_stream_done = time.perf_counter()
+ logger.info(
+ "[special-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
+ agent_name,
+ t_stream_done - (first_line_ts or t_payload_built),
+ line_count,
+ t_stream_done - t0,
+ )
+
+ messages = _parse_last_messages(captured)
+ # Sub-agent's final deliverable is the latest AIMessage's text content.
+ last_msg: dict[str, Any] = {}
+ for msg in reversed(messages):
+ if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
+ last_msg = msg
+ break
+ content = _flatten_content(last_msg.get("content", ""))
+ # Kick the "子智能体完成" broadcast off in the background and yield the
+ # status frame first — the next leader cycle that depends on this
+ # broadcast having landed only starts after a fresh /run/stream request
+ # from the frontend, which is many seconds away (full leader LLM call).
+ # Awaiting after the yield ensures the task finishes before the AsyncClient
+ # closes; the SSE connection stays open a bit longer but the frontend has
+ # already consumed the result.
+ t_before_post = time.perf_counter()
+ post_broadcast_task = asyncio.create_task(
+ _broadcast(
+ client,
+ base,
+ headers,
+ f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content,
+ agent_threads,
+ [agent_name],
+ )
+ )
+ yield {
+ "status": f"子智能体{agent_name}完成{new_message}工作",
+ "content": content,
+ "agent_name": agent_name,
+ }
+ try:
+ await post_broadcast_task
+ except Exception as exc:
+ logger.warning("post-broadcast failed: %s", exc)
+ logger.info(
+ "[special-timing] %s post_broadcast_total=%.3fs (broadcast time after yield — keeps SSE open this long)",
+ agent_name,
+ time.perf_counter() - t_before_post,
+ )
+
+
+# ---------------------------------------------------------------------------
+# POST /api/multi-agent/run/stream — leader / special 单轮 SSE
+# ---------------------------------------------------------------------------
+
+
+@router.post("/run/stream")
+async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
+ """驱动总控或子智能体的一轮 LangGraph run。
+
+ 请求体::
+
+ {
+ "agent_type": "leader" | "special",
+ "d_agent_thread_id": { "": "", ... }, // init_done.thread_ids
+ "agent_name": "<本次运行的 agent_id>",
+ "new_message": "<用户消息或派活 task 文本>",
+ "model": "deepseek-chat",
+ "skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration
+ }
+
+ 响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。
+ 客户端应使用 AbortSignal 支持「挂起协商」时取消 in-flight 请求。
+ """
+ agent_type: str = str(payload.get("agent_type") or "leader").lower()
+ agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
+ agent_name: str = str(payload.get("agent_name") or "").lower()
+ new_message: str = str(payload.get("new_message") or "")
+ skill_stop_names: list[str] = payload.get("skill_stop_names") or []
+ model_name: str = str(payload.get("model") or "deepseek-chat")
+
+ if not agent_name or agent_name not in agent_threads:
+ raise HTTPException(
+ status_code=400,
+ detail=f"agent_name '{agent_name}' is missing from d_agent_thread_id",
+ )
+ _validate_agent_id(agent_name, "agent_name")
+ for key in agent_threads:
+ _validate_agent_id(key, "d_agent_thread_id key")
+
+ base = _loopback_base(request)
+ headers = _auth_headers(request)
+
+ async def stream_generator() -> AsyncIterator[str]:
+ # trust_env=False: same-process loopback to 127.0.0.1 — honoring
+ # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
+ # causes httpx to route loopback calls through an unreachable upstream
+ # proxy and wait ~40 seconds for timeout before falling back.
+ async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
+ try:
+ if agent_type == "leader":
+ gen = _leader_run(
+ client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
+ )
+ else:
+ gen = _special_run(
+ client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
+ )
+ async for chunk in gen:
+ if isinstance(chunk, dict):
+ yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
+ else:
+ yield chunk if chunk.endswith("\n") else chunk + "\n"
+ except HTTPException as exc:
+ logger.warning("multi-agent stream HTTPException: %s %s", exc.status_code, exc.detail)
+ err_frame = {
+ "status": "error",
+ "content": f"[{exc.status_code}] {exc.detail}",
+ "agent_name": agent_name,
+ }
+ yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
+ except Exception as exc:
+ logger.exception("multi-agent stream unexpected failure")
+ err_frame = {
+ "status": "error",
+ "content": f"unexpected: {exc!r}",
+ "agent_name": agent_name,
+ }
+ yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
+
+ return StreamingResponse(
+ stream_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "Connection": "keep-alive",
+ },
+ )
diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py
new file mode 100644
index 00000000..9acc527f
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py
@@ -0,0 +1,275 @@
+"""圆桌规划 · Step 1→2 之间「智能体推荐」Gateway 路由。
+
+前端页面:推荐弹窗 ``RecommendAgentsDialog``
+前端模块:``frontend-web/src/roundtable-planning/api/recommend.ts``
+
+职责
+----
+根据 Step 1 的 ``IntentSummary`` 与前端过滤后的候选池,驱动内置
+``roundtable-recommender``(SOUL 见 ``.deer-flow/agents/roundtable-recommender/``),
+输出 2–6 个 ``{agent_id, reason}`` 推荐席位。
+
+设计为**无状态单次调用**:
+- **不暴露** ``/init``:每次请求内部 ``_create_thread``,推荐 agent 不需要历史轮次。
+- 候选池过滤由前端完成(剔除 ``roundtable-intent``、``roundtable-recommender``、
+ ``roundtable-coordinator-*``),本路由只做校验与 clamp。
+
+SSE 协议
+--------
+1. 透传上游 LangGraph 流(前端用 ``parseRecommendStreamContent`` 把正文拆成
+ ``[RECOMMEND_READY]`` 前的 ``rationale`` 与 JSON 里的 ``picks``,实现卡片增量渲染)。
+2. 末尾状态帧::
+
+ { "status": "done"|"asking"|"error", "content": "<全文>", "picks": [...]|null }
+
+ - ``done``:``picks`` 经 ``_validate_picks`` 后 ≥2 条且 id 均在候选池内;
+ - ``asking``:未匹配 ``[RECOMMEND_READY]`` 或校验后不足 2 条(前端走 FALLBACK 默认 6 席);
+ - ``error``:HTTP/未捕获异常。
+
+与 SOUL 的契约
+--------------
+模型须在自然语言思路后输出::
+
+ [RECOMMEND_READY]
+ ```json
+ {"picks": [{"agent_id": "...", "reason": "..."}, ...]}
+ ```
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any, AsyncIterator
+
+import httpx
+from fastapi import APIRouter, Body, HTTPException, Request
+from fastapi.responses import StreamingResponse
+
+from app.gateway.routers.multi_agent import (
+ _auth_headers,
+ _create_thread,
+ _flatten_content,
+ _loopback_base,
+ _parse_last_messages,
+ _run_payload,
+ _stream_upstream,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/recommend", tags=["recommend"])
+
+RECOMMENDER_AGENT_ID = "roundtable-recommender"
+
+# 从模型最终 AIMessage 全文提取 picks;非贪婪 ``.*?`` 只匹配第一个 json 块。
+_RECOMMEND_READY_PATTERN = re.compile(
+ r"\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```",
+ re.DOTALL,
+)
+
+# 与 SOUL / 前端 ``RecommendAgentsDialog`` 一致:少于 2 无法成桌,多于 6 由网关截断。
+# 前端确认按钮另限制 2≤N≤8(用户可手动多选),后端此处只负责模型输出校验。
+_MIN_PICKS = 2
+_MAX_PICKS = 6
+
+
+def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> str:
+ """Render the intent + candidate pool into the single user message the
+ recommender agent reads. Keep the format close to what the SOUL example
+ shows so the model has a clean pattern to mirror."""
+ objective = str(intent.get("objective") or "").strip() or "(未提供)"
+ constraints = intent.get("constraints") or []
+ assumptions = intent.get("assumptions") or []
+
+ constraint_lines = "; ".join(str(c).strip() for c in constraints if str(c).strip()) or "(无)"
+ assumption_lines = "; ".join(str(a).strip() for a in assumptions if str(a).strip()) or "(无)"
+
+ candidate_lines: list[str] = []
+ for c in candidates:
+ cid = str(c.get("agent_id") or c.get("id") or "").strip()
+ cname = str(c.get("name") or "").strip()
+ cdesc = str(c.get("description") or "").strip()
+ if not cid:
+ continue
+ # Format: "- (): " — matches the SOUL example.
+ suffix = f" ({cname})" if cname else ""
+ desc_part = f": {cdesc}" if cdesc else ""
+ candidate_lines.append(f"- {cid}{suffix}{desc_part}")
+
+ candidate_block = "\n".join(candidate_lines) if candidate_lines else "(候选池为空)"
+
+ return (
+ f"任务目标:{objective}\n"
+ f"约束:{constraint_lines}\n"
+ f"关键假设:{assumption_lines}\n\n"
+ f"候选智能体池:\n{candidate_block}\n\n"
+ f"请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。"
+ )
+
+
+def _validate_picks(
+ raw_picks: Any,
+ candidate_ids: set[str],
+) -> list[dict[str, str]]:
+ """Filter the model's picks against the candidate pool and clamp to 2-6.
+
+ Drops picks whose ``agent_id`` isn't in the candidate set (guards against
+ model hallucination), de-duplicates, and trims to ``_MAX_PICKS`` if the
+ model over-picked. Returns ``[]`` if validation leaves us with fewer than
+ ``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
+ defaults".
+ """
+ if not isinstance(raw_picks, list):
+ return []
+
+ picked: list[dict[str, str]] = []
+ seen: set[str] = set()
+ for item in raw_picks:
+ if not isinstance(item, dict):
+ continue
+ aid = str(item.get("agent_id") or "").strip()
+ reason = str(item.get("reason") or "").strip()
+ if not aid or aid not in candidate_ids or aid in seen:
+ continue
+ picked.append({"agent_id": aid, "reason": reason})
+ seen.add(aid)
+ if len(picked) >= _MAX_PICKS:
+ break
+
+ if len(picked) < _MIN_PICKS:
+ return []
+ return picked
+
+
+@router.post("/stream")
+async def stream_recommend(request: Request, payload: dict = Body(...)) -> StreamingResponse:
+ """Drive one recommendation turn via SSE.
+
+ Body::
+
+ {
+ "intent": {"objective": "...", "constraints": [...], "assumptions": [...]},
+ "candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...],
+ "model": "deepseek-chat" # optional
+ }
+
+ Stream: transparent upstream LangGraph frames, followed by one status
+ frame::
+
+ {
+ "status": "done" | "asking" | "error",
+ "content": "",
+ "picks": [{"agent_id": "...", "reason": "..."}] | null
+ }
+
+ ``done`` means picks were parsed and validated; ``asking`` means the
+ marker was missing or validation left fewer than 2 valid picks (caller
+ should apply its fallback list); ``error`` is upstream failure.
+ """
+ intent = payload.get("intent") or {}
+ candidates = payload.get("candidates") or []
+ model_name = str(payload.get("model") or "deepseek-chat")
+
+ if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip():
+ raise HTTPException(status_code=400, detail="intent.objective is required")
+ if not isinstance(candidates, list) or not candidates:
+ raise HTTPException(status_code=400, detail="candidates must be a non-empty array")
+
+ candidate_ids = {
+ str(c.get("agent_id") or c.get("id") or "").strip()
+ for c in candidates
+ if isinstance(c, dict)
+ }
+ candidate_ids.discard("")
+ if not candidate_ids:
+ raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id")
+
+ prompt = _build_prompt(intent, candidates)
+ base = _loopback_base(request)
+ headers = _auth_headers(request)
+
+ async def stream_generator() -> AsyncIterator[str]:
+ # trust_env=False: same-process loopback to 127.0.0.1 — honoring
+ # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
+ # causes httpx to route loopback calls through an unreachable upstream
+ # proxy and wait ~40 seconds for timeout before falling back.
+ async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
+ try:
+ # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
+ thread_id = await _create_thread(client, base, headers)
+
+ body = _run_payload(
+ agent_name=RECOMMENDER_AGENT_ID,
+ model_name=model_name,
+ thread_id=thread_id,
+ new_message=prompt,
+ # 推荐只做判断:禁止澄清、派活、文件等,避免跑题或二次追问。
+ excluded_tools=[
+ "web_search",
+ "present_files",
+ "view_image",
+ "agent_orchestration",
+ "ask_clarification",
+ ],
+ skill_stop_names=[],
+ )
+
+ captured: list[str] = []
+ 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_content = ""
+ for msg in reversed(messages):
+ if not isinstance(msg, dict):
+ continue
+ if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
+ continue
+ text = _flatten_content(msg.get("content", "")).strip()
+ if text:
+ last_ai_content = text
+ break
+
+ # 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
+ picks: list[dict[str, str]] = []
+ match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
+ if match:
+ try:
+ parsed = json.loads(match.group(1))
+ except json.JSONDecodeError as exc:
+ logger.warning("recommend picks JSON parse failed: %s", exc)
+ parsed = {}
+ if isinstance(parsed, dict):
+ picks = _validate_picks(parsed.get("picks"), candidate_ids)
+
+ # asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
+ status_value = "done" if picks else "asking"
+ final_frame: dict[str, Any] = {
+ "status": status_value,
+ "content": last_ai_content,
+ "picks": picks if picks else None,
+ }
+ yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
+
+ except HTTPException as exc:
+ logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail)
+ err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None}
+ yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
+ except Exception as exc:
+ logger.exception("recommend stream unexpected failure")
+ err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": None}
+ yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
+
+ return StreamingResponse(
+ stream_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "Connection": "keep-alive",
+ },
+ )
diff --git a/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py b/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py
index 0bb99f8b..38129fa5 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py
@@ -54,6 +54,9 @@ class RunCreateRequest(BaseModel):
after_seconds: float | None = Field(default=None, description="Delayed execution")
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
+ excluded_tools: list[str] | None = Field(default=None, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
+ skill_stop_names: list[str] | None = Field(default=None, description="Skill names to stop execution when invoked (e.g. ['newsletter-generation'])")
+ skill_stop_message: str | None = Field(default=None, description="Custom message to display when skill stop is triggered")
class RunResponse(BaseModel):
diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py
index 15db1e62..e29a3c93 100644
--- a/offline-backend-20260512/backend/app/gateway/services.py
+++ b/offline-backend-20260512/backend/app/gateway/services.py
@@ -163,6 +163,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"max_concurrent_subagents",
"agent_name",
"is_bootstrap",
+ "excluded_tools",
+ "skill_stop_names",
+ "skill_stop_message",
}
)
@@ -435,7 +438,15 @@ async def start_run(
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
- merge_run_context_overrides(config, getattr(body, "context", None))
+ body_context = dict(getattr(body, "context", None) or {})
+ # Promote whitelisted top-level body fields into the context map so
+ # callers can pass them either nested under ``context`` or as flat
+ # top-level fields (``excluded_tools``, ``skill_stop_names``, …).
+ for top_level_key in ("excluded_tools", "skill_stop_names", "skill_stop_message"):
+ value = getattr(body, top_level_key, None)
+ if value is not None and top_level_key not in body_context:
+ body_context[top_level_key] = value
+ merge_run_context_overrides(config, body_context or None)
stream_modes = normalize_stream_modes(body.stream_mode)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
index 330b6f76..d567ce52 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
@@ -312,6 +312,37 @@ def _build_middlewares(
middlewares.append(SetupAgentApprovalMiddleware())
+ # SkillStopMiddleware — intercepts skill invocations and halts execution
+ # when a configured skill is about to run. Request-level options
+ # (skill_stop_names / skill_stop_message) take precedence; static
+ # config in ``app_config.skill_stop`` is the fallback.
+ skill_stop_names = cfg.get("skill_stop_names")
+ skill_stop_message = cfg.get("skill_stop_message")
+ if skill_stop_names:
+ from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
+
+ middlewares.append(
+ SkillStopMiddleware(
+ skill_names=skill_stop_names,
+ skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
+ stop_message=skill_stop_message,
+ stop_on_match=True,
+ )
+ )
+ logger.info("SkillStopMiddleware enabled (from request): %s", skill_stop_names)
+ elif resolved_app_config.skill_stop.enabled and resolved_app_config.skill_stop.skill_names:
+ from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
+
+ middlewares.append(
+ SkillStopMiddleware(
+ skill_names=resolved_app_config.skill_stop.skill_names,
+ skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
+ stop_message=resolved_app_config.skill_stop.stop_message,
+ stop_on_match=resolved_app_config.skill_stop.stop_on_match,
+ )
+ )
+ logger.info("SkillStopMiddleware enabled (from config): %s", resolved_app_config.skill_stop.skill_names)
+
# ClarificationMiddleware should always be last
middlewares.append(ClarificationMiddleware())
return middlewares
@@ -346,6 +377,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
+ requested_excluded_tools = cfg.get("excluded_tools") or None
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
@@ -416,6 +448,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
model_name=model_name,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
+ excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
)
+ [setup_agent],
@@ -442,6 +475,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
+ excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
),
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py
index 27b732c7..3131c09b 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py
@@ -6,6 +6,8 @@ from collections.abc import Callable
from hashlib import sha256
from typing import override
+from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
+
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
@@ -67,7 +69,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
question = args.get("question", "")
clarification_type = args.get("clarification_type", "missing_info")
context = args.get("context")
- options = self._normalize_options(args.get("options", []))
+ options = self._normalize_options(args.get("options", []))
# Type-specific icons
type_icons = {
@@ -95,62 +97,63 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
# Add options in a cleaner format
if options and len(options) > 0:
message_parts.append("") # blank line for spacing
- for i, option in enumerate(options, 1):
- label = option.get("label") or option.get("id") or ""
- message_parts.append(f" {i}. {label}")
-
- return "\n".join(message_parts)
-
- def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
- """Normalize model-produced options into a stable UI-friendly shape."""
- options = raw_options
-
- # Some models serialize array parameters as JSON strings instead of native arrays.
- if isinstance(options, str):
- try:
- options = json.loads(options)
- except (json.JSONDecodeError, TypeError):
- options = [options]
-
- if options is None:
- values: list[object] = []
- elif isinstance(options, list):
- values = options
- else:
- values = [options]
-
- normalized: list[dict[str, str]] = []
- for index, value in enumerate(values, 1):
- if isinstance(value, dict):
- raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
- label = str(raw_label).strip() if raw_label is not None else ""
- if not label:
- continue
- raw_id = value.get("id") or value.get("value") or f"option-{index}"
- option = {
- "id": str(raw_id).strip() or f"option-{index}",
- "label": label,
- }
- description = value.get("description")
- if description is not None and str(description).strip():
- option["description"] = str(description).strip()
- normalized.append(option)
- else:
- label = str(value).strip()
- if label:
- normalized.append({"id": f"option-{index}", "label": label})
-
- return normalized
-
- def _build_payload(self, args: dict) -> dict:
- """Build structured clarification data for rich clients."""
- return {
- "question": str(args.get("question", "")).strip(),
- "clarification_type": args.get("clarification_type", "missing_info"),
- "context": args.get("context"),
- "options": self._normalize_options(args.get("options", [])),
- "allow_custom": True,
- }
+ for i, option in enumerate(options, 1):
+ label = option.get("label") or option.get("id") or ""
+ message_parts.append(f" {i}. {label}")
+
+ return "\n".join(message_parts)
+
+ def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
+ """Normalize model-produced options into a stable UI-friendly shape."""
+ options = raw_options
+
+ # Some models serialize array parameters as JSON strings instead of native arrays.
+ if isinstance(options, str):
+ try:
+ options = json.loads(options)
+ except (json.JSONDecodeError, TypeError):
+ options = [options]
+
+ if options is None:
+ values: list[object] = []
+ elif isinstance(options, list):
+ values = options
+ else:
+ values = [options]
+
+ normalized: list[dict[str, str]] = []
+ for index, value in enumerate(values, 1):
+ if isinstance(value, dict):
+ raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
+ label = str(raw_label).strip() if raw_label is not None else ""
+ if not label:
+ continue
+ raw_id = value.get("id") or value.get("value") or f"option-{index}"
+ option = {
+ "id": str(raw_id).strip() or f"option-{index}",
+ "label": label,
+ }
+ description = value.get("description")
+ if description is not None and str(description).strip():
+ option["description"] = str(description).strip()
+ normalized.append(option)
+ else:
+ label = str(value).strip()
+ if label:
+ normalized.append({"id": f"option-{index}", "label": label})
+
+ return normalized
+
+ def _build_payload(self, args: dict) -> dict:
+ """Build structured clarification data for rich clients."""
+ return {
+ "question": str(args.get("question", "")).strip(),
+ "clarification_type": args.get("clarification_type", "missing_info"),
+ "context": args.get("context"),
+ "options": self._normalize_options(args.get("options", [])),
+ "allow_custom": True,
+ "allow_multiple": resolve_allow_multiple(args),
+ }
def _handle_clarification(self, request: ToolCallRequest) -> Command:
"""Handle clarification request and return command to interrupt execution.
@@ -176,13 +179,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
# Create a ToolMessage with the formatted question
# This will be added to the message history
- tool_message = ToolMessage(
- id=self._stable_message_id(tool_call_id, formatted_message),
- content=formatted_message,
- tool_call_id=tool_call_id,
- name="ask_clarification",
- additional_kwargs={"clarification": self._build_payload(args)},
- )
+ tool_message = ToolMessage(
+ id=self._stable_message_id(tool_call_id, formatted_message),
+ content=formatted_message,
+ tool_call_id=tool_call_id,
+ name="ask_clarification",
+ additional_kwargs={"clarification": self._build_payload(args)},
+ )
# Return a Command that:
# 1. Adds the formatted tool message
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_stop_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_stop_middleware.py
new file mode 100644
index 00000000..3e39423a
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_stop_middleware.py
@@ -0,0 +1,158 @@
+"""Middleware for intercepting skill calls and stopping execution when specific skills are invoked."""
+
+import logging
+import re
+from collections.abc import Callable
+from typing import Any, override
+
+from langchain.agents import AgentState
+from langchain.agents.middleware import AgentMiddleware
+from langchain_core.messages import ToolMessage
+from langgraph.prebuilt.tool_node import ToolCallRequest
+from langgraph.types import Command, interrupt
+
+logger = logging.getLogger(__name__)
+
+
+def extract_name_task(text: str) -> tuple[str, str]:
+ """Extract `agent_name` and `task_content` from a bash invocation that triggers
+ a sub-agent via ``python3 scripts/agent_orchestration.py "" ""``.
+
+ Returns empty strings when no match is found so the caller can treat the
+ absence of a match as "not a target skill call".
+ """
+ try:
+ pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
+ match = re.search(pattern, text)
+ if match:
+ return match.group(1), match.group(2)
+ return "", ""
+ except Exception:
+ return "", ""
+
+
+class SkillStopMiddlewareState(AgentState):
+ """Compatible with the `ThreadState` schema."""
+
+ pass
+
+
+class SkillStopMiddleware(AgentMiddleware[SkillStopMiddlewareState]):
+ """Intercepts skill file reads and stops execution when specific skills are invoked.
+
+ When the model reads a file from a skill directory that matches one of the
+ configured skill names, this middleware:
+ 1. Intercepts the tool call before execution
+ 2. Returns a Command that stops execution (goes to END)
+ 3. Optionally returns a message indicating which skill was triggered
+
+ This is useful for implementing "skill triggers" that pause the agent
+ when certain skills are about to be used.
+ """
+
+ state_schema = SkillStopMiddlewareState
+
+ def __init__(
+ self,
+ *,
+ skill_names: list[str],
+ skills_root: str = "/mnt/skills",
+ stop_message: str | None = None,
+ stop_on_match: bool = True,
+ ):
+ """Initialize the skill stop middleware.
+
+ Args:
+ skill_names: List of skill names to intercept (e.g., ["newsletter-generation", "podcast-generation"]).
+ skills_root: Root directory where skills are located (default: "/mnt/skills").
+ stop_message: Optional message to return when stopping. If None, a default message is used.
+ stop_on_match: If True, stop when skill matches. If False, continue execution (for logging only).
+ """
+ super().__init__()
+ self._skill_names = set(skill_names)
+ self._skills_root = skills_root.rstrip("/")
+ self._stop_message = stop_message
+ self._stop_on_match = stop_on_match
+
+ def _is_target_skill_call(self, tool_call: dict[str, Any]) -> str | None:
+ """Return the matched skill name if this tool call invokes one of the
+ configured target skills, otherwise ``None``.
+
+ Two invocation patterns are recognized:
+
+ 1. **Structured tool call** — the model emits a tool call whose ``name``
+ literally matches one of the configured skill names (e.g. the
+ multi-agent leader calling
+ ``agent_orchestration({"agent_name": "...", "task": "..."})``).
+ This is the natural form most LLMs produce when told to "use a
+ skill", and it's the form that drives the roundtable dispatch.
+
+ 2. **Bash + argv** — the legacy form where the lead agent reaches into
+ a skill by running ``python3 scripts/.py "" ""``.
+ Kept for backward compatibility with skills that use bash for
+ dispatch.
+ """
+ name = tool_call.get("name") or ""
+ lowered_targets = {s.lower() for s in self._skill_names}
+
+ # Pattern 1: structured tool call whose tool name IS the skill name.
+ if name and (name in self._skill_names or name.lower() in lowered_targets):
+ logger.info("SkillStopMiddleware: matched structured skill call name=%s", name)
+ return name
+
+ # Pattern 2: bash invocation with argv-style skill name.
+ if name == "bash" and "args" in tool_call:
+ skill_name, _ = extract_name_task(str(tool_call["args"]))
+ if skill_name and (skill_name in self._skill_names or skill_name.lower() in lowered_targets):
+ logger.info("SkillStopMiddleware: matched bash skill call name=%s", skill_name)
+ return skill_name
+
+ return None
+
+ def _handle_skill_stop(self, request: ToolCallRequest, skill_name: str) -> Command:
+ """Build the stop response — an interrupt that includes a ToolMessage
+ describing which skill was triggered."""
+ logger.info("Skill stop triggered: skill_name=%s", skill_name)
+
+ tool_call_id = request.tool_call.get("id", "")
+ message_content = self._stop_message or f"⏹️ Skill '{skill_name}' triggered. Execution stopped."
+
+ tool_message = ToolMessage(
+ id=f"skill_stop:{tool_call_id}",
+ content=message_content,
+ tool_call_id=tool_call_id,
+ name="skill_stop",
+ )
+
+ return interrupt(
+ {
+ "messages": [tool_message],
+ "skill_name": skill_name,
+ }
+ )
+
+ @override
+ def wrap_tool_call(
+ self,
+ request: ToolCallRequest,
+ handler: Callable[[ToolCallRequest], ToolMessage | Command],
+ ) -> ToolMessage | Command:
+ matched_skill = self._is_target_skill_call(request.tool_call)
+ if matched_skill:
+ if self._stop_on_match:
+ return self._handle_skill_stop(request, matched_skill)
+ logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
+ return handler(request)
+
+ @override
+ async def awrap_tool_call(
+ self,
+ request: ToolCallRequest,
+ handler: Callable[[ToolCallRequest], ToolMessage | Command],
+ ) -> ToolMessage | Command:
+ matched_skill = self._is_target_skill_call(request.tool_call)
+ if matched_skill:
+ if self._stop_on_match:
+ return self._handle_skill_stop(request, matched_skill)
+ logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
+ return await handler(request)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py
index fce0f936..2d56d443 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py
@@ -22,6 +22,7 @@ from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
+from deerflow.config.skill_stop_config import SkillStopConfig
from deerflow.config.skills_config import SkillsConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
@@ -117,6 +118,8 @@ class AppConfig(BaseModel):
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
+ excluded_tools: list[str] = Field(default_factory=list, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
+ skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
model_config = ConfigDict(extra="allow")
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_stop_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_stop_config.py
new file mode 100644
index 00000000..a3b29262
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_stop_config.py
@@ -0,0 +1,31 @@
+"""Configuration for skill stop middleware."""
+
+from pydantic import BaseModel, Field
+
+
+class SkillStopConfig(BaseModel):
+ """Configuration for the skill stop middleware.
+
+ When enabled, the middleware will intercept skill invocations and stop
+ execution when a matching skill is about to be used.
+ """
+
+ enabled: bool = Field(
+ default=False,
+ description="Enable skill stop middleware. When True, execution will stop when specified skills are invoked.",
+ )
+
+ skill_names: list[str] = Field(
+ default_factory=list,
+ description="List of skill names to intercept. When the model reads a file from these skill directories, execution will stop.",
+ )
+
+ stop_message: str | None = Field(
+ default=None,
+ description="Custom message to display when stopping. If None, a default message is used.",
+ )
+
+ stop_on_match: bool = Field(
+ default=True,
+ description="If True, stop execution when skill matches. If False, only log the match without stopping.",
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py
index b371da44..0c4e45af 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py
@@ -1,3 +1,4 @@
+from .agent_orchestration_tool import agent_orchestration_tool
from .clarification_tool import ask_clarification_tool
from .memory_tool import memory_tool
from .present_file_tool import present_file_tool
@@ -10,6 +11,7 @@ __all__ = [
"setup_agent",
"present_file_tool",
"ask_clarification_tool",
+ "agent_orchestration_tool",
"memory_tool",
"scheduled_task_tool",
"view_image_tool",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/agent_orchestration_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/agent_orchestration_tool.py
new file mode 100644
index 00000000..a9aa5796
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/agent_orchestration_tool.py
@@ -0,0 +1,46 @@
+"""Placeholder tool that lets the roundtable leader agent dispatch sub-agents.
+
+Background
+----------
+The multi-agent ``/api/multi-agent/run/stream`` endpoint (``app.gateway.routers.multi_agent``)
+drives a *leader* agent whose only job is to delegate work to seat-specific sub-agents.
+Its SOUL.md tells the model to "use the agent_orchestration skill, passing agent_name
+and task". With no real tool registered under that name, the model used to emit
+``{"name": "agent_orchestration", "args": {"agent_name": ..., "task": ...}}`` calls
+that the runtime rejected with "agent_orchestration is not a valid tool", driving it
+into an unproductive retry loop (a single leader call could exceed 10 000 SSE frames).
+
+This module exposes a no-op tool with the exact signature the leader naturally
+emits. The actual dispatch is handled by ``SkillStopMiddleware`` which intercepts
+the call, halts the run, and lets ``multi_agent.py`` parse the AIMessage's
+``tool_calls`` to extract ``(agent_name, task)`` pairs for the frontend.
+"""
+
+from langchain.tools import tool
+
+
+@tool("agent_orchestration", parse_docstring=True)
+def agent_orchestration_tool(agent_name: str, task: str) -> str:
+ """Delegate a task to a named sub-agent in the multi-agent roundtable.
+
+ Use this tool ONLY when you are the roundtable coordinator and need to
+ dispatch work to one of the configured seat agents. Each call delegates
+ ONE task to ONE agent. Emit multiple calls in the same turn to dispatch
+ several seats in a single round of the discussion.
+
+ The call is intercepted by the runtime — you do not get a real result
+ back synchronously. The orchestrator will collect every dispatched
+ ``(agent_name, task)`` pair, run the corresponding sub-agents, then
+ re-prompt you with a synthesis instruction once they finish.
+
+ Args:
+ agent_name: Name of the sub-agent to dispatch. MUST exactly match one of
+ the names listed in your SOUL prompt's ``可调度智能体`` section.
+ task: The instruction to hand to that sub-agent. Be specific — the
+ sub-agent only sees this string (plus its own SOUL prompt), not the
+ broader conversation context.
+ """
+ # Real handling is in SkillStopMiddleware + the multi_agent router. This
+ # body only runs as a fallback if the middleware is not configured; it
+ # returns a placeholder so the model does not see a "not a valid tool" error.
+ return f"dispatched:{agent_name}"
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py
index ab2b69d3..7420260a 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py
@@ -15,6 +15,7 @@ def ask_clarification_tool(
],
context: str | None = None,
options: list[str] | None = None,
+ allow_multiple: bool | None = None,
) -> str:
"""Ask the user for clarification when you need more information to proceed.
@@ -50,8 +51,9 @@ def ask_clarification_tool(
Best practices:
- One question per ask_clarification call — keep each `question` focused on a single decision.
- Be specific and clear in your question.
- - Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
- this lets the user multi-select in the UI instead of typing free text.
+ - Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices.
+ - By default the UI is **single-select** (click an option to send). Pass `allow_multiple=True`
+ only when the user may legitimately pick several options together (e.g. multiple constraints).
- Don't make assumptions when clarification is needed.
- For risky operations, ALWAYS ask for confirmation.
- After calling this tool (or several in one turn), execution will be interrupted automatically.
@@ -60,7 +62,9 @@ def ask_clarification_tool(
question: The clarification question to ask the user. Focused on a single decision; be specific and clear.
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
- options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from; the UI supports multi-select.
+ options: Optional list of choices. Present clear options for the user to choose from.
+ allow_multiple: When True, the UI allows multi-select plus a submit button. When False or
+ omitted, the UI is single-select and sends immediately on click (default).
"""
# This is a placeholder implementation
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py
new file mode 100644
index 00000000..0bf0c6f0
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py
@@ -0,0 +1,37 @@
+"""ask_clarification 工具参数的共享解析(Harness + Gateway)。
+
+圆桌 Step 1(``intent.py``)与 Step 2 总控(``multi_agent.py``)在 SSE 终态帧里
+都会带上 ``allow_multiple``,供前端 ``resolveAllowMultiple`` 决定选项卡片是
+「点击即发送(单选)」还是「多选 + 发送按钮」。
+
+约定(与前端 ``roundtable-planning/lib/clarification.ts`` 一致):
+- 模型**未传** ``allow_multiple`` 或传 ``null`` → **False**(单选);
+- 仅当模型显式传 ``true`` 时才多选;
+- 不再根据 ``clarification_type`` 推断多选(旧逻辑已移除)。
+"""
+
+from __future__ import annotations
+
+
+def coerce_bool(value: object | None, *, default: bool) -> bool:
+ """把 LLM tool args 里可能出现的 bool/数字/字符串统一成 bool。"""
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in ("true", "1", "yes", "y", "on"):
+ return True
+ if lowered in ("false", "0", "no", "n", "off"):
+ return False
+ return bool(value)
+
+
+def resolve_allow_multiple(args: dict) -> bool:
+ """是否允许多选。仅 ``args["allow_multiple"]`` 显式为真时返回 True。"""
+ if "allow_multiple" in args and args["allow_multiple"] is not None:
+ return coerce_bool(args["allow_multiple"], default=False)
+ return False
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py
index bd5673c3..b8e6d516 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py
@@ -6,7 +6,7 @@ from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.reflection import resolve_variable
from deerflow.sandbox.security import is_host_bash_allowed
-from deerflow.tools.builtins import ask_clarification_tool, memory_tool, present_file_tool, scheduled_task_tool, task_tool, view_image_tool
+from deerflow.tools.builtins import agent_orchestration_tool, ask_clarification_tool, memory_tool, present_file_tool, scheduled_task_tool, task_tool, view_image_tool
from deerflow.tools.builtins.tool_search import reset_deferred_registry
logger = logging.getLogger(__name__)
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
BUILTIN_TOOLS = [
present_file_tool,
ask_clarification_tool,
+ agent_orchestration_tool,
]
SUBAGENT_TOOLS = [
@@ -39,6 +40,7 @@ def get_available_tools(
model_name: str | None = None,
subagent_enabled: bool = False,
is_scheduled_run: bool = False,
+ excluded_tools: list[str] | None = None,
*,
app_config: AppConfig | None = None,
) -> list[BaseTool]:
@@ -52,6 +54,9 @@ def get_available_tools(
include_mcp: Whether to include tools from MCP servers (default: True).
model_name: Optional model name to determine if vision tools should be included.
subagent_enabled: Whether to include subagent tools (task, task_status).
+ excluded_tools: Optional list of tool names to exclude from the result
+ (applied AFTER all other resolution). Takes precedence over
+ ``app_config.excluded_tools``.
Returns:
List of available tools.
@@ -209,4 +214,19 @@ def get_available_tools(
"Duplicate tool name %r detected and skipped — check your config.yaml and MCP server registrations (issue #1803).",
t.name,
)
+
+ # Drop tools the caller (or config) wants excluded. Request-level
+ # ``excluded_tools`` wins; the config-level list is the static fallback.
+ effective_excluded = excluded_tools if excluded_tools else getattr(config, "excluded_tools", None) or []
+ if effective_excluded:
+ excluded_set = set(effective_excluded)
+ filtered_tools = [t for t in unique_tools if t.name not in excluded_set]
+ if len(filtered_tools) < len(unique_tools):
+ logger.info(
+ "Excluded %d tool(s) by request/config: %s",
+ len(unique_tools) - len(filtered_tools),
+ effective_excluded,
+ )
+ unique_tools = filtered_tools
+
return unique_tools
diff --git a/服务启停说明.md b/服务启停说明.md
index 348fa7e6..c7291d35 100644
--- a/服务启停说明.md
+++ b/服务启停说明.md
@@ -111,3 +111,10 @@ uv sync
```
再启动。
+
+
+{ "agent_type": "leader", "d_agent_thread_id": {
+ "查询天气的智能体": "76c95f3e-ee02-4e9b-b767-6df129a5aea5",
+ "查询黄历的智能体": "9a5536a1-5e1e-4345-b9b7-9807c31154e4",
+ "main-agent": "276eac46-1acb-47e3-ad42-8f01af51e869"
+ }, "agent_name": "main-agent", "new_message": "帮我看看今天出门合适吗", "skill_stop_names": ["查询天气的智能体","查询黄历的智能体"] }
\ No newline at end of file