diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index fe1c781d..88da48a8 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -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` 字段对齐。 +- **`` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `...`(含未闭合的流式中段)抽离成 `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:从模型流式文本里提取 `...` 思考过程 +│ ├── 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`(从 `` 拆出来)+ 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` | +| `` 提取工具 | `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` | diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 206c9335..d45eae3f 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.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, + ) => void; } export interface StreamIntentResult { @@ -269,10 +273,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise { + const setPhase = ( + next: IntentStreamPhase, + toolName?: string, + args?: Record, + ) => { 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) : 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) + : 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)) diff --git a/frontend-web/src/roundtable-planning/components/Avatars.tsx b/frontend-web/src/roundtable-planning/components/Avatars.tsx new file mode 100644 index 00000000..9bf46a58 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/Avatars.tsx @@ -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 }) => ( + + + + + + + + + + + + + + + + + + +); + +export const RobotAvatar = ({ + bg = "bg-primary/10", + headColor = "#f5222d", + size = "h-8 w-8", +}: { + bg?: string; + headColor?: string; + size?: string; +}) => ( + + + + + + + + + + + + + +); + +export const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); + +/** 根据字符串类型名挑对应头像。 */ +export function getAvatar(type: string, size: string = "h-8 w-8") { + switch (type) { + case "man": + case "human": + return ; + case "blue": + return ; + case "purple": + return ; + case "emerald": + return ; + case "amber": + return ; + case "pink": + return ; + default: + return ; + } +} diff --git a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx new file mode 100644 index 00000000..92476072 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx @@ -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 决定是 `` / `` / `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 ( +
+ {avatar} +
+
+
+ + {senderName} + + {badge && ( + + {badge} + + )} +
+ {time} +
+ {children} +
+
+ ); +} + +/** + * 流式中、但还没拿到任何 token 时的占位。spinner + label + 三个跳动点, + * 让用户感受到后端在工作而不是页面卡住。 + */ +export function MessageLoadingPlaceholder({ label }: { label?: string }) { + return ( +
+ + {label ?? "正在连接智能体..."} + + + + + +
+ ); +} + +/** 流式过程中末尾追的小竖线动画。 */ +export function StreamingCursor() { + return ; +} + +/** + * 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 }) => ( +

{children}

+ ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + li: ({ children }: { children?: React.ReactNode }) => ( +
  • {children}
  • + ), + h1: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h4: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + code: ({ + children, + className, + }: { + children?: React.ReactNode; + className?: string; + }) => { + const isBlock = /language-/.test(className ?? ""); + if (isBlock) { + return ( +
    +          {children}
    +        
    + ); + } + return ( + + {children} + + ); + }, + blockquote: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
    + ), + a: ({ children, href }: { children?: React.ReactNode; href?: string }) => ( + + {children} + + ), + table: ({ children }: { children?: React.ReactNode }) => ( + {children}
    + ), + th: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + td: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + hr: () =>
    , +}; + +/** + * 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。 + * 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。 + */ +export function MarkdownContent({ children }: { children: string }) { + return ( + + {children} + + ); +} + +/** + * 「思考过程」折叠卡。展示从 `...` 拆出的 reasoning 文本。 + * + * 视觉与交互对齐主聊天 `src/components/ai-elements/reasoning.tsx`: + * - 头部脑图标 + "正在思考..." / "思考完成 (Ns)" 文案 + * - 流式中默认展开,完成后自动延迟收起;用户点击切换后**不再自动收起**(尊重偏好) + * - 内容区灰底框,用 sharedMarkdownComponents 渲染 markdown + */ +export interface ReasoningBlockProps { + /** Reasoning 文本(已由 `splitInlineReasoning` 提取出来,不含 `` 标签)。 */ + 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(streaming); + const userTouchedRef = useRef(false); + const hasAutoClosedRef = useRef(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 ( +
    + + {open && ( +
    + {children} +
    + )} +
    + ); +} diff --git a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx new file mode 100644 index 00000000..e99f8330 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx @@ -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; +} + +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(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 ( +
    + + {display.label} + {lastReasoning.ts} +
    + ); + } + + return ( + + {/* 顶部:折叠/展开「其他 N 个步骤」。仅当真有 prior 步骤时显示。 */} + {priorToolSteps.length > 0 && ( + + )} + + {/* 中段:工具调用步骤。Last 工具始终展开;其它工具按 showAbove 控制。 */} + {lastToolStep && ( + + {showAbove && + priorToolSteps.map((s) => ( + + ))} + + + )} + + {/* 底部:streaming 阶段的「正在思考」标签。 */} + {lastReasoning && ( +
    + +
    + )} +
    + ); +} + +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 ( + + {display.label} + {step.ts} +
    + } + > + {argNode} + + ); +} + +function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boolean }) { + const display = getStepDisplay(step.key, step.label, step.args); + const Icon = display.icon; + return ( +
    + + {display.label} + {step.ts} +
    + ); +} diff --git a/frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx b/frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx new file mode 100644 index 00000000..eb4780b4 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx @@ -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; + setAgentConfigs: React.Dispatch< + React.SetStateAction> + >; + 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 ( +
    + + +
    +
    +
    + {getAvatar(avatarTypeForSeat(seatId), "h-10 w-10")} +
    +
    +

    + {roleName} 人设专有策略调制 +

    + + 系统就绪 + +
    +

    + 配置此席位智能体的运行大语言模型及系统 System Prompt 指令 +

    +
    +
    + +
    + +