@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,435 @@
1
+ /**
2
+ * Advanced family: callout/steps/keyvalue, plot/diff/json/code, tabs and
3
+ * accordion containers (recursing through renderNode), copy, mermaid,
4
+ * scene3d, timeline, file-tree, quiz, breadcrumb.
5
+ * @module @changfenhuang/dsh-genui/client/blocks/advanced
6
+ */
7
+ import { memo, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
8
+ import { CodeBlock, DiffBlock, JsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
9
+ import css from '../GenuiBlock.module.css'
10
+ import { GENUI_LIMITS } from '../guard.ts'
11
+ import { PlotBlock } from '../PlotBlock.tsx'
12
+ import { renderNode } from './render-node.tsx'
13
+ import type { AnswersState, GenuiBlockProps } from './state.ts'
14
+ import type {
15
+ GenuiAccordion, GenuiBreadcrumb, GenuiCallout, GenuiCode, GenuiCopy, GenuiDiff, GenuiFileTree, GenuiFileTreeNode,
16
+ GenuiJson, GenuiKeyValue, GenuiMermaid, GenuiPlot, GenuiQuiz, GenuiScene3D, GenuiSteps, GenuiTabs, GenuiTimeline,
17
+ } from '../spec.ts'
18
+
19
+ const CALLOUT_TONES: Record<string, string> = {
20
+ info: css.calloutInfo!, success: css.calloutSuccess!, warning: css.calloutWarning!, error: css.calloutError!,
21
+ }
22
+
23
+ /** Callout: a tinted notice box with an optional heading. */
24
+ export const CalloutNode = memo(function CalloutNode({ node }: { node: GenuiCallout }) {
25
+ const tone = node.tone ?? 'info'
26
+ const toneClass = CALLOUT_TONES[tone] ?? css.calloutInfo
27
+ return (
28
+ <div className={`${css.callout} ${toneClass}`} data-genui-callout>
29
+ {node.title !== undefined && <div className={css.calloutTitle}>{node.title}</div>}
30
+ <div className={css.calloutBody}>{node.content}</div>
31
+ </div>
32
+ )
33
+ })
34
+
35
+ /** Steps: a vertical progress checklist with an optional current index. */
36
+ export const StepsNode = memo(function StepsNode({ steps }: { steps: GenuiSteps }) {
37
+ const list = steps.steps.slice(0, GENUI_LIMITS.maxSteps)
38
+ const current = steps.current ?? list.length
39
+ return (
40
+ <ol className={css.steps}>
41
+ {list.map((step, i) => {
42
+ const done = i < current
43
+ const active = i === current
44
+ return (
45
+ <li key={i} className={`${css.step} ${done ? css.stepDone : ''} ${active ? css.stepActive : ''}`}>
46
+ <span className={css.stepMarker}>{done ? '✓' : String(i + 1)}</span>
47
+ <span className={css.stepContent}>
48
+ <span className={css.stepTitle}>{step.title}</span>
49
+ {step.desc !== undefined && <span className={css.stepDesc}>{step.desc}</span>}
50
+ </span>
51
+ </li>
52
+ )
53
+ })}
54
+ </ol>
55
+ )
56
+ })
57
+
58
+ /** KeyValue: a definition list for configs and metadata. */
59
+ export const KeyValueNode = memo(function KeyValueNode({ node }: { node: GenuiKeyValue }) {
60
+ const pairs = node.pairs.slice(0, GENUI_LIMITS.maxKeyValuePairs)
61
+ return (
62
+ <dl className={css.keyvalue}>
63
+ {pairs.map((pair, i) => (
64
+ <div key={i} className={css.kvRow}>
65
+ <dt className={css.kvKey}>{pair.key}</dt>
66
+ <dd className={css.kvValue}>{pair.value}</dd>
67
+ </div>
68
+ ))}
69
+ </dl>
70
+ )
71
+ })
72
+
73
+ /** Plot: SVG function plot over the SafeMath evaluator. The series mapping
74
+ * is memoized on the stable spec node so the memo boundary actually skips
75
+ * sibling-state re-renders (a fresh mapped array would defeat it). */
76
+ export const PlotNode = memo(function PlotNode({ plot }: { plot: GenuiPlot }) {
77
+ const series = useMemo(
78
+ () => plot.series.slice(0, GENUI_LIMITS.maxPlotSeries)
79
+ .map(s => ({ expr: s.expr, label: s.label, color: s.color, kind: s.kind, params: s.params })),
80
+ [plot],
81
+ )
82
+ return (
83
+ <PlotBlock
84
+ series={series}
85
+ xMin={plot.xMin} xMax={plot.xMax} yMin={plot.yMin} yMax={plot.yMax} title={plot.title}
86
+ />
87
+ )
88
+ })
89
+
90
+ /** Diff: 收编 dsh DiffBlock (same path/oldText/newText shape as DiffHunk). */
91
+ export const DiffNode = memo(function DiffNode({ node }: { node: GenuiDiff }) {
92
+ return <DiffBlock diffs={node.diffs} />
93
+ })
94
+
95
+ /** Json: 收编 dsh JsonTree. */
96
+ export const JsonNode = memo(function JsonNode({ node }: { node: GenuiJson }) {
97
+ const data = node.value
98
+ if (typeof data !== 'object' || data === null) {
99
+ return <div className={css.jsonScalar}>{String(data)}</div>
100
+ }
101
+ return <JsonTree data={data as object | unknown[]} copyable />
102
+ })
103
+
104
+ /** Code: 收编 dsh CodeBlock with explicit language. */
105
+ export const CodeNode = memo(function CodeNode({ node }: { node: GenuiCode }) {
106
+ return <CodeBlock code={node.code.slice(0, GENUI_LIMITS.maxCode)} lang={node.lang} />
107
+ })
108
+
109
+ /**
110
+ * Table: LOCAL sorting (v2.9) — click a header to sort ascending, click
111
+ * again for descending, a third click restores the spec order. Zero model
112
+ * round trip. Numeric cells (numbers or numeric strings) compare numerically;
113
+ * everything else compares as text.
114
+ */
115
+ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
116
+ tabs: GenuiTabs
117
+ onAction?: GenuiBlockProps['onAction']
118
+ depth?: number
119
+ answers?: AnswersState | undefined
120
+ }) {
121
+ const [active, setActive] = useState(0)
122
+ const uid = useId()
123
+ const list = tabs.tabs.slice(0, GENUI_LIMITS.maxTabs)
124
+ const current = list[active]
125
+ const move = (next: number): void => {
126
+ const n = (next + list.length) % list.length
127
+ setActive(n)
128
+ document.getElementById(`${uid}-tab-${n}`)?.focus()
129
+ }
130
+ return (
131
+ <div className={css.tabs}>
132
+ <div
133
+ className={css.tabBar}
134
+ role="tablist"
135
+ aria-orientation="horizontal"
136
+ onKeyDown={e => {
137
+ if (e.key === 'ArrowRight') { e.preventDefault(); move(active + 1) }
138
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); move(active - 1) }
139
+ else if (e.key === 'Home') { e.preventDefault(); move(0) }
140
+ else if (e.key === 'End') { e.preventDefault(); move(list.length - 1) }
141
+ }}
142
+ >
143
+ {list.map((tab, i) => (
144
+ <button
145
+ key={i}
146
+ id={`${uid}-tab-${i}`}
147
+ type="button"
148
+ role="tab"
149
+ aria-selected={i === active}
150
+ aria-controls={`${uid}-panel-${i}`}
151
+ tabIndex={i === active ? 0 : -1}
152
+ className={`${css.tab} ${i === active ? css.tabActive : ''}`}
153
+ onClick={() => setActive(i)}
154
+ >
155
+ {tab.label}
156
+ </button>
157
+ ))}
158
+ </div>
159
+ {current !== undefined && (
160
+ <div className={css.col} role="tabpanel" id={`${uid}-panel-${active}`} aria-labelledby={`${uid}-tab-${active}`}>
161
+ {current.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
162
+ </div>
163
+ )}
164
+ </div>
165
+ )
166
+ }
167
+
168
+ /** Radio: one option from a group; local selection state. The group name is
169
+ * useId-based so sibling groups never collide (deterministic per mount).
170
+ *
171
+ * v2.5 aggregation: when `group` is set, the selection is recorded into the
172
+ * block-wide answers registry instead of firing a per-click action — a
173
+ * sibling `submit` node then grades the paper IN PLACE (v2.6, questions
174
+ * carry `answer` data) or collects all groups in ONE action. Without
175
+ * `group`, the legacy per-click action fires. After a local grading the
176
+ * group locks until 重新作答 resets it. */
177
+ export function AccordionNode({ node, onAction, depth = 0, answers }: {
178
+ node: GenuiAccordion
179
+ onAction?: GenuiBlockProps['onAction']
180
+ depth?: number
181
+ answers?: AnswersState | undefined
182
+ }) {
183
+ const [open, setOpen] = useState<number | null>(0)
184
+ const uid = useId()
185
+ const items = node.items.slice(0, GENUI_LIMITS.maxAccordionItems)
186
+ return (
187
+ <div className={css.accordion}>
188
+ {items.map((item, i) => (
189
+ <div key={i} className={css.accItem}>
190
+ <button
191
+ type="button"
192
+ className={css.accHead}
193
+ id={`${uid}-head-${i}`}
194
+ aria-expanded={open === i}
195
+ aria-controls={`${uid}-body-${i}`}
196
+ onClick={() => setOpen(open === i ? null : i)}
197
+ >
198
+ <span className={css.accTitle}>{item.title}</span>
199
+ <span className={css.accChevron}>{open === i ? '▾' : '▸'}</span>
200
+ </button>
201
+ {open === i && (
202
+ <div className={css.accBody} id={`${uid}-body-${i}`} aria-labelledby={`${uid}-head-${i}`}>
203
+ {item.items.map((c, ci) => renderNode(c, ci, onAction, depth + 1, answers))}
204
+ </div>
205
+ )}
206
+ </div>
207
+ ))}
208
+ </div>
209
+ )
210
+ }
211
+
212
+ /** Copy: a one-click copy chip. The live region is a visually-hidden SIBLING
213
+ * (not inside the button) — button content is atomic to screen readers, so a
214
+ * live region inside it would never announce. */
215
+ export const CopyNode = memo(function CopyNode({ node }: { node: GenuiCopy }) {
216
+ const [copied, setCopied] = useState(false)
217
+ return (
218
+ <>
219
+ <button
220
+ type="button"
221
+ className={`${css.copyChip} ${copied ? css.copyChipDone : ''}`}
222
+ onClick={() => {
223
+ void navigator.clipboard?.writeText(node.text).catch(() => {})
224
+ setCopied(true)
225
+ setTimeout(() => setCopied(false), 1500)
226
+ }}
227
+ >
228
+ {copied ? '✓ 已复制' : (node.label ?? '复制')}
229
+ </button>
230
+ <span className={css.visuallyHidden} role="status">{copied ? '已复制到剪贴板' : ''}</span>
231
+ </>
232
+ )
233
+ })
234
+
235
+ /** Mermaid: lazily loaded diagram renderer. */
236
+ export const MermaidNode = memo(function MermaidNode({ node }: { node: GenuiMermaid }) {
237
+ const [html, setHtml] = useState<string | null>(null)
238
+ const [failed, setFailed] = useState(false)
239
+ const code = node.code.slice(0, GENUI_LIMITS.maxMermaid)
240
+ useEffect(() => {
241
+ let alive = true
242
+ void import('../mermaid-lazy.ts').then(async m => {
243
+ try {
244
+ const svg = await m.renderMermaid(code)
245
+ if (alive) setHtml(svg)
246
+ } catch {
247
+ if (alive) setFailed(true)
248
+ }
249
+ })
250
+ return () => { alive = false }
251
+ }, [code])
252
+ if (failed) return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidErr}>图语法有误,已降级显示源码</div></div>
253
+ if (html === null) return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidHint}>渲染中…</div></div>
254
+ return <div className={css.mermaid} dangerouslySetInnerHTML={{ __html: html }} data-genui-mermaid />
255
+ })
256
+
257
+ /** Scene3D: three.js WebGL canvas, lazily imported. */
258
+ export const Scene3DNode = memo(function Scene3DNode({ node }: { node: GenuiScene3D }) {
259
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
260
+ const ref = useRef<HTMLDivElement | null>(null)
261
+ // Mesh cap mirrored from the guard: a pathological scene never reaches
262
+ // three.js (per-frame cost scales with mesh count).
263
+ const scene = node.meshes.length > GENUI_LIMITS.maxMeshes ? { ...node, meshes: node.meshes.slice(0, GENUI_LIMITS.maxMeshes) } : node
264
+ useEffect(() => {
265
+ let alive = true
266
+ let dispose: (() => void) | undefined
267
+ void import('../scene3d-lazy.ts').then(async m => {
268
+ if (!alive || ref.current === null) return
269
+ try {
270
+ dispose = await m.mountScene(ref.current, scene)
271
+ if (alive) setStatus('ready')
272
+ } catch {
273
+ if (alive) setStatus('error')
274
+ }
275
+ })
276
+ return () => { alive = false; dispose?.() }
277
+ }, [scene])
278
+ return (
279
+ <div className={css.scene3dWrap} data-genui-scene3d>
280
+ {node.title !== undefined && <div className={css.scene3dTitle}>{node.title}</div>}
281
+ <div ref={ref} className={css.scene3dCanvas} />
282
+ {status === 'loading' && <div className={css.scene3dHint}>加载 3D 场景…</div>}
283
+ {status === 'error' && <div className={css.scene3dHint}>3D 渲染失败</div>}
284
+ </div>
285
+ )
286
+ })
287
+
288
+ /** Timeline: vertical event list with time markers. */
289
+ export const TimelineNode = memo(function TimelineNode({ node }: { node: GenuiTimeline }) {
290
+ const items = node.items.slice(0, GENUI_LIMITS.maxTimelineItems)
291
+ return (
292
+ <div className={css.timeline}>
293
+ {items.map((item, i) => (
294
+ <div key={i} className={css.tlItem}>
295
+ <div className={css.tlRail}>
296
+ <span className={css.tlDot} />
297
+ {i < items.length - 1 && <span className={css.tlLine} />}
298
+ </div>
299
+ <div className={css.tlBody}>
300
+ <div className={css.tlHead}>
301
+ <span className={css.tlTitle}>{item.title}</span>
302
+ {item.time !== undefined && <span className={css.tlTime}>{item.time}</span>}
303
+ </div>
304
+ {item.desc !== undefined && <div className={css.tlDesc}>{item.desc}</div>}
305
+ </div>
306
+ </div>
307
+ ))}
308
+ </div>
309
+ )
310
+ })
311
+
312
+ /** FileTree: indented tree of files and folders. Directory rows are LOCAL
313
+ * collapsible (spec.ts promised "collapsible children"; this makes it true)
314
+ * — click a dir to fold/unfold, default fully open. Zero model round trip. */
315
+ export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFileTree }) {
316
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
317
+ const pathKey = (depth: number, i: number): string => `${depth}-${i}`
318
+ const toggle = (k: string): void => {
319
+ setCollapsed(prev => {
320
+ const next = new Set(prev)
321
+ if (next.has(k)) next.delete(k)
322
+ else next.add(k)
323
+ return next
324
+ })
325
+ }
326
+ const renderNode = (n: GenuiFileTreeNode, depth: number, i: number): ReactNode => {
327
+ if (depth > GENUI_LIMITS.maxTreeDepth) return null
328
+ const isDir = n.type === 'dir' || (n.children !== undefined && n.children.length > 0)
329
+ const k = pathKey(depth, i)
330
+ const folded = isDir && collapsed.has(k)
331
+ return (
332
+ <div key={k} className={css.ftRow} style={{ paddingLeft: `${depth * 16}px` }}>
333
+ <button
334
+ type="button"
335
+ className={css.ftNameBtn}
336
+ aria-expanded={isDir ? !folded : undefined}
337
+ onClick={isDir ? () => toggle(k) : undefined}
338
+ >
339
+ <span className={`${css.ftIcon} ${isDir ? css.ftIconDir : ''}`} aria-hidden>{isDir ? (folded ? '▸' : '▾') : '·'}</span>
340
+ <span className={`${css.ftName} ${isDir ? css.ftDir : ''}`}>{n.name}</span>
341
+ </button>
342
+ {isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, ci))}
343
+ </div>
344
+ )
345
+ }
346
+ return <div className={css.fileTree}>{node.items.slice(0, GENUI_LIMITS.maxListItems).map((n, i) => renderNode(n, 0, i))}</div>
347
+ })
348
+
349
+ /** Quiz: a self-contained teaching question. Selecting an option marks it
350
+ * correct/incorrect in place and reveals feedback + explanation. With
351
+ * `action`, the chosen answer is ALSO sent back to the model
352
+ * (`{type:'quiz', question, answer, correct}`) so the model can collect or
353
+ * grade it — the in-place judging stays local (no round trip needed). */
354
+ export const QuizNode = memo(function QuizNode({ node, onAction }: {
355
+ node: GenuiQuiz
356
+ onAction?: GenuiBlockProps['onAction']
357
+ }) {
358
+ const [selected, setSelected] = useState<number | null>(null)
359
+ const options = node.options.slice(0, GENUI_LIMITS.maxQuizOptions)
360
+ const answered = selected !== null
361
+ const chosen = selected === null ? undefined : options[selected]
362
+ const correct = chosen?.correct === true
363
+ const action = node.action
364
+ return (
365
+ <div className={css.quiz} data-genui-quiz>
366
+ <div className={css.quizQuestion}>{node.question}</div>
367
+ <div className={css.quizOptions}>
368
+ {options.map((opt, i) => {
369
+ const isChosen = selected === i
370
+ const cls = answered
371
+ ? isChosen
372
+ ? opt.correct === true ? css.quizOptCorrect : css.quizOptWrong
373
+ : opt.correct === true ? css.quizOptReveal : css.quizOpt
374
+ : css.quizOpt
375
+ return (
376
+ <button
377
+ key={i}
378
+ type="button"
379
+ className={cls}
380
+ disabled={answered}
381
+ onClick={() => {
382
+ setSelected(i)
383
+ if (action !== undefined && onAction !== undefined) {
384
+ onAction(action, {
385
+ type: 'quiz',
386
+ question: node.question,
387
+ answer: opt.label,
388
+ correct: opt.correct === true,
389
+ })
390
+ }
391
+ }}
392
+ >
393
+ <span className={css.quizMarker}>{answered && (opt.correct === true ? '✓' : isChosen ? '✗' : '')}</span>
394
+ {opt.label}
395
+ </button>
396
+ )
397
+ })}
398
+ </div>
399
+ {answered && (
400
+ <div className={css.quizResult} aria-live="polite">
401
+ <div className={correct ? css.quizCorrectMsg : css.quizWrongMsg}>
402
+ {correct ? '✓ 回答正确!' : '✗ 再想想看'}
403
+ {chosen?.feedback !== undefined && <div className={css.quizFeedback}>{chosen.feedback}</div>}
404
+ </div>
405
+ {node.explanation !== undefined && <div className={css.quizExplanation}>{node.explanation}</div>}
406
+ <button type="button" className={css.quizRetry} onClick={() => setSelected(null)}>重新作答</button>
407
+ </div>
408
+ )}
409
+ </div>
410
+ )
411
+ })
412
+
413
+ /** Breadcrumb: path-style navigation trail. */
414
+ export const BreadcrumbNode = memo(function BreadcrumbNode({ node }: { node: GenuiBreadcrumb }) {
415
+ const items = node.items.slice(0, GENUI_LIMITS.maxBreadcrumbItems)
416
+ return (
417
+ <nav className={css.breadcrumb} aria-label="breadcrumb">
418
+ {items.map((item, i) => (
419
+ <span key={i} className={css.bcItem}>
420
+ <span className={`${css.bcText} ${i === items.length - 1 ? css.bcCurrent : ''}`}>{item}</span>
421
+ {i < items.length - 1 && <span className={css.bcSep}>/</span>}
422
+ </span>
423
+ ))}
424
+ </nav>
425
+ )
426
+ })
427
+
428
+ /**
429
+ * Trailing debounce window (ms) for one `[genui-action]` name: rapid
430
+ * repeated interactions on one control (button mashing, switch flipping)
431
+ * collapse into a single action with the LAST payload. Different action
432
+ * names stay independent. The model round-trip takes seconds, so a few
433
+ * hundred ms of trailing delay is imperceptible — and it stops bursts of
434
+ * queued user turns.
435
+ */
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Basic display family: avatar palette, and the local click-feedback button
3
+ * (the actionable-button chip). Used by the render dispatcher.
4
+ * @module @changfenhuang/dsh-genui/client/blocks/basic
5
+ */
6
+ import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
7
+ import css from '../GenuiBlock.module.css'
8
+ import type { GenuiAudio, GenuiVideo } from '../spec.ts'
9
+
10
+ /** Deterministic avatar color by name hash. Host static tokens ONLY —
11
+ * design system v2: no off-brand hexes, the palette always matches the
12
+ * theme's families (deepseek/blue/green/amber/red/neutral). */
13
+ const AVATAR_COLORS = [
14
+ 'var(--dsw-static-deepseek-400)',
15
+ 'var(--dsw-static-deepseek-450)',
16
+ 'var(--dsw-static-blue-450)',
17
+ 'var(--dsw-static-green-400)',
18
+ 'var(--dsw-static-amber-400)',
19
+ 'var(--dsw-static-red-400)',
20
+ 'var(--dsw-static-deepseek-300)',
21
+ 'var(--dsw-static-neutral-bluish-400)',
22
+ ]
23
+
24
+ export function avatarColor(name: string): string {
25
+ let h = 0
26
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
27
+ // The array is a literal with 8 entries; the index is always in range.
28
+ return AVATAR_COLORS[h % AVATAR_COLORS.length]!
29
+ }
30
+
31
+ /** Button with LOCAL click feedback: clicking an actionable button shows a
32
+ * brief "✓ 已触发" chip so the user sees the click registered even while the
33
+ * model round trip is in flight — no more "点了没反应" perception. The chip
34
+ * is purely cosmetic; the action fires through `onClick` as before. */
35
+ export function ClickFeedbackButton({ className, disabled, onClick, children }: {
36
+ className: string
37
+ disabled?: boolean
38
+ onClick?: (() => void) | undefined
39
+ children: ReactNode
40
+ }) {
41
+ const [sent, setSent] = useState(false)
42
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
43
+ useEffect(() => {
44
+ return () => {
45
+ if (timer.current !== null) clearTimeout(timer.current)
46
+ }
47
+ }, [])
48
+ return (
49
+ <>
50
+ <button
51
+ type="button"
52
+ className={className}
53
+ disabled={disabled}
54
+ onClick={onClick === undefined ? undefined : () => {
55
+ onClick()
56
+ if (timer.current !== null) clearTimeout(timer.current)
57
+ setSent(true)
58
+ timer.current = setTimeout(() => setSent(false), 1400)
59
+ }}
60
+ >
61
+ {children}
62
+ {sent && <span className={css.btnSent} aria-hidden>✓ 已触发</span>}
63
+ </button>
64
+ {/* Live-region sibling: button content is atomic to screen readers, so
65
+ * the "已触发" confirmation announces from a hidden status region. */}
66
+ <span className={css.visuallyHidden} role="status">{sent ? '已触发' : ''}</span>
67
+ </>
68
+ )
69
+ }
70
+
71
+ /** Native controls intentionally own play/pause/seek/volume. Model-authored
72
+ * autoplay and controls hints are ignored: media starts only after the user
73
+ * asks for it. */
74
+ export const AudioNode = memo(function AudioNode({ node }: { node: GenuiAudio }): ReactNode {
75
+ const [failed, setFailed] = useState(false)
76
+ return (
77
+ <figure className={css.media}>
78
+ {node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
79
+ {failed
80
+ ? <div className={css.mediaError} role="alert">音频无法播放</div>
81
+ : <audio
82
+ className={css.mediaPlayer}
83
+ src={node.src}
84
+ aria-label={node.alt ?? '音频'}
85
+ controls
86
+ preload="metadata"
87
+ loop={node.loop === true}
88
+ onError={() => setFailed(true)}
89
+ />}
90
+ </figure>
91
+ )
92
+ })
93
+
94
+ export const VideoNode = memo(function VideoNode({ node }: { node: GenuiVideo }): ReactNode {
95
+ const [failed, setFailed] = useState(false)
96
+ return (
97
+ <figure className={css.media}>
98
+ {node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
99
+ {failed
100
+ ? <div className={css.mediaError} role="alert">视频无法播放</div>
101
+ : <video
102
+ className={`${css.mediaPlayer} ${css.videoPlayer}`}
103
+ src={node.src}
104
+ poster={node.poster}
105
+ aria-label={node.alt ?? '视频'}
106
+ controls
107
+ preload="metadata"
108
+ playsInline
109
+ loop={node.loop === true}
110
+ muted={node.muted === true}
111
+ style={node.aspectRatio === undefined ? undefined : { aspectRatio: node.aspectRatio.replace(':', ' / ') }}
112
+ onError={() => setFailed(true)}
113
+ />}
114
+ </figure>
115
+ )
116
+ })