多智能体页面结构修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-26 23:20:50 +08:00
1 parent 8e7fc54548
commit 9bd049923e
17 files changed
+5004 -4075

No files matched your search

+97 -22
View File
@@ -8,7 +8,11 @@
## 0. 速读:你需要先知道的事
- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。
- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。
- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。
- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。
- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。
- **`<think>` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `<think>...</think>`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
@@ -44,29 +48,60 @@
```
frontend-web/src/roundtable-planning/
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── pages/
│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行)
│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
├── api/
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置
│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup)
│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props)
│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入)
│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport
│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model)
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
├── hooks/
│ └── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除
├── lib/
│ └── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `<think>...</think>` 思考过程
│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等)
│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染
├── utils/
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
└── styles/
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
```
**模块依赖方向(自上而下)**:
```
pages/RoundtablePlanningPage
├─ uses → hooks/useStep1Intent
├─ uses → hooks/useStep2Orchestration
├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate)
├─ uses → components/Step{1,2,3}Panel (纯 props 驱动)
├─ uses → components/RecommendAgentsDialog
└─ uses → components/Avatars
hooks/useStep1Intent → api/intent
hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型
hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型
```
主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
```tsx
@@ -242,6 +277,16 @@ window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 +
## 5. 主页面状态(`RoundtablePlanningPage`)
> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。
| 维度 | 所有者 | 暴露给主页面的方式 |
|------|--------|-------------------|
| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState |
| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 |
| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 |
| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 |
| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref |
### 5.1 步骤与门禁
- `currentStep`: `1 | 2 | 3`
@@ -411,7 +456,10 @@ isOrchestrating = false
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
- 阶段 UI(`InternalState.phase`):
- `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- `idle` → `loading_agents` → `recommending`(顶部「模型生成内容」区流式;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- **布局分两段**:
- **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `<think>` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。
- **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
@@ -626,13 +674,27 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
- 前端:`agentConfigs[meta.speakerId]` 一并传入
### 12.6 拆分页面
### 12.6 拆分页面(已完成 · 2026-05)
当前刻意单文件维护三步。若拆分建议:
主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
- 保持 `api/*` 不变
- 保持 `roundtable-planning.css` 主题变量
- `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步
- `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清
- `hooks/useDraftPersistence.ts` —— 草稿自动保存 / 手动保存 / 加载 / 重命名 / 删除
- `components/Step{1,2,3}Panel.tsx` —— 纯 UI,只接收 props,不持有业务状态
- `components/PersonaDrawer.tsx` —— Persona 抽屉
- `components/Avatars.tsx` —— 所有 SVG 头像 + getAvatar
- `lib/roundtable-constants.ts` —— 常量、类型、纯工具函数(buildRoleTemplates 等)
- `lib/step-display.tsx` —— ChainOfThought 折叠卡的图标 + 文案 + 子节点渲染
**主页面只保留**:顶栏(StepNavigation / HistoryDropdown)、左右两侧栏(LeftSidebar / RightSidebar)、步骤路由、推荐弹窗,以及编排 hook 与跨步 handler(`handleConfirmAgents` / `handleStartNewTask`)。
继续重构请遵守:
1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。
2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `utils/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改。
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持与重构前同样的 0 个新增错误(预存量 50 个 pre-existing 错误来自其它模块如 `next/link` shim,与本特性无关)。
### 12.7 文档协同
@@ -648,7 +710,20 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
| 资源 | 路径 |
|------|------|
| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 主页面(组合 + 顶栏 + 侧栏) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| Step 1 hook | `frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts` |
| Step 2 hook(含编排循环) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` |
| 草稿 hook | `frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts` |
| Step 1 面板 | `frontend-web/src/roundtable-planning/components/Step1Panel.tsx` |
| Step 2 面板 | `frontend-web/src/roundtable-planning/components/Step2Panel.tsx` |
| Step 3 面板 | `frontend-web/src/roundtable-planning/components/Step3Panel.tsx` |
| 消息气泡共享件 | `frontend-web/src/roundtable-planning/components/MessageBubble.tsx` |
| 步骤折叠卡(工具/思考) | `frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx` |
| `<think>` 提取工具 | `frontend-web/src/roundtable-planning/lib/reasoning.ts` |
| Persona 抽屉 | `frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx` |
| 头像集合 | `frontend-web/src/roundtable-planning/components/Avatars.tsx` |
| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` |
| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
@@ -155,7 +155,11 @@ export interface StreamIntentRequest {
* "正在输出" so users see what's happening during the inevitable LLM
* first-token wait.
*/
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
onPhaseChange?: (
phase: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => void;
}
export interface StreamIntentResult {
@@ -269,10 +273,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
// provided, since the same `tool_calling` phase may surface a new tool).
let currentPhase: IntentStreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: IntentStreamPhase, toolName?: string) => {
const setPhase = (
next: IntentStreamPhase,
toolName?: string,
args?: Record<string, unknown>,
) => {
if (next === currentPhase && !toolName) return;
currentPhase = next;
req.onPhaseChange?.(next, toolName);
req.onPhaseChange?.(next, toolName, args);
};
const result: StreamIntentResult = {
@@ -364,7 +372,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName);
// 把 tool args 一并透传给 UI;onPhaseChange 在 useStep1Intent 里
// 会把 args 累积进 message.steps,让 MessageStepsCard 渲染路径 chip /
// 命令代码块等子节点(与主聊天 MessageGroup 视觉一致)。
const toolArgs =
firstCall && typeof firstCall.args === "object" && firstCall.args !== null
? (firstCall.args as Record<string, unknown>)
: undefined;
setPhase("tool_calling", toolName, toolArgs);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
@@ -0,0 +1,93 @@
/**
* 圆桌规划用到的所有 SVG 头像。
*
* 这些原本散落在主页面里,提到这里集中维护,便于 Step 1 / Step 2 / Step 3
* 与 LeftSidebar、HighFidelityReport 复用。`getAvatar(type, size)` 根据
* 字符串类型名(blue / purple / emerald / amber / pink / human / man)挑对应组件。
*/
export const Man1Avatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<span
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-slate-200/80 shadow-sm`}
>
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#E2E8F0" />
<circle cx="50" cy="50" r="45" fill="#FEEDED" />
<path d="M15 95 C 15 70, 30 65, 50 65 C 70 65, 85 70, 85 95 Z" fill="#1E293B" />
<path d="M40 65 L50 78 L60 65 Z" fill="#FFFFFF" />
<path d="M47 72 L53 72 L51 98 L49 98 Z" fill="#E11D48" />
<path d="M40 65 L40 55 C 40 55, 45 58, 50 58 C 55 58, 60 55, 60 55 L60 65 Z" fill="#FBCFE8" />
<circle cx="50" cy="38" r="18" fill="#FBCFE8" />
<path d="M42 34 Q 45 32 47 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
<path d="M53 34 Q 55 32 58 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="45" cy="39" r="2.5" fill="#0F172A" />
<circle cx="55" cy="39" r="2.5" fill="#0F172A" />
<path d="M30 32 C 30 18, 70 18, 70 32 C 72 26, 68 15, 50 15 C 32 15, 28 26, 30 32 Z" fill="#0F172A" />
<path d="M30 32 C30 32, 28 35, 32 35 C34 35, 36 30, 36 30 Z" fill="#0F172A" />
<path d="M70 32 C70 32, 72 35, 68 35 C66 35, 64 30, 64 30 Z" fill="#0F172A" />
</svg>
</span>
);
export const RobotAvatar = ({
bg = "bg-primary/10",
headColor = "#f5222d",
size = "h-8 w-8",
}: {
bg?: string;
headColor?: string;
size?: string;
}) => (
<span
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-red-100/50 shadow-sm ${bg}`}
>
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="28" width="50" height="42" rx="12" fill={headColor} />
<rect x="32" y="35" width="36" height="24" rx="6" fill="#1E293B" />
<circle cx="42" cy="45" r="4" fill="#F74750" className="animate-pulse" />
<circle cx="58" cy="45" r="4" fill="#F74750" className="animate-pulse" />
<path d="M45 52 Q 50 55 55 52" stroke="#F74750" strokeWidth="2.2" strokeLinecap="round" />
<rect x="20" y="41" width="5" height="16" rx="2" fill="#cf1322" />
<rect x="75" y="41" width="5" height="16" rx="2" fill="#cf1322" />
<line x1="50" y1="28" x2="50" y2="15" stroke={headColor} strokeWidth="4" strokeLinecap="round" />
<circle cx="50" cy="12" r="5" fill="#FA9196" />
</svg>
</span>
);
export const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#cf1322" bg="bg-[var(--brand-200)]" size={size} />
);
export const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#7c3aed" bg="bg-purple-100/40" size={size} />
);
export const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#059669" bg="bg-emerald-100/40" size={size} />
);
export const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#d97706" bg="bg-amber-100/40" size={size} />
);
export const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
<RobotAvatar headColor="#db2777" bg="bg-pink-100/40" size={size} />
);
/** 根据字符串类型名挑对应头像。 */
export function getAvatar(type: string, size: string = "h-8 w-8") {
switch (type) {
case "man":
case "human":
return <Man1Avatar size={size} />;
case "blue":
return <BlueRobotAvatar size={size} />;
case "purple":
return <PurpleRobotAvatar size={size} />;
case "emerald":
return <EmeraldRobotAvatar size={size} />;
case "amber":
return <AmberRobotAvatar size={size} />;
case "pink":
return <PinkRobotAvatar size={size} />;
default:
return <RobotAvatar size={size} />;
}
}
@@ -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>
);
}
@@ -5,8 +5,6 @@ import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
DotIcon,
LightbulbIcon,
Loader2Icon,
RefreshCwIcon,
SparklesIcon,
@@ -21,26 +19,24 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { listAgents, type Agent } from "@/core/agents";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils";
import {
MarkdownContent,
ReasoningBlock,
StreamingCursor,
} from "./MessageBubble";
import { MessageStepsCard } from "./MessageStepsCard";
import type { IntentSummary } from "../api/intent";
import {
filterRecommendCandidates,
parseRecommendStreamContent,
streamRecommend,
type RecommendCandidate,
type RecommendPick,
type RecommendStreamPhase,
} from "../api/recommend";
import { splitInlineReasoning } from "../lib/reasoning";
/**
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
@@ -445,13 +441,30 @@ export function RecommendAgentsDialog({
</DialogDescription>
</DialogHeader>
{/* Rationale + status block */}
<div className="border rounded-md bg-muted/30 p-3 text-sm">
<RationaleBlock state={state} />
</div>
{/* ============================================================
TOP: 模型生成内容
包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
与主聊天消息气泡内容区视觉对齐。
**可折叠** —— 流式中默认展开方便观察生成过程,完成后自动收起一次,
让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。
============================================================ */}
<CollapsibleModelOutput
streaming={state.phase === "recommending"}
phase={state.phase}
>
<RecommenderOutput state={state} />
</CollapsibleModelOutput>
{/* Selection area: scrollable, takes remaining height */}
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
{/* ============================================================
BOTTOM: 推荐的智能体(可勾选)
滚动区,占据剩余高度。
============================================================ */}
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-2 flex items-center gap-1.5 shrink-0">
<CheckIcon className="size-3 text-emerald-500" />
推荐参会智能体
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
{state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"}
@@ -518,7 +531,8 @@ export function RecommendAgentsDialog({
)}
</>
) : null}
</div>
</div>
</section>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<div className="flex items-center gap-3 text-xs">
@@ -558,28 +572,122 @@ export function RecommendAgentsDialog({
);
}
function RationaleBlock({ state }: { state: InternalState }) {
// 默认展开:推荐流程一开始就让用户能看到生成过程。
// 生成完毕后(isStreaming 翻成 false)自动收起 —— 让 UI 聚焦在 picks 选择上。
// userTouchedRef 标记用户是否手动点过按钮:点过就尊重用户偏好,不再自动收起。
const [expanded, setExpanded] = useState(true);
const userTouchedRef = useRef(false);
// 步骤折叠卡:**始终默认收起**,header 上显示最新一步作为预览。
const [stepsOpen, setStepsOpen] = useState(false);
const isStreaming = state.phase === "recommending";
// 流式期间禁用逐字动画——避免不断 re-tokenize 拖慢渲染;完成后回到普通 markdown。
const rehypePlugins = useRehypeSplitWordsIntoSpans(isStreaming);
/**
* 「模型生成内容」整段的可折叠外壳。
*
* 默认行为:
* - 流式期间(`streaming=true`)默认展开,让用户看到实时生成
* - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
* 把焦点让给下方推荐卡片
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
*
* 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」),
* 让用户不用展开就知道当前阶段。
*/
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
// 流式状态切换时,如果用户没有手动调过展开/收起,执行默认行为:
// 流式中 → expanded=true,完成后 → expanded=false。
// 步骤折叠卡不自动跟随 —— 始终默认收起。
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) setExpanded(isStreaming);
}, [isStreaming]);
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">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体...
</div>
@@ -588,8 +696,8 @@ function RationaleBlock({ state }: { state: InternalState }) {
if (state.phase === "error") {
return (
<div className="text-xs text-destructive">
推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。
<div className="text-sm text-destructive">
推荐失败,请使用下方的「重新推荐」重试,或取消后再次进入。
</div>
);
}
@@ -598,23 +706,26 @@ function RationaleBlock({ state }: { state: InternalState }) {
state.phase === "recommending" ||
state.phase === "ready" ||
state.phase === "fallback";
if (!showRationaleStage) {
return null;
}
if (!showRationaleStage) return null;
const streamingLabel =
state.phase === "recommending"
? state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐..."
: null;
const isStreaming = state.phase === "recommending";
// 顶部状态行:跟主聊天的「正在思考...」类似,只是文案换成推荐阶段。
const streamingLabel = isStreaming
? state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐..."
: null;
const hasRationale = !!state.rationale;
// 把 `<think>...</think>` 抽出来作 reasoning,剩下的 rationale 用 markdown 渲染。
// 这与 Step1Panel / Step2Panel 的处理方式完全一致。
const { content: cleanedRationale, reasoning } = splitInlineReasoning(state.rationale);
const hasRationale = cleanedRationale.length > 0;
const hasReasoning = !!reasoning;
return (
<div className="space-y-1.5">
<div className="space-y-2">
{streamingLabel && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />
@@ -622,95 +733,23 @@ function RationaleBlock({ state }: { state: InternalState }) {
</div>
)}
{/* 推荐流程阶段时间线(视觉与 chats/new 消息列表的 ChainOfThought 对齐):
**始终默认收起**,收起 header 上显示最新一步作为预览。
streaming 用 LightbulbIcon,其它阶段用 DotIcon —— 推荐流程没有 tool_calls,
所以不需要 chats 那套完整的工具图标分支。 */}
{state.steps.length > 0 && (() => {
const lastStep = state.steps[state.steps.length - 1];
const LastIcon = lastStep?.key === "streaming" ? LightbulbIcon : DotIcon;
return (
<ChainOfThought
className="rounded-md border border-border/50 bg-muted/30 px-3 py-2"
open={stepsOpen}
onOpenChange={setStepsOpen}
>
<ChainOfThoughtHeader
className="text-[11px]"
icon={
// 仅「生成中 + 收起」时用最新一步的图标作进度预览;完成后用默认脑子图标。
!stepsOpen && isStreaming ? (
<LastIcon className="size-3.5 text-muted-foreground" />
) : undefined
}
>
{stepsOpen
? "步骤"
: isStreaming
? (lastStep?.label ?? `查看 ${state.steps.length} 个步骤`)
: "步骤列表"}
</ChainOfThoughtHeader>
<ChainOfThoughtContent>
{state.steps.map((s, i) => (
<ChainOfThoughtStep
key={s.id}
icon={s.key === "streaming" ? LightbulbIcon : DotIcon}
label={
<span className="text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">{s.label}</span>
<span className="ml-2 text-[10px] font-mono opacity-60">{s.ts}</span>
</span>
}
status={
isStreaming && i === state.steps.length - 1 ? "active" : "complete"
}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
})()}
{state.steps.length > 0 && (
<MessageStepsCard steps={state.steps} streaming={isStreaming} />
)}
{!hasRationale && isStreaming && (
{hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
)}
{!hasRationale && !hasReasoning && isStreaming && (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
{!hasRationale && !isStreaming && (
<div className="text-xs text-muted-foreground">推荐完成。</div>
)}
{hasRationale && (
<>
{/* 按钮在上、内容在下 —— 展开/收起按钮始终留在原位,避免点开时
内容把按钮挤到屏幕外,用户需要滚回去才能再点收起。
收起时完全不渲染内容区,只保留按钮(图标 + "思考过程")。
streamingLabel 已经在上方独立显示阶段进度,这里不重复 spinner。 */}
<button
type="button"
onClick={() => {
userTouchedRef.current = true;
setExpanded((v) => !v);
}}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{expanded ? (
<ChevronUpIcon className="size-3" />
) : (
<ChevronDownIcon className="size-3" />
)}
思考过程
</button>
{expanded && (
<div className="max-h-96 overflow-y-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 text-xs text-foreground leading-relaxed">
<MarkdownContent
content={state.rationale}
isLoading={isStreaming}
rehypePlugins={rehypePlugins}
className="text-xs"
/>
</div>
)}
</>
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<MarkdownContent>{cleanedRationale}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
)}
</div>
);
@@ -0,0 +1,423 @@
/**
* Step 1 主面板 —— 任务意图理解人机问答区。
*
* 主页面把所有 Step 1 状态(`useStep1Intent` 的返回值)+ 输入框 + 按钮处理函数
* 透传过来,这里只关心:对话列表渲染、INTENT_READY 摘要卡、澄清卡片嵌入气泡、
* 输入框 + 发送 / 暂停 / 跳过 / 继续追问 / 进入研讨 等底部按钮。
*
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 2 保持一致。
*
* **不要**在这里再 fetch / 建 thread / 跑 SSE —— 这些逻辑都在 `useStep1Intent`,
* 本组件保持纯展示 + 透传事件。
*/
import { useRef, useState } from "react";
import {
CheckCircle,
Loader2,
MessageSquare,
Pause,
Repeat,
Save,
Send,
Smile,
Sparkles,
X,
} from "lucide-react";
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import {
IntentClarificationCard,
type IntentClarificationSelection,
} from "@/roundtable-planning/components/IntentClarificationCard";
import {
MarkdownContent,
MessageBubble,
MessageLoadingPlaceholder,
ReasoningBlock,
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
import type {
ClarificationPayload,
} from "@/roundtable-planning/api/multi-agent";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
export interface Step1PanelProps {
// ─── State ────────────────────────────────────────────────────
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
isIntentSending: boolean;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<number, IntentClarificationSelection>;
// ─── Setters / Handlers ──────────────────────────────────────
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
>;
onSendMessage: (text: string) => void;
onStopIntent: () => void;
onSkipQuestion: () => void;
onFollowUp: () => void;
onEnterRoundtable: () => void;
onManualSave: () => void;
onTriggerToast: (msg: string) => void;
submitIntentClarification: (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
) => void;
}
export function Step1Panel(props: Step1PanelProps) {
const {
interactiveMessages,
intentThreadId,
intentReady,
isIntentSending,
pendingIntentClarificationMsgId,
intentClarificationSelections,
setIntentClarificationSelections,
onSendMessage,
onStopIntent,
onSkipQuestion,
onFollowUp,
onEnterRoundtable,
onManualSave,
onTriggerToast,
submitIntentClarification,
} = props;
const step1ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
useAutoScroll(step1ScrollRef, [interactiveMessages]);
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>("");
const charactersLeft = inputText.length;
const handleSend = () => {
if (!inputText.trim()) return;
onSendMessage(inputText);
setInputText("");
};
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Dialogue Header */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900">
任务意图理解 (人机问答)
</h3>
</div>
<span className="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
</div>
{/* Chat list */}
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
<div
ref={step1ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white"
>
{interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarification={submitIntentClarification}
/>
))}
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
</div>
{/* Bottom input area */}
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
<textarea
id="input-text-area"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder={
pendingIntentClarificationMsgId
? "也可在此输入自定义回答 (Enter 快速发送)"
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
}
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
maxLength={500}
/>
<div className="flex items-center justify-between border-t border-slate-50 pt-2 text-[#94a3b8]">
<div className="flex items-center gap-1.5">
<button
onClick={() => onTriggerToast("😄 表情包功能仍在微调状态(仅供演示)")}
className="p-1 hover:text-primary hover:bg-slate-50 rounded transition"
>
<Smile className="w-4 h-4" />
</button>
</div>
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
</div>
</div>
<div className="flex items-center justify-between mt-3">
<div className="flex items-center gap-2">
<button
id="send-message-btn"
onClick={handleSend}
disabled={isIntentSending || !intentThreadId}
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
>
{isIntentSending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Send className="w-3 h-3" />}
{isIntentSending ? "发送中" : "发送"}
</button>
{isIntentSending && (
<button
id="pause-intent-btn"
type="button"
onClick={onStopIntent}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition border border-amber-200/40 shadow-sm active:scale-95"
>
<Pause className="w-3 h-3" />
暂停
</button>
)}
<button
id="suggest-followup-btn"
onClick={onFollowUp}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<Repeat className="w-3 h-3 text-slate-500" />
继续追问
</button>
<button
id="skip-question-btn"
onClick={onSkipQuestion}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<X className="w-3 h-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
<button
id="enter-roundtable-btn"
onClick={onEnterRoundtable}
disabled={!intentReady}
title={intentReady ? undefined : "请先完成任务意图澄清"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${
intentReady
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
进入多智能体研讨 &gt;
</button>
</div>
<button
id="save-draft-btn"
onClick={onManualSave}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<Save className="w-3 h-3 text-slate-500" />
保存草稿
</button>
</div>
</div>
</div>
);
}
/**
* 单条 Step 1 消息。
*
* 拆成独立函数避免主 panel 里大量嵌套 JSX。三种正文形态:
* - 流式中、还没首字节 → MessageLoadingPlaceholder
* - agent 消息 → splitIntentReadyBlock 拆出前 / 摘要 / 后,分别渲染
* + IntentClarificationCard(若待澄清)
* - user 消息 → 纯文本
*/
interface Step1MessageProps {
msg: InteractiveMessage;
intentReady: object | null;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<number, IntentClarificationSelection>;
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
>;
isIntentSending: boolean;
submitIntentClarification: (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
) => void;
}
function Step1Message({
msg,
intentReady,
pendingIntentClarificationMsgId,
intentClarificationSelections,
setIntentClarificationSelections,
isIntentSending,
submitIntentClarification,
}: Step1MessageProps) {
const isStreaming = msg.streaming === true;
const isAgent = msg.sender !== "user";
// 先把 `<think>...</think>` 抽出来作 reasoning,剩下的正文再走 INTENT_READY 解析。
// 顺序很关键:若先 split INTENT_READY 再抽 think,think 块如果横跨 INTENT_READY
// 标记会导致 JSON 解析失败。
const rawContent = msg.content ?? "";
const { content: thoughtFreeContent, reasoning } = isAgent
? splitInlineReasoning(rawContent)
: { content: rawContent, reasoning: null };
const hasContent = thoughtFreeContent.length > 0;
const clarification = msg.clarification;
const parts = isAgent
? splitIntentReadyBlock(thoughtFreeContent)
: { before: thoughtFreeContent, summary: null as IntentSummary | null, after: "" };
const isPendingClarification =
isAgent &&
clarification &&
!intentReady &&
!parts.summary &&
pendingIntentClarificationMsgId === msg.id;
const clarificationSelection =
intentClarificationSelections.get(msg.id) ?? {
selectedIds: [],
custom: "",
};
const introText =
clarification &&
parts.before.trim() === (clarification.question || "").trim()
? ""
: parts.before;
// 主聊天里步骤卡放在「正文之上」(气泡内,header 之下),Step 2 也是这样。
// 流式中默认显示 last tool call + 折叠 "查看其他 N 个步骤";完成后保留卡。
const stepsCard =
isAgent && Array.isArray(msg.steps) && msg.steps.length > 0 ? (
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
) : null;
// 「思考过程」折叠卡 —— 流式中默认展开,完成后自动延迟收起。
const reasoningBlock =
isAgent && reasoning ? (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
) : null;
return (
<MessageBubble
avatar={
msg.sender === "user" ? (
<Man1Avatar size="h-9 w-9 shrink-0" />
) : (
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-9 w-9 shrink-0" />
)
}
senderName={msg.senderName}
senderNameClassName={msg.sender === "user" ? "text-primary" : undefined}
badge={isStreaming ? msg.phase : undefined}
time={msg.time}
>
{stepsCard}
{reasoningBlock}
{isStreaming && !hasContent ? (
<MessageLoadingPlaceholder label={msg.phase} />
) : isAgent ? (
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
{introText && <MarkdownContent>{introText}</MarkdownContent>}
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
{isStreaming && <StreamingCursor />}
{isPendingClarification && clarification && (
<IntentClarificationCard
payload={clarification}
fallbackContent={parts.before || undefined}
selection={clarificationSelection}
onSelectionChange={(next) => {
setIntentClarificationSelections((prev) => {
const map = new Map(prev);
map.set(msg.id, next);
return map;
});
}}
disabled={!isPendingClarification}
submitting={isIntentSending && Boolean(isPendingClarification)}
onSubmit={(override) =>
submitIntentClarification(msg.id, clarification, override)
}
/>
)}
</div>
) : (
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
{msg.content}
</div>
)}
</MessageBubble>
);
}
/** [INTENT_READY] 标记块解析出 IntentSummary 后的绿色摘要卡。 */
function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
return (
<div className="mt-1 border border-emerald-200 bg-emerald-50/50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-[10.5px] font-black text-emerald-700 tracking-wide">
任务意图已明确
</span>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
</div>
{summary.constraints?.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.constraints.map((c, i) => (
<li key={i} className="leading-snug">{c}</li>
))}
</ul>
</div>
)}
{summary.assumptions && summary.assumptions.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.assumptions.map((a, i) => (
<li key={i} className="leading-snug">{a}</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,356 @@
/**
* Step 2 主面板 —— 多智能体圆桌研讨。
*
* 包含 4 块:
* 1) 顶部 Headline + 挂起/恢复按钮
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
*
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 1 保持一致。
*
* 圆桌可视化 + 博弈边界调控台目前**未启用**(原代码用 `false &&` 隐藏),
* 这里保留同样的占位逻辑,后续要恢复时改 `SHOW_VISUALIZER` 即可。
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useRef } from "react";
import {
Cpu,
HelpCircle,
Loader2,
Pause,
Play,
Sparkles,
} from "lucide-react";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import {
MarkdownContent,
MessageBubble,
MessageLoadingPlaceholder,
ReasoningBlock,
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type {
AgentConfig,
Step2Dialogue,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type {
RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
interface ModelOption {
name: string;
display_name?: string;
}
export interface Step2PanelProps {
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
customAgentInput: string;
hasConsensus: boolean;
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
// ─── Setters ──────────────────────────────────────────────────
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>>
>;
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ─────────────────────────────────────────────────
onSubmitClarification: (answer: string) => void;
onInjectToRoundtable: () => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
onTriggerToast: (msg: string) => void;
}
export function Step2Panel(props: Step2PanelProps) {
const {
step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
isOrchestrating,
pendingClarification,
clarificationDraft,
customAgentInput,
hasConsensus,
agentConfigs,
selectedAgentId,
roles,
selectedModel,
availableModels,
modelsLoading,
setIsRoundTableRunning,
setClarificationDraft,
setCustomAgentInput,
setAgentConfigs,
setSelectedAgentId,
onSubmitClarification,
onInjectToRoundtable,
onSavePersona,
onProceedToStep3,
onTriggerToast,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
return (
<div className="flex-1 flex flex-col min-h-0 bg-slate-50/20">
{/* Headline */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[10px] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
</div>
</div>
<div className="flex items-center gap-2.5">
{isRoundTableRunning && isOrchestrating ? (
<button
onClick={() => {
setIsRoundTableRunning(false);
onTriggerToast("⏸️ 会商模拟暂挂!智能体挂起...");
}}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
>
<Pause className="w-3.5 h-3.5" />
挂起协商
</button>
) : !isRoundTableRunning ? (
<button
onClick={() => {
setIsRoundTableRunning(true);
onTriggerToast("▶️ 恢复大模型并行博弈与推理决策");
}}
className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
>
<Play className="w-3.5 h-3.5 fill-emerald-800" />
恢复会商
</button>
) : null}
</div>
</div>
{/* Persona drawer — 点圆桌头像后展开。当前圆桌可视化用 `false &&` 隐藏,
所以入口只剩 LeftSidebar 或将来重新启用的可视化按钮。 */}
{selectedAgentId && (
<PersonaDrawer
seatId={selectedAgentId}
roles={roles}
agentConfigs={agentConfigs}
setAgentConfigs={setAgentConfigs}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
onClose={() => setSelectedAgentId(null)}
onSave={onSavePersona}
/>
)}
{/* Conversation log */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={step2ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
>
{isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
</div>
)}
{step2RoundtableDialogues.map((msg) => (
<Step2Message key={msg.id} msg={msg} />
))}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
</div>
{/* Clarification card */}
{pendingClarification && (
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
<div className="flex items-start gap-3">
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{pendingClarification.clarificationType && (
<span className="text-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{pendingClarification.clarificationType}
</span>
)}
</div>
{pendingClarification.context && (
<p className="text-[11px] text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
pendingClarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{pendingClarification.options.map((opt, idx) => {
const label =
typeof opt === "string"
? opt
: opt && typeof opt === "object" && "label" in (opt as object)
? String((opt as { label: unknown }).label ?? "")
: String(opt ?? "");
if (!label) return null;
return (
<button
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(label)}
className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
);
})}
</div>
)}
{pendingClarification.allowCustom !== false && (
<div className="flex items-center gap-2 pt-1">
<input
type="text"
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmitClarification(clarificationDraft);
}}
placeholder="输入你的回答…"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
/>
<button
type="button"
onClick={() => onSubmitClarification(clarificationDraft)}
disabled={!clarificationDraft.trim()}
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
>
回复
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
<input
type="text"
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onInjectToRoundtable();
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
/>
<button
onClick={onInjectToRoundtable}
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预研审
</button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
* - 流式无内容的 loading 占位
* - markdown 正文 + 流式 cursor
*
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
*/
interface Step2MessageProps {
msg: Step2Dialogue;
}
function Step2Message({ msg }: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
// 用户「插话干预」/「澄清回复」这类系统注入的气泡内容里基本不会有 think 标签,
// 但 splitInlineReasoning 对空 reasoning 文本返回 null,放心套用就好。
const rawContent = msg.content ?? "";
const { content, reasoning } = splitInlineReasoning(rawContent);
const hasContent = content.length > 0;
return (
<MessageBubble
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
senderName={msg.sender}
badge={msg.confidence}
time={msg.time}
containerClassName={
isInjected
? "bg-[var(--brand-200)]/30 p-3 rounded-2xl border border-red-100/50"
: undefined
}
>
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
)}
{reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
<MarkdownContent>{content}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
</MessageBubble>
);
}
@@ -0,0 +1,61 @@
/**
* Step 3 主面板 —— 方案结果展示。
*
* 当前是 `HighFidelityReport` 的薄壳;放它在这里只是为了让 Step 1 / Step 2 /
* Step 3 三步在主页面里以同样的导入风格出现,方便阅读。
*
* Step 3 真实逻辑请改 `HighFidelityReport.tsx` —— 包括各席位 artifact
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
*/
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
export interface Step3PanelProps {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
hasConsensus: boolean;
step2RoundtableDialogues: Step2Dialogue[];
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
onReset: () => void;
}
export function Step3Panel(props: Step3PanelProps) {
const {
threadIds,
coordinatorName,
consensusText,
hasConsensus,
step2RoundtableDialogues,
agentNameToRole,
onReset,
} = props;
const seatDeliveries = step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
content: String(d.content ?? ""),
}));
const seatNameMap = Object.fromEntries(
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
);
return (
<HighFidelityReport
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
hasConsensus={hasConsensus}
seatDeliveries={seatDeliveries}
seatNameMap={seatNameMap}
onReset={onReset}
/>
);
}
@@ -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
@@ -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