Skip to content

[OSS26-Team] 新组件:Choice 选择交互 #1957

Description

@kimteayon

新组件提案:Choice 选择交互

📋 基本信息

字段 内容
组件名 Choice
中文名 选择交互
所属分组 Wake(唤醒)
优先级 P1
提案人
状态 待评审

🎯 背景与动机

现状问题

在大模型对话场景中,模型经常需要向用户呈现一组选项让用户做出选择,例如:

  1. 意图确认:模型推测用户意图后,呈现多个可能理解让用户选择("您是想 A 还是 B?")
  2. 参数收集:工具调用前需要用户从预定义值中选择参数(如选择日期范围、数据类型)
  3. 方案推荐:模型生成多个方案,用户挑选其中一个继续
  4. 流程引导:多步对话中引导用户选择下一步操作

当前 antd 的 SelectRadioCheckbox 是面向表单场景设计的静态控件,在 AI 对话上下文中存在以下不匹配

维度 antd 表单控件 AI 对话场景诉求
上下文 嵌入表单,静态存在 嵌入对话气泡,随消息流出现
数据来源 开发者硬编码 LLM 动态生成,结构化输出
视觉层次 紧凑、功能导向 内容丰富,需要承载描述/图标/推荐标签
交互反馈 仅值变化 需要流式加载态、选中后反馈到对话流
差异化 统一表单风格 需要 AI 语义化视觉(推荐标记、禁用原因等)

与现有组件的区别

现有组件 定位 与 Choice 的区别
Prompts 展示预设提示词建议,引导用户发起对话 Choice 是模型响应中的选项,是模型→用户的交互,而非唤醒
Suggestion 斜杠命令式级联选择,输入增强 Choice 是内容级选项,不是命令补全;不需要级联结构
Actions 消息后操作(复制/反馈/重试) Actions 是操作按钮,Choice 是选择性内容卡片

Choice 填补的是「模型输出结构化选项 → 用户选择 → 结果回传对话流」这一核心交互链路的空白。


🧩 组件设计

名词定义

  • Choice:组件名,代表一次选择交互
  • ChoiceItem:单个选项,包含标识、展示内容与元数据
  • 选择模式:单选(single)/ 多选(multiple),决定用户可选数量
  • 布局模式:列表(list)/ 网格(grid)/ 卡片(card),决定视觉呈现
  • 推荐标记:AI 对某选项的推荐度标记,差异化于普通选中态

核心 API

ChoiceProps

type SemanticType = 'root' | 'header' | 'list' | 'item' | 'itemContent' | 'indicator' | 'footer';

interface ChoiceProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect' | 'onChange'> {
  /** 选项列表 */
  items: ChoiceItemType[];

  /** 选择模式 */
  mode?: 'single' | 'multiple';

  /** 布局模式 */
  layout?: 'list' | 'grid' | 'card';

  /** 当前选中值(受控) */
  value?: ChoiceValueType | ChoiceValueType[];

  /** 默认选中值 */
  defaultValue?: ChoiceValueType | ChoiceValueType[];

  /** 选中变化回调 */
  onChange?: (value: ChoiceValueType | ChoiceValueType[], info: ChangeInfo) => void;

  /** 选项点击回调(不改变选中状态) */
  onItemClick?: (info: { data: ChoiceItemType; index: number }) => void;

  /** 确认回调,用户完成选择后触发 */
  onConfirm?: (value: ChoiceValueType | ChoiceValueType[], info: ChangeInfo) => void;

  /** 标题 */
  title?: React.ReactNode;

  /** 描述文本,标题下方的辅助说明 */
  description?: React.ReactNode;

  /** 底部操作区(如确认按钮) */
  footer?: React.ReactNode;

  /** 是否禁用 */
  disabled?: boolean;

  /** 是否显示 loading 态 */
  loading?: boolean;

  /** 多选模式下最大可选数量 */
  maxCount?: number;

  /** 选择指示器类型 */
  indicator?: 'check' | 'radio' | 'number' | 'none';

  /** 是否显示确认按钮 */
  confirmable?: boolean;

  /** 确认按钮文案 */
  confirmText?: React.ReactNode;

  /** 渐入动画 */
  fadeIn?: boolean;

  /** 从左渐入动画 */
  fadeInLeft?: boolean;

  // 语义化样式
  classNames?: Partial<Record<SemanticType, string>>;
  styles?: Partial<Record<SemanticType, React.CSSProperties>>;

  prefixCls?: string;
  rootClassName?: string;
}

ChoiceItemType

type ChoiceValueType = string | number;

interface ChoiceItemType {
  /** 唯一标识,同时作为选中值 */
  key: ChoiceValueType;

  /** 选项标签 */
  label: React.ReactNode;

  /** 选项描述,提供补充信息 */
  description?: React.ReactNode;

  /** 选项图标 */
  icon?: React.ReactNode;

  /** 是否禁用 */
  disabled?: boolean;

  /** 禁用原因提示 */
  disabledReason?: React.ReactNode;

  /** AI 推荐标记 */
  recommended?: boolean | 'primary' | 'secondary';

  /** 推荐原因 */
  recommendedReason?: React.ReactNode;

  /** 选项额外信息(如价格、标签等) */
  extra?: React.ReactNode;

  /** 选项附加元数据,用于 LLM 结构化数据透传 */
  meta?: Record<string, any>;

  /** 嵌套子选项(用于分组场景) */
  children?: ChoiceItemType[];
}

interface ChangeInfo {
  /** 变更后的值 */
  value: ChoiceValueType | ChoiceValueType[];

  /** 当前所有选项 */
  items: ChoiceItemType[];

  /** 本次变更涉及的选项 */
  changedItems: ChoiceItemType[];

  /** 变更类型 */
  type: 'select' | 'deselect';
}

ChoiceRef

interface ChoiceRef {
  nativeElement: HTMLDivElement;

  /** 滚动到指定选项 */
  scrollTo: (key: ChoiceValueType) => void;

  /** 获取当前选中值 */
  getValue: () => ChoiceValueType | ChoiceValueType[] | undefined;
}

与 LLM 数据结构的映射

Choice 组件的设计天然适配主流 LLM 结构化输出格式:

OpenAI Function Calling 路径

{
  "tool_call": {
    "function": {
      "name": "present_choice",
      "arguments": {
        "title": "请选择数据分析维度",
        "mode": "single",
        "layout": "card",
        "items": [
          { "key": "time", "label": "按时间", "description": "按时间趋势分析", "icon": "clock", "recommended": true },
          { "key": "region", "label": "按地区", "description": "按地域分布分析", "icon": "global" },
          { "key": "category", "label": "按品类", "description": "按商品类别分析", "icon": "tag" }
        ]
      }
    }
  }
}

Structured Output 路径

{
  "type": "choice_interaction",
  "data": {
    "mode": "multiple",
    "maxCount": 3,
    "items": [
      { "key": "opt_1", "label": "性能优化", "description": "提升系统响应速度和吞吐量", "recommended": "primary", "meta": { "score": 0.92 } },
      { "key": "opt_2", "label": "安全加固", "description": "修复已知漏洞,增强安全策略" },
      { "key": "opt_3", "label": "功能迭代", "description": "按需求优先级推进新功能", "recommended": "secondary" },
      { "key": "opt_4", "label": "技术债清理", "description": "重构遗留代码,改善可维护性", "disabled": true, "disabledReason": "当前冲刺无排期" }
    ]
  }
}

自定义 Agent Tool 路径

{
  "action": "user_choice",
  "payload": {
    "title": "下一步您希望如何继续?",
    "description": "诊断完成,以下是建议的后续步骤",
    "mode": "single",
    "layout": "list",
    "indicator": "number",
    "items": [
      { "key": "detail", "label": "查看详细报告", "icon": "file-text" },
      { "key": "fix", "label": "立即修复", "icon": "tool", "recommended": true, "recommendedReason": "检测到 3 个高危问题" },
      { "key": "schedule", "label": "加入排期", "icon": "calendar" },
      { "key": "ignore", "label": "暂时忽略", "icon": "eye-off" }
    ]
  }
}

🎨 视觉设计

布局模式

list(列表布局)

┌─────────────────────────────────────────────┐
│  🤔 请选择数据分析维度                         │
│  选择最符合您需求的维度,或让 AI 为您推荐        │
├─────────────────────────────────────────────┤
│  ○  🕐 按时间                                │
│     按时间趋势分析                    ⭐ 推荐  │
├─────────────────────────────────────────────┤
│  ○  🌍 按地区                                │
│     按地域分布分析                             │
├─────────────────────────────────────────────┤
│  ○  🏷️ 按品类                                │
│     按商品类别分析                             │
├─────────────────────────────────────────────┤
│  ○  📊 按渠道                   🔒 暂不可用   │
│     按销售渠道分析                             │
├─────────────────────────────────────────────┤
│                            [ 确认选择 ]       │
└─────────────────────────────────────────────┘

grid(网格布局)

┌─────────────────────────────────────────────┐
│  🤔 选择您感兴趣的方向                         │
├──────────────────┬──────────────────────────┤
│  🕐              │  🌍                      │
│  按时间          │  按地区                   │
│  趋势分析        │  地域分布                 │
│  ⭐ 推荐         │                          │
├──────────────────┼──────────────────────────┤
│  🏷️              │  📊                      │
│  按品类          │  按渠道                   │
│  类别分析        │  🔒 暂不可用              │
└──────────────────┴──────────────────────────┘

card(卡片布局)

┌─────────────────────────────────────────────┐
│  🎯 推荐方案                                  │
├─────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐         │
│  │  ⭐ 强烈推荐   │  │              │         │
│  │  性能优化     │  │  安全加固     │         │
│  │              │  │              │         │
│  │  提升系统响应  │  │  修复已知漏洞  │         │
│  │  速度和吞吐量  │  │  增强安全策略  │         │
│  │              │  │              │         │
│  │  AI 评分: 92  │  │              │         │
│  └──────────────┘  └──────────────┘         │
│  ┌──────────────┐  ┌──────────────┐         │
│  │              │  │  🔒 暂不可用   │         │
│  │  功能迭代     │  │  技术债清理    │         │
│  │              │  │              │         │
│  │  按需求优先级  │  │  当前冲刺无    │         │
│  │  推进新功能   │  │  排期         │         │
│  └──────────────┘  └──────────────┘         │
├─────────────────────────────────────────────┤
│  ☑ 已选 2 项(最多 3 项)       [ 确认选择 ]  │
└─────────────────────────────────────────────┘

与 antd 的视觉差异

特性 antd Select/Radio/Checkbox Choice
信息密度 紧凑,label 为主 宽松,icon + label + description + extra
推荐标记 ❌ 无 ✅ 支持 primary/secondary 推荐标签
禁用原因 ❌ 只有灰色态 ✅ 禁用时展示原因提示
布局灵活性 固定布局 list/grid/card 三种布局
上下文适配 表单内嵌 对话气泡内嵌,圆角/间距适配对话流
加载态 ❌ 无 ✅ 骨架屏/渐入动画
确认操作 即时触发 支持确认按钮,延迟触发(适配对话流)
指示器 固定(圆点/勾) 可选(check/radio/number/none)

Design Token

interface ComponentToken {
  // 颜色
  itemBg?: string;                    // 选项背景色
  itemHoverBg?: string;               // 选项悬停背景色
  itemActiveBg?: string;              // 选项激活背景色
  itemSelectedBg?: string;            // 选项选中背景色
  itemDisabledBg?: string;            // 选项禁用背景色
  itemBorderColor?: string;           // 选项边框色
  itemSelectedBorderColor?: string;   // 选项选中边框色
  itemRecommendedBorderColor?: string;// 推荐选项边框色
  itemDisabledBorderColor?: string;   // 禁用选项边框色

  // 文字
  titleFontSize?: number;             // 标题字号
  titleLineHeight?: number;           // 标题行高
  labelFontSize?: number;             // 标签字号
  labelLineHeight?: number;           // 标签行高
  descFontSize?: number;              // 描述字号
  descLineHeight?: number;            // 描述行高

  // 间距
  headerPadding?: number;             // 头部区域内边距
  itemPadding?: number;               // 选项内边距
  itemGap?: number;                   // 选项间距
  listPadding?: number;               // 列表区域内边距
  footerPadding?: number;            // 底部区域内边距

  // 圆角
  borderRadius?: number;              // 整体圆角
  itemBorderRadius?: number;          // 选项圆角

  // 尺寸
  gridColumns?: number;               // 网格布局列数

  // 动画
  motionDuration?: number;            // 动画时长
}

📁 文件结构

components/choice/
├── index.tsx              # 主组件,ChoiceRefs,forwardRef
├── interface.ts           # ChoiceProps, ChoiceItemType, ChoiceRef, SemanticType
├── Item.tsx               # ChoiceItem 子组件,选项渲染
├── context.ts             # ChoiceContext,传递 prefixCls/classNames/styles/mode/disabled
├── hooks/
│   └── use-select.ts      # 选择逻辑 hook(受控/非受控、单选/多选、maxCount)
├── style/
│   ├── index.ts           # genStyleHooks + prepareComponentToken + 主样式
│   ├── item.ts            # 选项样式(各布局、状态变体)
│   ├── header.ts          # 头部样式
│   ├── footer.ts          # 底部样式
│   ├── variant.ts         # 布局变体样式(list/grid/card)
│   └── indicator.ts       # 指示器样式
├── demo/
│   ├── basic.tsx          # 基础单选
│   ├── basic.md
│   ├── multiple.tsx       # 多选模式
│   ├── multiple.md
│   ├── card-layout.tsx    # 卡片布局
│   ├── card-layout.md
│   ├── grid-layout.tsx    # 网格布局
│   ├── grid-layout.md
│   ├── recommended.tsx    # 推荐标记
│   ├── recommended.md
│   ├── disabled.tsx       # 禁用与禁用原因
│   ├── disabled.md
│   ├── confirmable.tsx    # 带确认按钮
│   ├── confirmable.md
│   ├── indicator.tsx      # 指示器类型
│   ├── indicator.md
│   ├── loading.tsx        # 加载态
│   ├── loading.md
│   ├── in-bubble.tsx      # 在对话气泡中使用
│   ├── in-bubble.md
│   ├── _semantic.tsx      # 语义化 DOM 结构预览
│   └── controlled.tsx     # 受控模式
│       controlled.md
├── __tests__/
│   ├── index.test.tsx     # 组件基础测试
│   ├── select.test.tsx    # 选择逻辑测试
│   ├── demo-extend.test.ts
│   └── __snapshots__/
├── index.zh-CN.md         # 中文文档
└── index.en-US.md         # 英文文档

🔧 实现要点

1. 选择逻辑 Hook(useSelect

// hooks/use-select.ts
interface UseSelectOptions {
  mode: 'single' | 'multiple';
  value?: ChoiceValueType | ChoiceValueType[];
  defaultValue?: ChoiceValueType | ChoiceValueType[];
  onChange?: (value: ChoiceValueType | ChoiceValueType[], info: ChangeInfo) => void;
  maxCount?: number;
  disabled?: boolean;
}

function useSelect(options: UseSelectOptions): {
  /** 当前值 */
  mergedValue: ChoiceValueType[];
  /** 处理选项点击 */
  onItemClick: (item: ChoiceItemType, index: number) => void;
  /** 是否选中 */
  isSelected: (key: ChoiceValueType) => boolean;
  /** 是否达到上限 */
  isMaxReached: boolean;
}

核心要点:

  • mode: 'single' 时值为单元素数组,点击已选项取消选中
  • mode: 'multiple' 时值为数组,受 maxCount 约束
  • 使用 useControlledState@rc-component/util)处理受控/非受控
  • 单选模式下选中即触发 onChange,多选模式需配合 confirmable 确认

2. 联动 Bubble.List

Choice 作为 AI 消息内容嵌入 Bubble.List,需要:

  • Bubble.Listrole 配置协调样式
  • 选中结果通过 onChange 回调更新到对话状态管理(如 useXChat
  • 宽度自适应气泡容器

3. 推荐标记渲染

recommended 字段的渲染规则:

  • true / 'primary':使用主题色边框 + 高亮背景 + 推荐角标("AI 推荐")
  • 'secondary':使用次级色边框 + 轻背景 + 轻角标
  • 不影响选中逻辑,仅视觉标记

4. 禁用态与原因提示

  • disabled: true 时选项不可点击,视觉置灰
  • disabledReason 存在时,hover/tip 展示原因(使用 antd Tooltip
  • 禁用原因也作为行内文本展示(与 antd 纯灰色态的差异化)

5. 指示器

indicator single 模式 multiple 模式
'radio' 圆形单选指示器 圆形指示器(行为同 checkbox)
'check' 勾选框 勾选框(默认)
'number' 序号 ①②③ 序号 + 勾选
'none' 无指示器,选中态仅靠背景色区分 同左
  • 默认值:mode: 'single''radio'mode: 'multiple''check'

6. 动画

  • fadeIn / fadeInLeft:复用现有 @rc-component/motionCSSMotion,与 Prompts 组件一致
  • 选中态过渡:transition 处理边框色、背景色变化
  • loading 态:使用 antd Skeleton 或自定义骨架占位

7. 语义化结构(Semantic DOM)

root
├── header           # 标题 + 描述区域
│   ├── title
│   └── description
├── list             # 选项列表容器
│   └── item * N     # 单个选项
│       ├── indicator # 选择指示器
│       └── itemContent
│           ├── icon
│           ├── label
│           ├── description
│           ├── extra
│           └── recommended-badge
└── footer           # 底部操作区

8. 国际化

// locale/zh_CN.ts
const locale = {
  confirmText: '确认',
  recommended: '推荐',
  maxSelected: '已选 {count}/{max} 项',
  disabledHint: '暂不可用',
};

// locale/en_US.ts
const locale = {
  confirmText: 'Confirm',
  recommended: 'Recommended',
  maxSelected: '{count}/{max} selected',
  disabledHint: 'Unavailable',
};

9. 无障碍(a11y)

  • role="radiogroup"(single)/ role="group"(multiple)
  • 选项 role="radio" / role="checkbox"
  • aria-checkedaria-disabledaria-label
  • 键盘导航: 切换焦点,Space/Enter 选中,Tab 离开
  • 遵循 WAI-ARIA APG 的 Radio Group / Checkbox Group 模式

🔗 依赖关系

项目内依赖

// 内部引用
import { useXProviderContext } from '../x-provider';
import useXComponentConfig from '../_util/hooks/use-x-component-config';
import useProxyImperativeHandle from '../_util/hooks/use-proxy-imperative-handle';
import { useControlledState } from '@rc-component/util';

外部依赖

依赖 用途
antd Tooltip(禁用原因提示)、Button(确认按钮)、Skeleton(加载态)
@ant-design/icons CheckCircle/Radio 图标(指示器)
@ant-design/cssinjs CSS-in-JS 样式方案
@rc-component/motion 动画
clsx 类名合并
@rc-component/util 工具函数

✅ 验收标准

功能验收

  • mode: 'single' 单选:点击选中,再点取消
  • mode: 'multiple' 多选:点击切换选中,maxCount 限制
  • layout: 'list' | 'grid' | 'card' 三种布局正确渲染
  • indicator: 'check' | 'radio' | 'number' | 'none' 四种指示器
  • 受控模式(value + onChange)与非受控模式(defaultValue
  • recommended 标记渲染(true / 'primary' / 'secondary'
  • disabled + disabledReason 禁用态与原因提示
  • loading 骨架屏态
  • confirmable + confirmText 确认按钮
  • fadeIn / fadeInLeft 动画
  • 嵌套 children 分组渲染
  • title / description / footer 区域渲染
  • 语义化 classNames / styles 支持

质量验收

  • TypeScript 类型完整,无 any 漏洞
  • 单元测试覆盖率 ≥ 90%
  • 无障碍(a11y)测试通过
  • RTL 布局适配
  • Design Token 可定制
  • Bubble.List 集成 Demo 验证

文档验收

  • 中英文文档完整(index.zh-CN.md / index.en-US.md
  • API 表格完整(Props / ChoiceItemType)
  • Semantic DOM 文档
  • Design Token 表格
  • 至少 10 个 Demo 覆盖核心场景

📅 实施计划

阶段 内容 工期
P0 核心组件:interface、index、Item、context、useSelect、style 3 天
P1 布局变体:list/grid/card 三种布局样式 2 天
P2 增强特性:recommended、disabledReason、indicator、loading、confirmable 2 天
P3 Demo + 文档:所有 demo、中英文文档 2 天
P4 测试 + 修复:单元测试、a11y、RTL、集成测试 2 天
合计 约 11 天

📎 补充说明

与 A2UI 协议的协同

@ant-design/x-card 的 A2UI 协议可扩展 choice 卡片类型,Choice 组件可作为 A2UI 的 choice 类型渲染器:

{
  "type": "card",
  "cardType": "choice",
  "props": {
    "mode": "single",
    "layout": "card",
    "items": [...]
  }
}

这样 LLM 输出的 A2UI JSON 可直接驱动 Choice 组件渲染,实现端到端的结构化交互闭环。

与 useXChat 的集成

const [agent] = useXChat({
  provider,
  parser: (message) => {
    if (message.type === 'choice_interaction') {
      return {
        content: (
          <Choice
            mode={message.data.mode}
            items={message.data.items}
            onChange={(value) => {
              agent.send({ type: 'user_choice', value });
            }}
          />
        ),
      };
    }
    return { content: message.content };
  },
});

命名考量

候选名 优点 缺点 结论
Choice 语义清晰、简短、动词感强、「选择」直译 与 React Router 的 Choice 不冲突但需注意 ✅ 采用
Picker antd 已有 Picker(日期选择) 命名冲突
Selector 语义明确 与 antd Select 太相似,易混淆
OptionList 描述性 名字太长,不符合组件库命名惯例
Selection 名词感,沉稳 语义偏"已选结果"而非"选择交互"

Metadata

Metadata

Assignees

No one assigned

    Labels

    OSS26-TeamOSS26-TeamenhancementNew feature or requestjavascriptPull requests that update Javascript code

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions