写好一份定义
本页回答:手里有一份服务端的查询描述,怎样写出一份给对的人看、准入干净的定义?
入门里的定义只列了五个字段、两个系统视图,够把列表跑起来。本页接着写同一个销售订单(聚合 sales-order),把每一项选择摊开:列哪些字段、叫什么、收窄什么、随定义发布哪些视图和看板,以及怎样在测试里证明它站得住。定义在模型里的位置见核心概念。
1. 先有描述符,并把它提交
定义的事实来自服务端的查询描述:快照查询是 GET /{聚合}/snapshot/schema,事件流是 GET /{聚合}/event/schema。把它存成 JSON,放在定义旁边,和定义一起提交,并在注释或测试里记下它的 version:
// src/views/salesOrderDescriptor.ts
import type { QueryModelDescriptor } from '@ahoo-wang/wow-client';
// GET /sales-order/snapshot/schema,version sha256:a30adffc…
import snapshot from './salesOrderDescriptor.json';
export const salesOrderDescriptor = snapshot as unknown as QueryModelDescriptor;为什么提交一份快照,而不是在运行时去读:
- 定义在模块加载时同步建好。 页面不等网络,测试读的是同一份,结果可复现。
- 漂移在评审里看得见。 服务端升级后重新取一次覆盖它,
version变了,改动摆在 diff 里;准入会把每一处受影响的字段与视图报出来(第 7 节)。 - 运行时仍读当下的描述。 数据源提供
describe时,引擎在第一次查询前再向服务端读一次,按这次部署当下的能力收窄(第 4 节);快照只决定事实。
2. 事实、能力与选择
defineView(descriptor, spec) 把描述符里的东西分成两类,加上定义自己的一类:
| 哪一类 | 包括 | 从哪来 | 定义能做什么 |
|---|---|---|---|
| 事实 | 路径、值类型、枚举值、时间怎样存(语义)、敏感级别、弃用、数组的元素结构 | 提交的快照,固化进定义 | 只能选、命名;写了快照没有的路径或值是准入错误 |
| 能力 | 操作符、排序、聚合、检索、上限 | 数据源当下的描述,随存储变 | 不写就随部署;写了只能收窄 |
| 选择 | 列哪些字段、次序、措辞、单元格与语气、收窄、行的身份、看板的时刻、系统视图 | 定义 | 全部由你决定 |
同一个模型在 MongoDB 与 Elasticsearch 上能力不同(比如数组元素里的检索只有 Elasticsearch 有),所以能力不从快照固化:没收窄的那部分,同一份定义部署到哪种存储,就得到那种存储的能力。
字段的种类从事实推出:有枚举值或你写了 options 是 enum;语义是纪元时间或格式化时间的是 datetime,日期的是 date;布尔、数字、字符串各归其类;写了 elements 的数组是 elementMatch。推不出的(对象、联合类型)要你写 kind,否则准入报 definition.field.kind-unknown。时刻以数存,但意思是时间,所以它只按日历分组(DATE_HISTOGRAM、DATE_PART),只有最早与最晚(MIN、MAX)。
3. 一份定义,逐项选择
场景:仓库与客服两拨人每天处理销售订单。仓库要清「待发货」,客服要按城市看销量。下面是给他们写的定义:
// src/views/salesOrders.ts
import { AggregationDateUnit } from '@ahoo-wang/wow-client';
import { defineView, text } from '@ahoo-wang/wow-view-engine';
import { SALES_ORDER_VIEWS } from './salesOrderViews';
export const SALES_ORDERS = 'sales-orders';
export const salesOrders = defineView(salesOrderDescriptor, {
id: SALES_ORDERS,
// 宿主登记数据源时用的键。
source: 'sales-order',
title: text('orders.title'),
// 分析的计数单位:「12 张订单」,而不是「12 条记录」。
recordNoun: text('orders.noun'),
// 看板的日期筛选指的是下单时间。
timeField: 'firstEventTime',
// 只出现列出的字段,按这个次序:字段选择器与默认列都照它排。
fields: {
// 人们拿它查找、复制,从不按它分组。
aggregateId: {
label: text('orders.id'),
cell: 'copyable',
analysis: false,
},
'state.status': {
label: text('orders.status'),
cell: 'status',
// 读者会遇到的每个值都写上措辞;语气只给好、坏、等待中的值。
options: {
CREATED: { label: text('orders.created'), tone: 'neutral' },
PAID: { label: text('orders.paid'), tone: 'warning' },
SHIPPED: { label: text('orders.shipped'), tone: 'success' },
RECEIVED: { label: text('orders.received'), tone: 'success' },
},
},
'state.address.province': text('orders.province'),
'state.address.city': text('orders.city'),
// 金额的合计有意义;平均每页多少钱没有。
'state.totalAmount': {
label: text('orders.total'),
summary: ['SUM'],
more: { numberFormat: { style: 'currency', currency: 'CNY' } },
},
'state.paidAmount': {
label: text('orders.paidAmount'),
summary: ['SUM'],
more: { numberFormat: { style: 'currency', currency: 'CNY' } },
},
// 读者按天、按月看走势:用 wow-client 的枚举,不写字符串。
firstEventTime: {
label: text('orders.placedAt'),
analysis: {
dateUnits: [AggregationDateUnit.DAY, AggregationDateUnit.MONTH],
},
},
// 一张订单的明细,每一项按商品读。
'state.items': {
label: text('orders.items'),
elementTitle: 'productId',
elements: {
productId: text('orders.product'),
quantity: text('orders.quantity'),
price: text('orders.price'),
},
},
// 只在详情里读的自由文本:不筛选、不排序、不分组。
'state.address.detail': {
label: text('orders.address'),
cell: 'text',
operators: [],
sortable: false,
analysis: false,
},
},
record: {
layouts: ['table', 'card'],
// 宿主的「发货」读每一行的状态,不论视图显示不显示这一列(第 5 节)。
rowFields: ['state.status'],
},
// 随定义发布的系统视图(第 6 节)。
views: SALES_ORDER_VIEWS,
});没写的也是选择:租户、所有者、国家与区县、应付余额,这两拨人都用不上,就不列。定义是一份取舍,不是描述符的副本;列得越长,选择器越难用。
几条常用的选择,各对着一个问题:
| 问题 | 选择 |
|---|---|
| 读者会读、筛、按它分组吗? | 会就列,不会就不列。描述符多了字段,界面不会悄悄多一列 |
| 它是人们称呼一条记录的方式吗? | cell: 'copyable'、analysis: false;它唯一、可排序、人们按它查找时,做 record.rowKey |
| 是只计数、不分组的 id 吗? | analysis: { groups: [] }:仍能去重计数 |
| 是自由文本吗? | cell: 'text'、sortable: false、analysis: false;没人按它筛选时 operators: [] |
| 是类别吗? | 每个读者看得到的值都写措辞(options),没人遇到的写 false 隐藏;只有好、坏、等待中的值带 tone |
| 是金额或数量吗? | 只给读者真会读的合计(summary),币种与单位写在 more.numberFormat |
| 是时刻吗? | 用 analysis.dateUnits 收到读者想的那几种粒度;以秒为单位读的(事件流的时间)写 timePrecision: 'second' |
| 是数组吗? | 元素写在 elements 里,键相对元素;elementTitle 指出哪个元素字段说出「这是哪一项」,否则单元格只说「3 项」 |
字段很多时,用 fieldGroups: [{ id, label, fields }] 给选择器和记录详情分组,每组一个业务上的名字。
4. 措辞是键,收窄为了受众
措辞
每个标题、标签、选项、系统视图名与指标的显示名都写成 text(key),措辞放在每种语言一张的表里,交给宿主(ViewHost 的 messages,或引擎的 text)。定义、配置与存储里始终是键,只在显示的地方才说成话,所以一个应用一个引擎,换语言只是重画:不重开、不重查,也不把视图标成「已修改」。单语的宿主仍可以直接写字符串。
// src/views/words.ts
/** 每个键在宿主服务的每种语言里的措辞。`admit` 报出缺词的键(第 7 节)。 */
export const SALES_ORDER_WORDS = {
'zh-CN': {
'orders.title': '销售订单',
'orders.noun': '订单',
'orders.id': '订单号',
'orders.status': '状态',
'orders.created': '待付款',
'orders.paid': '已付款',
'orders.shipped': '已发货',
'orders.received': '已签收',
'orders.province': '省份',
'orders.city': '城市',
'orders.total': '金额',
'orders.paidAmount': '实付',
'orders.placedAt': '下单时间',
'orders.items': '明细',
'orders.product': '商品',
'orders.quantity': '件数',
'orders.price': '单价',
'orders.address': '详细地址',
// 系统视图与分析的措辞。
'orders.toShip': '待发货',
'orders.all': '全部订单',
'orders.cityTotals': '各城市销售额',
'orders.count': '订单数',
'orders.perOrder': '客单价',
'orders.day': '日期',
// 看板。
'overview.title': '订单概览',
'overview.daily': '每日',
'overview.today': '今天',
'overview.dailyTotal': '每日销售额',
},
en: {
'orders.title': 'Sales orders',
'orders.noun': 'order',
'orders.id': 'Order no.',
'orders.status': 'Status',
'orders.created': 'Awaiting payment',
'orders.paid': 'Paid',
'orders.shipped': 'Shipped',
'orders.received': 'Delivered',
'orders.province': 'Province',
'orders.city': 'City',
'orders.total': 'Amount',
'orders.paidAmount': 'Amount paid',
'orders.placedAt': 'Placed at',
'orders.items': 'Items',
'orders.product': 'Product',
'orders.quantity': 'Quantity',
'orders.price': 'Unit price',
'orders.address': 'Street address',
'orders.toShip': 'To ship',
'orders.all': 'All orders',
'orders.cityTotals': 'Sales by city',
'orders.count': 'Order count',
'orders.perOrder': 'Average order value',
'orders.day': 'Day',
'overview.title': 'Order overview',
'overview.daily': 'Daily',
'overview.today': 'Today',
'overview.dailyTotal': 'Sales per day',
},
} as const;措辞是读者的话,一个意思一个词:不写路径(state.totalAmount)、常量(PAID)、类名,也不写存储的词(文档、集合、快照、聚合)。漏了措辞的字段不会报错到界面上,而是退回描述里的 description,再退回路径本身——读者于是看到一个程序里的词,准入为此记一条 definition.field.unlabelled。类别的值同理:没写措辞的值显示描述,没有描述就显示原始常量。
收窄
能力不写,就随部署:同一份定义在 Elasticsearch 上有短语检索、在 MongoDB 上没有;服务端调高上限,引擎跟着读到,不必重新部署。所以只为受众收窄,不为复述存储收窄:
- 收窄那些会误导读者的:订单号的平均值、按堆栈排序、按 id 分组。上面的
analysis: false、operators: []、sortable: false都是这一类。 - 不复述描述已经不给的东西,也不替服务端写上限(
record.maxWindow、analysis.limits),除非这群读者真要一个更低的。 - 收窄到快照之外(比如列出这个存储没有的操作符)是一条 warning,不是 error:另一种存储可能给。运行时,引擎再把你写的与当下描述取交集;只有你写了的能力被这个存储削掉时,才报
capability.*的发现。
两类字段引擎替你守:带 sensitivity 的敏感字段自动退出分析(不做维度、指标、公式的操作数与指标条件),机密字段不能做任何比较与排序。你要决定的只是它出不出现——只在读者必须看到掩码值时列出,也别加一个能把掩码推回来的派生字段或条件。被弃用的路径换成它的消息里点名的那一条;确实要留,写 deprecated: { message: '为什么' },否则一直有 warning。
5. 记录:行的身份与 rowFields
record.rowKey 是每一行的身份,缺省是描述符的 identity(Wow 快照是 aggregateId)。它必须是列出的字段、可以排序:每次记录查询都以它收尾,分页才不重不漏。人们按业务编号查找时可以换成它;但命令仍按聚合 id 寻址,这时把聚合 id 也列出来,放进 rowFields。
引擎只查询视图显示的字段。 一页要的只有:显示的列、卡片的字段、排序与汇总读的字段,以及行的身份。没显示的列不取,导出也不带——这让宽表的每一页都轻。代价是:宿主的代码(声明的操作的 available、批量操作、自定义单元格)读一个这个视图没显示的字段时,行上根本没有它。比如「发货」读 state.status 判断能不能发,而读者把状态列隐藏了,或者打开的系统视图本来就不显示状态:判断读到 undefined,按钮就一直是停用的。
所以凡是宿主代码要从行上读的字段,不论显示与否,都写进 record.rowFields:
import type { RecordCapability } from '@ahoo-wang/wow-view-engine';
export const record: Partial<RecordCapability> = {
layouts: ['table', 'card'],
// 「发货」读状态;行的身份总会取,不必写。
rowFields: ['state.status'],
};rowFields 里的每一项都必须是定义列出的字段(否则 definition.record.row-field-unknown),而且得是有值的路径,不能是搜索框这类无字段的种类(definition.record.row-field-not-a-path)。
6. 系统视图与看板
系统视图:读者每天打开的那几张
系统视图是定义随发布带上的起点:所有人可见、只读,读者另存为自己的再改。只写这群读者每天打开的那几张,不是所有可能的视图:一个队列一张,标题说它装着什么(「待发货」,而不是「订单列表 2」),按决定下一张先处理谁的字段排序,列按读的次序排,合计只放在有意义的地方。
一个小函数让同一份定义的视图完整而一致:
// src/views/salesOrderViews.ts
import {
text,
type AnalysisViewConfig,
type FilterNode,
type RecordViewConfig,
type SystemView,
} from '@ahoo-wang/wow-view-engine';
const COLUMNS = [
'aggregateId',
'state.status',
'state.address.city',
'state.totalAmount',
'firstEventTime',
];
function queue(
filter: FilterNode[],
overrides: Partial<RecordViewConfig> = {},
): RecordViewConfig {
return {
kind: 'record',
filter: { op: 'and', children: filter },
filterMode: 'simple',
refresh: { interval: null },
sort: [{ field: 'firstEventTime', direction: 'DESC' }],
pageSize: 20,
summaries: [{ field: 'state.totalAmount', fn: 'SUM' }],
layout: 'table',
table: { columns: COLUMNS.map(field => ({ field })) },
card: {
title: 'aggregateId',
fields: ['state.status', 'state.totalAmount'],
},
...overrides,
};
}
/** 各城市的销售额:最近 30 天,每次执行都按当下的日历求值。 */
const cityTotals: AnalysisViewConfig = {
kind: 'analysis',
filter: {
op: 'and',
children: [
{
field: 'firstEventTime',
operator: 'BETWEEN',
value: { type: 'relative', amount: 30, unit: 'day' },
},
],
},
filterMode: 'simple',
refresh: { interval: null },
groups: [
{
type: 'TERMS',
field: 'state.address.city',
alias: 'city',
label: text('orders.city'),
},
],
metrics: [
{ type: 'COUNT', alias: 'orders', label: text('orders.count') },
{
type: 'NUMERIC',
alias: 'total',
function: 'SUM',
expression: { type: 'FIELD', field: 'state.totalAmount' },
label: text('orders.total'),
},
{
// 比率是指标之上的指标,不是字段。
type: 'DERIVED',
alias: 'perOrder',
label: text('orders.perOrder'),
expression: {
type: 'BINARY',
operator: 'DIVIDE',
left: { type: 'METRIC_REF', metric: 'total' },
right: { type: 'METRIC_REF', metric: 'orders' },
},
format: { style: 'currency', currency: 'CNY' },
},
],
sort: [{ alias: 'total', direction: 'DESC' }],
limit: 20,
layout: 'chart',
table: { columns: [] },
chart: {
type: 'bar',
cartesian: { x: 'city', series: [{ metric: 'total' }] },
},
};
export const SALES_ORDER_VIEWS: SystemView[] = [
{
id: 'to-ship',
title: text('orders.toShip'),
// 写在系统视图上,不在配置里:队列整体读,看板的日期筛选不作用于它。
timeField: null,
// 付款最早的先发。
config: queue(
[{ field: 'state.status', operator: 'IN', value: ['PAID'] }],
{ sort: [{ field: 'firstEventTime', direction: 'ASC' }] },
),
},
{ id: 'all', title: text('orders.all'), config: queue([]) },
{ id: 'city-totals', title: text('orders.cityTotals'), config: cityTotals },
];- id 在定义内唯一、不含
:。引擎以system:<定义 id>:<视图 id>作它的实例 id,systemInstanceId('sales-orders', 'to-ship')拼出同一个值,看板与路由按它引用。 - 时间是意图。 一段时间写成跟着日历走的
BETWEEN({ type: 'preset', preset: 'yesterday' }、{ type: 'relative', amount: 7, unit: 'day' }),绝不把今天的日期写进配置。类别给IN、NOT_IN,没有EQ。 timeField写在SystemView上:换一个时刻,或null让这张视图整体读、不受看板的日期筛选影响。- 分析视图按问题命名(「各城市销售额」),每个维度与指标有显示名,按回答问题的那个指标排序,图型选能回答问题的那种,而不是最醒目的那种。
看板:读者先扫一眼的那一页
看板是自己的一份定义(kind: 'dashboard',没有数据源),它的系统视图就是系统看板。问自己:读者早上先看什么、按什么次序、哪个数点进去打开哪个队列。
// src/views/overview.ts
import {
emptyDashboardConfig,
systemInstanceId,
text,
type AnalysisViewConfig,
type DashboardDefinition,
} from '@ahoo-wang/wow-view-engine';
import { SALES_ORDERS } from './salesOrders';
/** 一个数:没有维度,范围内只有一行,所以 limit 为 1。 */
function card(
metric: AnalysisViewConfig['metrics'][number],
filter: AnalysisViewConfig['filter']['children'] = [],
): AnalysisViewConfig {
return {
kind: 'analysis',
filter: { op: 'and', children: filter },
filterMode: 'simple',
refresh: { interval: null },
groups: [],
metrics: [metric],
sort: [],
limit: 1,
layout: 'chart',
table: { columns: [] },
chart: { type: 'metric', metric: { metric: metric.alias } },
};
}
const total = {
type: 'NUMERIC',
alias: 'total',
function: 'SUM',
expression: { type: 'FIELD', field: 'state.totalAmount' },
label: text('orders.total'),
} as const;
const dailyTotal: AnalysisViewConfig = {
...card(total),
groups: [
{
type: 'DATE_HISTOGRAM',
field: 'firstEventTime',
alias: 'day',
unit: 'DAY',
label: text('orders.day'),
},
],
sort: [{ alias: 'day', direction: 'ASC' }],
// 一天一行:给读者能选的最长窗口留够。
limit: 92,
chart: { type: 'line', cartesian: { x: 'day', series: [{ metric: 'total' }] } },
};
export const overview: DashboardDefinition = {
id: 'overview',
title: text('overview.title'),
kind: 'dashboard',
views: [
{
id: 'daily',
title: text('overview.daily'),
config: {
...emptyDashboardConfig(),
// 看板唯一的日期筛选:经每份定义的 timeField 接到各块面板上。
fields: [
{
name: 'window',
label: text('orders.placedAt'),
kind: 'datetime',
default: { type: 'preset', preset: 'today' },
required: true,
},
],
panels: [
{
id: 'today',
kind: 'heading',
content: text('overview.today'),
layout: { x: 0, y: 0, w: 24, h: 1 },
},
{
id: 'total',
kind: 'view',
title: text('orders.total'),
bindings: [],
layout: { x: 0, y: 1, w: 6, h: 2 },
owned: { definitionId: SALES_ORDERS, config: card(total) },
},
{
id: 'to-ship-count',
kind: 'view',
title: text('orders.toShip'),
bindings: [],
// 现在还在等的,不管哪天付的款。
ignoresTime: true,
layout: { x: 6, y: 1, w: 6, h: 2 },
owned: {
definitionId: SALES_ORDERS,
config: card(
{ type: 'COUNT', alias: 'orders', label: text('orders.count') },
[{ field: 'state.status', operator: 'IN', value: ['PAID'] }],
),
},
// 「在工作台中打开」打开队列本身,而不是一个计数。
opens: systemInstanceId(SALES_ORDERS, 'to-ship'),
},
{
id: 'daily-total',
kind: 'view',
title: text('overview.dailyTotal'),
bindings: [],
layout: { x: 0, y: 3, w: 24, h: 6 },
owned: { definitionId: SALES_ORDERS, config: dailyTotal },
},
{
id: 'to-ship',
kind: 'view',
title: text('orders.toShip'),
bindings: [],
// 它的系统视图写了 timeField: null,所以整体读。
layout: { x: 0, y: 9, w: 24, h: 6 },
instanceId: systemInstanceId(SALES_ORDERS, 'to-ship'),
},
],
},
},
],
};- 栅格 24 列,同一行的面板共用一个
y。 - 默认日期是读者先查的那一段:日报开在「昨天」(一整天,已经结算),实时看板开在「今天」或一个短的相对窗口,一板待办的堆积不需要日期筛选。
- 看板的日期与面板自己的时段是「且」。 看板的窗口经
timeField到达每块面板,与面板视图自己的条件取交集:一张自带「最近 30 天」的视图放到 7 天的看板上,读的是两者的重叠。给看板做的视图别带自己的时段。整张视图到处都整体读,就在它的系统视图上写timeField: null;只有某一块面板不跟随,就在面板上写ignoresTime: true。 opens让「在工作台中打开」打开同一份定义的另一张视图:计数卡数的是队列列的东西时用它。- 系统看板只引用系统视图:系统视图对所有租户可见,共享视图只属于一个租户(核心概念)。看板自己拥有的分析不受这条约束。
7. 用 admit 自检
定义没有编译期的路径检查:路径是字符串,描述符才知道它对不对。补上这一道的是 /testing 的 admit:它按引擎注册时的同一套规则准入你的声明——每个键在给定的措辞里说不说得出、定义自己的规则与每一份系统视图配置、看板对其他定义的引用,以及每份数据定义按它的 source 对着提交的描述快照收窄。全部站得住时返回 []。
一个测试准入宿主注册的全部定义,覆盖宿主服务的每种语言:
// src/views/admit.test.ts
import { admit } from '@ahoo-wang/wow-view-engine/testing';
import { overview } from './overview';
import { salesOrders } from './salesOrders';
import { SALES_ORDER_WORDS } from './words';
describe('the order views', () => {
it.each(['zh-CN', 'en'] as const)('are admitted in %s', locale => {
const words: Readonly<Record<string, string>> = SALES_ORDER_WORDS[locale];
expect(
admit(
// 看板和它读的数据定义一起准入,引用才核对得到。
[salesOrders, overview],
// 按每份数据定义的 source:提交的快照。
{ 'sales-order': salesOrderDescriptor },
{ text: key => words[key] },
),
).toEqual([]);
});
});admit 与定义都不需要浏览器,在 Node 环境里跑;宿主的 Vitest 若是浏览器或 jsdom 项目,给定义单独一份 environment: 'node' 的配置。
每条发现带 code、params、指向定义里那一处的 path,以及它说的是哪份定义(definition);/ui 的 en、zhCN 两套措辞为每个 code 写了一句话。在它点名的那项选择上改,不要靠放宽 text、不给描述符或过滤结果让它闭嘴:
| 发现 | 回头看 |
|---|---|
definition.text.unknown | 这个键在这种语言里没有措辞:补上,或改正拼错的键 |
definition.field.undescribed、definition.option.undescribed | 描述符里没有的路径或值。不要猜另一条路径;去掉它,说明缺什么、该从哪来(读模型的字段、模型的声明) |
definition.field.unlabelled | 给字段一个读者的词 |
definition.field.deprecated | 换成替代的路径,或保留并写明理由 |
capability.field.protected | 列了敏感或机密字段:读者必须看到掩码时才留,并在测试里把这条写明 |
definition.field.*-wider、capability.field.*-narrowed | 收窄到了这个存储没有的能力:去掉,或者只为另一种有它的部署保留,并写明 |
definition.record.row-key-*、definition.record.row-field-* | 行的身份或 rowFields 不是列出的、可排序的、有值的字段 |
definition.descriptor.missing | 没把这个 source 的描述快照交给 admit |
definition.view.*、filter.*、analysis.*、dashboard.*、record.* | 某张系统视图或看板的配置:它点名的字段、操作符、别名或面板 |
有意留下的发现照原样断言,旁边写上理由,这样新出现的发现仍会让测试失败。
admit 判断不了的,是读者:列出的每个字段是不是他们读、筛、分组用的,次序对不对;措辞是不是他们的话、每种语言都有;每一处收窄是在保护读者还是在复述存储;系统视图与看板是不是开在他们最先要做的事上。这些在评审里看。
完整的可运行版本
- Storybook 的接入导览:一份订单定义从声明到页面;随部署收窄把同一份定义配上两种存储的描述,看能力怎样跟着变。
- 它的源文件:
ordersDefinition.ts、ordersDescriptor.json,以及用admit核对它们的integration.test.ts。 - 一个真实的宿主:补偿控制台的
src/views/,定义、每种语言的措辞与准入测试都在这里。 - 设计文档:
defineView的合并规则、采用服务端的能力描述。 - 让智能体照这套写定义:Skill
wow-view-definition。
下一步
| 接下来 | 阅读 |
|---|---|
| 定义、视图、看板与存储在模型里的位置 | 核心概念 |
把定义接进引擎、ViewHost 与一个操作 | 视图引擎入门 |
| 换一套外观、接上宿主的主题 | 视图引擎的主题 |