Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy
This commit is contained in:
commit
cfd0faf5e7
35 files changed
+6499
-3823
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -1535,3 +1535,673 @@ Hover 弹出 Tooltip(沿用 §9.3.3 Tooltip 配色),承载完整说明文
|
||||
--history-sep: #4A6491;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 选择器(Select / Multi-Select / Cascader)
|
||||
|
||||
> 对应设计稿「多项选择.png」。覆盖**多项选择**与**多项带搜索**两种主要变体(基础单选选择器同规格,仅触发器不渲染 Tag、面板项不带复选框;级联选择器原则相同,差异在面板分栏横向铺开)。配色全部从 §1.1.3 渐蓝系(ZS-002)与 §1.1.5 深蓝系(ZS-004)取,与 §14 搜索框完全对齐。
|
||||
|
||||
### 15.1 公共规格
|
||||
|
||||
#### 15.1.1 触发器(Trigger)
|
||||
|
||||
触发器即「请选择 ▾」或「已选 Tag … ▾」那一行。尺寸、边框、状态机与 §14.1 输入框完全对齐:
|
||||
|
||||
| 属性 | 取值 | 来源 |
|
||||
|------|------|------|
|
||||
| 高度 | `32px` | §14.1 |
|
||||
| 圆角 | `2px` | §4.1 |
|
||||
| 水平 padding | `--spacing-3` (12px) | §7.1 |
|
||||
| 背景 | `#0C1836` (ZS-004-09) | — |
|
||||
| 边框 | `1px solid #1A2F5A` (ZS-004-07) | — |
|
||||
| 占位文字 "请选择" | `14px Regular`,`#6E88B0` (ZS-004-04) | — |
|
||||
| 右侧箭头 `▾` | `12px`,`#6E88B0`,hover `#4096FF` | ZS-002-05 |
|
||||
|
||||
边框状态机(默认 / hover / focus / 失效 / 错误)直接复用 §14.1.1。
|
||||
|
||||
#### 15.1.2 下拉面板(Panel)
|
||||
|
||||
面板沿用 §14.4.4 联想下拉的容器规格,仅项内容不同:
|
||||
|
||||
| 属性 | 取值 |
|
||||
|------|------|
|
||||
| 背景 | `#122347` (ZS-004-08) |
|
||||
| 边框 | `1px solid #1A2F5A` (ZS-004-07) |
|
||||
| 阴影 | `0 4px 16px rgba(6, 14, 36, 0.6)`(§3.1.4 三级阴影) |
|
||||
| 圆角 | `2px` |
|
||||
| 项高度 | `36px` |
|
||||
| 项水平 padding | `--spacing-3` (12px) |
|
||||
| 项字号 | `14px Regular`,`#C5CFE0` (ZS-004-02) |
|
||||
| 面板宽度 | 与触发器等宽(搜索框、底部按钮均内嵌等宽) |
|
||||
| 面板与触发器纵向间距 | `--spacing-1` (4px) |
|
||||
|
||||
#### 15.1.3 项状态
|
||||
|
||||
| 状态 | 背景 | 文字 |
|
||||
|------|------|------|
|
||||
| 默认 | `transparent` | `#C5CFE0` (ZS-004-02) |
|
||||
| Hover / 键盘焦点 | `rgba(22,119,255,0.16)` 或 `#1A2F5A` | `#FFFFFF` |
|
||||
| 已选中 | `transparent`(依赖左侧复选框示态,不改行底色) | `#FFFFFF` |
|
||||
| 禁用 | `transparent` | `#4A6491` (ZS-004-05) |
|
||||
|
||||
点击行的任意位置等价于点击复选框(命中区 = 整行)。
|
||||
|
||||
---
|
||||
|
||||
### 15.2 多项选择
|
||||
|
||||
> 触发器内渲染**已选 Tag 列表**而不是单行文字;面板项左侧带**复选框**,多选不关闭面板。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ n1 × n2 × +1 × ▾ │ ← 触发器(已选 Tag)
|
||||
└─────────────────────────────────────┘
|
||||
┌─────────────────────────────────────┐ ← 下拉面板
|
||||
│ ☑ n1 │
|
||||
│ ☐ n2 │
|
||||
│ ☐ n3 │
|
||||
│ ☐ n4 │
|
||||
│ ☐ n5 │
|
||||
├─────────────────────────────────────┤
|
||||
│ [+ 添加项] │ ← 可选:底部「添加」按钮(§15.2.4)
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 15.2.1 已选 Tag
|
||||
|
||||
触发器内每个已选项渲染为一颗 Tag。**实心主色蓝底 + 白字**,右侧带关闭按钮 `×`。
|
||||
|
||||
| 元素 | 色值 / 规格 | 来源 |
|
||||
|------|-------------|------|
|
||||
| 背景 | `#1677FF` (ZS-002-06) | — |
|
||||
| 文字 | `12px Regular`,`#FFFFFF` | — |
|
||||
| 圆角 | `2px` | — |
|
||||
| 内边距 | `2px 6px` | — |
|
||||
| Tag 间距 | `--spacing-1` (4px) | §7.1 |
|
||||
| 关闭按钮 `×` | `12px`,`rgba(255,255,255,0.72)`,hover `#FFFFFF` | — |
|
||||
| 折叠展示 `+N ×` | 当选中超过宽度可容纳时,把剩余 N 项收成一颗 `+N ×` Tag。Hover 显示 Tooltip 列出剩余项名;点击 `×` 清空溢出选项 | — |
|
||||
|
||||
#### 15.2.2 复选项
|
||||
|
||||
| 元素 | 色值 / 规格 | 来源 |
|
||||
|------|-------------|------|
|
||||
| 复选框尺寸 | `14px × 14px` | — |
|
||||
| 复选框圆角 | `2px` | — |
|
||||
| 未选边框 | `1px solid #6E88B0` (ZS-004-04) | — |
|
||||
| 未选背景 | `transparent` | — |
|
||||
| 已选填充 | `#1677FF` (ZS-002-06) | — |
|
||||
| 已选勾 ✓ | `#FFFFFF`,描边 `2px` | — |
|
||||
| 半选填充 | `#1677FF` | — |
|
||||
| 半选指示 | 中央横线 `width 8px / height 2px / #FFFFFF` | — |
|
||||
| Hover 边框 | `#4096FF` (ZS-002-05) | — |
|
||||
| 禁用边框 | `#4A6491` (ZS-004-05) + 背景 `rgba(22,119,255,0.04)` | — |
|
||||
| 复选框 → 文字间距 | `--spacing-2` (8px) | §7.1 |
|
||||
|
||||
#### 15.2.3 全选模式(顶部「全部」节点)
|
||||
|
||||
面板首行可选地渲染一个「全部」聚合节点,作用类似一次性勾 / 取消所有项:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ ▢ 全部 │ ← 半选 / 未选 / 全选三态
|
||||
│ ☑ n1 │
|
||||
│ ☐ n2 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 子状态 | 复选框外观 | 触发动作 |
|
||||
|--------|-----------|----------|
|
||||
| 一个都没选 | 空心,边框 `#6E88B0` | 点击 → 勾上**全部**子项 |
|
||||
| 部分选中 | 实心 `#1677FF` + 中央 `2px` 白色横线(半选) | 点击 → 勾上**全部**剩余未选 |
|
||||
| 全部选中 | 实心 `#1677FF` + 白色勾 ✓ | 点击 → 清空全部 |
|
||||
|
||||
文字 "全部" 与下方项同色 `#C5CFE0`,不加粗。
|
||||
|
||||
#### 15.2.4 底部「+ 添加项」按钮
|
||||
|
||||
面板底部可选地嵌入一颗虚线主色按钮,常用于「在已知池子里允许用户**就地新增**一项」(如标签、自定义分类):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [+ 正常] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 色值 / 规格 | 来源 |
|
||||
|------|-------------|------|
|
||||
| 高度 | `32px`(与复选项行齐) | — |
|
||||
| 边框 | `1px dashed #1677FF` (ZS-002-06) | — |
|
||||
| 背景 | `transparent` | — |
|
||||
| 文字 | `14px Regular`,`#4096FF` (ZS-002-05) | — |
|
||||
| 前缀图标 `+` | `12px`,与文字同色 | — |
|
||||
| Hover | 背景 `rgba(22,119,255,0.16)`,文字 `#1677FF` | ZS-002-05 → ZS-002-06 |
|
||||
| Active | 背景 `rgba(22,119,255,0.24)`,文字 `#FFFFFF` | — |
|
||||
| 与最后一条复选项纵向分隔 | 上方分割线 `1px solid #1A2F5A` + `--spacing-1` (4px) 间距 | §6.1 |
|
||||
|
||||
点击行为由业务自行实现(如弹出输入框、内联编辑)。
|
||||
|
||||
---
|
||||
|
||||
### 15.3 多项带搜索
|
||||
|
||||
> 在 §15.2 基础上,**面板顶部嵌入一行搜索框**,输入时实时过滤复选项列表。其它结构(已选 Tag / 复选项 / 全选 / 底部添加)完全沿用 §15.2。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ n1 × +1 × ▾ │
|
||||
└─────────────────────────────────────┘
|
||||
┌─────────────────────────────────────┐
|
||||
│ [ 搜索 🔍 ] │ ← 嵌入式搜索框(§15.3.1)
|
||||
├─────────────────────────────────────┤
|
||||
│ ▢ 全部 │
|
||||
│ ☑ n1 │
|
||||
│ ☐ n2 │
|
||||
│ ☐ n3 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 15.3.1 嵌入式搜索框
|
||||
|
||||
搜索框尺寸采用 §14.1 公共规格的**小号**(高度 28px),保持与面板视觉上的"嵌入感":
|
||||
|
||||
| 属性 | 取值 |
|
||||
|------|------|
|
||||
| 高度 | `28px`(小号) |
|
||||
| 水平 padding | `--spacing-2` (8px) |
|
||||
| 圆角 | `2px` |
|
||||
| 背景 | `#0C1836` (ZS-004-09,比面板背景深一档) |
|
||||
| 边框 | `1px solid #1A2F5A` (ZS-004-07) |
|
||||
| 文字 | `14px Regular`,`#C5CFE0` |
|
||||
| 占位符 | "搜索",`#6E88B0` |
|
||||
| 右侧 🔍 / ⊗ 图标 | `14px`,`#6E88B0`,hover `#4096FF` |
|
||||
| 搜索框 → 列表项纵向间距 | `--spacing-2` (8px) + 分割线 `1px solid #1A2F5A` |
|
||||
|
||||
输入非空时右侧图标切换为 `⊗`(清空),点击清空输入并恢复完整列表。
|
||||
|
||||
#### 15.3.2 加载与空状态
|
||||
|
||||
异步获取选项(如远程搜索)时,列表区按以下规则渲染:
|
||||
|
||||
| 状态 | 渲染 |
|
||||
|------|------|
|
||||
| 加载中 | 文字 "加载中…",单行居左,`14px Regular`,`#6E88B0`,行高 `32px` |
|
||||
| 无匹配 | 文字 "无匹配项",同上 |
|
||||
| 网络错误 | 文字 "加载失败,点击重试",`#FF7875` (ZS-001-04),可点击 |
|
||||
|
||||
---
|
||||
|
||||
### 15.4 选用决策
|
||||
|
||||
| 场景 | 选择 |
|
||||
|------|------|
|
||||
| 候选池 ≤ 10 项,单值 | §15.1 公共规格 + 去掉复选框(基础单选) |
|
||||
| 候选池 ≤ 10 项,多值 | §15.2 多项选择 |
|
||||
| 候选池 > 10 项 / 不可枚举 | §15.3 多项带搜索 |
|
||||
| 候选池有层级关系(如部门 / 区域) | 级联选择器:每级独立面板横向铺开,单级内部规格同 §15.1~15.3 |
|
||||
| 允许用户**新增不在候选池**的项 | 在 §15.2 / §15.3 末尾启用 §15.2.4「+ 添加项」按钮 |
|
||||
|
||||
---
|
||||
|
||||
### 15.5 CSS Token 参考
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 触发器 —— 复用 §14 搜索框 */
|
||||
--select-trigger-bg: var(--search-bg); /* #0C1836 */
|
||||
--select-trigger-border: var(--search-border); /* #1A2F5A */
|
||||
--select-trigger-placeholder: var(--search-placeholder); /* #6E88B0 */
|
||||
--select-trigger-arrow: #6E88B0;
|
||||
--select-trigger-arrow-hover: #4096FF;
|
||||
|
||||
/* 下拉面板 —— 复用 §14.4.4 联想 */
|
||||
--select-panel-bg: var(--suggest-bg); /* #122347 */
|
||||
--select-panel-border: #1A2F5A;
|
||||
--select-item-text: var(--suggest-item-text); /* #C5CFE0 */
|
||||
--select-item-hover-bg: var(--suggest-item-hover); /* rgba(22,119,255,0.16) */
|
||||
--select-item-hover-text: var(--suggest-item-hover-text); /* #FFFFFF */
|
||||
--select-item-disabled: #4A6491; /* ZS-004-05 */
|
||||
|
||||
/* 已选 Tag */
|
||||
--select-tag-bg: #1677FF; /* ZS-002-06 */
|
||||
--select-tag-text: #FFFFFF;
|
||||
--select-tag-close: rgba(255,255,255,0.72);
|
||||
--select-tag-close-hover: #FFFFFF;
|
||||
|
||||
/* 复选框 */
|
||||
--check-border: #6E88B0; /* ZS-004-04 */
|
||||
--check-border-hover: #4096FF; /* ZS-002-05 */
|
||||
--check-fill: #1677FF; /* ZS-002-06 */
|
||||
--check-mark: #FFFFFF;
|
||||
--check-disabled-border: #4A6491; /* ZS-004-05 */
|
||||
--check-disabled-bg: rgba(22,119,255,0.04);
|
||||
|
||||
/* 底部「+ 添加项」 */
|
||||
--select-add-border: #1677FF; /* ZS-002-06 */
|
||||
--select-add-text: #4096FF; /* ZS-002-05 */
|
||||
--select-add-hover-bg: rgba(22,119,255,0.16);
|
||||
--select-add-hover-text: #1677FF;
|
||||
--select-add-active-bg: rgba(22,119,255,0.24);
|
||||
--select-add-active-text: #FFFFFF;
|
||||
|
||||
/* 加载 / 错误 */
|
||||
--select-loading-text: #6E88B0;
|
||||
--select-error-text: #FF7875; /* ZS-001-04 */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 单选与多选(Radio / Checkbox)
|
||||
|
||||
> 对应设计稿「多选框.png」。本章定义独立作为表单字段使用的**单选(Radio)**和**多选(Checkbox)**两类原子控件,包含**未选 / 已选 / 未选禁用 / 已选禁用**四种状态。§15.2.2「多项选择的复选项」中的复选框直接复用本章 §16.3 规格,本章是单一真源。
|
||||
|
||||
### 16.1 公共规格
|
||||
|
||||
| 属性 | 取值 |
|
||||
|------|------|
|
||||
| 控件尺寸 | `14px × 14px` |
|
||||
| 控件描边宽度 | `1px` |
|
||||
| 控件 → 文字间距 | `--spacing-2` (8px)(§7.1) |
|
||||
| 文字字号 | `14px Regular` |
|
||||
| 行高 | `20px`(点击命中区与文字垂直对齐) |
|
||||
| 同一组**横向相邻**项的间距 | `--spacing-6` (24px)(§7.1) |
|
||||
| 同一组**纵向相邻**项的间距 | `--spacing-3` (12px)(§7.1) |
|
||||
|
||||
整行(控件 + 文字)任意位置可点击 = 等效切换该项状态。
|
||||
|
||||
---
|
||||
|
||||
### 16.2 单选(Radio)
|
||||
|
||||
> 一组互斥选项中只能选中一个。视觉为圆形描边 + 内圆实心点。
|
||||
|
||||
```
|
||||
○ 未选 ● 已选 ○ 未选禁用 ● 已选禁用
|
||||
(整组降饱和)
|
||||
```
|
||||
|
||||
#### 16.2.1 外观规格
|
||||
|
||||
| 状态 | 外环 | 内圆 | 文字 |
|
||||
|------|------|------|------|
|
||||
| 未选 | `1px solid #4096FF` (ZS-002-05) + 背景 `transparent` | — | `#C5CFE0` (ZS-004-02) |
|
||||
| 未选 Hover | `1px solid #1677FF` (ZS-002-06) | — | `#FFFFFF` |
|
||||
| 已选 | `1px solid #1677FF` (ZS-002-06) | 中心实心圆 `6px × 6px`,`#1677FF` | `#FFFFFF` |
|
||||
| 已选 Focus | 外环外加 `0 0 0 2px rgba(22,119,255,0.16)` 焦点环 | — | `#FFFFFF` |
|
||||
| 未选禁用 | `1px solid #4A6491` (ZS-004-05) + 背景 `rgba(22,119,255,0.04)` | — | `#4A6491` |
|
||||
| 已选禁用 | `1px solid #4A6491` (ZS-004-05) | 中心实心圆 `6px × 6px`,`#4A6491` | `#4A6491` |
|
||||
|
||||
外环与内圆均为正圆(`border-radius: 50%`)。
|
||||
|
||||
#### 16.2.2 与 label 组合使用
|
||||
|
||||
「基本使用-组合」场景:选中某 Radio 项后,**下方展开关联输入字段**(如 Antd 的 Form.Item 嵌入式组合)。
|
||||
|
||||
```
|
||||
● 已选
|
||||
|
||||
lable: [ ]
|
||||
lable: [ ]
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| Radio → 下方第一行 label 纵向间距 | `--spacing-3` (12px)(§7.1) |
|
||||
| 嵌入字段左缩进 | 与 Radio 文字左边缘对齐(即外环宽 14px + 间距 8px = **22px 左缩进**) |
|
||||
| 嵌入字段的 label 与 input | 沿用 §14.1 输入框规格 |
|
||||
| 嵌入区分组背景 | 可选地用 `#1A2F5A` (ZS-004-07) 作浅色卡片底色,强化"属于该 Radio"的视觉归属 |
|
||||
|
||||
---
|
||||
|
||||
### 16.3 多选(Checkbox)
|
||||
|
||||
> 多个选项可同时选中,互不影响。视觉为方形描边 + 内填勾 ✓。
|
||||
|
||||
```
|
||||
☐ 未选 ☑ 已选 ☐ 未选禁用 ☑ 已选禁用
|
||||
(整组降饱和)
|
||||
```
|
||||
|
||||
#### 16.3.1 外观规格
|
||||
|
||||
| 状态 | 边框 / 背景 | 勾 ✓ | 文字 |
|
||||
|------|------------|------|------|
|
||||
| 未选 | `1px solid #4096FF` (ZS-002-05) + 背景 `transparent` | — | `#C5CFE0` (ZS-004-02) |
|
||||
| 未选 Hover | `1px solid #1677FF` (ZS-002-06) | — | `#FFFFFF` |
|
||||
| 已选 | 实心填充 `#1677FF` (ZS-002-06) | `#FFFFFF`,描边 `2px` | `#FFFFFF` |
|
||||
| 已选 Focus | 外加 `0 0 0 2px rgba(22,119,255,0.16)` 焦点环 | `#FFFFFF` | `#FFFFFF` |
|
||||
| 半选 | 实心填充 `#1677FF` (ZS-002-06) | 中央横线 `8px × 2px`,`#FFFFFF` | `#FFFFFF` |
|
||||
| 未选禁用 | `1px solid #4A6491` (ZS-004-05) + 背景 `rgba(22,119,255,0.04)` | — | `#4A6491` |
|
||||
| 已选禁用 | 实心填充 `#4A6491` (ZS-004-05) | `#C5CFE0` 或 `rgba(255,255,255,0.4)` | `#4A6491` |
|
||||
|
||||
复选框统一圆角 `2px`(§4.1)。
|
||||
|
||||
#### 16.3.2 与 label 组合使用
|
||||
|
||||
与 §16.2.2 完全一致 —— Checkbox 选中后下方展开嵌入式输入字段,缩进与间距规格相同(左缩进 22px、纵向间距 12px)。
|
||||
|
||||
```
|
||||
☑ 已选
|
||||
|
||||
lable: [ ]
|
||||
lable: [ ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 16.4 状态对照表
|
||||
|
||||
| 状态 | Radio 外环 | Radio 内圆 | Checkbox 外框 | Checkbox 勾 | 文字 |
|
||||
|------|-----------|-----------|--------------|------------|------|
|
||||
| 未选 | `#4096FF` 描边 | — | `#4096FF` 描边 | — | `#C5CFE0` |
|
||||
| Hover | `#1677FF` 描边 | — | `#1677FF` 描边 | — | `#FFFFFF` |
|
||||
| 已选 | `#1677FF` 描边 | `#1677FF` 6px 圆 | `#1677FF` 填充 | `#FFFFFF` ✓ | `#FFFFFF` |
|
||||
| 半选(仅 Checkbox) | — | — | `#1677FF` 填充 | `#FFFFFF` 横线 | `#FFFFFF` |
|
||||
| Focus | + `0 0 0 2px rgba(22,119,255,0.16)` | — | + `0 0 0 2px rgba(22,119,255,0.16)` | — | `#FFFFFF` |
|
||||
| 未选禁用 | `#4A6491` 描边 | — | `#4A6491` 描边 | — | `#4A6491` |
|
||||
| 已选禁用 | `#4A6491` 描边 | `#4A6491` 6px 圆 | `#4A6491` 填充 | `#C5CFE0` ✓ | `#4A6491` |
|
||||
|
||||
---
|
||||
|
||||
### 16.5 与 §15.2.2 的关系
|
||||
|
||||
§15.2.2「多项选择面板内的复选项」直接复用本章 §16.3 的复选框外观规格,**唯一差异**:
|
||||
|
||||
- 在多项选择下拉面板里,**未选状态边框**是 `#6E88B0` (ZS-004-04,更"灰"的占位色),而非本章 §16.3 默认的 `#4096FF`(更"蓝"的强调色)
|
||||
- 原因:下拉面板里项数多,所有复选框同时蓝色描边会过于喧闹;独立表单字段里数量少(通常 2–4 项),用强调色更醒目
|
||||
|
||||
如需统一也可以全面采用本章规格 —— 二选一即可,但**一个项目内部应保持一致**。
|
||||
|
||||
---
|
||||
|
||||
### 16.6 CSS Token 参考
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 公共 */
|
||||
--field-size: 14px;
|
||||
--field-border-w: 1px;
|
||||
--field-radius: 2px; /* 仅 Checkbox 用 */
|
||||
--field-text: #C5CFE0; /* ZS-004-02 */
|
||||
--field-text-hover: #FFFFFF;
|
||||
--field-text-disabled: #4A6491; /* ZS-004-05 */
|
||||
--field-focus-ring: 0 0 0 2px rgba(22,119,255,0.16);
|
||||
|
||||
/* Radio */
|
||||
--radio-border: #4096FF; /* ZS-002-05 */
|
||||
--radio-border-hover: #1677FF; /* ZS-002-06 */
|
||||
--radio-border-checked: #1677FF;
|
||||
--radio-dot: #1677FF; /* 中心 6×6 实心 */
|
||||
--radio-border-disabled: #4A6491; /* ZS-004-05 */
|
||||
--radio-dot-disabled: #4A6491;
|
||||
--radio-bg-disabled: rgba(22,119,255,0.04);
|
||||
|
||||
/* Checkbox */
|
||||
--checkbox-border: #4096FF; /* ZS-002-05 */
|
||||
--checkbox-border-hover: #1677FF; /* ZS-002-06 */
|
||||
--checkbox-fill-checked: #1677FF; /* ZS-002-06 */
|
||||
--checkbox-mark: #FFFFFF;
|
||||
--checkbox-border-disabled: #4A6491; /* ZS-004-05 */
|
||||
--checkbox-fill-disabled: #4A6491;
|
||||
--checkbox-mark-disabled: #C5CFE0;
|
||||
--checkbox-bg-disabled: rgba(22,119,255,0.04);
|
||||
|
||||
/* 组合:嵌入式 label + 输入框(§16.2.2 / §16.3.2) */
|
||||
--field-group-indent: 22px; /* 14px 控件 + 8px 间距 */
|
||||
--field-group-gap-y: var(--spacing-3); /* 12px */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 日期选择器(DatePicker)
|
||||
|
||||
> 对应设计稿「日期时间筛选框.png」。覆盖**单日期选择**与**日期范围选择**两种场景。面板包含**左侧快捷模式**(按天 / 按周 / 按月 / 自定义)、**起止输入区**、**双月日历主体**、**底部确认按钮**四块。配色全部从 §1.1.3 渐蓝系(ZS-002)与 §1.1.5 深蓝系(ZS-004)取,与 §14 搜索框 / §15 选择器完全对齐。
|
||||
|
||||
### 17.1 触发器(Trigger)
|
||||
|
||||
触发器即「2021-12-09 📅」那一行。规格与 §14.1 输入框完全一致:
|
||||
|
||||
| 属性 | 取值 | 来源 |
|
||||
|------|------|------|
|
||||
| 高度 | `32px` | §14.1 |
|
||||
| 圆角 | `2px` | §4.1 |
|
||||
| 水平 padding | `--spacing-3` (12px) | §7.1 |
|
||||
| 背景 | `#0C1836` (ZS-004-09) | — |
|
||||
| 边框 | `1px solid #1A2F5A` (ZS-004-07) | — |
|
||||
| 默认日期文字 | `14px Regular`,`#C5CFE0` (ZS-004-02);占位时 "请选择日期" 用 `#6E88B0` (ZS-004-04) | — |
|
||||
| 右侧 📅 图标 | `14px`,`#6E88B0`,hover `#4096FF` | ZS-002-05 |
|
||||
|
||||
边框状态机(默认 / hover / focus / 失效 / 错误)直接复用 §14.1.1。
|
||||
|
||||
**范围选择**触发器变体:宽度自动延展,内显示 `2021-12-09 至 2022-01-09`。
|
||||
|
||||
---
|
||||
|
||||
### 17.2 下拉面板(Panel)
|
||||
|
||||
面板为**左 + 右**两栏组合:
|
||||
|
||||
```
|
||||
┌──────────┬───────────────────────────────────────────────────────┐
|
||||
│ 按天 › │ [ 2021-12-09 ⊗ ] 至 [ 2021-12-09 ⊗ ] │
|
||||
│ 按周 › ├───────────────────────────────────────────────────────┤
|
||||
│ 按月 › │ « < 2021 年 12 月 > » « < 2022 年 1 月 > » │
|
||||
│ 自定义 │ 日 一 二 三 四 五 六 日 一 二 三 四 五 六 │
|
||||
│ │ 29 30 1 2 3 4 5 29 30 1 2 3 4 5 │
|
||||
│ │ 6 7 8 ⓿ 10 11 12 6 7 8 ⓿ 10 11 12 │
|
||||
│ │ 13 14 ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ │
|
||||
│ │ 20 ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ │
|
||||
│ │ 27 ▓▓ ▓▓ ▓▓ ▓▓ 1 2 3 4 5 6 7 8 9 │
|
||||
│ │ 3 4 5 6 7 8 9 …… │
|
||||
│ ├───────────────────────────────────────────────────────┤
|
||||
│ │ [确定] [取消] │
|
||||
└──────────┴───────────────────────────────────────────────────────┘
|
||||
⓿ = 起止日(实心蓝圆) ▓▓ = 范围内(浅蓝底矩形)
|
||||
```
|
||||
|
||||
#### 17.2.1 面板容器规格
|
||||
|
||||
| 属性 | 取值 |
|
||||
|------|------|
|
||||
| 背景 | `#122347` (ZS-004-08) |
|
||||
| 边框 | `1px solid #1A2F5A` (ZS-004-07) |
|
||||
| 阴影 | `0 4px 16px rgba(6, 14, 36, 0.6)`(§3.1.4 三级阴影) |
|
||||
| 圆角 | `2px` |
|
||||
| 与触发器纵向间距 | `--spacing-1` (4px) |
|
||||
| 整体宽度 | 由左栏宽 `80px` + 右栏(双月并列)自适应得出 |
|
||||
| 左右栏分隔线 | `1px solid #1A2F5A`(ZS-004-07) |
|
||||
|
||||
---
|
||||
|
||||
### 17.3 左侧快捷模式(Sidebar)
|
||||
|
||||
| 属性 | 取值 |
|
||||
|------|------|
|
||||
| 宽度 | `80px` |
|
||||
| 项高度 | `36px` |
|
||||
| 水平 padding | `--spacing-3` (12px) |
|
||||
| 字号 | `14px Regular` |
|
||||
| 右侧 `›` 图标(除"自定义"外) | `12px`,与文字同色 |
|
||||
|
||||
#### 17.3.1 项状态
|
||||
|
||||
| 状态 | 背景 | 文字 |
|
||||
|------|------|------|
|
||||
| 默认 | `transparent` | `#C5CFE0` (ZS-004-02) |
|
||||
| Hover | `rgba(22,119,255,0.16)` | `#FFFFFF` |
|
||||
| 已选 | `#1677FF` (ZS-002-06) | `#FFFFFF` |
|
||||
|
||||
#### 17.3.2 四种模式语义
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| 按天 | 点击日期格 → 立即选中单日,关闭面板,触发器显示该日 |
|
||||
| 按周 | hover 任意日期格 → **整周高亮**,点击 → 选中该周 |
|
||||
| 按月 | 切换到月视图(日期格变成 12 个月名),点击 → 选中该月 |
|
||||
| 自定义 | 默认显示双月日历,支持选中起止日做范围(详见 §17.5) |
|
||||
|
||||
---
|
||||
|
||||
### 17.4 起止日期输入区(Range Inputs)
|
||||
|
||||
仅在**自定义模式**下显示在右栏顶部:
|
||||
|
||||
```
|
||||
[ 2021-12-09 ⊗ ] 至 [ 2021-12-09 ⊗ ]
|
||||
```
|
||||
|
||||
| 元素 | 色值 / 规格 | 来源 |
|
||||
|------|-------------|------|
|
||||
| 输入框 | 沿用 §14.1 公共规格,宽度 `132px`,**只读**(点击日历格回填,不直接输入) | §14.1 |
|
||||
| 中间 "至" 文字 | `14px Regular`,`#6E88B0` (ZS-004-04),左右 padding `--spacing-3` (12px) | — |
|
||||
| 右侧 `⊗` 清空按钮 | `14px`,`#6E88B0`,hover `#4096FF` | ZS-002-05 |
|
||||
| 输入区 → 日历主体纵向间距 | `--spacing-3` (12px) + 分割线 `1px solid #1A2F5A` | §6.1 |
|
||||
| 整行右内 padding | `--spacing-4` (16px) | §7.1 |
|
||||
|
||||
未选起 / 止时对应输入框留空显示占位 "开始日期" / "结束日期"(`#6E88B0`)。
|
||||
|
||||
---
|
||||
|
||||
### 17.5 双月日历主体(Calendar Grid)
|
||||
|
||||
并列展示**当月**与**下月**两张日历,便于跨月选范围。
|
||||
|
||||
#### 17.5.1 标题栏
|
||||
|
||||
```
|
||||
« < 2021 年 12 月 > »
|
||||
```
|
||||
|
||||
| 元素 | 色值 / 规格 |
|
||||
|------|-------------|
|
||||
| 年月文字 | `14px Medium`,`#C5CFE0` (ZS-004-02) |
|
||||
| 翻页箭头 `‹ ›` | `14px`,`#6E88B0`,hover `#4096FF` |
|
||||
| 翻年箭头 `« »` | `14px`,`#6E88B0`,hover `#4096FF` |
|
||||
| 标题栏高度 | `36px` |
|
||||
| 双月之间间距 | `--spacing-6` (24px)(§7.1) |
|
||||
|
||||
行为:`«` `»` 翻年;`‹` `›` 翻月。左日历翻动时右日历同步前移一月,保持"双月连续"。
|
||||
|
||||
#### 17.5.2 星期表头
|
||||
|
||||
| 属性 | 取值 |
|
||||
|------|------|
|
||||
| 文字 | `日 一 二 三 四 五 六` |
|
||||
| 字号 | `12px Regular` |
|
||||
| 颜色 | `#6E88B0` (ZS-004-04) |
|
||||
| 行高 | `28px` |
|
||||
|
||||
#### 17.5.3 日期格规格
|
||||
|
||||
| 属性 | 取值 |
|
||||
|------|------|
|
||||
| 单元尺寸 | `28px × 28px`(点击命中区) |
|
||||
| 数字字号 | `14px Regular` |
|
||||
| 圆角 | `50%`(已选时实心圆) |
|
||||
| 行间距 | `0`(紧凑排列) |
|
||||
|
||||
#### 17.5.4 日期格状态
|
||||
|
||||
| 状态 | 背景 | 文字 |
|
||||
|------|------|------|
|
||||
| 当月普通 | `transparent` | `#C5CFE0` (ZS-004-02) |
|
||||
| 上下月填充(如 12 月日历里显示的 11/29、11/30、1/1、1/2) | `transparent` | `#4A6491` (ZS-004-05) |
|
||||
| 今天(无边框样式) | `transparent` + `1px solid #1677FF` | `#C5CFE0` |
|
||||
| Hover | `rgba(22,119,255,0.16)` | `#FFFFFF` |
|
||||
| 已选起止日 | 实心圆 `#1677FF` (ZS-002-06) | `#FFFFFF` |
|
||||
| 范围内(起止之间) | `rgba(22,119,255,0.16)` 矩形(无圆角,左右相连成连续色带) | `#FFFFFF` |
|
||||
| 范围内 + 端点 | 矩形底色 + 端点实心圆叠加(视觉上"圆形扣"在色带头尾) | `#FFFFFF` |
|
||||
| 禁用(超出 min/max) | `transparent` | `#4A6491`,鼠标 `not-allowed` |
|
||||
|
||||
> ⚠️ **范围色带的实现要点**:范围内日期格不能各自圆角,否则中间会出现空隙。推荐方案:每行整体作为容器,范围内的日期用同色底向左右各延伸 `4px` 模拟连续;端点处再叠加 `28×28` 实心圆。
|
||||
|
||||
---
|
||||
|
||||
### 17.6 底部「确定 / 取消」按钮
|
||||
|
||||
| 元素 | 规格 | 来源 |
|
||||
|------|------|------|
|
||||
| 区域高度 | `48px` |
|
||||
| 顶部分割线 | `1px solid #1A2F5A` (ZS-004-07) | §6.1 |
|
||||
| 右内 padding | `--spacing-4` (16px) | §7.1 |
|
||||
| 按钮对齐 | 右对齐 |
|
||||
| 「确定」 | 沿用 §5.2.1 主按钮(实心 `#1677FF`),尺寸"小号"`28px` | §5.2.1 |
|
||||
| 「取消」 | 沿用 §5.2.2 默认按钮(描边 + 透明底),尺寸"小号"`28px` | §5.2.2 |
|
||||
| 两按钮间距 | `--spacing-2` (8px) | §7.1 |
|
||||
|
||||
行为:
|
||||
- 「确定」 → 提交当前选择,关闭面板,触发器显示选中值
|
||||
- 「取消」 → 丢弃临时选择,关闭面板,触发器值不变
|
||||
|
||||
按 `Esc` 等价于「取消」;按 `Enter` 等价于「确定」(当选择合法时)。
|
||||
|
||||
---
|
||||
|
||||
### 17.7 选用决策与变体
|
||||
|
||||
| 场景 | 选择 |
|
||||
|------|------|
|
||||
| 单日期 | 触发器 + 单月日历(无左侧快捷栏,无起止输入区) |
|
||||
| 日期范围(常见筛选) | 完整组合:左侧快捷栏 + 双月日历 + 起止输入区 |
|
||||
| 仅按周 / 按月 / 按季度 | 左侧只保留对应模式项;右侧日历切换为相应粒度 |
|
||||
| 带时间(小时 / 分) | 在 §17.4 起止输入区下方追加时分选择列(24h 滚轮,未在本节展开) |
|
||||
| 多日期独立选(非连续) | 不推荐用本组件 —— 改用 §15 多项选择 + 日期值列表 |
|
||||
|
||||
---
|
||||
|
||||
### 17.8 CSS Token 参考
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 触发器 —— 复用 §14 搜索框 */
|
||||
--date-trigger-bg: var(--search-bg); /* #0C1836 */
|
||||
--date-trigger-border: var(--search-border); /* #1A2F5A */
|
||||
--date-trigger-text: var(--search-text); /* #C5CFE0 */
|
||||
--date-trigger-placeholder: var(--search-placeholder); /* #6E88B0 */
|
||||
--date-trigger-icon: #6E88B0;
|
||||
--date-trigger-icon-hover: #4096FF; /* ZS-002-05 */
|
||||
|
||||
/* 面板 —— 复用 §14.4.4 联想 */
|
||||
--date-panel-bg: var(--suggest-bg); /* #122347 */
|
||||
--date-panel-border: #1A2F5A;
|
||||
--date-panel-divider: #1A2F5A; /* 左右栏 + 顶/底分割线 */
|
||||
|
||||
/* 左侧快捷栏 */
|
||||
--date-sidebar-w: 80px;
|
||||
--date-sidebar-item-h: 36px;
|
||||
--date-sidebar-text: #C5CFE0; /* ZS-004-02 */
|
||||
--date-sidebar-text-hover: #FFFFFF;
|
||||
--date-sidebar-bg-hover: rgba(22,119,255,0.16);
|
||||
--date-sidebar-bg-active: #1677FF; /* ZS-002-06 */
|
||||
--date-sidebar-text-active: #FFFFFF;
|
||||
|
||||
/* 起止输入区 */
|
||||
--date-range-sep: #6E88B0; /* "至" 文字 */
|
||||
--date-range-clear: #6E88B0;
|
||||
--date-range-clear-hover: #4096FF;
|
||||
|
||||
/* 日历单元 */
|
||||
--date-cell-size: 28px;
|
||||
--date-cell-text: #C5CFE0; /* 当月 */
|
||||
--date-cell-text-outside: #4A6491; /* 上下月填充, ZS-004-05 */
|
||||
--date-cell-text-disabled: #4A6491;
|
||||
--date-cell-bg-hover: rgba(22,119,255,0.16);
|
||||
--date-cell-text-hover: #FFFFFF;
|
||||
--date-cell-bg-selected: #1677FF; /* 起止实心圆 */
|
||||
--date-cell-text-selected: #FFFFFF;
|
||||
--date-cell-bg-in-range: rgba(22,119,255,0.16); /* 范围色带 */
|
||||
--date-cell-border-today: #1677FF; /* 今天 1px 边框 */
|
||||
|
||||
/* 标题栏 */
|
||||
--date-title-text: #C5CFE0;
|
||||
--date-title-arrow: #6E88B0;
|
||||
--date-title-arrow-hover: #4096FF;
|
||||
--date-weekday-text: #6E88B0; /* 日一二…六 */
|
||||
|
||||
/* 底部按钮区 */
|
||||
--date-footer-h: 48px;
|
||||
--date-footer-divider: #1A2F5A;
|
||||
}
|
||||
```
|
||||
@@ -20,13 +20,13 @@ import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
|
||||
const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
storageKey="strategy-theme"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
|
||||
@@ -72,12 +72,14 @@ const menuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{
|
||||
id: "qa-roundtable",
|
||||
label: "圆桌规划",
|
||||
icon: Bot,
|
||||
path: "/page/roundtable/planning",
|
||||
},
|
||||
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
|
||||
// 需要重新开放时取消注释即可。
|
||||
// {
|
||||
// id: "qa-roundtable",
|
||||
// label: "圆桌规划",
|
||||
// icon: Bot,
|
||||
// path: "/page/roundtable/planning",
|
||||
// },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
|
||||
@@ -63,6 +63,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
|
||||
@@ -500,7 +501,7 @@ export function InputBox({
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{q.title}
|
||||
{desensitize(q.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -86,10 +86,10 @@ const STORAGE_KEY = 'strategy-theme';
|
||||
|
||||
function readPersistedTheme(): ThemeType {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
return 'light';
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'light' ? 'light' : 'dark';
|
||||
return saved === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// 主题上下文
|
||||
@@ -125,7 +125,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
|
||||
if (current !== 'light' && current !== 'dark') {
|
||||
const fallback =
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'light';
|
||||
setNextTheme(fallback);
|
||||
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
setNextTheme(legacy);
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
态势: "TS",
|
||||
滨海作战体系: "第一张网",
|
||||
滨海作战部队: "第一张网",
|
||||
'Taiwan Air Force Flight Units': "第二张网",
|
||||
台海空军飞行部队: "第二张网",
|
||||
空军: "KJ",
|
||||
海军: "HJ",
|
||||
陆军: "LJ",
|
||||
陆战队: "LZD",
|
||||
海巡: "HX",
|
||||
憲兵: "XB",
|
||||
資通電軍: "ZTDJ",
|
||||
情报: "QB",
|
||||
指挥: "ZH",
|
||||
信息准备: "XX准备",
|
||||
@@ -30,6 +41,7 @@ export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
军: "J",
|
||||
美军: "MJ",
|
||||
台军: "TJ",
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import { normalizeKeyPoint } from '../../types/ai-writing';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
|
||||
interface OutlineSectionProps {
|
||||
@@ -10,7 +11,7 @@ interface OutlineSectionProps {
|
||||
onChange: (updated: OutlineSectionType) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
// Drag-and-drop
|
||||
// Drag-and-drop (章节级别)
|
||||
isDragging?: boolean;
|
||||
isOver?: boolean;
|
||||
onDragStart?: () => void;
|
||||
@@ -19,6 +20,14 @@ interface OutlineSectionProps {
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
|
||||
* subPoints[] 为 bullet 列表(Level 3)。
|
||||
*
|
||||
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
|
||||
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
|
||||
* 编辑过的旧 session 大纲会被持久化为新格式)。
|
||||
*/
|
||||
export function OutlineSection({
|
||||
section, index, onChange, onRemove, readOnly,
|
||||
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
|
||||
@@ -27,6 +36,13 @@ export function OutlineSection({
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
|
||||
|
||||
// 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
|
||||
// 都生成新引用导致子组件无谓重渲染。
|
||||
const keyPoints: KeyPoint[] = useMemo(
|
||||
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
|
||||
[section.keyPoints],
|
||||
);
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
@@ -37,20 +53,23 @@ export function OutlineSection({
|
||||
}
|
||||
}
|
||||
|
||||
function updateKeyPoint(i: number, value: string) {
|
||||
const next = [...section.keyPoints];
|
||||
next[i] = value;
|
||||
function updateKeyPoint(i: number, updated: KeyPoint) {
|
||||
const next = [...keyPoints];
|
||||
next[i] = updated;
|
||||
onChange({ ...section, keyPoints: next });
|
||||
}
|
||||
|
||||
function removeKeyPoint(i: number) {
|
||||
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
|
||||
onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addKeyPoint() {
|
||||
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
|
||||
onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
|
||||
}
|
||||
|
||||
// 子要点总数,显示在章节头给个粗略概览
|
||||
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!readOnly}
|
||||
@@ -104,7 +123,9 @@ export function OutlineSection({
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
|
||||
<span className="text-muted-foreground/50 shrink-0">
|
||||
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
@@ -112,24 +133,151 @@ export function OutlineSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 要点列表 */}
|
||||
{/* 小节列表 (Level 2) */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-8 pb-2.5 space-y-1 border-t">
|
||||
{section.keyPoints.map((kp, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 pt-1.5">
|
||||
<span className="text-muted-foreground/40 shrink-0">•</span>
|
||||
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
|
||||
{keyPoints.map((kp, i) => (
|
||||
<KeyPointRow
|
||||
key={i}
|
||||
keyPoint={kp}
|
||||
onChange={(updated) => updateKeyPoint(i, updated)}
|
||||
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加小节
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
|
||||
interface KeyPointRowProps {
|
||||
keyPoint: KeyPoint;
|
||||
onChange: (updated: KeyPoint) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
if (trimmed !== keyPoint.pointTitle) {
|
||||
onChange({ ...keyPoint, pointTitle: trimmed });
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubPoint(i: number, value: string) {
|
||||
const next = [...keyPoint.subPoints];
|
||||
next[i] = value;
|
||||
onChange({ ...keyPoint, subPoints: next });
|
||||
}
|
||||
|
||||
function removeSubPoint(i: number) {
|
||||
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addSubPoint() {
|
||||
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
|
||||
setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border/40 bg-muted/10">
|
||||
{/* 小节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
aria-label="展开/收起子要点"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/60 transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span className="text-purple-500/70 shrink-0">###</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none"
|
||||
placeholder="小节标题"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
|
||||
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
|
||||
aria-label="删除小节"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
|
||||
{keyPoint.subPoints.map((sp, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 pt-1">
|
||||
{/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
|
||||
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
|
||||
{readOnly ? (
|
||||
<span className="flex-1 text-muted-foreground">{kp}</span>
|
||||
<span className="flex-1 text-muted-foreground">{sp}</span>
|
||||
) : (
|
||||
<input
|
||||
value={kp}
|
||||
onChange={(e) => updateKeyPoint(i, e.target.value)}
|
||||
placeholder="输入要点…"
|
||||
value={sp}
|
||||
onChange={(e) => updateSubPoint(i, e.target.value)}
|
||||
placeholder="子要点标题(会展开成一段正文)"
|
||||
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSubPoint(i)}
|
||||
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
|
||||
aria-label="删除子要点"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -138,11 +286,11 @@ export function OutlineSection({
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
|
||||
onClick={addSubPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加要点
|
||||
添加子要点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,25 @@ export interface AIMaterial {
|
||||
}
|
||||
|
||||
// ── 大纲 ─────────────────────────────────────────────────────────────────────
|
||||
// 三级结构:章节(##) → 小节(###) → 子要点(bullet)
|
||||
// AIOutline.sections[] = Level 1 章节(渲染为 ## 由后端注入)
|
||||
// OutlineSection.keyPoints[] = Level 2 小节(渲染为 ###)
|
||||
// KeyPoint.subPoints[] = Level 3 子要点(正文里的 bullet 列表)
|
||||
//
|
||||
// 历史兼容:旧版 keyPoints: string[](2 级)。后端 _parse_outline 已经在解析时把
|
||||
// 旧 string 自动 lift 成 KeyPoint;前端类型只保留新格式,但 UI 渲染层做 type
|
||||
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
|
||||
|
||||
export interface KeyPoint {
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
}
|
||||
|
||||
export interface OutlineSection {
|
||||
sectionTitle: string;
|
||||
keyPoints: string[];
|
||||
// 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。
|
||||
// 新生成的大纲一律 KeyPoint[]。
|
||||
keyPoints: Array<KeyPoint | string>;
|
||||
materialIds: string[];
|
||||
}
|
||||
|
||||
@@ -25,6 +40,32 @@ export interface AIOutline {
|
||||
sections: OutlineSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 keyPoints 单项规整成 KeyPoint。兼容三种输入:
|
||||
* - 新版 KeyPoint object
|
||||
* - 旧版 string (lift 为 { pointTitle: <str>, subPoints: [] })
|
||||
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
|
||||
*/
|
||||
export function normalizeKeyPoint(raw: unknown): KeyPoint {
|
||||
if (typeof raw === 'string') {
|
||||
return { pointTitle: raw, subPoints: [] };
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const r = raw as Record<string, unknown>;
|
||||
const title =
|
||||
typeof r.pointTitle === 'string' ? r.pointTitle :
|
||||
typeof r.point_title === 'string' ? r.point_title :
|
||||
typeof r.title === 'string' ? r.title :
|
||||
'';
|
||||
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
|
||||
const subPoints: string[] = Array.isArray(subsRaw)
|
||||
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
|
||||
: [];
|
||||
return { pointTitle: title, subPoints };
|
||||
}
|
||||
return { pointTitle: '', subPoints: [] };
|
||||
}
|
||||
|
||||
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 严格模式下素材不足、待用户决策的章节 */
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -193,6 +193,13 @@ export interface StreamMultiAgentRequest {
|
||||
agentType: StreamAgentType;
|
||||
dAgentThreadId: ThreadIdMap;
|
||||
agentName: string;
|
||||
/**
|
||||
* 人类可读名称(中文/英文)。仅 special 走广播时使用 —— 后端把它拼进
|
||||
* 「子智能体X完成Y工作,交付内容为:...」里,这样总控 LLM 在下一轮看到自己
|
||||
* 历史里的交付摘要时能用"语义化的名字"识别,而不是 UUID 字符串(LLM 对
|
||||
* UUID 不友好,会导致重复派活)。后端缺失时 fallback 到 agentName。
|
||||
*/
|
||||
displayName?: string;
|
||||
newMessage: string;
|
||||
model?: string;
|
||||
skillStopNames?: string[];
|
||||
@@ -209,8 +216,14 @@ export interface StreamMultiAgentRequest {
|
||||
* Fires whenever the inferred SSE phase changes. The UI can use this to
|
||||
* show progressive status messages during the inevitable first-token wait.
|
||||
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
||||
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
|
||||
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
|
||||
*/
|
||||
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
|
||||
onPhaseChange?: (
|
||||
phase: StreamPhase,
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ClarificationPayload {
|
||||
@@ -327,6 +340,8 @@ export async function streamMultiAgent(
|
||||
agent_type: req.agentType,
|
||||
d_agent_thread_id: req.dAgentThreadId,
|
||||
agent_name: req.agentName,
|
||||
// 仅 special 用到;后端缺失时 fallback 到 agent_name,旧前端也不破坏。
|
||||
display_name: req.displayName,
|
||||
new_message: req.newMessage,
|
||||
model: req.model,
|
||||
skill_stop_names: req.skillStopNames ?? [],
|
||||
@@ -366,10 +381,17 @@ export async function streamMultiAgent(
|
||||
// value actually changes (the reader loop sees many frames per phase).
|
||||
let currentPhase: StreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: StreamPhase, name?: string) => {
|
||||
const setPhase = (
|
||||
next: StreamPhase,
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层;
|
||||
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层
|
||||
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。
|
||||
if (next === currentPhase && !name) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next, name);
|
||||
req.onPhaseChange?.(next, name, args);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
@@ -426,13 +448,22 @@ export async function streamMultiAgent(
|
||||
setPhase("tool_result");
|
||||
} else if (hasToolCall) {
|
||||
// Pick the most informative name we can find in either field.
|
||||
// 优先用 tool_calls(完整对象,含解析好的 args),tool_call_chunks 的 args
|
||||
// 通常是局部 JSON 字符串(LangGraph 流式期间逐字符发),没法直接用。
|
||||
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
|
||||
? (toolCalls[0] as Record<string, unknown>)
|
||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||
? (toolCallChunks[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||
setPhase("tool_calling", toolName);
|
||||
// 仅当来自 tool_calls(完整对象)时透传 args;tool_call_chunks 的 args 是
|
||||
// string 拼接中,不传上去给 UI,等下一个完整 chunk 再带。
|
||||
const fromCompleteToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
|
||||
const args =
|
||||
fromCompleteToolCalls && firstCall && typeof firstCall.args === "object" && firstCall.args !== null
|
||||
? (firstCall.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
setPhase("tool_calling", toolName, args);
|
||||
} else if (
|
||||
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||||
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 圆桌规划用到的所有 SVG 头像。
|
||||
*
|
||||
* 这些原本散落在主页面里,提到这里集中维护,便于 Step 1 / Step 2 / Step 3
|
||||
* 与 LeftSidebar、HighFidelityReport 复用。`getAvatar(type, size)` 根据
|
||||
* 字符串类型名(blue / purple / emerald / amber / pink / human / man)挑对应组件。
|
||||
*/
|
||||
|
||||
export const Man1Avatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<span
|
||||
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-slate-200/80 shadow-sm`}
|
||||
>
|
||||
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" fill="#E2E8F0" />
|
||||
<circle cx="50" cy="50" r="45" fill="#FEEDED" />
|
||||
<path d="M15 95 C 15 70, 30 65, 50 65 C 70 65, 85 70, 85 95 Z" fill="#1E293B" />
|
||||
<path d="M40 65 L50 78 L60 65 Z" fill="#FFFFFF" />
|
||||
<path d="M47 72 L53 72 L51 98 L49 98 Z" fill="#E11D48" />
|
||||
<path d="M40 65 L40 55 C 40 55, 45 58, 50 58 C 55 58, 60 55, 60 55 L60 65 Z" fill="#FBCFE8" />
|
||||
<circle cx="50" cy="38" r="18" fill="#FBCFE8" />
|
||||
<path d="M42 34 Q 45 32 47 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M53 34 Q 55 32 58 34" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="45" cy="39" r="2.5" fill="#0F172A" />
|
||||
<circle cx="55" cy="39" r="2.5" fill="#0F172A" />
|
||||
<path d="M30 32 C 30 18, 70 18, 70 32 C 72 26, 68 15, 50 15 C 32 15, 28 26, 30 32 Z" fill="#0F172A" />
|
||||
<path d="M30 32 C30 32, 28 35, 32 35 C34 35, 36 30, 36 30 Z" fill="#0F172A" />
|
||||
<path d="M70 32 C70 32, 72 35, 68 35 C66 35, 64 30, 64 30 Z" fill="#0F172A" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const RobotAvatar = ({
|
||||
bg = "bg-primary/10",
|
||||
headColor = "#f5222d",
|
||||
size = "h-8 w-8",
|
||||
}: {
|
||||
bg?: string;
|
||||
headColor?: string;
|
||||
size?: string;
|
||||
}) => (
|
||||
<span
|
||||
className={`relative flex ${size} shrink-0 overflow-hidden rounded-full border border-red-100/50 shadow-sm ${bg}`}
|
||||
>
|
||||
<svg className="h-full w-full" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="25" y="28" width="50" height="42" rx="12" fill={headColor} />
|
||||
<rect x="32" y="35" width="36" height="24" rx="6" fill="#1E293B" />
|
||||
<circle cx="42" cy="45" r="4" fill="#F74750" className="animate-pulse" />
|
||||
<circle cx="58" cy="45" r="4" fill="#F74750" className="animate-pulse" />
|
||||
<path d="M45 52 Q 50 55 55 52" stroke="#F74750" strokeWidth="2.2" strokeLinecap="round" />
|
||||
<rect x="20" y="41" width="5" height="16" rx="2" fill="#cf1322" />
|
||||
<rect x="75" y="41" width="5" height="16" rx="2" fill="#cf1322" />
|
||||
<line x1="50" y1="28" x2="50" y2="15" stroke={headColor} strokeWidth="4" strokeLinecap="round" />
|
||||
<circle cx="50" cy="12" r="5" fill="#FA9196" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#cf1322" bg="bg-[var(--brand-200)]" size={size} />
|
||||
);
|
||||
export const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#7c3aed" bg="bg-purple-100/40" size={size} />
|
||||
);
|
||||
export const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#059669" bg="bg-emerald-100/40" size={size} />
|
||||
);
|
||||
export const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#d97706" bg="bg-amber-100/40" size={size} />
|
||||
);
|
||||
export const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => (
|
||||
<RobotAvatar headColor="#db2777" bg="bg-pink-100/40" size={size} />
|
||||
);
|
||||
|
||||
/** 根据字符串类型名挑对应头像。 */
|
||||
export function getAvatar(type: string, size: string = "h-8 w-8") {
|
||||
switch (type) {
|
||||
case "man":
|
||||
case "human":
|
||||
return <Man1Avatar size={size} />;
|
||||
case "blue":
|
||||
return <BlueRobotAvatar size={size} />;
|
||||
case "purple":
|
||||
return <PurpleRobotAvatar size={size} />;
|
||||
case "emerald":
|
||||
return <EmeraldRobotAvatar size={size} />;
|
||||
case "amber":
|
||||
return <AmberRobotAvatar size={size} />;
|
||||
case "pink":
|
||||
return <PinkRobotAvatar size={size} />;
|
||||
default:
|
||||
return <RobotAvatar size={size} />;
|
||||
}
|
||||
}
|
||||
@@ -368,8 +368,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 3: Generated files per seat */}
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
{/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
|
||||
<section className="hidden bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-primary" />
|
||||
生成的报告文件
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,15 +22,21 @@ import { Button } from "@/components/ui/button";
|
||||
import { listAgents, type Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
MarkdownContent,
|
||||
ReasoningBlock,
|
||||
StreamingCursor,
|
||||
} from "./MessageBubble";
|
||||
import { MessageStepsCard } from "./MessageStepsCard";
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
parseRecommendStreamContent,
|
||||
streamRecommend,
|
||||
type RecommendCandidate,
|
||||
type RecommendPick,
|
||||
type RecommendStreamPhase,
|
||||
} from "../api/recommend";
|
||||
import { splitInlineReasoning } from "../lib/reasoning";
|
||||
|
||||
/**
|
||||
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
|
||||
@@ -43,7 +56,7 @@ export interface RecommendAgentsDialogProps {
|
||||
model?: string;
|
||||
onCancel: () => void;
|
||||
/** Called with the user's final selection of agent ids (in display order). */
|
||||
onConfirm: (agentIds: string[]) => void;
|
||||
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
|
||||
}
|
||||
|
||||
const MIN_SELECTED = 2;
|
||||
@@ -71,12 +84,21 @@ type DialogPhase =
|
||||
| "fallback"
|
||||
| "error";
|
||||
|
||||
interface RecommendStep {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface InternalState {
|
||||
phase: DialogPhase;
|
||||
/** Streaming + final rationale text from the recommender. */
|
||||
rationale: string;
|
||||
/** Streaming SSE phase from the recommender helper. */
|
||||
streamPhase: RecommendStreamPhase | null;
|
||||
/** 推荐流程阶段时间线 —— 调度 / 已连接 / 流式生成 …… 用 ChainOfThought 折叠展示。 */
|
||||
steps: RecommendStep[];
|
||||
/** Pre-filtered candidate pool fetched from `/api/agents`. */
|
||||
candidates: RecommendCandidate[];
|
||||
/** Reason-by-agent_id, only for picks returned by the recommender. */
|
||||
@@ -93,6 +115,7 @@ const INITIAL_STATE: InternalState = {
|
||||
phase: "idle",
|
||||
rationale: "",
|
||||
streamPhase: null,
|
||||
steps: [],
|
||||
candidates: [],
|
||||
reasons: {},
|
||||
recommendedIds: [],
|
||||
@@ -100,6 +123,24 @@ const INITIAL_STATE: InternalState = {
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
|
||||
function recommendPhaseLabel(phase: RecommendStreamPhase): string {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度推荐智能体";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "streaming":
|
||||
return "正在生成推荐";
|
||||
default:
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
|
||||
function nowTimeStr(): string {
|
||||
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which candidate ids should be checked by default given a set of
|
||||
* recommended ids. The result preserves recommendation order and silently
|
||||
@@ -193,6 +234,10 @@ export function RecommendAgentsDialog({
|
||||
candidates,
|
||||
rationale: "",
|
||||
streamPhase: "connecting",
|
||||
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
|
||||
steps: [
|
||||
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
|
||||
],
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -227,7 +272,24 @@ export function RecommendAgentsDialog({
|
||||
},
|
||||
onPhaseChange: (phase) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, streamPhase: phase }));
|
||||
setState((prev) => {
|
||||
const key = phase;
|
||||
const last = prev.steps[prev.steps.length - 1];
|
||||
// 连续重复阶段不重复 push,只在阶段切换时打点。
|
||||
const steps =
|
||||
last && last.key === key
|
||||
? prev.steps
|
||||
: [
|
||||
...prev.steps,
|
||||
{
|
||||
id: String(prev.steps.length),
|
||||
key,
|
||||
label: recommendPhaseLabel(phase),
|
||||
ts: nowTimeStr(),
|
||||
},
|
||||
];
|
||||
return { ...prev, streamPhase: phase, steps };
|
||||
});
|
||||
},
|
||||
});
|
||||
if (myGen !== generationRef.current) return;
|
||||
@@ -346,12 +408,15 @@ export function RecommendAgentsDialog({
|
||||
// Order: recommended-and-selected first (in recommender order), then
|
||||
// user-added picks (in pool order). Keeps the leader's "可调度智能体"
|
||||
// list visually stable.
|
||||
const ordered: string[] = [];
|
||||
//
|
||||
// 传完整对象(含 name)而不是只传 agent_id —— 非内置 agent 的 id 是 UUID,
|
||||
// 上游若只拿 id,在左侧席位列表就会显示 UUID 而非中文名。
|
||||
const ordered: Array<{ agent_id: string; name: string }> = [];
|
||||
for (const c of recommendedCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
|
||||
}
|
||||
for (const c of otherCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
|
||||
}
|
||||
onConfirm(ordered);
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
|
||||
@@ -376,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 ?? "未知错误"}
|
||||
@@ -390,7 +472,7 @@ export function RecommendAgentsDialog({
|
||||
) : state.phase === "recommending" ? (
|
||||
<>
|
||||
{recommendedCards.length > 0 ? (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
@@ -419,7 +501,7 @@ export function RecommendAgentsDialog({
|
||||
)}
|
||||
|
||||
{recommendedCards.length > 0 && (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
@@ -449,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">
|
||||
@@ -489,61 +572,230 @@ export function RecommendAgentsDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function RationaleBlock({ state }: { state: InternalState }) {
|
||||
/**
|
||||
* 「模型生成内容」整段的可折叠外壳。
|
||||
*
|
||||
* 默认行为:
|
||||
* - 流式期间(`streaming=true`)默认展开,让用户看到实时生成
|
||||
* - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* 把焦点让给下方推荐卡片
|
||||
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
|
||||
*
|
||||
* 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」),
|
||||
* 让用户不用展开就知道当前阶段。
|
||||
*/
|
||||
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
function CollapsibleModelOutput({
|
||||
streaming,
|
||||
phase,
|
||||
children,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
phase: DialogPhase;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(true);
|
||||
const userTouchedRef = useRef<boolean>(false);
|
||||
const hasAutoClosedRef = useRef<boolean>(false);
|
||||
|
||||
// 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。
|
||||
// 流式结束 → 延迟自动收起一次(仅一次)。
|
||||
useEffect(() => {
|
||||
if (userTouchedRef.current) return;
|
||||
if (streaming) {
|
||||
setOpen(true);
|
||||
hasAutoClosedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!hasAutoClosedRef.current && open) {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(false);
|
||||
hasAutoClosedRef.current = true;
|
||||
}, MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [streaming, open]);
|
||||
|
||||
const statusBadge = (() => {
|
||||
switch (phase) {
|
||||
case "loading_agents":
|
||||
return { label: "加载中", className: "bg-slate-100 text-slate-600" };
|
||||
case "recommending":
|
||||
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
|
||||
case "ready":
|
||||
return { label: "推荐已生成", className: "bg-emerald-50 text-emerald-700 border border-emerald-200" };
|
||||
case "fallback":
|
||||
return { label: "已使用兜底", className: "bg-amber-50 text-amber-700 border border-amber-200" };
|
||||
case "error":
|
||||
return { label: "推荐失败", className: "bg-red-50 text-red-700 border border-red-200" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<section className="border rounded-md bg-muted/20 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userTouchedRef.current = true;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<SparklesIcon
|
||||
className={cn(
|
||||
"size-3 text-purple-500 shrink-0",
|
||||
streaming && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 text-left">模型生成内容</span>
|
||||
{statusBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9.5px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded normal-case",
|
||||
statusBadge.className,
|
||||
)}
|
||||
>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 text-slate-400 shrink-0 transition-transform",
|
||||
open ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="px-3 pb-3">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推荐弹窗顶部「模型生成内容」面板。
|
||||
*
|
||||
* 视觉与交互对齐主聊天 chats/new 的消息列表:
|
||||
* - 顶部一行 spinner + 阶段文案(连接中 / 流式 / 完成)
|
||||
* - 步骤折叠卡(MessageStepsCard,与对话气泡里同一实现)
|
||||
* - 「思考过程」折叠卡(从 `<think>` 抽出来 reasoning,流式中默认展开)
|
||||
* - 正文 Markdown(去掉了 `<think>` 标签后的 rationale)
|
||||
* - 流式中正文末尾跟一个闪烁竖线
|
||||
*/
|
||||
function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
if (state.phase === "loading_agents") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载候选智能体...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "recommending") {
|
||||
const label =
|
||||
state.streamPhase === "connecting"
|
||||
? "调度推荐智能体..."
|
||||
: state.streamPhase === "connected"
|
||||
? "已连接,等待响应..."
|
||||
: "正在生成推荐...";
|
||||
|
||||
if (state.phase === "error") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
{state.rationale ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
|
||||
{state.rationale}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
|
||||
)}
|
||||
<div className="text-sm text-destructive">
|
||||
推荐失败,请使用下方的「重新推荐」重试,或取消后再次进入。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "ready" || state.phase === "fallback") {
|
||||
if (!state.rationale) {
|
||||
return <div className="text-xs text-muted-foreground">推荐完成。</div>;
|
||||
}
|
||||
return (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
|
||||
{state.rationale}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.phase === "error") {
|
||||
return <div className="text-xs text-destructive">推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。</div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
const showRationaleStage =
|
||||
state.phase === "recommending" ||
|
||||
state.phase === "ready" ||
|
||||
state.phase === "fallback";
|
||||
if (!showRationaleStage) return null;
|
||||
|
||||
const isStreaming = state.phase === "recommending";
|
||||
// 顶部状态行:跟主聊天的「正在思考...」类似,只是文案换成推荐阶段。
|
||||
const streamingLabel = isStreaming
|
||||
? state.streamPhase === "connecting"
|
||||
? "调度推荐智能体..."
|
||||
: state.streamPhase === "connected"
|
||||
? "已连接,等待响应..."
|
||||
: "正在生成推荐..."
|
||||
: null;
|
||||
|
||||
// 把 `<think>...</think>` 抽出来作 reasoning,剩下的 rationale 用 markdown 渲染。
|
||||
// 这与 Step1Panel / Step2Panel 的处理方式完全一致。
|
||||
const { content: cleanedRationale, reasoning } = splitInlineReasoning(state.rationale);
|
||||
const hasRationale = cleanedRationale.length > 0;
|
||||
const hasReasoning = !!reasoning;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</div>
|
||||
<div className="grid gap-2">{children}</div>
|
||||
{streamingLabel && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{streamingLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.steps.length > 0 && (
|
||||
<MessageStepsCard steps={state.steps} streaming={isStreaming} />
|
||||
)}
|
||||
|
||||
{hasReasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
|
||||
)}
|
||||
|
||||
{!hasRationale && !hasReasoning && isStreaming && (
|
||||
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
|
||||
)}
|
||||
|
||||
{hasRationale && (
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
|
||||
<MarkdownContent>{cleanedRationale}</MarkdownContent>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
/** True 时 header 可点击折叠/展开;false 保持原行为(始终展开,不可点击)。 */
|
||||
collapsible?: boolean;
|
||||
/** 仅当 collapsible=true 时生效。 */
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronUpIcon className="size-3" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3" />
|
||||
)}
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{/* flex flex-col + w-full + items-stretch:替代原 `grid gap-2`,
|
||||
强制所有 AgentCard 满父宽。grid 在某些浏览器对 <button> 子项的
|
||||
justify-self: stretch 处理不一致,导致"推荐参会"与"其他候选"
|
||||
两个 Section 视觉上有几像素宽度差。 */}
|
||||
{(!collapsible || open) && (
|
||||
<div className="flex w-full flex-col gap-2">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -571,7 +823,9 @@ function AgentCard({
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
|
||||
// w-full:`<button>` 默认 inline-block,在 grid cell 里不一定拉满;
|
||||
// 显式 w-full 保证推荐区与其他候选区的卡片**宽度严格一致**。
|
||||
"w-full text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
|
||||
checked
|
||||
? "border-primary bg-primary/5 hover:bg-primary/10"
|
||||
: "border-border hover:bg-muted/40",
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Step 1 主面板 —— 任务意图理解人机问答区。
|
||||
*
|
||||
* 主页面把所有 Step 1 状态(`useStep1Intent` 的返回值)+ 输入框 + 按钮处理函数
|
||||
* 透传过来,这里只关心:对话列表渲染、INTENT_READY 摘要卡、澄清卡片嵌入气泡、
|
||||
* 输入框 + 发送 / 暂停 / 跳过 / 继续追问 / 进入研讨 等底部按钮。
|
||||
*
|
||||
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
|
||||
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 2 保持一致。
|
||||
*
|
||||
* **不要**在这里再 fetch / 建 thread / 跑 SSE —— 这些逻辑都在 `useStep1Intent`,
|
||||
* 本组件保持纯展示 + 透传事件。
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pause,
|
||||
Repeat,
|
||||
Save,
|
||||
Send,
|
||||
Smile,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
IntentClarificationCard,
|
||||
type IntentClarificationSelection,
|
||||
} from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import {
|
||||
MarkdownContent,
|
||||
MessageBubble,
|
||||
MessageLoadingPlaceholder,
|
||||
ReasoningBlock,
|
||||
StreamingCursor,
|
||||
} from "@/roundtable-planning/components/MessageBubble";
|
||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import type {
|
||||
ClarificationPayload,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
|
||||
export interface Step1PanelProps {
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
intentThreadId: string | null;
|
||||
intentReady: object | null;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
// ─── Setters / Handlers ──────────────────────────────────────
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
>;
|
||||
onSendMessage: (text: string) => void;
|
||||
onStopIntent: () => void;
|
||||
onSkipQuestion: () => void;
|
||||
onFollowUp: () => void;
|
||||
onEnterRoundtable: () => void;
|
||||
onManualSave: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
submitIntentClarification: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function Step1Panel(props: Step1PanelProps) {
|
||||
const {
|
||||
interactiveMessages,
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
isIntentSending,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
setIntentClarificationSelections,
|
||||
onSendMessage,
|
||||
onStopIntent,
|
||||
onSkipQuestion,
|
||||
onFollowUp,
|
||||
onEnterRoundtable,
|
||||
onManualSave,
|
||||
onTriggerToast,
|
||||
submitIntentClarification,
|
||||
} = props;
|
||||
|
||||
const step1ScrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
|
||||
useAutoScroll(step1ScrollRef, [interactiveMessages]);
|
||||
|
||||
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
|
||||
const charactersLeft = inputText.length;
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputText.trim()) return;
|
||||
onSendMessage(inputText);
|
||||
setInputText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Dialogue Header */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
|
||||
<h3 className="text-sm font-extrabold text-slate-900">
|
||||
任务意图理解 (人机问答)
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
|
||||
<div
|
||||
ref={step1ScrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white"
|
||||
>
|
||||
{interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarification={submitIntentClarification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
|
||||
</div>
|
||||
|
||||
{/* Bottom input area */}
|
||||
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
|
||||
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
|
||||
<textarea
|
||||
id="input-text-area"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
pendingIntentClarificationMsgId
|
||||
? "也可在此输入自定义回答 (Enter 快速发送)"
|
||||
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
|
||||
}
|
||||
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-50 pt-2 text-[#94a3b8]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => onTriggerToast("😄 表情包功能仍在微调状态(仅供演示)")}
|
||||
className="p-1 hover:text-primary hover:bg-slate-50 rounded transition"
|
||||
>
|
||||
<Smile className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
id="send-message-btn"
|
||||
onClick={handleSend}
|
||||
disabled={isIntentSending || !intentThreadId}
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
|
||||
>
|
||||
{isIntentSending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Send className="w-3 h-3" />}
|
||||
{isIntentSending ? "发送中" : "发送"}
|
||||
</button>
|
||||
|
||||
{isIntentSending && (
|
||||
<button
|
||||
id="pause-intent-btn"
|
||||
type="button"
|
||||
onClick={onStopIntent}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition border border-amber-200/40 shadow-sm active:scale-95"
|
||||
>
|
||||
<Pause className="w-3 h-3" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
onClick={onFollowUp}
|
||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
|
||||
>
|
||||
<Repeat className="w-3 h-3 text-slate-500" />
|
||||
继续追问
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="skip-question-btn"
|
||||
onClick={onSkipQuestion}
|
||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
|
||||
>
|
||||
<X className="w-3 h-3 text-slate-500" strokeWidth={2.5} />
|
||||
跳过此项
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="enter-roundtable-btn"
|
||||
onClick={onEnterRoundtable}
|
||||
disabled={!intentReady}
|
||||
title={intentReady ? undefined : "请先完成任务意图澄清"}
|
||||
className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${
|
||||
intentReady
|
||||
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
|
||||
: "bg-emerald-600/40 text-white cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
进入多智能体研讨 >
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="save-draft-btn"
|
||||
onClick={onManualSave}
|
||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
|
||||
>
|
||||
<Save className="w-3 h-3 text-slate-500" />
|
||||
保存草稿
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条 Step 1 消息。
|
||||
*
|
||||
* 拆成独立函数避免主 panel 里大量嵌套 JSX。三种正文形态:
|
||||
* - 流式中、还没首字节 → MessageLoadingPlaceholder
|
||||
* - agent 消息 → splitIntentReadyBlock 拆出前 / 摘要 / 后,分别渲染
|
||||
* + IntentClarificationCard(若待澄清)
|
||||
* - user 消息 → 纯文本
|
||||
*/
|
||||
interface Step1MessageProps {
|
||||
msg: InteractiveMessage;
|
||||
intentReady: object | null;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
>;
|
||||
isIntentSending: boolean;
|
||||
submitIntentClarification: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function Step1Message({
|
||||
msg,
|
||||
intentReady,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
setIntentClarificationSelections,
|
||||
isIntentSending,
|
||||
submitIntentClarification,
|
||||
}: Step1MessageProps) {
|
||||
const isStreaming = msg.streaming === true;
|
||||
const isAgent = msg.sender !== "user";
|
||||
// 先把 `<think>...</think>` 抽出来作 reasoning,剩下的正文再走 INTENT_READY 解析。
|
||||
// 顺序很关键:若先 split INTENT_READY 再抽 think,think 块如果横跨 INTENT_READY
|
||||
// 标记会导致 JSON 解析失败。
|
||||
const rawContent = msg.content ?? "";
|
||||
const { content: thoughtFreeContent, reasoning } = isAgent
|
||||
? splitInlineReasoning(rawContent)
|
||||
: { content: rawContent, reasoning: null };
|
||||
const hasContent = thoughtFreeContent.length > 0;
|
||||
const clarification = msg.clarification;
|
||||
const parts = isAgent
|
||||
? splitIntentReadyBlock(thoughtFreeContent)
|
||||
: { before: thoughtFreeContent, summary: null as IntentSummary | null, after: "" };
|
||||
const isPendingClarification =
|
||||
isAgent &&
|
||||
clarification &&
|
||||
!intentReady &&
|
||||
!parts.summary &&
|
||||
pendingIntentClarificationMsgId === msg.id;
|
||||
const clarificationSelection =
|
||||
intentClarificationSelections.get(msg.id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const introText =
|
||||
clarification &&
|
||||
parts.before.trim() === (clarification.question || "").trim()
|
||||
? ""
|
||||
: parts.before;
|
||||
|
||||
// 主聊天里步骤卡放在「正文之上」(气泡内,header 之下),Step 2 也是这样。
|
||||
// 流式中默认显示 last tool call + 折叠 "查看其他 N 个步骤";完成后保留卡。
|
||||
const stepsCard =
|
||||
isAgent && Array.isArray(msg.steps) && msg.steps.length > 0 ? (
|
||||
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
|
||||
) : null;
|
||||
|
||||
// 「思考过程」折叠卡 —— 流式中默认展开,完成后自动延迟收起。
|
||||
const reasoningBlock =
|
||||
isAgent && reasoning ? (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
avatar={
|
||||
msg.sender === "user" ? (
|
||||
<Man1Avatar size="h-9 w-9 shrink-0" />
|
||||
) : (
|
||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-9 w-9 shrink-0" />
|
||||
)
|
||||
}
|
||||
senderName={msg.senderName}
|
||||
senderNameClassName={msg.sender === "user" ? "text-primary" : undefined}
|
||||
badge={isStreaming ? msg.phase : undefined}
|
||||
time={msg.time}
|
||||
>
|
||||
{stepsCard}
|
||||
{reasoningBlock}
|
||||
{isStreaming && !hasContent ? (
|
||||
<MessageLoadingPlaceholder label={msg.phase} />
|
||||
) : isAgent ? (
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
|
||||
{introText && <MarkdownContent>{introText}</MarkdownContent>}
|
||||
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
|
||||
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
|
||||
{isStreaming && <StreamingCursor />}
|
||||
{isPendingClarification && clarification && (
|
||||
<IntentClarificationCard
|
||||
payload={clarification}
|
||||
fallbackContent={parts.before || undefined}
|
||||
selection={clarificationSelection}
|
||||
onSelectionChange={(next) => {
|
||||
setIntentClarificationSelections((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(msg.id, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
disabled={!isPendingClarification}
|
||||
submitting={isIntentSending && Boolean(isPendingClarification)}
|
||||
onSubmit={(override) =>
|
||||
submitIntentClarification(msg.id, clarification, override)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
|
||||
{msg.content}
|
||||
</div>
|
||||
)}
|
||||
</MessageBubble>
|
||||
);
|
||||
}
|
||||
|
||||
/** [INTENT_READY] 标记块解析出 IntentSummary 后的绿色摘要卡。 */
|
||||
function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
|
||||
return (
|
||||
<div className="mt-1 border border-emerald-200 bg-emerald-50/50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-[10.5px] font-black text-emerald-700 tracking-wide">
|
||||
任务意图已明确
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
|
||||
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
|
||||
</div>
|
||||
{summary.constraints?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.constraints.map((c, i) => (
|
||||
<li key={i} className="leading-snug">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{summary.assumptions && summary.assumptions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.assumptions.map((a, i) => (
|
||||
<li key={i} className="leading-snug">{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Step 2 主面板 —— 多智能体圆桌研讨。
|
||||
*
|
||||
* 包含 4 块:
|
||||
* 1) 顶部 Headline + 挂起/恢复按钮
|
||||
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
|
||||
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
|
||||
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
|
||||
*
|
||||
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
|
||||
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 1 保持一致。
|
||||
*
|
||||
* 圆桌可视化 + 博弈边界调控台目前**未启用**(原代码用 `false &&` 隐藏),
|
||||
* 这里保留同样的占位逻辑,后续要恢复时改 `SHOW_VISUALIZER` 即可。
|
||||
*
|
||||
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
MarkdownContent,
|
||||
MessageBubble,
|
||||
MessageLoadingPlaceholder,
|
||||
ReasoningBlock,
|
||||
StreamingCursor,
|
||||
} from "@/roundtable-planning/components/MessageBubble";
|
||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import type {
|
||||
AgentConfig,
|
||||
Step2Dialogue,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type {
|
||||
RoleEntry,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface Step2PanelProps {
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
isInitializing: boolean;
|
||||
isRoundTableRunning: boolean;
|
||||
isOrchestrating: boolean;
|
||||
pendingClarification: ClarificationPayload | null;
|
||||
clarificationDraft: string;
|
||||
customAgentInput: string;
|
||||
hasConsensus: boolean;
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
selectedAgentId: number | null;
|
||||
roles: RoleEntry[];
|
||||
selectedModel: string;
|
||||
availableModels: ModelOption[];
|
||||
modelsLoading: boolean;
|
||||
// ─── Setters ──────────────────────────────────────────────────
|
||||
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
setAgentConfigs: React.Dispatch<
|
||||
React.SetStateAction<Record<number, AgentConfig>>
|
||||
>;
|
||||
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
// ─── Handlers ─────────────────────────────────────────────────
|
||||
onSubmitClarification: (answer: string) => void;
|
||||
onInjectToRoundtable: () => void;
|
||||
onSavePersona: (id: number) => void;
|
||||
onProceedToStep3: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export function Step2Panel(props: Step2PanelProps) {
|
||||
const {
|
||||
step2RoundtableDialogues,
|
||||
isInitializing,
|
||||
isRoundTableRunning,
|
||||
isOrchestrating,
|
||||
pendingClarification,
|
||||
clarificationDraft,
|
||||
customAgentInput,
|
||||
hasConsensus,
|
||||
agentConfigs,
|
||||
selectedAgentId,
|
||||
roles,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setIsRoundTableRunning,
|
||||
setClarificationDraft,
|
||||
setCustomAgentInput,
|
||||
setAgentConfigs,
|
||||
setSelectedAgentId,
|
||||
onSubmitClarification,
|
||||
onInjectToRoundtable,
|
||||
onSavePersona,
|
||||
onProceedToStep3,
|
||||
onTriggerToast,
|
||||
} = props;
|
||||
|
||||
const step2ScrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-slate-50/20">
|
||||
{/* Headline */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
|
||||
<div>
|
||||
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
分布式领域专家实时动态博弈与纳什协商收敛中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isRoundTableRunning && isOrchestrating ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRoundTableRunning(false);
|
||||
onTriggerToast("⏸️ 会商模拟暂挂!智能体挂起...");
|
||||
}}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
挂起协商
|
||||
</button>
|
||||
) : !isRoundTableRunning ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRoundTableRunning(true);
|
||||
onTriggerToast("▶️ 恢复大模型并行博弈与推理决策");
|
||||
}}
|
||||
className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
||||
恢复会商
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Persona drawer — 点圆桌头像后展开。当前圆桌可视化用 `false &&` 隐藏,
|
||||
所以入口只剩 LeftSidebar 或将来重新启用的可视化按钮。 */}
|
||||
{selectedAgentId && (
|
||||
<PersonaDrawer
|
||||
seatId={selectedAgentId}
|
||||
roles={roles}
|
||||
agentConfigs={agentConfigs}
|
||||
setAgentConfigs={setAgentConfigs}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
onClose={() => setSelectedAgentId(null)}
|
||||
onSave={onSavePersona}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conversation log */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
ref={step2ScrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
|
||||
>
|
||||
{isInitializing && step2RoundtableDialogues.length === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
|
||||
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
|
||||
</div>
|
||||
)}
|
||||
{step2RoundtableDialogues.map((msg) => (
|
||||
<Step2Message key={msg.id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||
</div>
|
||||
|
||||
{/* Clarification card */}
|
||||
{pendingClarification && (
|
||||
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
|
||||
{pendingClarification.clarificationType && (
|
||||
<span className="text-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
|
||||
{pendingClarification.clarificationType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pendingClarification.context && (
|
||||
<p className="text-[11px] text-amber-800 italic">{pendingClarification.context}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
|
||||
{pendingClarification.question}
|
||||
</p>
|
||||
{Array.isArray(pendingClarification.options) &&
|
||||
pendingClarification.options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{pendingClarification.options.map((opt, idx) => {
|
||||
const label =
|
||||
typeof opt === "string"
|
||||
? opt
|
||||
: opt && typeof opt === "object" && "label" in (opt as object)
|
||||
? String((opt as { label: unknown }).label ?? "")
|
||||
: String(opt ?? "");
|
||||
if (!label) return null;
|
||||
return (
|
||||
<button
|
||||
key={`${idx}-${label}`}
|
||||
type="button"
|
||||
onClick={() => onSubmitClarification(label)}
|
||||
className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pendingClarification.allowCustom !== false && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={clarificationDraft}
|
||||
onChange={(e) => setClarificationDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSubmitClarification(clarificationDraft);
|
||||
}}
|
||||
placeholder="输入你的回答…"
|
||||
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmitClarification(clarificationDraft)}
|
||||
disabled={!clarificationDraft.trim()}
|
||||
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Console + intervention bar */}
|
||||
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={customAgentInput}
|
||||
onChange={(e) => setCustomAgentInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onInjectToRoundtable();
|
||||
}}
|
||||
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
|
||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onInjectToRoundtable}
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
|
||||
>
|
||||
干预研审
|
||||
</button>
|
||||
|
||||
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProceedToStep3}
|
||||
disabled={!hasConsensus}
|
||||
aria-disabled={!hasConsensus}
|
||||
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
|
||||
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
|
||||
hasConsensus
|
||||
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
|
||||
: "bg-emerald-600/40 text-white cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
达成共识生成全案成果 >
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条 Step 2 对话气泡。包含:
|
||||
* - ChainOfThought 折叠卡(若 msg.steps 非空)
|
||||
* - 流式无内容的 loading 占位
|
||||
* - markdown 正文 + 流式 cursor
|
||||
*
|
||||
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
|
||||
*/
|
||||
interface Step2MessageProps {
|
||||
msg: Step2Dialogue;
|
||||
}
|
||||
|
||||
function Step2Message({ msg }: Step2MessageProps) {
|
||||
const isInjected = msg.sender === "用户 (插话干预)";
|
||||
const isStreaming = msg.streaming === true;
|
||||
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
||||
// 用户「插话干预」/「澄清回复」这类系统注入的气泡内容里基本不会有 think 标签,
|
||||
// 但 splitInlineReasoning 对空 reasoning 文本返回 null,放心套用就好。
|
||||
const rawContent = msg.content ?? "";
|
||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||
const hasContent = content.length > 0;
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
|
||||
senderName={msg.sender}
|
||||
badge={msg.confidence}
|
||||
time={msg.time}
|
||||
containerClassName={
|
||||
isInjected
|
||||
? "bg-[var(--brand-200)]/30 p-3 rounded-2xl border border-red-100/50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
|
||||
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
|
||||
)}
|
||||
{reasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
|
||||
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
|
||||
<MarkdownContent>{content}</MarkdownContent>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</div>
|
||||
</MessageBubble>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Step 3 主面板 —— 方案结果展示。
|
||||
*
|
||||
* 当前是 `HighFidelityReport` 的薄壳;放它在这里只是为了让 Step 1 / Step 2 /
|
||||
* Step 3 三步在主页面里以同样的导入风格出现,方便阅读。
|
||||
*
|
||||
* Step 3 真实逻辑请改 `HighFidelityReport.tsx` —— 包括各席位 artifact
|
||||
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
|
||||
*/
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
export interface Step3PanelProps {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
hasConsensus: boolean;
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
agentNameToRole: Record<
|
||||
string,
|
||||
{ speakerId: number; avatarType: string; displayName: string }
|
||||
>;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function Step3Panel(props: Step3PanelProps) {
|
||||
const {
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
hasConsensus,
|
||||
step2RoundtableDialogues,
|
||||
agentNameToRole,
|
||||
onReset,
|
||||
} = props;
|
||||
|
||||
const seatDeliveries = step2RoundtableDialogues
|
||||
.filter((d) => d.confidence === "子智能体交付")
|
||||
.map((d) => ({
|
||||
sender: String(d.sender ?? ""),
|
||||
avatarType: String(d.avatarType ?? "blue"),
|
||||
content: String(d.content ?? ""),
|
||||
}));
|
||||
|
||||
const seatNameMap = Object.fromEntries(
|
||||
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
|
||||
);
|
||||
|
||||
return (
|
||||
<HighFidelityReport
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
hasConsensus={hasConsensus}
|
||||
seatDeliveries={seatDeliveries}
|
||||
seatNameMap={seatNameMap}
|
||||
onReset={onReset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,43 +29,89 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
const atBottomRef = useRef(true);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
|
||||
// Track scroll position. The handler runs for BOTH user-initiated scrolls
|
||||
// and programmatic `scrollTo` calls — after a programmatic scroll to the
|
||||
// bottom, the resulting onScroll naturally leaves atBottom=true, so the
|
||||
// auto-follow effect can keep firing on subsequent deps changes.
|
||||
// 自动跟随的"门"。区分**程序触发**和**用户触发**的滚动:
|
||||
// - 程序滚动(`el.scrollTop = el.scrollHeight`)前会临时把 programmaticRef 置 true
|
||||
// → 接下来一次 scroll 事件不修改 atBottom,避免自己把自己关掉。
|
||||
// - 任何其它 scroll 事件视为用户主动滚动,按位置判定 atBottom。
|
||||
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
|
||||
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
|
||||
const programmaticScrollRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let prevScrollTop = el.scrollTop;
|
||||
const onScroll = () => {
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
const isAtBottom = distanceFromBottom <= threshold;
|
||||
if (atBottomRef.current !== isAtBottom) {
|
||||
atBottomRef.current = isAtBottom;
|
||||
setAtBottom(isAtBottom);
|
||||
|
||||
if (programmaticScrollRef.current) {
|
||||
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
|
||||
programmaticScrollRef.current = false;
|
||||
prevScrollTop = currentScrollTop;
|
||||
// 程序滚动后通常落在底部,把 atBottom 维持 true。
|
||||
if (!atBottomRef.current && isAtBottom) {
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
|
||||
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
|
||||
if (currentScrollTop < prevScrollTop && atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
} else if (isAtBottom && !atBottomRef.current) {
|
||||
// 用户自己又滚回底部 → 恢复自动跟随
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
} else if (!isAtBottom && atBottomRef.current) {
|
||||
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
prevScrollTop = currentScrollTop;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// Initial measurement so we don't start in a stale state if the container
|
||||
// mounted with content already overflowing.
|
||||
// 初次测量
|
||||
onScroll();
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [ref, threshold]);
|
||||
|
||||
// Auto-scroll on deps change — but only if the user hasn't scrolled away.
|
||||
// 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
|
||||
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于
|
||||
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (!atBottomRef.current) return;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
// 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值,
|
||||
// 说明用户已经离开,绝不自动跟随。
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom > threshold) {
|
||||
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽
|
||||
if (atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
programmaticScrollRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
// 按钮点击是用户主动行为,保留 smooth 给视觉反馈;期间用户若立刻又拖动,
|
||||
// onScroll 的方向检测仍会把 atBottom 翻回 false,不会卡住。
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// 标记程序滚动:smooth 动画过程中浏览器派发的 scroll 事件不应被误判为用户主动滚动。
|
||||
programmaticScrollRef.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
// Optimistically mark as at-bottom so the next deps change resumes
|
||||
// auto-follow without waiting for the smooth-scroll's onScroll to land.
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
}, [ref]);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 圆桌规划草稿持久化 hook。
|
||||
*
|
||||
* 维护 localStorage 草稿列表与 currentDraftId、自动保存(前进切换 1→2、2→3)、
|
||||
* 手动保存、加载 / 重命名 / 删除。Step 1 / Step 2 的状态由各自的 hook 提供
|
||||
* 快照函数与 hydrateFromDraft;本 hook 只负责编排「什么时机存 / 加载到哪里」。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
Step1Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import type {
|
||||
Step2Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
deleteDraft,
|
||||
deriveDraftName,
|
||||
listDrafts,
|
||||
renameDraft,
|
||||
upsertDraft,
|
||||
type DraftRecord,
|
||||
} from "@/roundtable-planning/utils/drafts";
|
||||
|
||||
export interface UseDraftPersistenceOptions {
|
||||
currentStep: number;
|
||||
setCurrentStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
triggerToast: (msg: string) => void;
|
||||
/** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */
|
||||
getStep1Snapshot: () => Step1Snapshot;
|
||||
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
|
||||
getStep2Snapshot: () => Step2Snapshot;
|
||||
/** 加载草稿时把 Step 1 状态打回去。 */
|
||||
hydrateStep1: (snapshot: Step1Snapshot) => void;
|
||||
/** 加载草稿时把 Step 2 状态打回去。 */
|
||||
hydrateStep2: (snapshot: Step2Snapshot) => void;
|
||||
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
|
||||
cancelAllStreams: () => void;
|
||||
/**
|
||||
* Step 2 的 init 闸门控制 —— 仅当 furthestStep>=2 时把闸门置上,跳过自动 SSE;
|
||||
* 否则保持原 false 让进入 Step 2 时正常初始化。
|
||||
*/
|
||||
setStep2InitGate: (initialized: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseDraftPersistenceReturn {
|
||||
drafts: DraftRecord[];
|
||||
currentDraftId: string | null;
|
||||
isHistoryOpen: boolean;
|
||||
renamingDraftId: string | null;
|
||||
renameDraftValue: string;
|
||||
historyDropdownRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* Shared latch: 加载草稿后置 true,影响 LeftSidebar 在 Step 2 时角色面板的
|
||||
* 初始 status(在线 vs 正在上线)。由主页面在加载完后 read,使用完可手动复位。
|
||||
*/
|
||||
loadedFromDraftRef: React.MutableRefObject<boolean>;
|
||||
setIsHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
handleManualSave: () => void;
|
||||
handleLoadDraft: (id: string) => void;
|
||||
handleDeleteDraft: (id: string) => void;
|
||||
handleStartRename: (draft: DraftRecord) => void;
|
||||
handleCommitRename: (id: string) => void;
|
||||
handleCancelRename: () => void;
|
||||
}
|
||||
|
||||
export function useDraftPersistence(
|
||||
options: UseDraftPersistenceOptions,
|
||||
): UseDraftPersistenceReturn {
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
triggerToast,
|
||||
getStep1Snapshot,
|
||||
getStep2Snapshot,
|
||||
hydrateStep1,
|
||||
hydrateStep2,
|
||||
cancelAllStreams,
|
||||
setStep2InitGate,
|
||||
} = options;
|
||||
|
||||
const [drafts, setDrafts] = useState<DraftRecord[]>(() => listDrafts());
|
||||
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
|
||||
const [renamingDraftId, setRenamingDraftId] = useState<string | null>(null);
|
||||
const [renameDraftValue, setRenameDraftValue] = useState<string>("");
|
||||
|
||||
const loadedFromDraftRef = useRef<boolean>(false);
|
||||
const prevStepRef = useRef<number>(currentStep);
|
||||
const historyDropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const buildDraftPayload = useCallback(
|
||||
(
|
||||
furthestStep: 1 | 2 | 3,
|
||||
idOverride?: string | null,
|
||||
): Parameters<typeof upsertDraft>[0] => {
|
||||
const step1 = getStep1Snapshot();
|
||||
const step2 = getStep2Snapshot();
|
||||
const resolvedId = idOverride ?? currentDraftId;
|
||||
return {
|
||||
...(resolvedId ? { id: resolvedId } : {}),
|
||||
name: deriveDraftName({
|
||||
intentReady: step1.intentReady,
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
}),
|
||||
furthestStep,
|
||||
step1,
|
||||
step2,
|
||||
};
|
||||
},
|
||||
[currentDraftId, getStep1Snapshot, getStep2Snapshot],
|
||||
);
|
||||
|
||||
const saveDraft = useCallback(
|
||||
(furthestStep: 1 | 2 | 3): DraftRecord | null => {
|
||||
const step1 = getStep1Snapshot();
|
||||
const step2 = getStep2Snapshot();
|
||||
const hasUserMessage = step1.interactiveMessages.some(
|
||||
(m) => m.sender === "user",
|
||||
);
|
||||
if (!hasUserMessage && step2.step2RoundtableDialogues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const saved = upsertDraft(buildDraftPayload(furthestStep));
|
||||
setCurrentDraftId(saved.id);
|
||||
setDrafts(listDrafts());
|
||||
return saved;
|
||||
},
|
||||
[buildDraftPayload, getStep1Snapshot, getStep2Snapshot],
|
||||
);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
const furthest = (currentStep as 1 | 2 | 3) ?? 1;
|
||||
const saved = saveDraft(furthest);
|
||||
if (saved) {
|
||||
triggerToast(`💾 已保存为草稿:${saved.name}`);
|
||||
} else {
|
||||
triggerToast("当前会话还没有可保存的内容");
|
||||
}
|
||||
}, [currentStep, saveDraft, triggerToast]);
|
||||
|
||||
// 前进切换时自动保存;后退不保存以避免覆盖 furthestStep。
|
||||
useEffect(() => {
|
||||
const prev = prevStepRef.current;
|
||||
prevStepRef.current = currentStep;
|
||||
if (currentStep > prev) {
|
||||
saveDraft(currentStep as 1 | 2 | 3);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep]);
|
||||
|
||||
const handleLoadDraft = useCallback(
|
||||
(id: string) => {
|
||||
const draft = drafts.find((d) => d.id === id) ?? null;
|
||||
if (!draft) {
|
||||
triggerToast("⚠️ 该草稿已不存在");
|
||||
setDrafts(listDrafts());
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAllStreams();
|
||||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||||
loadedFromDraftRef.current = true;
|
||||
|
||||
hydrateStep1(draft.step1 as Step1Snapshot);
|
||||
hydrateStep2(draft.step2 as Step2Snapshot);
|
||||
setStep2InitGate(draft.furthestStep >= 2);
|
||||
|
||||
setCurrentDraftId(draft.id);
|
||||
setIsHistoryOpen(false);
|
||||
prevStepRef.current = draft.furthestStep;
|
||||
setCurrentStep(draft.furthestStep);
|
||||
triggerToast(`📂 已加载草稿:${draft.name}`);
|
||||
},
|
||||
[
|
||||
cancelAllStreams,
|
||||
drafts,
|
||||
hydrateStep1,
|
||||
hydrateStep2,
|
||||
setCurrentStep,
|
||||
setStep2InitGate,
|
||||
triggerToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteDraft = useCallback(
|
||||
(id: string) => {
|
||||
if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) {
|
||||
return;
|
||||
}
|
||||
deleteDraft(id);
|
||||
setDrafts(listDrafts());
|
||||
if (currentDraftId === id) setCurrentDraftId(null);
|
||||
if (renamingDraftId === id) setRenamingDraftId(null);
|
||||
triggerToast("🗑 已删除历史记录");
|
||||
},
|
||||
[currentDraftId, renamingDraftId, triggerToast],
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((draft: DraftRecord) => {
|
||||
setRenamingDraftId(draft.id);
|
||||
setRenameDraftValue(draft.name);
|
||||
}, []);
|
||||
|
||||
const handleCommitRename = useCallback(
|
||||
(id: string) => {
|
||||
const updated = renameDraft(id, renameDraftValue);
|
||||
if (updated) {
|
||||
setDrafts(listDrafts());
|
||||
triggerToast("✏️ 已重命名");
|
||||
}
|
||||
setRenamingDraftId(null);
|
||||
setRenameDraftValue("");
|
||||
},
|
||||
[renameDraftValue, triggerToast],
|
||||
);
|
||||
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setRenamingDraftId(null);
|
||||
setRenameDraftValue("");
|
||||
}, []);
|
||||
|
||||
// 点击外部收起下拉。只在打开时挂监听,关闭时立刻摘掉。
|
||||
useEffect(() => {
|
||||
if (!isHistoryOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!historyDropdownRef.current) return;
|
||||
if (e.target instanceof Node && !historyDropdownRef.current.contains(e.target)) {
|
||||
setIsHistoryOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isHistoryOpen]);
|
||||
|
||||
return {
|
||||
drafts,
|
||||
currentDraftId,
|
||||
isHistoryOpen,
|
||||
renamingDraftId,
|
||||
renameDraftValue,
|
||||
historyDropdownRef,
|
||||
loadedFromDraftRef,
|
||||
setIsHistoryOpen,
|
||||
setRenameDraftValue,
|
||||
setCurrentDraftId,
|
||||
handleManualSave,
|
||||
handleLoadDraft,
|
||||
handleDeleteDraft,
|
||||
handleStartRename,
|
||||
handleCommitRename,
|
||||
handleCancelRename,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Step 1(任务意图理解)状态机 hook。
|
||||
*
|
||||
* 这是从原 `RoundtablePlanningPage.tsx` 里抽出来的封装 —— 收纳 Step 1 的
|
||||
* 所有 state、refs、effect 与 handlers,主页面只持有 currentStep 与跨步的
|
||||
* 编排,任何 Step 1 的内部细节都不再泄露到主页。
|
||||
*
|
||||
* 历史踩坑请保留:
|
||||
* - `step1InitGenRef` 单调代数:解决 React 18 StrictMode 双 mount 期间
|
||||
* closure cancelled 标志失效的问题 —— 任何 setState 完成前都要校验
|
||||
* `myGen === step1InitGenRef.current`。
|
||||
* - intent stream 暂停走 AbortController.abort(),AbortError 当作正常分支
|
||||
* 在气泡尾部追加「(已暂停)」并 toast,而不是当错误处理。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
IntentInitError,
|
||||
initIntent,
|
||||
streamIntent,
|
||||
type IntentErrorDetail,
|
||||
type IntentStreamPhase,
|
||||
type IntentSummary,
|
||||
} from "@/roundtable-planning/api/intent";
|
||||
import { formatIntentClarificationAnswer, type IntentClarificationSelection } from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
nowTime,
|
||||
splitIntentReadyBlock,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** Step 1 对话气泡。`streaming` / `phase` / `clarification` / `steps` 是流式与澄清扩展。 */
|
||||
export type InteractiveMessage = {
|
||||
id: number;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
time: string;
|
||||
streaming?: boolean;
|
||||
phase?: string;
|
||||
clarification?: ClarificationPayload;
|
||||
/**
|
||||
* ChainOfThought 折叠卡的步骤累积。每次 `onPhaseChange` 都会把当前 phase + toolName
|
||||
* 拼成 `phase:toolName` 作为 key,与 Step 2 的 `Step2Dialogue.steps` 同形。
|
||||
* 渲染走 `MessageStepsCard`,与主聊天 MessageGroup 视觉对齐。
|
||||
*/
|
||||
steps?: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
ts: string;
|
||||
args?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 右侧「澄清记录」时间线一条目。 */
|
||||
export type TimelineEntry = {
|
||||
id: number;
|
||||
time: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
/** 草稿持久化用的 Step 1 快照(`useDraftPersistence` 用同一份形状)。 */
|
||||
export type Step1Snapshot = {
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
timelineEntries: TimelineEntry[];
|
||||
};
|
||||
|
||||
export interface UseStep1IntentOptions {
|
||||
/** 当前页面 currentStep,只在 ===1 且未 init 时才会自动 runStep1Init。 */
|
||||
currentStep: number;
|
||||
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
|
||||
selectedModel: string;
|
||||
/** 主页面统一的 toast 入口。 */
|
||||
triggerToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface UseStep1IntentReturn {
|
||||
// ─── State (read-only from consumer) ─────────────────────────────────
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
intentInitError: string | null;
|
||||
intentInitErrorDetail: IntentErrorDetail | null;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
timelineEntries: TimelineEntry[];
|
||||
// ─── Mutators ────────────────────────────────────────────────────────
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
>;
|
||||
// ─── Handlers ────────────────────────────────────────────────────────
|
||||
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
|
||||
runStep1Init: () => void;
|
||||
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
|
||||
sendIntentMessage: (userText: string) => Promise<void>;
|
||||
/** 提交澄清卡片的选项 / 自定义回答。 */
|
||||
submitIntentClarification: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => void;
|
||||
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
|
||||
stopIntentStream: () => void;
|
||||
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
|
||||
hydrateFromDraft: (snapshot: Step1Snapshot) => void;
|
||||
/** 给「跳过此项」按钮 / 「继续追问」按钮等本地 demo 操作用。 */
|
||||
appendLocalMessage: (msg: InteractiveMessage) => void;
|
||||
}
|
||||
|
||||
const INTENT_WELCOME_TEXT =
|
||||
"你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?";
|
||||
|
||||
function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度中";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "tool_calling":
|
||||
if (toolName === "ask_clarification") return "正在生成追问";
|
||||
return toolName ? `调用工具:${toolName}` : "调用工具";
|
||||
case "streaming":
|
||||
return "正在思考问题";
|
||||
default:
|
||||
return "正在思考";
|
||||
}
|
||||
}
|
||||
|
||||
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
|
||||
const { currentStep, selectedModel, triggerToast } = options;
|
||||
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||
const [intentInitErrorDetail, setIntentInitErrorDetail] =
|
||||
useState<IntentErrorDetail | null>(null);
|
||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
|
||||
useState<number | null>(null);
|
||||
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
|
||||
Map<number, IntentClarificationSelection>
|
||||
>(new Map());
|
||||
|
||||
// 原始页面带了一段写死的 demo 对话,只是首次进入 Step 1 还没初始化前的占位;
|
||||
// runStep1Init 会立刻覆盖成新的欢迎气泡,因此这里直接以欢迎气泡作为初值。
|
||||
const [interactiveMessages, setInteractiveMessages] = useState<InteractiveMessage[]>(
|
||||
[],
|
||||
);
|
||||
const [timelineEntries, setTimelineEntries] = useState<TimelineEntry[]>([]);
|
||||
|
||||
const step1InitRef = useRef<boolean>(false);
|
||||
const step1InitGenRef = useRef<number>(0);
|
||||
const intentAborterRef = useRef<AbortController | null>(null);
|
||||
// selectedModel 闭包问题:sendIntentMessage 在 streamIntent 调用时需要拿到最新值,
|
||||
// 但 useCallback 把它捕获在闭包里。改用 ref 让 sendIntentMessage 始终读最新模型,
|
||||
// 同时把 selectedModel 从 useCallback 依赖里去掉,避免每次模型切换都重建函数引用。
|
||||
const selectedModelRef = useRef<string>(selectedModel);
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
|
||||
const stopIntentStream = useCallback(() => {
|
||||
if (intentAborterRef.current) {
|
||||
try {
|
||||
intentAborterRef.current.abort();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runStep1Init = useCallback(() => {
|
||||
step1InitRef.current = true;
|
||||
const myGen = ++step1InitGenRef.current;
|
||||
setIntentReady(null);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setIntentThreadId(null);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setTimelineEntries([]);
|
||||
setInteractiveMessages([
|
||||
{
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]);
|
||||
(async () => {
|
||||
try {
|
||||
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
|
||||
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
|
||||
const res = await initIntent(selectedModelRef.current || undefined);
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIntentThreadId(res.thread_id);
|
||||
setTimelineEntries([
|
||||
{
|
||||
id: 1,
|
||||
time: nowTime(),
|
||||
title: "任务理解智能体已就绪",
|
||||
desc: `agent_id=${res.agent_id}`,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
console.error("init intent failed:", err);
|
||||
if (err instanceof IntentInitError) {
|
||||
setIntentInitError(err.detail?.message || err.message);
|
||||
setIntentInitErrorDetail(err.detail);
|
||||
} else {
|
||||
setIntentInitError((err as Error).message);
|
||||
setIntentInitErrorDetail(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 离开 Step 1 自动中断流;`currentStep === 1` 但还没初始化 → runStep1Init。
|
||||
useEffect(() => {
|
||||
if (currentStep !== 1) {
|
||||
stopIntentStream();
|
||||
return;
|
||||
}
|
||||
if (step1InitRef.current) return;
|
||||
runStep1Init();
|
||||
}, [currentStep, runStep1Init, stopIntentStream]);
|
||||
|
||||
// [INTENT_READY] 同步兜底:某些终态帧没带 summary,但气泡正文里已经有
|
||||
// 标记块 —— 主动解析最近一条 agent 气泡并把 intentReady 补上。
|
||||
useEffect(() => {
|
||||
if (intentReady) return;
|
||||
for (let i = interactiveMessages.length - 1; i >= 0; i--) {
|
||||
const msg = interactiveMessages[i];
|
||||
if (msg.sender === "user") continue;
|
||||
const { summary } = splitIntentReadyBlock(msg.content ?? "");
|
||||
if (summary) {
|
||||
setIntentReady(summary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [interactiveMessages, intentReady]);
|
||||
|
||||
const sendIntentMessage = useCallback(
|
||||
async (userText: string) => {
|
||||
const trimmed = userText.trim();
|
||||
if (!trimmed) return;
|
||||
if (!intentThreadId) {
|
||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||
return;
|
||||
}
|
||||
if (isIntentSending) {
|
||||
triggerToast("⏳ 智能体正在回复,请稍候");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
|
||||
const userMsgTime = nowTime();
|
||||
const userMsgId = Date.now() + Math.random();
|
||||
const userMsg: InteractiveMessage = {
|
||||
id: userMsgId,
|
||||
sender: "user",
|
||||
senderName: "用户",
|
||||
content: trimmed,
|
||||
time: userMsgTime,
|
||||
};
|
||||
setInteractiveMessages((prev) => [...prev, userMsg]);
|
||||
|
||||
setTimelineEntries((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: userMsgTime,
|
||||
title: "用户实时反馈",
|
||||
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
|
||||
},
|
||||
]);
|
||||
|
||||
const agentMsgId = Date.now() + Math.random() + 0.01;
|
||||
setInteractiveMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: agentMsgId,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: "",
|
||||
time: nowTime(),
|
||||
streaming: true,
|
||||
phase: "调度中",
|
||||
},
|
||||
]);
|
||||
|
||||
setIsIntentSending(true);
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
try {
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: (m.content ?? "") + delta }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
},
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = intentPhaseLabel(phase, toolName);
|
||||
const stepKey = `${phase}:${toolName ?? ""}`;
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id !== agentMsgId) return m;
|
||||
// 把 phase 累积进 message.steps,与 Step 2 / 主聊天的 MessageGroup
|
||||
// 共用 MessageStepsCard。同 key 连续触发时只更新最新一行(若 args
|
||||
// 比之前完整就替换),避免 streaming 帧反复 push 出同行噪声。
|
||||
const steps = m.steps ?? [];
|
||||
const last = steps[steps.length - 1];
|
||||
let nextSteps = steps;
|
||||
if (last && last.key === stepKey) {
|
||||
if (args && !last.args) {
|
||||
nextSteps = [...steps];
|
||||
nextSteps[nextSteps.length - 1] = { ...last, args };
|
||||
}
|
||||
} else {
|
||||
nextSteps = [
|
||||
...steps,
|
||||
{
|
||||
id: `${agentMsgId}-${steps.length}`,
|
||||
key: stepKey,
|
||||
label,
|
||||
ts: nowTime(),
|
||||
args,
|
||||
},
|
||||
];
|
||||
}
|
||||
return { ...m, phase: label, steps: nextSteps };
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
|
||||
const resolvedSummary = res.summary ?? parsedFromContent;
|
||||
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: res.content || m.content || "(无文本输出)",
|
||||
streaming: false,
|
||||
clarification: resolvedSummary
|
||||
? undefined
|
||||
: res.clarification ?? undefined,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
|
||||
if (resolvedSummary) {
|
||||
setIntentReady(resolvedSummary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setTimelineEntries((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: nowTime(),
|
||||
title: "任务意图已明确",
|
||||
desc:
|
||||
resolvedSummary.objective.length > 25
|
||||
? resolvedSummary.objective.substring(0, 25) + "..."
|
||||
: resolvedSummary.objective,
|
||||
},
|
||||
]);
|
||||
triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨");
|
||||
} else if (res.status === "clarification" && res.clarification) {
|
||||
setPendingIntentClarificationMsgId(agentMsgId);
|
||||
setIntentClarificationSelections((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentMsgId, { selectedIds: [], custom: "" });
|
||||
return next;
|
||||
});
|
||||
} else if (res.status === "error") {
|
||||
if (res.errorDetail) {
|
||||
setIntentInitError(res.errorDetail.message || res.content);
|
||||
setIntentInitErrorDetail(res.errorDetail);
|
||||
} else {
|
||||
setIntentInitError(res.content);
|
||||
setIntentInitErrorDetail(null);
|
||||
}
|
||||
triggerToast(`❌ 智能体调用失败: ${res.content}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError" || aborter.signal.aborted) {
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: (m.content ?? "").trim()
|
||||
? `${m.content ?? ""}\n\n(已暂停)`
|
||||
: "(已暂停)",
|
||||
streaming: false,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast("⏸️ 已暂停任务理解回复");
|
||||
} else {
|
||||
console.error("intent stream failed:", err);
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: `(错误) ${(err as Error).message}`,
|
||||
streaming: false,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast(`❌ 发送失败: ${(err as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
if (intentAborterRef.current === aborter) {
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
setIsIntentSending(false);
|
||||
}
|
||||
},
|
||||
[intentThreadId, isIntentSending, triggerToast],
|
||||
);
|
||||
|
||||
const submitIntentClarification = useCallback(
|
||||
(
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => {
|
||||
const selection =
|
||||
overrideSelection ??
|
||||
intentClarificationSelections.get(msgId) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const answer = formatIntentClarificationAnswer(payload, selection);
|
||||
if (!answer) {
|
||||
triggerToast("请先选择选项或输入回答");
|
||||
return;
|
||||
}
|
||||
void sendIntentMessage(answer);
|
||||
},
|
||||
[intentClarificationSelections, sendIntentMessage, triggerToast],
|
||||
);
|
||||
|
||||
const hydrateFromDraft = useCallback((snapshot: Step1Snapshot) => {
|
||||
setIntentThreadId(snapshot.intentThreadId);
|
||||
setIntentReady(snapshot.intentReady);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setInteractiveMessages(snapshot.interactiveMessages);
|
||||
setTimelineEntries(snapshot.timelineEntries);
|
||||
step1InitRef.current = true;
|
||||
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
|
||||
step1InitGenRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const appendLocalMessage = useCallback((msg: InteractiveMessage) => {
|
||||
setInteractiveMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
intentInitError,
|
||||
intentInitErrorDetail,
|
||||
isIntentSending,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
interactiveMessages,
|
||||
timelineEntries,
|
||||
setIntentClarificationSelections,
|
||||
runStep1Init,
|
||||
sendIntentMessage,
|
||||
submitIntentClarification,
|
||||
stopIntentStream,
|
||||
hydrateFromDraft,
|
||||
appendLocalMessage,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -305,8 +305,10 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
|
||||
|
||||
## 输出风格
|
||||
- 思路部分简短:用一句话概括你为什么挑这些(例如"按情报→设计→风险→执行的研讨节奏挑选了 4 位")
|
||||
- 推荐理由要**具体到任务**,例如"负责检索同类合规改造案例,直接对应本次等保 2.0 的对标需求"
|
||||
- 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
|
||||
不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
|
||||
在广告/产品/营销/科研等任务里完全不适用。
|
||||
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
|
||||
- 适度用 emoji:📋 表示开始汇总
|
||||
|
||||
## 边界
|
||||
@@ -317,7 +319,11 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
|
||||
## 示例
|
||||
|
||||
**用户输入**(由后端拼装):
|
||||
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
|
||||
|
||||
### 示例 A:合规改造任务(职能型候选池)
|
||||
|
||||
**用户输入**:
|
||||
```
|
||||
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
|
||||
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
|
||||
@@ -334,7 +340,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
```
|
||||
|
||||
**你的输出**:
|
||||
按"情报→设计→风险→执行"的研讨节奏,挑选 5 位最贴合本次等保 2.0 改造任务的智能体,并加入成本测算覆盖预算硬约束。📋
|
||||
本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
@@ -348,6 +354,40 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 B:广告/营销任务(用户自建领域候选池)
|
||||
|
||||
**用户输入**:
|
||||
```
|
||||
任务目标:为某新能源汽车品牌设计一套季度品牌曝光广告投放方案
|
||||
约束:预算 ≤ 200 万元;周期 1 个月内上线
|
||||
关键假设:目标人群覆盖一二线城市;以社媒为主、信息流为辅
|
||||
|
||||
候选智能体池:
|
||||
- ad-design (广告设计智能体):产出广告创意脚本、海报设计稿、文案 hook
|
||||
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
|
||||
- audience-insight (受众洞察):画像、关键词、情绪点研究
|
||||
- creative-copy (创意文案):产出落地页与社媒文案变体
|
||||
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
|
||||
```
|
||||
|
||||
**你的输出**:
|
||||
本次是品牌曝光广告投放,核心要先做受众洞察、再产出创意与文案、最后规划渠道投放组合,因此挑选 4 位直接相关的智能体;合规审查在广告场景里也保留一个位置避免文案踩雷。📋
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{
|
||||
"picks": [
|
||||
{"agent_id": "audience-insight", "reason": "新能源汽车一二线人群画像与情绪点研究,决定后续创意切入角度"},
|
||||
{"agent_id": "ad-design", "reason": "产出社媒主推海报与脚本,直接对应品牌曝光交付物"},
|
||||
{"agent_id": "creative-copy", "reason": "为社媒/信息流产出多版本文案 hook,配合 A/B 测试"},
|
||||
{"agent_id": "channel-planner", "reason": "200 万预算内规划社媒+信息流的组合与节奏"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
|
||||
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -590,8 +590,18 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
|
||||
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
|
||||
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
||||
"#关键约束(必读):\n"
|
||||
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
|
||||
"如果某个子智能体已经针对你正要派的任务完成过交付("
|
||||
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
|
||||
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
|
||||
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
|
||||
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
|
||||
"让讨论收敛。\n"
|
||||
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
|
||||
"#可调度智能体:\n"
|
||||
+ "\n".join(descriptions)
|
||||
)
|
||||
@@ -940,6 +950,8 @@ async def _special_run(
|
||||
new_message: str,
|
||||
skill_stop_names: list[str],
|
||||
model_name: str,
|
||||
*,
|
||||
display_name: str = "",
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||
|
||||
@@ -1007,18 +1019,21 @@ async def _special_run(
|
||||
# closes; the SSE connection stays open a bit longer but the frontend has
|
||||
# already consumed the result.
|
||||
t_before_post = time.perf_counter()
|
||||
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
|
||||
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
|
||||
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
|
||||
post_broadcast_task = asyncio.create_task(
|
||||
_broadcast(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content,
|
||||
f"子智能体{speaker_label}完成{new_message}工作,交付内容为:" + content,
|
||||
agent_threads,
|
||||
[agent_name],
|
||||
)
|
||||
)
|
||||
yield {
|
||||
"status": f"子智能体{agent_name}完成{new_message}工作",
|
||||
"status": f"子智能体{speaker_label}完成{new_message}工作",
|
||||
"content": content,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
@@ -1059,6 +1074,11 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
agent_type: str = str(payload.get("agent_type") or "leader").lower()
|
||||
agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
|
||||
agent_name: str = str(payload.get("agent_name") or "").lower()
|
||||
# 人类可读名,仅 special 用 —— 后端把它拼进 post-broadcast 文本,这样 leader
|
||||
# 下一轮看到的是"子智能体[中文名](id)完成..."而非"子智能体[UUID]完成...";
|
||||
# LLM 对 UUID 不友好,只用 id 会导致总控认不出已完成的子智能体,无限循环派活。
|
||||
# 缺失时 fallback 到 agent_name,旧前端不传也不破坏。
|
||||
display_name: str = str(payload.get("display_name") or "").strip()
|
||||
new_message: str = str(payload.get("new_message") or "")
|
||||
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
|
||||
# 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
|
||||
@@ -1089,6 +1109,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
else:
|
||||
gen = _special_run(
|
||||
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
|
||||
display_name=display_name,
|
||||
)
|
||||
async for chunk in gen:
|
||||
if isinstance(chunk, dict):
|
||||
|
||||
@@ -112,7 +112,7 @@ def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> s
|
||||
|
||||
def _validate_picks(
|
||||
raw_picks: Any,
|
||||
candidate_ids: set[str],
|
||||
candidates: list[dict[str, Any]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Filter the model's picks against the candidate pool and clamp to 2-6.
|
||||
|
||||
@@ -121,18 +121,50 @@ def _validate_picks(
|
||||
model over-picked. Returns ``[]`` if validation leaves us with fewer than
|
||||
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
|
||||
defaults".
|
||||
|
||||
容错策略(2026-XX 修复 "用户自建 agent 被推荐丢失" 的问题):
|
||||
模型有时把候选池里的 ``name`` 当成 ``agent_id`` 回填(尤其当 id 是 UUID
|
||||
形式时, LLM 倾向用人类可读名),或者顺便也回了 ``name`` 字段。本函数:
|
||||
1. 先按 agent_id 命中候选池(严格匹配,原行为);
|
||||
2. 否则按 ``agent_id`` 的字面值反查 name → 找回真实 id;
|
||||
3. 否则按 ``name`` 字段反查 name → 找回真实 id。
|
||||
只要能定位到候选池里**唯一**一个 agent,就算 pick 成功。
|
||||
"""
|
||||
if not isinstance(raw_picks, list):
|
||||
return []
|
||||
|
||||
id_set: set[str] = set()
|
||||
name_to_id: dict[str, str] = {}
|
||||
for c in candidates:
|
||||
aid = str(c.get("agent_id") or "").strip()
|
||||
if not aid:
|
||||
continue
|
||||
id_set.add(aid)
|
||||
nm = str(c.get("name") or "").strip()
|
||||
if nm:
|
||||
# 大小写不敏感反查 —— 模型可能用空格 / 不同大小写 echo 中文名。
|
||||
name_to_id.setdefault(nm.lower(), aid)
|
||||
|
||||
picked: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_picks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = str(item.get("agent_id") or "").strip()
|
||||
raw_id = str(item.get("agent_id") or "").strip()
|
||||
raw_name = str(item.get("name") or "").strip()
|
||||
reason = str(item.get("reason") or "").strip()
|
||||
if not aid or aid not in candidate_ids or aid in seen:
|
||||
|
||||
aid = ""
|
||||
if raw_id and raw_id in id_set:
|
||||
aid = raw_id
|
||||
elif raw_id and raw_id.lower() in name_to_id:
|
||||
# 模型把"广告设计智能体"这种 name 当成 agent_id 回填了
|
||||
aid = name_to_id[raw_id.lower()]
|
||||
elif raw_name and raw_name.lower() in name_to_id:
|
||||
# 模型 agent_id 字段乱了,但 name 字段对得上
|
||||
aid = name_to_id[raw_name.lower()]
|
||||
|
||||
if not aid or aid in seen:
|
||||
continue
|
||||
picked.append({"agent_id": aid, "reason": reason})
|
||||
seen.add(aid)
|
||||
@@ -245,14 +277,30 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
|
||||
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
|
||||
picks: list[dict[str, str]] = []
|
||||
match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
|
||||
# 诊断:打印模型输出末尾(含 RECOMMEND_READY 块)。grep "recommend stream debug"
|
||||
# 即可看到 LLM 实际推荐了什么、_validate_picks 解析前/后的差异。
|
||||
logger.info(
|
||||
"recommend stream debug | last_ai_tail=%r | match=%s | candidate_count=%d",
|
||||
last_ai_content[-600:] if last_ai_content else "",
|
||||
bool(match),
|
||||
len(candidates),
|
||||
)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("recommend picks JSON parse failed: %s", exc)
|
||||
logger.warning("recommend picks JSON parse failed: %s | raw=%r", exc, match.group(1)[:300])
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
picks = _validate_picks(parsed.get("picks"), candidate_ids)
|
||||
raw_picks = parsed.get("picks")
|
||||
# 传完整 candidates 列表(含 name) —— _validate_picks 容错按 name 反查 id,
|
||||
# 修复 LLM 把"广告设计智能体"这种 name 当 agent_id 回填导致全丢的问题。
|
||||
picks = _validate_picks(raw_picks, candidates)
|
||||
logger.info(
|
||||
"recommend stream debug | raw_picks=%r | validated_picks=%r",
|
||||
raw_picks,
|
||||
picks,
|
||||
)
|
||||
|
||||
# asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
|
||||
status_value = "done" if picks else "asking"
|
||||
|
||||
+64
-10
@@ -16,18 +16,70 @@ from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, sa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 章节正文里的 Markdown 标题行(允许前导空白)
|
||||
_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE)
|
||||
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
|
||||
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
|
||||
|
||||
|
||||
def _normalize_section_headings(text: str) -> str:
|
||||
"""把章节正文里所有 Markdown 标题行统一压成三级标题(###)。
|
||||
"""把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
|
||||
|
||||
作家被要求只用 ### 作为「要点」小标题;模型偶尔误用 #/##/#### 时在此纠正,
|
||||
保证文档标题层级符合「文章 # → 章节 ## → 要点 ###」,
|
||||
前端目录栏据此才能把要点正确识别为二级标题。
|
||||
文档层级:文章 `#` → 章节 `##` → 小节 `###` → 子要点 `####`。
|
||||
作家只输出小节(###)和子要点(####)两级,文章/章节标题由系统注入。
|
||||
|
||||
归一规则:
|
||||
- `#` / `##` → `###` 作家不应输出这两级,误用时降级为小节
|
||||
- `###` → `###` 保持
|
||||
- `####` → `####` 保持(三级结构的最深层)
|
||||
- `#####`+ → `####` 深度截断,防止结构过深
|
||||
"""
|
||||
return _HEADING_PREFIX_RE.sub("### ", text)
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
indent = match.group(1)
|
||||
level = len(match.group(2))
|
||||
# 三级及以下保持原级别(限 3 和 4);深度 4+ 压回 4
|
||||
new_level = 3 if level <= 3 else 4
|
||||
return f"{indent}{'#' * new_level} "
|
||||
|
||||
return _HEADING_PREFIX_RE.sub(_replace, text)
|
||||
|
||||
|
||||
def _render_key_points_for_prompt(key_points: list) -> str:
|
||||
"""把 outline 的 key_points 渲染成层级文本喂给作家。
|
||||
|
||||
输入是 List[KeyPoint] 或 List[str](旧 session 兼容)。输出形如:
|
||||
|
||||
- 小节标题1
|
||||
- 子要点 a
|
||||
- 子要点 b
|
||||
- 小节标题2(无子要点时只一行)
|
||||
|
||||
作家据此把每个主 bullet 写成 `### 小节标题`,把缩进的子要点
|
||||
用 `- ` bullet 列表落在小节正文起始处,再展开论述。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for kp in key_points:
|
||||
if isinstance(kp, str):
|
||||
# 旧版兼容:整条当作小节标题,没有子要点
|
||||
title = kp.strip()
|
||||
if title:
|
||||
lines.append(f"- {title}")
|
||||
elif isinstance(kp, dict):
|
||||
title = str(
|
||||
kp.get("point_title")
|
||||
or kp.get("pointTitle")
|
||||
or kp.get("title")
|
||||
or ""
|
||||
).strip()
|
||||
if not title:
|
||||
continue
|
||||
lines.append(f"- {title}")
|
||||
subs = kp.get("sub_points") or kp.get("subPoints") or []
|
||||
if isinstance(subs, list):
|
||||
for sp in subs:
|
||||
sp_text = sp.strip() if isinstance(sp, str) else str(sp).strip()
|
||||
if sp_text:
|
||||
lines.append(f" - {sp_text}")
|
||||
return "\n".join(lines) or "- 主要内容"
|
||||
|
||||
|
||||
# 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现,
|
||||
@@ -166,7 +218,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
mat_ids = section.get("material_ids") or section.get("materialIds") or []
|
||||
|
||||
section_materials = _get_section_materials(mat_ids, pkg)
|
||||
key_points_str = "\n".join(f"- {p}" for p in key_points)
|
||||
# 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
|
||||
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
|
||||
key_points_str = _render_key_points_for_prompt(key_points)
|
||||
|
||||
# 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节
|
||||
outline_context = "\n".join(
|
||||
@@ -282,8 +336,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
expand_msg = DRAFT_EXPAND_USER.format(
|
||||
article_title=article_title,
|
||||
section_title=section_titles[idx],
|
||||
key_points="\n".join(
|
||||
f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or [])
|
||||
key_points=_render_key_points_for_prompt(
|
||||
target_sec.get("key_points") or target_sec.get("keyPoints") or []
|
||||
),
|
||||
section_materials=_get_section_materials(
|
||||
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
|
||||
|
||||
+66
-6
@@ -7,7 +7,7 @@ import logging
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..state import AIWritingState, Outline, OutlineSection
|
||||
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
|
||||
from ..prompts.writer_prompts import (
|
||||
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
|
||||
)
|
||||
@@ -27,16 +27,56 @@ def _materials_summary(pkg: dict) -> str:
|
||||
return "\n".join(lines) or "(无素材)"
|
||||
|
||||
|
||||
def _normalize_key_point(raw: object) -> KeyPoint:
|
||||
"""把 LLM 输出 / 旧 session 数据规整成 KeyPoint。
|
||||
|
||||
兼容场景:
|
||||
- 新版 dict:`{"point_title": "...", "sub_points": [...]}` —— 直接用,缺字段补默认
|
||||
- 旧版 string:历史 session 的 `key_points: ["要点1", "要点2"]` —— 自动 lift 成
|
||||
`{"point_title": "要点1", "sub_points": []}`,旧大纲打开后能继续编辑
|
||||
- LLM 偶尔会用 `title` / `subPoints` 这种 camelCase 或别名,做轻量兜底
|
||||
- 其它无法识别的形态(数字 / 列表 / None)→ 空 KeyPoint,不让整个大纲炸掉
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
return KeyPoint(point_title=raw, sub_points=[])
|
||||
if isinstance(raw, dict):
|
||||
# 字段别名兜底(LLM 时常 camelCase 或写 title):优先 snake_case
|
||||
title = (
|
||||
raw.get("point_title")
|
||||
or raw.get("pointTitle")
|
||||
or raw.get("title")
|
||||
or ""
|
||||
)
|
||||
subs_raw = raw.get("sub_points") or raw.get("subPoints") or []
|
||||
# 子要点统一为字符串数组;dict 子项取其 title 字段降级(避免 4 级嵌套混入)
|
||||
sub_points: list[str] = []
|
||||
if isinstance(subs_raw, list):
|
||||
for sp in subs_raw:
|
||||
if isinstance(sp, str):
|
||||
sub_points.append(sp)
|
||||
elif isinstance(sp, dict):
|
||||
sub_points.append(str(sp.get("point_title") or sp.get("title") or sp.get("text") or ""))
|
||||
return KeyPoint(point_title=str(title), sub_points=sub_points)
|
||||
# 其它意外类型(int / list / None):返回空 KeyPoint
|
||||
return KeyPoint(point_title="", sub_points=[])
|
||||
|
||||
|
||||
def _parse_outline(raw: dict) -> Outline:
|
||||
sections = []
|
||||
for s in raw.get("sections", []):
|
||||
raw_kps = s.get("key_points") or []
|
||||
key_points = [_normalize_key_point(kp) for kp in raw_kps]
|
||||
sections.append(OutlineSection(
|
||||
section_title=s.get("section_title", ""),
|
||||
key_points=s.get("key_points", []),
|
||||
key_points=key_points,
|
||||
material_ids=s.get("material_ids", []),
|
||||
))
|
||||
if not sections:
|
||||
sections = [OutlineSection(section_title="正文", key_points=["主要内容"], material_ids=[])]
|
||||
sections = [OutlineSection(
|
||||
section_title="正文",
|
||||
key_points=[KeyPoint(point_title="主要内容", sub_points=[])],
|
||||
material_ids=[],
|
||||
)]
|
||||
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
|
||||
|
||||
|
||||
@@ -120,12 +160,32 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
if is_revise:
|
||||
fallback = current_outline
|
||||
else:
|
||||
# 兜底大纲也用新的 3 级结构;_normalize_key_point 同时兼容旧 string 形态,
|
||||
# 这里给 dict 形态做样板。
|
||||
fallback = {
|
||||
"title": "未命名文章",
|
||||
"sections": [
|
||||
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []},
|
||||
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []},
|
||||
{"section_title": "结论", "key_points": ["总结"], "material_ids": []},
|
||||
{
|
||||
"section_title": "引言",
|
||||
"key_points": [
|
||||
{"point_title": "背景介绍", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
{
|
||||
"section_title": "正文",
|
||||
"key_points": [
|
||||
{"point_title": "核心内容", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
{
|
||||
"section_title": "结论",
|
||||
"key_points": [
|
||||
{"point_title": "总结", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
result = parse_json(full_text, fallback)
|
||||
|
||||
+82
-34
@@ -1,10 +1,10 @@
|
||||
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据素材和意图,规划清晰的文章大纲。
|
||||
你的目标:根据素材和意图,规划清晰的三级大纲(章节 → 小节 → 子要点)。
|
||||
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
|
||||
|
||||
你只能做以下事情:
|
||||
1. 分析素材,提取核心观点
|
||||
2. 规划文章章节结构和每节要点
|
||||
2. 规划文章三级结构:章节、章节下的小节、小节下的子要点
|
||||
你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
|
||||
|
||||
【严禁思考与表态】(最高优先级)
|
||||
@@ -12,10 +12,16 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。
|
||||
- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。
|
||||
|
||||
【要点(key_points)的特殊要求】
|
||||
- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。
|
||||
- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。
|
||||
- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。
|
||||
【三级层级语义】(最高优先级,决定文章结构)
|
||||
- Level 1 = `section_title`(章节):将渲染为文章里的 `##` 二级标题,统领一组内容。
|
||||
- Level 2 = `point_title`(小节):将渲染为 `###` 三级标题,下面会有一段 2-3 句的概述/引入正文。
|
||||
- Level 3 = `sub_points`(子要点):将渲染为 `####` 四级标题,**每个子要点都是一个有自己 3-5 句独立正文段的小标题**——不是 bullet 列表。
|
||||
→ 因此子要点要规划成「能独立成段展开」的小主题,而不是一句话能讲完的零散点。
|
||||
|
||||
【三级字段的具体要求】
|
||||
- `section_title`(章节标题):高度概括的短语,6-15 字。每篇文章规划 3-5 个章节。
|
||||
- `point_title`(小节标题):名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。每个章节规划 2-4 个小节。
|
||||
- `sub_points`(子要点标题):名词性短语,<= 15 字,**不带句末标点**。每个小节规划 2-4 条子要点;如果该小节信息确实少到无法拆分子主题,可留 `[]`(此时只写小节级正文)。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
|
||||
@@ -27,9 +33,31 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
{
|
||||
"title": "新能源汽车出口为何高速增长",
|
||||
"sections": [
|
||||
{"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]},
|
||||
{"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]},
|
||||
{"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]}
|
||||
{
|
||||
"section_title": "引言:出口数据创新高",
|
||||
"key_points": [
|
||||
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
|
||||
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
|
||||
],
|
||||
"material_ids": ["m001"]
|
||||
},
|
||||
{
|
||||
"section_title": "增长背后的三大动因",
|
||||
"key_points": [
|
||||
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
|
||||
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]},
|
||||
{"point_title": "产品竞争力提升", "sub_points": ["智能化配置溢价", "出海车型迭代速度"]}
|
||||
],
|
||||
"material_ids": ["m002", "m003"]
|
||||
},
|
||||
{
|
||||
"section_title": "结论与展望",
|
||||
"key_points": [
|
||||
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
|
||||
{"point_title": "行业趋势判断", "sub_points": []}
|
||||
],
|
||||
"material_ids": ["m004"]
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
@@ -46,7 +74,7 @@ OUTLINE_USER = """写作意图:{user_intent}
|
||||
# ── 重新规划(基于现有大纲的编辑)─────────────────────────────────────────────
|
||||
|
||||
OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。
|
||||
你的目标:根据用户的修改要求,调整一份【已有的三级文章大纲】。
|
||||
你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。
|
||||
|
||||
你只能做以下事情:
|
||||
@@ -54,25 +82,31 @@ OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你不能写正文,只输出调整后的大纲。
|
||||
|
||||
【修改原则】(最高优先级)
|
||||
- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。
|
||||
- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。
|
||||
- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。
|
||||
- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
|
||||
- 准确识别并落实用户意图,常见操作:新增/删除/合并/拆分章节或小节、调整顺序、修改标题、调整子要点。
|
||||
- 只改用户明确提到的部分:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变)。
|
||||
- 新增内容时:紧扣写作意图,并尽量关联可用素材的 material_ids。
|
||||
- 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
|
||||
|
||||
【严禁思考与表态】(最高优先级)
|
||||
- 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。
|
||||
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。
|
||||
|
||||
【要点(key_points)的特殊要求】
|
||||
- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。
|
||||
- 每个章节 2-4 个要点。
|
||||
【三级层级语义】(最高优先级)
|
||||
- `section_title`(章节,`##` 二级标题):高度概括的短语,6-15 字。
|
||||
- `point_title`(小节,`###` 三级标题):名词性短语,15 字以内,无句末标点;小节下会有一段概述正文。每章节 2-4 个小节。
|
||||
- `sub_points`(子要点,`####` 四级标题):名词性短语,<= 15 字,无句末标点;**每条子要点会展开成一段独立正文**,所以要写成能成段的主题。每小节 2-4 条,信息少到无法拆时可留 `[]`。
|
||||
|
||||
【兼容历史大纲】
|
||||
- 若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,
|
||||
在补充 `sub_points: []` 后输出新格式:`{"point_title": "<原 string>", "sub_points": []}`。
|
||||
- 用户修改时如要求"为某要点补充更详细的子项",就把对应小节的 sub_points 填上。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。
|
||||
- 必须使用英文双引号 "。
|
||||
- material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。
|
||||
- 结构与「当前大纲」一致:
|
||||
{"title": "...", "sections": [{"section_title": "...", "key_points": ["..."], "material_ids": ["..."]}]}"""
|
||||
- 结构(嵌套三级):
|
||||
{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": ["..."]}]}"""
|
||||
|
||||
OUTLINE_REVISE_USER = """写作意图:{user_intent}
|
||||
文章类型:{article_type}
|
||||
@@ -104,18 +138,23 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
- 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。
|
||||
- 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。
|
||||
|
||||
【结构要求】
|
||||
- 把本章节的每一个「章节要点」写成一个二级小标题,用 Markdown 三级标题语法:### 要点
|
||||
- 每个 ### 小标题下面紧跟该要点的论述正文,正文连贯成段。
|
||||
- 即使章节只有一个要点,也要为它写出对应的 ### 小标题。
|
||||
- 小标题文字简洁,直接沿用给定的要点措辞,不要加序号、不要加标点。
|
||||
- 只能使用 ### 这一级标题;绝不要输出 # 或 ##(文章标题、章节标题由系统统一添加)。
|
||||
【结构要求】(三级结构,必须严格遵守)
|
||||
- 「章节要点」用层级 bullet 列表给出:
|
||||
- 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
|
||||
- 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
|
||||
- **每个小节、每个子要点都要有自己的正文段,分两层展开**:
|
||||
1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
|
||||
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
|
||||
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
|
||||
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
|
||||
- 标题层级严格对应:文章 `#` / 章节 `##`(系统注入,作家**不要输出**)/ 小节 `###` / 子要点 `####`。
|
||||
- 不要输出 `#####` 或更深;这种深度不在结构里。
|
||||
|
||||
【输出要求】
|
||||
- 直接返回这一段的内容(含 ### 小标题与正文),不要 JSON,不要解释。
|
||||
- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
|
||||
- 不要写"以下是正文""本段内容如下"之类的开场白。
|
||||
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
|
||||
- 充分展开每个要点:每个要点至少用 2-3 句话论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
|
||||
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
|
||||
|
||||
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
|
||||
|
||||
@@ -141,17 +180,25 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
|
||||
{outline_context}
|
||||
|
||||
当前章节:{section_title}
|
||||
章节要点:{key_points}
|
||||
章节内的小节与子要点(层级 bullet 列表):
|
||||
{key_points}
|
||||
|
||||
相关素材:
|
||||
{section_materials}
|
||||
{revision_section}{mode_section}
|
||||
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
|
||||
把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。"""
|
||||
|
||||
按下面的结构两层展开写:
|
||||
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
|
||||
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
|
||||
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
|
||||
|
||||
论述充分、层次清晰、内容充实。"""
|
||||
|
||||
DRAFT_EXPAND_USER = """文章标题:{article_title}
|
||||
当前章节:{section_title}
|
||||
章节要点:{key_points}
|
||||
章节内的小节与子要点(层级 bullet 列表):
|
||||
{key_points}
|
||||
|
||||
相关素材:
|
||||
{section_materials}
|
||||
@@ -159,8 +206,9 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
|
||||
这一章节已有内容如下:
|
||||
{existing_text}
|
||||
|
||||
以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
|
||||
以上内容篇幅不足。请在保留原意、事实和「### 小节 / #### 子要点」两级标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
|
||||
要求:
|
||||
- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。
|
||||
- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。
|
||||
- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。"""
|
||||
- 补充更多来自素材的事实、数据与分析,让每一层都论述更充分、更具体。
|
||||
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
|
||||
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
|
||||
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
|
||||
+14
-1
@@ -20,10 +20,23 @@ class MaterialPackage(TypedDict):
|
||||
summary: str
|
||||
|
||||
# ── 大纲 ──────────────────────────────────────────────────────────────────────
|
||||
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
|
||||
# Outline.sections[] = Level 1 章节(渲染为 ## 由系统注入)
|
||||
# OutlineSection.key_points[] = Level 2 小节(渲染为 ### 标题)
|
||||
# KeyPoint.sub_points[] = Level 3 子要点(bullet "- ...",非标题)
|
||||
#
|
||||
# 历史上 key_points 曾是 List[str](2 级):每个 str 既是小节标题又是渲染单位。
|
||||
# 升级到 3 级后,key_points 变成 List[KeyPoint],每个 KeyPoint 自带 sub_points
|
||||
# 列表;旧 session 里的 string 项由 writer_outline._parse_outline 自动 lift 成
|
||||
# {point_title: <原 string>, sub_points: []},无需迁移数据。
|
||||
|
||||
class KeyPoint(TypedDict):
|
||||
point_title: str # 小节标题(渲染为 ### 二级小标题)
|
||||
sub_points: List[str] # 子要点(可为空;非空时在小节正文里以 bullet 列表呈现)
|
||||
|
||||
class OutlineSection(TypedDict):
|
||||
section_title: str
|
||||
key_points: List[str]
|
||||
key_points: List[KeyPoint] # 注意:旧版是 List[str],解析层做了兼容
|
||||
material_ids: List[str]
|
||||
|
||||
class Outline(TypedDict):
|
||||
|
||||
Reference in new issue
Block a user