@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.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SKILL.md +128 -6
- package/lib/assets/echarts-core.js +43 -0
- package/lib/assets/{echarts.js → echarts-full.js} +1 -1
- package/lib/assets/mermaid.js +1 -1
- package/lib/client.js +5 -3
- package/lib/index.js +230 -69
- package/lib/types/client/EChartNode.d.ts +9 -0
- package/lib/types/client/GenuiBlock.d.ts +3 -4
- package/lib/types/client/asset-echarts-core.d.ts +1 -0
- package/lib/types/client/asset-echarts.d.ts +3 -2
- package/lib/types/client/asset-loader.d.ts +2 -2
- package/lib/types/client/blocks/advanced.d.ts +0 -3
- package/lib/types/client/blocks/charts.d.ts +23 -12
- package/lib/types/client/blocks/state.d.ts +5 -3
- package/lib/types/client/echarts-lazy.d.ts +4 -1
- package/lib/types/client/genui-runtime/schema.d.ts +11 -1
- package/lib/types/client/inline.d.ts +29 -0
- package/lib/types/client/spec.d.ts +76 -6
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +4 -3
- package/src/client/EChartNode.tsx +233 -19
- package/src/client/GenuiBlock.module.css +895 -145
- package/src/client/GenuiBlock.tsx +31 -23
- package/src/client/PlotBlock.module.css +17 -1
- package/src/client/PlotBlock.tsx +33 -1
- package/src/client/asset-echarts-core.ts +39 -0
- package/src/client/asset-echarts.ts +4 -3
- package/src/client/asset-loader.ts +5 -3
- package/src/client/blocks/advanced.tsx +60 -16
- package/src/client/blocks/charts.tsx +761 -144
- package/src/client/blocks/render-node.tsx +252 -11
- package/src/client/blocks/state.ts +5 -3
- package/src/client/dom-fence.tsx +60 -6
- package/src/client/echarts-lazy.ts +11 -2
- package/src/client/fence-render.tsx +6 -5
- package/src/client/gallery.ts +78 -2
- package/src/client/genui-runtime/schema.ts +45 -11
- package/src/client/guard.ts +161 -23
- package/src/client/index.tsx +14 -3
- package/src/client/inline.ts +73 -0
- package/src/client/mermaid-core.ts +42 -5
- package/src/client/spec.ts +79 -6
- package/src/plugin/index.ts +33 -45
- package/src/plugin/tool.ts +5 -1
- 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
|
|
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
|
-
{
|
|
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}>
|
|
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}
|
|
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
|
|
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':
|
|
195
|
-
|
|
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
|
|
24
|
-
*
|
|
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. */
|
package/src/client/dom-fence.tsx
CHANGED
|
@@ -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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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}>{
|
|
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(
|
|
33
|
-
|
|
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
|
-
//
|
|
188
|
-
//
|
|
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={
|
|
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
|
|
203
|
+
: fenceStateKey(sessionId, context.source.id, JSON.stringify(spec))}
|
|
203
204
|
/>
|
|
204
205
|
</ErrorBoundary>
|
|
205
206
|
)
|
package/src/client/gallery.ts
CHANGED
|
@@ -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: '
|
|
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' },
|