This commit is contained in:
董宏凯 committed 2026-05-26 21:10:33 +08:00
commit 67152f1c83
14 files changed
+1541 -158

No files matched your search

+670
View File
@@ -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;
}
```
+2 -2
View File
@@ -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}
+8 -6
View File
@@ -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>
+3 -3
View File
@@ -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);
+12
View File
@@ -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",
};
/**
@@ -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))
@@ -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" />
生成的报告文件
@@ -1,7 +1,16 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
DotIcon,
LightbulbIcon,
Loader2Icon,
RefreshCwIcon,
SparklesIcon,
} from "lucide-react";
import {
Dialog,
@@ -12,7 +21,15 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { listAgents, type Agent } from "@/core/agents";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils";
import type { IntentSummary } from "../api/intent";
@@ -43,7 +60,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 +88,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 +119,7 @@ const INITIAL_STATE: InternalState = {
phase: "idle",
rationale: "",
streamPhase: null,
steps: [],
candidates: [],
reasons: {},
recommendedIds: [],
@@ -100,6 +127,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 +238,10 @@ export function RecommendAgentsDialog({
candidates,
rationale: "",
streamPhase: "connecting",
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
steps: [
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
],
}));
try {
@@ -227,7 +276,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 +412,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]);
@@ -390,7 +459,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 +488,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}
@@ -490,6 +559,24 @@ export function RecommendAgentsDialog({
}
function RationaleBlock({ state }: { state: InternalState }) {
// 默认展开:推荐流程一开始就让用户能看到生成过程。
// 生成完毕后(isStreaming 翻成 false)自动收起 —— 让 UI 聚焦在 picks 选择上。
// userTouchedRef 标记用户是否手动点过按钮:点过就尊重用户偏好,不再自动收起。
const [expanded, setExpanded] = useState(true);
const userTouchedRef = useRef(false);
// 步骤折叠卡:**始终默认收起**,header 上显示最新一步作为预览。
const [stepsOpen, setStepsOpen] = useState(false);
const isStreaming = state.phase === "recommending";
// 流式期间禁用逐字动画——避免不断 re-tokenize 拖慢渲染;完成后回到普通 markdown。
const rehypePlugins = useRehypeSplitWordsIntoSpans(isStreaming);
// 流式状态切换时,如果用户没有手动调过展开/收起,执行默认行为:
// 流式中 → expanded=true,完成后 → expanded=false。
// 步骤折叠卡不自动跟随 —— 始终默认收起。
useEffect(() => {
if (!userTouchedRef.current) setExpanded(isStreaming);
}, [isStreaming]);
if (state.phase === "loading_agents") {
return (
<div className="flex items-center gap-2 text-muted-foreground">
@@ -498,52 +585,178 @@ function RationaleBlock({ state }: { state: InternalState }) {
</div>
);
}
if (state.phase === "recommending") {
const label =
state.streamPhase === "connecting"
if (state.phase === "error") {
return (
<div className="text-xs text-destructive">
推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。
</div>
);
}
const showRationaleStage =
state.phase === "recommending" ||
state.phase === "ready" ||
state.phase === "fallback";
if (!showRationaleStage) {
return null;
}
const streamingLabel =
state.phase === "recommending"
? state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐...";
: "正在生成推荐..."
: null;
const hasRationale = !!state.rationale;
return (
<div className="space-y-1">
<div className="space-y-1.5">
{streamingLabel && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />
{label}
{streamingLabel}
</div>
{state.rationale ? (
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
{state.rationale}
</p>
) : (
)}
{/* 推荐流程阶段时间线(视觉与 chats/new 消息列表的 ChainOfThought 对齐):
**始终默认收起**,收起 header 上显示最新一步作为预览。
streaming 用 LightbulbIcon,其它阶段用 DotIcon —— 推荐流程没有 tool_calls,
所以不需要 chats 那套完整的工具图标分支。 */}
{state.steps.length > 0 && (() => {
const lastStep = state.steps[state.steps.length - 1];
const LastIcon = lastStep?.key === "streaming" ? LightbulbIcon : DotIcon;
return (
<ChainOfThought
className="rounded-md border border-border/50 bg-muted/30 px-3 py-2"
open={stepsOpen}
onOpenChange={setStepsOpen}
>
<ChainOfThoughtHeader
className="text-[11px]"
icon={
// 仅「生成中 + 收起」时用最新一步的图标作进度预览;完成后用默认脑子图标。
!stepsOpen && isStreaming ? (
<LastIcon className="size-3.5 text-muted-foreground" />
) : undefined
}
>
{stepsOpen
? "步骤"
: isStreaming
? (lastStep?.label ?? `查看 ${state.steps.length} 个步骤`)
: "步骤列表"}
</ChainOfThoughtHeader>
<ChainOfThoughtContent>
{state.steps.map((s, i) => (
<ChainOfThoughtStep
key={s.id}
icon={s.key === "streaming" ? LightbulbIcon : DotIcon}
label={
<span className="text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">{s.label}</span>
<span className="ml-2 text-[10px] font-mono opacity-60">{s.ts}</span>
</span>
}
status={
isStreaming && i === state.steps.length - 1 ? "active" : "complete"
}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
})()}
{!hasRationale && isStreaming && (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
{!hasRationale && !isStreaming && (
<div className="text-xs text-muted-foreground">推荐完成。</div>
)}
{hasRationale && (
<>
{/* 按钮在上、内容在下 —— 展开/收起按钮始终留在原位,避免点开时
内容把按钮挤到屏幕外,用户需要滚回去才能再点收起。
收起时完全不渲染内容区,只保留按钮(图标 + "思考过程")。
streamingLabel 已经在上方独立显示阶段进度,这里不重复 spinner。 */}
<button
type="button"
onClick={() => {
userTouchedRef.current = true;
setExpanded((v) => !v);
}}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{expanded ? (
<ChevronUpIcon className="size-3" />
) : (
<ChevronDownIcon className="size-3" />
)}
思考过程
</button>
{expanded && (
<div className="max-h-96 overflow-y-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 text-xs text-foreground leading-relaxed">
<MarkdownContent
content={state.rationale}
isLoading={isStreaming}
rehypePlugins={rehypePlugins}
className="text-xs"
/>
</div>
)}
</>
)}
</div>
);
}
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 }) {
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>
<div className="grid gap-2">{children}</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 +784,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",
@@ -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]);
@@ -44,6 +44,13 @@ import remarkGfm from "remark-gfm";
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtSearchResult,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { useModels } from "@/core/models/hooks";
@@ -111,6 +118,15 @@ import {
Loader2,
History,
Trash2,
// 步骤折叠卡的图标 —— 跟 chats/new 的 message-group ToolCall 用的一套保持一致,
// 让圆桌 Step 2 与主聊天里的"步骤"视觉语义统一。
Wrench as WrenchIcon,
Search as SearchIcon,
Lightbulb as LightbulbIcon,
FolderOpen as FolderOpenIcon,
BookOpenText as BookOpenTextIcon,
MessageCircleQuestionMark as MessageCircleQuestionMarkIcon,
Dot as DotIcon,
Pencil,
Upload,
} from "lucide-react";
@@ -251,6 +267,108 @@ function getDisplayNameFor(agent: SelectedAgent): string {
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
}
/** 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
* 数据来自前端 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。
* 与 chats/new 的 message-group ToolCall 子节点保持一致风格。 */
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": {
const query = typeof args.query === "string" ? args.query : "";
return query ? <ChainOfThoughtSearchResult>{query}</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 "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: {
// 兜底:把 args 紧凑展示为 key=value chip
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 的图标 + 文案,
* 与 chats/new 的 message-group ToolCall 视觉语义对齐 —— 工具调用走 WrenchIcon
* + 「使用 "X" 工具」;特殊工具按截图风格特化(搜索 → SearchIcon、文件 → 文件夹图标…)。
* 非工具阶段(connecting / streaming 等)走 fallback label + 简洁的指示点/灯泡图标。 */
function getStepDisplay(
key: string,
fallbackLabel: string,
): { icon: typeof DotIcon; label: string } {
const [phase, toolName] = key.split(":");
if (phase === "tool_calling" && toolName) {
switch (toolName) {
case "ask_clarification":
return { icon: MessageCircleQuestionMarkIcon, label: "需要你的协助" };
case "present_files":
return { icon: FolderOpenIcon, label: "展示文件" };
case "web_search":
return { icon: SearchIcon, label: "搜索相关信息" };
case "read_file":
return { icon: BookOpenTextIcon, label: "读取文件" };
default:
return { icon: WrenchIcon, label: `使用 "${toolName}" 工具` };
}
}
if (phase === "streaming") {
return { icon: LightbulbIcon, label: fallbackLabel };
}
// connecting / connected / tool_result / 其它 — 一律用小点 + fallback 文案
return { icon: DotIcon, label: fallbackLabel };
}
/**
* Build the seat-template list shown in the right-rail role panel.
* The commander always sits at id=1; agents take id=2..N+1, in the order
@@ -490,6 +608,10 @@ export default function RoundtablePlanningPage() {
// Step 2 dialogue list — populated by the real orchestration loop once the
// user enters the roundtable. Starts empty so we never flash mock content.
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<any[]>([]);
// 步骤折叠卡的展开覆盖 dict —— key 是 dialogue.id,value 是用户手动设置的展开状态。
// 未设置(undefined)时由「msg.streaming」决定:streaming 中默认展开,完成后自动收起。
// 用户点击 header 后写入 true/false,从此覆盖默认行为(用户的偏好优先)。
const [openSteps, setOpenSteps] = useState<Record<string | number, boolean>>({});
// Inputs for Step 2 conversation simulation controls
const [isRoundTableRunning, setIsRoundTableRunning] = useState<boolean>(true);
@@ -689,6 +811,49 @@ export default function RoundtablePlanningPage() {
);
}, []);
// 把一条新 phase 步骤追加到 dialogue 的 steps 历史里(参考 chats/new 的
// ChainOfThought 折叠卡)。key 由 phase + toolName 组合,**连续重复时不重复 push**,
// 避免 streaming 帧每来一次都重新打点产生同一行噪声。
// args 是 tool_calling 阶段携带的工具入参(来自上游 LangGraph 的 tool_calls[0].args),
// 渲染层根据工具类型展示路径 chip / 命令代码块 / 搜索词等子节点。
const appendStepIfNew = useCallback((
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setStep2RoundtableDialogues(prev =>
prev.map(d => {
if (d.id !== bubbleId) return d;
const steps: Array<{
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
}> = d.steps ?? [];
const last = steps[steps.length - 1];
// 同 key 已存在 —— 但如果 args 是新拿到的(之前可能只有 toolName,这次有了完整 args),
// 还是要更新这一行;否则直接跳过。
if (last && last.key === key) {
if (args && !last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
}, []);
// Streaming variant: append a placeholder card and return its id so subsequent
// text deltas can be appended in-place via appendToDialogue.
const appendStreamingDialogue = useCallback((card: {
@@ -708,6 +873,8 @@ export default function RoundtablePlanningPage() {
time: nowTime(),
confidence: card.confidence ?? "正在输出…",
streaming: true,
// ChainOfThought 折叠卡用 —— phase 改变时 appendStepIfNew 往里塞。
steps: [] as Array<{ id: string; key: string; label: string; ts: string }>,
},
]);
return id;
@@ -875,8 +1042,10 @@ export default function RoundtablePlanningPage() {
model: selectedModel || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName) => {
setDialogueConfidence(leaderBubbleId, phaseLabel(phase, "leader", toolName));
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
},
});
} catch (err) {
@@ -952,13 +1121,19 @@ export default function RoundtablePlanningPage() {
agentType: "special",
dAgentThreadId: ids,
agentName: subName,
// 把中文显示名一起传后端,广播回 coordinator thread 时拼进
// 「子智能体X完成...」让 LLM 易识别 —— 非内置 agent 的 id 是
// UUID,只用 id 时总控认不出已完成的人,会无限循环派活。
displayName: meta?.displayName,
newMessage: task,
// 优先级: 席位 Persona 覆盖 → 全局选中模型 → 让后端回退到 config.yaml models[0]
model: perSeatModel || selectedModel || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName) => {
setDialogueConfidence(subBubbleId, phaseLabel(phase, "special", toolName));
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
},
});
if (orchestrationCancelRef.current) {
@@ -1001,23 +1176,28 @@ export default function RoundtablePlanningPage() {
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
}, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]);
}, [appendStreamingDialogue, appendToDialogue, appendStepIfNew, setDialogueConfidence, phaseLabel, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]);
// Called by RecommendAgentsDialog onConfirm: update the participant roster,
// reset step-2 init so the new coordinator is created for the new roster,
// then navigate to Step 2.
const handleConfirmAgents = useCallback((agentIds: string[]) => {
const handleConfirmAgents = useCallback(
(agents: Array<{ agent_id: string; name: string }>) => {
setSelectedAgents(
agentIds.map((id) => ({
agent_id: id,
name: BUILTIN_AGENT_META[id]?.displayName ?? id,
agents.map(({ agent_id, name }) => ({
agent_id,
// 内置 agent 的中文显示名固定在 BUILTIN_AGENT_META 里(SOUL 也是按这个名);
// 非内置走对话框传过来的 name —— 来自候选池 agent.name,与 /api/agents 一致。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
})),
);
step2InitRef.current = false;
setIsRecommendDialogOpen(false);
setCurrentStep(2);
setShowToast("成功确认!已切换至第二阶段圆桌研讨系统");
}, []);
},
[],
);
// Public stop handler used by the pause/stop button and the step-2 teardown.
// Sets the cancel flag and aborts every in-flight stream so the loop can
@@ -2154,8 +2334,8 @@ export default function RoundtablePlanningPage() {
{currentStep === 3 ? (
<>
{/* Panel 1: 任务成果概览 (Task Outcomes Overview) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
{/* Panel 1: 任务成果概览 —— 暂时隐藏(数据仍是写死示例,等真实统计接入后再恢复) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">任务成果概览</h2>
@@ -2195,13 +2375,25 @@ export default function RoundtablePlanningPage() {
</div>
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{/* 第一项固定是用户(指挥官);后面动态读 selectedAgents —— 这是用户在
推荐弹窗确认后选定的圆桌席位,跟第二步左侧角色面板一致。
内置 agent 用 BUILTIN_AGENT_META 的中文名 + 颜色;非内置(用户自建)
用 agent.name + FALLBACK_AVATAR_TYPES 轮换上色。 */}
{[
{ id: "human", name: "用户 (指挥官)", role: "人类介入协调", icon: <Man1Avatar size="h-7 w-7" /> },
{ id: "agent2", name: "任务理解与前置专家", role: "理解专家 (Gemini)", icon: <RobotAvatar headColor="#f5222d" size="h-7 w-7" /> },
{ id: "agent3", name: "极端条件信息情报专家", role: "情报情报 (Gemini)", icon: <PurpleRobotAvatar size="h-7 w-7" /> },
{ id: "agent4", name: "算力与热力调度专家", role: "算力评估 (Gemini)", icon: <EmeraldRobotAvatar size="h-7 w-7" /> },
{ id: "agent6", name: "安全合规及风控大管家", role: "安全主管 (Gemini)", icon: <AmberRobotAvatar size="h-7 w-7" /> },
{ id: "agent7", name: "执行序列和路径规划专家", role: "执行规划 (Gemini)", icon: <PinkRobotAvatar size="h-7 w-7" /> },
{
id: "human",
name: "用户 (指挥官)",
role: "人类介入协调",
icon: <Man1Avatar size="h-7 w-7" />,
},
...selectedAgents.map((agent, i) => ({
id: agent.agent_id,
name: getDisplayNameFor(agent),
role: BUILTIN_AGENT_META[agent.agent_id]
? `${BUILTIN_AGENT_META[agent.agent_id].displayName} 席位`
: "圆桌席位",
icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"),
})),
].map((contrib, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100 transition hover:border-emerald-100 hover:bg-emerald-50/10">
<div className="flex items-center gap-2.5">
@@ -2224,8 +2416,8 @@ export default function RoundtablePlanningPage() {
</div>
</div>
{/* Panel 3: 版本记录 (Version History) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
{/* Panel 3: 版本记录 —— 暂时隐藏(无真实版本数据接入) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Clock className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">版本记录</h2>
@@ -2427,8 +2619,10 @@ export default function RoundtablePlanningPage() {
</span>
</div>
{/* Chat list viewport */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 font-normal">
{/* Chat list viewport —— 与第二步消息列表同构:
外层 relative + flex-col 给 ScrollToBottomButton 浮在右下;
内层 step1ScrollRef 直接占满高度滚动,不再嵌套"问答交互区"卡片。 */}
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
{/* Step 1 dialogue is rendered by `interactiveMessages.map()`
inside the 问答交互区 sub-block below. The static demo
@@ -2439,7 +2633,7 @@ export default function RoundtablePlanningPage() {
structured summary; otherwise shows a placeholder with
a disabled "进入下一步" button so the user knows what they
need to wait for. */}
{intentReady ? (
{/* {intentReady ? (
<div className="border border-emerald-200 bg-[#f0fdf4]/60 rounded-2xl p-4.5 shadow-[0_2px_12px_rgba(16,185,129,0.04)]">
<div className="flex items-center justify-between gap-3 border-b border-emerald-100 pb-3 mb-3.5">
<div className="flex items-center gap-2">
@@ -2535,27 +2729,13 @@ export default function RoundtablePlanningPage() {
</div>
)}
</div>
)}
)} */}
{/* --- Q&A INTERACTION AREA (问答交互区) --- */}
<div className="border border-red-100 rounded-2xl bg-slate-50/20 shadow-[0_2px_12px_rgba(245,34,45,0.01)] overflow-hidden">
{/* Inside header banner */}
<div className="px-4 py-2.5 bg-[var(--brand-200)]/40 border-b border-red-100/50 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<HelpCircle className="h-4 w-4 text-primary" strokeWidth={2.5} />
<span className="text-xs font-extrabold text-[var(--brand-950)]">
问答交互区
</span>
</div>
<span className="text-[10px] text-slate-400 font-medium">持续沟通可有效提供细节决策</span>
</div>
{/* Content dialogue lines inside interaction */}
<div className="relative">
{/* Step 1 消息列表 —— 直接铺在主视区,样式与第二步一致(满高 + 滚动 + 大间距)。
原"问答交互区"外框 + header banner 已移除。 */}
<div
ref={step1ScrollRef}
className="p-4.5 space-y-3.5 max-h-[280px] overflow-y-auto"
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white"
>
{interactiveMessages.map((msg) => {
const isStreaming = (msg as { streaming?: boolean }).streaming === true;
@@ -2581,28 +2761,48 @@ export default function RoundtablePlanningPage() {
parts.before.trim() === (clarification.question || "").trim()
? ""
: parts.before;
const phaseLabel = (msg as { phase?: string }).phase;
return (
<div key={msg.id} className="flex items-start justify-between gap-3 border-b border-slate-100/50 pb-2.5 last:border-0 last:pb-0">
<div className="flex items-start gap-2.5 min-w-0">
<div
key={msg.id}
className="flex gap-4 items-start"
>
{msg.sender === "user" ? (
<Man1Avatar size="h-6 w-6" />
<Man1Avatar size="h-9 w-9 shrink-0" />
) : (
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-6 w-6" />
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-9 w-9 shrink-0" />
)}
<div className="min-w-0">
<span className={`text-[10.5px] font-bold block mb-0.5 ${msg.sender === 'user' ? 'text-primary' : 'text-slate-700'}`}>
<div className="flex-1 min-w-0 space-y-1">
{/* Header 行:发言人 + 阶段标签 + 时间 —— 与第二步气泡一致。 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`text-xs font-extrabold ${msg.sender === 'user' ? 'text-primary' : 'text-slate-900'}`}>
{msg.senderName}
</span>
{/* Pre-text spinner: shown only when streaming AND no content has arrived.
Caption reflects the inferred SSE phase so users see what the
backend is doing during the first-token wait. */}
{isStreaming && !hasContent ? (
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 italic">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
{(msg as { phase?: string }).phase ?? "正在连接智能体..."}
{isStreaming && phaseLabel && (
<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">
{phaseLabel}
</span>
)}
</div>
<span className="text-[10px] text-slate-400 font-mono font-medium shrink-0">
{msg.time}
</span>
</div>
{/* 内容区:streaming + 无内容 → 大 spinner + 三点动画;有内容 → markdown / 用户文本。 */}
{isStreaming && !hasContent ? (
<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">{phaseLabel ?? "正在连接智能体..."}</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>
) : isAgent ? (
<div className="text-xs text-slate-700 leading-normal font-normal break-words space-y-2">
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
{introText && (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@@ -2705,24 +2905,18 @@ export default function RoundtablePlanningPage() {
)}
</div>
) : (
<span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words">
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
{msg.content}
</span>
</div>
)}
</div>
</div>
<span className="text-[9px] text-slate-400 font-mono shrink-0">{msg.time}</span>
</div>
);
})}
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
</div>
</div>
</div>
{/* Botton Input Drawer Area (Ask/Reply Box) */}
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
@@ -2897,7 +3091,7 @@ export default function RoundtablePlanningPage() {
{/* Collapsed handle: when the upper panel is hidden, show a thin
bar at the top of the conversation area so the user can bring
it back. */}
{isUpperPanelCollapsed && (
{false && isUpperPanelCollapsed && (
<div className="shrink-0 px-6 py-2 border-b border-slate-100 bg-white flex items-center justify-center">
<button
onClick={() => setIsUpperPanelCollapsed(false)}
@@ -2911,7 +3105,7 @@ export default function RoundtablePlanningPage() {
)}
{/* Split layout: Roundtable visualizer and Interactive Controllers */}
{!isUpperPanelCollapsed && (
{false && !isUpperPanelCollapsed && (
<div className="shrink-0 px-6 py-5 bg-gradient-to-b from-[var(--brand-200)]/10 to-transparent border-b border-slate-100 bg-white relative">
{/* Close button — collapses this whole upper panel so the
conversation log takes the freed vertical space. */}
@@ -3252,6 +3446,17 @@ export default function RoundtablePlanningPage() {
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"
>
{/* 初始化期间(创建协调智能体 + 各席位 thread)对话列表是空的,
会有 1-3 秒 UI 空白让用户感觉卡住 —— 加居中加载占位,
用文案告诉用户后端正在做什么。dialogues.length === 0 收紧条件,
避免后续 reset 时短暂闪过(reset 完会立即 init,有覆盖期)。 */}
{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) => (
<div
key={msg.id}
@@ -3270,16 +3475,100 @@ export default function RoundtablePlanningPage() {
</div>
<span className="text-[10px] text-slate-400 font-mono font-medium">{msg.time}</span>
</div>
{/* 步骤折叠卡(视觉 + 文案与 chats/new 消息列表的 ChainOfThought 对齐):
位于生成内容**上方**;**始终默认收起**(避免抢占注意力),
收起 header 上显示最新一步的图标 + 文案作为预览。
openSteps[msg.id] 用户手动覆盖优先。
每一步根据 step.key(`phase:toolName`)分配工具图标 + 中文化文案,
例如 `tool_calling:agent_orchestration` → 扳手 + 「使用 "..." 工具」。 */}
{Array.isArray(msg.steps) && msg.steps.length > 0 && (() => {
const stepsOpen = openSteps[msg.id] ?? false;
const lastStep = msg.steps[msg.steps.length - 1];
const lastDisplay = lastStep
? getStepDisplay(lastStep.key, lastStep.label)
: null;
const LastIcon = lastDisplay?.icon;
return (
<ChainOfThought
className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2"
open={stepsOpen}
onOpenChange={(v) => setOpenSteps((prev) => ({ ...prev, [msg.id]: v }))}
>
<ChainOfThoughtHeader
className="text-[11px]"
icon={
// 仅在「生成中 + 收起」时显示最新一步的工具图标(作进度预览);
// 完成后用默认脑子图标 + 「步骤列表」标题,提示这是历史时间线。
!stepsOpen && msg.streaming && LastIcon ? (
<LastIcon className="size-3.5 text-slate-500" />
) : undefined
}
>
{stepsOpen
? "步骤"
: msg.streaming
? (lastDisplay?.label ?? `查看 ${msg.steps.length} 个步骤`)
: "步骤列表"}
</ChainOfThoughtHeader>
<ChainOfThoughtContent>
{msg.steps.map(
(
s: {
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
},
i: number,
) => {
const display = getStepDisplay(s.key, s.label);
const argNode = renderStepArgs(s.key, s.args);
return (
<ChainOfThoughtStep
key={s.id}
icon={display.icon}
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">{s.ts}</span>
</span>
}
status={
msg.streaming && i === msg.steps.length - 1
? "active"
: "complete"
}
>
{argNode}
</ChainOfThoughtStep>
);
},
)}
</ChainOfThoughtContent>
</ChainOfThought>
);
})()}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
{/* Pre-streaming hint — when the bubble is created but
no AI token has arrived yet, show a small spinner
+ the current phase label so the user can see the
backend is doing something (调度中 → 已连接 →
正在派活/调用工具 → 流式输出). */}
{/* 气泡刚创建、token 还没到 —— 显示更显眼的"思考中"动画。
首字节通常 1-2s,之前是 12px 行内小 spinner,用户感受不到
后端在工作。换成稍大 spinner + 跳动点,与气泡正文同色阶。 */}
{msg.streaming && !msg.content && (
<div className="flex items-center gap-2 py-1 text-[11px] text-slate-500 italic">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>{msg.confidence}</span>
<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">{msg.confidence}</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>
)}
<ReactMarkdown
@@ -3505,13 +3794,21 @@ export default function RoundtablePlanningPage() {
{/* ========================================================
RIGHT COLUMN (Current Intent Summary, Timeline/Logs)
第三步时所有 panel 都已隐藏 → 整个 right-sidebar 也不渲染,
让中间方案展示区自适应铺满空间。
======================================================== */}
<section id="right-sidebar" className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1">
<section
id="right-sidebar"
className={cn(
"w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1",
currentStep === 3 && "hidden",
)}
>
{currentStep === 3 ? (
<>
{/* Panel 1: 最终共识结论 (Final Consensus Conclusion) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
{/* Panel 1: 最终共识结论 —— 暂时隐藏(等真实研讨结论接入后再恢复) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3.5">
<CheckCircle className="w-4 h-4 text-emerald-500" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">最终共识结论</h2>
@@ -3559,8 +3856,8 @@ export default function RoundtablePlanningPage() {
</div>
</div>
{/* Panel 2: 风险与预算摘要 (Risks & Budget Summary) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md flex-1 flex flex-col min-h-[300px]">
{/* Panel 2: 风险与预算摘要 —— 暂时隐藏(写死示例,等真实风险/预算数据接入再恢复) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3 shrink-0">
<AlertTriangle className="w-4 h-4 text-amber-500" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">风险与预算摘要</h2>
@@ -3626,8 +3923,8 @@ export default function RoundtablePlanningPage() {
</div>
</div>
{/* Panel 3: 导出与交付 (Export and Delivery) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
{/* Panel 3: 导出与交付 —— 暂时隐藏(无真实导出能力接入) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Share2 className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">导出与交付</h2>
@@ -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"