@webskill/sdk 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +3 -3
- package/dist/agent.js +2442 -3
- package/dist/{dist-Bfga1TmS.js → approval-BnLLCOG0.js} +235 -2932
- package/dist/browser.d.ts +106 -5
- package/dist/browser.js +364 -10
- package/dist/{openUiLibrary-BKXW7Iwx-DjvEnMlJ.js → dist-CTsxblSD.js} +496 -28
- package/dist/{echarts-De78wXqV.js → echarts-BE7oV_Dl.js} +1 -1
- package/dist/{env-8cY40DXB-CGnEVZby.js → env-Bj1MI2Ww.js} +1 -1
- package/dist/errors-BDZNpC13.js +22 -0
- package/dist/{eventTypes-FllCrX-Z-DNDeHWoG.js → eventTypes-Y07N8IAC.js} +14 -2
- package/dist/external-_ZRQe-V9.js +53 -0
- package/dist/governance.d.ts +15 -3
- package/dist/governance.js +86 -4
- package/dist/{index-DQwGvHAI.d.ts → index-CQYo4tcT.d.ts} +85 -6
- package/dist/{index-sd-gVKey.d.ts → index-CU2md8R1.d.ts} +26 -18
- package/dist/{index-CWoKOFBP.d.ts → index-Oc3H89TJ.d.ts} +159 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1001 -5
- package/dist/kind-DxgS8LM-.js +77 -0
- package/dist/linkedDocument-xNXF-z8n.js +94 -0
- package/dist/llm-B7lLH0ZI.js +1767 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +41 -4
- package/dist/memoryArtifactStore-D6dLeY55.js +48 -0
- package/dist/miniChart-owdCdaw-.js +533 -0
- package/dist/node.d.ts +3 -3
- package/dist/node.js +106 -9
- package/dist/openUiLibrary-CY_ADoe0.js +30 -0
- package/dist/openUiSpecLang-DuOCdw9G.js +1849 -0
- package/dist/pathSecurity-B1owvJAF.js +65 -0
- package/dist/processSandboxEntry.js +5 -1
- package/dist/renderResult-D9Q-Vu2x.js +143 -0
- package/dist/{rolldown-runtime-BOF7iYI8.js → rolldown-runtime-BHkdefai.js} +1 -2
- package/dist/sandboxWorkerEntry.js +5 -1
- package/dist/{dist-D5YhlCdY.js → skill-CAJMsLod.js} +44 -372
- package/dist/{skillVersionStore-D-qHk9ZE-C0OFDTQY.d.ts → skillVersionStore-B24Jjjry-C4zomhMZ.d.ts} +14 -2
- package/dist/surface-DVGiCmwq.js +145 -0
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +64 -3
- package/dist/{types-C3V6lAeO-BgeOvc1Y.d.ts → types-C3V6lAeO-BIf7UB2e.d.ts} +17 -1
- package/dist/types-TUydnxaj.js +78 -0
- package/dist/ui-react.d.ts +12 -5
- package/dist/ui-react.js +128548 -4729
- package/dist/ui-vue.d.ts +1 -1
- package/dist/ui-vue.js +11 -2
- package/dist/ui.d.ts +3 -3
- package/dist/ui.js +1254 -3
- package/dist/urlSafety-CiSuCJvX.js +65 -0
- package/dist/webSkillApi-DOOyz2G5.js +106 -0
- package/dist/{webskillLitCatalog-D_zCqeQF-swXsWoAe.js → webskillLitCatalog-DSqVFQjK.js} +11 -3
- package/package.json +1 -1
- package/dist/catalogComponents-BgAJN0p8-Cr55ouOg.js +0 -124384
- package/dist/client-BCM6Z3yq-qUQNkKrK.js +0 -7787
- package/dist/dist-BnqAkSS6.js +0 -2384
- package/dist/dist-CiaDIqkV.js +0 -3509
- package/dist/dist-DxyxmXPU-DGC-dpXf.js +0 -5621
- package/dist/memoryArtifactStore-52Zn9npI-BxtUkrgV.js +0 -86
- package/dist/stdio-CFMoANJJ-BxrTeXh7.js +0 -31
- package/dist/testing-Cm8MseLF.js +0 -142
- package/dist/types-WovEf4ED-CZSDiiBU.js +0 -6215
package/dist/dist-CiaDIqkV.js
DELETED
|
@@ -1,3509 +0,0 @@
|
|
|
1
|
-
import { b as WebSkillError } from "./dist-D5YhlCdY.js";
|
|
2
|
-
import { toJSONSchema, z } from "zod";
|
|
3
|
-
|
|
4
|
-
//#region ../ui/dist/webskillCatalog-nTgkL9Xe.js
|
|
5
|
-
const DEFAULT_INTERACTION_TEXTS = {
|
|
6
|
-
submit: "Submit",
|
|
7
|
-
cancel: "Cancel",
|
|
8
|
-
confirm: "Confirm",
|
|
9
|
-
select: "Select",
|
|
10
|
-
allow: "Allow",
|
|
11
|
-
deny: "Deny",
|
|
12
|
-
chooseFile: "Choose file",
|
|
13
|
-
decline: "Decline",
|
|
14
|
-
required: "This field is required",
|
|
15
|
-
suggested: "Suggested:",
|
|
16
|
-
useSuggestion: "Use"
|
|
17
|
-
};
|
|
18
|
-
function resolveInteractionTexts(texts) {
|
|
19
|
-
return texts ? {
|
|
20
|
-
...DEFAULT_INTERACTION_TEXTS,
|
|
21
|
-
...texts
|
|
22
|
-
} : DEFAULT_INTERACTION_TEXTS;
|
|
23
|
-
}
|
|
24
|
-
/** 可复用的部分取自 DEFAULT_INTERACTION_TEXTS,不另抄一份(AC-G10) */
|
|
25
|
-
const DEFAULT_SURFACE_FORM_TEXTS = {
|
|
26
|
-
required: DEFAULT_INTERACTION_TEXTS.required,
|
|
27
|
-
submit: DEFAULT_INTERACTION_TEXTS.submit,
|
|
28
|
-
cancel: DEFAULT_INTERACTION_TEXTS.cancel,
|
|
29
|
-
addItem: "Add",
|
|
30
|
-
removeItem: "Remove",
|
|
31
|
-
arrayFirstItemOnly: "This renderer shows only the first item of a repeatable group.",
|
|
32
|
-
readOnlySnapshot: "This is a saved record — its actions are no longer available.",
|
|
33
|
-
suggested: DEFAULT_INTERACTION_TEXTS.suggested,
|
|
34
|
-
useSuggestion: DEFAULT_INTERACTION_TEXTS.useSuggestion
|
|
35
|
-
};
|
|
36
|
-
function resolveSurfaceFormTexts(texts) {
|
|
37
|
-
return texts ? {
|
|
38
|
-
...DEFAULT_SURFACE_FORM_TEXTS,
|
|
39
|
-
...texts
|
|
40
|
-
} : DEFAULT_SURFACE_FORM_TEXTS;
|
|
41
|
-
}
|
|
42
|
-
const DEFAULT_SURFACE_HOST_CONTROL_TEXTS = {
|
|
43
|
-
previousPage: "Previous page",
|
|
44
|
-
previous: "Previous",
|
|
45
|
-
nextPage: "Next page",
|
|
46
|
-
next: "Next",
|
|
47
|
-
carousel: "Carousel"
|
|
48
|
-
};
|
|
49
|
-
function resolveSurfaceHostControlTexts(texts) {
|
|
50
|
-
return texts ? {
|
|
51
|
-
...DEFAULT_SURFACE_HOST_CONTROL_TEXTS,
|
|
52
|
-
...texts
|
|
53
|
-
} : DEFAULT_SURFACE_HOST_CONTROL_TEXTS;
|
|
54
|
-
}
|
|
55
|
-
/** 提交值按请求类型归形(WebFormBridge 与框架组件库共享单一来源) */
|
|
56
|
-
function shapeInteractionValue(model, values) {
|
|
57
|
-
switch (model.kind) {
|
|
58
|
-
case "ask": return values["answer"];
|
|
59
|
-
case "confirm": return values["confirmed"] === true;
|
|
60
|
-
case "select": return values["selected"];
|
|
61
|
-
case "authorize": return true;
|
|
62
|
-
case "file-pick": return values["file"];
|
|
63
|
-
case "form": return values;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
/** 五类 InteractionRequest → 统一中间模型(框架无关) */
|
|
67
|
-
function interactionToFormModel(request, texts) {
|
|
68
|
-
const t = resolveInteractionTexts(texts);
|
|
69
|
-
switch (request.type) {
|
|
70
|
-
case "ask": return {
|
|
71
|
-
kind: "ask",
|
|
72
|
-
message: request.message,
|
|
73
|
-
controls: [{
|
|
74
|
-
name: "answer",
|
|
75
|
-
label: request.message,
|
|
76
|
-
control: "text",
|
|
77
|
-
required: true,
|
|
78
|
-
...request.suggestion ? { suggestion: request.suggestion } : {}
|
|
79
|
-
}],
|
|
80
|
-
submitLabel: t.submit,
|
|
81
|
-
cancelLabel: t.cancel
|
|
82
|
-
};
|
|
83
|
-
case "confirm": return {
|
|
84
|
-
kind: "confirm",
|
|
85
|
-
message: request.message,
|
|
86
|
-
controls: [{
|
|
87
|
-
name: "confirmed",
|
|
88
|
-
label: request.message,
|
|
89
|
-
control: "boolean",
|
|
90
|
-
defaultValue: request.defaultValue ?? true
|
|
91
|
-
}],
|
|
92
|
-
submitLabel: t.confirm,
|
|
93
|
-
cancelLabel: t.cancel
|
|
94
|
-
};
|
|
95
|
-
case "form": return {
|
|
96
|
-
kind: "form",
|
|
97
|
-
...request.title ? { title: request.title } : {},
|
|
98
|
-
controls: request.fields.map((f) => ({
|
|
99
|
-
name: f.name,
|
|
100
|
-
label: f.label,
|
|
101
|
-
control: f.type,
|
|
102
|
-
...f.required ? { required: true } : {},
|
|
103
|
-
...f.description ? { description: f.description } : {},
|
|
104
|
-
...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
|
|
105
|
-
...f.suggestion ? { suggestion: f.suggestion } : {},
|
|
106
|
-
...f.options ? { options: f.options } : {}
|
|
107
|
-
})),
|
|
108
|
-
submitLabel: t.submit,
|
|
109
|
-
cancelLabel: t.cancel
|
|
110
|
-
};
|
|
111
|
-
case "select": return {
|
|
112
|
-
kind: "select",
|
|
113
|
-
message: request.message,
|
|
114
|
-
controls: [{
|
|
115
|
-
name: "selected",
|
|
116
|
-
label: request.message,
|
|
117
|
-
control: "select",
|
|
118
|
-
required: true,
|
|
119
|
-
options: request.options,
|
|
120
|
-
...request.suggestion ? { suggestion: request.suggestion } : {}
|
|
121
|
-
}],
|
|
122
|
-
submitLabel: t.select,
|
|
123
|
-
cancelLabel: t.cancel
|
|
124
|
-
};
|
|
125
|
-
case "authorize": return {
|
|
126
|
-
kind: "authorize",
|
|
127
|
-
title: "Authorization required",
|
|
128
|
-
message: request.message,
|
|
129
|
-
controls: [],
|
|
130
|
-
submitLabel: t.allow,
|
|
131
|
-
cancelLabel: t.deny
|
|
132
|
-
};
|
|
133
|
-
case "file-pick": return {
|
|
134
|
-
kind: "file-pick",
|
|
135
|
-
title: "File requested",
|
|
136
|
-
message: request.message,
|
|
137
|
-
controls: [{
|
|
138
|
-
name: "file",
|
|
139
|
-
label: request.field ?? "file",
|
|
140
|
-
control: "file",
|
|
141
|
-
required: true,
|
|
142
|
-
...request.accept ? { accept: request.accept } : {},
|
|
143
|
-
...request.multiple ? { multiple: true } : {}
|
|
144
|
-
}],
|
|
145
|
-
submitLabel: t.chooseFile,
|
|
146
|
-
cancelLabel: t.decline
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* 最小子集 markdown → DOM:标题(#..###)、无序列表、代码块(```)、加粗、链接。
|
|
152
|
-
* 全部内容经 textContent 写入,用户内容天然转义防 HTML 注入。
|
|
153
|
-
*/
|
|
154
|
-
const INLINE_RE = /\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
155
|
-
function appendInline(container, text, doc) {
|
|
156
|
-
let last = 0;
|
|
157
|
-
for (const match of text.matchAll(INLINE_RE)) {
|
|
158
|
-
const index = match.index ?? 0;
|
|
159
|
-
if (index > last) container.appendChild(doc.createTextNode(text.slice(last, index)));
|
|
160
|
-
if (match[1] !== void 0) {
|
|
161
|
-
const strong = doc.createElement("strong");
|
|
162
|
-
strong.textContent = match[1];
|
|
163
|
-
container.appendChild(strong);
|
|
164
|
-
} else {
|
|
165
|
-
const label = match[2] ?? "";
|
|
166
|
-
const url = match[3] ?? "";
|
|
167
|
-
if (/^https?:\/\//.test(url)) {
|
|
168
|
-
const a = doc.createElement("a");
|
|
169
|
-
a.href = url;
|
|
170
|
-
a.textContent = label;
|
|
171
|
-
a.rel = "noopener noreferrer";
|
|
172
|
-
container.appendChild(a);
|
|
173
|
-
} else container.appendChild(doc.createTextNode(match[0]));
|
|
174
|
-
}
|
|
175
|
-
last = index + match[0].length;
|
|
176
|
-
}
|
|
177
|
-
if (last < text.length) container.appendChild(doc.createTextNode(text.slice(last)));
|
|
178
|
-
}
|
|
179
|
-
function renderMiniMarkdown(text, doc) {
|
|
180
|
-
const root = doc.createElement("div");
|
|
181
|
-
root.className = "webskill-md";
|
|
182
|
-
const lines = text.split(/\r?\n/);
|
|
183
|
-
let i = 0;
|
|
184
|
-
while (i < lines.length) {
|
|
185
|
-
const line = lines[i] ?? "";
|
|
186
|
-
if (line.trimStart().startsWith("```")) {
|
|
187
|
-
const codeLines = [];
|
|
188
|
-
i++;
|
|
189
|
-
while (i < lines.length && !(lines[i] ?? "").trimStart().startsWith("```")) {
|
|
190
|
-
codeLines.push(lines[i] ?? "");
|
|
191
|
-
i++;
|
|
192
|
-
}
|
|
193
|
-
i++;
|
|
194
|
-
const pre = doc.createElement("pre");
|
|
195
|
-
const code = doc.createElement("code");
|
|
196
|
-
code.textContent = codeLines.join("\n");
|
|
197
|
-
pre.appendChild(code);
|
|
198
|
-
root.appendChild(pre);
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
202
|
-
if (heading) {
|
|
203
|
-
const level = heading[1].length;
|
|
204
|
-
const h = doc.createElement(`h${level}`);
|
|
205
|
-
appendInline(h, heading[2] ?? "", doc);
|
|
206
|
-
root.appendChild(h);
|
|
207
|
-
i++;
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
if (/^\s*[-*]\s+/.test(line)) {
|
|
211
|
-
const ul = doc.createElement("ul");
|
|
212
|
-
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i] ?? "")) {
|
|
213
|
-
const li = doc.createElement("li");
|
|
214
|
-
appendInline(li, (lines[i] ?? "").replace(/^\s*[-*]\s+/, ""), doc);
|
|
215
|
-
ul.appendChild(li);
|
|
216
|
-
i++;
|
|
217
|
-
}
|
|
218
|
-
root.appendChild(ul);
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
if (line.trim() === "") {
|
|
222
|
-
i++;
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
const p = doc.createElement("p");
|
|
226
|
-
appendInline(p, line, doc);
|
|
227
|
-
root.appendChild(p);
|
|
228
|
-
i++;
|
|
229
|
-
}
|
|
230
|
-
return root;
|
|
231
|
-
}
|
|
232
|
-
/** 与 catalog `Chart` 的 props 枚举逐字一致(`ui/catalog/uiCatalog.ts`) */
|
|
233
|
-
const KINDS = /* @__PURE__ */ new Set([
|
|
234
|
-
"bar",
|
|
235
|
-
"line",
|
|
236
|
-
"area",
|
|
237
|
-
"pie",
|
|
238
|
-
"scatter",
|
|
239
|
-
"stacked-bar",
|
|
240
|
-
"dual-axis"
|
|
241
|
-
]);
|
|
242
|
-
const list = (value) => Array.isArray(value) ? value : [];
|
|
243
|
-
/**
|
|
244
|
-
* catalog `Chart` 节点 props → `ChartSpec`。四档共用这一份转换:
|
|
245
|
-
* 此前 native / OpenUI / json-render / A2UI 各抄了一遍,改一处就会漂移。
|
|
246
|
-
* 非法枚举**原样保留**,由 `toEchartsOption` 给出可见降级提示——
|
|
247
|
-
* 此前偷偷改写成 `'line'`,画出的图与模型的意图无关且不可察觉。
|
|
248
|
-
*/
|
|
249
|
-
function chartSpecFromProps(props) {
|
|
250
|
-
const series = list(props["series"]);
|
|
251
|
-
return {
|
|
252
|
-
kind: typeof props["type"] === "string" ? props["type"] : "",
|
|
253
|
-
labels: list(props["labels"]),
|
|
254
|
-
series: series.map((item) => ({
|
|
255
|
-
...typeof item.name === "string" ? { name: item.name } : {},
|
|
256
|
-
data: list(item.values)
|
|
257
|
-
})),
|
|
258
|
-
...typeof props["title"] === "string" ? { title: props["title"] } : {}
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
const seriesNames = (chart) => chart.series.map((series) => series.name ?? "");
|
|
262
|
-
/** 字号默认值的**全仓唯一来源**;`12` 是 echarts 的全局默认,显式写出后渲染不变 */
|
|
263
|
-
const DEFAULT_CHART_FONT_SIZES = {
|
|
264
|
-
title: 14,
|
|
265
|
-
axisLabel: 12,
|
|
266
|
-
legend: 12
|
|
267
|
-
};
|
|
268
|
-
/** 逐字段兜底:宿主通常只想调大其中一两项 */
|
|
269
|
-
function resolveChartFontSizes(sizes) {
|
|
270
|
-
return {
|
|
271
|
-
...DEFAULT_CHART_FONT_SIZES,
|
|
272
|
-
...sizes
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
/** echarts 的图例条目来源:饼图按标签分项,其余按系列名 */
|
|
276
|
-
const legendEntries = (chart) => chart.kind === "pie" ? chart.labels : seriesNames(chart);
|
|
277
|
-
/**
|
|
278
|
-
* 非饼图的 series 形状表:catalog 声明的每种类型对应「基础 type + 零到一个修饰」。
|
|
279
|
-
* 饼图不在表内——它的 data 要按 labels 重组,形状与其余六种不同。
|
|
280
|
-
*/
|
|
281
|
-
const SERIES_SHAPE = {
|
|
282
|
-
bar: { type: "bar" },
|
|
283
|
-
line: { type: "line" },
|
|
284
|
-
area: {
|
|
285
|
-
type: "line",
|
|
286
|
-
areaStyle: {}
|
|
287
|
-
},
|
|
288
|
-
scatter: { type: "scatter" },
|
|
289
|
-
"stacked-bar": {
|
|
290
|
-
type: "bar",
|
|
291
|
-
stack: "total"
|
|
292
|
-
},
|
|
293
|
-
"dual-axis": { type: "line" }
|
|
294
|
-
};
|
|
295
|
-
const FALLBACK_KIND = "bar";
|
|
296
|
-
const fallbackNotice = (kind) => kind === "" ? `Missing chart type; rendered as a ${FALLBACK_KIND} chart.` : `Unsupported chart type "${kind}"; rendered as a ${FALLBACK_KIND} chart.`;
|
|
297
|
-
/** `ChartSpec` → ECharts option:图表类型、数据系列、图例的唯一定义处 */
|
|
298
|
-
function toEchartsOption(chart, fontSizes) {
|
|
299
|
-
const font = resolveChartFontSizes(fontSizes);
|
|
300
|
-
const known = KINDS.has(chart.kind);
|
|
301
|
-
const shape = SERIES_SHAPE[known ? chart.kind : FALLBACK_KIND];
|
|
302
|
-
const notice = known ? void 0 : fallbackNotice(chart.kind);
|
|
303
|
-
const valueAxis = {
|
|
304
|
-
type: "value",
|
|
305
|
-
axisLabel: { fontSize: font.axisLabel }
|
|
306
|
-
};
|
|
307
|
-
return {
|
|
308
|
-
animation: false,
|
|
309
|
-
title: chart.title || notice ? {
|
|
310
|
-
text: chart.title ?? "",
|
|
311
|
-
left: "center",
|
|
312
|
-
textStyle: { fontSize: font.title },
|
|
313
|
-
...notice ? { subtext: notice } : {}
|
|
314
|
-
} : void 0,
|
|
315
|
-
tooltip: { trigger: chart.kind === "pie" ? "item" : "axis" },
|
|
316
|
-
legend: {
|
|
317
|
-
bottom: 0,
|
|
318
|
-
textStyle: { fontSize: font.legend }
|
|
319
|
-
},
|
|
320
|
-
xAxis: chart.kind === "pie" ? void 0 : {
|
|
321
|
-
type: "category",
|
|
322
|
-
data: chart.labels,
|
|
323
|
-
axisLabel: { fontSize: font.axisLabel }
|
|
324
|
-
},
|
|
325
|
-
yAxis: chart.kind === "pie" ? void 0 : chart.kind === "dual-axis" ? [valueAxis, { ...valueAxis }] : valueAxis,
|
|
326
|
-
series: chart.kind === "pie" ? chart.series.map((series) => ({
|
|
327
|
-
type: "pie",
|
|
328
|
-
name: series.name,
|
|
329
|
-
data: chart.labels.map((label, index) => ({
|
|
330
|
-
name: label,
|
|
331
|
-
value: series.data[index] ?? 0
|
|
332
|
-
}))
|
|
333
|
-
})) : chart.series.map((series, index) => ({
|
|
334
|
-
...shape,
|
|
335
|
-
name: series.name,
|
|
336
|
-
data: series.data,
|
|
337
|
-
...chart.kind === "dual-axis" ? { yAxisIndex: index === 0 ? 0 : 1 } : {}
|
|
338
|
-
}))
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
/**
|
|
342
|
-
* 图表的 DOM 契约。echarts 画在 canvas 上,DOM 里读不到图表类型/系列/图例,
|
|
343
|
-
* 「同一份 ChartSpec 在各档下一致」只能靠宿主元素上的这三个属性对拍。
|
|
344
|
-
*/
|
|
345
|
-
function applyChartDomContract(container, chart) {
|
|
346
|
-
container.setAttribute("data-webskill-chart", chart.kind);
|
|
347
|
-
container.setAttribute("data-webskill-chart-series", seriesNames(chart).join("|"));
|
|
348
|
-
container.setAttribute("data-webskill-chart-legend", legendEntries(chart).join("|"));
|
|
349
|
-
if (chart.title) container.setAttribute("aria-label", chart.title);
|
|
350
|
-
else container.removeAttribute("aria-label");
|
|
351
|
-
}
|
|
352
|
-
/**
|
|
353
|
-
* 一篇文档常同时挂载多张图表;共用同一个模块 promise,
|
|
354
|
-
* 避免每个挂载点各自发起一次动态 import。
|
|
355
|
-
*/
|
|
356
|
-
let echartsModule;
|
|
357
|
-
const loadEcharts = () => echartsModule ??= import("./echarts-De78wXqV.js");
|
|
358
|
-
/**
|
|
359
|
-
* 框架无关的 echarts 挂载点:三档的 React 组件与 A2UI 档的 Lit 元素共用同一实现,
|
|
360
|
-
* 图表不再因档位而异。echarts 走动态 import,不打进首屏。
|
|
361
|
-
*/
|
|
362
|
-
function mountEchart(container) {
|
|
363
|
-
let disposed = false;
|
|
364
|
-
let instance;
|
|
365
|
-
let queued;
|
|
366
|
-
let observer;
|
|
367
|
-
const apply = (chart, fontSizes) => {
|
|
368
|
-
instance?.setOption(toEchartsOption(chart, fontSizes), {
|
|
369
|
-
notMerge: true,
|
|
370
|
-
lazyUpdate: true
|
|
371
|
-
});
|
|
372
|
-
};
|
|
373
|
-
loadEcharts().then((echarts) => {
|
|
374
|
-
if (disposed) return;
|
|
375
|
-
instance = echarts.init(container);
|
|
376
|
-
if (typeof ResizeObserver !== "undefined") {
|
|
377
|
-
observer = new ResizeObserver(() => instance?.resize());
|
|
378
|
-
observer.observe(container);
|
|
379
|
-
}
|
|
380
|
-
if (queued) apply(queued.chart, queued.fontSizes);
|
|
381
|
-
});
|
|
382
|
-
return {
|
|
383
|
-
setChart(chart, fontSizes) {
|
|
384
|
-
applyChartDomContract(container, chart);
|
|
385
|
-
queued = {
|
|
386
|
-
chart,
|
|
387
|
-
...fontSizes ? { fontSizes } : {}
|
|
388
|
-
};
|
|
389
|
-
apply(chart, fontSizes);
|
|
390
|
-
},
|
|
391
|
-
dispose() {
|
|
392
|
-
disposed = true;
|
|
393
|
-
observer?.disconnect();
|
|
394
|
-
observer = void 0;
|
|
395
|
-
instance?.dispose();
|
|
396
|
-
instance = void 0;
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
/**
|
|
401
|
-
* catalog 请求体积上限(0.11.0 分册 14 / AC-G20)的**数字单一来源**。
|
|
402
|
-
*
|
|
403
|
-
* 度量与报错格式在 `scripts/catalogBudget.mjs`(`check:contracts` 与 vitest 共用);
|
|
404
|
-
* 那边不重复这些数字,一律从本模块读——两处各写一份阈值就等于没有阈值。
|
|
405
|
-
*
|
|
406
|
-
* catalog 进入每一次 LLM 请求的有两处:系统提示词 `toPrompt()` 与
|
|
407
|
-
* `render_ui` 入参 `toJsonSchema()`。只量前者会漏掉约 2/3 的成本。
|
|
408
|
-
*
|
|
409
|
-
* **超阈值的处置是砍组件或收窄枚举取值,不是抬高这里的数字。**
|
|
410
|
-
*/
|
|
411
|
-
/**
|
|
412
|
-
* 预算阶段。分册 12 是本版最后一处 catalog 增量(分册 11 只把 `date` 加进
|
|
413
|
-
* 已有的 `Field.type`,分册 23 不碰 catalog),故在此切到 `'final'`:
|
|
414
|
-
* 阈值收紧到实测终态 + 余量,且余量小于一个平均组件的成本
|
|
415
|
-
* ——余量大于一个组件,等于默许下一版无声塞进一个。
|
|
416
|
-
*/
|
|
417
|
-
const CATALOG_BUDGET_STAGE = "final";
|
|
418
|
-
/**
|
|
419
|
-
* 阈值 = **已落地实测** + 余量,余量须小于一个平均组件的成本。
|
|
420
|
-
*
|
|
421
|
-
* 2026-08-14 实测终态(分册 12 落地后):prompt 12 744 / schema 21 470,30 个组件。
|
|
422
|
-
* 平均组件成本 prompt 425 / schema 716。
|
|
423
|
-
* 当前余量 prompt 256 / schema 330,均小于一个平均组件——再加一个组件必红。
|
|
424
|
-
*
|
|
425
|
-
* ⚠️ **本版这两个数字被上调过两次**,两次都由需求方裁决并改写了需求文档
|
|
426
|
-
* (分册 29 §2 范围订正 第 7 条与 §2.2),不是实现阶段自行放宽:
|
|
427
|
-
*
|
|
428
|
-
* 1. 11 200 → 12 200 / 20 500 → 21 200:分册 13 组件描述由短改长。
|
|
429
|
-
* `example` 是既有护栏的硬要求(`catalog.test.ts` 与 `catalogMatrix.test.tsx`
|
|
430
|
-
* 逐个组件校验并四档渲染),靠省掉示例腾出的空间收不回来。
|
|
431
|
-
* 2. 12 200 → 13 000 / 21 200 → 21 800:分册 12 的 `Split` 实测 490 + 694,
|
|
432
|
-
* 超出计划增量(403 + 1 047)的 prompt 侧。需求方裁定
|
|
433
|
-
* 「描述要长,可用性大于预算阈值」——收紧措辞可省回 158 字节,
|
|
434
|
-
* 但砍的正是各组件「何时改用 X」的辨析句,即选型准确率本身。
|
|
435
|
-
*
|
|
436
|
-
* 两次上调合计把单请求成本从 21.7 KB 抬到约 34.2 KB。
|
|
437
|
-
* **AC-G20 原文「超了砍组件、不抬阈值」已被推翻,但仅限本版;
|
|
438
|
-
* 后续再撞顶仍须回到需求方,不得沿用本版作为先例。**
|
|
439
|
-
*/
|
|
440
|
-
const CATALOG_PROMPT_MAX = 13e3;
|
|
441
|
-
const CATALOG_SCHEMA_MAX = 21800;
|
|
442
|
-
/**
|
|
443
|
-
* **尚未落地**的计划增量。本版已无剩余 catalog 增量,故归零;
|
|
444
|
-
* `CATALOG_BUDGET_STAGE` 同时切到 `'final'`,余量改由「小于一个平均组件」判定。
|
|
445
|
-
*/
|
|
446
|
-
const PLANNED_INCREMENT = {
|
|
447
|
-
prompt: 0,
|
|
448
|
-
schema: 0
|
|
449
|
-
};
|
|
450
|
-
/** 码与人话之间的分隔符。放在消息最前面,`codeOfIssue` 才能从 zod 的 issue 里认出它。 */
|
|
451
|
-
const SEPARATOR = ": ";
|
|
452
|
-
/**
|
|
453
|
-
* 给 zod 的 `error` 文案打上稳定码。
|
|
454
|
-
*
|
|
455
|
-
* 组件自己在 schema 里声明,sanitize 只负责把码搬到 `UiSpecDegradation.code` 上——
|
|
456
|
-
* 这样新增一条策略性约束不需要去 sanitize 里加组件名分支(写死清单是本仓反复出问题的地方)。
|
|
457
|
-
*/
|
|
458
|
-
function codedError(code, message) {
|
|
459
|
-
return `${code}${SEPARATOR}${message}`;
|
|
460
|
-
}
|
|
461
|
-
/** 从 zod issue 的消息里取回码;没有码就返回 undefined(普通类型错误就是这种)。 */
|
|
462
|
-
function codeOfIssue(message) {
|
|
463
|
-
const index = message.indexOf(SEPARATOR);
|
|
464
|
-
if (index <= 0) return void 0;
|
|
465
|
-
const head = message.slice(0, index);
|
|
466
|
-
return /^[A-Z][A-Z0-9_]*$/.test(head) ? head : void 0;
|
|
467
|
-
}
|
|
468
|
-
function isRecord$1$1(value) {
|
|
469
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* 逐节点降级(FR-23.3)。
|
|
473
|
-
*
|
|
474
|
-
* 与 `validate` 的关系见 `UiCatalog.sanitize` 的注释:一个整块拒绝、一个逐节点救。
|
|
475
|
-
*
|
|
476
|
-
* **不写回 `safeParse` 的结果**是刻意的:zod 会剥离未声明的键,
|
|
477
|
-
* 而渲染层确实在读若干未进 zod 的 prop(如 `suggestion`,由 SDK 侧下发而非模型生成)。
|
|
478
|
-
* 写回等于把它们悄悄删掉。这里只删「确实解析失败」的那几个键。
|
|
479
|
-
*/
|
|
480
|
-
function sanitizeNode(catalog, byName, node, path, degradations) {
|
|
481
|
-
if (!isRecord$1$1(node)) {
|
|
482
|
-
degradations.push({
|
|
483
|
-
path,
|
|
484
|
-
kind: "node-dropped",
|
|
485
|
-
component: "unknown",
|
|
486
|
-
detail: "Node is not an object."
|
|
487
|
-
});
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
const name = node["component"];
|
|
491
|
-
if (typeof name !== "string") {
|
|
492
|
-
degradations.push({
|
|
493
|
-
path,
|
|
494
|
-
kind: "node-dropped",
|
|
495
|
-
component: "unknown",
|
|
496
|
-
detail: "Node has no \"component\" name."
|
|
497
|
-
});
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
const def = byName.get(name);
|
|
501
|
-
if (!def) {
|
|
502
|
-
degradations.push({
|
|
503
|
-
path,
|
|
504
|
-
kind: "unknown-component",
|
|
505
|
-
component: name,
|
|
506
|
-
detail: `Unknown component "${name}"; the catalog declares: ${catalog.components.map((c) => c.name).join(", ")}`
|
|
507
|
-
});
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
const rawProps = isRecord$1$1(node["props"]) ? node["props"] : {};
|
|
511
|
-
let props = rawProps;
|
|
512
|
-
const parsed = def.props.safeParse(rawProps);
|
|
513
|
-
if (!parsed.success) {
|
|
514
|
-
const offending = new Set(parsed.error.issues.map((issue) => String(issue.path[0] ?? "")).filter((key) => key !== ""));
|
|
515
|
-
const codes = /* @__PURE__ */ new Map();
|
|
516
|
-
for (const issue of parsed.error.issues) {
|
|
517
|
-
const key = String(issue.path[0] ?? "");
|
|
518
|
-
const code = codeOfIssue(issue.message);
|
|
519
|
-
if (key !== "" && code !== void 0) codes.set(key, code);
|
|
520
|
-
}
|
|
521
|
-
const kept = {};
|
|
522
|
-
for (const [key, value] of Object.entries(rawProps)) if (!offending.has(key)) kept[key] = value;
|
|
523
|
-
const retry = def.props.safeParse(kept);
|
|
524
|
-
if (!retry.success) {
|
|
525
|
-
const carried = [...codes.values()].find((code) => code !== void 0);
|
|
526
|
-
degradations.push({
|
|
527
|
-
path,
|
|
528
|
-
kind: "node-dropped",
|
|
529
|
-
component: name,
|
|
530
|
-
detail: `Props cannot be repaired by dropping invalid keys: ${retry.error.issues.map((i) => i.message).join("; ")}`,
|
|
531
|
-
...carried === void 0 ? {} : { code: carried }
|
|
532
|
-
});
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
for (const key of offending) {
|
|
536
|
-
const code = codes.get(key);
|
|
537
|
-
degradations.push({
|
|
538
|
-
path: `${path}.props.${key}`,
|
|
539
|
-
kind: "prop-dropped",
|
|
540
|
-
component: name,
|
|
541
|
-
detail: `Dropped invalid prop "${key}".`,
|
|
542
|
-
...code === void 0 ? {} : { code }
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
props = kept;
|
|
546
|
-
}
|
|
547
|
-
const sanitized = {
|
|
548
|
-
component: name,
|
|
549
|
-
props
|
|
550
|
-
};
|
|
551
|
-
if (typeof node["id"] === "string") sanitized.id = node["id"];
|
|
552
|
-
const children = node["children"];
|
|
553
|
-
if (children === void 0) return sanitized;
|
|
554
|
-
if (!Array.isArray(children)) {
|
|
555
|
-
degradations.push({
|
|
556
|
-
path: `${path}.children`,
|
|
557
|
-
kind: "constraint-ignored",
|
|
558
|
-
component: name,
|
|
559
|
-
detail: "children is not an array; rendered without children."
|
|
560
|
-
});
|
|
561
|
-
return sanitized;
|
|
562
|
-
}
|
|
563
|
-
if (def.children === void 0) {
|
|
564
|
-
degradations.push({
|
|
565
|
-
path: `${path}.children`,
|
|
566
|
-
kind: "constraint-ignored",
|
|
567
|
-
component: name,
|
|
568
|
-
detail: `Component "${name}" is a leaf; its children were dropped.`
|
|
569
|
-
});
|
|
570
|
-
return sanitized;
|
|
571
|
-
}
|
|
572
|
-
const allowed = def.children;
|
|
573
|
-
const singletons = /* @__PURE__ */ new Map();
|
|
574
|
-
const kept = [];
|
|
575
|
-
children.forEach((child, index) => {
|
|
576
|
-
const childPath = `${path}.children[${index}]`;
|
|
577
|
-
const childName = isRecord$1$1(child) && typeof child["component"] === "string" ? child["component"] : void 0;
|
|
578
|
-
if (allowed !== "any" && childName !== void 0 && !allowed.includes(childName)) {
|
|
579
|
-
degradations.push({
|
|
580
|
-
path: childPath,
|
|
581
|
-
kind: byName.has(childName) ? "node-dropped" : "unknown-component",
|
|
582
|
-
component: childName,
|
|
583
|
-
detail: byName.has(childName) ? `Component "${name}" only accepts children: ${[...allowed].join(", ")}` : `Unknown component "${childName}"; the catalog declares: ${catalog.components.map((c) => c.name).join(", ")}`
|
|
584
|
-
});
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
if (childName !== void 0 && byName.get(childName)?.singletonPerContainer === true) {
|
|
588
|
-
const seen = (singletons.get(childName) ?? 0) + 1;
|
|
589
|
-
singletons.set(childName, seen);
|
|
590
|
-
if (seen > 1) {
|
|
591
|
-
degradations.push({
|
|
592
|
-
path: childPath,
|
|
593
|
-
kind: "node-dropped",
|
|
594
|
-
component: childName,
|
|
595
|
-
detail: `At most one "${childName}" per container; the extra one was dropped.`
|
|
596
|
-
});
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
const sanitizedChild = sanitizeNode(catalog, byName, child, childPath, degradations);
|
|
601
|
-
if (sanitizedChild) kept.push(sanitizedChild);
|
|
602
|
-
});
|
|
603
|
-
sanitized.children = kept;
|
|
604
|
-
return sanitized;
|
|
605
|
-
}
|
|
606
|
-
function sanitizeUiSpec(catalog, byName, spec) {
|
|
607
|
-
const degradations = [];
|
|
608
|
-
return {
|
|
609
|
-
node: sanitizeNode(catalog, byName, spec, "root", degradations),
|
|
610
|
-
degradations
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
function componentSchema(def, rootDefs) {
|
|
614
|
-
const props = liftDefs(toJSONSchema(def.props, { io: "input" }), def.name, rootDefs);
|
|
615
|
-
const node = {
|
|
616
|
-
type: "object",
|
|
617
|
-
description: def.description,
|
|
618
|
-
properties: {
|
|
619
|
-
component: {
|
|
620
|
-
type: "string",
|
|
621
|
-
const: def.name
|
|
622
|
-
},
|
|
623
|
-
id: {
|
|
624
|
-
type: "string",
|
|
625
|
-
description: "Stable node id, reused on replay"
|
|
626
|
-
},
|
|
627
|
-
props
|
|
628
|
-
},
|
|
629
|
-
required: ["component"]
|
|
630
|
-
};
|
|
631
|
-
if (def.children !== void 0) node.properties = {
|
|
632
|
-
...node.properties,
|
|
633
|
-
children: {
|
|
634
|
-
type: "array",
|
|
635
|
-
description: def.children === "any" ? "Any catalog component" : `One of: ${[...def.children].join(", ")}`,
|
|
636
|
-
items: { $ref: "#" }
|
|
637
|
-
}
|
|
638
|
-
};
|
|
639
|
-
return node;
|
|
640
|
-
}
|
|
641
|
-
function isRecord$2(value) {
|
|
642
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
643
|
-
}
|
|
644
|
-
const DEFS_REF_PREFIX = "#/$defs/";
|
|
645
|
-
function rewriteDefRefs(node, renames) {
|
|
646
|
-
if (Array.isArray(node)) return node.map((child) => rewriteDefRefs(child, renames));
|
|
647
|
-
if (!isRecord$2(node)) return node;
|
|
648
|
-
const out = {};
|
|
649
|
-
for (const [key, value] of Object.entries(node)) {
|
|
650
|
-
if (key === "$ref" && typeof value === "string" && value.startsWith(DEFS_REF_PREFIX)) {
|
|
651
|
-
const [head, ...tail] = value.slice(8).split("/");
|
|
652
|
-
const mapped = head === void 0 ? void 0 : renames.get(head);
|
|
653
|
-
out[key] = mapped === void 0 ? value : [`${DEFS_REF_PREFIX}${mapped}`, ...tail].join("/");
|
|
654
|
-
continue;
|
|
655
|
-
}
|
|
656
|
-
out[key] = rewriteDefRefs(value, renames);
|
|
657
|
-
}
|
|
658
|
-
return out;
|
|
659
|
-
}
|
|
660
|
-
/**
|
|
661
|
-
* zod 每个组件各自产出 `$defs.__schemaN`,其 `#/$defs/...` 指的是那次转换自己的根;
|
|
662
|
-
* 直接嵌进 catalog 大 schema 后那个 `#` 变成了 catalog 根,引用从产物根解析不到。
|
|
663
|
-
* 提到根并以组件名限定,避免不同组件的同名定义互相覆盖。
|
|
664
|
-
*/
|
|
665
|
-
function liftDefs(schema, owner, rootDefs) {
|
|
666
|
-
const defs = schema["$defs"];
|
|
667
|
-
if (!isRecord$2(defs)) return schema;
|
|
668
|
-
const renames = /* @__PURE__ */ new Map();
|
|
669
|
-
for (const name of Object.keys(defs)) renames.set(name, `${owner}_${name}`);
|
|
670
|
-
for (const [name, def] of Object.entries(defs)) rootDefs[renames.get(name)] = rewriteDefRefs(def, renames);
|
|
671
|
-
const body = {};
|
|
672
|
-
for (const [key, value] of Object.entries(schema)) if (key !== "$defs") body[key] = value;
|
|
673
|
-
return rewriteDefRefs(body, renames);
|
|
674
|
-
}
|
|
675
|
-
function typeOf(schema) {
|
|
676
|
-
if (Array.isArray(schema.enum)) return schema.enum.map((option) => JSON.stringify(option)).join(" | ");
|
|
677
|
-
if (schema["const"] !== void 0) return JSON.stringify(schema["const"]);
|
|
678
|
-
const union = schema["anyOf"] ?? schema["oneOf"];
|
|
679
|
-
if (Array.isArray(union)) return union.map(typeOf).join(" | ");
|
|
680
|
-
if (schema.type === "array" && schema.items) return `${typeOf(schema.items)}[]`;
|
|
681
|
-
return typeof schema.type === "string" ? schema.type : "unknown";
|
|
682
|
-
}
|
|
683
|
-
function describeProps(def) {
|
|
684
|
-
const schema = toJSONSchema(def.props, { io: "input" });
|
|
685
|
-
const properties = schema.properties ?? {};
|
|
686
|
-
const required = new Set(schema.required ?? []);
|
|
687
|
-
const entries = Object.entries(properties).map(([key, value]) => `${key}${required.has(key) ? "" : "?"}: ${typeOf(value)}`);
|
|
688
|
-
return entries.length === 0 ? "—" : entries.join(", ");
|
|
689
|
-
}
|
|
690
|
-
function promptFor(catalog, options) {
|
|
691
|
-
const lines = [(options.mode === "tool-description" ? "" : `# ${catalog.title} (${catalog.version})\n\n`) + catalog.instructions.trim(), ""];
|
|
692
|
-
const groups = /* @__PURE__ */ new Map();
|
|
693
|
-
for (const def of catalog.components) groups.set(def.group, [...groups.get(def.group) ?? [], def]);
|
|
694
|
-
for (const [group, defs] of groups) {
|
|
695
|
-
lines.push(`## ${group}`, "");
|
|
696
|
-
for (const def of defs) {
|
|
697
|
-
lines.push(`### ${def.name}`, def.description, "", `props: ${describeProps(def)}`);
|
|
698
|
-
if (def.children !== void 0) lines.push(`children: ${def.children === "any" ? "any component" : [...def.children].join(" | ")}`);
|
|
699
|
-
for (const constraint of def.constraints ?? []) lines.push(`- ${constraint}`);
|
|
700
|
-
if (def.example) lines.push("", "```json", JSON.stringify(def.example), "```");
|
|
701
|
-
lines.push("");
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
if (catalog.actions.length > 0) {
|
|
705
|
-
lines.push("## actions", "");
|
|
706
|
-
for (const action of catalog.actions) lines.push(`- \`${action.name}\` — ${action.description}`);
|
|
707
|
-
lines.push("");
|
|
708
|
-
}
|
|
709
|
-
return lines.join("\n").trimEnd();
|
|
710
|
-
}
|
|
711
|
-
function validateNode(catalog, byName, node, path, issues) {
|
|
712
|
-
if (!isRecord$2(node)) {
|
|
713
|
-
issues.push({
|
|
714
|
-
path,
|
|
715
|
-
message: "Node must be an object"
|
|
716
|
-
});
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
const name = node["component"];
|
|
720
|
-
if (typeof name !== "string") {
|
|
721
|
-
issues.push({
|
|
722
|
-
path,
|
|
723
|
-
message: "Node is missing a \"component\" name"
|
|
724
|
-
});
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
const def = byName.get(name);
|
|
728
|
-
if (!def) {
|
|
729
|
-
issues.push({
|
|
730
|
-
path,
|
|
731
|
-
message: `Unknown component "${name}"; the catalog declares: ${catalog.components.map((c) => c.name).join(", ")}`
|
|
732
|
-
});
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
const parsed = def.props.safeParse(node["props"] ?? {});
|
|
736
|
-
if (!parsed.success) for (const issue of parsed.error.issues) issues.push({
|
|
737
|
-
path: `${path}.props${issue.path.map((part) => `.${String(part)}`).join("")}`,
|
|
738
|
-
message: issue.message
|
|
739
|
-
});
|
|
740
|
-
const children = node["children"];
|
|
741
|
-
if (children === void 0) return;
|
|
742
|
-
if (!Array.isArray(children)) {
|
|
743
|
-
issues.push({
|
|
744
|
-
path: `${path}.children`,
|
|
745
|
-
message: "children must be an array"
|
|
746
|
-
});
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
if (def.children === void 0) {
|
|
750
|
-
issues.push({
|
|
751
|
-
path: `${path}.children`,
|
|
752
|
-
message: `Component "${name}" is a leaf and cannot have children`
|
|
753
|
-
});
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
const allowed = def.children;
|
|
757
|
-
const singletons = /* @__PURE__ */ new Map();
|
|
758
|
-
children.forEach((child, index) => {
|
|
759
|
-
const childPath = `${path}.children[${index}]`;
|
|
760
|
-
if (allowed !== "any" && isRecord$2(child) && typeof child["component"] === "string") {
|
|
761
|
-
if (!allowed.includes(child["component"])) {
|
|
762
|
-
issues.push({
|
|
763
|
-
path: childPath,
|
|
764
|
-
message: `Component "${name}" only accepts children: ${[...allowed].join(", ")}`
|
|
765
|
-
});
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
if (isRecord$2(child) && typeof child["component"] === "string") {
|
|
770
|
-
const childName = child["component"];
|
|
771
|
-
if (byName.get(childName)?.singletonPerContainer === true) {
|
|
772
|
-
const seen = (singletons.get(childName) ?? 0) + 1;
|
|
773
|
-
singletons.set(childName, seen);
|
|
774
|
-
if (seen > 1) issues.push({
|
|
775
|
-
path: childPath,
|
|
776
|
-
message: `At most one "${childName}" per container; "${name}" already contains one`
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
validateNode(catalog, byName, child, childPath, issues);
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
/**
|
|
784
|
-
* 同一份定义派生出 JSON Schema / prompt / validator 三种投影,
|
|
785
|
-
* 不允许任何一方手写副本(21 号文档 §1)。
|
|
786
|
-
*/
|
|
787
|
-
function defineUiCatalog(input) {
|
|
788
|
-
const byName = new Map(input.components.map((def) => [def.name, def]));
|
|
789
|
-
if (byName.size !== input.components.length) {
|
|
790
|
-
const seen = /* @__PURE__ */ new Set();
|
|
791
|
-
const duplicate = input.components.find((def) => seen.has(def.name) ? true : (seen.add(def.name), false));
|
|
792
|
-
throw new Error(`Duplicate component name "${duplicate?.name}" in UI catalog "${input.id}"`);
|
|
793
|
-
}
|
|
794
|
-
return {
|
|
795
|
-
...input,
|
|
796
|
-
componentNames: input.components.map((def) => def.name),
|
|
797
|
-
actionNames: input.actions.map((action) => action.name),
|
|
798
|
-
component: (name) => byName.get(name),
|
|
799
|
-
toJsonSchema: () => {
|
|
800
|
-
const $defs = {};
|
|
801
|
-
const oneOf = input.components.map((def) => componentSchema(def, $defs));
|
|
802
|
-
return {
|
|
803
|
-
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
804
|
-
$id: input.id,
|
|
805
|
-
title: input.title,
|
|
806
|
-
description: input.instructions,
|
|
807
|
-
...Object.keys($defs).length > 0 ? { $defs } : {},
|
|
808
|
-
oneOf
|
|
809
|
-
};
|
|
810
|
-
},
|
|
811
|
-
toPrompt: (options = {}) => promptFor(input, options),
|
|
812
|
-
validate: (spec) => {
|
|
813
|
-
const issues = [];
|
|
814
|
-
validateNode(input, byName, spec, "root", issues);
|
|
815
|
-
return issues.length === 0 ? { ok: true } : {
|
|
816
|
-
ok: false,
|
|
817
|
-
issues
|
|
818
|
-
};
|
|
819
|
-
},
|
|
820
|
-
sanitize: (spec) => sanitizeUiSpec(input, byName, spec)
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
const tone = z.enum([
|
|
824
|
-
"neutral",
|
|
825
|
-
"success",
|
|
826
|
-
"warning",
|
|
827
|
-
"info",
|
|
828
|
-
"destructive"
|
|
829
|
-
]);
|
|
830
|
-
/**
|
|
831
|
-
* 装饰色。与 `tone` 语义**不重叠**:tone 表状态(success 是「成功」),
|
|
832
|
-
* palette 表美观(「行程第三天」用绿色不代表它成功了)。
|
|
833
|
-
* 取值是色板名不是颜色值——必须映射到 design token,深浅色主题才能自动跟随。
|
|
834
|
-
*/
|
|
835
|
-
const palette = z.enum([
|
|
836
|
-
"sky",
|
|
837
|
-
"violet",
|
|
838
|
-
"amber",
|
|
839
|
-
"rose",
|
|
840
|
-
"emerald"
|
|
841
|
-
]);
|
|
842
|
-
/**
|
|
843
|
-
* surface 级视觉档(FR-13.4)。只在**根节点**上有意义,切的是一组 token
|
|
844
|
-
* (圆角/阴影/底色),不是逐组件改样式;作用域限于该 surface 子树。
|
|
845
|
-
* `office` 是缺省,与本版之前逐字节一致——它是回归基线。
|
|
846
|
-
*/
|
|
847
|
-
const surfaceTheme = z.enum([
|
|
848
|
-
"office",
|
|
849
|
-
"vivid",
|
|
850
|
-
"dashboard-dark"
|
|
851
|
-
]);
|
|
852
|
-
/** 图标白名单:任意名字会让模型产出渲染不出来的节点,且枚举长度直接计入每次请求 */
|
|
853
|
-
const iconName = z.enum([
|
|
854
|
-
"info",
|
|
855
|
-
"check",
|
|
856
|
-
"alert",
|
|
857
|
-
"x",
|
|
858
|
-
"clock",
|
|
859
|
-
"calendar",
|
|
860
|
-
"user",
|
|
861
|
-
"star",
|
|
862
|
-
"search",
|
|
863
|
-
"settings",
|
|
864
|
-
"download",
|
|
865
|
-
"link",
|
|
866
|
-
"chart",
|
|
867
|
-
"file",
|
|
868
|
-
"folder",
|
|
869
|
-
"mail"
|
|
870
|
-
]);
|
|
871
|
-
/**
|
|
872
|
-
* `Image.src` 的同源约束(AC-13.4)。**判定放在 schema 上,`validate` 与 `sanitize` 两条路径就都覆盖**;
|
|
873
|
-
* 只写进组件描述等于把安全交给模型自觉——提示注入可以用 `src` 把数据带到任意主机上去。
|
|
874
|
-
*
|
|
875
|
-
* 同源按**语法**判:`data:image/` 与相对路径放行,任何带 scheme 的绝对 URL
|
|
876
|
-
* 以及协议相对的 `//host/...` 一律拒。这样判定不需要知道当前 origin,
|
|
877
|
-
* `@webskill/ui` 得以保持环境无关。
|
|
878
|
-
*/
|
|
879
|
-
const imageSrc = z.string().refine((src) => /^data:image\//i.test(src) || !/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(src), { error: codedError("IMAGE_SRC_NOT_ALLOWED", "Image.src must be a same-origin path or a data:image URL; remote URLs are rejected by policy.") });
|
|
880
|
-
const actionRef = z.object({
|
|
881
|
-
/** catalog actions 里声明过的名字 */
|
|
882
|
-
action: z.string().describe("Action name declared by the catalog"),
|
|
883
|
-
label: z.string(),
|
|
884
|
-
disabled: z.boolean().optional()
|
|
885
|
-
});
|
|
886
|
-
const CONTENT = [
|
|
887
|
-
"Stack",
|
|
888
|
-
"Card",
|
|
889
|
-
"Separator",
|
|
890
|
-
"Heading",
|
|
891
|
-
"Text",
|
|
892
|
-
"Markdown",
|
|
893
|
-
"Badge"
|
|
894
|
-
];
|
|
895
|
-
const DATA = [
|
|
896
|
-
"Metric",
|
|
897
|
-
"Table",
|
|
898
|
-
"Chart",
|
|
899
|
-
"Timeline",
|
|
900
|
-
"FileLink"
|
|
901
|
-
];
|
|
902
|
-
const INPUT = [
|
|
903
|
-
"Form",
|
|
904
|
-
"Field",
|
|
905
|
-
"FieldArray",
|
|
906
|
-
"Button"
|
|
907
|
-
];
|
|
908
|
-
/**
|
|
909
|
-
* `visibleWhen` 的结构化条件(设计 23 §1.2)。用 `z.lazy` 自引用;
|
|
910
|
-
* 语义深度上限不在 zod 里卡,由 `evaluateFieldCondition` 按 `MAX_CONDITION_DEPTH` 降级处理。
|
|
911
|
-
*/
|
|
912
|
-
const fieldCondition = z.lazy(() => z.union([
|
|
913
|
-
z.object({
|
|
914
|
-
field: z.string(),
|
|
915
|
-
equals: z.union([
|
|
916
|
-
z.string(),
|
|
917
|
-
z.number(),
|
|
918
|
-
z.boolean()
|
|
919
|
-
])
|
|
920
|
-
}),
|
|
921
|
-
z.object({
|
|
922
|
-
field: z.string(),
|
|
923
|
-
in: z.array(z.union([z.string(), z.number()]))
|
|
924
|
-
}),
|
|
925
|
-
z.object({
|
|
926
|
-
field: z.string(),
|
|
927
|
-
notEmpty: z.literal(true)
|
|
928
|
-
}),
|
|
929
|
-
z.object({ allOf: z.array(fieldCondition).min(1) }),
|
|
930
|
-
z.object({ anyOf: z.array(fieldCondition).min(1) })
|
|
931
|
-
]));
|
|
932
|
-
/** 选项既可以写死,也可以声明成异步取值(取值通道属分册 24) */
|
|
933
|
-
const fieldOptions = z.array(z.object({
|
|
934
|
-
label: z.string(),
|
|
935
|
-
value: z.union([z.string(), z.number()])
|
|
936
|
-
}));
|
|
937
|
-
/** Tabs 的面板与 Grid 的格子必须自成容器,否则「每容器至多一个表单」就没有落脚点 */
|
|
938
|
-
const PANEL = ["Card", "Stack"];
|
|
939
|
-
/**
|
|
940
|
-
* v3 首发 catalog:覆盖旧六类 surface(metric / chart / table / form / file / custom)的全部表达力,先窄后宽。
|
|
941
|
-
* 只依赖 zod —— 不 import 任何 UI framework(21 号文档 §5.1)。
|
|
942
|
-
*/
|
|
943
|
-
const uiCatalog = defineUiCatalog({
|
|
944
|
-
id: "https://webskill.dev/catalogs/v1",
|
|
945
|
-
version: "1.0.0",
|
|
946
|
-
title: "WebSkill AI Component Catalog",
|
|
947
|
-
instructions: [
|
|
948
|
-
"Compose the answer UI from the components below. Rules:",
|
|
949
|
-
"",
|
|
950
|
-
"- The root node of a surface must be `Stack` or `Card`.",
|
|
951
|
-
"- Ask for user input with `Form` + `Field`; never describe an input in `Text`.",
|
|
952
|
-
"- A `Button`'s `action` must be one of the catalog actions.",
|
|
953
|
-
"- Never emit HTML or scripts. `Image.src` must be same-origin or a data URL.",
|
|
954
|
-
"- Prefer `Table` for many rows, `Chart` for trends, `Metric` for a single number,",
|
|
955
|
-
" `Timeline` for ordered steps and `Progress` for how far a long task has come.",
|
|
956
|
-
"- Match `Field.type` to the question instead of using `text` for everything:",
|
|
957
|
-
" `select` / `multi-select` for a fixed set of options, `date` for dates,",
|
|
958
|
-
" `toggle` for yes/no, `number` for quantities, `textarea` for several sentences.",
|
|
959
|
-
"- Group independent sections with `Tabs` or `Grid` instead of one long column.",
|
|
960
|
-
"- At most one `Form` per container; a `Tabs` panel and a `Grid` cell are separate containers."
|
|
961
|
-
].join("\n"),
|
|
962
|
-
components: [
|
|
963
|
-
{
|
|
964
|
-
name: "Stack",
|
|
965
|
-
group: "layout",
|
|
966
|
-
description: "Vertical or horizontal container. Use it as the root of most surfaces.",
|
|
967
|
-
props: z.object({
|
|
968
|
-
direction: z.enum(["vertical", "horizontal"]).default("vertical"),
|
|
969
|
-
gap: z.enum([
|
|
970
|
-
"sm",
|
|
971
|
-
"md",
|
|
972
|
-
"lg"
|
|
973
|
-
]).default("md"),
|
|
974
|
-
align: z.enum([
|
|
975
|
-
"start",
|
|
976
|
-
"center",
|
|
977
|
-
"end",
|
|
978
|
-
"stretch"
|
|
979
|
-
]).optional(),
|
|
980
|
-
theme: surfaceTheme.optional()
|
|
981
|
-
}),
|
|
982
|
-
children: "any",
|
|
983
|
-
example: {
|
|
984
|
-
component: "Stack",
|
|
985
|
-
props: { direction: "vertical" },
|
|
986
|
-
children: [{
|
|
987
|
-
component: "Heading",
|
|
988
|
-
props: {
|
|
989
|
-
level: 2,
|
|
990
|
-
text: "Q4 results"
|
|
991
|
-
}
|
|
992
|
-
}]
|
|
993
|
-
}
|
|
994
|
-
},
|
|
995
|
-
{
|
|
996
|
-
name: "Card",
|
|
997
|
-
group: "layout",
|
|
998
|
-
description: "A titled section that groups related content.",
|
|
999
|
-
props: z.object({
|
|
1000
|
-
title: z.string(),
|
|
1001
|
-
description: z.string().optional(),
|
|
1002
|
-
palette: palette.optional(),
|
|
1003
|
-
theme: surfaceTheme.optional(),
|
|
1004
|
-
span: z.union([z.literal(1), z.literal(2)]).default(1),
|
|
1005
|
-
density: z.enum([
|
|
1006
|
-
"compact",
|
|
1007
|
-
"default",
|
|
1008
|
-
"comfortable"
|
|
1009
|
-
]).default("default")
|
|
1010
|
-
}),
|
|
1011
|
-
children: "any",
|
|
1012
|
-
example: {
|
|
1013
|
-
component: "Card",
|
|
1014
|
-
props: { title: "Revenue" },
|
|
1015
|
-
children: [{
|
|
1016
|
-
component: "Metric",
|
|
1017
|
-
props: {
|
|
1018
|
-
label: "MRR",
|
|
1019
|
-
value: "12,400"
|
|
1020
|
-
}
|
|
1021
|
-
}]
|
|
1022
|
-
}
|
|
1023
|
-
},
|
|
1024
|
-
{
|
|
1025
|
-
name: "Separator",
|
|
1026
|
-
group: "layout",
|
|
1027
|
-
description: "A horizontal rule between sections.",
|
|
1028
|
-
props: z.object({}),
|
|
1029
|
-
example: { component: "Separator" }
|
|
1030
|
-
},
|
|
1031
|
-
{
|
|
1032
|
-
name: "Tabs",
|
|
1033
|
-
group: "layout",
|
|
1034
|
-
description: "Switchable panels. Each child is one panel and its own form container.",
|
|
1035
|
-
props: z.object({ labels: z.array(z.string()).min(2) }),
|
|
1036
|
-
children: PANEL,
|
|
1037
|
-
constraints: ["One child per label, in the same order"],
|
|
1038
|
-
example: {
|
|
1039
|
-
component: "Tabs",
|
|
1040
|
-
props: { labels: ["Filters", "Export"] },
|
|
1041
|
-
children: [{
|
|
1042
|
-
component: "Stack",
|
|
1043
|
-
children: [{
|
|
1044
|
-
component: "Text",
|
|
1045
|
-
props: { text: "Pick a date range." }
|
|
1046
|
-
}]
|
|
1047
|
-
}, {
|
|
1048
|
-
component: "Stack",
|
|
1049
|
-
children: [{
|
|
1050
|
-
component: "Text",
|
|
1051
|
-
props: { text: "Choose a file format." }
|
|
1052
|
-
}]
|
|
1053
|
-
}]
|
|
1054
|
-
}
|
|
1055
|
-
},
|
|
1056
|
-
{
|
|
1057
|
-
name: "Split",
|
|
1058
|
-
group: "layout",
|
|
1059
|
-
description: "Two panes side by side at a fixed ratio — a chart next to its takeaways, a form next to its help text, a summary next to the detail it summarises. Takes exactly two children and stacks them vertically when the container is narrow, so the main pane always comes first. Use `Grid` when the cells are peers rather than a main/side pair.",
|
|
1060
|
-
props: z.object({ ratio: z.enum([
|
|
1061
|
-
"1:1",
|
|
1062
|
-
"2:1",
|
|
1063
|
-
"3:1",
|
|
1064
|
-
"1:2"
|
|
1065
|
-
]).default("1:1") }),
|
|
1066
|
-
children: PANEL,
|
|
1067
|
-
example: {
|
|
1068
|
-
component: "Split",
|
|
1069
|
-
props: { ratio: "2:1" },
|
|
1070
|
-
children: [{
|
|
1071
|
-
component: "Card",
|
|
1072
|
-
props: { title: "Trend" }
|
|
1073
|
-
}, {
|
|
1074
|
-
component: "Card",
|
|
1075
|
-
props: { title: "Notes" }
|
|
1076
|
-
}]
|
|
1077
|
-
}
|
|
1078
|
-
},
|
|
1079
|
-
{
|
|
1080
|
-
name: "Grid",
|
|
1081
|
-
group: "layout",
|
|
1082
|
-
description: "Dashboard grid: several tiles, metrics or charts the reader scans in parallel. Each child is one cell and its own form container. Prefer `columns: 'auto'` with a `minColumnWidth` — it fits as many cells as the container allows and reflows when narrow, which a fixed count cannot do. Set a fixed count only when the layout really needs exactly N.",
|
|
1083
|
-
props: z.object({
|
|
1084
|
-
columns: z.union([
|
|
1085
|
-
z.literal(2),
|
|
1086
|
-
z.literal(3),
|
|
1087
|
-
z.literal(4),
|
|
1088
|
-
z.literal("auto")
|
|
1089
|
-
]).default(2),
|
|
1090
|
-
minColumnWidth: z.enum([
|
|
1091
|
-
"sm",
|
|
1092
|
-
"md",
|
|
1093
|
-
"lg"
|
|
1094
|
-
]).default("md")
|
|
1095
|
-
}),
|
|
1096
|
-
children: PANEL,
|
|
1097
|
-
example: {
|
|
1098
|
-
component: "Grid",
|
|
1099
|
-
props: { columns: 2 },
|
|
1100
|
-
children: [{
|
|
1101
|
-
component: "Card",
|
|
1102
|
-
props: { title: "Revenue" },
|
|
1103
|
-
children: [{
|
|
1104
|
-
component: "Metric",
|
|
1105
|
-
props: {
|
|
1106
|
-
label: "MRR",
|
|
1107
|
-
value: "12,400"
|
|
1108
|
-
}
|
|
1109
|
-
}]
|
|
1110
|
-
}, {
|
|
1111
|
-
component: "Card",
|
|
1112
|
-
props: { title: "Churn" },
|
|
1113
|
-
children: [{
|
|
1114
|
-
component: "Metric",
|
|
1115
|
-
props: {
|
|
1116
|
-
label: "Rate",
|
|
1117
|
-
value: "2.1%"
|
|
1118
|
-
}
|
|
1119
|
-
}]
|
|
1120
|
-
}]
|
|
1121
|
-
}
|
|
1122
|
-
},
|
|
1123
|
-
{
|
|
1124
|
-
name: "Heading",
|
|
1125
|
-
group: "content",
|
|
1126
|
-
description: "Section heading. Level 2 is the largest allowed inside a message.",
|
|
1127
|
-
props: z.object({
|
|
1128
|
-
level: z.union([
|
|
1129
|
-
z.literal(2),
|
|
1130
|
-
z.literal(3),
|
|
1131
|
-
z.literal(4)
|
|
1132
|
-
]).default(3),
|
|
1133
|
-
text: z.string(),
|
|
1134
|
-
palette: palette.optional()
|
|
1135
|
-
}),
|
|
1136
|
-
example: {
|
|
1137
|
-
component: "Heading",
|
|
1138
|
-
props: {
|
|
1139
|
-
level: 3,
|
|
1140
|
-
text: "Summary"
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
},
|
|
1144
|
-
{
|
|
1145
|
-
name: "Text",
|
|
1146
|
-
group: "content",
|
|
1147
|
-
description: "A paragraph of plain text.",
|
|
1148
|
-
props: z.object({
|
|
1149
|
-
text: z.string(),
|
|
1150
|
-
tone: z.enum(["default", "muted"]).default("default")
|
|
1151
|
-
}),
|
|
1152
|
-
example: {
|
|
1153
|
-
component: "Text",
|
|
1154
|
-
props: { text: "Revenue grew 12% quarter over quarter." }
|
|
1155
|
-
}
|
|
1156
|
-
},
|
|
1157
|
-
{
|
|
1158
|
-
name: "Markdown",
|
|
1159
|
-
group: "content",
|
|
1160
|
-
description: "Restricted Markdown (no HTML). Use for lists and emphasis, not for tables.",
|
|
1161
|
-
props: z.object({ text: z.string() }),
|
|
1162
|
-
constraints: ["HTML tags are rejected; use Table for tabular data"],
|
|
1163
|
-
example: {
|
|
1164
|
-
component: "Markdown",
|
|
1165
|
-
props: { text: "- first\n- second" }
|
|
1166
|
-
}
|
|
1167
|
-
},
|
|
1168
|
-
{
|
|
1169
|
-
name: "Badge",
|
|
1170
|
-
group: "content",
|
|
1171
|
-
description: "A short status label.",
|
|
1172
|
-
props: z.object({
|
|
1173
|
-
text: z.string(),
|
|
1174
|
-
tone: tone.default("neutral"),
|
|
1175
|
-
palette: palette.optional()
|
|
1176
|
-
}),
|
|
1177
|
-
example: {
|
|
1178
|
-
component: "Badge",
|
|
1179
|
-
props: {
|
|
1180
|
-
text: "active",
|
|
1181
|
-
tone: "success"
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
},
|
|
1185
|
-
{
|
|
1186
|
-
name: "Metric",
|
|
1187
|
-
group: "data",
|
|
1188
|
-
description: "A single headline number with an optional change indicator.",
|
|
1189
|
-
props: z.object({
|
|
1190
|
-
label: z.string(),
|
|
1191
|
-
value: z.union([z.string(), z.number()]),
|
|
1192
|
-
change: z.string().optional(),
|
|
1193
|
-
trend: z.enum([
|
|
1194
|
-
"up",
|
|
1195
|
-
"down",
|
|
1196
|
-
"neutral"
|
|
1197
|
-
]).optional()
|
|
1198
|
-
}),
|
|
1199
|
-
example: {
|
|
1200
|
-
component: "Metric",
|
|
1201
|
-
props: {
|
|
1202
|
-
label: "MRR",
|
|
1203
|
-
value: "12,400",
|
|
1204
|
-
trend: "up",
|
|
1205
|
-
change: "+12%"
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
},
|
|
1209
|
-
{
|
|
1210
|
-
name: "Table",
|
|
1211
|
-
group: "data",
|
|
1212
|
-
description: "Tabular data with explicit columns. Prefer it over Markdown tables.",
|
|
1213
|
-
props: z.object({
|
|
1214
|
-
title: z.string().optional(),
|
|
1215
|
-
columns: z.array(z.string()).min(1),
|
|
1216
|
-
rows: z.array(z.array(z.union([
|
|
1217
|
-
z.string(),
|
|
1218
|
-
z.number(),
|
|
1219
|
-
z.boolean(),
|
|
1220
|
-
z.null()
|
|
1221
|
-
]))),
|
|
1222
|
-
/** 列宽权重,长度需与 columns 一致;不一致时整项忽略而不拒绝渲染 */
|
|
1223
|
-
columnWidths: z.array(z.number().positive()).optional()
|
|
1224
|
-
}),
|
|
1225
|
-
example: {
|
|
1226
|
-
component: "Table",
|
|
1227
|
-
props: {
|
|
1228
|
-
columns: ["Quarter", "Revenue"],
|
|
1229
|
-
rows: [["Q3", 9800], ["Q4", 12400]]
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
},
|
|
1233
|
-
{
|
|
1234
|
-
name: "Chart",
|
|
1235
|
-
group: "data",
|
|
1236
|
-
description: "A bar, line, area, pie, scatter, stacked or dual-axis chart over labelled series.",
|
|
1237
|
-
props: z.object({
|
|
1238
|
-
type: z.enum([
|
|
1239
|
-
"bar",
|
|
1240
|
-
"line",
|
|
1241
|
-
"area",
|
|
1242
|
-
"pie",
|
|
1243
|
-
"scatter",
|
|
1244
|
-
"stacked-bar",
|
|
1245
|
-
"dual-axis"
|
|
1246
|
-
]),
|
|
1247
|
-
title: z.string().optional(),
|
|
1248
|
-
labels: z.array(z.string()).min(1),
|
|
1249
|
-
series: z.array(z.object({
|
|
1250
|
-
name: z.string(),
|
|
1251
|
-
values: z.array(z.number())
|
|
1252
|
-
})).min(1)
|
|
1253
|
-
}),
|
|
1254
|
-
example: {
|
|
1255
|
-
component: "Chart",
|
|
1256
|
-
props: {
|
|
1257
|
-
type: "bar",
|
|
1258
|
-
labels: ["Q3", "Q4"],
|
|
1259
|
-
series: [{
|
|
1260
|
-
name: "Revenue",
|
|
1261
|
-
values: [9800, 12400]
|
|
1262
|
-
}]
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
},
|
|
1266
|
-
{
|
|
1267
|
-
name: "Timeline",
|
|
1268
|
-
group: "data",
|
|
1269
|
-
description: "Ordered steps or events, earliest first.",
|
|
1270
|
-
props: z.object({ items: z.array(z.object({
|
|
1271
|
-
title: z.string(),
|
|
1272
|
-
time: z.string().optional(),
|
|
1273
|
-
description: z.string().optional(),
|
|
1274
|
-
state: z.enum([
|
|
1275
|
-
"done",
|
|
1276
|
-
"active",
|
|
1277
|
-
"pending"
|
|
1278
|
-
]).default("pending")
|
|
1279
|
-
})).min(1) }),
|
|
1280
|
-
example: {
|
|
1281
|
-
component: "Timeline",
|
|
1282
|
-
props: { items: [{
|
|
1283
|
-
title: "Plan",
|
|
1284
|
-
time: "Mon",
|
|
1285
|
-
state: "done"
|
|
1286
|
-
}, {
|
|
1287
|
-
title: "Build",
|
|
1288
|
-
time: "Tue",
|
|
1289
|
-
state: "active"
|
|
1290
|
-
}] }
|
|
1291
|
-
}
|
|
1292
|
-
},
|
|
1293
|
-
{
|
|
1294
|
-
name: "FileLink",
|
|
1295
|
-
group: "data",
|
|
1296
|
-
description: "A download entry for a file. The host automatically renders every artifact the run produced as a download card, so never create a FileLink for those; use it only for files that are not run artifacts.",
|
|
1297
|
-
props: z.object({
|
|
1298
|
-
path: z.string(),
|
|
1299
|
-
label: z.string().optional(),
|
|
1300
|
-
mimeType: z.string().optional(),
|
|
1301
|
-
/** 字节数;人类可读格式化是渲染层的事,不进 props */
|
|
1302
|
-
size: z.number().int().nonnegative().optional(),
|
|
1303
|
-
actions: z.array(actionRef).optional()
|
|
1304
|
-
}),
|
|
1305
|
-
constraints: ["size is a byte count, not a formatted string"],
|
|
1306
|
-
example: {
|
|
1307
|
-
component: "FileLink",
|
|
1308
|
-
props: {
|
|
1309
|
-
path: "report.csv",
|
|
1310
|
-
label: "Download report",
|
|
1311
|
-
size: 2411724
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
},
|
|
1315
|
-
{
|
|
1316
|
-
name: "Form",
|
|
1317
|
-
group: "input",
|
|
1318
|
-
description: "Collects user input. Children must be Field nodes; actions go in the buttons prop.",
|
|
1319
|
-
props: z.object({
|
|
1320
|
-
title: z.string().optional(),
|
|
1321
|
-
submitLabel: z.string().default("Submit"),
|
|
1322
|
-
cancelLabel: z.string().optional()
|
|
1323
|
-
}),
|
|
1324
|
-
children: ["Field", "FieldArray"],
|
|
1325
|
-
singletonPerContainer: true,
|
|
1326
|
-
constraints: ["At most one Form per container", "Every Field name must be unique inside the Form"],
|
|
1327
|
-
example: {
|
|
1328
|
-
component: "Form",
|
|
1329
|
-
props: { title: "Schedule report" },
|
|
1330
|
-
children: [{
|
|
1331
|
-
component: "Field",
|
|
1332
|
-
props: {
|
|
1333
|
-
name: "email",
|
|
1334
|
-
label: "Email",
|
|
1335
|
-
type: "text",
|
|
1336
|
-
required: true
|
|
1337
|
-
}
|
|
1338
|
-
}]
|
|
1339
|
-
}
|
|
1340
|
-
},
|
|
1341
|
-
{
|
|
1342
|
-
name: "Field",
|
|
1343
|
-
group: "input",
|
|
1344
|
-
description: "A single form control. Only valid inside a Form.",
|
|
1345
|
-
props: z.object({
|
|
1346
|
-
name: z.string(),
|
|
1347
|
-
label: z.string(),
|
|
1348
|
-
type: z.enum([
|
|
1349
|
-
"text",
|
|
1350
|
-
"number",
|
|
1351
|
-
"date",
|
|
1352
|
-
"textarea",
|
|
1353
|
-
"select",
|
|
1354
|
-
"multi-select",
|
|
1355
|
-
"toggle",
|
|
1356
|
-
"file",
|
|
1357
|
-
"password"
|
|
1358
|
-
]),
|
|
1359
|
-
required: z.boolean().optional(),
|
|
1360
|
-
description: z.string().optional(),
|
|
1361
|
-
defaultValue: z.unknown().optional(),
|
|
1362
|
-
options: fieldOptions.optional(),
|
|
1363
|
-
/** 声明后选项由宕主异步提供;与写死的 options 互斥 */
|
|
1364
|
-
optionsSource: z.string().optional(),
|
|
1365
|
-
visibleWhen: fieldCondition.optional()
|
|
1366
|
-
}),
|
|
1367
|
-
constraints: [
|
|
1368
|
-
"select / multi-select must provide options or optionsSource",
|
|
1369
|
-
"password values are never persisted, never echoed and never enter the user profile",
|
|
1370
|
-
"visibleWhen may only reference fields declared in the same Form"
|
|
1371
|
-
],
|
|
1372
|
-
example: {
|
|
1373
|
-
component: "Field",
|
|
1374
|
-
props: {
|
|
1375
|
-
name: "range",
|
|
1376
|
-
label: "Range",
|
|
1377
|
-
type: "select",
|
|
1378
|
-
options: []
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
},
|
|
1382
|
-
{
|
|
1383
|
-
name: "FieldArray",
|
|
1384
|
-
group: "input",
|
|
1385
|
-
description: "A repeatable group of fields. Only valid inside a Form; children must be Field nodes.",
|
|
1386
|
-
props: z.object({
|
|
1387
|
-
name: z.string(),
|
|
1388
|
-
label: z.string(),
|
|
1389
|
-
minItems: z.number().int().min(0).optional(),
|
|
1390
|
-
maxItems: z.number().int().min(1).optional(),
|
|
1391
|
-
addLabel: z.string().optional(),
|
|
1392
|
-
removeLabel: z.string().optional()
|
|
1393
|
-
}),
|
|
1394
|
-
children: ["Field"],
|
|
1395
|
-
constraints: ["Only valid inside a Form", "Submitted as an array of objects keyed by the inner Field names"],
|
|
1396
|
-
example: {
|
|
1397
|
-
component: "FieldArray",
|
|
1398
|
-
props: {
|
|
1399
|
-
name: "recipients",
|
|
1400
|
-
label: "Recipients",
|
|
1401
|
-
minItems: 1
|
|
1402
|
-
},
|
|
1403
|
-
children: [{
|
|
1404
|
-
component: "Field",
|
|
1405
|
-
props: {
|
|
1406
|
-
name: "email",
|
|
1407
|
-
label: "Email",
|
|
1408
|
-
type: "text"
|
|
1409
|
-
}
|
|
1410
|
-
}]
|
|
1411
|
-
}
|
|
1412
|
-
},
|
|
1413
|
-
{
|
|
1414
|
-
name: "Button",
|
|
1415
|
-
group: "input",
|
|
1416
|
-
description: "Triggers one of the catalog actions.",
|
|
1417
|
-
props: actionRef,
|
|
1418
|
-
example: {
|
|
1419
|
-
component: "Button",
|
|
1420
|
-
props: {
|
|
1421
|
-
action: "submit",
|
|
1422
|
-
label: "Send"
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
},
|
|
1426
|
-
{
|
|
1427
|
-
name: "Progress",
|
|
1428
|
-
group: "feedback",
|
|
1429
|
-
description: "How far a long-running task has come, in percent.",
|
|
1430
|
-
props: z.object({
|
|
1431
|
-
label: z.string().optional(),
|
|
1432
|
-
value: z.number().min(0).max(100),
|
|
1433
|
-
tone: z.enum([
|
|
1434
|
-
"neutral",
|
|
1435
|
-
"success",
|
|
1436
|
-
"warning"
|
|
1437
|
-
]).default("neutral")
|
|
1438
|
-
}),
|
|
1439
|
-
example: {
|
|
1440
|
-
component: "Progress",
|
|
1441
|
-
props: {
|
|
1442
|
-
label: "Indexing",
|
|
1443
|
-
value: 42
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
},
|
|
1447
|
-
{
|
|
1448
|
-
name: "Icon",
|
|
1449
|
-
group: "content",
|
|
1450
|
-
description: "A small semantic icon, picked from the catalog whitelist. Use it to mark an item inside a list or next to a heading, never on its own as decoration. Names outside the whitelist are dropped by the renderer, so choose the closest listed name instead of inventing one.",
|
|
1451
|
-
props: z.object({
|
|
1452
|
-
name: iconName,
|
|
1453
|
-
palette: palette.optional()
|
|
1454
|
-
}),
|
|
1455
|
-
example: {
|
|
1456
|
-
component: "Icon",
|
|
1457
|
-
props: {
|
|
1458
|
-
name: "check",
|
|
1459
|
-
palette: "emerald"
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
},
|
|
1463
|
-
{
|
|
1464
|
-
name: "Image",
|
|
1465
|
-
group: "content",
|
|
1466
|
-
description: "An image the host already serves. `src` must be same-origin or a data URL — remote URLs are rejected by policy, so never link to an image you found on the web. `alt` is required: it is what a screen reader and a later model read instead of the pixels.",
|
|
1467
|
-
props: z.object({
|
|
1468
|
-
src: imageSrc,
|
|
1469
|
-
alt: z.string(),
|
|
1470
|
-
ratio: z.enum([
|
|
1471
|
-
"auto",
|
|
1472
|
-
"1:1",
|
|
1473
|
-
"4:3",
|
|
1474
|
-
"16:9"
|
|
1475
|
-
]).default("auto")
|
|
1476
|
-
}),
|
|
1477
|
-
example: {
|
|
1478
|
-
component: "Image",
|
|
1479
|
-
props: {
|
|
1480
|
-
src: "/chart.png",
|
|
1481
|
-
alt: "Revenue by region",
|
|
1482
|
-
ratio: "16:9"
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
},
|
|
1486
|
-
{
|
|
1487
|
-
name: "Quote",
|
|
1488
|
-
group: "content",
|
|
1489
|
-
description: "Someone else's words, set apart from your own. Use it when you are repeating a customer comment, a policy sentence or a source document, so the reader can tell it apart from your summary. Do not use it for emphasis.",
|
|
1490
|
-
props: z.object({
|
|
1491
|
-
text: z.string(),
|
|
1492
|
-
by: z.string().optional()
|
|
1493
|
-
}),
|
|
1494
|
-
example: {
|
|
1495
|
-
component: "Quote",
|
|
1496
|
-
props: {
|
|
1497
|
-
text: "Delivery was two days late.",
|
|
1498
|
-
by: "Order A-1002"
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
},
|
|
1502
|
-
{
|
|
1503
|
-
name: "Callout",
|
|
1504
|
-
group: "content",
|
|
1505
|
-
description: "A side note the reader may want but does not have to act on — background, a caveat, a definition. Use `Alert` instead when it is a state they must react to, and plain `Text` when it belongs in the flow of the answer.",
|
|
1506
|
-
props: z.object({
|
|
1507
|
-
title: z.string().optional(),
|
|
1508
|
-
text: z.string(),
|
|
1509
|
-
palette: palette.optional()
|
|
1510
|
-
}),
|
|
1511
|
-
example: {
|
|
1512
|
-
component: "Callout",
|
|
1513
|
-
props: {
|
|
1514
|
-
title: "How this is counted",
|
|
1515
|
-
text: "Refunds are excluded."
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
},
|
|
1519
|
-
{
|
|
1520
|
-
name: "KeyValue",
|
|
1521
|
-
group: "data",
|
|
1522
|
-
description: "Facts about one thing, as label/value pairs. Use it when a `Table` would have a single row, for example the details of one order. For several comparable rows use `Table`, and for one headline number use `Metric`.",
|
|
1523
|
-
props: z.object({ items: z.array(z.object({
|
|
1524
|
-
label: z.string(),
|
|
1525
|
-
value: z.string()
|
|
1526
|
-
})).min(1) }),
|
|
1527
|
-
example: {
|
|
1528
|
-
component: "KeyValue",
|
|
1529
|
-
props: { items: [{
|
|
1530
|
-
label: "Status",
|
|
1531
|
-
value: "Shipped"
|
|
1532
|
-
}, {
|
|
1533
|
-
label: "Carrier",
|
|
1534
|
-
value: "DHL"
|
|
1535
|
-
}] }
|
|
1536
|
-
}
|
|
1537
|
-
},
|
|
1538
|
-
{
|
|
1539
|
-
name: "Gauge",
|
|
1540
|
-
group: "data",
|
|
1541
|
-
description: "One value read against a range — capacity used, budget spent, a score out of a maximum. Give `min` and `max` when the range is not 0-100. Use `Progress` for how far a task has come, and `Metric` when there is no range to compare against.",
|
|
1542
|
-
props: z.object({
|
|
1543
|
-
label: z.string().optional(),
|
|
1544
|
-
value: z.number(),
|
|
1545
|
-
min: z.number().default(0),
|
|
1546
|
-
max: z.number().default(100),
|
|
1547
|
-
tone: z.enum([
|
|
1548
|
-
"neutral",
|
|
1549
|
-
"success",
|
|
1550
|
-
"warning"
|
|
1551
|
-
]).default("neutral")
|
|
1552
|
-
}),
|
|
1553
|
-
example: {
|
|
1554
|
-
component: "Gauge",
|
|
1555
|
-
props: {
|
|
1556
|
-
label: "Disk used",
|
|
1557
|
-
value: 82,
|
|
1558
|
-
tone: "warning"
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
},
|
|
1562
|
-
{
|
|
1563
|
-
name: "Accordion",
|
|
1564
|
-
group: "layout",
|
|
1565
|
-
description: "Sections the reader opens one at a time. Use it for a long report where most readers only need one part, so the answer stays scannable instead of becoming a wall of text. Use `Tabs` when the sections are alternatives rather than a sequence, and plain `Card`s when there are only two or three short ones.",
|
|
1566
|
-
props: z.object({ items: z.array(z.object({
|
|
1567
|
-
title: z.string(),
|
|
1568
|
-
text: z.string()
|
|
1569
|
-
})).min(1) }),
|
|
1570
|
-
example: {
|
|
1571
|
-
component: "Accordion",
|
|
1572
|
-
props: { items: [{
|
|
1573
|
-
title: "Method",
|
|
1574
|
-
text: "Sampled hourly."
|
|
1575
|
-
}, {
|
|
1576
|
-
title: "Caveats",
|
|
1577
|
-
text: "Week 12 is partial."
|
|
1578
|
-
}] }
|
|
1579
|
-
}
|
|
1580
|
-
},
|
|
1581
|
-
{
|
|
1582
|
-
name: "Carousel",
|
|
1583
|
-
group: "layout",
|
|
1584
|
-
description: "Slides the reader scrolls through sideways — an itinerary day by day, a set of options, a small gallery. Each slide carries its own title and optional image, and only one is in view at a time. Use `Grid` when the reader should compare the items side by side instead of one at a time.",
|
|
1585
|
-
props: z.object({ items: z.array(z.object({
|
|
1586
|
-
title: z.string(),
|
|
1587
|
-
text: z.string().optional(),
|
|
1588
|
-
src: z.string().optional()
|
|
1589
|
-
})).min(1) }),
|
|
1590
|
-
example: {
|
|
1591
|
-
component: "Carousel",
|
|
1592
|
-
props: { items: [{
|
|
1593
|
-
title: "Day 1",
|
|
1594
|
-
text: "Arrival"
|
|
1595
|
-
}, {
|
|
1596
|
-
title: "Day 2",
|
|
1597
|
-
text: "Old town"
|
|
1598
|
-
}] }
|
|
1599
|
-
}
|
|
1600
|
-
},
|
|
1601
|
-
{
|
|
1602
|
-
name: "Alert",
|
|
1603
|
-
group: "feedback",
|
|
1604
|
-
description: "Draws attention to a state the user must know about.",
|
|
1605
|
-
props: z.object({
|
|
1606
|
-
tone: z.enum([
|
|
1607
|
-
"info",
|
|
1608
|
-
"warning",
|
|
1609
|
-
"destructive"
|
|
1610
|
-
]).default("info"),
|
|
1611
|
-
title: z.string(),
|
|
1612
|
-
description: z.string().optional()
|
|
1613
|
-
}),
|
|
1614
|
-
example: {
|
|
1615
|
-
component: "Alert",
|
|
1616
|
-
props: {
|
|
1617
|
-
tone: "warning",
|
|
1618
|
-
title: "Partial data"
|
|
1619
|
-
}
|
|
1620
|
-
}
|
|
1621
|
-
},
|
|
1622
|
-
{
|
|
1623
|
-
name: "Custom",
|
|
1624
|
-
group: "layout",
|
|
1625
|
-
description: "Host extension point; the name must be registered in the host SurfaceRegistry.",
|
|
1626
|
-
props: z.object({
|
|
1627
|
-
name: z.string(),
|
|
1628
|
-
props: z.record(z.string(), z.unknown()).default({})
|
|
1629
|
-
}),
|
|
1630
|
-
constraints: ["Rejected when the host has not registered the component name"],
|
|
1631
|
-
example: {
|
|
1632
|
-
component: "Custom",
|
|
1633
|
-
props: {
|
|
1634
|
-
name: "sales-map",
|
|
1635
|
-
props: {}
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
],
|
|
1640
|
-
actions: [
|
|
1641
|
-
{
|
|
1642
|
-
name: "submit",
|
|
1643
|
-
description: "Submit the surface form values back to the waiting run."
|
|
1644
|
-
},
|
|
1645
|
-
{
|
|
1646
|
-
name: "cancel",
|
|
1647
|
-
description: "Dismiss the surface without submitting."
|
|
1648
|
-
},
|
|
1649
|
-
{
|
|
1650
|
-
name: "select",
|
|
1651
|
-
description: "Report a selected row or option."
|
|
1652
|
-
},
|
|
1653
|
-
{
|
|
1654
|
-
name: "download",
|
|
1655
|
-
description: "Download the referenced artifact."
|
|
1656
|
-
},
|
|
1657
|
-
{
|
|
1658
|
-
name: "refresh",
|
|
1659
|
-
description: "Ask the run to recompute the surface."
|
|
1660
|
-
}
|
|
1661
|
-
]
|
|
1662
|
-
});
|
|
1663
|
-
/** prompt / 文档里引用的分组顺序(Console 展示同序) */
|
|
1664
|
-
const UI_CATALOG_GROUPS = {
|
|
1665
|
-
content: CONTENT,
|
|
1666
|
-
data: DATA,
|
|
1667
|
-
input: INPUT
|
|
1668
|
-
};
|
|
1669
|
-
/**
|
|
1670
|
-
* catalog 系统提示的体积上限(UTF-8 字节,FR-6.6)。
|
|
1671
|
-
*
|
|
1672
|
-
* **0.11.0 起只是 `CATALOG_PROMPT_MAX` 的别名**:旧值 20 480 相对实际 7 788 宽了 2.6 倍,
|
|
1673
|
-
* 拦不住任何东西;且它只量提示词,漏掉了 `render_ui` 入参 Schema 那约两分之一的成本。
|
|
1674
|
-
* @deprecated 用 `CATALOG_PROMPT_MAX` / `CATALOG_SCHEMA_MAX`(`./budget`)。
|
|
1675
|
-
*/
|
|
1676
|
-
const UI_CATALOG_PROMPT_BUDGET_BYTES = CATALOG_PROMPT_MAX;
|
|
1677
|
-
/** A2UI 协议公共类型(与 @a2ui/web_core 的 common-types 同一套定义) */
|
|
1678
|
-
const A2UI_COMMON_TYPES = "https://a2ui.org/specification/v0_9/common_types.json";
|
|
1679
|
-
const CHILD_LIST_REF = `${A2UI_COMMON_TYPES}#/$defs/ChildList`;
|
|
1680
|
-
const ACTION_REF = `${A2UI_COMMON_TYPES}#/$defs/Action`;
|
|
1681
|
-
const DYNAMIC_VALUE_REF = `${A2UI_COMMON_TYPES}#/$defs/DynamicValue`;
|
|
1682
|
-
/** catalog 里承载 action 名的 props 字段 */
|
|
1683
|
-
const ACTION_PROP = "action";
|
|
1684
|
-
/** 表单控件的标识字段;有它才谈得上把值写进数据模型 */
|
|
1685
|
-
const NAME_PROP = "name";
|
|
1686
|
-
const SUBMIT_LABEL_PROP = "submitLabel";
|
|
1687
|
-
const CANCEL_LABEL_PROP = "cancelLabel";
|
|
1688
|
-
/** A2UI 线上形状里表单控件的取值字段 */
|
|
1689
|
-
const A2UI_VALUE_PROP = "value";
|
|
1690
|
-
/** A2UI 线上形状里的子节点字段 */
|
|
1691
|
-
const A2UI_CHILDREN_PROP = "children";
|
|
1692
|
-
/** catalog → 每个组件的 A2UI 线上形状 */
|
|
1693
|
-
function a2uiComponentShapes(catalog) {
|
|
1694
|
-
const branches = catalog.toJsonSchema()["oneOf"] ?? [];
|
|
1695
|
-
const byName = /* @__PURE__ */ new Map();
|
|
1696
|
-
for (const branch of branches) {
|
|
1697
|
-
const name = (branch.properties?.["component"])?.["const"];
|
|
1698
|
-
if (typeof name === "string") byName.set(name, branch);
|
|
1699
|
-
}
|
|
1700
|
-
return catalog.components.map((def) => {
|
|
1701
|
-
const branch = byName.get(def.name);
|
|
1702
|
-
const props = branch?.properties?.["props"] ?? { type: "object" };
|
|
1703
|
-
const keys = Object.keys(props.properties ?? {});
|
|
1704
|
-
const childrenDescription = (branch?.properties?.["children"])?.description;
|
|
1705
|
-
return {
|
|
1706
|
-
name: def.name,
|
|
1707
|
-
description: def.description,
|
|
1708
|
-
props,
|
|
1709
|
-
staticProps: keys.filter((key) => key !== ACTION_PROP),
|
|
1710
|
-
childList: def.children !== void 0,
|
|
1711
|
-
...childrenDescription ? { childListDescription: childrenDescription } : {},
|
|
1712
|
-
actionProps: keys.filter((key) => key === ACTION_PROP),
|
|
1713
|
-
valueProps: def.group === "input" && def.children === void 0 && keys.includes(NAME_PROP) ? [A2UI_VALUE_PROP] : [],
|
|
1714
|
-
...keys.includes(SUBMIT_LABEL_PROP) ? { submitLabelProp: SUBMIT_LABEL_PROP } : {},
|
|
1715
|
-
...keys.includes(CANCEL_LABEL_PROP) ? { cancelLabelProp: CANCEL_LABEL_PROP } : {}
|
|
1716
|
-
};
|
|
1717
|
-
});
|
|
1718
|
-
}
|
|
1719
|
-
/** 单个组件的 A2UI props JSON Schema(协议要求每个组件是一份 schema) */
|
|
1720
|
-
function a2uiComponentSchema(shape) {
|
|
1721
|
-
const properties = { ...shape.props.properties ?? {} };
|
|
1722
|
-
for (const key of shape.actionProps) properties[key] = {
|
|
1723
|
-
$ref: ACTION_REF,
|
|
1724
|
-
description: "What happens when the user activates this component."
|
|
1725
|
-
};
|
|
1726
|
-
for (const key of shape.valueProps) properties[key] = {
|
|
1727
|
-
$ref: DYNAMIC_VALUE_REF,
|
|
1728
|
-
description: "Two-way binding to the surface data model."
|
|
1729
|
-
};
|
|
1730
|
-
if (shape.childList) properties[A2UI_CHILDREN_PROP] = {
|
|
1731
|
-
$ref: CHILD_LIST_REF,
|
|
1732
|
-
description: shape.childListDescription ? `${shape.childListDescription}. Children are referenced by id, never inlined.` : "Child component ids, in render order."
|
|
1733
|
-
};
|
|
1734
|
-
return {
|
|
1735
|
-
...shape.props,
|
|
1736
|
-
description: shape.description,
|
|
1737
|
-
properties
|
|
1738
|
-
};
|
|
1739
|
-
}
|
|
1740
|
-
/** 自定义 catalog 的协议 id(对齐 https://a2ui.org/specification/v1_0/catalog_definition.json) */
|
|
1741
|
-
const WEBSKILL_A2UI_CATALOG_ID = "https://webskill.dev/a2ui/catalogs/v1/catalog.json";
|
|
1742
|
-
/**
|
|
1743
|
-
* WebSkill catalog → A2UI `catalog_definition.json`。
|
|
1744
|
-
* 组件 schema 由 catalog 的 JSON Schema 投影经 `a2uiComponentShapes` 改写成 A2UI 线上形状
|
|
1745
|
-
* (嵌套变 id 引用、action 名变协议信封),不手写第二份词汇表——
|
|
1746
|
-
* Lit 元素的 zod API 也由同一份 shape 派生。
|
|
1747
|
-
*/
|
|
1748
|
-
function buildA2uiCatalogDefinition(catalog) {
|
|
1749
|
-
const components = {};
|
|
1750
|
-
for (const shape of a2uiComponentShapes(catalog)) components[shape.name] = a2uiComponentSchema(shape);
|
|
1751
|
-
return {
|
|
1752
|
-
catalogId: WEBSKILL_A2UI_CATALOG_ID,
|
|
1753
|
-
title: catalog.title,
|
|
1754
|
-
description: `${catalog.title} ${catalog.version}`,
|
|
1755
|
-
instructions: catalog.instructions,
|
|
1756
|
-
components,
|
|
1757
|
-
functions: Object.fromEntries(catalog.actions.map((action) => [action.name, { description: action.description }]))
|
|
1758
|
-
};
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
//#endregion
|
|
1762
|
-
//#region ../ui/dist/index.js
|
|
1763
|
-
const isEmpty$1 = (v) => v === void 0 || v === "";
|
|
1764
|
-
/**
|
|
1765
|
-
* 控件名 → 属性选择器:优先 CSS.escape(标识符形式,免引号转义);
|
|
1766
|
-
* 无 CSS.escape 的环境退化为引号包裹 + 转义反斜杠/双引号(防选择器注入崩溃)
|
|
1767
|
-
*/
|
|
1768
|
-
function controlSelector(name) {
|
|
1769
|
-
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return `[data-webskill-control=${CSS.escape(name)}]`;
|
|
1770
|
-
return `[data-webskill-control="${name.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
|
|
1771
|
-
}
|
|
1772
|
-
/**
|
|
1773
|
-
* 严格类型化控件值收集:
|
|
1774
|
-
* - number 空串 → undefined(非 0);非数值输入(NaN)按缺失处理(required 时入 missingRequired)
|
|
1775
|
-
* - select 用 option 值本身经 JSON 编码比对命中(对象值可正确命中)
|
|
1776
|
-
* - required 空值列入 missingRequired(由调用方阻止提交并标记)
|
|
1777
|
-
*/
|
|
1778
|
-
function collectValues(controls, container) {
|
|
1779
|
-
const values = {};
|
|
1780
|
-
const missingRequired = [];
|
|
1781
|
-
for (const control of controls) {
|
|
1782
|
-
const el = container.querySelector(controlSelector(control.name));
|
|
1783
|
-
let value;
|
|
1784
|
-
if (el === null) value = void 0;
|
|
1785
|
-
else if (control.control === "boolean") value = el.checked;
|
|
1786
|
-
else if (control.control === "number") {
|
|
1787
|
-
const raw = el.value.trim();
|
|
1788
|
-
if (raw === "") value = void 0;
|
|
1789
|
-
else {
|
|
1790
|
-
const n = Number(raw);
|
|
1791
|
-
value = Number.isNaN(n) ? void 0 : n;
|
|
1792
|
-
}
|
|
1793
|
-
} else if (control.control === "select") {
|
|
1794
|
-
const attr = el.value;
|
|
1795
|
-
const hit = control.options?.find((o) => JSON.stringify(o.value) === attr);
|
|
1796
|
-
value = hit ? hit.value : attr === "" ? void 0 : attr;
|
|
1797
|
-
} else {
|
|
1798
|
-
const raw = el.value;
|
|
1799
|
-
value = raw === "" ? void 0 : raw;
|
|
1800
|
-
}
|
|
1801
|
-
values[control.name] = value;
|
|
1802
|
-
if (control.required && isEmpty$1(value)) missingRequired.push(control.name);
|
|
1803
|
-
}
|
|
1804
|
-
return {
|
|
1805
|
-
values,
|
|
1806
|
-
missingRequired
|
|
1807
|
-
};
|
|
1808
|
-
}
|
|
1809
|
-
/**
|
|
1810
|
-
* 采纳建议值(FR-5.9):把 `control.suggestion` 写进已渲染的控件。
|
|
1811
|
-
* 与 `collectValues` 共用同一套选择器与编码约定——两边分开实现的话,
|
|
1812
|
-
* select 的 JSON 编码值迟早会对不上。
|
|
1813
|
-
* @experimental
|
|
1814
|
-
*/
|
|
1815
|
-
function applySuggestion(control, container) {
|
|
1816
|
-
if (control.suggestion === void 0) return;
|
|
1817
|
-
const el = container.querySelector(controlSelector(control.name));
|
|
1818
|
-
if (el === null) return;
|
|
1819
|
-
const { value } = control.suggestion;
|
|
1820
|
-
if (control.control === "boolean") {
|
|
1821
|
-
el.checked = value === true;
|
|
1822
|
-
return;
|
|
1823
|
-
}
|
|
1824
|
-
if (control.control === "select") {
|
|
1825
|
-
el.value = JSON.stringify(value);
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
el.value = value === void 0 || value === null ? "" : String(value);
|
|
1829
|
-
}
|
|
1830
|
-
/**
|
|
1831
|
-
* 轻量 SVG 图表自绘(bar/line/pie,零依赖,三端复用单一来源)。
|
|
1832
|
-
* 全部经 doc.createElementNS 构建(内容来自技能输出,不拼 innerHTML)。
|
|
1833
|
-
*/
|
|
1834
|
-
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
1835
|
-
/**
|
|
1836
|
-
* 固定 8 色循环调色板。这是**数据编码**(区分序列),不是主题色;
|
|
1837
|
-
* 图表的轴线与文字等“外壳”颜色走 currentColor,跟随宿主容器的主题。
|
|
1838
|
-
*/
|
|
1839
|
-
const CHART_PALETTE = [
|
|
1840
|
-
"#4e79a7",
|
|
1841
|
-
"#f28e2b",
|
|
1842
|
-
"#e15759",
|
|
1843
|
-
"#76b7b2",
|
|
1844
|
-
"#59a14f",
|
|
1845
|
-
"#edc948",
|
|
1846
|
-
"#b07aa1",
|
|
1847
|
-
"#ff9da7"
|
|
1848
|
-
];
|
|
1849
|
-
const WIDTH = 320;
|
|
1850
|
-
const HEIGHT = 200;
|
|
1851
|
-
function el(doc, name, attrs) {
|
|
1852
|
-
const node = doc.createElementNS(SVG_NS, name);
|
|
1853
|
-
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
|
1854
|
-
return node;
|
|
1855
|
-
}
|
|
1856
|
-
function text(doc, x, y, content, attrs = {}) {
|
|
1857
|
-
const node = el(doc, "text", {
|
|
1858
|
-
x: String(x),
|
|
1859
|
-
y: String(y),
|
|
1860
|
-
"font-size": "9",
|
|
1861
|
-
fill: "currentColor",
|
|
1862
|
-
"fill-opacity": "0.65",
|
|
1863
|
-
...attrs
|
|
1864
|
-
});
|
|
1865
|
-
node.textContent = content;
|
|
1866
|
-
return node;
|
|
1867
|
-
}
|
|
1868
|
-
const fmt = (n) => Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
1869
|
-
function maxValue(chart) {
|
|
1870
|
-
let max = 0;
|
|
1871
|
-
for (const s of chart.series) for (const v of s.data) if (v > max) max = v;
|
|
1872
|
-
return max;
|
|
1873
|
-
}
|
|
1874
|
-
function renderBar(chart, svg, doc) {
|
|
1875
|
-
const plot = {
|
|
1876
|
-
x0: 30,
|
|
1877
|
-
y0: 24,
|
|
1878
|
-
x1: 312,
|
|
1879
|
-
y1: 164
|
|
1880
|
-
};
|
|
1881
|
-
const max = maxValue(chart);
|
|
1882
|
-
svg.appendChild(el(doc, "line", {
|
|
1883
|
-
x1: String(plot.x0),
|
|
1884
|
-
y1: String(plot.y0),
|
|
1885
|
-
x2: String(plot.x0),
|
|
1886
|
-
y2: String(plot.y1),
|
|
1887
|
-
stroke: "currentColor",
|
|
1888
|
-
"stroke-opacity": "0.35"
|
|
1889
|
-
}));
|
|
1890
|
-
svg.appendChild(el(doc, "line", {
|
|
1891
|
-
x1: String(plot.x0),
|
|
1892
|
-
y1: String(plot.y1),
|
|
1893
|
-
x2: String(plot.x1),
|
|
1894
|
-
y2: String(plot.y1),
|
|
1895
|
-
stroke: "currentColor",
|
|
1896
|
-
"stroke-opacity": "0.35"
|
|
1897
|
-
}));
|
|
1898
|
-
if (max <= 0 || chart.labels.length === 0 || chart.series.length === 0) return;
|
|
1899
|
-
const groupWidth = (plot.x1 - plot.x0) / chart.labels.length;
|
|
1900
|
-
const barWidth = Math.max(1, groupWidth / chart.series.length * .7);
|
|
1901
|
-
chart.series.forEach((series, si) => {
|
|
1902
|
-
const color = CHART_PALETTE[si % CHART_PALETTE.length];
|
|
1903
|
-
series.data.forEach((value, li) => {
|
|
1904
|
-
if (li >= chart.labels.length) return;
|
|
1905
|
-
const height = (plot.y1 - plot.y0) * value / max;
|
|
1906
|
-
const x = plot.x0 + li * groupWidth + (groupWidth - barWidth * chart.series.length) / 2 + si * barWidth;
|
|
1907
|
-
const y = plot.y1 - height;
|
|
1908
|
-
svg.appendChild(el(doc, "rect", {
|
|
1909
|
-
x: String(x),
|
|
1910
|
-
y: String(y),
|
|
1911
|
-
width: String(barWidth),
|
|
1912
|
-
height: String(height),
|
|
1913
|
-
fill: color,
|
|
1914
|
-
"data-series": series.name ?? String(si)
|
|
1915
|
-
}));
|
|
1916
|
-
if (chart.series.length === 1) svg.appendChild(text(doc, x + barWidth / 2, y - 2, fmt(value), { "text-anchor": "middle" }));
|
|
1917
|
-
});
|
|
1918
|
-
});
|
|
1919
|
-
chart.labels.forEach((label, li) => {
|
|
1920
|
-
svg.appendChild(text(doc, plot.x0 + li * groupWidth + groupWidth / 2, plot.y1 + 12, label, { "text-anchor": "middle" }));
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
function renderLine(chart, svg, doc) {
|
|
1924
|
-
const plot = {
|
|
1925
|
-
x0: 30,
|
|
1926
|
-
y0: 24,
|
|
1927
|
-
x1: 312,
|
|
1928
|
-
y1: 164
|
|
1929
|
-
};
|
|
1930
|
-
const max = maxValue(chart);
|
|
1931
|
-
svg.appendChild(el(doc, "line", {
|
|
1932
|
-
x1: String(plot.x0),
|
|
1933
|
-
y1: String(plot.y0),
|
|
1934
|
-
x2: String(plot.x0),
|
|
1935
|
-
y2: String(plot.y1),
|
|
1936
|
-
stroke: "currentColor",
|
|
1937
|
-
"stroke-opacity": "0.35"
|
|
1938
|
-
}));
|
|
1939
|
-
svg.appendChild(el(doc, "line", {
|
|
1940
|
-
x1: String(plot.x0),
|
|
1941
|
-
y1: String(plot.y1),
|
|
1942
|
-
x2: String(plot.x1),
|
|
1943
|
-
y2: String(plot.y1),
|
|
1944
|
-
stroke: "currentColor",
|
|
1945
|
-
"stroke-opacity": "0.35"
|
|
1946
|
-
}));
|
|
1947
|
-
if (max <= 0 || chart.labels.length === 0 || chart.series.length === 0) return;
|
|
1948
|
-
const step = chart.labels.length > 1 ? (plot.x1 - plot.x0) / (chart.labels.length - 1) : 0;
|
|
1949
|
-
const px = (li) => chart.labels.length > 1 ? plot.x0 + li * step : (plot.x0 + plot.x1) / 2;
|
|
1950
|
-
const py = (v) => plot.y1 - (plot.y1 - plot.y0) * v / max;
|
|
1951
|
-
chart.series.forEach((series, si) => {
|
|
1952
|
-
const color = CHART_PALETTE[si % CHART_PALETTE.length];
|
|
1953
|
-
const points = series.data.slice(0, chart.labels.length).map((v, li) => `${px(li)},${py(v)}`).join(" ");
|
|
1954
|
-
if (points !== "") svg.appendChild(el(doc, "polyline", {
|
|
1955
|
-
points,
|
|
1956
|
-
fill: "none",
|
|
1957
|
-
stroke: color,
|
|
1958
|
-
"stroke-width": "1.5"
|
|
1959
|
-
}));
|
|
1960
|
-
series.data.slice(0, chart.labels.length).forEach((v, li) => {
|
|
1961
|
-
svg.appendChild(el(doc, "circle", {
|
|
1962
|
-
cx: String(px(li)),
|
|
1963
|
-
cy: String(py(v)),
|
|
1964
|
-
r: "2",
|
|
1965
|
-
fill: color
|
|
1966
|
-
}));
|
|
1967
|
-
});
|
|
1968
|
-
});
|
|
1969
|
-
chart.labels.forEach((label, li) => {
|
|
1970
|
-
svg.appendChild(text(doc, px(li), plot.y1 + 12, label, { "text-anchor": "middle" }));
|
|
1971
|
-
});
|
|
1972
|
-
}
|
|
1973
|
-
function renderPie(chart, svg, doc) {
|
|
1974
|
-
const data = (chart.series[0]?.data ?? []).slice(0, chart.labels.length);
|
|
1975
|
-
const total = data.reduce((sum, v) => sum + (v > 0 ? v : 0), 0);
|
|
1976
|
-
const cx = 90;
|
|
1977
|
-
const cy = 104;
|
|
1978
|
-
const r = 72;
|
|
1979
|
-
if (total <= 0) {
|
|
1980
|
-
svg.appendChild(text(doc, cx, cy, "No data", { "text-anchor": "middle" }));
|
|
1981
|
-
return;
|
|
1982
|
-
}
|
|
1983
|
-
let angle = -Math.PI / 2;
|
|
1984
|
-
data.forEach((value, i) => {
|
|
1985
|
-
const label = chart.labels[i] ?? String(i);
|
|
1986
|
-
const color = CHART_PALETTE[i % CHART_PALETTE.length];
|
|
1987
|
-
const slice = Math.max(value, 0) / total * Math.PI * 2;
|
|
1988
|
-
const x0 = cx + r * Math.cos(angle);
|
|
1989
|
-
const y0 = cy + r * Math.sin(angle);
|
|
1990
|
-
const x1 = cx + r * Math.cos(angle + slice);
|
|
1991
|
-
const y1 = cy + r * Math.sin(angle + slice);
|
|
1992
|
-
const path = el(doc, "path", {
|
|
1993
|
-
d: `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${slice > Math.PI ? "1" : "0"} 1 ${x1} ${y1} Z`,
|
|
1994
|
-
fill: color,
|
|
1995
|
-
"data-label": label
|
|
1996
|
-
});
|
|
1997
|
-
svg.appendChild(path);
|
|
1998
|
-
angle += slice;
|
|
1999
|
-
const ly = 40 + i * 14;
|
|
2000
|
-
svg.appendChild(el(doc, "rect", {
|
|
2001
|
-
x: "190",
|
|
2002
|
-
y: String(ly - 7),
|
|
2003
|
-
width: "8",
|
|
2004
|
-
height: "8",
|
|
2005
|
-
fill: color
|
|
2006
|
-
}));
|
|
2007
|
-
svg.appendChild(text(doc, 202, ly, `${label} (${fmt(value)})`));
|
|
2008
|
-
});
|
|
2009
|
-
}
|
|
2010
|
-
/**
|
|
2011
|
-
* ChartSpec → SVG(bar 等宽柱 + 值标签;line 折线 + 点;pie 扇形 + 图例;空数据容错)。
|
|
2012
|
-
* mini 档只有这三种画法,其余类型走最接近的一种并在图内注明是近似呈现。
|
|
2013
|
-
* @stable
|
|
2014
|
-
*/
|
|
2015
|
-
function renderMiniChart(chart, doc) {
|
|
2016
|
-
const svg = el(doc, "svg", {
|
|
2017
|
-
viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
|
|
2018
|
-
width: String(WIDTH),
|
|
2019
|
-
height: String(HEIGHT),
|
|
2020
|
-
class: `webskill-chart webskill-chart--${chart.kind}`,
|
|
2021
|
-
role: "img"
|
|
2022
|
-
});
|
|
2023
|
-
if (chart.title) svg.appendChild(text(doc, WIDTH / 2, 14, chart.title, {
|
|
2024
|
-
"text-anchor": "middle",
|
|
2025
|
-
"font-size": "11",
|
|
2026
|
-
fill: "currentColor",
|
|
2027
|
-
"fill-opacity": "1"
|
|
2028
|
-
}));
|
|
2029
|
-
switch (chart.kind) {
|
|
2030
|
-
case "bar":
|
|
2031
|
-
case "stacked-bar":
|
|
2032
|
-
renderBar(chart, svg, doc);
|
|
2033
|
-
break;
|
|
2034
|
-
case "line":
|
|
2035
|
-
renderLine(chart, svg, doc);
|
|
2036
|
-
break;
|
|
2037
|
-
case "pie":
|
|
2038
|
-
renderPie(chart, svg, doc);
|
|
2039
|
-
break;
|
|
2040
|
-
default:
|
|
2041
|
-
renderLine(chart, svg, doc);
|
|
2042
|
-
break;
|
|
2043
|
-
}
|
|
2044
|
-
if (chart.kind !== "bar" && chart.kind !== "line" && chart.kind !== "pie") svg.appendChild(text(doc, WIDTH / 2, HEIGHT - 4, `Approximate rendering of "${chart.kind}"`, { "text-anchor": "middle" }));
|
|
2045
|
-
return svg;
|
|
2046
|
-
}
|
|
2047
|
-
/** A2UI/OpenUI 降级:chart → table(labels 为首列,series 各为一列) */
|
|
2048
|
-
function chartToTable(chart) {
|
|
2049
|
-
return {
|
|
2050
|
-
type: "table",
|
|
2051
|
-
columns: ["label", ...chart.series.map((s, i) => s.name ?? `series ${i + 1}`)],
|
|
2052
|
-
rows: chart.labels.map((label, li) => [label, ...chart.series.map((s) => s.data[li] ?? null)])
|
|
2053
|
-
};
|
|
2054
|
-
}
|
|
2055
|
-
/**
|
|
2056
|
-
* `Gauge` 的百分比换算(0.11.0 分册 19 FR-19.2)。
|
|
2057
|
-
*
|
|
2058
|
-
* catalog 的 React `SpecGauge` 与文档面的预置组件都读这一份:
|
|
2059
|
-
* 换算里有两个容易各写各的判断——`max === min` 时不能除零、越界值要夹紧——
|
|
2060
|
-
* 两处各写一遍就会在边界值上给出不同的条。
|
|
2061
|
-
*/
|
|
2062
|
-
function gaugePercent(value, min, max) {
|
|
2063
|
-
const span = max - min;
|
|
2064
|
-
if (span <= 0) return 0;
|
|
2065
|
-
return Math.max(0, Math.min(100, (value - min) / span * 100));
|
|
2066
|
-
}
|
|
2067
|
-
/** RenderBlock → DOM(markdown/json/table/image/file/chart 六种) */
|
|
2068
|
-
function renderBlocks(container, blocks, doc) {
|
|
2069
|
-
for (const block of blocks) switch (block.type) {
|
|
2070
|
-
case "chart":
|
|
2071
|
-
container.appendChild(renderMiniChart(block.chart, doc));
|
|
2072
|
-
break;
|
|
2073
|
-
case "markdown":
|
|
2074
|
-
container.appendChild(renderMiniMarkdown(block.text, doc));
|
|
2075
|
-
break;
|
|
2076
|
-
case "json": {
|
|
2077
|
-
const pre = doc.createElement("pre");
|
|
2078
|
-
pre.className = "webskill-result__json";
|
|
2079
|
-
pre.textContent = JSON.stringify(block.data, null, 2);
|
|
2080
|
-
container.appendChild(pre);
|
|
2081
|
-
break;
|
|
2082
|
-
}
|
|
2083
|
-
case "table": {
|
|
2084
|
-
const table = doc.createElement("table");
|
|
2085
|
-
const thead = doc.createElement("thead");
|
|
2086
|
-
const headRow = doc.createElement("tr");
|
|
2087
|
-
for (const column of block.columns) {
|
|
2088
|
-
const th = doc.createElement("th");
|
|
2089
|
-
th.textContent = column;
|
|
2090
|
-
headRow.appendChild(th);
|
|
2091
|
-
}
|
|
2092
|
-
thead.appendChild(headRow);
|
|
2093
|
-
table.appendChild(thead);
|
|
2094
|
-
const tbody = doc.createElement("tbody");
|
|
2095
|
-
for (const row of block.rows) {
|
|
2096
|
-
const tr = doc.createElement("tr");
|
|
2097
|
-
for (const cell of row) {
|
|
2098
|
-
const td = doc.createElement("td");
|
|
2099
|
-
td.textContent = typeof cell === "object" ? JSON.stringify(cell) : String(cell ?? "");
|
|
2100
|
-
tr.appendChild(td);
|
|
2101
|
-
}
|
|
2102
|
-
tbody.appendChild(tr);
|
|
2103
|
-
}
|
|
2104
|
-
table.appendChild(tbody);
|
|
2105
|
-
container.appendChild(table);
|
|
2106
|
-
break;
|
|
2107
|
-
}
|
|
2108
|
-
case "image": {
|
|
2109
|
-
const img = doc.createElement("img");
|
|
2110
|
-
img.src = block.url;
|
|
2111
|
-
img.alt = block.alt ?? "";
|
|
2112
|
-
container.appendChild(img);
|
|
2113
|
-
break;
|
|
2114
|
-
}
|
|
2115
|
-
case "file": {
|
|
2116
|
-
const div = doc.createElement("div");
|
|
2117
|
-
div.className = "webskill-result__file";
|
|
2118
|
-
const meta = [block.mimeType, block.size !== void 0 ? `${block.size} bytes` : void 0].filter(Boolean).join(", ");
|
|
2119
|
-
div.textContent = meta ? `${block.path} (${meta})` : block.path;
|
|
2120
|
-
container.appendChild(div);
|
|
2121
|
-
break;
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
/** 完整 RenderResultRequest → DOM(summary 头 + blocks) */
|
|
2126
|
-
function renderRenderResult(request, doc) {
|
|
2127
|
-
const root = doc.createElement("div");
|
|
2128
|
-
root.className = "webskill-result";
|
|
2129
|
-
if (request.title) {
|
|
2130
|
-
const title = doc.createElement("div");
|
|
2131
|
-
title.className = "webskill-form__title";
|
|
2132
|
-
title.textContent = request.title;
|
|
2133
|
-
root.appendChild(title);
|
|
2134
|
-
}
|
|
2135
|
-
if (request.summary) {
|
|
2136
|
-
const summary = doc.createElement("div");
|
|
2137
|
-
summary.className = "webskill-result__summary";
|
|
2138
|
-
summary.textContent = request.summary;
|
|
2139
|
-
root.appendChild(summary);
|
|
2140
|
-
}
|
|
2141
|
-
renderBlocks(root, request.blocks, doc);
|
|
2142
|
-
return root;
|
|
2143
|
-
}
|
|
2144
|
-
/**
|
|
2145
|
-
* viewer 的宿主预置组件(0.11.0 分册 18 FR-18.5 / 分册 19)。
|
|
2146
|
-
*
|
|
2147
|
-
* 技能**不写 JS**:文档 HTML 里只留占位,由这里挂载真实组件。
|
|
2148
|
-
* 交互只落在图表上——表格排序的例子已作废(分册 29 §2 登记 #13)。
|
|
2149
|
-
*
|
|
2150
|
-
* 三条硬约束:
|
|
2151
|
-
* - props 用 **catalog 自己的 zod schema** 解析,同形因此是机制上成立的,
|
|
2152
|
-
* 不是靠一句「记得同步」的注释(FR-19.2);
|
|
2153
|
-
* - 值一律经 `textContent` 落地,**永不** `innerHTML`(FR-19.4 / AC-19.5);
|
|
2154
|
-
* - 白名单外的名字只渲染降级提示,**不触发任何按名 import**(FR-19.3)。
|
|
2155
|
-
*/
|
|
2156
|
-
/** 占位元素的属性名,模板与挂载方共用(FR-19.1 的契约) */
|
|
2157
|
-
const VIEWER_COMPONENT_ATTR = "data-webskill-component";
|
|
2158
|
-
const VIEWER_PROPS_ATTR = "data-webskill-props";
|
|
2159
|
-
/** 降级提示的标记;模板 CSS 与判据都认这个属性,不认某个 class 名 */
|
|
2160
|
-
const VIEWER_FALLBACK_ATTR = "data-webskill-fallback";
|
|
2161
|
-
/** 文档面可用的组件白名单(FR-19.3)。字面量数组——不接受任何按名动态加载 */
|
|
2162
|
-
const DOCUMENT_COMPONENTS = [
|
|
2163
|
-
"Chart",
|
|
2164
|
-
"Table",
|
|
2165
|
-
"Metric",
|
|
2166
|
-
"Gauge",
|
|
2167
|
-
"KeyValue"
|
|
2168
|
-
];
|
|
2169
|
-
const WHITELIST = new Set(DOCUMENT_COMPONENTS);
|
|
2170
|
-
/**
|
|
2171
|
-
* 扫描 `root` 下的占位并挂载预置组件。
|
|
2172
|
-
*
|
|
2173
|
-
* 逐个占位独立处理:一个坏占位只降级它自己,不会让整篇文档变空白。
|
|
2174
|
-
*/
|
|
2175
|
-
function mountViewerComponents(root, options) {
|
|
2176
|
-
const handles = [];
|
|
2177
|
-
for (const node of Array.from(root.querySelectorAll(`[${VIEWER_COMPONENT_ATTR}]`))) {
|
|
2178
|
-
const element = node;
|
|
2179
|
-
const name = element.getAttribute("data-webskill-component") ?? "";
|
|
2180
|
-
if (!WHITELIST.has(name)) {
|
|
2181
|
-
fallback(element, `Unsupported component "${name}"`);
|
|
2182
|
-
continue;
|
|
2183
|
-
}
|
|
2184
|
-
const props = readProps(element, name);
|
|
2185
|
-
if (!props) continue;
|
|
2186
|
-
const handle = render(element, name, props, options?.fontSizes);
|
|
2187
|
-
if (handle) handles.push(handle);
|
|
2188
|
-
}
|
|
2189
|
-
return { dispose: () => {
|
|
2190
|
-
for (const handle of handles) handle.dispose();
|
|
2191
|
-
handles.length = 0;
|
|
2192
|
-
} };
|
|
2193
|
-
}
|
|
2194
|
-
/** 用 catalog 的 schema 解析:多余字段被丢弃,缺省值与 catalog 完全一致 */
|
|
2195
|
-
function readProps(element, name) {
|
|
2196
|
-
const raw = element.getAttribute(VIEWER_PROPS_ATTR);
|
|
2197
|
-
if (raw === null || raw === "") {
|
|
2198
|
-
fallback(element, `Component "${name}" is missing ${VIEWER_PROPS_ATTR}`);
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2201
|
-
let parsed;
|
|
2202
|
-
try {
|
|
2203
|
-
parsed = JSON.parse(raw);
|
|
2204
|
-
} catch {
|
|
2205
|
-
fallback(element, `Component "${name}" has invalid props JSON`);
|
|
2206
|
-
return;
|
|
2207
|
-
}
|
|
2208
|
-
const schema = uiCatalog.component(name)?.props;
|
|
2209
|
-
if (!schema) {
|
|
2210
|
-
fallback(element, `Component "${name}" is not declared in the catalog`);
|
|
2211
|
-
return;
|
|
2212
|
-
}
|
|
2213
|
-
const result = schema.safeParse(parsed);
|
|
2214
|
-
if (!result.success) {
|
|
2215
|
-
fallback(element, `Component "${name}" has props the catalog rejects`);
|
|
2216
|
-
return;
|
|
2217
|
-
}
|
|
2218
|
-
return result.data;
|
|
2219
|
-
}
|
|
2220
|
-
function render(element, name, props, fontSizes) {
|
|
2221
|
-
const doc = element.ownerDocument;
|
|
2222
|
-
element.replaceChildren();
|
|
2223
|
-
switch (name) {
|
|
2224
|
-
case "Chart": {
|
|
2225
|
-
const handle = mountEchart(element);
|
|
2226
|
-
handle.setChart(chartSpecFromProps(props), fontSizes);
|
|
2227
|
-
return handle;
|
|
2228
|
-
}
|
|
2229
|
-
case "Table":
|
|
2230
|
-
renderBlocks(element, [{
|
|
2231
|
-
type: "table",
|
|
2232
|
-
columns: props["columns"],
|
|
2233
|
-
rows: props["rows"]
|
|
2234
|
-
}], doc);
|
|
2235
|
-
return;
|
|
2236
|
-
case "Metric": {
|
|
2237
|
-
append(doc, element, "span", String(props["label"]), { "data-webskill-metric": "label" });
|
|
2238
|
-
append(doc, element, "strong", String(props["value"]), { "data-webskill-metric": "value" });
|
|
2239
|
-
const change = props["change"];
|
|
2240
|
-
if (typeof change === "string") append(doc, element, "span", change, {
|
|
2241
|
-
"data-webskill-metric": "change",
|
|
2242
|
-
"data-trend": String(props["trend"] ?? "neutral")
|
|
2243
|
-
});
|
|
2244
|
-
return;
|
|
2245
|
-
}
|
|
2246
|
-
case "Gauge": {
|
|
2247
|
-
const value = props["value"];
|
|
2248
|
-
const min = props["min"];
|
|
2249
|
-
const max = props["max"];
|
|
2250
|
-
const label = typeof props["label"] === "string" ? props["label"] : "";
|
|
2251
|
-
element.setAttribute("data-tone", String(props["tone"]));
|
|
2252
|
-
if (label !== "") append(doc, element, "span", label, { "data-webskill-gauge": "label" });
|
|
2253
|
-
const fill = append(doc, append(doc, element, "div", "", {
|
|
2254
|
-
role: "meter",
|
|
2255
|
-
"aria-label": label === "" ? "Gauge" : label,
|
|
2256
|
-
"aria-valuenow": String(value),
|
|
2257
|
-
"aria-valuemin": String(min),
|
|
2258
|
-
"aria-valuemax": String(max),
|
|
2259
|
-
"data-webskill-gauge": "track"
|
|
2260
|
-
}), "div", "", { "data-webskill-gauge": "fill" });
|
|
2261
|
-
fill.style.width = `${gaugePercent(value, min, max)}%`;
|
|
2262
|
-
append(doc, element, "span", String(value), { "data-webskill-gauge": "value" });
|
|
2263
|
-
return;
|
|
2264
|
-
}
|
|
2265
|
-
case "KeyValue": {
|
|
2266
|
-
const dl = append(doc, element, "dl", "", {});
|
|
2267
|
-
for (const item of props["items"]) {
|
|
2268
|
-
append(doc, dl, "dt", item.label, {});
|
|
2269
|
-
append(doc, dl, "dd", item.value, {});
|
|
2270
|
-
}
|
|
2271
|
-
return;
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
function append(doc, parent, tag, text, attrs) {
|
|
2276
|
-
const el = doc.createElement(tag);
|
|
2277
|
-
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
|
|
2278
|
-
if (text !== "") el.textContent = text;
|
|
2279
|
-
parent.appendChild(el);
|
|
2280
|
-
return el;
|
|
2281
|
-
}
|
|
2282
|
-
/**
|
|
2283
|
-
* 可见降级(AC-19.4)。
|
|
2284
|
-
*
|
|
2285
|
-
* 静默留空是最坏的结果:文档看着「渲染成功」,作者却不知道自己写错了组件名。
|
|
2286
|
-
* 原有内容整体替换,避免半截渲染与提示并存。
|
|
2287
|
-
*/
|
|
2288
|
-
function fallback(element, reason) {
|
|
2289
|
-
element.setAttribute(VIEWER_FALLBACK_ATTR, "true");
|
|
2290
|
-
element.textContent = reason;
|
|
2291
|
-
}
|
|
2292
|
-
/** 最小样式(BEM class,应用可整体覆写) */
|
|
2293
|
-
const WEBSKILL_STYLES_CSS = `
|
|
2294
|
-
.webskill-form { display: block; padding: 12px; border: 1px solid #d0d0d0; border-radius: 8px; font-family: system-ui, sans-serif; max-width: 420px; }
|
|
2295
|
-
.webskill-form__title { margin: 0 0 8px; font-size: 15px; font-weight: 600; }
|
|
2296
|
-
.webskill-form__message { margin: 0 0 10px; font-size: 14px; }
|
|
2297
|
-
.webskill-form__control { margin-bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
2298
|
-
.webskill-form__control--invalid .webskill-form__input { border-color: #d33; }
|
|
2299
|
-
.webskill-form__label { font-size: 13px; font-weight: 500; }
|
|
2300
|
-
.webskill-form__label-required::after { content: ' *'; color: #d33; }
|
|
2301
|
-
.webskill-form__description { font-size: 12px; color: #666; }
|
|
2302
|
-
.webskill-form__error { font-size: 12px; color: #d33; display: none; }
|
|
2303
|
-
.webskill-form__control--invalid .webskill-form__error { display: block; }
|
|
2304
|
-
.webskill-form__input { padding: 6px 8px; border: 1px solid #bbb; border-radius: 4px; font-size: 14px; font-family: inherit; }
|
|
2305
|
-
.webskill-form__suggestion { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #666; }
|
|
2306
|
-
.webskill-form__suggestion-use { padding: 1px 8px; border-radius: 4px; border: 1px solid #bbb; background: #fff; cursor: pointer; font-size: 12px; }
|
|
2307
|
-
.webskill-form__actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
2308
|
-
.webskill-form__button { padding: 6px 14px; border-radius: 4px; border: 1px solid #bbb; background: #fff; cursor: pointer; font-size: 14px; }
|
|
2309
|
-
.webskill-form__button--primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
2310
|
-
.webskill-form__button--ghost { background: transparent; }
|
|
2311
|
-
.webskill-result { font-family: system-ui, sans-serif; font-size: 14px; }
|
|
2312
|
-
.webskill-result__summary { font-size: 12px; color: #666; margin-bottom: 6px; }
|
|
2313
|
-
.webskill-result__json { background: #f5f5f5; padding: 8px; border-radius: 4px; overflow-x: auto; }
|
|
2314
|
-
.webskill-result__file { padding: 4px 0; font-size: 13px; }
|
|
2315
|
-
.webskill-md pre { background: #f5f5f5; padding: 8px; border-radius: 4px; overflow-x: auto; }
|
|
2316
|
-
.webskill-md h1, .webskill-md h2, .webskill-md h3 { margin: 8px 0 4px; }
|
|
2317
|
-
.webskill-md p { margin: 4px 0; }
|
|
2318
|
-
.webskill-md ul { margin: 4px 0; padding-left: 20px; }
|
|
2319
|
-
`;
|
|
2320
|
-
/** 每个 document 只注入一次 */
|
|
2321
|
-
function ensureStyles(doc) {
|
|
2322
|
-
if (doc.querySelector("style[data-webskill]")) return;
|
|
2323
|
-
const style = doc.createElement("style");
|
|
2324
|
-
style.setAttribute("data-webskill", "");
|
|
2325
|
-
style.textContent = WEBSKILL_STYLES_CSS;
|
|
2326
|
-
doc.head.appendChild(style);
|
|
2327
|
-
}
|
|
2328
|
-
const CONTROL_TAG = "data-webskill-control";
|
|
2329
|
-
/**
|
|
2330
|
-
* 框架无关原生 DOM 的 UiBridge:request 渲染表单并返回 Promise
|
|
2331
|
-
* (提交 resolve / 取消 {cancelled:true}),完成后卸载 DOM。
|
|
2332
|
-
* document 可注入(jsdom 测试);BEM class 样式可覆写。
|
|
2333
|
-
*/
|
|
2334
|
-
var WebFormBridge = class {
|
|
2335
|
-
#mount;
|
|
2336
|
-
#doc;
|
|
2337
|
-
#styles;
|
|
2338
|
-
#progressEl;
|
|
2339
|
-
#texts;
|
|
2340
|
-
#interactionTexts;
|
|
2341
|
-
/** 等待中的 request 取消句柄(id → 触发 cancelled 并卸载 DOM) */
|
|
2342
|
-
#pending = /* @__PURE__ */ new Map();
|
|
2343
|
-
constructor(options) {
|
|
2344
|
-
this.#mount = options.mount;
|
|
2345
|
-
this.#doc = options.document ?? options.mount.ownerDocument;
|
|
2346
|
-
this.#styles = options.styles ?? true;
|
|
2347
|
-
this.#interactionTexts = options.texts;
|
|
2348
|
-
this.#texts = resolveSurfaceFormTexts(options.texts);
|
|
2349
|
-
}
|
|
2350
|
-
request(input) {
|
|
2351
|
-
if (this.#styles) ensureStyles(this.#doc);
|
|
2352
|
-
this.#clearProgress();
|
|
2353
|
-
const model = interactionToFormModel(input, this.#interactionTexts);
|
|
2354
|
-
const { form, cleanup } = this.#renderForm(model);
|
|
2355
|
-
return new Promise((resolve) => {
|
|
2356
|
-
const cancel = () => {
|
|
2357
|
-
cleanup();
|
|
2358
|
-
this.#pending.delete(input.id);
|
|
2359
|
-
resolve({
|
|
2360
|
-
id: input.id,
|
|
2361
|
-
cancelled: true
|
|
2362
|
-
});
|
|
2363
|
-
};
|
|
2364
|
-
this.#pending.set(input.id, cancel);
|
|
2365
|
-
form.addEventListener("submit", (event) => {
|
|
2366
|
-
event.preventDefault();
|
|
2367
|
-
const { values, missingRequired } = collectValues(model.controls, form);
|
|
2368
|
-
if (missingRequired.length > 0) {
|
|
2369
|
-
for (const name of missingRequired) {
|
|
2370
|
-
const wrapper = form.querySelector(`[${CONTROL_TAG}="${name}"]`)?.closest(".webskill-form__control");
|
|
2371
|
-
wrapper?.classList.add("webskill-form__control--invalid");
|
|
2372
|
-
wrapper?.querySelector(".webskill-form__input")?.setAttribute("aria-invalid", "true");
|
|
2373
|
-
}
|
|
2374
|
-
return;
|
|
2375
|
-
}
|
|
2376
|
-
this.#pending.delete(input.id);
|
|
2377
|
-
cleanup();
|
|
2378
|
-
resolve({
|
|
2379
|
-
id: input.id,
|
|
2380
|
-
value: shapeInteractionValue(model, values)
|
|
2381
|
-
});
|
|
2382
|
-
});
|
|
2383
|
-
form.querySelector("[data-webskill-cancel]")?.addEventListener("click", (event) => {
|
|
2384
|
-
event.preventDefault();
|
|
2385
|
-
cancel();
|
|
2386
|
-
});
|
|
2387
|
-
this.#mount.appendChild(form);
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
/** 尽力取消等待中的 request(超时后清理遗留表单) */
|
|
2391
|
-
cancel(id) {
|
|
2392
|
-
this.#pending.get(id)?.();
|
|
2393
|
-
}
|
|
2394
|
-
async renderResult(input) {
|
|
2395
|
-
if (this.#styles) ensureStyles(this.#doc);
|
|
2396
|
-
this.#clearProgress();
|
|
2397
|
-
this.#mount.querySelector(".webskill-result")?.remove();
|
|
2398
|
-
this.#mount.appendChild(renderRenderResult(input, this.#doc));
|
|
2399
|
-
}
|
|
2400
|
-
async progress(input) {
|
|
2401
|
-
if (!this.#progressEl) {
|
|
2402
|
-
this.#progressEl = this.#doc.createElement("div");
|
|
2403
|
-
this.#progressEl.className = "webskill-result__summary";
|
|
2404
|
-
this.#mount.appendChild(this.#progressEl);
|
|
2405
|
-
}
|
|
2406
|
-
const pct = input.value !== void 0 ? ` (${Math.round(input.value * 100)}%)` : "";
|
|
2407
|
-
this.#progressEl.textContent = `${input.message}${pct}`;
|
|
2408
|
-
}
|
|
2409
|
-
/** 新一轮交互/结果渲染开始时移除遗留 progress 元素 */
|
|
2410
|
-
#clearProgress() {
|
|
2411
|
-
this.#progressEl?.remove();
|
|
2412
|
-
this.#progressEl = void 0;
|
|
2413
|
-
}
|
|
2414
|
-
#renderForm(model) {
|
|
2415
|
-
const doc = this.#doc;
|
|
2416
|
-
const form = doc.createElement("form");
|
|
2417
|
-
form.className = "webskill-form";
|
|
2418
|
-
form.noValidate = true;
|
|
2419
|
-
if (model.title) {
|
|
2420
|
-
const title = doc.createElement("div");
|
|
2421
|
-
title.className = "webskill-form__title";
|
|
2422
|
-
title.textContent = model.title;
|
|
2423
|
-
form.appendChild(title);
|
|
2424
|
-
}
|
|
2425
|
-
if (model.message && model.kind !== "confirm" && model.kind !== "ask" && model.kind !== "select") {
|
|
2426
|
-
const message = doc.createElement("p");
|
|
2427
|
-
message.className = "webskill-form__message";
|
|
2428
|
-
message.textContent = model.message;
|
|
2429
|
-
form.appendChild(message);
|
|
2430
|
-
}
|
|
2431
|
-
for (const control of model.controls) form.appendChild(this.#renderControl(control));
|
|
2432
|
-
const actions = doc.createElement("div");
|
|
2433
|
-
actions.className = "webskill-form__actions";
|
|
2434
|
-
const submit = doc.createElement("button");
|
|
2435
|
-
submit.type = "submit";
|
|
2436
|
-
submit.className = "webskill-form__button webskill-form__button--primary";
|
|
2437
|
-
submit.textContent = model.submitLabel;
|
|
2438
|
-
actions.appendChild(submit);
|
|
2439
|
-
if (model.cancelLabel) {
|
|
2440
|
-
const cancel = doc.createElement("button");
|
|
2441
|
-
cancel.type = "button";
|
|
2442
|
-
cancel.className = "webskill-form__button webskill-form__button--ghost";
|
|
2443
|
-
cancel.textContent = model.cancelLabel;
|
|
2444
|
-
cancel.setAttribute("data-webskill-cancel", "");
|
|
2445
|
-
actions.appendChild(cancel);
|
|
2446
|
-
}
|
|
2447
|
-
form.appendChild(actions);
|
|
2448
|
-
return {
|
|
2449
|
-
form,
|
|
2450
|
-
cleanup: () => form.remove()
|
|
2451
|
-
};
|
|
2452
|
-
}
|
|
2453
|
-
#renderControl(control) {
|
|
2454
|
-
const doc = this.#doc;
|
|
2455
|
-
const wrapper = doc.createElement("div");
|
|
2456
|
-
wrapper.className = "webskill-form__control";
|
|
2457
|
-
const controlId = `webskill-field-${control.name}`;
|
|
2458
|
-
const errorId = `webskill-error-${control.name}`;
|
|
2459
|
-
const label = doc.createElement("label");
|
|
2460
|
-
label.className = control.required ? "webskill-form__label webskill-form__label-required" : "webskill-form__label";
|
|
2461
|
-
label.textContent = control.label;
|
|
2462
|
-
label.htmlFor = controlId;
|
|
2463
|
-
wrapper.appendChild(label);
|
|
2464
|
-
if (control.description) {
|
|
2465
|
-
const desc = doc.createElement("div");
|
|
2466
|
-
desc.className = "webskill-form__description";
|
|
2467
|
-
desc.textContent = control.description;
|
|
2468
|
-
wrapper.appendChild(desc);
|
|
2469
|
-
}
|
|
2470
|
-
let input;
|
|
2471
|
-
if (control.control === "select") {
|
|
2472
|
-
const select = doc.createElement("select");
|
|
2473
|
-
select.className = "webskill-form__input";
|
|
2474
|
-
for (const option of control.options ?? []) {
|
|
2475
|
-
const opt = doc.createElement("option");
|
|
2476
|
-
opt.value = JSON.stringify(option.value);
|
|
2477
|
-
opt.textContent = option.label;
|
|
2478
|
-
if (control.defaultValue !== void 0 && JSON.stringify(control.defaultValue) === opt.value) opt.selected = true;
|
|
2479
|
-
select.appendChild(opt);
|
|
2480
|
-
}
|
|
2481
|
-
input = select;
|
|
2482
|
-
} else if (control.control === "boolean") {
|
|
2483
|
-
const checkbox = doc.createElement("input");
|
|
2484
|
-
checkbox.type = "checkbox";
|
|
2485
|
-
checkbox.className = "webskill-form__input";
|
|
2486
|
-
checkbox.checked = control.defaultValue === true;
|
|
2487
|
-
input = checkbox;
|
|
2488
|
-
} else if (control.control === "textarea") {
|
|
2489
|
-
const textarea = doc.createElement("textarea");
|
|
2490
|
-
textarea.className = "webskill-form__input";
|
|
2491
|
-
textarea.value = control.defaultValue !== void 0 ? String(control.defaultValue) : "";
|
|
2492
|
-
input = textarea;
|
|
2493
|
-
} else {
|
|
2494
|
-
const textInput = doc.createElement("input");
|
|
2495
|
-
textInput.type = control.control === "number" ? "number" : control.control === "date" ? "date" : "text";
|
|
2496
|
-
textInput.className = "webskill-form__input";
|
|
2497
|
-
if (control.defaultValue !== void 0) textInput.value = String(control.defaultValue);
|
|
2498
|
-
input = textInput;
|
|
2499
|
-
}
|
|
2500
|
-
input.setAttribute(CONTROL_TAG, control.name);
|
|
2501
|
-
input.id = controlId;
|
|
2502
|
-
input.setAttribute("aria-describedby", errorId);
|
|
2503
|
-
wrapper.appendChild(input);
|
|
2504
|
-
if (control.suggestion !== void 0) wrapper.appendChild(this.#renderSuggestion(control, wrapper));
|
|
2505
|
-
const error = doc.createElement("div");
|
|
2506
|
-
error.className = "webskill-form__error";
|
|
2507
|
-
error.id = errorId;
|
|
2508
|
-
error.setAttribute("role", "alert");
|
|
2509
|
-
error.textContent = this.#texts.required;
|
|
2510
|
-
wrapper.appendChild(error);
|
|
2511
|
-
return wrapper;
|
|
2512
|
-
}
|
|
2513
|
-
/** 建议值以**建议**形态出现(FR-19.4):控件初始值仍为空,点「使用」才写入 */
|
|
2514
|
-
#renderSuggestion(control, container) {
|
|
2515
|
-
const doc = this.#doc;
|
|
2516
|
-
const row = doc.createElement("div");
|
|
2517
|
-
row.className = "webskill-form__suggestion";
|
|
2518
|
-
row.setAttribute("data-webskill-suggestion", control.name);
|
|
2519
|
-
const preview = doc.createElement("span");
|
|
2520
|
-
preview.className = "webskill-form__suggestion-value";
|
|
2521
|
-
preview.textContent = `Suggested: ${String(control.suggestion?.value)}`;
|
|
2522
|
-
row.appendChild(preview);
|
|
2523
|
-
const reason = control.suggestion?.reason;
|
|
2524
|
-
if (reason !== void 0 && reason !== "") {
|
|
2525
|
-
const why = doc.createElement("span");
|
|
2526
|
-
why.className = "webskill-form__suggestion-reason";
|
|
2527
|
-
why.textContent = `(${reason})`;
|
|
2528
|
-
row.appendChild(why);
|
|
2529
|
-
}
|
|
2530
|
-
const use = doc.createElement("button");
|
|
2531
|
-
use.type = "button";
|
|
2532
|
-
use.className = "webskill-form__suggestion-use";
|
|
2533
|
-
use.textContent = "Use";
|
|
2534
|
-
use.setAttribute("data-webskill-suggestion-use", control.name);
|
|
2535
|
-
use.addEventListener("click", () => applySuggestion(control, container));
|
|
2536
|
-
row.appendChild(use);
|
|
2537
|
-
return row;
|
|
2538
|
-
}
|
|
2539
|
-
};
|
|
2540
|
-
function fieldNamesOf(form) {
|
|
2541
|
-
const names = [];
|
|
2542
|
-
const walk = (node) => {
|
|
2543
|
-
const name = node.props?.["name"];
|
|
2544
|
-
if (node.component === "Field" && typeof name === "string") names.push(name);
|
|
2545
|
-
for (const child of node.children ?? []) walk(child);
|
|
2546
|
-
};
|
|
2547
|
-
walk(form);
|
|
2548
|
-
return names;
|
|
2549
|
-
}
|
|
2550
|
-
/**
|
|
2551
|
-
* 按出现序列出声明树里的每个 `Form`。
|
|
2552
|
-
*
|
|
2553
|
-
* 单表单时 action id 保持 `submit` / `cancel`,回传形状与容器级放宽前逐字节一致——
|
|
2554
|
-
* 向后兼容是 FR-6.3 的硬约束,不能让既有单表单卡片跟着改。
|
|
2555
|
-
* 多表单时每个表单拿到独立的 action id 与 `scopeId`,渲染层据此只收集本表单子树内的字段。
|
|
2556
|
-
*/
|
|
2557
|
-
function collectFormScopes(spec) {
|
|
2558
|
-
const forms = [];
|
|
2559
|
-
const walk = (node) => {
|
|
2560
|
-
if (node.component === "Form") forms.push(node);
|
|
2561
|
-
for (const child of node.children ?? []) walk(child);
|
|
2562
|
-
};
|
|
2563
|
-
walk(spec);
|
|
2564
|
-
if (forms.length === 1) {
|
|
2565
|
-
const form = forms[0];
|
|
2566
|
-
return [{
|
|
2567
|
-
scopeId: form.id ?? "form",
|
|
2568
|
-
submitActionId: "submit",
|
|
2569
|
-
cancelActionId: "cancel",
|
|
2570
|
-
fieldNames: fieldNamesOf(form),
|
|
2571
|
-
form
|
|
2572
|
-
}];
|
|
2573
|
-
}
|
|
2574
|
-
return forms.map((form, index) => {
|
|
2575
|
-
const scopeId = form.id ?? `form-${index}`;
|
|
2576
|
-
return {
|
|
2577
|
-
scopeId,
|
|
2578
|
-
submitActionId: `${scopeId}:submit`,
|
|
2579
|
-
cancelActionId: `${scopeId}:cancel`,
|
|
2580
|
-
fieldNames: fieldNamesOf(form),
|
|
2581
|
-
form
|
|
2582
|
-
};
|
|
2583
|
-
});
|
|
2584
|
-
}
|
|
2585
|
-
/**
|
|
2586
|
-
* 多表单时字段值在渲染层的存储键。
|
|
2587
|
-
*
|
|
2588
|
-
* 跨表单重名过去不可能发生,容器级放宽后它变成合法输入:两个表单各有一个 `email`,
|
|
2589
|
-
* 共用一张扁平表就是后写的覆盖先写的,用户在 A 里输入会串到 B。
|
|
2590
|
-
* 因此多表单时按作用域限定键;单表单不加前缀,存储与草稿形状保持不变。
|
|
2591
|
-
*/
|
|
2592
|
-
function qualifyFieldName(name, scopeId) {
|
|
2593
|
-
return scopeId === void 0 ? name : `${scopeId}.${name}`;
|
|
2594
|
-
}
|
|
2595
|
-
/**
|
|
2596
|
-
* 提交时回传的字段值。
|
|
2597
|
-
*
|
|
2598
|
-
* 不给 `scope` → 整张 surface 的扁平值,与容器级放宽前完全一致(FR-6.3 第 3 条)。
|
|
2599
|
-
* 给了 `scope` → 只取该表单子树内的字段,键回落成裸字段名,
|
|
2600
|
-
* 因此接收端看到的载荷形状与单表单时相同。
|
|
2601
|
-
*/
|
|
2602
|
-
function collectScopedValues(values, scope) {
|
|
2603
|
-
if (!scope) return { ...values };
|
|
2604
|
-
const out = {};
|
|
2605
|
-
for (const name of scope.fieldNames) {
|
|
2606
|
-
const key = qualifyFieldName(name, scope.scopeId);
|
|
2607
|
-
if (key in values) out[name] = values[key];
|
|
2608
|
-
else if (name in values) out[name] = values[name];
|
|
2609
|
-
}
|
|
2610
|
-
return out;
|
|
2611
|
-
}
|
|
2612
|
-
/**
|
|
2613
|
-
* 条件树的语义深度上限。
|
|
2614
|
-
* `MAX_JSON_DEPTH` 管的是 props 的 JSON 深度,管不到这里——超它之前就能先把栈打爆。
|
|
2615
|
-
*/
|
|
2616
|
-
const MAX_CONDITION_DEPTH = 8;
|
|
2617
|
-
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2618
|
-
const isEmpty = (value) => value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
2619
|
-
function invalid(path, detail) {
|
|
2620
|
-
return {
|
|
2621
|
-
visible: true,
|
|
2622
|
-
degraded: {
|
|
2623
|
-
path,
|
|
2624
|
-
kind: "condition-invalid",
|
|
2625
|
-
detail
|
|
2626
|
-
}
|
|
2627
|
-
};
|
|
2628
|
-
}
|
|
2629
|
-
function evaluate(condition, context, depth) {
|
|
2630
|
-
if (depth > 8) return invalid(context.path, `Condition nesting exceeds 8 levels.`);
|
|
2631
|
-
if (!isRecord$1(condition)) return invalid(context.path, "Condition must be an object.");
|
|
2632
|
-
if (Array.isArray(condition["allOf"]) || Array.isArray(condition["anyOf"])) {
|
|
2633
|
-
const isAll = Array.isArray(condition["allOf"]);
|
|
2634
|
-
const branches = isAll ? condition["allOf"] : condition["anyOf"];
|
|
2635
|
-
if (branches.length === 0) return invalid(context.path, `"${isAll ? "allOf" : "anyOf"}" must not be empty.`);
|
|
2636
|
-
let visible = isAll;
|
|
2637
|
-
for (const branch of branches) {
|
|
2638
|
-
const result = evaluate(branch, context, depth + 1);
|
|
2639
|
-
if (result.degraded) return result;
|
|
2640
|
-
visible = isAll ? visible && result.visible : visible || result.visible;
|
|
2641
|
-
}
|
|
2642
|
-
return { visible };
|
|
2643
|
-
}
|
|
2644
|
-
const field = condition["field"];
|
|
2645
|
-
if (typeof field !== "string" || field === "") return invalid(context.path, "Condition needs a \"field\" name.");
|
|
2646
|
-
const value = context.resolve(field);
|
|
2647
|
-
if ("equals" in condition) {
|
|
2648
|
-
const expected = condition["equals"];
|
|
2649
|
-
if (typeof expected !== "string" && typeof expected !== "number" && typeof expected !== "boolean") return invalid(context.path, "\"equals\" must be a string, number or boolean.");
|
|
2650
|
-
return { visible: value === expected };
|
|
2651
|
-
}
|
|
2652
|
-
if ("in" in condition) {
|
|
2653
|
-
const options = condition["in"];
|
|
2654
|
-
if (!Array.isArray(options)) return invalid(context.path, "\"in\" must be an array.");
|
|
2655
|
-
return { visible: options.some((option) => option === value) };
|
|
2656
|
-
}
|
|
2657
|
-
if ("notEmpty" in condition) {
|
|
2658
|
-
if (condition["notEmpty"] !== true) return invalid(context.path, "\"notEmpty\" must be true.");
|
|
2659
|
-
return { visible: !isEmpty(value) };
|
|
2660
|
-
}
|
|
2661
|
-
return invalid(context.path, "Condition has no recognised operator (equals / in / notEmpty / allOf / anyOf).");
|
|
2662
|
-
}
|
|
2663
|
-
/**
|
|
2664
|
-
* 全仓唯一的条件求值实现(AC-23.4)。纯函数、无 React 依赖——
|
|
2665
|
-
* `useSurfaceForm` 在 ui-react 且是 hook,ui-vue 用不了它。
|
|
2666
|
-
*/
|
|
2667
|
-
function evaluateFieldCondition(condition, values, options = {}) {
|
|
2668
|
-
const resolveField = options.resolveField;
|
|
2669
|
-
return evaluate(condition, {
|
|
2670
|
-
values,
|
|
2671
|
-
resolve: (field) => values[resolveField ? resolveField(field) : field],
|
|
2672
|
-
path: options.path ?? "visibleWhen"
|
|
2673
|
-
}, 1);
|
|
2674
|
-
}
|
|
2675
|
-
/**
|
|
2676
|
-
* 生成式表格的列模型(分册 15-03 定义 `priority`,分册 25 在同一结构上追加 `weight`)。
|
|
2677
|
-
*
|
|
2678
|
-
* 四个渲染档共用这一份:native / json-render / OpenUI 走 TS,a2ui 是 Lit,
|
|
2679
|
-
* 后者读不到 TS 常量,所以最小列宽以 **CSS 变量**交付,两侧引用同一个名字。
|
|
2680
|
-
*/
|
|
2681
|
-
/** 最小列宽的分档取值。`desktop` 沿用历史值,改它会动所有既有表格的视觉基线 */
|
|
2682
|
-
const SPEC_TABLE_MIN_COLUMN_WIDTH = {
|
|
2683
|
-
desktop: "8rem",
|
|
2684
|
-
mobile: "5rem"
|
|
2685
|
-
};
|
|
2686
|
-
/** CSS 侧的引用名:`surfaces.css` 与 a2ui 的 Lit 样式都只写这个变量,不写字面量 */
|
|
2687
|
-
const SPEC_TABLE_MIN_COLUMN_VAR = "--webskill-table-min-col";
|
|
2688
|
-
/**
|
|
2689
|
-
* 校验并归一化 `columnWidths`。
|
|
2690
|
-
*
|
|
2691
|
-
* **整项忽略**是刻意的:部分采纳(例如只丢掉那个负数)会让模型拿到一个
|
|
2692
|
-
* 「看起来生效了」的结果,更难发现自己写错了(FR-25.3)。
|
|
2693
|
-
*/
|
|
2694
|
-
function normalizeColumnWidths(raw, columnCount) {
|
|
2695
|
-
const equal = () => Array.from({ length: columnCount }, () => 1);
|
|
2696
|
-
if (raw === void 0) return { weights: equal() };
|
|
2697
|
-
if (raw.length !== columnCount) return {
|
|
2698
|
-
weights: equal(),
|
|
2699
|
-
rejected: "length-mismatch"
|
|
2700
|
-
};
|
|
2701
|
-
if (raw.some((value) => typeof value !== "number" || !Number.isFinite(value))) return {
|
|
2702
|
-
weights: equal(),
|
|
2703
|
-
rejected: "non-finite"
|
|
2704
|
-
};
|
|
2705
|
-
if (raw.some((value) => value < 0)) return {
|
|
2706
|
-
weights: equal(),
|
|
2707
|
-
rejected: "negative"
|
|
2708
|
-
};
|
|
2709
|
-
if (raw.every((value) => value === 0)) return {
|
|
2710
|
-
weights: equal(),
|
|
2711
|
-
rejected: "all-zero"
|
|
2712
|
-
};
|
|
2713
|
-
return { weights: [...raw] };
|
|
2714
|
-
}
|
|
2715
|
-
/**
|
|
2716
|
-
* 按权重分配列宽,且每列不低于 `minWidth`。
|
|
2717
|
-
*
|
|
2718
|
-
* 朴素实现(按权重算一遍、低于下限的抬到下限)会让总宽超出容器,
|
|
2719
|
-
* 白白产生本可避免的横滚。这里迭代到不动点:每轮把触底的列钉住,
|
|
2720
|
-
* 剩余空间在剩余列间按权重重新分配(FR-25.2)。
|
|
2721
|
-
*
|
|
2722
|
-
* 容器本身放不下 `columnCount * minWidth` 时无解——此时全部取下限并返回,
|
|
2723
|
-
* 由调用方决定横滚(AC-25.4)。
|
|
2724
|
-
*/
|
|
2725
|
-
function resolveColumnWidths(weights, available, minWidth) {
|
|
2726
|
-
const count = weights.length;
|
|
2727
|
-
if (count === 0) return [];
|
|
2728
|
-
if (available <= count * minWidth) return Array.from({ length: count }, () => minWidth);
|
|
2729
|
-
const result = Array.from({ length: count }, () => 0);
|
|
2730
|
-
const pinned = new Array(count).fill(false);
|
|
2731
|
-
for (let round = 0; round <= count; round += 1) {
|
|
2732
|
-
const freeIndexes = result.map((_, index) => index).filter((index) => !pinned[index]);
|
|
2733
|
-
const remaining = available - result.reduce((sum, value, index) => pinned[index] ? sum + value : sum, 0);
|
|
2734
|
-
const weightTotal = freeIndexes.reduce((sum, index) => sum + (weights[index] ?? 0), 0);
|
|
2735
|
-
let changed = false;
|
|
2736
|
-
for (const index of freeIndexes) {
|
|
2737
|
-
const share = weightTotal > 0 ? remaining * (weights[index] ?? 0) / weightTotal : remaining / freeIndexes.length;
|
|
2738
|
-
if (share < minWidth) {
|
|
2739
|
-
result[index] = minWidth;
|
|
2740
|
-
pinned[index] = true;
|
|
2741
|
-
changed = true;
|
|
2742
|
-
} else result[index] = share;
|
|
2743
|
-
}
|
|
2744
|
-
if (!changed) break;
|
|
2745
|
-
}
|
|
2746
|
-
return result;
|
|
2747
|
-
}
|
|
2748
|
-
/** 五类场景预设(FR-6.4)。@experimental */
|
|
2749
|
-
const UI_PRESETS = [
|
|
2750
|
-
{
|
|
2751
|
-
name: "charts",
|
|
2752
|
-
summary: "One question, one chart, with the takeaway spelled out above it.",
|
|
2753
|
-
guidance: [
|
|
2754
|
-
"Layout: `Card` → `Heading` (the takeaway, not \"Chart\") → `Chart` → optional `Text` for the caveat.",
|
|
2755
|
-
"Pick the chart type from the question: `line` / `area` for time, `bar` for comparison, `pie` for shares.",
|
|
2756
|
-
"Every series needs a name; labels and values must be the same length.",
|
|
2757
|
-
"Never repeat the same numbers in a `Table` right under the chart."
|
|
2758
|
-
].join("\n"),
|
|
2759
|
-
example: {
|
|
2760
|
-
component: "Card",
|
|
2761
|
-
props: { title: "Revenue" },
|
|
2762
|
-
children: [
|
|
2763
|
-
{
|
|
2764
|
-
component: "Heading",
|
|
2765
|
-
props: {
|
|
2766
|
-
level: 3,
|
|
2767
|
-
text: "Revenue grew 27% in Q4"
|
|
2768
|
-
}
|
|
2769
|
-
},
|
|
2770
|
-
{
|
|
2771
|
-
component: "Chart",
|
|
2772
|
-
props: {
|
|
2773
|
-
type: "line",
|
|
2774
|
-
labels: [
|
|
2775
|
-
"Q1",
|
|
2776
|
-
"Q2",
|
|
2777
|
-
"Q3",
|
|
2778
|
-
"Q4"
|
|
2779
|
-
],
|
|
2780
|
-
series: [{
|
|
2781
|
-
name: "Revenue",
|
|
2782
|
-
values: [
|
|
2783
|
-
8,
|
|
2784
|
-
9,
|
|
2785
|
-
9.8,
|
|
2786
|
-
12.4
|
|
2787
|
-
]
|
|
2788
|
-
}]
|
|
2789
|
-
}
|
|
2790
|
-
},
|
|
2791
|
-
{
|
|
2792
|
-
component: "Text",
|
|
2793
|
-
props: {
|
|
2794
|
-
text: "Figures in millions, unaudited.",
|
|
2795
|
-
tone: "muted"
|
|
2796
|
-
}
|
|
2797
|
-
}
|
|
2798
|
-
]
|
|
2799
|
-
}
|
|
2800
|
-
},
|
|
2801
|
-
{
|
|
2802
|
-
name: "cards",
|
|
2803
|
-
summary: "A short list of comparable items, one `Card` each.",
|
|
2804
|
-
guidance: [
|
|
2805
|
-
"Layout: `Grid` (2 or 3 columns) → one `Card` per item.",
|
|
2806
|
-
"Keep every card the same shape: title, one `Metric` or `Text`, at most one `Badge` for status.",
|
|
2807
|
-
"Use `Grid` rather than a vertical `Stack` as soon as there are three or more items.",
|
|
2808
|
-
"Put an action on a card only when the user can act on that single item."
|
|
2809
|
-
].join("\n"),
|
|
2810
|
-
example: {
|
|
2811
|
-
component: "Grid",
|
|
2812
|
-
props: { columns: 2 },
|
|
2813
|
-
children: [{
|
|
2814
|
-
component: "Card",
|
|
2815
|
-
props: { title: "Checkout" },
|
|
2816
|
-
children: [{
|
|
2817
|
-
component: "Metric",
|
|
2818
|
-
props: {
|
|
2819
|
-
label: "Errors",
|
|
2820
|
-
value: 12,
|
|
2821
|
-
trend: "down"
|
|
2822
|
-
}
|
|
2823
|
-
}, {
|
|
2824
|
-
component: "Badge",
|
|
2825
|
-
props: {
|
|
2826
|
-
text: "healthy",
|
|
2827
|
-
tone: "success"
|
|
2828
|
-
}
|
|
2829
|
-
}]
|
|
2830
|
-
}, {
|
|
2831
|
-
component: "Card",
|
|
2832
|
-
props: { title: "Search" },
|
|
2833
|
-
children: [{
|
|
2834
|
-
component: "Metric",
|
|
2835
|
-
props: {
|
|
2836
|
-
label: "Errors",
|
|
2837
|
-
value: 143,
|
|
2838
|
-
trend: "up"
|
|
2839
|
-
}
|
|
2840
|
-
}, {
|
|
2841
|
-
component: "Badge",
|
|
2842
|
-
props: {
|
|
2843
|
-
text: "degraded",
|
|
2844
|
-
tone: "warning"
|
|
2845
|
-
}
|
|
2846
|
-
}]
|
|
2847
|
-
}]
|
|
2848
|
-
}
|
|
2849
|
-
},
|
|
2850
|
-
{
|
|
2851
|
-
name: "dashboards",
|
|
2852
|
-
summary: "Headline numbers first, then detail, optionally split across `Tabs`.",
|
|
2853
|
-
guidance: [
|
|
2854
|
-
"Layout: `Stack` → `Grid` of `Metric` cards → `Chart` or `Table` for the detail.",
|
|
2855
|
-
"Use `Tabs` when the detail splits into independent groups (filters, exports, alerts).",
|
|
2856
|
-
"Each `Tabs` panel and each `Grid` cell is its own form container: at most one `Form` inside.",
|
|
2857
|
-
"Show at most four headline metrics; anything more belongs in a `Table`."
|
|
2858
|
-
].join("\n"),
|
|
2859
|
-
example: {
|
|
2860
|
-
component: "Stack",
|
|
2861
|
-
children: [{
|
|
2862
|
-
component: "Grid",
|
|
2863
|
-
props: { columns: 2 },
|
|
2864
|
-
children: [{
|
|
2865
|
-
component: "Card",
|
|
2866
|
-
props: { title: "MRR" },
|
|
2867
|
-
children: [{
|
|
2868
|
-
component: "Metric",
|
|
2869
|
-
props: {
|
|
2870
|
-
label: "This month",
|
|
2871
|
-
value: "12,400",
|
|
2872
|
-
trend: "up"
|
|
2873
|
-
}
|
|
2874
|
-
}]
|
|
2875
|
-
}, {
|
|
2876
|
-
component: "Card",
|
|
2877
|
-
props: { title: "Churn" },
|
|
2878
|
-
children: [{
|
|
2879
|
-
component: "Metric",
|
|
2880
|
-
props: {
|
|
2881
|
-
label: "This month",
|
|
2882
|
-
value: "2.1%",
|
|
2883
|
-
trend: "down"
|
|
2884
|
-
}
|
|
2885
|
-
}]
|
|
2886
|
-
}]
|
|
2887
|
-
}, {
|
|
2888
|
-
component: "Tabs",
|
|
2889
|
-
props: { labels: ["Trend", "Breakdown"] },
|
|
2890
|
-
children: [{
|
|
2891
|
-
component: "Stack",
|
|
2892
|
-
children: [{
|
|
2893
|
-
component: "Chart",
|
|
2894
|
-
props: {
|
|
2895
|
-
type: "bar",
|
|
2896
|
-
labels: ["Q3", "Q4"],
|
|
2897
|
-
series: [{
|
|
2898
|
-
name: "Revenue",
|
|
2899
|
-
values: [9800, 12400]
|
|
2900
|
-
}]
|
|
2901
|
-
}
|
|
2902
|
-
}]
|
|
2903
|
-
}, {
|
|
2904
|
-
component: "Stack",
|
|
2905
|
-
children: [{
|
|
2906
|
-
component: "Table",
|
|
2907
|
-
props: {
|
|
2908
|
-
columns: ["Region", "Revenue"],
|
|
2909
|
-
rows: [["EMEA", 5400], ["AMER", 7e3]]
|
|
2910
|
-
}
|
|
2911
|
-
}]
|
|
2912
|
-
}]
|
|
2913
|
-
}]
|
|
2914
|
-
}
|
|
2915
|
-
},
|
|
2916
|
-
{
|
|
2917
|
-
name: "slides",
|
|
2918
|
-
summary: "One idea per `Tabs` panel, headline plus at most three supporting lines.",
|
|
2919
|
-
guidance: [
|
|
2920
|
-
"Layout: `Tabs` → one `Stack` per slide, labelled with the slide title.",
|
|
2921
|
-
"Each slide: one `Heading` (level 2) stating the point, then a `Chart`, `Metric` or short `Markdown` list.",
|
|
2922
|
-
"Never put more than one idea on a slide; add a panel instead.",
|
|
2923
|
-
"No `Form` inside slides — a deck is for reading, not for input."
|
|
2924
|
-
].join("\n"),
|
|
2925
|
-
example: {
|
|
2926
|
-
component: "Tabs",
|
|
2927
|
-
props: { labels: ["Where we are", "What we do next"] },
|
|
2928
|
-
children: [{
|
|
2929
|
-
component: "Stack",
|
|
2930
|
-
children: [{
|
|
2931
|
-
component: "Heading",
|
|
2932
|
-
props: {
|
|
2933
|
-
level: 2,
|
|
2934
|
-
text: "Revenue grew 27% in Q4"
|
|
2935
|
-
}
|
|
2936
|
-
}, {
|
|
2937
|
-
component: "Metric",
|
|
2938
|
-
props: {
|
|
2939
|
-
label: "Q4 revenue",
|
|
2940
|
-
value: "12,400",
|
|
2941
|
-
trend: "up"
|
|
2942
|
-
}
|
|
2943
|
-
}]
|
|
2944
|
-
}, {
|
|
2945
|
-
component: "Stack",
|
|
2946
|
-
children: [{
|
|
2947
|
-
component: "Heading",
|
|
2948
|
-
props: {
|
|
2949
|
-
level: 2,
|
|
2950
|
-
text: "Double down on EMEA"
|
|
2951
|
-
}
|
|
2952
|
-
}, {
|
|
2953
|
-
component: "Markdown",
|
|
2954
|
-
props: { text: "- hire two AEs\n- localise pricing" }
|
|
2955
|
-
}]
|
|
2956
|
-
}]
|
|
2957
|
-
}
|
|
2958
|
-
},
|
|
2959
|
-
{
|
|
2960
|
-
name: "reports",
|
|
2961
|
-
summary: "A long-form document: sections, tables and an optional `Timeline`.",
|
|
2962
|
-
guidance: [
|
|
2963
|
-
"Layout: `Stack` → `Heading` (level 2) → body → `Separator` between sections.",
|
|
2964
|
-
"Use `Table` for figures, `Timeline` for what happened when, `Markdown` for prose.",
|
|
2965
|
-
"Open with a one-paragraph summary before any detail.",
|
|
2966
|
-
"Attach generated files with `FileLink` at the end rather than inlining raw data."
|
|
2967
|
-
].join("\n"),
|
|
2968
|
-
example: {
|
|
2969
|
-
component: "Stack",
|
|
2970
|
-
children: [
|
|
2971
|
-
{
|
|
2972
|
-
component: "Heading",
|
|
2973
|
-
props: {
|
|
2974
|
-
level: 2,
|
|
2975
|
-
text: "Q4 incident review"
|
|
2976
|
-
}
|
|
2977
|
-
},
|
|
2978
|
-
{
|
|
2979
|
-
component: "Text",
|
|
2980
|
-
props: { text: "Three incidents, all recovered within the hour." }
|
|
2981
|
-
},
|
|
2982
|
-
{ component: "Separator" },
|
|
2983
|
-
{
|
|
2984
|
-
component: "Timeline",
|
|
2985
|
-
props: { items: [{
|
|
2986
|
-
title: "Search latency spike",
|
|
2987
|
-
time: "12 Nov",
|
|
2988
|
-
state: "done"
|
|
2989
|
-
}, {
|
|
2990
|
-
title: "Checkout timeouts",
|
|
2991
|
-
time: "03 Dec",
|
|
2992
|
-
state: "done"
|
|
2993
|
-
}] }
|
|
2994
|
-
},
|
|
2995
|
-
{
|
|
2996
|
-
component: "FileLink",
|
|
2997
|
-
props: {
|
|
2998
|
-
path: "q4-incidents.csv",
|
|
2999
|
-
label: "Full log",
|
|
3000
|
-
size: 24117
|
|
3001
|
-
}
|
|
3002
|
-
}
|
|
3003
|
-
]
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
];
|
|
3007
|
-
const UI_PRESET_NAMES = UI_PRESETS.map((preset) => preset.name);
|
|
3008
|
-
/** Returns undefined for names outside the catalog presets. @experimental */
|
|
3009
|
-
function uiPreset(name) {
|
|
3010
|
-
return UI_PRESETS.find((preset) => preset.name === name);
|
|
3011
|
-
}
|
|
3012
|
-
/**
|
|
3013
|
-
* catalog 声明树 → json-render 扁平 spec。纯数据变换,不 import json-render
|
|
3014
|
-
* (`@webskill/ui` 保持环境无关)。
|
|
3015
|
-
*/
|
|
3016
|
-
function toJsonRenderSpec(spec) {
|
|
3017
|
-
const elements = {};
|
|
3018
|
-
let counter = 0;
|
|
3019
|
-
const walk = (node) => {
|
|
3020
|
-
const key = node.id ?? `${node.component.toLowerCase()}-${counter++}`;
|
|
3021
|
-
const children = (node.children ?? []).map(walk);
|
|
3022
|
-
elements[key] = {
|
|
3023
|
-
type: node.component,
|
|
3024
|
-
props: node.props ?? {},
|
|
3025
|
-
...children.length > 0 ? { children } : {}
|
|
3026
|
-
};
|
|
3027
|
-
return key;
|
|
3028
|
-
};
|
|
3029
|
-
return {
|
|
3030
|
-
root: walk(spec),
|
|
3031
|
-
elements
|
|
3032
|
-
};
|
|
3033
|
-
}
|
|
3034
|
-
/** ControlModel 的控件词汇 → catalog `Field.type`(catalog 的 9 种是超集) */
|
|
3035
|
-
const FIELD_TYPE = {
|
|
3036
|
-
text: "text",
|
|
3037
|
-
number: "number",
|
|
3038
|
-
boolean: "toggle",
|
|
3039
|
-
select: "select",
|
|
3040
|
-
textarea: "textarea",
|
|
3041
|
-
file: "file",
|
|
3042
|
-
password: "password",
|
|
3043
|
-
date: "date"
|
|
3044
|
-
};
|
|
3045
|
-
function toOptions(options) {
|
|
3046
|
-
return (options ?? []).map((option) => ({
|
|
3047
|
-
label: option.label,
|
|
3048
|
-
value: typeof option.value === "number" ? option.value : String(option.value)
|
|
3049
|
-
}));
|
|
3050
|
-
}
|
|
3051
|
-
function toField(control) {
|
|
3052
|
-
return {
|
|
3053
|
-
component: "Field",
|
|
3054
|
-
props: {
|
|
3055
|
-
name: control.name,
|
|
3056
|
-
label: control.label,
|
|
3057
|
-
type: FIELD_TYPE[control.control],
|
|
3058
|
-
...control.required ? { required: true } : {},
|
|
3059
|
-
...control.description ? { description: control.description } : {},
|
|
3060
|
-
...control.defaultValue !== void 0 ? { defaultValue: control.defaultValue } : {},
|
|
3061
|
-
...control.suggestion ? { suggestion: control.suggestion } : {},
|
|
3062
|
-
...control.accept ? { accept: [...control.accept] } : {},
|
|
3063
|
-
...control.multiple ? { multiple: true } : {},
|
|
3064
|
-
...control.options ? { options: toOptions(control.options) } : {}
|
|
3065
|
-
}
|
|
3066
|
-
};
|
|
3067
|
-
}
|
|
3068
|
-
/**
|
|
3069
|
-
* 五类 `InteractionRequest` → catalog 声明树。
|
|
3070
|
-
*
|
|
3071
|
-
* 路径 A(runtime 发起的交互请求)与路径 B(模型产出的 surface)的差别只在「谁产出这棵树」;
|
|
3072
|
-
* 产出之后四档走的是同一条 spec 渲染路径,因此这份生成器全仓只写一份。
|
|
3073
|
-
*/
|
|
3074
|
-
function interactionToUiSpec(request, labels = {}) {
|
|
3075
|
-
const model = interactionToFormModel(request);
|
|
3076
|
-
const title = labels.title ?? model.title;
|
|
3077
|
-
const cancelLabel = labels.cancelLabel ?? model.cancelLabel;
|
|
3078
|
-
const form = {
|
|
3079
|
-
component: "Form",
|
|
3080
|
-
props: {
|
|
3081
|
-
...title ? { title } : {},
|
|
3082
|
-
submitLabel: labels.submitLabel ?? model.submitLabel,
|
|
3083
|
-
...cancelLabel ? { cancelLabel } : {}
|
|
3084
|
-
},
|
|
3085
|
-
children: model.controls.map(toField)
|
|
3086
|
-
};
|
|
3087
|
-
if (model.controls.length > 0 || !model.message) return form;
|
|
3088
|
-
return {
|
|
3089
|
-
component: "Stack",
|
|
3090
|
-
children: [{
|
|
3091
|
-
component: "Text",
|
|
3092
|
-
props: { text: model.message }
|
|
3093
|
-
}, form]
|
|
3094
|
-
};
|
|
3095
|
-
}
|
|
3096
|
-
const RENDER_UI_TOOL = "render_ui";
|
|
3097
|
-
const DESCRIBE_UI_PRESET_TOOL = "describe_ui_preset";
|
|
3098
|
-
const defaultId = () => `ui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3099
|
-
/**
|
|
3100
|
-
* 工具描述只留一句指针:catalog 本体走 system 消息(C4)。
|
|
3101
|
-
* 工具 `description` 的长度上限与截断在各 provider 上不一致且是静默的,
|
|
3102
|
-
* 近 5000 字符的 catalog 放在那里,被截断时模型拿到的是半份规格而请求照样成功。
|
|
3103
|
-
*/
|
|
3104
|
-
const RENDER_UI_DESCRIPTION = [
|
|
3105
|
-
"Render a UI for the answer instead of prose.",
|
|
3106
|
-
"Use it for forms, tables, charts, metrics or file downloads.",
|
|
3107
|
-
"The component catalog is in the system prompt."
|
|
3108
|
-
].join(" ");
|
|
3109
|
-
const INTENTS = /* @__PURE__ */ new Set([
|
|
3110
|
-
"submit",
|
|
3111
|
-
"cancel",
|
|
3112
|
-
"select",
|
|
3113
|
-
"download",
|
|
3114
|
-
"refresh"
|
|
3115
|
-
]);
|
|
3116
|
-
/** 去重靠指纹兜底,但重复调用本身也浪费一轮,所以先在提示词里劝阻(FR-21.5) */
|
|
3117
|
-
const NO_DUPLICATE_RENDER = "If a skill has already rendered a surface for this request, do not call render_ui again with the same content.";
|
|
3118
|
-
function isIntent(value) {
|
|
3119
|
-
return typeof value === "string" && INTENTS.has(value);
|
|
3120
|
-
}
|
|
3121
|
-
/**
|
|
3122
|
-
* 从节点树抽出 action 能力表。能力表走事件外壳而非树内:
|
|
3123
|
-
* 树是模型可写的,nonce 放进去就等于让模型自己签发能力。
|
|
3124
|
-
* action id 取节点 id;Button 未声明 id 时退回 intent 名(同 intent 只留一条)。
|
|
3125
|
-
* 表单的提交/取消取自作用域表:容器级放宽后一张 surface 可能有多个表单。
|
|
3126
|
-
*/
|
|
3127
|
-
function collectActions(node, out, scopes) {
|
|
3128
|
-
if (node.component === "Button" && isIntent(node.props?.["action"])) {
|
|
3129
|
-
const intent = node.props["action"];
|
|
3130
|
-
const id = node.id ?? intent;
|
|
3131
|
-
if (!out.has(id)) out.set(id, {
|
|
3132
|
-
id,
|
|
3133
|
-
intent,
|
|
3134
|
-
...intent === "submit" ? { awaitResponse: true } : {}
|
|
3135
|
-
});
|
|
3136
|
-
}
|
|
3137
|
-
const scope = scopes.get(node);
|
|
3138
|
-
if (scope) {
|
|
3139
|
-
if (!out.has(scope.submitActionId)) out.set(scope.submitActionId, {
|
|
3140
|
-
id: scope.submitActionId,
|
|
3141
|
-
intent: "submit",
|
|
3142
|
-
awaitResponse: true
|
|
3143
|
-
});
|
|
3144
|
-
if (typeof node.props?.["cancelLabel"] === "string" && !out.has(scope.cancelActionId)) out.set(scope.cancelActionId, {
|
|
3145
|
-
id: scope.cancelActionId,
|
|
3146
|
-
intent: "cancel"
|
|
3147
|
-
});
|
|
3148
|
-
}
|
|
3149
|
-
for (const child of node.children ?? []) collectActions(child, out, scopes);
|
|
3150
|
-
}
|
|
3151
|
-
/** 节点树声明的 action 能力表(renderer 据此生成按钮,runtime 据此签发 nonce)。@experimental */
|
|
3152
|
-
function collectSpecActions(spec) {
|
|
3153
|
-
const actions = /* @__PURE__ */ new Map();
|
|
3154
|
-
collectActions(spec, actions, new Map(collectFormScopes(spec).map((scope) => [scope.form, scope])));
|
|
3155
|
-
return [...actions.values()];
|
|
3156
|
-
}
|
|
3157
|
-
/**
|
|
3158
|
-
* 把 catalog 变成一个工具的入参 Schema,让模型直接生成声明式 UI(21 号文档 §3):
|
|
3159
|
-
* agentLoop / UiBridge / extractUiSpecEvents 全部零改动,装配处只多一个 externalTools 条目。
|
|
3160
|
-
*/
|
|
3161
|
-
function createUiCatalogToolSource(options = {}) {
|
|
3162
|
-
const catalog = options.catalog ?? uiCatalog;
|
|
3163
|
-
const newSurfaceId = options.newSurfaceId ?? defaultId;
|
|
3164
|
-
const presets = options.presets ? options.presets.flatMap((name) => uiPreset(name) ?? []) : UI_PRESETS;
|
|
3165
|
-
const presetChoices = presets.map((preset) => `${preset.name} — ${preset.summary}`).join("\n");
|
|
3166
|
-
const presetTool = () => ({
|
|
3167
|
-
name: DESCRIBE_UI_PRESET_TOOL,
|
|
3168
|
-
description: `Fetch the layout guidance for one scenario preset before calling ${RENDER_UI_TOOL}.\n${presetChoices}`,
|
|
3169
|
-
inputSchema: {
|
|
3170
|
-
type: "object",
|
|
3171
|
-
properties: { preset: {
|
|
3172
|
-
type: "string",
|
|
3173
|
-
enum: presets.map((preset) => preset.name)
|
|
3174
|
-
} },
|
|
3175
|
-
required: ["preset"],
|
|
3176
|
-
additionalProperties: false
|
|
3177
|
-
}
|
|
3178
|
-
});
|
|
3179
|
-
const renderTool = () => ({
|
|
3180
|
-
name: RENDER_UI_TOOL,
|
|
3181
|
-
description: RENDER_UI_DESCRIPTION,
|
|
3182
|
-
inputSchema: {
|
|
3183
|
-
type: "object",
|
|
3184
|
-
properties: {
|
|
3185
|
-
spec: catalog.toJsonSchema(),
|
|
3186
|
-
...presets.length > 0 ? { preset: {
|
|
3187
|
-
type: "string",
|
|
3188
|
-
enum: presets.map((preset) => preset.name),
|
|
3189
|
-
description: `Scenario preset this surface follows; call ${DESCRIBE_UI_PRESET_TOOL} first for its layout guidance.`
|
|
3190
|
-
} } : {}
|
|
3191
|
-
},
|
|
3192
|
-
required: ["spec"],
|
|
3193
|
-
additionalProperties: false
|
|
3194
|
-
}
|
|
3195
|
-
});
|
|
3196
|
-
return {
|
|
3197
|
-
kind: "ui-catalog",
|
|
3198
|
-
systemPrompt: () => Promise.resolve(`${catalog.toPrompt()}\n\n${NO_DUPLICATE_RENDER}`),
|
|
3199
|
-
listToolSpecs: () => Promise.resolve(presets.length > 0 ? [renderTool(), presetTool()] : [renderTool()]),
|
|
3200
|
-
canHandle: (name) => name === "render_ui" || presets.length > 0 && name === "describe_ui_preset",
|
|
3201
|
-
argCaptureTrust: (name) => name === "render_ui" || name === "describe_ui_preset" ? { tier: "reviewed" } : void 0,
|
|
3202
|
-
call: (name, args) => {
|
|
3203
|
-
if (name === "describe_ui_preset") {
|
|
3204
|
-
const requested = args["preset"];
|
|
3205
|
-
const preset = presets.find((candidate) => candidate.name === requested);
|
|
3206
|
-
if (!preset) return Promise.resolve({
|
|
3207
|
-
ok: false,
|
|
3208
|
-
content: [],
|
|
3209
|
-
error: {
|
|
3210
|
-
code: "VALIDATION_FAILED",
|
|
3211
|
-
message: `Unknown UI preset "${String(requested)}"; available presets: ${presets.map((candidate) => candidate.name).join(", ")}`
|
|
3212
|
-
}
|
|
3213
|
-
});
|
|
3214
|
-
return Promise.resolve({
|
|
3215
|
-
ok: true,
|
|
3216
|
-
content: [{
|
|
3217
|
-
type: "text",
|
|
3218
|
-
text: `${preset.guidance}\n\nExample spec:\n${JSON.stringify(preset.example)}`
|
|
3219
|
-
}]
|
|
3220
|
-
});
|
|
3221
|
-
}
|
|
3222
|
-
const spec = args["spec"];
|
|
3223
|
-
const result = catalog.validate(spec);
|
|
3224
|
-
if (!result.ok) return Promise.resolve({
|
|
3225
|
-
ok: false,
|
|
3226
|
-
content: [],
|
|
3227
|
-
error: {
|
|
3228
|
-
code: "UI_SPEC_INVALID",
|
|
3229
|
-
message: `The UI spec does not match the catalog: ${result.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`
|
|
3230
|
-
}
|
|
3231
|
-
});
|
|
3232
|
-
const node = spec;
|
|
3233
|
-
const surfaceId = newSurfaceId();
|
|
3234
|
-
const actions = collectSpecActions(node);
|
|
3235
|
-
return Promise.resolve({
|
|
3236
|
-
ok: true,
|
|
3237
|
-
content: [{
|
|
3238
|
-
type: "json",
|
|
3239
|
-
data: { $surface: {
|
|
3240
|
-
type: "open",
|
|
3241
|
-
id: surfaceId,
|
|
3242
|
-
node,
|
|
3243
|
-
...actions.length > 0 ? { actions } : {}
|
|
3244
|
-
} }
|
|
3245
|
-
}, {
|
|
3246
|
-
type: "text",
|
|
3247
|
-
text: `Rendered surface ${surfaceId}.`
|
|
3248
|
-
}]
|
|
3249
|
-
});
|
|
3250
|
-
}
|
|
3251
|
-
};
|
|
3252
|
-
}
|
|
3253
|
-
const VERCEL_INTERACTION_TOOL_NAME = "webskill_interaction";
|
|
3254
|
-
/** InteractionRequest → SDK tool UI 结构(应用聊天 UI 直接消费) */
|
|
3255
|
-
function toVercelToolInvocation(request) {
|
|
3256
|
-
return {
|
|
3257
|
-
type: "tool-call",
|
|
3258
|
-
toolCallId: request.id,
|
|
3259
|
-
toolName: VERCEL_INTERACTION_TOOL_NAME,
|
|
3260
|
-
input: request
|
|
3261
|
-
};
|
|
3262
|
-
}
|
|
3263
|
-
/** SDK 回传(tool result / output)→ InteractionResponse */
|
|
3264
|
-
function fromVercelToolResult(part) {
|
|
3265
|
-
const p = part ?? {};
|
|
3266
|
-
const id = String(p["toolCallId"] ?? p["id"] ?? "");
|
|
3267
|
-
if (p["cancelled"] === true) return {
|
|
3268
|
-
id,
|
|
3269
|
-
cancelled: true
|
|
3270
|
-
};
|
|
3271
|
-
return {
|
|
3272
|
-
id,
|
|
3273
|
-
value: p["output"] ?? p["result"] ?? p["value"]
|
|
3274
|
-
};
|
|
3275
|
-
}
|
|
3276
|
-
/**
|
|
3277
|
-
* 薄桥接类:request → SDK tool UI 结构 → 应用的聊天 UI(sendInteraction 回调实现)
|
|
3278
|
-
* → SDK 回传 → InteractionResponse。
|
|
3279
|
-
*/
|
|
3280
|
-
var VercelUiBridge = class {
|
|
3281
|
-
#sendInteraction;
|
|
3282
|
-
constructor(deps) {
|
|
3283
|
-
this.#sendInteraction = deps.sendInteraction;
|
|
3284
|
-
}
|
|
3285
|
-
async request(input) {
|
|
3286
|
-
return fromVercelToolResult(await this.#sendInteraction(toVercelToolInvocation(input)));
|
|
3287
|
-
}
|
|
3288
|
-
};
|
|
3289
|
-
const literal = (value) => JSON.stringify(value ?? null);
|
|
3290
|
-
/** 组件的位置参数顺序 = catalog props 的 schema key 顺序(OpenUI 的 param map 同源) */
|
|
3291
|
-
function propOrder(catalog) {
|
|
3292
|
-
const branches = catalog.toJsonSchema()["oneOf"] ?? [];
|
|
3293
|
-
const order = /* @__PURE__ */ new Map();
|
|
3294
|
-
for (const branch of branches) {
|
|
3295
|
-
const name = (branch.properties?.["component"])?.["const"];
|
|
3296
|
-
const props = branch.properties?.["props"];
|
|
3297
|
-
if (typeof name === "string") order.set(name, Object.keys(props?.properties ?? {}));
|
|
3298
|
-
}
|
|
3299
|
-
return order;
|
|
3300
|
-
}
|
|
3301
|
-
/**
|
|
3302
|
-
* catalog 声明树 → openui-lang 文本。语法为每行一条 `id = Component(位置参数…)`,
|
|
3303
|
-
* `root = …` 为入口;容器组件的子节点作为最后一个参数传 id 数组。
|
|
3304
|
-
*/
|
|
3305
|
-
function toOpenUiSpecLang(spec, catalog = uiCatalog) {
|
|
3306
|
-
const order = propOrder(catalog);
|
|
3307
|
-
const lines = [];
|
|
3308
|
-
let counter = 0;
|
|
3309
|
-
const walk = (node) => {
|
|
3310
|
-
const id = node.id ?? `n${counter++}`;
|
|
3311
|
-
const childIds = (node.children ?? []).map(walk);
|
|
3312
|
-
const def = catalog.component(node.component);
|
|
3313
|
-
const args = (order.get(node.component) ?? []).map((key) => literal(node.props?.[key]));
|
|
3314
|
-
if (def?.children !== void 0) args.push(`[${childIds.join(", ")}]`);
|
|
3315
|
-
lines.push(`${id} = ${node.component}(${args.join(", ")})`);
|
|
3316
|
-
return id;
|
|
3317
|
-
};
|
|
3318
|
-
const rootId = walk(spec);
|
|
3319
|
-
return [...lines.filter((line) => !line.startsWith(`${rootId} =`)), `root = ${lines.find((line) => line.startsWith(`${rootId} =`)).slice(rootId.length + 3)}`].join("\n");
|
|
3320
|
-
}
|
|
3321
|
-
const WEBSKILL_SURFACE_ACTION = "webskill:surface-action";
|
|
3322
|
-
function toUiSurfaceActionDispatch(snapshot, action, value, scopeId) {
|
|
3323
|
-
return {
|
|
3324
|
-
...snapshot.runId ? { runId: snapshot.runId } : {},
|
|
3325
|
-
surfaceId: snapshot.id,
|
|
3326
|
-
actionId: action.id,
|
|
3327
|
-
intent: action.intent,
|
|
3328
|
-
...action.nonce ? { nonce: action.nonce } : {},
|
|
3329
|
-
...scopeId ? { scopeId } : {},
|
|
3330
|
-
...value ? { value } : {}
|
|
3331
|
-
};
|
|
3332
|
-
}
|
|
3333
|
-
/** Returns undefined for malformed or non-WebSkill renderer events. @experimental */
|
|
3334
|
-
function fromUiSurfaceActionDispatch(value) {
|
|
3335
|
-
if (typeof value !== "object" || value === null) return void 0;
|
|
3336
|
-
const event = value;
|
|
3337
|
-
if (typeof event["surfaceId"] !== "string" || typeof event["actionId"] !== "string" || !isSurfaceActionIntent(event["intent"])) return;
|
|
3338
|
-
return {
|
|
3339
|
-
...typeof event["runId"] === "string" ? { runId: event["runId"] } : {},
|
|
3340
|
-
surfaceId: event["surfaceId"],
|
|
3341
|
-
actionId: event["actionId"],
|
|
3342
|
-
intent: event["intent"],
|
|
3343
|
-
...typeof event["nonce"] === "string" ? { nonce: event["nonce"] } : {},
|
|
3344
|
-
...typeof event["scopeId"] === "string" ? { scopeId: event["scopeId"] } : {},
|
|
3345
|
-
...isRecord(event["value"]) ? { value: event["value"] } : {},
|
|
3346
|
-
...event["cancelled"] === true ? { cancelled: true } : {}
|
|
3347
|
-
};
|
|
3348
|
-
}
|
|
3349
|
-
function isSurfaceActionIntent(value) {
|
|
3350
|
-
return value === "submit" || value === "cancel" || value === "select" || value === "download" || value === "refresh";
|
|
3351
|
-
}
|
|
3352
|
-
function isRecord(value) {
|
|
3353
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3354
|
-
}
|
|
3355
|
-
/**
|
|
3356
|
-
* A2UI 协议常量与 surface 动作编解码(v0.9.1)。
|
|
3357
|
-
* 依据 https://a2ui.org/specification/v0.9-a2ui/:
|
|
3358
|
-
* - 信封 {version, createSurface|updateComponents|updateDataModel|deleteSurface}
|
|
3359
|
-
* - 组件为扁平邻接表(id 引用),root 必需
|
|
3360
|
-
* - 输入组件与数据模型经 JSON Pointer 双向绑定;action.context 回传
|
|
3361
|
-
*
|
|
3362
|
-
* 声明树 → A2UI 消息的转换在 `specToA2ui.ts`(四档共用的单一渲染路径)。
|
|
3363
|
-
*/
|
|
3364
|
-
const A2UI_VERSION = "v0.9.1";
|
|
3365
|
-
const A2UI_BASIC_CATALOG_ID = "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json";
|
|
3366
|
-
const A2UI_SURFACE_ACTION = WEBSKILL_SURFACE_ACTION;
|
|
3367
|
-
/** Decodes a nonce-bound action emitted by a WebSkill A2UI extension host. @experimental */
|
|
3368
|
-
function fromA2uiSurfaceAction(event) {
|
|
3369
|
-
const action = event;
|
|
3370
|
-
if (action?.name !== "webskill:surface-action") return void 0;
|
|
3371
|
-
return fromUiSurfaceActionDispatch(action.context);
|
|
3372
|
-
}
|
|
3373
|
-
/** Builds the A2UI event payload an extension host sends after a user action. @experimental */
|
|
3374
|
-
function toA2uiSurfaceAction(snapshot, action, value, scopeId) {
|
|
3375
|
-
return {
|
|
3376
|
-
name: A2UI_SURFACE_ACTION,
|
|
3377
|
-
context: toUiSurfaceActionDispatch(snapshot, action, value, scopeId)
|
|
3378
|
-
};
|
|
3379
|
-
}
|
|
3380
|
-
/** 声明树里按钮触发的事件名(宿主据 context.actionId 回传 surface action) */
|
|
3381
|
-
const A2UI_SPEC_ACTION = "webskill:spec-action";
|
|
3382
|
-
/** 表单值在 A2UI 数据模型里的根路径 */
|
|
3383
|
-
const A2UI_SPEC_FORM_PATH = "/form";
|
|
3384
|
-
/**
|
|
3385
|
-
* catalog 声明树 → A2UI 消息序列。
|
|
3386
|
-
*
|
|
3387
|
-
* 协议要求组件是扁平邻接表且根节点 id 为 `root`,因此这里做树 → 邻接表的展开。
|
|
3388
|
-
* 三处改写全部由 `a2uiComponentShapes` 决定,本函数不认识任何具体组件名:
|
|
3389
|
-
* - 子节点变成 props.children 里的 id 引用
|
|
3390
|
-
* - 表单控件的值经 JSON Pointer 与数据模型双向绑定
|
|
3391
|
-
* - 自带提交/取消标签的组件(A2UI 没有表单提交事件)把按钮物化成 Button 子节点,
|
|
3392
|
-
* 与 `toA2uiMessages` 对交互请求的处理同一手法
|
|
3393
|
-
*/
|
|
3394
|
-
function toA2uiSpecMessages(catalog, spec, options) {
|
|
3395
|
-
const shapes = new Map(a2uiComponentShapes(catalog).map((shape) => [shape.name, shape]));
|
|
3396
|
-
const components = [];
|
|
3397
|
-
const defaults = {};
|
|
3398
|
-
let counter = 0;
|
|
3399
|
-
const event = (actionId) => ({ event: {
|
|
3400
|
-
name: A2UI_SPEC_ACTION,
|
|
3401
|
-
context: {
|
|
3402
|
-
...options.context,
|
|
3403
|
-
actionId
|
|
3404
|
-
}
|
|
3405
|
-
} });
|
|
3406
|
-
const button = (id, text, actionId) => {
|
|
3407
|
-
components.push({
|
|
3408
|
-
id,
|
|
3409
|
-
component: "Button",
|
|
3410
|
-
label: text,
|
|
3411
|
-
action: event(actionId)
|
|
3412
|
-
});
|
|
3413
|
-
return id;
|
|
3414
|
-
};
|
|
3415
|
-
const walk = (node, id) => {
|
|
3416
|
-
const props = { ...node.props };
|
|
3417
|
-
const shape = shapes.get(node.component);
|
|
3418
|
-
const children = (node.children ?? []).map((child) => walk(child, child.id ?? `n${counter++}`));
|
|
3419
|
-
for (const key of shape?.valueProps ?? []) {
|
|
3420
|
-
const name = typeof props["name"] === "string" ? props["name"] : id;
|
|
3421
|
-
if (props["defaultValue"] !== void 0) defaults[name] = props["defaultValue"];
|
|
3422
|
-
props[key] = { path: `${A2UI_SPEC_FORM_PATH}/${name}` };
|
|
3423
|
-
}
|
|
3424
|
-
for (const key of shape?.actionProps ?? []) props[key] = event(typeof props[key] === "string" ? props[key] : "select");
|
|
3425
|
-
if (shape?.submitLabelProp) {
|
|
3426
|
-
const text = props[shape.submitLabelProp];
|
|
3427
|
-
children.push(button(`${id}-submit`, typeof text === "string" ? text : "Submit", "submit"));
|
|
3428
|
-
}
|
|
3429
|
-
if (shape?.cancelLabelProp && typeof props[shape.cancelLabelProp] === "string") children.push(button(`${id}-cancel`, props[shape.cancelLabelProp], "cancel"));
|
|
3430
|
-
if (children.length > 0) props[A2UI_CHILDREN_PROP] = children;
|
|
3431
|
-
components.push({
|
|
3432
|
-
...props,
|
|
3433
|
-
id,
|
|
3434
|
-
component: node.component
|
|
3435
|
-
});
|
|
3436
|
-
return id;
|
|
3437
|
-
};
|
|
3438
|
-
walk(spec, "root");
|
|
3439
|
-
const messages = [{
|
|
3440
|
-
version: A2UI_VERSION,
|
|
3441
|
-
createSurface: {
|
|
3442
|
-
surfaceId: options.surfaceId,
|
|
3443
|
-
catalogId: options.catalogId ?? "https://webskill.dev/a2ui/catalogs/v1/catalog.json",
|
|
3444
|
-
sendDataModel: true
|
|
3445
|
-
}
|
|
3446
|
-
}, {
|
|
3447
|
-
version: A2UI_VERSION,
|
|
3448
|
-
updateComponents: {
|
|
3449
|
-
surfaceId: options.surfaceId,
|
|
3450
|
-
components
|
|
3451
|
-
}
|
|
3452
|
-
}];
|
|
3453
|
-
if (Object.keys(defaults).length > 0) messages.push({
|
|
3454
|
-
version: A2UI_VERSION,
|
|
3455
|
-
updateDataModel: {
|
|
3456
|
-
surfaceId: options.surfaceId,
|
|
3457
|
-
path: A2UI_SPEC_FORM_PATH,
|
|
3458
|
-
value: defaults
|
|
3459
|
-
}
|
|
3460
|
-
});
|
|
3461
|
-
return messages;
|
|
3462
|
-
}
|
|
3463
|
-
/** A2UI action 事件 → 声明树 action(非本 catalog 的事件返回 undefined) */
|
|
3464
|
-
function fromA2uiSpecAction(event) {
|
|
3465
|
-
const action = event;
|
|
3466
|
-
if (action?.name !== "webskill:spec-action") return void 0;
|
|
3467
|
-
const context = action.context ?? {};
|
|
3468
|
-
const actionId = context["actionId"];
|
|
3469
|
-
if (typeof actionId !== "string") return void 0;
|
|
3470
|
-
return {
|
|
3471
|
-
actionId,
|
|
3472
|
-
context
|
|
3473
|
-
};
|
|
3474
|
-
}
|
|
3475
|
-
/**
|
|
3476
|
-
* BYOC Lit 元素的加载入口。
|
|
3477
|
-
*
|
|
3478
|
-
* 元素模块静态依赖 `lit` 与 `@a2ui/*`(三者都是 optional peer),
|
|
3479
|
-
* 因此只能经动态 import 进来:未用 A2UI 档的应用不为这段体积买单,
|
|
3480
|
-
* 缺依赖时给出带安装提示的 `UI_UNAVAILABLE`,而不是一个空白 surface。
|
|
3481
|
-
*/
|
|
3482
|
-
async function loadWebSkillLitCatalog() {
|
|
3483
|
-
try {
|
|
3484
|
-
const { webskillLitCatalog } = await import("./webskillLitCatalog-D_zCqeQF-swXsWoAe.js");
|
|
3485
|
-
return webskillLitCatalog();
|
|
3486
|
-
} catch (cause) {
|
|
3487
|
-
throw new WebSkillError("UI_UNAVAILABLE", "The WebSkill A2UI catalog is unavailable; install @a2ui/lit, @a2ui/web_core and lit to render catalog surfaces with A2UI", cause);
|
|
3488
|
-
}
|
|
3489
|
-
}
|
|
3490
|
-
/**
|
|
3491
|
-
* Loads the optional OpenUI peers (@openuidev/react-lang and zod) for the OpenUI
|
|
3492
|
-
* interaction/surface paths. Throws a WebSkillError with code UI_UNAVAILABLE and an
|
|
3493
|
-
* install hint when either peer is missing, so callers can fall back to native
|
|
3494
|
-
* rendering instead of leaving a blank surface.
|
|
3495
|
-
*/
|
|
3496
|
-
async function loadOpenUiPeers() {
|
|
3497
|
-
try {
|
|
3498
|
-
const [openuiModule, zodModule] = await Promise.all([import("./dist-DxyxmXPU-DGC-dpXf.js"), import("zod")]);
|
|
3499
|
-
return {
|
|
3500
|
-
openui: openuiModule,
|
|
3501
|
-
z: zodModule.z
|
|
3502
|
-
};
|
|
3503
|
-
} catch (cause) {
|
|
3504
|
-
throw new WebSkillError("UI_UNAVAILABLE", "The OpenUI renderer is unavailable; install @openuidev/react-lang and zod to render surfaces and interactions with the OpenUI track", cause);
|
|
3505
|
-
}
|
|
3506
|
-
}
|
|
3507
|
-
|
|
3508
|
-
//#endregion
|
|
3509
|
-
export { uiPreset as $, ensureStyles as A, mountViewerComponents as B, applySuggestion as C, resolveSurfaceFormTexts as Ct, collectSpecActions as D, uiCatalog as Dt, collectScopedValues as E, toEchartsOption as Et, fromVercelToolResult as F, renderRenderResult as G, qualifyFieldName as H, gaugePercent as I, toA2uiSurfaceAction as J, resolveColumnWidths as K, interactionToUiSpec as L, fromA2uiSpecAction as M, fromA2uiSurfaceAction as N, collectValues as O, fromUiSurfaceActionDispatch as P, toVercelToolInvocation as Q, loadOpenUiPeers as R, WebFormBridge as S, resolveInteractionTexts as St, collectFormScopes as T, shapeInteractionValue as Tt, renderBlocks as U, normalizeColumnWidths as V, renderMiniChart as W, toOpenUiSpecLang as X, toJsonRenderSpec as Y, toUiSurfaceActionDispatch as Z, VIEWER_FALLBACK_ATTR as _, defineUiCatalog as _t, A2UI_VERSION as a, DEFAULT_CHART_FONT_SIZES as at, WEBSKILL_STYLES_CSS as b, renderMiniMarkdown as bt, DOCUMENT_COMPONENTS as c, DEFAULT_SURFACE_HOST_CONTROL_TEXTS as ct, SPEC_TABLE_MIN_COLUMN_VAR as d, UI_CATALOG_PROMPT_BUDGET_BYTES as dt, A2UI_CHILDREN_PROP as et, SPEC_TABLE_MIN_COLUMN_WIDTH as f, WEBSKILL_A2UI_CATALOG_ID as ft, VIEWER_COMPONENT_ATTR as g, chartSpecFromProps as gt, VERCEL_INTERACTION_TOOL_NAME as h, buildA2uiCatalogDefinition as ht, A2UI_SURFACE_ACTION as i, CATALOG_SCHEMA_MAX as it, evaluateFieldCondition as j, createUiCatalogToolSource as k, MAX_CONDITION_DEPTH as l, PLANNED_INCREMENT as lt, UI_PRESET_NAMES as m, a2uiComponentShapes as mt, A2UI_SPEC_ACTION as n, CATALOG_BUDGET_STAGE as nt, CHART_PALETTE as o, DEFAULT_INTERACTION_TEXTS as ot, UI_PRESETS as p, a2uiComponentSchema as pt, toA2uiSpecMessages as q, A2UI_SPEC_FORM_PATH as r, CATALOG_PROMPT_MAX as rt, DESCRIBE_UI_PRESET_TOOL as s, DEFAULT_SURFACE_FORM_TEXTS as st, A2UI_BASIC_CATALOG_ID as t, A2UI_COMMON_TYPES as tt, RENDER_UI_TOOL as u, UI_CATALOG_GROUPS as ut, VIEWER_PROPS_ATTR as v, interactionToFormModel as vt, chartToTable as w, resolveSurfaceHostControlTexts as wt, WEBSKILL_SURFACE_ACTION as x, resolveChartFontSizes as xt, VercelUiBridge as y, mountEchart as yt, loadWebSkillLitCatalog as z };
|