@changfenhuang/dsh-genui 0.9.9 → 0.10.0

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 (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/SKILL.md +128 -6
  5. package/lib/assets/echarts-core.js +43 -0
  6. package/lib/assets/{echarts.js → echarts-full.js} +1 -1
  7. package/lib/assets/mermaid.js +1 -1
  8. package/lib/client.js +5 -3
  9. package/lib/index.js +230 -69
  10. package/lib/types/client/EChartNode.d.ts +9 -0
  11. package/lib/types/client/GenuiBlock.d.ts +3 -4
  12. package/lib/types/client/asset-echarts-core.d.ts +1 -0
  13. package/lib/types/client/asset-echarts.d.ts +3 -2
  14. package/lib/types/client/asset-loader.d.ts +2 -2
  15. package/lib/types/client/blocks/advanced.d.ts +0 -3
  16. package/lib/types/client/blocks/charts.d.ts +23 -12
  17. package/lib/types/client/blocks/state.d.ts +5 -3
  18. package/lib/types/client/echarts-lazy.d.ts +4 -1
  19. package/lib/types/client/genui-runtime/schema.d.ts +11 -1
  20. package/lib/types/client/inline.d.ts +29 -0
  21. package/lib/types/client/spec.d.ts +76 -6
  22. package/lib/types/plugin/index.d.ts +1 -1
  23. package/package.json +4 -3
  24. package/src/client/EChartNode.tsx +233 -19
  25. package/src/client/GenuiBlock.module.css +895 -145
  26. package/src/client/GenuiBlock.tsx +31 -23
  27. package/src/client/PlotBlock.module.css +17 -1
  28. package/src/client/PlotBlock.tsx +33 -1
  29. package/src/client/asset-echarts-core.ts +39 -0
  30. package/src/client/asset-echarts.ts +4 -3
  31. package/src/client/asset-loader.ts +5 -3
  32. package/src/client/blocks/advanced.tsx +60 -16
  33. package/src/client/blocks/charts.tsx +761 -144
  34. package/src/client/blocks/render-node.tsx +252 -11
  35. package/src/client/blocks/state.ts +5 -3
  36. package/src/client/dom-fence.tsx +60 -6
  37. package/src/client/echarts-lazy.ts +11 -2
  38. package/src/client/fence-render.tsx +6 -5
  39. package/src/client/gallery.ts +78 -2
  40. package/src/client/genui-runtime/schema.ts +45 -11
  41. package/src/client/guard.ts +161 -23
  42. package/src/client/index.tsx +14 -3
  43. package/src/client/inline.ts +73 -0
  44. package/src/client/mermaid-core.ts +42 -5
  45. package/src/client/spec.ts +79 -6
  46. package/src/plugin/index.ts +33 -45
  47. package/src/plugin/tool.ts +5 -1
  48. package/tsdown.config.ts +2 -1
@@ -4,10 +4,11 @@
4
4
  * the sibling block modules. Depth-guarded against pathological specs.
5
5
  * @module @changfenhuang/dsh-genui/client/blocks/render-node
6
6
  */
7
- import { type ReactNode, type ComponentType } from 'react'
7
+ import { type ComponentType, type ReactNode, useEffect, useState, type CSSProperties } from 'react'
8
8
  import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
9
9
  import css from '../GenuiBlock.module.css'
10
10
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
11
+ import { renderInline } from '../inline.ts'
11
12
  import type { GenuiList, GenuiNode } from '../spec.ts'
12
13
  import type { AnswersState, GenuiBlockProps } from './state.ts'
13
14
  import { AudioNode, avatarColor, ClickFeedbackButton, VideoNode } from './basic.tsx'
@@ -48,6 +49,142 @@ function isListItemNode(item: GenuiList['items'][number]): item is GenuiNode {
48
49
  return typeof item === 'object' && item !== null && 'type' in item
49
50
  }
50
51
 
52
+ /**
53
+ * Split a stat value into its numeric core and unit suffix so the unit can
54
+ * render at half size on the same baseline (`99.96%` → `99.96` + `%`,
55
+ * `6.8 GB` → `6.8` + `GB`). Values that do not start with a number are left
56
+ * whole.
57
+ */
58
+ function splitStatValue(value: string): {
59
+ num: string
60
+ unit: string
61
+ /** Parsed number when the numeric core is a plain decimal (for count-up). */
62
+ numeric: number | undefined
63
+ /** Decimal places to keep while animating. */
64
+ decimals: number
65
+ } {
66
+ const match = /^([+\-−]?[\d.,]+(?:[eE][+\-]?\d+)?)\s*(.*)$/.exec(value.trim())
67
+ if (match === null || match[1] === undefined) return { num: value, unit: '', numeric: undefined, decimals: 0 }
68
+ const raw = match[1]
69
+ const plain = raw.replace(/[,,]/g, '')
70
+ const parsed = Number(plain)
71
+ // Commas are thousands separators in a number, but a grouped metric like
72
+ // "1,2" is not worth animating — keep it static.
73
+ const numeric = Number.isFinite(parsed) && !/[eE]/.test(plain) ? parsed : undefined
74
+ const decimals = plain.includes('.') ? (plain.split('.')[1] ?? '').length : 0
75
+ return { num: raw, unit: match[2] ?? '', numeric, decimals: Math.min(decimals, 4) }
76
+ }
77
+
78
+ /**
79
+ * Micro trend line for `stat.spark`: a 96×24 box stretched to the card width,
80
+ * with an area wash under the line and a constant-size end dot (a zero-length
81
+ * round-capped path with non-scaling stroke, so the stretched viewBox cannot
82
+ * turn the dot into an ellipse).
83
+ */
84
+ function Sparkline({ values }: { values: number[] }) {
85
+ const W = 96
86
+ const H = 24
87
+ const pad = 2
88
+ const min = Math.min(...values)
89
+ const max = Math.max(...values)
90
+ const span = max - min || 1
91
+ const step = values.length <= 1 ? 0 : (W - pad * 2) / (values.length - 1)
92
+ const coords = values.map((v, i) => [
93
+ pad + i * step,
94
+ H - pad - ((v - min) / span) * (H - pad * 2),
95
+ ] as const)
96
+ const points = coords.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(' ')
97
+ const last = coords[coords.length - 1]!
98
+ const area = `M ${pad},${H - pad} L ${points.split(' ').join(' L ')} L ${last[0].toFixed(1)},${H - pad} Z`
99
+ return (
100
+ <svg className={css.statSpark} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
101
+ <path d={area} fill="var(--dsl-g-accent)" opacity="0.14" />
102
+ <polyline
103
+ points={points}
104
+ fill="none"
105
+ stroke="var(--dsl-g-accent)"
106
+ strokeWidth="1.5"
107
+ strokeLinejoin="round"
108
+ strokeLinecap="round"
109
+ vectorEffect="non-scaling-stroke"
110
+ />
111
+ <path
112
+ d={`M ${last[0].toFixed(1)} ${last[1].toFixed(1)} L ${last[0].toFixed(1)} ${last[1].toFixed(1)}`}
113
+ stroke="var(--dsl-g-accent)"
114
+ strokeWidth={5}
115
+ strokeLinecap="round"
116
+ vectorEffect="non-scaling-stroke"
117
+ fill="none"
118
+ />
119
+ </svg>
120
+ )
121
+ }
122
+
123
+ /** Live value of a bound control (`node.filter` / `node.sortField`): the field id
124
+ * resolves against the block's shared field registry, so typing in an input
125
+ * re-renders every component bound to it. */
126
+ /**
127
+ * Animate a metric from 0 to its target once on mount. Only used for values
128
+ * that are pure numbers with an optional unit — counting up "v0.9.9" would be
129
+ * nonsense, and prefers-reduced-motion turns it off entirely.
130
+ */
131
+ function useCountUp(target: number, decimals: number, enabled: boolean): string {
132
+ const [value, setValue] = useState(enabled ? 0 : target)
133
+ useEffect(() => {
134
+ if (!enabled) { setValue(target); return }
135
+ const reduce = typeof window !== 'undefined'
136
+ && typeof window.matchMedia === 'function'
137
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches
138
+ if (reduce) { setValue(target); return }
139
+ let raf = 0
140
+ const start = performance.now()
141
+ const duration = 720
142
+ const tick = (now: number): void => {
143
+ const t = Math.min(1, (now - start) / duration)
144
+ const eased = 1 - Math.pow(1 - t, 3)
145
+ setValue(target * eased)
146
+ if (t < 1) raf = requestAnimationFrame(tick)
147
+ }
148
+ raf = requestAnimationFrame(tick)
149
+ return () => cancelAnimationFrame(raf)
150
+ }, [target, enabled])
151
+ return value.toFixed(decimals)
152
+ }
153
+
154
+ /** Animated metric text: splits the value into number + unit and counts the
155
+ * number up once. A component (not a helper) so the hook has a stable owner —
156
+ * renderNode's switch must never call hooks conditionally. */
157
+ function AnimatedValue({ value, unitClass, animate = false }: {
158
+ value: string
159
+ unitClass?: string | undefined
160
+ /** Only hero-scale numbers count up: a grid of eight stats all ticking at
161
+ * once reads as a gimmick, not as polish. */
162
+ animate?: boolean | undefined
163
+ }) {
164
+ const parts = splitStatValue(value)
165
+ const target = parts.numeric
166
+ const animated = useCountUp(target ?? 0, parts.decimals, animate && target !== undefined)
167
+ return (
168
+ <>
169
+ {target === undefined ? parts.num : animated}
170
+ {parts.unit !== '' && <span className={unitClass ?? css.statUnit}>{parts.unit}</span>}
171
+ </>
172
+ )
173
+ }
174
+
175
+ /** Plain text of a list item, for the local filter. */
176
+ function listItemText(item: GenuiList['items'][number]): string {
177
+ if (typeof item === 'string') return item
178
+ if (isListItemNode(item)) return ''
179
+ return `${item.title} ${item.desc ?? ''}`
180
+ }
181
+
182
+ function boundField(answers: AnswersState | undefined, id: string | undefined): string | undefined {
183
+ if (id === undefined) return undefined
184
+ const value = answers?.fields[id]
185
+ return value === undefined || value === '' ? undefined : value
186
+ }
187
+
51
188
  export function renderNode(
52
189
  node: GenuiNode,
53
190
  key: number,
@@ -66,7 +203,7 @@ export function renderNode(
66
203
  const size = node.size ?? 'body'
67
204
  return (
68
205
  <div key={key} className={`${css.text} ${css[size]}` + (node.center ? ` ${css.center}` : '')}>
69
- {node.content}
206
+ {renderInline(node.content)}
70
207
  </div>
71
208
  )
72
209
  }
@@ -88,13 +225,33 @@ export function renderNode(
88
225
  case 'grid': {
89
226
  return (
90
227
  <div key={key} className={css.grid} style={{ gridTemplateColumns: `repeat(${Math.max(1, node.cols)}, minmax(0, 1fr))` }}>
91
- {node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
228
+ {/* `span` is the bento primitive: a child can occupy several columns,
229
+ so one wide hero card can sit next to two narrow ones. */}
230
+ {node.items.map((c, i) => {
231
+ const child = renderNode(c, i, onAction, depth + 1, answers)
232
+ const span = typeof c === 'object' && c !== null && typeof (c as { span?: unknown }).span === 'number'
233
+ ? Math.max(1, Math.min(12, (c as { span: number }).span))
234
+ : 1
235
+ return span > 1 ? <div key={i} className={css.gridSpan} style={{ gridColumn: `span ${span}` }}>{child}</div> : child
236
+ })}
92
237
  </div>
93
238
  )
94
239
  }
95
240
  case 'card': {
241
+ const toneClass = node.tone === undefined ? '' : ` ${css[`card${node.tone[0]!.toUpperCase()}${node.tone.slice(1)}`] ?? ''}`
242
+ // An explicit accent drives border + title + a very light wash; it is a
243
+ // colour hint, not a layout change, so it composes with tone.
244
+ // An accent card keeps its NEUTRAL surface: tinting the background with
245
+ // 7% of a warm hue over a dark theme produced a muddy olive block that
246
+ // read as a warning box. The hue now shows up only where it can stay
247
+ // clean — a blended border and the title — which also keeps charts and
248
+ // tables inside the card colour-neutral.
249
+ const accentStyle = node.accent === undefined ? undefined : {
250
+ borderColor: `color-mix(in srgb, ${node.accent} 18%, var(--dsl-g-border))`,
251
+ '--dsl-card-accent': node.accent,
252
+ } as CSSProperties
96
253
  return (
97
- <div key={key} className={css.card}>
254
+ <div key={key} className={`${css.card}${toneClass}`} style={accentStyle}>
98
255
  {node.title !== undefined && <div className={css.cardTitle}>{node.title}</div>}
99
256
  {node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
100
257
  </div>
@@ -144,18 +301,80 @@ export function renderNode(
144
301
  </span>
145
302
  )
146
303
  }
304
+ case 'hero': {
305
+ const tone = node.tone ?? 'accent'
306
+ const heroTone = css[`hero${tone[0]!.toUpperCase()}${tone.slice(1)}`] ?? ''
307
+ const heroDown = node.delta !== undefined && node.delta.startsWith('-')
308
+ return (
309
+ <div key={key} className={`${css.hero} ${heroTone}`}>
310
+ {node.label !== undefined && <span className={css.heroLabel}>{node.label}</span>}
311
+ <div className={css.heroTop}>
312
+ {node.value !== undefined && (
313
+ <span className={css.heroValue}><AnimatedValue value={node.value} unitClass={css.heroUnit} animate /></span>
314
+ )}
315
+ {node.delta !== undefined && (
316
+ <span className={`${css.statDelta} ${heroDown ? css.down : css.up}`}>{node.delta}</span>
317
+ )}
318
+ {node.spark !== undefined && <span className={css.heroSpark}><Sparkline values={node.spark} /></span>}
319
+ </div>
320
+ <span className={css.heroTitle}>{node.title}</span>
321
+ {node.subtitle !== undefined && <span className={css.heroSubtitle}>{node.subtitle}</span>}
322
+ </div>
323
+ )
324
+ }
147
325
  case 'stat': {
148
326
  const down = node.delta !== undefined && node.delta.startsWith('-')
149
327
  return (
150
- <div key={key} className={css.stat}>
328
+ <div key={key} className={`${css.stat}${node.size === 'hero' ? ` ${css.statHero}` : ''}`}>
151
329
  <span className={css.statLabel}>{node.label}</span>
152
- <span className={css.statValue}>{node.value}</span>
330
+ <span className={css.statValue}>
331
+ <AnimatedValue value={node.value} animate={node.size === 'hero'} />
332
+ </span>
153
333
  {node.delta !== undefined && <span className={`${css.statDelta} ${down ? css.down : css.up}`}>{node.delta}</span>}
334
+ {node.spark !== undefined && <Sparkline values={node.spark} />}
154
335
  </div>
155
336
  )
156
337
  }
157
338
  case 'progress': {
158
339
  const v = Math.max(0, Math.min(100, Number(node.value) || 0))
340
+ if (node.variant === 'ring') {
341
+ const R = 34
342
+ const C = 2 * Math.PI * R
343
+ const size = 92
344
+ return (
345
+ <div
346
+ key={key}
347
+ className={css.ring}
348
+ role="progressbar"
349
+ aria-valuemin={0}
350
+ aria-valuemax={100}
351
+ aria-valuenow={v}
352
+ aria-label={node.label ?? node.valueLabel ?? undefined}
353
+ >
354
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
355
+ <circle cx={size / 2} cy={size / 2} r={R} fill="none" strokeWidth={9} className={css.ringTrack} />
356
+ <circle
357
+ cx={size / 2}
358
+ cy={size / 2}
359
+ r={R}
360
+ fill="none"
361
+ strokeWidth={9}
362
+ strokeLinecap="round"
363
+ className={css.ringFill}
364
+ strokeDasharray={`${(v / 100) * C} ${C}`}
365
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
366
+ />
367
+ <text x={size / 2} y={size / 2 + 5} textAnchor="middle" className={css.ringValue}>{v}%</text>
368
+ </svg>
369
+ {(node.label !== undefined || node.valueLabel !== undefined) && (
370
+ <div className={css.ringMeta}>
371
+ {node.label !== undefined && <span className={css.ringLabel}>{node.label}</span>}
372
+ {node.valueLabel !== undefined && <span className={css.ringSub}>{node.valueLabel}</span>}
373
+ </div>
374
+ )}
375
+ </div>
376
+ )
377
+ }
159
378
  return (
160
379
  <div
161
380
  key={key}
@@ -172,27 +391,49 @@ export function renderNode(
172
391
  {node.valueLabel !== undefined && <span>{node.valueLabel}</span>}
173
392
  </div>
174
393
  )}
175
- <div className={css.track}><div className={css.fill} style={{ width: `${v}%` }} /></div>
394
+ <div className={css.track}>
395
+ <div className={css.fill} style={{ width: `${v}%` }} />
396
+ {node.target !== undefined && (
397
+ <span className={css.targetMark} style={{ left: `${node.target}%` }} title={`目标 ${node.target}%`} />
398
+ )}
399
+ </div>
176
400
  </div>
177
401
  )
178
402
  }
179
403
  case 'divider': return <hr key={key} className={css.divider} />
180
404
  case 'list': {
181
- const items = node.items.slice(0, GENUI_LIMITS.maxListItems)
405
+ const bound = boundField(answers, node.filter)?.toLowerCase()
406
+ const all = node.items.slice(0, GENUI_LIMITS.maxListItems)
407
+ const items = bound === undefined
408
+ ? all
409
+ : all.filter(item => listItemText(item).toLowerCase().includes(bound))
182
410
  return (
183
411
  <div key={key} className={css.list}>
184
412
  {items.map((item, i) => (
185
413
  <div key={i} className={css.li}>
186
414
  {isListItemNode(item)
187
415
  ? renderNode(item, i, onAction, depth + 1, answers)
188
- : <><span className={css.liTitle}>{typeof item === 'string' ? item : item.title}</span>{typeof item !== 'string' && item.desc !== undefined && <span className={css.liDesc}>{item.desc}</span>}</>}
416
+ : <><span className={css.liTitle}>{renderInline(typeof item === 'string' ? item : item.title)}</span>{typeof item !== 'string' && item.desc !== undefined && <span className={css.liDesc}>{renderInline(item.desc)}</span>}</>}
189
417
  </div>
190
418
  ))}
419
+ {bound !== undefined && <span className={css.filterHint}>匹配 {items.length} / {all.length} 项</span>}
191
420
  </div>
192
421
  )
193
422
  }
194
- case 'table': return <TableNode key={key} node={node} />
195
- case 'chart': return <ChartNode key={key} chart={node} />
423
+ case 'table':
424
+ return (
425
+ <TableNode
426
+ key={key}
427
+ node={node}
428
+ // Bound controls are resolved here: the table (and the chart below)
429
+ // receive plain strings, so they stay presentational and the live
430
+ // filtering never needs a model round trip.
431
+ filterValue={boundField(answers, node.filter)}
432
+ sortValue={boundField(answers, node.sortField)}
433
+ renderDetail={items => items.map((child, i) => renderNode(child, i, onAction, depth + 1, answers))}
434
+ />
435
+ )
436
+ case 'chart': return <ChartNode key={key} chart={node} filterValue={boundField(answers, node.filter)} />
196
437
  case 'tabs': return <TabsNode key={key} tabs={node} onAction={onAction} depth={depth + 1} answers={answers} />
197
438
  case 'avatar': {
198
439
  return (
@@ -20,11 +20,13 @@ export interface GenuiBlockProps {
20
20
  * v2.7: durable-state key (session + slot + content fingerprint). When set,
21
21
  * interaction state (radio answers, checkbox groups, submit lock, field
22
22
  * values) persists to localStorage and restores on refresh / re-render of
23
- * the same content. Changing this key also starts a fresh in-memory
24
- * interaction-state lifetime; callers only provide stateKey — GenuiBlock
25
- * owns the matching React identity.
23
+ * the same content. Changing an existing key starts a fresh interaction
24
+ * lifetime; a streaming instance adopts its first key without losing input.
25
+ * GenuiBlock owns the matching React identity.
26
26
  */
27
27
  stateKey?: string | undefined
28
+ /** Animate newly arriving items; settled message replays opt out. */
29
+ animateEntrance?: boolean | undefined
28
30
  }
29
31
 
30
32
  /** Per-question metadata registered by grouped radios for local grading. */
@@ -50,6 +50,7 @@ import type { Context } from '@deepseek-ai/cordis'
50
50
  import type {} from '@deepseek-ai/dsh-api-session-controller/client'
51
51
  import type { SessionId } from '@deepseek-ai/dsh-session/types'
52
52
  import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
53
+ import css from './GenuiBlock.module.css'
53
54
  import { renderResolvedFenceNode, type GenuiFenceContext } from './fence-render.tsx'
54
55
 
55
56
  /** Fence surfaces the channel can take over, newest host first: the shared
@@ -102,6 +103,34 @@ function isPanelRoot(node: ReactNode): boolean {
102
103
  return isValidElement(node) && node.type === Fragment
103
104
  }
104
105
 
106
+ /** Streaming placeholder gate: the body is a GenUI spec still arriving.
107
+ * Requires the shape of a spec (an object carrying items/type/title/panel) so
108
+ * a streaming ```json code block never gets a skeleton. */
109
+ function looksLikeGenuiInProgress(raw: string): boolean {
110
+ const text = raw.trimStart()
111
+ if (text.length < 8 || !text.startsWith('{')) return false
112
+ return /"(items|type|title|panel)"\s*:/.test(text)
113
+ }
114
+
115
+ /** Skeleton mounted while a fence body is still streaming and no component has
116
+ * finished yet. It is a REAL mounted tree, so the channel's rule "never hide
117
+ * the stock block without a mounted replacement" (issue #19) still holds: if
118
+ * the body never parses, the settle sweep unmounts the skeleton and the raw
119
+ * code block returns. */
120
+ function GenuiSkeleton() {
121
+ return (
122
+ <div className={css.skeleton} role="status" aria-label="正在生成界面">
123
+ <span className={css.skeletonTitle} />
124
+ <span className={css.skeletonBars}>
125
+ <span style={{ width: '32%' }} />
126
+ <span style={{ width: '52%' }} />
127
+ <span style={{ width: '24%' }} />
128
+ </span>
129
+ <span className={css.skeletonBlock} />
130
+ </div>
131
+ )
132
+ }
133
+
105
134
  interface Mount {
106
135
  root: Root
107
136
  container: HTMLElement
@@ -109,6 +138,8 @@ interface Mount {
109
138
  lastRaw: string
110
139
  lastSettled: boolean
111
140
  lastNode: ReactNode
141
+ /** True while this mount is the streaming skeleton (no component yet). */
142
+ skeleton: boolean
112
143
  }
113
144
 
114
145
  function isTextNode(node: Node): node is Text {
@@ -429,9 +460,15 @@ export function installDomFenceRenderer(
429
460
  // the stock code block stays visible until something renders. A settled
430
461
  // unrepairable body warns once (the DOM channel has no visible
431
462
  // diagnostic of its own — the stock block keeps the raw content).
432
- if (node === null) {
433
- if (settled) warnOnce(block, 'settled dsh-ui fence body does not parse; keeping the code block')
434
- return
463
+ let payload = node
464
+ if (payload === null) {
465
+ if (settled || !looksLikeGenuiInProgress(raw)) {
466
+ if (settled) warnOnce(block, 'settled dsh-ui fence body does not parse; keeping the code block')
467
+ return
468
+ }
469
+ // Streaming, spec-shaped, nothing renderable yet: show the skeleton
470
+ // rather than a wall of half-written JSON.
471
+ payload = <GenuiSkeleton />
435
472
  }
436
473
  // Mount FIRST, hide AFTER (issue #19): the stock block is only ever
437
474
  // hidden once a successfully mounted replacement stands next to it. A
@@ -454,7 +491,7 @@ export function installDomFenceRenderer(
454
491
  if (sid === undefined) return
455
492
  sendAction(sid, action, payload)
456
493
  }
457
- root.render(<GenuiActionContext.Provider value={handler}>{node}</GenuiActionContext.Provider>)
494
+ root.render(<GenuiActionContext.Provider value={handler}>{payload}</GenuiActionContext.Provider>)
458
495
  } catch (error) {
459
496
  try {
460
497
  root.unmount()
@@ -467,7 +504,7 @@ export function installDomFenceRenderer(
467
504
  }
468
505
  block.style.display = 'none'
469
506
  block.setAttribute(PROCESSED, '')
470
- mounts.set(block, { root, container, block, lastRaw: raw, lastSettled: settled, lastNode: node })
507
+ mounts.set(block, { root, container, block, lastRaw: raw, lastSettled: settled, lastNode: payload, skeleton: node === null })
471
508
  }
472
509
 
473
510
  /** Pre-paint repair: the host's React re-renders during streaming can wipe
@@ -547,6 +584,14 @@ export function installDomFenceRenderer(
547
584
  const { key, context } = contextOf(anchor, block, settled)
548
585
  const node = renderResolvedFenceNode(raw, key, context)
549
586
  if (node === null) {
587
+ if (mount.skeleton && !settled) {
588
+ // Still streaming and still incomplete: keep the skeleton mounted
589
+ // (no React re-render needed) and wait for the next chunk.
590
+ mount.lastRaw = raw
591
+ mount.lastSettled = settled
592
+ continue
593
+ }
594
+ // Settled, or no longer spec-shaped: restore the raw code block.
550
595
  unmountBlock(block)
551
596
  continue
552
597
  }
@@ -601,6 +646,7 @@ export function installDomFenceRenderer(
601
646
  mount.lastRaw = raw
602
647
  mount.lastSettled = settled
603
648
  mount.lastNode = node
649
+ mount.skeleton = false
604
650
  }
605
651
  }
606
652
  repairSurgery()
@@ -618,7 +664,15 @@ export function installDomFenceRenderer(
618
664
  })
619
665
  }
620
666
 
621
- const observer = new MutationObserver(() => {
667
+ const observer = new MutationObserver(records => {
668
+ // Restore detached roots before paint, retaining input and pending actions.
669
+ // The latest removal owns the current tree if several commits were batched.
670
+ for (const record of [...records].reverse()) {
671
+ if (record.removedNodes.length === 0 || record.target.childNodes.length > 0) continue
672
+ const mount = [...mounts.values()].find(candidate => candidate.container === record.target)
673
+ if (mount === undefined || !mount.block.isConnected || isPanelRoot(mount.lastNode)) continue
674
+ mount.container.append(...record.removedNodes)
675
+ }
622
676
  // Pre-paint pass: surgery repair only (cheap DOM ops); the React
623
677
  // re-render goes through the rAF-scheduled sweep.
624
678
  repairSurgery()
@@ -29,7 +29,16 @@ interface EChartsAssetApi {
29
29
  * @param opts - optional height override.
30
30
  * @returns the ECharts instance (setOption/resize/dispose).
31
31
  */
32
- export async function createChart(el: HTMLElement, option: unknown, opts?: { height?: number }): Promise<EChartsInstance> {
33
- const api = await loadGenuiAsset<EChartsAssetApi>('echarts')
32
+ export async function createChart(
33
+ el: HTMLElement,
34
+ option: unknown,
35
+ opts?: { height?: number },
36
+ engine: 'core' | 'full' = 'core',
37
+ ): Promise<EChartsInstance> {
38
+ const api = await loadGenuiAsset<EChartsAssetApi>(engine === 'full' ? 'echarts-full' : 'echarts-core')
34
39
  return api.createChart(el, option, opts)
35
40
  }
41
+
42
+ /** Presets the core bundle can draw; anything else (or a raw `option`) needs
43
+ * the full engine. Kept next to the loader so the split stays in one place. */
44
+ export const CORE_PRESETS: ReadonlySet<string> = new Set(['bar', 'line', 'area', 'pie', 'scatter', 'bigline'])
@@ -184,22 +184,23 @@ export function resolveGenuiSpec(raw: string, context?: GenuiFenceContext): Genu
184
184
  function renderInlineFence(key: Key, context: GenuiFenceContext | undefined, spec: GenuiSpec): ReactNode {
185
185
  const sessionId = context?.sessionId
186
186
  return (
187
- // React key carries the stable source identity when present (atomic
188
- // remount at streaming→settled), falling back to the document key.
187
+ // Keep the document slot mounted across streaming→settled; GenuiBlock
188
+ // owns durable-state changes, while a session change resets the tree.
189
189
  // Repaired specs render SILENTLY: once the UI renders, no amber note
190
190
  // tells the user something was wrong — only an unrecoverable body keeps
191
191
  // the red diagnostic.
192
- <ErrorBoundary key={context?.source?.id ?? key} label="该界面">
192
+ <ErrorBoundary key={JSON.stringify([sessionId, key])} label="该界面">
193
193
  <GenuiBlock
194
194
  spec={spec}
195
+ animateEntrance={context?.source === undefined}
195
196
  // v2.7 durable state: session + stable source + content fingerprint —
196
197
  // replaying the same content restores answers/lock/field values; new
197
198
  // content (换题, edited spec) gets a fresh key. Without a stable
198
199
  // source (streaming / non-conversation surfaces) state is not
199
200
  // persisted.
200
- stateKey={sessionId === undefined
201
+ stateKey={sessionId === undefined || context?.source === undefined
201
202
  ? undefined
202
- : fenceStateKey(sessionId, context?.source?.id ?? String(key), JSON.stringify(spec))}
203
+ : fenceStateKey(sessionId, context.source.id, JSON.stringify(spec))}
203
204
  />
204
205
  </ErrorBoundary>
205
206
  )
@@ -9,6 +9,20 @@ export const gallerySpec: GenuiSpec = {
9
9
  title: 'GenUI · 组件画廊',
10
10
  gap: 14,
11
11
  items: [
12
+ { type: 'hero', label: '可用率 · 近 30 天', value: '99.96%', delta: '+0.02%', tone: 'accent', spark: [99.8, 99.85, 99.9, 99.88, 99.94, 99.96], title: 'hero 封面块', subtitle: '一条回答最多一个:eyebrow + 超大数字 + 标题 + 副标题,带 tone 渐变底色。' },
13
+ { type: 'grid', cols: 3, items: [
14
+ { type: 'card', title: '默认', items: [{ type: 'text', size: 'body', content: '不写 accent:中性表面。' }] },
15
+ { type: 'card', accent: '#f59e0b', title: 'accent #f59e0b', items: [{ type: 'text', size: 'body', content: '只有边框与标题带上色相,表面保持中性。' }] },
16
+ { type: 'card', accent: '#3ecf8e', title: 'accent #3ecf8e', items: [{ type: 'text', size: 'body', content: '深色主题下不会发脏。' }] },
17
+ ] },
18
+ { type: 'grid', cols: 3, items: [
19
+ { type: 'card', span: 2, title: 'span:2 · 宽卡', items: [
20
+ { type: 'text', size: 'body', content: 'grid 子节点加 span 就能跨列:一张宽卡 + 一张窄卡。卡片高度由该行最高的一张决定,内容会自动撑满或居中。' },
21
+ { type: 'chart', kind: 'line', data: [], series: [
22
+ { label: '本周', data: [{ label: '一', value: 8 }, { label: '二', value: 12 }, { label: '三', value: 9 }, { label: '四', value: 14 }] },
23
+ { label: '上周', data: [{ label: '一', value: 6 }, { label: '二', value: 9 }, { label: '三', value: 7 }, { label: '四', value: 10 }] }] }] },
24
+ { type: 'card', title: 'span:1', items: [{ type: 'chart', kind: 'donut', data: [{ label: '视觉', value: 42 }, { label: '采纳', value: 33 }, { label: '可靠', value: 25 }] }] },
25
+ ] },
12
26
  { type: 'text', size: 'h1', content: '排版层级' },
13
27
  { type: 'text', size: 'h2', content: '二级标题' },
14
28
  { type: 'text', size: 'h3', content: '三级标题' },
@@ -29,11 +43,14 @@ export const gallerySpec: GenuiSpec = {
29
43
  { type: 'audio', src: '/demo-audio.mp3', alt: '音频播放器演示' },
30
44
  { type: 'video', src: '/demo-video.mp4', alt: '视频播放器演示', poster: '/demo-video.jpg', aspectRatio: '16:9' },
31
45
  { type: 'grid', cols: 3, items: [
32
- { type: 'stat', label: 'CPU', value: '42%', delta: '+3.1%' },
46
+ { type: 'stat', label: '在线率', value: '99.96%', size: 'hero', delta: '+0.02%' },
47
+ { type: 'stat', label: 'CPU', value: '42%', delta: '+3.1%', spark: [31, 38, 35, 44, 40, 42] },
33
48
  { type: 'stat', label: '内存', value: '6.8 GB', delta: '-1.2%' },
34
- { type: 'stat', label: '请求数', value: '128.4k' },
49
+ { type: 'stat', label: '请求数', value: '128.4k', spark: [90, 104, 98, 121, 116, 128] },
35
50
  ] },
36
51
  { type: 'progress', label: '训练进度', value: 72, valueLabel: '72%' },
52
+ { type: 'progress', label: '覆盖率', value: 64, target: 80, valueLabel: '64% / 目标 80%' },
53
+ { type: 'progress', variant: 'ring', value: 72, label: '本轮完成度', valueLabel: '第 3 / 4 轮' },
37
54
  { type: 'card', title: '性能指标', items: [
38
55
  // 表头可点击排序:数值感知(千分位 / k / 万 / % 都能正确比较),
39
56
  // 数值列自动右对齐。
@@ -63,6 +80,51 @@ export const gallerySpec: GenuiSpec = {
63
80
  { label: '本月', data: [{ label: 'Q1', value: 3 }, { label: 'Q2', value: 5 }] },
64
81
  { label: '上月', data: [{ label: 'Q1', value: 2 }, { label: 'Q2', value: 4 }] },
65
82
  ] },
83
+ { type: 'chart', kind: 'line', data: [], series: [
84
+ { label: '本月', data: [{ label: '一', value: 8 }, { label: '二', value: 12 }, { label: '三', value: 9 }] },
85
+ { label: '上月', data: [{ label: '一', value: 6 }, { label: '二', value: 9 }, { label: '三', value: 7 }] },
86
+ ] },
87
+ { type: 'chart', kind: 'bars', data: [], stacked: true, series: [
88
+ { label: '已完成', data: [{ label: 'Q1', value: 42 }, { label: 'Q2', value: 58 }, { label: 'Q3', value: 61 }] },
89
+ { label: '进行中', data: [{ label: 'Q1', value: 18 }, { label: 'Q2', value: 14 }, { label: 'Q3', value: 9 }] },
90
+ ] },
91
+ { type: 'chart', horizontal: true, data: [
92
+ { label: '自然搜索', value: 82 }, { label: '直接访问', value: 64 }, { label: '社交媒体', value: 41 },
93
+ ] },
94
+ { type: 'input', label: '筛选服务 / 状态', placeholder: '输入关键字即时过滤下表', id: 'gallery-filter' },
95
+ { type: 'table', columns: ['服务', 'P95', '状态'], types: ['text', 'num', 'badge'], filter: 'gallery-filter', rows: [
96
+ ['API 网关', '128', '正常'],
97
+ ['搜索', '190', '关注'],
98
+ ['推荐', '250', '偏高'],
99
+ ] },
100
+ { type: 'table', columns: ['服务', 'P95', '状态'], types: ['text', 'num', 'badge'], details: [
101
+ [{ type: 'keyvalue', pairs: [{ key: '负责人', value: '平台组' }, { key: 'SLO', value: 'P95 < 150ms' }] },
102
+ { type: 'text', size: 'body', content: '展开行可以放任意组件:指标、图表、列表、表单都可以。' }],
103
+ null,
104
+ [{ type: 'progress', value: 91, target: 80, label: '负载水位', valueLabel: '91% / 目标 80%' }],
105
+ ], rows: [
106
+ ['API 网关', '128', '正常'],
107
+ ['搜索', '190', '关注'],
108
+ ['推荐', '250', '偏高'],
109
+ ] },
110
+ { type: 'table', columns: ['区域', 'Q1', 'Q2', 'Q3'], types: ['group', 'num', 'num', 'num'], total: true, rows: [
111
+ ['华东', '', '', ''],
112
+ ['上海', '120', '138', '151'],
113
+ ['杭州', '96', '104', '118'],
114
+ ['华北', '', '', ''],
115
+ ['北京', '88', '95', '103'],
116
+ ] },
117
+ { type: 'card', tone: 'success', title: '已通过', items: [
118
+ { type: 'text', size: 'body', content: '分组表:首列为 group 时,只有第一格有内容的行会渲染成跨列小标题;total 追加一行合计。' },
119
+ ] },
120
+ { type: 'table', columns: ['#', '服务', '近 6 期延迟', '可用率', '负载', '状态'], types: ['index', 'text', 'spark', 'ring', 'bar', 'badge'], rows: [
121
+ ['1', 'API 网关', '180,164,150,140,133,128', '99.96', '62', '正常'],
122
+ ['2', '搜索', '220,210,230,205,198,190', '99.82', '78', '关注'],
123
+ ['3', '推荐', '310,340,300,280,260,250', '99.41', '91', '偏高'],
124
+ ] },
125
+ { type: 'table', columns: ['渠道', '完成度', '状态'], types: ['text', 'bar', 'badge'], rows: [
126
+ ['自然搜索', '82', '健康'], ['直接访问', '64', '关注'], ['社交媒体', '41', '偏低'],
127
+ ] },
66
128
  { type: 'tabs', tabs: [
67
129
  { label: '概览', items: [{ type: 'text', content: '标签页一的内容' }] },
68
130
  { label: '明细', items: [{ type: 'list', items: ['明细 A', '明细 B'] }] },
@@ -89,6 +151,20 @@ export const gallerySpec: GenuiSpec = {
89
151
  { title: '第二项', items: [{ type: 'code', lang: 'ts', code: 'export const x = 1' }] },
90
152
  ] },
91
153
  { type: 'copy', label: '复制令牌', text: 'sk-1234567890' },
154
+ { type: 'echart', preset: 'bar', title: 'echart · preset:bar(只下载 core 引擎)', height: 240, data: [
155
+ { label: '自然搜索', value: 82 }, { label: '直接访问', value: 64 }, { label: '社交媒体', value: 41 },
156
+ ] },
157
+ { type: 'echart', preset: 'radar', title: 'echart · preset:radar(按需拉完整引擎)', height: 280, series: [
158
+ { label: '本轮', data: [{ label: '视觉', value: 86 }, { label: '可用', value: 92 }, { label: '性能', value: 74 }, { label: '采纳', value: 88 }, { label: '稳定', value: 90 }] },
159
+ { label: '上轮', data: [{ label: '视觉', value: 62 }, { label: '可用', value: 70 }, { label: '性能', value: 58 }, { label: '采纳', value: 61 }, { label: '稳定', value: 72 }] },
160
+ ] },
161
+ { type: 'echart', preset: 'sankey', title: 'echart · preset:sankey(links 驱动)', height: 260, links: [
162
+ { from: '入口', to: 'API', value: 40 },
163
+ { from: '入口', to: '缓存', value: 25 },
164
+ { from: 'API', to: '渲染', value: 32 },
165
+ { from: '渲染', to: '完成', value: 30 },
166
+ { from: '缓存', to: '完成', value: 28 },
167
+ ] },
92
168
  { type: 'plot', title: '波动叠加', xMin: -6.28, xMax: 6.28, series: [
93
169
  { expr: 'sin(x)', label: 'sin(x)', color: '#4f8ef7' },
94
170
  { expr: '0.8*cos(x)', label: 'cos', color: '#3ecf8e' },