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/open-canvas/components/ai-writing/OutlineSection.tsx b/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx index b4dfeeec..b4d2af8f 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { ChevronRight, Plus, X, GripVertical } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing'; +import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing'; +import { normalizeKeyPoint } from '../../types/ai-writing'; import { CollapseTransition } from './CollapseTransition'; interface OutlineSectionProps { @@ -10,7 +11,7 @@ interface OutlineSectionProps { onChange: (updated: OutlineSectionType) => void; onRemove?: () => void; readOnly?: boolean; - // Drag-and-drop + // Drag-and-drop (章节级别) isDragging?: boolean; isOver?: boolean; onDragStart?: () => void; @@ -19,6 +20,14 @@ interface OutlineSectionProps { onDragEnd?: () => void; } +/** + * 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染 + * subPoints[] 为 bullet 列表(Level 3)。 + * + * 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过 + * normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户 + * 编辑过的旧 session 大纲会被持久化为新格式)。 + */ export function OutlineSection({ section, index, onChange, onRemove, readOnly, isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd, @@ -27,6 +36,13 @@ export function OutlineSection({ const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(section.sectionTitle); + // 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render + // 都生成新引用导致子组件无谓重渲染。 + const keyPoints: KeyPoint[] = useMemo( + () => (section.keyPoints ?? []).map(normalizeKeyPoint), + [section.keyPoints], + ); + function commitTitle() { setEditingTitle(false); const trimmed = titleDraft.trim(); @@ -37,20 +53,23 @@ export function OutlineSection({ } } - function updateKeyPoint(i: number, value: string) { - const next = [...section.keyPoints]; - next[i] = value; + function updateKeyPoint(i: number, updated: KeyPoint) { + const next = [...keyPoints]; + next[i] = updated; onChange({ ...section, keyPoints: next }); } function removeKeyPoint(i: number) { - onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) }); + onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) }); } function addKeyPoint() { - onChange({ ...section, keyPoints: [...section.keyPoints, ''] }); + onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] }); } + // 子要点总数,显示在章节头给个粗略概览 + const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0); + return (
)} - {section.keyPoints.length} 要点 + + {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''} + {!readOnly && onRemove && (
- {/* 要点列表 */} + {/* 小节列表 (Level 2) */} -
- {section.keyPoints.map((kp, i) => ( -
- • +
+ {keyPoints.map((kp, i) => ( + updateKeyPoint(i, updated)} + onRemove={readOnly ? undefined : () => removeKeyPoint(i)} + readOnly={readOnly} + /> + ))} + {!readOnly && ( + + )} +
+ +
+ ); +} + +// ─── 小节行(Level 2,内嵌 Level 3 子要点) ─────────────────────────────────────── +interface KeyPointRowProps { + keyPoint: KeyPoint; + onChange: (updated: KeyPoint) => void; + onRemove?: () => void; + readOnly?: boolean; +} + +function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) { + const [expanded, setExpanded] = useState(false); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle); + + function commitTitle() { + setEditingTitle(false); + const trimmed = titleDraft.trim(); + if (trimmed !== keyPoint.pointTitle) { + onChange({ ...keyPoint, pointTitle: trimmed }); + } + } + + function updateSubPoint(i: number, value: string) { + const next = [...keyPoint.subPoints]; + next[i] = value; + onChange({ ...keyPoint, subPoints: next }); + } + + function removeSubPoint(i: number) { + onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) }); + } + + function addSubPoint() { + onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] }); + setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框 + } + + return ( +
+ {/* 小节头 */} +
+ + + ### + + {editingTitle && !readOnly ? ( + setTitleDraft(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle(); + if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); } + }} + className="flex-1 bg-transparent border-b border-primary outline-none" + placeholder="小节标题" + /> + ) : ( + !readOnly && setEditingTitle(true)} + > + {keyPoint.pointTitle || (未命名小节)} + + )} + + + {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'} + + {!readOnly && onRemove && ( + + )} +
+ + {/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */} + +
+ {keyPoint.subPoints.map((sp, i) => ( +
+ {/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */} + #### {readOnly ? ( - {kp} + {sp} ) : ( updateKeyPoint(i, e.target.value)} - placeholder="输入要点…" + value={sp} + onChange={(e) => updateSubPoint(i, e.target.value)} + placeholder="子要点标题(会展开成一段正文)" className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" /> )} {!readOnly && ( - )} @@ -138,11 +286,11 @@ export function OutlineSection({ {!readOnly && ( )}
diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 76fac065..e73e703c 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -13,10 +13,25 @@ export interface AIMaterial { } // ── 大纲 ───────────────────────────────────────────────────────────────────── +// 三级结构:章节(##) → 小节(###) → 子要点(bullet) +// AIOutline.sections[] = Level 1 章节(渲染为 ## 由后端注入) +// OutlineSection.keyPoints[] = Level 2 小节(渲染为 ###) +// KeyPoint.subPoints[] = Level 3 子要点(正文里的 bullet 列表) +// +// 历史兼容:旧版 keyPoints: string[](2 级)。后端 _parse_outline 已经在解析时把 +// 旧 string 自动 lift 成 KeyPoint;前端类型只保留新格式,但 UI 渲染层做 type +// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。 + +export interface KeyPoint { + pointTitle: string; // 小节标题(渲染为 ### 三级标题) + subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现) +} export interface OutlineSection { sectionTitle: string; - keyPoints: string[]; + // 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。 + // 新生成的大纲一律 KeyPoint[]。 + keyPoints: Array; materialIds: string[]; } @@ -25,6 +40,32 @@ export interface AIOutline { sections: OutlineSection[]; } +/** + * 把 keyPoints 单项规整成 KeyPoint。兼容三种输入: + * - 新版 KeyPoint object + * - 旧版 string (lift 为 { pointTitle: , subPoints: [] }) + * - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常) + */ +export function normalizeKeyPoint(raw: unknown): KeyPoint { + if (typeof raw === 'string') { + return { pointTitle: raw, subPoints: [] }; + } + if (raw && typeof raw === 'object') { + const r = raw as Record; + const title = + typeof r.pointTitle === 'string' ? r.pointTitle : + typeof r.point_title === 'string' ? r.point_title : + typeof r.title === 'string' ? r.title : + ''; + const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown; + const subPoints: string[] = Array.isArray(subsRaw) + ? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean) + : []; + return { pointTitle: title, subPoints }; + } + return { pointTitle: '', subPoints: [] }; +} + // ── 素材不足求助 ────────────────────────────────────────────────────────────── /** 严格模式下素材不足、待用户决策的章节 */ 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/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 6c658f21..a7ac2f31 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -368,8 +368,8 @@ export const HighFidelityReport: React.FC = ({ )} - {/* Section 3: Generated files per seat */} -
+ {/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */} +

生成的报告文件 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 指令 +

    +
    +
    + +
    + +