@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.
- package/CHANGELOG.md +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- 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
|
+
})
|