# 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
+9751 -4326

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. 速读:你需要先知道的事 ## 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`)。 - **不使用 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` 配自家模型即可,前后端均无需改动**。 - **不写死模型名**:与 `/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`)。 - **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
@@ -44,29 +48,60 @@
``` ```
frontend-web/src/roundtable-planning/ frontend-web/src/roundtable-planning/
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport ├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── pages/ ├── pages/
│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行) │ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
├── api/ ├── api/
│ ├── intent.ts # Step 1:initIntent、streamIntent │ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent │ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl │ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/ ├── components/
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer │ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选) │ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单 │ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup)
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览 │ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props)
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮 │ ├── 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/ ├── hooks/
│ └── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随 │ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除
├── lib/ ├── 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/ ├── utils/
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName) │ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
└── styles/ └── 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`): **路由注册**(`src/pages/WorkspaceRoutes.tsx`):
```tsx ```tsx
@@ -242,6 +277,16 @@ window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 +
## 5. 主页面状态(`RoundtablePlanningPage`) ## 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 步骤与门禁 ### 5.1 步骤与门禁
- `currentStep`: `1 | 2 | 3` - `currentStep`: `1 | 2 | 3`
@@ -411,7 +456,10 @@ isOrchestrating = false
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()` - 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
- 阶段 UI(`InternalState.phase`): - 阶段 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」 - 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选 - 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state - 反竞态:内部 `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` 字段 - 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
- 前端:`agentConfigs[meta.speakerId]` 一并传入 - 前端:`agentConfigs[meta.speakerId]` 一并传入
### 12.6 拆分页面 ### 12.6 拆分页面(已完成 · 2026-05)
当前刻意单文件维护三步。若拆分建议: 主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用 - `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步
- 保持 `api/*` 不变 - `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清
- 保持 `roundtable-planning.css` 主题变量 - `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 文档协同 ### 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` | | Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` | | 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.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, threadId,
tokenUsageEnabled = false, tokenUsageEnabled = false,
displaySources = [], displaySources = [],
extraRehypePlugins,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
@@ -89,6 +90,7 @@ export function MessageListItem({
threadId: string; threadId: string;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[]; displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) { }) {
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const messageMarkdown = const messageMarkdown =
@@ -107,6 +109,7 @@ export function MessageListItem({
threadId={threadId} threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources} displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/> />
{!isLoading && ( {!isLoading && (
<MessageToolbar <MessageToolbar
@@ -168,6 +171,7 @@ function MessageContent_({
threadId, threadId,
tokenUsageEnabled = false, tokenUsageEnabled = false,
displaySources = [], displaySources = [],
extraRehypePlugins,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
@@ -175,8 +179,13 @@ function MessageContent_({
threadId: string; threadId: string;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[]; 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 isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId); const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo( const components = useMemo(
@@ -55,6 +55,7 @@ export function MessageList({
thread, thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit, onClarificationSubmit,
onSetupAgentApprove, onSetupAgentApprove,
onSetupAgentReject, onSetupAgentReject,
@@ -64,12 +65,16 @@ export function MessageList({
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
paddingBottom?: number; paddingBottom?: number;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>; onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) { }) {
const { t } = useI18n(); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = thread.messages; const messages = thread.messages;
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
@@ -217,6 +222,7 @@ export function MessageList({
threadId={threadId} threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources} displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/> />
); );
}); });
@@ -17,6 +17,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react"; import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -54,6 +55,7 @@ export function WorkspaceNavChatList({
}); });
}; };
return ( return (
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}> <SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarMenu className="gap-1"> <SidebarMenu className="gap-1">
<SidebarMenuItem> <SidebarMenuItem>
@@ -163,5 +165,7 @@ export function WorkspaceNavChatList({
)} )}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </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 分离)。 */ /** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */
export function getNoteApiBaseURL(): string { export function getNoteApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) { 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 { visit } from "unist-util-visit";
import type { BuildVisitor } 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 = const CJK_TEXT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; /[\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; offset += response.length;
} }
return threads; return threads.filter(
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
);
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
+2
View File
@@ -6,6 +6,7 @@ function readEnv(
| "VITE_LANGGRAPH_BASE_URL" | "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL" | "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_NOTE_API_BASE_URL" | "VITE_NOTE_API_BASE_URL"
| "VITE_NOTEBOOK_API_BASE_URL"
| "VITE_STATIC_WEBSITE_ONLY" | "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN", | "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue { ): EnvValue {
@@ -20,6 +21,7 @@ export const env = {
// routed (e.g. a separate canvas-agent server). Falls back to the main URL. // 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_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_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"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), 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 { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing'; import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition'; import { CollapseTransition } from './CollapseTransition';
interface OutlineSectionProps { interface OutlineSectionProps {
@@ -10,7 +11,7 @@ interface OutlineSectionProps {
onChange: (updated: OutlineSectionType) => void; onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void; onRemove?: () => void;
readOnly?: boolean; readOnly?: boolean;
// Drag-and-drop // Drag-and-drop (章节级别)
isDragging?: boolean; isDragging?: boolean;
isOver?: boolean; isOver?: boolean;
onDragStart?: () => void; onDragStart?: () => void;
@@ -19,6 +20,14 @@ interface OutlineSectionProps {
onDragEnd?: () => void; onDragEnd?: () => void;
} }
/**
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
* subPoints[] 为 bullet 列表(Level 3)。
*
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
* 编辑过的旧 session 大纲会被持久化为新格式)。
*/
export function OutlineSection({ export function OutlineSection({
section, index, onChange, onRemove, readOnly, section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
@@ -27,6 +36,13 @@ export function OutlineSection({
const [editingTitle, setEditingTitle] = useState(false); const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle); const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
// 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
// 都生成新引用导致子组件无谓重渲染。
const keyPoints: KeyPoint[] = useMemo(
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
[section.keyPoints],
);
function commitTitle() { function commitTitle() {
setEditingTitle(false); setEditingTitle(false);
const trimmed = titleDraft.trim(); const trimmed = titleDraft.trim();
@@ -37,20 +53,23 @@ export function OutlineSection({
} }
} }
function updateKeyPoint(i: number, value: string) { function updateKeyPoint(i: number, updated: KeyPoint) {
const next = [...section.keyPoints]; const next = [...keyPoints];
next[i] = value; next[i] = updated;
onChange({ ...section, keyPoints: next }); onChange({ ...section, keyPoints: next });
} }
function removeKeyPoint(i: number) { function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) }); onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
} }
function addKeyPoint() { function addKeyPoint() {
onChange({ ...section, keyPoints: [...section.keyPoints, ''] }); onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
} }
// 子要点总数,显示在章节头给个粗略概览
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
return ( return (
<div <div
draggable={!readOnly} draggable={!readOnly}
@@ -104,7 +123,9 @@ export function OutlineSection({
</span> </span>
)} )}
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span> <span className="text-muted-foreground/50 shrink-0">
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
</span>
{!readOnly && onRemove && ( {!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive"> <button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" /> <X className="size-3" />
@@ -112,24 +133,151 @@ export function OutlineSection({
)} )}
</div> </div>
{/* 要点列表 */} {/* 小节列表 (Level 2) */}
<CollapseTransition open={expanded}> <CollapseTransition open={expanded}>
<div className="px-8 pb-2.5 space-y-1 border-t"> <div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
{section.keyPoints.map((kp, i) => ( {keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5"> <KeyPointRow
<span className="text-muted-foreground/40 shrink-0">•</span> key={i}
keyPoint={kp}
onChange={(updated) => updateKeyPoint(i, updated)}
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
readOnly={readOnly}
/>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加小节
</button>
)}
</div>
</CollapseTransition>
</div>
);
}
// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
interface KeyPointRowProps {
keyPoint: KeyPoint;
onChange: (updated: KeyPoint) => void;
onRemove?: () => void;
readOnly?: boolean;
}
function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
const [expanded, setExpanded] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed !== keyPoint.pointTitle) {
onChange({ ...keyPoint, pointTitle: trimmed });
}
}
function updateSubPoint(i: number, value: string) {
const next = [...keyPoint.subPoints];
next[i] = value;
onChange({ ...keyPoint, subPoints: next });
}
function removeSubPoint(i: number) {
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
}
function addSubPoint() {
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
}
return (
<div className="rounded border border-border/40 bg-muted/10">
{/* 小节头 */}
<div className="flex items-center gap-1.5 px-2 py-1.5">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
aria-label="展开/收起子要点"
>
<ChevronRight
className={cn(
'size-3 text-muted-foreground/60 transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<span className="text-purple-500/70 shrink-0">###</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none"
placeholder="小节标题"
/>
) : (
<span
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
</span>
)}
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
</span>
{!readOnly && onRemove && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
aria-label="删除小节"
>
<X className="size-3" />
</button>
)}
</div>
{/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
{keyPoint.subPoints.map((sp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1">
{/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
{readOnly ? ( {readOnly ? (
<span className="flex-1 text-muted-foreground">{kp}</span> <span className="flex-1 text-muted-foreground">{sp}</span>
) : ( ) : (
<input <input
value={kp} value={sp}
onChange={(e) => updateKeyPoint(i, e.target.value)} onChange={(e) => updateSubPoint(i, e.target.value)}
placeholder="输入要点…" placeholder="子要点标题(会展开成一段正文)"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/> />
)} )}
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive"> <button
type="button"
onClick={() => removeSubPoint(i)}
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
aria-label="删除子要点"
>
<X className="size-3" /> <X className="size-3" />
</button> </button>
)} )}
@@ -138,11 +286,11 @@ export function OutlineSection({
{!readOnly && ( {!readOnly && (
<button <button
type="button" type="button"
onClick={addKeyPoint} onClick={addSubPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5" className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
> >
<Plus className="size-3" /> <Plus className="size-3" />
添加要点 添加子要点
</button> </button>
)} )}
</div> </div>
@@ -13,10 +13,25 @@ export interface AIMaterial {
} }
// ── 大纲 ───────────────────────────────────────────────────────────────────── // ── 大纲 ─────────────────────────────────────────────────────────────────────
// 三级结构:章节(##) → 小节(###) → 子要点(bullet)
// AIOutline.sections[] = Level 1 章节(渲染为 ## 由后端注入)
// OutlineSection.keyPoints[] = Level 2 小节(渲染为 ###)
// KeyPoint.subPoints[] = Level 3 子要点(正文里的 bullet 列表)
//
// 历史兼容:旧版 keyPoints: string[](2 级)。后端 _parse_outline 已经在解析时把
// 旧 string 自动 lift 成 KeyPoint;前端类型只保留新格式,但 UI 渲染层做 type
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
export interface KeyPoint {
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
}
export interface OutlineSection { export interface OutlineSection {
sectionTitle: string; sectionTitle: string;
keyPoints: string[]; // 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。
// 新生成的大纲一律 KeyPoint[]。
keyPoints: Array<KeyPoint | string>;
materialIds: string[]; materialIds: string[];
} }
@@ -25,6 +40,32 @@ export interface AIOutline {
sections: OutlineSection[]; sections: OutlineSection[];
} }
/**
* 把 keyPoints 单项规整成 KeyPoint。兼容三种输入:
* - 新版 KeyPoint object
* - 旧版 string (lift 为 { pointTitle: <str>, subPoints: [] })
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
*/
export function normalizeKeyPoint(raw: unknown): KeyPoint {
if (typeof raw === 'string') {
return { pointTitle: raw, subPoints: [] };
}
if (raw && typeof raw === 'object') {
const r = raw as Record<string, unknown>;
const title =
typeof r.pointTitle === 'string' ? r.pointTitle :
typeof r.point_title === 'string' ? r.point_title :
typeof r.title === 'string' ? r.title :
'';
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
const subPoints: string[] = Array.isArray(subsRaw)
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
: [];
return { pointTitle: title, subPoints };
}
return { pointTitle: '', subPoints: [] };
}
// ── 素材不足求助 ────────────────────────────────────────────────────────────── // ── 素材不足求助 ──────────────────────────────────────────────────────────────
/** 严格模式下素材不足、待用户决策的章节 */ /** 严格模式下素材不足、待用户决策的章节 */
@@ -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 TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage"; import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage"; import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css"; import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -192,6 +194,24 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="studio/notebooks"
element={
<WorkspaceLayout {...layoutProps}>
<StudioNotebooksPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks/:notebookId"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route <Route
path="roundtable" path="roundtable"
element={<Navigate to="/page/workspace/roundtable/planning" replace />} element={<Navigate to="/page/workspace/roundtable/planning" replace />}
@@ -200,7 +220,9 @@ export default function WorkspaceRoutes({
path="roundtable/planning" path="roundtable/planning"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<RoundtablePlanningPage /> <ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
@@ -155,7 +155,11 @@ export interface StreamIntentRequest {
* "正在输出" so users see what's happening during the inevitable LLM * "正在输出" so users see what's happening during the inevitable LLM
* first-token wait. * first-token wait.
*/ */
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void; onPhaseChange?: (
phase: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => void;
} }
export interface StreamIntentResult { export interface StreamIntentResult {
@@ -269,10 +273,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
// provided, since the same `tool_calling` phase may surface a new tool). // provided, since the same `tool_calling` phase may surface a new tool).
let currentPhase: IntentStreamPhase = "connecting"; let currentPhase: IntentStreamPhase = "connecting";
req.onPhaseChange?.("connecting"); req.onPhaseChange?.("connecting");
const setPhase = (next: IntentStreamPhase, toolName?: string) => { const setPhase = (
next: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => {
if (next === currentPhase && !toolName) return; if (next === currentPhase && !toolName) return;
currentPhase = next; currentPhase = next;
req.onPhaseChange?.(next, toolName); req.onPhaseChange?.(next, toolName, args);
}; };
const result: StreamIntentResult = { const result: StreamIntentResult = {
@@ -364,7 +372,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
? (toolCallChunks[0] as Record<string, unknown>) ? (toolCallChunks[0] as Record<string, unknown>)
: null; : null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName); // 把 tool args 一并透传给 UI;onPhaseChange 在 useStep1Intent 里
// 会把 args 累积进 message.steps,让 MessageStepsCard 渲染路径 chip /
// 命令代码块等子节点(与主聊天 MessageGroup 视觉一致)。
const toolArgs =
firstCall && typeof firstCall.args === "object" && firstCall.args !== null
? (firstCall.args as Record<string, unknown>)
: undefined;
setPhase("tool_calling", toolName, toolArgs);
} else if ( } else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
@@ -0,0 +1,93 @@
/**
* 圆桌规划用到的所有 SVG 头像。
*
* 这些原本散落在主页面里,提到这里集中维护,便于 Step 1 / Step 2 / Step 3
* 与 LeftSidebar、HighFidelityReport 复用。`getAvatar(type, size)` 根据
* 字符串类型名(blue / purple / emerald / amber / pink / human / man)挑对应组件。
*/
export const Man1Avatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<span
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-slate-200/80 shadow-sm`}
>
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#E2E8F0" />
<circle cx="50" cy="50" r="45" fill="#FEEDED" />
<path d="M15 95 C 15 70, 30 65, 50 65 C 70 65, 85 70, 85 95 Z" fill="#1E293B" />
<path d="M40 65 L50 78 L60 65 Z" fill="#FFFFFF" />
<path d="M47 72 L53 72 L51 98 L49 98 Z" fill="#E11D48" />
<path d="M40 65 L40 55 C 40 55, 45 58, 50 58 C 55 58, 60 55, 60 55 L60 65 Z" fill="#FBCFE8" />
<circle cx="50" cy="38" r="18" fill="#FBCFE8" />
<path d="M42 34 Q 45 32 47 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
<path d="M53 34 Q 55 32 58 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="45" cy="39" r="2.5" fill="#0F172A" />
<circle cx="55" cy="39" r="2.5" fill="#0F172A" />
<path d="M30 32 C 30 18, 70 18, 70 32 C 72 26, 68 15, 50 15 C 32 15, 28 26, 30 32 Z" fill="#0F172A" />
<path d="M30 32 C30 32, 28 35, 32 35 C34 35, 36 30, 36 30 Z" fill="#0F172A" />
<path d="M70 32 C70 32, 72 35, 68 35 C66 35, 64 30, 64 30 Z" fill="#0F172A" />
</svg>
</span>
);
export const RobotAvatar = ({
bg = "bg-primary/10",
headColor = "#f5222d",
size = "h-8 w-8",
}: {
bg?: string;
headColor?: string;
size?: string;
}) => (
<span
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-red-100/50 shadow-sm ${bg}`}
>
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="28" width="50" height="42" rx="12" fill={headColor} />
<rect x="32" y="35" width="36" height="24" rx="6" fill="#1E293B" />
<circle cx="42" cy="45" r="4" fill="#F74750" className="animate-pulse" />
<circle cx="58" cy="45" r="4" fill="#F74750" className="animate-pulse" />
<path d="M45 52 Q 50 55 55 52" stroke="#F74750" strokeWidth="2.2" strokeLinecap="round" />
<rect x="20" y="41" width="5" height="16" rx="2" fill="#cf1322" />
<rect x="75" y="41" width="5" height="16" rx="2" fill="#cf1322" />
<line x1="50" y1="28" x2="50" y2="15" stroke={headColor} strokeWidth="4" strokeLinecap="round" />
<circle cx="50" cy="12" r="5" fill="#FA9196" />
</svg>
</span>
);
export const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#cf1322" bg="bg-[var(--brand-200)]" size={size} />
);
export const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#7c3aed" bg="bg-purple-100/40" size={size} />
);
export const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#059669" bg="bg-emerald-100/40" size={size} />
);
export const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#d97706" bg="bg-amber-100/40" size={size} />
);
export const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#db2777" bg="bg-pink-100/40" size={size} />
);
/** 根据字符串类型名挑对应头像。 */
export function getAvatar(type: string, size: string = "h-8 w-8") {
switch (type) {
case "man":
case "human":
return <Man1Avatar size={size} />;
case "blue":
return <BlueRobotAvatar size={size} />;
case "purple":
return <PurpleRobotAvatar size={size} />;
case "emerald":
return <EmeraldRobotAvatar size={size} />;
case "amber":
return <AmberRobotAvatar size={size} />;
case "pink":
return <PinkRobotAvatar size={size} />;
default:
return <RobotAvatar size={size} />;
}
}
@@ -368,8 +368,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)} )}
</section> </section>
{/* Section 3: Generated files per seat */} {/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]"> <section className="hidden bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2"> <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" /> <Download className="w-4 h-4 text-primary" />
生成的报告文件 生成的报告文件
@@ -0,0 +1,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, CheckIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
DotIcon,
LightbulbIcon,
Loader2Icon, Loader2Icon,
RefreshCwIcon, RefreshCwIcon,
SparklesIcon, SparklesIcon,
@@ -21,26 +19,24 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; 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 { listAgents, type Agent } from "@/core/agents";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
MarkdownContent,
ReasoningBlock,
StreamingCursor,
} from "./MessageBubble";
import { MessageStepsCard } from "./MessageStepsCard";
import type { IntentSummary } from "../api/intent"; import type { IntentSummary } from "../api/intent";
import { import {
filterRecommendCandidates, filterRecommendCandidates,
parseRecommendStreamContent, parseRecommendStreamContent,
streamRecommend, streamRecommend,
type RecommendCandidate, type RecommendCandidate,
type RecommendPick,
type RecommendStreamPhase, type RecommendStreamPhase,
} from "../api/recommend"; } from "../api/recommend";
import { splitInlineReasoning } from "../lib/reasoning";
/** /**
* Picks-selection dialog that sits between Step 1 (intent) and Step 2 * Picks-selection dialog that sits between Step 1 (intent) and Step 2
@@ -445,13 +441,32 @@ export function RecommendAgentsDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Rationale + status block */} {/* ============================================================
<div className="border rounded-md bg-muted/30 p-3 text-sm"> TOP: 模型生成内容
<RationaleBlock state={state} /> 包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
</div> 与主聊天消息气泡内容区视觉对齐。
**可折叠** —— 流式中默认展开方便观察生成过程,完成后自动收起一次,
让 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" ? ( {state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5"> <div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"} {state.errorMessage ?? "未知错误"}
@@ -518,7 +533,8 @@ export function RecommendAgentsDialog({
)} )}
</> </>
) : null} ) : null}
</div> </div>
</section>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap"> <DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<div className="flex items-center gap-3 text-xs"> <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); * - 「正在加载候选」+「正在生成推荐」期间(`isLoading=true`)始终保持展开,
const userTouchedRef = useRef(false); * 让用户看到内部 spinner / 实时生成内容,不会在中途被自动收起。
// 步骤折叠卡:**始终默认收起**,header 上显示最新一步作为预览。 * - 推荐完成(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
const [stepsOpen, setStepsOpen] = useState(false); * 把焦点让给下方推荐卡片
const isStreaming = state.phase === "recommending"; * - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
// 流式期间禁用逐字动画——避免不断 re-tokenize 拖慢渲染;完成后回到普通 markdown。 *
const rehypePlugins = useRehypeSplitWordsIntoSpans(isStreaming); * 收起态 header 上显示:
* - 加载中/生成中 → 旋转 spinner 图标(替代静态 ✨),即便折叠也能看到在加载
* - 完成态 → 静态 ✨ + 状态徽标(已生成 / 已使用兜底 / 推荐失败)
*/
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
// 流式状态切换时,如果用户没有手动调过展开/收起,执行默认行为: function CollapsibleModelOutput({
// 流式中 → expanded=true,完成后 → expanded=false。 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(() => { useEffect(() => {
if (!userTouchedRef.current) setExpanded(isStreaming); if (userTouchedRef.current) return;
}, [isStreaming]); 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") { if (state.phase === "loading_agents") {
return ( 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" /> <Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体... 正在加载候选智能体...
</div> </div>
@@ -588,8 +703,8 @@ function RationaleBlock({ state }: { state: InternalState }) {
if (state.phase === "error") { if (state.phase === "error") {
return ( return (
<div className="text-xs text-destructive"> <div className="text-sm text-destructive">
推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。 推荐失败,请使用下方的「重新推荐」重试,或取消后再次进入。
</div> </div>
); );
} }
@@ -598,23 +713,26 @@ function RationaleBlock({ state }: { state: InternalState }) {
state.phase === "recommending" || state.phase === "recommending" ||
state.phase === "ready" || state.phase === "ready" ||
state.phase === "fallback"; state.phase === "fallback";
if (!showRationaleStage) { if (!showRationaleStage) return null;
return null;
}
const streamingLabel = const isStreaming = state.phase === "recommending";
state.phase === "recommending" // 顶部状态行:跟主聊天的「正在思考...」类似,只是文案换成推荐阶段。
? state.streamPhase === "connecting" const streamingLabel = isStreaming
? "调度推荐智能体..." ? state.streamPhase === "connecting"
: state.streamPhase === "connected" ? "调度推荐智能体..."
? "已连接,等待响应..." : state.streamPhase === "connected"
: "正在生成推荐..." ? "已连接,等待响应..."
: null; : "正在生成推荐..."
: 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 ( return (
<div className="space-y-1.5"> <div className="space-y-2">
{streamingLabel && ( {streamingLabel && (
<div className="flex items-center gap-2 text-muted-foreground text-xs"> <div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" /> <Loader2Icon className="size-3 animate-spin" />
@@ -622,95 +740,23 @@ function RationaleBlock({ state }: { state: InternalState }) {
</div> </div>
)} )}
{/* 推荐流程阶段时间线(视觉与 chats/new 消息列表的 ChainOfThought 对齐): {state.steps.length > 0 && (
**始终默认收起**,收起 header 上显示最新一步作为预览。 <MessageStepsCard steps={state.steps} streaming={isStreaming} />
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>
);
})()}
{!hasRationale && isStreaming && ( {hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
)}
{!hasRationale && !hasReasoning && isStreaming && (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p> <p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)} )}
{!hasRationale && !isStreaming && (
<div className="text-xs text-muted-foreground">推荐完成。</div>
)}
{hasRationale && ( {hasRationale && (
<> <div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
{/* 按钮在上、内容在下 —— 展开/收起按钮始终留在原位,避免点开时 <MarkdownContent>{cleanedRationale}</MarkdownContent>
内容把按钮挤到屏幕外,用户需要滚回去才能再点收起。 {isStreaming && <StreamingCursor />}
收起时完全不渲染内容区,只保留按钮(图标 + "思考过程")。 </div>
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> </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, // 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。 // 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
const programmaticScrollRef = useRef(false); const programmaticScrollRef = useRef(false);
// 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。
// 用来在 effect 里区分两种 distance 暴涨的情况:
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
const lastObservedScrollTopRef = useRef(0);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
let prevScrollTop = el.scrollTop; let prevScrollTop = el.scrollTop;
lastObservedScrollTopRef.current = prevScrollTop;
const onScroll = () => { const onScroll = () => {
const currentScrollTop = el.scrollTop; const currentScrollTop = el.scrollTop;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
@@ -50,6 +56,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。 // 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
programmaticScrollRef.current = false; programmaticScrollRef.current = false;
prevScrollTop = currentScrollTop; prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop;
// 程序滚动后通常落在底部,把 atBottom 维持 true。 // 程序滚动后通常落在底部,把 atBottom 维持 true。
if (!atBottomRef.current && isAtBottom) { if (!atBottomRef.current && isAtBottom) {
atBottomRef.current = true; atBottomRef.current = true;
@@ -67,12 +74,12 @@ export function useAutoScroll<T extends HTMLElement>(
// 用户自己又滚回底部 → 恢复自动跟随 // 用户自己又滚回底部 → 恢复自动跟随
atBottomRef.current = true; atBottomRef.current = true;
setAtBottom(true); setAtBottom(true);
} else if (!isAtBottom && atBottomRef.current) {
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
atBottomRef.current = false;
setAtBottom(false);
} }
// **不再**因为「scrollTop 没明显减少但 distance > threshold」就退出自动跟随。
// 这种情况只可能由内容突然增长触发(如澄清卡片一帧渲染 200+ px),
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
prevScrollTop = currentScrollTop; prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop;
}; };
el.addEventListener("scroll", onScroll, { passive: true }); el.addEventListener("scroll", onScroll, { passive: true });
// 初次测量 // 初次测量
@@ -83,24 +90,34 @@ export function useAutoScroll<T extends HTMLElement>(
}, [ref, threshold]); }, [ref, threshold]);
// 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。 // 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于 // 关键:区分「用户向上滚」(scrollTop 变小) 与「内容增长」(scrollTop 不变、
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。 // scrollHeight 变大)。仅前者算作用户离开,后者继续自动跟随 ——
// 否则像澄清卡片这种一帧渲染 200+ px 的场景会被误判成"用户滚走了"。
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
// 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值, const currentScrollTop = el.scrollTop;
// 说明用户已经离开,绝不自动跟随。 const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; // 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
if (distanceFromBottom > threshold) { const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽 if (userScrolledUp && distanceFromBottom > threshold) {
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
if (atBottomRef.current) { if (atBottomRef.current) {
atBottomRef.current = false; atBottomRef.current = false;
setAtBottom(false); setAtBottom(false);
} }
lastObservedScrollTopRef.current = currentScrollTop;
return; return;
} }
if (!atBottomRef.current) {
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
lastObservedScrollTopRef.current = currentScrollTop;
return;
}
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
programmaticScrollRef.current = true; programmaticScrollRef.current = true;
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, 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; hasConsensus: boolean;
consensusPercentage: number; consensusPercentage: number;
budgetLimit: 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 { 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_EXTRA_INDEX_URL=
# PYTHON_TRUSTED_HOST=your-python-mirror # PYTHON_TRUSTED_HOST=your-python-mirror
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4 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 = """# 任务理解智能体 INTENT_AGENT_SOUL = """# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。 你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。
--- ---
## 1. 你的目标 ## 1. 你的目标
通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用: 把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用:
| 字段 | 含义 | | 字段 | 含义 | 是否必填 |
|------|------| |------|------|----------|
| `objective` | 一句话任务目标 | | `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 |
| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) | | `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** |
| `assumptions` | 你替用户做的合理假设(可空数组) | | `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 |
**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。 **核心原则**:
- 用户已经说清楚的,**直接汇总,不要追问**。
- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。
- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。
--- ---
## 2. 工作流(必须遵守) ## 2. 工作流(必须遵守)
``` ```
用户描述任务 用户输入
↓ ↓
信息够不够? 能不能用一句话写出 objective?
├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片) ├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束
↓ └─ 不能(完全模糊、多种理解、关键决策点缺失)
用户回复(可能是「我选择:…」或自由文本) → 调用 ask_clarification(一次只问一个最关键的点)
↓ ↓
重复 2–5 轮 用户回复后再判断;最多 2-3 轮就要收敛
↓
信息充分 → 自然语言汇总 + [INTENT_READY] + JSON
↓
停止追问(本轮及之后都不要再 ask_clarification)
``` ```
**关键规则**: **关键规则**:
- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。 - **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。
- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。 - 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。
- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。 - **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。
- 一旦输出 `[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` ## 3. 唯一工具:`ask_clarification`
当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。 仅当 §2 工作流判断「确实需要追问」时使用。
### 3.1 参数说明 ### 3.1 参数说明
@@ -102,9 +138,9 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
| 类型 | 何时使用 | 选项 UI | | 类型 | 何时使用 | 选项 UI |
|------|----------|---------| |------|----------|---------|
| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` | | `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 |
| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 | | `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 |
| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) | | `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** |
| `risk_confirmation` | 需要用户明确确认风险 | **单选** | | `risk_confirmation` | 需要用户明确确认风险 | **单选** |
| `suggestion` | 你有建议,需用户批准 | **单选** | | `suggestion` | 你有建议,需用户批准 | **单选** |
@@ -115,63 +151,46 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。 - **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。 - 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
### 3.4 调用示例 ### 3.4 调用示例(仅在真正模糊时使用)
**缺信息 + 单选预算档:** **真正模糊的需求,需要确认核心方向:**
``` ```
ask_clarification( ask_clarification(
question="这次任务的预算大致在哪个区间?", question="你希望我帮你完成的核心目标是什么?",
clarification_type="missing_info", clarification_type="ambiguous_requirement",
context="预算会影响后续圆桌的可行方案范围", context="目前的描述还有多种理解,我需要确认你最想达成的方向",
options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"] options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"]
) )
``` ```
**选部署方式(单选):** **用户表达的方向矛盾,需要拍板:**
``` ```
ask_clarification( ask_clarification(
question="优先采用哪种部署方式?", question="预算和工期之间如果只能优先一个,你希望优先哪个?",
clarification_type="approach_choice", clarification_type="approach_choice",
context="不同部署方式对工期和合规要求差异很大", context="你提到的预算上限和工期上限同时存在冲突",
options=["仅内网私有化", "混合云", "全公有云"] options=["优先预算 (允许延期)", "优先工期 (允许超预算)"]
) )
``` ```
**多项硬约束可同时满足(多选):**
```
ask_clarification(
question="以下哪些约束必须同时满足?(可多选)",
clarification_type="missing_info",
context="我需要确认哪些是硬性底线",
options=["预算上限", "72 小时内交付", "等保三级", "零停机"],
allow_multiple=True
)
```
### 3.5 同轮多个独立问题
若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。
若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。
--- ---
## 4. 信息充分性判定 ## 4. 信息充分性判定
满足 **全部** 条件即可结束澄清: **只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。**
1. 能写出一句明确的 `objective`(用户希望实现 X) - ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总
2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一) - ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总
3. 没有会妨碍 Step 2 研讨的明显歧义 - ✅ 用户给了目标+约束 → 直接汇总,不要多问
- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛
若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。
--- ---
## 5. 结束协议:`[INTENT_READY]` ## 5. 结束协议:`[INTENT_READY]`
信息充分时,回复结构必须是: 信息可以收敛时,回复结构必须是:
1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review) 1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review)
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」) 2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
格式要求(**严格**): 格式要求(**严格**):
@@ -180,9 +199,9 @@ ask_clarification(
[INTENT_READY] [INTENT_READY]
```json ```json
{ {
"objective": "用一句话写出用户最终要做的事", "objective": "用一句话写出用户想做/想知道的事",
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...], "constraints": [],
"assumptions": ["假设 1", "假设 2"] "assumptions": ["…可空"]
} }
``` ```
``` ```
@@ -190,7 +209,8 @@ ask_clarification(
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符 - `[INTENT_READY]` **单独成行**,前后不要夹其他字符
- 标记后**紧跟** ```json 代码块 - 标记后**紧跟** ```json 代码块
- 字段名必须是 `objective` / `constraints` / `assumptions` - 字段名必须是 `objective` / `constraints` / `assumptions`
- `constraints` 至少 1 条;`assumptions` 可为 `[]` - `constraints` **可以为空数组** —— 用户没明确给约束就不要编造
- `assumptions` 可以为空数组
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。** **输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
@@ -198,50 +218,72 @@ ask_clarification(
## 6. 输出风格 ## 6. 输出风格
- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短) - 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]`
- 提问要**具体**(❓),汇总要**结构化**(✅ 📋) - 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时)
- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总
--- ---
## 7. 禁止事项 ## 7. 禁止事项
- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型
- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化)
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用 - ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
- ❌ 输出具体实施方案、架构图、代码 - ❌ 输出具体实施方案、架构图、代码
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问 - ❌ 在已输出 `[INTENT_READY]` 之后继续提问
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification` - ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致)
- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点
--- ---
## 8. 完整对话示例 ## 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个月 ---
**你**(若还需确认合规): ### 示例 B:创作类(无明确约束,直接收敛)
```
ask_clarification( **用户**:帮我写一首关于秋天的诗
question="是否需要满足特定合规标准?",
clarification_type="missing_info", **你**(直接收敛):
options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"] ✅ 我理解你想要一首以秋天为主题的诗作。
)
📋
[INTENT_READY]
```json
{
"objective": "创作一首以秋天为主题的诗",
"constraints": [],
"assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"]
}
``` ```
**用户**:我选择:等保 2.0 ---
**你**(汇总,不再调用工具): ### 示例 C:规划类(有明显约束,但首条已经充分)
✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
**你**(不追问,直接收敛):
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。
📋 📋
@@ -249,10 +291,53 @@ ask_clarification(
```json ```json
{ {
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求", "objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"], "constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"] "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` 必须**具体**,不要"很合适"、"很强"这类空话 - `reason` 必须**具体**,不要"很合适"、"很强"这类空话
## 输出风格 ## 输出风格
- 思路部分简短:用一句话概括你为什么挑这些(例如"按情报→设计→风险→执行的研讨节奏挑选了 4 位") - 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
- 推荐理由要**具体到任务**,例如"负责检索同类合规改造案例,直接对应本次等保 2.0 的对标需求" 不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
在广告/产品/营销/科研等任务里完全不适用。
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
- 适度用 emoji:📋 表示开始汇总 - 适度用 emoji:📋 表示开始汇总
## 边界 ## 边界
@@ -317,7 +404,11 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
## 示例 ## 示例
**用户输入**(由后端拼装): > 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(职能型候选池)
**用户输入**:
``` ```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求 任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0 约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
@@ -334,7 +425,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
``` ```
**你的输出**: **你的输出**:
按"情报→设计→风险→执行"的研讨节奏,挑选 5 位最贴合本次等保 2.0 改造任务的智能体,并加入成本测算覆盖预算硬约束。📋 本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
[RECOMMEND_READY] [RECOMMEND_READY]
```json ```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") @router.post("/init")
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]: async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
"""创建任务理解会话用的空 thread。 """创建任务理解会话用的空 thread。
@@ -264,6 +367,34 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。 # 比 tool_call 旁的短 preamble 更适合作为气泡正文。
follow_up = _find_trailing_ai_text(messages, exclude_text=content) follow_up = _find_trailing_ai_text(messages, exclude_text=content)
display_content = follow_up or content or question 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] = { final_frame: dict[str, Any] = {
"status": "clarification", "status": "clarification",
"content": display_content, "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]: 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 Trailing ToolMessages (skill_stop / clarification ack injected by
``SkillStopMiddleware`` when it intercepts the dispatch, or a clarification ``SkillStopMiddleware`` or ``ClarificationMiddleware``) are skipped because
ToolMessage. Either way, the actionable tool_calls live on the AIMessage the actionable tool_calls live on the AIMessage that emitted them. We stop
*before* it, so we walk backwards until we hit one with ``tool_calls``. at the **first AIMessage encountered** — its ``tool_calls`` (or lack
Returns the latest AIMessage when none of them carry tool_calls so the thereof) is the authoritative signal for this turn.
caller can still extract its text content.
历史踩坑:之前实现是"一直往前翻直到撞到带 tool_calls 的 AI",看似优雅,
实际上让 leader 永远收敛不了:本轮 LLM 不论怎么共识,只要历史上派过活,
上一轮的派活 AI 就会被翻出来再用一次,前端会把同样的 task 又发给子智能体,
陷入死循环。现在停在最新一条 AI 上,把判定交给调用方:`tool_calls=[]` →
共识分支,有 tool_calls → 派活分支。
""" """
last_ai: dict[str, Any] = {}
for msg in reversed(messages): for msg in reversed(messages):
if not isinstance(msg, dict): if not isinstance(msg, dict):
continue continue
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): msg_type = (msg.get("type") or "").lower()
if not last_ai: if msg_type in ("tool", "toolmessage"):
last_ai = msg continue # 跳过末尾 skill_stop / clarification ack
if msg.get("tool_calls"): if msg_type in ("ai", "aimessage", "aimessagechunk"):
return msg return msg
return last_ai break # 撞到 Human/其他类型,本轮没 AI(异常路径,留空让上游报错)
return {}
async def _leader_run( async def _leader_run(
@@ -112,7 +112,7 @@ def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> s
def _validate_picks( def _validate_picks(
raw_picks: Any, raw_picks: Any,
candidate_ids: set[str], candidates: list[dict[str, Any]],
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
"""Filter the model's picks against the candidate pool and clamp to 2-6. """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 model over-picked. Returns ``[]`` if validation leaves us with fewer than
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to ``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
defaults". 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): if not isinstance(raw_picks, list):
return [] 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]] = [] picked: list[dict[str, str]] = []
seen: set[str] = set() seen: set[str] = set()
for item in raw_picks: for item in raw_picks:
if not isinstance(item, dict): if not isinstance(item, dict):
continue 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() 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 continue
picked.append({"agent_id": aid, "reason": reason}) picked.append({"agent_id": aid, "reason": reason})
seen.add(aid) seen.add(aid)
@@ -245,14 +277,30 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。 # 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
picks: list[dict[str, str]] = [] picks: list[dict[str, str]] = []
match = _RECOMMEND_READY_PATTERN.search(last_ai_content) 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: if match:
try: try:
parsed = json.loads(match.group(1)) parsed = json.loads(match.group(1))
except json.JSONDecodeError as exc: 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 = {} parsed = {}
if isinstance(parsed, dict): 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。 # asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
status_value = "done" if picks else "asking" status_value = "done" if picks else "asking"
@@ -58,16 +58,6 @@ models:
temperature: 1.0 temperature: 1.0
supports_vision: true supports_vision: true
supports_thinking: 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: title:
enabled: true enabled: true
@@ -83,11 +83,16 @@ _SYSTEM_TEMPLATE = """你是一名写作助手。
_USER_TEMPLATE = """用户写作意图:{user_intent} _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} 之一", "article_type": "{preconfigured_article_type}",
"target_audience": "目标读者描述", "target_audience": "{preconfigured_audience}",
"word_count_target": 800, "word_count_target": {preconfigured_word_count},
"refined_intent": "精炼后的写作意图(一句话)" "refined_intent": "精炼后的写作意图(一句话)"
}}}}""" }}}}"""
@@ -124,6 +129,44 @@ def _coerce_word_count(raw) -> int | None:
return value if value > 0 else 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: async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
# 从 configurable 读取动态类型列表,未配置时退回默认值 # 从 configurable 读取动态类型列表,未配置时退回默认值
configurable = (config or {}).get("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) type_options = _build_type_options(valid_article_types)
system = _SYSTEM_TEMPLATE.format(type_options=type_options) 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) fallback_type = state.get("article_type", default_key)
if fallback_type not in valid_keys: 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"], "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, # 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果, # 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。 # 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
@@ -193,13 +243,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}", "detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
}) })
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值 final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
raw_type = result.get("article_type", "") state, result, fallback_result, valid_keys, default_key
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"]
# 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图 # 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图
writer({ writer({
@@ -214,11 +260,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
updates: dict = {"status": "researching", "progress_events": progress_events} updates: dict = {"status": "researching", "progress_events": progress_events}
updates["article_type"] = final_article_type updates["article_type"] = final_article_type
if result.get("target_audience"): updates["target_audience"] = final_target_audience
updates["target_audience"] = final_target_audience updates["word_count_target"] = final_word_count
if word_count is not None: if final_refined_intent != state["user_intent"]:
updates["word_count_target"] = final_word_count
if result.get("refined_intent"):
updates["user_intent"] = final_refined_intent updates["user_intent"] = final_refined_intent
return updates return updates
@@ -16,18 +16,70 @@ from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, sa
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 章节正文里的 Markdown 标题行(允许前导空白) # 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE) _HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
def _normalize_section_headings(text: str) -> str: def _normalize_section_headings(text: str) -> str:
"""把章节正文里所有 Markdown 标题行统一压成三级标题(###)。 """把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
作家被要求只用 ### 作为「要点」小标题;模型偶尔误用 #/##/#### 时在此纠正, 文档层级:文章 `#` → 章节 `##` → 小节 `###` → 子要点 `####`。
保证文档标题层级符合「文章 # → 章节 ## → 要点 ###」, 作家只输出小节(###)和子要点(####)两级,文章/章节标题由系统注入。
前端目录栏据此才能把要点正确识别为二级标题。
归一规则:
- `#` / `##` → `###` 作家不应输出这两级,误用时降级为小节
- `###` → `###` 保持
- `####` → `####` 保持(三级结构的最深层)
- `#####`+ → `####` 深度截断,防止结构过深
""" """
return _HEADING_PREFIX_RE.sub("### ", text)
def _replace(match: re.Match[str]) -> str:
indent = match.group(1)
level = len(match.group(2))
# 三级及以下保持原级别(限 3 和 4);深度 4+ 压回 4
new_level = 3 if level <= 3 else 4
return f"{indent}{'#' * new_level} "
return _HEADING_PREFIX_RE.sub(_replace, text)
def _render_key_points_for_prompt(key_points: list) -> str:
"""把 outline 的 key_points 渲染成层级文本喂给作家。
输入是 List[KeyPoint] 或 List[str](旧 session 兼容)。输出形如:
- 小节标题1
- 子要点 a
- 子要点 b
- 小节标题2(无子要点时只一行)
作家据此把每个主 bullet 写成 `### 小节标题`,把缩进的子要点
用 `- ` bullet 列表落在小节正文起始处,再展开论述。
"""
lines: list[str] = []
for kp in key_points:
if isinstance(kp, str):
# 旧版兼容:整条当作小节标题,没有子要点
title = kp.strip()
if title:
lines.append(f"- {title}")
elif isinstance(kp, dict):
title = str(
kp.get("point_title")
or kp.get("pointTitle")
or kp.get("title")
or ""
).strip()
if not title:
continue
lines.append(f"- {title}")
subs = kp.get("sub_points") or kp.get("subPoints") or []
if isinstance(subs, list):
for sp in subs:
sp_text = sp.strip() if isinstance(sp, str) else str(sp).strip()
if sp_text:
lines.append(f" - {sp_text}")
return "\n".join(lines) or "- 主要内容"
# 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现, # 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现,
@@ -166,7 +218,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
mat_ids = section.get("material_ids") or section.get("materialIds") or [] mat_ids = section.get("material_ids") or section.get("materialIds") or []
section_materials = _get_section_materials(mat_ids, pkg) section_materials = _get_section_materials(mat_ids, pkg)
key_points_str = "\n".join(f"- {p}" for p in key_points) # 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
key_points_str = _render_key_points_for_prompt(key_points)
# 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节 # 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节
outline_context = "\n".join( outline_context = "\n".join(
@@ -282,8 +336,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
expand_msg = DRAFT_EXPAND_USER.format( expand_msg = DRAFT_EXPAND_USER.format(
article_title=article_title, article_title=article_title,
section_title=section_titles[idx], section_title=section_titles[idx],
key_points="\n".join( key_points=_render_key_points_for_prompt(
f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or []) target_sec.get("key_points") or target_sec.get("keyPoints") or []
), ),
section_materials=_get_section_materials( section_materials=_get_section_materials(
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
@@ -7,7 +7,7 @@ import logging
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, Outline, OutlineSection from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ..prompts.writer_prompts import ( from ..prompts.writer_prompts import (
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER, OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
) )
@@ -27,16 +27,56 @@ def _materials_summary(pkg: dict) -> str:
return "\n".join(lines) or "(无素材)" return "\n".join(lines) or "(无素材)"
def _normalize_key_point(raw: object) -> KeyPoint:
"""把 LLM 输出 / 旧 session 数据规整成 KeyPoint。
兼容场景:
- 新版 dict:`{"point_title": "...", "sub_points": [...]}` —— 直接用,缺字段补默认
- 旧版 string:历史 session 的 `key_points: ["要点1", "要点2"]` —— 自动 lift 成
`{"point_title": "要点1", "sub_points": []}`,旧大纲打开后能继续编辑
- LLM 偶尔会用 `title` / `subPoints` 这种 camelCase 或别名,做轻量兜底
- 其它无法识别的形态(数字 / 列表 / None)→ 空 KeyPoint,不让整个大纲炸掉
"""
if isinstance(raw, str):
return KeyPoint(point_title=raw, sub_points=[])
if isinstance(raw, dict):
# 字段别名兜底(LLM 时常 camelCase 或写 title):优先 snake_case
title = (
raw.get("point_title")
or raw.get("pointTitle")
or raw.get("title")
or ""
)
subs_raw = raw.get("sub_points") or raw.get("subPoints") or []
# 子要点统一为字符串数组;dict 子项取其 title 字段降级(避免 4 级嵌套混入)
sub_points: list[str] = []
if isinstance(subs_raw, list):
for sp in subs_raw:
if isinstance(sp, str):
sub_points.append(sp)
elif isinstance(sp, dict):
sub_points.append(str(sp.get("point_title") or sp.get("title") or sp.get("text") or ""))
return KeyPoint(point_title=str(title), sub_points=sub_points)
# 其它意外类型(int / list / None):返回空 KeyPoint
return KeyPoint(point_title="", sub_points=[])
def _parse_outline(raw: dict) -> Outline: def _parse_outline(raw: dict) -> Outline:
sections = [] sections = []
for s in raw.get("sections", []): for s in raw.get("sections", []):
raw_kps = s.get("key_points") or []
key_points = [_normalize_key_point(kp) for kp in raw_kps]
sections.append(OutlineSection( sections.append(OutlineSection(
section_title=s.get("section_title", ""), section_title=s.get("section_title", ""),
key_points=s.get("key_points", []), key_points=key_points,
material_ids=s.get("material_ids", []), material_ids=s.get("material_ids", []),
)) ))
if not sections: if not sections:
sections = [OutlineSection(section_title="正文", key_points=["主要内容"], material_ids=[])] sections = [OutlineSection(
section_title="正文",
key_points=[KeyPoint(point_title="主要内容", sub_points=[])],
material_ids=[],
)]
return Outline(title=raw.get("title", "未命名文章"), sections=sections) return Outline(title=raw.get("title", "未命名文章"), sections=sections)
@@ -120,12 +160,32 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
if is_revise: if is_revise:
fallback = current_outline fallback = current_outline
else: else:
# 兜底大纲也用新的 3 级结构;_normalize_key_point 同时兼容旧 string 形态,
# 这里给 dict 形态做样板。
fallback = { fallback = {
"title": "未命名文章", "title": "未命名文章",
"sections": [ "sections": [
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []}, {
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []}, "section_title": "引言",
{"section_title": "结论", "key_points": ["总结"], "material_ids": []}, "key_points": [
{"point_title": "背景介绍", "sub_points": []},
],
"material_ids": [],
},
{
"section_title": "正文",
"key_points": [
{"point_title": "核心内容", "sub_points": []},
],
"material_ids": [],
},
{
"section_title": "结论",
"key_points": [
{"point_title": "总结", "sub_points": []},
],
"material_ids": [],
},
], ],
} }
result = parse_json(full_text, fallback) result = parse_json(full_text, fallback)
@@ -1,10 +1,10 @@
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。 OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据素材和意图,规划清晰的文章大纲。 你的目标:根据素材和意图,规划清晰的三级大纲(章节 → 小节 → 子要点)。
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。 你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只能做以下事情: 你只能做以下事情:
1. 分析素材,提取核心观点 1. 分析素材,提取核心观点
2. 规划文章章节结构和每节要点 2. 规划文章三级结构:章节、章节下的小节、小节下的子要点
你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。 你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
【严禁思考与表态】(最高优先级) 【严禁思考与表态】(最高优先级)
@@ -12,10 +12,16 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。 - 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。
- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。 - 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。
【要点(key_points)的特殊要求】 【三级层级语义】(最高优先级,决定文章结构)
- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。 - Level 1 = `section_title`(章节):将渲染为文章里的 `##` 二级标题,统领一组内容。
- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。 - Level 2 = `point_title`(小节):将渲染为 `###` 三级标题,下面会有一段 2-3 句的概述/引入正文。
- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。 - Level 3 = `sub_points`(子要点):将渲染为 `####` 四级标题,**每个子要点都是一个有自己 3-5 句独立正文段的小标题**——不是 bullet 列表。
→ 因此子要点要规划成「能独立成段展开」的小主题,而不是一句话能讲完的零散点。
【三级字段的具体要求】
- `section_title`(章节标题):高度概括的短语,6-15 字。每篇文章规划 3-5 个章节。
- `point_title`(小节标题):名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。每个章节规划 2-4 个小节。
- `sub_points`(子要点标题):名词性短语,<= 15 字,**不带句末标点**。每个小节规划 2-4 条子要点;如果该小节信息确实少到无法拆分子主题,可留 `[]`(此时只写小节级正文)。
【输出要求】 【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。 - 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
@@ -27,9 +33,31 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
{ {
"title": "新能源汽车出口为何高速增长", "title": "新能源汽车出口为何高速增长",
"sections": [ "sections": [
{"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]}, {
{"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]}, "section_title": "引言:出口数据创新高",
{"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]} "key_points": [
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
],
"material_ids": ["m001"]
},
{
"section_title": "增长背后的三大动因",
"key_points": [
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]},
{"point_title": "产品竞争力提升", "sub_points": ["智能化配置溢价", "出海车型迭代速度"]}
],
"material_ids": ["m002", "m003"]
},
{
"section_title": "结论与展望",
"key_points": [
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
{"point_title": "行业趋势判断", "sub_points": []}
],
"material_ids": ["m004"]
}
] ]
}""" }"""
@@ -46,7 +74,7 @@ OUTLINE_USER = """写作意图:{user_intent}
# ── 重新规划(基于现有大纲的编辑)───────────────────────────────────────────── # ── 重新规划(基于现有大纲的编辑)─────────────────────────────────────────────
OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。 OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。 你的目标:根据用户的修改要求,调整一份【已有的三级文章大纲】。
你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。 你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。
你只能做以下事情: 你只能做以下事情:
@@ -54,25 +82,31 @@ OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
你不能写正文,只输出调整后的大纲。 你不能写正文,只输出调整后的大纲。
【修改原则】(最高优先级) 【修改原则】(最高优先级)
- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。 - 准确识别并落实用户意图,常见操作:新增/删除/合并/拆分章节或小节、调整顺序、修改标题、调整子要点。
- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。 - 只改用户明确提到的部分:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变)。
- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。 - 新增内容时:紧扣写作意图,并尽量关联可用素材的 material_ids。
- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。 - 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
【严禁思考与表态】(最高优先级) 【严禁思考与表态】(最高优先级)
- 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。 - 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。 - 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。
【要点(key_points)的特殊要求】 【三级层级语义】(最高优先级)
- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。 - `section_title`(章节,`##` 二级标题):高度概括的短语,6-15 字。
- 每个章节 2-4 个要点。 - `point_title`(小节,`###` 三级标题):名词性短语,15 字以内,无句末标点;小节下会有一段概述正文。每章节 2-4 个小节。
- `sub_points`(子要点,`####` 四级标题):名词性短语,<= 15 字,无句末标点;**每条子要点会展开成一段独立正文**,所以要写成能成段的主题。每小节 2-4 条,信息少到无法拆时可留 `[]`。
【兼容历史大纲】
- 若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,
在补充 `sub_points: []` 后输出新格式:`{"point_title": "<原 string>", "sub_points": []}`。
- 用户修改时如要求"为某要点补充更详细的子项",就把对应小节的 sub_points 填上。
【输出要求】 【输出要求】
- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。 - 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。 - 必须使用英文双引号 "。
- material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。 - material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。
- 结构与「当前大纲」一致: - 结构(嵌套三级):
{"title": "...", "sections": [{"section_title": "...", "key_points": ["..."], "material_ids": ["..."]}]}""" {"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": ["..."]}]}"""
OUTLINE_REVISE_USER = """写作意图:{user_intent} OUTLINE_REVISE_USER = """写作意图:{user_intent}
文章类型:{article_type} 文章类型:{article_type}
@@ -104,18 +138,23 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
- 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。 - 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。
- 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。 - 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。
【结构要求】 【结构要求】(三级结构,必须严格遵守)
- 把本章节的每一个「章节要点」写成一个二级小标题,用 Markdown 三级标题语法:### 要点 - 「章节要点」用层级 bullet 列表给出:
- 每个 ### 小标题下面紧跟该要点的论述正文,正文连贯成段。 - 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
- 即使章节只有一个要点,也要为它写出对应的 ### 小标题。 - 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
- 小标题文字简洁,直接沿用给定的要点措辞,不要加序号、不要加标点。 - **每个小节、每个子要点都要有自己的正文段,分两层展开**:
- 只能使用 ### 这一级标题;绝不要输出 # 或 ##(文章标题、章节标题由系统统一添加)。 1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
- 标题层级严格对应:文章 `#` / 章节 `##`(系统注入,作家**不要输出**)/ 小节 `###` / 子要点 `####`。
- 不要输出 `#####` 或更深;这种深度不在结构里。
【输出要求】 【输出要求】
- 直接返回这一段的内容(含 ### 小标题与正文),不要 JSON,不要解释。 - 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
- 不要写"以下是正文""本段内容如下"之类的开场白。 - 不要写"以下是正文""本段内容如下"之类的开场白。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。 - 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每个要点:每个要点至少用 2-3 句话论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。""" - 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ─────────────────── # ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
@@ -141,17 +180,25 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
{outline_context} {outline_context}
当前章节:{section_title} 当前章节:{section_title}
章节要点:{key_points} 章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材: 相关素材:
{section_materials} {section_materials}
{revision_section}{mode_section} {revision_section}{mode_section}
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。 请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。"""
按下面的结构两层展开写:
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
论述充分、层次清晰、内容充实。"""
DRAFT_EXPAND_USER = """文章标题:{article_title} DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title} 当前章节:{section_title}
章节要点:{key_points} 章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材: 相关素材:
{section_materials} {section_materials}
@@ -159,8 +206,9 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
这一章节已有内容如下: 这一章节已有内容如下:
{existing_text} {existing_text}
以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。 以上内容篇幅不足。请在保留原意、事实和「### 小节 / #### 子要点」两级标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
要求: 要求:
- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。 - 补充更多来自素材的事实、数据与分析,让每一层都论述更充分、更具体。
- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。 - 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。""" - 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
@@ -20,10 +20,23 @@ class MaterialPackage(TypedDict):
summary: str summary: str
# ── 大纲 ────────────────────────────────────────────────────────────────────── # ── 大纲 ──────────────────────────────────────────────────────────────────────
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
# Outline.sections[] = Level 1 章节(渲染为 ## 由系统注入)
# OutlineSection.key_points[] = Level 2 小节(渲染为 ### 标题)
# KeyPoint.sub_points[] = Level 3 子要点(bullet "- ...",非标题)
#
# 历史上 key_points 曾是 List[str](2 级):每个 str 既是小节标题又是渲染单位。
# 升级到 3 级后,key_points 变成 List[KeyPoint],每个 KeyPoint 自带 sub_points
# 列表;旧 session 里的 string 项由 writer_outline._parse_outline 自动 lift 成
# {point_title: <原 string>, sub_points: []},无需迁移数据。
class KeyPoint(TypedDict):
point_title: str # 小节标题(渲染为 ### 二级小标题)
sub_points: List[str] # 子要点(可为空;非空时在小节正文里以 bullet 列表呈现)
class OutlineSection(TypedDict): class OutlineSection(TypedDict):
section_title: str section_title: str
key_points: List[str] key_points: List[KeyPoint] # 注意:旧版是 List[str],解析层做了兼容
material_ids: List[str] material_ids: List[str]
class Outline(TypedDict): class Outline(TypedDict):
@@ -471,16 +471,24 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
) )
# Default lead agent (unchanged behavior) # 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( return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
tools=get_available_tools( tools=default_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,
),
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config), middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=apply_prompt_template( system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
@@ -493,6 +501,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
app_config=resolved_app_config, app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled, memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled, rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids,
), ),
state_schema=ThreadState, state_schema=ThreadState,
) )
@@ -915,6 +915,8 @@ def apply_prompt_template(
app_config: AppConfig | None = None, app_config: AppConfig | None = None,
memory_injection_enabled: bool = True, memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False, rag_mode_enabled: bool = False,
is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None,
) -> str: ) -> str:
storage_agent_id = agent_id if agent_id is not None else agent_name storage_agent_id = agent_id if agent_id is not None else agent_name
@@ -1021,4 +1023,46 @@ When a search, knowledge, skill, or MCP tool returns a result list:
acp_section=acp_and_mounts_section, 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}"