Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xhy
This commit is contained in:
commit
ffe42ca8f1
81 files changed
+12822
-8863
No files matched your search
Whitespace-only changes.
@@ -1,140 +0,0 @@
|
||||
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
当前 `frontend-web/src/App.tsx` 同时承载了:
|
||||
|
||||
1. 登录/首页相关路由(如 `/login`、`/landing`)
|
||||
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
|
||||
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
|
||||
|
||||
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
|
||||
|
||||
---
|
||||
|
||||
## 二、目标(你提出的核心诉求)
|
||||
|
||||
你希望:
|
||||
|
||||
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
|
||||
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
|
||||
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
|
||||
|
||||
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构范围
|
||||
|
||||
### 1)保留在 `App.tsx` 的内容
|
||||
|
||||
- 全局 Provider:`HashRouter`、`AppProviders`
|
||||
- 登录/首页路由:
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/login/:username`
|
||||
- `/landing`
|
||||
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
|
||||
|
||||
### 2)迁移出 `App.tsx` 的内容
|
||||
|
||||
将以下与工作台有关的路由从 `App.tsx` 抽离:
|
||||
|
||||
- `/workspace`(重定向)
|
||||
- `/workspace/chats`
|
||||
- `/workspace/chats/:thread_id`
|
||||
- `/workspace/agents`
|
||||
- `/workspace/agents/new`
|
||||
- `/workspace/agents/:agent_id/chats`(重定向)
|
||||
- `/workspace/agents/:agent_id/chats/:thread_id`
|
||||
- `/workspace/scheduled`
|
||||
- `/workspace/scheduled/tasks`
|
||||
- `/workspace/scheduled/runs/:run_id`
|
||||
|
||||
以及配套的包装组件职责:
|
||||
|
||||
- `WorkspaceLayout`
|
||||
- `ChatRuntime`
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的新结构(方案)
|
||||
|
||||
## 方案 A(推荐):新增“业务路由容器组件”
|
||||
|
||||
在 `pages` 下新增一个路由容器组件,例如:
|
||||
|
||||
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
|
||||
|
||||
职责:
|
||||
- 专门维护 `/workspace/*` 相关路由
|
||||
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
|
||||
- 输出一个可被 `App.tsx` 挂载的路由节点
|
||||
|
||||
同时在 `App.tsx` 里只保留:
|
||||
- 登录/首页/404
|
||||
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
|
||||
|
||||
---
|
||||
|
||||
## 五、“再建立一个页面放置导航”的落地解释
|
||||
|
||||
你说的“建立一个页面放置这些导航”,可以有两种理解:
|
||||
|
||||
1. **路由容器页(推荐)**:
|
||||
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
|
||||
- 即上面的 `WorkspaceRoutes.tsx`。
|
||||
|
||||
2. **可见的导航页(可选)**:
|
||||
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
|
||||
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
|
||||
|
||||
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施步骤(开发任务拆解)
|
||||
|
||||
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
|
||||
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
|
||||
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
|
||||
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
|
||||
5. 校验以下访问路径行为一致:
|
||||
- `/workspace` 重定向
|
||||
- chats/agents/scheduled 各页面可正常进入
|
||||
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
|
||||
- 404 不受影响
|
||||
6. 运行前端并做手工回归。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
|
||||
2. `/workspace/*` 全部由新建组件统一管理。
|
||||
3. 功能行为与改造前一致(或符合新定义)。
|
||||
4. 代码可读性提升:登录入口与业务入口职责分离。
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
|
||||
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
|
||||
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
|
||||
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议的命名(可直接采用)
|
||||
|
||||
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
|
||||
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 十、后续可选优化
|
||||
|
||||
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
|
||||
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
|
||||
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,742 @@
|
||||
# 多智能体圆桌研讨 · 前端开发文档
|
||||
|
||||
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
|
||||
|
||||
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
|
||||
|
||||
---
|
||||
|
||||
## 0. 速读:你需要先知道的事
|
||||
|
||||
- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。
|
||||
- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。
|
||||
- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。
|
||||
- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。
|
||||
- **`<think>` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `<think>...</think>`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。
|
||||
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
|
||||
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
|
||||
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
|
||||
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
|
||||
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
|
||||
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定位
|
||||
|
||||
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
|
||||
|
||||
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
|
||||
|
||||
**核心能力**:
|
||||
|
||||
| 步骤 | 用户目标 | 真实后端对接 |
|
||||
|------|----------|--------------|
|
||||
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
|
||||
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
|
||||
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
|
||||
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
|
||||
|
||||
**步骤门禁**:
|
||||
|
||||
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
|
||||
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
frontend-web/src/roundtable-planning/
|
||||
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
|
||||
├── pages/
|
||||
│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
|
||||
├── api/
|
||||
│ ├── intent.ts # Step 1:initIntent、streamIntent
|
||||
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
|
||||
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
|
||||
├── components/
|
||||
│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
|
||||
│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置
|
||||
│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup)
|
||||
│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props)
|
||||
│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入)
|
||||
│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport
|
||||
│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model)
|
||||
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
|
||||
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
|
||||
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
|
||||
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
|
||||
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
|
||||
├── hooks/
|
||||
│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
|
||||
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
|
||||
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
|
||||
│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除
|
||||
├── lib/
|
||||
│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
|
||||
│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `<think>...</think>` 思考过程
|
||||
│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等)
|
||||
│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染
|
||||
├── utils/
|
||||
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
|
||||
└── styles/
|
||||
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
|
||||
```
|
||||
|
||||
**模块依赖方向(自上而下)**:
|
||||
|
||||
```
|
||||
pages/RoundtablePlanningPage
|
||||
├─ uses → hooks/useStep1Intent
|
||||
├─ uses → hooks/useStep2Orchestration
|
||||
├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate)
|
||||
├─ uses → components/Step{1,2,3}Panel (纯 props 驱动)
|
||||
├─ uses → components/RecommendAgentsDialog
|
||||
└─ uses → components/Avatars
|
||||
|
||||
hooks/useStep1Intent → api/intent
|
||||
hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型
|
||||
hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型
|
||||
```
|
||||
|
||||
主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。
|
||||
|
||||
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
|
||||
|
||||
```tsx
|
||||
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
|
||||
```
|
||||
|
||||
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
|
||||
|
||||
**环境变量**(由 `scripts/start-frontend.sh` 注入):
|
||||
|
||||
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
|
||||
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
|
||||
|
||||
---
|
||||
|
||||
## 3. 三步数据流(前端视角)
|
||||
|
||||
```
|
||||
[进入 Step 1]
|
||||
useEffect → runStep1Init()
|
||||
initIntent(model) → { agent_id, thread_id }
|
||||
setIntentThreadId、setTimelineEntries
|
||||
interactiveMessages seed 欢迎语(本地 seed,非 API)
|
||||
|
||||
[用户发送 / 选项卡片提交]
|
||||
sendIntentMessage(text)
|
||||
追加 user 气泡 + agent 占位(streaming + phase)
|
||||
streamIntent({ onTextDelta, onPhaseChange, signal })
|
||||
终态:
|
||||
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
|
||||
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
|
||||
asking → 继续对话
|
||||
error → toast + 红色 (错误) 文案
|
||||
|
||||
[点击「进入多智能体研讨」且 intentReady]
|
||||
setIsRecommendDialogOpen(true)
|
||||
RecommendAgentsDialog
|
||||
listAgents() → filterRecommendCandidates()
|
||||
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
|
||||
用户勾选 2–8 个 → onConfirm(agentIds)
|
||||
handleConfirmAgents
|
||||
setSelectedAgents(agentIds.map(...))
|
||||
step2InitRef.current = false // 复位 Step 2 init 闸门
|
||||
setCurrentStep(2)
|
||||
|
||||
[进入 Step 2]
|
||||
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
|
||||
onSeatReady:右侧席位面板逐个「在线」
|
||||
onCoordinatorReady / init_done → threadIds + coordinatorName
|
||||
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
|
||||
while !cancelled && cycles<8:
|
||||
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
|
||||
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
|
||||
dispatched.length === 0 → hasConsensus = true,可进 Step 3
|
||||
|
||||
[进入 Step 3]
|
||||
HighFidelityReport
|
||||
consensusText ← lastLeaderContent
|
||||
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
|
||||
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
|
||||
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 层
|
||||
|
||||
### 4.1 `api/intent.ts` — Step 1
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
|
||||
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
|
||||
|
||||
**`StreamIntentRequest` 重要字段**:
|
||||
|
||||
- `threadId`:必填,跨多轮对话保持
|
||||
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
|
||||
- `onTextDelta(text, messageId)`:每个 AI 文本增量
|
||||
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
|
||||
|
||||
**`StreamIntentResult`**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
|
||||
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
|
||||
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
|
||||
| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 |
|
||||
|
||||
**SSE 解析要点**:
|
||||
|
||||
- `event: metadata` → phase `connected`
|
||||
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
|
||||
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
|
||||
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`)
|
||||
|
||||
**Abort 行为**:
|
||||
|
||||
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
|
||||
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
|
||||
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
|
||||
|
||||
### 4.2 `api/recommend.ts` — 推荐弹窗
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
|
||||
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
|
||||
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
|
||||
|
||||
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
|
||||
|
||||
- `roundtable-intent`
|
||||
- `roundtable-recommender`
|
||||
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
|
||||
|
||||
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
|
||||
|
||||
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
|
||||
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
|
||||
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
|
||||
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
|
||||
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
|
||||
|
||||
**终态语义**:
|
||||
|
||||
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
|
||||
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
|
||||
|
||||
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
|
||||
| `streamMultiAgent(req)` | leader / special 一轮 |
|
||||
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
|
||||
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
|
||||
|
||||
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
|
||||
|
||||
```ts
|
||||
{ event: "seat_ready", agent_name, thread_id, display_name, description }
|
||||
{ event: "coordinator_ready", main_agent_name, thread_id }
|
||||
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
|
||||
{ event: "error", detail }
|
||||
```
|
||||
|
||||
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
|
||||
|
||||
| `status` | 含义 | 前端处理 |
|
||||
|----------|------|----------|
|
||||
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
|
||||
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
|
||||
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
|
||||
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
|
||||
| `"error"` | 失败 | toast + 红色气泡 |
|
||||
|
||||
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
|
||||
|
||||
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
|
||||
|
||||
**调试开关**:
|
||||
|
||||
```js
|
||||
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
|
||||
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
|
||||
```
|
||||
|
||||
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
|
||||
|
||||
---
|
||||
|
||||
## 5. 主页面状态(`RoundtablePlanningPage`)
|
||||
|
||||
> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。
|
||||
|
||||
| 维度 | 所有者 | 暴露给主页面的方式 |
|
||||
|------|--------|-------------------|
|
||||
| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState |
|
||||
| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 |
|
||||
| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 |
|
||||
| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 |
|
||||
| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref |
|
||||
|
||||
### 5.1 步骤与门禁
|
||||
|
||||
- `currentStep`: `1 | 2 | 3`
|
||||
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
|
||||
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
|
||||
|
||||
### 5.2 Step 1 关键 state
|
||||
|
||||
| 状态 | 用途 |
|
||||
|------|------|
|
||||
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
|
||||
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
|
||||
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
|
||||
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
|
||||
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
|
||||
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
|
||||
| `intentAborterRef` | 当前 stream 的 AbortController |
|
||||
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
|
||||
| `step1InitRef` | 闸门:true 即认为已 init 过 |
|
||||
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
|
||||
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
|
||||
|
||||
**`step1InitGenRef` 的存在原因**:
|
||||
|
||||
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
|
||||
|
||||
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
|
||||
|
||||
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
|
||||
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
|
||||
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
|
||||
|
||||
### 5.3 推荐与 Step 2 席位
|
||||
|
||||
| 状态 | 用途 |
|
||||
|------|------|
|
||||
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
|
||||
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
|
||||
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
|
||||
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
|
||||
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
|
||||
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
|
||||
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
|
||||
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
|
||||
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
|
||||
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
|
||||
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
|
||||
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
|
||||
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
|
||||
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
|
||||
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
|
||||
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
|
||||
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
|
||||
|
||||
**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。
|
||||
|
||||
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
|
||||
|
||||
伪代码:
|
||||
|
||||
```
|
||||
isOrchestrating = true
|
||||
orchestrationCancelRef = false
|
||||
nextLeaderTask = seedMessage
|
||||
cycles = 0
|
||||
MAX_CYCLES = 8
|
||||
|
||||
while !cancelled && cycles < MAX_CYCLES:
|
||||
cycles += 1
|
||||
highlight(speakerId=2) // 总控
|
||||
leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
|
||||
res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
|
||||
|
||||
if res.clarification:
|
||||
finalize(leaderBubble, content, "等待用户澄清")
|
||||
setPendingClarification(res.clarification)
|
||||
break // 等用户回复后由 submitClarification 重启循环
|
||||
|
||||
finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
|
||||
if res.content: setLastLeaderContent(res.content)
|
||||
|
||||
if res.dispatched.length === 0:
|
||||
setHasConsensus(true); setConsensusPercentage(100); break
|
||||
|
||||
for [subName, task] of res.dispatched: // **顺序**,不是并行
|
||||
meta = agentNameToRole[subName.toLowerCase()]
|
||||
markSpeakerStreaming(meta.speakerId)
|
||||
perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
|
||||
subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
|
||||
specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
|
||||
finalize(subBubble, specRes.content, "子智能体交付")
|
||||
markSpeakerIdle(meta.speakerId)
|
||||
if cancelled: break
|
||||
|
||||
setConsensusPercentage(prev => min(95, prev + 8))
|
||||
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
|
||||
|
||||
if cycles >= MAX_CYCLES:
|
||||
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
|
||||
|
||||
clearAllStreamingSpeakers()
|
||||
isOrchestrating = false
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **`buildInitialIntent()`**:
|
||||
- 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
|
||||
- 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
|
||||
- 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
|
||||
- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
|
||||
- **挂起 / 恢复**:
|
||||
- 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
|
||||
- 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
|
||||
- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
|
||||
|
||||
### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
|
||||
|
||||
- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
|
||||
- 抽屉控件:
|
||||
- **System Prompt**:仅前端记录(后端覆写未开放)
|
||||
- **Temp**:仅前端记录
|
||||
- **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel`
|
||||
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
|
||||
- 把新的 `(seatId, model)` 写入 `agentConfigs`
|
||||
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
|
||||
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
|
||||
|
||||
### 5.6 全局模型选择器(右侧 sidebar 顶部)
|
||||
|
||||
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
|
||||
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
|
||||
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
|
||||
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
|
||||
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
|
||||
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
|
||||
|
||||
### 5.7 草稿持久化(`utils/drafts.ts`)
|
||||
|
||||
- Key:`roundtable-planning:drafts:v1`(`localStorage`)
|
||||
- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
|
||||
- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字
|
||||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep`
|
||||
- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave`
|
||||
- **加载草稿**:`handleLoadDraft`
|
||||
- 中断所有 in-flight(`abortAllInFlight + stopIntentStream`)
|
||||
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉
|
||||
- `step1InitGenRef` 自增,淘汰可能晚归的旧 init
|
||||
- `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init
|
||||
- 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件说明
|
||||
|
||||
### 6.1 `IntentClarificationCard`
|
||||
|
||||
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`
|
||||
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
|
||||
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
|
||||
- 仅选项 → `我选择:A、B`
|
||||
- 仅自定义 → 直接发自定义文本
|
||||
- 同时存在 → `我选择:A、B;补充:…`
|
||||
- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片)
|
||||
|
||||
### 6.2 `RecommendAgentsDialog`
|
||||
|
||||
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
|
||||
- 阶段 UI(`InternalState.phase`):
|
||||
- `idle` → `loading_agents` → `recommending`(顶部「模型生成内容」区流式;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
|
||||
- **布局分两段**:
|
||||
- **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `<think>` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。
|
||||
- **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。
|
||||
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
|
||||
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
|
||||
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
|
||||
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
|
||||
|
||||
### 6.3 `HighFidelityReport`(Step 3)
|
||||
|
||||
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
|
||||
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
|
||||
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
|
||||
- **文件预览策略**(`shouldPreviewInModal`):
|
||||
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
|
||||
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
|
||||
- 命中规则:
|
||||
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
|
||||
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
|
||||
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
|
||||
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
|
||||
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
|
||||
|
||||
### 6.4 `ArtifactPreviewModal`
|
||||
|
||||
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
|
||||
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
|
||||
- 其它文本类 → `<pre>` 直出
|
||||
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
|
||||
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
|
||||
|
||||
### 6.5 共享 UI 模式与辅助
|
||||
|
||||
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
|
||||
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
|
||||
- `connecting` → 「调度中」
|
||||
- `connected` → 「已连接,等待响应」
|
||||
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
|
||||
- `tool_result` → 「处理工具结果」
|
||||
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
|
||||
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
|
||||
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
|
||||
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
|
||||
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
|
||||
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
|
||||
|
||||
---
|
||||
|
||||
## 7. 澄清交互(与主聊天对齐)
|
||||
|
||||
| 能力 | 实现位置 |
|
||||
|------|----------|
|
||||
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
|
||||
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
|
||||
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
|
||||
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
|
||||
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
|
||||
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
|
||||
|
||||
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
|
||||
|
||||
**关键差异**:
|
||||
|
||||
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
|
||||
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与主聊天的差异
|
||||
|
||||
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|
||||
|------|-----------------------------------|----------|
|
||||
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
|
||||
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
|
||||
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
|
||||
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
|
||||
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
|
||||
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
|
||||
|
||||
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键交互清单
|
||||
|
||||
| 交互 | 行为 |
|
||||
|------|------|
|
||||
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
|
||||
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
|
||||
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
|
||||
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
|
||||
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
|
||||
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
|
||||
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
|
||||
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
|
||||
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
|
||||
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
|
||||
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 类型与常量速查
|
||||
|
||||
```ts
|
||||
// Step 1 输出 → Step 2 输入
|
||||
interface IntentSummary {
|
||||
objective: string;
|
||||
constraints: string[];
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
// Step 2 线程映射(init_done.thread_ids)
|
||||
type ThreadIdMap = Record<string, string>;
|
||||
|
||||
// 派活
|
||||
type LeaderDispatch = [string, string]; // [agent_name, task]
|
||||
|
||||
// 当前选中的研讨席位
|
||||
type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
// Step 1 单条澄清问题
|
||||
interface ClarificationPayload {
|
||||
question: string;
|
||||
clarificationType: string;
|
||||
context: string;
|
||||
options: unknown[];
|
||||
allowCustom: boolean;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
// Step 2 stream 终态
|
||||
interface StreamMultiAgentResult {
|
||||
dispatched: LeaderDispatch[];
|
||||
message: string | null;
|
||||
content: string;
|
||||
agentName: string | null;
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
```
|
||||
|
||||
**核心常量**:
|
||||
|
||||
| 常量 | 值 | 位置 |
|
||||
|------|----|------|
|
||||
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
|
||||
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
|
||||
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
|
||||
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
|
||||
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
|
||||
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
|
||||
| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
|
||||
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
|
||||
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
|
||||
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
|
||||
|
||||
---
|
||||
|
||||
## 11. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查 |
|
||||
|------|----------|------|
|
||||
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
|
||||
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
|
||||
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
|
||||
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
|
||||
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
|
||||
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
|
||||
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
|
||||
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
|
||||
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
|
||||
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
|
||||
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
|
||||
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
|
||||
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
|
||||
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
|
||||
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
|
||||
|
||||
**本地调试开关**:
|
||||
|
||||
```js
|
||||
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
|
||||
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 扩展指南
|
||||
|
||||
### 12.1 新增默认研讨席位
|
||||
|
||||
1. 后端 `.deer-flow/agents/roundtable-xxx/`
|
||||
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
|
||||
3. 无需改 API 层(`agent_names` 由推荐结果传入)
|
||||
|
||||
### 12.2 调整 Step 1 澄清轮次上限
|
||||
|
||||
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
|
||||
|
||||
### 12.3 调整 Step 2 编排上限
|
||||
|
||||
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
|
||||
|
||||
### 12.4 把 special 改回并行
|
||||
|
||||
不推荐;若一定要做:
|
||||
|
||||
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
|
||||
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
|
||||
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
|
||||
|
||||
### 12.5 接入真实 Persona 覆盖
|
||||
|
||||
当前 prompt / temp 只在前端记录;要落地需:
|
||||
|
||||
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
|
||||
- 前端:`agentConfigs[meta.speakerId]` 一并传入
|
||||
|
||||
### 12.6 拆分页面(已完成 · 2026-05)
|
||||
|
||||
主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
|
||||
|
||||
- `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步
|
||||
- `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清
|
||||
- `hooks/useDraftPersistence.ts` —— 草稿自动保存 / 手动保存 / 加载 / 重命名 / 删除
|
||||
- `components/Step{1,2,3}Panel.tsx` —— 纯 UI,只接收 props,不持有业务状态
|
||||
- `components/PersonaDrawer.tsx` —— Persona 抽屉
|
||||
- `components/Avatars.tsx` —— 所有 SVG 头像 + getAvatar
|
||||
- `lib/roundtable-constants.ts` —— 常量、类型、纯工具函数(buildRoleTemplates 等)
|
||||
- `lib/step-display.tsx` —— ChainOfThought 折叠卡的图标 + 文案 + 子节点渲染
|
||||
|
||||
**主页面只保留**:顶栏(StepNavigation / HistoryDropdown)、左右两侧栏(LeftSidebar / RightSidebar)、步骤路由、推荐弹窗,以及编排 hook 与跨步 handler(`handleConfirmAgents` / `handleStartNewTask`)。
|
||||
|
||||
继续重构请遵守:
|
||||
|
||||
1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。
|
||||
2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。
|
||||
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `utils/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改。
|
||||
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持与重构前同样的 0 个新增错误(预存量 50 个 pre-existing 错误来自其它模块如 `next/link` shim,与本特性无关)。
|
||||
|
||||
### 12.7 文档协同
|
||||
|
||||
`@/roundtable-planning/` 内一切公共 API 都应当:
|
||||
|
||||
1. 在 `api/*.ts` 文件级注释里说明 endpoint
|
||||
2. 在本页第 4 节 / 第 10 节同步对照
|
||||
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 13. 路径速查
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| 主页面(组合 + 顶栏 + 侧栏) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| Step 1 hook | `frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts` |
|
||||
| Step 2 hook(含编排循环) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` |
|
||||
| 草稿 hook | `frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts` |
|
||||
| Step 1 面板 | `frontend-web/src/roundtable-planning/components/Step1Panel.tsx` |
|
||||
| Step 2 面板 | `frontend-web/src/roundtable-planning/components/Step2Panel.tsx` |
|
||||
| Step 3 面板 | `frontend-web/src/roundtable-planning/components/Step3Panel.tsx` |
|
||||
| 消息气泡共享件 | `frontend-web/src/roundtable-planning/components/MessageBubble.tsx` |
|
||||
| 步骤折叠卡(工具/思考) | `frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx` |
|
||||
| `<think>` 提取工具 | `frontend-web/src/roundtable-planning/lib/reasoning.ts` |
|
||||
| Persona 抽屉 | `frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx` |
|
||||
| 头像集合 | `frontend-web/src/roundtable-planning/components/Avatars.tsx` |
|
||||
| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` |
|
||||
| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
|
||||
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
|
||||
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
|
||||
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
|
||||
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||||
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
|
||||
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
|
||||
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
|
||||
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
|
||||
| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
|
||||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
|
||||
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
|
||||
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
|
||||
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
|
||||
@@ -1,492 +0,0 @@
|
||||
# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现
|
||||
|
||||
> Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。
|
||||
>
|
||||
> 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
### ✅ 要做的事
|
||||
- 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位
|
||||
- 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream`
|
||||
- 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper)
|
||||
- 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration`
|
||||
|
||||
### ❌ 不做的事
|
||||
- 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样)
|
||||
- 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可)
|
||||
- 不做时间线(timelineEntries)的后端持久化(前端临时状态够用)
|
||||
- 不做"评分/置信度"等假指标
|
||||
- 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL`
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据流概览
|
||||
|
||||
```
|
||||
[Step 1 入口]
|
||||
↓
|
||||
前端:POST /api/intent/init
|
||||
← {agent_id: "roundtable-intent", thread_id: "..."}
|
||||
|
||||
用户输入第一句话:"帮我规划一个生产系统安全改造方案"
|
||||
↓
|
||||
前端:POST /api/intent/stream (SSE)
|
||||
← (流式 AI 文本) "好的,预算大致是多少?"
|
||||
← {status: "asking", content: "..."} ← 智能体反问中,需要用户继续
|
||||
|
||||
用户回:"500 万左右"
|
||||
↓
|
||||
前端再次 POST /api/intent/stream
|
||||
← (流式) "明白,还有什么时间约束吗?"
|
||||
← {status: "asking", ...}
|
||||
|
||||
...对话进行几轮 ...
|
||||
|
||||
用户:可以了
|
||||
↓
|
||||
前端再 POST /api/intent/stream
|
||||
← (流式) "好,我理解的任务是: <汇总>"
|
||||
← {status: "done", content: "<最终汇总文本>", summary: {...}} ← 信息充分,可进入 step 2
|
||||
|
||||
[Step 1 完成 → Step 2 init]
|
||||
前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口设计
|
||||
|
||||
跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。
|
||||
|
||||
### 3.1 `POST /api/intent/init`
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"model": "deepseek-chat" // 可选,默认全局 DEFAULT_MODEL
|
||||
}
|
||||
```
|
||||
|
||||
**响应 200**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"agent_id": "roundtable-intent",
|
||||
"thread_id": "<langgraph_thread_id>"
|
||||
}
|
||||
```
|
||||
|
||||
**行为**:
|
||||
1. 鉴权(透传 `Authorization`)
|
||||
2. 调上游 `POST /api/threads` 建 1 个 thread
|
||||
3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**)
|
||||
|
||||
**与 step 2 `/init` 的区别**:
|
||||
- 没有"协调智能体"概念,只有 1 个 agent
|
||||
- 没有 6 个 sub 席位,只有 1 个 thread
|
||||
- 不需要传 `agent_names` 数组
|
||||
- `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的)
|
||||
|
||||
### 3.2 `POST /api/intent/stream`
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"thread_id": "<from /init>",
|
||||
"message": "用户这一轮要说的话",
|
||||
"model": "deepseek-chat" // 可选
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:`text/event-stream`,SSE 流式。
|
||||
|
||||
**SSE 帧组成**:
|
||||
1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量
|
||||
2. 流末追加一个**状态帧**(跟 step 2 同样模式):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "asking" | "done" | "error",
|
||||
"content": "<本轮 AI 文本>",
|
||||
"summary": {
|
||||
"objective": "...", // 仅 status=done 时有: 任务目标
|
||||
"constraints": ["...", ...], // 仅 status=done 时有: 约束清单
|
||||
"assumptions": ["...", ...] // 仅 status=done 时有: 关键假设
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`status` 取值**:
|
||||
- `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框
|
||||
- `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent
|
||||
- `"error"` —— 上游异常,`content` 里是错误信息
|
||||
|
||||
**判定 done/asking 的简单策略**(后端实现):
|
||||
- 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧
|
||||
- 否则一律是 `asking`
|
||||
|
||||
> 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取:
|
||||
>
|
||||
> ```
|
||||
> [INTENT_READY]
|
||||
> ```json
|
||||
> {"objective": "...", "constraints": ["..."], "assumptions": ["..."]}
|
||||
> ```
|
||||
> ```
|
||||
>
|
||||
> 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 内置智能体配置
|
||||
|
||||
**位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/`
|
||||
|
||||
### 4.1 `config.yaml`
|
||||
```yaml
|
||||
id: roundtable-intent
|
||||
name: 任务理解智能体
|
||||
description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。
|
||||
```
|
||||
|
||||
### 4.2 `SOUL.md`
|
||||
|
||||
```markdown
|
||||
# 任务理解智能体
|
||||
|
||||
## 角色定位
|
||||
你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
|
||||
通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。
|
||||
|
||||
## 核心职责
|
||||
- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
|
||||
- 不要追问已经清楚的事
|
||||
- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
|
||||
- 信息足够后,立即停止追问,产出结构化汇总
|
||||
|
||||
## 信息充分性判定
|
||||
当满足以下条件时,认为信息已充分:
|
||||
1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
|
||||
2. 至少 1 条硬约束(预算/时间/合规/范围之一)
|
||||
3. 没有明显的歧义点
|
||||
|
||||
## 信息汇总产出
|
||||
信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):
|
||||
|
||||
```
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "用一句话写出用户最终要做的事",
|
||||
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
|
||||
"assumptions": ["假设 1", "假设 2", ...]
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。
|
||||
|
||||
## 输出风格
|
||||
- 一次回复不超过 3 句话
|
||||
- 提问要具体,不要"还有什么补充吗"这种空问
|
||||
- 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总)
|
||||
|
||||
## 边界
|
||||
- 不调用 agent_orchestration(这是 step 2 的协调智能体的事)
|
||||
- 不写文件、不做计算、不联网
|
||||
- 用户消息歧义大时,优先调 ask_clarification 工具引导
|
||||
```
|
||||
|
||||
### 4.3 注册方式
|
||||
|
||||
跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端实现要点
|
||||
|
||||
### 5.1 新建 router 文件
|
||||
**位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py`
|
||||
|
||||
```python
|
||||
"""任务理解智能体接口 - Step 1 的极简包装。
|
||||
|
||||
跟 multi_agent.py 同构但简化:
|
||||
- 没有 broadcast(单 thread,无需广播)
|
||||
- 没有派活逻辑
|
||||
- 没有 leader/special 分支,只有一种调用方式
|
||||
- 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx, json, re, asyncio, logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 复用 multi_agent.py 里已有的工具函数
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_loopback_base, _auth_headers,
|
||||
_create_thread, _stream_upstream, _parse_last_messages,
|
||||
_flatten_content, _run_payload,
|
||||
)
|
||||
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
|
||||
_INTENT_READY_PATTERN = re.compile(
|
||||
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_intent(request: Request, payload: dict = Body(...)) -> dict:
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return {
|
||||
"status": "success",
|
||||
"agent_id": INTENT_AGENT_ID,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
thread_id = str(payload.get("thread_id") or "").strip()
|
||||
message = str(payload.get("message") or "").strip()
|
||||
model_name = str(payload.get("model") or "deepseek-chat")
|
||||
if not thread_id or not message:
|
||||
raise HTTPException(status_code=400, detail="thread_id and message required")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def gen():
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
try:
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=message,
|
||||
excluded_tools=["web_search", "present_files",
|
||||
"view_image", "agent_orchestration"],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
captured = []
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
yield (line if line.endswith("\n") else line + "\n")
|
||||
elif done is not None:
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
last_ai = {}
|
||||
for m in reversed(messages):
|
||||
if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
last_ai = m
|
||||
break
|
||||
content = _flatten_content(last_ai.get("content", ""))
|
||||
|
||||
# 检测 [INTENT_READY] 标记
|
||||
match = _INTENT_READY_PATTERN.search(content)
|
||||
if match:
|
||||
try:
|
||||
summary = json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
summary = None
|
||||
frame = {"status": "done", "content": content, "summary": summary}
|
||||
else:
|
||||
frame = {"status": "asking", "content": content}
|
||||
yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
|
||||
except HTTPException as exc:
|
||||
yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("intent stream failure")
|
||||
yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2 在 `app/gateway/app.py` 注册
|
||||
```python
|
||||
from app.gateway.routers import intent
|
||||
app.include_router(intent.router)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要点
|
||||
|
||||
### 6.1 新增 API helper
|
||||
**位置**:`frontend-web/src/roundtable-planning/api/intent.ts`
|
||||
|
||||
```ts
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface IntentInitResponse {
|
||||
status: "success";
|
||||
agent_id: string;
|
||||
thread_id: string;
|
||||
}
|
||||
|
||||
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface IntentSummary {
|
||||
objective: string;
|
||||
constraints: string[];
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
export interface StreamIntentRequest {
|
||||
threadId: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
onTextDelta?: (text: string, id: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamIntentResult {
|
||||
status: "asking" | "done" | "error";
|
||||
content: string;
|
||||
summary: IntentSummary | null;
|
||||
}
|
||||
|
||||
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
|
||||
// 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
|
||||
// {status, content, summary?} 格式。
|
||||
// ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染
|
||||
|
||||
替换 `handleSendMessage`:
|
||||
|
||||
```ts
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 1 && !intentThreadId) {
|
||||
initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id));
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputText.trim() || !intentThreadId) return;
|
||||
const userText = inputText.trim();
|
||||
// (push user msg to interactiveMessages 略)
|
||||
setInputText("");
|
||||
|
||||
// 占位 agent 气泡
|
||||
const agentMsgId = pushAgentPlaceholder("任务理解智能体");
|
||||
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: userText,
|
||||
model: DEFAULT_MODEL,
|
||||
onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta),
|
||||
});
|
||||
|
||||
finalizeAgentMsg(agentMsgId, res.content);
|
||||
if (res.status === "done" && res.summary) {
|
||||
setIntentReady(res.summary);
|
||||
triggerToast("✅ 任务理解完成,可以进入圆桌研讨");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 6.3 "进入 step 2"按钮的联动
|
||||
|
||||
- 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`)
|
||||
- 改造:只有 `intentReady !== null` 时该按钮才 enabled
|
||||
- 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑)
|
||||
|
||||
```ts
|
||||
const buildInitialIntent = useCallback(() => {
|
||||
if (intentReady) {
|
||||
return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||
}
|
||||
// fallback: 旧逻辑保留兜底
|
||||
const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
|
||||
const userIntent = lastUserMessage?.content ?? "请围绕给定任务展开圆桌研讨";
|
||||
const constraints = `预算硬约束:${budgetLimit} 万元`;
|
||||
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||
}, [interactiveMessages, budgetLimit, intentReady]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施清单
|
||||
|
||||
| 优先级 | 项 | 文件 |
|
||||
|---|---|---|
|
||||
| P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` |
|
||||
| P0 | 新 router | `backend/app/gateway/routers/intent.py` |
|
||||
| P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) |
|
||||
| P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) |
|
||||
| P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 |
|
||||
| P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) |
|
||||
| P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 与 step 2 的衔接
|
||||
|
||||
```
|
||||
step 1 输出: intentReady.objective + intentReady.constraints
|
||||
↓
|
||||
step 2 入口: buildInitialIntent() 拼成 leader 的 new_message
|
||||
↓
|
||||
step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了),
|
||||
除非模型遇到完全意料之外的歧义
|
||||
↓
|
||||
研讨更聚焦,减少 step2 阶段的 clarification 轮次
|
||||
```
|
||||
|
||||
副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。
|
||||
|
||||
---
|
||||
|
||||
## 9. 路径速查
|
||||
|
||||
| 资源 | 位置 |
|
||||
|---|---|
|
||||
| Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) |
|
||||
| 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
|
||||
| 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
|
||||
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
|
||||
| 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收清单
|
||||
|
||||
- [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id
|
||||
- [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout)
|
||||
- [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary
|
||||
- [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态
|
||||
- [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话)
|
||||
- [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)
|
||||
@@ -1,264 +0,0 @@
|
||||
# 多智能体圆桌 · Step 3 · 接口与对接说明
|
||||
|
||||
> Step 3(`/page/roundtable/planning` 的"最终方案展示"页)对应 `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx`。当前页面是产品早期的"展厅效果",绝大多数内容是硬编码 mock。本文档梳理**真正需要的数据**、**用哪些接口**、**前后端怎么对接**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 改造方向(在做什么,不做什么)
|
||||
|
||||
### ✅ 保留并对接真实数据
|
||||
|
||||
| 区块 | 现状 | 改造后 |
|
||||
|---|---|---|
|
||||
| 标题栏 + 生成时间 | 硬编码"2026-05-21 11:01:12" | 用本地 `Date.now()` 或 init/最后一次 leader 完成时刻 |
|
||||
| "已达成共识"徽章 | 静态显示 | step2 自然收敛(`leaderRes.dispatched.length === 0`)后才显示 |
|
||||
| **总控最终结论** | 完全是 mock 文本 | 取最后一次 `leaderRes.content`(已经在 step2 拿到了) |
|
||||
| **各席位最终交付(折叠卡片)** | 当前 step3 没渲染 | 取每个 sub 线程的最后一条 AIMessage 文本 |
|
||||
| **生成的报告文件清单** | 5 张假"档案" | 真实列出 `outputs/` 目录里的所有产出(markdown / pdf / csv 等) |
|
||||
| **文件下载** | 假 toast | 用现有 `GET /api/threads/{tid}/artifacts/{path}` |
|
||||
| "重新起草新规划" | 调用 `setCurrentStep(1)` 重置 | **保留**,含义就是回到 step1 重新开始 |
|
||||
|
||||
### ❌ 不做(直接删 / 不接口)
|
||||
|
||||
| 区块 | 原因 |
|
||||
|---|---|
|
||||
| 4 张 metric 卡片(设计全要素通过率 99.4% / Tier III+ / 终审共识度 42 轮研商 / 90.3分)| 全是按 `budgetLimit` 编出来的"展厅指标",没有真实数据源 |
|
||||
| 三阶段推进路线图(0-24h / 24-60h / 60-72h 三张卡)| 假设业务必然是 72 小时交付,写死场景,不通用 |
|
||||
| "采纳理由与最终裁定判词"段 | 整段都是模板话术,没接口能算出"90.3 分总评" |
|
||||
| "12 轮极限对抗研商" 数据 | 当前没记录"轮次"语义,临时就用 leader 实际轮数代替即可,不值得为它出接口 |
|
||||
| "签发起草成果并投产运行" 按钮 | 无对应业务动作(不是要部署、不是要审批),删掉 |
|
||||
| "执行摘要" 段写死的 3 个 bullet | 模板话术,跟实际任务无关 |
|
||||
|
||||
最终 step3 页面只保留四块:**头部状态 + 总控结论 + 席位交付列表 + 文件下载清单**,再加底部"重新起草"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据来源全景
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ step2 全程结束时,前端手里已经有的本地状态 │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ coordinatorName: "roundtable-coordinator-xxx" │
|
||||
│ threadIds: { [agent_name]: thread_id, ... } │ ← /init 返回
|
||||
│ step2RoundtableDialogues: [...所有气泡, 含每个 sub 最终 │
|
||||
│ 交付的 content...] │
|
||||
│ leaderRes.content (最后一次 leader 的 final text) │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
|
||||
后端权威数据(需要时从后端拉):
|
||||
- 每个 sub 线程的完整 state → GET /api/threads/{tid}/state
|
||||
- 单个 outputs 文件内容/下载 → GET /api/threads/{tid}/artifacts/{path}
|
||||
- outputs 目录文件清单 ❗暂无 → 新增接口(见 §3)
|
||||
```
|
||||
|
||||
**关键判断**:席位最终交付文本 + 总控结论文本 **已经在 step2 内存里**,无需重新调接口。文件清单是**目前唯一缺失**的数据源。
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口需求
|
||||
|
||||
### 3.1 新增(**必需**):列出某 thread 的产出文件
|
||||
|
||||
```
|
||||
GET /api/multi-agent/sessions/artifacts?thread_id=<sub_thread_id>
|
||||
```
|
||||
|
||||
或者更通用、放进 artifacts 路由下:
|
||||
|
||||
```
|
||||
GET /api/threads/{thread_id}/artifacts
|
||||
```
|
||||
|
||||
**为什么需要**:现有 artifacts endpoint 只能"按已知路径下载文件",没有"列出目录"功能。Step 3 需要知道 sub-agent 写到 `/mnt/user-data/outputs/` 下哪些文件,才能在前端列出可下载清单。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"thread_id": "<sub_thread_id>",
|
||||
"files": [
|
||||
{
|
||||
"path": "mnt/user-data/outputs/方案设计报告_生产系统安全改造技术方案.md",
|
||||
"name": "方案设计报告_生产系统安全改造技术方案.md",
|
||||
"size_bytes": 12480,
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-05-23T14:32:30.123Z"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**实现建议**:
|
||||
- 列 `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` 下的文件
|
||||
- 鉴权复用 `@require_permission("threads", "read", owner_check=True)`
|
||||
- 路径要做 traversal 保护(虚拟路径仅限 `mnt/user-data/outputs/`)
|
||||
|
||||
### 3.2 新增(**可选,体感优化**):一次性聚合 step3 数据
|
||||
|
||||
```
|
||||
POST /api/multi-agent/sessions/summary
|
||||
Body: {
|
||||
"main_agent_name": "roundtable-coordinator-xxx",
|
||||
"thread_ids": { "<agent_name>": "<thread_id>", ... }
|
||||
}
|
||||
```
|
||||
|
||||
**为什么可选**:不加这个接口的话,前端需要为每个席位发一次 `GET /state` + 一次 `GET /artifacts`(共 7-13 次 round-trip)。加了这个接口,**一次返回所有数据**。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"main_agent_name": "roundtable-coordinator-xxx",
|
||||
"coordinator": {
|
||||
"thread_id": "...",
|
||||
"final_text": "...", // 最后一条 AIMessage content
|
||||
"dispatch_rounds": 3 // leader 派活的轮数(可选展示)
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"agent_name": "roundtable-intelligence",
|
||||
"thread_id": "...",
|
||||
"display_name": "情报收集",
|
||||
"final_text": "...", // 最后一条 AIMessage content
|
||||
"files": [ // 该席位写到 outputs/ 的文件
|
||||
{ "path": "...", "name": "...", "size_bytes": ..., "mime_type": "..." }
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**取舍**:
|
||||
- 优点:1 次请求 vs 13 次;体验更顺
|
||||
- 缺点:等于把 3.1 + N 次 state 读放到一个 endpoint,实现稍复杂
|
||||
- 推荐:**先做 3.1,3.2 暂缓**;前端用现有 endpoint 串起来够用
|
||||
|
||||
### 3.3 复用(**无需改动**)
|
||||
|
||||
| Endpoint | 用途 |
|
||||
|---|---|
|
||||
| `GET /api/threads/{tid}/state` | 取席位 / 总控线程的完整状态,提取最后一条 AIMessage content |
|
||||
| `GET /api/threads/{tid}/artifacts/{path}` | 点击文件名预览或下载(已有 `?download=true` 支持) |
|
||||
|
||||
> Note: 这两个 endpoint 已经在 `app.gateway.routers.threads` / `app.gateway.routers.artifacts` 里实现,前端 `fetch-client` 也有现成调用方式。
|
||||
|
||||
---
|
||||
|
||||
## 4. 前后端对接流程
|
||||
|
||||
### 4.1 进入 step3 时
|
||||
|
||||
```ts
|
||||
// 触发条件:runOrchestration 自然结束(leaderRes.dispatched.length === 0
|
||||
// 且无 clarification),用户点"达成共识生成全案成果"按钮 / 自动跳转。
|
||||
|
||||
// 前端手里已有:threadIds, coordinatorName, 最后的 leaderRes
|
||||
// 1. 总控结论文本 — 已在内存,直接用
|
||||
const consensusText = lastLeaderRes.content;
|
||||
|
||||
// 2. 各席位交付 — 已在 step2RoundtableDialogues 里,过滤"子智能体交付"的卡片
|
||||
const seatDeliveries = step2RoundtableDialogues
|
||||
.filter(d => d.confidence === "子智能体交付")
|
||||
.map(d => ({ sender: d.sender, content: d.content }));
|
||||
|
||||
// 3. 文件清单 — 调新接口
|
||||
const filesPerSeat = await Promise.all(
|
||||
Object.entries(threadIds)
|
||||
.filter(([name]) => name !== coordinatorName)
|
||||
.map(async ([name, tid]) => ({
|
||||
agentName: name,
|
||||
files: (await fetch(`/api/threads/${tid}/artifacts`).then(r => r.json())).files,
|
||||
})),
|
||||
);
|
||||
```
|
||||
|
||||
### 4.2 用户点击文件时
|
||||
|
||||
```ts
|
||||
// 在线预览(mime 是 markdown/text/pdf 自动 inline)
|
||||
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}`);
|
||||
|
||||
// 强制下载
|
||||
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}?download=true`);
|
||||
```
|
||||
|
||||
### 4.3 "重新起草新规划" 按钮
|
||||
|
||||
```ts
|
||||
// 跟现在一样,纯前端:清掉 dialogues + 跳 step1
|
||||
setCurrentStep(1);
|
||||
setInteractiveMessages(...); // 已有逻辑
|
||||
setStep2RoundtableDialogues([]);
|
||||
// 不需要调任何接口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 改造前端的具体文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` | **大改**。删除四张 metric 卡 / 三阶段路线图 / 论证要件 / 裁定判词;新增结论卡 + 席位折叠列表 + 文件清单 |
|
||||
| `frontend-web/src/roundtable-planning/api/multi-agent.ts` | 新增 `listSessionArtifacts(threadId)` helper(封装 §3.1 接口) |
|
||||
| `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 把 `threadIds` / `coordinatorName` / `lastLeaderContent` 传进 `HighFidelityReport` 的 props(目前没传) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 改造后端的具体文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | 新增 `GET /api/threads/{tid}/artifacts`(不带 path,列目录) |
|
||||
| `offline-backend-20260512/backend/CLAUDE.md` | 在 artifacts 路由表里增加新条目说明 |
|
||||
| `offline-backend-20260512/backend/tests/test_artifacts_list.py` | 列目录新接口的单测(覆盖:空目录返回 [];traversal 路径返回 403;不存在 thread 返回 404)|
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施优先级
|
||||
|
||||
| 优先级 | 项 | 备注 |
|
||||
|---|---|---|
|
||||
| P0 | 新增 `GET /api/threads/{tid}/artifacts` 列文件接口 | 必需,没它前端无法列文件 |
|
||||
| P0 | step3 大改:删 mock 区块、加结论/席位/文件三块 | 主要价值改造 |
|
||||
| P1 | 前端 helper `listSessionArtifacts` | 封装 fetch + 类型 |
|
||||
| P2 | 单测 `test_artifacts_list.py` | 上线后跟进 |
|
||||
| P3 | `POST /api/multi-agent/sessions/summary` 聚合接口 | 13 次→1 次,体感优化,**非必需** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 显式不做的事 ❌
|
||||
|
||||
1. **不做"PDF 导出"**:后端没有现成 PDF 渲染管线,要做就是单独的项目。当前页面那个 `导出方案 PDF` 按钮直接删掉,或者保留按钮但点击只触发 `window.print()`(让浏览器自己生成 PDF)。
|
||||
2. **不做"安全分享"**:项目里已有 `/api/thread-shares`(per-thread 分享)和 `/api/public/shares`(公开页面),但那是针对单个 thread 的,不是多 thread 会话的。要做"多 thread 整合分享"是新功能,远超 step3 的范围。
|
||||
3. **不做"指标雷达图"**(什么 90.3 分总评):没有真实数据源,强行加只会变成下一个 mock。
|
||||
4. **不做"签发投产"**:纯展示按钮,不接业务系统,删。
|
||||
5. **不做"轮次/对抗次数"显示**:除非前端记一下 `dispatch_count`,否则后端没现成统计。如果真要展示,用现成的"派活了几次"代替"对抗 N 轮"。
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收清单
|
||||
|
||||
step3 改造完成后应该能:
|
||||
|
||||
- [ ] 看到当前 session 的总控最终结论,markdown 渲染
|
||||
- [ ] 看到 6 个席位各自的最终交付内容(默认折叠,点击展开)
|
||||
- [ ] 看到所有 sub-agent 写到 outputs 的文件列表,含文件名、大小、类型 icon
|
||||
- [ ] 点击文件名能在线预览(markdown / pdf / 图片)
|
||||
- [ ] 点击下载按钮能下载(带正确文件名)
|
||||
- [ ] 点"重新起草新规划"能回到 step1 且 step2 内存清空
|
||||
- [ ] 页面不再包含任何来自 `budgetLimit` 的"假指标"
|
||||
|
||||
---
|
||||
|
||||
## 10. 路径速查
|
||||
|
||||
| 资源 | 位置 |
|
||||
|---|---|
|
||||
| Step 3 组件 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||||
| 父页面 (state 持有者) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||
| 后端 artifacts 路由 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
|
||||
| 后端 multi-agent 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
|
||||
| 物理 outputs 目录 | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,312 +0,0 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 最后更新:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
### Canvas 系统(双栏写作/编码工作区)
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
|
||||
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
|
||||
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
|
||||
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
|
||||
| 阶段七 | 版本历史导航 | ✅ 已完成 |
|
||||
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
|
||||
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
|
||||
|
||||
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
|
||||
|
||||
| 步骤 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
|
||||
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
|
||||
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
|
||||
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
|
||||
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
|
||||
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
|
||||
|
||||
**当前状态:全部功能已完成 🎉**
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端核心文件结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
├── api/
|
||||
│ ├── client.ts # LangGraph SDK 客户端实例
|
||||
│ ├── models.ts # 模型列表 API
|
||||
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
|
||||
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
|
||||
│ └── threads.ts # createCanvasThread、getCanvasThread
|
||||
├── components/
|
||||
│ ├── artifact/
|
||||
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
|
||||
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
|
||||
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
|
||||
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
|
||||
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
|
||||
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
|
||||
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
|
||||
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
|
||||
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
|
||||
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
|
||||
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
|
||||
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
|
||||
│ ├── chat/
|
||||
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
|
||||
│ ├── layout/
|
||||
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
|
||||
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
|
||||
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
|
||||
│ └── selectors/
|
||||
│ └── CanvasModelSelector.tsx # 模型下拉选择器
|
||||
├── contexts/
|
||||
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
|
||||
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
|
||||
│ ├── CanvasThreadContext.tsx # threadId、modelName
|
||||
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
|
||||
├── editor/
|
||||
│ ├── TiptapEditor.tsx # EditorContent 包装器
|
||||
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
|
||||
├── hooks/
|
||||
│ ├── useCanvasArtifact.ts # artifact 初始化工具
|
||||
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
|
||||
│ ├── useCanvasSelection.ts # 文字/代码选区状态
|
||||
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
|
||||
│ ├── useCanvasThread.ts # 线程加载工具
|
||||
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
|
||||
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
|
||||
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
|
||||
├── pages/
|
||||
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
|
||||
│ └── CanvasThreadPage.tsx # Provider 装配层
|
||||
├── routes/
|
||||
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
|
||||
├── types/
|
||||
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
│ ├── model.ts # CanvasModel
|
||||
│ ├── quick-action.ts # QuickAction、QuickActionId
|
||||
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
│ └── thread.ts # CanvasMessage、CanvasThreadValues
|
||||
└── utils/
|
||||
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
|
||||
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
|
||||
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
|
||||
├── selection.ts # buildMessageWithSelection
|
||||
├── stream.ts # 流式工具
|
||||
└── thread.ts # 线程工具
|
||||
```
|
||||
|
||||
### 2.2 后端核心文件结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── packages/harness/deerflow/agents/
|
||||
│ ├── canvas_agent/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
|
||||
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
|
||||
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
|
||||
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
|
||||
└── app/gateway/
|
||||
├── app.py # 注册 writing router
|
||||
└── routers/
|
||||
└── writing.py # POST /api/writing/rewrite(SSE 流式)
|
||||
```
|
||||
|
||||
**langgraph.json** 注册了两个 graph:
|
||||
- `lead_agent` → 主聊天 Agent
|
||||
- `canvas_agent` → Canvas 写作 Agent
|
||||
|
||||
### 2.3 关键 Context 层级
|
||||
|
||||
```
|
||||
CanvasThreadPage
|
||||
├── CanvasThreadProvider # threadId、modelName
|
||||
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
|
||||
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
|
||||
│ │ └── CanvasShell
|
||||
│ │ └── CanvasResizableLayout
|
||||
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
|
||||
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
|
||||
│ │ └── CanvasArtifactPanel
|
||||
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、关键架构决策与注意事项
|
||||
|
||||
### 3.1 流式架构:单例流(重要)
|
||||
|
||||
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
|
||||
|
||||
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
|
||||
|
||||
```
|
||||
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
|
||||
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
|
||||
```
|
||||
|
||||
### 3.2 artifact 内容注入机制(重要)
|
||||
|
||||
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
|
||||
|
||||
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
|
||||
|
||||
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
|
||||
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
|
||||
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
|
||||
|
||||
```typescript
|
||||
// utils/quick-actions.ts
|
||||
export function buildQuickActionMessage(id, content) {
|
||||
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
|
||||
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
|
||||
}
|
||||
|
||||
// core/messages/utils.ts
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ThinkTagParser(AI 改写思考过程解析)
|
||||
|
||||
后端支持两种思考格式:
|
||||
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
|
||||
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
|
||||
|
||||
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
|
||||
|
||||
### 3.4 版本历史:本地切换
|
||||
|
||||
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
|
||||
|
||||
### 3.5 Writing Editor 流程(三阶段状态机)
|
||||
|
||||
```
|
||||
idle → menu → preview → idle
|
||||
↑ ↓
|
||||
openAiMenu closeAll
|
||||
```
|
||||
|
||||
- **idle**:正常编辑
|
||||
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
|
||||
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
|
||||
|
||||
---
|
||||
|
||||
## 四、已解决的技术难题
|
||||
|
||||
| 问题 | 解法 |
|
||||
|------|------|
|
||||
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
|
||||
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
|
||||
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
|
||||
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
|
||||
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
|
||||
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
|
||||
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
|
||||
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
|
||||
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
|
||||
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
|
||||
|
||||
---
|
||||
|
||||
## 五、当前已知限制(非 bug,设计取舍)
|
||||
|
||||
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
|
||||
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
|
||||
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
|
||||
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
|
||||
|
||||
---
|
||||
|
||||
## 六、快速开始下一次工作
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 后端(从 offline-backend-20260512/backend/)
|
||||
make dev # 启动 Gateway API on port 8001
|
||||
|
||||
# 前端(从 frontend-web/)
|
||||
pnpm dev # 启动 Vite on port 5174
|
||||
```
|
||||
|
||||
### 访问路径
|
||||
|
||||
- 主页:`http://localhost:5174`
|
||||
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
|
||||
|
||||
### 类型检查
|
||||
|
||||
```bash
|
||||
cd frontend-web && pnpm typecheck
|
||||
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、如需继续开发的潜在方向
|
||||
|
||||
| 方向 | 描述 | 优先级 |
|
||||
|------|------|------|
|
||||
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
|
||||
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
|
||||
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
|
||||
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
|
||||
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
|
||||
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键文件速查
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
|
||||
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
|
||||
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
|
||||
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
|
||||
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
|
||||
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
|
||||
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
|
||||
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
|
||||
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
|
||||
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
|
||||
|
||||
---
|
||||
|
||||
## 九、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
|
||||
详细进度见:frontend-web/docs/openCanvas-progress.md
|
||||
|
||||
当前全部功能已完成,包括:
|
||||
- Canvas 双栏布局、canvas_agent、版本历史
|
||||
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
|
||||
- 快捷动作(通过 CanvasStreamContext 统一流)
|
||||
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
|
||||
|
||||
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
|
||||
```
|
||||
@@ -1,950 +0,0 @@
|
||||
# Open Canvas 模式需求拆解文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
|
||||
|
||||
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
|
||||
|
||||
- 前端:基于 `React 19 + Vite + react-router-dom`
|
||||
- 后端:基于 `Python + FastAPI + LangGraph`
|
||||
- 模型层:支持云模型与本地模型自由配置
|
||||
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
|
||||
|
||||
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终目标
|
||||
|
||||
### 2.1 用户视角目标
|
||||
|
||||
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
|
||||
|
||||
- 左侧是聊天区,右侧是 Canvas 编辑区
|
||||
- 可以从空白文本或代码模板快速开始
|
||||
- 可以通过聊天让 AI 生成一份结构化 artifact
|
||||
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
|
||||
- artifact 支持版本历史切换
|
||||
- artifact 支持文本和代码两种主要类型
|
||||
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
|
||||
- 支持启用 Web 搜索增强
|
||||
- 支持切换模型
|
||||
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
|
||||
|
||||
### 2.2 工程视角目标
|
||||
|
||||
新增的 Canvas 模式需要满足以下工程原则:
|
||||
|
||||
- 不破坏现有 DeerFlow 工作区
|
||||
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
|
||||
- 可逐阶段上线
|
||||
- 前后端状态协议清晰
|
||||
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
### 3.1 本次需求包含
|
||||
|
||||
- 前端新增 `open-canvas` 专用目录
|
||||
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
|
||||
- 前端新增 Canvas 页面、上下文、组件、状态管理
|
||||
- 后端新增 Canvas 专用线程状态字段
|
||||
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
|
||||
- 模型配置统一为 DeerFlow 后端配置驱动
|
||||
- 支持本地大模型配置与模型列表动态下发
|
||||
- 支持结构化 artifact 的保存、读取、更新、版本切换
|
||||
|
||||
### 3.2 本次需求不包含
|
||||
|
||||
- 一次性复刻 open-canvas 全部 UI 细节
|
||||
- 完整照搬 Supabase 鉴权体系
|
||||
- 直接复用 open-canvas 的 Next.js API Route
|
||||
- 直接运行 open-canvas 的 LangGraph.js 后端
|
||||
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
|
||||
|
||||
### 3.3 迁移原则
|
||||
|
||||
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
|
||||
- 前端尽量复用其交互设计和数据结构
|
||||
- 后端以 Python 实现同等能力
|
||||
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体设计思路
|
||||
|
||||
### 4.1 前端设计思路
|
||||
|
||||
在现有 `frontend-web/src` 下新增一个专用目录,例如:
|
||||
|
||||
```text
|
||||
src/
|
||||
open-canvas/
|
||||
pages/
|
||||
routes/
|
||||
components/
|
||||
contexts/
|
||||
hooks/
|
||||
types/
|
||||
api/
|
||||
utils/
|
||||
constants/
|
||||
```
|
||||
|
||||
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
|
||||
|
||||
建议路由形式:
|
||||
|
||||
- `/canvas`
|
||||
- `/canvas/:threadId`
|
||||
|
||||
也可以挂在现有页面布局下,例如:
|
||||
|
||||
- `/workspace/canvas`
|
||||
- `/workspace/canvas/:threadId`
|
||||
|
||||
推荐优先采用独立一级功能路由:
|
||||
|
||||
- 逻辑更清晰
|
||||
- 状态隔离更容易
|
||||
- 不容易污染现有 `workspace` 功能
|
||||
|
||||
### 4.2 后端设计思路
|
||||
|
||||
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
|
||||
|
||||
#### 方案 A:新增独立 Canvas Assistant
|
||||
|
||||
- 新增 `canvas_agent`
|
||||
- 使用单独的 LangGraph 状态定义
|
||||
- 前端调用时通过 `assistant_id=canvas_agent`
|
||||
|
||||
优点:
|
||||
|
||||
- 与现有 `lead_agent` 隔离
|
||||
- 风险更低
|
||||
- 更容易对齐 open-canvas 的图结构
|
||||
|
||||
缺点:
|
||||
|
||||
- 会产生一套新的 Agent 图维护成本
|
||||
|
||||
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
|
||||
|
||||
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
|
||||
- Agent 根据模式进入 Canvas 路径
|
||||
|
||||
优点:
|
||||
|
||||
- 统一入口
|
||||
|
||||
缺点:
|
||||
|
||||
- 会显著增加现有 Agent 复杂度
|
||||
- 容易影响现有聊天和文件产物模式
|
||||
|
||||
推荐先做方案 A。
|
||||
|
||||
### 4.3 模型配置设计思路
|
||||
|
||||
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
|
||||
|
||||
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
|
||||
|
||||
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `provider`
|
||||
- `supports_thinking`
|
||||
- `supports_reasoning_effort`
|
||||
- `supports_vision`
|
||||
- `supports_tool_calling`
|
||||
- `supports_artifact_editing`
|
||||
- `supports_code_editing`
|
||||
- `temperature_range`
|
||||
- `max_tokens_range`
|
||||
- `base_url_source` 或 provider 标识
|
||||
- `is_local`
|
||||
|
||||
---
|
||||
|
||||
## 5. 与当前项目的关键差异
|
||||
|
||||
### 5.1 当前 DeerFlow 现状
|
||||
|
||||
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
|
||||
|
||||
- `artifacts: string[]`
|
||||
|
||||
这更接近“生成文件产物”的工作模式。
|
||||
|
||||
前端当前 artifact 面板也主要是:
|
||||
|
||||
- 文件列表
|
||||
- 文件详情
|
||||
- 文件下载/预览
|
||||
|
||||
### 5.2 open-canvas 的关键能力
|
||||
|
||||
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
|
||||
|
||||
- 一个 conversation 对应一个主 artifact
|
||||
- artifact 具有类型、标题、内容、版本
|
||||
- 用户可以选中部分内容让 AI 改写
|
||||
- 每次改写都会生成新版本
|
||||
|
||||
### 5.3 需要新增的核心能力
|
||||
|
||||
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
|
||||
|
||||
- 当前 artifact
|
||||
- 当前版本索引
|
||||
- 历史版本列表
|
||||
- 选区信息
|
||||
- 当前编辑模式
|
||||
- 快速动作状态
|
||||
- Web 搜索结果状态
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施方案
|
||||
|
||||
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段一:前端路由与目录骨架
|
||||
|
||||
### 7.1 目标
|
||||
|
||||
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
|
||||
|
||||
### 7.2 需求
|
||||
|
||||
- 新增 `src/open-canvas/` 目录
|
||||
- 新增 Canvas 页面路由
|
||||
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
|
||||
- 提供 Canvas 首页和 Canvas Thread 页
|
||||
- 支持空状态展示
|
||||
|
||||
### 7.3 建议目录
|
||||
|
||||
```text
|
||||
src/open-canvas/
|
||||
api/
|
||||
threads.ts
|
||||
runs.ts
|
||||
models.ts
|
||||
components/
|
||||
layout/
|
||||
chat/
|
||||
canvas/
|
||||
artifacts/
|
||||
quick-actions/
|
||||
selectors/
|
||||
contexts/
|
||||
CanvasThreadContext.tsx
|
||||
CanvasGraphContext.tsx
|
||||
CanvasUIContext.tsx
|
||||
hooks/
|
||||
useCanvasThread.ts
|
||||
useCanvasStream.ts
|
||||
useCanvasArtifact.ts
|
||||
pages/
|
||||
CanvasHomePage.tsx
|
||||
CanvasThreadPage.tsx
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx
|
||||
types/
|
||||
artifact.ts
|
||||
thread.ts
|
||||
model.ts
|
||||
utils/
|
||||
artifact.ts
|
||||
selection.ts
|
||||
stream.ts
|
||||
constants/
|
||||
index.ts
|
||||
```
|
||||
|
||||
### 7.4 路由建议
|
||||
|
||||
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
|
||||
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
|
||||
|
||||
建议形式:
|
||||
|
||||
```text
|
||||
/canvas
|
||||
/canvas/:threadId
|
||||
```
|
||||
|
||||
### 7.5 验收标准
|
||||
|
||||
- 能访问 Canvas 首页
|
||||
- 能访问指定 thread 的 Canvas 页面
|
||||
- 不影响现有 `workspace/*` 与 `strategy/*`
|
||||
- 页面有独立布局骨架
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段二:前端 Canvas 布局与交互容器
|
||||
|
||||
### 8.1 目标
|
||||
|
||||
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
|
||||
|
||||
### 8.2 需求
|
||||
|
||||
- 左侧聊天区
|
||||
- 右侧画布区
|
||||
- 支持面板拖拽或折叠
|
||||
- 初始空状态
|
||||
- 支持“快速开始”:
|
||||
- 新建空白文本 artifact
|
||||
- 新建代码 artifact
|
||||
|
||||
### 8.3 组件拆分建议
|
||||
|
||||
- `CanvasShell`
|
||||
- `CanvasSidebar`
|
||||
- `CanvasChatPanel`
|
||||
- `CanvasArtifactPanel`
|
||||
- `CanvasEmptyState`
|
||||
- `QuickStartActions`
|
||||
|
||||
### 8.4 快速开始能力
|
||||
|
||||
用户进入空线程时可以选择:
|
||||
|
||||
- 开始写文章
|
||||
- 开始写代码
|
||||
- 从模板开始
|
||||
|
||||
首版模板可只做最小集:
|
||||
|
||||
- 空白文档
|
||||
- HTML 模板
|
||||
- React 组件模板
|
||||
- Python 脚本模板
|
||||
|
||||
### 8.5 验收标准
|
||||
|
||||
- 页面可稳定展示双栏布局
|
||||
- 快速开始能创建前端本地 artifact 状态
|
||||
- 代码和文本可分别进入不同渲染器
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段三:结构化 Artifact 数据模型
|
||||
|
||||
### 9.1 目标
|
||||
|
||||
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
|
||||
|
||||
### 9.2 核心要求
|
||||
|
||||
需要支持以下能力:
|
||||
|
||||
- 文本 artifact
|
||||
- 代码 artifact
|
||||
- 标题
|
||||
- 当前版本索引
|
||||
- 版本历史
|
||||
- 当前选中版本切换
|
||||
- 后续支持局部高亮编辑
|
||||
|
||||
### 9.3 建议数据结构
|
||||
|
||||
```ts
|
||||
type CanvasArtifact = {
|
||||
currentIndex: number;
|
||||
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
|
||||
};
|
||||
|
||||
type CanvasArtifactText = {
|
||||
index: number;
|
||||
type: "text";
|
||||
title: string;
|
||||
fullMarkdown: string;
|
||||
};
|
||||
|
||||
type CanvasArtifactCode = {
|
||||
index: number;
|
||||
type: "code";
|
||||
title: string;
|
||||
language: string;
|
||||
code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.4 线程状态建议新增字段
|
||||
|
||||
前端与后端需共同支持:
|
||||
|
||||
- `artifact`
|
||||
- `highlighted_text`
|
||||
- `highlighted_code`
|
||||
- `canvas_mode`
|
||||
- `web_search_enabled`
|
||||
- `web_search_results`
|
||||
- `quick_action_id`
|
||||
|
||||
### 9.5 与现有 `artifacts: string[]` 的关系
|
||||
|
||||
建议保留现有 `artifacts: string[]` 不动。
|
||||
|
||||
新增字段:
|
||||
|
||||
- `artifact` 用于 Canvas 主文档
|
||||
- `artifacts` 继续用于文件产物
|
||||
|
||||
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
|
||||
|
||||
### 9.6 验收标准
|
||||
|
||||
- 前端类型定义完成
|
||||
- 后端状态字段设计完成
|
||||
- 确认与现有线程状态兼容
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段四:后端线程状态与 API 扩展
|
||||
|
||||
### 10.1 目标
|
||||
|
||||
让后端线程状态可以安全存储和返回 Canvas Artifact。
|
||||
|
||||
### 10.2 需求
|
||||
|
||||
- 扩展 Python `ThreadState`
|
||||
- 扩展状态序列化逻辑
|
||||
- 确保 `/api/threads/*` 返回结构化 `artifact`
|
||||
- 确保 LangGraph 流中可带上 `artifact` 更新
|
||||
|
||||
### 10.3 后端改造点
|
||||
|
||||
建议关注以下模块:
|
||||
|
||||
- `packages/harness/deerflow/agents/thread_state.py`
|
||||
- 线程 state serializer
|
||||
- `app/gateway/routers/threads.py`
|
||||
- 流事件适配层
|
||||
|
||||
### 10.4 注意事项
|
||||
|
||||
- 旧线程没有 `artifact` 字段时必须兼容
|
||||
- 状态更新不能破坏现有消息结构
|
||||
- 必须确保 JSON 可序列化
|
||||
|
||||
### 10.5 验收标准
|
||||
|
||||
- 新建线程可写入 `artifact`
|
||||
- 读取线程时可返回 `artifact`
|
||||
- 不影响现有线程读取
|
||||
- 前端能从线程 state 中恢复 Canvas 内容
|
||||
|
||||
---
|
||||
|
||||
## 11. 阶段五:Canvas 专用 Agent 图
|
||||
|
||||
### 11.1 目标
|
||||
|
||||
实现与 open-canvas 类似的 AI 路由能力。
|
||||
|
||||
### 11.2 推荐方案
|
||||
|
||||
新增 `canvas_agent`,单独维护图逻辑。
|
||||
|
||||
### 11.3 节点拆分建议
|
||||
|
||||
首版建议至少拆成这些节点:
|
||||
|
||||
- `route_user_intent`
|
||||
- `reply_general_input`
|
||||
- `generate_artifact`
|
||||
- `rewrite_artifact`
|
||||
- `update_highlighted_text`
|
||||
- `update_highlighted_code`
|
||||
- `generate_followup`
|
||||
|
||||
二期可加:
|
||||
|
||||
- `rewrite_artifact_theme`
|
||||
- `rewrite_code_theme`
|
||||
- `change_length`
|
||||
- `change_reading_level`
|
||||
- `translate_artifact`
|
||||
- `add_comments`
|
||||
- `add_logs`
|
||||
- `fix_bugs`
|
||||
- `port_language`
|
||||
- `web_search_enrich`
|
||||
|
||||
### 11.4 路由逻辑要求
|
||||
|
||||
系统需要能判断:
|
||||
|
||||
- 用户是在闲聊
|
||||
- 用户要新生成 artifact
|
||||
- 用户要整体重写当前 artifact
|
||||
- 用户要局部修改当前 artifact
|
||||
- 用户要执行某个快捷动作
|
||||
|
||||
### 11.5 状态输入要求
|
||||
|
||||
节点需要读取:
|
||||
|
||||
- 当前消息历史
|
||||
- 当前 artifact
|
||||
- 当前选区
|
||||
- 当前模型配置
|
||||
- Web 搜索开关
|
||||
|
||||
### 11.6 输出要求
|
||||
|
||||
节点输出必须能更新:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `web_search_results`
|
||||
- `followup`
|
||||
|
||||
### 11.7 验收标准
|
||||
|
||||
- 能根据输入创建新 artifact
|
||||
- 能根据输入整体改写 artifact
|
||||
- 能保留版本历史
|
||||
- 能在改写后返回自然语言 followup
|
||||
|
||||
---
|
||||
|
||||
## 12. 阶段六:前端流式同步与版本管理
|
||||
|
||||
### 12.1 目标
|
||||
|
||||
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
|
||||
|
||||
### 12.2 需求
|
||||
|
||||
- 复用现有 LangGraph stream client
|
||||
- 在 Canvas 模式中解析 `artifact` 更新
|
||||
- 支持流式文本更新
|
||||
- 支持版本切换
|
||||
- 支持断线后恢复线程状态
|
||||
|
||||
### 12.3 前端状态层建议
|
||||
|
||||
- `CanvasThreadContext`:线程基础信息
|
||||
- `CanvasGraphContext`:消息、artifact、streaming 状态
|
||||
- `CanvasUIContext`:面板、编辑态、选中态
|
||||
|
||||
### 12.4 关键能力
|
||||
|
||||
- 当前版本切换
|
||||
- 新版本创建后自动定位到最新版本
|
||||
- 手动编辑内容后保存
|
||||
- 流结束后线程状态持久化
|
||||
|
||||
### 12.5 验收标准
|
||||
|
||||
- 发送消息时聊天和画布可同步更新
|
||||
- 版本数会增加
|
||||
- 切换历史版本稳定
|
||||
- 刷新页面后线程状态能恢复
|
||||
|
||||
---
|
||||
|
||||
## 13. 阶段七:局部选区改写
|
||||
|
||||
### 13.1 目标
|
||||
|
||||
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
|
||||
|
||||
### 13.2 文本选区需求
|
||||
|
||||
- 支持在 Markdown 文本中选择一段文本
|
||||
- 记录选区位置与原始文本
|
||||
- 用户发出修改请求后,仅重写该选区
|
||||
- 将选区结果合并为新版本 artifact
|
||||
|
||||
### 13.3 代码选区需求
|
||||
|
||||
- 支持在代码编辑器中选择一段代码
|
||||
- 记录 start/end index 或行列信息
|
||||
- AI 只返回替换片段
|
||||
- 前端或后端负责将新片段拼接回完整代码
|
||||
|
||||
### 13.4 实现建议
|
||||
|
||||
首版建议:
|
||||
|
||||
- 文本:前端记录纯文本字符位置
|
||||
- 代码:使用 CodeMirror selection 信息
|
||||
|
||||
不建议首版就做复杂块级富文本局部编辑。
|
||||
|
||||
### 13.5 风险
|
||||
|
||||
- Markdown 渲染态与源码态位置映射复杂
|
||||
- 富文本块级位置难维护
|
||||
- 流式更新中选区同步容易错位
|
||||
|
||||
### 13.6 首版折中方案
|
||||
|
||||
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
|
||||
|
||||
### 13.7 验收标准
|
||||
|
||||
- 文本局部改写成功
|
||||
- 代码局部改写成功
|
||||
- 原内容未改动部分保持不变
|
||||
- 新版本正确入历史
|
||||
|
||||
---
|
||||
|
||||
## 14. 阶段八:快捷动作系统
|
||||
|
||||
### 14.1 目标
|
||||
|
||||
实现 open-canvas 风格的快捷动作体系。
|
||||
|
||||
### 14.2 首批推荐动作
|
||||
|
||||
文本类:
|
||||
|
||||
- 扩写
|
||||
- 精简
|
||||
- 改正式语气
|
||||
- 改口语语气
|
||||
- 改阅读难度
|
||||
- 翻译
|
||||
|
||||
代码类:
|
||||
|
||||
- 加注释
|
||||
- 加日志
|
||||
- 修复 bug
|
||||
- 优化代码
|
||||
- 转换语言
|
||||
|
||||
### 14.3 设计方式
|
||||
|
||||
快捷动作不要直接写死在 UI,建议配置化:
|
||||
|
||||
- `id`
|
||||
- `label`
|
||||
- `type`
|
||||
- `applicable_to`
|
||||
- `prompt_template`
|
||||
|
||||
### 14.4 后端设计
|
||||
|
||||
快捷动作可通过:
|
||||
|
||||
- 前端传 `quick_action_id`
|
||||
- 后端路由节点根据动作 id 选择 prompt
|
||||
|
||||
### 14.5 验收标准
|
||||
|
||||
- 文本动作可执行
|
||||
- 代码动作可执行
|
||||
- 结果进入新版本
|
||||
- 不同动作提示词可扩展
|
||||
|
||||
---
|
||||
|
||||
## 15. 阶段九:模型配置统一与本地模型支持
|
||||
|
||||
### 15.1 目标
|
||||
|
||||
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
|
||||
|
||||
### 15.2 原则
|
||||
|
||||
- 后端 `config.yaml` 为真源
|
||||
- 前端只展示后端暴露的模型信息
|
||||
- 不维护一份脱离后端的前端常量模型表
|
||||
|
||||
### 15.3 本地模型支持范围
|
||||
|
||||
建议优先支持两类:
|
||||
|
||||
- `Ollama`
|
||||
- OpenAI-compatible 本地服务
|
||||
- LM Studio
|
||||
- vLLM
|
||||
- LocalAI
|
||||
- one-api 风格代理
|
||||
|
||||
### 15.4 后端配置建议
|
||||
|
||||
后端模型配置应至少支持:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `use`
|
||||
- `model`
|
||||
- `api_key`
|
||||
- `base_url`
|
||||
- `supports_vision`
|
||||
- `supports_thinking`
|
||||
- `supports_tool_calling`
|
||||
- `is_local`
|
||||
- `canvas_capabilities`
|
||||
|
||||
### 15.5 Canvas 模式额外要求
|
||||
|
||||
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
|
||||
|
||||
- `supports_canvas_generation`
|
||||
- `supports_canvas_rewrite`
|
||||
- `supports_canvas_code_edit`
|
||||
- `supports_streaming_text`
|
||||
- `supports_streaming_tool_calls`
|
||||
|
||||
### 15.6 与 open-canvas 的关系
|
||||
|
||||
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
|
||||
|
||||
### 15.7 验收标准
|
||||
|
||||
- 前端模型选择器可显示后端返回模型
|
||||
- 可切换本地模型
|
||||
- Canvas 模式能用本地模型生成和改写
|
||||
|
||||
---
|
||||
|
||||
## 16. 阶段十:Web 搜索增强
|
||||
|
||||
### 16.1 目标
|
||||
|
||||
实现类似 open-canvas 的可选 Web 搜索增强。
|
||||
|
||||
### 16.2 需求
|
||||
|
||||
- UI 提供 Web 搜索开关
|
||||
- 后端在需要时走搜索节点
|
||||
- 搜索结果写入线程状态
|
||||
- 结果可在画布旁侧栏展示
|
||||
|
||||
### 16.3 首版建议
|
||||
|
||||
只做:
|
||||
|
||||
- 开关
|
||||
- 搜索结果列表
|
||||
- 搜索结果用于生成 artifact
|
||||
|
||||
暂不做复杂网页抓取编辑链路。
|
||||
|
||||
### 16.4 验收标准
|
||||
|
||||
- 打开搜索后可返回搜索结果
|
||||
- 搜索结果可参与 artifact 生成
|
||||
- 结果展示不阻塞主画布
|
||||
|
||||
---
|
||||
|
||||
## 17. 阶段十一:持久化、导出与文件产物联动
|
||||
|
||||
### 17.1 目标
|
||||
|
||||
让 Canvas 文档与 DeerFlow 原有文件能力协同。
|
||||
|
||||
### 17.2 需求
|
||||
|
||||
- artifact 持久化在线程 state 中
|
||||
- 可手动导出为文件
|
||||
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
|
||||
- Canvas artifact 与 `artifacts: string[]` 可双向关联
|
||||
|
||||
### 17.3 推荐能力
|
||||
|
||||
- 导出 Markdown
|
||||
- 导出代码文件
|
||||
- 另存为 artifact file
|
||||
- 从 artifact file 回填到 Canvas
|
||||
|
||||
### 17.4 验收标准
|
||||
|
||||
- 当前版本可导出
|
||||
- 导出文件可在现有文件面板查看
|
||||
- 文件型 artifact 和 Canvas artifact 能共存
|
||||
|
||||
---
|
||||
|
||||
## 18. 推荐开发顺序
|
||||
|
||||
建议严格按以下顺序推进:
|
||||
|
||||
1. 路由和目录骨架
|
||||
2. Canvas 页面布局
|
||||
3. 前端 artifact 数据模型
|
||||
4. 后端线程状态扩展
|
||||
5. 新建 Canvas Agent 图
|
||||
6. 前后端流式同步
|
||||
7. 整体生成与整体改写
|
||||
8. 局部选区改写
|
||||
9. 快捷动作
|
||||
10. 本地模型支持完善
|
||||
11. Web 搜索增强
|
||||
12. 导出与文件联动
|
||||
|
||||
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
|
||||
|
||||
---
|
||||
|
||||
## 19. 里程碑定义
|
||||
|
||||
### M1:页面可进入
|
||||
|
||||
- 路由接通
|
||||
- 双栏布局完成
|
||||
- 本地 mock artifact 可渲染
|
||||
|
||||
### M2:后端状态打通
|
||||
|
||||
- 线程可保存结构化 artifact
|
||||
- 页面刷新后能恢复
|
||||
|
||||
### M3:AI 生成可用
|
||||
|
||||
- 可新建文本/代码 artifact
|
||||
- 可整体改写
|
||||
- 可产生版本历史
|
||||
|
||||
### M4:核心体验可用
|
||||
|
||||
- 选区改写可用
|
||||
- 快捷动作可用
|
||||
- 模型切换可用
|
||||
|
||||
### M5:增强能力完整
|
||||
|
||||
- 本地模型支持完善
|
||||
- Web 搜索增强
|
||||
- 导出和文件联动
|
||||
|
||||
---
|
||||
|
||||
## 20. 风险与难点
|
||||
|
||||
### 20.1 最大难点
|
||||
|
||||
- 结构化 artifact 状态从无到有
|
||||
- 局部选区改写的精确性
|
||||
- 流式状态与版本管理的一致性
|
||||
- 不破坏现有 DeerFlow 文件型 artifact 体系
|
||||
|
||||
### 20.2 技术风险
|
||||
|
||||
- 线程 state JSON 结构过大
|
||||
- 大模型对局部改写指令遵循不稳定
|
||||
- 本地模型 tool calling 与流式能力差异大
|
||||
- 文本渲染态与源码态位置映射复杂
|
||||
|
||||
### 20.3 规避建议
|
||||
|
||||
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
|
||||
- 首版只支持文本和代码两种 artifact 类型
|
||||
- 首版先做整体生成和整体改写,再做局部改写
|
||||
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
|
||||
|
||||
---
|
||||
|
||||
## 21. 验收总清单
|
||||
|
||||
### 21.1 前端
|
||||
|
||||
- 有独立 Canvas 路由
|
||||
- 有专用目录和模块边界
|
||||
- 双栏布局可用
|
||||
- 文本和代码两类 artifact 可渲染
|
||||
- 历史版本可切换
|
||||
- 选区改写可交互
|
||||
- 快捷动作可触发
|
||||
- 模型可切换
|
||||
|
||||
### 21.2 后端
|
||||
|
||||
- 线程状态支持 `artifact`
|
||||
- 新增 `canvas_agent` 或等价模式
|
||||
- 生成、重写、局部改写可用
|
||||
- 流式返回稳定
|
||||
- 模型列表接口支持 Canvas 能力元数据
|
||||
|
||||
### 21.3 本地模型
|
||||
|
||||
- 可配置 Ollama
|
||||
- 可配置 OpenAI-compatible 本地服务
|
||||
- 前端可见并可切换
|
||||
- 至少能完成文本生成和整体改写
|
||||
|
||||
---
|
||||
|
||||
## 22. 推荐首版最小可交付范围
|
||||
|
||||
如果要控制风险,建议首版只交付以下内容:
|
||||
|
||||
- 新增 `canvas/*` 路由
|
||||
- 双栏布局
|
||||
- 结构化 `artifact`
|
||||
- `canvas_agent`
|
||||
- 文本/代码 artifact 新建
|
||||
- 整体生成
|
||||
- 整体改写
|
||||
- 历史版本切换
|
||||
- 后端模型动态加载
|
||||
- Ollama 或本地 OpenAI-compatible 至少一种可用
|
||||
|
||||
先不要把以下内容放进首版强制目标:
|
||||
|
||||
- 富文本块级精确编辑
|
||||
- 全量快捷动作
|
||||
- 复杂 Web 搜索增强
|
||||
- 多 artifact 并行管理
|
||||
|
||||
---
|
||||
|
||||
## 23. 后续实施建议
|
||||
|
||||
建议下一步基于本文档继续输出两份落地文档:
|
||||
|
||||
### 23.1 前端实施清单
|
||||
|
||||
逐文件列出:
|
||||
|
||||
- 要新建哪些文件
|
||||
- 哪些组件可复用
|
||||
- 哪些页面要接入路由
|
||||
- 哪些状态要从现有 hooks 抽离
|
||||
|
||||
### 23.2 后端实施清单
|
||||
|
||||
逐模块列出:
|
||||
|
||||
- 要改哪些 Python 文件
|
||||
- 新增哪些状态字段
|
||||
- 新增哪些 Agent 节点
|
||||
- 哪些 API 需要扩展
|
||||
|
||||
---
|
||||
|
||||
## 24. 结论
|
||||
|
||||
这个需求很大,但完全可拆。
|
||||
|
||||
正确做法不是“先抄 UI”,而是按下面的主线推进:
|
||||
|
||||
- 先建前端 Canvas 子系统
|
||||
- 再建结构化 artifact 状态
|
||||
- 再建 Canvas Agent 图
|
||||
- 再补局部改写和快捷动作
|
||||
- 最后统一本地模型能力
|
||||
|
||||
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,243 +0,0 @@
|
||||
# 素材专家检索 —— 内网接口对接说明
|
||||
|
||||
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
|
||||
涉及的代码文件,以及常见错误的排查方式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 检索来源切换
|
||||
|
||||
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
|
||||
search_provider: internet
|
||||
# 以下两项仅在 search_provider=intranet 时生效
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples
|
||||
```
|
||||
|
||||
| `search_provider` 取值 | 含义 |
|
||||
|---|---|
|
||||
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
|
||||
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
|
||||
| `tavily` | Tavily 互联网检索 |
|
||||
| `demo` | 内置演示素材,仅用于本地调试 |
|
||||
|
||||
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
|
||||
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 检索关键词数量
|
||||
|
||||
素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
|
||||
范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
|
||||
|
||||
- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
|
||||
- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
|
||||
(`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
|
||||
- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
|
||||
规整到 `[1, 10]`,越界回退默认 `6`。
|
||||
- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
|
||||
|
||||
---
|
||||
|
||||
## 1.6 素材总量上限
|
||||
|
||||
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 素材总量上限,范围 1~100,非法值回退 20
|
||||
max_materials: 20
|
||||
```
|
||||
|
||||
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
|
||||
|
||||
| `max_materials` | 检索词数 | 每词检索条数 |
|
||||
|---|---|---|
|
||||
| 20 | 1 | 20 |
|
||||
| 20 | 2 | 10 |
|
||||
| 20 | 3 | 7(共 21,合并后截断到 20) |
|
||||
| 20 | 5 | 4 |
|
||||
|
||||
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
|
||||
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
|
||||
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
|
||||
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
|
||||
|
||||
> `config.yaml` 支持热加载,改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内网接口规格
|
||||
|
||||
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
|
||||
- **方法**:`POST`
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "检索关键词",
|
||||
"knowledge_base_name": "samples",
|
||||
"ts": "yes"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应体**:一个数组,每个元素结构如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"page_content": "正文片段",
|
||||
"metadata": {
|
||||
"m_title": "标题",
|
||||
"content": "内容",
|
||||
"recUuid": "原文id",
|
||||
"m_publish":"发布时间",
|
||||
"source1": "来源"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
我们需要的字段都在 `metadata` 里。
|
||||
|
||||
### 字段映射
|
||||
|
||||
后端把内网响应映射为统一的 `SearchResult` 结构:
|
||||
|
||||
| 内网响应字段 | SearchResult 字段 | 说明 |
|
||||
|--------------------------|--------------------|------|
|
||||
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
|
||||
| `metadata.content` | `content` | 内容,截断到 500 字 |
|
||||
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
|
||||
| `metadata.m_publish` | `published_at` | 发布时间 |
|
||||
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
|
||||
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
|
||||
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
|
||||
| — | `source_type` | 固定为 `"intranet"` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 对接逻辑与涉及文件
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
|
||||
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
|
||||
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
|
||||
|
||||
调用链:
|
||||
|
||||
```
|
||||
ai_writing.py(_make_config 注入 ai_writing_config)
|
||||
→ researcher_node
|
||||
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
|
||||
→ IntranetSearchProvider.search() # POST search_docs_es
|
||||
→ SearchResult[] # 字段映射
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
|
||||
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
|
||||
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
|
||||
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
|
||||
|
||||
「查看原文」链接拼接规则:
|
||||
|
||||
```
|
||||
INTRANET_SOURCE_URL_BASE + recUuid
|
||||
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
|
||||
```
|
||||
|
||||
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
|
||||
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理与排查
|
||||
|
||||
### 4.1 内网检索返回空 / 调不通
|
||||
|
||||
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
|
||||
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
|
||||
|
||||
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
|
||||
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
|
||||
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
|
||||
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
|
||||
**接口报错不影响写作流程继续**。
|
||||
|
||||
> 容错相关代码:
|
||||
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
|
||||
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
|
||||
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
|
||||
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
|
||||
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. **确认网络可达**:在后端所在机器执行
|
||||
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
|
||||
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
|
||||
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
|
||||
|
||||
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
|
||||
用户可以选择「重新搜索」或「无数据继续写作」。
|
||||
|
||||
### 4.2 字段缺失 / 响应结构变化
|
||||
|
||||
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
|
||||
单条数据字段缺失不会导致整批失败。
|
||||
|
||||
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
|
||||
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
|
||||
|
||||
### 4.3 「查看原文」按钮不显示
|
||||
|
||||
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
|
||||
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
|
||||
|
||||
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
|
||||
|
||||
### 4.4 原文链接打不开 / 地址不对
|
||||
|
||||
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
|
||||
若内网原文详情页地址有变,只改这一个常量即可。
|
||||
|
||||
### 4.5 检索到的素材全是无关内容
|
||||
|
||||
**现象**:素材卡片内容与写作意图完全不沾边。
|
||||
|
||||
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
|
||||
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
|
||||
|
||||
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
|
||||
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
|
||||
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调上线步骤
|
||||
|
||||
1. 确认后端机器能访问 `10.106.57.180:7863`。
|
||||
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
|
||||
3. 后端配置热加载生效(或重启后端)。
|
||||
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
|
||||
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
|
||||
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
|
||||
@@ -20,13 +20,13 @@ import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
|
||||
const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
storageKey="strategy-theme"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
|
||||
@@ -72,12 +72,14 @@ const menuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{
|
||||
id: "qa-roundtable",
|
||||
label: "圆桌规划",
|
||||
icon: Bot,
|
||||
path: "/page/roundtable/planning",
|
||||
},
|
||||
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
|
||||
// 需要重新开放时取消注释即可。
|
||||
// {
|
||||
// id: "qa-roundtable",
|
||||
// label: "圆桌规划",
|
||||
// icon: Bot,
|
||||
// path: "/page/roundtable/planning",
|
||||
// },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
@@ -400,63 +402,26 @@ export function PageSidebar({
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
: "border-r border-red-100 bg-white"
|
||||
} ${collapsed ? "w-16" : "w-64"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 items-center theme-transition ${
|
||||
isDark ? "border-b border-blue-800/30" : "border-b border-red-100"
|
||||
} ${collapsed ? "justify-center px-2" : "justify-between px-6"}`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
|
||||
isDark
|
||||
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg transition-transform duration-300 ease-out ${
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-gradient-to-br from-red-500 to-red-400"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
|
||||
<h1
|
||||
className={`text-sm font-semibold theme-transition ${
|
||||
isDark ? "text-white" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
CM助手
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={iconButtonClass}
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={`flex w-full items-center justify-center ${iconButtonClass}`}
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{visibleMenuItems.map((item) =>
|
||||
item.children ? renderBranch(item) : renderLeaf(item),
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AgentWelcome({
|
||||
@@ -14,8 +15,10 @@ export function AgentWelcome({
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
}) {
|
||||
const displayName = agent?.name ?? agentName;
|
||||
const description = agent?.description;
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -48,6 +48,7 @@ import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentCardProps {
|
||||
@@ -195,12 +196,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-base" title={agent.name}>
|
||||
{agent.name}
|
||||
<CardTitle className="truncate text-base" title={desensitize(agent.name)}>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{agent.model}
|
||||
{desensitize(agent.model)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -252,7 +253,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</div>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{agent.description}
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -272,7 +273,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{group}
|
||||
{desensitize(group)}
|
||||
</Badge>
|
||||
))}
|
||||
{agent.skills?.map((skill) => (
|
||||
@@ -286,7 +287,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
{desensitize(skill)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -377,7 +378,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
|
||||
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||
</DialogDescription>
|
||||
@@ -428,10 +429,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={skill.description}
|
||||
title={desensitize(skill.description ?? "")}
|
||||
className="h-8 max-w-[200px] rounded-r-none"
|
||||
>
|
||||
<span className="truncate">{skill.name}</span>
|
||||
<span className="truncate">{desensitize(skill.name)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -50,6 +50,7 @@ import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ArtifactLink } from "../citations/artifact-link";
|
||||
@@ -608,7 +609,7 @@ export function ArtifactFileDetail({
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
content={desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
@@ -618,7 +619,11 @@ export function ArtifactFileDetail({
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
value={
|
||||
canEditArtifact
|
||||
? (displayContent ?? "")
|
||||
: desensitize(displayContent ?? "")
|
||||
}
|
||||
readonly={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
|
||||
@@ -63,6 +63,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
|
||||
@@ -500,7 +501,7 @@ export function InputBox({
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{q.title}
|
||||
{desensitize(q.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
@@ -63,6 +64,8 @@ export function MarkdownContent({
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
const displayContent = desensitize(content);
|
||||
|
||||
return (
|
||||
<MessageResponse
|
||||
className={className}
|
||||
@@ -70,7 +73,7 @@ export function MarkdownContent({
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
{displayContent}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CopyButton } from "../copy-button";
|
||||
@@ -226,7 +227,7 @@ function MessageContent_({
|
||||
<TaskTrigger title="">
|
||||
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
|
||||
<Loader className="size-4" />
|
||||
<span>{contentToDisplay}</span>
|
||||
<span>{desensitize(contentToDisplay)}</span>
|
||||
</div>
|
||||
</TaskTrigger>
|
||||
</Task>
|
||||
@@ -240,7 +241,7 @@ function MessageContent_({
|
||||
<AIElementMessageContent className={className}>
|
||||
<Reasoning isStreaming={isLoading}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
|
||||
</Reasoning>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -259,7 +260,7 @@ function MessageContent_({
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{contentToDisplay}
|
||||
{desensitize(contentToDisplay)}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
@@ -102,7 +103,9 @@ export function MessageList({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmitAllClarifications = useCallback(async () => {
|
||||
const handleSubmitAllClarifications = useCallback(async (
|
||||
selectionOverrides?: Map<string, ClarificationSelection>,
|
||||
) => {
|
||||
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +114,7 @@ export function MessageList({
|
||||
const id = pendingClarificationIds[index]!;
|
||||
const message = messages.find((msg) => msg.id === id);
|
||||
const payload = message ? getClarificationPayload(message) : null;
|
||||
const selection = clarificationSelections.get(id) ?? {
|
||||
const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
@@ -221,7 +224,12 @@ export function MessageList({
|
||||
}
|
||||
submitting={submittingClarification}
|
||||
canSubmit={hasAnyClarificationAnswer}
|
||||
onSubmitAll={handleSubmitAllClarifications}
|
||||
onSubmitAll={(override) => {
|
||||
const overrides = override
|
||||
? new Map([[messageId, override]])
|
||||
: undefined;
|
||||
void handleSubmitAllClarifications(overrides);
|
||||
}}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -412,6 +420,7 @@ type ClarificationPayload = {
|
||||
clarification_type?: string;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
};
|
||||
|
||||
type ClarificationSelection = {
|
||||
@@ -600,7 +609,7 @@ function ClarificationCard({
|
||||
onSelectionChange: (next: ClarificationSelection) => void;
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onSubmitAll: () => void | Promise<void>;
|
||||
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
|
||||
@@ -624,11 +633,21 @@ function ClarificationCard({
|
||||
|
||||
const disabled = !isPending || submitting;
|
||||
const multiPending = (pendingCount ?? 0) > 1;
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allow_multiple,
|
||||
payload.clarification_type,
|
||||
);
|
||||
|
||||
function toggleOption(optionId: string) {
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (!allowMultiple) {
|
||||
const next = { selectedIds: [optionId], custom: "" };
|
||||
onSelectionChange(next);
|
||||
void onSubmitAll(next);
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
@@ -666,7 +685,7 @@ function ClarificationCard({
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可多选(再次点击取消选择)
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
@@ -675,10 +694,10 @@ function ClarificationCard({
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
variant={isSelected && allowMultiple ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleOption(optionId)}
|
||||
onClick={() => handleOptionClick(optionId)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
@@ -695,7 +714,7 @@ function ClarificationCard({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
{isSelected && allowMultiple ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</Button>
|
||||
@@ -720,10 +739,10 @@ function ClarificationCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? (
|
||||
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending ? (
|
||||
{multiPending && allowMultiple ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
@@ -735,14 +754,14 @@ function ClarificationCard({
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : allowMultiple ? (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
|
||||
export function RecentChatList() {
|
||||
@@ -211,7 +212,7 @@ export function RecentChatList() {
|
||||
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
{desensitize(titleOfThread(thread))}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
MemoryTarget,
|
||||
MemoryV2Config,
|
||||
} from "@/core/memory/types";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -173,7 +174,7 @@ export function MemorySettingsPage() {
|
||||
className="flex items-start justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm [overflow-wrap:anywhere]">
|
||||
{entry}
|
||||
{desensitize(entry)}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
@@ -234,7 +235,7 @@ export function MemorySettingsPage() {
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{desensitize(a.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -272,7 +273,7 @@ export function MemorySettingsPage() {
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{desensitize(a.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -356,7 +357,7 @@ export function MemorySettingsPage() {
|
||||
<div className="text-muted-foreground mb-1 font-medium">
|
||||
{m.entryToDeleteLabel}
|
||||
</div>
|
||||
<p className="break-words">{entryToDelete.content}</p>
|
||||
<p className="break-words">{desensitize(entryToDelete.content)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
@@ -539,10 +540,10 @@ function MemoryV1View({
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{fact.sourceError}
|
||||
避免:{desensitize(fact.sourceError)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -566,7 +567,7 @@ function MemoryV1View({
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -585,7 +586,7 @@ function MemoryV1View({
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -686,7 +687,7 @@ function MemorySearchPanel() {
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{desensitize(a.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -719,7 +720,7 @@ function MemorySearchPanel() {
|
||||
) : results !== null ? (
|
||||
mode === "reflect" ? (
|
||||
<div className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{reflectText.trim() || m.searchNoResults}
|
||||
{reflectText.trim() ? desensitize(reflectText.trim()) : m.searchNoResults}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm">
|
||||
@@ -735,7 +736,7 @@ function MemorySearchPanel() {
|
||||
key={`${index}-${item.slice(0, 16)}`}
|
||||
className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]"
|
||||
>
|
||||
{item}
|
||||
{desensitize(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -404,7 +405,7 @@ function SkillRow({
|
||||
>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
<span>{desensitize(skill.name)}</span>
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
@@ -474,7 +475,7 @@ function SkillRow({
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
{desensitize(skill.description ?? "")}
|
||||
</ItemDescription>
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
@@ -560,7 +561,9 @@ function SkillRow({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
|
||||
{desensitize(
|
||||
t.settings.skills.deleteConfirm.replace("{name}", skill.name),
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -809,22 +812,22 @@ function InstallSkillDialog({
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">{validation.skill_name}</span>
|
||||
<span className="font-medium">{desensitize(validation.skill_name)}</span>
|
||||
</div>
|
||||
<Field label={dialog.descriptionLabel}>
|
||||
<span className="line-clamp-3 whitespace-pre-wrap">
|
||||
{validation.description || "—"}
|
||||
{validation.description ? desensitize(validation.description) : "—"}
|
||||
</span>
|
||||
</Field>
|
||||
{validation.license && (
|
||||
<Field label={dialog.licenseLabel}>{validation.license}</Field>
|
||||
<Field label={dialog.licenseLabel}>{desensitize(validation.license)}</Field>
|
||||
)}
|
||||
{validation.allowed_tools.length > 0 && (
|
||||
<Field label={dialog.toolsLabel}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{validation.allowed_tools.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="text-xs">
|
||||
{tool}
|
||||
{desensitize(tool)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -834,7 +837,7 @@ function InstallSkillDialog({
|
||||
<Field label={dialog.warningsLabel}>
|
||||
<ul className="list-disc pl-4 text-xs">
|
||||
{validation.warnings.map((w, idx) => (
|
||||
<li key={idx}>{w}</li>
|
||||
<li key={idx}>{desensitize(w)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Field>
|
||||
@@ -843,7 +846,9 @@ function InstallSkillDialog({
|
||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
|
||||
{desensitize(
|
||||
dialog.conflictCustom.replace("{name}", validation.skill_name),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -851,7 +856,9 @@ function InstallSkillDialog({
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
|
||||
{desensitize(
|
||||
(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Kind = "stale" | "archived";
|
||||
@@ -146,7 +147,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
<span className="font-medium">{desensitize(skill.name)}</span>
|
||||
{isArchived ? (
|
||||
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
|
||||
{tt.badgeArchived}
|
||||
@@ -180,7 +181,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||
{skill.description}
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
|
||||
<span>{timeLabel}</span>
|
||||
@@ -254,7 +255,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tt.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tt.deleteConfirm.replace("{name}", skill.name)}
|
||||
{desensitize(tt.deleteConfirm.replace("{name}", skill.name))}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -295,12 +296,12 @@ function SkillViewDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skill.name}</DialogTitle>
|
||||
<DialogTitle>{desensitize(skill.name)}</DialogTitle>
|
||||
<DialogDescription>{tv.title}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<Row label={tv.descriptionLabel}>
|
||||
<span className="whitespace-pre-wrap">{skill.description}</span>
|
||||
<span className="whitespace-pre-wrap">{desensitize(skill.description ?? "")}</span>
|
||||
</Row>
|
||||
<Row label={tv.stateLabel}>
|
||||
<span className={cn("font-medium", stateColor(skill.state))}>
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const markdownClassName =
|
||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||
@@ -147,7 +148,7 @@ export function SkillDetailDialog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{skill?.name ?? skillName ?? ""}
|
||||
{desensitize(skill?.name ?? skillName ?? "")}
|
||||
</DialogTitle>
|
||||
{skill?.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -165,7 +166,7 @@ export function SkillDetailDialog({
|
||||
)}
|
||||
</div>
|
||||
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
||||
{skill?.description || "-"}
|
||||
{skill?.description ? desensitize(skill.description) : "-"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,7 +329,7 @@ export function SkillDetailDialog({
|
||||
</div>
|
||||
) : showRawSource ? (
|
||||
<pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{contentData?.content}
|
||||
{desensitize(contentData?.content ?? "")}
|
||||
</pre>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
@@ -24,7 +25,7 @@ export function TagChip({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
{desensitize(tag.name)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
@@ -44,7 +45,7 @@ export function ThreadTitle({
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{thread.values.title ?? "Untitled"}
|
||||
{desensitize(thread.values.title ?? "Untitled")}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
@@ -86,10 +86,10 @@ const STORAGE_KEY = 'strategy-theme';
|
||||
|
||||
function readPersistedTheme(): ThemeType {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
return 'light';
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'light' ? 'light' : 'dark';
|
||||
return saved === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// 主题上下文
|
||||
@@ -125,7 +125,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
|
||||
if (current !== 'light' && current !== 'dark') {
|
||||
const fallback =
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'light';
|
||||
setNextTheme(fallback);
|
||||
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
setNextTheme(legacy);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { env } from "@/env";
|
||||
* 素材的 recUuid 直接拼接在其后即为完整原文链接。
|
||||
*/
|
||||
export const INTRANET_SOURCE_URL_BASE =
|
||||
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid=";
|
||||
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid-detail?recUuid=";
|
||||
|
||||
/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */
|
||||
export function buildIntranetSourceUrl(recUuid?: string | null): string {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { useI18nContext } from "./context";
|
||||
import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
|
||||
@@ -13,10 +15,34 @@ import {
|
||||
type Locale,
|
||||
} from "./index";
|
||||
|
||||
function wrapWithDesensitize<T>(value: T): T {
|
||||
if (typeof value === "string") {
|
||||
return desensitize(value) as T;
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return ((...args: unknown[]) => {
|
||||
const result = (value as (...a: unknown[]) => unknown)(...args);
|
||||
return wrapWithDesensitize(result);
|
||||
}) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => wrapWithDesensitize(item)) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return new Proxy(value as object, {
|
||||
get(target, prop, receiver) {
|
||||
return wrapWithDesensitize(Reflect.get(target, prop, receiver));
|
||||
},
|
||||
}) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const { locale, setLocale } = useI18nContext();
|
||||
|
||||
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
|
||||
const rawT = translations[locale] ?? translations[DEFAULT_LOCALE];
|
||||
const t = useMemo(() => wrapWithDesensitize(rawT), [rawT]);
|
||||
|
||||
const changeLocale = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 脱敏词映射表
|
||||
* key: 原始词(支持正则特殊字符,会被转义)
|
||||
* value: 脱敏后替换词
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
态势: "TS",
|
||||
滨海作战体系: "第一张网",
|
||||
滨海作战部队: "第一张网",
|
||||
'Taiwan Air Force Flight Units': "第二张网",
|
||||
台海空军飞行部队: "第二张网",
|
||||
空军: "KJ",
|
||||
海军: "HJ",
|
||||
陆军: "LJ",
|
||||
陆战队: "LZD",
|
||||
海巡: "HX",
|
||||
憲兵: "XB",
|
||||
資通電軍: "ZTDJ",
|
||||
情报: "QB",
|
||||
指挥: "ZH",
|
||||
信息准备: "XX准备",
|
||||
行为体: "XWT",
|
||||
驱动因素: "QDYS",
|
||||
叙事: "XS",
|
||||
前三定: "QSD",
|
||||
受众目标: "SZMB",
|
||||
作战: "ZZ",
|
||||
目的: "MD",
|
||||
关键传播节点: "GJCBJD",
|
||||
战法: "ZF",
|
||||
行动: "XD",
|
||||
打击: "DJ",
|
||||
目标: "MB",
|
||||
战力: "ZL",
|
||||
筹划: "COH",
|
||||
策划: "CEH",
|
||||
模拟: "MN",
|
||||
推演: "TY",
|
||||
认知: "RZ",
|
||||
触及: "CJ",
|
||||
军: "J",
|
||||
美军: "MJ",
|
||||
台军: "TJ",
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 DESENSITIZE_MAP 编译为单个正则,避免多次遍历字符串。
|
||||
* 仅在模块首次加载时执行一次。
|
||||
*/
|
||||
function buildRegex(map: Record<string, string>): RegExp | null {
|
||||
const keys = Object.keys(map);
|
||||
if (keys.length === 0) return null;
|
||||
const pattern = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
||||
return new RegExp(pattern, "g");
|
||||
}
|
||||
|
||||
let _regex: RegExp | null = buildRegex(DESENSITIZE_MAP);
|
||||
|
||||
/**
|
||||
* 对文本进行脱敏处理,将 DESENSITIZE_MAP 中的词替换为对应脱敏词。
|
||||
*
|
||||
* @param text 原始文本
|
||||
* @returns 脱敏后的文本
|
||||
*
|
||||
* @example
|
||||
* desensitize("当前态势分析") // => "当前TS分析"
|
||||
*/
|
||||
export function desensitize(text: string): string {
|
||||
if (!_regex) return text;
|
||||
// 每次 exec 需要重置 lastIndex,使用 replace 更安全
|
||||
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时动态追加脱敏词,追加后自动重编译正则。
|
||||
*
|
||||
* @param entries 要追加的词条,格式同 DESENSITIZE_MAP
|
||||
*
|
||||
* @example
|
||||
* addDesensitizeEntries({ 情报: "QB", 指挥: "ZH" });
|
||||
*/
|
||||
export function addDesensitizeEntries(entries: Record<string, string>): void {
|
||||
Object.assign(DESENSITIZE_MAP, entries);
|
||||
_regex = buildRegex(DESENSITIZE_MAP);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
disabled?: boolean;
|
||||
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
|
||||
/** workspace 入口:隐藏严格模式、目标读者 */
|
||||
hideAdvancedFields?: boolean;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,17 @@ interface ModelOption {
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
|
||||
'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'focus-visible:ring-ring disabled:opacity-50';
|
||||
|
||||
const DEFAULT_KEYWORD_COUNT = 6;
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
const MAX_INTENT_LENGTH = 500;
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
const FIELD_TRIGGER_CLASS =
|
||||
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
|
||||
|
||||
export function AIWritingForm({
|
||||
onSubmit,
|
||||
@@ -45,7 +50,6 @@ export function AIWritingForm({
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [keywordCountInput, setKeywordCountInput] = useState('1');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
@@ -105,23 +109,10 @@ export function AIWritingForm({
|
||||
}
|
||||
}
|
||||
|
||||
// 检索关键词数量限定 1~10,非法值回退默认 6
|
||||
function handleKeywordCountBlur() {
|
||||
const num = parseInt(keywordCountInput, 10);
|
||||
if (isNaN(num)) {
|
||||
setKeywordCountInput('1');
|
||||
} else {
|
||||
setKeywordCountInput(String(Math.max(1, Math.min(10, num))));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||
const keywordCount = hideAdvancedFields
|
||||
? DEFAULT_KEYWORD_COUNT
|
||||
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
|
||||
onSubmit({
|
||||
userIntent: intent.trim(),
|
||||
articleType,
|
||||
@@ -129,103 +120,126 @@ export function AIWritingForm({
|
||||
? DEFAULT_AUDIENCE
|
||||
: audience.trim() || DEFAULT_AUDIENCE,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
modelName: modelName || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
|
||||
<Textarea
|
||||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
|
||||
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4">
|
||||
<div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3">
|
||||
<PenLine className="size-4 text-[#8b6f6f]" />
|
||||
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f]">写作配置选项</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={FIELD_LABEL_CLASS}>写作意图</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{intent.length} / {MAX_INTENT_LENGTH} 字
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局..."
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={5}
|
||||
maxLength={MAX_INTENT_LENGTH}
|
||||
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>文章类型</label>
|
||||
<Select
|
||||
value={articleType}
|
||||
onValueChange={handleArticleTypeChange}
|
||||
disabled={disabled || typesLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleTypes.map((t) => (
|
||||
<SelectItem key={t.key} value={t.key} className="text-sm">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>目标字数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={100}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="800"
|
||||
value={wordCountInput}
|
||||
onChange={(e) => setWordCountInput(e.target.value)}
|
||||
onBlur={handleWordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>写作模型</label>
|
||||
<Select
|
||||
value={articleType}
|
||||
onValueChange={handleArticleTypeChange}
|
||||
disabled={disabled || typesLoading}
|
||||
value={modelName}
|
||||
onValueChange={setModelName}
|
||||
disabled={disabled || models.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={models.length > 0 ? '选择模型…' : '模型加载中…'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleTypes.map((t) => (
|
||||
<SelectItem key={t.key} value={t.key} className="text-xs">
|
||||
{t.label}
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-sm">
|
||||
{m.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={100}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="800"
|
||||
value={wordCountInput}
|
||||
onChange={(e) => setWordCountInput(e.target.value)}
|
||||
onBlur={handleWordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !intent.trim()}
|
||||
className="mt-4 h-12 w-full gap-2 rounded-2xl text-base font-semibold"
|
||||
>
|
||||
<PenLine className="size-4" />
|
||||
开始 AI 写作
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="6"
|
||||
value={keywordCountInput}
|
||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||
onBlur={handleKeywordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||
<span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
@@ -238,32 +252,6 @@ export function AIWritingForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
|
||||
<Select
|
||||
value={modelName}
|
||||
onValueChange={setModelName}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="选择模型…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
|
||||
<PenLine className="size-3.5" />
|
||||
开始 AI 写作
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import { normalizeKeyPoint } from '../../types/ai-writing';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
|
||||
interface OutlineSectionProps {
|
||||
@@ -10,7 +11,7 @@ interface OutlineSectionProps {
|
||||
onChange: (updated: OutlineSectionType) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
// Drag-and-drop
|
||||
// Drag-and-drop (章节级别)
|
||||
isDragging?: boolean;
|
||||
isOver?: boolean;
|
||||
onDragStart?: () => void;
|
||||
@@ -19,6 +20,14 @@ interface OutlineSectionProps {
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
|
||||
* subPoints[] 为 bullet 列表(Level 3)。
|
||||
*
|
||||
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
|
||||
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
|
||||
* 编辑过的旧 session 大纲会被持久化为新格式)。
|
||||
*/
|
||||
export function OutlineSection({
|
||||
section, index, onChange, onRemove, readOnly,
|
||||
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
|
||||
@@ -27,6 +36,13 @@ export function OutlineSection({
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
|
||||
|
||||
// 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
|
||||
// 都生成新引用导致子组件无谓重渲染。
|
||||
const keyPoints: KeyPoint[] = useMemo(
|
||||
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
|
||||
[section.keyPoints],
|
||||
);
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
@@ -37,20 +53,23 @@ export function OutlineSection({
|
||||
}
|
||||
}
|
||||
|
||||
function updateKeyPoint(i: number, value: string) {
|
||||
const next = [...section.keyPoints];
|
||||
next[i] = value;
|
||||
function updateKeyPoint(i: number, updated: KeyPoint) {
|
||||
const next = [...keyPoints];
|
||||
next[i] = updated;
|
||||
onChange({ ...section, keyPoints: next });
|
||||
}
|
||||
|
||||
function removeKeyPoint(i: number) {
|
||||
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
|
||||
onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addKeyPoint() {
|
||||
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
|
||||
onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
|
||||
}
|
||||
|
||||
// 子要点总数,显示在章节头给个粗略概览
|
||||
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!readOnly}
|
||||
@@ -104,7 +123,9 @@ export function OutlineSection({
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
|
||||
<span className="text-muted-foreground/50 shrink-0">
|
||||
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
@@ -112,24 +133,151 @@ export function OutlineSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 要点列表 */}
|
||||
{/* 小节列表 (Level 2) */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-8 pb-2.5 space-y-1 border-t">
|
||||
{section.keyPoints.map((kp, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 pt-1.5">
|
||||
<span className="text-muted-foreground/40 shrink-0">•</span>
|
||||
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
|
||||
{keyPoints.map((kp, i) => (
|
||||
<KeyPointRow
|
||||
key={i}
|
||||
keyPoint={kp}
|
||||
onChange={(updated) => updateKeyPoint(i, updated)}
|
||||
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加小节
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
|
||||
interface KeyPointRowProps {
|
||||
keyPoint: KeyPoint;
|
||||
onChange: (updated: KeyPoint) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
if (trimmed !== keyPoint.pointTitle) {
|
||||
onChange({ ...keyPoint, pointTitle: trimmed });
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubPoint(i: number, value: string) {
|
||||
const next = [...keyPoint.subPoints];
|
||||
next[i] = value;
|
||||
onChange({ ...keyPoint, subPoints: next });
|
||||
}
|
||||
|
||||
function removeSubPoint(i: number) {
|
||||
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addSubPoint() {
|
||||
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
|
||||
setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border/40 bg-muted/10">
|
||||
{/* 小节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
aria-label="展开/收起子要点"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/60 transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span className="text-purple-500/70 shrink-0">###</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none"
|
||||
placeholder="小节标题"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
|
||||
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
|
||||
aria-label="删除小节"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
|
||||
{keyPoint.subPoints.map((sp, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 pt-1">
|
||||
{/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
|
||||
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
|
||||
{readOnly ? (
|
||||
<span className="flex-1 text-muted-foreground">{kp}</span>
|
||||
<span className="flex-1 text-muted-foreground">{sp}</span>
|
||||
) : (
|
||||
<input
|
||||
value={kp}
|
||||
onChange={(e) => updateKeyPoint(i, e.target.value)}
|
||||
placeholder="输入要点…"
|
||||
value={sp}
|
||||
onChange={(e) => updateSubPoint(i, e.target.value)}
|
||||
placeholder="子要点标题(会展开成一段正文)"
|
||||
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSubPoint(i)}
|
||||
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
|
||||
aria-label="删除子要点"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -138,11 +286,11 @@ export function OutlineSection({
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
|
||||
onClick={addSubPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加要点
|
||||
添加子要点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function OpenCanvasRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<CanvasHomePage />} />
|
||||
<Route path="ai-writing" element={<AIWritingPage />} />
|
||||
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
|
||||
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -13,10 +13,25 @@ export interface AIMaterial {
|
||||
}
|
||||
|
||||
// ── 大纲 ─────────────────────────────────────────────────────────────────────
|
||||
// 三级结构:章节(##) → 小节(###) → 子要点(bullet)
|
||||
// AIOutline.sections[] = Level 1 章节(渲染为 ## 由后端注入)
|
||||
// OutlineSection.keyPoints[] = Level 2 小节(渲染为 ###)
|
||||
// KeyPoint.subPoints[] = Level 3 子要点(正文里的 bullet 列表)
|
||||
//
|
||||
// 历史兼容:旧版 keyPoints: string[](2 级)。后端 _parse_outline 已经在解析时把
|
||||
// 旧 string 自动 lift 成 KeyPoint;前端类型只保留新格式,但 UI 渲染层做 type
|
||||
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
|
||||
|
||||
export interface KeyPoint {
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
}
|
||||
|
||||
export interface OutlineSection {
|
||||
sectionTitle: string;
|
||||
keyPoints: string[];
|
||||
// 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。
|
||||
// 新生成的大纲一律 KeyPoint[]。
|
||||
keyPoints: Array<KeyPoint | string>;
|
||||
materialIds: string[];
|
||||
}
|
||||
|
||||
@@ -25,6 +40,32 @@ export interface AIOutline {
|
||||
sections: OutlineSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 keyPoints 单项规整成 KeyPoint。兼容三种输入:
|
||||
* - 新版 KeyPoint object
|
||||
* - 旧版 string (lift 为 { pointTitle: <str>, subPoints: [] })
|
||||
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
|
||||
*/
|
||||
export function normalizeKeyPoint(raw: unknown): KeyPoint {
|
||||
if (typeof raw === 'string') {
|
||||
return { pointTitle: raw, subPoints: [] };
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const r = raw as Record<string, unknown>;
|
||||
const title =
|
||||
typeof r.pointTitle === 'string' ? r.pointTitle :
|
||||
typeof r.point_title === 'string' ? r.point_title :
|
||||
typeof r.title === 'string' ? r.title :
|
||||
'';
|
||||
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
|
||||
const subPoints: string[] = Array.isArray(subsRaw)
|
||||
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
|
||||
: [];
|
||||
return { pointTitle: title, subPoints };
|
||||
}
|
||||
return { pointTitle: '', subPoints: [] };
|
||||
}
|
||||
|
||||
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 严格模式下素材不足、待用户决策的章节 */
|
||||
@@ -158,7 +199,7 @@ export interface AIWritingRequest {
|
||||
targetAudience: string;
|
||||
wordCountTarget: number;
|
||||
modelName?: string;
|
||||
/** 素材检索关键词数量(1~10,默认 6) */
|
||||
/** 素材检索关键词数量(1~10,默认 1) */
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
@@ -61,8 +62,9 @@ export default function AgentChatPage() {
|
||||
);
|
||||
|
||||
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
|
||||
const agentDisplayName =
|
||||
agent?.name ?? (isAgentLoading ? t.common.loading : "");
|
||||
const agentDisplayName = desensitize(
|
||||
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
|
||||
);
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import { usePathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
export default function ChatsPage() {
|
||||
const pathOfThread = usePathOfThread();
|
||||
@@ -69,7 +70,7 @@ export default function ChatsPage() {
|
||||
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
||||
<div className="flex flex-col gap-2 border-b p-4">
|
||||
<div>
|
||||
<div>{titleOfThread(thread)}</div>
|
||||
<div>{desensitize(titleOfThread(thread))}</div>
|
||||
</div>
|
||||
{thread.updated_at && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -15,20 +15,20 @@ function PageLayout() {
|
||||
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-muted/20">
|
||||
{sidebarEnabled && (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex flex-col flex-1 overflow-hidden transition-[padding] duration-300 ${
|
||||
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
|
||||
}`}
|
||||
>
|
||||
{sidebarEnabled && <Header />}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<div className="h-screen flex flex-col bg-muted/20">
|
||||
{sidebarEnabled && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebarEnabled && (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
)}
|
||||
<main
|
||||
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
|
||||
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
|
||||
}`}
|
||||
>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="workspace" replace />} />
|
||||
<Route
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
@@ -334,7 +335,7 @@ export default function ScheduledTasksPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug break-all">
|
||||
{task.name}
|
||||
{desensitize(task.name)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{isRunningNow && (
|
||||
@@ -358,7 +359,7 @@ export default function ScheduledTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||||
{task.schedule_text} · {task.timezone}
|
||||
{desensitize(task.schedule_text ?? "")} · {task.timezone}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
|
||||
@@ -531,9 +532,9 @@ export default function ScheduledTasksPage() {
|
||||
<div key={item.task_id} className="bg-card rounded-md border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.name}</div>
|
||||
<div className="truncate font-medium">{desensitize(item.name)}</div>
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||||
{item.schedule_text} · {item.timezone}
|
||||
{desensitize(item.schedule_text ?? "")} · {item.timezone}
|
||||
</div>
|
||||
</div>
|
||||
{subscribed && (
|
||||
@@ -543,7 +544,7 @@ export default function ScheduledTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-3 line-clamp-3 text-xs whitespace-pre-wrap">
|
||||
{item.prompt_summary || "(无摘要)"}
|
||||
{item.prompt_summary ? desensitize(item.prompt_summary) : "(无摘要)"}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -585,7 +586,7 @@ export default function ScheduledTasksPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{selectedTask ? selectedTask.name : "请选择左侧任务"}
|
||||
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
|
||||
</p>
|
||||
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -625,7 +626,7 @@ export default function ScheduledTasksPage() {
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{run.result?.title || formatDate(run.scheduled_for)}
|
||||
{run.result?.title ? desensitize(run.result.title) : formatDate(run.scheduled_for)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
计划:{formatDate(run.scheduled_for)} · 开始:
|
||||
@@ -642,7 +643,7 @@ export default function ScheduledTasksPage() {
|
||||
</div>
|
||||
{run.error && (
|
||||
<div className="text-destructive mt-3 line-clamp-2 text-sm">
|
||||
{run.error}
|
||||
{desensitize(run.error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
@@ -703,13 +704,13 @@ export default function ScheduledTasksPage() {
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{subscribeTarget?.name}</div>
|
||||
<div className="font-medium">{desensitize(subscribeTarget?.name ?? "")}</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{subscribeTarget?.schedule_text} · {subscribeTarget?.timezone}
|
||||
{desensitize(subscribeTarget?.schedule_text ?? "")} · {subscribeTarget?.timezone}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap">
|
||||
{subscribeTarget?.prompt_summary || "(无摘要)"}
|
||||
{subscribeTarget?.prompt_summary ? desensitize(subscribeTarget.prompt_summary) : "(无摘要)"}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
@@ -809,8 +810,8 @@ export default function ScheduledTasksPage() {
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block truncate">
|
||||
{agent.name}
|
||||
{agent.description ? ` — ${agent.description}` : ""}
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ClarificationPayload } from "./multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type { ClarificationPayload };
|
||||
|
||||
/**
|
||||
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
|
||||
* built-in agent and no per-call coordinator: one /init creates a thread
|
||||
@@ -16,9 +21,80 @@ export interface IntentInitResponse {
|
||||
thread_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured diagnostic payload surfaced by the backend whenever an intent
|
||||
* route raises (init) or yields a final `status: "error"` frame (stream).
|
||||
* Same shape across `init_intent` / `stream_intent`; renderer keys off `code`
|
||||
* for quick visual triage, but also shows `hint` + `trace_tail` as a panel
|
||||
* so deployment-time issues (config.yaml unreachable LLM, missing builtin
|
||||
* agent dirs, loopback going through proxy) are debuggable from the UI alone
|
||||
* without tail-ing backend logs.
|
||||
*/
|
||||
export interface IntentErrorDetail {
|
||||
/** Stable short category — see backend `_diagnose_exception`. */
|
||||
code: string;
|
||||
/** One-line human-readable description (already includes exception type). */
|
||||
message: string;
|
||||
exception_type: string;
|
||||
exception_repr: string;
|
||||
/** Root of the `__cause__` chain when distinct from the outer exception. */
|
||||
root_type?: string | null;
|
||||
root_repr?: string | null;
|
||||
/** Actionable hint in Chinese, keyed by `code`. May be null for unrecognized errors. */
|
||||
hint?: string | null;
|
||||
/** Last ~12 lines of the traceback for in-UI inspection. */
|
||||
trace_tail?: string[];
|
||||
/** Caller-supplied label (e.g. "intent_init"). */
|
||||
context?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `initIntent` when the HTTP call fails. Carries the parsed
|
||||
* `IntentErrorDetail` (if the backend emitted one) so the page can render
|
||||
* a rich diagnostic panel instead of a one-line message.
|
||||
*/
|
||||
export class IntentInitError extends Error {
|
||||
/** HTTP status (typically 500 for diagnosed failures, 401/403 for auth). */
|
||||
readonly status: number;
|
||||
/** Structured detail when the backend emitted `_diagnose_exception` output. */
|
||||
readonly detail: IntentErrorDetail | null;
|
||||
/** Raw body text when JSON parse failed (kept for completeness). */
|
||||
readonly rawBody: string;
|
||||
|
||||
constructor(status: number, detail: IntentErrorDetail | null, rawBody: string) {
|
||||
super(detail?.message || `init intent failed (${status}): ${rawBody}`);
|
||||
this.name = "IntentInitError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
this.rawBody = rawBody;
|
||||
}
|
||||
}
|
||||
|
||||
function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj.code !== "string" || typeof obj.message !== "string") return null;
|
||||
return {
|
||||
code: obj.code,
|
||||
message: obj.message,
|
||||
exception_type: typeof obj.exception_type === "string" ? obj.exception_type : "",
|
||||
exception_repr: typeof obj.exception_repr === "string" ? obj.exception_repr : "",
|
||||
root_type: typeof obj.root_type === "string" ? obj.root_type : null,
|
||||
root_repr: typeof obj.root_repr === "string" ? obj.root_repr : null,
|
||||
hint: typeof obj.hint === "string" ? obj.hint : null,
|
||||
trace_tail: Array.isArray(obj.trace_tail) ? obj.trace_tail.filter((l): l is string => typeof l === "string") : [],
|
||||
context: typeof obj.context === "string" ? obj.context : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh thread for an intent-clarification session.
|
||||
* The backend returns the built-in agent id so the frontend doesn't hardcode it.
|
||||
*
|
||||
* On failure throws `IntentInitError` whose `.detail` carries the backend's
|
||||
* `_diagnose_exception` payload — the page should surface `detail.hint` /
|
||||
* `detail.trace_tail` to help diagnose intranet deployment issues without
|
||||
* shell access to the server.
|
||||
*/
|
||||
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||
@@ -27,8 +103,18 @@ export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`init intent failed (${res.status}): ${text}`);
|
||||
const raw = await res.text().catch(() => "");
|
||||
let detail: IntentErrorDetail | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// FastAPI HTTPException(detail=dict) → { "detail": {...} }
|
||||
if (parsed && typeof parsed === "object" && parsed !== null) {
|
||||
detail = parseIntentErrorDetail((parsed as { detail?: unknown }).detail) || parseIntentErrorDetail(parsed);
|
||||
}
|
||||
} catch {
|
||||
// body wasn't JSON — fall through with raw text only.
|
||||
}
|
||||
throw new IntentInitError(res.status, detail, raw);
|
||||
}
|
||||
return (await res.json()) as IntentInitResponse;
|
||||
}
|
||||
@@ -69,16 +155,31 @@ export interface StreamIntentRequest {
|
||||
* "正在输出" so users see what's happening during the inevitable LLM
|
||||
* first-token wait.
|
||||
*/
|
||||
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
|
||||
onPhaseChange?: (
|
||||
phase: IntentStreamPhase,
|
||||
toolName?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface StreamIntentResult {
|
||||
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */
|
||||
status: "asking" | "done" | "error";
|
||||
/** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */
|
||||
status: "asking" | "clarification" | "done" | "error";
|
||||
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
|
||||
content: string;
|
||||
/** Populated only when status === "done". */
|
||||
summary: IntentSummary | null;
|
||||
/**
|
||||
* Populated when the intent agent called ask_clarification — render option
|
||||
* cards + custom input instead of treating the turn as plain text only.
|
||||
*/
|
||||
clarification: ClarificationPayload | null;
|
||||
/**
|
||||
* Populated only when status === "error" and the backend emitted a
|
||||
* structured diagnostic (new in `_diagnose_exception`). UI uses this to show
|
||||
* the same rich panel as init failures.
|
||||
*/
|
||||
errorDetail: IntentErrorDetail | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,14 +188,26 @@ export interface StreamIntentResult {
|
||||
* is optional.
|
||||
*/
|
||||
function isFinalIntentFrame(value: unknown): value is {
|
||||
status: "asking" | "done" | "error";
|
||||
status: "asking" | "clarification" | "done" | "error";
|
||||
content?: string;
|
||||
summary?: IntentSummary | null;
|
||||
question?: string;
|
||||
clarification_type?: string;
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
error_detail?: unknown;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
return status === "asking" || status === "done" || status === "error";
|
||||
return (
|
||||
status === "asking" ||
|
||||
status === "clarification" ||
|
||||
status === "done" ||
|
||||
status === "error"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,13 +273,23 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
// provided, since the same `tool_calling` phase may surface a new tool).
|
||||
let currentPhase: IntentStreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: IntentStreamPhase, toolName?: string) => {
|
||||
const setPhase = (
|
||||
next: IntentStreamPhase,
|
||||
toolName?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
if (next === currentPhase && !toolName) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next, toolName);
|
||||
req.onPhaseChange?.(next, toolName, args);
|
||||
};
|
||||
|
||||
const result: StreamIntentResult = { status: "asking", content: "", summary: null };
|
||||
const result: StreamIntentResult = {
|
||||
status: "asking",
|
||||
content: "",
|
||||
summary: null,
|
||||
clarification: null,
|
||||
errorDetail: null,
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -205,6 +328,25 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
if (parsed.summary && typeof parsed.summary === "object") {
|
||||
result.summary = parsed.summary as IntentSummary;
|
||||
}
|
||||
if (parsed.status === "error") {
|
||||
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
|
||||
}
|
||||
if (parsed.status === "clarification") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType,
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -230,7 +372,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
? (toolCallChunks[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||
setPhase("tool_calling", toolName);
|
||||
// 把 tool args 一并透传给 UI;onPhaseChange 在 useStep1Intent 里
|
||||
// 会把 args 累积进 message.steps,让 MessageStepsCard 渲染路径 chip /
|
||||
// 命令代码块等子节点(与主聊天 MessageGroup 视觉一致)。
|
||||
const toolArgs =
|
||||
firstCall && typeof firstCall.args === "object" && firstCall.args !== null
|
||||
? (firstCall.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
setPhase("tool_calling", toolName, toolArgs);
|
||||
} else if (
|
||||
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||||
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
export interface SeatReadyEvent {
|
||||
@@ -191,6 +193,13 @@ export interface StreamMultiAgentRequest {
|
||||
agentType: StreamAgentType;
|
||||
dAgentThreadId: ThreadIdMap;
|
||||
agentName: string;
|
||||
/**
|
||||
* 人类可读名称(中文/英文)。仅 special 走广播时使用 —— 后端把它拼进
|
||||
* 「子智能体X完成Y工作,交付内容为:...」里,这样总控 LLM 在下一轮看到自己
|
||||
* 历史里的交付摘要时能用"语义化的名字"识别,而不是 UUID 字符串(LLM 对
|
||||
* UUID 不友好,会导致重复派活)。后端缺失时 fallback 到 agentName。
|
||||
*/
|
||||
displayName?: string;
|
||||
newMessage: string;
|
||||
model?: string;
|
||||
skillStopNames?: string[];
|
||||
@@ -207,8 +216,14 @@ export interface StreamMultiAgentRequest {
|
||||
* Fires whenever the inferred SSE phase changes. The UI can use this to
|
||||
* show progressive status messages during the inevitable first-token wait.
|
||||
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
||||
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
|
||||
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
|
||||
*/
|
||||
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
|
||||
onPhaseChange?: (
|
||||
phase: StreamPhase,
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ClarificationPayload {
|
||||
@@ -222,6 +237,8 @@ export interface ClarificationPayload {
|
||||
options: unknown[];
|
||||
/** Whether the user is allowed to type a custom answer. */
|
||||
allowCustom: boolean;
|
||||
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamMultiAgentResult {
|
||||
@@ -257,6 +274,7 @@ function isFinalStatusFrame(value: unknown): value is {
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
@@ -322,6 +340,8 @@ export async function streamMultiAgent(
|
||||
agent_type: req.agentType,
|
||||
d_agent_thread_id: req.dAgentThreadId,
|
||||
agent_name: req.agentName,
|
||||
// 仅 special 用到;后端缺失时 fallback 到 agent_name,旧前端也不破坏。
|
||||
display_name: req.displayName,
|
||||
new_message: req.newMessage,
|
||||
model: req.model,
|
||||
skill_stop_names: req.skillStopNames ?? [],
|
||||
@@ -361,10 +381,17 @@ export async function streamMultiAgent(
|
||||
// value actually changes (the reader loop sees many frames per phase).
|
||||
let currentPhase: StreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: StreamPhase, name?: string) => {
|
||||
const setPhase = (
|
||||
next: StreamPhase,
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层;
|
||||
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层
|
||||
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。
|
||||
if (next === currentPhase && !name) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next, name);
|
||||
req.onPhaseChange?.(next, name, args);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
@@ -421,13 +448,22 @@ export async function streamMultiAgent(
|
||||
setPhase("tool_result");
|
||||
} else if (hasToolCall) {
|
||||
// Pick the most informative name we can find in either field.
|
||||
// 优先用 tool_calls(完整对象,含解析好的 args),tool_call_chunks 的 args
|
||||
// 通常是局部 JSON 字符串(LangGraph 流式期间逐字符发),没法直接用。
|
||||
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
|
||||
? (toolCalls[0] as Record<string, unknown>)
|
||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||
? (toolCallChunks[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||
setPhase("tool_calling", toolName);
|
||||
// 仅当来自 tool_calls(完整对象)时透传 args;tool_call_chunks 的 args 是
|
||||
// string 拼接中,不传上去给 UI,等下一个完整 chunk 再带。
|
||||
const fromCompleteToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
|
||||
const args =
|
||||
fromCompleteToolCalls && firstCall && typeof firstCall.args === "object" && firstCall.args !== null
|
||||
? (firstCall.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
setPhase("tool_calling", toolName, args);
|
||||
} else if (
|
||||
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||||
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||
@@ -447,14 +483,19 @@ export async function streamMultiAgent(
|
||||
// a question (or chip choices) instead of mistaking the run for
|
||||
// a consensus.
|
||||
if (parsed.status === "clarification") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType:
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
|
||||
clarificationType,
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { Agent } from "@/core/agents";
|
||||
|
||||
import type { IntentSummary } from "./intent";
|
||||
|
||||
/**
|
||||
* Agent-recommendation API. Sits between Step 1 (intent clarification) and
|
||||
* Step 2 (multi-agent roundtable): given the IntentSummary and a candidate
|
||||
* agent pool, the built-in `roundtable-recommender` picks 2-6 agents most
|
||||
* relevant to the task and explains why.
|
||||
*
|
||||
* One-shot, stateless: no /init, no thread_id, each call spins up a fresh
|
||||
* backend-managed thread. Use {@link filterRecommendCandidates} to clean the
|
||||
* `/api/agents` list before passing it as `candidates`.
|
||||
*/
|
||||
|
||||
/** Single agent pick returned by the recommender. */
|
||||
export interface RecommendPick {
|
||||
agent_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Final status frame the backend appends after the upstream stream finishes. */
|
||||
export interface StreamRecommendResult {
|
||||
/**
|
||||
* `done` → picks parsed and validated, ready to populate the dialog as
|
||||
* default-checked selections.
|
||||
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
|
||||
* caller should apply its fallback list (typically the 6 roundtable-* seats).
|
||||
* `error` → upstream / network failure.
|
||||
*/
|
||||
status: "done" | "asking" | "error";
|
||||
/** Full final AI text (includes the natural-language rationale). */
|
||||
content: string;
|
||||
/** Populated only when status === "done"; null otherwise. */
|
||||
picks: RecommendPick[] | null;
|
||||
}
|
||||
|
||||
/** Phase mirror of streamIntent so the UI can reuse one loading-label mapper. */
|
||||
export type RecommendStreamPhase = "connecting" | "connected" | "streaming";
|
||||
|
||||
export interface StreamRecommendRequest {
|
||||
/** Step 1 output. `objective` is required by the backend. */
|
||||
intent: IntentSummary;
|
||||
/** Pre-filtered candidate pool (use {@link filterRecommendCandidates}). */
|
||||
candidates: RecommendCandidate[];
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
/** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */
|
||||
onRationaleUpdate?: (rationale: string) => void;
|
||||
/** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */
|
||||
onPicksUpdate?: (picks: RecommendPick[]) => void;
|
||||
/** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
/** Fires whenever the inferred SSE phase changes. */
|
||||
onPhaseChange?: (phase: RecommendStreamPhase) => void;
|
||||
}
|
||||
|
||||
/** Shape the backend expects in `candidates[]`. */
|
||||
export interface RecommendCandidate {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent ids that must never enter the candidate pool. Centralized here so
|
||||
* every caller (recommend dialog, future tools) applies the same rule.
|
||||
*
|
||||
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
|
||||
* - `roundtable-recommender`— the recommender itself, can't recommend itself.
|
||||
* - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB
|
||||
* accumulates one per session); meaningless as roundtable participants.
|
||||
*/
|
||||
const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-";
|
||||
const EXCLUDED_AGENT_IDS = new Set<string>(["roundtable-intent", "roundtable-recommender"]);
|
||||
|
||||
/**
|
||||
* Drop excluded ids from a raw `GET /api/agents` list and project each entry
|
||||
* to the slim shape the backend expects. Returns a fresh array; original
|
||||
* objects are not mutated.
|
||||
*/
|
||||
export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] {
|
||||
const out: RecommendCandidate[] = [];
|
||||
for (const a of agents) {
|
||||
if (!a || !a.id) continue;
|
||||
if (EXCLUDED_AGENT_IDS.has(a.id)) continue;
|
||||
if (a.id.startsWith(EXCLUDED_AGENT_ID_PREFIX)) continue;
|
||||
out.push({
|
||||
agent_id: a.id,
|
||||
name: a.name ?? a.id,
|
||||
description: a.description ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isFinalRecommendFrame(value: unknown): value is {
|
||||
status: "done" | "asking" | "error";
|
||||
content?: string;
|
||||
picks?: RecommendPick[] | null;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
return status === "done" || status === "asking" || status === "error";
|
||||
}
|
||||
|
||||
const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]";
|
||||
|
||||
/** Match one complete pick object inside a partially streamed JSON block. */
|
||||
const PICK_OBJECT_PATTERN =
|
||||
/\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g;
|
||||
|
||||
function unescapeJsonString(raw: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${raw}"`) as string;
|
||||
} catch {
|
||||
return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split accumulated recommender output into visible rationale text and any
|
||||
* pick objects that have fully arrived in the JSON section.
|
||||
*/
|
||||
export function parseRecommendStreamContent(raw: string): {
|
||||
rationale: string;
|
||||
picks: RecommendPick[];
|
||||
} {
|
||||
const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER);
|
||||
let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw;
|
||||
rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd();
|
||||
|
||||
const jsonSection =
|
||||
markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : "";
|
||||
|
||||
const picks: RecommendPick[] = [];
|
||||
const seen = new Set<string>();
|
||||
PICK_OBJECT_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) {
|
||||
const agent_id = unescapeJsonString(match[1]).trim();
|
||||
const reason = unescapeJsonString(match[2]).trim();
|
||||
if (!agent_id || seen.has(agent_id)) continue;
|
||||
seen.add(agent_id);
|
||||
picks.push({ agent_id, reason });
|
||||
}
|
||||
|
||||
return { rationale, picks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an AI text delta from one LangGraph `messages-tuple` frame. Same
|
||||
* shape as the helper in intent.ts; duplicated rather than imported to keep
|
||||
* each helper independently readable.
|
||||
*/
|
||||
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
|
||||
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||
|
||||
if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") {
|
||||
const meta = parsed[1] as Record<string, unknown>;
|
||||
const node = String(meta.langgraph_node ?? "");
|
||||
if (node.includes("TitleMiddleware")) return null;
|
||||
const tags = meta.tags;
|
||||
if (Array.isArray(tags) && tags.includes("middleware:title")) return null;
|
||||
}
|
||||
|
||||
const chunk = parsed[0];
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
const obj = chunk as Record<string, unknown>;
|
||||
const type = String(obj.type ?? "").toLowerCase();
|
||||
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
|
||||
const content = obj.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") text += item;
|
||||
else if (item && typeof item === "object") {
|
||||
const block = item as Record<string, unknown>;
|
||||
const blockText = block.text;
|
||||
if (typeof blockText === "string") text += blockText;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text) return null;
|
||||
const id = typeof obj.id === "string" ? obj.id : "";
|
||||
return { text, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to `/api/recommend/stream` and resolve once the
|
||||
* upstream stream finishes. Each call is one recommendation round — the
|
||||
* recommender is stateless, so calling twice in a row produces two
|
||||
* independent runs.
|
||||
*/
|
||||
export async function streamRecommend(req: StreamRecommendRequest): Promise<StreamRecommendResult> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/recommend/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent: req.intent,
|
||||
candidates: req.candidates,
|
||||
model: req.model,
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`recommend/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let lastEvent = "";
|
||||
|
||||
let currentPhase: RecommendStreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: RecommendStreamPhase) => {
|
||||
if (next === currentPhase) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next);
|
||||
};
|
||||
|
||||
const result: StreamRecommendResult = { status: "asking", content: "", picks: null };
|
||||
let rawContent = "";
|
||||
let lastRationale = "";
|
||||
let lastPickCount = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("event:")) {
|
||||
lastEvent = line.slice(6).trim();
|
||||
if (lastEvent === "metadata" && currentPhase === "connecting") {
|
||||
setPhase("connected");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFinalRecommendFrame(parsed)) {
|
||||
result.status = parsed.status;
|
||||
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||
if (Array.isArray(parsed.picks)) {
|
||||
result.picks = parsed.picks as RecommendPick[];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastEvent === "messages") {
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
setPhase("streaming");
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
rawContent += delta.text;
|
||||
const { rationale, picks } = parseRecommendStreamContent(rawContent);
|
||||
if (rationale !== lastRationale) {
|
||||
lastRationale = rationale;
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
lastPickCount = picks.length;
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.content) {
|
||||
const { rationale, picks } = parseRecommendStreamContent(result.content);
|
||||
if (rationale !== lastRationale) {
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 圆桌规划用到的所有 SVG 头像。
|
||||
*
|
||||
* 这些原本散落在主页面里,提到这里集中维护,便于 Step 1 / Step 2 / Step 3
|
||||
* 与 LeftSidebar、HighFidelityReport 复用。`getAvatar(type, size)` 根据
|
||||
* 字符串类型名(blue / purple / emerald / amber / pink / human / man)挑对应组件。
|
||||
*/
|
||||
|
||||
export const Man1Avatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<span
|
||||
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-slate-200/80 shadow-sm`}
|
||||
>
|
||||
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" fill="#E2E8F0" />
|
||||
<circle cx="50" cy="50" r="45" fill="#FEEDED" />
|
||||
<path d="M15 95 C 15 70, 30 65, 50 65 C 70 65, 85 70, 85 95 Z" fill="#1E293B" />
|
||||
<path d="M40 65 L50 78 L60 65 Z" fill="#FFFFFF" />
|
||||
<path d="M47 72 L53 72 L51 98 L49 98 Z" fill="#E11D48" />
|
||||
<path d="M40 65 L40 55 C 40 55, 45 58, 50 58 C 55 58, 60 55, 60 55 L60 65 Z" fill="#FBCFE8" />
|
||||
<circle cx="50" cy="38" r="18" fill="#FBCFE8" />
|
||||
<path d="M42 34 Q 45 32 47 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M53 34 Q 55 32 58 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="45" cy="39" r="2.5" fill="#0F172A" />
|
||||
<circle cx="55" cy="39" r="2.5" fill="#0F172A" />
|
||||
<path d="M30 32 C 30 18, 70 18, 70 32 C 72 26, 68 15, 50 15 C 32 15, 28 26, 30 32 Z" fill="#0F172A" />
|
||||
<path d="M30 32 C30 32, 28 35, 32 35 C34 35, 36 30, 36 30 Z" fill="#0F172A" />
|
||||
<path d="M70 32 C70 32, 72 35, 68 35 C66 35, 64 30, 64 30 Z" fill="#0F172A" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const RobotAvatar = ({
|
||||
bg = "bg-primary/10",
|
||||
headColor = "#f5222d",
|
||||
size = "h-8 w-8",
|
||||
}: {
|
||||
bg?: string;
|
||||
headColor?: string;
|
||||
size?: string;
|
||||
}) => (
|
||||
<span
|
||||
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-red-100/50 shadow-sm ${bg}`}
|
||||
>
|
||||
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="25" y="28" width="50" height="42" rx="12" fill={headColor} />
|
||||
<rect x="32" y="35" width="36" height="24" rx="6" fill="#1E293B" />
|
||||
<circle cx="42" cy="45" r="4" fill="#F74750" className="animate-pulse" />
|
||||
<circle cx="58" cy="45" r="4" fill="#F74750" className="animate-pulse" />
|
||||
<path d="M45 52 Q 50 55 55 52" stroke="#F74750" strokeWidth="2.2" strokeLinecap="round" />
|
||||
<rect x="20" y="41" width="5" height="16" rx="2" fill="#cf1322" />
|
||||
<rect x="75" y="41" width="5" height="16" rx="2" fill="#cf1322" />
|
||||
<line x1="50" y1="28" x2="50" y2="15" stroke={headColor} strokeWidth="4" strokeLinecap="round" />
|
||||
<circle cx="50" cy="12" r="5" fill="#FA9196" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#cf1322" bg="bg-[var(--brand-200)]" size={size} />
|
||||
);
|
||||
export const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#7c3aed" bg="bg-purple-100/40" size={size} />
|
||||
);
|
||||
export const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#059669" bg="bg-emerald-100/40" size={size} />
|
||||
);
|
||||
export const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#d97706" bg="bg-amber-100/40" size={size} />
|
||||
);
|
||||
export const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#db2777" bg="bg-pink-100/40" size={size} />
|
||||
);
|
||||
|
||||
/** 根据字符串类型名挑对应头像。 */
|
||||
export function getAvatar(type: string, size: string = "h-8 w-8") {
|
||||
switch (type) {
|
||||
case "man":
|
||||
case "human":
|
||||
return <Man1Avatar size={size} />;
|
||||
case "blue":
|
||||
return <BlueRobotAvatar size={size} />;
|
||||
case "purple":
|
||||
return <PurpleRobotAvatar size={size} />;
|
||||
case "emerald":
|
||||
return <EmeraldRobotAvatar size={size} />;
|
||||
case "amber":
|
||||
return <AmberRobotAvatar size={size} />;
|
||||
case "pink":
|
||||
return <PinkRobotAvatar size={size} />;
|
||||
default:
|
||||
return <RobotAvatar size={size} />;
|
||||
}
|
||||
}
|
||||
@@ -368,8 +368,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 3: Generated files per seat */}
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
{/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
|
||||
<section className="hidden bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-primary" />
|
||||
生成的报告文件
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type IntentClarificationSelection = {
|
||||
selectedIds: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
function normalizeOptions(raw: unknown[]): ClarificationOption[] {
|
||||
return raw
|
||||
.map((opt, index) => {
|
||||
if (typeof opt === "string") {
|
||||
const label = opt.trim();
|
||||
return label ? { id: `option-${index}`, label } : null;
|
||||
}
|
||||
if (opt && typeof opt === "object" && "label" in opt) {
|
||||
const obj = opt as { id?: unknown; label?: unknown; description?: unknown };
|
||||
const label = String(obj.label ?? "").trim();
|
||||
if (!label) return null;
|
||||
const id = String(obj.id ?? label).trim() || `option-${index}`;
|
||||
const description =
|
||||
typeof obj.description === "string" && obj.description.trim()
|
||||
? obj.description.trim()
|
||||
: undefined;
|
||||
return { id, label, description };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((opt): opt is ClarificationOption => opt !== null);
|
||||
}
|
||||
|
||||
export function formatIntentClarificationAnswer(
|
||||
payload: ClarificationPayload,
|
||||
selection: IntentClarificationSelection,
|
||||
): string | null {
|
||||
const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []);
|
||||
const selectedLabels = selection.selectedIds
|
||||
.map((sid) => options.find((option) => option.id === sid)?.label)
|
||||
.filter((label): label is string => Boolean(label && label.trim()));
|
||||
const trimmedCustom = selection.custom.trim();
|
||||
|
||||
if (selectedLabels.length > 0 && trimmedCustom) {
|
||||
return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
||||
}
|
||||
if (selectedLabels.length > 0) {
|
||||
return `我选择:${selectedLabels.join("、")}`;
|
||||
}
|
||||
if (trimmedCustom) {
|
||||
return trimmedCustom;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface IntentClarificationCardProps {
|
||||
payload: ClarificationPayload;
|
||||
fallbackContent?: string;
|
||||
selection: IntentClarificationSelection;
|
||||
onSelectionChange: (next: IntentClarificationSelection) => void;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
/** Multi-select: submit current selection. Single-select: also used for custom text. */
|
||||
onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
|
||||
}
|
||||
|
||||
export function IntentClarificationCard({
|
||||
payload,
|
||||
fallbackContent,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
}: IntentClarificationCardProps) {
|
||||
const options = useMemo(
|
||||
() => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
|
||||
[payload.options],
|
||||
);
|
||||
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allowMultiple,
|
||||
payload.clarificationType,
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
|
||||
}, [selection.custom, selection.selectedIds]);
|
||||
|
||||
const showSubmitButton = allowMultiple || payload.allowCustom !== false;
|
||||
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled || submitting) return;
|
||||
if (!allowMultiple) {
|
||||
onSubmit({ selectedIds: [optionId], custom: "" });
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
onSelectionChange({ ...selection, selectedIds: next });
|
||||
}
|
||||
|
||||
function updateCustom(value: string) {
|
||||
if (disabled || submitting) return;
|
||||
onSelectionChange({ ...selection, custom: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
需要你的确认
|
||||
</span>
|
||||
{payload.clarificationType ? (
|
||||
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
|
||||
{payload.clarificationType}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{payload.context ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-xs font-medium leading-relaxed text-slate-800">
|
||||
{payload.question || fallbackContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const isSelected = selection.selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={disabled || submitting}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
|
||||
isSelected && allowMultiple
|
||||
? "border-primary bg-primary text-white shadow-sm"
|
||||
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
|
||||
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
|
||||
(disabled || submitting) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span className="text-[11px] font-bold">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-snug",
|
||||
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && allowMultiple ? (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allowCustom !== false ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={selection.custom}
|
||||
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||
placeholder={
|
||||
options.length > 0
|
||||
? allowMultiple
|
||||
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||
}
|
||||
disabled={disabled || submitting}
|
||||
className="min-h-16 resize-none border-slate-200 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSubmitButton ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || submitting || !canSubmit}
|
||||
onClick={() => onSubmit()}
|
||||
className="h-8 rounded-xl px-4 text-xs font-bold"
|
||||
>
|
||||
{submitting ? "发送中…" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Step 1 / Step 2 消息列表共享的原子件。
|
||||
*
|
||||
* 两步的消息渲染共用了:头像 + 发送人/徽标/时间的 header 行、流式空内容时的
|
||||
* spinner+跳点占位、流式中尾部闪烁竖线、以及 Markdown 紧凑间距渲染配置。
|
||||
* 把它们提到这里:
|
||||
* - `MessageBubble` 单条消息壳(avatar + header + children slot)
|
||||
* - `MessageLoadingPlaceholder` 流式但还没首字节时的 spinner + 跳点 + label
|
||||
* - `StreamingCursor` 流式中末尾的小竖线动画
|
||||
* - `sharedMarkdownComponents` react-markdown 的紧凑渲染配置
|
||||
*
|
||||
* 各步特有的内容(Step 1 INTENT_READY 摘要卡 / 澄清卡片;Step 2 ChainOfThought
|
||||
* 折叠卡 + 「插话干预」红底容器)仍由对应 Panel 自己组合 —— 它们差异较大,
|
||||
* 强行抽象成 props 反而难读。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
/** 左侧头像。caller 决定是 `<Man1Avatar>` / `<RobotAvatar>` / `getAvatar(type)`。 */
|
||||
avatar: React.ReactNode;
|
||||
/** 发送人显示名。 */
|
||||
senderName: string;
|
||||
/** 发送人名字额外 className(例如 user 消息用 `text-primary`)。 */
|
||||
senderNameClassName?: string;
|
||||
/** Header 右上小徽标:Step 1 的 phase 标签、Step 2 的 confidence 标签。 */
|
||||
badge?: string;
|
||||
/** 时间字符串(`nowTime()` 输出)。 */
|
||||
time: string;
|
||||
/** 外层容器额外 className —— Step 2「插话干预」需要红底, caller 传入。 */
|
||||
containerClassName?: string;
|
||||
/** 消息正文区:由 caller 自由组合(MD / 摘要卡 / Steps 卡 / 澄清卡 / cursor 等)。 */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
avatar,
|
||||
senderName,
|
||||
senderNameClassName,
|
||||
badge,
|
||||
time,
|
||||
containerClassName,
|
||||
children,
|
||||
}: MessageBubbleProps) {
|
||||
return (
|
||||
<div className={cn("flex gap-4 items-start", containerClassName)}>
|
||||
{avatar}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-xs font-extrabold text-slate-900", senderNameClassName)}>
|
||||
{senderName}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="text-[9px] font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono font-medium shrink-0">{time}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式中、但还没拿到任何 token 时的占位。spinner + label + 三个跳动点,
|
||||
* 让用户感受到后端在工作而不是页面卡住。
|
||||
*/
|
||||
export function MessageLoadingPlaceholder({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1.5 text-xs text-slate-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<span className="font-medium">{label ?? "正在连接智能体..."}</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.3s]" />
|
||||
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.15s]" />
|
||||
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 流式过程中末尾追的小竖线动画。 */
|
||||
export function StreamingCursor() {
|
||||
return <span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* react-markdown 的紧凑间距配置 —— Step 1 / Step 2 / 主聊天默认气泡都用同一份。
|
||||
* 默认 block 边距(`margin: 1em`)在小行高 chat 气泡里太松,这里全压到 my-1。
|
||||
* 如果某步需要更多元素(table / blockquote / hr 等),不用单独 override,直接复用本表即可:
|
||||
* react-markdown 只会按需启用对应组件,多写无害。
|
||||
*/
|
||||
export const sharedMarkdownComponents = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className="my-1 whitespace-pre-wrap">{children}</p>
|
||||
),
|
||||
ul: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ul className="list-disc pl-5 my-1 space-y-0.5">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ol className="list-decimal pl-5 my-1 space-y-0.5">{children}</ol>
|
||||
),
|
||||
li: ({ children }: { children?: React.ReactNode }) => (
|
||||
<li className="leading-snug">{children}</li>
|
||||
),
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className="text-sm font-bold mt-2 mb-1">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className="text-sm font-bold mt-2 mb-1">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className="text-xs font-bold mt-1.5 mb-0.5">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h4 className="text-xs font-bold mt-1 mb-0.5">{children}</h4>
|
||||
),
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className="font-bold text-slate-900">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => (
|
||||
<em className="italic">{children}</em>
|
||||
),
|
||||
code: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const isBlock = /language-/.test(className ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<table className="my-1 border-collapse text-[11px]">{children}</table>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className="border border-slate-200 px-1.5 py-0.5">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="my-2 border-slate-200" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。
|
||||
* 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。
|
||||
*/
|
||||
export function MarkdownContent({ children }: { children: string }) {
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「思考过程」折叠卡。展示从 `<think>...</think>` 拆出的 reasoning 文本。
|
||||
*
|
||||
* 视觉与交互对齐主聊天 `src/components/ai-elements/reasoning.tsx`:
|
||||
* - 头部脑图标 + "正在思考..." / "思考完成 (Ns)" 文案
|
||||
* - 流式中默认展开,完成后自动延迟收起;用户点击切换后**不再自动收起**(尊重偏好)
|
||||
* - 内容区灰底框,用 sharedMarkdownComponents 渲染 markdown
|
||||
*/
|
||||
export interface ReasoningBlockProps {
|
||||
/** Reasoning 文本(已由 `splitInlineReasoning` 提取出来,不含 `<think>` 标签)。 */
|
||||
children: string;
|
||||
/** 是否仍在流式生成。决定头部 shimmer 与默认展开行为。 */
|
||||
streaming?: boolean;
|
||||
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
|
||||
durationSec?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
export function ReasoningBlock({
|
||||
children,
|
||||
streaming = false,
|
||||
durationSec,
|
||||
className,
|
||||
}: ReasoningBlockProps) {
|
||||
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
|
||||
const [open, setOpen] = useState<boolean>(streaming);
|
||||
const userTouchedRef = useRef<boolean>(false);
|
||||
const hasAutoClosedRef = useRef<boolean>(false);
|
||||
|
||||
// 流式开始时若用户没动过开关 → 自动展开;流式结束 → 延迟自动收起(仅一次)。
|
||||
useEffect(() => {
|
||||
if (userTouchedRef.current) return;
|
||||
if (streaming) {
|
||||
setOpen(true);
|
||||
hasAutoClosedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!hasAutoClosedRef.current && open) {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(false);
|
||||
hasAutoClosedRef.current = true;
|
||||
}, REASONING_AUTO_CLOSE_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [streaming, open]);
|
||||
|
||||
const handleToggle = () => {
|
||||
userTouchedRef.current = true;
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
const headerLabel = streaming
|
||||
? "正在思考..."
|
||||
: durationSec !== undefined
|
||||
? `思考用时 ${durationSec} 秒`
|
||||
: "思考过程";
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose space-y-2", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
|
||||
>
|
||||
<BrainIcon className={cn("size-3.5", streaming && "text-primary animate-pulse")} />
|
||||
<span className={cn(streaming && "animate-pulse")}>{headerLabel}</span>
|
||||
<ChevronDownIcon
|
||||
className={cn("size-3.5 transition-transform", open ? "rotate-180" : "rotate-0")}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed">
|
||||
<MarkdownContent>{children}</MarkdownContent>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 模型生成过程的「步骤卡」 —— Step 1 / Step 2 共享。
|
||||
*
|
||||
* 视觉与交互对齐主聊天 `chats/new` 的 `MessageGroup`(`src/components/workspace/messages/message-group.tsx`):
|
||||
* 1) 顶部 "查看其他 N 个步骤" 折叠按钮 —— 隐藏除最后一个工具调用之外的所有步骤
|
||||
* 2) 最后一个工具调用步骤 **始终展开** 显示(图标 + 文案 + args 子节点)
|
||||
* 3) 若末尾是 streaming/connecting 等非工具阶段,作为「正在思考」式标签放在卡底部
|
||||
*
|
||||
* 数据来源:`Step2Dialogue.steps[]` / `InteractiveMessage.steps[]` —— 由对应 hook
|
||||
* 在 `onPhaseChange` 回调里累积。每条 `step = { id, key:"phase:toolName", label, ts, args? }`。
|
||||
*
|
||||
* 历史背景:之前的 `StepsCard` 用 "全收/全展" 的折叠模式 —— 流式时收起、点击全展开,
|
||||
* 没法跟随主聊天的「凸显最后一个工具」体验。本组件改为「always-show-last + hide-prior」,
|
||||
* 与主聊天观感一致。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getStepDisplay,
|
||||
renderStepArgs,
|
||||
} from "@/roundtable-planning/lib/step-display";
|
||||
|
||||
/** 单条步骤 —— 与 Step2Dialogue.steps / InteractiveMessage.steps 同形。 */
|
||||
export interface BubbleStep {
|
||||
id: string;
|
||||
/** 形如 `tool_calling:web_search` / `streaming:` / `connecting:`。 */
|
||||
key: string;
|
||||
/** 原始 fallback 文案(getStepDisplay 在没有工具名时回退到它)。 */
|
||||
label: string;
|
||||
ts: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MessageStepsCardProps {
|
||||
steps: BubbleStep[];
|
||||
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
|
||||
streaming?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
||||
function isToolStep(step: BubbleStep): boolean {
|
||||
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
||||
}
|
||||
|
||||
export function MessageStepsCard({ steps, streaming, className }: MessageStepsCardProps) {
|
||||
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
||||
// `lastReasoningStep` 划分对齐。
|
||||
const toolSteps = steps.filter(isToolStep);
|
||||
const lastToolStep = toolSteps[toolSteps.length - 1];
|
||||
const priorToolSteps =
|
||||
lastToolStep && toolSteps.length > 1 ? toolSteps.slice(0, -1) : [];
|
||||
|
||||
// 末尾若是非工具步骤(streaming/connecting/tool_result),作为「思考中」尾标签。
|
||||
// 找到最后一个工具步骤之后的所有步骤;若没有工具步骤,则全部都算思考。
|
||||
const lastToolIdx = lastToolStep ? steps.lastIndexOf(lastToolStep) : -1;
|
||||
const trailingReasoning = steps.slice(lastToolIdx + 1).filter((s) => !isToolStep(s));
|
||||
const lastReasoning = trailingReasoning[trailingReasoning.length - 1];
|
||||
|
||||
const [showAbove, setShowAbove] = useState<boolean>(false);
|
||||
|
||||
// 没有任何步骤 —— 不渲染卡(由 caller 决定是否还要其他占位)。
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
// 只有一种「思考」尾标签、没有任何工具调用 —— 直接渲染一行小灯泡,不用大卡。
|
||||
// 这样避免「调度中 / 已连接」这类瞬时阶段被搞成完整的折叠卡。
|
||||
if (!lastToolStep && lastReasoning) {
|
||||
const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args);
|
||||
const Icon = display.icon;
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-[11.5px] text-slate-500 py-0.5", className)}>
|
||||
<Icon className="size-3.5 text-slate-400" />
|
||||
<span>{display.label}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{lastReasoning.ts}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-1 rounded-lg border border-slate-200 bg-slate-50/50 p-0.5", className)}
|
||||
open={true}
|
||||
>
|
||||
{/* 顶部:折叠/展开「其他 N 个步骤」。仅当真有 prior 步骤时显示。 */}
|
||||
{priorToolSteps.length > 0 && (
|
||||
<Button
|
||||
className="w-full h-auto items-start justify-start py-1.5 text-left"
|
||||
variant="ghost"
|
||||
onClick={() => setShowAbove((v) => !v)}
|
||||
>
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
<span className="text-[11.5px] opacity-60">
|
||||
{showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
"size-4 opacity-60 transition-transform duration-200",
|
||||
showAbove ? "rotate-180" : "",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 中段:工具调用步骤。Last 工具始终展开;其它工具按 showAbove 控制。 */}
|
||||
{lastToolStep && (
|
||||
<ChainOfThoughtContent className="px-3 pb-2">
|
||||
{showAbove &&
|
||||
priorToolSteps.map((s) => (
|
||||
<ToolStep key={s.id} step={s} status="complete" />
|
||||
))}
|
||||
<ToolStep
|
||||
step={lastToolStep}
|
||||
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
||||
/>
|
||||
</ChainOfThoughtContent>
|
||||
)}
|
||||
|
||||
{/* 底部:streaming 阶段的「正在思考」标签。 */}
|
||||
{lastReasoning && (
|
||||
<div className="px-3 pb-2">
|
||||
<ReasoningStep step={lastReasoning} streaming={streaming === true} />
|
||||
</div>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolStep({
|
||||
step,
|
||||
status,
|
||||
}: {
|
||||
step: BubbleStep;
|
||||
status: "complete" | "active";
|
||||
}) {
|
||||
const display = getStepDisplay(step.key, step.label, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args);
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
icon={display.icon}
|
||||
status={status}
|
||||
label={
|
||||
<span className="text-[12px] text-slate-700">
|
||||
<span className="font-medium">{display.label}</span>
|
||||
<span className="ml-2 text-[10px] text-slate-400 font-mono">{step.ts}</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{argNode}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boolean }) {
|
||||
const display = getStepDisplay(step.key, step.label, step.args);
|
||||
const Icon = display.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[11.5px] text-slate-500">
|
||||
<Icon className={cn("size-3.5", streaming ? "text-primary animate-pulse" : "text-slate-400")} />
|
||||
<span>{display.label}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{step.ts}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Persona 抽屉。
|
||||
*
|
||||
* Step 2 圆桌可视化里点击智能体头像后展开。允许覆写该席位的:
|
||||
* - System Prompt(本地记录,后端覆写未开放)
|
||||
* - 推理模型(下一轮派活时由 `runOrchestration` 读 `agentConfigs[id].model`)
|
||||
* - Temperature(本地记录)
|
||||
*
|
||||
* 「参数覆盖并重启动算研讨」按钮 = `onSave`,不会立即重启编排,只更新本地配置;
|
||||
* 真正生效在该席位下一次被 leader 派活时。
|
||||
*/
|
||||
import { X, Sparkles } from "lucide-react";
|
||||
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import type {
|
||||
AgentConfig,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type {
|
||||
RoleEntry,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface PersonaDrawerProps {
|
||||
seatId: number;
|
||||
roles: RoleEntry[];
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
setAgentConfigs: React.Dispatch<
|
||||
React.SetStateAction<Record<number, AgentConfig>>
|
||||
>;
|
||||
selectedModel: string;
|
||||
availableModels: ModelOption[];
|
||||
modelsLoading: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (id: number) => void;
|
||||
}
|
||||
|
||||
/** 席位 id → 头像配色(与圆桌可视化里的硬编码保持一致)。 */
|
||||
function avatarTypeForSeat(seatId: number): string {
|
||||
switch (seatId) {
|
||||
case 2:
|
||||
return "blue";
|
||||
case 3:
|
||||
return "purple";
|
||||
case 4:
|
||||
return "emerald";
|
||||
case 5:
|
||||
return "human";
|
||||
case 6:
|
||||
return "amber";
|
||||
case 7:
|
||||
return "pink";
|
||||
default:
|
||||
return "blue";
|
||||
}
|
||||
}
|
||||
|
||||
export function PersonaDrawer({
|
||||
seatId,
|
||||
roles,
|
||||
agentConfigs,
|
||||
setAgentConfigs,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
onClose,
|
||||
onSave,
|
||||
}: PersonaDrawerProps) {
|
||||
const cfg = agentConfigs[seatId];
|
||||
const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体";
|
||||
|
||||
return (
|
||||
<div className="mx-6 mb-4 p-4.5 bg-gradient-to-r from-[var(--brand-200)]/50 via-[var(--brand-200)]/15 to-white border-2 border-primary rounded-2xl shadow-[0_8px_30px_rgba(245,34,45,0.06)] relative animate-fade-in">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3.5 right-3.5 text-slate-400 hover:text-slate-650 hover:bg-slate-100 p-1.5 rounded-full transition cursor-pointer"
|
||||
title="关闭 Override"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-5 text-xs text-slate-600">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAvatar(avatarTypeForSeat(seatId), "h-10 w-10")}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-extrabold text-slate-900">
|
||||
{roleName} 人设专有策略调制
|
||||
</h4>
|
||||
<span className="text-[9px] font-black bg-[var(--brand-200)] text-primary px-2 py-0.5 rounded-full scale-90">
|
||||
系统就绪
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-bold">
|
||||
配置此席位智能体的运行大语言模型及系统 System Prompt 指令
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase block tracking-wider">
|
||||
该席位系统提示词指令 override (System Instruction)
|
||||
</label>
|
||||
<textarea
|
||||
value={cfg?.prompt || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setAgentConfigs((prev) => ({
|
||||
...prev,
|
||||
[seatId]: { ...prev[seatId], prompt: val },
|
||||
}));
|
||||
}}
|
||||
className="w-full text-xs text-slate-700 bg-white border border-slate-200 focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 rounded-xl px-3 py-2.5 h-[65px] resize-none focus:outline-none font-medium leading-relaxed"
|
||||
placeholder="请输入覆盖的专家提示词内容..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-[260px] flex flex-col justify-between gap-3 border-t lg:border-t-0 lg:border-l border-slate-100 lg:pl-5 pt-3.5 lg:pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">
|
||||
推理本源模型
|
||||
</label>
|
||||
<select
|
||||
value={cfg?.model || selectedModel || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setAgentConfigs((prev) => ({
|
||||
...prev,
|
||||
[seatId]: { ...prev[seatId], model: val },
|
||||
}));
|
||||
}}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs text-slate-700 font-bold focus:outline-none"
|
||||
disabled={modelsLoading}
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">
|
||||
{modelsLoading ? "加载中…" : "未配置模型"}
|
||||
</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[9px] font-black text-slate-500">
|
||||
<span>随机创造性倾向 (Temp)</span>
|
||||
<span className="font-mono text-primary font-bold">{cfg?.temp || 0.2}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1.0}
|
||||
step={0.1}
|
||||
value={cfg?.temp || 0.2}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setAgentConfigs((prev) => ({
|
||||
...prev,
|
||||
[seatId]: { ...prev[seatId], temp: val },
|
||||
}));
|
||||
}}
|
||||
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onSave(seatId)}
|
||||
className="w-full bg-primary hover:bg-[var(--brand-900)] text-white text-[10.5px] font-black py-2 rounded-xl transition shadow active:scale-95 cursor-pointer flex items-center justify-center gap-1"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-red-200" />
|
||||
参数覆盖并重启动算研讨
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listAgents, type Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
MarkdownContent,
|
||||
ReasoningBlock,
|
||||
StreamingCursor,
|
||||
} from "./MessageBubble";
|
||||
import { MessageStepsCard } from "./MessageStepsCard";
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
parseRecommendStreamContent,
|
||||
streamRecommend,
|
||||
type RecommendCandidate,
|
||||
type RecommendStreamPhase,
|
||||
} from "../api/recommend";
|
||||
import { splitInlineReasoning } from "../lib/reasoning";
|
||||
|
||||
/**
|
||||
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
|
||||
* (roundtable). Opens with a streaming recommendation from
|
||||
* `roundtable-recommender`, defaults its picks to checked, and lets the user
|
||||
* trim or extend the selection from the rest of the agent pool before
|
||||
* confirming.
|
||||
*
|
||||
* Selection bounds (2 ≤ N ≤ 8) are enforced on the confirm button — the
|
||||
* backend also clamps but UI feedback is faster here.
|
||||
*/
|
||||
|
||||
export interface RecommendAgentsDialogProps {
|
||||
open: boolean;
|
||||
intent: IntentSummary | null;
|
||||
/** Falls back to the recommender's own default if omitted. */
|
||||
model?: string;
|
||||
onCancel: () => void;
|
||||
/** Called with the user's final selection of agent ids (in display order). */
|
||||
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
|
||||
}
|
||||
|
||||
const MIN_SELECTED = 2;
|
||||
const MAX_SELECTED = 8;
|
||||
|
||||
/**
|
||||
* Default selection when the recommender fails or returns < 2 valid picks.
|
||||
* Intersected with the live candidate pool at runtime so missing built-ins
|
||||
* don't break the dialog.
|
||||
*/
|
||||
const FALLBACK_AGENT_IDS = [
|
||||
"roundtable-intelligence",
|
||||
"roundtable-environment",
|
||||
"roundtable-solution-design",
|
||||
"roundtable-risk-review",
|
||||
"roundtable-execution-plan",
|
||||
"roundtable-summary",
|
||||
];
|
||||
|
||||
type DialogPhase =
|
||||
| "idle"
|
||||
| "loading_agents"
|
||||
| "recommending"
|
||||
| "ready"
|
||||
| "fallback"
|
||||
| "error";
|
||||
|
||||
interface RecommendStep {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface InternalState {
|
||||
phase: DialogPhase;
|
||||
/** Streaming + final rationale text from the recommender. */
|
||||
rationale: string;
|
||||
/** Streaming SSE phase from the recommender helper. */
|
||||
streamPhase: RecommendStreamPhase | null;
|
||||
/** 推荐流程阶段时间线 —— 调度 / 已连接 / 流式生成 …… 用 ChainOfThought 折叠展示。 */
|
||||
steps: RecommendStep[];
|
||||
/** Pre-filtered candidate pool fetched from `/api/agents`. */
|
||||
candidates: RecommendCandidate[];
|
||||
/** Reason-by-agent_id, only for picks returned by the recommender. */
|
||||
reasons: Record<string, string>;
|
||||
/** Agent ids the recommender returned (in order). */
|
||||
recommendedIds: string[];
|
||||
/** User-visible warning ("推荐失败,已使用默认席位") for the fallback case. */
|
||||
warning: string | null;
|
||||
/** Latest error message; only shown when `phase === "error"`. */
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InternalState = {
|
||||
phase: "idle",
|
||||
rationale: "",
|
||||
streamPhase: null,
|
||||
steps: [],
|
||||
candidates: [],
|
||||
reasons: {},
|
||||
recommendedIds: [],
|
||||
warning: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
|
||||
function recommendPhaseLabel(phase: RecommendStreamPhase): string {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度推荐智能体";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "streaming":
|
||||
return "正在生成推荐";
|
||||
default:
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
|
||||
function nowTimeStr(): string {
|
||||
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which candidate ids should be checked by default given a set of
|
||||
* recommended ids. The result preserves recommendation order and silently
|
||||
* drops ids that aren't in the candidate pool.
|
||||
*/
|
||||
function intersectInOrder(preferred: string[], pool: RecommendCandidate[]): string[] {
|
||||
const poolIds = new Set(pool.map((c) => c.agent_id));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of preferred) {
|
||||
if (!poolIds.has(id) || seen.has(id)) continue;
|
||||
out.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function RecommendAgentsDialog({
|
||||
open,
|
||||
intent,
|
||||
model,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RecommendAgentsDialogProps) {
|
||||
const [state, setState] = useState<InternalState>(INITIAL_STATE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Latest stream abort controller, so closing or re-running cancels the
|
||||
// in-flight request instead of leaking it.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Generation token guards against stale completion handlers writing into
|
||||
// state after a re-run (e.g. user clicks "重新推荐" mid-stream).
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const cancelInflight = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The full pipeline: fetch agents → run recommendation → seed selection.
|
||||
* Wrapped so the user can re-trigger it via the "重新推荐" button.
|
||||
*/
|
||||
const runRecommendation = useCallback(async () => {
|
||||
if (!intent || !intent.objective) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "缺少任务目标,无法启动推荐",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cancelInflight();
|
||||
const myGen = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({ ...INITIAL_STATE, phase: "loading_agents" });
|
||||
setSelectedIds(new Set());
|
||||
|
||||
let agents: Agent[];
|
||||
try {
|
||||
agents = await listAgents();
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
if (candidates.length === 0) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "recommending",
|
||||
candidates,
|
||||
rationale: "",
|
||||
streamPhase: "connecting",
|
||||
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
|
||||
steps: [
|
||||
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
|
||||
],
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await streamRecommend({
|
||||
intent,
|
||||
candidates,
|
||||
model,
|
||||
signal: controller.signal,
|
||||
onRationaleUpdate: (rationale) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, rationale }));
|
||||
},
|
||||
onPicksUpdate: (picks) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
for (const p of picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of seed) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
}));
|
||||
},
|
||||
onPhaseChange: (phase) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => {
|
||||
const key = phase;
|
||||
const last = prev.steps[prev.steps.length - 1];
|
||||
// 连续重复阶段不重复 push,只在阶段切换时打点。
|
||||
const steps =
|
||||
last && last.key === key
|
||||
? prev.steps
|
||||
: [
|
||||
...prev.steps,
|
||||
{
|
||||
id: String(prev.steps.length),
|
||||
key,
|
||||
label: recommendPhaseLabel(phase),
|
||||
ts: nowTimeStr(),
|
||||
},
|
||||
];
|
||||
return { ...prev, streamPhase: phase, steps };
|
||||
});
|
||||
},
|
||||
});
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
if (result.picks) {
|
||||
for (const p of result.picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "ready",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: status === "asking" or picks too few. Use the 6 built-in
|
||||
// roundtable seats, intersected against the live pool.
|
||||
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
|
||||
const seed = fallback.length >= MIN_SELECTED
|
||||
? fallback
|
||||
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "fallback",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds: seed,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
|
||||
}));
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
// AbortError fires when the user closes the dialog mid-stream — no
|
||||
// state update needed because the dialog is going away.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
streamPhase: null,
|
||||
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
|
||||
}));
|
||||
}
|
||||
}, [intent, model, cancelInflight]);
|
||||
|
||||
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
|
||||
// in-flight stream so the next open starts clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
cancelInflight();
|
||||
return;
|
||||
}
|
||||
void runRecommendation();
|
||||
return () => {
|
||||
cancelInflight();
|
||||
};
|
||||
}, [open, runRecommendation, cancelInflight]);
|
||||
|
||||
const handleToggle = useCallback((agentId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(agentId)) next.delete(agentId);
|
||||
else next.add(agentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Split candidates into "recommended" and "other" — order within each
|
||||
// group matches the recommender's pick order / the API's list order so the
|
||||
// dialog is deterministic between renders.
|
||||
const { recommendedCards, otherCards } = useMemo(() => {
|
||||
const recSet = new Set(state.recommendedIds);
|
||||
const byId = new Map(state.candidates.map((c) => [c.agent_id, c]));
|
||||
const recommended: RecommendCandidate[] = [];
|
||||
for (const id of state.recommendedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) recommended.push(c);
|
||||
}
|
||||
const others = state.candidates.filter((c) => !recSet.has(c.agent_id));
|
||||
return { recommendedCards: recommended, otherCards: others };
|
||||
}, [state.candidates, state.recommendedIds]);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const canConfirm =
|
||||
(state.phase === "ready" || state.phase === "fallback") &&
|
||||
selectedCount >= MIN_SELECTED &&
|
||||
selectedCount <= MAX_SELECTED;
|
||||
|
||||
const selectionHint = (() => {
|
||||
if (selectedCount < MIN_SELECTED) {
|
||||
return `至少再选 ${MIN_SELECTED - selectedCount} 个`;
|
||||
}
|
||||
if (selectedCount > MAX_SELECTED) {
|
||||
return `已超出上限 ${selectedCount - MAX_SELECTED} 个`;
|
||||
}
|
||||
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
|
||||
})();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canConfirm) return;
|
||||
// Order: recommended-and-selected first (in recommender order), then
|
||||
// user-added picks (in pool order). Keeps the leader's "可调度智能体"
|
||||
// list visually stable.
|
||||
//
|
||||
// 传完整对象(含 name)而不是只传 agent_id —— 非内置 agent 的 id 是 UUID,
|
||||
// 上游若只拿 id,在左侧席位列表就会显示 UUID 而非中文名。
|
||||
const ordered: Array<{ agent_id: string; name: string }> = [];
|
||||
for (const c of recommendedCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
|
||||
}
|
||||
for (const c of otherCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
|
||||
}
|
||||
onConfirm(ordered);
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-purple-500" />
|
||||
选择参会智能体
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ============================================================
|
||||
TOP: 模型生成内容
|
||||
包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
|
||||
与主聊天消息气泡内容区视觉对齐。
|
||||
**可折叠** —— 流式中默认展开方便观察生成过程,完成后自动收起一次,
|
||||
让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。
|
||||
============================================================ */}
|
||||
<CollapsibleModelOutput
|
||||
streaming={state.phase === "recommending"}
|
||||
phase={state.phase}
|
||||
>
|
||||
<RecommenderOutput state={state} />
|
||||
</CollapsibleModelOutput>
|
||||
|
||||
{/* ============================================================
|
||||
BOTTOM: 推荐的智能体(可勾选)
|
||||
滚动区,占据剩余高度。
|
||||
============================================================ */}
|
||||
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-2 flex items-center gap-1.5 shrink-0">
|
||||
<CheckIcon className="size-3 text-emerald-500" />
|
||||
推荐参会智能体
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
|
||||
{state.phase === "error" ? (
|
||||
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
||||
{state.errorMessage ?? "未知错误"}
|
||||
</div>
|
||||
) : state.phase === "recommending" ? (
|
||||
<>
|
||||
{recommendedCards.length > 0 ? (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
streaming
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 py-2">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
等待推荐结果...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (state.phase === "ready" || state.phase === "fallback") ? (
|
||||
<>
|
||||
{state.warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
|
||||
{state.warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recommendedCards.length > 0 && (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{otherCards.length > 0 && (
|
||||
<Section title={`其他候选 (${otherCards.length})`}>
|
||||
{otherCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={undefined}
|
||||
recommended={false}
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
selectedCount < MIN_SELECTED || selectedCount > MAX_SELECTED
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selectionHint}
|
||||
</span>
|
||||
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void runRecommendation()}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon className="size-3 mr-1" />
|
||||
重新推荐
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||||
进入研讨
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「模型生成内容」整段的可折叠外壳。
|
||||
*
|
||||
* 默认行为:
|
||||
* - 流式期间(`streaming=true`)默认展开,让用户看到实时生成
|
||||
* - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* 把焦点让给下方推荐卡片
|
||||
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
|
||||
*
|
||||
* 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」),
|
||||
* 让用户不用展开就知道当前阶段。
|
||||
*/
|
||||
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
function CollapsibleModelOutput({
|
||||
streaming,
|
||||
phase,
|
||||
children,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
phase: DialogPhase;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(true);
|
||||
const userTouchedRef = useRef<boolean>(false);
|
||||
const hasAutoClosedRef = useRef<boolean>(false);
|
||||
|
||||
// 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。
|
||||
// 流式结束 → 延迟自动收起一次(仅一次)。
|
||||
useEffect(() => {
|
||||
if (userTouchedRef.current) return;
|
||||
if (streaming) {
|
||||
setOpen(true);
|
||||
hasAutoClosedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!hasAutoClosedRef.current && open) {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(false);
|
||||
hasAutoClosedRef.current = true;
|
||||
}, MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [streaming, open]);
|
||||
|
||||
const statusBadge = (() => {
|
||||
switch (phase) {
|
||||
case "loading_agents":
|
||||
return { label: "加载中", className: "bg-slate-100 text-slate-600" };
|
||||
case "recommending":
|
||||
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
|
||||
case "ready":
|
||||
return { label: "推荐已生成", className: "bg-emerald-50 text-emerald-700 border border-emerald-200" };
|
||||
case "fallback":
|
||||
return { label: "已使用兜底", className: "bg-amber-50 text-amber-700 border border-amber-200" };
|
||||
case "error":
|
||||
return { label: "推荐失败", className: "bg-red-50 text-red-700 border border-red-200" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<section className="border rounded-md bg-muted/20 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userTouchedRef.current = true;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<SparklesIcon
|
||||
className={cn(
|
||||
"size-3 text-purple-500 shrink-0",
|
||||
streaming && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 text-left">模型生成内容</span>
|
||||
{statusBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9.5px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded normal-case",
|
||||
statusBadge.className,
|
||||
)}
|
||||
>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 text-slate-400 shrink-0 transition-transform",
|
||||
open ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="px-3 pb-3">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推荐弹窗顶部「模型生成内容」面板。
|
||||
*
|
||||
* 视觉与交互对齐主聊天 chats/new 的消息列表:
|
||||
* - 顶部一行 spinner + 阶段文案(连接中 / 流式 / 完成)
|
||||
* - 步骤折叠卡(MessageStepsCard,与对话气泡里同一实现)
|
||||
* - 「思考过程」折叠卡(从 `<think>` 抽出来 reasoning,流式中默认展开)
|
||||
* - 正文 Markdown(去掉了 `<think>` 标签后的 rationale)
|
||||
* - 流式中正文末尾跟一个闪烁竖线
|
||||
*/
|
||||
function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
if (state.phase === "loading_agents") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载候选智能体...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "error") {
|
||||
return (
|
||||
<div className="text-sm text-destructive">
|
||||
推荐失败,请使用下方的「重新推荐」重试,或取消后再次进入。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showRationaleStage =
|
||||
state.phase === "recommending" ||
|
||||
state.phase === "ready" ||
|
||||
state.phase === "fallback";
|
||||
if (!showRationaleStage) return null;
|
||||
|
||||
const isStreaming = state.phase === "recommending";
|
||||
// 顶部状态行:跟主聊天的「正在思考...」类似,只是文案换成推荐阶段。
|
||||
const streamingLabel = isStreaming
|
||||
? state.streamPhase === "connecting"
|
||||
? "调度推荐智能体..."
|
||||
: state.streamPhase === "connected"
|
||||
? "已连接,等待响应..."
|
||||
: "正在生成推荐..."
|
||||
: null;
|
||||
|
||||
// 把 `<think>...</think>` 抽出来作 reasoning,剩下的 rationale 用 markdown 渲染。
|
||||
// 这与 Step1Panel / Step2Panel 的处理方式完全一致。
|
||||
const { content: cleanedRationale, reasoning } = splitInlineReasoning(state.rationale);
|
||||
const hasRationale = cleanedRationale.length > 0;
|
||||
const hasReasoning = !!reasoning;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{streamingLabel && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{streamingLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.steps.length > 0 && (
|
||||
<MessageStepsCard steps={state.steps} streaming={isStreaming} />
|
||||
)}
|
||||
|
||||
{hasReasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
|
||||
)}
|
||||
|
||||
{!hasRationale && !hasReasoning && isStreaming && (
|
||||
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
|
||||
)}
|
||||
|
||||
{hasRationale && (
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
|
||||
<MarkdownContent>{cleanedRationale}</MarkdownContent>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
/** True 时 header 可点击折叠/展开;false 保持原行为(始终展开,不可点击)。 */
|
||||
collapsible?: boolean;
|
||||
/** 仅当 collapsible=true 时生效。 */
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronUpIcon className="size-3" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3" />
|
||||
)}
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{/* flex flex-col + w-full + items-stretch:替代原 `grid gap-2`,
|
||||
强制所有 AgentCard 满父宽。grid 在某些浏览器对 <button> 子项的
|
||||
justify-self: stretch 处理不一致,导致"推荐参会"与"其他候选"
|
||||
两个 Section 视觉上有几像素宽度差。 */}
|
||||
{(!collapsible || open) && (
|
||||
<div className="flex w-full flex-col gap-2">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentCardProps {
|
||||
candidate: RecommendCandidate;
|
||||
reason: string | undefined;
|
||||
recommended: boolean;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
/** Card is still being filled in from the SSE stream. */
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
function AgentCard({
|
||||
candidate,
|
||||
reason,
|
||||
recommended,
|
||||
checked,
|
||||
onToggle,
|
||||
streaming = false,
|
||||
}: AgentCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
// w-full:`<button>` 默认 inline-block,在 grid cell 里不一定拉满;
|
||||
// 显式 w-full 保证推荐区与其他候选区的卡片**宽度严格一致**。
|
||||
"w-full text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
|
||||
checked
|
||||
? "border-primary bg-primary/5 hover:bg-primary/10"
|
||||
: "border-border hover:bg-muted/40",
|
||||
streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300",
|
||||
)}
|
||||
>
|
||||
{/* Visual check box — simulated, since the project ships no Checkbox primitive */}
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 size-4 rounded border flex items-center justify-center shrink-0",
|
||||
checked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
|
||||
{recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
|
||||
推荐
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{candidate.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{candidate.description}
|
||||
</div>
|
||||
)}
|
||||
{reason ? (
|
||||
<div className="text-xs text-primary/80 mt-1 italic">
|
||||
<span className="font-medium not-italic">推荐理由:</span> {reason}
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Step 1 主面板 —— 任务意图理解人机问答区。
|
||||
*
|
||||
* 主页面把所有 Step 1 状态(`useStep1Intent` 的返回值)+ 输入框 + 按钮处理函数
|
||||
* 透传过来,这里只关心:对话列表渲染、INTENT_READY 摘要卡、澄清卡片嵌入气泡、
|
||||
* 输入框 + 发送 / 暂停 / 跳过 / 继续追问 / 进入研讨 等底部按钮。
|
||||
*
|
||||
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
|
||||
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 2 保持一致。
|
||||
*
|
||||
* **不要**在这里再 fetch / 建 thread / 跑 SSE —— 这些逻辑都在 `useStep1Intent`,
|
||||
* 本组件保持纯展示 + 透传事件。
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pause,
|
||||
Repeat,
|
||||
Save,
|
||||
Send,
|
||||
Smile,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
IntentClarificationCard,
|
||||
type IntentClarificationSelection,
|
||||
} from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import {
|
||||
MarkdownContent,
|
||||
MessageBubble,
|
||||
MessageLoadingPlaceholder,
|
||||
ReasoningBlock,
|
||||
StreamingCursor,
|
||||
} from "@/roundtable-planning/components/MessageBubble";
|
||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import type {
|
||||
ClarificationPayload,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
|
||||
export interface Step1PanelProps {
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
intentThreadId: string | null;
|
||||
intentReady: object | null;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
// ─── Setters / Handlers ──────────────────────────────────────
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
>;
|
||||
onSendMessage: (text: string) => void;
|
||||
onStopIntent: () => void;
|
||||
onSkipQuestion: () => void;
|
||||
onFollowUp: () => void;
|
||||
onEnterRoundtable: () => void;
|
||||
onManualSave: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
submitIntentClarification: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function Step1Panel(props: Step1PanelProps) {
|
||||
const {
|
||||
interactiveMessages,
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
isIntentSending,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
setIntentClarificationSelections,
|
||||
onSendMessage,
|
||||
onStopIntent,
|
||||
onSkipQuestion,
|
||||
onFollowUp,
|
||||
onEnterRoundtable,
|
||||
onManualSave,
|
||||
onTriggerToast,
|
||||
submitIntentClarification,
|
||||
} = props;
|
||||
|
||||
const step1ScrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
|
||||
useAutoScroll(step1ScrollRef, [interactiveMessages]);
|
||||
|
||||
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
|
||||
const charactersLeft = inputText.length;
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputText.trim()) return;
|
||||
onSendMessage(inputText);
|
||||
setInputText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Dialogue Header */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
|
||||
<h3 className="text-sm font-extrabold text-slate-900">
|
||||
任务意图理解 (人机问答)
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
|
||||
<div
|
||||
ref={step1ScrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white"
|
||||
>
|
||||
{interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarification={submitIntentClarification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
|
||||
</div>
|
||||
|
||||
{/* Bottom input area */}
|
||||
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
|
||||
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
|
||||
<textarea
|
||||
id="input-text-area"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
pendingIntentClarificationMsgId
|
||||
? "也可在此输入自定义回答 (Enter 快速发送)"
|
||||
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
|
||||
}
|
||||
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-50 pt-2 text-[#94a3b8]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => onTriggerToast("😄 表情包功能仍在微调状态(仅供演示)")}
|
||||
className="p-1 hover:text-primary hover:bg-slate-50 rounded transition"
|
||||
>
|
||||
<Smile className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
id="send-message-btn"
|
||||
onClick={handleSend}
|
||||
disabled={isIntentSending || !intentThreadId}
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
|
||||
>
|
||||
{isIntentSending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Send className="w-3 h-3" />}
|
||||
{isIntentSending ? "发送中" : "发送"}
|
||||
</button>
|
||||
|
||||
{isIntentSending && (
|
||||
<button
|
||||
id="pause-intent-btn"
|
||||
type="button"
|
||||
onClick={onStopIntent}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition border border-amber-200/40 shadow-sm active:scale-95"
|
||||
>
|
||||
<Pause className="w-3 h-3" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
onClick={onFollowUp}
|
||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
|
||||
>
|
||||
<Repeat className="w-3 h-3 text-slate-500" />
|
||||
继续追问
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="skip-question-btn"
|
||||
onClick={onSkipQuestion}
|
||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
|
||||
>
|
||||
<X className="w-3 h-3 text-slate-500" strokeWidth={2.5} />
|
||||
跳过此项
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="enter-roundtable-btn"
|
||||
onClick={onEnterRoundtable}
|
||||
disabled={!intentReady}
|
||||
title={intentReady ? undefined : "请先完成任务意图澄清"}
|
||||
className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${
|
||||
intentReady
|
||||
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
|
||||
: "bg-emerald-600/40 text-white cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
进入多智能体研讨 >
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="save-draft-btn"
|
||||
onClick={onManualSave}
|
||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
|
||||
>
|
||||
<Save className="w-3 h-3 text-slate-500" />
|
||||
保存草稿
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条 Step 1 消息。
|
||||
*
|
||||
* 拆成独立函数避免主 panel 里大量嵌套 JSX。三种正文形态:
|
||||
* - 流式中、还没首字节 → MessageLoadingPlaceholder
|
||||
* - agent 消息 → splitIntentReadyBlock 拆出前 / 摘要 / 后,分别渲染
|
||||
* + IntentClarificationCard(若待澄清)
|
||||
* - user 消息 → 纯文本
|
||||
*/
|
||||
interface Step1MessageProps {
|
||||
msg: InteractiveMessage;
|
||||
intentReady: object | null;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
>;
|
||||
isIntentSending: boolean;
|
||||
submitIntentClarification: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function Step1Message({
|
||||
msg,
|
||||
intentReady,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
setIntentClarificationSelections,
|
||||
isIntentSending,
|
||||
submitIntentClarification,
|
||||
}: Step1MessageProps) {
|
||||
const isStreaming = msg.streaming === true;
|
||||
const isAgent = msg.sender !== "user";
|
||||
// 先把 `<think>...</think>` 抽出来作 reasoning,剩下的正文再走 INTENT_READY 解析。
|
||||
// 顺序很关键:若先 split INTENT_READY 再抽 think,think 块如果横跨 INTENT_READY
|
||||
// 标记会导致 JSON 解析失败。
|
||||
const rawContent = msg.content ?? "";
|
||||
const { content: thoughtFreeContent, reasoning } = isAgent
|
||||
? splitInlineReasoning(rawContent)
|
||||
: { content: rawContent, reasoning: null };
|
||||
const hasContent = thoughtFreeContent.length > 0;
|
||||
const clarification = msg.clarification;
|
||||
const parts = isAgent
|
||||
? splitIntentReadyBlock(thoughtFreeContent)
|
||||
: { before: thoughtFreeContent, summary: null as IntentSummary | null, after: "" };
|
||||
const isPendingClarification =
|
||||
isAgent &&
|
||||
clarification &&
|
||||
!intentReady &&
|
||||
!parts.summary &&
|
||||
pendingIntentClarificationMsgId === msg.id;
|
||||
const clarificationSelection =
|
||||
intentClarificationSelections.get(msg.id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const introText =
|
||||
clarification &&
|
||||
parts.before.trim() === (clarification.question || "").trim()
|
||||
? ""
|
||||
: parts.before;
|
||||
|
||||
// 主聊天里步骤卡放在「正文之上」(气泡内,header 之下),Step 2 也是这样。
|
||||
// 流式中默认显示 last tool call + 折叠 "查看其他 N 个步骤";完成后保留卡。
|
||||
const stepsCard =
|
||||
isAgent && Array.isArray(msg.steps) && msg.steps.length > 0 ? (
|
||||
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
|
||||
) : null;
|
||||
|
||||
// 「思考过程」折叠卡 —— 流式中默认展开,完成后自动延迟收起。
|
||||
const reasoningBlock =
|
||||
isAgent && reasoning ? (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
avatar={
|
||||
msg.sender === "user" ? (
|
||||
<Man1Avatar size="h-9 w-9 shrink-0" />
|
||||
) : (
|
||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-9 w-9 shrink-0" />
|
||||
)
|
||||
}
|
||||
senderName={msg.senderName}
|
||||
senderNameClassName={msg.sender === "user" ? "text-primary" : undefined}
|
||||
badge={isStreaming ? msg.phase : undefined}
|
||||
time={msg.time}
|
||||
>
|
||||
{stepsCard}
|
||||
{reasoningBlock}
|
||||
{isStreaming && !hasContent ? (
|
||||
<MessageLoadingPlaceholder label={msg.phase} />
|
||||
) : isAgent ? (
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
|
||||
{introText && <MarkdownContent>{introText}</MarkdownContent>}
|
||||
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
|
||||
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
|
||||
{isStreaming && <StreamingCursor />}
|
||||
{isPendingClarification && clarification && (
|
||||
<IntentClarificationCard
|
||||
payload={clarification}
|
||||
fallbackContent={parts.before || undefined}
|
||||
selection={clarificationSelection}
|
||||
onSelectionChange={(next) => {
|
||||
setIntentClarificationSelections((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(msg.id, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
disabled={!isPendingClarification}
|
||||
submitting={isIntentSending && Boolean(isPendingClarification)}
|
||||
onSubmit={(override) =>
|
||||
submitIntentClarification(msg.id, clarification, override)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
|
||||
{msg.content}
|
||||
</div>
|
||||
)}
|
||||
</MessageBubble>
|
||||
);
|
||||
}
|
||||
|
||||
/** [INTENT_READY] 标记块解析出 IntentSummary 后的绿色摘要卡。 */
|
||||
function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
|
||||
return (
|
||||
<div className="mt-1 border border-emerald-200 bg-emerald-50/50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-[10.5px] font-black text-emerald-700 tracking-wide">
|
||||
任务意图已明确
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
|
||||
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
|
||||
</div>
|
||||
{summary.constraints?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.constraints.map((c, i) => (
|
||||
<li key={i} className="leading-snug">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{summary.assumptions && summary.assumptions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.assumptions.map((a, i) => (
|
||||
<li key={i} className="leading-snug">{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Step 2 主面板 —— 多智能体圆桌研讨。
|
||||
*
|
||||
* 包含 4 块:
|
||||
* 1) 顶部 Headline + 挂起/恢复按钮
|
||||
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
|
||||
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
|
||||
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
|
||||
*
|
||||
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
|
||||
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 1 保持一致。
|
||||
*
|
||||
* 圆桌可视化 + 博弈边界调控台目前**未启用**(原代码用 `false &&` 隐藏),
|
||||
* 这里保留同样的占位逻辑,后续要恢复时改 `SHOW_VISUALIZER` 即可。
|
||||
*
|
||||
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
MarkdownContent,
|
||||
MessageBubble,
|
||||
MessageLoadingPlaceholder,
|
||||
ReasoningBlock,
|
||||
StreamingCursor,
|
||||
} from "@/roundtable-planning/components/MessageBubble";
|
||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import type {
|
||||
AgentConfig,
|
||||
Step2Dialogue,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type {
|
||||
RoleEntry,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface Step2PanelProps {
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
isInitializing: boolean;
|
||||
isRoundTableRunning: boolean;
|
||||
isOrchestrating: boolean;
|
||||
pendingClarification: ClarificationPayload | null;
|
||||
clarificationDraft: string;
|
||||
customAgentInput: string;
|
||||
hasConsensus: boolean;
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
selectedAgentId: number | null;
|
||||
roles: RoleEntry[];
|
||||
selectedModel: string;
|
||||
availableModels: ModelOption[];
|
||||
modelsLoading: boolean;
|
||||
// ─── Setters ──────────────────────────────────────────────────
|
||||
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
setAgentConfigs: React.Dispatch<
|
||||
React.SetStateAction<Record<number, AgentConfig>>
|
||||
>;
|
||||
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
// ─── Handlers ─────────────────────────────────────────────────
|
||||
onSubmitClarification: (answer: string) => void;
|
||||
onInjectToRoundtable: () => void;
|
||||
onSavePersona: (id: number) => void;
|
||||
onProceedToStep3: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export function Step2Panel(props: Step2PanelProps) {
|
||||
const {
|
||||
step2RoundtableDialogues,
|
||||
isInitializing,
|
||||
isRoundTableRunning,
|
||||
isOrchestrating,
|
||||
pendingClarification,
|
||||
clarificationDraft,
|
||||
customAgentInput,
|
||||
hasConsensus,
|
||||
agentConfigs,
|
||||
selectedAgentId,
|
||||
roles,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setIsRoundTableRunning,
|
||||
setClarificationDraft,
|
||||
setCustomAgentInput,
|
||||
setAgentConfigs,
|
||||
setSelectedAgentId,
|
||||
onSubmitClarification,
|
||||
onInjectToRoundtable,
|
||||
onSavePersona,
|
||||
onProceedToStep3,
|
||||
onTriggerToast,
|
||||
} = props;
|
||||
|
||||
const step2ScrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-slate-50/20">
|
||||
{/* Headline */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
|
||||
<div>
|
||||
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
分布式领域专家实时动态博弈与纳什协商收敛中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isRoundTableRunning && isOrchestrating ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRoundTableRunning(false);
|
||||
onTriggerToast("⏸️ 会商模拟暂挂!智能体挂起...");
|
||||
}}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
挂起协商
|
||||
</button>
|
||||
) : !isRoundTableRunning ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRoundTableRunning(true);
|
||||
onTriggerToast("▶️ 恢复大模型并行博弈与推理决策");
|
||||
}}
|
||||
className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
||||
恢复会商
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Persona drawer — 点圆桌头像后展开。当前圆桌可视化用 `false &&` 隐藏,
|
||||
所以入口只剩 LeftSidebar 或将来重新启用的可视化按钮。 */}
|
||||
{selectedAgentId && (
|
||||
<PersonaDrawer
|
||||
seatId={selectedAgentId}
|
||||
roles={roles}
|
||||
agentConfigs={agentConfigs}
|
||||
setAgentConfigs={setAgentConfigs}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
onClose={() => setSelectedAgentId(null)}
|
||||
onSave={onSavePersona}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conversation log */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
ref={step2ScrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
|
||||
>
|
||||
{isInitializing && step2RoundtableDialogues.length === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
|
||||
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
|
||||
</div>
|
||||
)}
|
||||
{step2RoundtableDialogues.map((msg) => (
|
||||
<Step2Message key={msg.id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||
</div>
|
||||
|
||||
{/* Clarification card */}
|
||||
{pendingClarification && (
|
||||
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
|
||||
{pendingClarification.clarificationType && (
|
||||
<span className="text-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
|
||||
{pendingClarification.clarificationType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pendingClarification.context && (
|
||||
<p className="text-[11px] text-amber-800 italic">{pendingClarification.context}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
|
||||
{pendingClarification.question}
|
||||
</p>
|
||||
{Array.isArray(pendingClarification.options) &&
|
||||
pendingClarification.options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{pendingClarification.options.map((opt, idx) => {
|
||||
const label =
|
||||
typeof opt === "string"
|
||||
? opt
|
||||
: opt && typeof opt === "object" && "label" in (opt as object)
|
||||
? String((opt as { label: unknown }).label ?? "")
|
||||
: String(opt ?? "");
|
||||
if (!label) return null;
|
||||
return (
|
||||
<button
|
||||
key={`${idx}-${label}`}
|
||||
type="button"
|
||||
onClick={() => onSubmitClarification(label)}
|
||||
className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pendingClarification.allowCustom !== false && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={clarificationDraft}
|
||||
onChange={(e) => setClarificationDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSubmitClarification(clarificationDraft);
|
||||
}}
|
||||
placeholder="输入你的回答…"
|
||||
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmitClarification(clarificationDraft)}
|
||||
disabled={!clarificationDraft.trim()}
|
||||
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Console + intervention bar */}
|
||||
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={customAgentInput}
|
||||
onChange={(e) => setCustomAgentInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onInjectToRoundtable();
|
||||
}}
|
||||
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
|
||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onInjectToRoundtable}
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
|
||||
>
|
||||
干预研审
|
||||
</button>
|
||||
|
||||
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProceedToStep3}
|
||||
disabled={!hasConsensus}
|
||||
aria-disabled={!hasConsensus}
|
||||
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
|
||||
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
|
||||
hasConsensus
|
||||
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
|
||||
: "bg-emerald-600/40 text-white cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
达成共识生成全案成果 >
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条 Step 2 对话气泡。包含:
|
||||
* - ChainOfThought 折叠卡(若 msg.steps 非空)
|
||||
* - 流式无内容的 loading 占位
|
||||
* - markdown 正文 + 流式 cursor
|
||||
*
|
||||
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
|
||||
*/
|
||||
interface Step2MessageProps {
|
||||
msg: Step2Dialogue;
|
||||
}
|
||||
|
||||
function Step2Message({ msg }: Step2MessageProps) {
|
||||
const isInjected = msg.sender === "用户 (插话干预)";
|
||||
const isStreaming = msg.streaming === true;
|
||||
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
||||
// 用户「插话干预」/「澄清回复」这类系统注入的气泡内容里基本不会有 think 标签,
|
||||
// 但 splitInlineReasoning 对空 reasoning 文本返回 null,放心套用就好。
|
||||
const rawContent = msg.content ?? "";
|
||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||
const hasContent = content.length > 0;
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
|
||||
senderName={msg.sender}
|
||||
badge={msg.confidence}
|
||||
time={msg.time}
|
||||
containerClassName={
|
||||
isInjected
|
||||
? "bg-[var(--brand-200)]/30 p-3 rounded-2xl border border-red-100/50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
|
||||
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
|
||||
)}
|
||||
{reasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
|
||||
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
|
||||
<MarkdownContent>{content}</MarkdownContent>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</div>
|
||||
</MessageBubble>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Step 3 主面板 —— 方案结果展示。
|
||||
*
|
||||
* 当前是 `HighFidelityReport` 的薄壳;放它在这里只是为了让 Step 1 / Step 2 /
|
||||
* Step 3 三步在主页面里以同样的导入风格出现,方便阅读。
|
||||
*
|
||||
* Step 3 真实逻辑请改 `HighFidelityReport.tsx` —— 包括各席位 artifact
|
||||
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
|
||||
*/
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
export interface Step3PanelProps {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
hasConsensus: boolean;
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
agentNameToRole: Record<
|
||||
string,
|
||||
{ speakerId: number; avatarType: string; displayName: string }
|
||||
>;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function Step3Panel(props: Step3PanelProps) {
|
||||
const {
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
hasConsensus,
|
||||
step2RoundtableDialogues,
|
||||
agentNameToRole,
|
||||
onReset,
|
||||
} = props;
|
||||
|
||||
const seatDeliveries = step2RoundtableDialogues
|
||||
.filter((d) => d.confidence === "子智能体交付")
|
||||
.map((d) => ({
|
||||
sender: String(d.sender ?? ""),
|
||||
avatarType: String(d.avatarType ?? "blue"),
|
||||
content: String(d.content ?? ""),
|
||||
}));
|
||||
|
||||
const seatNameMap = Object.fromEntries(
|
||||
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
|
||||
);
|
||||
|
||||
return (
|
||||
<HighFidelityReport
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
hasConsensus={hasConsensus}
|
||||
seatDeliveries={seatDeliveries}
|
||||
seatNameMap={seatNameMap}
|
||||
onReset={onReset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,43 +29,89 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
const atBottomRef = useRef(true);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
|
||||
// Track scroll position. The handler runs for BOTH user-initiated scrolls
|
||||
// and programmatic `scrollTo` calls — after a programmatic scroll to the
|
||||
// bottom, the resulting onScroll naturally leaves atBottom=true, so the
|
||||
// auto-follow effect can keep firing on subsequent deps changes.
|
||||
// 自动跟随的"门"。区分**程序触发**和**用户触发**的滚动:
|
||||
// - 程序滚动(`el.scrollTop = el.scrollHeight`)前会临时把 programmaticRef 置 true
|
||||
// → 接下来一次 scroll 事件不修改 atBottom,避免自己把自己关掉。
|
||||
// - 任何其它 scroll 事件视为用户主动滚动,按位置判定 atBottom。
|
||||
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
|
||||
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
|
||||
const programmaticScrollRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let prevScrollTop = el.scrollTop;
|
||||
const onScroll = () => {
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
const isAtBottom = distanceFromBottom <= threshold;
|
||||
if (atBottomRef.current !== isAtBottom) {
|
||||
atBottomRef.current = isAtBottom;
|
||||
setAtBottom(isAtBottom);
|
||||
|
||||
if (programmaticScrollRef.current) {
|
||||
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
|
||||
programmaticScrollRef.current = false;
|
||||
prevScrollTop = currentScrollTop;
|
||||
// 程序滚动后通常落在底部,把 atBottom 维持 true。
|
||||
if (!atBottomRef.current && isAtBottom) {
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
|
||||
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
|
||||
if (currentScrollTop < prevScrollTop && atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
} else if (isAtBottom && !atBottomRef.current) {
|
||||
// 用户自己又滚回底部 → 恢复自动跟随
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
} else if (!isAtBottom && atBottomRef.current) {
|
||||
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
prevScrollTop = currentScrollTop;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// Initial measurement so we don't start in a stale state if the container
|
||||
// mounted with content already overflowing.
|
||||
// 初次测量
|
||||
onScroll();
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [ref, threshold]);
|
||||
|
||||
// Auto-scroll on deps change — but only if the user hasn't scrolled away.
|
||||
// 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
|
||||
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于
|
||||
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (!atBottomRef.current) return;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
// 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值,
|
||||
// 说明用户已经离开,绝不自动跟随。
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom > threshold) {
|
||||
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽
|
||||
if (atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
programmaticScrollRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
// 按钮点击是用户主动行为,保留 smooth 给视觉反馈;期间用户若立刻又拖动,
|
||||
// onScroll 的方向检测仍会把 atBottom 翻回 false,不会卡住。
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// 标记程序滚动:smooth 动画过程中浏览器派发的 scroll 事件不应被误判为用户主动滚动。
|
||||
programmaticScrollRef.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
// Optimistically mark as at-bottom so the next deps change resumes
|
||||
// auto-follow without waiting for the smooth-scroll's onScroll to land.
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
}, [ref]);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 圆桌规划草稿持久化 hook。
|
||||
*
|
||||
* 维护 localStorage 草稿列表与 currentDraftId、自动保存(前进切换 1→2、2→3)、
|
||||
* 手动保存、加载 / 重命名 / 删除。Step 1 / Step 2 的状态由各自的 hook 提供
|
||||
* 快照函数与 hydrateFromDraft;本 hook 只负责编排「什么时机存 / 加载到哪里」。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
Step1Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import type {
|
||||
Step2Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
deleteDraft,
|
||||
deriveDraftName,
|
||||
listDrafts,
|
||||
renameDraft,
|
||||
upsertDraft,
|
||||
type DraftRecord,
|
||||
} from "@/roundtable-planning/utils/drafts";
|
||||
|
||||
export interface UseDraftPersistenceOptions {
|
||||
currentStep: number;
|
||||
setCurrentStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
triggerToast: (msg: string) => void;
|
||||
/** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */
|
||||
getStep1Snapshot: () => Step1Snapshot;
|
||||
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
|
||||
getStep2Snapshot: () => Step2Snapshot;
|
||||
/** 加载草稿时把 Step 1 状态打回去。 */
|
||||
hydrateStep1: (snapshot: Step1Snapshot) => void;
|
||||
/** 加载草稿时把 Step 2 状态打回去。 */
|
||||
hydrateStep2: (snapshot: Step2Snapshot) => void;
|
||||
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
|
||||
cancelAllStreams: () => void;
|
||||
/**
|
||||
* Step 2 的 init 闸门控制 —— 仅当 furthestStep>=2 时把闸门置上,跳过自动 SSE;
|
||||
* 否则保持原 false 让进入 Step 2 时正常初始化。
|
||||
*/
|
||||
setStep2InitGate: (initialized: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseDraftPersistenceReturn {
|
||||
drafts: DraftRecord[];
|
||||
currentDraftId: string | null;
|
||||
isHistoryOpen: boolean;
|
||||
renamingDraftId: string | null;
|
||||
renameDraftValue: string;
|
||||
historyDropdownRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* Shared latch: 加载草稿后置 true,影响 LeftSidebar 在 Step 2 时角色面板的
|
||||
* 初始 status(在线 vs 正在上线)。由主页面在加载完后 read,使用完可手动复位。
|
||||
*/
|
||||
loadedFromDraftRef: React.MutableRefObject<boolean>;
|
||||
setIsHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
handleManualSave: () => void;
|
||||
handleLoadDraft: (id: string) => void;
|
||||
handleDeleteDraft: (id: string) => void;
|
||||
handleStartRename: (draft: DraftRecord) => void;
|
||||
handleCommitRename: (id: string) => void;
|
||||
handleCancelRename: () => void;
|
||||
}
|
||||
|
||||
export function useDraftPersistence(
|
||||
options: UseDraftPersistenceOptions,
|
||||
): UseDraftPersistenceReturn {
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
triggerToast,
|
||||
getStep1Snapshot,
|
||||
getStep2Snapshot,
|
||||
hydrateStep1,
|
||||
hydrateStep2,
|
||||
cancelAllStreams,
|
||||
setStep2InitGate,
|
||||
} = options;
|
||||
|
||||
const [drafts, setDrafts] = useState<DraftRecord[]>(() => listDrafts());
|
||||
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
|
||||
const [renamingDraftId, setRenamingDraftId] = useState<string | null>(null);
|
||||
const [renameDraftValue, setRenameDraftValue] = useState<string>("");
|
||||
|
||||
const loadedFromDraftRef = useRef<boolean>(false);
|
||||
const prevStepRef = useRef<number>(currentStep);
|
||||
const historyDropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const buildDraftPayload = useCallback(
|
||||
(
|
||||
furthestStep: 1 | 2 | 3,
|
||||
idOverride?: string | null,
|
||||
): Parameters<typeof upsertDraft>[0] => {
|
||||
const step1 = getStep1Snapshot();
|
||||
const step2 = getStep2Snapshot();
|
||||
const resolvedId = idOverride ?? currentDraftId;
|
||||
return {
|
||||
...(resolvedId ? { id: resolvedId } : {}),
|
||||
name: deriveDraftName({
|
||||
intentReady: step1.intentReady,
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
}),
|
||||
furthestStep,
|
||||
step1,
|
||||
step2,
|
||||
};
|
||||
},
|
||||
[currentDraftId, getStep1Snapshot, getStep2Snapshot],
|
||||
);
|
||||
|
||||
const saveDraft = useCallback(
|
||||
(furthestStep: 1 | 2 | 3): DraftRecord | null => {
|
||||
const step1 = getStep1Snapshot();
|
||||
const step2 = getStep2Snapshot();
|
||||
const hasUserMessage = step1.interactiveMessages.some(
|
||||
(m) => m.sender === "user",
|
||||
);
|
||||
if (!hasUserMessage && step2.step2RoundtableDialogues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const saved = upsertDraft(buildDraftPayload(furthestStep));
|
||||
setCurrentDraftId(saved.id);
|
||||
setDrafts(listDrafts());
|
||||
return saved;
|
||||
},
|
||||
[buildDraftPayload, getStep1Snapshot, getStep2Snapshot],
|
||||
);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
const furthest = (currentStep as 1 | 2 | 3) ?? 1;
|
||||
const saved = saveDraft(furthest);
|
||||
if (saved) {
|
||||
triggerToast(`💾 已保存为草稿:${saved.name}`);
|
||||
} else {
|
||||
triggerToast("当前会话还没有可保存的内容");
|
||||
}
|
||||
}, [currentStep, saveDraft, triggerToast]);
|
||||
|
||||
// 前进切换时自动保存;后退不保存以避免覆盖 furthestStep。
|
||||
useEffect(() => {
|
||||
const prev = prevStepRef.current;
|
||||
prevStepRef.current = currentStep;
|
||||
if (currentStep > prev) {
|
||||
saveDraft(currentStep as 1 | 2 | 3);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep]);
|
||||
|
||||
const handleLoadDraft = useCallback(
|
||||
(id: string) => {
|
||||
const draft = drafts.find((d) => d.id === id) ?? null;
|
||||
if (!draft) {
|
||||
triggerToast("⚠️ 该草稿已不存在");
|
||||
setDrafts(listDrafts());
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAllStreams();
|
||||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||||
loadedFromDraftRef.current = true;
|
||||
|
||||
hydrateStep1(draft.step1 as Step1Snapshot);
|
||||
hydrateStep2(draft.step2 as Step2Snapshot);
|
||||
setStep2InitGate(draft.furthestStep >= 2);
|
||||
|
||||
setCurrentDraftId(draft.id);
|
||||
setIsHistoryOpen(false);
|
||||
prevStepRef.current = draft.furthestStep;
|
||||
setCurrentStep(draft.furthestStep);
|
||||
triggerToast(`📂 已加载草稿:${draft.name}`);
|
||||
},
|
||||
[
|
||||
cancelAllStreams,
|
||||
drafts,
|
||||
hydrateStep1,
|
||||
hydrateStep2,
|
||||
setCurrentStep,
|
||||
setStep2InitGate,
|
||||
triggerToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteDraft = useCallback(
|
||||
(id: string) => {
|
||||
if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) {
|
||||
return;
|
||||
}
|
||||
deleteDraft(id);
|
||||
setDrafts(listDrafts());
|
||||
if (currentDraftId === id) setCurrentDraftId(null);
|
||||
if (renamingDraftId === id) setRenamingDraftId(null);
|
||||
triggerToast("🗑 已删除历史记录");
|
||||
},
|
||||
[currentDraftId, renamingDraftId, triggerToast],
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((draft: DraftRecord) => {
|
||||
setRenamingDraftId(draft.id);
|
||||
setRenameDraftValue(draft.name);
|
||||
}, []);
|
||||
|
||||
const handleCommitRename = useCallback(
|
||||
(id: string) => {
|
||||
const updated = renameDraft(id, renameDraftValue);
|
||||
if (updated) {
|
||||
setDrafts(listDrafts());
|
||||
triggerToast("✏️ 已重命名");
|
||||
}
|
||||
setRenamingDraftId(null);
|
||||
setRenameDraftValue("");
|
||||
},
|
||||
[renameDraftValue, triggerToast],
|
||||
);
|
||||
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setRenamingDraftId(null);
|
||||
setRenameDraftValue("");
|
||||
}, []);
|
||||
|
||||
// 点击外部收起下拉。只在打开时挂监听,关闭时立刻摘掉。
|
||||
useEffect(() => {
|
||||
if (!isHistoryOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!historyDropdownRef.current) return;
|
||||
if (e.target instanceof Node && !historyDropdownRef.current.contains(e.target)) {
|
||||
setIsHistoryOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isHistoryOpen]);
|
||||
|
||||
return {
|
||||
drafts,
|
||||
currentDraftId,
|
||||
isHistoryOpen,
|
||||
renamingDraftId,
|
||||
renameDraftValue,
|
||||
historyDropdownRef,
|
||||
loadedFromDraftRef,
|
||||
setIsHistoryOpen,
|
||||
setRenameDraftValue,
|
||||
setCurrentDraftId,
|
||||
handleManualSave,
|
||||
handleLoadDraft,
|
||||
handleDeleteDraft,
|
||||
handleStartRename,
|
||||
handleCommitRename,
|
||||
handleCancelRename,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Step 1(任务意图理解)状态机 hook。
|
||||
*
|
||||
* 这是从原 `RoundtablePlanningPage.tsx` 里抽出来的封装 —— 收纳 Step 1 的
|
||||
* 所有 state、refs、effect 与 handlers,主页面只持有 currentStep 与跨步的
|
||||
* 编排,任何 Step 1 的内部细节都不再泄露到主页。
|
||||
*
|
||||
* 历史踩坑请保留:
|
||||
* - `step1InitGenRef` 单调代数:解决 React 18 StrictMode 双 mount 期间
|
||||
* closure cancelled 标志失效的问题 —— 任何 setState 完成前都要校验
|
||||
* `myGen === step1InitGenRef.current`。
|
||||
* - intent stream 暂停走 AbortController.abort(),AbortError 当作正常分支
|
||||
* 在气泡尾部追加「(已暂停)」并 toast,而不是当错误处理。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
IntentInitError,
|
||||
initIntent,
|
||||
streamIntent,
|
||||
type IntentErrorDetail,
|
||||
type IntentStreamPhase,
|
||||
type IntentSummary,
|
||||
} from "@/roundtable-planning/api/intent";
|
||||
import { formatIntentClarificationAnswer, type IntentClarificationSelection } from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
nowTime,
|
||||
splitIntentReadyBlock,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** Step 1 对话气泡。`streaming` / `phase` / `clarification` / `steps` 是流式与澄清扩展。 */
|
||||
export type InteractiveMessage = {
|
||||
id: number;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
time: string;
|
||||
streaming?: boolean;
|
||||
phase?: string;
|
||||
clarification?: ClarificationPayload;
|
||||
/**
|
||||
* ChainOfThought 折叠卡的步骤累积。每次 `onPhaseChange` 都会把当前 phase + toolName
|
||||
* 拼成 `phase:toolName` 作为 key,与 Step 2 的 `Step2Dialogue.steps` 同形。
|
||||
* 渲染走 `MessageStepsCard`,与主聊天 MessageGroup 视觉对齐。
|
||||
*/
|
||||
steps?: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
ts: string;
|
||||
args?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 右侧「澄清记录」时间线一条目。 */
|
||||
export type TimelineEntry = {
|
||||
id: number;
|
||||
time: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
/** 草稿持久化用的 Step 1 快照(`useDraftPersistence` 用同一份形状)。 */
|
||||
export type Step1Snapshot = {
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
timelineEntries: TimelineEntry[];
|
||||
};
|
||||
|
||||
export interface UseStep1IntentOptions {
|
||||
/** 当前页面 currentStep,只在 ===1 且未 init 时才会自动 runStep1Init。 */
|
||||
currentStep: number;
|
||||
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
|
||||
selectedModel: string;
|
||||
/** 主页面统一的 toast 入口。 */
|
||||
triggerToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface UseStep1IntentReturn {
|
||||
// ─── State (read-only from consumer) ─────────────────────────────────
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
intentInitError: string | null;
|
||||
intentInitErrorDetail: IntentErrorDetail | null;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
timelineEntries: TimelineEntry[];
|
||||
// ─── Mutators ────────────────────────────────────────────────────────
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
>;
|
||||
// ─── Handlers ────────────────────────────────────────────────────────
|
||||
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
|
||||
runStep1Init: () => void;
|
||||
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
|
||||
sendIntentMessage: (userText: string) => Promise<void>;
|
||||
/** 提交澄清卡片的选项 / 自定义回答。 */
|
||||
submitIntentClarification: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => void;
|
||||
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
|
||||
stopIntentStream: () => void;
|
||||
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
|
||||
hydrateFromDraft: (snapshot: Step1Snapshot) => void;
|
||||
/** 给「跳过此项」按钮 / 「继续追问」按钮等本地 demo 操作用。 */
|
||||
appendLocalMessage: (msg: InteractiveMessage) => void;
|
||||
}
|
||||
|
||||
const INTENT_WELCOME_TEXT =
|
||||
"你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?";
|
||||
|
||||
function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度中";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "tool_calling":
|
||||
if (toolName === "ask_clarification") return "正在生成追问";
|
||||
return toolName ? `调用工具:${toolName}` : "调用工具";
|
||||
case "streaming":
|
||||
return "正在思考问题";
|
||||
default:
|
||||
return "正在思考";
|
||||
}
|
||||
}
|
||||
|
||||
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
|
||||
const { currentStep, selectedModel, triggerToast } = options;
|
||||
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||
const [intentInitErrorDetail, setIntentInitErrorDetail] =
|
||||
useState<IntentErrorDetail | null>(null);
|
||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
|
||||
useState<number | null>(null);
|
||||
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
|
||||
Map<number, IntentClarificationSelection>
|
||||
>(new Map());
|
||||
|
||||
// 原始页面带了一段写死的 demo 对话,只是首次进入 Step 1 还没初始化前的占位;
|
||||
// runStep1Init 会立刻覆盖成新的欢迎气泡,因此这里直接以欢迎气泡作为初值。
|
||||
const [interactiveMessages, setInteractiveMessages] = useState<InteractiveMessage[]>(
|
||||
[],
|
||||
);
|
||||
const [timelineEntries, setTimelineEntries] = useState<TimelineEntry[]>([]);
|
||||
|
||||
const step1InitRef = useRef<boolean>(false);
|
||||
const step1InitGenRef = useRef<number>(0);
|
||||
const intentAborterRef = useRef<AbortController | null>(null);
|
||||
// selectedModel 闭包问题:sendIntentMessage 在 streamIntent 调用时需要拿到最新值,
|
||||
// 但 useCallback 把它捕获在闭包里。改用 ref 让 sendIntentMessage 始终读最新模型,
|
||||
// 同时把 selectedModel 从 useCallback 依赖里去掉,避免每次模型切换都重建函数引用。
|
||||
const selectedModelRef = useRef<string>(selectedModel);
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
|
||||
const stopIntentStream = useCallback(() => {
|
||||
if (intentAborterRef.current) {
|
||||
try {
|
||||
intentAborterRef.current.abort();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runStep1Init = useCallback(() => {
|
||||
step1InitRef.current = true;
|
||||
const myGen = ++step1InitGenRef.current;
|
||||
setIntentReady(null);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setIntentThreadId(null);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setTimelineEntries([]);
|
||||
setInteractiveMessages([
|
||||
{
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]);
|
||||
(async () => {
|
||||
try {
|
||||
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
|
||||
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
|
||||
const res = await initIntent(selectedModelRef.current || undefined);
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIntentThreadId(res.thread_id);
|
||||
setTimelineEntries([
|
||||
{
|
||||
id: 1,
|
||||
time: nowTime(),
|
||||
title: "任务理解智能体已就绪",
|
||||
desc: `agent_id=${res.agent_id}`,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
console.error("init intent failed:", err);
|
||||
if (err instanceof IntentInitError) {
|
||||
setIntentInitError(err.detail?.message || err.message);
|
||||
setIntentInitErrorDetail(err.detail);
|
||||
} else {
|
||||
setIntentInitError((err as Error).message);
|
||||
setIntentInitErrorDetail(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 离开 Step 1 自动中断流;`currentStep === 1` 但还没初始化 → runStep1Init。
|
||||
useEffect(() => {
|
||||
if (currentStep !== 1) {
|
||||
stopIntentStream();
|
||||
return;
|
||||
}
|
||||
if (step1InitRef.current) return;
|
||||
runStep1Init();
|
||||
}, [currentStep, runStep1Init, stopIntentStream]);
|
||||
|
||||
// [INTENT_READY] 同步兜底:某些终态帧没带 summary,但气泡正文里已经有
|
||||
// 标记块 —— 主动解析最近一条 agent 气泡并把 intentReady 补上。
|
||||
useEffect(() => {
|
||||
if (intentReady) return;
|
||||
for (let i = interactiveMessages.length - 1; i >= 0; i--) {
|
||||
const msg = interactiveMessages[i];
|
||||
if (msg.sender === "user") continue;
|
||||
const { summary } = splitIntentReadyBlock(msg.content ?? "");
|
||||
if (summary) {
|
||||
setIntentReady(summary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [interactiveMessages, intentReady]);
|
||||
|
||||
const sendIntentMessage = useCallback(
|
||||
async (userText: string) => {
|
||||
const trimmed = userText.trim();
|
||||
if (!trimmed) return;
|
||||
if (!intentThreadId) {
|
||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||
return;
|
||||
}
|
||||
if (isIntentSending) {
|
||||
triggerToast("⏳ 智能体正在回复,请稍候");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
|
||||
const userMsgTime = nowTime();
|
||||
const userMsgId = Date.now() + Math.random();
|
||||
const userMsg: InteractiveMessage = {
|
||||
id: userMsgId,
|
||||
sender: "user",
|
||||
senderName: "用户",
|
||||
content: trimmed,
|
||||
time: userMsgTime,
|
||||
};
|
||||
setInteractiveMessages((prev) => [...prev, userMsg]);
|
||||
|
||||
setTimelineEntries((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: userMsgTime,
|
||||
title: "用户实时反馈",
|
||||
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
|
||||
},
|
||||
]);
|
||||
|
||||
const agentMsgId = Date.now() + Math.random() + 0.01;
|
||||
setInteractiveMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: agentMsgId,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: "",
|
||||
time: nowTime(),
|
||||
streaming: true,
|
||||
phase: "调度中",
|
||||
},
|
||||
]);
|
||||
|
||||
setIsIntentSending(true);
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
try {
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: (m.content ?? "") + delta }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
},
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = intentPhaseLabel(phase, toolName);
|
||||
const stepKey = `${phase}:${toolName ?? ""}`;
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id !== agentMsgId) return m;
|
||||
// 把 phase 累积进 message.steps,与 Step 2 / 主聊天的 MessageGroup
|
||||
// 共用 MessageStepsCard。同 key 连续触发时只更新最新一行(若 args
|
||||
// 比之前完整就替换),避免 streaming 帧反复 push 出同行噪声。
|
||||
const steps = m.steps ?? [];
|
||||
const last = steps[steps.length - 1];
|
||||
let nextSteps = steps;
|
||||
if (last && last.key === stepKey) {
|
||||
if (args && !last.args) {
|
||||
nextSteps = [...steps];
|
||||
nextSteps[nextSteps.length - 1] = { ...last, args };
|
||||
}
|
||||
} else {
|
||||
nextSteps = [
|
||||
...steps,
|
||||
{
|
||||
id: `${agentMsgId}-${steps.length}`,
|
||||
key: stepKey,
|
||||
label,
|
||||
ts: nowTime(),
|
||||
args,
|
||||
},
|
||||
];
|
||||
}
|
||||
return { ...m, phase: label, steps: nextSteps };
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
|
||||
const resolvedSummary = res.summary ?? parsedFromContent;
|
||||
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: res.content || m.content || "(无文本输出)",
|
||||
streaming: false,
|
||||
clarification: resolvedSummary
|
||||
? undefined
|
||||
: res.clarification ?? undefined,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
|
||||
if (resolvedSummary) {
|
||||
setIntentReady(resolvedSummary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setTimelineEntries((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: nowTime(),
|
||||
title: "任务意图已明确",
|
||||
desc:
|
||||
resolvedSummary.objective.length > 25
|
||||
? resolvedSummary.objective.substring(0, 25) + "..."
|
||||
: resolvedSummary.objective,
|
||||
},
|
||||
]);
|
||||
triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨");
|
||||
} else if (res.status === "clarification" && res.clarification) {
|
||||
setPendingIntentClarificationMsgId(agentMsgId);
|
||||
setIntentClarificationSelections((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentMsgId, { selectedIds: [], custom: "" });
|
||||
return next;
|
||||
});
|
||||
} else if (res.status === "error") {
|
||||
if (res.errorDetail) {
|
||||
setIntentInitError(res.errorDetail.message || res.content);
|
||||
setIntentInitErrorDetail(res.errorDetail);
|
||||
} else {
|
||||
setIntentInitError(res.content);
|
||||
setIntentInitErrorDetail(null);
|
||||
}
|
||||
triggerToast(`❌ 智能体调用失败: ${res.content}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError" || aborter.signal.aborted) {
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: (m.content ?? "").trim()
|
||||
? `${m.content ?? ""}\n\n(已暂停)`
|
||||
: "(已暂停)",
|
||||
streaming: false,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast("⏸️ 已暂停任务理解回复");
|
||||
} else {
|
||||
console.error("intent stream failed:", err);
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: `(错误) ${(err as Error).message}`,
|
||||
streaming: false,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast(`❌ 发送失败: ${(err as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
if (intentAborterRef.current === aborter) {
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
setIsIntentSending(false);
|
||||
}
|
||||
},
|
||||
[intentThreadId, isIntentSending, triggerToast],
|
||||
);
|
||||
|
||||
const submitIntentClarification = useCallback(
|
||||
(
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => {
|
||||
const selection =
|
||||
overrideSelection ??
|
||||
intentClarificationSelections.get(msgId) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const answer = formatIntentClarificationAnswer(payload, selection);
|
||||
if (!answer) {
|
||||
triggerToast("请先选择选项或输入回答");
|
||||
return;
|
||||
}
|
||||
void sendIntentMessage(answer);
|
||||
},
|
||||
[intentClarificationSelections, sendIntentMessage, triggerToast],
|
||||
);
|
||||
|
||||
const hydrateFromDraft = useCallback((snapshot: Step1Snapshot) => {
|
||||
setIntentThreadId(snapshot.intentThreadId);
|
||||
setIntentReady(snapshot.intentReady);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setInteractiveMessages(snapshot.interactiveMessages);
|
||||
setTimelineEntries(snapshot.timelineEntries);
|
||||
step1InitRef.current = true;
|
||||
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
|
||||
step1InitGenRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const appendLocalMessage = useCallback((msg: InteractiveMessage) => {
|
||||
setInteractiveMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
intentInitError,
|
||||
intentInitErrorDetail,
|
||||
isIntentSending,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
interactiveMessages,
|
||||
timelineEntries,
|
||||
setIntentClarificationSelections,
|
||||
runStep1Init,
|
||||
sendIntentMessage,
|
||||
submitIntentClarification,
|
||||
stopIntentStream,
|
||||
hydrateFromDraft,
|
||||
appendLocalMessage,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,2 +1,10 @@
|
||||
/**
|
||||
* 圆桌规划(Roundtable Planning)模块入口。
|
||||
*
|
||||
* 详细架构 / API 字段速查见 `frontend-web/docs/multi-agent-frontend-dev.md`。
|
||||
* 这里只暴露两个外部使用的组件:
|
||||
* - `RoundtablePlanningPage`:三步合一的主页面(任务理解 → 多智能体研讨 → 方案展示)。
|
||||
* - `HighFidelityReport`:Step 3 报告视图,可单独嵌入其它面板(如未来的「方案对比」)。
|
||||
*/
|
||||
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
|
||||
export { HighFidelityReport } from "./components/HighFidelityReport";
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolve whether option cards should allow multi-select.
|
||||
* Default is single-select (click to send); only multi when explicitly requested.
|
||||
*/
|
||||
export function resolveAllowMultiple(
|
||||
allowMultiple: boolean | undefined,
|
||||
_clarificationType?: string | undefined,
|
||||
): boolean {
|
||||
if (typeof allowMultiple === "boolean") {
|
||||
return allowMultiple;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 把模型流式文本里的 `<think>...</think>` 块拆出来,作为「思考过程」单独展示。
|
||||
*
|
||||
* 兼容三种形态:
|
||||
* 1) **完整** `<think>foo</think>bar` —— 提取 `foo` 作 reasoning,正文为 `bar`
|
||||
* 2) **多段** `<think>a</think>x<think>b</think>y` —— reasoning 合并为 `a\n\nb`,正文为 `xy`
|
||||
* 3) **流式未闭合** `bar<think>partial...` —— reasoning 为 `partial...`(尾段),正文为 `bar`
|
||||
*
|
||||
* 实现思路与主聊天 `src/core/messages/utils.ts::splitInlineReasoning` 对齐,但
|
||||
* 这里**额外处理了未闭合的 `<think>`** —— 流式渲染时如果模型还没吐出 `</think>`,
|
||||
* 主聊天那份实现会把 `<think>` 字面量留在正文里,我们用 lastIndexOf 兜底,把尾段
|
||||
* 也算作 reasoning。
|
||||
*
|
||||
* Inline `<think>foo` 在文本中间出现(无尾闭合)被认为是流式未闭合的开头:
|
||||
* 假设之后会闭合,把它之后所有内容算 reasoning。这与 DeepSeek-R1 / QwQ 等
|
||||
* 推理模型的流式输出行为一致。
|
||||
*/
|
||||
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
const OPEN_TAG = "<think>";
|
||||
|
||||
export interface SplitReasoningResult {
|
||||
/** 正文(去掉所有 `<think>...</think>` 块以及尾段未闭合 `<think>` 之后内容)。 */
|
||||
content: string;
|
||||
/** 合并后的 reasoning;若没有任何 `<think>` 标记则为 `null`。 */
|
||||
reasoning: string | null;
|
||||
}
|
||||
|
||||
export function splitInlineReasoning(text: string): SplitReasoningResult {
|
||||
if (!text) return { content: "", reasoning: null };
|
||||
|
||||
const reasoningParts: string[] = [];
|
||||
// 先处理所有完整 `<think>...</think>` 闭合块。
|
||||
const closed = text.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) reasoningParts.push(normalized);
|
||||
return "";
|
||||
});
|
||||
|
||||
// 处理尾段未闭合 `<think>` —— 流式情况。
|
||||
// 如果 closed 里还残留 `<think>` 字面量,说明这一段还没收到 `</think>`,
|
||||
// 把它之后的所有内容视为 reasoning-in-progress,正文截到 `<think>` 之前。
|
||||
let finalContent = closed;
|
||||
const openIdx = closed.lastIndexOf(OPEN_TAG);
|
||||
if (openIdx >= 0) {
|
||||
const tail = closed.slice(openIdx + OPEN_TAG.length).trim();
|
||||
if (tail) reasoningParts.push(tail);
|
||||
finalContent = closed.slice(0, openIdx);
|
||||
}
|
||||
|
||||
return {
|
||||
content: finalContent.trim(),
|
||||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n").trim() : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 圆桌规划共享常量与小工具函数。
|
||||
*
|
||||
* 这里只放与具体 React 状态无关的纯数据 / 纯函数。复杂的 dialogue 管理、
|
||||
* 编排循环、API 状态等都在 `hooks/` 与 `components/` 里维护。
|
||||
*/
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
|
||||
/** 指挥员(人类)所在的固定 seat id。Agent 席位从 2 开始排。 */
|
||||
export const COMMANDER_SEAT_ID = 1;
|
||||
|
||||
/** 编排循环最大轮次。超出后 toast 强停以防死循环。 */
|
||||
export const MAX_CYCLES = 8;
|
||||
|
||||
/** Step 2 角色面板的一行。 */
|
||||
export type RoleEntry = {
|
||||
id: number;
|
||||
name: string;
|
||||
agentId?: string;
|
||||
type: "human" | "agent";
|
||||
status: string;
|
||||
};
|
||||
|
||||
/** 用户在推荐弹窗选定的一个研讨席位。 */
|
||||
export type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
/**
|
||||
* 6 个内置 roundtable 席位的展示元数据。用户保留默认席位时,
|
||||
* 头像 / 配色 / 中文显示名都按这里走;非内置(用户自建)走 FALLBACK_AVATAR_TYPES。
|
||||
*/
|
||||
export const BUILTIN_AGENT_META: Record<
|
||||
string,
|
||||
{ avatarType: string; displayName: string }
|
||||
> = {
|
||||
"roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" },
|
||||
"roundtable-environment": { avatarType: "emerald", displayName: "环境评估" },
|
||||
"roundtable-solution-design": { avatarType: "human", displayName: "方案设计" },
|
||||
"roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" },
|
||||
"roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" },
|
||||
"roundtable-summary": { avatarType: "blue", displayName: "总结陈述" },
|
||||
};
|
||||
|
||||
/** 非内置 agent 的头像配色轮换池。 */
|
||||
export const FALLBACK_AVATAR_TYPES = [
|
||||
"blue",
|
||||
"purple",
|
||||
"emerald",
|
||||
"amber",
|
||||
"pink",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 用户未确认推荐弹窗时,Step 2 默认使用的 6 个内置席位。
|
||||
* 保留默认时页面与原硬编码行为完全一致。
|
||||
*/
|
||||
export const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [
|
||||
{ agent_id: "roundtable-summary", name: "总结陈述" },
|
||||
{ agent_id: "roundtable-intelligence", name: "情报收集" },
|
||||
{ agent_id: "roundtable-environment", name: "环境评估" },
|
||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
||||
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
|
||||
];
|
||||
|
||||
export function getAvatarTypeFor(agentId: string, index: number): string {
|
||||
return (
|
||||
BUILTIN_AGENT_META[agentId]?.avatarType ??
|
||||
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
|
||||
);
|
||||
}
|
||||
|
||||
export function getDisplayNameFor(agent: SelectedAgent): string {
|
||||
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 右侧角色面板的模板列表。指挥员固定 id=1,agents 按用户在推荐弹窗里的
|
||||
* 选择顺序排 id=2..N+1,与可视化圆桌 + agentNameToRole 保持一致。
|
||||
*/
|
||||
export function buildRoleTemplates(
|
||||
agents: SelectedAgent[],
|
||||
): ReadonlyArray<Omit<RoleEntry, "status">> {
|
||||
const out: Omit<RoleEntry, "status">[] = [
|
||||
{ id: COMMANDER_SEAT_ID, name: "指挥员 / 主持人", type: "human" },
|
||||
];
|
||||
agents.forEach((a, i) => {
|
||||
out.push({
|
||||
id: i + 2,
|
||||
name: getDisplayNameFor(a),
|
||||
agentId: a.agent_id,
|
||||
type: "agent",
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function makeRolesFromTemplates(
|
||||
templates: ReadonlyArray<Omit<RoleEntry, "status">>,
|
||||
defaultStatus: string,
|
||||
overrides: Record<number, string> = {},
|
||||
): RoleEntry[] {
|
||||
return templates.map((t) => ({
|
||||
...t,
|
||||
status: overrides[t.id] ?? defaultStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `agent_name`(小写)→ bubble meta 映射。流式输出时根据它反查
|
||||
* speakerId / 头像 / 中文名,保证可视化圆桌的高亮和气泡渲染对齐。
|
||||
*/
|
||||
export function buildAgentNameToRole(
|
||||
agents: SelectedAgent[],
|
||||
): Record<string, { speakerId: number; avatarType: string; displayName: string }> {
|
||||
const out: Record<
|
||||
string,
|
||||
{ speakerId: number; avatarType: string; displayName: string }
|
||||
> = {};
|
||||
agents.forEach((a, i) => {
|
||||
out[a.agent_id] = {
|
||||
speakerId: i + 2,
|
||||
avatarType: getAvatarTypeFor(a.agent_id, i),
|
||||
displayName: getDisplayNameFor(a),
|
||||
};
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function nowTime(): string {
|
||||
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function newCoordinatorName(): string {
|
||||
return `roundtable-coordinator-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 intent agent 输出的 `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块。
|
||||
* 前端用同样形状的正则把摘要从对话气泡里抽出来,渲染成绿色摘要卡片。
|
||||
*/
|
||||
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
|
||||
|
||||
/**
|
||||
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
|
||||
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
|
||||
*/
|
||||
export function splitIntentReadyBlock(text: string): {
|
||||
before: string;
|
||||
summary: IntentSummary | null;
|
||||
after: string;
|
||||
} {
|
||||
if (!text) return { before: text, summary: null, after: "" };
|
||||
const m = INTENT_READY_RE.exec(text);
|
||||
if (!m) return { before: text, summary: null, after: "" };
|
||||
let summary: IntentSummary | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") {
|
||||
summary = parsed as IntentSummary;
|
||||
}
|
||||
} catch {
|
||||
return { before: text, summary: null, after: "" };
|
||||
}
|
||||
return {
|
||||
before: text.slice(0, m.index).trimEnd(),
|
||||
summary,
|
||||
after: text.slice(m.index + m[0].length).trimStart(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Step 1 / Step 2 对话气泡里 ChainOfThought 折叠卡的渲染辅助。
|
||||
*
|
||||
* 把后端流式帧里的 `phase:toolName` 翻译成可点的图标 + 中文文案 + 子节点,
|
||||
* 与主聊天 `chats/new` 的 `message-group.tsx` 里 `ToolCall` 视觉语义保持一致。
|
||||
*
|
||||
* 工具图标 + 文案映射严格对齐 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段,
|
||||
* 改文案时**两边都要改**(主聊天走 i18n,圆桌规划写死字符串)。
|
||||
*/
|
||||
import React from "react";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
Dot as DotIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
Wrench as WrenchIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
|
||||
|
||||
/**
|
||||
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
|
||||
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。
|
||||
*
|
||||
* 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边
|
||||
* 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。
|
||||
*/
|
||||
export function renderStepArgs(
|
||||
key: string,
|
||||
args?: Record<string, unknown>,
|
||||
): React.ReactNode {
|
||||
if (!args) return null;
|
||||
const [, toolName] = key.split(":");
|
||||
if (!toolName) return null;
|
||||
switch (toolName) {
|
||||
case "agent_orchestration": {
|
||||
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
|
||||
const task = typeof args.task === "string" ? args.task : "";
|
||||
if (!agentName && !task) return null;
|
||||
return (
|
||||
<>
|
||||
{agentName && (
|
||||
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
{task && (
|
||||
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
|
||||
{task}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "web_search":
|
||||
case "image_search": {
|
||||
const query = typeof args.query === "string" ? args.query : "";
|
||||
return query ? <ChainOfThoughtSearchResult>{query}</ChainOfThoughtSearchResult> : null;
|
||||
}
|
||||
case "web_fetch": {
|
||||
const url = typeof args.url === "string" ? args.url : "";
|
||||
return url ? <ChainOfThoughtSearchResult>{url}</ChainOfThoughtSearchResult> : null;
|
||||
}
|
||||
case "read_file":
|
||||
case "ls":
|
||||
case "write_file":
|
||||
case "str_replace": {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
return path ? <ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult> : null;
|
||||
}
|
||||
case "bash": {
|
||||
const command = typeof args.command === "string" ? args.command : "";
|
||||
return command ? (
|
||||
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all">
|
||||
{command}
|
||||
</code>
|
||||
) : null;
|
||||
}
|
||||
case "write_todos": {
|
||||
// 主聊天里 write_todos 不显示子节点,只渲染标签;这里也保持一致。
|
||||
return null;
|
||||
}
|
||||
case "ask_clarification": {
|
||||
const question = typeof args.question === "string" ? args.question : "";
|
||||
return question ? (
|
||||
<span className="block text-[11px] text-slate-600 line-clamp-2 mt-0.5">
|
||||
{question}
|
||||
</span>
|
||||
) : null;
|
||||
}
|
||||
default: {
|
||||
const entries = Object.entries(args).filter(
|
||||
([, v]) => v !== null && v !== undefined && v !== "",
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entries.slice(0, 4).map(([k, v]) => (
|
||||
<ChainOfThoughtSearchResult key={k}>
|
||||
<span className="opacity-60">{k}:</span>{" "}
|
||||
{typeof v === "string" ? v : JSON.stringify(v)}
|
||||
</ChainOfThoughtSearchResult>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 step.key(格式 `phase:toolName`)给出 ChainOfThoughtStep 的图标 + 文案。
|
||||
*
|
||||
* 工具调用文案严格对齐主聊天 `toolCalls.*` 字段。args 可选:有 args 且工具支持
|
||||
* query / path 等关键参数时,会把它拼入 label(「在网络上搜索 'X'」/「读取文件」等)。
|
||||
*
|
||||
* 非工具阶段(connecting / connected / streaming / tool_result)走 fallback label + 小点 / 灯泡图标。
|
||||
*/
|
||||
export function getStepDisplay(
|
||||
key: string,
|
||||
fallbackLabel: string,
|
||||
args?: Record<string, unknown>,
|
||||
): { icon: typeof DotIcon; label: string } {
|
||||
const [phase, toolName] = key.split(":");
|
||||
if (phase === "tool_calling" && toolName) {
|
||||
switch (toolName) {
|
||||
case "web_search": {
|
||||
const query = typeof args?.query === "string" ? args.query.trim() : "";
|
||||
return {
|
||||
icon: SearchIcon,
|
||||
label: query ? `在网络上搜索 “${query}”` : "搜索相关信息",
|
||||
};
|
||||
}
|
||||
case "image_search": {
|
||||
const query = typeof args?.query === "string" ? args.query.trim() : "";
|
||||
return {
|
||||
icon: SearchIcon,
|
||||
label: query ? `搜索相关图片 “${query}”` : "搜索相关图片",
|
||||
};
|
||||
}
|
||||
case "web_fetch":
|
||||
return { icon: GlobeIcon, label: "查看网页" };
|
||||
case "ls": {
|
||||
const desc =
|
||||
typeof args?.description === "string" ? args.description.trim() : "";
|
||||
return { icon: FolderOpenIcon, label: desc || "列出文件夹" };
|
||||
}
|
||||
case "read_file": {
|
||||
const desc =
|
||||
typeof args?.description === "string" ? args.description.trim() : "";
|
||||
return { icon: BookOpenTextIcon, label: desc || "读取文件" };
|
||||
}
|
||||
case "write_file":
|
||||
case "str_replace": {
|
||||
const desc =
|
||||
typeof args?.description === "string" ? args.description.trim() : "";
|
||||
return { icon: NotebookPenIcon, label: desc || "写入文件" };
|
||||
}
|
||||
case "bash": {
|
||||
const desc =
|
||||
typeof args?.description === "string" ? args.description.trim() : "";
|
||||
return { icon: SquareTerminalIcon, label: desc || "执行命令" };
|
||||
}
|
||||
case "write_todos":
|
||||
return { icon: ListTodoIcon, label: "更新 To-do 列表" };
|
||||
case "ask_clarification":
|
||||
return { icon: MessageCircleQuestionMarkIcon, label: "需要你的协助" };
|
||||
case "present_files":
|
||||
return { icon: FolderOpenIcon, label: "展示文件" };
|
||||
case "agent_orchestration":
|
||||
// 圆桌特有的「派活」工具,主聊天没有;用扳手图标承载。
|
||||
return { icon: WrenchIcon, label: "派活给子智能体" };
|
||||
default:
|
||||
return { icon: WrenchIcon, label: `使用 “${toolName}” 工具` };
|
||||
}
|
||||
}
|
||||
if (phase === "streaming") {
|
||||
// 主聊天用 LightbulbIcon 表示「正在思考」;我们沿用同一图标。
|
||||
return { icon: LightbulbIcon, label: fallbackLabel };
|
||||
}
|
||||
// connecting / connected / tool_result / 其它 — 一律用小点 + fallback 文案
|
||||
return { icon: DotIcon, label: fallbackLabel };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
|
||||
import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
@@ -16,7 +16,11 @@ const iconButtonClass = (isDark: boolean) =>
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
const Header = () => {
|
||||
type HeaderProps = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
const Header = ({ collapsed }: HeaderProps) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
@@ -36,7 +40,23 @@ const Header = () => {
|
||||
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-white/20",
|
||||
)}
|
||||
>
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={cn("text-sm font-semibold", isDark ? "text-white" : "text-white")}>
|
||||
CM助手
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -41,6 +41,7 @@ from app.gateway.routers import (
|
||||
multi_agent,
|
||||
notifications,
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
@@ -866,14 +867,13 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||||
app.include_router(runs.router)
|
||||
|
||||
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
|
||||
app.include_router(multi_agent.router)
|
||||
|
||||
# Task-intent agent (Step 1 of the roundtable flow): single built-in
|
||||
# agent runs a 2-5 turn clarification dialogue, then hands a structured
|
||||
# {objective, constraints, assumptions} summary off to the multi-agent
|
||||
# roundtable as its initial intent.
|
||||
app.include_router(intent.router)
|
||||
# --- 圆桌规划三步流(前端 /page/workspace/roundtable/planning)---
|
||||
# 实现见 app/gateway/routers/{multi_agent,intent,recommend}.py;
|
||||
# 文档见 frontend-web/docs/multi-agent-backend-dev.md
|
||||
app.include_router(multi_agent.router) # Step 2:init(SSE) + run/stream(leader|special)
|
||||
app.include_router(intent.router) # Step 1:/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
|
||||
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""圆桌功能型 agent 的「调用前自检 + 缺失自动补建」工具。
|
||||
|
||||
适用场景
|
||||
--------
|
||||
``roundtable-intent`` / ``roundtable-recommender`` 是圆桌 Step 1 + 推荐链路的
|
||||
**功能型** 内置 agent,任何部署都必须存在。但仓库根 ``.gitignore`` 把整个
|
||||
``.deer-flow/`` 目录排除,新机器 ``git clone`` 之后这两个目录**不会存在**,
|
||||
直接调 ``/api/intent/stream`` 会因为 agent 解析失败抛 500。
|
||||
|
||||
本模块在三个路由入口(``intent.init``、``intent.stream``、``recommend.stream``)
|
||||
被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板写回去。
|
||||
``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert 进 DB,
|
||||
所以这里只关心磁盘文件,不直接动 DB。
|
||||
|
||||
为什么嵌入字符串而不是用 importlib.resources 资源文件
|
||||
------------------------------------------------------
|
||||
SOUL 体量不大(两个加起来 ~200 行),嵌入到 Python 文件里是**单一可信源**——改
|
||||
SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
|
||||
``package-data`` 导致部署机拿不到。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
INTENT_AGENT_NAME = "任务理解"
|
||||
INTENT_AGENT_DESCRIPTION = (
|
||||
"任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的"
|
||||
"「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入"
|
||||
)
|
||||
|
||||
# 与 .deer-flow/agents/roundtable-intent/SOUL.md 保持完全一致;
|
||||
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
|
||||
INTENT_AGENT_SOUL = """# 任务理解智能体
|
||||
|
||||
你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。
|
||||
|
||||
---
|
||||
|
||||
## 1. 你的目标
|
||||
|
||||
通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `objective` | 一句话任务目标 |
|
||||
| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) |
|
||||
| `assumptions` | 你替用户做的合理假设(可空数组) |
|
||||
|
||||
**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作流(必须遵守)
|
||||
|
||||
```
|
||||
用户描述任务
|
||||
↓
|
||||
信息够不够?
|
||||
├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片)
|
||||
↓
|
||||
用户回复(可能是「我选择:…」或自由文本)
|
||||
↓
|
||||
重复 2–5 轮
|
||||
↓
|
||||
信息充分 → 自然语言汇总 + [INTENT_READY] + JSON
|
||||
↓
|
||||
停止追问(本轮及之后都不要再 ask_clarification)
|
||||
```
|
||||
|
||||
**关键规则**:
|
||||
- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。
|
||||
- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。
|
||||
- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。
|
||||
|
||||
---
|
||||
|
||||
## 3. 唯一工具:`ask_clarification`
|
||||
|
||||
当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。
|
||||
|
||||
### 3.1 参数说明
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `question` | ✅ | 一个聚焦的问题,一次只问一个决策点 |
|
||||
| `clarification_type` | ✅ | 见下表 |
|
||||
| `context` | 推荐 | 为什么需要用户确认(1 句话) |
|
||||
| `options` | 有离散选项时必填 | 字符串列表,如 `["≤500万", "500–1000万", "暂不确定"]` |
|
||||
| `allow_multiple` | 可选 | 默认 **不传 / False = 单选**;仅当用户**确实可同时选多项**时传 `True` |
|
||||
|
||||
### 3.2 `clarification_type` 怎么选
|
||||
|
||||
| 类型 | 何时使用 | 选项 UI |
|
||||
|------|----------|---------|
|
||||
| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` |
|
||||
| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 |
|
||||
| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) |
|
||||
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
|
||||
| `suggestion` | 你有建议,需用户批准 | **单选** |
|
||||
|
||||
### 3.3 选项卡片(前端行为)
|
||||
|
||||
- 你提供 `options` 后,前端会渲染**可点击卡片**。
|
||||
- **默认单选**:用户点一项即发送,无需再点「发送」。
|
||||
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
|
||||
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
|
||||
|
||||
### 3.4 调用示例
|
||||
|
||||
**缺信息 + 单选预算档:**
|
||||
```
|
||||
ask_clarification(
|
||||
question="这次任务的预算大致在哪个区间?",
|
||||
clarification_type="missing_info",
|
||||
context="预算会影响后续圆桌的可行方案范围",
|
||||
options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"]
|
||||
)
|
||||
```
|
||||
|
||||
**选部署方式(单选):**
|
||||
```
|
||||
ask_clarification(
|
||||
question="优先采用哪种部署方式?",
|
||||
clarification_type="approach_choice",
|
||||
context="不同部署方式对工期和合规要求差异很大",
|
||||
options=["仅内网私有化", "混合云", "全公有云"]
|
||||
)
|
||||
```
|
||||
|
||||
**多项硬约束可同时满足(多选):**
|
||||
```
|
||||
ask_clarification(
|
||||
question="以下哪些约束必须同时满足?(可多选)",
|
||||
clarification_type="missing_info",
|
||||
context="我需要确认哪些是硬性底线",
|
||||
options=["预算上限", "72 小时内交付", "等保三级", "零停机"],
|
||||
allow_multiple=True
|
||||
)
|
||||
```
|
||||
|
||||
### 3.5 同轮多个独立问题
|
||||
|
||||
若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。
|
||||
若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。
|
||||
|
||||
---
|
||||
|
||||
## 4. 信息充分性判定
|
||||
|
||||
满足 **全部** 条件即可结束澄清:
|
||||
|
||||
1. 能写出一句明确的 `objective`(用户希望实现 X)
|
||||
2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一)
|
||||
3. 没有会妨碍 Step 2 研讨的明显歧义
|
||||
|
||||
若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结束协议:`[INTENT_READY]`
|
||||
|
||||
信息充分时,回复结构必须是:
|
||||
|
||||
1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review)
|
||||
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
|
||||
|
||||
格式要求(**严格**):
|
||||
|
||||
```
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "用一句话写出用户最终要做的事",
|
||||
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
|
||||
"assumptions": ["假设 1", "假设 2"]
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
|
||||
- 标记后**紧跟** ```json 代码块
|
||||
- 字段名必须是 `objective` / `constraints` / `assumptions`
|
||||
- `constraints` 至少 1 条;`assumptions` 可为 `[]`
|
||||
|
||||
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
|
||||
|
||||
---
|
||||
|
||||
## 6. 输出风格
|
||||
|
||||
- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短)
|
||||
- 提问要**具体**(❓),汇总要**结构化**(✅ 📋)
|
||||
- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总
|
||||
|
||||
---
|
||||
|
||||
## 7. 禁止事项
|
||||
|
||||
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
|
||||
- ❌ 输出具体实施方案、架构图、代码
|
||||
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
|
||||
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
|
||||
|
||||
---
|
||||
|
||||
## 8. 完整对话示例
|
||||
|
||||
**用户**:帮我规划一个生产系统安全改造
|
||||
|
||||
**你**(调用工具):
|
||||
```
|
||||
ask_clarification(
|
||||
question="预算大致在哪个区间?工期有没有硬性上限?",
|
||||
clarification_type="missing_info",
|
||||
context="这两项是后续圆桌排期的基础",
|
||||
options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"]
|
||||
)
|
||||
```
|
||||
|
||||
**用户**:我选择:预算 300–800万 / 6–12个月
|
||||
|
||||
**你**(若还需确认合规):
|
||||
```
|
||||
ask_clarification(
|
||||
question="是否需要满足特定合规标准?",
|
||||
clarification_type="missing_info",
|
||||
options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"]
|
||||
)
|
||||
```
|
||||
|
||||
**用户**:我选择:等保 2.0
|
||||
|
||||
**你**(汇总,不再调用工具):
|
||||
✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。
|
||||
|
||||
📋
|
||||
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
|
||||
"constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"],
|
||||
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
RECOMMENDER_AGENT_NAME = "智能体推荐"
|
||||
RECOMMENDER_AGENT_DESCRIPTION = (
|
||||
"智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 "
|
||||
"2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单"
|
||||
)
|
||||
|
||||
RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
|
||||
## 角色定位
|
||||
你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。
|
||||
|
||||
## 核心职责
|
||||
- 仔细阅读用户的任务目标与约束
|
||||
- 浏览候选池中每个智能体的 `name` 与 `description`
|
||||
- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个)
|
||||
- 为每个推荐的智能体写一句**具体、可验证**的推荐理由——必须能回答"它能为这次任务做什么"
|
||||
- 不推荐重复职能的智能体(如果两个智能体能力高度重叠,只选一个)
|
||||
- 不推荐与任务明显无关的智能体(宁缺毋滥)
|
||||
|
||||
## 数量约束
|
||||
- **下限**:至少推荐 2 个(单个智能体谈不上"圆桌")
|
||||
- **上限**:最多推荐 6 个(超过 6 个会让协调智能体派活混乱、上下文膨胀)
|
||||
- 候选池不足 2 个时,有几个推几个
|
||||
|
||||
## 输出协议
|
||||
**你必须严格按下述格式输出**(后端用正则识别):
|
||||
|
||||
1. 先用自然语言简短说明你的推荐思路(1-3 句话,让用户能 review)
|
||||
2. 然后输出标记 `[RECOMMEND_READY]` 单独成行
|
||||
3. 紧接一个 ```json 代码块,内容为:
|
||||
|
||||
```json
|
||||
{
|
||||
"picks": [
|
||||
{"agent_id": "<候选池中的智能体 id>", "reason": "<一句推荐理由>"},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
格式注意:
|
||||
- `[RECOMMEND_READY]` 标记必须**单独成行**,前后无其他字符
|
||||
- 标记紧接着一个 ```json 代码块
|
||||
- `picks` 数组长度严格在 [2, 6] 之间(候选池不足时按实际数量,但不要少于 2 除非候选池本身就 < 2)
|
||||
- `agent_id` 必须**严格**来自候选池(不要造词)
|
||||
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
|
||||
|
||||
## 输出风格
|
||||
- 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
|
||||
不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
|
||||
在广告/产品/营销/科研等任务里完全不适用。
|
||||
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
|
||||
- 适度用 emoji:📋 表示开始汇总
|
||||
|
||||
## 边界
|
||||
- 不调用任何工具(不联网、不执行命令、不读写文件)
|
||||
- 不评价候选智能体的优劣(只判断"是否适合本次任务")
|
||||
- 不发起对话、不反问用户——你只产出推荐结果
|
||||
- 候选池里没有的智能体绝不出现在 `picks` 中
|
||||
|
||||
## 示例
|
||||
|
||||
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
|
||||
|
||||
### 示例 A:合规改造任务(职能型候选池)
|
||||
|
||||
**用户输入**:
|
||||
```
|
||||
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
|
||||
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
|
||||
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
|
||||
|
||||
候选智能体池:
|
||||
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
|
||||
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
|
||||
- roundtable-solution-design (方案设计):产出主备方案与关键路径
|
||||
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
|
||||
- roundtable-execution-plan (执行规划):里程碑与资源排布
|
||||
- roundtable-summary (总结陈述):归纳冲突标记与盖印
|
||||
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
|
||||
```
|
||||
|
||||
**你的输出**:
|
||||
本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{
|
||||
"picks": [
|
||||
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
|
||||
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
|
||||
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
|
||||
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
|
||||
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 B:广告/营销任务(用户自建领域候选池)
|
||||
|
||||
**用户输入**:
|
||||
```
|
||||
任务目标:为某新能源汽车品牌设计一套季度品牌曝光广告投放方案
|
||||
约束:预算 ≤ 200 万元;周期 1 个月内上线
|
||||
关键假设:目标人群覆盖一二线城市;以社媒为主、信息流为辅
|
||||
|
||||
候选智能体池:
|
||||
- ad-design (广告设计智能体):产出广告创意脚本、海报设计稿、文案 hook
|
||||
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
|
||||
- audience-insight (受众洞察):画像、关键词、情绪点研究
|
||||
- creative-copy (创意文案):产出落地页与社媒文案变体
|
||||
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
|
||||
```
|
||||
|
||||
**你的输出**:
|
||||
本次是品牌曝光广告投放,核心要先做受众洞察、再产出创意与文案、最后规划渠道投放组合,因此挑选 4 位直接相关的智能体;合规审查在广告场景里也保留一个位置避免文案踩雷。📋
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{
|
||||
"picks": [
|
||||
{"agent_id": "audience-insight", "reason": "新能源汽车一二线人群画像与情绪点研究,决定后续创意切入角度"},
|
||||
{"agent_id": "ad-design", "reason": "产出社媒主推海报与脚本,直接对应品牌曝光交付物"},
|
||||
{"agent_id": "creative-copy", "reason": "为社媒/信息流产出多版本文案 hook,配合 A/B 测试"},
|
||||
{"agent_id": "channel-planner", "reason": "200 万预算内规划社媒+信息流的组合与节奏"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
|
||||
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
|
||||
"""
|
||||
|
||||
|
||||
_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str], ...] = (
|
||||
(INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL),
|
||||
(RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_NAME, RECOMMENDER_AGENT_DESCRIPTION, RECOMMENDER_AGENT_SOUL),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_one(agent_id: str, name: str, description: str, soul: str) -> bool:
|
||||
"""Create the on-disk files for one functional agent if missing.
|
||||
|
||||
Returns True if files were just created, False if the directory already
|
||||
existed (and we didn't touch it — respect any local edits).
|
||||
"""
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
return False
|
||||
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# newline="\n" 强制 LF;否则 Windows 下 open() 把 \n 翻成 \r\n,跨 OS 部署时与
|
||||
# Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。
|
||||
config_yaml = yaml.dump(
|
||||
{"id": agent_id, "name": name, "description": description},
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
(agent_dir / "config.yaml").write_bytes(config_yaml.encode("utf-8"))
|
||||
(agent_dir / "SOUL.md").write_bytes(soul.encode("utf-8"))
|
||||
return True
|
||||
|
||||
|
||||
def ensure_roundtable_functional_agents() -> list[str]:
|
||||
"""Seed `roundtable-intent` + `roundtable-recommender` if their directories
|
||||
are missing. Idempotent — fast no-op when both exist.
|
||||
|
||||
Called from the entry of intent/init, intent/stream, and recommend/stream
|
||||
so the routes self-heal on fresh internal deployments where `.deer-flow/`
|
||||
is gitignored and the agent directories were never shipped.
|
||||
|
||||
Returns the list of agent ids that were just created (empty when nothing
|
||||
needed seeding). Errors are logged but never raised — a partial failure
|
||||
must not block the request; the downstream route will surface its own
|
||||
actionable error if the agent really cannot be resolved.
|
||||
"""
|
||||
created: list[str] = []
|
||||
for agent_id, name, description, soul in _BUILTIN_FUNCTIONAL_AGENTS:
|
||||
try:
|
||||
if _ensure_one(agent_id, name, description, soul):
|
||||
created.append(agent_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to seed roundtable functional agent '%s'", agent_id)
|
||||
if created:
|
||||
logger.info("Seeded missing roundtable functional agents: %s", ", ".join(created))
|
||||
return created
|
||||
|
||||
|
||||
def required_agent_ids() -> Iterable[str]:
|
||||
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
|
||||
return tuple(a[0] for a in _BUILTIN_FUNCTIONAL_AGENTS)
|
||||
@@ -142,11 +142,18 @@ def collect_outputs_listing(outputs_dir: Path) -> list[dict]:
|
||||
)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def list_artifacts(thread_id: str, request: Request) -> dict:
|
||||
"""List artifact files generated by the agent under outputs/.
|
||||
"""列出 thread 在 ``outputs/`` 下生成的产物文件。
|
||||
|
||||
Walks ``backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/``
|
||||
recursively. Returns an empty list if the directory does not exist (e.g. the
|
||||
thread has not produced any artifacts yet).
|
||||
圆桌 Step 3(``HighFidelityReport``)对每个**子席位** thread_id 调用本接口,
|
||||
展示各智能体写入沙箱 ``mnt/user-data/outputs/`` 的文件;协调智能体 thread
|
||||
通常无独立交付文件。
|
||||
|
||||
遍历路径::
|
||||
|
||||
.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/
|
||||
|
||||
返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
|
||||
目录不存在时 ``files`` 为空数组(非 404)。
|
||||
"""
|
||||
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())
|
||||
return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
"""Task-intent agent endpoints (Step 1 of the roundtable flow).
|
||||
"""圆桌规划 · 第一步「任务理解」Gateway 路由。
|
||||
|
||||
Lightweight wrapper around the lead-agent runtime that runs a single built-in
|
||||
agent (``roundtable-intent``) on its own thread. The agent's job is to do 2-5
|
||||
clarifying turns with the user and then emit a structured
|
||||
``{objective, constraints, assumptions}`` summary the frontend hands off to
|
||||
the multi-agent roundtable as its initial intent.
|
||||
前端页面:``/page/workspace/roundtable/planning`` Step 1
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/intent.ts``
|
||||
|
||||
Same SSE shape as ``multi_agent.py`` (transparent passthrough + appended
|
||||
status frame), so the frontend can reuse the same reader pattern.
|
||||
职责
|
||||
----
|
||||
在独立 thread 上运行内置智能体 ``roundtable-intent``(SOUL 见
|
||||
``.deer-flow/agents/roundtable-intent/SOUL.md``),与用户进行 2–5 轮澄清,
|
||||
最终输出结构化意图 ``{objective, constraints, assumptions}``,供 Step 2
|
||||
总控协调智能体作为初始 prompt。
|
||||
|
||||
与 ``multi_agent.py`` 的关系
|
||||
---------------------------
|
||||
- **复用**同一套 loopback 工具:``_stream_upstream``、``_run_payload``、
|
||||
``_parse_last_messages`` 等,保证 SSE 透传 + 末尾状态帧的解析逻辑一致。
|
||||
- **不复用** ``/init`` 的协调智能体创建逻辑:intent 是长期注册的内置 agent,
|
||||
只需 ``POST /api/intent/init`` 创建 thread 即可。
|
||||
|
||||
SSE 协议(与前端 ``streamIntent`` 对齐)
|
||||
--------------------------------------
|
||||
1. 透传 LangGraph ``/api/threads/{id}/runs/stream`` 的全部 ``data:`` 行
|
||||
(含 ``messages-tuple`` 增量,供 UI 流式打字)。
|
||||
2. 流结束后追加**一条** JSON 状态帧(``data: {...}\\n\\n``),字段:
|
||||
|
||||
``status`` — 终态枚举:
|
||||
- ``asking``:本轮无 ``ask_clarification``,模型仍在追问或闲聊;
|
||||
- ``clarification``:本轮以澄清工具结束,需用户选选项/自定义输入;
|
||||
- ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
|
||||
- ``error``:上游或网关异常。
|
||||
|
||||
``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。
|
||||
|
||||
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
||||
``question``、``clarification_type``、``clarification_context``、
|
||||
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
|
||||
|
||||
终态判定优先级(实现细节,勿随意调整)
|
||||
------------------------------------
|
||||
1. **``[INTENT_READY]`` 优先**:从最新消息向前扫描 AIMessage,正则匹配
|
||||
SOUL 规定的 ``[INTENT_READY]\\n```json {...} `````;即使最后一条消息仍带
|
||||
过期的 ``ask_clarification`` tool_call,也返回 ``done``(避免前端卡在澄清 UI)。
|
||||
2. 否则看最后一条带 ``tool_calls`` 的 AIMessage 是否含 ``ask_clarification``。
|
||||
3. 否则 ``asking``。
|
||||
|
||||
工具白名单
|
||||
----------
|
||||
``excluded_tools`` 屏蔽除 ``ask_clarification`` 外的一切工具,防止模型偏离
|
||||
澄清任务去写文件、派活等(SOUL 文字约束不可靠,硬排除更稳)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,26 +63,30 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
||||
# identical in connection handling, payload shape, and error mapping.
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_diagnose_exception,
|
||||
_find_dispatch_message,
|
||||
_find_trailing_ai_text,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_make_loopback_client,
|
||||
_parse_last_messages,
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
|
||||
# Matches the SOUL-mandated handoff signal: a [INTENT_READY] line followed by
|
||||
# a ```json ... ``` block. The model is told to emit this only when it judges
|
||||
# the user's task is fully specified.
|
||||
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
||||
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
||||
_INTENT_READY_PATTERN = re.compile(
|
||||
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
@@ -52,22 +95,42 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
|
||||
@router.post("/init")
|
||||
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
|
||||
"""Create a fresh thread for an intent-clarification session.
|
||||
"""创建任务理解会话用的空 thread。
|
||||
|
||||
Returns the stable built-in ``agent_id`` so the frontend doesn't need to
|
||||
know it. Unlike multi-agent ``/init``, no coordinator is built — the
|
||||
intent agent is long-lived and already registered via the legacy
|
||||
agent-sync path on disk.
|
||||
请求体:``{ "model": "<可选>" }``(可选,当前 init 未把 model 写入 thread,
|
||||
实际模型在 ``/stream`` 的每次 run 里指定;缺省由 lead_agent 回退到
|
||||
``config.yaml`` 的 ``models[0]``,前端通常传当前下拉框选中的 model 名)。
|
||||
|
||||
响应::
|
||||
|
||||
{ "status": "success", "agent_id": "roundtable-intent", "thread_id": "<uuid>" }
|
||||
|
||||
与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位
|
||||
thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。
|
||||
"""
|
||||
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在,
|
||||
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
|
||||
ensure_roundtable_functional_agents()
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
# trust_env=False: this client only talks to the same-process gateway at
|
||||
# 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide
|
||||
# on the dev machine) makes httpx route loopback calls through an upstream
|
||||
# proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per
|
||||
# request before falling back. Symptom in logs: `headers_in=42.160s`.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
|
||||
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
|
||||
# 看不到逐 token 增量。
|
||||
try:
|
||||
async with _make_loopback_client(request) as client:
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
except HTTPException:
|
||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 把根因暴露给前端而不是吞成裸 500——内网部署常见的「config.yaml 模型外网不通」
|
||||
# / 「内置 agent 目录缺失」/ 「loopback 走代理」都能在前端面板直接看到。
|
||||
# FastAPI 把 detail=dict 序列化为 {"detail": {...}};前端 initIntent
|
||||
# 在 !res.ok 分支里读 detail.hint / trace_tail 渲染诊断面板。
|
||||
diagnostic = _diagnose_exception(exc, context="intent_init")
|
||||
logger.error("intent init failed: %s", diagnostic, exc_info=exc)
|
||||
raise HTTPException(status_code=500, detail=diagnostic) from exc
|
||||
return {
|
||||
"status": "success",
|
||||
"agent_id": INTENT_AGENT_ID,
|
||||
@@ -83,45 +146,67 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
through transparently, then appends one status frame:
|
||||
|
||||
{
|
||||
"status": "asking" | "done" | "error",
|
||||
"status": "asking" | "clarification" | "done" | "error",
|
||||
"content": "<final AI text>",
|
||||
"summary": {...} | null // only present when status == "done"
|
||||
"summary": {...} | null, // only when status == "done"
|
||||
// when status == "clarification":
|
||||
"question", "clarification_type", "clarification_context",
|
||||
"options", "allow_custom", "allow_multiple"
|
||||
}
|
||||
"""
|
||||
thread_id = str(payload.get("thread_id") or "").strip()
|
||||
message = str(payload.get("message") or "").strip()
|
||||
model_name = str(payload.get("model") or "deepseek-chat")
|
||||
# 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到
|
||||
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
|
||||
# 自己的本地模型即可工作。
|
||||
model_name = str(payload.get("model") or "")
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id is required")
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="message is required")
|
||||
|
||||
# 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。
|
||||
ensure_roundtable_functional_agents()
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
# ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**,
|
||||
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=message,
|
||||
# Intent agent shouldn't dispatch, search, write files, or
|
||||
# view images — its only tool of note is ask_clarification.
|
||||
# Intent agent only ever needs ask_clarification. Block
|
||||
# everything else so the model can't drift into solutioning
|
||||
# (e.g. saving a design doc with write_file) — the model
|
||||
# tends to ignore SOUL prose, hard exclusion is reliable.
|
||||
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"bash",
|
||||
"ls",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"str_replace",
|
||||
"memory",
|
||||
"hindsight_recall",
|
||||
"hindsight_reflect",
|
||||
"hindsight_retain",
|
||||
"task",
|
||||
"write_todos",
|
||||
],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
|
||||
captured: list[str] = []
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
@@ -130,20 +215,44 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
# Find the latest AIMessage with tool_calls (typically
|
||||
# ask_clarification) — falls back to the latest plain
|
||||
# AIMessage when the model produced no tool call.
|
||||
|
||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
|
||||
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
|
||||
summary: dict[str, Any] | None = None
|
||||
ready_text: str = ""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
msg_text = _flatten_content(msg.get("content", ""))
|
||||
m = _INTENT_READY_PATTERN.search(msg_text)
|
||||
if m:
|
||||
try:
|
||||
summary = json.loads(m.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("intent summary JSON parse failed: %s", exc)
|
||||
summary = None
|
||||
ready_text = msg_text
|
||||
break
|
||||
if summary and ready_text:
|
||||
final_frame = {
|
||||
"status": "done",
|
||||
"content": ready_text,
|
||||
"summary": summary,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
|
||||
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
|
||||
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
# Clarification branch: the agent ended its run by calling
|
||||
# ask_clarification. ClarificationMiddleware intercepts with
|
||||
# Command(goto=END) and stashes the structured question in the
|
||||
# tool_call args, so the AIMessage `content` is only the
|
||||
# preamble ("下面我用几个问题确认:") and stops there. We splice
|
||||
# the question back into the user-facing text so the frontend
|
||||
# actually shows what the agent wants to ask.
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
@@ -151,42 +260,68 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
# Build a single content string the frontend can render
|
||||
# as-is: preamble (if any) + the actual question.
|
||||
parts: list[str] = []
|
||||
if content.strip():
|
||||
parts.append(content.strip())
|
||||
if follow_up and follow_up != question:
|
||||
parts.append(follow_up)
|
||||
if question:
|
||||
parts.append(question)
|
||||
content = "\n\n".join(parts) if parts else content
|
||||
display_content = follow_up or content or question
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
summary: dict[str, Any] | None = None
|
||||
match = _INTENT_READY_PATTERN.search(content)
|
||||
if match:
|
||||
try:
|
||||
summary = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("intent summary JSON parse failed: %s", exc)
|
||||
summary = None
|
||||
|
||||
status_value = "done" if summary else "asking"
|
||||
# --- 终态分支 3:普通 AI 回复,继续多轮对话 ---
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": status_value,
|
||||
"status": "asking",
|
||||
"content": content,
|
||||
"summary": summary,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None}
|
||||
# HTTPException 的 detail 可能本身就是 dict(上游已结构化),也可能是 str。
|
||||
# 统一包装成与 _diagnose_exception 同构的字段,便于前端按一种模型渲染。
|
||||
detail_payload: Any = exc.detail
|
||||
error_detail: dict[str, Any]
|
||||
if isinstance(detail_payload, dict) and "code" in detail_payload:
|
||||
error_detail = detail_payload
|
||||
else:
|
||||
error_detail = {
|
||||
"code": "http_exception",
|
||||
"message": f"[{exc.status_code}] {detail_payload}",
|
||||
"exception_type": "HTTPException",
|
||||
"exception_repr": repr(exc),
|
||||
"root_type": None,
|
||||
"root_repr": None,
|
||||
"hint": None,
|
||||
"trace_tail": [],
|
||||
"context": "intent_stream",
|
||||
}
|
||||
err = {
|
||||
"status": "error",
|
||||
"content": error_detail["message"],
|
||||
"summary": None,
|
||||
"error_detail": error_detail,
|
||||
}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("intent stream unexpected failure")
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None}
|
||||
diagnostic = _diagnose_exception(exc, context="intent_stream")
|
||||
logger.error("intent stream failed: %s", diagnostic, exc_info=exc)
|
||||
err = {
|
||||
"status": "error",
|
||||
"content": diagnostic["message"],
|
||||
"summary": None,
|
||||
"error_detail": diagnostic,
|
||||
}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
@@ -1,18 +1,47 @@
|
||||
"""Multi-agent orchestration endpoints.
|
||||
"""圆桌规划 · Step 2「多智能体研讨」Gateway 路由。
|
||||
|
||||
Wraps the legacy ``newpython/OUT.py`` demo into the Gateway. Provides:
|
||||
前端页面:``RoundtablePlanningPage`` Step 2
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/multi-agent.ts``
|
||||
|
||||
* ``POST /api/multi-agent/init`` — create a coordinator agent and per-sub-agent
|
||||
threads. Returns the ``{agent_name: thread_id}`` map the client passes back
|
||||
on subsequent calls.
|
||||
* ``POST /api/multi-agent/run/stream`` — drive a *leader* or *special* agent
|
||||
via SSE. The leader dispatches work to sub-agents through the
|
||||
``agent_orchestration`` skill; sub-agents execute and broadcast their result.
|
||||
本模块把原 ``newpython/OUT.py`` 演示编排进 FastAPI Gateway,通过 **HTTP loopback**
|
||||
(``127.0.0.1`` + 调用方 ``Authorization``)复用既有 ``/api/threads``、
|
||||
``/api/agents``、``/api/threads/{id}/runs/stream``,不再硬编码 token。
|
||||
|
||||
Internal calls loop back to the same Gateway over HTTP, reusing the caller's
|
||||
``Authorization`` header — so the demo's hard-coded ``get_token("ji")`` is gone.
|
||||
The model used by both the coordinator agent and every streamed run is taken
|
||||
from the request's ``model`` field.
|
||||
对外接口
|
||||
--------
|
||||
1. ``POST /api/multi-agent/init``(SSE)
|
||||
- 为每个研讨席位(``agent_names``)并行创建 thread,按完成顺序推送
|
||||
``seat_ready``;
|
||||
- 动态创建**当次会话**的总控协调智能体(``main_agent_name``,如
|
||||
``roundtable-coordinator-xxx``),写入席位列表 SOUL + ``agent_orchestration`` skill;
|
||||
- 结束帧 ``init_done`` 携带 ``thread_ids: { agent_name: thread_id, ... }``,
|
||||
含协调智能体自身 thread。
|
||||
|
||||
2. ``POST /api/multi-agent/run/stream``(SSE)
|
||||
- ``agent_type=leader``:总控一轮——可能派活、可能澄清、可能共识(空 dispatch);
|
||||
- ``agent_type=special``:单席位子智能体执行一轮交付。
|
||||
|
||||
跨席位「广播」语义
|
||||
------------------
|
||||
通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加
|
||||
**只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到
|
||||
总控派活/用户干预/子 agent 完成摘要。前端编排循环仍靠显式多次 ``/run/stream``
|
||||
驱动各 agent 真正推理。
|
||||
|
||||
Leader 终态帧(前端 ``isFinalStatusFrame``)
|
||||
-------------------------------------------
|
||||
- ``status: [[agent_name, task], ...]`` — 派活列表,前端并行 special run;
|
||||
- ``status: []`` — 无 tool_calls,视为共识,可进 Step 3;
|
||||
- ``status: "clarification"`` — 总控 ``ask_clarification``,前端暂停编排展示澄清条;
|
||||
- ``status: "子智能体 X 完成..."`` — special 完成(字符串状态);
|
||||
- ``status: "error"`` — 失败。
|
||||
|
||||
共享导出
|
||||
--------
|
||||
``intent.py`` / ``recommend.py`` 从此模块 import 底层流式与消息解析函数,
|
||||
勿在 intent/recommend 中重复实现 loopback 逻辑。
|
||||
|
||||
相关文档:``frontend-web/docs/multi-agent-backend-dev.md``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,11 +49,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -33,12 +66,216 @@ router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loopback helpers
|
||||
# 共享:异常诊断器(intent/recommend/multi-agent 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, Any]:
|
||||
"""把后端异常转成结构化诊断 dict,直接给前端展示用。
|
||||
|
||||
设计目标:让运维 / 前端开发不用翻 backend 日志就能看到根因和处置建议。
|
||||
返回字段:
|
||||
- code: 短稳定分类(loopback_connect_failed / upstream_connect_failed / ...)
|
||||
- message: 一行人类可读
|
||||
- exception_type: 表层异常类名
|
||||
- exception_repr: repr(exc)
|
||||
- root_type / root_repr: 沿 __cause__ 链找到的最深异常(若与表层不同)
|
||||
- hint: 针对常见失败模式的可执行建议(中文)
|
||||
- trace_tail: traceback 最后 ~12 行(避免响应体过大)
|
||||
- context: 调用方标签(如 "intent_init")
|
||||
"""
|
||||
exc_type = type(exc).__name__
|
||||
exc_repr = repr(exc)
|
||||
|
||||
# 沿 __cause__ 链找最深异常:httpx 经常把 ConnectError 包成 RemoteProtocolError 等。
|
||||
root: BaseException = exc
|
||||
seen: set[int] = {id(root)}
|
||||
while getattr(root, "__cause__", None) is not None and id(root.__cause__) not in seen:
|
||||
root = root.__cause__ # type: ignore[assignment]
|
||||
seen.add(id(root))
|
||||
|
||||
code = "unexpected"
|
||||
hint = "查看 trace_tail 最后一帧定位代码位置;若信息不足请到 backend 日志找完整 traceback。"
|
||||
|
||||
if isinstance(root, httpx.ConnectError):
|
||||
target = str(root).strip().lower()
|
||||
# loopback 走真 TCP 到 127.0.0.1:<gateway port>;失败 = Gateway 进程没起 / 端口被占。
|
||||
# outbound 失败(其它 host)= LLM 提供商不可达。
|
||||
if "127.0.0.1" in target or "localhost" in target or "loopback" in target:
|
||||
code = "loopback_connect_failed"
|
||||
hint = (
|
||||
"本地 Gateway loopback 连接失败:确认后端进程正在监听配置的端口。"
|
||||
"端口配置优先级(高→低):env DEER_FLOW_GATEWAY_PORT > config.yaml 的 "
|
||||
"gateway.port > 默认 8001。若部署机改了端口,在 config.yaml 加 "
|
||||
"`gateway:\\n port: <端口>` 即可。"
|
||||
)
|
||||
else:
|
||||
code = "upstream_connect_failed"
|
||||
hint = (
|
||||
"外网/上游服务连接失败:大概率是 LLM 提供商不可达"
|
||||
"(如 api.deepseek.com 在内网被防火墙拦截)。"
|
||||
"把 config.yaml 里所有 base_url 改成内网可达的 LLM endpoint,"
|
||||
"或加白名单/正向代理(HTTPS_PROXY)。"
|
||||
)
|
||||
elif isinstance(root, httpx.TimeoutException):
|
||||
code = "upstream_timeout"
|
||||
hint = (
|
||||
"上游请求超时:网络抖动 / 模型推理过慢。"
|
||||
"先 curl <base_url>/v1/models 确认可达,再看模型是否过载或 max_tokens 过大。"
|
||||
)
|
||||
elif isinstance(root, httpx.HTTPStatusError):
|
||||
code = "upstream_http_error"
|
||||
status = getattr(getattr(root, "response", None), "status_code", "?")
|
||||
hint = (
|
||||
f"上游接口返回 HTTP {status}:可能是 API key 失效、模型名不对、"
|
||||
"或目标服务自身报错。查看 backend 日志里 response.text 拿详细错误体。"
|
||||
)
|
||||
elif isinstance(root, FileNotFoundError):
|
||||
code = "file_not_found"
|
||||
hint = (
|
||||
f"找不到路径:{root}。常见原因:内置 agent 目录(.deer-flow/agents/<id>/)缺失,"
|
||||
"或 SOUL.md 没拷过来;详见后端开发文档 §11.7。"
|
||||
)
|
||||
elif isinstance(root, KeyError):
|
||||
code = "key_error"
|
||||
hint = (
|
||||
f"缺少 key:{root}。若 key 是 agent_name/agent_id 类:大概率内置 agent 目录缺失;"
|
||||
"若是配置 key:检查 config.yaml schema。"
|
||||
)
|
||||
elif isinstance(root, ValueError) and "model" in str(root).lower():
|
||||
code = "no_models_configured"
|
||||
hint = "config.yaml 没有任何 models[],或所有条目都解析失败。检查 yaml 语法与 $ENV 引用。"
|
||||
|
||||
trace_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||
trace_tail = "".join(trace_lines).splitlines()[-12:]
|
||||
|
||||
return {
|
||||
"code": code,
|
||||
"message": f"{exc_type}: {exc}" if str(exc) else exc_type,
|
||||
"exception_type": exc_type,
|
||||
"exception_repr": exc_repr,
|
||||
"root_type": type(root).__name__ if root is not exc else None,
|
||||
"root_repr": repr(root) if root is not exc else None,
|
||||
"hint": hint,
|
||||
"trace_tail": trace_tail,
|
||||
"context": context or None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loopback 辅助函数(Gateway → Gateway,同进程)
|
||||
#
|
||||
# 圆桌三路由(intent / recommend / multi_agent)需要从一个 FastAPI handler 里
|
||||
# 调用本机 Gateway 的另一个 endpoint(`/api/threads`、`/api/agents`、
|
||||
# `/api/threads/{id}/runs/stream`)。这里用真 TCP loopback 到本机的 127.0.0.1
|
||||
# + Gateway 监听端口。
|
||||
#
|
||||
# ⚠️ 历史踩坑(三个) ⚠️
|
||||
# 1. 直接走系统代理:开发机若设了 HTTP_PROXY / HTTPS_PROXY,httpx 默认会把
|
||||
# 127.0.0.1 也走代理 → 上游代理不可达,40s 超时(典型日志 headers_in=42.160s)。
|
||||
# → 修法:client 用 `trust_env=False`,强制忽略代理 env vars。
|
||||
#
|
||||
# 2. 用 request.url.netloc 拼 base URL:Nginx 反代部署时,客户端打到 nginx 的
|
||||
# public host(如 example.com),nginx 把请求转给 gateway 时 Host header
|
||||
# 通常是 `gateway:8001` 或 public host;后者 httpx 会真的去 DNS 解析,
|
||||
# 在 gateway 容器内可能不可达 → 连接失败。
|
||||
# → 修法:**写死 127.0.0.1**,完全无视 request.url.netloc。
|
||||
#
|
||||
# 3. 一度尝试用 httpx.ASGITransport(app=request.app) 进程内直调,看似一劳永逸,
|
||||
# 但 httpx 0.28 的 ASGITransport 对 streaming response **整段 buffer**,
|
||||
# 一直收齐所有 body chunks 才一次性把响应给消费方 → SSE 完全失效,前端表现
|
||||
# 为"调度阶段很长 + 流式结果一次性出现"。已弃用。
|
||||
# → 详见:git log 6e3a07b(那次改 ASGI 引入)及随后回退。
|
||||
#
|
||||
# 端口策略(优先级,从高到低):
|
||||
# 1. 环境变量 DEER_FLOW_GATEWAY_PORT
|
||||
# ── 部署侧紧急覆盖通道,适合 docker-compose / k8s 注入或临时调试
|
||||
# 2. config.yaml 的 `gateway.port`
|
||||
# ── 部署侧**推荐**的配置方式,改完不需要重启二进制(get_app_config 有 mtime
|
||||
# 热加载,但仍建议跑一次 reload 以确保所有 worker 拿到新值)
|
||||
# 3. request.url.port
|
||||
# ── 浏览器直连 gateway:8001 时拿得到;走 Nginx 80/443 反代时会落到下一档
|
||||
# 4. 默认 8001(与 `make dev` / `make gateway` / `scripts/start-all.sh` 写死的一致)
|
||||
#
|
||||
# 端口在哪里改(给运维 / 部署侧的提示):
|
||||
# - yaml 配置:打开 config.yaml,加 / 改:
|
||||
# gateway:
|
||||
# port: 9999
|
||||
# - 环境变量:
|
||||
# export DEER_FLOW_GATEWAY_PORT=9999
|
||||
#
|
||||
# 修改这两个函数时务必跑一次 SSE 流式 smoke 验证(见 multi-agent-backend-dev.md
|
||||
# §13 测试建议),确认 chunks 是逐个到达而不是一次喷出。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 写死的最终 fallback。**不要**为了改端口而修改这个常量——优先用 config.yaml /
|
||||
# env 这两层(它们在部署侧不需要改源码即可生效)。这个值只在 yaml 缺失 + env
|
||||
# 没设 + 请求里也拿不到合法 port 时才会用到。
|
||||
_DEFAULT_GATEWAY_PORT = 8001
|
||||
|
||||
|
||||
def _resolve_gateway_port(request: Request) -> int:
|
||||
"""按四档 fallback 解析 Gateway 监听端口。详见模块顶部「端口策略」。"""
|
||||
# 第 1 档:环境变量。允许非法值(garbage),只警告并继续下一档,不抛异常。
|
||||
env_port = os.environ.get("DEER_FLOW_GATEWAY_PORT", "").strip()
|
||||
if env_port:
|
||||
try:
|
||||
port = int(env_port)
|
||||
if 1 <= port <= 65535:
|
||||
return port
|
||||
raise ValueError("out of range")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"DEER_FLOW_GATEWAY_PORT=%r 不是合法端口,fallback 到 config.yaml / request / %d",
|
||||
env_port,
|
||||
_DEFAULT_GATEWAY_PORT,
|
||||
)
|
||||
|
||||
# 第 2 档:config.yaml 的 gateway.port。GatewayConfig.port 的 pydantic 校验
|
||||
# 已经保证它在 1-65535 范围内,无需再次校验。读 config 失败时降级,不阻断业务。
|
||||
try:
|
||||
configured = get_app_config().gateway.port
|
||||
if configured:
|
||||
return configured
|
||||
except Exception as exc:
|
||||
# 极端场景:config.yaml 不存在或解析失败。这种情况下 gateway 本身也起不来,
|
||||
# loopback 端口"猜错"也已经无所谓。仅记 debug 日志方便排查。
|
||||
logger.debug("read gateway.port from app config failed: %s", exc)
|
||||
|
||||
# 第 3 档:从当前请求拿 port。浏览器直连 gateway 时是真实监听端口;
|
||||
# 走 Nginx 反代时通常是 80/443(public port),那就跳过,落到最终默认。
|
||||
inbound_port = request.url.port
|
||||
if inbound_port is not None and inbound_port not in (80, 443):
|
||||
return inbound_port
|
||||
|
||||
# 第 4 档:写死默认。
|
||||
return _DEFAULT_GATEWAY_PORT
|
||||
|
||||
|
||||
def _loopback_base(request: Request) -> str:
|
||||
return f"{request.url.scheme}://{request.url.netloc}"
|
||||
"""返回 `http://127.0.0.1:<port>`,供 loopback 调用拼 URL。
|
||||
|
||||
强制 127.0.0.1 是为了避免 Nginx 反代场景下读到经过 Host 改写的 netloc。
|
||||
端口按 `_resolve_gateway_port` 的四档 fallback 解析。
|
||||
"""
|
||||
return f"http://127.0.0.1:{_resolve_gateway_port(request)}"
|
||||
|
||||
|
||||
def _make_loopback_client(request: Request) -> httpx.AsyncClient:
|
||||
"""构造同进程 loopback 用的 httpx.AsyncClient(真 TCP)。
|
||||
|
||||
所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用必须走这里,
|
||||
保持配置一致。
|
||||
- `trust_env=False`:**关键**,忽略 HTTP_PROXY / HTTPS_PROXY,防代理拦截 127.0.0.1
|
||||
- `timeout=None`:SSE 长连接需要,LangGraph runs/stream 可能跑数十秒
|
||||
- `request` 参数当前未被使用,保留是为了:(a) 未来若需要从 request 取部署
|
||||
上下文(如多租户端口隔离)可以无侵入扩展;(b) 与调用方现有签名兼容。
|
||||
"""
|
||||
return httpx.AsyncClient(
|
||||
timeout=None,
|
||||
trust_env=False,
|
||||
)
|
||||
|
||||
|
||||
def _auth_headers(request: Request) -> dict[str, str]:
|
||||
@@ -49,6 +286,7 @@ def _auth_headers(request: Request) -> dict[str, str]:
|
||||
|
||||
|
||||
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
|
||||
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
|
||||
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["thread_id"]
|
||||
@@ -92,6 +330,11 @@ async def _append_thread_message(
|
||||
thread_id: str,
|
||||
content: str,
|
||||
) -> None:
|
||||
"""向指定 thread 的 checkpoint 追加一条 human 消息(广播上下文,不触发 LLM run)。
|
||||
|
||||
失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
|
||||
前端对该席位 thread 发起的 ``/run/stream``。
|
||||
"""
|
||||
state_url = f"{base}/api/threads/{thread_id}/state"
|
||||
get_resp = await client.get(state_url, headers=headers)
|
||||
if get_resp.status_code != 200:
|
||||
@@ -117,6 +360,7 @@ async def _broadcast(
|
||||
agent_threads: dict[str, str],
|
||||
skip: list[str],
|
||||
) -> None:
|
||||
"""并行向 ``agent_threads`` 中除 ``skip`` 外的所有 thread 追加同一条 human 消息。"""
|
||||
targets = [tid for name, tid in agent_threads.items() if name not in skip]
|
||||
if not targets:
|
||||
return
|
||||
@@ -213,6 +457,13 @@ def _run_payload(
|
||||
excluded_tools: list[str],
|
||||
skill_stop_names: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||
|
||||
- ``context.agent_name``:本次 run 使用的 agent(内置 id 或 init 创建的 coordinator id)。
|
||||
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
|
||||
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
|
||||
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
|
||||
"""
|
||||
return {
|
||||
"input": {
|
||||
"messages": [
|
||||
@@ -245,26 +496,31 @@ def _run_payload(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init endpoint
|
||||
# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_multi_agent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""Streaming /init.
|
||||
"""流式初始化圆桌 Step 2 所需的 thread 映射与协调智能体。
|
||||
|
||||
Emits text/event-stream frames as each seat/coordinator becomes ready, so
|
||||
the frontend can visualize the parallel bring-up rather than blocking on
|
||||
one bulk JSON. Frames:
|
||||
请求体::
|
||||
|
||||
data: {"event":"seat_ready","agent_name":"...","thread_id":"...","display_name":"...","description":"..."}
|
||||
data: {"event":"coordinator_ready","main_agent_name":"...","thread_id":"..."}
|
||||
data: {"event":"init_done","main_agent_name":"...","thread_ids":{...}}
|
||||
data: {"event":"error","detail":"..."} # on failure
|
||||
{
|
||||
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
|
||||
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
|
||||
"model": "<可选>" // 可选,写入 coordinator agent;不传由 lead_agent 回退到 config.yaml models[0]
|
||||
}
|
||||
|
||||
Validation errors (missing agent_names, bad id pattern) still raise
|
||||
HTTPException synchronously so the connection fails fast with a real 4xx
|
||||
instead of a 200 SSE that just carries an error frame.
|
||||
SSE 事件(每条 ``data: {...}\\n\\n``)::
|
||||
|
||||
seat_ready — 某一席位 thread 已创建(``asyncio.as_completed`` 顺序,先完先发)
|
||||
coordinator_ready — 总控 agent + thread 已就绪
|
||||
init_done — ``thread_ids`` 全量映射,前端存 ``threadIds`` 并开始编排
|
||||
error — 失败详情(同步校验失败则直接 HTTP 4xx,不进 SSE)
|
||||
|
||||
协调智能体 SOUL 在运行时根据各席位 ``GET /api/agents/{id}`` 的 name/description 拼接,
|
||||
强调 ``agent_name`` 必须用英文 id(``roundtable-intelligence``),禁止带中文括号后缀。
|
||||
"""
|
||||
agent_names_in = payload.get("agent_names") or []
|
||||
agent_names: list[str] = [str(name).lower() for name in agent_names_in]
|
||||
@@ -285,11 +541,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
|
||||
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
# Per-seat prepare task — same body as before, but we no longer
|
||||
# await them with asyncio.gather. Instead, asyncio.as_completed
|
||||
@@ -329,14 +583,25 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
for sid, _, display_name, description in prepared
|
||||
]
|
||||
|
||||
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
||||
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
|
||||
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
|
||||
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
||||
"#关键约束(必读):\n"
|
||||
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
|
||||
"如果某个子智能体已经针对你正要派的任务完成过交付("
|
||||
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
|
||||
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
|
||||
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
|
||||
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
|
||||
"让讨论收敛。\n"
|
||||
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
|
||||
"#可调度智能体:\n"
|
||||
+ "\n".join(descriptions)
|
||||
)
|
||||
@@ -387,7 +652,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stream endpoint
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -398,7 +663,17 @@ async def _stream_upstream(
|
||||
thread_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> AsyncIterator[tuple[str | None, list[str] | None]]:
|
||||
"""Yield (raw_line, None) for each SSE line, then ``(None, captured_data_lines)`` once."""
|
||||
"""消费 ``/api/threads/{thread_id}/runs/stream``,逐行 yield 原始 SSE。
|
||||
|
||||
产出元组:
|
||||
- ``(line, None)`` — 可原样转发给浏览器(含 ``event:`` / ``data:`` 行);
|
||||
- ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表,
|
||||
供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。
|
||||
|
||||
日志 ``[upstream-timing]``:排查首 token 慢;headers_in 长尾若 >> 网络往返(loopback
|
||||
本机往返通常 <10ms),怀疑 HTTP_PROXY 拦了回环——检查 client 是否走 _make_loopback_client
|
||||
(trust_env=False)。
|
||||
"""
|
||||
|
||||
url = f"{base}/api/threads/{thread_id}/runs/stream"
|
||||
captured: list[str] = []
|
||||
@@ -520,12 +795,19 @@ async def _leader_run(
|
||||
skill_stop_names: list[str],
|
||||
model_name: str,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
# Fine-grained timing for the leader path. The user reported the
|
||||
# coordinator "stalls before output" — we log each major await so we can
|
||||
# see whether the wait is in pre-broadcast scheduling, upstream stream
|
||||
# setup, first-token latency, or post-stream parsing.
|
||||
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
||||
|
||||
时序要点:
|
||||
1. **预广播**(与 LLM 并行):把用户/综合消息写入其它席位 thread,skip 总控自身;
|
||||
2. 跑总控 thread 的 stream,yield 每一行原始 SSE;
|
||||
3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表;
|
||||
4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
|
||||
|
||||
``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
|
||||
pre_broadcast_task = asyncio.create_task(
|
||||
_broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
|
||||
)
|
||||
@@ -594,12 +876,8 @@ async def _leader_run(
|
||||
leader_text = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
# Clarification branch: the coordinator called ask_clarification. The
|
||||
# ClarificationMiddleware intercepts with Command(goto=END) and stashes
|
||||
# the structured question in the tool_call args; sometimes a richer
|
||||
# follow-up AIMessage is also produced after the ToolMessage. Emit a
|
||||
# dedicated frame so the frontend can render it as a question instead of
|
||||
# treating an empty dispatch as "consensus reached".
|
||||
# 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
|
||||
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
@@ -616,15 +894,17 @@ async def _leader_run(
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
return
|
||||
|
||||
if not tool_calls:
|
||||
# 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
|
||||
yield {"status": [], "content": leader_text, "agent_name": agent_name}
|
||||
return
|
||||
|
||||
# Run all dispatch broadcasts concurrently — they target disjoint thread
|
||||
# 派活广播:告知各席位「总控给 X 派了什么活」,与即将发起的 special run 配合。
|
||||
# sets and we don't care about ordering, so serializing them just
|
||||
# multiplied latency before the frontend could start a sub-agent run.
|
||||
dispatched: list[list[str]] = []
|
||||
@@ -670,8 +950,16 @@ async def _special_run(
|
||||
new_message: str,
|
||||
skill_stop_names: list[str],
|
||||
model_name: str,
|
||||
*,
|
||||
display_name: str = "",
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
# Same instrumentation as _leader_run so all 6 seats are diagnosable.
|
||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||
|
||||
- ``excluded_tools`` 禁用派活/澄清等,避免席位抢总控活;
|
||||
- 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文;
|
||||
- **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要,
|
||||
同时尽量让前端先收到结果关闭 loading。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
thread_id = agent_threads[agent_name]
|
||||
body = _run_payload(
|
||||
@@ -731,18 +1019,21 @@ async def _special_run(
|
||||
# closes; the SSE connection stays open a bit longer but the frontend has
|
||||
# already consumed the result.
|
||||
t_before_post = time.perf_counter()
|
||||
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
|
||||
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
|
||||
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
|
||||
post_broadcast_task = asyncio.create_task(
|
||||
_broadcast(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content,
|
||||
f"子智能体{speaker_label}完成{new_message}工作,交付内容为:" + content,
|
||||
agent_threads,
|
||||
[agent_name],
|
||||
)
|
||||
)
|
||||
yield {
|
||||
"status": f"子智能体{agent_name}完成{new_message}工作",
|
||||
"status": f"子智能体{speaker_label}完成{new_message}工作",
|
||||
"content": content,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
@@ -757,14 +1048,42 @@ async def _special_run(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/multi-agent/run/stream — leader / special 单轮 SSE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/run/stream")
|
||||
async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""驱动总控或子智能体的一轮 LangGraph run。
|
||||
|
||||
请求体::
|
||||
|
||||
{
|
||||
"agent_type": "leader" | "special",
|
||||
"d_agent_thread_id": { "<agent_id>": "<thread_id>", ... }, // init_done.thread_ids
|
||||
"agent_name": "<本次运行的 agent_id>",
|
||||
"new_message": "<用户消息或派活 task 文本>",
|
||||
"model": "<可选>", // 不传由 lead_agent 回退到 config.yaml models[0]
|
||||
"skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration
|
||||
}
|
||||
|
||||
响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。
|
||||
客户端应使用 AbortSignal 支持「挂起协商」时取消 in-flight 请求。
|
||||
"""
|
||||
agent_type: str = str(payload.get("agent_type") or "leader").lower()
|
||||
agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
|
||||
agent_name: str = str(payload.get("agent_name") or "").lower()
|
||||
# 人类可读名,仅 special 用 —— 后端把它拼进 post-broadcast 文本,这样 leader
|
||||
# 下一轮看到的是"子智能体[中文名](id)完成..."而非"子智能体[UUID]完成...";
|
||||
# LLM 对 UUID 不友好,只用 id 会导致总控认不出已完成的子智能体,无限循环派活。
|
||||
# 缺失时 fallback 到 agent_name,旧前端不传也不破坏。
|
||||
display_name: str = str(payload.get("display_name") or "").strip()
|
||||
new_message: str = str(payload.get("new_message") or "")
|
||||
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
|
||||
model_name: str = str(payload.get("model") or "deepseek-chat")
|
||||
# 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
|
||||
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。
|
||||
model_name: str = str(payload.get("model") or "")
|
||||
|
||||
if not agent_name or agent_name not in agent_threads:
|
||||
raise HTTPException(
|
||||
@@ -779,11 +1098,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
|
||||
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
if agent_type == "leader":
|
||||
gen = _leader_run(
|
||||
@@ -792,6 +1109,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
else:
|
||||
gen = _special_run(
|
||||
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
|
||||
display_name=display_name,
|
||||
)
|
||||
async for chunk in gen:
|
||||
if isinstance(chunk, dict):
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""圆桌规划 · Step 1→2 之间「智能体推荐」Gateway 路由。
|
||||
|
||||
前端页面:推荐弹窗 ``RecommendAgentsDialog``
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/recommend.ts``
|
||||
|
||||
职责
|
||||
----
|
||||
根据 Step 1 的 ``IntentSummary`` 与前端过滤后的候选池,驱动内置
|
||||
``roundtable-recommender``(SOUL 见 ``.deer-flow/agents/roundtable-recommender/``),
|
||||
输出 2–6 个 ``{agent_id, reason}`` 推荐席位。
|
||||
|
||||
设计为**无状态单次调用**:
|
||||
- **不暴露** ``/init``:每次请求内部 ``_create_thread``,推荐 agent 不需要历史轮次。
|
||||
- 候选池过滤由前端完成(剔除 ``roundtable-intent``、``roundtable-recommender``、
|
||||
``roundtable-coordinator-*``),本路由只做校验与 clamp。
|
||||
|
||||
SSE 协议
|
||||
--------
|
||||
1. 透传上游 LangGraph 流(前端用 ``parseRecommendStreamContent`` 把正文拆成
|
||||
``[RECOMMEND_READY]`` 前的 ``rationale`` 与 JSON 里的 ``picks``,实现卡片增量渲染)。
|
||||
2. 末尾状态帧::
|
||||
|
||||
{ "status": "done"|"asking"|"error", "content": "<全文>", "picks": [...]|null }
|
||||
|
||||
- ``done``:``picks`` 经 ``_validate_picks`` 后 ≥2 条且 id 均在候选池内;
|
||||
- ``asking``:未匹配 ``[RECOMMEND_READY]`` 或校验后不足 2 条(前端走 FALLBACK 默认 6 席);
|
||||
- ``error``:HTTP/未捕获异常。
|
||||
|
||||
与 SOUL 的契约
|
||||
--------------
|
||||
模型须在自然语言思路后输出::
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{"picks": [{"agent_id": "...", "reason": "..."}, ...]}
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_make_loopback_client,
|
||||
_parse_last_messages,
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/recommend", tags=["recommend"])
|
||||
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
|
||||
# 从模型最终 AIMessage 全文提取 picks;非贪婪 ``.*?`` 只匹配第一个 json 块。
|
||||
_RECOMMEND_READY_PATTERN = re.compile(
|
||||
r"\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# 与 SOUL / 前端 ``RecommendAgentsDialog`` 一致:少于 2 无法成桌,多于 6 由网关截断。
|
||||
# 前端确认按钮另限制 2≤N≤8(用户可手动多选),后端此处只负责模型输出校验。
|
||||
_MIN_PICKS = 2
|
||||
_MAX_PICKS = 6
|
||||
|
||||
|
||||
def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> str:
|
||||
"""Render the intent + candidate pool into the single user message the
|
||||
recommender agent reads. Keep the format close to what the SOUL example
|
||||
shows so the model has a clean pattern to mirror."""
|
||||
objective = str(intent.get("objective") or "").strip() or "(未提供)"
|
||||
constraints = intent.get("constraints") or []
|
||||
assumptions = intent.get("assumptions") or []
|
||||
|
||||
constraint_lines = "; ".join(str(c).strip() for c in constraints if str(c).strip()) or "(无)"
|
||||
assumption_lines = "; ".join(str(a).strip() for a in assumptions if str(a).strip()) or "(无)"
|
||||
|
||||
candidate_lines: list[str] = []
|
||||
for c in candidates:
|
||||
cid = str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
cname = str(c.get("name") or "").strip()
|
||||
cdesc = str(c.get("description") or "").strip()
|
||||
if not cid:
|
||||
continue
|
||||
# Format: "- <id> (<name>): <description>" — matches the SOUL example.
|
||||
suffix = f" ({cname})" if cname else ""
|
||||
desc_part = f": {cdesc}" if cdesc else ""
|
||||
candidate_lines.append(f"- {cid}{suffix}{desc_part}")
|
||||
|
||||
candidate_block = "\n".join(candidate_lines) if candidate_lines else "(候选池为空)"
|
||||
|
||||
return (
|
||||
f"任务目标:{objective}\n"
|
||||
f"约束:{constraint_lines}\n"
|
||||
f"关键假设:{assumption_lines}\n\n"
|
||||
f"候选智能体池:\n{candidate_block}\n\n"
|
||||
f"请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。"
|
||||
)
|
||||
|
||||
|
||||
def _validate_picks(
|
||||
raw_picks: Any,
|
||||
candidates: list[dict[str, Any]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Filter the model's picks against the candidate pool and clamp to 2-6.
|
||||
|
||||
Drops picks whose ``agent_id`` isn't in the candidate set (guards against
|
||||
model hallucination), de-duplicates, and trims to ``_MAX_PICKS`` if the
|
||||
model over-picked. Returns ``[]`` if validation leaves us with fewer than
|
||||
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
|
||||
defaults".
|
||||
|
||||
容错策略(2026-XX 修复 "用户自建 agent 被推荐丢失" 的问题):
|
||||
模型有时把候选池里的 ``name`` 当成 ``agent_id`` 回填(尤其当 id 是 UUID
|
||||
形式时, LLM 倾向用人类可读名),或者顺便也回了 ``name`` 字段。本函数:
|
||||
1. 先按 agent_id 命中候选池(严格匹配,原行为);
|
||||
2. 否则按 ``agent_id`` 的字面值反查 name → 找回真实 id;
|
||||
3. 否则按 ``name`` 字段反查 name → 找回真实 id。
|
||||
只要能定位到候选池里**唯一**一个 agent,就算 pick 成功。
|
||||
"""
|
||||
if not isinstance(raw_picks, list):
|
||||
return []
|
||||
|
||||
id_set: set[str] = set()
|
||||
name_to_id: dict[str, str] = {}
|
||||
for c in candidates:
|
||||
aid = str(c.get("agent_id") or "").strip()
|
||||
if not aid:
|
||||
continue
|
||||
id_set.add(aid)
|
||||
nm = str(c.get("name") or "").strip()
|
||||
if nm:
|
||||
# 大小写不敏感反查 —— 模型可能用空格 / 不同大小写 echo 中文名。
|
||||
name_to_id.setdefault(nm.lower(), aid)
|
||||
|
||||
picked: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_picks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
raw_id = str(item.get("agent_id") or "").strip()
|
||||
raw_name = str(item.get("name") or "").strip()
|
||||
reason = str(item.get("reason") or "").strip()
|
||||
|
||||
aid = ""
|
||||
if raw_id and raw_id in id_set:
|
||||
aid = raw_id
|
||||
elif raw_id and raw_id.lower() in name_to_id:
|
||||
# 模型把"广告设计智能体"这种 name 当成 agent_id 回填了
|
||||
aid = name_to_id[raw_id.lower()]
|
||||
elif raw_name and raw_name.lower() in name_to_id:
|
||||
# 模型 agent_id 字段乱了,但 name 字段对得上
|
||||
aid = name_to_id[raw_name.lower()]
|
||||
|
||||
if not aid or aid in seen:
|
||||
continue
|
||||
picked.append({"agent_id": aid, "reason": reason})
|
||||
seen.add(aid)
|
||||
if len(picked) >= _MAX_PICKS:
|
||||
break
|
||||
|
||||
if len(picked) < _MIN_PICKS:
|
||||
return []
|
||||
return picked
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_recommend(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""Drive one recommendation turn via SSE.
|
||||
|
||||
Body::
|
||||
|
||||
{
|
||||
"intent": {"objective": "...", "constraints": [...], "assumptions": [...]},
|
||||
"candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...],
|
||||
"model": "<可选>" # optional;不传由 lead_agent 回退到 config.yaml models[0]
|
||||
}
|
||||
|
||||
Stream: transparent upstream LangGraph frames, followed by one status
|
||||
frame::
|
||||
|
||||
{
|
||||
"status": "done" | "asking" | "error",
|
||||
"content": "<final AI text>",
|
||||
"picks": [{"agent_id": "...", "reason": "..."}] | null
|
||||
}
|
||||
|
||||
``done`` means picks were parsed and validated; ``asking`` means the
|
||||
marker was missing or validation left fewer than 2 valid picks (caller
|
||||
should apply its fallback list); ``error`` is upstream failure.
|
||||
"""
|
||||
intent = payload.get("intent") or {}
|
||||
candidates = payload.get("candidates") or []
|
||||
# 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
|
||||
# config.yaml 的 models[0]。内网部署可能没有 deepseek-chat,前端传当前选中的
|
||||
# model 名即可,缺失也不会硬错。
|
||||
model_name = str(payload.get("model") or "")
|
||||
|
||||
if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="intent.objective is required")
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
raise HTTPException(status_code=400, detail="candidates must be a non-empty array")
|
||||
|
||||
candidate_ids = {
|
||||
str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
for c in candidates
|
||||
if isinstance(c, dict)
|
||||
}
|
||||
candidate_ids.discard("")
|
||||
if not candidate_ids:
|
||||
raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id")
|
||||
|
||||
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,roundtable-recommender 目录可能不存在,
|
||||
# 缺失则用嵌入模板补建,避免下游 lead_agent 解析失败抛 500。
|
||||
ensure_roundtable_functional_agents()
|
||||
|
||||
prompt = _build_prompt(intent, candidates)
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
|
||||
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
|
||||
# 看不到逐 token 增量。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=RECOMMENDER_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=prompt,
|
||||
# 推荐只做判断:禁止澄清、派活、文件等,避免跑题或二次追问。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"ask_clarification",
|
||||
],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
yield line if line.endswith("\n") else line + "\n"
|
||||
elif done is not None:
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
last_ai_content = ""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
text = _flatten_content(msg.get("content", "")).strip()
|
||||
if text:
|
||||
last_ai_content = text
|
||||
break
|
||||
|
||||
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
|
||||
picks: list[dict[str, str]] = []
|
||||
match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
|
||||
# 诊断:打印模型输出末尾(含 RECOMMEND_READY 块)。grep "recommend stream debug"
|
||||
# 即可看到 LLM 实际推荐了什么、_validate_picks 解析前/后的差异。
|
||||
logger.info(
|
||||
"recommend stream debug | last_ai_tail=%r | match=%s | candidate_count=%d",
|
||||
last_ai_content[-600:] if last_ai_content else "",
|
||||
bool(match),
|
||||
len(candidates),
|
||||
)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("recommend picks JSON parse failed: %s | raw=%r", exc, match.group(1)[:300])
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
raw_picks = parsed.get("picks")
|
||||
# 传完整 candidates 列表(含 name) —— _validate_picks 容错按 name 反查 id,
|
||||
# 修复 LLM 把"广告设计智能体"这种 name 当 agent_id 回填导致全丢的问题。
|
||||
picks = _validate_picks(raw_picks, candidates)
|
||||
logger.info(
|
||||
"recommend stream debug | raw_picks=%r | validated_picks=%r",
|
||||
raw_picks,
|
||||
picks,
|
||||
)
|
||||
|
||||
# asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
|
||||
status_value = "done" if picks else "asking"
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": status_value,
|
||||
"content": last_ai_content,
|
||||
"picks": picks if picks else None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("recommend stream unexpected failure")
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -2,6 +2,13 @@
|
||||
config_version: 8
|
||||
log_level: info
|
||||
|
||||
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
|
||||
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
|
||||
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
|
||||
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
|
||||
gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
|
||||
+64
-10
@@ -16,18 +16,70 @@ from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, sa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 章节正文里的 Markdown 标题行(允许前导空白)
|
||||
_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE)
|
||||
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
|
||||
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
|
||||
|
||||
|
||||
def _normalize_section_headings(text: str) -> str:
|
||||
"""把章节正文里所有 Markdown 标题行统一压成三级标题(###)。
|
||||
"""把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
|
||||
|
||||
作家被要求只用 ### 作为「要点」小标题;模型偶尔误用 #/##/#### 时在此纠正,
|
||||
保证文档标题层级符合「文章 # → 章节 ## → 要点 ###」,
|
||||
前端目录栏据此才能把要点正确识别为二级标题。
|
||||
文档层级:文章 `#` → 章节 `##` → 小节 `###` → 子要点 `####`。
|
||||
作家只输出小节(###)和子要点(####)两级,文章/章节标题由系统注入。
|
||||
|
||||
归一规则:
|
||||
- `#` / `##` → `###` 作家不应输出这两级,误用时降级为小节
|
||||
- `###` → `###` 保持
|
||||
- `####` → `####` 保持(三级结构的最深层)
|
||||
- `#####`+ → `####` 深度截断,防止结构过深
|
||||
"""
|
||||
return _HEADING_PREFIX_RE.sub("### ", text)
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
indent = match.group(1)
|
||||
level = len(match.group(2))
|
||||
# 三级及以下保持原级别(限 3 和 4);深度 4+ 压回 4
|
||||
new_level = 3 if level <= 3 else 4
|
||||
return f"{indent}{'#' * new_level} "
|
||||
|
||||
return _HEADING_PREFIX_RE.sub(_replace, text)
|
||||
|
||||
|
||||
def _render_key_points_for_prompt(key_points: list) -> str:
|
||||
"""把 outline 的 key_points 渲染成层级文本喂给作家。
|
||||
|
||||
输入是 List[KeyPoint] 或 List[str](旧 session 兼容)。输出形如:
|
||||
|
||||
- 小节标题1
|
||||
- 子要点 a
|
||||
- 子要点 b
|
||||
- 小节标题2(无子要点时只一行)
|
||||
|
||||
作家据此把每个主 bullet 写成 `### 小节标题`,把缩进的子要点
|
||||
用 `- ` bullet 列表落在小节正文起始处,再展开论述。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for kp in key_points:
|
||||
if isinstance(kp, str):
|
||||
# 旧版兼容:整条当作小节标题,没有子要点
|
||||
title = kp.strip()
|
||||
if title:
|
||||
lines.append(f"- {title}")
|
||||
elif isinstance(kp, dict):
|
||||
title = str(
|
||||
kp.get("point_title")
|
||||
or kp.get("pointTitle")
|
||||
or kp.get("title")
|
||||
or ""
|
||||
).strip()
|
||||
if not title:
|
||||
continue
|
||||
lines.append(f"- {title}")
|
||||
subs = kp.get("sub_points") or kp.get("subPoints") or []
|
||||
if isinstance(subs, list):
|
||||
for sp in subs:
|
||||
sp_text = sp.strip() if isinstance(sp, str) else str(sp).strip()
|
||||
if sp_text:
|
||||
lines.append(f" - {sp_text}")
|
||||
return "\n".join(lines) or "- 主要内容"
|
||||
|
||||
|
||||
# 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现,
|
||||
@@ -166,7 +218,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
mat_ids = section.get("material_ids") or section.get("materialIds") or []
|
||||
|
||||
section_materials = _get_section_materials(mat_ids, pkg)
|
||||
key_points_str = "\n".join(f"- {p}" for p in key_points)
|
||||
# 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
|
||||
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
|
||||
key_points_str = _render_key_points_for_prompt(key_points)
|
||||
|
||||
# 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节
|
||||
outline_context = "\n".join(
|
||||
@@ -282,8 +336,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
expand_msg = DRAFT_EXPAND_USER.format(
|
||||
article_title=article_title,
|
||||
section_title=section_titles[idx],
|
||||
key_points="\n".join(
|
||||
f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or [])
|
||||
key_points=_render_key_points_for_prompt(
|
||||
target_sec.get("key_points") or target_sec.get("keyPoints") or []
|
||||
),
|
||||
section_materials=_get_section_materials(
|
||||
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
|
||||
|
||||
+66
-6
@@ -7,7 +7,7 @@ import logging
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..state import AIWritingState, Outline, OutlineSection
|
||||
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
|
||||
from ..prompts.writer_prompts import (
|
||||
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
|
||||
)
|
||||
@@ -27,16 +27,56 @@ def _materials_summary(pkg: dict) -> str:
|
||||
return "\n".join(lines) or "(无素材)"
|
||||
|
||||
|
||||
def _normalize_key_point(raw: object) -> KeyPoint:
|
||||
"""把 LLM 输出 / 旧 session 数据规整成 KeyPoint。
|
||||
|
||||
兼容场景:
|
||||
- 新版 dict:`{"point_title": "...", "sub_points": [...]}` —— 直接用,缺字段补默认
|
||||
- 旧版 string:历史 session 的 `key_points: ["要点1", "要点2"]` —— 自动 lift 成
|
||||
`{"point_title": "要点1", "sub_points": []}`,旧大纲打开后能继续编辑
|
||||
- LLM 偶尔会用 `title` / `subPoints` 这种 camelCase 或别名,做轻量兜底
|
||||
- 其它无法识别的形态(数字 / 列表 / None)→ 空 KeyPoint,不让整个大纲炸掉
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
return KeyPoint(point_title=raw, sub_points=[])
|
||||
if isinstance(raw, dict):
|
||||
# 字段别名兜底(LLM 时常 camelCase 或写 title):优先 snake_case
|
||||
title = (
|
||||
raw.get("point_title")
|
||||
or raw.get("pointTitle")
|
||||
or raw.get("title")
|
||||
or ""
|
||||
)
|
||||
subs_raw = raw.get("sub_points") or raw.get("subPoints") or []
|
||||
# 子要点统一为字符串数组;dict 子项取其 title 字段降级(避免 4 级嵌套混入)
|
||||
sub_points: list[str] = []
|
||||
if isinstance(subs_raw, list):
|
||||
for sp in subs_raw:
|
||||
if isinstance(sp, str):
|
||||
sub_points.append(sp)
|
||||
elif isinstance(sp, dict):
|
||||
sub_points.append(str(sp.get("point_title") or sp.get("title") or sp.get("text") or ""))
|
||||
return KeyPoint(point_title=str(title), sub_points=sub_points)
|
||||
# 其它意外类型(int / list / None):返回空 KeyPoint
|
||||
return KeyPoint(point_title="", sub_points=[])
|
||||
|
||||
|
||||
def _parse_outline(raw: dict) -> Outline:
|
||||
sections = []
|
||||
for s in raw.get("sections", []):
|
||||
raw_kps = s.get("key_points") or []
|
||||
key_points = [_normalize_key_point(kp) for kp in raw_kps]
|
||||
sections.append(OutlineSection(
|
||||
section_title=s.get("section_title", ""),
|
||||
key_points=s.get("key_points", []),
|
||||
key_points=key_points,
|
||||
material_ids=s.get("material_ids", []),
|
||||
))
|
||||
if not sections:
|
||||
sections = [OutlineSection(section_title="正文", key_points=["主要内容"], material_ids=[])]
|
||||
sections = [OutlineSection(
|
||||
section_title="正文",
|
||||
key_points=[KeyPoint(point_title="主要内容", sub_points=[])],
|
||||
material_ids=[],
|
||||
)]
|
||||
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
|
||||
|
||||
|
||||
@@ -120,12 +160,32 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
if is_revise:
|
||||
fallback = current_outline
|
||||
else:
|
||||
# 兜底大纲也用新的 3 级结构;_normalize_key_point 同时兼容旧 string 形态,
|
||||
# 这里给 dict 形态做样板。
|
||||
fallback = {
|
||||
"title": "未命名文章",
|
||||
"sections": [
|
||||
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []},
|
||||
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []},
|
||||
{"section_title": "结论", "key_points": ["总结"], "material_ids": []},
|
||||
{
|
||||
"section_title": "引言",
|
||||
"key_points": [
|
||||
{"point_title": "背景介绍", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
{
|
||||
"section_title": "正文",
|
||||
"key_points": [
|
||||
{"point_title": "核心内容", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
{
|
||||
"section_title": "结论",
|
||||
"key_points": [
|
||||
{"point_title": "总结", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
result = parse_json(full_text, fallback)
|
||||
|
||||
+82
-34
@@ -1,10 +1,10 @@
|
||||
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据素材和意图,规划清晰的文章大纲。
|
||||
你的目标:根据素材和意图,规划清晰的三级大纲(章节 → 小节 → 子要点)。
|
||||
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
|
||||
|
||||
你只能做以下事情:
|
||||
1. 分析素材,提取核心观点
|
||||
2. 规划文章章节结构和每节要点
|
||||
2. 规划文章三级结构:章节、章节下的小节、小节下的子要点
|
||||
你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
|
||||
|
||||
【严禁思考与表态】(最高优先级)
|
||||
@@ -12,10 +12,16 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。
|
||||
- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。
|
||||
|
||||
【要点(key_points)的特殊要求】
|
||||
- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。
|
||||
- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。
|
||||
- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。
|
||||
【三级层级语义】(最高优先级,决定文章结构)
|
||||
- Level 1 = `section_title`(章节):将渲染为文章里的 `##` 二级标题,统领一组内容。
|
||||
- Level 2 = `point_title`(小节):将渲染为 `###` 三级标题,下面会有一段 2-3 句的概述/引入正文。
|
||||
- Level 3 = `sub_points`(子要点):将渲染为 `####` 四级标题,**每个子要点都是一个有自己 3-5 句独立正文段的小标题**——不是 bullet 列表。
|
||||
→ 因此子要点要规划成「能独立成段展开」的小主题,而不是一句话能讲完的零散点。
|
||||
|
||||
【三级字段的具体要求】
|
||||
- `section_title`(章节标题):高度概括的短语,6-15 字。每篇文章规划 3-5 个章节。
|
||||
- `point_title`(小节标题):名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。每个章节规划 2-4 个小节。
|
||||
- `sub_points`(子要点标题):名词性短语,<= 15 字,**不带句末标点**。每个小节规划 2-4 条子要点;如果该小节信息确实少到无法拆分子主题,可留 `[]`(此时只写小节级正文)。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
|
||||
@@ -27,9 +33,31 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
{
|
||||
"title": "新能源汽车出口为何高速增长",
|
||||
"sections": [
|
||||
{"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]},
|
||||
{"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]},
|
||||
{"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]}
|
||||
{
|
||||
"section_title": "引言:出口数据创新高",
|
||||
"key_points": [
|
||||
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
|
||||
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
|
||||
],
|
||||
"material_ids": ["m001"]
|
||||
},
|
||||
{
|
||||
"section_title": "增长背后的三大动因",
|
||||
"key_points": [
|
||||
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
|
||||
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]},
|
||||
{"point_title": "产品竞争力提升", "sub_points": ["智能化配置溢价", "出海车型迭代速度"]}
|
||||
],
|
||||
"material_ids": ["m002", "m003"]
|
||||
},
|
||||
{
|
||||
"section_title": "结论与展望",
|
||||
"key_points": [
|
||||
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
|
||||
{"point_title": "行业趋势判断", "sub_points": []}
|
||||
],
|
||||
"material_ids": ["m004"]
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
@@ -46,7 +74,7 @@ OUTLINE_USER = """写作意图:{user_intent}
|
||||
# ── 重新规划(基于现有大纲的编辑)─────────────────────────────────────────────
|
||||
|
||||
OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。
|
||||
你的目标:根据用户的修改要求,调整一份【已有的三级文章大纲】。
|
||||
你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。
|
||||
|
||||
你只能做以下事情:
|
||||
@@ -54,25 +82,31 @@ OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你不能写正文,只输出调整后的大纲。
|
||||
|
||||
【修改原则】(最高优先级)
|
||||
- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。
|
||||
- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。
|
||||
- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。
|
||||
- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
|
||||
- 准确识别并落实用户意图,常见操作:新增/删除/合并/拆分章节或小节、调整顺序、修改标题、调整子要点。
|
||||
- 只改用户明确提到的部分:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变)。
|
||||
- 新增内容时:紧扣写作意图,并尽量关联可用素材的 material_ids。
|
||||
- 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
|
||||
|
||||
【严禁思考与表态】(最高优先级)
|
||||
- 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。
|
||||
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。
|
||||
|
||||
【要点(key_points)的特殊要求】
|
||||
- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。
|
||||
- 每个章节 2-4 个要点。
|
||||
【三级层级语义】(最高优先级)
|
||||
- `section_title`(章节,`##` 二级标题):高度概括的短语,6-15 字。
|
||||
- `point_title`(小节,`###` 三级标题):名词性短语,15 字以内,无句末标点;小节下会有一段概述正文。每章节 2-4 个小节。
|
||||
- `sub_points`(子要点,`####` 四级标题):名词性短语,<= 15 字,无句末标点;**每条子要点会展开成一段独立正文**,所以要写成能成段的主题。每小节 2-4 条,信息少到无法拆时可留 `[]`。
|
||||
|
||||
【兼容历史大纲】
|
||||
- 若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,
|
||||
在补充 `sub_points: []` 后输出新格式:`{"point_title": "<原 string>", "sub_points": []}`。
|
||||
- 用户修改时如要求"为某要点补充更详细的子项",就把对应小节的 sub_points 填上。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。
|
||||
- 必须使用英文双引号 "。
|
||||
- material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。
|
||||
- 结构与「当前大纲」一致:
|
||||
{"title": "...", "sections": [{"section_title": "...", "key_points": ["..."], "material_ids": ["..."]}]}"""
|
||||
- 结构(嵌套三级):
|
||||
{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": ["..."]}]}"""
|
||||
|
||||
OUTLINE_REVISE_USER = """写作意图:{user_intent}
|
||||
文章类型:{article_type}
|
||||
@@ -104,18 +138,23 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
- 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。
|
||||
- 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。
|
||||
|
||||
【结构要求】
|
||||
- 把本章节的每一个「章节要点」写成一个二级小标题,用 Markdown 三级标题语法:### 要点
|
||||
- 每个 ### 小标题下面紧跟该要点的论述正文,正文连贯成段。
|
||||
- 即使章节只有一个要点,也要为它写出对应的 ### 小标题。
|
||||
- 小标题文字简洁,直接沿用给定的要点措辞,不要加序号、不要加标点。
|
||||
- 只能使用 ### 这一级标题;绝不要输出 # 或 ##(文章标题、章节标题由系统统一添加)。
|
||||
【结构要求】(三级结构,必须严格遵守)
|
||||
- 「章节要点」用层级 bullet 列表给出:
|
||||
- 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
|
||||
- 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
|
||||
- **每个小节、每个子要点都要有自己的正文段,分两层展开**:
|
||||
1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
|
||||
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
|
||||
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
|
||||
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
|
||||
- 标题层级严格对应:文章 `#` / 章节 `##`(系统注入,作家**不要输出**)/ 小节 `###` / 子要点 `####`。
|
||||
- 不要输出 `#####` 或更深;这种深度不在结构里。
|
||||
|
||||
【输出要求】
|
||||
- 直接返回这一段的内容(含 ### 小标题与正文),不要 JSON,不要解释。
|
||||
- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
|
||||
- 不要写"以下是正文""本段内容如下"之类的开场白。
|
||||
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
|
||||
- 充分展开每个要点:每个要点至少用 2-3 句话论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
|
||||
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
|
||||
|
||||
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
|
||||
|
||||
@@ -141,17 +180,25 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
|
||||
{outline_context}
|
||||
|
||||
当前章节:{section_title}
|
||||
章节要点:{key_points}
|
||||
章节内的小节与子要点(层级 bullet 列表):
|
||||
{key_points}
|
||||
|
||||
相关素材:
|
||||
{section_materials}
|
||||
{revision_section}{mode_section}
|
||||
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
|
||||
把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。"""
|
||||
|
||||
按下面的结构两层展开写:
|
||||
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
|
||||
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
|
||||
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
|
||||
|
||||
论述充分、层次清晰、内容充实。"""
|
||||
|
||||
DRAFT_EXPAND_USER = """文章标题:{article_title}
|
||||
当前章节:{section_title}
|
||||
章节要点:{key_points}
|
||||
章节内的小节与子要点(层级 bullet 列表):
|
||||
{key_points}
|
||||
|
||||
相关素材:
|
||||
{section_materials}
|
||||
@@ -159,8 +206,9 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
|
||||
这一章节已有内容如下:
|
||||
{existing_text}
|
||||
|
||||
以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
|
||||
以上内容篇幅不足。请在保留原意、事实和「### 小节 / #### 子要点」两级标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
|
||||
要求:
|
||||
- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。
|
||||
- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。
|
||||
- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。"""
|
||||
- 补充更多来自素材的事实、数据与分析,让每一层都论述更充分、更具体。
|
||||
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
|
||||
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
|
||||
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
|
||||
+14
-1
@@ -20,10 +20,23 @@ class MaterialPackage(TypedDict):
|
||||
summary: str
|
||||
|
||||
# ── 大纲 ──────────────────────────────────────────────────────────────────────
|
||||
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
|
||||
# Outline.sections[] = Level 1 章节(渲染为 ## 由系统注入)
|
||||
# OutlineSection.key_points[] = Level 2 小节(渲染为 ### 标题)
|
||||
# KeyPoint.sub_points[] = Level 3 子要点(bullet "- ...",非标题)
|
||||
#
|
||||
# 历史上 key_points 曾是 List[str](2 级):每个 str 既是小节标题又是渲染单位。
|
||||
# 升级到 3 级后,key_points 变成 List[KeyPoint],每个 KeyPoint 自带 sub_points
|
||||
# 列表;旧 session 里的 string 项由 writer_outline._parse_outline 自动 lift 成
|
||||
# {point_title: <原 string>, sub_points: []},无需迁移数据。
|
||||
|
||||
class KeyPoint(TypedDict):
|
||||
point_title: str # 小节标题(渲染为 ### 二级小标题)
|
||||
sub_points: List[str] # 子要点(可为空;非空时在小节正文里以 bullet 列表呈现)
|
||||
|
||||
class OutlineSection(TypedDict):
|
||||
section_title: str
|
||||
key_points: List[str]
|
||||
key_points: List[KeyPoint] # 注意:旧版是 List[str],解析层做了兼容
|
||||
material_ids: List[str]
|
||||
|
||||
class Outline(TypedDict):
|
||||
|
||||
+67
-64
@@ -6,6 +6,8 @@ from collections.abc import Callable
|
||||
from hashlib import sha256
|
||||
from typing import override
|
||||
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
@@ -67,7 +69,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
question = args.get("question", "")
|
||||
clarification_type = args.get("clarification_type", "missing_info")
|
||||
context = args.get("context")
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
|
||||
# Type-specific icons
|
||||
type_icons = {
|
||||
@@ -95,62 +97,63 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
# Add options in a cleaner format
|
||||
if options and len(options) > 0:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
}
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
"allow_multiple": resolve_allow_multiple(args),
|
||||
}
|
||||
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||
"""Handle clarification request and return command to interrupt execution.
|
||||
@@ -176,13 +179,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
|
||||
# Return a Command that:
|
||||
# 1. Adds the formatted tool message
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .app_config import get_app_config
|
||||
from .extensions_config import ExtensionsConfig, get_extensions_config
|
||||
from .gateway_config import GatewayConfig
|
||||
from .memory_config import MemoryConfig, get_memory_config
|
||||
from .paths import Paths, get_paths
|
||||
from .skill_evolution_config import SkillEvolutionConfig
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"SkillsConfig",
|
||||
"ExtensionsConfig",
|
||||
"get_extensions_config",
|
||||
"GatewayConfig",
|
||||
"MemoryConfig",
|
||||
"get_memory_config",
|
||||
"get_tracing_config",
|
||||
|
||||
@@ -13,6 +13,7 @@ from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_c
|
||||
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
|
||||
from deerflow.config.database_config import DatabaseConfig
|
||||
from deerflow.config.extensions_config import ExtensionsConfig
|
||||
from deerflow.config.gateway_config import GatewayConfig
|
||||
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
||||
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
@@ -122,6 +123,7 @@ class AppConfig(BaseModel):
|
||||
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
|
||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)")
|
||||
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
||||
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
|
||||
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Gateway 进程的本机绑定配置。
|
||||
|
||||
目前唯一字段是 ``port``——Gateway FastAPI 的监听端口。圆桌三路由
|
||||
(intent / recommend / multi_agent) 内部用 loopback 调本机的其它 endpoint
|
||||
(``/api/threads``、``/api/agents`` 等),需要知道这个端口才能拼出 base URL。
|
||||
|
||||
为什么放在配置里而不是写死
|
||||
--------------------------
|
||||
- 部署方常常会改 Gateway 监听端口(端口被占用、合规要求、容器化端口映射),
|
||||
写死代码意味着每次改端口都得改源码 + 重新构建/分发,**改 config 不需要重启二进制**
|
||||
- ``DEER_FLOW_GATEWAY_PORT`` 环境变量仍然是更高优先级的紧急覆盖通道,适合
|
||||
临时调试或者 docker-compose / k8s 注入
|
||||
|
||||
修改方式(部署侧)
|
||||
------------------
|
||||
在 ``config.yaml`` 顶层加:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
gateway:
|
||||
port: 9999 # 默认 8001;部署机改了 gateway 端口时改这里
|
||||
|
||||
只想临时改一次(不动 yaml)→ 设环境变量:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
export DEER_FLOW_GATEWAY_PORT=9999
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GatewayConfig(BaseModel):
|
||||
"""Gateway 本机 loopback 端口配置。"""
|
||||
|
||||
port: int = Field(
|
||||
default=8001,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description=(
|
||||
"Gateway FastAPI 监听端口。圆桌内部 loopback 调用会拼到 "
|
||||
"``http://127.0.0.1:<port>``。默认 8001(与 Makefile/scripts/start-all.sh 对齐)。"
|
||||
),
|
||||
)
|
||||
+7
-3
@@ -15,6 +15,7 @@ def ask_clarification_tool(
|
||||
],
|
||||
context: str | None = None,
|
||||
options: list[str] | None = None,
|
||||
allow_multiple: bool | None = None,
|
||||
) -> str:
|
||||
"""Ask the user for clarification when you need more information to proceed.
|
||||
|
||||
@@ -50,8 +51,9 @@ def ask_clarification_tool(
|
||||
Best practices:
|
||||
- One question per ask_clarification call — keep each `question` focused on a single decision.
|
||||
- Be specific and clear in your question.
|
||||
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
|
||||
this lets the user multi-select in the UI instead of typing free text.
|
||||
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices.
|
||||
- By default the UI is **single-select** (click an option to send). Pass `allow_multiple=True`
|
||||
only when the user may legitimately pick several options together (e.g. multiple constraints).
|
||||
- Don't make assumptions when clarification is needed.
|
||||
- For risky operations, ALWAYS ask for confirmation.
|
||||
- After calling this tool (or several in one turn), execution will be interrupted automatically.
|
||||
@@ -60,7 +62,9 @@ def ask_clarification_tool(
|
||||
question: The clarification question to ask the user. Focused on a single decision; be specific and clear.
|
||||
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
|
||||
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
|
||||
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from; the UI supports multi-select.
|
||||
options: Optional list of choices. Present clear options for the user to choose from.
|
||||
allow_multiple: When True, the UI allows multi-select plus a submit button. When False or
|
||||
omitted, the UI is single-select and sends immediately on click (default).
|
||||
"""
|
||||
# This is a placeholder implementation
|
||||
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""ask_clarification 工具参数的共享解析(Harness + Gateway)。
|
||||
|
||||
圆桌 Step 1(``intent.py``)与 Step 2 总控(``multi_agent.py``)在 SSE 终态帧里
|
||||
都会带上 ``allow_multiple``,供前端 ``resolveAllowMultiple`` 决定选项卡片是
|
||||
「点击即发送(单选)」还是「多选 + 发送按钮」。
|
||||
|
||||
约定(与前端 ``roundtable-planning/lib/clarification.ts`` 一致):
|
||||
- 模型**未传** ``allow_multiple`` 或传 ``null`` → **False**(单选);
|
||||
- 仅当模型显式传 ``true`` 时才多选;
|
||||
- 不再根据 ``clarification_type`` 推断多选(旧逻辑已移除)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def coerce_bool(value: object | None, *, default: bool) -> bool:
|
||||
"""把 LLM tool args 里可能出现的 bool/数字/字符串统一成 bool。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "yes", "y", "on"):
|
||||
return True
|
||||
if lowered in ("false", "0", "no", "n", "off"):
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def resolve_allow_multiple(args: dict) -> bool:
|
||||
"""是否允许多选。仅 ``args["allow_multiple"]`` 显式为真时返回 True。"""
|
||||
if "allow_multiple" in args and args["allow_multiple"] is not None:
|
||||
return coerce_bool(args["allow_multiple"], default=False)
|
||||
return False
|
||||
Reference in new issue
Block a user