# Conflicts:
#	frontend-web/src/components/workspace/messages/message-list-item.tsx
#	frontend-web/src/components/workspace/messages/message-list.tsx
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
This commit is contained in:
吉鑫升 committed 2026-05-27 19:10:27 +08:00
commit 6fd6d564eb
54 files changed
+9982 -4557

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
]
}
}
+97 -22
View File
@@ -8,7 +8,11 @@
## 0. 速读:你需要先知道的事
- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。
- **三步 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`)。
@@ -44,29 +48,60 @@
```
frontend-web/src/roundtable-planning/
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── pages/
│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行)
│ └── 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
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
│ ├── 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 # 滚动跟随 + 用户上滑暂停跟随
│ ├── 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 对齐
│ ├── 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)
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
└── styles/
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
└── 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
@@ -242,6 +277,16 @@ window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 +
## 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`
@@ -411,7 +456,10 @@ isOrchestrating = false
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
- 阶段 UI(`InternalState.phase`):
- `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- `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
@@ -626,13 +674,27 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
- 前端:`agentConfigs[meta.speakerId]` 一并传入
### 12.6 拆分页面
### 12.6 拆分页面(已完成 · 2026-05)
当前刻意单文件维护三步。若拆分建议:
主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
- 保持 `api/*` 不变
- 保持 `roundtable-planning.css` 主题变量
- `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 文档协同
@@ -648,7 +710,20 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
| 资源 | 路径 |
|------|------|
| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 主页面(组合 + 顶栏 + 侧栏) | `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` |
@@ -0,0 +1,207 @@
# 圆桌 Step 2 沙箱预览 · 开发进度
> 最后更新:2026-05-27
> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口)
> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`)
---
## 1. 目标
在 Step 2「多智能体圆桌研讨」中,当子智能体调用 `write_file` / `str_replace` / `present_files` 时,**行为与主聊天一致**:
| 能力 | 主聊天 | Step 2 目标 |
|------|--------|-------------|
| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL |
| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ |
| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` |
| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 |
| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` |
| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) |
| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP |
| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 |
---
## 2. 已完成
### 2.1 Step 2 布局调整
| 项 | 状态 | 说明 |
|----|------|------|
| 推理模型下拉 | ✅ | 从右侧栏迁至 Step 2 顶栏「多智能体圆桌会商中心」右侧 |
| 意图理解 | ✅ | 移至左侧栏顶部,支持展开/收起;收起时仅显示「目标」 |
| 任务概览 | ✅ | 放在「人工参与」下方 |
| 右侧栏 Step 2/3 隐藏 | ✅ | `RightSidebar` 仅在 `currentStep === 1` 渲染 |
| 左侧栏整体收起 | ✅ | 宽度 56px;展开按钮在顶部;研讨中角色 ring + ping 动画 |
| 沙箱打开 → 左栏收起 | ✅ | `Step2SandboxSidebarSync` 监听 `useArtifacts().open` |
**主要文件**:`RoundtablePlanningPage.tsx`(`step2SidebarExpanded`、`LeftSidebar`、`Step2SandboxSidebarSync`)
### 2.2 沙箱基础设施(Phase 1)
| 项 | 状态 | 说明 |
|----|------|------|
| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` |
| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` |
| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel |
| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 |
### 2.3 沙箱打开逻辑(Phase 2)
| 项 | 状态 | 说明 |
|----|------|------|
| `write_file` 自动打开 | ✅ | `handleArtifactToolPhase` 在 `tool_calling` + 完整 `args.path` 时触发 |
| 步骤卡点击打开 | ✅ | `MessageStepsCard` → `openArtifactFromStep` |
| 虚拟 URL 机制 | ✅ | `write-file:<path>?message_id=...&tool_call_id=...`(对齐主聊天) |
| Stub messages 注入 | ✅ | `seatStubMessages` + `upsertSeatToolCall` → `loadArtifactContentFromToolCall` |
| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect |
| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) |
### 2.4 上游帧兜底注入(Phase 3)
| 项 | 状态 | 说明 |
|----|------|------|
| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 |
| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 |
| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 |
### 2.5 `present_files` 文件卡(Phase 4)
| 项 | 状态 | 说明 |
|----|------|------|
| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` |
| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) |
| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 |
| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip |
| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 |
### 2.6 草稿持久化(Phase 5)
| 项 | 状态 | 说明 |
|----|------|------|
| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 |
| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` |
| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 |
| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 |
**主要文件**:
```
frontend-web/src/pages/WorkspaceRoutes.tsx
frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx
frontend-web/src/roundtable-planning/components/Step2Panel.tsx
frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx
frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
frontend-web/src/roundtable-planning/utils/drafts.ts
```
### 2.7 与主聊天对齐的核心数据流
```
special seat SSE (streamMultiAgent)
├── onPhaseChange("tool_calling", "write_file", args)
│ ↓
│ upsertSeatToolCall(seatThreadId, "write_file", args)
│ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }]
│ ↓
│ buildWriteFileVirtualUrl(path, messageId, toolCallId)
│ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..."
│ ↓
│ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true)
│ → selectArtifact(virtualUrl) + setArtifactsOpen(true)
│
└── onUpstreamFrame(frame) ← 兜底通道
↓
handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱)
Step2SandboxLayout
ThreadContext.thread.messages = seatStubMessages[activeThreadId]
ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId)
↓
useArtifactContent → loadArtifactContentFromToolCall
→ 从 thread.messages 读 tool_call.args.content(不发 HTTP)
```
**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。
---
## 3. 未完成 / 待验证
### 3.1 功能缺口(按优先级)
| 优先级 | 项 | 状态 | 说明 |
|--------|----|------|------|
| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 |
| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 |
| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 |
| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 |
| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 |
### 3.2 已知风险 / 边界
1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。
2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。
3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。
4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。
5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。
---
## 4. 分阶段计划(原始规划 vs 现状)
| 阶段 | 内容 | 状态 |
|------|------|------|
| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 |
| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 |
| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) |
| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 |
| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 |
---
## 5. 测试清单
在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选:
- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天)
- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}`
- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧)
- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件
- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览
- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起**
- [ ] 关闭沙箱(X)后对话区恢复全宽
- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空
- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原)
- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致
**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。
---
## 6. 关键代码索引
| 用途 | 路径 |
|------|------|
| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` |
| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` |
| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` |
| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` |
| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` |
| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` |
| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` |
| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` |
| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` |
---
## 7. 下一步建议(给接续开发者)
1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。
2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。
3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。
4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。
File diff suppressed because it is too large. Load diff
@@ -82,6 +82,7 @@ export function MessageListItem({
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: {
className?: string;
message: Message;
@@ -89,6 +90,7 @@ export function MessageListItem({
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const isHuman = message.type === "human";
const messageMarkdown =
@@ -107,6 +109,7 @@ export function MessageListItem({
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/>
{!isLoading && (
<MessageToolbar
@@ -168,6 +171,7 @@ function MessageContent_({
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: {
className?: string;
message: Message;
@@ -175,8 +179,13 @@ function MessageContent_({
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
@@ -184,13 +193,13 @@ function MessageContent_({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href);
return (
@@ -291,21 +300,21 @@ function MessageContent_({
);
}
return (
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
return (
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
</AIElementMessageContent>
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
@@ -9,7 +9,7 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
@@ -20,14 +20,14 @@ import {
hasReasoning,
hasToolCalls,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
@@ -55,6 +55,7 @@ export function MessageList({
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit,
onSetupAgentApprove,
onSetupAgentReject,
@@ -64,22 +65,26 @@ export function MessageList({
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
@@ -197,29 +202,30 @@ export function MessageList({
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const displaySources =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai"
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
/>
);
});
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const displaySources =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai"
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/>
);
});
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
@@ -454,33 +460,33 @@ type ClarificationSelection = {
custom: string;
};
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: string;
skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
function getSetupAgentApprovalPayload(
message: unknown,
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: string;
skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -17,6 +17,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -54,6 +55,7 @@ export function WorkspaceNavChatList({
});
};
return (
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarMenu className="gap-1">
<SidebarMenuItem>
@@ -163,5 +165,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{!showCollapseTrigger && <WorkspaceNavStudio />}
</>
);
}
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, Loader2, Plus } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
const NAV_ITEM_CLASS =
"text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
const SIDEBAR_PREVIEW_COUNT = 5;
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
setCreating(true);
setError("");
try {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
await createSearchSpace(trimmed, token);
setDialogOpen(false);
setName("");
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
if (isError || (!isLoading && notebooks.length === 0)) {
return null;
}
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
return (
<>
<Collapsible open={open} onOpenChange={setOpen}>
<SidebarGroup className="px-2 pt-2">
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="flex cursor-pointer select-none items-center justify-between px-1 text-xs font-medium text-sidebar-foreground/50 hover:text-sidebar-foreground/80 transition-colors">
<span>工作室</span>
<ChevronDown
className={cn(
"size-3.5 transition-transform duration-200",
open ? "rotate-0" : "-rotate-90",
)}
/>
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenu className="mt-0.5 gap-0.5">
{/* 新建笔记本 */}
<SidebarMenuItem>
<SidebarMenuButton
className={cn(NAV_ITEM_CLASS)}
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
>
<Plus className="shrink-0" />
<span>新建笔记本</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{isLoading
? Array.from({ length: 3 }).map((_, i) => (
<SidebarMenuItem key={i}>
<div className="mx-3 my-1 h-5 animate-pulse rounded-md bg-sidebar-accent/40" />
</SidebarMenuItem>
))
: preview.map((nb) => (
<SidebarMenuItem key={nb.id}>
<SidebarMenuButton
asChild
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
className={cn(NAV_ITEM_CLASS)}
>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
title={nb.description || nb.name}
>
<BookOpen className="shrink-0 size-4" />
<span className="truncate">{nb.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{/* 所有笔记本 */}
{!isLoading && notebooks.length > 0 && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/page/workspace/studio/notebooks"}
className={cn(NAV_ITEM_CLASS)}
>
<Link to="/page/workspace/studio/notebooks">
<span className="mr-0.5 text-base leading-none">···</span>
<span>所有笔记本</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+8
View File
@@ -69,6 +69,14 @@ export function getLangGraphBaseURL(isMock?: boolean) {
}
}
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
export function getNotebookApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
}
return "";
}
/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */
export function getNoteApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) {
+49
View File
@@ -3,6 +3,55 @@ import { useMemo } from "react";
import { visit } from "unist-util-visit";
import type { BuildVisitor } from "unist-util-visit";
const CITE_RE = /\[\[cite:(\d+)\]\]/g;
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
return (tree: Root) => {
visit(tree, "text", (node, index, parent) => {
if (!CITE_RE.test(node.value)) return;
CITE_RE.lastIndex = 0;
const newNodes: ElementContent[] = [];
const citeCounter = new Map<number, number>();
let citeIndex = 0;
let last = 0;
let match: RegExpExecArray | null;
while ((match = CITE_RE.exec(node.value)) !== null) {
if (match.index > last) {
newNodes.push({ type: "text", value: node.value.slice(last, match.index) });
}
const docId = Number(match[1]);
if (!citeCounter.has(docId)) citeCounter.set(docId, ++citeIndex);
const num = citeCounter.get(docId)!;
const title = sourceMap.get(docId) ?? `来源 ${docId}`;
newNodes.push({
type: "element",
tagName: "sup",
properties: {
title,
"data-chunk-id": String(docId),
style:
"display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;border-radius:9999px;background:hsl(var(--primary));color:hsl(var(--primary-foreground));font-size:10px;font-weight:700;margin-left:1px;cursor:pointer;vertical-align:super;",
},
children: [{ type: "text", value: String(num) }],
});
last = match.index + match[0].length;
}
if (last < node.value.length) {
newNodes.push({ type: "text", value: node.value.slice(last) });
}
if (parent && index !== undefined && newNodes.length > 0) {
(parent.children as ElementContent[]).splice(index, 1, ...newNodes);
return index + newNodes.length;
}
});
};
}
const CJK_TEXT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
+94
View File
@@ -0,0 +1,94 @@
import { getNotebookApiBaseURL } from "@/core/config";
import type { SourceItem, StudioNotebook } from "./types";
function base(): string {
return getNotebookApiBaseURL();
}
export async function fetchStudioToken(email: string): Promise<string> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
);
if (!res.ok) throw new Error(`获取 Token 失败:${res.status}`);
const data = (await res.json()) as { token?: string } | string;
const token = typeof data === "string" ? data : data?.token;
if (!token) throw new Error("接口未返回有效 token");
return token;
}
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`);
return res.json() as Promise<StudioNotebook[]>;
}
export async function createSearchSpace(
name: string,
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, description: "", citations_enabled: true }),
});
if (!res.ok) throw new Error(`创建失败:${res.status}`);
}
export interface ChunkRead {
id: number;
content: string;
document_id: number;
created_at: string;
}
export interface DocumentWithChunks {
id: number;
title: string;
document_type: string;
file_type: string;
chunks: ChunkRead[];
}
export async function fetchDocumentByChunk(
chunkId: number,
token: string,
): Promise<DocumentWithChunks> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`获取文档失败:${res.status}`);
return res.json() as Promise<DocumentWithChunks>;
}
export async function fetchNotebookSources(
notebookId: number,
token: string,
): Promise<SourceItem[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) throw new Error(`获取来源失败:${res.status}`);
return res.json() as Promise<SourceItem[]>;
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useStream } from "@langchain/langgraph-sdk/react";
import { getStoredAuth } from "@/core/auth";
import { getAPIClient } from "@/core/api";
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api";
import type { AgentThreadState } from "@/core/threads/types";
import type { SourceItem, StudioNotebook } from "./types";
export function useStudioNotebooks(): {
notebooks: StudioNotebook[];
isLoading: boolean;
isError: boolean;
} {
const email = getStoredAuth()?.email ?? "";
const tokenQuery = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const notebooksQuery = useQuery({
queryKey: ["studio", "notebooks", tokenQuery.data],
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
enabled: Boolean(tokenQuery.data),
staleTime: 60 * 1000,
retry: 1,
});
return {
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
isError: tokenQuery.isError || notebooksQuery.isError,
};
}
export function useNotebookThreads(notebookId: number | null) {
const client = getAPIClient();
return useQuery({
queryKey: ["notebook-threads", notebookId],
queryFn: () =>
client.threads.search({
metadata: { thread_type: "notebook", note_id: notebookId },
limit: 20,
sortBy: "updated_at",
sortOrder: "desc",
}),
enabled: notebookId !== null,
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
});
}
export function useNotebookChat(
notebookId: number,
sources: SourceItem[],
modelName?: string,
initialThreadId?: string,
) {
const [threadId, setThreadId] = useState<string | undefined>(initialThreadId);
const email = getStoredAuth()?.email ?? "";
const { data: token } = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const thread = useStream<AgentThreadState>({
client: getAPIClient(),
assistantId: "lead_agent",
threadId,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
setThreadId(meta.thread_id);
void getAPIClient()
.threads.update(meta.thread_id, {
metadata: { thread_type: "notebook", note_id: notebookId },
})
.catch(() => ({}));
},
});
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
const sourceIds =
selectedSourceIds.length > 0
? selectedSourceIds
: sources.map((s) => s.id).filter(Boolean);
await thread.submit(
{ messages: [{ type: "human", content: text }] },
{
threadId,
streamSubgraphs: true,
config: {
configurable: {
notebook_search_space_id: notebookId,
notebook_source_ids: sourceIds,
notebook_token: token,
...(modelName ? { model_name: modelName } : {}),
},
},
},
);
};
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
}
export function useNotebookSources(notebookId: number | null): {
sources: SourceItem[];
isLoading: boolean;
isError: boolean;
} {
const email = getStoredAuth()?.email ?? "";
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求
const tokenQuery = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const sourcesQuery = useQuery({
queryKey: ["studio", "sources", notebookId],
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!),
enabled: notebookId !== null && Boolean(tokenQuery.data),
staleTime: 60 * 1000,
retry: 1,
});
return {
sources: sourcesQuery.data ?? [],
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading,
isError: tokenQuery.isError || sourcesQuery.isError,
};
}
+36
View File
@@ -0,0 +1,36 @@
export interface StudioTokenResponse {
token: string;
}
export interface DocumentRead {
id: number;
title: string;
document_type: string;
file_type: string | null;
created_at: string | null;
}
export interface SourceItem {
id: number;
search_space_id: number;
source_type: string;
title: string | null;
status: string | null;
created_at: string;
document: DocumentRead | null;
}
export interface StudioNotebook {
id: number;
name: string;
description: string;
user_id: string;
owner_email: string;
member_count: number;
source_count: number;
is_shared: boolean;
is_owner: boolean;
membership_role: string;
created_at: string;
avatar_url: string | null;
}
+3 -1
View File
@@ -694,7 +694,9 @@ export function useThreads(
offset += response.length;
}
return threads;
return threads.filter(
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
);
},
refetchOnWindowFocus: false,
});
+2
View File
@@ -6,6 +6,7 @@ function readEnv(
| "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_NOTE_API_BASE_URL"
| "VITE_NOTEBOOK_API_BASE_URL"
| "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue {
@@ -20,6 +21,7 @@ export const env = {
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
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';
interface OutlineSectionProps {
@@ -10,7 +11,7 @@ interface OutlineSectionProps {
onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void;
readOnly?: boolean;
// Drag-and-drop
// Drag-and-drop (章节级别)
isDragging?: boolean;
isOver?: boolean;
onDragStart?: () => void;
@@ -19,6 +20,14 @@ interface OutlineSectionProps {
onDragEnd?: () => void;
}
/**
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
* subPoints[] 为 bullet 列表(Level 3)。
*
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
* 编辑过的旧 session 大纲会被持久化为新格式)。
*/
export function OutlineSection({
section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
@@ -27,6 +36,13 @@ export function OutlineSection({
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
// 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
// 都生成新引用导致子组件无谓重渲染。
const keyPoints: KeyPoint[] = useMemo(
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
[section.keyPoints],
);
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
@@ -37,20 +53,23 @@ export function OutlineSection({
}
}
function updateKeyPoint(i: number, value: string) {
const next = [...section.keyPoints];
next[i] = value;
function updateKeyPoint(i: number, updated: KeyPoint) {
const next = [...keyPoints];
next[i] = updated;
onChange({ ...section, keyPoints: next });
}
function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
}
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 (
<div
draggable={!readOnly}
@@ -104,7 +123,9 @@ export function OutlineSection({
</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 && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
@@ -112,24 +133,151 @@ export function OutlineSection({
)}
</div>
{/* 要点列表 */}
{/* 小节列表 (Level 2) */}
<CollapseTransition open={expanded}>
<div className="px-8 pb-2.5 space-y-1 border-t">
{section.keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5">
<span className="text-muted-foreground/40 shrink-0">•</span>
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
{keyPoints.map((kp, i) => (
<KeyPointRow
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 ? (
<span className="flex-1 text-muted-foreground">{kp}</span>
<span className="flex-1 text-muted-foreground">{sp}</span>
) : (
<input
value={kp}
onChange={(e) => updateKeyPoint(i, e.target.value)}
placeholder="输入要点…"
value={sp}
onChange={(e) => updateSubPoint(i, e.target.value)}
placeholder="子要点标题(会展开成一段正文)"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!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" />
</button>
)}
@@ -138,11 +286,11 @@ export function OutlineSection({
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
onClick={addSubPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
>
<Plus className="size-3" />
添加要点
添加子要点
</button>
)}
</div>
@@ -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 {
sectionTitle: string;
keyPoints: string[];
// 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。
// 新生成的大纲一律 KeyPoint[]。
keyPoints: Array<KeyPoint | string>;
materialIds: string[];
}
@@ -25,6 +40,32 @@ export interface AIOutline {
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: [] };
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
/** 严格模式下素材不足、待用户决策的章节 */
@@ -0,0 +1,494 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
AlignLeft,
ArrowLeft,
CloudUpload,
Files,
Globe,
Loader2,
Plus,
Send,
X,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
import { getStoredAuth } from "@/core/auth";
import { useModels } from "@/core/models/hooks";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MessageList } from "@/components/workspace/messages/message-list";
import { rehypeCitations } from "@/core/rehype";
// ── 来源卡片图标 ────────────────────────────────────────
function SourceIcon({ item }: { item: SourceItem }) {
const fileType = item.document?.file_type?.toLowerCase();
if (item.source_type === "FILE" && fileType === "pdf") {
return (
<span className="flex size-8 items-center justify-center rounded-lg bg-red-600 text-[10px] font-bold text-white">
PDF
</span>
);
}
if (item.source_type === "KNOWLEDGE_CONNECTOR") return <Globe className="size-6 text-muted-foreground" />;
if (item.source_type === "NOTE") return <AlignLeft className="size-6 text-muted-foreground" />;
return <Files className="size-6 text-muted-foreground" />;
}
// ── 单个来源卡片 ────────────────────────────────────────
function SourceCard({
item,
selected,
onToggle,
}: {
item: SourceItem;
selected: boolean;
onToggle: (id: number) => void;
}) {
const title = item.title ?? item.document?.title ?? "未命名";
const isProcessing = item.status === "processing";
return (
<div
title={title}
onClick={() => onToggle(item.id)}
className={`group relative flex h-32 w-full cursor-pointer flex-col justify-between rounded-2xl p-4 ring-1 transition-colors ${
selected
? "bg-primary/10 ring-primary"
: "bg-muted ring-border/50 hover:bg-muted/70"
}`}
>
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
{isProcessing ? (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
) : (
<SourceIcon item={item} />
)}
</div>
<p className="truncate text-xs font-medium text-foreground">{title}</p>
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[200px] -translate-x-1/2 rounded-lg bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md ring-1 ring-border group-hover:block">
{title}
</div>
</div>
);
}
// ── 来源骨架屏 ──────────────────────────────────────────
function SourceCardSkeleton() {
return (
<div className="flex h-32 w-full flex-col justify-between rounded-2xl bg-muted p-4">
<div className="size-10 animate-pulse rounded-xl bg-muted-foreground/20" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted-foreground/20" />
</div>
);
}
// ── 来源 Dialog ─────────────────────────────────────────
function SourcesDialog({
open,
onClose,
notebookName,
sources,
isLoadingSources,
selectedSourceIds,
onToggle,
}: {
open: boolean;
onClose: () => void;
notebookName: string;
sources: SourceItem[];
isLoadingSources: boolean;
selectedSourceIds: number[];
onToggle: (id: number) => void;
}) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent
showCloseButton={false}
className="flex h-[75vh] w-[900px] max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[900px]"
>
<DialogHeader className="flex flex-row items-start justify-between border-b border-border px-6 py-5">
<div>
<DialogTitle className="text-base font-semibold text-foreground">来源</DialogTitle>
<p className="mt-0.5 text-sm text-muted-foreground">
{notebookName} · 选中的来源将用于 AI 回答(不选默认全部)
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</DialogHeader>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
{[
{ icon: Plus, label: "上传文件" },
{ icon: CloudUpload, label: "从云端硬盘添加" },
{ icon: Globe, label: "添加网站" },
{ icon: AlignLeft, label: "复制的文字" },
].map(({ icon: Icon, label }) => (
<button
key={label}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Icon className="size-4 shrink-0" />
<span className="text-left leading-tight">{label}</span>
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-4 gap-3">
{isLoadingSources
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
: sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
/>
))}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── 主页面 ──────────────────────────────────────────────
export default function StudioNotebookDetailPage() {
const { notebookId } = useParams<{ notebookId: string }>();
const { notebooks, isLoading } = useStudioNotebooks();
const { models } = useModels();
const queryClient = useQueryClient();
const [sourcesOpen, setSourcesOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [input, setInput] = useState("");
// null = 显示历史线程列表;string = 显示该线程的聊天
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
const [citationLoading, setCitationLoading] = useState(false);
useEffect(() => {
if (!selectedModel && models.length > 0) {
setSelectedModel(models[0]!.name);
}
}, [models, selectedModel]);
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
const citationRehypePlugins = useMemo(() => {
const mapped = sources.map((s) => ({
id: s.id,
title: s.title ?? (s.document as { title?: string } | undefined)?.title ?? String(s.id),
}));
return [() => rehypeCitations(mapped)] as ReturnType<typeof import("@/core/rehype").useRehypeSplitWordsIntoSpans>;
}, [sources]);
const { data: notebookThreads, refetch: refetchThreads } = useNotebookThreads(notebook?.id ?? null);
const { thread, threadId: chatThreadId, isStreaming, send, setThreadId } = useNotebookChat(
notebook?.id ?? 0,
sources,
selectedModel || undefined,
activeThreadId ?? undefined,
);
const handleToggleSource = (id: number) => {
setSelectedSourceIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleSend = async (e: React.SyntheticEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
// 发送后切换到聊天视图(activeThreadId 由 onCreated 回调设置)
await send(text, selectedSourceIds);
};
const handleBackToList = () => {
setActiveThreadId(null);
setThreadId(undefined);
void refetchThreads();
};
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 bg-background px-8">
<div className="size-16 animate-pulse rounded-2xl bg-muted" />
<div className="h-8 w-64 animate-pulse rounded-lg bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
);
}
if (!notebook) {
return (
<div className="flex h-full flex-col items-center justify-center bg-background">
<p className="text-muted-foreground">笔记本不存在</p>
</div>
);
}
const hasMessages = (thread.messages ?? []).some(
(m) => m.type === "human" || m.type === "ai",
);
// 聊天视图:有 activeThreadId 或已有消息
const isChatView = activeThreadId !== null || hasMessages;
return (
<div className="relative flex h-full flex-col overflow-hidden bg-background">
{/* 背景渐变(列表视图时显示) */}
{!isChatView && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 60%, hsl(var(--primary) / 0.12) 0%, transparent 70%)",
}}
/>
)}
{/* 聊天视图:顶部栏 */}
{isChatView && (
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
<div className="flex items-center gap-3">
<button
onClick={handleBackToList}
className="rounded-lg p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</button>
<div className="flex size-8 items-center justify-center rounded-xl bg-muted">
{notebook.avatar_url ? (
<img src={notebook.avatar_url} alt="" className="size-5 rounded-lg object-contain" />
) : (
<Files className="size-4 text-muted-foreground" />
)}
</div>
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
</div>
<button
onClick={() => setSourcesOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
>
<Files className="size-3" />
<span>{notebook.source_count} 个来源</span>
</button>
</div>
)}
{/* 列表视图:笔记本标题 + 历史线程列表 */}
{!isChatView && (
<div className="relative z-10 flex flex-1 flex-col items-center px-8 py-10 overflow-y-auto">
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted text-4xl">
{notebook.avatar_url ? (
<img src={notebook.avatar_url} alt="" className="size-10 rounded-xl object-contain" />
) : (
<Files className="size-8 text-muted-foreground" />
)}
</div>
<h1 className="mt-4 max-w-xl text-2xl font-semibold leading-snug text-foreground text-center">
{notebook.name}
</h1>
<button
onClick={() => setSourcesOpen(true)}
className="mt-3 flex cursor-pointer items-center gap-2 rounded-full bg-muted px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted/70"
>
<Files className="size-3.5" />
<span>{notebook.source_count} 个来源</span>
</button>
{/* 历史线程列表 */}
{notebookThreads && notebookThreads.length > 0 && (
<div className="mt-8 w-full max-w-lg flex flex-col gap-2">
<p className="mb-1 text-xs font-medium text-muted-foreground">历史对话</p>
{notebookThreads.map((t) => {
const values = t.values as Record<string, unknown> | undefined;
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
const firstHuman = msgs?.find((m) => m.type === "human");
const rawContent = firstHuman?.content;
const preview =
typeof rawContent === "string"
? rawContent.slice(0, 60)
: Array.isArray(rawContent)
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
: "对话";
const title = (values?.title as string | undefined) || preview || "对话";
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={t.thread_id}
onClick={() => {
setActiveThreadId(t.thread_id);
setThreadId(t.thread_id);
}}
className="flex items-center justify-between rounded-xl bg-muted px-4 py-3 text-left transition-colors hover:bg-muted/70"
>
<span className="truncate text-sm font-medium text-foreground">{title}</span>
<span className="ml-4 shrink-0 text-xs text-muted-foreground">{time}</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* 聊天视图:消息列表 */}
{isChatView && (
<div
className="min-h-0 flex-1 overflow-hidden"
onClick={(e) => {
const sup = (e.target as HTMLElement).closest<HTMLElement>("[data-chunk-id]");
if (!sup) return;
const chunkId = Number(sup.dataset.chunkId);
if (!chunkId) return;
setCitationChunkId(chunkId);
setCitationDoc(null);
setCitationLoading(true);
const email = getStoredAuth()?.email ?? "";
const tok = queryClient.getQueryData<string>(["studio", "token", email]) ?? "";
void fetchDocumentByChunk(chunkId, tok)
.then((doc) => setCitationDoc(doc))
.finally(() => setCitationLoading(false));
}}
>
<MessageList
threadId={chatThreadId ?? ""}
thread={thread}
paddingBottom={120}
extraRehypePlugins={citationRehypePlugins}
/>
</div>
)}
{/* 输入框区域 */}
<div className={`shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "pb-12"}`}>
<form
onSubmit={(e) => { void handleSend(e); }}
className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border"
>
<button
type="button"
onClick={() => setSourcesOpen(true)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" />
</button>
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-lg border-0 bg-muted px-2 text-xs text-muted-foreground shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问 AI..."
disabled={isStreaming}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={!input.trim() || isStreaming}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-30"
>
{isStreaming ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</button>
</form>
</div>
{/* 引用来源弹窗 */}
<Dialog open={citationChunkId !== null} onOpenChange={(v) => { if (!v) setCitationChunkId(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-base">引用来源</DialogTitle>
</DialogHeader>
{citationLoading && (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{!citationLoading && citationDoc && (
<div className="flex flex-col gap-4 pt-1">
<div className="flex items-start gap-3">
<span className="mt-0.5 shrink-0 rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground uppercase">
{citationDoc.file_type || citationDoc.document_type}
</span>
<span className="text-sm font-medium text-foreground leading-snug">{citationDoc.title}</span>
</div>
{citationDoc.chunks.find((c) => c.id === citationChunkId) && (
<div className="max-h-60 overflow-y-auto rounded-xl bg-muted p-4 text-sm text-foreground leading-relaxed">
{citationDoc.chunks.find((c) => c.id === citationChunkId)!.content}
</div>
)}
</div>
)}
{!citationLoading && !citationDoc && (
<p className="py-4 text-sm text-muted-foreground">加载失败,请重试。</p>
)}
</DialogContent>
</Dialog>
{/* 来源 Dialog */}
<SourcesDialog
open={sourcesOpen}
onClose={() => setSourcesOpen(false)}
notebookName={notebook.name}
sources={sources}
isLoadingSources={isLoadingSources}
selectedSourceIds={selectedSourceIds}
onToggle={handleToggleSource}
/>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Files, Loader2, Plus } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
import type { StudioNotebook } from "@/core/studio/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
function NotebookCardSkeleton() {
return (
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4">
<div className="size-10 animate-pulse rounded-xl bg-muted" />
<div className="flex flex-col gap-1.5">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
</div>
</div>
);
}
function NotebookCard({ nb }: { nb: StudioNotebook }) {
return (
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="group flex flex-col gap-3 rounded-2xl bg-card p-4 transition-colors hover:bg-muted ring-1 ring-border"
>
<div className="flex items-start justify-between">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-xl group-hover:bg-background">
{nb.avatar_url ? (
<img src={nb.avatar_url} alt="" className="size-6 rounded object-contain" />
) : (
<Files className="size-5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
{nb.name}
</span>
<span className="text-xs text-muted-foreground">{nb.source_count} 个来源</span>
</div>
</Link>
);
}
export default function StudioNotebooksPage() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
setCreating(true);
setError("");
try {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
await createSearchSpace(trimmed, token);
setDialogOpen(false);
setName("");
// 刷新笔记本列表
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
return (
<div className="min-h-full bg-background p-8">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
<button
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="size-4" />
新建笔记本
</button>
</div>
{isError && (
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
: notebooks.map((nb) => <NotebookCard key={nb.id} nb={nb} />)}
</div>
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+23 -1
View File
@@ -36,6 +36,8 @@ import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -192,6 +194,24 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
<WorkspaceLayout {...layoutProps}>
<StudioNotebooksPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks/:notebookId"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
@@ -200,7 +220,9 @@ export default function WorkspaceRoutes({
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<RoundtablePlanningPage />
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
@@ -155,7 +155,11 @@ export interface StreamIntentRequest {
* "正在输出" so users see what's happening during the inevitable LLM
* first-token wait.
*/
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
onPhaseChange?: (
phase: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => void;
}
export interface StreamIntentResult {
@@ -269,10 +273,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
// provided, since the same `tool_calling` phase may surface a new tool).
let currentPhase: IntentStreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: IntentStreamPhase, toolName?: string) => {
const setPhase = (
next: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => {
if (next === currentPhase && !toolName) return;
currentPhase = next;
req.onPhaseChange?.(next, toolName);
req.onPhaseChange?.(next, toolName, args);
};
const result: StreamIntentResult = {
@@ -364,7 +372,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
? (toolCallChunks[0] as Record<string, unknown>)
: null;
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 (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
@@ -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 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 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
<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">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
@@ -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,203 @@
/**
* 模型生成过程的「步骤卡」 —— 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;
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
onArtifactStepClick?: (step: BubbleStep) => void;
}
/** 工具调用阶段的 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,
onArtifactStepClick,
}: 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"
onArtifactStepClick={onArtifactStepClick}
/>
))}
<ToolStep
step={lastToolStep}
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
onArtifactStepClick={onArtifactStepClick}
/>
</ChainOfThoughtContent>
)}
{/* 底部:streaming 阶段的「正在思考」标签。 */}
{lastReasoning && (
<div className="px-3 pb-2">
<ReasoningStep step={lastReasoning} streaming={streaming === true} />
</div>
)}
</ChainOfThought>
);
}
function ToolStep({
step,
status,
onArtifactStepClick,
}: {
step: BubbleStep;
status: "complete" | "active";
onArtifactStepClick?: (step: BubbleStep) => void;
}) {
const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args);
const [, toolName] = step.key.split(":");
const isArtifactStep =
toolName === "write_file" ||
toolName === "str_replace" ||
toolName === "present_files";
return (
<ChainOfThoughtStep
icon={display.icon}
status={status}
className={isArtifactStep && onArtifactStepClick ? "cursor-pointer" : undefined}
onClick={
isArtifactStep && onArtifactStepClick
? () => onArtifactStepClick(step)
: undefined
}
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>
);
}
@@ -5,8 +5,6 @@ import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
DotIcon,
LightbulbIcon,
Loader2Icon,
RefreshCwIcon,
SparklesIcon,
@@ -21,26 +19,24 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { listAgents, type Agent } from "@/core/agents";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
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 RecommendPick,
type RecommendStreamPhase,
} from "../api/recommend";
import { splitInlineReasoning } from "../lib/reasoning";
/**
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
@@ -445,13 +441,32 @@ export function RecommendAgentsDialog({
</DialogDescription>
</DialogHeader>
{/* Rationale + status block */}
<div className="border rounded-md bg-muted/30 p-3 text-sm">
<RationaleBlock state={state} />
</div>
{/* ============================================================
TOP: 模型生成内容
包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
与主聊天消息气泡内容区视觉对齐。
**可折叠** —— 流式中默认展开方便观察生成过程,完成后自动收起一次,
让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。
============================================================ */}
<CollapsibleModelOutput
isLoading={
state.phase === "loading_agents" || state.phase === "recommending"
}
phase={state.phase}
>
<RecommenderOutput state={state} />
</CollapsibleModelOutput>
{/* Selection area: scrollable, takes remaining height */}
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
{/* ============================================================
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 ?? "未知错误"}
@@ -518,7 +533,8 @@ export function RecommendAgentsDialog({
)}
</>
) : null}
</div>
</div>
</section>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<div className="flex items-center gap-3 text-xs">
@@ -558,28 +574,127 @@ export function RecommendAgentsDialog({
);
}
function RationaleBlock({ state }: { state: InternalState }) {
// 默认展开:推荐流程一开始就让用户能看到生成过程。
// 生成完毕后(isStreaming 翻成 false)自动收起 —— 让 UI 聚焦在 picks 选择上。
// userTouchedRef 标记用户是否手动点过按钮:点过就尊重用户偏好,不再自动收起。
const [expanded, setExpanded] = useState(true);
const userTouchedRef = useRef(false);
// 步骤折叠卡:**始终默认收起**,header 上显示最新一步作为预览。
const [stepsOpen, setStepsOpen] = useState(false);
const isStreaming = state.phase === "recommending";
// 流式期间禁用逐字动画——避免不断 re-tokenize 拖慢渲染;完成后回到普通 markdown。
const rehypePlugins = useRehypeSplitWordsIntoSpans(isStreaming);
/**
* 「模型生成内容」整段的可折叠外壳。
*
* 默认行为:
* - 「正在加载候选」+「正在生成推荐」期间(`isLoading=true`)始终保持展开,
* 让用户看到内部 spinner / 实时生成内容,不会在中途被自动收起。
* - 推荐完成(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
* 把焦点让给下方推荐卡片
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
*
* 收起态 header 上显示:
* - 加载中/生成中 → 旋转 spinner 图标(替代静态 ✨),即便折叠也能看到在加载
* - 完成态 → 静态 ✨ + 状态徽标(已生成 / 已使用兜底 / 推荐失败)
*/
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
// 流式状态切换时,如果用户没有手动调过展开/收起,执行默认行为:
// 流式中 → expanded=true,完成后 → expanded=false。
// 步骤折叠卡不自动跟随 —— 始终默认收起。
function CollapsibleModelOutput({
isLoading,
phase,
children,
}: {
/** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */
isLoading: boolean;
phase: DialogPhase;
children: React.ReactNode;
}) {
const [open, setOpen] = useState<boolean>(true);
const userTouchedRef = useRef<boolean>(false);
const hasAutoClosedRef = useRef<boolean>(false);
// 加载中 → 保持展开(用户没手动动过的前提下)。
// 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。
// 关键区分:idle / loading_agents 早期阶段以前会被 1s 计时器误关——
// 现在只有真正进入「已完成/兜底/失败」三种终态才触发自动收起。
useEffect(() => {
if (!userTouchedRef.current) setExpanded(isStreaming);
}, [isStreaming]);
if (userTouchedRef.current) return;
if (isLoading) {
setOpen(true);
hasAutoClosedRef.current = false;
return;
}
const isCompleted = phase === "ready" || phase === "fallback" || phase === "error";
if (isCompleted && !hasAutoClosedRef.current && open) {
const timer = setTimeout(() => {
setOpen(false);
hasAutoClosedRef.current = true;
}, MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS);
return () => clearTimeout(timer);
}
return undefined;
}, [isLoading, phase, 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"
>
{isLoading ? (
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
) : (
<SparklesIcon className="size-3 text-purple-500 shrink-0" />
)}
<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">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体...
</div>
@@ -588,8 +703,8 @@ function RationaleBlock({ state }: { state: InternalState }) {
if (state.phase === "error") {
return (
<div className="text-xs text-destructive">
推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。
<div className="text-sm text-destructive">
推荐失败,请使用下方的「重新推荐」重试,或取消后再次进入。
</div>
);
}
@@ -598,23 +713,26 @@ function RationaleBlock({ state }: { state: InternalState }) {
state.phase === "recommending" ||
state.phase === "ready" ||
state.phase === "fallback";
if (!showRationaleStage) {
return null;
}
if (!showRationaleStage) return null;
const streamingLabel =
state.phase === "recommending"
? state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐..."
: null;
const isStreaming = state.phase === "recommending";
// 顶部状态行:跟主聊天的「正在思考...」类似,只是文案换成推荐阶段。
const streamingLabel = isStreaming
? state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐..."
: null;
const hasRationale = !!state.rationale;
// 把 `<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-1.5">
<div className="space-y-2">
{streamingLabel && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />
@@ -622,95 +740,23 @@ function RationaleBlock({ state }: { state: InternalState }) {
</div>
)}
{/* 推荐流程阶段时间线(视觉与 chats/new 消息列表的 ChainOfThought 对齐):
**始终默认收起**,收起 header 上显示最新一步作为预览。
streaming 用 LightbulbIcon,其它阶段用 DotIcon —— 推荐流程没有 tool_calls,
所以不需要 chats 那套完整的工具图标分支。 */}
{state.steps.length > 0 && (() => {
const lastStep = state.steps[state.steps.length - 1];
const LastIcon = lastStep?.key === "streaming" ? LightbulbIcon : DotIcon;
return (
<ChainOfThought
className="rounded-md border border-border/50 bg-muted/30 px-3 py-2"
open={stepsOpen}
onOpenChange={setStepsOpen}
>
<ChainOfThoughtHeader
className="text-[11px]"
icon={
// 仅「生成中 + 收起」时用最新一步的图标作进度预览;完成后用默认脑子图标。
!stepsOpen && isStreaming ? (
<LastIcon className="size-3.5 text-muted-foreground" />
) : undefined
}
>
{stepsOpen
? "步骤"
: isStreaming
? (lastStep?.label ?? `查看 ${state.steps.length} 个步骤`)
: "步骤列表"}
</ChainOfThoughtHeader>
<ChainOfThoughtContent>
{state.steps.map((s, i) => (
<ChainOfThoughtStep
key={s.id}
icon={s.key === "streaming" ? LightbulbIcon : DotIcon}
label={
<span className="text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">{s.label}</span>
<span className="ml-2 text-[10px] font-mono opacity-60">{s.ts}</span>
</span>
}
status={
isStreaming && i === state.steps.length - 1 ? "active" : "complete"
}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
})()}
{state.steps.length > 0 && (
<MessageStepsCard steps={state.steps} streaming={isStreaming} />
)}
{!hasRationale && isStreaming && (
{hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
)}
{!hasRationale && !hasReasoning && isStreaming && (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
{!hasRationale && !isStreaming && (
<div className="text-xs text-muted-foreground">推荐完成。</div>
)}
{hasRationale && (
<>
{/* 按钮在上、内容在下 —— 展开/收起按钮始终留在原位,避免点开时
内容把按钮挤到屏幕外,用户需要滚回去才能再点收起。
收起时完全不渲染内容区,只保留按钮(图标 + "思考过程")。
streamingLabel 已经在上方独立显示阶段进度,这里不重复 spinner。 */}
<button
type="button"
onClick={() => {
userTouchedRef.current = true;
setExpanded((v) => !v);
}}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{expanded ? (
<ChevronUpIcon className="size-3" />
) : (
<ChevronDownIcon className="size-3" />
)}
思考过程
</button>
{expanded && (
<div className="max-h-96 overflow-y-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 text-xs text-foreground leading-relaxed">
<MarkdownContent
content={state.rationale}
isLoading={isStreaming}
rehypePlugins={rehypePlugins}
className="text-xs"
/>
</div>
)}
</>
<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>
);
@@ -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,554 @@
/**
* 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,
FileIcon,
HelpCircle,
Loader2,
Pause,
Play,
Sparkles,
} from "lucide-react";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
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;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
activeArtifactThreadId: string | null;
seatStubMessages: Record<string, unknown[]>;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
openArtifactPreview: (threadId: string, path: string) => void;
// ─── 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,
setSelectedModel,
activeArtifactThreadId,
seatStubMessages,
openArtifactFromStep,
openArtifactPreview,
setIsRoundTableRunning,
setClarificationDraft,
setCustomAgentInput,
setAgentConfigs,
setSelectedAgentId,
onSubmitClarification,
onInjectToRoundtable,
onSavePersona,
onProceedToStep3,
onTriggerToast,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
const isSandboxStreaming =
isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
const chatBody = (
<>
{/* 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 shrink-0">
<select
id="step2-global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型"
}
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{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>
{isRoundTableRunning ? (
<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>
) : (
<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>
)}
</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}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
/>
))}
</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>
</>
);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
return (
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isSandboxStreaming}
seatMessages={activeSeatMessages}
>
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
</Step2SandboxLayout>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
* - 流式无内容的 loading 占位
* - markdown 正文 + 流式 cursor
*
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
*/
interface Step2MessageProps {
msg: Step2Dialogue;
onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
onFileCardClick?: (path: string) => void;
}
function Step2Message({
msg,
onArtifactStepClick,
onFileCardClick,
}: 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;
const presentedFiles = collectPresentedFiles(msg.steps);
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}
onArtifactStepClick={onArtifactStepClick}
/>
)}
{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>
{presentedFiles.length > 0 && (
<PresentFilesRow
files={presentedFiles}
onClick={onFileCardClick}
disabled={!msg.agentThreadId}
/>
)}
</MessageBubble>
);
}
/**
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
*/
function collectPresentedFiles(
steps: Step2Dialogue["steps"],
): string[] {
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const step of steps) {
if (step.key !== "tool_calling:present_files") continue;
const fps = step.args?.filepaths;
if (!Array.isArray(fps)) continue;
for (const fp of fps) {
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
seen.add(fp);
out.push(fp);
}
}
return out;
}
function PresentFilesRow({
files,
onClick,
disabled,
}: {
files: string[];
onClick?: (path: string) => void;
disabled: boolean;
}) {
return (
<div className="mt-2 flex flex-wrap gap-2">
{files.map((path) => (
<PresentFileCard
key={path}
path={path}
disabled={disabled || !onClick}
onClick={() => onClick?.(path)}
/>
))}
</div>
);
}
const FILE_EXT_LABELS: Record<string, string> = {
md: "Markdown",
json: "JSON",
csv: "CSV",
txt: "TXT",
py: "Python",
js: "JS",
ts: "TS",
tsx: "TSX",
jsx: "JSX",
html: "HTML",
css: "CSS",
xml: "XML",
yaml: "YAML",
yml: "YAML",
pdf: "PDF",
png: "PNG",
jpg: "JPG",
jpeg: "JPEG",
svg: "SVG",
zip: "ZIP",
};
function PresentFileCard({
path,
disabled,
onClick,
}: {
path: string;
disabled: boolean;
onClick: () => void;
}) {
const filename = path.split("/").pop() ?? path;
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="truncate text-[12px] font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="text-[10px] text-slate-400">点击预览</span>
</div>
</button>
);
}
@@ -0,0 +1,163 @@
/**
* Step 2 沙箱分栏 —— 对齐主聊天 `ChatBox` 的 60/40 对话 | 产物预览布局。
*
* 圆桌 Step 2 走自定义 SSE,没有 LangGraph SDK thread;这里用最小 ThreadContext stub
* 供 `ArtifactFileDetail` / `useArtifactContent` 工作(真实 path 走 artifacts API 轮询)。
*/
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FilesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
interface Step2SandboxLayoutProps {
children: React.ReactNode;
/** 当前预览文件所属的席位 thread id。 */
activeThreadId: string | null;
/** 是否有流式 run 进行中 —— 开启 artifacts API 轮询。 */
isStreaming?: boolean;
/**
* 当前 active 席位的 stub ai messages —— 让 useArtifactContent 通过虚拟 URL
* `write-file:...?message_id=...&tool_call_id=...` 直接读 tool_call.args.content,
* 对齐主聊天 chats/:threadId 的实时预览体验。
*/
seatMessages?: unknown[];
}
export function Step2SandboxLayout({
children,
activeThreadId,
isStreaming = false,
seatMessages,
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
} = useArtifacts();
const artifactPanelOpen = artifactsOpen;
useEffect(() => {
if (layoutRef.current) {
layoutRef.current.setLayout(
artifactPanelOpen ? OPEN_MODE : CLOSE_MODE,
);
}
}, [artifactPanelOpen]);
const threadStub = useMemo(() => {
const messages = seatMessages ?? [];
return {
messages,
isLoading: isStreaming,
isThreadLoading: false,
values: {
title: "",
messages,
artifacts,
},
} as unknown as BaseStream<AgentThreadState>;
}, [artifacts, isStreaming, seatMessages]);
const previewThreadId = activeThreadId ?? "";
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
id="roundtable-step2-panels"
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
className="h-full min-h-0 flex-1"
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id="roundtable-step2-separator"
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact && previewThreadId ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="未选择文件"
description="智能体写入文件后将在此预览"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">产出文件</h2>
</header>
<main className="min-h-0 grow">
{previewThreadId ? (
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={previewThreadId}
/>
) : null}
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ThreadContext.Provider>
);
}
@@ -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}
/>
);
}
@@ -36,11 +36,17 @@ export function useAutoScroll<T extends HTMLElement>(
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
const programmaticScrollRef = useRef(false);
// 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。
// 用来在 effect 里区分两种 distance 暴涨的情况:
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
const lastObservedScrollTopRef = useRef(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
let prevScrollTop = el.scrollTop;
lastObservedScrollTopRef.current = prevScrollTop;
const onScroll = () => {
const currentScrollTop = el.scrollTop;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
@@ -50,6 +56,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
programmaticScrollRef.current = false;
prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop;
// 程序滚动后通常落在底部,把 atBottom 维持 true。
if (!atBottomRef.current && isAtBottom) {
atBottomRef.current = true;
@@ -67,12 +74,12 @@ export function useAutoScroll<T extends HTMLElement>(
// 用户自己又滚回底部 → 恢复自动跟随
atBottomRef.current = true;
setAtBottom(true);
} else if (!isAtBottom && atBottomRef.current) {
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
atBottomRef.current = false;
setAtBottom(false);
}
// **不再**因为「scrollTop 没明显减少但 distance > threshold」就退出自动跟随。
// 这种情况只可能由内容突然增长触发(如澄清卡片一帧渲染 200+ px),
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop;
};
el.addEventListener("scroll", onScroll, { passive: true });
// 初次测量
@@ -83,24 +90,34 @@ export function useAutoScroll<T extends HTMLElement>(
}, [ref, threshold]);
// 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。
// 关键:区分「用户向上滚」(scrollTop 变小) 与「内容增长」(scrollTop 不变、
// scrollHeight 变大)。仅前者算作用户离开,后者继续自动跟随 ——
// 否则像澄清卡片这种一帧渲染 200+ px 的场景会被误判成"用户滚走了"。
useEffect(() => {
const el = ref.current;
if (!el) return;
// 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值,
// 说明用户已经离开,绝不自动跟随。
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > threshold) {
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽
const currentScrollTop = el.scrollTop;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
if (userScrolledUp && distanceFromBottom > threshold) {
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
if (atBottomRef.current) {
atBottomRef.current = false;
setAtBottom(false);
}
lastObservedScrollTopRef.current = currentScrollTop;
return;
}
if (!atBottomRef.current) {
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
lastObservedScrollTopRef.current = currentScrollTop;
return;
}
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
programmaticScrollRef.current = true;
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
@@ -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,507 @@
/**
* 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;
}
// 重入保护用 ref 而不是 state:isIntentSending 是 React state,
// 同一 event-loop tick 内闭包还看到旧值。极速双击 / 输入法回车连击
// 会两次都通过 state 检查,结果发两次答案、跑两路 stream,
// 回来两张几乎一样的澄清卡——这正是用户报告的"同样选项又出现一次"。
// intentAborterRef 在 stream 完成 / abort 后会被清空,正好用来标记"在途"。
if (intentAborterRef.current) {
triggerToast("⏳ 智能体正在回复,请稍候");
return;
}
const aborter = new AbortController();
intentAborterRef.current = aborter;
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);
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, 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
@@ -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
@@ -35,6 +35,11 @@ export interface DraftStep2Snapshot {
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
/**
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
* loader. Older drafts won't have this — callers should default to `{}`.
*/
seatStubMessages?: Record<string, unknown[]>;
}
export interface DraftRecord {
+1
View File
@@ -59,3 +59,4 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
# PYTHON_EXTRA_INDEX_URL=
# PYTHON_TRUSTED_HOST=your-python-mirror
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
@@ -42,51 +42,87 @@ INTENT_AGENT_DESCRIPTION = (
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
INTENT_AGENT_SOUL = """# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。
---
## 1. 你的目标
通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用:
把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用:
| 字段 | 含义 |
|------|------|
| `objective` | 一句话任务目标 |
| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) |
| `assumptions` | 你替用户做的合理假设(可空数组) |
| 字段 | 含义 | 是否必填 |
|------|------|----------|
| `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 |
| `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** |
| `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 |
**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。
**核心原则**:
- 用户已经说清楚的,**直接汇总,不要追问**。
- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。
- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。
---
## 2. 工作流(必须遵守)
```
用户描述任务
用户输入
↓
信息够不够?
├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片)
↓
用户回复(可能是「我选择:…」或自由文本)
↓
重复 2–5 轮
↓
信息充分 → 自然语言汇总 + [INTENT_READY] + JSON
↓
停止追问(本轮及之后都不要再 ask_clarification)
能不能用一句话写出 objective?
├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束
└─ 不能(完全模糊、多种理解、关键决策点缺失)
→ 调用 ask_clarification(一次只问一个最关键的点)
↓
用户回复后再判断;最多 2-3 轮就要收敛
```
**关键规则**:
- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。
- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。
- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。
- **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。
- 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。
- **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。
- 一旦输出 `[INTENT_READY]`,本轮结束,不再追加追问或工具调用。
- 你**不亲自回答用户的问题**(那是 Step 2 圆桌的事),只负责把它结构化。
---
## 2.5 提问前检查清单(必读)
准备 `ask_clarification` 前先在心里走完:**任何一条命中"是"就不要发出**:
1. **能不能直接用一句话写 objective?** 能写就别问。哪怕是「赖清德的儿子是谁」这种事实问题,objective 就是「了解赖清德儿子的相关信息」,直接汇总。
2. **这个 `question` 文本和上一轮的 `ask_clarification.question` 字面/语义重叠吗?** 重叠就别再问。
3. **`options` 列表是否和过往某次完全相同(或仅顺序、标点不同)?** 是 → 别问。
4. **用户在过往消息里已经用自然语言或「我选择:X」回答过这个决策点了吗?** 是 → 写进 constraints 直接汇总。
5. **能不能用合理 `assumptions` 兜底,把这条信息写进汇总而不是再多问一轮?** 能就别问。
> 反面教材(❌ 绝对禁止):
> ```
> 用户:赖清德的儿子是谁
> 你:你好!我是任务理解专员……这不在我的职责范围……
> ```
> 正面示范(✅):
> ```
> 用户:赖清德的儿子是谁
> 你:✅ 我理解你想了解赖清德儿子的相关信息。
> [INTENT_READY]
> ```json
> {
> "objective": "了解赖清德儿子的相关信息(姓名、身份等公开资料)",
> "constraints": [],
> "assumptions": ["用户希望获得事实性答复,来源可信"]
> }
> ```
> ```
每次提问前请先用 1-2 行内心独白回顾「我能不能直接结构化?上一轮问了什么?这次还需要问的不同点是?」再决定是否调工具。
---
## 3. 唯一工具:`ask_clarification`
当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。
仅当 §2 工作流判断「确实需要追问」时使用。
### 3.1 参数说明
@@ -102,9 +138,9 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
| 类型 | 何时使用 | 选项 UI |
|------|----------|---------|
| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` |
| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 |
| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) |
| `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 |
| `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 |
| `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** |
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
| `suggestion` | 你有建议,需用户批准 | **单选** |
@@ -115,63 +151,46 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
### 3.4 调用示例
### 3.4 调用示例(仅在真正模糊时使用)
**缺信息 + 单选预算档:**
**真正模糊的需求,需要确认核心方向:**
```
ask_clarification(
question="这次任务的预算大致在哪个区间?",
clarification_type="missing_info",
context="预算会影响后续圆桌的可行方案范围",
options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"]
question="你希望我帮你完成的核心目标是什么?",
clarification_type="ambiguous_requirement",
context="目前的描述还有多种理解,我需要确认你最想达成的方向",
options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"]
)
```
**选部署方式(单选):**
**用户表达的方向矛盾,需要拍板:**
```
ask_clarification(
question="优先采用哪种部署方式?",
question="预算和工期之间如果只能优先一个,你希望优先哪个?",
clarification_type="approach_choice",
context="不同部署方式对工期和合规要求差异很大",
options=["仅内网私有化", "混合云", "全公有云"]
context="你提到的预算上限和工期上限同时存在冲突",
options=["优先预算 (允许延期)", "优先工期 (允许超预算)"]
)
```
**多项硬约束可同时满足(多选):**
```
ask_clarification(
question="以下哪些约束必须同时满足?(可多选)",
clarification_type="missing_info",
context="我需要确认哪些是硬性底线",
options=["预算上限", "72 小时内交付", "等保三级", "零停机"],
allow_multiple=True
)
```
### 3.5 同轮多个独立问题
若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。
若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。
---
## 4. 信息充分性判定
满足 **全部** 条件即可结束澄清:
**只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。**
1. 能写出一句明确的 `objective`(用户希望实现 X)
2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一)
3. 没有会妨碍 Step 2 研讨的明显歧义
若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。
- ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总
- ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总
- ✅ 用户给了目标+约束 → 直接汇总,不要多问
- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛
---
## 5. 结束协议:`[INTENT_READY]`
信息充分时,回复结构必须是:
信息可以收敛时,回复结构必须是:
1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review)
1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review)
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
格式要求(**严格**):
@@ -180,9 +199,9 @@ ask_clarification(
[INTENT_READY]
```json
{
"objective": "用一句话写出用户最终要做的事",
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
"assumptions": ["假设 1", "假设 2"]
"objective": "用一句话写出用户想做/想知道的事",
"constraints": [],
"assumptions": ["…可空"]
}
```
```
@@ -190,7 +209,8 @@ ask_clarification(
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
- 标记后**紧跟** ```json 代码块
- 字段名必须是 `objective` / `constraints` / `assumptions`
- `constraints` 至少 1 条;`assumptions` 可为 `[]`
- `constraints` **可以为空数组** —— 用户没明确给约束就不要编造
- `assumptions` 可以为空数组
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
@@ -198,50 +218,72 @@ ask_clarification(
## 6. 输出风格
- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短)
- 提问要**具体**(❓),汇总要**结构化**(✅ 📋)
- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总
- 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]`
- 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时)
---
## 7. 禁止事项
- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型
- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化)
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
- ❌ 输出具体实施方案、架构图、代码
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致)
- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点
---
## 8. 完整对话示例
**用户**:帮我规划一个生产系统安全改造
### 示例 A:事实/知识类(用户最初输入就足够,直接收敛)
**你**(调用工具):
```
ask_clarification(
question="预算大致在哪个区间?工期有没有硬性上限?",
clarification_type="missing_info",
context="这两项是后续圆桌排期的基础",
options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"]
)
**用户**:赖清德的儿子是谁
**你**(不调用工具,直接收敛):
✅ 我理解你想了解赖清德儿子的相关公开信息。
📋
[INTENT_READY]
```json
{
"objective": "了解赖清德儿子的姓名、身份等公开信息",
"constraints": [],
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
}
```
**用户**:我选择:预算 300–800万 / 6–12个月
---
**你**(若还需确认合规):
```
ask_clarification(
question="是否需要满足特定合规标准?",
clarification_type="missing_info",
options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"]
)
### 示例 B:创作类(无明确约束,直接收敛)
**用户**:帮我写一首关于秋天的诗
**你**(直接收敛):
✅ 我理解你想要一首以秋天为主题的诗作。
📋
[INTENT_READY]
```json
{
"objective": "创作一首以秋天为主题的诗",
"constraints": [],
"assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"]
}
```
**用户**:我选择:等保 2.0
---
**你**(汇总,不再调用工具):
✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。
### 示例 C:规划类(有明显约束,但首条已经充分)
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
**你**(不追问,直接收敛):
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。
📋
@@ -249,10 +291,53 @@ ask_clarification(
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"],
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
---
### 示例 D:首条真正模糊时才追问
**用户**:做点什么
**你**(此时才用 ask_clarification):
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前的描述太抽象,需要确认基本方向",
options=["策划/规划类(项目、活动、方案)", "查询/咨询类(获取某方面的信息)", "创作类(文案、文章、设计建议)", "其它,我手动描述"]
)
```
**用户**:我选择:查询/咨询类(获取某方面的信息)
**你**(继续追问一个最关键的点):
```
ask_clarification(
question="你想了解的具体主题是什么?可以一句话描述。",
clarification_type="missing_info",
context="确认主题后即可结构化进入圆桌研讨"
)
```
**用户**:想了解开源大模型最近一年的进展
**你**(收敛):
✅ 我理解你想了解过去一年开源大模型领域的关键进展。
📋
[INTENT_READY]
```json
{
"objective": "梳理过去一年开源大模型领域的关键进展",
"constraints": [],
"assumptions": ["以中文输出综述", "重点关注开源许可、能力、生态三个维度"]
}
```
"""
@@ -305,8 +390,10 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
## 输出风格
- 思路部分简短:用一句话概括你为什么挑这些(例如"按情报→设计→风险→执行的研讨节奏挑选了 4 位")
- 推荐理由要**具体到任务**,例如"负责检索同类合规改造案例,直接对应本次等保 2.0 的对标需求"
- 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
在广告/产品/营销/科研等任务里完全不适用。
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
- 适度用 emoji:📋 表示开始汇总
## 边界
@@ -317,7 +404,11 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
## 示例
**用户输入**(由后端拼装):
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(职能型候选池)
**用户输入**:
```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
@@ -334,7 +425,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
```
**你的输出**:
按"情报→设计→风险→执行"的研讨节奏,挑选 5 位最贴合本次等保 2.0 改造任务的智能体,并加入成本测算覆盖预算硬约束。📋
本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
[RECOMMEND_READY]
```json
@@ -348,6 +439,40 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
]
}
```
### 示例 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 的职能链。
"""
@@ -93,6 +93,109 @@ _INTENT_READY_PATTERN = re.compile(
)
def _normalize_question(text: str) -> str:
"""归一化澄清问题文本,用于"和过往是否重复"的判断。
LLM 复述时常见模式:
- 完全一样的字符串
- 仅前后空白、标点、emoji 不同(如多/少一个问号)
- 仅大小写差异(中文场景几乎不见,但兼容英文/拼音)
所以归一化时统一:小写 + 去除非字母/数字/中文字符 + 折叠空白。
Levenshtein/语义相似度等模糊匹配先不做,等观察到误判再迭代。
"""
if not isinstance(text, str):
return ""
lowered = text.strip().lower()
# 只保留中英文字符与数字,去掉 ?。!?,。"":/、() 等标点 + emoji + 空白。
return re.sub(r"[^0-9a-z一-鿿]+", "", lowered)
def _normalize_options(raw: object) -> tuple[str, ...]:
"""把 options 拍平成可比较的归一化元组,用于"选项是否和上一轮一致"的判断。"""
if raw is None:
return ()
if isinstance(raw, str):
# 偶有模型把 list 序列化成 JSON 字符串发回来。
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return (_normalize_question(raw),)
if not isinstance(raw, list):
return (_normalize_question(str(raw)),)
out: list[str] = []
for item in raw:
if isinstance(item, dict):
label = item.get("label") or item.get("title") or item.get("name") or item.get("value") or item.get("id") or ""
out.append(_normalize_question(str(label)))
else:
out.append(_normalize_question(str(item)))
# 顺序无关:LLM 偶尔会调换选项顺序,但语义上还是同一组。
return tuple(sorted(o for o in out if o))
def _is_repeated_clarification(
messages: list[dict[str, Any]],
*,
new_question: str,
new_options: object,
new_tool_call_id: str | None = None,
) -> bool:
"""检测本轮的 ask_clarification 是否在过往轮次问过。
判定为重复必须同时满足:
1. 历史里至少有一次 ``ask_clarification`` 的问题文本 / 选项 与本次归一化相同;
2. 用户在那次之后**已经发过消息**(即"已回答过")。
没有这条限制会把"模型在同一 run 内多次输出同一 tool_call_chunks"
误判成重复,导致首次澄清都吞掉。
3. 不是同一个 tool_call(LangGraph 偶有把同一 call 拆成多块返回的情况)。
传回 True → 上层应把 status 从 ``clarification`` 降级为 ``asking``,
避免前端弹同一张卡片(配合 SOUL §2.5 prompt 双层防御)。
"""
norm_new_q = _normalize_question(new_question)
norm_new_opts = _normalize_options(new_options)
if not norm_new_q and not norm_new_opts:
return False
seen_match_index: int | None = None
for idx, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
for tc in msg.get("tool_calls") or []:
if (tc.get("name") or "").lower() != "ask_clarification":
continue
if new_tool_call_id and tc.get("id") == new_tool_call_id:
# 同一个 tool_call 的不同 chunk,不算重复。
continue
args = tc.get("args") or {}
past_q = _normalize_question(str(args.get("question") or ""))
past_opts = _normalize_options(args.get("options"))
# 问题文本和选项任一明确重叠就视为重复:
# - question 相同(options 可能空)
# - options 相同且非空(question 可能被改写)
q_match = bool(past_q) and past_q == norm_new_q
opt_match = bool(past_opts) and past_opts == norm_new_opts
if q_match or opt_match:
seen_match_index = idx
break
if seen_match_index is not None:
break
if seen_match_index is None:
return False
# 校验用户在该次澄清之后是否已经发过 human 消息(= 回答过)。
for msg in messages[seen_match_index + 1:]:
if not isinstance(msg, dict):
continue
msg_type = (msg.get("type") or "").lower()
if msg_type in ("human", "humanmessage", "user"):
return True
return False
@router.post("/init")
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
"""创建任务理解会话用的空 thread。
@@ -264,6 +367,34 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
display_content = follow_up or content or question
# 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt
# 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全
# 重复时,把 status 从 clarification 降级为 asking,前端只渲染
# 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。
if _is_repeated_clarification(
messages,
new_question=question,
new_options=cargs.get("options"),
new_tool_call_id=clarification_call.get("id"),
):
logger.warning(
"intent stream: dropping duplicate ask_clarification "
"(question=%r) — already asked & answered earlier in thread",
question[:80],
)
fallback_msg = (
display_content
or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)"
)
final_frame: dict[str, Any] = {
"status": "asking",
"content": fallback_msg,
"summary": None,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
final_frame: dict[str, Any] = {
"status": "clarification",
"content": display_content,
@@ -764,25 +764,30 @@ def _find_trailing_ai_text(messages: list[dict[str, Any]], *, exclude_text: str
def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Locate the latest AIMessage that carries dispatch tool calls.
"""Locate the latest AIMessage from THIS turn.
The very last message can be a skill-stop ToolMessage injected by
``SkillStopMiddleware`` when it intercepts the dispatch, or a clarification
ToolMessage. Either way, the actionable tool_calls live on the AIMessage
*before* it, so we walk backwards until we hit one with ``tool_calls``.
Returns the latest AIMessage when none of them carry tool_calls so the
caller can still extract its text content.
Trailing ToolMessages (skill_stop / clarification ack injected by
``SkillStopMiddleware`` or ``ClarificationMiddleware``) are skipped because
the actionable tool_calls live on the AIMessage that emitted them. We stop
at the **first AIMessage encountered** — its ``tool_calls`` (or lack
thereof) is the authoritative signal for this turn.
历史踩坑:之前实现是"一直往前翻直到撞到带 tool_calls 的 AI",看似优雅,
实际上让 leader 永远收敛不了:本轮 LLM 不论怎么共识,只要历史上派过活,
上一轮的派活 AI 就会被翻出来再用一次,前端会把同样的 task 又发给子智能体,
陷入死循环。现在停在最新一条 AI 上,把判定交给调用方:`tool_calls=[]` →
共识分支,有 tool_calls → 派活分支。
"""
last_ai: dict[str, Any] = {}
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
if not last_ai:
last_ai = msg
if msg.get("tool_calls"):
return msg
return last_ai
msg_type = (msg.get("type") or "").lower()
if msg_type in ("tool", "toolmessage"):
continue # 跳过末尾 skill_stop / clarification ack
if msg_type in ("ai", "aimessage", "aimessagechunk"):
return msg
break # 撞到 Human/其他类型,本轮没 AI(异常路径,留空让上游报错)
return {}
async def _leader_run(
@@ -112,7 +112,7 @@ def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> s
def _validate_picks(
raw_picks: Any,
candidate_ids: set[str],
candidates: list[dict[str, Any]],
) -> list[dict[str, str]]:
"""Filter the model's picks against the candidate pool and clamp to 2-6.
@@ -121,18 +121,50 @@ def _validate_picks(
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
aid = str(item.get("agent_id") or "").strip()
raw_id = str(item.get("agent_id") or "").strip()
raw_name = str(item.get("name") or "").strip()
reason = str(item.get("reason") or "").strip()
if not aid or aid not in candidate_ids or aid in seen:
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)
@@ -245,14 +277,30 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
# 只扫描「最后一条非空 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", exc)
logger.warning("recommend picks JSON parse failed: %s | raw=%r", exc, match.group(1)[:300])
parsed = {}
if isinstance(parsed, dict):
picks = _validate_picks(parsed.get("picks"), candidate_ids)
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"
@@ -58,16 +58,6 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: minimax-m2.5
display_name: MiniMax M2.5
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.5
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: false
title:
enabled: true
@@ -83,11 +83,16 @@ _SYSTEM_TEMPLATE = """你是一名写作助手。
_USER_TEMPLATE = """用户写作意图:{user_intent}
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
用户在表单中已指定写作配置(以下三项必须原样输出,不要自行修改):
- 文章类型(article_type):{preconfigured_article_type}
- 目标读者(target_audience):{preconfigured_audience}
- 目标字数(word_count_target):{preconfigured_word_count}
请输出以下 JSON,article_type / target_audience / word_count_target 必须与上述一致,只需根据用户意图精炼 refined_intent:
{{{{
"article_type": "{type_options} 之一",
"target_audience": "目标读者描述",
"word_count_target": 800,
"article_type": "{preconfigured_article_type}",
"target_audience": "{preconfigured_audience}",
"word_count_target": {preconfigured_word_count},
"refined_intent": "精炼后的写作意图(一句话)"
}}}}"""
@@ -124,6 +129,44 @@ def _coerce_word_count(raw) -> int | None:
return value if value > 0 else None
def _resolve_intent_fields(
state: AIWritingState,
result: dict,
fallback_result: dict,
valid_keys: list[str],
default_key: str,
) -> tuple[str, str, int, str]:
"""合并 LLM 解析结果与用户表单预填字段;预填字段优先(见 AI_WRITING_BACKEND_DEV_ZH §intent_parser)。"""
fallback_type = fallback_result["article_type"]
if fallback_type not in valid_keys:
fallback_type = default_key
raw_type = result.get("article_type", "")
llm_type = raw_type if raw_type in valid_keys else fallback_type
preconfigured_type = (state.get("article_type") or "").strip()
if preconfigured_type in valid_keys:
final_article_type = preconfigured_type
else:
final_article_type = llm_type
preconfigured_audience = (state.get("target_audience") or "").strip()
if preconfigured_audience:
final_target_audience = preconfigured_audience
else:
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
preconfigured_wc = state.get("word_count_target")
if isinstance(preconfigured_wc, int) and preconfigured_wc > 0:
final_word_count = preconfigured_wc
else:
word_count = _coerce_word_count(result.get("word_count_target"))
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
final_refined_intent = result.get("refined_intent") or state["user_intent"]
return final_article_type, final_target_audience, final_word_count, final_refined_intent
async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
# 从 configurable 读取动态类型列表,未配置时退回默认值
configurable = (config or {}).get("configurable", {})
@@ -137,10 +180,6 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
type_options = _build_type_options(valid_article_types)
system = _SYSTEM_TEMPLATE.format(type_options=type_options)
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
type_options=" | ".join(valid_keys),
)
fallback_type = state.get("article_type", default_key)
if fallback_type not in valid_keys:
@@ -153,6 +192,17 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
"refined_intent": state["user_intent"],
}
preconfigured_wc = fallback_result["word_count_target"]
if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0:
preconfigured_wc = 800
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
preconfigured_article_type=fallback_type,
preconfigured_audience=fallback_result["target_audience"],
preconfigured_word_count=preconfigured_wc,
)
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
@@ -193,13 +243,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
})
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
raw_type = result.get("article_type", "")
final_article_type = raw_type if raw_type in valid_keys else fallback_type
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
word_count = _coerce_word_count(result.get("word_count_target"))
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
final_refined_intent = result.get("refined_intent") or state["user_intent"]
final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
state, result, fallback_result, valid_keys, default_key
)
# 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图
writer({
@@ -214,11 +260,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
updates: dict = {"status": "researching", "progress_events": progress_events}
updates["article_type"] = final_article_type
if result.get("target_audience"):
updates["target_audience"] = final_target_audience
if word_count is not None:
updates["word_count_target"] = final_word_count
if result.get("refined_intent"):
updates["target_audience"] = final_target_audience
updates["word_count_target"] = final_word_count
if final_refined_intent != state["user_intent"]:
updates["user_intent"] = final_refined_intent
return updates
@@ -16,18 +16,70 @@ from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, sa
logger = logging.getLogger(__name__)
# 章节正文里的 Markdown 标题行(允许前导空白)
_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE)
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
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 []
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(
@@ -282,8 +336,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
expand_msg = DRAFT_EXPAND_USER.format(
article_title=article_title,
section_title=section_titles[idx],
key_points="\n".join(
f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or [])
key_points=_render_key_points_for_prompt(
target_sec.get("key_points") or target_sec.get("keyPoints") or []
),
section_materials=_get_section_materials(
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.runnables import RunnableConfig
from ..state import AIWritingState, Outline, OutlineSection
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ..prompts.writer_prompts import (
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 "(无素材)"
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:
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(
section_title=s.get("section_title", ""),
key_points=s.get("key_points", []),
key_points=key_points,
material_ids=s.get("material_ids", []),
))
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)
@@ -120,12 +160,32 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
if is_revise:
fallback = current_outline
else:
# 兜底大纲也用新的 3 级结构;_normalize_key_point 同时兼容旧 string 形态,
# 这里给 dict 形态做样板。
fallback = {
"title": "未命名文章",
"sections": [
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []},
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []},
{"section_title": "结论", "key_points": ["总结"], "material_ids": []},
{
"section_title": "引言",
"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)
@@ -1,10 +1,10 @@
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据素材和意图,规划清晰的文章大纲。
你的目标:根据素材和意图,规划清晰的三级大纲(章节 → 小节 → 子要点)。
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只能做以下事情:
1. 分析素材,提取核心观点
2. 规划文章章节结构和每节要点
2. 规划文章三级结构:章节、章节下的小节、小节下的子要点
你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
【严禁思考与表态】(最高优先级)
@@ -12,10 +12,16 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。
- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。
【要点(key_points)的特殊要求】
- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。
- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。
- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。
【三级层级语义】(最高优先级,决定文章结构)
- Level 1 = `section_title`(章节):将渲染为文章里的 `##` 二级标题,统领一组内容。
- Level 2 = `point_title`(小节):将渲染为 `###` 三级标题,下面会有一段 2-3 句的概述/引入正文。
- 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 代码块。
@@ -27,9 +33,31 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
{
"title": "新能源汽车出口为何高速增长",
"sections": [
{"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]},
{"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]},
{"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]}
{
"section_title": "引言:出口数据创新高",
"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 = """你是一名专业内容创作者(作家)。
你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。
你的目标:根据用户的修改要求,调整一份【已有的三级文章大纲】。
你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。
你只能做以下事情:
@@ -54,25 +82,31 @@ OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
你不能写正文,只输出调整后的大纲。
【修改原则】(最高优先级)
- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。
- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。
- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。
- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
- 准确识别并落实用户意图,常见操作:新增/删除/合并/拆分章节或小节、调整顺序、修改标题、调整子要点。
- 只改用户明确提到的部分:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变)。
- 新增内容时:紧扣写作意图,并尽量关联可用素材的 material_ids。
- 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
【严禁思考与表态】(最高优先级)
- 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。
【要点(key_points)的特殊要求】
- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。
- 每个章节 2-4 个要点。
【三级层级语义】(最高优先级)
- `section_title`(章节,`##` 二级标题):高度概括的短语,6-15 字。
- `point_title`(小节,`###` 三级标题):名词性短语,15 字以内,无句末标点;小节下会有一段概述正文。每章节 2-4 个小节。
- `sub_points`(子要点,`####` 四级标题):名词性短语,<= 15 字,无句末标点;**每条子要点会展开成一段独立正文**,所以要写成能成段的主题。每小节 2-4 条,信息少到无法拆时可留 `[]`。
【兼容历史大纲】
- 若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,
在补充 `sub_points: []` 后输出新格式:`{"point_title": "<原 string>", "sub_points": []}`。
- 用户修改时如要求"为某要点补充更详细的子项",就把对应小节的 sub_points 填上。
【输出要求】
- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- 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}
文章类型:{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} ───────────────────
@@ -141,17 +180,25 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
{outline_context}
当前章节:{section_title}
章节要点:{key_points}
章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材:
{section_materials}
{revision_section}{mode_section}
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。"""
按下面的结构两层展开写:
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
论述充分、层次清晰、内容充实。"""
DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title}
章节要点:{key_points}
章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材:
{section_materials}
@@ -159,8 +206,9 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
这一章节已有内容如下:
{existing_text}
以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
以上内容篇幅不足。请在保留原意、事实和「### 小节 / #### 子要点」两级标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
要求:
- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。
- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。
- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。"""
- 补充更多来自素材的事实、数据与分析,让每一层都论述更充分、更具体。
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
@@ -20,10 +20,23 @@ class MaterialPackage(TypedDict):
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):
section_title: str
key_points: List[str]
key_points: List[KeyPoint] # 注意:旧版是 List[str],解析层做了兼容
material_ids: List[str]
class Outline(TypedDict):
@@ -7,10 +7,10 @@ from langchain_core.runnables import RunnableConfig
from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
@@ -253,16 +253,16 @@ def _build_middlewares(
Returns:
List of middleware instances.
"""
resolved_app_config = app_config or get_app_config()
cfg = _get_runtime_config(config)
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
"""
resolved_app_config = app_config or get_app_config()
cfg = _get_runtime_config(config)
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
# Inject the admin prompt prefix into the model request (kept out of the
# persisted/displayed message so the user never sees it).
middlewares.append(PromptPrefixMiddleware())
if cfg.get("rag_mode_enabled", False):
middlewares.append(RagCitationMiddleware())
# Inject the admin prompt prefix into the model request (kept out of the
# persisted/displayed message so the user never sees it).
middlewares.append(PromptPrefixMiddleware())
if cfg.get("rag_mode_enabled", False):
middlewares.append(RagCitationMiddleware())
# Add summarization middleware if enabled
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
@@ -270,7 +270,7 @@ def _build_middlewares(
middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False)
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
if todo_list_middleware is not None:
@@ -284,9 +284,9 @@ def _build_middlewares(
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
@@ -372,10 +372,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
is_plan_mode = cfg.get("is_plan_mode", False)
subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
is_bootstrap = cfg.get("is_bootstrap", False)
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
writing_artifact_path = None
@@ -464,23 +464,31 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=None,
is_bootstrap=True,
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
),
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
),
state_schema=ThreadState,
)
# Default lead agent (unchanged behavior)
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
default_tools = get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
)
if is_notebook_mode:
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
default_tools = [notebook_search_tool] + default_tools
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
tools=get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
),
tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled,
@@ -491,8 +499,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_artifact_path=writing_artifact_path,
available_skills=(set(agent_config.skills or []) if agent_config else None),
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
),
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids,
),
state_schema=ThreadState,
)
@@ -665,28 +665,28 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
@lru_cache(maxsize=32)
def _get_cached_skills_prompt_section(
skill_signature: tuple[tuple[str, str, str, str], ...],
display_signature: tuple[tuple[str, str, str], ...],
available_skills_key: tuple[str, ...] | None,
container_base_path: str,
skill_evolution_section: str,
) -> str:
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
skills_list = ""
if filtered:
items = []
for name, description, category, location in filtered:
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
display_hint = ""
if display_mode:
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
items.append(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
)
skill_items = "\n".join(items)
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
def _get_cached_skills_prompt_section(
skill_signature: tuple[tuple[str, str, str, str], ...],
display_signature: tuple[tuple[str, str, str], ...],
available_skills_key: tuple[str, ...] | None,
container_base_path: str,
skill_evolution_section: str,
) -> str:
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
skills_list = ""
if filtered:
items = []
for name, description, category, location in filtered:
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
display_hint = ""
if display_mode:
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
items.append(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
)
skill_items = "\n".join(items)
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
# Restricted mode: agent has an explicit skill allowlist.
# We deliberately omit the "Skills are located at: <path>" hint and the
@@ -698,44 +698,44 @@ def _get_cached_skills_prompt_section(
if is_restricted:
scope_note = "\nYou have access ONLY to the skills listed below. Do NOT attempt to read, list, or invoke any skill that is not in this list, even if you remember other skill paths.\n"
else:
scope_note = ""
display_pattern = (
"""
**Skill Result Display Pattern:**
- If a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[[1]]`, the second as `[[2]]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
)
return f"""<skill_system>
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
{scope_note}
**Progressive Loading Pattern:**
1. When a user query matches a skill's use case, immediately call `read_file` on the skill's main file using the path attribute provided in the skill tag below
else:
scope_note = ""
display_pattern = (
"""
**Skill Result Display Pattern:**
- If a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[[1]]`, the second as `[[2]]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
)
return f"""<skill_system>
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
{scope_note}
**Progressive Loading Pattern:**
1. When a user query matches a skill's use case, immediately call `read_file` on the skill's main file using the path attribute provided in the skill tag below
2. Read and understand the skill's workflow and instructions
3. The skill file contains references to external resources under the same folder
4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely
{display_pattern}
{location_hint}{evolution_block}
{skills_list}
</skill_system>"""
def get_skills_prompt_section(
available_skills: set[str] | None = None,
*,
app_config: AppConfig | None = None,
display_enabled: bool = False,
) -> str:
3. The skill file contains references to external resources under the same folder
4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely
{display_pattern}
{location_hint}{evolution_block}
{skills_list}
</skill_system>"""
def get_skills_prompt_section(
available_skills: set[str] | None = None,
*,
app_config: AppConfig | None = None,
display_enabled: bool = False,
) -> str:
"""Generate the skills prompt section with available skills list."""
skills = _get_enabled_skills_for_config(app_config)
@@ -765,31 +765,31 @@ def get_skills_prompt_section(
if available_skills is not None and not any(skill.name in available_skills for skill in skills):
return ""
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
display_signature: tuple[tuple[str, str, str], ...] = ()
if display_enabled:
try:
from deerflow.config.extensions_config import get_extensions_config
extensions = get_extensions_config()
display_signature = tuple(
(
name,
skill_config.display.mode,
skill_config.display.custom_instruction,
)
for name, skill_config in sorted(extensions.skills.items())
if skill_config.display is not None
and skill_config.display.enabled
and skill_config.display.mode != "none"
)
except Exception:
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
if not skill_signature and available_key is not None:
return ""
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
display_signature: tuple[tuple[str, str, str], ...] = ()
if display_enabled:
try:
from deerflow.config.extensions_config import get_extensions_config
extensions = get_extensions_config()
display_signature = tuple(
(
name,
skill_config.display.mode,
skill_config.display.custom_instruction,
)
for name, skill_config in sorted(extensions.skills.items())
if skill_config.display is not None
and skill_config.display.enabled
and skill_config.display.mode != "none"
)
except Exception:
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
if not skill_signature and available_key is not None:
return ""
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
def get_agent_soul(agent_id: str | None) -> str:
@@ -912,10 +912,12 @@ def apply_prompt_template(
writing_artifact_path: str | None = None,
available_skills: set[str] | None = None,
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False,
) -> str:
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False,
is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None,
) -> str:
storage_agent_id = agent_id if agent_id is not None else agent_name
# Get memory context
@@ -943,15 +945,15 @@ def apply_prompt_template(
else ""
)
# Bootstrap section: skill-matching guidance (only in agent creation flow)
# Bootstrap section: skill-matching guidance (only in agent creation flow)
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
# Get skills section
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
# Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
@@ -988,22 +990,22 @@ Writing mode is enabled for this run.
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If the skill has a display adapter, follow that adapter first.
- For `citation`, cite returned items with `[[1]]`, `[[2]]`, etc. Only cite real returned items.
- For `table`, do not hand-write the table. Summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the table should appear. The frontend will render the real tool result as a stable table.
- For `list` or `card`, summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="list" />` or `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the structured result should appear.
- If no display adapter exists, default to citation mode with numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If the skill has a display adapter, follow that adapter first.
- For `citation`, cite returned items with `[[1]]`, `[[2]]`, etc. Only cite real returned items.
- For `table`, do not hand-write the table. Summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the table should appear. The frontend will render the real tool result as a stable table.
- For `list` or `card`, summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="list" />` or `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the structured result should appear.
- If no display adapter exists, default to citation mode with numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
# Format the prompt with dynamic skills and memory
prompt = SYSTEM_PROMPT_TEMPLATE.format(
@@ -1021,4 +1023,46 @@ When a search, knowledge, skill, or MCP tool returns a result list:
acp_section=acp_and_mounts_section,
)
return prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
if is_notebook_mode:
source_ids = notebook_source_ids or []
if source_ids:
source_line = f"当前检索范围:{len(source_ids)} 个来源(IDs: {', '.join(str(i) for i in source_ids)})"
else:
source_line = "当前检索范围:全部来源"
prompt += f"""
<notebook_mode>
## 角色
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
## 可用知识来源
{source_line}
## 强制工作流程(每次用户提问都必须严格执行,无例外)
1. **第一步(必须)**:立即调用 `search_notebook_sources` 工具,`query` 填写用户问题或精炼的检索关键词。
- 无论问题多简单,都必须先检索,不得跳过。
- 若检索结果不足,可再次调用不同 query 补充检索。
2. **第二步**:仅基于检索到的内容组织回答,**不得使用训练知识或推断**。
3. **第三步**:若检索结果中确实无相关内容,明确告知用户"暂未在来源中找到相关信息"。
## 引用格式(强制)
- 每个事实/句子后立即插入 `[[cite:<chunk_id>]]` 标记,chunk_id 来自检索结果中每个文本块的 id 字段。
- 多个来源支持同一句话时连续插入:`[[cite:101]][[cite:102]]`
- 引用标记紧跟被引用内容末尾,不加空格,不单独成行。
## 示例
用户:合同中违约金是多少?
(调用 search_notebook_sources query="违约金",返回 chunk_id=101 的结果)
回答:根据合同第五条,违约金为合同总额的 0.5%[[cite:101]],乙方拒绝收货视为违约[[cite:101]]。
## 绝对禁止
- 未调用 `search_notebook_sources` 就直接回答
- 引用不在检索结果中的 chunk_id
- 省略引用标记
- 使用笔记本来源之外的知识
</notebook_mode>"""
return prompt
@@ -0,0 +1,71 @@
"""笔记本 RAG 检索工具。
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
"""
from __future__ import annotations
import logging
import os
import httpx
from langchain.tools import tool
from langgraph.config import get_config
logger = logging.getLogger(__name__)
_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
def _notebook_api_base() -> str:
return (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
@tool("search_notebook_sources", parse_docstring=True)
def notebook_search_tool(query: str, top_k: int = 10) -> str:
"""Search the notebook's knowledge sources to find relevant content.
Call this tool for EVERY user question. After retrieving results, cite
sources inline using [[cite:<document_id>]] markers immediately after
each claim, e.g. "违约金为0.5%[[cite:101]]".
Args:
query: The user's question or a refined search query.
top_k: Number of top chunks to retrieve (default 10).
"""
base = _notebook_api_base()
if not base:
return "Notebook API base URL not configured (DEER_FLOW_NOTEBOOK_API_BASE)."
cfg = get_config().get("configurable", {})
search_space_id: int | None = cfg.get("notebook_search_space_id")
source_ids: list[int] = cfg.get("notebook_source_ids") or []
token: str = cfg.get("notebook_token") or ""
if not search_space_id:
return "No notebook search space configured."
logger.info("notebook_search_tool query=%r source_ids=%s search_space_id=%s", query, source_ids, search_space_id)
try:
resp = httpx.post(
f"{base}{_SEARCH_PATH}",
headers={"Authorization": f"Bearer {token}"},
json={
"query": query,
"source_ids": [source_ids],
"search_space_id": search_space_id,
"top_k": top_k,
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
content = data.get("content") if isinstance(data, dict) else None
return content if content else "No relevant content found."
except Exception as e:
logger.warning("notebook_search_tool failed: %s", e)
return f"Search failed: {e}"