@changfenhuang/dsh-genui 0.9.6 → 0.9.8
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/CHANGELOG.md +15 -0
- package/README.md +7 -18
- package/README.zh-CN.md +7 -18
- package/SKILL.md +4 -2
- package/lib/assets/three.js +1 -1
- package/lib/client.js +3 -3
- package/lib/index.js +273 -61
- package/lib/types/client/GenuiBlock.d.ts +5 -2
- package/lib/types/client/blocks/advanced.d.ts +1 -1
- package/lib/types/client/blocks/image.d.ts +6 -0
- package/lib/types/client/blocks/state.d.ts +2 -0
- package/lib/types/client/fence-render.d.ts +3 -0
- package/lib/types/client/gallery.d.ts +3 -5
- package/lib/types/client/guard.d.ts +10 -5
- package/lib/types/client/parse-partial.d.ts +29 -3
- package/lib/types/client/spec.d.ts +8 -1
- package/lib/types/plugin/chart-contract.d.ts +7 -0
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +18 -17
- package/src/client/GenuiBlock.module.css +1 -3
- package/src/client/GenuiBlock.tsx +37 -24
- package/src/client/PlotBlock.tsx +25 -10
- package/src/client/blocks/advanced.tsx +34 -25
- package/src/client/blocks/charts.tsx +4 -4
- package/src/client/blocks/image.tsx +25 -0
- package/src/client/blocks/render-node.tsx +3 -0
- package/src/client/blocks/state.ts +2 -0
- package/src/client/dom-fence.tsx +12 -5
- package/src/client/fence-render.tsx +35 -11
- package/src/client/gallery.ts +4 -5
- package/src/client/guard.ts +125 -29
- package/src/client/panel-command.ts +14 -5
- package/src/client/parse-partial.ts +19 -25
- package/src/client/safe-math.ts +29 -1
- package/src/client/scene3d-core.ts +5 -2
- package/src/client/spec.ts +9 -1
- package/src/plugin/chart-contract.ts +103 -0
- package/src/plugin/index.ts +48 -2
- package/src/plugin/tool.ts +25 -4
|
@@ -24,6 +24,7 @@ import { parsePartialGenuiSpec } from './parse-partial.ts'
|
|
|
24
24
|
import { applyPanelOperation, diagnosePanelBudget, type PanelOperationStatus } from './panel-store.ts'
|
|
25
25
|
import type { GenuiSpec } from './spec.ts'
|
|
26
26
|
import { completeFenceJson, describeJsonFailure, isCompleteJson, repairFenceJson } from '../shared/fence-repair.ts'
|
|
27
|
+
import { validateRenderableChartSemantics } from '../plugin/chart-contract.ts'
|
|
27
28
|
|
|
28
29
|
/** Settled fence source identity (data shape, host-independent). */
|
|
29
30
|
export interface GenuiFenceSource {
|
|
@@ -53,6 +54,14 @@ const FENCE_ERROR_STYLE: CSSProperties = {
|
|
|
53
54
|
whiteSpace: 'pre-wrap',
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** Return a chart-semantic diagnostic for parseable raw fence content. */
|
|
58
|
+
function chartSemanticFailure(raw: string): string | null {
|
|
59
|
+
const parsed = parsePartialGenuiSpec(raw)
|
|
60
|
+
if (parsed === null) return null
|
|
61
|
+
const errors = validateRenderableChartSemantics(parsed)
|
|
62
|
+
return errors.length === 0 ? null : errors.join(';')
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
/**
|
|
57
66
|
* Fallback for a ```dsh-ui fence whose body has no finished component yet.
|
|
58
67
|
* Two very different situations land here and they must not be conflated:
|
|
@@ -64,11 +73,11 @@ const FENCE_ERROR_STYLE: CSSProperties = {
|
|
|
64
73
|
* correct rendering (partial JSON must never look like an error).
|
|
65
74
|
*
|
|
66
75
|
* 2. **Settled defect** — the message is finished but the body still does
|
|
67
|
-
* not parse as JSON (a malformed fence like a missing `}`)
|
|
68
|
-
* fail silently: the fence degraded to
|
|
69
|
-
* author had no way to know the UI never
|
|
70
|
-
* marker is gone, surface a compact diagnostic
|
|
71
|
-
* the defect is visible instead of silent.
|
|
76
|
+
* not parse as JSON (a malformed fence like a missing `}`) or violates the
|
|
77
|
+
* native chart contract. This used to fail silently: the fence degraded to
|
|
78
|
+
* a code block with no hint, and the author had no way to know the UI never
|
|
79
|
+
* rendered. Once the streaming marker is gone, surface a compact diagnostic
|
|
80
|
+
* so the defect is visible instead of silent.
|
|
72
81
|
*/
|
|
73
82
|
function FenceFallback({ raw, fenceKey }: { raw: string; fenceKey: Key }) {
|
|
74
83
|
const ref = useRef<HTMLDivElement | null>(null)
|
|
@@ -77,12 +86,18 @@ function FenceFallback({ raw, fenceKey }: { raw: string; fenceKey: Key }) {
|
|
|
77
86
|
const node = ref.current
|
|
78
87
|
if (node !== null && node.closest('[data-streaming]') === null) setSettled(true)
|
|
79
88
|
})
|
|
80
|
-
const
|
|
89
|
+
const chartDiagnostic = settled ? chartSemanticFailure(raw) : null
|
|
90
|
+
const parseDiagnostic = settled && chartDiagnostic === null && raw.trim() !== '' ? describeJsonFailure(raw) : null
|
|
81
91
|
return (
|
|
82
92
|
<div ref={ref}>
|
|
83
|
-
{
|
|
93
|
+
{chartDiagnostic !== null && (
|
|
94
|
+
<div style={FENCE_ERROR_STYLE} role="alert">
|
|
95
|
+
⚠️ dsh-ui chart 字段验证失败:{chartDiagnostic} —— 围栏保持为代码块;请修正 chart 字段后重发。
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{chartDiagnostic === null && parseDiagnostic !== null && (
|
|
84
99
|
<div style={FENCE_ERROR_STYLE} role="alert">
|
|
85
|
-
⚠️ dsh-ui fence JSON 解析失败{
|
|
100
|
+
⚠️ dsh-ui fence JSON 解析失败{parseDiagnostic} —— 围栏保持为代码块;请让模型检查并修复 JSON 后重发。
|
|
86
101
|
</div>
|
|
87
102
|
)}
|
|
88
103
|
<CodeBlock key={fenceKey} code={`${raw}\n`} lang="dsh-ui" />
|
|
@@ -115,6 +130,12 @@ function FencePanelPublisher({ sessionId, sourceId, order, spec }: {
|
|
|
115
130
|
return null
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
/** Repair one parsed value only when its native charts are actually renderable. */
|
|
134
|
+
function repairRenderableSpec(value: unknown): GenuiSpec | null {
|
|
135
|
+
if (validateRenderableChartSemantics(value).length > 0) return null
|
|
136
|
+
return repairGenuiSpec(value)
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
/**
|
|
119
140
|
* Resolve a raw fence body to a guarded spec.
|
|
120
141
|
*
|
|
@@ -124,21 +145,24 @@ function FencePanelPublisher({ sessionId, sourceId, order, spec }: {
|
|
|
124
145
|
* - Tier-2 completion (missing quotes/brackets): settled renders only —
|
|
125
146
|
* `context.source` exists exclusively once the message finished, so
|
|
126
147
|
* streaming halves are never completed early.
|
|
148
|
+
* - Native chart semantics are checked before repair on every candidate so
|
|
149
|
+
* aliases, empty collections, or line/donut series cannot repair into a
|
|
150
|
+
* default/blank chart.
|
|
127
151
|
*/
|
|
128
152
|
export function resolveGenuiSpec(raw: string, context?: GenuiFenceContext): GenuiSpec | null {
|
|
129
153
|
const parsed = parsePartialGenuiSpec(raw)
|
|
130
|
-
let spec = parsed === null ? null :
|
|
154
|
+
let spec = parsed === null ? null : repairRenderableSpec(parsed)
|
|
131
155
|
if (spec === null) {
|
|
132
156
|
const repaired = repairFenceJson(raw)
|
|
133
157
|
if (repaired !== null) {
|
|
134
158
|
const reparsed = parsePartialGenuiSpec(repaired.text)
|
|
135
|
-
spec = reparsed === null ? null :
|
|
159
|
+
spec = reparsed === null ? null : repairRenderableSpec(reparsed)
|
|
136
160
|
}
|
|
137
161
|
if (spec === null && context?.source !== undefined) {
|
|
138
162
|
const completed = completeFenceJson(raw)
|
|
139
163
|
if (completed !== null) {
|
|
140
164
|
const reparsed = parsePartialGenuiSpec(completed.text)
|
|
141
|
-
spec = reparsed === null ? null :
|
|
165
|
+
spec = reparsed === null ? null : repairRenderableSpec(reparsed)
|
|
142
166
|
}
|
|
143
167
|
}
|
|
144
168
|
}
|
package/src/client/gallery.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GenUI gallery:
|
|
3
|
-
*
|
|
4
|
-
* component family appears; docs and demos reuse it as the "show everything"
|
|
5
|
-
* fence).
|
|
2
|
+
* GenUI gallery: canonical broad-coverage sample of built-in node types.
|
|
3
|
+
* Docs, demos, and renderer tests reuse it as the component gallery fence.
|
|
6
4
|
*/
|
|
7
5
|
import type { GenuiSpec } from './spec.ts'
|
|
8
6
|
|
|
9
|
-
/**
|
|
7
|
+
/** Canonical gallery spec for the built-in GenUI component vocabulary. */
|
|
10
8
|
export const gallerySpec: GenuiSpec = {
|
|
11
9
|
title: 'GenUI · 组件画廊',
|
|
12
10
|
gap: 14,
|
|
@@ -27,6 +25,7 @@ export const gallerySpec: GenuiSpec = {
|
|
|
27
25
|
{ type: 'link', label: '详情链接' },
|
|
28
26
|
], wrap: true },
|
|
29
27
|
{ type: 'divider' },
|
|
28
|
+
{ type: 'image', src: '/demo-image.png', alt: '图片展示演示' },
|
|
30
29
|
{ type: 'audio', src: '/demo-audio.mp3', alt: '音频播放器演示' },
|
|
31
30
|
{ type: 'video', src: '/demo-video.mp4', alt: '视频播放器演示', poster: '/demo-video.jpg', aspectRatio: '16:9' },
|
|
32
31
|
{ type: 'grid', cols: 3, items: [
|
package/src/client/guard.ts
CHANGED
|
@@ -283,6 +283,14 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
283
283
|
if (label === undefined) return null
|
|
284
284
|
return { type: 'link', label, ...opt('href', safeHref(v.href)) }
|
|
285
285
|
}
|
|
286
|
+
case 'image': {
|
|
287
|
+
const src = safeMediaSrc(v.src)
|
|
288
|
+
if (src === undefined) return null
|
|
289
|
+
return {
|
|
290
|
+
type: 'image', src,
|
|
291
|
+
...opt('alt', str(v.alt, GENUI_LIMITS.maxString)),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
286
294
|
case 'audio': {
|
|
287
295
|
const src = safeMediaSrc(v.src)
|
|
288
296
|
if (src === undefined) return null
|
|
@@ -1179,61 +1187,96 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
|
|
|
1179
1187
|
* `"type"` strings (e.g. file-tree's `{type:'file'}` children). */
|
|
1180
1188
|
export const GENUI_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
1181
1189
|
'accordion', 'audio', 'avatar', 'badge', 'breadcrumb', 'button', 'callout', 'card', 'chart',
|
|
1182
|
-
'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'input', 'json',
|
|
1190
|
+
'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'image', 'input', 'json',
|
|
1183
1191
|
'keyvalue', 'link', 'list', 'mermaid', 'plot', 'progress', 'quiz', 'radio', 'row', 'scene3d',
|
|
1184
1192
|
'select', 'slider', 'spacer', 'stat', 'steps', 'submit', 'switch', 'table', 'tabs', 'text',
|
|
1185
1193
|
'textarea', 'timeline', 'video', 'echart', 'diagram',
|
|
1186
1194
|
])
|
|
1187
1195
|
|
|
1188
1196
|
/**
|
|
1189
|
-
*
|
|
1197
|
+
* Visit and count declared nodes in a raw spec tree: objects whose `type` is a
|
|
1190
1198
|
* white-listed string, descending the same containers `countGenuiNodes`
|
|
1191
|
-
* walks.
|
|
1192
|
-
*
|
|
1193
|
-
* #42) instead of reporting a green check on a half-empty tree.
|
|
1199
|
+
* walks. Callers can inspect field semantics or compare the count with the
|
|
1200
|
+
* repaired tree without maintaining another traversal.
|
|
1194
1201
|
*/
|
|
1195
|
-
|
|
1202
|
+
function visitDeclaredGenuiNodes(
|
|
1203
|
+
value: unknown,
|
|
1204
|
+
cap: number,
|
|
1205
|
+
visit: (node: Record<string, unknown>, at: string) => void,
|
|
1206
|
+
): number {
|
|
1196
1207
|
let count = 0
|
|
1197
1208
|
const declared = (candidate: unknown): boolean => {
|
|
1198
1209
|
const o = obj(candidate)
|
|
1199
1210
|
return o !== undefined && typeof o.type === 'string' && GENUI_NODE_TYPES.has(o.type)
|
|
1200
1211
|
}
|
|
1201
|
-
|
|
1212
|
+
function walk(list: unknown, path: string): void {
|
|
1202
1213
|
if (!Array.isArray(list)) return
|
|
1203
|
-
for (
|
|
1214
|
+
for (let index = 0; index < list.length; index++) {
|
|
1215
|
+
walkNode(list[index], `${path}[${index}]`)
|
|
1204
1216
|
if (count >= cap) return
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
function walkNode(item: unknown, at: string): void {
|
|
1220
|
+
if (count >= cap || !declared(item)) return
|
|
1221
|
+
const v = obj(item)
|
|
1222
|
+
if (v === undefined) return
|
|
1223
|
+
count += 1
|
|
1224
|
+
visit(v, at)
|
|
1225
|
+
if (v.type === 'tabs' && Array.isArray(v.tabs)) {
|
|
1226
|
+
for (let tab = 0; tab < v.tabs.length; tab++) {
|
|
1227
|
+
walkItemsOf(v.tabs[tab], `${at}.tabs[${tab}]`)
|
|
1228
|
+
}
|
|
1229
|
+
} else if (v.type === 'accordion' && Array.isArray(v.items)) {
|
|
1230
|
+
for (let row = 0; row < v.items.length; row++) {
|
|
1231
|
+
walkItemsOf(v.items[row], `${at}.items[${row}]`)
|
|
1232
|
+
}
|
|
1233
|
+
} else if ((v.type === 'row' || v.type === 'col' || v.type === 'grid' || v.type === 'card') && Array.isArray(v.items)) {
|
|
1234
|
+
walk(v.items, `${at}.items`)
|
|
1235
|
+
} else if (v.type === 'list' && Array.isArray(v.items)) {
|
|
1236
|
+
for (let row = 0; row < v.items.length; row++) {
|
|
1237
|
+
walkNode(v.items[row], `${at}.items[${row}]`)
|
|
1219
1238
|
}
|
|
1220
1239
|
}
|
|
1221
1240
|
}
|
|
1222
|
-
|
|
1241
|
+
function walkItemsOf(holder: unknown, path: string): void {
|
|
1223
1242
|
const o = obj(holder)
|
|
1224
1243
|
if (o === undefined) return
|
|
1225
1244
|
const items = o.items !== undefined ? o.items : o.content
|
|
1226
|
-
if (Array.isArray(items)) walk(items)
|
|
1227
|
-
else
|
|
1245
|
+
if (Array.isArray(items)) walk(items, `${path}.items`)
|
|
1246
|
+
else walkNode(items, `${path}.items`)
|
|
1228
1247
|
}
|
|
1229
1248
|
const root = obj(value)
|
|
1230
1249
|
if (root === undefined) return count
|
|
1231
1250
|
// Single-component root (no items array): the root itself is the declared node.
|
|
1232
|
-
if (!Array.isArray(root.items) && declared(value))
|
|
1233
|
-
else walk(root.items)
|
|
1251
|
+
if (!Array.isArray(root.items) && declared(value)) walkNode(value, 'spec')
|
|
1252
|
+
else walk(root.items, 'items')
|
|
1234
1253
|
return count
|
|
1235
1254
|
}
|
|
1236
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* Count white-listed nodes declared by a raw spec before repair drops invalid entries.
|
|
1258
|
+
* @param value - raw GenUI spec.
|
|
1259
|
+
* @param cap - traversal ceiling.
|
|
1260
|
+
* @returns declared node count up to the ceiling.
|
|
1261
|
+
*/
|
|
1262
|
+
export function countDeclaredGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
|
|
1263
|
+
return visitDeclaredGenuiNodes(value, cap, () => {})
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Return field-level chart errors without changing other repairable component families.
|
|
1268
|
+
* @param value - raw GenUI spec.
|
|
1269
|
+
* @returns chart semantic errors in tree order.
|
|
1270
|
+
*/
|
|
1271
|
+
export function validateGenuiChartSemantics(value: unknown): string[] {
|
|
1272
|
+
const errors: string[] = []
|
|
1273
|
+
visitDeclaredGenuiNodes(value, GENUI_LIMITS.maxNodes + 1, (node, at) => {
|
|
1274
|
+
if (node.type !== 'chart') return
|
|
1275
|
+
validateChartNode(node, at, errors)
|
|
1276
|
+
})
|
|
1277
|
+
return errors
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1237
1280
|
/**
|
|
1238
1281
|
* Validate a raw spec value against the white list and limits, collecting
|
|
1239
1282
|
* human-readable problems. Unlike repair this never mutates: it is a
|
|
@@ -1281,6 +1324,59 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
|
|
|
1281
1324
|
|
|
1282
1325
|
type Walker = (list: unknown, depth: number, path: string) => void
|
|
1283
1326
|
|
|
1327
|
+
function validateChartData(value: unknown, at: string, errors: string[]): void {
|
|
1328
|
+
if (!Array.isArray(value)) return
|
|
1329
|
+
for (let index = 0; index < value.length; index++) {
|
|
1330
|
+
const datum = obj(value[index])
|
|
1331
|
+
const path = `${at}[${index}]`
|
|
1332
|
+
if (datum === undefined) {
|
|
1333
|
+
errors.push(`${path} must be an object`)
|
|
1334
|
+
continue
|
|
1335
|
+
}
|
|
1336
|
+
if (typeof datum.label !== 'string') errors.push(`${path}.label must be a string`)
|
|
1337
|
+
if (typeof datum.value !== 'number' || !Number.isFinite(datum.value)) {
|
|
1338
|
+
errors.push(`${path}.value must be a finite number`)
|
|
1339
|
+
}
|
|
1340
|
+
if (datum.color !== undefined && typeof datum.color !== 'string') {
|
|
1341
|
+
errors.push(`${path}.color must be a string`)
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function validateChartSeries(value: unknown, at: string, errors: string[]): void {
|
|
1347
|
+
if (!Array.isArray(value)) return
|
|
1348
|
+
for (let index = 0; index < value.length; index++) {
|
|
1349
|
+
const series = obj(value[index])
|
|
1350
|
+
const path = `${at}[${index}]`
|
|
1351
|
+
if (series === undefined) {
|
|
1352
|
+
errors.push(`${path} must be an object`)
|
|
1353
|
+
continue
|
|
1354
|
+
}
|
|
1355
|
+
if (typeof series.label !== 'string') errors.push(`${path}.label must be a string`)
|
|
1356
|
+
if (series.color !== undefined && typeof series.color !== 'string') {
|
|
1357
|
+
errors.push(`${path}.color must be a string`)
|
|
1358
|
+
}
|
|
1359
|
+
if (!Array.isArray(series.data)) errors.push(`${path}.data must be an array`)
|
|
1360
|
+
validateChartData(series.data, `${path}.data`, errors)
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function validateChartNode(v: Record<string, unknown>, at: string, errors: string[]): void {
|
|
1365
|
+
if (!Array.isArray(v.data) && !Array.isArray(v.series)) {
|
|
1366
|
+
errors.push(`${at}: type 'chart' requires data or series (array)`)
|
|
1367
|
+
}
|
|
1368
|
+
if (v.variant !== undefined) errors.push(`${at}.variant is unsupported; use kind`)
|
|
1369
|
+
if (v.data !== undefined && !Array.isArray(v.data)) errors.push(`${at}.data must be an array`)
|
|
1370
|
+
if (v.series !== undefined && !Array.isArray(v.series)) errors.push(`${at}.series must be an array`)
|
|
1371
|
+
if (v.kind !== undefined
|
|
1372
|
+
&& (typeof v.kind !== 'string'
|
|
1373
|
+
|| !CHART_KINDS.includes(v.kind as typeof CHART_KINDS[number]))) {
|
|
1374
|
+
errors.push(`${at}.kind must be bars, line, or donut`)
|
|
1375
|
+
}
|
|
1376
|
+
validateChartData(v.data, `${at}.data`, errors)
|
|
1377
|
+
validateChartSeries(v.series, `${at}.series`, errors)
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1284
1380
|
function validateNode(value: unknown, depth: number, at: string, errors: string[], walk: Walker): void {
|
|
1285
1381
|
if (depth > GENUI_LIMITS.maxDepth) {
|
|
1286
1382
|
errors.push(`${at}: exceeds max depth ${GENUI_LIMITS.maxDepth}`)
|
|
@@ -1315,7 +1411,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1315
1411
|
if (typeof v.label !== 'string') errors.push(`${at}: type '${type}' requires label (string)`)
|
|
1316
1412
|
isStr('label')
|
|
1317
1413
|
break
|
|
1318
|
-
case 'audio': case 'video':
|
|
1414
|
+
case 'image': case 'audio': case 'video':
|
|
1319
1415
|
if (typeof v.src !== 'string') errors.push(`${at}: type '${type}' requires src (string)`)
|
|
1320
1416
|
isStr('src')
|
|
1321
1417
|
isStr('alt')
|
|
@@ -1378,7 +1474,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1378
1474
|
if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
|
|
1379
1475
|
break
|
|
1380
1476
|
case 'chart':
|
|
1381
|
-
|
|
1477
|
+
validateChartNode(v, at, errors)
|
|
1382
1478
|
break
|
|
1383
1479
|
case 'tabs': {
|
|
1384
1480
|
if (!Array.isArray(v.tabs)) errors.push(`${at}: type 'tabs' requires tabs (array)`)
|
|
@@ -103,11 +103,20 @@ export function createPanelSlashSource(sendInstruction: (sessionId: SessionId, i
|
|
|
103
103
|
trigger: '/',
|
|
104
104
|
name: 'genui',
|
|
105
105
|
order: 60,
|
|
106
|
-
candidates: async (_session,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
candidates: async (_session, req) => {
|
|
107
|
+
// Filter by query prefix: once the user types past "panel" the genui
|
|
108
|
+
// group must disappear. Unfiltered candidates kept this group the only
|
|
109
|
+
// ready non-empty group for unmatched queries, so the menu's default
|
|
110
|
+
// highlight fell through to `panel` and Enter picked it instead of the
|
|
111
|
+
// intended command/skill candidate.
|
|
112
|
+
const query = req.query.toLowerCase()
|
|
113
|
+
if (query !== '' && !'panel'.startsWith(query)) return []
|
|
114
|
+
return [{
|
|
115
|
+
name: 'panel',
|
|
116
|
+
description: '开启 GenUI 面板(/panel clear 清空;/panel <指令> 定制内容)',
|
|
117
|
+
hint: '/panel',
|
|
118
|
+
}]
|
|
119
|
+
},
|
|
111
120
|
onPick(pick) {
|
|
112
121
|
return { claim: panelClaim(pick.session.sessionId, sendInstruction) }
|
|
113
122
|
},
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* 1. Full parse first (the common settled case) — at most one.
|
|
9
9
|
* 2. ONE left-to-right scan collects the repair candidates: every position
|
|
10
10
|
* where the prefix is bracket-balanced (trailing comma / fence tail) and
|
|
11
|
-
* every `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* every top-level `items[]` component close (an unfinished trailing
|
|
12
|
+
* component is dropped by closing the root array/object). Partial candidates
|
|
13
|
+
* must parse as a spec root; bare components wait for their own root close.
|
|
14
|
+
* The ring buffer keeps only the longest-direction candidates (≤ MAX_PARTIAL_REPAIR_ATTEMPTS),
|
|
14
15
|
* so the work is O(n) with a hard parse-attempt cap — a pathological
|
|
15
16
|
* input can never re-scan prefixes or burn seconds in JSON.parse.
|
|
16
17
|
* 3. Candidates are tried longest first; the first that parses as a GenUI
|
|
@@ -19,11 +20,10 @@
|
|
|
19
20
|
* The result is only ever a PREFIX of the intended spec, so it is always
|
|
20
21
|
* safe to render: components already present are complete and valid.
|
|
21
22
|
*
|
|
22
|
-
* `ponytail:` the 32-candidate / 33-parse budget is the protection ceiling
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* `ponytail:` the 32-candidate / 33-parse budget is the protection ceiling;
|
|
24
|
+
* only real streaming samples proving a recovery shortfall justify switching
|
|
25
|
+
* to a tokenizing parser.
|
|
25
26
|
*/
|
|
26
|
-
import { GENUI_LIMITS } from './guard.ts'
|
|
27
27
|
import { isGenuiSpec, type GenuiSpec } from './spec.ts'
|
|
28
28
|
import { wrapSingleComponentRoot } from './spec.ts'
|
|
29
29
|
|
|
@@ -48,9 +48,9 @@ export interface PartialCandidate {
|
|
|
48
48
|
* (skipping strings/escapes correctly) and records:
|
|
49
49
|
* - every position where the prefix is fully balanced (trailing comma or
|
|
50
50
|
* fence tail) — candidate with an empty closing suffix;
|
|
51
|
-
* - every `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* - every top-level `items[]` component close — candidate with the root
|
|
52
|
+
* array/object closed (an unfinished trailing component is dropped). Nested
|
|
53
|
+
* object closes are not candidates because their component is still partial.
|
|
54
54
|
* Candidates are ring-buffered to the attempts budget (the LAST pushes are
|
|
55
55
|
* the longest), then returned longest-first, deduplicated by end. The scan
|
|
56
56
|
* stops at the first unbalanced close — earlier balanced prefixes remain
|
|
@@ -93,10 +93,11 @@ export function collectPartialCandidates(raw: string): { candidates: PartialCand
|
|
|
93
93
|
// balanced prefixes recorded earlier stay valid. Stop scanning.
|
|
94
94
|
break
|
|
95
95
|
}
|
|
96
|
-
if (ch === '}' && stack.length
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
96
|
+
if (ch === '}' && stack.length === 2 && stack[0] === '{' && stack[1] === '[') {
|
|
97
|
+
// Only a direct root-array member can be a finished component.
|
|
98
|
+
// Partial parsing later requires an actual spec root, so a bare
|
|
99
|
+
// component's data[] member cannot become a candidate.
|
|
100
|
+
push({ end: scanned + 1, closingSuffix: ']}' })
|
|
100
101
|
}
|
|
101
102
|
if (stack.length === 0) {
|
|
102
103
|
// Whole prefix balanced: a complete-JSON candidate (trailing comma /
|
|
@@ -114,22 +115,15 @@ export function collectPartialCandidates(raw: string): { candidates: PartialCand
|
|
|
114
115
|
return { candidates: deduped.slice(0, repairAttemptsLimit), scannedChars: scanned }
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
/** Closing brackets for the remaining open stack (top first). */
|
|
118
|
-
function closeSuffix(stack: string[]): string {
|
|
119
|
-
let suffix = ''
|
|
120
|
-
for (let i = stack.length - 1; i >= 0; i--) suffix += stack[i] === '{' ? '}' : ']'
|
|
121
|
-
return suffix
|
|
122
|
-
}
|
|
123
|
-
|
|
124
118
|
/** Try to parse a candidate as a GenuiSpec. */
|
|
125
|
-
function trySpec(candidate: string): GenuiSpec | null {
|
|
119
|
+
function trySpec(candidate: string, allowSingleComponentRoot: boolean): GenuiSpec | null {
|
|
126
120
|
try {
|
|
127
121
|
const value: unknown = JSON.parse(candidate)
|
|
128
122
|
if (isGenuiSpec(value)) return value
|
|
129
123
|
// Single-component roots are part of the documented fence vocabulary
|
|
130
124
|
// (e.g. a bare {"type":"callout",…} body) — wrap into a col so the
|
|
131
125
|
// items-gated pipeline renders them (panel/append hoisted).
|
|
132
|
-
return wrapSingleComponentRoot(value)
|
|
126
|
+
return allowSingleComponentRoot ? wrapSingleComponentRoot(value) : null
|
|
133
127
|
} catch {
|
|
134
128
|
return null
|
|
135
129
|
}
|
|
@@ -146,14 +140,14 @@ export function parsePartialGenuiSpec(raw: string): GenuiSpec | null {
|
|
|
146
140
|
if (text === '') return null
|
|
147
141
|
|
|
148
142
|
// 1. Full parse (settled / already-complete body).
|
|
149
|
-
const full = trySpec(text)
|
|
143
|
+
const full = trySpec(text, true)
|
|
150
144
|
if (full !== null) return full
|
|
151
145
|
|
|
152
146
|
// 2. Bounded repair: ONE forward scan, at most `repairAttemptsLimit`
|
|
153
147
|
// candidates, longest first — never a re-scan per `}`.
|
|
154
148
|
const { candidates } = collectPartialCandidates(text)
|
|
155
149
|
for (const candidate of candidates) {
|
|
156
|
-
const spec = trySpec(text.slice(0, candidate.end) + candidate.closingSuffix)
|
|
150
|
+
const spec = trySpec(text.slice(0, candidate.end) + candidate.closingSuffix, false)
|
|
157
151
|
if (spec !== null) return spec
|
|
158
152
|
}
|
|
159
153
|
|
package/src/client/safe-math.ts
CHANGED
|
@@ -139,7 +139,35 @@ class SafeMathParser {
|
|
|
139
139
|
|
|
140
140
|
private parseNumber(): number {
|
|
141
141
|
const start = this.i
|
|
142
|
-
|
|
142
|
+
let sawDigit = false
|
|
143
|
+
|
|
144
|
+
while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]!)) {
|
|
145
|
+
sawDigit = true
|
|
146
|
+
this.i++
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.peek() === '.') {
|
|
150
|
+
this.i++
|
|
151
|
+
while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]!)) {
|
|
152
|
+
sawDigit = true
|
|
153
|
+
this.i++
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!sawDigit) {
|
|
158
|
+
throw new ParseError(`invalid number '${this.src.slice(start, this.i)}'`, start)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (this.peek() === 'e' || this.peek() === 'E') {
|
|
162
|
+
this.i++
|
|
163
|
+
if (this.peek() === '+' || this.peek() === '-') this.i++
|
|
164
|
+
const exponentStart = this.i
|
|
165
|
+
while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]!)) this.i++
|
|
166
|
+
if (this.i === exponentStart) {
|
|
167
|
+
throw new ParseError(`invalid number '${this.src.slice(start, this.i)}'`, start)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
143
171
|
const text = this.src.slice(start, this.i)
|
|
144
172
|
const value = Number(text)
|
|
145
173
|
if (Number.isNaN(value)) throw new ParseError(`invalid number '${text}'`, start)
|
|
@@ -135,8 +135,11 @@ export async function mountScene(container: HTMLElement, scene: GenuiScene3D): P
|
|
|
135
135
|
}
|
|
136
136
|
const onPointerMove = (e: PointerEvent): void => {
|
|
137
137
|
if (!isDragging) return
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// Drag follows the cursor (standard orbit feel): dragging right/left
|
|
139
|
+
// sweeps the azimuth with the pointer, dragging up/down tilts the polar
|
|
140
|
+
// angle with the pointer (phi grows from +Y down toward the equator).
|
|
141
|
+
theta += (e.clientX - prevX) * 0.01
|
|
142
|
+
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi - (e.clientY - prevY) * 0.01))
|
|
140
143
|
prevX = e.clientX
|
|
141
144
|
prevY = e.clientY
|
|
142
145
|
orbit()
|
package/src/client/spec.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type GenuiNode =
|
|
|
21
21
|
| GenuiSelect
|
|
22
22
|
| GenuiCheckbox
|
|
23
23
|
| GenuiLink
|
|
24
|
+
| GenuiImage
|
|
24
25
|
| GenuiAudio
|
|
25
26
|
| GenuiVideo
|
|
26
27
|
| GenuiBadge
|
|
@@ -151,6 +152,14 @@ export interface GenuiLink {
|
|
|
151
152
|
href?: string
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
/** User-controlled image from a browser-reachable URL. */
|
|
156
|
+
export interface GenuiImage {
|
|
157
|
+
type: 'image'
|
|
158
|
+
src: string
|
|
159
|
+
/** Visible caption and accessible image description. */
|
|
160
|
+
alt?: string
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
/** User-controlled audio from a browser-reachable URL. */
|
|
155
164
|
export interface GenuiAudio {
|
|
156
165
|
type: 'audio'
|
|
@@ -517,7 +526,6 @@ export interface GenuiScene3D {
|
|
|
517
526
|
}
|
|
518
527
|
|
|
519
528
|
/* ---------------- v1.4: more content structure ---------------- */
|
|
520
|
-
|
|
521
529
|
export interface GenuiTimelineItem {
|
|
522
530
|
/** Title of the event. */
|
|
523
531
|
title: string
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { validateGenuiChartSemantics } from '../client/guard.ts'
|
|
2
|
+
|
|
3
|
+
/** Plain object helper for raw model-authored specs. */
|
|
4
|
+
function obj(value: unknown): Record<string, unknown> | undefined {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
6
|
+
? value as Record<string, unknown>
|
|
7
|
+
: undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Visit chart nodes through the component-bearing containers understood by
|
|
12
|
+
* the GenUI guard. This deliberately does not recursively inspect arbitrary
|
|
13
|
+
* objects (for example `json.value` or `echart.option`), where a nested
|
|
14
|
+
* `{type:"chart"}` is data rather than a GenUI node.
|
|
15
|
+
*/
|
|
16
|
+
function visitChartNodes(
|
|
17
|
+
value: unknown,
|
|
18
|
+
visit: (node: Record<string, unknown>, at: string) => void,
|
|
19
|
+
): void {
|
|
20
|
+
function walkNode(value: unknown, at: string): void {
|
|
21
|
+
const node = obj(value)
|
|
22
|
+
if (node === undefined || typeof node.type !== 'string') return
|
|
23
|
+
if (node.type === 'chart') visit(node, at)
|
|
24
|
+
|
|
25
|
+
if ((node.type === 'row' || node.type === 'col' || node.type === 'grid' || node.type === 'card') && Array.isArray(node.items)) {
|
|
26
|
+
walk(node.items, `${at}.items`)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
if (node.type === 'tabs' && Array.isArray(node.tabs)) {
|
|
30
|
+
for (let index = 0; index < node.tabs.length; index++) {
|
|
31
|
+
const tab = obj(node.tabs[index])
|
|
32
|
+
if (tab === undefined) continue
|
|
33
|
+
const items = tab.items !== undefined ? tab.items : tab.content
|
|
34
|
+
if (Array.isArray(items)) walk(items, `${at}.tabs[${index}].items`)
|
|
35
|
+
else walkNode(items, `${at}.tabs[${index}].items`)
|
|
36
|
+
}
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (node.type === 'accordion' && Array.isArray(node.items)) {
|
|
40
|
+
for (let index = 0; index < node.items.length; index++) {
|
|
41
|
+
const item = obj(node.items[index])
|
|
42
|
+
if (item === undefined) continue
|
|
43
|
+
const items = item.items !== undefined ? item.items : item.content
|
|
44
|
+
if (Array.isArray(items)) walk(items, `${at}.items[${index}].items`)
|
|
45
|
+
else walkNode(items, `${at}.items[${index}].items`)
|
|
46
|
+
}
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (node.type === 'list' && Array.isArray(node.items)) {
|
|
50
|
+
for (let index = 0; index < node.items.length; index++) {
|
|
51
|
+
walkNode(node.items[index], `${at}.items[${index}]`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function walk(list: unknown, path: string): void {
|
|
57
|
+
if (!Array.isArray(list)) return
|
|
58
|
+
for (let index = 0; index < list.length; index++) {
|
|
59
|
+
walkNode(list[index], `${path}[${index}]`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const root = obj(value)
|
|
64
|
+
if (root === undefined) return
|
|
65
|
+
if (!Array.isArray(root.items) && root.type === 'chart') walkNode(root, 'spec')
|
|
66
|
+
else walk(root.items, 'items')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate the native chart contract all the way to something the renderer
|
|
71
|
+
* can actually draw. The shared guard still owns field/type validation; this
|
|
72
|
+
* layer adds renderer-specific invariants that would otherwise produce an
|
|
73
|
+
* empty line/donut chart or a grouped-bars shell with no points.
|
|
74
|
+
*/
|
|
75
|
+
export function validateRenderableChartSemantics(value: unknown): string[] {
|
|
76
|
+
const errors = validateGenuiChartSemantics(value)
|
|
77
|
+
visitChartNodes(value, (node, at) => {
|
|
78
|
+
const kind = node.kind === undefined ? 'bars' : node.kind
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(node.data) && node.data.length === 0) {
|
|
81
|
+
errors.push(`${at}.data must not be empty`)
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(node.series)) {
|
|
84
|
+
if (node.series.length === 0) errors.push(`${at}.series must not be empty`)
|
|
85
|
+
for (let index = 0; index < node.series.length; index++) {
|
|
86
|
+
const series = obj(node.series[index])
|
|
87
|
+
if (series !== undefined && Array.isArray(series.data) && series.data.length === 0) {
|
|
88
|
+
errors.push(`${at}.series[${index}].data must not be empty`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (kind === 'line' || kind === 'donut') {
|
|
94
|
+
if (node.series !== undefined) {
|
|
95
|
+
errors.push(`${at}.series is only supported for bars`)
|
|
96
|
+
}
|
|
97
|
+
if (node.data === undefined) {
|
|
98
|
+
errors.push(`${at}.data is required for ${kind}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
return errors
|
|
103
|
+
}
|