呈现 Hints 契约(x-ui-*)
状态:Current — 本文档是 JSON Schema 呈现 hints(
x-ui-*扩展字段)的唯一规范出处。权威消费实现是web/src/utils/schemaHints.ts(推导器);本文与实现不一致时以代码为准并修正本文。
背景与问题
FunctionContract.input_schema(JSON Schema)是表单校验的事实源,但不含任何呈现语义。 后果:
- Invoke 工作台只能把裸 schema 直接交给
SchemaFormRenderer,字段 label 退化为英文 key; - widget/分组/联动等能力只在 PageSpec 体系(Page Studio 人工配置)中存在,与 descriptor 注册链路脱节;
- 游戏方最了解自己参数的呈现意图("这个字段是玩家选择器"、"这两个参数是一组"),却没有任何表达通道。
本契约定义 JSON Schema x-ui-* 扩展字段作为呈现意图的载体:游戏方在 schema 内声明, 前端(derivePresentationSpec)与服务端(buildFormFields,U4 起含 x-options-source)分别推导为 FormPresentationSpec。wire 层零改动 (input_schema 本就是字符串透传)。
分层与原则
input_schema(事实源)
└─ 字段 schema 上的 x-ui-* hints ← SDK/游戏方声明(本文档)
→ derivePresentationSpec() 推导 ← 前端纯函数
→ FormPresentationSpec ← 表单呈现层(pagespec-protocol.md)
→ SchemaFormRenderer 渲染- 页面编排不在此列:hints 只表达字段级表单呈现意图,不表达页面类型、列表、 导航、路由。页面编排仍归 PageSpec / Page Studio(见 pagespec-protocol.md)。
- schema 永远是校验事实源:hints 不得改变 schema 的类型、required、enum 取值域。
x-enum-options只补充选项标签,取值域仍以 schemaenum/enumNames为准。 - hints 是意图,不是协议持久化:hints 随
input_schema整体持久化于 contract (SourceDigest天然覆盖),前端派生的 uiSchema 仍只在内存中(遵守 ui-generation.md 「表单渲染」约束)。
字段清单与映射
hints 放置在 字段 schema 对象上(支持嵌套,推导为点路径 key);布局/分组声明放在 根 schema 上。
字段级 hints
| hint | 类型 | 映射到 FormFieldSpec | 说明 |
|---|---|---|---|
x-widget | FormWidget 枚举 | widget | 受控枚举,取值同 pagespec-protocol.md「FormPresentationSpec」(Input/Select/TreeSelect/…);未知值忽略并按 schema 默认推导 |
x-label | LocalizedText 或 string | label | 覆盖 schema title;string 归一为 { "zh-CN": s } |
x-placeholder | LocalizedText 或 string | placeholder | |
x-description | LocalizedText 或 string | description | 帮助文本;schema description 缺失时的兜底来源之一 |
x-width | 1..12 int | width | grid 布局栅格宽度 |
x-order | number | order | 排序,小者在前;未声明时按 schema properties 顺序 |
x-disabled | bool | disabled | |
x-visible-when | ConditionSpec | visibleWhen | 受限表达式,结构同 pagespec-protocol.md(equals/notEquals/exists/all/any,path 为 JSON Pointer) |
x-enum-options | [{ value, label }] | enumOptions | 只补充标签展示;label 为 LocalizedText 或 string |
x-widget-props | object | widgetProps | 透传给 widget 的额外 props(如 treeData、accept、maxCount) |
x-group | string | 归入 groups[].fields | 分组 key;组标题在根级 x-ui-groups 声明 |
x-options-source | 见下文 | remoteOptions | 远程选项源,消费实现见下文 |
根级 hints
| hint | 类型 | 映射到 FormPresentationSpec | 说明 |
|---|---|---|---|
x-ui-layout | vertical/horizontal/inline/grid | layout | 缺省 vertical |
x-ui-groups | [{ key, title?, collapsible?, collapsed? }] | groups | 组成员由字段 x-group 归属;未在 x-ui-groups 声明的分组 key 按字段出现顺序自动补组(title 取 key 人性化) |
远程选项源(x-options-source)
"x-options-source": {
"functionId": "player.list",
"labelPath": "/items/*/name",
"valuePath": "/items/*/id",
"searchParam": "keyword"
}指向同 (game_id, env) 作用域内已注册的 collection_query 函数,运行时调用获取选项:
- 取值路径:
labelPath/valuePath为 JSON Pointer,支持*通配数组段 (/items/*/id);valuePath缺省复用labelPath,选项 label 缺省复用 value。 - 搜索:声明
searchParam后,下拉搜索以该参数重新调用数据源;未声明则 首次拉取后本地过滤。 - 缓存:会话级按
functionId + 搜索词缓存,同参数只调用一次。 - 权限:调用走既有 RBAC——无权限时调用失败,静默降级为普通输入。
- 消费 widget:
Select(覆盖内置实现,非远程选项仍委托内置)与TreeSelect(远程选项降级为平铺列表)。
推导规则
前端调试路径(Invoke 页 / 编辑器预览)
derivePresentationSpec(schema: JSONSchema): FormPresentationSpec (web/src/utils/schemaHints.ts,纯函数、无副作用):
- key 路径:推导器只处理顶层
properties,每项生成一个FormFieldSpec;嵌套 object 保留为整体字段(其上的x-widget/x-label等 hints 照常生效),不展开为 点路径——点路径展开需要渲染器支持 dotted uiSchema,待该能力落地后启用 (todo.md F 系列)。 数组/additionalProperties不展开。 - 优先级(高→低):Page Studio 平台覆盖 >
x-ui-*hints > schema 派生 (title/format/enum)> 兜底(key 人性化,见 todo.md F5)。 - 顺序:
x-order升序,其余按 schema properties 定义顺序稳定排序。 - 容错:hints 值类型非法(如
x-widget: "NoSuchWidget"、x-width: "6")时忽略该 hint,不影响其余推导,也不产生运行时错误。未知x-ui-*键一律忽略(向前兼容)。 - 无 hints 时:输出与现状等价的
{ jsonSchema, layout: 'vertical' },保证回退行为 与历史一致。
服务端发布路径(PageSpec 生成器)
buildFormFields(schema, locale)(internal/dashboard/generator/form_hints.go + generator.go)在生成 published FormPresentationSpec 时消费同一套 hints,保证同一 函数在调试页与发布页(operation/task/report/resource/composite 全部页型)表现一致:
- hints 优先:
x-widget(受控枚举校验,非法忽略)、x-label、x-placeholder、x-description、x-width(1-12)、x-order、x-disabled、x-visible-when、x-enum-options、x-widget-props、x-options-source(U4:派生为FormFieldSpec.remoteOptions——functionId必填缺失静默忽略,与前端asRemoteOptions一致;widget 名仍由x-widget决定,不强制 Select)。 - 类型缺省控件(
x-widget缺省时):integer/number→InputNumber、 boolean→Switch、enum→Select、array(enum items)→MultiSelect、 formatdate/date-time→DatePicker、time→TimePicker、 textarea/maxLength>120→TextArea。 - label 兜底链:
x-label> schematitle(不重复下发)> key 人性化。 - 顺序:
x-order升序,未声明者按 key 字母序稳定排后。 - 全覆盖:每个顶层
properties字段都产出条目(保证ui:order完整), 含有title的字段也不例外(此时不下发 label)。 - 发布一致性:
buildFormFromContract(resource/composite 表单)与buildFormPresentation(operation/task/report 表单)共用同一派生,禁止页型间 表单表现漂移。
兼容性
- JSON Schema 校验器:未知关键字被 AJV 等校验器忽略,hints 不影响 payload 校验。
- OpenAPI 3.0:
x-前缀是官方 vendor extension 语法,OpenAPI 注册链路(prompt→ contract 透传)同样携带。 - proto/wire:
FunctionDescriptor.input_schema是字符串透传,零改动。 - 服务端:
function_contracts.input_schema原样存储;SourceDigest覆盖含 hints 的 整个 schema——hints 变更即契约变更,天然进入既有 stale/diff 流程(PageSpec 失效检测)。 - 消费端版本差:旧版前端忽略未知
x-ui-*,表现为现状裸 schema 渲染,不报错。
治理边界
- hints 不产生导航/页面级 labels:页面 title、菜单分类仍只能来自 Page Studio 人工 编辑(ui-generation.md「生成器职责」的 labels 治理不变)。
x-widget只能取受控枚举值;扩展 widget 须修改internal/dashboard/spec包与前端 类型后同步本表,不允许前端私加(pagespec-protocol.md 同款约束)。- hints 属于函数契约的一部分,随契约变更走既有 review/diff 流程;平台可通过契约 Diagnostics 对非法 hints 记录告警(todo.md F12 范围)。
- 本地化遵循 LocalizedText 契约(
localized-text-contract.md):x-label等的LocalizedText形态 key 必须是"zh-CN"/"en-US"。
示例
1. 基础 widget 与标签
{
"type": "object",
"required": ["playerId", "reason"],
"properties": {
"playerId": {
"type": "string",
"x-widget": "Select",
"x-label": { "zh-CN": "玩家", "en-US": "Player" },
"x-placeholder": { "zh-CN": "选择玩家", "en-US": "Select player" }
},
"reason": {
"type": "string",
"maxLength": 200,
"x-label": "封禁原因",
"x-widget": "TextArea"
},
"duration": {
"type": "integer",
"minimum": 1,
"x-widget": "InputNumber",
"x-width": 6
}
}
}2. 分组与布局
{
"type": "object",
"x-ui-layout": "grid",
"x-ui-groups": [
{ "key": "basic", "title": { "zh-CN": "基本信息", "en-US": "Basic" } },
{ "key": "advanced", "title": { "zh-CN": "高级选项" }, "collapsible": true }
],
"properties": {
"title": { "type": "string", "x-group": "basic" },
"level": { "type": "integer", "x-group": "basic", "x-width": 6 },
"whisper": {
"type": "boolean",
"x-widget": "Switch",
"x-group": "advanced"
},
"template": { "type": "string", "x-widget": "Code", "x-group": "advanced" }
}
}3. 联动与远程选项(保留字段示例)
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["single", "batch"],
"x-widget": "Radio",
"x-label": { "zh-CN": "发放模式" }
},
"targetPlayer": {
"type": "string",
"x-widget": "TreeSelect",
"x-options-source": {
"functionId": "player.search",
"labelPath": "/items/*/name",
"valuePath": "/items/*/id"
},
"x-visible-when": { "kind": "equals", "path": "/mode", "value": "single" }
},
"batchFile": {
"type": "string",
"x-widget": "FileUpload",
"x-visible-when": { "kind": "equals", "path": "/mode", "value": "batch" }
}
}
}