This commit is contained in:
许海洋 committed 2026-05-27 09:27:08 +08:00
commit ffe42ca8f1
81 files changed
+12755 -8796

No files matched your search

View File
Whitespace-only changes.
-140
View File
@@ -1,140 +0,0 @@
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
## 一、需求背景
当前 `frontend-web/src/App.tsx` 同时承载了:
1. 登录/首页相关路由(如 `/login`、`/landing`)
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
---
## 二、目标(你提出的核心诉求)
你希望:
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
---
## 三、重构范围
### 1)保留在 `App.tsx` 的内容
- 全局 Provider:`HashRouter`、`AppProviders`
- 登录/首页路由:
- `/`
- `/login`
- `/login/:username`
- `/landing`
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
### 2)迁移出 `App.tsx` 的内容
将以下与工作台有关的路由从 `App.tsx` 抽离:
- `/workspace`(重定向)
- `/workspace/chats`
- `/workspace/chats/:thread_id`
- `/workspace/agents`
- `/workspace/agents/new`
- `/workspace/agents/:agent_id/chats`(重定向)
- `/workspace/agents/:agent_id/chats/:thread_id`
- `/workspace/scheduled`
- `/workspace/scheduled/tasks`
- `/workspace/scheduled/runs/:run_id`
以及配套的包装组件职责:
- `WorkspaceLayout`
- `ChatRuntime`
---
## 四、建议的新结构(方案)
## 方案 A(推荐):新增“业务路由容器组件”
在 `pages` 下新增一个路由容器组件,例如:
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
职责:
- 专门维护 `/workspace/*` 相关路由
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
- 输出一个可被 `App.tsx` 挂载的路由节点
同时在 `App.tsx` 里只保留:
- 登录/首页/404
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
---
## 五、“再建立一个页面放置导航”的落地解释
你说的“建立一个页面放置这些导航”,可以有两种理解:
1. **路由容器页(推荐)**:
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
- 即上面的 `WorkspaceRoutes.tsx`。
2. **可见的导航页(可选)**:
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
---
## 六、实施步骤(开发任务拆解)
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
5. 校验以下访问路径行为一致:
- `/workspace` 重定向
- chats/agents/scheduled 各页面可正常进入
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
- 404 不受影响
6. 运行前端并做手工回归。
---
## 七、验收标准
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
2. `/workspace/*` 全部由新建组件统一管理。
3. 功能行为与改造前一致(或符合新定义)。
4. 代码可读性提升:登录入口与业务入口职责分离。
---
## 八、注意事项
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
---
## 九、建议的命名(可直接采用)
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
---
## 十、后续可选优化
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,742 @@
# 多智能体圆桌研讨 · 前端开发文档
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
---
## 0. 速读:你需要先知道的事
- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。
- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。
- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。
- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。
- **`<think>` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `<think>...</think>`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
---
## 1. 功能定位
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
**核心能力**:
| 步骤 | 用户目标 | 真实后端对接 |
|------|----------|--------------|
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
**步骤门禁**:
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
---
## 2. 目录结构
```
frontend-web/src/roundtable-planning/
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── pages/
│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
├── api/
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/
│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置
│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup)
│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props)
│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入)
│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport
│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model)
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
├── hooks/
│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除
├── lib/
│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `<think>...</think>` 思考过程
│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等)
│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染
├── utils/
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
└── styles/
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
```
**模块依赖方向(自上而下)**:
```
pages/RoundtablePlanningPage
├─ uses → hooks/useStep1Intent
├─ uses → hooks/useStep2Orchestration
├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate)
├─ uses → components/Step{1,2,3}Panel (纯 props 驱动)
├─ uses → components/RecommendAgentsDialog
└─ uses → components/Avatars
hooks/useStep1Intent → api/intent
hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型
hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型
```
主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
```tsx
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
```
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
**环境变量**(由 `scripts/start-frontend.sh` 注入):
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
---
## 3. 三步数据流(前端视角)
```
[进入 Step 1]
useEffect → runStep1Init()
initIntent(model) → { agent_id, thread_id }
setIntentThreadId、setTimelineEntries
interactiveMessages seed 欢迎语(本地 seed,非 API)
[用户发送 / 选项卡片提交]
sendIntentMessage(text)
追加 user 气泡 + agent 占位(streaming + phase)
streamIntent({ onTextDelta, onPhaseChange, signal })
终态:
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
asking → 继续对话
error → toast + 红色 (错误) 文案
[点击「进入多智能体研讨」且 intentReady]
setIsRecommendDialogOpen(true)
RecommendAgentsDialog
listAgents() → filterRecommendCandidates()
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
用户勾选 2–8 个 → onConfirm(agentIds)
handleConfirmAgents
setSelectedAgents(agentIds.map(...))
step2InitRef.current = false // 复位 Step 2 init 闸门
setCurrentStep(2)
[进入 Step 2]
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
onSeatReady:右侧席位面板逐个「在线」
onCoordinatorReady / init_done → threadIds + coordinatorName
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
while !cancelled && cycles<8:
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
dispatched.length === 0 → hasConsensus = true,可进 Step 3
[进入 Step 3]
HighFidelityReport
consensusText ← lastLeaderContent
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
```
---
## 4. API 层
### 4.1 `api/intent.ts` — Step 1
| 函数 | 说明 |
|------|------|
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
**`StreamIntentRequest` 重要字段**:
- `threadId`:必填,跨多轮对话保持
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
- `onTextDelta(text, messageId)`:每个 AI 文本增量
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
**`StreamIntentResult`**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 |
**SSE 解析要点**:
- `event: metadata` → phase `connected`
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`)
**Abort 行为**:
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
### 4.2 `api/recommend.ts` — 推荐弹窗
| 函数 | 说明 |
|------|------|
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
- `roundtable-intent`
- `roundtable-recommender`
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
**终态语义**:
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
| 函数 | 说明 |
|------|------|
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
| `streamMultiAgent(req)` | leader / special 一轮 |
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
```ts
{ event: "seat_ready", agent_name, thread_id, display_name, description }
{ event: "coordinator_ready", main_agent_name, thread_id }
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
{ event: "error", detail }
```
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
| `status` | 含义 | 前端处理 |
|----------|------|----------|
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
| `"error"` | 失败 | toast + 红色气泡 |
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
**调试开关**:
```js
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
```
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
---
## 5. 主页面状态(`RoundtablePlanningPage`)
> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。
| 维度 | 所有者 | 暴露给主页面的方式 |
|------|--------|-------------------|
| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState |
| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 |
| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 |
| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 |
| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref |
### 5.1 步骤与门禁
- `currentStep`: `1 | 2 | 3`
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
### 5.2 Step 1 关键 state
| 状态 | 用途 |
|------|------|
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
| `intentAborterRef` | 当前 stream 的 AbortController |
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
| `step1InitRef` | 闸门:true 即认为已 init 过 |
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
**`step1InitGenRef` 的存在原因**:
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
### 5.3 推荐与 Step 2 席位
| 状态 | 用途 |
|------|------|
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` 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()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel`
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
- 把新的 `(seatId, model)` 写入 `agentConfigs`
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
### 5.6 全局模型选择器(右侧 sidebar 顶部)
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
### 5.7 草稿持久化(`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`(顶部「模型生成内容」区流式;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- **布局分两段**:
- **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `<think>` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。
- **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。
- 选择约束:`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
- 其它文本类 → `<pre>` 直出
- 头部「下载」按钮单独走 `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 → 正在交付文件`,其它 → 「调用工具:<name>」
- `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<string, string>;
// 派活
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;
}
```
**核心常量**:
| 常量 | 值 | 位置 |
|------|----|------|
| `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<id, Bubble>` + ref 缓存)改善之后再启用
### 12.5 接入真实 Persona 覆盖
当前 prompt / temp 只在前端记录;要落地需:
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
- 前端:`agentConfigs[meta.speakerId]` 一并传入
### 12.6 拆分页面(已完成 · 2026-05)
主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
- `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步
- `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清
- `hooks/useDraftPersistence.ts` —— 草稿自动保存 / 手动保存 / 加载 / 重命名 / 删除
- `components/Step{1,2,3}Panel.tsx` —— 纯 UI,只接收 props,不持有业务状态
- `components/PersonaDrawer.tsx` —— Persona 抽屉
- `components/Avatars.tsx` —— 所有 SVG 头像 + getAvatar
- `lib/roundtable-constants.ts` —— 常量、类型、纯工具函数(buildRoleTemplates 等)
- `lib/step-display.tsx` —— ChainOfThought 折叠卡的图标 + 文案 + 子节点渲染
**主页面只保留**:顶栏(StepNavigation / HistoryDropdown)、左右两侧栏(LeftSidebar / RightSidebar)、步骤路由、推荐弹窗,以及编排 hook 与跨步 handler(`handleConfirmAgents` / `handleStartNewTask`)。
继续重构请遵守:
1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。
2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `utils/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改。
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持与重构前同样的 0 个新增错误(预存量 50 个 pre-existing 错误来自其它模块如 `next/link` shim,与本特性无关)。
### 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 hook | `frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts` |
| Step 2 hook(含编排循环) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` |
| 草稿 hook | `frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts` |
| Step 1 面板 | `frontend-web/src/roundtable-planning/components/Step1Panel.tsx` |
| Step 2 面板 | `frontend-web/src/roundtable-planning/components/Step2Panel.tsx` |
| Step 3 面板 | `frontend-web/src/roundtable-planning/components/Step3Panel.tsx` |
| 消息气泡共享件 | `frontend-web/src/roundtable-planning/components/MessageBubble.tsx` |
| 步骤折叠卡(工具/思考) | `frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx` |
| `<think>` 提取工具 | `frontend-web/src/roundtable-planning/lib/reasoning.ts` |
| Persona 抽屉 | `frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx` |
| 头像集合 | `frontend-web/src/roundtable-planning/components/Avatars.tsx` |
| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` |
| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.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` |
-492
View File
@@ -1,492 +0,0 @@
# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现
> Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。
>
> 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。
---
## 1. 目标与边界
### ✅ 要做的事
- 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位
- 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream`
- 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper)
- 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration`
### ❌ 不做的事
- 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样)
- 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可)
- 不做时间线(timelineEntries)的后端持久化(前端临时状态够用)
- 不做"评分/置信度"等假指标
- 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL`
---
## 2. 数据流概览
```
[Step 1 入口]
↓
前端:POST /api/intent/init
← {agent_id: "roundtable-intent", thread_id: "..."}
用户输入第一句话:"帮我规划一个生产系统安全改造方案"
↓
前端:POST /api/intent/stream (SSE)
← (流式 AI 文本) "好的,预算大致是多少?"
← {status: "asking", content: "..."} ← 智能体反问中,需要用户继续
用户回:"500 万左右"
↓
前端再次 POST /api/intent/stream
← (流式) "明白,还有什么时间约束吗?"
← {status: "asking", ...}
...对话进行几轮 ...
用户:可以了
↓
前端再 POST /api/intent/stream
← (流式) "好,我理解的任务是: <汇总>"
← {status: "done", content: "<最终汇总文本>", summary: {...}} ← 信息充分,可进入 step 2
[Step 1 完成 → Step 2 init]
前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent()
```
---
## 3. 接口设计
跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。
### 3.1 `POST /api/intent/init`
**请求体**
```json
{
"model": "deepseek-chat" // 可选,默认全局 DEFAULT_MODEL
}
```
**响应 200**
```json
{
"status": "success",
"agent_id": "roundtable-intent",
"thread_id": "<langgraph_thread_id>"
}
```
**行为**:
1. 鉴权(透传 `Authorization`)
2. 调上游 `POST /api/threads` 建 1 个 thread
3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**)
**与 step 2 `/init` 的区别**:
- 没有"协调智能体"概念,只有 1 个 agent
- 没有 6 个 sub 席位,只有 1 个 thread
- 不需要传 `agent_names` 数组
- `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的)
### 3.2 `POST /api/intent/stream`
**请求体**
```json
{
"thread_id": "<from /init>",
"message": "用户这一轮要说的话",
"model": "deepseek-chat" // 可选
}
```
**响应**:`text/event-stream`,SSE 流式。
**SSE 帧组成**:
1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量
2. 流末追加一个**状态帧**(跟 step 2 同样模式):
```json
{
"status": "asking" | "done" | "error",
"content": "<本轮 AI 文本>",
"summary": {
"objective": "...", // 仅 status=done 时有: 任务目标
"constraints": ["...", ...], // 仅 status=done 时有: 约束清单
"assumptions": ["...", ...] // 仅 status=done 时有: 关键假设
}
}
```
**`status` 取值**:
- `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框
- `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent
- `"error"` —— 上游异常,`content` 里是错误信息
**判定 done/asking 的简单策略**(后端实现):
- 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧
- 否则一律是 `asking`
> 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取:
>
> ```
> [INTENT_READY]
> ```json
> {"objective": "...", "constraints": ["..."], "assumptions": ["..."]}
> ```
> ```
>
> 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。
---
## 4. 内置智能体配置
**位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/`
### 4.1 `config.yaml`
```yaml
id: roundtable-intent
name: 任务理解智能体
description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。
```
### 4.2 `SOUL.md`
```markdown
# 任务理解智能体
## 角色定位
你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。
## 核心职责
- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
- 不要追问已经清楚的事
- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
- 信息足够后,立即停止追问,产出结构化汇总
## 信息充分性判定
当满足以下条件时,认为信息已充分:
1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
2. 至少 1 条硬约束(预算/时间/合规/范围之一)
3. 没有明显的歧义点
## 信息汇总产出
信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):
```
[INTENT_READY]
```json
{
"objective": "用一句话写出用户最终要做的事",
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
"assumptions": ["假设 1", "假设 2", ...]
}
```
```
输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。
## 输出风格
- 一次回复不超过 3 句话
- 提问要具体,不要"还有什么补充吗"这种空问
- 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总)
## 边界
- 不调用 agent_orchestration(这是 step 2 的协调智能体的事)
- 不写文件、不做计算、不联网
- 用户消息歧义大时,优先调 ask_clarification 工具引导
```
### 4.3 注册方式
跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。
---
## 5. 后端实现要点
### 5.1 新建 router 文件
**位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py`
```python
"""任务理解智能体接口 - Step 1 的极简包装。
跟 multi_agent.py 同构但简化:
- 没有 broadcast(单 thread,无需广播)
- 没有派活逻辑
- 没有 leader/special 分支,只有一种调用方式
- 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking
"""
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
import httpx, json, re, asyncio, logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"])
# 复用 multi_agent.py 里已有的工具函数
from app.gateway.routers.multi_agent import (
_loopback_base, _auth_headers,
_create_thread, _stream_upstream, _parse_last_messages,
_flatten_content, _run_payload,
)
INTENT_AGENT_ID = "roundtable-intent"
_INTENT_READY_PATTERN = re.compile(
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
re.DOTALL,
)
@router.post("/init")
async def init_intent(request: Request, payload: dict = Body(...)) -> dict:
base = _loopback_base(request)
headers = _auth_headers(request)
async with httpx.AsyncClient(timeout=None) as client:
thread_id = await _create_thread(client, base, headers)
return {
"status": "success",
"agent_id": INTENT_AGENT_ID,
"thread_id": thread_id,
}
@router.post("/stream")
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
thread_id = str(payload.get("thread_id") or "").strip()
message = str(payload.get("message") or "").strip()
model_name = str(payload.get("model") or "deepseek-chat")
if not thread_id or not message:
raise HTTPException(status_code=400, detail="thread_id and message required")
base = _loopback_base(request)
headers = _auth_headers(request)
async def gen():
async with httpx.AsyncClient(timeout=None) as client:
try:
body = _run_payload(
agent_name=INTENT_AGENT_ID,
model_name=model_name,
thread_id=thread_id,
new_message=message,
excluded_tools=["web_search", "present_files",
"view_image", "agent_orchestration"],
skill_stop_names=[],
)
captured = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield (line if line.endswith("\n") else line + "\n")
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
last_ai = {}
for m in reversed(messages):
if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_ai = m
break
content = _flatten_content(last_ai.get("content", ""))
# 检测 [INTENT_READY] 标记
match = _INTENT_READY_PATTERN.search(content)
if match:
try:
summary = json.loads(match.group(1))
except json.JSONDecodeError:
summary = None
frame = {"status": "done", "content": content, "summary": summary}
else:
frame = {"status": "asking", "content": content}
yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
except HTTPException as exc:
yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.exception("intent stream failure")
yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
```
### 5.2 在 `app/gateway/app.py` 注册
```python
from app.gateway.routers import intent
app.include_router(intent.router)
```
---
## 6. 前端实现要点
### 6.1 新增 API helper
**位置**:`frontend-web/src/roundtable-planning/api/intent.ts`
```ts
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface IntentInitResponse {
status: "success";
agent_id: string;
thread_id: string;
}
export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`);
return res.json();
}
export interface IntentSummary {
objective: string;
constraints: string[];
assumptions?: string[];
}
export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
signal?: AbortSignal;
onTextDelta?: (text: string, id: string) => void;
}
export interface StreamIntentResult {
status: "asking" | "done" | "error";
content: string;
summary: IntentSummary | null;
}
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
// 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
// {status, content, summary?} 格式。
// ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
}
```
### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染
替换 `handleSendMessage`:
```ts
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
useEffect(() => {
if (currentStep === 1 && !intentThreadId) {
initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id));
}
}, [currentStep]);
const handleSendMessage = async () => {
if (!inputText.trim() || !intentThreadId) return;
const userText = inputText.trim();
// (push user msg to interactiveMessages 略)
setInputText("");
// 占位 agent 气泡
const agentMsgId = pushAgentPlaceholder("任务理解智能体");
const res = await streamIntent({
threadId: intentThreadId,
message: userText,
model: DEFAULT_MODEL,
onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta),
});
finalizeAgentMsg(agentMsgId, res.content);
if (res.status === "done" && res.summary) {
setIntentReady(res.summary);
triggerToast("✅ 任务理解完成,可以进入圆桌研讨");
}
};
```
### 6.3 "进入 step 2"按钮的联动
- 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`)
- 改造:只有 `intentReady !== null` 时该按钮才 enabled
- 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑)
```ts
const buildInitialIntent = useCallback(() => {
if (intentReady) {
return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
}
// fallback: 旧逻辑保留兜底
const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
const userIntent = lastUserMessage?.content ?? "请围绕给定任务展开圆桌研讨";
const constraints = `预算硬约束:${budgetLimit} 万元`;
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
}, [interactiveMessages, budgetLimit, intentReady]);
```
---
## 7. 实施清单
| 优先级 | 项 | 文件 |
|---|---|---|
| P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` |
| P0 | 新 router | `backend/app/gateway/routers/intent.py` |
| P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) |
| P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
| P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) |
| P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 |
| P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) |
| P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 |
---
## 8. 与 step 2 的衔接
```
step 1 输出: intentReady.objective + intentReady.constraints
↓
step 2 入口: buildInitialIntent() 拼成 leader 的 new_message
↓
step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了),
除非模型遇到完全意料之外的歧义
↓
研讨更聚焦,减少 step2 阶段的 clarification 轮次
```
副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。
---
## 9. 路径速查
| 资源 | 位置 |
|---|---|
| Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) |
| 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
| 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
| 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) |
---
## 10. 验收清单
- [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id
- [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout)
- [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary
- [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态
- [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话)
- [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)
-264
View File
@@ -1,264 +0,0 @@
# 多智能体圆桌 · Step 3 · 接口与对接说明
> Step 3(`/page/roundtable/planning` 的"最终方案展示"页)对应 `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx`。当前页面是产品早期的"展厅效果",绝大多数内容是硬编码 mock。本文档梳理**真正需要的数据**、**用哪些接口**、**前后端怎么对接**。
---
## 1. 改造方向(在做什么,不做什么)
### ✅ 保留并对接真实数据
| 区块 | 现状 | 改造后 |
|---|---|---|
| 标题栏 + 生成时间 | 硬编码"2026-05-21 11:01:12" | 用本地 `Date.now()` 或 init/最后一次 leader 完成时刻 |
| "已达成共识"徽章 | 静态显示 | step2 自然收敛(`leaderRes.dispatched.length === 0`)后才显示 |
| **总控最终结论** | 完全是 mock 文本 | 取最后一次 `leaderRes.content`(已经在 step2 拿到了) |
| **各席位最终交付(折叠卡片)** | 当前 step3 没渲染 | 取每个 sub 线程的最后一条 AIMessage 文本 |
| **生成的报告文件清单** | 5 张假"档案" | 真实列出 `outputs/` 目录里的所有产出(markdown / pdf / csv 等) |
| **文件下载** | 假 toast | 用现有 `GET /api/threads/{tid}/artifacts/{path}` |
| "重新起草新规划" | 调用 `setCurrentStep(1)` 重置 | **保留**,含义就是回到 step1 重新开始 |
### ❌ 不做(直接删 / 不接口)
| 区块 | 原因 |
|---|---|
| 4 张 metric 卡片(设计全要素通过率 99.4% / Tier III+ / 终审共识度 42 轮研商 / 90.3分)| 全是按 `budgetLimit` 编出来的"展厅指标",没有真实数据源 |
| 三阶段推进路线图(0-24h / 24-60h / 60-72h 三张卡)| 假设业务必然是 72 小时交付,写死场景,不通用 |
| "采纳理由与最终裁定判词"段 | 整段都是模板话术,没接口能算出"90.3 分总评" |
| "12 轮极限对抗研商" 数据 | 当前没记录"轮次"语义,临时就用 leader 实际轮数代替即可,不值得为它出接口 |
| "签发起草成果并投产运行" 按钮 | 无对应业务动作(不是要部署、不是要审批),删掉 |
| "执行摘要" 段写死的 3 个 bullet | 模板话术,跟实际任务无关 |
最终 step3 页面只保留四块:**头部状态 + 总控结论 + 席位交付列表 + 文件下载清单**,再加底部"重新起草"。
---
## 2. 数据来源全景
```
┌───────────────────────────────────────────────────────────┐
│ step2 全程结束时,前端手里已经有的本地状态 │
├───────────────────────────────────────────────────────────┤
│ coordinatorName: "roundtable-coordinator-xxx" │
│ threadIds: { [agent_name]: thread_id, ... } │ ← /init 返回
│ step2RoundtableDialogues: [...所有气泡, 含每个 sub 最终 │
│ 交付的 content...] │
│ leaderRes.content (最后一次 leader 的 final text) │
└───────────────────────────────────────────────────────────┘
后端权威数据(需要时从后端拉):
- 每个 sub 线程的完整 state → GET /api/threads/{tid}/state
- 单个 outputs 文件内容/下载 → GET /api/threads/{tid}/artifacts/{path}
- outputs 目录文件清单 ❗暂无 → 新增接口(见 §3)
```
**关键判断**:席位最终交付文本 + 总控结论文本 **已经在 step2 内存里**,无需重新调接口。文件清单是**目前唯一缺失**的数据源。
---
## 3. 接口需求
### 3.1 新增(**必需**):列出某 thread 的产出文件
```
GET /api/multi-agent/sessions/artifacts?thread_id=<sub_thread_id>
```
或者更通用、放进 artifacts 路由下:
```
GET /api/threads/{thread_id}/artifacts
```
**为什么需要**:现有 artifacts endpoint 只能"按已知路径下载文件",没有"列出目录"功能。Step 3 需要知道 sub-agent 写到 `/mnt/user-data/outputs/` 下哪些文件,才能在前端列出可下载清单。
**响应**:
```json
{
"thread_id": "<sub_thread_id>",
"files": [
{
"path": "mnt/user-data/outputs/方案设计报告_生产系统安全改造技术方案.md",
"name": "方案设计报告_生产系统安全改造技术方案.md",
"size_bytes": 12480,
"mime_type": "text/markdown",
"modified_at": "2026-05-23T14:32:30.123Z"
},
...
]
}
```
**实现建议**:
- 列 `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` 下的文件
- 鉴权复用 `@require_permission("threads", "read", owner_check=True)`
- 路径要做 traversal 保护(虚拟路径仅限 `mnt/user-data/outputs/`)
### 3.2 新增(**可选,体感优化**):一次性聚合 step3 数据
```
POST /api/multi-agent/sessions/summary
Body: {
"main_agent_name": "roundtable-coordinator-xxx",
"thread_ids": { "<agent_name>": "<thread_id>", ... }
}
```
**为什么可选**:不加这个接口的话,前端需要为每个席位发一次 `GET /state` + 一次 `GET /artifacts`(共 7-13 次 round-trip)。加了这个接口,**一次返回所有数据**。
**响应**:
```json
{
"main_agent_name": "roundtable-coordinator-xxx",
"coordinator": {
"thread_id": "...",
"final_text": "...", // 最后一条 AIMessage content
"dispatch_rounds": 3 // leader 派活的轮数(可选展示)
},
"seats": [
{
"agent_name": "roundtable-intelligence",
"thread_id": "...",
"display_name": "情报收集",
"final_text": "...", // 最后一条 AIMessage content
"files": [ // 该席位写到 outputs/ 的文件
{ "path": "...", "name": "...", "size_bytes": ..., "mime_type": "..." }
]
},
...
]
}
```
**取舍**:
- 优点:1 次请求 vs 13 次;体验更顺
- 缺点:等于把 3.1 + N 次 state 读放到一个 endpoint,实现稍复杂
- 推荐:**先做 3.1,3.2 暂缓**;前端用现有 endpoint 串起来够用
### 3.3 复用(**无需改动**)
| Endpoint | 用途 |
|---|---|
| `GET /api/threads/{tid}/state` | 取席位 / 总控线程的完整状态,提取最后一条 AIMessage content |
| `GET /api/threads/{tid}/artifacts/{path}` | 点击文件名预览或下载(已有 `?download=true` 支持) |
> Note: 这两个 endpoint 已经在 `app.gateway.routers.threads` / `app.gateway.routers.artifacts` 里实现,前端 `fetch-client` 也有现成调用方式。
---
## 4. 前后端对接流程
### 4.1 进入 step3 时
```ts
// 触发条件:runOrchestration 自然结束(leaderRes.dispatched.length === 0
// 且无 clarification),用户点"达成共识生成全案成果"按钮 / 自动跳转。
// 前端手里已有:threadIds, coordinatorName, 最后的 leaderRes
// 1. 总控结论文本 — 已在内存,直接用
const consensusText = lastLeaderRes.content;
// 2. 各席位交付 — 已在 step2RoundtableDialogues 里,过滤"子智能体交付"的卡片
const seatDeliveries = step2RoundtableDialogues
.filter(d => d.confidence === "子智能体交付")
.map(d => ({ sender: d.sender, content: d.content }));
// 3. 文件清单 — 调新接口
const filesPerSeat = await Promise.all(
Object.entries(threadIds)
.filter(([name]) => name !== coordinatorName)
.map(async ([name, tid]) => ({
agentName: name,
files: (await fetch(`/api/threads/${tid}/artifacts`).then(r => r.json())).files,
})),
);
```
### 4.2 用户点击文件时
```ts
// 在线预览(mime 是 markdown/text/pdf 自动 inline)
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}`);
// 强制下载
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}?download=true`);
```
### 4.3 "重新起草新规划" 按钮
```ts
// 跟现在一样,纯前端:清掉 dialogues + 跳 step1
setCurrentStep(1);
setInteractiveMessages(...); // 已有逻辑
setStep2RoundtableDialogues([]);
// 不需要调任何接口
```
---
## 5. 改造前端的具体文件清单
| 文件 | 改动 |
|---|---|
| `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` | **大改**。删除四张 metric 卡 / 三阶段路线图 / 论证要件 / 裁定判词;新增结论卡 + 席位折叠列表 + 文件清单 |
| `frontend-web/src/roundtable-planning/api/multi-agent.ts` | 新增 `listSessionArtifacts(threadId)` helper(封装 §3.1 接口) |
| `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 把 `threadIds` / `coordinatorName` / `lastLeaderContent` 传进 `HighFidelityReport` 的 props(目前没传) |
---
## 6. 改造后端的具体文件清单
| 文件 | 改动 |
|---|---|
| `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | 新增 `GET /api/threads/{tid}/artifacts`(不带 path,列目录) |
| `offline-backend-20260512/backend/CLAUDE.md` | 在 artifacts 路由表里增加新条目说明 |
| `offline-backend-20260512/backend/tests/test_artifacts_list.py` | 列目录新接口的单测(覆盖:空目录返回 [];traversal 路径返回 403;不存在 thread 返回 404)|
---
## 7. 实施优先级
| 优先级 | 项 | 备注 |
|---|---|---|
| P0 | 新增 `GET /api/threads/{tid}/artifacts` 列文件接口 | 必需,没它前端无法列文件 |
| P0 | step3 大改:删 mock 区块、加结论/席位/文件三块 | 主要价值改造 |
| P1 | 前端 helper `listSessionArtifacts` | 封装 fetch + 类型 |
| P2 | 单测 `test_artifacts_list.py` | 上线后跟进 |
| P3 | `POST /api/multi-agent/sessions/summary` 聚合接口 | 13 次→1 次,体感优化,**非必需** |
---
## 8. 显式不做的事 ❌
1. **不做"PDF 导出"**:后端没有现成 PDF 渲染管线,要做就是单独的项目。当前页面那个 `导出方案 PDF` 按钮直接删掉,或者保留按钮但点击只触发 `window.print()`(让浏览器自己生成 PDF)。
2. **不做"安全分享"**:项目里已有 `/api/thread-shares`(per-thread 分享)和 `/api/public/shares`(公开页面),但那是针对单个 thread 的,不是多 thread 会话的。要做"多 thread 整合分享"是新功能,远超 step3 的范围。
3. **不做"指标雷达图"**(什么 90.3 分总评):没有真实数据源,强行加只会变成下一个 mock。
4. **不做"签发投产"**:纯展示按钮,不接业务系统,删。
5. **不做"轮次/对抗次数"显示**:除非前端记一下 `dispatch_count`,否则后端没现成统计。如果真要展示,用现成的"派活了几次"代替"对抗 N 轮"。
---
## 9. 验收清单
step3 改造完成后应该能:
- [ ] 看到当前 session 的总控最终结论,markdown 渲染
- [ ] 看到 6 个席位各自的最终交付内容(默认折叠,点击展开)
- [ ] 看到所有 sub-agent 写到 outputs 的文件列表,含文件名、大小、类型 icon
- [ ] 点击文件名能在线预览(markdown / pdf / 图片)
- [ ] 点击下载按钮能下载(带正确文件名)
- [ ] 点"重新起草新规划"能回到 step1 且 step2 内存清空
- [ ] 页面不再包含任何来自 `budgetLimit` 的"假指标"
---
## 10. 路径速查
| 资源 | 位置 |
|---|---|
| Step 3 组件 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
| 父页面 (state 持有者) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 后端 artifacts 路由 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 后端 multi-agent 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| 物理 outputs 目录 | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-312
View File
@@ -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 内容以 `<artifact>` 标签嵌入人类消息末尾。
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
- 快捷动作:`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<artifact ${tag}>\n${body}\n</artifact>`;
}
// core/messages/utils.ts
export function stripArtifactTag(content: string): string {
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
}
```
### 3.3 ThinkTagParser(AI 改写思考过程解析)
后端支持两种思考格式:
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
### 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 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
| `<artifact>` 标签在聊天面板里显示 | `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 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
```
-950
View File
@@ -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<CanvasArtifactText | CanvasArtifactCode>;
};
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 架构保持长期兼容。
File diff suppressed because it is too large. Load diff
-243
View File
@@ -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-内网检索返回空--调不通) 排查。
+2 -2
View File
@@ -20,13 +20,13 @@ import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) { function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []); const locale = useMemo(() => detectLocale(), []);
const location = useLocation(); const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined; const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
return ( return (
<NextThemesProvider <NextThemesProvider
attribute="class" attribute="class"
storageKey="strategy-theme" storageKey="strategy-theme"
defaultTheme="dark" defaultTheme="light"
enableSystem={false} enableSystem={false}
disableTransitionOnChange disableTransitionOnChange
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
+20 -55
View File
@@ -72,12 +72,14 @@ const menuItems: MenuItem[] = [
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ // 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
id: "qa-roundtable", // 需要重新开放时取消注释即可。
label: "圆桌规划", // {
icon: Bot, // id: "qa-roundtable",
path: "/page/roundtable/planning", // label: "圆桌规划",
}, // icon: Bot,
// path: "/page/roundtable/planning",
// },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot }, { id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout }, { id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
@@ -400,63 +402,26 @@ export function PageSidebar({
onOpenChange={setNotificationOpen} onOpenChange={setNotificationOpen}
/> />
<aside <aside
className={`fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${ className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark isDark
? "border-r border-blue-800/30 bg-slate-900" ? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white" : "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`} } ${collapsed ? "w-16" : "w-64"}`}
> >
<div <button
className={`flex h-16 items-center theme-transition ${ type="button"
isDark ? "border-b border-blue-800/30" : "border-b border-red-100" onClick={onToggleCollapsed}
} ${collapsed ? "justify-center px-2" : "justify-between px-6"}`} title={collapsed ? "展开侧边栏" : "收起侧边栏"}
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
isDark
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
}`}
> >
<div className="flex min-w-0 items-center gap-3"> {collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
<div </button>
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg transition-transform duration-300 ease-out ${
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-gradient-to-br from-red-500 to-red-400"
}`}
>
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
<h1
className={`text-sm font-semibold theme-transition ${
isDark ? "text-white" : "text-gray-900"
}`}
>
CM助手
</h1>
</div>
)}
</div>
{!collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={iconButtonClass}
title="Collapse sidebar"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
</div>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}> <nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={`flex w-full items-center justify-center ${iconButtonClass}`}
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5" />
</button>
)}
{visibleMenuItems.map((item) => {visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item), item.children ? renderBranch(item) : renderLeaf(item),
)} )}
@@ -3,6 +3,7 @@
import { BotIcon } from "lucide-react"; import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents"; import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function AgentWelcome({ export function AgentWelcome({
@@ -14,8 +15,10 @@ export function AgentWelcome({
agent: Agent | null | undefined; agent: Agent | null | undefined;
agentName: string; agentName: string;
}) { }) {
const displayName = agent?.name ?? agentName; const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description; const description = agent?.description
? desensitize(agent.description)
: undefined;
return ( return (
<div <div
@@ -48,6 +48,7 @@ import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AgentCardProps { interface AgentCardProps {
@@ -195,12 +196,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<BotIcon className="h-5 w-5" /> <BotIcon className="h-5 w-5" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<CardTitle className="truncate text-base" title={agent.name}> <CardTitle className="truncate text-base" title={desensitize(agent.name)}>
{agent.name} {desensitize(agent.name)}
</CardTitle> </CardTitle>
{agent.model && ( {agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs"> <Badge variant="secondary" className="mt-0.5 text-xs">
{agent.model} {desensitize(agent.model)}
</Badge> </Badge>
)} )}
</div> </div>
@@ -252,7 +253,7 @@ export function AgentCard({ agent }: AgentCardProps) {
</div> </div>
{agent.description && ( {agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm"> <CardDescription className="mt-2 line-clamp-2 text-sm">
{agent.description} {desensitize(agent.description)}
</CardDescription> </CardDescription>
)} )}
</CardHeader> </CardHeader>
@@ -272,7 +273,7 @@ export function AgentCard({ agent }: AgentCardProps) {
variant="outline" variant="outline"
className="text-xs" className="text-xs"
> >
{group} {desensitize(group)}
</Badge> </Badge>
))} ))}
{agent.skills?.map((skill) => ( {agent.skills?.map((skill) => (
@@ -286,7 +287,7 @@ export function AgentCard({ agent }: AgentCardProps) {
setDetailSkillName(skill); setDetailSkillName(skill);
}} }}
> >
{skill} {desensitize(skill)}
</Badge> </Badge>
))} ))}
</div> </div>
@@ -377,7 +378,7 @@ export function AgentCard({ agent }: AgentCardProps) {
<Dialog open={editOpen} onOpenChange={setEditOpen}> <Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>编辑智能体:{agent.name}</DialogTitle> <DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
<DialogDescription> <DialogDescription>
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。 可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
</DialogDescription> </DialogDescription>
@@ -428,10 +429,10 @@ export function AgentCard({ agent }: AgentCardProps) {
size="sm" size="sm"
variant={checked ? "default" : "outline"} variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)} onClick={() => toggleSkill(skill.name)}
title={skill.description} title={desensitize(skill.description ?? "")}
className="h-8 max-w-[200px] rounded-r-none" className="h-8 max-w-[200px] rounded-r-none"
> >
<span className="truncate">{skill.name}</span> <span className="truncate">{desensitize(skill.name)}</span>
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -50,6 +50,7 @@ import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArtifactLink } from "../citations/artifact-link"; import { ArtifactLink } from "../citations/artifact-link";
@@ -608,7 +609,7 @@ export function ArtifactFileDetail({
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={desensitize(displayContent ?? "")}
language={language ?? "text"} language={language ?? "text"}
animated={showPreviewSwitchPulse} animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit} activeCursor={isApplyingEdit}
@@ -618,7 +619,11 @@ export function ArtifactFileDetail({
<div className="size-full"> <div className="size-full">
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent" className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""} value={
canEditArtifact
? (displayContent ?? "")
: desensitize(displayContent ?? "")
}
readonly={!canEditArtifact} readonly={!canEditArtifact}
onChange={ onChange={
canEditArtifact canEditArtifact
@@ -63,6 +63,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AgentSelector } from "@/components/workspace/agents/agent-selector"; import { AgentSelector } from "@/components/workspace/agents/agent-selector";
@@ -500,7 +501,7 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0); setTimeout(() => requestFormSubmit(), 0);
}} }}
> >
{q.title} {desensitize(q.title)}
</button> </button>
))} ))}
</div> </div>
@@ -8,6 +8,7 @@ import {
type MessageResponseProps, type MessageResponseProps,
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
@@ -63,6 +64,8 @@ export function MarkdownContent({
if (!content) return null; if (!content) return null;
const displayContent = desensitize(content);
return ( return (
<MessageResponse <MessageResponse
className={className} className={className}
@@ -70,7 +73,7 @@ export function MarkdownContent({
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
components={components} components={components}
> >
{content} {displayContent}
</MessageResponse> </MessageResponse>
); );
} }
@@ -37,6 +37,7 @@ import {
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button"; import { CopyButton } from "../copy-button";
@@ -226,7 +227,7 @@ function MessageContent_({
<TaskTrigger title=""> <TaskTrigger title="">
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none"> <div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
<Loader className="size-4" /> <Loader className="size-4" />
<span>{contentToDisplay}</span> <span>{desensitize(contentToDisplay)}</span>
</div> </div>
</TaskTrigger> </TaskTrigger>
</Task> </Task>
@@ -240,7 +241,7 @@ function MessageContent_({
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}> <Reasoning isStreaming={isLoading}>
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent>{reasoningContent}</ReasoningContent> <ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
</Reasoning> </Reasoning>
<MessageTokenUsage <MessageTokenUsage
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -259,7 +260,7 @@ function MessageContent_({
components={components} components={components}
parseIncompleteMarkdown={false} parseIncompleteMarkdown={false}
> >
{contentToDisplay} {desensitize(contentToDisplay)}
</AIElementMessageResponse> </AIElementMessageResponse>
) : null; ) : null;
return ( return (
@@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { StreamingIndicator } from "../streaming-indicator"; import { StreamingIndicator } from "../streaming-indicator";
@@ -102,7 +103,9 @@ export function MessageList({
[], [],
); );
const handleSubmitAllClarifications = useCallback(async () => { const handleSubmitAllClarifications = useCallback(async (
selectionOverrides?: Map<string, ClarificationSelection>,
) => {
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) { if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
return; return;
} }
@@ -111,7 +114,7 @@ export function MessageList({
const id = pendingClarificationIds[index]!; const id = pendingClarificationIds[index]!;
const message = messages.find((msg) => msg.id === id); const message = messages.find((msg) => msg.id === id);
const payload = message ? getClarificationPayload(message) : null; const payload = message ? getClarificationPayload(message) : null;
const selection = clarificationSelections.get(id) ?? { const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
selectedIds: [], selectedIds: [],
custom: "", custom: "",
}; };
@@ -221,7 +224,12 @@ export function MessageList({
} }
submitting={submittingClarification} submitting={submittingClarification}
canSubmit={hasAnyClarificationAnswer} canSubmit={hasAnyClarificationAnswer}
onSubmitAll={handleSubmitAllClarifications} onSubmitAll={(override) => {
const overrides = override
? new Map([[messageId, override]])
: undefined;
void handleSubmitAllClarifications(overrides);
}}
/> />
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -412,6 +420,7 @@ type ClarificationPayload = {
clarification_type?: string; clarification_type?: string;
options?: ClarificationOption[]; options?: ClarificationOption[];
allow_custom?: boolean; allow_custom?: boolean;
allow_multiple?: boolean;
}; };
type ClarificationSelection = { type ClarificationSelection = {
@@ -600,7 +609,7 @@ function ClarificationCard({
onSelectionChange: (next: ClarificationSelection) => void; onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean; submitting: boolean;
canSubmit: boolean; canSubmit: boolean;
onSubmitAll: () => void | Promise<void>; onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
}) { }) {
const payload = getClarificationPayload(message); const payload = getClarificationPayload(message);
@@ -624,11 +633,21 @@ function ClarificationCard({
const disabled = !isPending || submitting; const disabled = !isPending || submitting;
const multiPending = (pendingCount ?? 0) > 1; const multiPending = (pendingCount ?? 0) > 1;
const allowMultiple = resolveAllowMultiple(
payload.allow_multiple,
payload.clarification_type,
);
function toggleOption(optionId: string) { function handleOptionClick(optionId: string) {
if (disabled) { if (disabled) {
return; return;
} }
if (!allowMultiple) {
const next = { selectedIds: [optionId], custom: "" };
onSelectionChange(next);
void onSubmitAll(next);
return;
}
const next = selection.selectedIds.includes(optionId) const next = selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId) ? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId]; : [...selection.selectedIds, optionId];
@@ -666,7 +685,7 @@ function ClarificationCard({
{options.length > 0 ? ( {options.length > 0 ? (
<div className="mt-4 grid gap-2"> <div className="mt-4 grid gap-2">
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
可多选(再次点击取消选择) {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
</div> </div>
{options.map((option) => { {options.map((option) => {
const optionId = option.id || option.label; const optionId = option.id || option.label;
@@ -675,10 +694,10 @@ function ClarificationCard({
<Button <Button
key={optionId} key={optionId}
type="button" type="button"
variant={isSelected ? "default" : "outline"} variant={isSelected && allowMultiple ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left" className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled} disabled={disabled}
onClick={() => toggleOption(optionId)} onClick={() => handleOptionClick(optionId)}
> >
<span className="flex min-w-0 flex-1 flex-col items-start gap-1"> <span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span> <span>{option.label}</span>
@@ -695,7 +714,7 @@ function ClarificationCard({
</span> </span>
) : null} ) : null}
</span> </span>
{isSelected ? ( {isSelected && allowMultiple ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" /> <CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null} ) : null}
</Button> </Button>
@@ -720,10 +739,10 @@ function ClarificationCard({
</div> </div>
) : null} ) : null}
{isPending ? ( {isPending && (allowMultiple || payload.allow_custom !== false) ? (
isLastPending ? ( isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2"> <div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? ( {multiPending && allowMultiple ? (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答 将一起提交以上 {pendingCount} 个问题的回答
</span> </span>
@@ -735,14 +754,14 @@ function ClarificationCard({
onClick={() => void onSubmitAll()} onClick={() => void onSubmitAll()}
> >
<SendIcon className="h-4 w-4" /> <SendIcon className="h-4 w-4" />
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"} {submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
</Button> </Button>
</div> </div>
) : ( ) : allowMultiple ? (
<div className="text-muted-foreground mt-3 text-right text-xs"> <div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交 选择后等待最后一题统一提交
</div> </div>
) ) : null
) : null} ) : null}
</div> </div>
); );
@@ -58,6 +58,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
export function RecentChatList() { export function RecentChatList() {
@@ -211,7 +212,7 @@ export function RecentChatList() {
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden" className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)} href={pathOfThread(thread)}
> >
{titleOfThread(thread)} {desensitize(titleOfThread(thread))}
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -42,6 +42,7 @@ import type {
MemoryTarget, MemoryTarget,
MemoryV2Config, MemoryV2Config,
} from "@/core/memory/types"; } from "@/core/memory/types";
import { desensitize } from "@/lib/desensitize";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -173,7 +174,7 @@ export function MemorySettingsPage() {
className="flex items-start justify-between gap-3 rounded-md border p-3" className="flex items-start justify-between gap-3 rounded-md border p-3"
> >
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm [overflow-wrap:anywhere]"> <p className="min-w-0 flex-1 whitespace-pre-wrap text-sm [overflow-wrap:anywhere]">
{entry} {desensitize(entry)}
</p> </p>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
@@ -234,7 +235,7 @@ export function MemorySettingsPage() {
</SelectItem> </SelectItem>
{experts.map((a) => ( {experts.map((a) => (
<SelectItem key={a.id} value={a.id}> <SelectItem key={a.id} value={a.id}>
{a.name} {desensitize(a.name)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -272,7 +273,7 @@ export function MemorySettingsPage() {
</SelectItem> </SelectItem>
{experts.map((a) => ( {experts.map((a) => (
<SelectItem key={a.id} value={a.id}> <SelectItem key={a.id} value={a.id}>
{a.name} {desensitize(a.name)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -356,7 +357,7 @@ export function MemorySettingsPage() {
<div className="text-muted-foreground mb-1 font-medium"> <div className="text-muted-foreground mb-1 font-medium">
{m.entryToDeleteLabel} {m.entryToDeleteLabel}
</div> </div>
<p className="break-words">{entryToDelete.content}</p> <p className="break-words">{desensitize(entryToDelete.content)}</p>
</div> </div>
) : null} ) : null}
<DialogFooter> <DialogFooter>
@@ -539,10 +540,10 @@ function MemoryV1View({
{Math.round(fact.confidence * 100)}% {Math.round(fact.confidence * 100)}%
</span> </span>
</div> </div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p> <p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && ( {fact.sourceError && (
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1"> <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
避免:{fact.sourceError} 避免:{desensitize(fact.sourceError)}
</p> </p>
)} )}
</div> </div>
@@ -566,7 +567,7 @@ function MemoryV1View({
<dt className="font-medium text-muted-foreground mb-0.5"> <dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key} {CTX_LABELS[key] ?? key}
</dt> </dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd> <dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -585,7 +586,7 @@ function MemoryV1View({
<dt className="font-medium text-muted-foreground mb-0.5"> <dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key} {HISTORY_LABELS[key] ?? key}
</dt> </dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd> <dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -686,7 +687,7 @@ function MemorySearchPanel() {
</SelectItem> </SelectItem>
{experts.map((a) => ( {experts.map((a) => (
<SelectItem key={a.id} value={a.id}> <SelectItem key={a.id} value={a.id}>
{a.name} {desensitize(a.name)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -719,7 +720,7 @@ function MemorySearchPanel() {
) : results !== null ? ( ) : results !== null ? (
mode === "reflect" ? ( mode === "reflect" ? (
<div className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]"> <div className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">
{reflectText.trim() || m.searchNoResults} {reflectText.trim() ? desensitize(reflectText.trim()) : m.searchNoResults}
</div> </div>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm"> <div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm">
@@ -735,7 +736,7 @@ function MemorySearchPanel() {
key={`${index}-${item.slice(0, 16)}`} key={`${index}-${item.slice(0, 16)}`}
className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]" className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]"
> >
{item} {desensitize(item)}
</div> </div>
))} ))}
</div> </div>
@@ -68,6 +68,7 @@ import {
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -404,7 +405,7 @@ function SkillRow({
> >
<ItemTitle> <ItemTitle>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span> <span>{desensitize(skill.name)}</span>
{isBuiltin && ( {isBuiltin && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin} {t.settings.skills.badgeBuiltin}
@@ -474,7 +475,7 @@ function SkillRow({
</div> </div>
</ItemTitle> </ItemTitle>
<ItemDescription className="line-clamp-4"> <ItemDescription className="line-clamp-4">
{skill.description} {desensitize(skill.description ?? "")}
</ItemDescription> </ItemDescription>
{skill.use_count !== undefined && ( {skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
@@ -560,7 +561,9 @@ function SkillRow({
<DialogHeader> <DialogHeader>
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle> <DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)} {desensitize(
t.settings.skills.deleteConfirm.replace("{name}", skill.name),
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -809,22 +812,22 @@ function InstallSkillDialog({
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm"> <div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
<div className="flex items-center gap-2 text-emerald-600"> <div className="flex items-center gap-2 text-emerald-600">
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
<span className="font-medium">{validation.skill_name}</span> <span className="font-medium">{desensitize(validation.skill_name)}</span>
</div> </div>
<Field label={dialog.descriptionLabel}> <Field label={dialog.descriptionLabel}>
<span className="line-clamp-3 whitespace-pre-wrap"> <span className="line-clamp-3 whitespace-pre-wrap">
{validation.description || "—"} {validation.description ? desensitize(validation.description) : "—"}
</span> </span>
</Field> </Field>
{validation.license && ( {validation.license && (
<Field label={dialog.licenseLabel}>{validation.license}</Field> <Field label={dialog.licenseLabel}>{desensitize(validation.license)}</Field>
)} )}
{validation.allowed_tools.length > 0 && ( {validation.allowed_tools.length > 0 && (
<Field label={dialog.toolsLabel}> <Field label={dialog.toolsLabel}>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{validation.allowed_tools.map((tool) => ( {validation.allowed_tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-xs"> <Badge key={tool} variant="secondary" className="text-xs">
{tool} {desensitize(tool)}
</Badge> </Badge>
))} ))}
</div> </div>
@@ -834,7 +837,7 @@ function InstallSkillDialog({
<Field label={dialog.warningsLabel}> <Field label={dialog.warningsLabel}>
<ul className="list-disc pl-4 text-xs"> <ul className="list-disc pl-4 text-xs">
{validation.warnings.map((w, idx) => ( {validation.warnings.map((w, idx) => (
<li key={idx}>{w}</li> <li key={idx}>{desensitize(w)}</li>
))} ))}
</ul> </ul>
</Field> </Field>
@@ -843,7 +846,9 @@ function InstallSkillDialog({
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs"> <div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" /> <AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span> <span>
{dialog.conflictCustom.replace("{name}", validation.skill_name)} {desensitize(
dialog.conflictCustom.replace("{name}", validation.skill_name),
)}
</span> </span>
</div> </div>
)} )}
@@ -851,7 +856,9 @@ function InstallSkillDialog({
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}> <div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" /> <AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span> <span>
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)} {desensitize(
(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name),
)}
</span> </span>
</div> </div>
)} )}
@@ -30,6 +30,7 @@ import {
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks"; import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Kind = "stale" | "archived"; type Kind = "stale" | "archived";
@@ -146,7 +147,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{skill.name}</span> <span className="font-medium">{desensitize(skill.name)}</span>
{isArchived ? ( {isArchived ? (
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}> <Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
{tt.badgeArchived} {tt.badgeArchived}
@@ -180,7 +181,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm"> <p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{skill.description} {desensitize(skill.description ?? "")}
</p> </p>
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs"> <p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>{timeLabel}</span> <span>{timeLabel}</span>
@@ -254,7 +255,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
<DialogHeader> <DialogHeader>
<DialogTitle>{tt.deleteAction}</DialogTitle> <DialogTitle>{tt.deleteAction}</DialogTitle>
<DialogDescription> <DialogDescription>
{tt.deleteConfirm.replace("{name}", skill.name)} {desensitize(tt.deleteConfirm.replace("{name}", skill.name))}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -295,12 +296,12 @@ function SkillViewDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{skill.name}</DialogTitle> <DialogTitle>{desensitize(skill.name)}</DialogTitle>
<DialogDescription>{tv.title}</DialogDescription> <DialogDescription>{tv.title}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3 text-sm"> <div className="grid gap-3 text-sm">
<Row label={tv.descriptionLabel}> <Row label={tv.descriptionLabel}>
<span className="whitespace-pre-wrap">{skill.description}</span> <span className="whitespace-pre-wrap">{desensitize(skill.description ?? "")}</span>
</Row> </Row>
<Row label={tv.stateLabel}> <Row label={tv.stateLabel}>
<span className={cn("font-medium", stateColor(skill.state))}> <span className={cn("font-medium", stateColor(skill.state))}>
@@ -34,6 +34,7 @@ import {
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
const markdownClassName = const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " + "!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
@@ -147,7 +148,7 @@ export function SkillDetailDialog({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base"> <DialogTitle className="truncate text-base">
{skill?.name ?? skillName ?? ""} {desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle> </DialogTitle>
{skill?.builtin && ( {skill?.builtin && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
@@ -165,7 +166,7 @@ export function SkillDetailDialog({
)} )}
</div> </div>
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed"> <DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description || "-"} {skill?.description ? desensitize(skill.description) : "-"}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@@ -328,7 +329,7 @@ export function SkillDetailDialog({
</div> </div>
) : showRawSource ? ( ) : showRawSource ? (
<pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap"> <pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{contentData?.content} {desensitize(contentData?.content ?? "")}
</pre> </pre>
) : ( ) : (
<MarkdownContent <MarkdownContent
@@ -1,5 +1,6 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Tag } from "@/core/tags"; import type { Tag } from "@/core/tags";
@@ -24,7 +25,7 @@ export function TagChip({
className, className,
)} )}
> >
{tag.name} {desensitize(tag.name)}
{onRemove && ( {onRemove && (
<button <button
type="button" type="button"
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats"; import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display"; import { FlipDisplay } from "./flip-display";
@@ -44,7 +45,7 @@ export function ThreadTitle({
} }
return ( return (
<FlipDisplay uniqueKey={threadId}> <FlipDisplay uniqueKey={threadId}>
{thread.values.title ?? "Untitled"} {desensitize(thread.values.title ?? "Untitled")}
</FlipDisplay> </FlipDisplay>
); );
} }
+3 -3
View File
@@ -86,10 +86,10 @@ const STORAGE_KEY = 'strategy-theme';
function readPersistedTheme(): ThemeType { function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return 'dark'; return 'light';
} }
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'light' ? 'light' : 'dark'; return saved === 'dark' ? 'dark' : 'light';
} }
// 主题上下文 // 主题上下文
@@ -125,7 +125,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
if (current !== 'light' && current !== 'dark') { if (current !== 'light' && current !== 'dark') {
const fallback = const fallback =
legacy === 'light' || legacy === 'dark' ? legacy : 'dark'; legacy === 'light' || legacy === 'dark' ? legacy : 'light';
setNextTheme(fallback); setNextTheme(fallback);
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) { } else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy); setNextTheme(legacy);
+1 -1
View File
@@ -5,7 +5,7 @@ import { env } from "@/env";
* 素材的 recUuid 直接拼接在其后即为完整原文链接。 * 素材的 recUuid 直接拼接在其后即为完整原文链接。
*/ */
export const INTRANET_SOURCE_URL_BASE = export const INTRANET_SOURCE_URL_BASE =
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid="; "https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid-detail?recUuid=";
/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */ /** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */
export function buildIntranetSourceUrl(recUuid?: string | null): string { export function buildIntranetSourceUrl(recUuid?: string | null): string {
+28 -2
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { desensitize } from "@/lib/desensitize";
import { useI18nContext } from "./context"; import { useI18nContext } from "./context";
import { getLocaleFromCookie, setLocaleInCookie } from "./cookies"; import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
@@ -13,10 +15,34 @@ import {
type Locale, type Locale,
} from "./index"; } from "./index";
function wrapWithDesensitize<T>(value: T): T {
if (typeof value === "string") {
return desensitize(value) as T;
}
if (typeof value === "function") {
return ((...args: unknown[]) => {
const result = (value as (...a: unknown[]) => unknown)(...args);
return wrapWithDesensitize(result);
}) as T;
}
if (Array.isArray(value)) {
return value.map((item) => wrapWithDesensitize(item)) as T;
}
if (value !== null && typeof value === "object") {
return new Proxy(value as object, {
get(target, prop, receiver) {
return wrapWithDesensitize(Reflect.get(target, prop, receiver));
},
}) as T;
}
return value;
}
export function useI18n() { export function useI18n() {
const { locale, setLocale } = useI18nContext(); const { locale, setLocale } = useI18nContext();
const t = translations[locale] ?? translations[DEFAULT_LOCALE]; const rawT = translations[locale] ?? translations[DEFAULT_LOCALE];
const t = useMemo(() => wrapWithDesensitize(rawT), [rawT]);
const changeLocale = (newLocale: Locale) => { const changeLocale = (newLocale: Locale) => {
setLocale(newLocale); setLocale(newLocale);
+86
View File
@@ -0,0 +1,86 @@
/**
* 脱敏词映射表
* key: 原始词(支持正则特殊字符,会被转义)
* value: 脱敏后替换词
*/
export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS",
滨海作战体系: "第一张网",
滨海作战部队: "第一张网",
'Taiwan Air Force Flight Units': "第二张网",
台海空军飞行部队: "第二张网",
空军: "KJ",
海军: "HJ",
陆军: "LJ",
陆战队: "LZD",
海巡: "HX",
憲兵: "XB",
資通電軍: "ZTDJ",
情报: "QB",
指挥: "ZH",
信息准备: "XX准备",
行为体: "XWT",
驱动因素: "QDYS",
叙事: "XS",
前三定: "QSD",
受众目标: "SZMB",
作战: "ZZ",
目的: "MD",
关键传播节点: "GJCBJD",
战法: "ZF",
行动: "XD",
打击: "DJ",
目标: "MB",
战力: "ZL",
筹划: "COH",
策划: "CEH",
模拟: "MN",
推演: "TY",
认知: "RZ",
触及: "CJ",
军: "J",
美军: "MJ",
台军: "TJ",
};
/**
* 将 DESENSITIZE_MAP 编译为单个正则,避免多次遍历字符串。
* 仅在模块首次加载时执行一次。
*/
function buildRegex(map: Record<string, string>): RegExp | null {
const keys = Object.keys(map);
if (keys.length === 0) return null;
const pattern = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
return new RegExp(pattern, "g");
}
let _regex: RegExp | null = buildRegex(DESENSITIZE_MAP);
/**
* 对文本进行脱敏处理,将 DESENSITIZE_MAP 中的词替换为对应脱敏词。
*
* @param text 原始文本
* @returns 脱敏后的文本
*
* @example
* desensitize("当前态势分析") // => "当前TS分析"
*/
export function desensitize(text: string): string {
if (!_regex) return text;
// 每次 exec 需要重置 lastIndex,使用 replace 更安全
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
}
/**
* 运行时动态追加脱敏词,追加后自动重编译正则。
*
* @param entries 要追加的词条,格式同 DESENSITIZE_MAP
*
* @example
* addDesensitizeEntries({ 情报: "QB", 指挥: "ZH" });
*/
export function addDesensitizeEntries(entries: Record<string, string>): void {
Object.assign(DESENSITIZE_MAP, entries);
_regex = buildRegex(DESENSITIZE_MAP);
}
@@ -19,7 +19,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps { interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void; onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean; disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */ /** workspace 入口:隐藏严格模式、目标读者 */
hideAdvancedFields?: boolean; hideAdvancedFields?: boolean;
} }
@@ -29,12 +29,17 @@ interface ModelOption {
} }
const INPUT_CLASS = const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' + 'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50'; 'focus-visible:ring-ring disabled:opacity-50';
const DEFAULT_KEYWORD_COUNT = 6; const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者'; const DEFAULT_AUDIENCE = '普通读者';
const MAX_INTENT_LENGTH = 500;
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
const FIELD_TRIGGER_CLASS =
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
export function AIWritingForm({ export function AIWritingForm({
onSubmit, onSubmit,
@@ -45,7 +50,6 @@ export function AIWritingForm({
const [articleType, setArticleType] = useState('analysis'); const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800'); const [wordCountInput, setWordCountInput] = useState('800');
const [keywordCountInput, setKeywordCountInput] = useState('1');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]); const [models, setModels] = useState<ModelOption[]>([]);
@@ -105,23 +109,10 @@ export function AIWritingForm({
} }
} }
// 检索关键词数量限定 1~10,非法值回退默认 6
function handleKeywordCountBlur() {
const num = parseInt(keywordCountInput, 10);
if (isNaN(num)) {
setKeywordCountInput('1');
} else {
setKeywordCountInput(String(Math.max(1, Math.min(10, num))));
}
}
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!intent.trim()) return; if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
const keywordCount = hideAdvancedFields
? DEFAULT_KEYWORD_COUNT
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({ onSubmit({
userIntent: intent.trim(), userIntent: intent.trim(),
articleType, articleType,
@@ -129,103 +120,126 @@ export function AIWritingForm({
? DEFAULT_AUDIENCE ? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE, : audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount, wordCountTarget: wordCount,
keywordCount, keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined, modelName: modelName || undefined,
}); });
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4"> <form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
<div className="space-y-1.5"> <div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4">
<label className="text-xs font-medium text-muted-foreground">写作意图</label> <div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3">
<Textarea <PenLine className="size-4 text-[#8b6f6f]" />
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…" <h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f]">写作配置选项</h3>
value={intent} </div>
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={4}
className="resize-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2"> <div className="mt-4 space-y-2">
<div className="space-y-1.5"> <div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">文章类型</label> <label className={FIELD_LABEL_CLASS}>写作意图</label>
<span className="text-sm text-muted-foreground">
{intent.length} / {MAX_INTENT_LENGTH} 字
</span>
</div>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局..."
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={5}
maxLength={MAX_INTENT_LENGTH}
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
/>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>文章类型</label>
<Select
value={articleType}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-sm">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>目标字数</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模型</label>
<Select <Select
value={articleType} value={modelName}
onValueChange={handleArticleTypeChange} onValueChange={setModelName}
disabled={disabled || typesLoading} disabled={disabled || models.length === 0}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} /> <SelectValue placeholder={models.length > 0 ? '选择模型…' : '模型加载中…'} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{articleTypes.map((t) => ( {models.map((m) => (
<SelectItem key={t.key} value={t.key} className="text-xs"> <SelectItem key={m.name} value={m.name} className="text-sm">
{t.label} {m.displayName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <Button
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label> type="submit"
<input disabled={disabled || !intent.trim()}
type="number" className="mt-4 h-12 w-full gap-2 rounded-2xl text-base font-semibold"
min={100} >
step={100} <PenLine className="size-4" />
className={INPUT_CLASS} 开始 AI 写作
placeholder="800" </Button>
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
</div> </div>
{!hideAdvancedFields && ( {!hideAdvancedFields && (
<> <>
<div className="space-y-1.5"> <div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input <input
type="checkbox" type="checkbox"
id="strict-mode" id="strict-mode"
checked={strictMode} checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)} onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled} disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer" className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
/> />
<label htmlFor="strict-mode" className="cursor-pointer select-none"> <label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span> <span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed"> <p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。 勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p> </p>
</label> </label>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">目标读者</label> <label className="text-sm font-semibold text-foreground">目标读者</label>
<input <input
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
@@ -238,32 +252,6 @@ export function AIWritingForm({
</> </>
)} )}
{models.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
<Select
value={modelName}
onValueChange={setModelName}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="选择模型…" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
<PenLine className="size-3.5" />
开始 AI 写作
</Button>
</form> </form>
); );
} }
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react'; import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing'; import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition'; import { CollapseTransition } from './CollapseTransition';
interface OutlineSectionProps { interface OutlineSectionProps {
@@ -10,7 +11,7 @@ interface OutlineSectionProps {
onChange: (updated: OutlineSectionType) => void; onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void; onRemove?: () => void;
readOnly?: boolean; readOnly?: boolean;
// Drag-and-drop // Drag-and-drop (章节级别)
isDragging?: boolean; isDragging?: boolean;
isOver?: boolean; isOver?: boolean;
onDragStart?: () => void; onDragStart?: () => void;
@@ -19,6 +20,14 @@ interface OutlineSectionProps {
onDragEnd?: () => void; onDragEnd?: () => void;
} }
/**
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
* subPoints[] 为 bullet 列表(Level 3)。
*
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
* 编辑过的旧 session 大纲会被持久化为新格式)。
*/
export function OutlineSection({ export function OutlineSection({
section, index, onChange, onRemove, readOnly, section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
@@ -27,6 +36,13 @@ export function OutlineSection({
const [editingTitle, setEditingTitle] = useState(false); const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle); const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
// 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
// 都生成新引用导致子组件无谓重渲染。
const keyPoints: KeyPoint[] = useMemo(
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
[section.keyPoints],
);
function commitTitle() { function commitTitle() {
setEditingTitle(false); setEditingTitle(false);
const trimmed = titleDraft.trim(); const trimmed = titleDraft.trim();
@@ -37,20 +53,23 @@ export function OutlineSection({
} }
} }
function updateKeyPoint(i: number, value: string) { function updateKeyPoint(i: number, updated: KeyPoint) {
const next = [...section.keyPoints]; const next = [...keyPoints];
next[i] = value; next[i] = updated;
onChange({ ...section, keyPoints: next }); onChange({ ...section, keyPoints: next });
} }
function removeKeyPoint(i: number) { function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) }); onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
} }
function addKeyPoint() { function addKeyPoint() {
onChange({ ...section, keyPoints: [...section.keyPoints, ''] }); onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
} }
// 子要点总数,显示在章节头给个粗略概览
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
return ( return (
<div <div
draggable={!readOnly} draggable={!readOnly}
@@ -104,7 +123,9 @@ export function OutlineSection({
</span> </span>
)} )}
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span> <span className="text-muted-foreground/50 shrink-0">
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
</span>
{!readOnly && onRemove && ( {!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive"> <button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" /> <X className="size-3" />
@@ -112,24 +133,151 @@ export function OutlineSection({
)} )}
</div> </div>
{/* 要点列表 */} {/* 小节列表 (Level 2) */}
<CollapseTransition open={expanded}> <CollapseTransition open={expanded}>
<div className="px-8 pb-2.5 space-y-1 border-t"> <div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
{section.keyPoints.map((kp, i) => ( {keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5"> <KeyPointRow
<span className="text-muted-foreground/40 shrink-0">•</span> key={i}
keyPoint={kp}
onChange={(updated) => updateKeyPoint(i, updated)}
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
readOnly={readOnly}
/>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加小节
</button>
)}
</div>
</CollapseTransition>
</div>
);
}
// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
interface KeyPointRowProps {
keyPoint: KeyPoint;
onChange: (updated: KeyPoint) => void;
onRemove?: () => void;
readOnly?: boolean;
}
function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
const [expanded, setExpanded] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed !== keyPoint.pointTitle) {
onChange({ ...keyPoint, pointTitle: trimmed });
}
}
function updateSubPoint(i: number, value: string) {
const next = [...keyPoint.subPoints];
next[i] = value;
onChange({ ...keyPoint, subPoints: next });
}
function removeSubPoint(i: number) {
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
}
function addSubPoint() {
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
}
return (
<div className="rounded border border-border/40 bg-muted/10">
{/* 小节头 */}
<div className="flex items-center gap-1.5 px-2 py-1.5">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
aria-label="展开/收起子要点"
>
<ChevronRight
className={cn(
'size-3 text-muted-foreground/60 transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<span className="text-purple-500/70 shrink-0">###</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none"
placeholder="小节标题"
/>
) : (
<span
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
</span>
)}
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
</span>
{!readOnly && onRemove && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
aria-label="删除小节"
>
<X className="size-3" />
</button>
)}
</div>
{/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
{keyPoint.subPoints.map((sp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1">
{/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
{readOnly ? ( {readOnly ? (
<span className="flex-1 text-muted-foreground">{kp}</span> <span className="flex-1 text-muted-foreground">{sp}</span>
) : ( ) : (
<input <input
value={kp} value={sp}
onChange={(e) => updateKeyPoint(i, e.target.value)} onChange={(e) => updateSubPoint(i, e.target.value)}
placeholder="输入要点…" placeholder="子要点标题(会展开成一段正文)"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/> />
)} )}
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive"> <button
type="button"
onClick={() => removeSubPoint(i)}
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
aria-label="删除子要点"
>
<X className="size-3" /> <X className="size-3" />
</button> </button>
)} )}
@@ -138,11 +286,11 @@ export function OutlineSection({
{!readOnly && ( {!readOnly && (
<button <button
type="button" type="button"
onClick={addKeyPoint} onClick={addSubPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5" className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
> >
<Plus className="size-3" /> <Plus className="size-3" />
添加要点 添加子要点
</button> </button>
)} )}
</div> </div>
@@ -9,7 +9,7 @@ export default function OpenCanvasRoutes() {
return ( return (
<Routes> <Routes>
<Route index element={<CanvasHomePage />} /> <Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} /> <Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} /> <Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} /> <Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes> </Routes>
@@ -13,10 +13,25 @@ export interface AIMaterial {
} }
// ── 大纲 ───────────────────────────────────────────────────────────────────── // ── 大纲 ─────────────────────────────────────────────────────────────────────
// 三级结构:章节(##) → 小节(###) → 子要点(bullet)
// AIOutline.sections[] = Level 1 章节(渲染为 ## 由后端注入)
// OutlineSection.keyPoints[] = Level 2 小节(渲染为 ###)
// KeyPoint.subPoints[] = Level 3 子要点(正文里的 bullet 列表)
//
// 历史兼容:旧版 keyPoints: string[](2 级)。后端 _parse_outline 已经在解析时把
// 旧 string 自动 lift 成 KeyPoint;前端类型只保留新格式,但 UI 渲染层做 type
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
export interface KeyPoint {
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
}
export interface OutlineSection { export interface OutlineSection {
sectionTitle: string; sectionTitle: string;
keyPoints: string[]; // 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。
// 新生成的大纲一律 KeyPoint[]。
keyPoints: Array<KeyPoint | string>;
materialIds: string[]; materialIds: string[];
} }
@@ -25,6 +40,32 @@ export interface AIOutline {
sections: OutlineSection[]; sections: OutlineSection[];
} }
/**
* 把 keyPoints 单项规整成 KeyPoint。兼容三种输入:
* - 新版 KeyPoint object
* - 旧版 string (lift 为 { pointTitle: <str>, subPoints: [] })
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
*/
export function normalizeKeyPoint(raw: unknown): KeyPoint {
if (typeof raw === 'string') {
return { pointTitle: raw, subPoints: [] };
}
if (raw && typeof raw === 'object') {
const r = raw as Record<string, unknown>;
const title =
typeof r.pointTitle === 'string' ? r.pointTitle :
typeof r.point_title === 'string' ? r.point_title :
typeof r.title === 'string' ? r.title :
'';
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
const subPoints: string[] = Array.isArray(subsRaw)
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
: [];
return { pointTitle: title, subPoints };
}
return { pointTitle: '', subPoints: [] };
}
// ── 素材不足求助 ────────────────────────────────────────────────────────────── // ── 素材不足求助 ──────────────────────────────────────────────────────────────
/** 严格模式下素材不足、待用户决策的章节 */ /** 严格模式下素材不足、待用户决策的章节 */
@@ -158,7 +199,7 @@ export interface AIWritingRequest {
targetAudience: string; targetAudience: string;
wordCountTarget: number; wordCountTarget: number;
modelName?: string; modelName?: string;
/** 素材检索关键词数量(1~10,默认 6) */ /** 素材检索关键词数量(1~10,默认 1) */
keywordCount: number; keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */ /** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose'; writingMode: 'strict' | 'loose';
+4 -2
View File
@@ -30,6 +30,7 @@ import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function AgentChatPage() { export default function AgentChatPage() {
@@ -61,8 +62,9 @@ export default function AgentChatPage() {
); );
const { agent, isLoading: isAgentLoading } = useAgent(agent_id); const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
const agentDisplayName = const agentDisplayName = desensitize(
agent?.name ?? (isAgentLoading ? t.common.loading : ""); agent?.name ?? (isAgentLoading ? t.common.loading : ""),
);
const { threadId, setThreadId, isNewThread, setIsNewThread } = const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat(); useThreadChat();
+2 -1
View File
@@ -17,6 +17,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks"; import { useThreads } from "@/core/threads/hooks";
import { usePathOfThread, titleOfThread } from "@/core/threads/utils"; import { usePathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime"; import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
export default function ChatsPage() { export default function ChatsPage() {
const pathOfThread = usePathOfThread(); const pathOfThread = usePathOfThread();
@@ -69,7 +70,7 @@ export default function ChatsPage() {
<Link key={thread.thread_id} href={pathOfThread(thread)}> <Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4"> <div className="flex flex-col gap-2 border-b p-4">
<div> <div>
<div>{titleOfThread(thread)}</div> <div>{desensitize(titleOfThread(thread))}</div>
</div> </div>
{thread.updated_at && ( {thread.updated_at && (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
+14 -14
View File
@@ -15,20 +15,20 @@ function PageLayout() {
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []); const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
return ( return (
<div className="h-screen flex bg-muted/20"> <div className="h-screen flex flex-col bg-muted/20">
{sidebarEnabled && ( {sidebarEnabled && <Header collapsed={collapsed} />}
<PageSidebar <div className="flex flex-1 overflow-hidden">
collapsed={collapsed} {sidebarEnabled && (
onToggleCollapsed={() => setCollapsed((prev) => !prev)} <PageSidebar
/> collapsed={collapsed}
)} onToggleCollapsed={() => setCollapsed((prev) => !prev)}
<div />
className={`flex flex-col flex-1 overflow-hidden transition-[padding] duration-300 ${ )}
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64" <main
}`} className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
> !sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
{sidebarEnabled && <Header />} }`}
<main className="flex-1 overflow-hidden"> >
<Routes> <Routes>
<Route index element={<Navigate to="workspace" replace />} /> <Route index element={<Navigate to="workspace" replace />} />
<Route <Route
+14 -13
View File
@@ -62,6 +62,7 @@ import {
statusLabel, statusLabel,
statusVariant, statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder"; } from "@/components/workspace/scheduled-tasks/schedule-builder";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function formatDate(value?: string | null) { function formatDate(value?: string | null) {
@@ -334,7 +335,7 @@ export default function ScheduledTasksPage() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium leading-snug break-all"> <div className="font-medium leading-snug break-all">
{task.name} {desensitize(task.name)}
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-1"> <div className="mt-1 flex flex-wrap items-center gap-1">
{isRunningNow && ( {isRunningNow && (
@@ -358,7 +359,7 @@ export default function ScheduledTasksPage() {
)} )}
</div> </div>
<div className="text-muted-foreground mt-1 truncate text-xs"> <div className="text-muted-foreground mt-1 truncate text-xs">
{task.schedule_text} · {task.timezone} {desensitize(task.schedule_text ?? "")} · {task.timezone}
</div> </div>
</div> </div>
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0"> <Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
@@ -531,9 +532,9 @@ export default function ScheduledTasksPage() {
<div key={item.task_id} className="bg-card rounded-md border p-4"> <div key={item.task_id} className="bg-card rounded-md border p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium">{item.name}</div> <div className="truncate font-medium">{desensitize(item.name)}</div>
<div className="text-muted-foreground mt-1 truncate text-xs"> <div className="text-muted-foreground mt-1 truncate text-xs">
{item.schedule_text} · {item.timezone} {desensitize(item.schedule_text ?? "")} · {item.timezone}
</div> </div>
</div> </div>
{subscribed && ( {subscribed && (
@@ -543,7 +544,7 @@ export default function ScheduledTasksPage() {
)} )}
</div> </div>
<div className="text-muted-foreground mt-3 line-clamp-3 text-xs whitespace-pre-wrap"> <div className="text-muted-foreground mt-3 line-clamp-3 text-xs whitespace-pre-wrap">
{item.prompt_summary || "(无摘要)"} {item.prompt_summary ? desensitize(item.prompt_summary) : "(无摘要)"}
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button <Button
@@ -585,7 +586,7 @@ export default function ScheduledTasksPage() {
</Select> </Select>
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"} {selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
</p> </p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && ( {selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -625,7 +626,7 @@ export default function ScheduledTasksPage() {
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium"> <div className="truncate font-medium">
{run.result?.title || formatDate(run.scheduled_for)} {run.result?.title ? desensitize(run.result.title) : formatDate(run.scheduled_for)}
</div> </div>
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)} · 开始: 计划:{formatDate(run.scheduled_for)} · 开始:
@@ -642,7 +643,7 @@ export default function ScheduledTasksPage() {
</div> </div>
{run.error && ( {run.error && (
<div className="text-destructive mt-3 line-clamp-2 text-sm"> <div className="text-destructive mt-3 line-clamp-2 text-sm">
{run.error} {desensitize(run.error)}
</div> </div>
)} )}
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
@@ -703,13 +704,13 @@ export default function ScheduledTasksPage() {
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <div>
<div className="font-medium">{subscribeTarget?.name}</div> <div className="font-medium">{desensitize(subscribeTarget?.name ?? "")}</div>
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
{subscribeTarget?.schedule_text} · {subscribeTarget?.timezone} {desensitize(subscribeTarget?.schedule_text ?? "")} · {subscribeTarget?.timezone}
</div> </div>
</div> </div>
<div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap"> <div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap">
{subscribeTarget?.prompt_summary || "(无摘要)"} {subscribeTarget?.prompt_summary ? desensitize(subscribeTarget.prompt_summary) : "(无摘要)"}
</div> </div>
<div className="flex items-center justify-between rounded-md border p-3"> <div className="flex items-center justify-between rounded-md border p-3">
<div> <div>
@@ -809,8 +810,8 @@ export default function ScheduledTasksPage() {
{agents.map((agent) => ( {agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}> <SelectItem key={agent.id} value={agent.id}>
<span className="block truncate"> <span className="block truncate">
{agent.name} {desensitize(agent.name)}
{agent.description ? ` — ${agent.description}` : ""} {agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span> </span>
</SelectItem> </SelectItem>
))} ))}
@@ -1,6 +1,11 @@
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; 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 * 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 * built-in agent and no per-call coordinator: one /init creates a thread
@@ -16,9 +21,80 @@ export interface IntentInitResponse {
thread_id: string; thread_id: string;
} }
/**
* Structured diagnostic payload surfaced by the backend whenever an intent
* route raises (init) or yields a final `status: "error"` frame (stream).
* Same shape across `init_intent` / `stream_intent`; renderer keys off `code`
* for quick visual triage, but also shows `hint` + `trace_tail` as a panel
* so deployment-time issues (config.yaml unreachable LLM, missing builtin
* agent dirs, loopback going through proxy) are debuggable from the UI alone
* without tail-ing backend logs.
*/
export interface IntentErrorDetail {
/** Stable short category — see backend `_diagnose_exception`. */
code: string;
/** One-line human-readable description (already includes exception type). */
message: string;
exception_type: string;
exception_repr: string;
/** Root of the `__cause__` chain when distinct from the outer exception. */
root_type?: string | null;
root_repr?: string | null;
/** Actionable hint in Chinese, keyed by `code`. May be null for unrecognized errors. */
hint?: string | null;
/** Last ~12 lines of the traceback for in-UI inspection. */
trace_tail?: string[];
/** Caller-supplied label (e.g. "intent_init"). */
context?: string | null;
}
/**
* Thrown by `initIntent` when the HTTP call fails. Carries the parsed
* `IntentErrorDetail` (if the backend emitted one) so the page can render
* a rich diagnostic panel instead of a one-line message.
*/
export class IntentInitError extends Error {
/** HTTP status (typically 500 for diagnosed failures, 401/403 for auth). */
readonly status: number;
/** Structured detail when the backend emitted `_diagnose_exception` output. */
readonly detail: IntentErrorDetail | null;
/** Raw body text when JSON parse failed (kept for completeness). */
readonly rawBody: string;
constructor(status: number, detail: IntentErrorDetail | null, rawBody: string) {
super(detail?.message || `init intent failed (${status}): ${rawBody}`);
this.name = "IntentInitError";
this.status = status;
this.detail = detail;
this.rawBody = rawBody;
}
}
function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
if (!value || typeof value !== "object") return null;
const obj = value as Record<string, unknown>;
if (typeof obj.code !== "string" || typeof obj.message !== "string") return null;
return {
code: obj.code,
message: obj.message,
exception_type: typeof obj.exception_type === "string" ? obj.exception_type : "",
exception_repr: typeof obj.exception_repr === "string" ? obj.exception_repr : "",
root_type: typeof obj.root_type === "string" ? obj.root_type : null,
root_repr: typeof obj.root_repr === "string" ? obj.root_repr : null,
hint: typeof obj.hint === "string" ? obj.hint : null,
trace_tail: Array.isArray(obj.trace_tail) ? obj.trace_tail.filter((l): l is string => typeof l === "string") : [],
context: typeof obj.context === "string" ? obj.context : null,
};
}
/** /**
* Create a fresh thread for an intent-clarification session. * Create a fresh thread for an intent-clarification session.
* The backend returns the built-in agent id so the frontend doesn't hardcode it. * The backend returns the built-in agent id so the frontend doesn't hardcode it.
*
* On failure throws `IntentInitError` whose `.detail` carries the backend's
* `_diagnose_exception` payload — the page should surface `detail.hint` /
* `detail.trace_tail` to help diagnose intranet deployment issues without
* shell access to the server.
*/ */
export async function initIntent(model?: string): Promise<IntentInitResponse> { export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
@@ -27,8 +103,18 @@ export async function initIntent(model?: string): Promise<IntentInitResponse> {
body: JSON.stringify({ model }), body: JSON.stringify({ model }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const raw = await res.text().catch(() => "");
throw new Error(`init intent failed (${res.status}): ${text}`); let detail: IntentErrorDetail | null = null;
try {
const parsed = JSON.parse(raw);
// FastAPI HTTPException(detail=dict) → { "detail": {...} }
if (parsed && typeof parsed === "object" && parsed !== null) {
detail = parseIntentErrorDetail((parsed as { detail?: unknown }).detail) || parseIntentErrorDetail(parsed);
}
} catch {
// body wasn't JSON — fall through with raw text only.
}
throw new IntentInitError(res.status, detail, raw);
} }
return (await res.json()) as IntentInitResponse; return (await res.json()) as IntentInitResponse;
} }
@@ -69,16 +155,31 @@ export interface StreamIntentRequest {
* "正在输出" so users see what's happening during the inevitable LLM * "正在输出" so users see what's happening during the inevitable LLM
* first-token wait. * first-token wait.
*/ */
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void; onPhaseChange?: (
phase: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => void;
} }
export interface StreamIntentResult { export interface StreamIntentResult {
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */ /** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */
status: "asking" | "done" | "error"; status: "asking" | "clarification" | "done" | "error";
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */ /** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
content: string; content: string;
/** Populated only when status === "done". */ /** Populated only when status === "done". */
summary: IntentSummary | null; 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;
/**
* Populated only when status === "error" and the backend emitted a
* structured diagnostic (new in `_diagnose_exception`). UI uses this to show
* the same rich panel as init failures.
*/
errorDetail: IntentErrorDetail | null;
} }
/** /**
@@ -87,14 +188,26 @@ export interface StreamIntentResult {
* is optional. * is optional.
*/ */
function isFinalIntentFrame(value: unknown): value is { function isFinalIntentFrame(value: unknown): value is {
status: "asking" | "done" | "error"; status: "asking" | "clarification" | "done" | "error";
content?: string; content?: string;
summary?: IntentSummary | null; summary?: IntentSummary | null;
question?: string;
clarification_type?: string;
clarification_context?: string;
options?: unknown[];
allow_custom?: boolean;
allow_multiple?: boolean;
error_detail?: unknown;
} { } {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false; if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status; const status = (value as { status: unknown }).status;
return status === "asking" || status === "done" || status === "error"; return (
status === "asking" ||
status === "clarification" ||
status === "done" ||
status === "error"
);
} }
/** /**
@@ -160,13 +273,23 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
// provided, since the same `tool_calling` phase may surface a new tool). // provided, since the same `tool_calling` phase may surface a new tool).
let currentPhase: IntentStreamPhase = "connecting"; let currentPhase: IntentStreamPhase = "connecting";
req.onPhaseChange?.("connecting"); req.onPhaseChange?.("connecting");
const setPhase = (next: IntentStreamPhase, toolName?: string) => { const setPhase = (
next: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => {
if (next === currentPhase && !toolName) return; if (next === currentPhase && !toolName) return;
currentPhase = next; currentPhase = next;
req.onPhaseChange?.(next, toolName); req.onPhaseChange?.(next, toolName, args);
}; };
const result: StreamIntentResult = { status: "asking", content: "", summary: null }; const result: StreamIntentResult = {
status: "asking",
content: "",
summary: null,
clarification: null,
errorDetail: null,
};
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -205,6 +328,25 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
if (parsed.summary && typeof parsed.summary === "object") { if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary; result.summary = parsed.summary as IntentSummary;
} }
if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
}
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; continue;
} }
@@ -230,7 +372,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
? (toolCallChunks[0] as Record<string, unknown>) ? (toolCallChunks[0] as Record<string, unknown>)
: null; : null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName); // 把 tool args 一并透传给 UI;onPhaseChange 在 useStep1Intent 里
// 会把 args 累积进 message.steps,让 MessageStepsCard 渲染路径 chip /
// 命令代码块等子节点(与主聊天 MessageGroup 视觉一致)。
const toolArgs =
firstCall && typeof firstCall.args === "object" && firstCall.args !== null
? (firstCall.args as Record<string, unknown>)
: undefined;
setPhase("tool_calling", toolName, toolArgs);
} else if ( } else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
@@ -1,6 +1,8 @@
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type ThreadIdMap = Record<string, string>; export type ThreadIdMap = Record<string, string>;
export interface SeatReadyEvent { export interface SeatReadyEvent {
@@ -191,6 +193,13 @@ export interface StreamMultiAgentRequest {
agentType: StreamAgentType; agentType: StreamAgentType;
dAgentThreadId: ThreadIdMap; dAgentThreadId: ThreadIdMap;
agentName: string; agentName: string;
/**
* 人类可读名称(中文/英文)。仅 special 走广播时使用 —— 后端把它拼进
* 「子智能体X完成Y工作,交付内容为:...」里,这样总控 LLM 在下一轮看到自己
* 历史里的交付摘要时能用"语义化的名字"识别,而不是 UUID 字符串(LLM 对
* UUID 不友好,会导致重复派活)。后端缺失时 fallback 到 agentName。
*/
displayName?: string;
newMessage: string; newMessage: string;
model?: string; model?: string;
skillStopNames?: string[]; skillStopNames?: string[];
@@ -207,8 +216,14 @@ export interface StreamMultiAgentRequest {
* Fires whenever the inferred SSE phase changes. The UI can use this to * Fires whenever the inferred SSE phase changes. The UI can use this to
* show progressive status messages during the inevitable first-token wait. * show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise). * `name` is the tool name when phase is `tool_calling` (or empty otherwise).
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
*/ */
onPhaseChange?: (phase: StreamPhase, name?: string) => void; onPhaseChange?: (
phase: StreamPhase,
name?: string,
args?: Record<string, unknown>,
) => void;
} }
export interface ClarificationPayload { export interface ClarificationPayload {
@@ -222,6 +237,8 @@ export interface ClarificationPayload {
options: unknown[]; options: unknown[];
/** Whether the user is allowed to type a custom answer. */ /** Whether the user is allowed to type a custom answer. */
allowCustom: boolean; allowCustom: boolean;
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
allowMultiple?: boolean;
} }
export interface StreamMultiAgentResult { export interface StreamMultiAgentResult {
@@ -257,6 +274,7 @@ function isFinalStatusFrame(value: unknown): value is {
clarification_context?: string; clarification_context?: string;
options?: unknown[]; options?: unknown[];
allow_custom?: boolean; allow_custom?: boolean;
allow_multiple?: boolean;
} { } {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false; if (!("status" in value) || !("content" in value)) return false;
@@ -322,6 +340,8 @@ export async function streamMultiAgent(
agent_type: req.agentType, agent_type: req.agentType,
d_agent_thread_id: req.dAgentThreadId, d_agent_thread_id: req.dAgentThreadId,
agent_name: req.agentName, agent_name: req.agentName,
// 仅 special 用到;后端缺失时 fallback 到 agent_name,旧前端也不破坏。
display_name: req.displayName,
new_message: req.newMessage, new_message: req.newMessage,
model: req.model, model: req.model,
skill_stop_names: req.skillStopNames ?? [], skill_stop_names: req.skillStopNames ?? [],
@@ -361,10 +381,17 @@ export async function streamMultiAgent(
// value actually changes (the reader loop sees many frames per phase). // value actually changes (the reader loop sees many frames per phase).
let currentPhase: StreamPhase = "connecting"; let currentPhase: StreamPhase = "connecting";
req.onPhaseChange?.("connecting"); req.onPhaseChange?.("connecting");
const setPhase = (next: StreamPhase, name?: string) => { const setPhase = (
next: StreamPhase,
name?: string,
args?: Record<string, unknown>,
) => {
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层;
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。
if (next === currentPhase && !name) return; if (next === currentPhase && !name) return;
currentPhase = next; currentPhase = next;
req.onPhaseChange?.(next, name); req.onPhaseChange?.(next, name, args);
}; };
while (true) { while (true) {
@@ -421,13 +448,22 @@ export async function streamMultiAgent(
setPhase("tool_result"); setPhase("tool_result");
} else if (hasToolCall) { } else if (hasToolCall) {
// Pick the most informative name we can find in either field. // Pick the most informative name we can find in either field.
// 优先用 tool_calls(完整对象,含解析好的 args),tool_call_chunks 的 args
// 通常是局部 JSON 字符串(LangGraph 流式期间逐字符发),没法直接用。
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0 const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
? (toolCalls[0] as Record<string, unknown>) ? (toolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0 : Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>) ? (toolCallChunks[0] as Record<string, unknown>)
: null; : null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName); // 仅当来自 tool_calls(完整对象)时透传 args;tool_call_chunks 的 args 是
// string 拼接中,不传上去给 UI,等下一个完整 chunk 再带。
const fromCompleteToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
const args =
fromCompleteToolCalls && firstCall && typeof firstCall.args === "object" && firstCall.args !== null
? (firstCall.args as Record<string, unknown>)
: undefined;
setPhase("tool_calling", toolName, args);
} else if ( } else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
@@ -447,14 +483,19 @@ export async function streamMultiAgent(
// a question (or chip choices) instead of mistaking the run for // a question (or chip choices) instead of mistaking the run for
// a consensus. // a consensus.
if (parsed.status === "clarification") { if (parsed.status === "clarification") {
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
result.clarification = { result.clarification = {
question: typeof parsed.question === "string" ? parsed.question : "", question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType: clarificationType,
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
context: context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "", typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [], options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false, allowCustom: parsed.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
clarificationType,
),
}; };
} }
} }
@@ -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<string>(["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<string>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<StreamRecommendResult> {
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;
}
@@ -0,0 +1,93 @@
/**
* 圆桌规划用到的所有 SVG 头像。
*
* 这些原本散落在主页面里,提到这里集中维护,便于 Step 1 / Step 2 / Step 3
* 与 LeftSidebar、HighFidelityReport 复用。`getAvatar(type, size)` 根据
* 字符串类型名(blue / purple / emerald / amber / pink / human / man)挑对应组件。
*/
export const Man1Avatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<span
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-slate-200/80 shadow-sm`}
>
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#E2E8F0" />
<circle cx="50" cy="50" r="45" fill="#FEEDED" />
<path d="M15 95 C 15 70, 30 65, 50 65 C 70 65, 85 70, 85 95 Z" fill="#1E293B" />
<path d="M40 65 L50 78 L60 65 Z" fill="#FFFFFF" />
<path d="M47 72 L53 72 L51 98 L49 98 Z" fill="#E11D48" />
<path d="M40 65 L40 55 C 40 55, 45 58, 50 58 C 55 58, 60 55, 60 55 L60 65 Z" fill="#FBCFE8" />
<circle cx="50" cy="38" r="18" fill="#FBCFE8" />
<path d="M42 34 Q 45 32 47 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
<path d="M53 34 Q 55 32 58 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="45" cy="39" r="2.5" fill="#0F172A" />
<circle cx="55" cy="39" r="2.5" fill="#0F172A" />
<path d="M30 32 C 30 18, 70 18, 70 32 C 72 26, 68 15, 50 15 C 32 15, 28 26, 30 32 Z" fill="#0F172A" />
<path d="M30 32 C30 32, 28 35, 32 35 C34 35, 36 30, 36 30 Z" fill="#0F172A" />
<path d="M70 32 C70 32, 72 35, 68 35 C66 35, 64 30, 64 30 Z" fill="#0F172A" />
</svg>
</span>
);
export const RobotAvatar = ({
bg = "bg-primary/10",
headColor = "#f5222d",
size = "h-8 w-8",
}: {
bg?: string;
headColor?: string;
size?: string;
}) => (
<span
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-red-100/50 shadow-sm ${bg}`}
>
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="28" width="50" height="42" rx="12" fill={headColor} />
<rect x="32" y="35" width="36" height="24" rx="6" fill="#1E293B" />
<circle cx="42" cy="45" r="4" fill="#F74750" className="animate-pulse" />
<circle cx="58" cy="45" r="4" fill="#F74750" className="animate-pulse" />
<path d="M45 52 Q 50 55 55 52" stroke="#F74750" strokeWidth="2.2" strokeLinecap="round" />
<rect x="20" y="41" width="5" height="16" rx="2" fill="#cf1322" />
<rect x="75" y="41" width="5" height="16" rx="2" fill="#cf1322" />
<line x1="50" y1="28" x2="50" y2="15" stroke={headColor} strokeWidth="4" strokeLinecap="round" />
<circle cx="50" cy="12" r="5" fill="#FA9196" />
</svg>
</span>
);
export const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#cf1322" bg="bg-[var(--brand-200)]" size={size} />
);
export const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#7c3aed" bg="bg-purple-100/40" size={size} />
);
export const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#059669" bg="bg-emerald-100/40" size={size} />
);
export const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#d97706" bg="bg-amber-100/40" size={size} />
);
export const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#db2777" bg="bg-pink-100/40" size={size} />
);
/** 根据字符串类型名挑对应头像。 */
export function getAvatar(type: string, size: string = "h-8 w-8") {
switch (type) {
case "man":
case "human":
return <Man1Avatar size={size} />;
case "blue":
return <BlueRobotAvatar size={size} />;
case "purple":
return <PurpleRobotAvatar size={size} />;
case "emerald":
return <EmeraldRobotAvatar size={size} />;
case "amber":
return <AmberRobotAvatar size={size} />;
case "pink":
return <PinkRobotAvatar size={size} />;
default:
return <RobotAvatar size={size} />;
}
}
@@ -368,8 +368,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)} )}
</section> </section>
{/* Section 3: Generated files per seat */} {/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]"> <section className="hidden bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2"> <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" /> <Download className="w-4 h-4 text-primary" />
生成的报告文件 生成的报告文件
@@ -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 (
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
需要你的确认
</span>
{payload.clarificationType ? (
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
{payload.clarificationType}
</span>
) : null}
</div>
{payload.context ? (
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
) : null}
<div className="text-xs font-medium leading-relaxed text-slate-800">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-3 grid gap-2">
<div className="text-[10px] text-slate-500">
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const isSelected = selection.selectedIds.includes(option.id);
return (
<button
key={option.id}
type="button"
disabled={disabled || submitting}
onClick={() => 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",
)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span className="text-[11px] font-bold">{option.label}</span>
{option.description ? (
<span
className={cn(
"text-[10px] leading-snug",
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
)}
>
{option.description}
</span>
) : null}
</span>
{isSelected && allowMultiple ? (
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
) : null}
</button>
);
})}
</div>
) : null}
{payload.allowCustom !== false ? (
<div className="mt-3 flex flex-col gap-2">
<Textarea
value={selection.custom}
onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder={
options.length > 0
? allowMultiple
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled || submitting}
className="min-h-16 resize-none border-slate-200 text-xs"
/>
</div>
) : null}
{showSubmitButton ? (
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || submitting || !canSubmit}
onClick={() => onSubmit()}
className="h-8 rounded-xl px-4 text-xs font-bold"
>
{submitting ? "发送中…" : "发送回答"}
</Button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,269 @@
/**
* Step 1 / Step 2 消息列表共享的原子件。
*
* 两步的消息渲染共用了:头像 + 发送人/徽标/时间的 header 行、流式空内容时的
* spinner+跳点占位、流式中尾部闪烁竖线、以及 Markdown 紧凑间距渲染配置。
* 把它们提到这里:
* - `MessageBubble` 单条消息壳(avatar + header + children slot)
* - `MessageLoadingPlaceholder` 流式但还没首字节时的 spinner + 跳点 + label
* - `StreamingCursor` 流式中末尾的小竖线动画
* - `sharedMarkdownComponents` react-markdown 的紧凑渲染配置
*
* 各步特有的内容(Step 1 INTENT_READY 摘要卡 / 澄清卡片;Step 2 ChainOfThought
* 折叠卡 + 「插话干预」红底容器)仍由对应 Panel 自己组合 —— 它们差异较大,
* 强行抽象成 props 反而难读。
*/
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface MessageBubbleProps {
/** 左侧头像。caller 决定是 `<Man1Avatar>` / `<RobotAvatar>` / `getAvatar(type)`。 */
avatar: React.ReactNode;
/** 发送人显示名。 */
senderName: string;
/** 发送人名字额外 className(例如 user 消息用 `text-primary`)。 */
senderNameClassName?: string;
/** Header 右上小徽标:Step 1 的 phase 标签、Step 2 的 confidence 标签。 */
badge?: string;
/** 时间字符串(`nowTime()` 输出)。 */
time: string;
/** 外层容器额外 className —— Step 2「插话干预」需要红底, caller 传入。 */
containerClassName?: string;
/** 消息正文区:由 caller 自由组合(MD / 摘要卡 / Steps 卡 / 澄清卡 / cursor 等)。 */
children: React.ReactNode;
}
export function MessageBubble({
avatar,
senderName,
senderNameClassName,
badge,
time,
containerClassName,
children,
}: MessageBubbleProps) {
return (
<div className={cn("flex gap-4 items-start", containerClassName)}>
{avatar}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("text-xs font-extrabold text-slate-900", senderNameClassName)}>
{senderName}
</span>
{badge && (
<span className="text-[9px] font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
{badge}
</span>
)}
</div>
<span className="text-[10px] text-slate-400 font-mono font-medium shrink-0">{time}</span>
</div>
{children}
</div>
</div>
);
}
/**
* 流式中、但还没拿到任何 token 时的占位。spinner + label + 三个跳动点,
* 让用户感受到后端在工作而不是页面卡住。
*/
export function MessageLoadingPlaceholder({ label }: { label?: string }) {
return (
<div className="flex items-center gap-2 py-1.5 text-xs text-slate-600">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="font-medium">{label ?? "正在连接智能体..."}</span>
<span className="inline-flex items-center gap-0.5">
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.3s]" />
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.15s]" />
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce" />
</span>
</div>
);
}
/** 流式过程中末尾追的小竖线动画。 */
export function StreamingCursor() {
return <span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />;
}
/**
* react-markdown 的紧凑间距配置 —— Step 1 / Step 2 / 主聊天默认气泡都用同一份。
* 默认 block 边距(`margin: 1em`)在小行高 chat 气泡里太松,这里全压到 my-1。
* 如果某步需要更多元素(table / blockquote / hr 等),不用单独 override,直接复用本表即可:
* react-markdown 只会按需启用对应组件,多写无害。
*/
export const sharedMarkdownComponents = {
p: ({ children }: { children?: React.ReactNode }) => (
<p className="my-1 whitespace-pre-wrap">{children}</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="list-disc pl-5 my-1 space-y-0.5">{children}</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="list-decimal pl-5 my-1 space-y-0.5">{children}</ol>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li className="leading-snug">{children}</li>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className="text-sm font-bold mt-2 mb-1">{children}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className="text-sm font-bold mt-2 mb-1">{children}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-xs font-bold mt-1.5 mb-0.5">{children}</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4 className="text-xs font-bold mt-1 mb-0.5">{children}</h4>
),
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-bold text-slate-900">{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => (
<em className="italic">{children}</em>
),
code: ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">
{children}
</code>
);
},
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">
{children}
</blockquote>
),
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: { children?: React.ReactNode }) => (
<table className="my-1 border-collapse text-[11px]">{children}</table>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
{children}
</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="border border-slate-200 px-1.5 py-0.5">{children}</td>
),
hr: () => <hr className="my-2 border-slate-200" />,
};
/**
* 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。
* 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。
*/
export function MarkdownContent({ children }: { children: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}>
{children}
</ReactMarkdown>
);
}
/**
* 「思考过程」折叠卡。展示从 `<think>...</think>` 拆出的 reasoning 文本。
*
* 视觉与交互对齐主聊天 `src/components/ai-elements/reasoning.tsx`:
* - 头部脑图标 + "正在思考..." / "思考完成 (Ns)" 文案
* - 流式中默认展开,完成后自动延迟收起;用户点击切换后**不再自动收起**(尊重偏好)
* - 内容区灰底框,用 sharedMarkdownComponents 渲染 markdown
*/
export interface ReasoningBlockProps {
/** Reasoning 文本(已由 `splitInlineReasoning` 提取出来,不含 `<think>` 标签)。 */
children: string;
/** 是否仍在流式生成。决定头部 shimmer 与默认展开行为。 */
streaming?: boolean;
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
durationSec?: number;
className?: string;
}
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
export function ReasoningBlock({
children,
streaming = false,
durationSec,
className,
}: ReasoningBlockProps) {
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
const [open, setOpen] = useState<boolean>(streaming);
const userTouchedRef = useRef<boolean>(false);
const hasAutoClosedRef = useRef<boolean>(false);
// 流式开始时若用户没动过开关 → 自动展开;流式结束 → 延迟自动收起(仅一次)。
useEffect(() => {
if (userTouchedRef.current) return;
if (streaming) {
setOpen(true);
hasAutoClosedRef.current = false;
return;
}
if (!hasAutoClosedRef.current && open) {
const timer = setTimeout(() => {
setOpen(false);
hasAutoClosedRef.current = true;
}, REASONING_AUTO_CLOSE_DELAY_MS);
return () => clearTimeout(timer);
}
return undefined;
}, [streaming, open]);
const handleToggle = () => {
userTouchedRef.current = true;
setOpen((v) => !v);
};
const headerLabel = streaming
? "正在思考..."
: durationSec !== undefined
? `思考用时 ${durationSec} 秒`
: "思考过程";
return (
<div className={cn("not-prose space-y-2", className)}>
<button
type="button"
onClick={handleToggle}
className="inline-flex items-center gap-1.5 text-[11.5px] text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
>
<BrainIcon className={cn("size-3.5", streaming && "text-primary animate-pulse")} />
<span className={cn(streaming && "animate-pulse")}>{headerLabel}</span>
<ChevronDownIcon
className={cn("size-3.5 transition-transform", open ? "rotate-180" : "rotate-0")}
/>
</button>
{open && (
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed">
<MarkdownContent>{children}</MarkdownContent>
</div>
)}
</div>
);
}
@@ -0,0 +1,176 @@
/**
* 模型生成过程的「步骤卡」 —— Step 1 / Step 2 共享。
*
* 视觉与交互对齐主聊天 `chats/new` 的 `MessageGroup`(`src/components/workspace/messages/message-group.tsx`):
* 1) 顶部 "查看其他 N 个步骤" 折叠按钮 —— 隐藏除最后一个工具调用之外的所有步骤
* 2) 最后一个工具调用步骤 **始终展开** 显示(图标 + 文案 + args 子节点)
* 3) 若末尾是 streaming/connecting 等非工具阶段,作为「正在思考」式标签放在卡底部
*
* 数据来源:`Step2Dialogue.steps[]` / `InteractiveMessage.steps[]` —— 由对应 hook
* 在 `onPhaseChange` 回调里累积。每条 `step = { id, key:"phase:toolName", label, ts, args? }`。
*
* 历史背景:之前的 `StepsCard` 用 "全收/全展" 的折叠模式 —— 流式时收起、点击全展开,
* 没法跟随主聊天的「凸显最后一个工具」体验。本组件改为「always-show-last + hide-prior」,
* 与主聊天观感一致。
*/
import { useState } from "react";
import { ChevronUp } from "lucide-react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
getStepDisplay,
renderStepArgs,
} from "@/roundtable-planning/lib/step-display";
/** 单条步骤 —— 与 Step2Dialogue.steps / InteractiveMessage.steps 同形。 */
export interface BubbleStep {
id: string;
/** 形如 `tool_calling:web_search` / `streaming:` / `connecting:`。 */
key: string;
/** 原始 fallback 文案(getStepDisplay 在没有工具名时回退到它)。 */
label: string;
ts: string;
args?: Record<string, unknown>;
}
export interface MessageStepsCardProps {
steps: BubbleStep[];
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
streaming?: boolean;
className?: string;
}
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
function isToolStep(step: BubbleStep): boolean {
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
}
export function MessageStepsCard({ steps, streaming, className }: MessageStepsCardProps) {
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
// `lastReasoningStep` 划分对齐。
const toolSteps = steps.filter(isToolStep);
const lastToolStep = toolSteps[toolSteps.length - 1];
const priorToolSteps =
lastToolStep && toolSteps.length > 1 ? toolSteps.slice(0, -1) : [];
// 末尾若是非工具步骤(streaming/connecting/tool_result),作为「思考中」尾标签。
// 找到最后一个工具步骤之后的所有步骤;若没有工具步骤,则全部都算思考。
const lastToolIdx = lastToolStep ? steps.lastIndexOf(lastToolStep) : -1;
const trailingReasoning = steps.slice(lastToolIdx + 1).filter((s) => !isToolStep(s));
const lastReasoning = trailingReasoning[trailingReasoning.length - 1];
const [showAbove, setShowAbove] = useState<boolean>(false);
// 没有任何步骤 —— 不渲染卡(由 caller 决定是否还要其他占位)。
if (steps.length === 0) return null;
// 只有一种「思考」尾标签、没有任何工具调用 —— 直接渲染一行小灯泡,不用大卡。
// 这样避免「调度中 / 已连接」这类瞬时阶段被搞成完整的折叠卡。
if (!lastToolStep && lastReasoning) {
const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args);
const Icon = display.icon;
return (
<div className={cn("flex items-center gap-2 text-[11.5px] text-slate-500 py-0.5", className)}>
<Icon className="size-3.5 text-slate-400" />
<span>{display.label}</span>
<span className="text-[10px] text-slate-400 font-mono">{lastReasoning.ts}</span>
</div>
);
}
return (
<ChainOfThought
className={cn("w-full gap-1 rounded-lg border border-slate-200 bg-slate-50/50 p-0.5", className)}
open={true}
>
{/* 顶部:折叠/展开「其他 N 个步骤」。仅当真有 prior 步骤时显示。 */}
{priorToolSteps.length > 0 && (
<Button
className="w-full h-auto items-start justify-start py-1.5 text-left"
variant="ghost"
onClick={() => setShowAbove((v) => !v)}
>
<ChainOfThoughtStep
label={
<span className="text-[11.5px] opacity-60">
{showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`}
</span>
}
icon={
<ChevronUp
className={cn(
"size-4 opacity-60 transition-transform duration-200",
showAbove ? "rotate-180" : "",
)}
/>
}
/>
</Button>
)}
{/* 中段:工具调用步骤。Last 工具始终展开;其它工具按 showAbove 控制。 */}
{lastToolStep && (
<ChainOfThoughtContent className="px-3 pb-2">
{showAbove &&
priorToolSteps.map((s) => (
<ToolStep key={s.id} step={s} status="complete" />
))}
<ToolStep
step={lastToolStep}
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
/>
</ChainOfThoughtContent>
)}
{/* 底部:streaming 阶段的「正在思考」标签。 */}
{lastReasoning && (
<div className="px-3 pb-2">
<ReasoningStep step={lastReasoning} streaming={streaming === true} />
</div>
)}
</ChainOfThought>
);
}
function ToolStep({
step,
status,
}: {
step: BubbleStep;
status: "complete" | "active";
}) {
const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args);
return (
<ChainOfThoughtStep
icon={display.icon}
status={status}
label={
<span className="text-[12px] text-slate-700">
<span className="font-medium">{display.label}</span>
<span className="ml-2 text-[10px] text-slate-400 font-mono">{step.ts}</span>
</span>
}
>
{argNode}
</ChainOfThoughtStep>
);
}
function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boolean }) {
const display = getStepDisplay(step.key, step.label, step.args);
const Icon = display.icon;
return (
<div className="flex items-center gap-2 text-[11.5px] text-slate-500">
<Icon className={cn("size-3.5", streaming ? "text-primary animate-pulse" : "text-slate-400")} />
<span>{display.label}</span>
<span className="text-[10px] text-slate-400 font-mono">{step.ts}</span>
</div>
);
}
@@ -0,0 +1,190 @@
/**
* Persona 抽屉。
*
* Step 2 圆桌可视化里点击智能体头像后展开。允许覆写该席位的:
* - System Prompt(本地记录,后端覆写未开放)
* - 推理模型(下一轮派活时由 `runOrchestration` 读 `agentConfigs[id].model`)
* - Temperature(本地记录)
*
* 「参数覆盖并重启动算研讨」按钮 = `onSave`,不会立即重启编排,只更新本地配置;
* 真正生效在该席位下一次被 leader 派活时。
*/
import { X, Sparkles } from "lucide-react";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import type {
AgentConfig,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type {
RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
interface ModelOption {
name: string;
display_name?: string;
}
export interface PersonaDrawerProps {
seatId: number;
roles: RoleEntry[];
agentConfigs: Record<number, AgentConfig>;
setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>>
>;
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
onClose: () => void;
onSave: (id: number) => void;
}
/** 席位 id → 头像配色(与圆桌可视化里的硬编码保持一致)。 */
function avatarTypeForSeat(seatId: number): string {
switch (seatId) {
case 2:
return "blue";
case 3:
return "purple";
case 4:
return "emerald";
case 5:
return "human";
case 6:
return "amber";
case 7:
return "pink";
default:
return "blue";
}
}
export function PersonaDrawer({
seatId,
roles,
agentConfigs,
setAgentConfigs,
selectedModel,
availableModels,
modelsLoading,
onClose,
onSave,
}: PersonaDrawerProps) {
const cfg = agentConfigs[seatId];
const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体";
return (
<div className="mx-6 mb-4 p-4.5 bg-gradient-to-r from-[var(--brand-200)]/50 via-[var(--brand-200)]/15 to-white border-2 border-primary rounded-2xl shadow-[0_8px_30px_rgba(245,34,45,0.06)] relative animate-fade-in">
<button
onClick={onClose}
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"
>
<X className="w-4 h-4" />
</button>
<div className="flex flex-col lg:flex-row gap-5 text-xs text-slate-600">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
{getAvatar(avatarTypeForSeat(seatId), "h-10 w-10")}
<div>
<div className="flex items-center gap-1.5">
<h4 className="text-sm font-extrabold text-slate-900">
{roleName} 人设专有策略调制
</h4>
<span className="text-[9px] font-black bg-[var(--brand-200)] text-primary px-2 py-0.5 rounded-full scale-90">
系统就绪
</span>
</div>
<p className="text-[10px] text-slate-400 font-bold">
配置此席位智能体的运行大语言模型及系统 System Prompt 指令
</p>
</div>
</div>
<div className="space-y-1">
<label className="text-[9px] font-black text-slate-500 uppercase block tracking-wider">
该席位系统提示词指令 override (System Instruction)
</label>
<textarea
value={cfg?.prompt || ""}
onChange={(e) => {
const val = e.target.value;
setAgentConfigs((prev) => ({
...prev,
[seatId]: { ...prev[seatId], prompt: val },
}));
}}
className="w-full text-xs text-slate-700 bg-white border border-slate-200 focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 rounded-xl px-3 py-2.5 h-[65px] resize-none focus:outline-none font-medium leading-relaxed"
placeholder="请输入覆盖的专家提示词内容..."
/>
</div>
</div>
<div className="w-full lg:w-[260px] flex flex-col justify-between gap-3 border-t lg:border-t-0 lg:border-l border-slate-100 lg:pl-5 pt-3.5 lg:pt-0">
<div className="space-y-3">
<div className="space-y-1">
<label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">
推理本源模型
</label>
<select
value={cfg?.model || selectedModel || ""}
onChange={(e) => {
const val = e.target.value;
setAgentConfigs((prev) => ({
...prev,
[seatId]: { ...prev[seatId], 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 && (
<option value="">
{modelsLoading ? "加载中…" : "未配置模型"}
</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-[9px] font-black text-slate-500">
<span>随机创造性倾向 (Temp)</span>
<span className="font-mono text-primary font-bold">{cfg?.temp || 0.2}</span>
</div>
<input
type="range"
min={0.1}
max={1.0}
step={0.1}
value={cfg?.temp || 0.2}
onChange={(e) => {
const val = parseFloat(e.target.value);
setAgentConfigs((prev) => ({
...prev,
[seatId]: { ...prev[seatId], temp: val },
}));
}}
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
<button
onClick={() => onSave(seatId)}
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"
>
<Sparkles className="w-3.5 h-3.5 text-red-200" />
参数覆盖并重启动算研讨
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,868 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
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 {
MarkdownContent,
ReasoningBlock,
StreamingCursor,
} from "./MessageBubble";
import { MessageStepsCard } from "./MessageStepsCard";
import type { IntentSummary } from "../api/intent";
import {
filterRecommendCandidates,
parseRecommendStreamContent,
streamRecommend,
type RecommendCandidate,
type RecommendStreamPhase,
} from "../api/recommend";
import { splitInlineReasoning } from "../lib/reasoning";
/**
* 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: (agents: Array<{ agent_id: string; name: 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 RecommendStep {
id: string;
key: string;
label: string;
ts: string;
}
interface InternalState {
phase: DialogPhase;
/** Streaming + final rationale text from the recommender. */
rationale: string;
/** Streaming SSE phase from the recommender helper. */
streamPhase: RecommendStreamPhase | null;
/** 推荐流程阶段时间线 —— 调度 / 已连接 / 流式生成 …… 用 ChainOfThought 折叠展示。 */
steps: RecommendStep[];
/** Pre-filtered candidate pool fetched from `/api/agents`. */
candidates: RecommendCandidate[];
/** Reason-by-agent_id, only for picks returned by the recommender. */
reasons: Record<string, string>;
/** 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,
steps: [],
candidates: [],
reasons: {},
recommendedIds: [],
warning: null,
errorMessage: null,
};
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
function recommendPhaseLabel(phase: RecommendStreamPhase): string {
switch (phase) {
case "connecting":
return "调度推荐智能体";
case "connected":
return "已连接,等待响应";
case "streaming":
return "正在生成推荐";
default:
return phase;
}
}
function nowTimeStr(): string {
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
/**
* 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<string>();
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<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Latest stream abort controller, so closing or re-running cancels the
// in-flight request instead of leaking it.
const abortRef = useRef<AbortController | null>(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",
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
steps: [
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
],
}));
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<string, string> = {};
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) => {
const key = phase;
const last = prev.steps[prev.steps.length - 1];
// 连续重复阶段不重复 push,只在阶段切换时打点。
const steps =
last && last.key === key
? prev.steps
: [
...prev.steps,
{
id: String(prev.steps.length),
key,
label: recommendPhaseLabel(phase),
ts: nowTimeStr(),
},
];
return { ...prev, streamPhase: phase, steps };
});
},
});
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
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.
//
// 传完整对象(含 name)而不是只传 agent_id —— 非内置 agent 的 id 是 UUID,
// 上游若只拿 id,在左侧席位列表就会显示 UUID 而非中文名。
const ordered: Array<{ agent_id: string; name: string }> = [];
for (const c of recommendedCards) {
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
onConfirm(ordered);
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) onCancel();
},
[onCancel],
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" />
选择参会智能体
</DialogTitle>
<DialogDescription>
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
</DialogDescription>
</DialogHeader>
{/* ============================================================
TOP: 模型生成内容
包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
与主聊天消息气泡内容区视觉对齐。
**可折叠** —— 流式中默认展开方便观察生成过程,完成后自动收起一次,
让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。
============================================================ */}
<CollapsibleModelOutput
streaming={state.phase === "recommending"}
phase={state.phase}
>
<RecommenderOutput state={state} />
</CollapsibleModelOutput>
{/* ============================================================
BOTTOM: 推荐的智能体(可勾选)
滚动区,占据剩余高度。
============================================================ */}
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-2 flex items-center gap-1.5 shrink-0">
<CheckIcon className="size-3 text-emerald-500" />
推荐参会智能体
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
{state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"}
</div>
) : state.phase === "recommending" ? (
<>
{recommendedCards.length > 0 ? (
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={state.reasons[c.agent_id]}
recommended
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
streaming
/>
))}
</Section>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 py-2">
<Loader2Icon className="size-3 animate-spin" />
等待推荐结果...
</div>
)}
</>
) : (state.phase === "ready" || state.phase === "fallback") ? (
<>
{state.warning && (
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
{state.warning}
</div>
)}
{recommendedCards.length > 0 && (
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={state.reasons[c.agent_id]}
recommended
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
/>
))}
</Section>
)}
{otherCards.length > 0 && (
<Section title={`其他候选 (${otherCards.length})`}>
{otherCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={undefined}
recommended={false}
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
/>
))}
</Section>
)}
</>
) : null}
</div>
</section>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<div className="flex items-center gap-3 text-xs">
<span
className={cn(
"tabular-nums",
selectedCount < MIN_SELECTED || selectedCount > MAX_SELECTED
? "text-destructive"
: "text-muted-foreground",
)}
>
{selectionHint}
</span>
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
<Button
variant="ghost"
size="sm"
onClick={() => void runRecommendation()}
className="h-7 px-2 text-xs"
>
<RefreshCwIcon className="size-3 mr-1" />
重新推荐
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
进入研讨
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* 「模型生成内容」整段的可折叠外壳。
*
* 默认行为:
* - 流式期间(`streaming=true`)默认展开,让用户看到实时生成
* - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
* 把焦点让给下方推荐卡片
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
*
* 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」),
* 让用户不用展开就知道当前阶段。
*/
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
function CollapsibleModelOutput({
streaming,
phase,
children,
}: {
streaming: boolean;
phase: DialogPhase;
children: React.ReactNode;
}) {
const [open, setOpen] = useState<boolean>(true);
const userTouchedRef = useRef<boolean>(false);
const hasAutoClosedRef = useRef<boolean>(false);
// 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。
// 流式结束 → 延迟自动收起一次(仅一次)。
useEffect(() => {
if (userTouchedRef.current) return;
if (streaming) {
setOpen(true);
hasAutoClosedRef.current = false;
return;
}
if (!hasAutoClosedRef.current && open) {
const timer = setTimeout(() => {
setOpen(false);
hasAutoClosedRef.current = true;
}, MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS);
return () => clearTimeout(timer);
}
return undefined;
}, [streaming, open]);
const statusBadge = (() => {
switch (phase) {
case "loading_agents":
return { label: "加载中", className: "bg-slate-100 text-slate-600" };
case "recommending":
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
case "ready":
return { label: "推荐已生成", className: "bg-emerald-50 text-emerald-700 border border-emerald-200" };
case "fallback":
return { label: "已使用兜底", className: "bg-amber-50 text-amber-700 border border-amber-200" };
case "error":
return { label: "推荐失败", className: "bg-red-50 text-red-700 border border-red-200" };
default:
return null;
}
})();
return (
<section className="border rounded-md bg-muted/20 shrink-0">
<button
type="button"
onClick={() => {
userTouchedRef.current = true;
setOpen((v) => !v);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
>
<SparklesIcon
className={cn(
"size-3 text-purple-500 shrink-0",
streaming && "animate-pulse",
)}
/>
<span className="flex-1 text-left">模型生成内容</span>
{statusBadge && (
<span
className={cn(
"text-[9.5px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded normal-case",
statusBadge.className,
)}
>
{statusBadge.label}
</span>
)}
<ChevronDownIcon
className={cn(
"size-3.5 text-slate-400 shrink-0 transition-transform",
open ? "rotate-180" : "rotate-0",
)}
/>
</button>
{open && <div className="px-3 pb-3">{children}</div>}
</section>
);
}
/**
* 推荐弹窗顶部「模型生成内容」面板。
*
* 视觉与交互对齐主聊天 chats/new 的消息列表:
* - 顶部一行 spinner + 阶段文案(连接中 / 流式 / 完成)
* - 步骤折叠卡(MessageStepsCard,与对话气泡里同一实现)
* - 「思考过程」折叠卡(从 `<think>` 抽出来 reasoning,流式中默认展开)
* - 正文 Markdown(去掉了 `<think>` 标签后的 rationale)
* - 流式中正文末尾跟一个闪烁竖线
*/
function RecommenderOutput({ state }: { state: InternalState }) {
if (state.phase === "loading_agents") {
return (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体...
</div>
);
}
if (state.phase === "error") {
return (
<div className="text-sm text-destructive">
推荐失败,请使用下方的「重新推荐」重试,或取消后再次进入。
</div>
);
}
const showRationaleStage =
state.phase === "recommending" ||
state.phase === "ready" ||
state.phase === "fallback";
if (!showRationaleStage) return null;
const isStreaming = state.phase === "recommending";
// 顶部状态行:跟主聊天的「正在思考...」类似,只是文案换成推荐阶段。
const streamingLabel = isStreaming
? state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐..."
: null;
// 把 `<think>...</think>` 抽出来作 reasoning,剩下的 rationale 用 markdown 渲染。
// 这与 Step1Panel / Step2Panel 的处理方式完全一致。
const { content: cleanedRationale, reasoning } = splitInlineReasoning(state.rationale);
const hasRationale = cleanedRationale.length > 0;
const hasReasoning = !!reasoning;
return (
<div className="space-y-2">
{streamingLabel && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />
{streamingLabel}
</div>
)}
{state.steps.length > 0 && (
<MessageStepsCard steps={state.steps} streaming={isStreaming} />
)}
{hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
)}
{!hasRationale && !hasReasoning && isStreaming && (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
{hasRationale && (
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<MarkdownContent>{cleanedRationale}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
)}
</div>
);
}
function Section({
title,
children,
collapsible = false,
defaultOpen = true,
}: {
title: string;
children: React.ReactNode;
/** True 时 header 可点击折叠/展开;false 保持原行为(始终展开,不可点击)。 */
collapsible?: boolean;
/** 仅当 collapsible=true 时生效。 */
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="space-y-2">
{collapsible ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors cursor-pointer"
>
{open ? (
<ChevronUpIcon className="size-3" />
) : (
<ChevronDownIcon className="size-3" />
)}
<span>{title}</span>
</button>
) : (
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</div>
)}
{/* flex flex-col + w-full + items-stretch:替代原 `grid gap-2`,
强制所有 AgentCard 满父宽。grid 在某些浏览器对 <button> 子项的
justify-self: stretch 处理不一致,导致"推荐参会"与"其他候选"
两个 Section 视觉上有几像素宽度差。 */}
{(!collapsible || open) && (
<div className="flex w-full flex-col gap-2">{children}</div>
)}
</div>
);
}
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 (
<button
type="button"
onClick={onToggle}
className={cn(
// w-full:`<button>` 默认 inline-block,在 grid cell 里不一定拉满;
// 显式 w-full 保证推荐区与其他候选区的卡片**宽度严格一致**。
"w-full text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
checked
? "border-primary bg-primary/5 hover:bg-primary/10"
: "border-border hover:bg-muted/40",
streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)}
>
{/* Visual check box — simulated, since the project ships no Checkbox primitive */}
<span
className={cn(
"mt-0.5 size-4 rounded border flex items-center justify-center shrink-0",
checked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
)}
>
{checked && <CheckIcon className="size-3" />}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
{recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
推荐
</span>
)}
</div>
{candidate.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{candidate.description}
</div>
)}
{reason ? (
<div className="text-xs text-primary/80 mt-1 italic">
<span className="font-medium not-italic">推荐理由:</span> {reason}
</div>
) : streaming ? (
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
) : null}
</div>
</button>
);
}
@@ -0,0 +1,423 @@
/**
* Step 1 主面板 —— 任务意图理解人机问答区。
*
* 主页面把所有 Step 1 状态(`useStep1Intent` 的返回值)+ 输入框 + 按钮处理函数
* 透传过来,这里只关心:对话列表渲染、INTENT_READY 摘要卡、澄清卡片嵌入气泡、
* 输入框 + 发送 / 暂停 / 跳过 / 继续追问 / 进入研讨 等底部按钮。
*
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 2 保持一致。
*
* **不要**在这里再 fetch / 建 thread / 跑 SSE —— 这些逻辑都在 `useStep1Intent`,
* 本组件保持纯展示 + 透传事件。
*/
import { useRef, useState } from "react";
import {
CheckCircle,
Loader2,
MessageSquare,
Pause,
Repeat,
Save,
Send,
Smile,
Sparkles,
X,
} from "lucide-react";
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import {
IntentClarificationCard,
type IntentClarificationSelection,
} from "@/roundtable-planning/components/IntentClarificationCard";
import {
MarkdownContent,
MessageBubble,
MessageLoadingPlaceholder,
ReasoningBlock,
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
import type {
ClarificationPayload,
} from "@/roundtable-planning/api/multi-agent";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
export interface Step1PanelProps {
// ─── State ────────────────────────────────────────────────────
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
isIntentSending: boolean;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<number, IntentClarificationSelection>;
// ─── Setters / Handlers ──────────────────────────────────────
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
>;
onSendMessage: (text: string) => void;
onStopIntent: () => void;
onSkipQuestion: () => void;
onFollowUp: () => void;
onEnterRoundtable: () => void;
onManualSave: () => void;
onTriggerToast: (msg: string) => void;
submitIntentClarification: (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
) => void;
}
export function Step1Panel(props: Step1PanelProps) {
const {
interactiveMessages,
intentThreadId,
intentReady,
isIntentSending,
pendingIntentClarificationMsgId,
intentClarificationSelections,
setIntentClarificationSelections,
onSendMessage,
onStopIntent,
onSkipQuestion,
onFollowUp,
onEnterRoundtable,
onManualSave,
onTriggerToast,
submitIntentClarification,
} = props;
const step1ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
useAutoScroll(step1ScrollRef, [interactiveMessages]);
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>("");
const charactersLeft = inputText.length;
const handleSend = () => {
if (!inputText.trim()) return;
onSendMessage(inputText);
setInputText("");
};
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Dialogue Header */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900">
任务意图理解 (人机问答)
</h3>
</div>
<span className="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
</div>
{/* Chat list */}
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
<div
ref={step1ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white"
>
{interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarification={submitIntentClarification}
/>
))}
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
</div>
{/* Bottom input area */}
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
<textarea
id="input-text-area"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder={
pendingIntentClarificationMsgId
? "也可在此输入自定义回答 (Enter 快速发送)"
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
}
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
maxLength={500}
/>
<div className="flex items-center justify-between border-t border-slate-50 pt-2 text-[#94a3b8]">
<div className="flex items-center gap-1.5">
<button
onClick={() => onTriggerToast("😄 表情包功能仍在微调状态(仅供演示)")}
className="p-1 hover:text-primary hover:bg-slate-50 rounded transition"
>
<Smile className="w-4 h-4" />
</button>
</div>
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
</div>
</div>
<div className="flex items-center justify-between mt-3">
<div className="flex items-center gap-2">
<button
id="send-message-btn"
onClick={handleSend}
disabled={isIntentSending || !intentThreadId}
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
>
{isIntentSending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Send className="w-3 h-3" />}
{isIntentSending ? "发送中" : "发送"}
</button>
{isIntentSending && (
<button
id="pause-intent-btn"
type="button"
onClick={onStopIntent}
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"
>
<Pause className="w-3 h-3" />
暂停
</button>
)}
<button
id="suggest-followup-btn"
onClick={onFollowUp}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<Repeat className="w-3 h-3 text-slate-500" />
继续追问
</button>
<button
id="skip-question-btn"
onClick={onSkipQuestion}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<X className="w-3 h-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
<button
id="enter-roundtable-btn"
onClick={onEnterRoundtable}
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"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
进入多智能体研讨 &gt;
</button>
</div>
<button
id="save-draft-btn"
onClick={onManualSave}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<Save className="w-3 h-3 text-slate-500" />
保存草稿
</button>
</div>
</div>
</div>
);
}
/**
* 单条 Step 1 消息。
*
* 拆成独立函数避免主 panel 里大量嵌套 JSX。三种正文形态:
* - 流式中、还没首字节 → MessageLoadingPlaceholder
* - agent 消息 → splitIntentReadyBlock 拆出前 / 摘要 / 后,分别渲染
* + IntentClarificationCard(若待澄清)
* - user 消息 → 纯文本
*/
interface Step1MessageProps {
msg: InteractiveMessage;
intentReady: object | null;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<number, IntentClarificationSelection>;
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
>;
isIntentSending: boolean;
submitIntentClarification: (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
) => void;
}
function Step1Message({
msg,
intentReady,
pendingIntentClarificationMsgId,
intentClarificationSelections,
setIntentClarificationSelections,
isIntentSending,
submitIntentClarification,
}: Step1MessageProps) {
const isStreaming = msg.streaming === true;
const isAgent = msg.sender !== "user";
// 先把 `<think>...</think>` 抽出来作 reasoning,剩下的正文再走 INTENT_READY 解析。
// 顺序很关键:若先 split INTENT_READY 再抽 think,think 块如果横跨 INTENT_READY
// 标记会导致 JSON 解析失败。
const rawContent = msg.content ?? "";
const { content: thoughtFreeContent, reasoning } = isAgent
? splitInlineReasoning(rawContent)
: { content: rawContent, reasoning: null };
const hasContent = thoughtFreeContent.length > 0;
const clarification = msg.clarification;
const parts = isAgent
? splitIntentReadyBlock(thoughtFreeContent)
: { before: thoughtFreeContent, summary: null as IntentSummary | 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;
// 主聊天里步骤卡放在「正文之上」(气泡内,header 之下),Step 2 也是这样。
// 流式中默认显示 last tool call + 折叠 "查看其他 N 个步骤";完成后保留卡。
const stepsCard =
isAgent && Array.isArray(msg.steps) && msg.steps.length > 0 ? (
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
) : null;
// 「思考过程」折叠卡 —— 流式中默认展开,完成后自动延迟收起。
const reasoningBlock =
isAgent && reasoning ? (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
) : null;
return (
<MessageBubble
avatar={
msg.sender === "user" ? (
<Man1Avatar size="h-9 w-9 shrink-0" />
) : (
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-9 w-9 shrink-0" />
)
}
senderName={msg.senderName}
senderNameClassName={msg.sender === "user" ? "text-primary" : undefined}
badge={isStreaming ? msg.phase : undefined}
time={msg.time}
>
{stepsCard}
{reasoningBlock}
{isStreaming && !hasContent ? (
<MessageLoadingPlaceholder label={msg.phase} />
) : isAgent ? (
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
{introText && <MarkdownContent>{introText}</MarkdownContent>}
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
{isStreaming && <StreamingCursor />}
{isPendingClarification && clarification && (
<IntentClarificationCard
payload={clarification}
fallbackContent={parts.before || undefined}
selection={clarificationSelection}
onSelectionChange={(next) => {
setIntentClarificationSelections((prev) => {
const map = new Map(prev);
map.set(msg.id, next);
return map;
});
}}
disabled={!isPendingClarification}
submitting={isIntentSending && Boolean(isPendingClarification)}
onSubmit={(override) =>
submitIntentClarification(msg.id, clarification, override)
}
/>
)}
</div>
) : (
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
{msg.content}
</div>
)}
</MessageBubble>
);
}
/** [INTENT_READY] 标记块解析出 IntentSummary 后的绿色摘要卡。 */
function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
return (
<div className="mt-1 border border-emerald-200 bg-emerald-50/50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-[10.5px] font-black text-emerald-700 tracking-wide">
任务意图已明确
</span>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
</div>
{summary.constraints?.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.constraints.map((c, i) => (
<li key={i} className="leading-snug">{c}</li>
))}
</ul>
</div>
)}
{summary.assumptions && summary.assumptions.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.assumptions.map((a, i) => (
<li key={i} className="leading-snug">{a}</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,356 @@
/**
* Step 2 主面板 —— 多智能体圆桌研讨。
*
* 包含 4 块:
* 1) 顶部 Headline + 挂起/恢复按钮
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
*
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 1 保持一致。
*
* 圆桌可视化 + 博弈边界调控台目前**未启用**(原代码用 `false &&` 隐藏),
* 这里保留同样的占位逻辑,后续要恢复时改 `SHOW_VISUALIZER` 即可。
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useRef } from "react";
import {
Cpu,
HelpCircle,
Loader2,
Pause,
Play,
Sparkles,
} from "lucide-react";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import {
MarkdownContent,
MessageBubble,
MessageLoadingPlaceholder,
ReasoningBlock,
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type {
AgentConfig,
Step2Dialogue,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type {
RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
interface ModelOption {
name: string;
display_name?: string;
}
export interface Step2PanelProps {
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
customAgentInput: string;
hasConsensus: boolean;
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
// ─── Setters ──────────────────────────────────────────────────
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>>
>;
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ─────────────────────────────────────────────────
onSubmitClarification: (answer: string) => void;
onInjectToRoundtable: () => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
onTriggerToast: (msg: string) => void;
}
export function Step2Panel(props: Step2PanelProps) {
const {
step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
isOrchestrating,
pendingClarification,
clarificationDraft,
customAgentInput,
hasConsensus,
agentConfigs,
selectedAgentId,
roles,
selectedModel,
availableModels,
modelsLoading,
setIsRoundTableRunning,
setClarificationDraft,
setCustomAgentInput,
setAgentConfigs,
setSelectedAgentId,
onSubmitClarification,
onInjectToRoundtable,
onSavePersona,
onProceedToStep3,
onTriggerToast,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
return (
<div className="flex-1 flex flex-col min-h-0 bg-slate-50/20">
{/* Headline */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[10px] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
</div>
</div>
<div className="flex items-center gap-2.5">
{isRoundTableRunning && isOrchestrating ? (
<button
onClick={() => {
setIsRoundTableRunning(false);
onTriggerToast("⏸️ 会商模拟暂挂!智能体挂起...");
}}
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"
>
<Pause className="w-3.5 h-3.5" />
挂起协商
</button>
) : !isRoundTableRunning ? (
<button
onClick={() => {
setIsRoundTableRunning(true);
onTriggerToast("▶️ 恢复大模型并行博弈与推理决策");
}}
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"
>
<Play className="w-3.5 h-3.5 fill-emerald-800" />
恢复会商
</button>
) : null}
</div>
</div>
{/* Persona drawer — 点圆桌头像后展开。当前圆桌可视化用 `false &&` 隐藏,
所以入口只剩 LeftSidebar 或将来重新启用的可视化按钮。 */}
{selectedAgentId && (
<PersonaDrawer
seatId={selectedAgentId}
roles={roles}
agentConfigs={agentConfigs}
setAgentConfigs={setAgentConfigs}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
onClose={() => setSelectedAgentId(null)}
onSave={onSavePersona}
/>
)}
{/* Conversation log */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={step2ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
>
{isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
</div>
)}
{step2RoundtableDialogues.map((msg) => (
<Step2Message key={msg.id} msg={msg} />
))}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
</div>
{/* Clarification card */}
{pendingClarification && (
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
<div className="flex items-start gap-3">
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{pendingClarification.clarificationType && (
<span className="text-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{pendingClarification.clarificationType}
</span>
)}
</div>
{pendingClarification.context && (
<p className="text-[11px] text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
pendingClarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{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 (
<button
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(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}
</button>
);
})}
</div>
)}
{pendingClarification.allowCustom !== false && (
<div className="flex items-center gap-2 pt-1">
<input
type="text"
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmitClarification(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"
/>
<button
type="button"
onClick={() => onSubmitClarification(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"
>
回复
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
<input
type="text"
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onInjectToRoundtable();
}}
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"
/>
<button
onClick={onInjectToRoundtable}
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预研审
</button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onProceedToStep3}
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"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
* - 流式无内容的 loading 占位
* - markdown 正文 + 流式 cursor
*
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
*/
interface Step2MessageProps {
msg: Step2Dialogue;
}
function Step2Message({ msg }: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
// 用户「插话干预」/「澄清回复」这类系统注入的气泡内容里基本不会有 think 标签,
// 但 splitInlineReasoning 对空 reasoning 文本返回 null,放心套用就好。
const rawContent = msg.content ?? "";
const { content, reasoning } = splitInlineReasoning(rawContent);
const hasContent = content.length > 0;
return (
<MessageBubble
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
senderName={msg.sender}
badge={msg.confidence}
time={msg.time}
containerClassName={
isInjected
? "bg-[var(--brand-200)]/30 p-3 rounded-2xl border border-red-100/50"
: undefined
}
>
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
)}
{reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
<MarkdownContent>{content}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
</MessageBubble>
);
}
@@ -0,0 +1,61 @@
/**
* Step 3 主面板 —— 方案结果展示。
*
* 当前是 `HighFidelityReport` 的薄壳;放它在这里只是为了让 Step 1 / Step 2 /
* Step 3 三步在主页面里以同样的导入风格出现,方便阅读。
*
* Step 3 真实逻辑请改 `HighFidelityReport.tsx` —— 包括各席位 artifact
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
*/
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
export interface Step3PanelProps {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
hasConsensus: boolean;
step2RoundtableDialogues: Step2Dialogue[];
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
onReset: () => void;
}
export function Step3Panel(props: Step3PanelProps) {
const {
threadIds,
coordinatorName,
consensusText,
hasConsensus,
step2RoundtableDialogues,
agentNameToRole,
onReset,
} = props;
const seatDeliveries = step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
content: String(d.content ?? ""),
}));
const seatNameMap = Object.fromEntries(
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
);
return (
<HighFidelityReport
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
hasConsensus={hasConsensus}
seatDeliveries={seatDeliveries}
seatNameMap={seatNameMap}
onReset={onReset}
/>
);
}
@@ -29,43 +29,89 @@ export function useAutoScroll<T extends HTMLElement>(
const atBottomRef = useRef(true); const atBottomRef = useRef(true);
const [atBottom, setAtBottom] = useState(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 // - 程序滚动(`el.scrollTop = el.scrollHeight`)前会临时把 programmaticRef 置 true
// bottom, the resulting onScroll naturally leaves atBottom=true, so the // → 接下来一次 scroll 事件不修改 atBottom,避免自己把自己关掉。
// auto-follow effect can keep firing on subsequent deps changes. // - 任何其它 scroll 事件视为用户主动滚动,按位置判定 atBottom。
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
const programmaticScrollRef = useRef(false);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
let prevScrollTop = el.scrollTop;
const onScroll = () => { const onScroll = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const currentScrollTop = el.scrollTop;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold; const isAtBottom = distanceFromBottom <= threshold;
if (atBottomRef.current !== isAtBottom) {
atBottomRef.current = isAtBottom; if (programmaticScrollRef.current) {
setAtBottom(isAtBottom); // 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
programmaticScrollRef.current = false;
prevScrollTop = currentScrollTop;
// 程序滚动后通常落在底部,把 atBottom 维持 true。
if (!atBottomRef.current && isAtBottom) {
atBottomRef.current = true;
setAtBottom(true);
}
return;
} }
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
if (currentScrollTop < prevScrollTop && atBottomRef.current) {
atBottomRef.current = false;
setAtBottom(false);
} else if (isAtBottom && !atBottomRef.current) {
// 用户自己又滚回底部 → 恢复自动跟随
atBottomRef.current = true;
setAtBottom(true);
} else if (!isAtBottom && atBottomRef.current) {
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
atBottomRef.current = false;
setAtBottom(false);
}
prevScrollTop = currentScrollTop;
}; };
el.addEventListener("scroll", onScroll, { passive: true }); 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(); onScroll();
return () => el.removeEventListener("scroll", onScroll); return () => {
el.removeEventListener("scroll", onScroll);
};
}, [ref, threshold]); }, [ref, threshold]);
// Auto-scroll on deps change — but only if the user hasn't scrolled away. // 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
if (!atBottomRef.current) return; // 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值,
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); // 说明用户已经离开,绝不自动跟随。
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > threshold) {
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽
if (atBottomRef.current) {
atBottomRef.current = false;
setAtBottom(false);
}
return;
}
programmaticScrollRef.current = true;
el.scrollTop = el.scrollHeight;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps); }, deps);
// 按钮点击是用户主动行为,保留 smooth 给视觉反馈;期间用户若立刻又拖动,
// onScroll 的方向检测仍会把 atBottom 翻回 false,不会卡住。
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
// 标记程序滚动:smooth 动画过程中浏览器派发的 scroll 事件不应被误判为用户主动滚动。
programmaticScrollRef.current = true;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); 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; atBottomRef.current = true;
setAtBottom(true); setAtBottom(true);
}, [ref]); }, [ref]);
@@ -0,0 +1,257 @@
/**
* 圆桌规划草稿持久化 hook。
*
* 维护 localStorage 草稿列表与 currentDraftId、自动保存(前进切换 1→2、2→3)、
* 手动保存、加载 / 重命名 / 删除。Step 1 / Step 2 的状态由各自的 hook 提供
* 快照函数与 hydrateFromDraft;本 hook 只负责编排「什么时机存 / 加载到哪里」。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type {
Step1Snapshot,
} from "@/roundtable-planning/hooks/useStep1Intent";
import type {
Step2Snapshot,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
deleteDraft,
deriveDraftName,
listDrafts,
renameDraft,
upsertDraft,
type DraftRecord,
} from "@/roundtable-planning/utils/drafts";
export interface UseDraftPersistenceOptions {
currentStep: number;
setCurrentStep: React.Dispatch<React.SetStateAction<number>>;
triggerToast: (msg: string) => void;
/** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */
getStep1Snapshot: () => Step1Snapshot;
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
getStep2Snapshot: () => Step2Snapshot;
/** 加载草稿时把 Step 1 状态打回去。 */
hydrateStep1: (snapshot: Step1Snapshot) => void;
/** 加载草稿时把 Step 2 状态打回去。 */
hydrateStep2: (snapshot: Step2Snapshot) => void;
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
cancelAllStreams: () => void;
/**
* Step 2 的 init 闸门控制 —— 仅当 furthestStep>=2 时把闸门置上,跳过自动 SSE;
* 否则保持原 false 让进入 Step 2 时正常初始化。
*/
setStep2InitGate: (initialized: boolean) => void;
}
export interface UseDraftPersistenceReturn {
drafts: DraftRecord[];
currentDraftId: string | null;
isHistoryOpen: boolean;
renamingDraftId: string | null;
renameDraftValue: string;
historyDropdownRef: React.MutableRefObject<HTMLDivElement | null>;
/**
* Shared latch: 加载草稿后置 true,影响 LeftSidebar 在 Step 2 时角色面板的
* 初始 status(在线 vs 正在上线)。由主页面在加载完后 read,使用完可手动复位。
*/
loadedFromDraftRef: React.MutableRefObject<boolean>;
setIsHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
handleManualSave: () => void;
handleLoadDraft: (id: string) => void;
handleDeleteDraft: (id: string) => void;
handleStartRename: (draft: DraftRecord) => void;
handleCommitRename: (id: string) => void;
handleCancelRename: () => void;
}
export function useDraftPersistence(
options: UseDraftPersistenceOptions,
): UseDraftPersistenceReturn {
const {
currentStep,
setCurrentStep,
triggerToast,
getStep1Snapshot,
getStep2Snapshot,
hydrateStep1,
hydrateStep2,
cancelAllStreams,
setStep2InitGate,
} = options;
const [drafts, setDrafts] = useState<DraftRecord[]>(() => listDrafts());
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
const [renamingDraftId, setRenamingDraftId] = useState<string | null>(null);
const [renameDraftValue, setRenameDraftValue] = useState<string>("");
const loadedFromDraftRef = useRef<boolean>(false);
const prevStepRef = useRef<number>(currentStep);
const historyDropdownRef = useRef<HTMLDivElement | null>(null);
const buildDraftPayload = useCallback(
(
furthestStep: 1 | 2 | 3,
idOverride?: string | null,
): Parameters<typeof upsertDraft>[0] => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
const resolvedId = idOverride ?? currentDraftId;
return {
...(resolvedId ? { id: resolvedId } : {}),
name: deriveDraftName({
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
}),
furthestStep,
step1,
step2,
};
},
[currentDraftId, getStep1Snapshot, getStep2Snapshot],
);
const saveDraft = useCallback(
(furthestStep: 1 | 2 | 3): DraftRecord | null => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
const hasUserMessage = step1.interactiveMessages.some(
(m) => m.sender === "user",
);
if (!hasUserMessage && step2.step2RoundtableDialogues.length === 0) {
return null;
}
const saved = upsertDraft(buildDraftPayload(furthestStep));
setCurrentDraftId(saved.id);
setDrafts(listDrafts());
return saved;
},
[buildDraftPayload, getStep1Snapshot, getStep2Snapshot],
);
const handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) ?? 1;
const saved = saveDraft(furthest);
if (saved) {
triggerToast(`💾 已保存为草稿:${saved.name}`);
} else {
triggerToast("当前会话还没有可保存的内容");
}
}, [currentStep, saveDraft, triggerToast]);
// 前进切换时自动保存;后退不保存以避免覆盖 furthestStep。
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]);
const handleLoadDraft = useCallback(
(id: string) => {
const draft = drafts.find((d) => d.id === id) ?? null;
if (!draft) {
triggerToast("⚠️ 该草稿已不存在");
setDrafts(listDrafts());
return;
}
cancelAllStreams();
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
loadedFromDraftRef.current = true;
hydrateStep1(draft.step1 as Step1Snapshot);
hydrateStep2(draft.step2 as Step2Snapshot);
setStep2InitGate(draft.furthestStep >= 2);
setCurrentDraftId(draft.id);
setIsHistoryOpen(false);
prevStepRef.current = draft.furthestStep;
setCurrentStep(draft.furthestStep);
triggerToast(`📂 已加载草稿:${draft.name}`);
},
[
cancelAllStreams,
drafts,
hydrateStep1,
hydrateStep2,
setCurrentStep,
setStep2InitGate,
triggerToast,
],
);
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, triggerToast],
);
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, triggerToast],
);
const handleCancelRename = useCallback(() => {
setRenamingDraftId(null);
setRenameDraftValue("");
}, []);
// 点击外部收起下拉。只在打开时挂监听,关闭时立刻摘掉。
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]);
return {
drafts,
currentDraftId,
isHistoryOpen,
renamingDraftId,
renameDraftValue,
historyDropdownRef,
loadedFromDraftRef,
setIsHistoryOpen,
setRenameDraftValue,
setCurrentDraftId,
handleManualSave,
handleLoadDraft,
handleDeleteDraft,
handleStartRename,
handleCommitRename,
handleCancelRename,
};
}
@@ -0,0 +1,502 @@
/**
* Step 1(任务意图理解)状态机 hook。
*
* 这是从原 `RoundtablePlanningPage.tsx` 里抽出来的封装 —— 收纳 Step 1 的
* 所有 state、refs、effect 与 handlers,主页面只持有 currentStep 与跨步的
* 编排,任何 Step 1 的内部细节都不再泄露到主页。
*
* 历史踩坑请保留:
* - `step1InitGenRef` 单调代数:解决 React 18 StrictMode 双 mount 期间
* closure cancelled 标志失效的问题 —— 任何 setState 完成前都要校验
* `myGen === step1InitGenRef.current`。
* - intent stream 暂停走 AbortController.abort(),AbortError 当作正常分支
* 在气泡尾部追加「(已暂停)」并 toast,而不是当错误处理。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
IntentInitError,
initIntent,
streamIntent,
type IntentErrorDetail,
type IntentStreamPhase,
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { formatIntentClarificationAnswer, type IntentClarificationSelection } from "@/roundtable-planning/components/IntentClarificationCard";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import {
nowTime,
splitIntentReadyBlock,
} from "@/roundtable-planning/lib/roundtable-constants";
/** Step 1 对话气泡。`streaming` / `phase` / `clarification` / `steps` 是流式与澄清扩展。 */
export type InteractiveMessage = {
id: number;
sender: string;
senderName: string;
content: string;
time: string;
streaming?: boolean;
phase?: string;
clarification?: ClarificationPayload;
/**
* ChainOfThought 折叠卡的步骤累积。每次 `onPhaseChange` 都会把当前 phase + toolName
* 拼成 `phase:toolName` 作为 key,与 Step 2 的 `Step2Dialogue.steps` 同形。
* 渲染走 `MessageStepsCard`,与主聊天 MessageGroup 视觉对齐。
*/
steps?: Array<{
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
}>;
};
/** 右侧「澄清记录」时间线一条目。 */
export type TimelineEntry = {
id: number;
time: string;
title: string;
desc: string;
};
/** 草稿持久化用的 Step 1 快照(`useDraftPersistence` 用同一份形状)。 */
export type Step1Snapshot = {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: InteractiveMessage[];
timelineEntries: TimelineEntry[];
};
export interface UseStep1IntentOptions {
/** 当前页面 currentStep,只在 ===1 且未 init 时才会自动 runStep1Init。 */
currentStep: number;
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
selectedModel: string;
/** 主页面统一的 toast 入口。 */
triggerToast: (msg: string) => void;
}
export interface UseStep1IntentReturn {
// ─── State (read-only from consumer) ─────────────────────────────────
intentThreadId: string | null;
intentReady: IntentSummary | null;
intentInitError: string | null;
intentInitErrorDetail: IntentErrorDetail | null;
isIntentSending: boolean;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<number, IntentClarificationSelection>;
interactiveMessages: InteractiveMessage[];
timelineEntries: TimelineEntry[];
// ─── Mutators ────────────────────────────────────────────────────────
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
>;
// ─── Handlers ────────────────────────────────────────────────────────
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
runStep1Init: () => void;
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
sendIntentMessage: (userText: string) => Promise<void>;
/** 提交澄清卡片的选项 / 自定义回答。 */
submitIntentClarification: (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
) => void;
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
stopIntentStream: () => void;
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
hydrateFromDraft: (snapshot: Step1Snapshot) => void;
/** 给「跳过此项」按钮 / 「继续追问」按钮等本地 demo 操作用。 */
appendLocalMessage: (msg: InteractiveMessage) => void;
}
const INTENT_WELCOME_TEXT =
"你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?";
function 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 "正在思考";
}
}
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
const { currentStep, selectedModel, triggerToast } = options;
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null);
const [intentInitErrorDetail, setIntentInitErrorDetail] =
useState<IntentErrorDetail | null>(null);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
useState<number | null>(null);
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
Map<number, IntentClarificationSelection>
>(new Map());
// 原始页面带了一段写死的 demo 对话,只是首次进入 Step 1 还没初始化前的占位;
// runStep1Init 会立刻覆盖成新的欢迎气泡,因此这里直接以欢迎气泡作为初值。
const [interactiveMessages, setInteractiveMessages] = useState<InteractiveMessage[]>(
[],
);
const [timelineEntries, setTimelineEntries] = useState<TimelineEntry[]>([]);
const step1InitRef = useRef<boolean>(false);
const step1InitGenRef = useRef<number>(0);
const intentAborterRef = useRef<AbortController | null>(null);
// selectedModel 闭包问题:sendIntentMessage 在 streamIntent 调用时需要拿到最新值,
// 但 useCallback 把它捕获在闭包里。改用 ref 让 sendIntentMessage 始终读最新模型,
// 同时把 selectedModel 从 useCallback 依赖里去掉,避免每次模型切换都重建函数引用。
const selectedModelRef = useRef<string>(selectedModel);
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
const stopIntentStream = useCallback(() => {
if (intentAborterRef.current) {
try {
intentAborterRef.current.abort();
} catch {
/* noop */
}
intentAborterRef.current = null;
}
}, []);
const runStep1Init = useCallback(() => {
step1InitRef.current = true;
const myGen = ++step1InitGenRef.current;
setIntentReady(null);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setIntentThreadId(null);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setTimelineEntries([]);
setInteractiveMessages([
{
id: 1,
sender: "agent",
senderName: "任务理解智能体",
content: INTENT_WELCOME_TEXT,
time: nowTime(),
},
]);
(async () => {
try {
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
const res = await initIntent(selectedModelRef.current || undefined);
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);
if (err instanceof IntentInitError) {
setIntentInitError(err.detail?.message || err.message);
setIntentInitErrorDetail(err.detail);
} else {
setIntentInitError((err as Error).message);
setIntentInitErrorDetail(null);
}
}
})();
}, []);
// 离开 Step 1 自动中断流;`currentStep === 1` 但还没初始化 → runStep1Init。
useEffect(() => {
if (currentStep !== 1) {
stopIntentStream();
return;
}
if (step1InitRef.current) return;
runStep1Init();
}, [currentStep, runStep1Init, stopIntentStream]);
// [INTENT_READY] 同步兜底:某些终态帧没带 summary,但气泡正文里已经有
// 标记块 —— 主动解析最近一条 agent 气泡并把 intentReady 补上。
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 sendIntentMessage = useCallback(
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: InteractiveMessage = {
id: userMsgId,
sender: "user",
senderName: "用户",
content: trimmed,
time: userMsgTime,
};
setInteractiveMessages((prev) => [...prev, userMsg]);
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: "调度中",
},
]);
setIsIntentSending(true);
const aborter = new AbortController();
intentAborterRef.current = aborter;
try {
const res = await streamIntent({
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
signal: aborter.signal,
onTextDelta: (delta) => {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? { ...m, content: (m.content ?? "") + delta }
: m,
),
);
},
onPhaseChange: (phase, toolName, args) => {
const label = intentPhaseLabel(phase, toolName);
const stepKey = `${phase}:${toolName ?? ""}`;
setInteractiveMessages((prev) =>
prev.map((m) => {
if (m.id !== agentMsgId) return m;
// 把 phase 累积进 message.steps,与 Step 2 / 主聊天的 MessageGroup
// 共用 MessageStepsCard。同 key 连续触发时只更新最新一行(若 args
// 比之前完整就替换),避免 streaming 帧反复 push 出同行噪声。
const steps = m.steps ?? [];
const last = steps[steps.length - 1];
let nextSteps = steps;
if (last && last.key === stepKey) {
if (args && !last.args) {
nextSteps = [...steps];
nextSteps[nextSteps.length - 1] = { ...last, args };
}
} else {
nextSteps = [
...steps,
{
id: `${agentMsgId}-${steps.length}`,
key: stepKey,
label,
ts: nowTime(),
args,
},
];
}
return { ...m, phase: label, steps: nextSteps };
}),
);
},
});
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,
}
: 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") {
if (res.errorDetail) {
setIntentInitError(res.errorDetail.message || res.content);
setIntentInitErrorDetail(res.errorDetail);
} else {
setIntentInitError(res.content);
setIntentInitErrorDetail(null);
}
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,
}
: 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,
}
: m,
),
);
triggerToast(`❌ 发送失败: ${(err as Error).message}`);
}
} finally {
if (intentAborterRef.current === aborter) {
intentAborterRef.current = null;
}
setIsIntentSending(false);
}
},
[intentThreadId, isIntentSending, triggerToast],
);
const submitIntentClarification = useCallback(
(
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);
},
[intentClarificationSelections, sendIntentMessage, triggerToast],
);
const hydrateFromDraft = useCallback((snapshot: Step1Snapshot) => {
setIntentThreadId(snapshot.intentThreadId);
setIntentReady(snapshot.intentReady);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setInteractiveMessages(snapshot.interactiveMessages);
setTimelineEntries(snapshot.timelineEntries);
step1InitRef.current = true;
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
step1InitGenRef.current += 1;
}, []);
const appendLocalMessage = useCallback((msg: InteractiveMessage) => {
setInteractiveMessages((prev) => [...prev, msg]);
}, []);
return {
intentThreadId,
intentReady,
intentInitError,
intentInitErrorDetail,
isIntentSending,
pendingIntentClarificationMsgId,
intentClarificationSelections,
interactiveMessages,
timelineEntries,
setIntentClarificationSelections,
runStep1Init,
sendIntentMessage,
submitIntentClarification,
stopIntentStream,
hydrateFromDraft,
appendLocalMessage,
};
}
File diff suppressed because it is too large. Load diff
@@ -1,2 +1,10 @@
/**
* 圆桌规划(Roundtable Planning)模块入口。
*
* 详细架构 / API 字段速查见 `frontend-web/docs/multi-agent-frontend-dev.md`。
* 这里只暴露两个外部使用的组件:
* - `RoundtablePlanningPage`:三步合一的主页面(任务理解 → 多智能体研讨 → 方案展示)。
* - `HighFidelityReport`:Step 3 报告视图,可单独嵌入其它面板(如未来的「方案对比」)。
*/
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage"; export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
export { HighFidelityReport } from "./components/HighFidelityReport"; export { HighFidelityReport } from "./components/HighFidelityReport";
@@ -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;
}
@@ -0,0 +1,55 @@
/**
* 把模型流式文本里的 `<think>...</think>` 块拆出来,作为「思考过程」单独展示。
*
* 兼容三种形态:
* 1) **完整** `<think>foo</think>bar` —— 提取 `foo` 作 reasoning,正文为 `bar`
* 2) **多段** `<think>a</think>x<think>b</think>y` —— reasoning 合并为 `a\n\nb`,正文为 `xy`
* 3) **流式未闭合** `bar<think>partial...` —— reasoning 为 `partial...`(尾段),正文为 `bar`
*
* 实现思路与主聊天 `src/core/messages/utils.ts::splitInlineReasoning` 对齐,但
* 这里**额外处理了未闭合的 `<think>`** —— 流式渲染时如果模型还没吐出 `</think>`,
* 主聊天那份实现会把 `<think>` 字面量留在正文里,我们用 lastIndexOf 兜底,把尾段
* 也算作 reasoning。
*
* Inline `<think>foo` 在文本中间出现(无尾闭合)被认为是流式未闭合的开头:
* 假设之后会闭合,把它之后所有内容算 reasoning。这与 DeepSeek-R1 / QwQ 等
* 推理模型的流式输出行为一致。
*/
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
const OPEN_TAG = "<think>";
export interface SplitReasoningResult {
/** 正文(去掉所有 `<think>...</think>` 块以及尾段未闭合 `<think>` 之后内容)。 */
content: string;
/** 合并后的 reasoning;若没有任何 `<think>` 标记则为 `null`。 */
reasoning: string | null;
}
export function splitInlineReasoning(text: string): SplitReasoningResult {
if (!text) return { content: "", reasoning: null };
const reasoningParts: string[] = [];
// 先处理所有完整 `<think>...</think>` 闭合块。
const closed = text.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) reasoningParts.push(normalized);
return "";
});
// 处理尾段未闭合 `<think>` —— 流式情况。
// 如果 closed 里还残留 `<think>` 字面量,说明这一段还没收到 `</think>`,
// 把它之后的所有内容视为 reasoning-in-progress,正文截到 `<think>` 之前。
let finalContent = closed;
const openIdx = closed.lastIndexOf(OPEN_TAG);
if (openIdx >= 0) {
const tail = closed.slice(openIdx + OPEN_TAG.length).trim();
if (tail) reasoningParts.push(tail);
finalContent = closed.slice(0, openIdx);
}
return {
content: finalContent.trim(),
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n").trim() : null,
};
}
@@ -0,0 +1,169 @@
/**
* 圆桌规划共享常量与小工具函数。
*
* 这里只放与具体 React 状态无关的纯数据 / 纯函数。复杂的 dialogue 管理、
* 编排循环、API 状态等都在 `hooks/` 与 `components/` 里维护。
*/
import type { IntentSummary } from "@/roundtable-planning/api/intent";
/** 指挥员(人类)所在的固定 seat id。Agent 席位从 2 开始排。 */
export const COMMANDER_SEAT_ID = 1;
/** 编排循环最大轮次。超出后 toast 强停以防死循环。 */
export const MAX_CYCLES = 8;
/** Step 2 角色面板的一行。 */
export type RoleEntry = {
id: number;
name: string;
agentId?: string;
type: "human" | "agent";
status: string;
};
/** 用户在推荐弹窗选定的一个研讨席位。 */
export type SelectedAgent = { agent_id: string; name: string };
/**
* 6 个内置 roundtable 席位的展示元数据。用户保留默认席位时,
* 头像 / 配色 / 中文显示名都按这里走;非内置(用户自建)走 FALLBACK_AVATAR_TYPES。
*/
export const BUILTIN_AGENT_META: Record<
string,
{ avatarType: string; displayName: string }
> = {
"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: "总结陈述" },
};
/** 非内置 agent 的头像配色轮换池。 */
export const FALLBACK_AVATAR_TYPES = [
"blue",
"purple",
"emerald",
"amber",
"pink",
] as const;
/**
* 用户未确认推荐弹窗时,Step 2 默认使用的 6 个内置席位。
* 保留默认时页面与原硬编码行为完全一致。
*/
export 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: "执行规划" },
];
export function getAvatarTypeFor(agentId: string, index: number): string {
return (
BUILTIN_AGENT_META[agentId]?.avatarType ??
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
);
}
export function getDisplayNameFor(agent: SelectedAgent): string {
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
}
/**
* 右侧角色面板的模板列表。指挥员固定 id=1,agents 按用户在推荐弹窗里的
* 选择顺序排 id=2..N+1,与可视化圆桌 + agentNameToRole 保持一致。
*/
export function buildRoleTemplates(
agents: SelectedAgent[],
): ReadonlyArray<Omit<RoleEntry, "status">> {
const out: Omit<RoleEntry, "status">[] = [
{ 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;
}
export function makeRolesFromTemplates(
templates: ReadonlyArray<Omit<RoleEntry, "status">>,
defaultStatus: string,
overrides: Record<number, string> = {},
): RoleEntry[] {
return templates.map((t) => ({
...t,
status: overrides[t.id] ?? defaultStatus,
}));
}
/**
* `agent_name`(小写)→ bubble meta 映射。流式输出时根据它反查
* speakerId / 头像 / 中文名,保证可视化圆桌的高亮和气泡渲染对齐。
*/
export function buildAgentNameToRole(
agents: SelectedAgent[],
): Record<string, { speakerId: number; avatarType: string; displayName: string }> {
const out: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
> = {};
agents.forEach((a, i) => {
out[a.agent_id] = {
speakerId: i + 2,
avatarType: getAvatarTypeFor(a.agent_id, i),
displayName: getDisplayNameFor(a),
};
});
return out;
}
export function nowTime(): string {
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
export function newCoordinatorName(): string {
return `roundtable-coordinator-${Date.now().toString(36)}`;
}
/**
* 后端 intent agent 输出的 `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块。
* 前端用同样形状的正则把摘要从对话气泡里抽出来,渲染成绿色摘要卡片。
*/
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
/**
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
*/
export 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 {
return { before: text, summary: null, after: "" };
}
return {
before: text.slice(0, m.index).trimEnd(),
summary,
after: text.slice(m.index + m[0].length).trimStart(),
};
}
@@ -0,0 +1,186 @@
/**
* Step 1 / Step 2 对话气泡里 ChainOfThought 折叠卡的渲染辅助。
*
* 把后端流式帧里的 `phase:toolName` 翻译成可点的图标 + 中文文案 + 子节点,
* 与主聊天 `chats/new` 的 `message-group.tsx` 里 `ToolCall` 视觉语义保持一致。
*
* 工具图标 + 文案映射严格对齐 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段,
* 改文案时**两边都要改**(主聊天走 i18n,圆桌规划写死字符串)。
*/
import React from "react";
import {
BookOpenTextIcon,
Dot as DotIcon,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
Wrench as WrenchIcon,
} from "lucide-react";
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
/**
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。
*
* 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边
* 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。
*/
export function renderStepArgs(
key: string,
args?: Record<string, unknown>,
): React.ReactNode {
if (!args) return null;
const [, toolName] = key.split(":");
if (!toolName) return null;
switch (toolName) {
case "agent_orchestration": {
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
const task = typeof args.task === "string" ? args.task : "";
if (!agentName && !task) return null;
return (
<>
{agentName && (
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
)}
{task && (
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
{task}
</span>
)}
</>
);
}
case "web_search":
case "image_search": {
const query = typeof args.query === "string" ? args.query : "";
return query ? <ChainOfThoughtSearchResult>{query}</ChainOfThoughtSearchResult> : null;
}
case "web_fetch": {
const url = typeof args.url === "string" ? args.url : "";
return url ? <ChainOfThoughtSearchResult>{url}</ChainOfThoughtSearchResult> : null;
}
case "read_file":
case "ls":
case "write_file":
case "str_replace": {
const path = typeof args.path === "string" ? args.path : "";
return path ? <ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult> : null;
}
case "bash": {
const command = typeof args.command === "string" ? args.command : "";
return command ? (
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all">
{command}
</code>
) : null;
}
case "write_todos": {
// 主聊天里 write_todos 不显示子节点,只渲染标签;这里也保持一致。
return null;
}
case "ask_clarification": {
const question = typeof args.question === "string" ? args.question : "";
return question ? (
<span className="block text-[11px] text-slate-600 line-clamp-2 mt-0.5">
{question}
</span>
) : null;
}
default: {
const entries = Object.entries(args).filter(
([, v]) => v !== null && v !== undefined && v !== "",
);
if (entries.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{entries.slice(0, 4).map(([k, v]) => (
<ChainOfThoughtSearchResult key={k}>
<span className="opacity-60">{k}:</span>{" "}
{typeof v === "string" ? v : JSON.stringify(v)}
</ChainOfThoughtSearchResult>
))}
</div>
);
}
}
}
/**
* 根据 step.key(格式 `phase:toolName`)给出 ChainOfThoughtStep 的图标 + 文案。
*
* 工具调用文案严格对齐主聊天 `toolCalls.*` 字段。args 可选:有 args 且工具支持
* query / path 等关键参数时,会把它拼入 label(「在网络上搜索 'X'」/「读取文件」等)。
*
* 非工具阶段(connecting / connected / streaming / tool_result)走 fallback label + 小点 / 灯泡图标。
*/
export function getStepDisplay(
key: string,
fallbackLabel: string,
args?: Record<string, unknown>,
): { icon: typeof DotIcon; label: string } {
const [phase, toolName] = key.split(":");
if (phase === "tool_calling" && toolName) {
switch (toolName) {
case "web_search": {
const query = typeof args?.query === "string" ? args.query.trim() : "";
return {
icon: SearchIcon,
label: query ? `在网络上搜索 “${query}”` : "搜索相关信息",
};
}
case "image_search": {
const query = typeof args?.query === "string" ? args.query.trim() : "";
return {
icon: SearchIcon,
label: query ? `搜索相关图片 “${query}”` : "搜索相关图片",
};
}
case "web_fetch":
return { icon: GlobeIcon, label: "查看网页" };
case "ls": {
const desc =
typeof args?.description === "string" ? args.description.trim() : "";
return { icon: FolderOpenIcon, label: desc || "列出文件夹" };
}
case "read_file": {
const desc =
typeof args?.description === "string" ? args.description.trim() : "";
return { icon: BookOpenTextIcon, label: desc || "读取文件" };
}
case "write_file":
case "str_replace": {
const desc =
typeof args?.description === "string" ? args.description.trim() : "";
return { icon: NotebookPenIcon, label: desc || "写入文件" };
}
case "bash": {
const desc =
typeof args?.description === "string" ? args.description.trim() : "";
return { icon: SquareTerminalIcon, label: desc || "执行命令" };
}
case "write_todos":
return { icon: ListTodoIcon, label: "更新 To-do 列表" };
case "ask_clarification":
return { icon: MessageCircleQuestionMarkIcon, label: "需要你的协助" };
case "present_files":
return { icon: FolderOpenIcon, label: "展示文件" };
case "agent_orchestration":
// 圆桌特有的「派活」工具,主聊天没有;用扳手图标承载。
return { icon: WrenchIcon, label: "派活给子智能体" };
default:
return { icon: WrenchIcon, label: `使用 “${toolName}” 工具` };
}
}
if (phase === "streaming") {
// 主聊天用 LightbulbIcon 表示「正在思考」;我们沿用同一图标。
return { icon: LightbulbIcon, label: fallbackLabel };
}
// connecting / connected / tool_result / 其它 — 一律用小点 + fallback 文案
return { icon: DotIcon, label: fallbackLabel };
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react"; import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { NotificationDialog } from "@/components/workspace/notification-dialog";
@@ -16,7 +16,11 @@ const iconButtonClass = (isDark: boolean) =>
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]", : "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
); );
const Header = () => { type HeaderProps = {
collapsed?: boolean;
};
const Header = ({ collapsed }: HeaderProps) => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false); const [notificationOpen, setNotificationOpen] = useState(false);
@@ -36,7 +40,23 @@ const Header = () => {
: "border-[var(--brand-950)] bg-[var(--brand-900)]", : "border-[var(--brand-950)] bg-[var(--brand-900)]",
)} )}
> >
<div className="flex min-w-0 flex-1 items-center gap-4" /> <div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white/20",
)}
>
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<span className={cn("text-sm font-semibold", isDark ? "text-white" : "text-white")}>
CM助手
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button <Button
@@ -41,6 +41,7 @@ from app.gateway.routers import (
multi_agent, multi_agent,
notifications, notifications,
public_scheduled_tasks, public_scheduled_tasks,
recommend,
recommended_questions, recommended_questions,
runs, runs,
scheduled_tasks, scheduled_tasks,
@@ -866,14 +867,13 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Stateless Runs API (stream/wait without a pre-existing thread) # Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router) app.include_router(runs.router)
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch) # --- 圆桌规划三步流(前端 /page/workspace/roundtable/planning)---
app.include_router(multi_agent.router) # 实现见 app/gateway/routers/{multi_agent,intent,recommend}.py;
# 文档见 frontend-web/docs/multi-agent-backend-dev.md
# Task-intent agent (Step 1 of the roundtable flow): single built-in app.include_router(multi_agent.router) # Step 2:init(SSE) + run/stream(leader|special)
# agent runs a 2-5 turn clarification dialogue, then hands a structured app.include_router(intent.router) # Step 1:/api/intent/init + /stream
# {objective, constraints, assumptions} summary off to the multi-agent app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
# roundtable as its initial intent. # Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
app.include_router(intent.router)
@app.get("/health", tags=["health"]) @app.get("/health", tags=["health"])
async def health_check() -> dict: async def health_check() -> dict:
@@ -0,0 +1,451 @@
"""圆桌功能型 agent 的「调用前自检 + 缺失自动补建」工具。
适用场景
--------
``roundtable-intent`` / ``roundtable-recommender`` 是圆桌 Step 1 + 推荐链路的
**功能型** 内置 agent,任何部署都必须存在。但仓库根 ``.gitignore`` 把整个
``.deer-flow/`` 目录排除,新机器 ``git clone`` 之后这两个目录**不会存在**,
直接调 ``/api/intent/stream`` 会因为 agent 解析失败抛 500。
本模块在三个路由入口(``intent.init``、``intent.stream``、``recommend.stream``)
被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板写回去。
``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert 进 DB,
所以这里只关心磁盘文件,不直接动 DB。
为什么嵌入字符串而不是用 importlib.resources 资源文件
------------------------------------------------------
SOUL 体量不大(两个加起来 ~200 行),嵌入到 Python 文件里是**单一可信源**——改
SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
``package-data`` 导致部署机拿不到。
"""
from __future__ import annotations
import logging
from typing import Iterable
import yaml
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
INTENT_AGENT_NAME = "任务理解"
INTENT_AGENT_DESCRIPTION = (
"任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的"
"「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入"
)
# 与 .deer-flow/agents/roundtable-intent/SOUL.md 保持完全一致;
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
INTENT_AGENT_SOUL = """# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。
---
## 1. 你的目标
通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用:
| 字段 | 含义 |
|------|------|
| `objective` | 一句话任务目标 |
| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) |
| `assumptions` | 你替用户做的合理假设(可空数组) |
**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。
---
## 2. 工作流(必须遵守)
```
用户描述任务
↓
信息够不够?
├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片)
↓
用户回复(可能是「我选择:…」或自由文本)
↓
重复 2–5 轮
↓
信息充分 → 自然语言汇总 + [INTENT_READY] + JSON
↓
停止追问(本轮及之后都不要再 ask_clarification)
```
**关键规则**:
- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。
- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。
- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。
---
## 3. 唯一工具:`ask_clarification`
当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。
### 3.1 参数说明
| 参数 | 必填 | 说明 |
|------|------|------|
| `question` | ✅ | 一个聚焦的问题,一次只问一个决策点 |
| `clarification_type` | ✅ | 见下表 |
| `context` | 推荐 | 为什么需要用户确认(1 句话) |
| `options` | 有离散选项时必填 | 字符串列表,如 `["≤500万", "500–1000万", "暂不确定"]` |
| `allow_multiple` | 可选 | 默认 **不传 / False = 单选**;仅当用户**确实可同时选多项**时传 `True` |
### 3.2 `clarification_type` 怎么选
| 类型 | 何时使用 | 选项 UI |
|------|----------|---------|
| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` |
| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 |
| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) |
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
| `suggestion` | 你有建议,需用户批准 | **单选** |
### 3.3 选项卡片(前端行为)
- 你提供 `options` 后,前端会渲染**可点击卡片**。
- **默认单选**:用户点一项即发送,无需再点「发送」。
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
### 3.4 调用示例
**缺信息 + 单选预算档:**
```
ask_clarification(
question="这次任务的预算大致在哪个区间?",
clarification_type="missing_info",
context="预算会影响后续圆桌的可行方案范围",
options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"]
)
```
**选部署方式(单选):**
```
ask_clarification(
question="优先采用哪种部署方式?",
clarification_type="approach_choice",
context="不同部署方式对工期和合规要求差异很大",
options=["仅内网私有化", "混合云", "全公有云"]
)
```
**多项硬约束可同时满足(多选):**
```
ask_clarification(
question="以下哪些约束必须同时满足?(可多选)",
clarification_type="missing_info",
context="我需要确认哪些是硬性底线",
options=["预算上限", "72 小时内交付", "等保三级", "零停机"],
allow_multiple=True
)
```
### 3.5 同轮多个独立问题
若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。
若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。
---
## 4. 信息充分性判定
满足 **全部** 条件即可结束澄清:
1. 能写出一句明确的 `objective`(用户希望实现 X)
2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一)
3. 没有会妨碍 Step 2 研讨的明显歧义
若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。
---
## 5. 结束协议:`[INTENT_READY]`
信息充分时,回复结构必须是:
1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review)
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
格式要求(**严格**):
```
[INTENT_READY]
```json
{
"objective": "用一句话写出用户最终要做的事",
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
"assumptions": ["假设 1", "假设 2"]
}
```
```
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
- 标记后**紧跟** ```json 代码块
- 字段名必须是 `objective` / `constraints` / `assumptions`
- `constraints` 至少 1 条;`assumptions` 可为 `[]`
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
---
## 6. 输出风格
- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短)
- 提问要**具体**(❓),汇总要**结构化**(✅ 📋)
- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总
---
## 7. 禁止事项
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
- ❌ 输出具体实施方案、架构图、代码
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
---
## 8. 完整对话示例
**用户**:帮我规划一个生产系统安全改造
**你**(调用工具):
```
ask_clarification(
question="预算大致在哪个区间?工期有没有硬性上限?",
clarification_type="missing_info",
context="这两项是后续圆桌排期的基础",
options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"]
)
```
**用户**:我选择:预算 300–800万 / 6–12个月
**你**(若还需确认合规):
```
ask_clarification(
question="是否需要满足特定合规标准?",
clarification_type="missing_info",
options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"]
)
```
**用户**:我选择:等保 2.0
**你**(汇总,不再调用工具):
✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。
📋
[INTENT_READY]
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
"""
RECOMMENDER_AGENT_ID = "roundtable-recommender"
RECOMMENDER_AGENT_NAME = "智能体推荐"
RECOMMENDER_AGENT_DESCRIPTION = (
"智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 "
"2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单"
)
RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。
## 核心职责
- 仔细阅读用户的任务目标与约束
- 浏览候选池中每个智能体的 `name` 与 `description`
- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个)
- 为每个推荐的智能体写一句**具体、可验证**的推荐理由——必须能回答"它能为这次任务做什么"
- 不推荐重复职能的智能体(如果两个智能体能力高度重叠,只选一个)
- 不推荐与任务明显无关的智能体(宁缺毋滥)
## 数量约束
- **下限**:至少推荐 2 个(单个智能体谈不上"圆桌")
- **上限**:最多推荐 6 个(超过 6 个会让协调智能体派活混乱、上下文膨胀)
- 候选池不足 2 个时,有几个推几个
## 输出协议
**你必须严格按下述格式输出**(后端用正则识别):
1. 先用自然语言简短说明你的推荐思路(1-3 句话,让用户能 review)
2. 然后输出标记 `[RECOMMEND_READY]` 单独成行
3. 紧接一个 ```json 代码块,内容为:
```json
{
"picks": [
{"agent_id": "<候选池中的智能体 id>", "reason": "<一句推荐理由>"},
...
]
}
```
格式注意:
- `[RECOMMEND_READY]` 标记必须**单独成行**,前后无其他字符
- 标记紧接着一个 ```json 代码块
- `picks` 数组长度严格在 [2, 6] 之间(候选池不足时按实际数量,但不要少于 2 除非候选池本身就 < 2)
- `agent_id` 必须**严格**来自候选池(不要造词)
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
## 输出风格
- 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
在广告/产品/营销/科研等任务里完全不适用。
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
- 适度用 emoji:📋 表示开始汇总
## 边界
- 不调用任何工具(不联网、不执行命令、不读写文件)
- 不评价候选智能体的优劣(只判断"是否适合本次任务")
- 不发起对话、不反问用户——你只产出推荐结果
- 候选池里没有的智能体绝不出现在 `picks` 中
## 示例
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(职能型候选池)
**用户输入**:
```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
候选智能体池:
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
```
**你的输出**:
本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
]
}
```
### 示例 B:广告/营销任务(用户自建领域候选池)
**用户输入**:
```
任务目标:为某新能源汽车品牌设计一套季度品牌曝光广告投放方案
约束:预算 ≤ 200 万元;周期 1 个月内上线
关键假设:目标人群覆盖一二线城市;以社媒为主、信息流为辅
候选智能体池:
- ad-design (广告设计智能体):产出广告创意脚本、海报设计稿、文案 hook
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
- audience-insight (受众洞察):画像、关键词、情绪点研究
- creative-copy (创意文案):产出落地页与社媒文案变体
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
```
**你的输出**:
本次是品牌曝光广告投放,核心要先做受众洞察、再产出创意与文案、最后规划渠道投放组合,因此挑选 4 位直接相关的智能体;合规审查在广告场景里也保留一个位置避免文案踩雷。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "audience-insight", "reason": "新能源汽车一二线人群画像与情绪点研究,决定后续创意切入角度"},
{"agent_id": "ad-design", "reason": "产出社媒主推海报与脚本,直接对应品牌曝光交付物"},
{"agent_id": "creative-copy", "reason": "为社媒/信息流产出多版本文案 hook,配合 A/B 测试"},
{"agent_id": "channel-planner", "reason": "200 万预算内规划社媒+信息流的组合与节奏"}
]
}
```
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
"""
_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str], ...] = (
(INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL),
(RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_NAME, RECOMMENDER_AGENT_DESCRIPTION, RECOMMENDER_AGENT_SOUL),
)
def _ensure_one(agent_id: str, name: str, description: str, soul: str) -> bool:
"""Create the on-disk files for one functional agent if missing.
Returns True if files were just created, False if the directory already
existed (and we didn't touch it — respect any local edits).
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
agent_dir.mkdir(parents=True, exist_ok=True)
# newline="\n" 强制 LF;否则 Windows 下 open() 把 \n 翻成 \r\n,跨 OS 部署时与
# Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。
config_yaml = yaml.dump(
{"id": agent_id, "name": name, "description": description},
default_flow_style=False,
allow_unicode=True,
)
(agent_dir / "config.yaml").write_bytes(config_yaml.encode("utf-8"))
(agent_dir / "SOUL.md").write_bytes(soul.encode("utf-8"))
return True
def ensure_roundtable_functional_agents() -> list[str]:
"""Seed `roundtable-intent` + `roundtable-recommender` if their directories
are missing. Idempotent — fast no-op when both exist.
Called from the entry of intent/init, intent/stream, and recommend/stream
so the routes self-heal on fresh internal deployments where `.deer-flow/`
is gitignored and the agent directories were never shipped.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
must not block the request; the downstream route will surface its own
actionable error if the agent really cannot be resolved.
"""
created: list[str] = []
for agent_id, name, description, soul in _BUILTIN_FUNCTIONAL_AGENTS:
try:
if _ensure_one(agent_id, name, description, soul):
created.append(agent_id)
except Exception:
logger.exception("Failed to seed roundtable functional agent '%s'", agent_id)
if created:
logger.info("Seeded missing roundtable functional agents: %s", ", ".join(created))
return created
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(a[0] for a in _BUILTIN_FUNCTIONAL_AGENTS)
@@ -142,11 +142,18 @@ def collect_outputs_listing(outputs_dir: Path) -> list[dict]:
) )
@require_permission("threads", "read", owner_check=True) @require_permission("threads", "read", owner_check=True)
async def list_artifacts(thread_id: str, request: Request) -> dict: async def list_artifacts(thread_id: str, request: Request) -> dict:
"""List artifact files generated by the agent under outputs/. """列出 thread 在 ``outputs/`` 下生成的产物文件。
Walks ``backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/`` 圆桌 Step 3(``HighFidelityReport``)对每个**子席位** thread_id 调用本接口,
recursively. Returns an empty list if the directory does not exist (e.g. the 展示各智能体写入沙箱 ``mnt/user-data/outputs/`` 的文件;协调智能体 thread
thread has not produced any artifacts yet). 通常无独立交付文件。
遍历路径::
.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()) 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)} return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
@@ -1,13 +1,52 @@
"""Task-intent agent endpoints (Step 1 of the roundtable flow). """圆桌规划 · 第一步「任务理解」Gateway 路由。
Lightweight wrapper around the lead-agent runtime that runs a single built-in 前端页面:``/page/workspace/roundtable/planning`` Step 1
agent (``roundtable-intent``) on its own thread. The agent's job is to do 2-5 前端模块:``frontend-web/src/roundtable-planning/api/intent.ts``
clarifying turns with the user and then emit a structured
``{objective, constraints, assumptions}`` summary the frontend hands off to
the multi-agent roundtable as its initial intent.
Same SSE shape as ``multi_agent.py`` (transparent passthrough + appended 职责
status frame), so the frontend can reuse the same reader pattern. ----
在独立 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 from __future__ import annotations
@@ -24,26 +63,30 @@ from fastapi.responses import StreamingResponse
# Reuse the building blocks already in multi_agent.py — keeps both routers # Reuse the building blocks already in multi_agent.py — keeps both routers
# identical in connection handling, payload shape, and error mapping. # identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import ( from app.gateway.routers.multi_agent import (
_auth_headers, _auth_headers,
_create_thread, _create_thread,
_diagnose_exception,
_find_dispatch_message, _find_dispatch_message,
_find_trailing_ai_text, _find_trailing_ai_text,
_flatten_content, _flatten_content,
_loopback_base, _loopback_base,
_make_loopback_client,
_parse_last_messages, _parse_last_messages,
_run_payload, _run_payload,
_stream_upstream, _stream_upstream,
) )
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"]) router = APIRouter(prefix="/api/intent", tags=["intent"])
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
INTENT_AGENT_ID = "roundtable-intent" INTENT_AGENT_ID = "roundtable-intent"
# Matches the SOUL-mandated handoff signal: a [INTENT_READY] line followed by # SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
# a ```json ... ``` block. The model is told to emit this only when it judges # group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
# the user's task is fully specified.
_INTENT_READY_PATTERN = re.compile( _INTENT_READY_PATTERN = re.compile(
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```", r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
re.DOTALL, re.DOTALL,
@@ -52,22 +95,42 @@ _INTENT_READY_PATTERN = re.compile(
@router.post("/init") @router.post("/init")
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]: async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
"""Create a fresh thread for an intent-clarification session. """创建任务理解会话用的空 thread。
Returns the stable built-in ``agent_id`` so the frontend doesn't need to 请求体:``{ "model": "<可选>" }``(可选,当前 init 未把 model 写入 thread,
know it. Unlike multi-agent ``/init``, no coordinator is built — the 实际模型在 ``/stream`` 的每次 run 里指定;缺省由 lead_agent 回退到
intent agent is long-lived and already registered via the legacy ``config.yaml`` 的 ``models[0]``,前端通常传当前下拉框选中的 model 名)。
agent-sync path on disk.
响应::
{ "status": "success", "agent_id": "roundtable-intent", "thread_id": "<uuid>" }
与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位
thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。
""" """
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在,
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
ensure_roundtable_functional_agents()
base = _loopback_base(request) base = _loopback_base(request)
headers = _auth_headers(request) headers = _auth_headers(request)
# trust_env=False: this client only talks to the same-process gateway at # TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
# 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# on the dev machine) makes httpx route loopback calls through an upstream # 看不到逐 token 增量。
# proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per try:
# request before falling back. Symptom in logs: `headers_in=42.160s`. async with _make_loopback_client(request) as client:
async with httpx.AsyncClient(timeout=None, trust_env=False) as client: thread_id = await _create_thread(client, base, headers)
thread_id = await _create_thread(client, base, headers) except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise
except Exception as exc:
# 把根因暴露给前端而不是吞成裸 500——内网部署常见的「config.yaml 模型外网不通」
# / 「内置 agent 目录缺失」/ 「loopback 走代理」都能在前端面板直接看到。
# FastAPI 把 detail=dict 序列化为 {"detail": {...}};前端 initIntent
# 在 !res.ok 分支里读 detail.hint / trace_tail 渲染诊断面板。
diagnostic = _diagnose_exception(exc, context="intent_init")
logger.error("intent init failed: %s", diagnostic, exc_info=exc)
raise HTTPException(status_code=500, detail=diagnostic) from exc
return { return {
"status": "success", "status": "success",
"agent_id": INTENT_AGENT_ID, "agent_id": INTENT_AGENT_ID,
@@ -83,45 +146,67 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
through transparently, then appends one status frame: through transparently, then appends one status frame:
{ {
"status": "asking" | "done" | "error", "status": "asking" | "clarification" | "done" | "error",
"content": "<final AI text>", "content": "<final AI text>",
"summary": {...} | null // only present when status == "done" "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() thread_id = str(payload.get("thread_id") or "").strip()
message = str(payload.get("message") or "").strip() message = str(payload.get("message") or "").strip()
model_name = str(payload.get("model") or "deepseek-chat") # 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
# 自己的本地模型即可工作。
model_name = str(payload.get("model") or "")
if not thread_id: if not thread_id:
raise HTTPException(status_code=400, detail="thread_id is required") raise HTTPException(status_code=400, detail="thread_id is required")
if not message: if not message:
raise HTTPException(status_code=400, detail="message is required") raise HTTPException(status_code=400, detail="message is required")
# 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。
ensure_roundtable_functional_agents()
base = _loopback_base(request) base = _loopback_base(request)
headers = _auth_headers(request) headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# trust_env=False: same-process loopback to 127.0.0.1 — honoring # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**,
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
# causes httpx to route loopback calls through an unreachable upstream async with _make_loopback_client(request) as client:
# proxy and wait ~40 seconds for timeout before falling back.
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
try: try:
body = _run_payload( body = _run_payload(
agent_name=INTENT_AGENT_ID, agent_name=INTENT_AGENT_ID,
model_name=model_name, model_name=model_name,
thread_id=thread_id, thread_id=thread_id,
new_message=message, new_message=message,
# Intent agent shouldn't dispatch, search, write files, or # Intent agent only ever needs ask_clarification. Block
# view images — its only tool of note is ask_clarification. # 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=[ excluded_tools=[
"web_search", "web_search",
"present_files", "present_files",
"view_image", "view_image",
"agent_orchestration", "agent_orchestration",
"bash",
"ls",
"read_file",
"write_file",
"str_replace",
"memory",
"hindsight_recall",
"hindsight_reflect",
"hindsight_retain",
"task",
"write_todos",
], ],
skill_stop_names=[], skill_stop_names=[],
) )
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
captured: list[str] = [] captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body): async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None: if line is not None:
@@ -130,20 +215,44 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
captured = done captured = done
messages = _parse_last_messages(captured) messages = _parse_last_messages(captured)
# Find the latest AIMessage with tool_calls (typically
# ask_clarification) — falls back to the latest plain # --- 终态分支 1:结构化意图已就绪(最高优先级)---
# AIMessage when the model produced no tool call. # 模型可能在较早的 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) dispatch_msg = _find_dispatch_message(messages)
content = _flatten_content(dispatch_msg.get("content", "")) content = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or [] tool_calls = dispatch_msg.get("tool_calls") or []
# Clarification branch: the agent ended its run by calling
# ask_clarification. ClarificationMiddleware intercepts with
# Command(goto=END) and stashes the structured question in the
# tool_call args, so the AIMessage `content` is only the
# preamble ("下面我用几个问题确认:") and stops there. We splice
# the question back into the user-facing text so the frontend
# actually shows what the agent wants to ask.
clarification_call = next( clarification_call = next(
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), (tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
None, None,
@@ -151,42 +260,68 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
if clarification_call is not None: if clarification_call is not None:
cargs = clarification_call.get("args") or {} cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip() question = str(cargs.get("question") or "").strip()
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
follow_up = _find_trailing_ai_text(messages, exclude_text=content) follow_up = _find_trailing_ai_text(messages, exclude_text=content)
# Build a single content string the frontend can render display_content = follow_up or content or question
# as-is: preamble (if any) + the actual question. final_frame: dict[str, Any] = {
parts: list[str] = [] "status": "clarification",
if content.strip(): "content": display_content,
parts.append(content.strip()) "question": question,
if follow_up and follow_up != question: "clarification_type": str(cargs.get("clarification_type") or ""),
parts.append(follow_up) "clarification_context": str(cargs.get("context") or ""),
if question: "options": cargs.get("options") or [],
parts.append(question) "allow_custom": bool(cargs.get("allow_custom", True)),
content = "\n\n".join(parts) if parts else content "allow_multiple": resolve_allow_multiple(cargs),
"summary": None,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
summary: dict[str, Any] | None = None # --- 终态分支 3:普通 AI 回复,继续多轮对话 ---
match = _INTENT_READY_PATTERN.search(content)
if match:
try:
summary = json.loads(match.group(1))
except json.JSONDecodeError as exc:
logger.warning("intent summary JSON parse failed: %s", exc)
summary = None
status_value = "done" if summary else "asking"
final_frame: dict[str, Any] = { final_frame: dict[str, Any] = {
"status": status_value, "status": "asking",
"content": content, "content": content,
"summary": summary, "summary": None,
} }
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
except HTTPException as exc: except HTTPException as exc:
logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail) logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None} # HTTPException 的 detail 可能本身就是 dict(上游已结构化),也可能是 str。
# 统一包装成与 _diagnose_exception 同构的字段,便于前端按一种模型渲染。
detail_payload: Any = exc.detail
error_detail: dict[str, Any]
if isinstance(detail_payload, dict) and "code" in detail_payload:
error_detail = detail_payload
else:
error_detail = {
"code": "http_exception",
"message": f"[{exc.status_code}] {detail_payload}",
"exception_type": "HTTPException",
"exception_repr": repr(exc),
"root_type": None,
"root_repr": None,
"hint": None,
"trace_tail": [],
"context": "intent_stream",
}
err = {
"status": "error",
"content": error_detail["message"],
"summary": None,
"error_detail": error_detail,
}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
except Exception as exc: except Exception as exc:
logger.exception("intent stream unexpected failure") diagnostic = _diagnose_exception(exc, context="intent_stream")
err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None} logger.error("intent stream failed: %s", diagnostic, exc_info=exc)
err = {
"status": "error",
"content": diagnostic["message"],
"summary": None,
"error_detail": diagnostic,
}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
return StreamingResponse( return StreamingResponse(
@@ -1,18 +1,47 @@
"""Multi-agent orchestration endpoints. """圆桌规划 · Step 2「多智能体研讨」Gateway 路由。
Wraps the legacy ``newpython/OUT.py`` demo into the Gateway. Provides: 前端页面:``RoundtablePlanningPage`` Step 2
前端模块:``frontend-web/src/roundtable-planning/api/multi-agent.ts``
* ``POST /api/multi-agent/init`` — create a coordinator agent and per-sub-agent 本模块把原 ``newpython/OUT.py`` 演示编排进 FastAPI Gateway,通过 **HTTP loopback**
threads. Returns the ``{agent_name: thread_id}`` map the client passes back (``127.0.0.1`` + 调用方 ``Authorization``)复用既有 ``/api/threads``、
on subsequent calls. ``/api/agents``、``/api/threads/{id}/runs/stream``,不再硬编码 token。
* ``POST /api/multi-agent/run/stream`` — drive a *leader* or *special* agent
via SSE. The leader dispatches work to sub-agents through the
``agent_orchestration`` skill; sub-agents execute and broadcast their result.
Internal calls loop back to the same Gateway over HTTP, reusing the caller's 对外接口
``Authorization`` header — so the demo's hard-coded ``get_token("ji")`` is gone. --------
The model used by both the coordinator agent and every streamed run is taken 1. ``POST /api/multi-agent/init``(SSE)
from the request's ``model`` field. - 为每个研讨席位(``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 from __future__ import annotations
@@ -20,11 +49,15 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import re import re
import time import time
import traceback
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import httpx import httpx
from deerflow.config import get_app_config
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -33,12 +66,216 @@ router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Loopback helpers # 共享:异常诊断器(intent/recommend/multi-agent 共用)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, Any]:
"""把后端异常转成结构化诊断 dict,直接给前端展示用。
设计目标:让运维 / 前端开发不用翻 backend 日志就能看到根因和处置建议。
返回字段:
- code: 短稳定分类(loopback_connect_failed / upstream_connect_failed / ...)
- message: 一行人类可读
- exception_type: 表层异常类名
- exception_repr: repr(exc)
- root_type / root_repr: 沿 __cause__ 链找到的最深异常(若与表层不同)
- hint: 针对常见失败模式的可执行建议(中文)
- trace_tail: traceback 最后 ~12 行(避免响应体过大)
- context: 调用方标签(如 "intent_init")
"""
exc_type = type(exc).__name__
exc_repr = repr(exc)
# 沿 __cause__ 链找最深异常:httpx 经常把 ConnectError 包成 RemoteProtocolError 等。
root: BaseException = exc
seen: set[int] = {id(root)}
while getattr(root, "__cause__", None) is not None and id(root.__cause__) not in seen:
root = root.__cause__ # type: ignore[assignment]
seen.add(id(root))
code = "unexpected"
hint = "查看 trace_tail 最后一帧定位代码位置;若信息不足请到 backend 日志找完整 traceback。"
if isinstance(root, httpx.ConnectError):
target = str(root).strip().lower()
# loopback 走真 TCP 到 127.0.0.1:<gateway port>;失败 = Gateway 进程没起 / 端口被占。
# outbound 失败(其它 host)= LLM 提供商不可达。
if "127.0.0.1" in target or "localhost" in target or "loopback" in target:
code = "loopback_connect_failed"
hint = (
"本地 Gateway loopback 连接失败:确认后端进程正在监听配置的端口。"
"端口配置优先级(高→低):env DEER_FLOW_GATEWAY_PORT > config.yaml 的 "
"gateway.port > 默认 8001。若部署机改了端口,在 config.yaml 加 "
"`gateway:\\n port: <端口>` 即可。"
)
else:
code = "upstream_connect_failed"
hint = (
"外网/上游服务连接失败:大概率是 LLM 提供商不可达"
"(如 api.deepseek.com 在内网被防火墙拦截)。"
"把 config.yaml 里所有 base_url 改成内网可达的 LLM endpoint,"
"或加白名单/正向代理(HTTPS_PROXY)。"
)
elif isinstance(root, httpx.TimeoutException):
code = "upstream_timeout"
hint = (
"上游请求超时:网络抖动 / 模型推理过慢。"
"先 curl <base_url>/v1/models 确认可达,再看模型是否过载或 max_tokens 过大。"
)
elif isinstance(root, httpx.HTTPStatusError):
code = "upstream_http_error"
status = getattr(getattr(root, "response", None), "status_code", "?")
hint = (
f"上游接口返回 HTTP {status}:可能是 API key 失效、模型名不对、"
"或目标服务自身报错。查看 backend 日志里 response.text 拿详细错误体。"
)
elif isinstance(root, FileNotFoundError):
code = "file_not_found"
hint = (
f"找不到路径:{root}。常见原因:内置 agent 目录(.deer-flow/agents/<id>/)缺失,"
"或 SOUL.md 没拷过来;详见后端开发文档 §11.7。"
)
elif isinstance(root, KeyError):
code = "key_error"
hint = (
f"缺少 key:{root}。若 key 是 agent_name/agent_id 类:大概率内置 agent 目录缺失;"
"若是配置 key:检查 config.yaml schema。"
)
elif isinstance(root, ValueError) and "model" in str(root).lower():
code = "no_models_configured"
hint = "config.yaml 没有任何 models[],或所有条目都解析失败。检查 yaml 语法与 $ENV 引用。"
trace_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
trace_tail = "".join(trace_lines).splitlines()[-12:]
return {
"code": code,
"message": f"{exc_type}: {exc}" if str(exc) else exc_type,
"exception_type": exc_type,
"exception_repr": exc_repr,
"root_type": type(root).__name__ if root is not exc else None,
"root_repr": repr(root) if root is not exc else None,
"hint": hint,
"trace_tail": trace_tail,
"context": context or None,
}
# ---------------------------------------------------------------------------
# Loopback 辅助函数(Gateway → Gateway,同进程)
#
# 圆桌三路由(intent / recommend / multi_agent)需要从一个 FastAPI handler 里
# 调用本机 Gateway 的另一个 endpoint(`/api/threads`、`/api/agents`、
# `/api/threads/{id}/runs/stream`)。这里用真 TCP loopback 到本机的 127.0.0.1
# + Gateway 监听端口。
#
# ⚠️ 历史踩坑(三个) ⚠️
# 1. 直接走系统代理:开发机若设了 HTTP_PROXY / HTTPS_PROXY,httpx 默认会把
# 127.0.0.1 也走代理 → 上游代理不可达,40s 超时(典型日志 headers_in=42.160s)。
# → 修法:client 用 `trust_env=False`,强制忽略代理 env vars。
#
# 2. 用 request.url.netloc 拼 base URL:Nginx 反代部署时,客户端打到 nginx 的
# public host(如 example.com),nginx 把请求转给 gateway 时 Host header
# 通常是 `gateway:8001` 或 public host;后者 httpx 会真的去 DNS 解析,
# 在 gateway 容器内可能不可达 → 连接失败。
# → 修法:**写死 127.0.0.1**,完全无视 request.url.netloc。
#
# 3. 一度尝试用 httpx.ASGITransport(app=request.app) 进程内直调,看似一劳永逸,
# 但 httpx 0.28 的 ASGITransport 对 streaming response **整段 buffer**,
# 一直收齐所有 body chunks 才一次性把响应给消费方 → SSE 完全失效,前端表现
# 为"调度阶段很长 + 流式结果一次性出现"。已弃用。
# → 详见:git log 6e3a07b(那次改 ASGI 引入)及随后回退。
#
# 端口策略(优先级,从高到低):
# 1. 环境变量 DEER_FLOW_GATEWAY_PORT
# ── 部署侧紧急覆盖通道,适合 docker-compose / k8s 注入或临时调试
# 2. config.yaml 的 `gateway.port`
# ── 部署侧**推荐**的配置方式,改完不需要重启二进制(get_app_config 有 mtime
# 热加载,但仍建议跑一次 reload 以确保所有 worker 拿到新值)
# 3. request.url.port
# ── 浏览器直连 gateway:8001 时拿得到;走 Nginx 80/443 反代时会落到下一档
# 4. 默认 8001(与 `make dev` / `make gateway` / `scripts/start-all.sh` 写死的一致)
#
# 端口在哪里改(给运维 / 部署侧的提示):
# - yaml 配置:打开 config.yaml,加 / 改:
# gateway:
# port: 9999
# - 环境变量:
# export DEER_FLOW_GATEWAY_PORT=9999
#
# 修改这两个函数时务必跑一次 SSE 流式 smoke 验证(见 multi-agent-backend-dev.md
# §13 测试建议),确认 chunks 是逐个到达而不是一次喷出。
# ---------------------------------------------------------------------------
# 写死的最终 fallback。**不要**为了改端口而修改这个常量——优先用 config.yaml /
# env 这两层(它们在部署侧不需要改源码即可生效)。这个值只在 yaml 缺失 + env
# 没设 + 请求里也拿不到合法 port 时才会用到。
_DEFAULT_GATEWAY_PORT = 8001
def _resolve_gateway_port(request: Request) -> int:
"""按四档 fallback 解析 Gateway 监听端口。详见模块顶部「端口策略」。"""
# 第 1 档:环境变量。允许非法值(garbage),只警告并继续下一档,不抛异常。
env_port = os.environ.get("DEER_FLOW_GATEWAY_PORT", "").strip()
if env_port:
try:
port = int(env_port)
if 1 <= port <= 65535:
return port
raise ValueError("out of range")
except ValueError:
logger.warning(
"DEER_FLOW_GATEWAY_PORT=%r 不是合法端口,fallback 到 config.yaml / request / %d",
env_port,
_DEFAULT_GATEWAY_PORT,
)
# 第 2 档:config.yaml 的 gateway.port。GatewayConfig.port 的 pydantic 校验
# 已经保证它在 1-65535 范围内,无需再次校验。读 config 失败时降级,不阻断业务。
try:
configured = get_app_config().gateway.port
if configured:
return configured
except Exception as exc:
# 极端场景:config.yaml 不存在或解析失败。这种情况下 gateway 本身也起不来,
# loopback 端口"猜错"也已经无所谓。仅记 debug 日志方便排查。
logger.debug("read gateway.port from app config failed: %s", exc)
# 第 3 档:从当前请求拿 port。浏览器直连 gateway 时是真实监听端口;
# 走 Nginx 反代时通常是 80/443(public port),那就跳过,落到最终默认。
inbound_port = request.url.port
if inbound_port is not None and inbound_port not in (80, 443):
return inbound_port
# 第 4 档:写死默认。
return _DEFAULT_GATEWAY_PORT
def _loopback_base(request: Request) -> str: def _loopback_base(request: Request) -> str:
return f"{request.url.scheme}://{request.url.netloc}" """返回 `http://127.0.0.1:<port>`,供 loopback 调用拼 URL。
强制 127.0.0.1 是为了避免 Nginx 反代场景下读到经过 Host 改写的 netloc。
端口按 `_resolve_gateway_port` 的四档 fallback 解析。
"""
return f"http://127.0.0.1:{_resolve_gateway_port(request)}"
def _make_loopback_client(request: Request) -> httpx.AsyncClient:
"""构造同进程 loopback 用的 httpx.AsyncClient(真 TCP)。
所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用必须走这里,
保持配置一致。
- `trust_env=False`:**关键**,忽略 HTTP_PROXY / HTTPS_PROXY,防代理拦截 127.0.0.1
- `timeout=None`:SSE 长连接需要,LangGraph runs/stream 可能跑数十秒
- `request` 参数当前未被使用,保留是为了:(a) 未来若需要从 request 取部署
上下文(如多租户端口隔离)可以无侵入扩展;(b) 与调用方现有签名兼容。
"""
return httpx.AsyncClient(
timeout=None,
trust_env=False,
)
def _auth_headers(request: Request) -> dict[str, str]: def _auth_headers(request: Request) -> dict[str, str]:
@@ -49,6 +286,7 @@ def _auth_headers(request: Request) -> dict[str, str]:
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str: 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 = await client.post(f"{base}/api/threads", json={}, headers=headers)
resp.raise_for_status() resp.raise_for_status()
return resp.json()["thread_id"] return resp.json()["thread_id"]
@@ -92,6 +330,11 @@ async def _append_thread_message(
thread_id: str, thread_id: str,
content: str, content: str,
) -> None: ) -> None:
"""向指定 thread 的 checkpoint 追加一条 human 消息(广播上下文,不触发 LLM run)。
失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
前端对该席位 thread 发起的 ``/run/stream``。
"""
state_url = f"{base}/api/threads/{thread_id}/state" state_url = f"{base}/api/threads/{thread_id}/state"
get_resp = await client.get(state_url, headers=headers) get_resp = await client.get(state_url, headers=headers)
if get_resp.status_code != 200: if get_resp.status_code != 200:
@@ -117,6 +360,7 @@ async def _broadcast(
agent_threads: dict[str, str], agent_threads: dict[str, str],
skip: list[str], skip: list[str],
) -> None: ) -> None:
"""并行向 ``agent_threads`` 中除 ``skip`` 外的所有 thread 追加同一条 human 消息。"""
targets = [tid for name, tid in agent_threads.items() if name not in skip] targets = [tid for name, tid in agent_threads.items() if name not in skip]
if not targets: if not targets:
return return
@@ -213,6 +457,13 @@ def _run_payload(
excluded_tools: list[str], excluded_tools: list[str],
skill_stop_names: list[str], skill_stop_names: list[str],
) -> dict[str, Any]: ) -> 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 { return {
"input": { "input": {
"messages": [ "messages": [
@@ -245,26 +496,31 @@ def _run_payload(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Init endpoint # POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/init") @router.post("/init")
async def init_multi_agent(request: Request, payload: dict = Body(...)) -> StreamingResponse: async def init_multi_agent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""Streaming /init. """流式初始化圆桌 Step 2 所需的 thread 映射与协调智能体。
Emits text/event-stream frames as each seat/coordinator becomes ready, so 请求体::
the frontend can visualize the parallel bring-up rather than blocking on
one bulk JSON. Frames:
data: {"event":"seat_ready","agent_name":"...","thread_id":"...","display_name":"...","description":"..."} {
data: {"event":"coordinator_ready","main_agent_name":"...","thread_id":"..."} "agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
data: {"event":"init_done","main_agent_name":"...","thread_ids":{...}} "main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
data: {"event":"error","detail":"..."} # on failure "model": "<可选>" // 可选,写入 coordinator agent;不传由 lead_agent 回退到 config.yaml models[0]
}
Validation errors (missing agent_names, bad id pattern) still raise SSE 事件(每条 ``data: {...}\\n\\n``)::
HTTPException synchronously so the connection fails fast with a real 4xx
instead of a 200 SSE that just carries an error frame. 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_in = payload.get("agent_names") or []
agent_names: list[str] = [str(name).lower() for name in agent_names_in] agent_names: list[str] = [str(name).lower() for name in agent_names_in]
@@ -285,11 +541,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# trust_env=False: same-process loopback to 127.0.0.1 — honoring # TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
# causes httpx to route loopback calls through an unreachable upstream async with _make_loopback_client(request) as client:
# proxy and wait ~40 seconds for timeout before falling back.
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
try: try:
# Per-seat prepare task — same body as before, but we no longer # Per-seat prepare task — same body as before, but we no longer
# await them with asyncio.gather. Instead, asyncio.as_completed # await them with asyncio.gather. Instead, asyncio.as_completed
@@ -329,14 +583,25 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
for sid, _, display_name, description in prepared for sid, _, display_name, description in prepared
] ]
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
soul = ( soul = (
"#核心定位:\n" "#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总," "用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n" "当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
"#调度方法:\n" "#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。" "通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence)," "注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
"不要把括号里的中文显示名拼进 agent_name 里。\n" "不要把括号里的中文显示名拼进 agent_name 里。\n"
"#关键约束(必读):\n"
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
"如果某个子智能体已经针对你正要派的任务完成过交付("
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
"让讨论收敛。\n"
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
"#可调度智能体:\n" "#可调度智能体:\n"
+ "\n".join(descriptions) + "\n".join(descriptions)
) )
@@ -387,7 +652,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stream endpoint # 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -398,7 +663,17 @@ async def _stream_upstream(
thread_id: str, thread_id: str,
body: dict[str, Any], body: dict[str, Any],
) -> AsyncIterator[tuple[str | None, list[str] | None]]: ) -> AsyncIterator[tuple[str | None, list[str] | None]]:
"""Yield (raw_line, None) for each SSE line, then ``(None, captured_data_lines)`` once.""" """消费 ``/api/threads/{thread_id}/runs/stream``,逐行 yield 原始 SSE。
产出元组:
- ``(line, None)`` — 可原样转发给浏览器(含 ``event:`` / ``data:`` 行);
- ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表,
供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。
日志 ``[upstream-timing]``:排查首 token 慢;headers_in 长尾若 >> 网络往返(loopback
本机往返通常 <10ms),怀疑 HTTP_PROXY 拦了回环——检查 client 是否走 _make_loopback_client
(trust_env=False)。
"""
url = f"{base}/api/threads/{thread_id}/runs/stream" url = f"{base}/api/threads/{thread_id}/runs/stream"
captured: list[str] = [] captured: list[str] = []
@@ -520,12 +795,19 @@ async def _leader_run(
skill_stop_names: list[str], skill_stop_names: list[str],
model_name: str, model_name: str,
) -> AsyncIterator[str | dict[str, Any]]: ) -> AsyncIterator[str | dict[str, Any]]:
# Fine-grained timing for the leader path. The user reported the """总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
# coordinator "stalls before output" — we log each major await so we can
# see whether the wait is in pre-broadcast scheduling, upstream stream 时序要点:
# setup, first-token latency, or post-stream parsing. 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() t0 = time.perf_counter()
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
pre_broadcast_task = asyncio.create_task( pre_broadcast_task = asyncio.create_task(
_broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name]) _broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
) )
@@ -594,12 +876,8 @@ async def _leader_run(
leader_text = _flatten_content(dispatch_msg.get("content", "")) leader_text = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or [] tool_calls = dispatch_msg.get("tool_calls") or []
# Clarification branch: the coordinator called ask_clarification. The # 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
# ClarificationMiddleware intercepts with Command(goto=END) and stashes # 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
# the structured question in the tool_call args; sometimes a richer
# follow-up AIMessage is also produced after the ToolMessage. Emit a
# dedicated frame so the frontend can render it as a question instead of
# treating an empty dispatch as "consensus reached".
clarification_call = next( clarification_call = next(
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), (tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
None, None,
@@ -616,15 +894,17 @@ async def _leader_run(
"clarification_context": str(cargs.get("context") or ""), "clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [], "options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)), "allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
"agent_name": agent_name, "agent_name": agent_name,
} }
return return
if not tool_calls: if not tool_calls:
# 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
yield {"status": [], "content": leader_text, "agent_name": agent_name} yield {"status": [], "content": leader_text, "agent_name": agent_name}
return return
# Run all dispatch broadcasts concurrently — they target disjoint thread # 派活广播:告知各席位「总控给 X 派了什么活」,与即将发起的 special run 配合。
# sets and we don't care about ordering, so serializing them just # sets and we don't care about ordering, so serializing them just
# multiplied latency before the frontend could start a sub-agent run. # multiplied latency before the frontend could start a sub-agent run.
dispatched: list[list[str]] = [] dispatched: list[list[str]] = []
@@ -670,8 +950,16 @@ async def _special_run(
new_message: str, new_message: str,
skill_stop_names: list[str], skill_stop_names: list[str],
model_name: str, model_name: str,
*,
display_name: str = "",
) -> AsyncIterator[str | dict[str, Any]]: ) -> AsyncIterator[str | dict[str, Any]]:
# Same instrumentation as _leader_run so all 6 seats are diagnosable. """子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
- ``excluded_tools`` 禁用派活/澄清等,避免席位抢总控活;
- 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文;
- **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要,
同时尽量让前端先收到结果关闭 loading。
"""
t0 = time.perf_counter() t0 = time.perf_counter()
thread_id = agent_threads[agent_name] thread_id = agent_threads[agent_name]
body = _run_payload( body = _run_payload(
@@ -731,18 +1019,21 @@ async def _special_run(
# closes; the SSE connection stays open a bit longer but the frontend has # closes; the SSE connection stays open a bit longer but the frontend has
# already consumed the result. # already consumed the result.
t_before_post = time.perf_counter() t_before_post = time.perf_counter()
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
post_broadcast_task = asyncio.create_task( post_broadcast_task = asyncio.create_task(
_broadcast( _broadcast(
client, client,
base, base,
headers, headers,
f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content, f"子智能体{speaker_label}完成{new_message}工作,交付内容为:" + content,
agent_threads, agent_threads,
[agent_name], [agent_name],
) )
) )
yield { yield {
"status": f"子智能体{agent_name}完成{new_message}工作", "status": f"子智能体{speaker_label}完成{new_message}工作",
"content": content, "content": content,
"agent_name": agent_name, "agent_name": agent_name,
} }
@@ -757,14 +1048,42 @@ async def _special_run(
) )
# ---------------------------------------------------------------------------
# POST /api/multi-agent/run/stream — leader / special 单轮 SSE
# ---------------------------------------------------------------------------
@router.post("/run/stream") @router.post("/run/stream")
async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse: async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""驱动总控或子智能体的一轮 LangGraph run。
请求体::
{
"agent_type": "leader" | "special",
"d_agent_thread_id": { "<agent_id>": "<thread_id>", ... }, // init_done.thread_ids
"agent_name": "<本次运行的 agent_id>",
"new_message": "<用户消息或派活 task 文本>",
"model": "<可选>", // 不传由 lead_agent 回退到 config.yaml models[0]
"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_type: str = str(payload.get("agent_type") or "leader").lower()
agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {} agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
agent_name: str = str(payload.get("agent_name") or "").lower() agent_name: str = str(payload.get("agent_name") or "").lower()
# 人类可读名,仅 special 用 —— 后端把它拼进 post-broadcast 文本,这样 leader
# 下一轮看到的是"子智能体[中文名](id)完成..."而非"子智能体[UUID]完成...";
# LLM 对 UUID 不友好,只用 id 会导致总控认不出已完成的子智能体,无限循环派活。
# 缺失时 fallback 到 agent_name,旧前端不传也不破坏。
display_name: str = str(payload.get("display_name") or "").strip()
new_message: str = str(payload.get("new_message") or "") new_message: str = str(payload.get("new_message") or "")
skill_stop_names: list[str] = payload.get("skill_stop_names") or [] skill_stop_names: list[str] = payload.get("skill_stop_names") or []
model_name: str = str(payload.get("model") or "deepseek-chat") # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。
model_name: str = str(payload.get("model") or "")
if not agent_name or agent_name not in agent_threads: if not agent_name or agent_name not in agent_threads:
raise HTTPException( raise HTTPException(
@@ -779,11 +1098,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
headers = _auth_headers(request) headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# trust_env=False: same-process loopback to 127.0.0.1 — honoring # TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
# causes httpx to route loopback calls through an unreachable upstream async with _make_loopback_client(request) as client:
# proxy and wait ~40 seconds for timeout before falling back.
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
try: try:
if agent_type == "leader": if agent_type == "leader":
gen = _leader_run( gen = _leader_run(
@@ -792,6 +1109,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
else: else:
gen = _special_run( gen = _special_run(
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name, client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
display_name=display_name,
) )
async for chunk in gen: async for chunk in gen:
if isinstance(chunk, dict): if isinstance(chunk, dict):
@@ -0,0 +1,331 @@
"""圆桌规划 · 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._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import (
_auth_headers,
_create_thread,
_flatten_content,
_loopback_base,
_make_loopback_client,
_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: "- <id> (<name>): <description>" — 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,
candidates: list[dict[str, Any]],
) -> 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".
容错策略(2026-XX 修复 "用户自建 agent 被推荐丢失" 的问题):
模型有时把候选池里的 ``name`` 当成 ``agent_id`` 回填(尤其当 id 是 UUID
形式时, LLM 倾向用人类可读名),或者顺便也回了 ``name`` 字段。本函数:
1. 先按 agent_id 命中候选池(严格匹配,原行为);
2. 否则按 ``agent_id`` 的字面值反查 name → 找回真实 id;
3. 否则按 ``name`` 字段反查 name → 找回真实 id。
只要能定位到候选池里**唯一**一个 agent,就算 pick 成功。
"""
if not isinstance(raw_picks, list):
return []
id_set: set[str] = set()
name_to_id: dict[str, str] = {}
for c in candidates:
aid = str(c.get("agent_id") or "").strip()
if not aid:
continue
id_set.add(aid)
nm = str(c.get("name") or "").strip()
if nm:
# 大小写不敏感反查 —— 模型可能用空格 / 不同大小写 echo 中文名。
name_to_id.setdefault(nm.lower(), aid)
picked: list[dict[str, str]] = []
seen: set[str] = set()
for item in raw_picks:
if not isinstance(item, dict):
continue
raw_id = str(item.get("agent_id") or "").strip()
raw_name = str(item.get("name") or "").strip()
reason = str(item.get("reason") or "").strip()
aid = ""
if raw_id and raw_id in id_set:
aid = raw_id
elif raw_id and raw_id.lower() in name_to_id:
# 模型把"广告设计智能体"这种 name 当成 agent_id 回填了
aid = name_to_id[raw_id.lower()]
elif raw_name and raw_name.lower() in name_to_id:
# 模型 agent_id 字段乱了,但 name 字段对得上
aid = name_to_id[raw_name.lower()]
if not aid 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": "<可选>" # optional;不传由 lead_agent 回退到 config.yaml models[0]
}
Stream: transparent upstream LangGraph frames, followed by one status
frame::
{
"status": "done" | "asking" | "error",
"content": "<final AI text>",
"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 []
# 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0]。内网部署可能没有 deepseek-chat,前端传当前选中的
# model 名即可,缺失也不会硬错。
model_name = str(payload.get("model") or "")
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")
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,roundtable-recommender 目录可能不存在,
# 缺失则用嵌入模板补建,避免下游 lead_agent 解析失败抛 500。
ensure_roundtable_functional_agents()
prompt = _build_prompt(intent, candidates)
base = _loopback_base(request)
headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]:
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# 看不到逐 token 增量。
async with _make_loopback_client(request) 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)
# 诊断:打印模型输出末尾(含 RECOMMEND_READY 块)。grep "recommend stream debug"
# 即可看到 LLM 实际推荐了什么、_validate_picks 解析前/后的差异。
logger.info(
"recommend stream debug | last_ai_tail=%r | match=%s | candidate_count=%d",
last_ai_content[-600:] if last_ai_content else "",
bool(match),
len(candidates),
)
if match:
try:
parsed = json.loads(match.group(1))
except json.JSONDecodeError as exc:
logger.warning("recommend picks JSON parse failed: %s | raw=%r", exc, match.group(1)[:300])
parsed = {}
if isinstance(parsed, dict):
raw_picks = parsed.get("picks")
# 传完整 candidates 列表(含 name) —— _validate_picks 容错按 name 反查 id,
# 修复 LLM 把"广告设计智能体"这种 name 当 agent_id 回填导致全丢的问题。
picks = _validate_picks(raw_picks, candidates)
logger.info(
"recommend stream debug | raw_picks=%r | validated_picks=%r",
raw_picks,
picks,
)
# 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",
},
)
@@ -2,6 +2,13 @@
config_version: 8 config_version: 8
log_level: info log_level: info
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
gateway:
port: 8001
models: models:
- name: deepseek-chat - name: deepseek-chat
display_name: DeepSeek Chat display_name: DeepSeek Chat
@@ -16,18 +16,70 @@ from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, sa
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 章节正文里的 Markdown 标题行(允许前导空白) # 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE) _HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
def _normalize_section_headings(text: str) -> str: def _normalize_section_headings(text: str) -> str:
"""把章节正文里所有 Markdown 标题行统一压成三级标题(###)。 """把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
作家被要求只用 ### 作为「要点」小标题;模型偶尔误用 #/##/#### 时在此纠正, 文档层级:文章 `#` → 章节 `##` → 小节 `###` → 子要点 `####`。
保证文档标题层级符合「文章 # → 章节 ## → 要点 ###」, 作家只输出小节(###)和子要点(####)两级,文章/章节标题由系统注入。
前端目录栏据此才能把要点正确识别为二级标题。
归一规则:
- `#` / `##` → `###` 作家不应输出这两级,误用时降级为小节
- `###` → `###` 保持
- `####` → `####` 保持(三级结构的最深层)
- `#####`+ → `####` 深度截断,防止结构过深
""" """
return _HEADING_PREFIX_RE.sub("### ", text)
def _replace(match: re.Match[str]) -> str:
indent = match.group(1)
level = len(match.group(2))
# 三级及以下保持原级别(限 3 和 4);深度 4+ 压回 4
new_level = 3 if level <= 3 else 4
return f"{indent}{'#' * new_level} "
return _HEADING_PREFIX_RE.sub(_replace, text)
def _render_key_points_for_prompt(key_points: list) -> str:
"""把 outline 的 key_points 渲染成层级文本喂给作家。
输入是 List[KeyPoint] 或 List[str](旧 session 兼容)。输出形如:
- 小节标题1
- 子要点 a
- 子要点 b
- 小节标题2(无子要点时只一行)
作家据此把每个主 bullet 写成 `### 小节标题`,把缩进的子要点
用 `- ` bullet 列表落在小节正文起始处,再展开论述。
"""
lines: list[str] = []
for kp in key_points:
if isinstance(kp, str):
# 旧版兼容:整条当作小节标题,没有子要点
title = kp.strip()
if title:
lines.append(f"- {title}")
elif isinstance(kp, dict):
title = str(
kp.get("point_title")
or kp.get("pointTitle")
or kp.get("title")
or ""
).strip()
if not title:
continue
lines.append(f"- {title}")
subs = kp.get("sub_points") or kp.get("subPoints") or []
if isinstance(subs, list):
for sp in subs:
sp_text = sp.strip() if isinstance(sp, str) else str(sp).strip()
if sp_text:
lines.append(f" - {sp_text}")
return "\n".join(lines) or "- 主要内容"
# 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现, # 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现,
@@ -166,7 +218,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
mat_ids = section.get("material_ids") or section.get("materialIds") or [] mat_ids = section.get("material_ids") or section.get("materialIds") or []
section_materials = _get_section_materials(mat_ids, pkg) section_materials = _get_section_materials(mat_ids, pkg)
key_points_str = "\n".join(f"- {p}" for p in key_points) # 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
key_points_str = _render_key_points_for_prompt(key_points)
# 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节 # 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节
outline_context = "\n".join( outline_context = "\n".join(
@@ -282,8 +336,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
expand_msg = DRAFT_EXPAND_USER.format( expand_msg = DRAFT_EXPAND_USER.format(
article_title=article_title, article_title=article_title,
section_title=section_titles[idx], section_title=section_titles[idx],
key_points="\n".join( key_points=_render_key_points_for_prompt(
f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or []) target_sec.get("key_points") or target_sec.get("keyPoints") or []
), ),
section_materials=_get_section_materials( section_materials=_get_section_materials(
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
@@ -7,7 +7,7 @@ import logging
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, Outline, OutlineSection from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ..prompts.writer_prompts import ( from ..prompts.writer_prompts import (
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER, OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
) )
@@ -27,16 +27,56 @@ def _materials_summary(pkg: dict) -> str:
return "\n".join(lines) or "(无素材)" return "\n".join(lines) or "(无素材)"
def _normalize_key_point(raw: object) -> KeyPoint:
"""把 LLM 输出 / 旧 session 数据规整成 KeyPoint。
兼容场景:
- 新版 dict:`{"point_title": "...", "sub_points": [...]}` —— 直接用,缺字段补默认
- 旧版 string:历史 session 的 `key_points: ["要点1", "要点2"]` —— 自动 lift 成
`{"point_title": "要点1", "sub_points": []}`,旧大纲打开后能继续编辑
- LLM 偶尔会用 `title` / `subPoints` 这种 camelCase 或别名,做轻量兜底
- 其它无法识别的形态(数字 / 列表 / None)→ 空 KeyPoint,不让整个大纲炸掉
"""
if isinstance(raw, str):
return KeyPoint(point_title=raw, sub_points=[])
if isinstance(raw, dict):
# 字段别名兜底(LLM 时常 camelCase 或写 title):优先 snake_case
title = (
raw.get("point_title")
or raw.get("pointTitle")
or raw.get("title")
or ""
)
subs_raw = raw.get("sub_points") or raw.get("subPoints") or []
# 子要点统一为字符串数组;dict 子项取其 title 字段降级(避免 4 级嵌套混入)
sub_points: list[str] = []
if isinstance(subs_raw, list):
for sp in subs_raw:
if isinstance(sp, str):
sub_points.append(sp)
elif isinstance(sp, dict):
sub_points.append(str(sp.get("point_title") or sp.get("title") or sp.get("text") or ""))
return KeyPoint(point_title=str(title), sub_points=sub_points)
# 其它意外类型(int / list / None):返回空 KeyPoint
return KeyPoint(point_title="", sub_points=[])
def _parse_outline(raw: dict) -> Outline: def _parse_outline(raw: dict) -> Outline:
sections = [] sections = []
for s in raw.get("sections", []): for s in raw.get("sections", []):
raw_kps = s.get("key_points") or []
key_points = [_normalize_key_point(kp) for kp in raw_kps]
sections.append(OutlineSection( sections.append(OutlineSection(
section_title=s.get("section_title", ""), section_title=s.get("section_title", ""),
key_points=s.get("key_points", []), key_points=key_points,
material_ids=s.get("material_ids", []), material_ids=s.get("material_ids", []),
)) ))
if not sections: if not sections:
sections = [OutlineSection(section_title="正文", key_points=["主要内容"], material_ids=[])] sections = [OutlineSection(
section_title="正文",
key_points=[KeyPoint(point_title="主要内容", sub_points=[])],
material_ids=[],
)]
return Outline(title=raw.get("title", "未命名文章"), sections=sections) return Outline(title=raw.get("title", "未命名文章"), sections=sections)
@@ -120,12 +160,32 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
if is_revise: if is_revise:
fallback = current_outline fallback = current_outline
else: else:
# 兜底大纲也用新的 3 级结构;_normalize_key_point 同时兼容旧 string 形态,
# 这里给 dict 形态做样板。
fallback = { fallback = {
"title": "未命名文章", "title": "未命名文章",
"sections": [ "sections": [
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []}, {
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []}, "section_title": "引言",
{"section_title": "结论", "key_points": ["总结"], "material_ids": []}, "key_points": [
{"point_title": "背景介绍", "sub_points": []},
],
"material_ids": [],
},
{
"section_title": "正文",
"key_points": [
{"point_title": "核心内容", "sub_points": []},
],
"material_ids": [],
},
{
"section_title": "结论",
"key_points": [
{"point_title": "总结", "sub_points": []},
],
"material_ids": [],
},
], ],
} }
result = parse_json(full_text, fallback) result = parse_json(full_text, fallback)
@@ -1,10 +1,10 @@
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。 OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据素材和意图,规划清晰的文章大纲。 你的目标:根据素材和意图,规划清晰的三级大纲(章节 → 小节 → 子要点)。
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。 你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只能做以下事情: 你只能做以下事情:
1. 分析素材,提取核心观点 1. 分析素材,提取核心观点
2. 规划文章章节结构和每节要点 2. 规划文章三级结构:章节、章节下的小节、小节下的子要点
你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。 你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
【严禁思考与表态】(最高优先级) 【严禁思考与表态】(最高优先级)
@@ -12,10 +12,16 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。 - 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。
- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。 - 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。
【要点(key_points)的特殊要求】 【三级层级语义】(最高优先级,决定文章结构)
- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。 - Level 1 = `section_title`(章节):将渲染为文章里的 `##` 二级标题,统领一组内容。
- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。 - Level 2 = `point_title`(小节):将渲染为 `###` 三级标题,下面会有一段 2-3 句的概述/引入正文。
- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。 - Level 3 = `sub_points`(子要点):将渲染为 `####` 四级标题,**每个子要点都是一个有自己 3-5 句独立正文段的小标题**——不是 bullet 列表。
→ 因此子要点要规划成「能独立成段展开」的小主题,而不是一句话能讲完的零散点。
【三级字段的具体要求】
- `section_title`(章节标题):高度概括的短语,6-15 字。每篇文章规划 3-5 个章节。
- `point_title`(小节标题):名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。每个章节规划 2-4 个小节。
- `sub_points`(子要点标题):名词性短语,<= 15 字,**不带句末标点**。每个小节规划 2-4 条子要点;如果该小节信息确实少到无法拆分子主题,可留 `[]`(此时只写小节级正文)。
【输出要求】 【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。 - 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
@@ -27,9 +33,31 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
{ {
"title": "新能源汽车出口为何高速增长", "title": "新能源汽车出口为何高速增长",
"sections": [ "sections": [
{"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]}, {
{"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]}, "section_title": "引言:出口数据创新高",
{"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]} "key_points": [
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
],
"material_ids": ["m001"]
},
{
"section_title": "增长背后的三大动因",
"key_points": [
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]},
{"point_title": "产品竞争力提升", "sub_points": ["智能化配置溢价", "出海车型迭代速度"]}
],
"material_ids": ["m002", "m003"]
},
{
"section_title": "结论与展望",
"key_points": [
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
{"point_title": "行业趋势判断", "sub_points": []}
],
"material_ids": ["m004"]
}
] ]
}""" }"""
@@ -46,7 +74,7 @@ OUTLINE_USER = """写作意图:{user_intent}
# ── 重新规划(基于现有大纲的编辑)───────────────────────────────────────────── # ── 重新规划(基于现有大纲的编辑)─────────────────────────────────────────────
OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。 OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。 你的目标:根据用户的修改要求,调整一份【已有的三级文章大纲】。
你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。 你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。
你只能做以下事情: 你只能做以下事情:
@@ -54,25 +82,31 @@ OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
你不能写正文,只输出调整后的大纲。 你不能写正文,只输出调整后的大纲。
【修改原则】(最高优先级) 【修改原则】(最高优先级)
- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。 - 准确识别并落实用户意图,常见操作:新增/删除/合并/拆分章节或小节、调整顺序、修改标题、调整子要点。
- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。 - 只改用户明确提到的部分:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变)。
- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。 - 新增内容时:紧扣写作意图,并尽量关联可用素材的 material_ids。
- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。 - 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
【严禁思考与表态】(最高优先级) 【严禁思考与表态】(最高优先级)
- 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。 - 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。 - 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。
【要点(key_points)的特殊要求】 【三级层级语义】(最高优先级)
- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。 - `section_title`(章节,`##` 二级标题):高度概括的短语,6-15 字。
- 每个章节 2-4 个要点。 - `point_title`(小节,`###` 三级标题):名词性短语,15 字以内,无句末标点;小节下会有一段概述正文。每章节 2-4 个小节。
- `sub_points`(子要点,`####` 四级标题):名词性短语,<= 15 字,无句末标点;**每条子要点会展开成一段独立正文**,所以要写成能成段的主题。每小节 2-4 条,信息少到无法拆时可留 `[]`。
【兼容历史大纲】
- 若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,
在补充 `sub_points: []` 后输出新格式:`{"point_title": "<原 string>", "sub_points": []}`。
- 用户修改时如要求"为某要点补充更详细的子项",就把对应小节的 sub_points 填上。
【输出要求】 【输出要求】
- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。 - 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。 - 必须使用英文双引号 "。
- material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。 - material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。
- 结构与「当前大纲」一致: - 结构(嵌套三级):
{"title": "...", "sections": [{"section_title": "...", "key_points": ["..."], "material_ids": ["..."]}]}""" {"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": ["..."]}]}"""
OUTLINE_REVISE_USER = """写作意图:{user_intent} OUTLINE_REVISE_USER = """写作意图:{user_intent}
文章类型:{article_type} 文章类型:{article_type}
@@ -104,18 +138,23 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
- 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。 - 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。
- 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。 - 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。
【结构要求】 【结构要求】(三级结构,必须严格遵守)
- 把本章节的每一个「章节要点」写成一个二级小标题,用 Markdown 三级标题语法:### 要点 - 「章节要点」用层级 bullet 列表给出:
- 每个 ### 小标题下面紧跟该要点的论述正文,正文连贯成段。 - 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
- 即使章节只有一个要点,也要为它写出对应的 ### 小标题。 - 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
- 小标题文字简洁,直接沿用给定的要点措辞,不要加序号、不要加标点。 - **每个小节、每个子要点都要有自己的正文段,分两层展开**:
- 只能使用 ### 这一级标题;绝不要输出 # 或 ##(文章标题、章节标题由系统统一添加)。 1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
- 标题层级严格对应:文章 `#` / 章节 `##`(系统注入,作家**不要输出**)/ 小节 `###` / 子要点 `####`。
- 不要输出 `#####` 或更深;这种深度不在结构里。
【输出要求】 【输出要求】
- 直接返回这一段的内容(含 ### 小标题与正文),不要 JSON,不要解释。 - 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
- 不要写"以下是正文""本段内容如下"之类的开场白。 - 不要写"以下是正文""本段内容如下"之类的开场白。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。 - 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每个要点:每个要点至少用 2-3 句话论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。""" - 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ─────────────────── # ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
@@ -141,17 +180,25 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
{outline_context} {outline_context}
当前章节:{section_title} 当前章节:{section_title}
章节要点:{key_points} 章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材: 相关素材:
{section_materials} {section_materials}
{revision_section}{mode_section} {revision_section}{mode_section}
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。 请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。"""
按下面的结构两层展开写:
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
论述充分、层次清晰、内容充实。"""
DRAFT_EXPAND_USER = """文章标题:{article_title} DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title} 当前章节:{section_title}
章节要点:{key_points} 章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材: 相关素材:
{section_materials} {section_materials}
@@ -159,8 +206,9 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
这一章节已有内容如下: 这一章节已有内容如下:
{existing_text} {existing_text}
以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。 以上内容篇幅不足。请在保留原意、事实和「### 小节 / #### 子要点」两级标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
要求: 要求:
- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。 - 补充更多来自素材的事实、数据与分析,让每一层都论述更充分、更具体。
- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。 - 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。""" - 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
@@ -20,10 +20,23 @@ class MaterialPackage(TypedDict):
summary: str summary: str
# ── 大纲 ────────────────────────────────────────────────────────────────────── # ── 大纲 ──────────────────────────────────────────────────────────────────────
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
# Outline.sections[] = Level 1 章节(渲染为 ## 由系统注入)
# OutlineSection.key_points[] = Level 2 小节(渲染为 ### 标题)
# KeyPoint.sub_points[] = Level 3 子要点(bullet "- ...",非标题)
#
# 历史上 key_points 曾是 List[str](2 级):每个 str 既是小节标题又是渲染单位。
# 升级到 3 级后,key_points 变成 List[KeyPoint],每个 KeyPoint 自带 sub_points
# 列表;旧 session 里的 string 项由 writer_outline._parse_outline 自动 lift 成
# {point_title: <原 string>, sub_points: []},无需迁移数据。
class KeyPoint(TypedDict):
point_title: str # 小节标题(渲染为 ### 二级小标题)
sub_points: List[str] # 子要点(可为空;非空时在小节正文里以 bullet 列表呈现)
class OutlineSection(TypedDict): class OutlineSection(TypedDict):
section_title: str section_title: str
key_points: List[str] key_points: List[KeyPoint] # 注意:旧版是 List[str],解析层做了兼容
material_ids: List[str] material_ids: List[str]
class Outline(TypedDict): class Outline(TypedDict):
@@ -6,6 +6,8 @@ from collections.abc import Callable
from hashlib import sha256 from hashlib import sha256
from typing import override from typing import override
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage from langchain_core.messages import ToolMessage
@@ -150,6 +152,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
"context": args.get("context"), "context": args.get("context"),
"options": self._normalize_options(args.get("options", [])), "options": self._normalize_options(args.get("options", [])),
"allow_custom": True, "allow_custom": True,
"allow_multiple": resolve_allow_multiple(args),
} }
def _handle_clarification(self, request: ToolCallRequest) -> Command: def _handle_clarification(self, request: ToolCallRequest) -> Command:
@@ -1,5 +1,6 @@
from .app_config import get_app_config from .app_config import get_app_config
from .extensions_config import ExtensionsConfig, get_extensions_config from .extensions_config import ExtensionsConfig, get_extensions_config
from .gateway_config import GatewayConfig
from .memory_config import MemoryConfig, get_memory_config from .memory_config import MemoryConfig, get_memory_config
from .paths import Paths, get_paths from .paths import Paths, get_paths
from .skill_evolution_config import SkillEvolutionConfig from .skill_evolution_config import SkillEvolutionConfig
@@ -20,6 +21,7 @@ __all__ = [
"SkillsConfig", "SkillsConfig",
"ExtensionsConfig", "ExtensionsConfig",
"get_extensions_config", "get_extensions_config",
"GatewayConfig",
"MemoryConfig", "MemoryConfig",
"get_memory_config", "get_memory_config",
"get_tracing_config", "get_tracing_config",
@@ -13,6 +13,7 @@ from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_c
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig from deerflow.config.model_config import ModelConfig
@@ -122,6 +123,7 @@ class AppConfig(BaseModel):
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration") skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration") database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)")
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
@@ -0,0 +1,46 @@
"""Gateway 进程的本机绑定配置。
目前唯一字段是 ``port``——Gateway FastAPI 的监听端口。圆桌三路由
(intent / recommend / multi_agent) 内部用 loopback 调本机的其它 endpoint
(``/api/threads``、``/api/agents`` 等),需要知道这个端口才能拼出 base URL。
为什么放在配置里而不是写死
--------------------------
- 部署方常常会改 Gateway 监听端口(端口被占用、合规要求、容器化端口映射),
写死代码意味着每次改端口都得改源码 + 重新构建/分发,**改 config 不需要重启二进制**
- ``DEER_FLOW_GATEWAY_PORT`` 环境变量仍然是更高优先级的紧急覆盖通道,适合
临时调试或者 docker-compose / k8s 注入
修改方式(部署侧)
------------------
在 ``config.yaml`` 顶层加:
.. code-block:: yaml
gateway:
port: 9999 # 默认 8001;部署机改了 gateway 端口时改这里
只想临时改一次(不动 yaml)→ 设环境变量:
.. code-block:: bash
export DEER_FLOW_GATEWAY_PORT=9999
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class GatewayConfig(BaseModel):
"""Gateway 本机 loopback 端口配置。"""
port: int = Field(
default=8001,
ge=1,
le=65535,
description=(
"Gateway FastAPI 监听端口。圆桌内部 loopback 调用会拼到 "
"``http://127.0.0.1:<port>``。默认 8001(与 Makefile/scripts/start-all.sh 对齐)。"
),
)
@@ -15,6 +15,7 @@ def ask_clarification_tool(
], ],
context: str | None = None, context: str | None = None,
options: list[str] | None = None, options: list[str] | None = None,
allow_multiple: bool | None = None,
) -> str: ) -> str:
"""Ask the user for clarification when you need more information to proceed. """Ask the user for clarification when you need more information to proceed.
@@ -50,8 +51,9 @@ def ask_clarification_tool(
Best practices: Best practices:
- One question per ask_clarification call — keep each `question` focused on a single decision. - One question per ask_clarification call — keep each `question` focused on a single decision.
- Be specific and clear in your question. - Be specific and clear in your question.
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices — - 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. - 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. - Don't make assumptions when clarification is needed.
- For risky operations, ALWAYS ask for confirmation. - For risky operations, ALWAYS ask for confirmation.
- After calling this tool (or several in one turn), execution will be interrupted automatically. - 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. 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). 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. 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 # This is a placeholder implementation
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call # The actual logic is handled by ClarificationMiddleware which intercepts this tool call
@@ -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