生成式界面
助手用 JSON 描述一段界面,App 用原生组件把它显示出来,可以显示在对话里(`ui.render`),也可以显示在看板上。表单提交的内容会作为你的下一条消息发回给助手。
有些回答用文字表达并不方便。例如,助手需要你填写一个金额,或者需要你从列表里选一项,或者手上的数据更适合用卡片展示。这时,助手可以直接回复一段界面。
具体做法是:助手写出一份 JSON 格式的界面描述,我们叫它 spec。App 从一套固定的组件目录(catalog)里取出对应的原生组件,把这段界面显示出来。这套机制基于 json-render。
组件的属性都是语义化的,例如色调(tone)和尺寸(size),不能直接写颜色值。因此,助手搭出来的界面和 App 其他部分的风格一致。助手搭建看板小组件时用的也是这套组件目录,所以它只需要学会一种界面描述方式。
spec 的结构
// specSchema in @pku-harness/sdk (genui.ts)
type UISpec = {
root: string; // 根元素的 id
elements: Record<string, {
type: string; // 目录中的组件名
props: Record<string, unknown>; // 列出的属性都要给出,不用时为 null
children?: string[]; // 子元素 id
on?: Record<string, unknown>; // 事件,例如 { press: { action, params } }
visible?: unknown;
}>;
state?: Record<string, unknown>; // 初始状态,例如 { form: { amount: "50" } }
};spec 里可以保存一份状态(state)。组件的属性可以绑定到状态上,有三种绑定方式。
| 绑定 | 含义 |
|---|---|
{ "$state": "/card/balance" } | 从状态中读取一个值(JSON pointer) |
{ "$bindState": "/form/amount" } | 把输入控件和状态双向绑定 |
{ "$template": "今天共 ${/today/count} 节课" } | 插入状态值的字符串 |
组件目录
目录里共有 17 个组件。表中「可以有子元素」的组件用 children 包含其他组件;「绑定 value」表示这个输入控件的值应当用 $bindState 绑定到状态。
| 组件 | 属性 | 说明 |
|---|---|---|
Column | gap | 纵向排列,可以有子元素 |
Row | gap, align, wrap | 横向排列,可以有子元素 |
Card | title, subtitle | 分组面板,可以有子元素。不要嵌套卡片。 |
Heading | text, size | |
Text | text, tone, size | |
Stat | label, value, prefix, suffix, decimals, delta, deltaTone, caption, icon, size | 关键数字,数值变化时有动画 |
Badge | label, tone | |
ListItem | title, subtitle, value, icon | 事件 press |
Divider | — | |
Progress | label, value(0–1), tone | |
TextField | label, placeholder, value, keyboard | 绑定 value |
Toggle | label, value | 绑定 value |
Choice | label, options, value | 最多 12 个选项;绑定 value |
Sparkline | values, tone | |
Ring | value(0–1), label, caption, tone, size | |
DateTime | label, value, mode | 原生选择器;值为本地时间 "YYYY-MM-DD HH:mm" |
Button | label, variant | 事件 press |
组件被点击时,会触发 on.press 里定义的动作。动作有两种。
| 动作 | 参数 | 效果 |
|---|---|---|
submit | { label } | 把 /form 下的全部内容作为用户的下一条消息发回给助手 |
callTool | { tool, input } | 直接调用某个插件工具 |
callTool 调用工具时,权限检查和助手调用时相同。
这一页的内容可能落后于实际代码,但运行时的组件目录始终是最新的。ui.render 工具的描述里包含了 services.genui.describeCatalog() 的输出,所以助手看到的永远是当前的目录。services.genui.checkSpec(spec) 可以用来校验一份 spec。
示例:充值表单
下面的 spec 显示一张「充值校园卡」卡片,里面有一个金额选择和一个确认按钮。你点「确认」后,所选金额会作为你的下一条消息发回给助手。
{
"root": "card",
"elements": {
"card": { "type": "Card", "props": { "title": "充值校园卡", "subtitle": "选择金额" }, "children": ["amount", "ok"] },
"amount": {
"type": "Choice",
"props": {
"label": null,
"options": [{ "label": "¥50", "value": "50", "hint": null }, { "label": "¥100", "value": "100", "hint": "常用" }],
"value": { "$bindState": "/form/amount" }
}
},
"ok": { "type": "Button", "props": { "label": "确认", "variant": "primary" }, "on": { "press": { "action": "submit", "params": { "label": "充值" } } } }
},
"state": { "form": { "amount": "100" } }
}写给插件作者
远程插件不能自己显示界面。它们只返回数据,由助手来生成界面。
内置插件的页面可以用 host.components.GenUI 显示一份 spec:<GenUI spec={spec} onSubmit={(form, label) => …} />。
生成式界面适合对话中一次性的场景。如果用户会经常打开某个界面,请把它做成原生页面或小组件(contributes.screens / widgets)。