@changfenhuang/dsh-genui 0.9.1

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 (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The recursive render dispatcher: maps the white-listed GenuiNode union to
3
+ * concrete components. Leaf cases render inline; compound families live in
4
+ * the sibling block modules. Depth-guarded against pathological specs.
5
+ * @module @changfenhuang/dsh-genui/client/blocks/render-node
6
+ */
7
+ import { type ReactNode, type ComponentType } from 'react'
8
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
9
+ import css from '../GenuiBlock.module.css'
10
+ import { GENUI_LIMITS } from '../guard.ts'
11
+ import type { GenuiList, GenuiNode } from '../spec.ts'
12
+ import type { AnswersState, GenuiBlockProps } from './state.ts'
13
+ import { AudioNode, avatarColor, ClickFeedbackButton, VideoNode } from './basic.tsx'
14
+ import { ChartNode, TableNode } from './charts.tsx'
15
+ import {
16
+ InputNode, RadioNode, SelectNode, SliderNode, SubmitNode, SwitchNode, TextareaNode,
17
+ } from './forms.tsx'
18
+ import {
19
+ AccordionNode, BreadcrumbNode, CalloutNode, CodeNode, CopyNode, DiffNode, FileTreeNode, JsonNode, KeyValueNode,
20
+ MermaidNode, PlotNode, QuizNode, Scene3DNode, StepsNode, TabsNode, TimelineNode,
21
+ } from './advanced.tsx'
22
+ import { DiagramNode } from './diagram/index.tsx'
23
+
24
+ import { EChartNode } from '../EChartNode.tsx'
25
+
26
+ /** Custom node data shape (declared locally: pristine hosts export no type). */
27
+ interface GenuiCustomNode {
28
+ type: string
29
+ [key: string]: unknown
30
+ }
31
+
32
+ /** Props a registered custom renderer receives. */
33
+ interface GenuiCustomProps {
34
+ node: GenuiCustomNode
35
+ onAction?: GenuiBlockProps['onAction']
36
+ renderChildren: (nodes: unknown[], keyBase: string) => unknown
37
+ }
38
+
39
+ /** Custom-component registry lookup, feature-detected (contract hosts only). */
40
+ type HostGenuiExt = {
41
+ getGenuiComponent?: (type: string) => ComponentType<GenuiCustomProps> | undefined
42
+ }
43
+ const getGenuiComponent = (primitives as unknown as HostGenuiExt).getGenuiComponent
44
+
45
+ function isListItemNode(item: GenuiList['items'][number]): item is GenuiNode {
46
+ return typeof item === 'object' && item !== null && 'type' in item
47
+ }
48
+
49
+ export function renderNode(
50
+ node: GenuiNode,
51
+ key: number,
52
+ onAction: GenuiBlockProps['onAction'] | undefined,
53
+ depth = 0,
54
+ answers?: AnswersState,
55
+ ): ReactNode {
56
+ // Depth guard: a pathological spec must never recurse past the limit
57
+ // (stack overflow / DOM explosion). The fence path already repairs specs
58
+ // against the same limit; this is the belt-and-suspenders for direct
59
+ // GenuiBlock use and plugin-registered custom renderers.
60
+ if (depth > GENUI_LIMITS.maxDepth) return null
61
+ switch (node.type) {
62
+ case 'text': {
63
+ const size = node.size ?? 'body'
64
+ return (
65
+ <div key={key} className={`${css.text} ${css[size]}` + (node.center ? ` ${css.center}` : '')}>
66
+ {node.content}
67
+ </div>
68
+ )
69
+ }
70
+ case 'row': {
71
+ return (
72
+ <div key={key} className={css.row + (node.wrap ? ` ${css.wrap}` : '')}>
73
+ {node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
74
+ {node.spacer && <div className={css.spacer} />}
75
+ </div>
76
+ )
77
+ }
78
+ case 'col': {
79
+ return (
80
+ <div key={key} className={css.col} style={node.gap !== undefined ? { gap: `${node.gap}px` } : undefined}>
81
+ {node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
82
+ </div>
83
+ )
84
+ }
85
+ case 'grid': {
86
+ return (
87
+ <div key={key} className={css.grid} style={{ gridTemplateColumns: `repeat(${Math.max(1, node.cols)}, minmax(0, 1fr))` }}>
88
+ {node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
89
+ </div>
90
+ )
91
+ }
92
+ case 'card': {
93
+ return (
94
+ <div key={key} className={css.card}>
95
+ {node.title !== undefined && <div className={css.cardTitle}>{node.title}</div>}
96
+ {node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
97
+ </div>
98
+ )
99
+ }
100
+ case 'button': {
101
+ const tone = node.tone ?? ''
102
+ const cls = `${css.button} ${css[tone] || ''}` + (node.full ? ` ${css.full}` : '') + (node.small ? ` ${css.small}` : '')
103
+ const action = node.action
104
+ // A button without an action (or without an action provider) is a
105
+ // display-only control: render it DISABLED so the affordance is honest
106
+ // — clickable-looking dead buttons were the top complaint in the field.
107
+ const interactive = action !== undefined && onAction !== undefined
108
+ return (
109
+ <ClickFeedbackButton
110
+ key={key}
111
+ className={cls}
112
+ disabled={!interactive}
113
+ onClick={interactive ? () => onAction(action, { type: 'button', label: node.label }) : undefined}
114
+ >
115
+ {node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
116
+ {node.label}
117
+ </ClickFeedbackButton>
118
+ )
119
+ }
120
+ case 'input': return <InputNode key={key} node={node} onAction={onAction} answers={answers} />
121
+ case 'select': return <SelectNode key={key} node={node} onAction={onAction} answers={answers} />
122
+ case 'checkbox': {
123
+ const action = node.action
124
+ return (
125
+ <label key={key} className={css.checkbox}>
126
+ <input
127
+ type="checkbox"
128
+ defaultChecked={node.checked === true}
129
+ onChange={action !== undefined && onAction !== undefined
130
+ ? e => onAction(action, { type: 'checkbox', checked: e.currentTarget.checked })
131
+ : undefined}
132
+ />
133
+ <span>{node.label}</span>
134
+ </label>
135
+ )
136
+ }
137
+ case 'link': {
138
+ // Honest affordance: with a whitelisted href this is a REAL anchor;
139
+ // without one it is plain styled text (a dead clickable-looking button
140
+ // was the same complaint class as the disabled-button fix).
141
+ const href = node.href
142
+ return href !== undefined
143
+ ? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{node.label}</a>
144
+ : <span key={key} className={css.linkText}>{node.label}</span>
145
+ }
146
+ case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
147
+ case 'video': return <VideoNode key={`${key}:${node.src}`} node={node} />
148
+ case 'badge': {
149
+ const tone = node.tone ?? ''
150
+ return (
151
+ <span key={key} className={`${css.badge} ${css[tone] || ''}`}>
152
+ {node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
153
+ {node.label}
154
+ </span>
155
+ )
156
+ }
157
+ case 'stat': {
158
+ const down = node.delta !== undefined && node.delta.startsWith('-')
159
+ return (
160
+ <div key={key} className={css.stat}>
161
+ <span className={css.statLabel}>{node.label}</span>
162
+ <span className={css.statValue}>{node.value}</span>
163
+ {node.delta !== undefined && <span className={`${css.statDelta} ${down ? css.down : css.up}`}>{node.delta}</span>}
164
+ </div>
165
+ )
166
+ }
167
+ case 'progress': {
168
+ const v = Math.max(0, Math.min(100, Number(node.value) || 0))
169
+ return (
170
+ <div
171
+ key={key}
172
+ className={css.progress}
173
+ role="progressbar"
174
+ aria-valuemin={0}
175
+ aria-valuemax={100}
176
+ aria-valuenow={v}
177
+ aria-label={node.label ?? node.valueLabel ?? undefined}
178
+ >
179
+ {(node.label !== undefined || node.valueLabel !== undefined) && (
180
+ <div className={css.progressRow}>
181
+ <span>{node.label}</span>
182
+ {node.valueLabel !== undefined && <span>{node.valueLabel}</span>}
183
+ </div>
184
+ )}
185
+ <div className={css.track}><div className={css.fill} style={{ width: `${v}%` }} /></div>
186
+ </div>
187
+ )
188
+ }
189
+ case 'divider': return <hr key={key} className={css.divider} />
190
+ case 'list': {
191
+ const items = node.items.slice(0, GENUI_LIMITS.maxListItems)
192
+ return (
193
+ <div key={key} className={css.list}>
194
+ {items.map((item, i) => (
195
+ <div key={i} className={css.li}>
196
+ {isListItemNode(item)
197
+ ? renderNode(item, i, onAction, depth + 1, answers)
198
+ : <><span className={css.liTitle}>{typeof item === 'string' ? item : item.title}</span>{typeof item !== 'string' && item.desc !== undefined && <span className={css.liDesc}>{item.desc}</span>}</>}
199
+ </div>
200
+ ))}
201
+ </div>
202
+ )
203
+ }
204
+ case 'table': return <TableNode key={key} node={node} />
205
+ case 'chart': return <ChartNode key={key} chart={node} />
206
+ case 'tabs': return <TabsNode key={key} tabs={node} onAction={onAction} depth={depth + 1} answers={answers} />
207
+ case 'avatar': {
208
+ return (
209
+ <div key={key} className={css.avatar} style={{ background: node.color ?? avatarColor(node.name) }}>
210
+ {node.name.slice(0, 1).toUpperCase()}
211
+ </div>
212
+ )
213
+ }
214
+ case 'spacer': return <div key={key} className={css.spacer} />
215
+ case 'plot': return <PlotNode key={key} plot={node} />
216
+ case 'callout': return <CalloutNode key={key} node={node} />
217
+ case 'steps': return <StepsNode key={key} steps={node} />
218
+ case 'keyvalue': return <KeyValueNode key={key} node={node} />
219
+ case 'diff': return <DiffNode key={key} node={node} />
220
+ case 'json': return <JsonNode key={key} node={node} />
221
+ case 'code': return <CodeNode key={key} node={node} />
222
+ case 'radio': return <RadioNode key={`${key}:r${answers?.round ?? 0}`} node={node} onAction={onAction} answers={answers} />
223
+ case 'submit': return <SubmitNode key={key} node={node} onAction={onAction} answers={answers} />
224
+ case 'switch': return <SwitchNode key={key} node={node} onAction={onAction} />
225
+ case 'slider': return <SliderNode key={key} node={node} onAction={onAction} answers={answers} />
226
+ case 'textarea': return <TextareaNode key={key} node={node} onAction={onAction} answers={answers} />
227
+ case 'accordion': return <AccordionNode key={key} node={node} onAction={onAction} depth={depth + 1} answers={answers} />
228
+ case 'copy': return <CopyNode key={key} node={node} />
229
+ case 'mermaid': return <MermaidNode key={key} node={node} />
230
+ case 'scene3d': return <Scene3DNode key={key} node={node} />
231
+ case 'timeline': return <TimelineNode key={key} node={node} />
232
+ case 'file-tree': return <FileTreeNode key={key} node={node} />
233
+ case 'breadcrumb': return <BreadcrumbNode key={key} node={node} />
234
+ case 'quiz': return <QuizNode key={key} node={node} onAction={onAction} />
235
+ case 'diagram': return <DiagramNode key={key} node={node} />
236
+
237
+ case 'echart': return <EChartNode key={key} node={node} />
238
+ default: {
239
+ // Plugin-registered custom types: a plugin ships a renderer through
240
+ // registerGenuiComponent; unregistered unknowns render nothing. The
241
+ // spec union is exhaustive, so an unknown node arrives as a plugin
242
+ // extension — treat it as a generic data node.
243
+ const custom = node as unknown as GenuiCustomNode
244
+ const Custom = getGenuiComponent?.(custom.type)
245
+ if (Custom !== undefined) {
246
+ return (
247
+ <Custom
248
+ key={key}
249
+ node={custom}
250
+ onAction={onAction}
251
+ renderChildren={(nodes, base) => nodes.map((c, i) => renderNode(c as GenuiNode, Number(base) + i, onAction, depth + 1, answers))}
252
+ />
253
+ )
254
+ }
255
+ return null
256
+ }
257
+ }
258
+ }
259
+
260
+ /* ---------------- v1.1 nodes ---------------- */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared block-level state types: the block props contract and the answers
3
+ * registry (grouped radios → submit grading). Lives OUTSIDE GenuiBlock.tsx so
4
+ * the per-family block modules can import the types without a cycle back
5
+ * into the block shell.
6
+ * @module @changfenhuang/dsh-genui/client/blocks/state
7
+ */
8
+ import type { GenuiSpec } from '../spec.ts'
9
+
10
+ export interface GenuiBlockProps {
11
+ /** Parsed spec to render. */
12
+ spec: GenuiSpec
13
+ /**
14
+ * v2: optional action callback. Interactive components carrying an
15
+ * `action` field fire it (button click, switch toggle, form submit);
16
+ * absent = components are display-only (v1 behavior).
17
+ */
18
+ onAction?: ((action: string, payload: Record<string, unknown>) => void) | undefined
19
+ /**
20
+ * v2.7: durable-state key (session + slot + content fingerprint). When set,
21
+ * interaction state (radio answers, submit lock, field values) persists to
22
+ * localStorage and restores on refresh / re-render of the same content.
23
+ */
24
+ stateKey?: string | undefined
25
+ }
26
+
27
+ /** Per-question metadata registered by grouped radios for local grading. */
28
+ export interface QuestionMeta {
29
+ label: string
30
+ options: string[]
31
+ /** Correct option: index (number) or label (string); absent = no local grading. */
32
+ answer?: number | string | undefined
33
+ /** Shown after local grading. */
34
+ explanation?: string | undefined
35
+ }
36
+
37
+ /** Block-wide answers registry (v2.5/v2.6): grouped radios record selections
38
+ * and register their question metadata here; a sibling `submit` node either
39
+ * grades IN PLACE (questions carry `answer` data) or collects everything
40
+ * into ONE action. Lives in GenuiBlock state so re-renders keep the
41
+ * selections, threaded down through the recursive render walk. Answers are
42
+ * plain group → chosen-option-label strings (the display label lives in
43
+ * QuestionMeta; localStorage stores the same string table — no migration). */
44
+ export interface AnswersState {
45
+ answers: Record<string, string>
46
+ /** Field values by id (input/textarea with an `id`), collected by submit. */
47
+ fields: Record<string, string>
48
+ /** Field ids whose value must never be persisted or collected (secrets). */
49
+ secretFields: ReadonlySet<string>
50
+ meta: Record<string, QuestionMeta>
51
+ /** True after a local grading: questions are locked until 重新作答. */
52
+ locked: boolean
53
+ /** Bumped by every reset; radios use it as their remount key. */
54
+ round: number
55
+ setAnswer: (group: string, choice: string) => void
56
+ setField: (id: string, value: string) => void
57
+ registerSecretField: (id: string) => void
58
+ registerMeta: (group: string, meta: QuestionMeta) => void
59
+ clear: () => void
60
+ setLocked: (locked: boolean) => void
61
+ }