@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -8
  3. package/README.zh-CN.md +13 -8
  4. package/SKILL.md +5 -5
  5. package/lib/client.js +3 -3
  6. package/lib/index.js +1273 -338
  7. package/lib/types/client/blocks/advanced.d.ts +1 -1
  8. package/lib/types/client/blocks/checkbox.d.ts +17 -0
  9. package/lib/types/client/blocks/forms.d.ts +3 -8
  10. package/lib/types/client/blocks/state.d.ts +18 -14
  11. package/lib/types/client/dom-fence.d.ts +1 -1
  12. package/lib/types/client/genui-runtime/diagnostics.d.ts +18 -0
  13. package/lib/types/client/genui-runtime/index.d.ts +1 -0
  14. package/lib/types/client/genui-runtime/limits.d.ts +54 -0
  15. package/lib/types/client/genui-runtime/normalize.d.ts +15 -0
  16. package/lib/types/client/genui-runtime/schema.d.ts +73 -0
  17. package/lib/types/client/genui-runtime/value-utils.d.ts +18 -0
  18. package/lib/types/client/guard.d.ts +60 -71
  19. package/lib/types/client/interaction-store.d.ts +7 -2
  20. package/lib/types/client/panel-command.d.ts +1 -1
  21. package/lib/types/client/primitive-labels.d.ts +11 -0
  22. package/lib/types/client/spec.d.ts +17 -17
  23. package/lib/types/plugin/chart-contract.d.ts +6 -4
  24. package/lib/types/plugin/index.d.ts +1 -4
  25. package/package.json +74 -13
  26. package/src/client/GenuiBlock.tsx +27 -11
  27. package/src/client/blocks/advanced.tsx +6 -5
  28. package/src/client/blocks/charts.tsx +1 -1
  29. package/src/client/blocks/checkbox.tsx +60 -0
  30. package/src/client/blocks/diagram/index.tsx +1 -1
  31. package/src/client/blocks/forms.tsx +51 -28
  32. package/src/client/blocks/render-node.tsx +3 -16
  33. package/src/client/blocks/state.ts +18 -14
  34. package/src/client/dom-fence.tsx +2 -1
  35. package/src/client/fence-render.tsx +26 -15
  36. package/src/client/genui-runtime/diagnostics.ts +143 -0
  37. package/src/client/genui-runtime/index.ts +1 -0
  38. package/src/client/genui-runtime/limits.ts +54 -0
  39. package/src/client/genui-runtime/normalize.ts +115 -0
  40. package/src/client/genui-runtime/schema.ts +291 -0
  41. package/src/client/genui-runtime/value-utils.ts +67 -0
  42. package/src/client/guard.ts +314 -169
  43. package/src/client/index.tsx +3 -2
  44. package/src/client/interaction-store.ts +7 -2
  45. package/src/client/panel-command.ts +2 -1
  46. package/src/client/panel.tsx +1 -1
  47. package/src/client/primitive-labels.ts +37 -0
  48. package/src/client/spec.ts +17 -17
  49. package/src/client/toolview.tsx +2 -1
  50. package/src/index.ts +3 -6
  51. package/src/plugin/chart-contract.ts +7 -97
  52. package/src/plugin/index.ts +2 -6
  53. package/src/plugin/tool.ts +60 -40
@@ -16,15 +16,15 @@
16
16
  */
17
17
  import { Fragment, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type Key, type ReactNode } from 'react'
18
18
  import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
19
+ import { CODE_BLOCK_LABELS } from './primitive-labels.ts'
19
20
  import { ErrorBoundary } from './ErrorBoundary.tsx'
20
21
  import { GenuiBlock } from './GenuiBlock.tsx'
21
- import { repairGenuiSpec } from './guard.ts'
22
+ import { isRenderableProcess, processGenuiSpec } from './guard.ts'
22
23
  import { fenceStateKey } from './interaction-store.ts'
23
24
  import { parsePartialGenuiSpec } from './parse-partial.ts'
24
25
  import { applyPanelOperation, diagnosePanelBudget, type PanelOperationStatus } from './panel-store.ts'
25
26
  import type { GenuiSpec } from './spec.ts'
26
27
  import { completeFenceJson, describeJsonFailure, isCompleteJson, repairFenceJson } from '../shared/fence-repair.ts'
27
- import { validateRenderableChartSemantics } from '../plugin/chart-contract.ts'
28
28
 
29
29
  /** Settled fence source identity (data shape, host-independent). */
30
30
  export interface GenuiFenceSource {
@@ -54,12 +54,22 @@ const FENCE_ERROR_STYLE: CSSProperties = {
54
54
  whiteSpace: 'pre-wrap',
55
55
  }
56
56
 
57
- /** Return a chart-semantic diagnostic for parseable raw fence content. */
58
- function chartSemanticFailure(raw: string): string | null {
57
+ /** Format chart-specific process errors without maintaining a second validator. */
58
+ function formatChartProcessErrors(errors: string[]): string | null {
59
+ const chartErrors = errors.filter(error => /(?:variant is unsupported|kind must be bars, line, or donut|requires data or series|(?:data|series) is required for|(?:\.data|\.series)(?:\[\d+\])?(?:\.(?:data|label|value|color))? must|series is only supported for bars)/.test(error))
60
+ return chartErrors.length === 0 ? null : chartErrors.join(';')
61
+ }
62
+
63
+ /** Return a semantic/schema diagnostic for parseable raw fence content. */
64
+ function processSemanticFailure(raw: string): string | null {
59
65
  const parsed = parsePartialGenuiSpec(raw)
60
66
  if (parsed === null) return null
61
- const errors = validateRenderableChartSemantics(parsed)
62
- return errors.length === 0 ? null : errors.join(';')
67
+ const processed = processGenuiSpec(parsed)
68
+ if (isRenderableProcess(processed)) return null
69
+ const chartErrors = formatChartProcessErrors(processed.errors)
70
+ return chartErrors === null
71
+ ? `GenUI 字段验证失败:${processed.errors.join(';')}`
72
+ : `chart 字段验证失败:${chartErrors}`
63
73
  }
64
74
 
65
75
  /**
@@ -86,21 +96,21 @@ function FenceFallback({ raw, fenceKey }: { raw: string; fenceKey: Key }) {
86
96
  const node = ref.current
87
97
  if (node !== null && node.closest('[data-streaming]') === null) setSettled(true)
88
98
  })
89
- const chartDiagnostic = settled ? chartSemanticFailure(raw) : null
90
- const parseDiagnostic = settled && chartDiagnostic === null && raw.trim() !== '' ? describeJsonFailure(raw) : null
99
+ const processDiagnostic = settled ? processSemanticFailure(raw) : null
100
+ const parseDiagnostic = settled && processDiagnostic === null && raw.trim() !== '' ? describeJsonFailure(raw) : null
91
101
  return (
92
102
  <div ref={ref}>
93
- {chartDiagnostic !== null && (
103
+ {processDiagnostic !== null && (
94
104
  <div style={FENCE_ERROR_STYLE} role="alert">
95
- ⚠️ dsh-ui chart 字段验证失败:{chartDiagnostic} —— 围栏保持为代码块;请修正 chart 字段后重发。
105
+ ⚠️ dsh-ui {processDiagnostic} —— 围栏保持为代码块;请修正后重发。
96
106
  </div>
97
107
  )}
98
- {chartDiagnostic === null && parseDiagnostic !== null && (
108
+ {processDiagnostic === null && parseDiagnostic !== null && (
99
109
  <div style={FENCE_ERROR_STYLE} role="alert">
100
110
  ⚠️ dsh-ui fence JSON 解析失败{parseDiagnostic} —— 围栏保持为代码块;请让模型检查并修复 JSON 后重发。
101
111
  </div>
102
112
  )}
103
- <CodeBlock key={fenceKey} code={`${raw}\n`} lang="dsh-ui" />
113
+ <CodeBlock key={fenceKey} {...CODE_BLOCK_LABELS} code={`${raw}\n`} lang="dsh-ui" />
104
114
  </div>
105
115
  )
106
116
  }
@@ -130,10 +140,11 @@ function FencePanelPublisher({ sessionId, sourceId, order, spec }: {
130
140
  return null
131
141
  }
132
142
 
133
- /** Repair one parsed value only when its native charts are actually renderable. */
143
+ /** Process one parsed value and return its canonical repaired spec only when the shared pipeline is error-free. */
134
144
  function repairRenderableSpec(value: unknown): GenuiSpec | null {
135
- if (validateRenderableChartSemantics(value).length > 0) return null
136
- return repairGenuiSpec(value)
145
+ const processed = processGenuiSpec(value)
146
+ if (!isRenderableProcess(processed)) return null
147
+ return processed.spec
137
148
  }
138
149
 
139
150
  /**
@@ -0,0 +1,143 @@
1
+ /** GenUI runtime diagnostics for aliases and unknown fields. */
2
+ import { COMPONENT_SCHEMAS, GENUI_SPEC_SCHEMA } from './schema.ts'
3
+ import type { ComponentRecordSchema, ComponentSchema } from './schema.ts'
4
+
5
+ export interface GenuiDiagnostic {
6
+ readonly kind: 'alias' | 'unknown-field'
7
+ readonly path: string
8
+ readonly message: string
9
+ readonly type?: string
10
+ readonly field?: string
11
+ readonly canonical?: string
12
+ }
13
+
14
+
15
+ function record(value: unknown): Record<string, unknown> | undefined {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
17
+ ? value as Record<string, unknown>
18
+ : undefined
19
+ }
20
+
21
+ function isNode(value: unknown): value is Record<string, unknown> {
22
+ const candidate = record(value)
23
+ return candidate !== undefined && typeof candidate.type === 'string'
24
+ }
25
+
26
+ function visitNativeNodes(value: unknown, path: string, visit: (node: Record<string, unknown>, path: string, schema: ComponentSchema) => void): void {
27
+ if (!isNode(value)) return
28
+ const type = value.type as string
29
+ const definition = COMPONENT_SCHEMAS[type]
30
+ if (definition === undefined) return
31
+ visit(value, path, definition)
32
+ const children = (child: unknown, childPath: string): void => visitNativeNodes(child, childPath, visit)
33
+ if ((type === 'row' || type === 'col' || type === 'grid' || type === 'card') && Array.isArray(value.items)) {
34
+ value.items.forEach((child, index) => children(child, `${path}.items[${index}]`))
35
+ } else if (type === 'list' && Array.isArray(value.items)) {
36
+ value.items.forEach((child, index) => children(child, `${path}.items[${index}]`))
37
+ } else if (type === 'tabs' && Array.isArray(value.tabs)) {
38
+ value.tabs.forEach((tab, index) => {
39
+ const holder = record(tab)
40
+ if (holder === undefined) return
41
+ if (Array.isArray(holder.items)) holder.items.forEach((child, childIndex) => children(child, `${path}.tabs[${index}].items[${childIndex}]`))
42
+ else children(holder.items, `${path}.tabs[${index}].items`)
43
+ })
44
+ } else if (type === 'accordion' && Array.isArray(value.items)) {
45
+ value.items.forEach((item, index) => {
46
+ const holder = record(item)
47
+ if (holder?.items !== undefined && Array.isArray(holder.items)) holder.items.forEach((child, childIndex) => children(child, `${path}.items[${index}].items[${childIndex}]`))
48
+ })
49
+ }
50
+ }
51
+
52
+ function pushUnknownField(
53
+ warnings: GenuiDiagnostic[],
54
+ path: string,
55
+ field: string,
56
+ type: string,
57
+ ): void {
58
+ warnings.push({
59
+ kind: 'unknown-field',
60
+ path: `${path}.${field}`,
61
+ message: `${path}.${field}: unknown field for '${type}'`,
62
+ type,
63
+ field,
64
+ })
65
+ }
66
+
67
+ function diagnoseRecordFields(
68
+ value: unknown,
69
+ path: string,
70
+ definition: ComponentRecordSchema,
71
+ type: string,
72
+ warnings: GenuiDiagnostic[],
73
+ ): void {
74
+ const holder = record(value)
75
+ if (holder === undefined) return
76
+ for (const field of Object.keys(holder)) {
77
+ if (field in definition.fields) continue
78
+ pushUnknownField(warnings, path, field, type)
79
+ }
80
+ for (const [field, nested] of Object.entries(definition.nested)) {
81
+ const nestedValue = holder[field]
82
+ if (Array.isArray(nestedValue)) {
83
+ nestedValue.forEach((item, index) => diagnoseRecordFields(item, `${path}.${field}[${index}]`, nested, type, warnings))
84
+ } else if (nestedValue !== undefined) {
85
+ diagnoseRecordFields(nestedValue, `${path}.${field}`, nested, type, warnings)
86
+ }
87
+ }
88
+ }
89
+
90
+ function diagnoseNestedFields(
91
+ node: Record<string, unknown>,
92
+ path: string,
93
+ definition: ComponentSchema,
94
+ warnings: GenuiDiagnostic[],
95
+ ): void {
96
+ for (const [field, nested] of Object.entries(definition.nested)) {
97
+ const nestedValue = node[field]
98
+ if (Array.isArray(nestedValue)) {
99
+ nestedValue.forEach((item, index) => diagnoseRecordFields(item, `${path}.${field}[${index}]`, nested, node.type as string, warnings))
100
+ } else if (nestedValue !== undefined) {
101
+ diagnoseRecordFields(nestedValue, `${path}.${field}`, nested, node.type as string, warnings)
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Diagnose unknown direct fields on native nodes.
108
+ *
109
+ * Unknown types are intentionally skipped so custom renderers retain their
110
+ * opaque extension payloads. Native unknown fields are warnings, not errors.
111
+ *
112
+ * @param value - Canonical or raw GenUI value.
113
+ * @returns Stable field diagnostics in tree order.
114
+ */
115
+ export function diagnoseUnknownGenuiFields(value: unknown): GenuiDiagnostic[] {
116
+ const warnings: GenuiDiagnostic[] = []
117
+ const root = record(value)
118
+ if (root === undefined) return warnings
119
+ const visit = (node: Record<string, unknown>, path: string, definition: ComponentSchema): void => {
120
+ for (const field of Object.keys(node)) {
121
+ if (field === 'type' || field in definition.fields) continue
122
+ pushUnknownField(warnings, path, field, node.type as string)
123
+ }
124
+ diagnoseNestedFields(node, path, definition, warnings)
125
+ }
126
+ if (Array.isArray(root.items)) {
127
+ for (const field of Object.keys(root)) {
128
+ if (field in GENUI_SPEC_SCHEMA.fields) continue
129
+ pushUnknownField(warnings, 'spec', field, 'spec')
130
+ }
131
+ root.items.forEach((item, index) => visitNativeNodes(item, `items[${index}]`, visit))
132
+ } else if (typeof root.type === 'string') {
133
+ // A bare component root is a documented shorthand, so its `type` belongs
134
+ // to the native node schema rather than the root specification schema.
135
+ visitNativeNodes(root, 'spec', visit)
136
+ } else {
137
+ for (const field of Object.keys(root)) {
138
+ if (field in GENUI_SPEC_SCHEMA.fields) continue
139
+ pushUnknownField(warnings, 'spec', field, 'spec')
140
+ }
141
+ }
142
+ return warnings
143
+ }
@@ -0,0 +1 @@
1
+ export { GENUI_LIMITS } from './limits.ts'
@@ -0,0 +1,54 @@
1
+ /** Resource limits shared by GenUI repair, validation, and rendering. */
2
+ export const GENUI_LIMITS = {
3
+ /** Maximum nesting depth of the component tree. */
4
+ maxDepth: 8,
5
+ /** Maximum total nodes across the whole spec. */
6
+ maxNodes: 200,
7
+ /** Maximum length of any plain string field. */
8
+ maxString: 2000,
9
+ /** Maximum length of a `code` body. */
10
+ maxCode: 12_000,
11
+ /** Maximum length of a mermaid source. */
12
+ maxMermaid: 8000,
13
+ /** Maximum `grid` columns. */
14
+ maxGridCols: 12,
15
+ /** Maximum `tabs` count. */
16
+ maxTabs: 12,
17
+ /** Maximum `accordion` items. */
18
+ maxAccordionItems: 24,
19
+ /** Maximum `list` items. */
20
+ maxListItems: 50,
21
+ /** Maximum `select`/`radio` options. */
22
+ maxOptions: 50,
23
+ /** Maximum `table` rows / columns. */
24
+ maxTableRows: 50,
25
+ maxTableCols: 12,
26
+ /** Maximum `chart` data points per series. */
27
+ maxChartPoints: 60,
28
+ /** Maximum `plot` series and per-series parameters. */
29
+ maxPlotSeries: 8,
30
+ maxPlotParams: 6,
31
+ /** Maximum `scene3d` meshes. */
32
+ maxMeshes: 5,
33
+ /** Maximum `quiz` options. */
34
+ maxQuizOptions: 8,
35
+ /** Maximum `steps` / `timeline` / `breadcrumb` / `keyvalue` entries. */
36
+ maxSteps: 24,
37
+ maxTimelineItems: 24,
38
+ maxBreadcrumbItems: 12,
39
+ maxKeyValuePairs: 24,
40
+ /** Maximum `file-tree` nesting. */
41
+ maxTreeDepth: 6,
42
+ /** Maximum `diagram` nodes / edges / zones / focal accents. */
43
+ maxDiagramNodes: 9,
44
+ maxDiagramEdges: 12,
45
+ maxDiagramZones: 3,
46
+ maxDiagramFocal: 2,
47
+ maxDiagramLabel: 14,
48
+ /** Maximum depth of an `echart` option object. */
49
+ maxEChartOptionDepth: 10,
50
+ /** Maximum length of any single array inside an `echart` option. */
51
+ maxEChartArrayLen: 500,
52
+ /** Maximum entries traversed while sanitizing an `echart` option. */
53
+ maxEChartOptionNodes: 2000,
54
+ } as const
@@ -0,0 +1,115 @@
1
+ /** Deterministic GenUI alias and structural normalization. */
2
+ import { COMPONENT_SCHEMAS } from './schema.ts'
3
+ import type { GenuiDiagnostic } from './diagnostics.ts'
4
+
5
+ function record(value: unknown): Record<string, unknown> | undefined {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
7
+ ? value as Record<string, unknown>
8
+ : undefined
9
+ }
10
+
11
+ function isNode(value: unknown): value is Record<string, unknown> {
12
+ const candidate = record(value)
13
+ return candidate !== undefined && typeof candidate.type === 'string'
14
+ }
15
+
16
+ function normalizeAliasFields(value: Record<string, unknown>, path: string, type: string, warnings: GenuiDiagnostic[]): Record<string, unknown> {
17
+ const definition = COMPONENT_SCHEMAS[type]
18
+ if (definition === undefined) return value
19
+ const out: Record<string, unknown> = { ...value }
20
+ for (const [alias, canonical] of Object.entries(definition.aliases)) {
21
+ if (!(alias in out)) continue
22
+ const aliasPath = `${path}.${alias}`
23
+ const keptCanonical = canonical in out
24
+ if (!keptCanonical) out[canonical] = out[alias]
25
+ delete out[alias]
26
+ warnings.push({
27
+ kind: 'alias',
28
+ path: aliasPath,
29
+ message: keptCanonical
30
+ ? `${aliasPath} is ignored because canonical field '${canonical}' is present`
31
+ : `${aliasPath} normalized/adopted as '${canonical}'`,
32
+ type,
33
+ field: alias,
34
+ canonical,
35
+ })
36
+ }
37
+ return out
38
+ }
39
+
40
+ function normalizeNode(value: unknown, path: string, warnings: GenuiDiagnostic[]): unknown {
41
+ if (!isNode(value)) return value
42
+ const type = value.type as string
43
+ const definition = COMPONENT_SCHEMAS[type]
44
+ // Custom nodes are opaque by contract: don't inspect or rewrite their data.
45
+ if (definition === undefined) return value
46
+ const out = normalizeAliasFields(value, path, type, warnings)
47
+ const normalizeNodeValue = (child: unknown, childPath: string): unknown => normalizeNode(child, childPath, warnings)
48
+ const normalizeNodeArray = (children: unknown, childPath: string): unknown => Array.isArray(children)
49
+ ? children.map((child, index) => normalizeNodeValue(child, `${childPath}[${index}]`))
50
+ : children
51
+
52
+ if (type === 'row' || type === 'col' || type === 'grid' || type === 'card' || type === 'file-tree' || type === 'timeline' || type === 'breadcrumb') {
53
+ if (type !== 'file-tree' && type !== 'timeline' && type !== 'breadcrumb') out.items = normalizeNodeArray(out.items, `${path}.items`)
54
+ } else if (type === 'list' && Array.isArray(out.items)) {
55
+ out.items = out.items.map((child, index) => isNode(child) ? normalizeNodeValue(child, `${path}.items[${index}]`) : child)
56
+ } else if (type === 'tabs' && Array.isArray(out.tabs)) {
57
+ out.tabs = out.tabs.map((tab, index) => {
58
+ const holder = record(tab)
59
+ if (holder === undefined) return tab
60
+ const normalizedHolder = { ...holder }
61
+ if ('content' in normalizedHolder) {
62
+ const tabPath = `${path}.tabs[${index}].content`
63
+ const hasItems = 'items' in normalizedHolder
64
+ if (!hasItems) normalizedHolder.items = normalizedHolder.content
65
+ delete normalizedHolder.content
66
+ warnings.push({
67
+ kind: 'alias',
68
+ path: tabPath,
69
+ message: hasItems
70
+ ? `${tabPath} is ignored because canonical field 'items' is present`
71
+ : `${tabPath} normalized/adopted as 'items'`,
72
+ type,
73
+ field: 'content',
74
+ canonical: 'items',
75
+ })
76
+ }
77
+ normalizedHolder.items = Array.isArray(normalizedHolder.items)
78
+ ? normalizedHolder.items.map((child, childIndex) => normalizeNodeValue(child, `${path}.tabs[${index}].items[${childIndex}]`))
79
+ : normalizedHolder.items === undefined
80
+ ? normalizedHolder.items
81
+ : [normalizeNodeValue(normalizedHolder.items, `${path}.tabs[${index}].items[0]`)]
82
+ return normalizedHolder
83
+ })
84
+ } else if (type === 'accordion' && Array.isArray(out.items)) {
85
+ out.items = out.items.map((item, index) => {
86
+ const holder = record(item)
87
+ if (holder === undefined) return item
88
+ return { ...holder, items: Array.isArray(holder.items) ? holder.items.map((child, childIndex) => normalizeNodeValue(child, `${path}.items[${index}].items[${childIndex}]`)) : holder.items }
89
+ })
90
+ }
91
+ return out
92
+ }
93
+
94
+ /**
95
+ * Normalize a raw GenUI value into canonical field names.
96
+ *
97
+ * Only deterministic aliases and structural aliases are changed. Resource
98
+ * limits, type repair, security filtering, and semantic validation remain in
99
+ * the guard layer. Unknown component types are returned opaque.
100
+ *
101
+ * @param value - Raw GenUI spec or bare native component.
102
+ * @returns Canonical value and stable alias diagnostics.
103
+ */
104
+ export function normalizeGenuiSpec(value: unknown): { value: unknown; warnings: GenuiDiagnostic[] } {
105
+ const warnings: GenuiDiagnostic[] = []
106
+ const root = record(value)
107
+ if (root === undefined) return { value, warnings }
108
+ const out = { ...root }
109
+ if (Array.isArray(out.items)) {
110
+ out.items = out.items.map((item, index) => normalizeNode(item, `items[${index}]`, warnings))
111
+ } else if (typeof out.type === 'string') {
112
+ return { value: normalizeNode(out, 'spec', warnings), warnings }
113
+ }
114
+ return { value: out, warnings }
115
+ }