@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +7 -18
  3. package/README.zh-CN.md +7 -18
  4. package/SKILL.md +4 -2
  5. package/lib/assets/three.js +1 -1
  6. package/lib/client.js +3 -3
  7. package/lib/index.js +273 -61
  8. package/lib/types/client/GenuiBlock.d.ts +5 -2
  9. package/lib/types/client/blocks/advanced.d.ts +1 -1
  10. package/lib/types/client/blocks/image.d.ts +6 -0
  11. package/lib/types/client/blocks/state.d.ts +2 -0
  12. package/lib/types/client/fence-render.d.ts +3 -0
  13. package/lib/types/client/gallery.d.ts +3 -5
  14. package/lib/types/client/guard.d.ts +10 -5
  15. package/lib/types/client/parse-partial.d.ts +29 -3
  16. package/lib/types/client/spec.d.ts +8 -1
  17. package/lib/types/plugin/chart-contract.d.ts +7 -0
  18. package/lib/types/plugin/index.d.ts +1 -1
  19. package/package.json +18 -17
  20. package/src/client/GenuiBlock.module.css +1 -3
  21. package/src/client/GenuiBlock.tsx +37 -24
  22. package/src/client/PlotBlock.tsx +25 -10
  23. package/src/client/blocks/advanced.tsx +34 -25
  24. package/src/client/blocks/charts.tsx +4 -4
  25. package/src/client/blocks/image.tsx +25 -0
  26. package/src/client/blocks/render-node.tsx +3 -0
  27. package/src/client/blocks/state.ts +2 -0
  28. package/src/client/dom-fence.tsx +12 -5
  29. package/src/client/fence-render.tsx +35 -11
  30. package/src/client/gallery.ts +4 -5
  31. package/src/client/guard.ts +125 -29
  32. package/src/client/panel-command.ts +14 -5
  33. package/src/client/parse-partial.ts +19 -25
  34. package/src/client/safe-math.ts +29 -1
  35. package/src/client/scene3d-core.ts +5 -2
  36. package/src/client/spec.ts +9 -1
  37. package/src/plugin/chart-contract.ts +103 -0
  38. package/src/plugin/index.ts +48 -2
  39. 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 `}`). This used to
68
- * fail silently: the fence degraded to a code block with no hint, and the
69
- * author had no way to know the UI never rendered. Once the streaming
70
- * marker is gone, surface a compact diagnostic with the parse position so
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 diagnostic = settled && raw.trim() !== '' ? describeJsonFailure(raw) : null
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
- {diagnostic !== null && (
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 解析失败{diagnostic} —— 围栏保持为代码块;请让模型检查并修复 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 : repairGenuiSpec(parsed)
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 : repairGenuiSpec(reparsed)
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 : repairGenuiSpec(reparsed)
165
+ spec = reparsed === null ? null : repairRenderableSpec(reparsed)
142
166
  }
143
167
  }
144
168
  }
@@ -1,12 +1,10 @@
1
1
  /**
2
- * GenUI gallery: one spec exercising every white-listed node type, used as
3
- * the canonical full-vocabulary sample (tests render it and assert every
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
- /** A single spec covering all 40 node types in the vocabulary. */
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: [
@@ -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
- * Count DECLARED nodes in a raw spec tree: objects whose `type` is a
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. `validate_dsh_ui` compares this with the repaired count to surface
1192
- * children the repair silently dropped (blank-render class of bugs, issue
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
- export function countDeclaredGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
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
- const walk = (list: unknown): void => {
1212
+ function walk(list: unknown, path: string): void {
1202
1213
  if (!Array.isArray(list)) return
1203
- for (const item of list) {
1214
+ for (let index = 0; index < list.length; index++) {
1215
+ walkNode(list[index], `${path}[${index}]`)
1204
1216
  if (count >= cap) return
1205
- if (!declared(item)) continue
1206
- count += 1
1207
- const v = obj(item)
1208
- if (v === undefined) continue
1209
- if (v.type === 'tabs' && Array.isArray(v.tabs)) {
1210
- for (const t of v.tabs) walkItemsOf(t)
1211
- } else if (v.type === 'accordion' && Array.isArray(v.items)) {
1212
- for (const it of v.items) walkItemsOf(it)
1213
- } else if ((v.type === 'row' || v.type === 'col' || v.type === 'grid' || v.type === 'card') && Array.isArray(v.items)) {
1214
- walk(v.items)
1215
- } else if (v.type === 'list' && Array.isArray(v.items)) {
1216
- for (const li of v.items) {
1217
- if (declared(li)) walk([li])
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
- const walkItemsOf = (holder: unknown): void => {
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 if (declared(items)) walk([items])
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)) walk([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
- if (!Array.isArray(v.data) && !Array.isArray(v.series)) errors.push(`${at}: type 'chart' requires data or series (array)`)
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, _req) => [{
107
- name: 'panel',
108
- description: '开启 GenUI 面板(/panel clear 清空;/panel <指令> 定制内容)',
109
- hint: '/panel',
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 `}` object close within the depth budget (an unfinished trailing
12
- * element, closed by appending the remaining brackets). The ring buffer
13
- * keeps only the longest-direction candidates (≤ MAX_PARTIAL_REPAIR_ATTEMPTS),
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
- * for depth 8 / 200 nodes; only real streaming samples proving a recovery
24
- * shortfall justify switching to a tokenizing parser.
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 `}` object close whose remaining depth fits the spec budget —
52
- * candidate with the remaining brackets closed (an unfinished trailing
53
- * element).
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 <= GENUI_LIMITS.maxDepth) {
97
- // A finished object element: closing the remaining brackets yields
98
- // the longest legal prefix (an unfinished trailing element drops).
99
- push({ end: scanned + 1, closingSuffix: closeSuffix(stack) })
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
 
@@ -139,7 +139,35 @@ class SafeMathParser {
139
139
 
140
140
  private parseNumber(): number {
141
141
  const start = this.i
142
- while (this.i < this.src.length && /[0-9.eE+\-]/.test(this.src[this.i]!)) this.i++
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
- theta -= (e.clientX - prevX) * 0.01
139
- phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi + (e.clientY - prevY) * 0.01))
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()
@@ -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
+ }