@changfenhuang/dsh-genui 0.9.3 → 0.9.7
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 +17 -0
- package/README.md +7 -18
- package/README.zh-CN.md +13 -18
- package/SKILL.md +4 -2
- package/lib/assets/three.js +1 -1
- package/lib/client.js +3 -3
- package/lib/index.js +273 -61
- package/lib/types/client/GenuiBlock.d.ts +5 -2
- package/lib/types/client/TemplateDrawer.d.ts +7 -0
- package/lib/types/client/achievement-store.d.ts +35 -0
- package/lib/types/client/achievement-toast.d.ts +2 -0
- package/lib/types/client/achievements.d.ts +48 -0
- package/lib/types/client/blocks/advanced.d.ts +1 -1
- package/lib/types/client/blocks/image.d.ts +6 -0
- package/lib/types/client/blocks/state.d.ts +2 -0
- package/lib/types/client/fence-render.d.ts +3 -0
- package/lib/types/client/gallery.d.ts +3 -5
- package/lib/types/client/guard.d.ts +10 -5
- package/lib/types/client/panel.d.ts +4 -1
- package/lib/types/client/spec.d.ts +8 -1
- package/lib/types/client/templates.d.ts +22 -0
- package/lib/types/plugin/chart-contract.d.ts +7 -0
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +9 -8
- package/src/client/GenuiBlock.module.css +141 -10
- package/src/client/GenuiBlock.tsx +53 -25
- package/src/client/PlotBlock.tsx +25 -10
- package/src/client/TemplateDrawer.module.css +152 -0
- package/src/client/TemplateDrawer.tsx +137 -0
- package/src/client/achievement-store.ts +151 -0
- package/src/client/achievement-toast.tsx +68 -0
- package/src/client/achievements.ts +111 -0
- package/src/client/blocks/advanced.tsx +34 -25
- package/src/client/blocks/charts.tsx +4 -4
- package/src/client/blocks/image.tsx +25 -0
- package/src/client/blocks/render-node.tsx +3 -0
- package/src/client/blocks/state.ts +2 -0
- package/src/client/dom-fence.tsx +12 -5
- package/src/client/fence-render.tsx +35 -11
- package/src/client/gallery.ts +4 -5
- package/src/client/guard.ts +125 -29
- package/src/client/index.tsx +13 -0
- package/src/client/panel.tsx +109 -10
- package/src/client/safe-math.ts +29 -1
- package/src/client/scene3d-core.ts +5 -2
- package/src/client/spec.ts +9 -1
- package/src/client/templates.ts +280 -0
- package/src/plugin/chart-contract.ts +103 -0
- package/src/plugin/index.ts +48 -2
- package/src/plugin/tool.ts +25 -4
|
@@ -121,8 +121,13 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
|
|
|
121
121
|
const [active, setActive] = useState(0)
|
|
122
122
|
const uid = useId()
|
|
123
123
|
const list = tabs.tabs.slice(0, GENUI_LIMITS.maxTabs)
|
|
124
|
-
const
|
|
124
|
+
const safeActive = list.length === 0 ? 0 : Math.min(active, list.length - 1)
|
|
125
|
+
const current = list[safeActive]
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (safeActive !== active) setActive(safeActive)
|
|
128
|
+
}, [active, safeActive])
|
|
125
129
|
const move = (next: number): void => {
|
|
130
|
+
if (list.length === 0) return
|
|
126
131
|
const n = (next + list.length) % list.length
|
|
127
132
|
setActive(n)
|
|
128
133
|
document.getElementById(`${uid}-tab-${n}`)?.focus()
|
|
@@ -134,8 +139,8 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
|
|
|
134
139
|
role="tablist"
|
|
135
140
|
aria-orientation="horizontal"
|
|
136
141
|
onKeyDown={e => {
|
|
137
|
-
if (e.key === 'ArrowRight') { e.preventDefault(); move(
|
|
138
|
-
else if (e.key === 'ArrowLeft') { e.preventDefault(); move(
|
|
142
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); move(safeActive + 1) }
|
|
143
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); move(safeActive - 1) }
|
|
139
144
|
else if (e.key === 'Home') { e.preventDefault(); move(0) }
|
|
140
145
|
else if (e.key === 'End') { e.preventDefault(); move(list.length - 1) }
|
|
141
146
|
}}
|
|
@@ -146,10 +151,10 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
|
|
|
146
151
|
id={`${uid}-tab-${i}`}
|
|
147
152
|
type="button"
|
|
148
153
|
role="tab"
|
|
149
|
-
aria-selected={i ===
|
|
154
|
+
aria-selected={i === safeActive}
|
|
150
155
|
aria-controls={`${uid}-panel-${i}`}
|
|
151
|
-
tabIndex={i ===
|
|
152
|
-
className={`${css.tab} ${i ===
|
|
156
|
+
tabIndex={i === safeActive ? 0 : -1}
|
|
157
|
+
className={`${css.tab} ${i === safeActive ? css.tabActive : ''}`}
|
|
153
158
|
onClick={() => setActive(i)}
|
|
154
159
|
>
|
|
155
160
|
{tab.label}
|
|
@@ -157,7 +162,7 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
|
|
|
157
162
|
))}
|
|
158
163
|
</div>
|
|
159
164
|
{current !== undefined && (
|
|
160
|
-
<div className={css.col} role="tabpanel" id={`${uid}-panel-${
|
|
165
|
+
<div className={css.col} role="tabpanel" id={`${uid}-panel-${safeActive}`} aria-labelledby={`${uid}-tab-${safeActive}`}>
|
|
161
166
|
{current.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
|
|
162
167
|
</div>
|
|
163
168
|
)}
|
|
@@ -264,26 +269,32 @@ export const CopyNode = memo(function CopyNode({ node }: { node: GenuiCopy }) {
|
|
|
264
269
|
)
|
|
265
270
|
})
|
|
266
271
|
|
|
272
|
+
type MermaidRenderState =
|
|
273
|
+
| { status: 'loading' }
|
|
274
|
+
| { status: 'ready'; html: string }
|
|
275
|
+
| { status: 'error' }
|
|
276
|
+
|
|
267
277
|
/** Mermaid: lazily loaded diagram renderer. */
|
|
268
278
|
export const MermaidNode = memo(function MermaidNode({ node }: { node: GenuiMermaid }) {
|
|
269
|
-
const [
|
|
270
|
-
const [failed, setFailed] = useState(false)
|
|
279
|
+
const [state, setState] = useState<MermaidRenderState>({ status: 'loading' })
|
|
271
280
|
const code = node.code.slice(0, GENUI_LIMITS.maxMermaid)
|
|
272
281
|
useEffect(() => {
|
|
273
282
|
let alive = true
|
|
274
|
-
|
|
283
|
+
setState({ status: 'loading' })
|
|
284
|
+
void (async () => {
|
|
275
285
|
try {
|
|
286
|
+
const m = await import('../mermaid-lazy.ts')
|
|
276
287
|
const svg = await m.renderMermaid(code)
|
|
277
|
-
if (alive)
|
|
288
|
+
if (alive) setState({ status: 'ready', html: svg })
|
|
278
289
|
} catch {
|
|
279
|
-
if (alive)
|
|
290
|
+
if (alive) setState({ status: 'error' })
|
|
280
291
|
}
|
|
281
|
-
})
|
|
292
|
+
})()
|
|
282
293
|
return () => { alive = false }
|
|
283
294
|
}, [code])
|
|
284
|
-
if (
|
|
285
|
-
if (
|
|
286
|
-
return <div className={css.mermaid} dangerouslySetInnerHTML={{ __html: html }} data-genui-mermaid />
|
|
295
|
+
if (state.status === 'error') return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidErr}>图语法有误,已降级显示源码</div></div>
|
|
296
|
+
if (state.status === 'loading') return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidHint}>渲染中…</div></div>
|
|
297
|
+
return <div className={css.mermaid} dangerouslySetInnerHTML={{ __html: state.html }} data-genui-mermaid />
|
|
287
298
|
})
|
|
288
299
|
|
|
289
300
|
/** Scene3D: three.js WebGL canvas, lazily imported. */
|
|
@@ -346,7 +357,6 @@ export const TimelineNode = memo(function TimelineNode({ node }: { node: GenuiTi
|
|
|
346
357
|
* — click a dir to fold/unfold, default fully open. Zero model round trip. */
|
|
347
358
|
export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFileTree }) {
|
|
348
359
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
349
|
-
const pathKey = (depth: number, i: number): string => `${depth}-${i}`
|
|
350
360
|
const toggle = (k: string): void => {
|
|
351
361
|
setCollapsed(prev => {
|
|
352
362
|
const next = new Set(prev)
|
|
@@ -355,27 +365,26 @@ export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFi
|
|
|
355
365
|
return next
|
|
356
366
|
})
|
|
357
367
|
}
|
|
358
|
-
const renderNode = (n: GenuiFileTreeNode, depth: number,
|
|
368
|
+
const renderNode = (n: GenuiFileTreeNode, depth: number, path: string): ReactNode => {
|
|
359
369
|
if (depth > GENUI_LIMITS.maxTreeDepth) return null
|
|
360
370
|
const isDir = n.type === 'dir' || (n.children !== undefined && n.children.length > 0)
|
|
361
|
-
const
|
|
362
|
-
const folded = isDir && collapsed.has(k)
|
|
371
|
+
const folded = isDir && collapsed.has(path)
|
|
363
372
|
return (
|
|
364
|
-
<div key={
|
|
373
|
+
<div key={path} className={css.ftRow} style={{ paddingLeft: `${depth * 16}px` }}>
|
|
365
374
|
<button
|
|
366
375
|
type="button"
|
|
367
376
|
className={css.ftNameBtn}
|
|
368
377
|
aria-expanded={isDir ? !folded : undefined}
|
|
369
|
-
onClick={isDir ? () => toggle(
|
|
378
|
+
onClick={isDir ? () => toggle(path) : undefined}
|
|
370
379
|
>
|
|
371
380
|
<span className={`${css.ftIcon} ${isDir ? css.ftIconDir : ''}`} aria-hidden>{isDir ? (folded ? '▸' : '▾') : '·'}</span>
|
|
372
381
|
<span className={`${css.ftName} ${isDir ? css.ftDir : ''}`}>{n.name}</span>
|
|
373
382
|
</button>
|
|
374
|
-
{isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, ci))}
|
|
383
|
+
{isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, `${path}/${ci}`))}
|
|
375
384
|
</div>
|
|
376
385
|
)
|
|
377
386
|
}
|
|
378
|
-
return <div className={css.fileTree}>{node.items.slice(0, GENUI_LIMITS.maxListItems).map((n, i) => renderNode(n, 0, i))}</div>
|
|
387
|
+
return <div className={css.fileTree}>{node.items.slice(0, GENUI_LIMITS.maxListItems).map((n, i) => renderNode(n, 0, String(i)))}</div>
|
|
379
388
|
})
|
|
380
389
|
|
|
381
390
|
/** Quiz: a self-contained teaching question. Selecting an option marks it
|
|
@@ -464,4 +473,4 @@ export const BreadcrumbNode = memo(function BreadcrumbNode({ node }: { node: Gen
|
|
|
464
473
|
* names stay independent. The model round-trip takes seconds, so a few
|
|
465
474
|
* hundred ms of trailing delay is imperceptible — and it stops bursts of
|
|
466
475
|
* queued user turns.
|
|
467
|
-
*/
|
|
476
|
+
*/
|
|
@@ -138,7 +138,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
|
|
|
138
138
|
const labels = grouped[0]!.data.map(d => d.label)
|
|
139
139
|
const max = Math.max(...grouped.flatMap(s => s.data.map(d => Number(d.value) || 0)), 1)
|
|
140
140
|
return (
|
|
141
|
-
<div className={css.chart}>
|
|
141
|
+
<div className={css.chart} data-genui-chart="bars">
|
|
142
142
|
<div className={css.chartPlot}>
|
|
143
143
|
{[0, 25, 50, 75].map(p => (
|
|
144
144
|
<span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
|
|
@@ -181,7 +181,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
|
|
|
181
181
|
// and used to collapse the bar entirely.
|
|
182
182
|
const max = Math.max(...data.map(d => Number(d.value) || 0), 1)
|
|
183
183
|
return (
|
|
184
|
-
<div className={css.chart}>
|
|
184
|
+
<div className={css.chart} data-genui-chart="bars">
|
|
185
185
|
<div className={css.chartPlot}>
|
|
186
186
|
{[0, 25, 50, 75].map(p => (
|
|
187
187
|
<span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
|
|
@@ -233,7 +233,7 @@ export const LineChartNode = memo(function LineChartNode({ chart }: { chart: Gen
|
|
|
233
233
|
return t.toFixed(1)
|
|
234
234
|
}
|
|
235
235
|
return (
|
|
236
|
-
<div className={css.lineChart}>
|
|
236
|
+
<div className={css.lineChart} data-genui-chart="line">
|
|
237
237
|
<svg width="100%" viewBox={`0 0 ${W} ${H}`}>
|
|
238
238
|
{ticks.map((t, i) => {
|
|
239
239
|
const y = padT + (1 - (t - min) / span) * (H - padT - padB)
|
|
@@ -277,7 +277,7 @@ export const DonutNode = memo(function DonutNode({ chart }: { chart: GenuiChart
|
|
|
277
277
|
const C = 2 * Math.PI * R
|
|
278
278
|
let offset = 0
|
|
279
279
|
return (
|
|
280
|
-
<div className={css.donut}>
|
|
280
|
+
<div className={css.donut} data-genui-chart="donut">
|
|
281
281
|
<svg width="120" height="120" viewBox="0 0 120 120">
|
|
282
282
|
<circle cx="60" cy="60" r={R} fill="none" strokeWidth="14" className={css.donutTrack} />
|
|
283
283
|
{clamped.map((d, i) => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import css from '../GenuiBlock.module.css'
|
|
3
|
+
import type { GenuiImage } from '../spec.ts'
|
|
4
|
+
|
|
5
|
+
/** Native image display; source safety is enforced by repairGenuiSpec/safeMediaSrc. */
|
|
6
|
+
export function ImageNode({ node }: { node: GenuiImage }): ReactNode {
|
|
7
|
+
const [failed, setFailed] = useState(false)
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<figure className={css.media}>
|
|
11
|
+
{node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
|
|
12
|
+
{failed
|
|
13
|
+
? <div className={css.mediaError} role="alert">图片无法加载</div>
|
|
14
|
+
: <img
|
|
15
|
+
className={css.mediaPlayer}
|
|
16
|
+
src={node.src}
|
|
17
|
+
alt={node.alt ?? '图片'}
|
|
18
|
+
loading="lazy"
|
|
19
|
+
decoding="async"
|
|
20
|
+
style={{ maxHeight: 'min(70vh, 720px)', objectFit: 'contain' }}
|
|
21
|
+
onError={() => setFailed(true)}
|
|
22
|
+
/>}
|
|
23
|
+
</figure>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
MermaidNode, PlotNode, QuizNode, Scene3DNode, StepsNode, TabsNode, TimelineNode,
|
|
21
21
|
} from './advanced.tsx'
|
|
22
22
|
import { DiagramNode } from './diagram/index.tsx'
|
|
23
|
+
import { ImageNode } from './image.tsx'
|
|
23
24
|
|
|
24
25
|
import { EChartNode } from '../EChartNode.tsx'
|
|
25
26
|
|
|
@@ -58,6 +59,7 @@ export function renderNode(
|
|
|
58
59
|
// against the same limit; this is the belt-and-suspenders for direct
|
|
59
60
|
// GenuiBlock use and plugin-registered custom renderers.
|
|
60
61
|
if (depth > GENUI_LIMITS.maxDepth) return null
|
|
62
|
+
|
|
61
63
|
switch (node.type) {
|
|
62
64
|
case 'text': {
|
|
63
65
|
const size = node.size ?? 'body'
|
|
@@ -143,6 +145,7 @@ export function renderNode(
|
|
|
143
145
|
? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{node.label}</a>
|
|
144
146
|
: <span key={key} className={css.linkText}>{node.label}</span>
|
|
145
147
|
}
|
|
148
|
+
case 'image': return <ImageNode key={`${key}:${node.src}`} node={node} />
|
|
146
149
|
case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
|
|
147
150
|
case 'video': return <VideoNode key={`${key}:${node.src}`} node={node} />
|
|
148
151
|
case 'badge': {
|
|
@@ -20,6 +20,8 @@ export interface GenuiBlockProps {
|
|
|
20
20
|
* v2.7: durable-state key (session + slot + content fingerprint). When set,
|
|
21
21
|
* interaction state (radio answers, submit lock, field values) persists to
|
|
22
22
|
* localStorage and restores on refresh / re-render of the same content.
|
|
23
|
+
* Changing this key also starts a fresh in-memory interaction-state lifetime;
|
|
24
|
+
* callers only provide stateKey — GenuiBlock owns the matching React identity.
|
|
23
25
|
*/
|
|
24
26
|
stateKey?: string | undefined
|
|
25
27
|
}
|
package/src/client/dom-fence.tsx
CHANGED
|
@@ -354,6 +354,8 @@ export function installDomFenceRenderer(
|
|
|
354
354
|
driftWarned = false
|
|
355
355
|
plausibilityWarned = false
|
|
356
356
|
const mounts = new Map<HTMLElement, Mount>()
|
|
357
|
+
let disposed = false
|
|
358
|
+
let rafId: number | null = null
|
|
357
359
|
|
|
358
360
|
const sessionIdOf = (): SessionId | undefined => {
|
|
359
361
|
try {
|
|
@@ -513,6 +515,7 @@ export function installDomFenceRenderer(
|
|
|
513
515
|
* the streaming→settled transition), repair surgery, then take over every
|
|
514
516
|
* new dsh-ui block — settled or still streaming. */
|
|
515
517
|
function sweep(): void {
|
|
518
|
+
if (disposed) return
|
|
516
519
|
for (const [block, mount] of mounts) {
|
|
517
520
|
if (!block.isConnected) {
|
|
518
521
|
unmountBlock(block)
|
|
@@ -605,12 +608,11 @@ export function installDomFenceRenderer(
|
|
|
605
608
|
}
|
|
606
609
|
}
|
|
607
610
|
|
|
608
|
-
let scheduled = false
|
|
609
611
|
const schedule = (): void => {
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
if (disposed || rafId !== null) return
|
|
613
|
+
rafId = requestAnimationFrame(() => {
|
|
614
|
+
rafId = null
|
|
615
|
+
if (disposed) return
|
|
614
616
|
sweep()
|
|
615
617
|
})
|
|
616
618
|
}
|
|
@@ -634,8 +636,13 @@ export function installDomFenceRenderer(
|
|
|
634
636
|
sweep()
|
|
635
637
|
|
|
636
638
|
return () => {
|
|
639
|
+
disposed = true
|
|
637
640
|
observer.disconnect()
|
|
638
641
|
window.clearInterval(interval)
|
|
642
|
+
if (rafId !== null) {
|
|
643
|
+
cancelAnimationFrame(rafId)
|
|
644
|
+
rafId = null
|
|
645
|
+
}
|
|
639
646
|
for (const block of Array.from(mounts.keys())) unmountBlock(block)
|
|
640
647
|
}
|
|
641
648
|
}
|
|
@@ -24,6 +24,7 @@ import { parsePartialGenuiSpec } from './parse-partial.ts'
|
|
|
24
24
|
import { applyPanelOperation, diagnosePanelBudget, type PanelOperationStatus } from './panel-store.ts'
|
|
25
25
|
import type { GenuiSpec } from './spec.ts'
|
|
26
26
|
import { completeFenceJson, describeJsonFailure, isCompleteJson, repairFenceJson } from '../shared/fence-repair.ts'
|
|
27
|
+
import { validateRenderableChartSemantics } from '../plugin/chart-contract.ts'
|
|
27
28
|
|
|
28
29
|
/** Settled fence source identity (data shape, host-independent). */
|
|
29
30
|
export interface GenuiFenceSource {
|
|
@@ -53,6 +54,14 @@ const FENCE_ERROR_STYLE: CSSProperties = {
|
|
|
53
54
|
whiteSpace: 'pre-wrap',
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** Return a chart-semantic diagnostic for parseable raw fence content. */
|
|
58
|
+
function chartSemanticFailure(raw: string): string | null {
|
|
59
|
+
const parsed = parsePartialGenuiSpec(raw)
|
|
60
|
+
if (parsed === null) return null
|
|
61
|
+
const errors = validateRenderableChartSemantics(parsed)
|
|
62
|
+
return errors.length === 0 ? null : errors.join(';')
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
/**
|
|
57
66
|
* Fallback for a ```dsh-ui fence whose body has no finished component yet.
|
|
58
67
|
* Two very different situations land here and they must not be conflated:
|
|
@@ -64,11 +73,11 @@ const FENCE_ERROR_STYLE: CSSProperties = {
|
|
|
64
73
|
* correct rendering (partial JSON must never look like an error).
|
|
65
74
|
*
|
|
66
75
|
* 2. **Settled defect** — the message is finished but the body still does
|
|
67
|
-
* not parse as JSON (a malformed fence like a missing `}`)
|
|
68
|
-
* fail silently: the fence degraded to
|
|
69
|
-
* author had no way to know the UI never
|
|
70
|
-
* marker is gone, surface a compact diagnostic
|
|
71
|
-
* the defect is visible instead of silent.
|
|
76
|
+
* not parse as JSON (a malformed fence like a missing `}`) or violates the
|
|
77
|
+
* native chart contract. This used to fail silently: the fence degraded to
|
|
78
|
+
* a code block with no hint, and the author had no way to know the UI never
|
|
79
|
+
* rendered. Once the streaming marker is gone, surface a compact diagnostic
|
|
80
|
+
* so the defect is visible instead of silent.
|
|
72
81
|
*/
|
|
73
82
|
function FenceFallback({ raw, fenceKey }: { raw: string; fenceKey: Key }) {
|
|
74
83
|
const ref = useRef<HTMLDivElement | null>(null)
|
|
@@ -77,12 +86,18 @@ function FenceFallback({ raw, fenceKey }: { raw: string; fenceKey: Key }) {
|
|
|
77
86
|
const node = ref.current
|
|
78
87
|
if (node !== null && node.closest('[data-streaming]') === null) setSettled(true)
|
|
79
88
|
})
|
|
80
|
-
const
|
|
89
|
+
const chartDiagnostic = settled ? chartSemanticFailure(raw) : null
|
|
90
|
+
const parseDiagnostic = settled && chartDiagnostic === null && raw.trim() !== '' ? describeJsonFailure(raw) : null
|
|
81
91
|
return (
|
|
82
92
|
<div ref={ref}>
|
|
83
|
-
{
|
|
93
|
+
{chartDiagnostic !== null && (
|
|
94
|
+
<div style={FENCE_ERROR_STYLE} role="alert">
|
|
95
|
+
⚠️ dsh-ui chart 字段验证失败:{chartDiagnostic} —— 围栏保持为代码块;请修正 chart 字段后重发。
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{chartDiagnostic === null && parseDiagnostic !== null && (
|
|
84
99
|
<div style={FENCE_ERROR_STYLE} role="alert">
|
|
85
|
-
⚠️ dsh-ui fence JSON 解析失败{
|
|
100
|
+
⚠️ dsh-ui fence JSON 解析失败{parseDiagnostic} —— 围栏保持为代码块;请让模型检查并修复 JSON 后重发。
|
|
86
101
|
</div>
|
|
87
102
|
)}
|
|
88
103
|
<CodeBlock key={fenceKey} code={`${raw}\n`} lang="dsh-ui" />
|
|
@@ -115,6 +130,12 @@ function FencePanelPublisher({ sessionId, sourceId, order, spec }: {
|
|
|
115
130
|
return null
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
/** Repair one parsed value only when its native charts are actually renderable. */
|
|
134
|
+
function repairRenderableSpec(value: unknown): GenuiSpec | null {
|
|
135
|
+
if (validateRenderableChartSemantics(value).length > 0) return null
|
|
136
|
+
return repairGenuiSpec(value)
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
/**
|
|
119
140
|
* Resolve a raw fence body to a guarded spec.
|
|
120
141
|
*
|
|
@@ -124,21 +145,24 @@ function FencePanelPublisher({ sessionId, sourceId, order, spec }: {
|
|
|
124
145
|
* - Tier-2 completion (missing quotes/brackets): settled renders only —
|
|
125
146
|
* `context.source` exists exclusively once the message finished, so
|
|
126
147
|
* streaming halves are never completed early.
|
|
148
|
+
* - Native chart semantics are checked before repair on every candidate so
|
|
149
|
+
* aliases, empty collections, or line/donut series cannot repair into a
|
|
150
|
+
* default/blank chart.
|
|
127
151
|
*/
|
|
128
152
|
export function resolveGenuiSpec(raw: string, context?: GenuiFenceContext): GenuiSpec | null {
|
|
129
153
|
const parsed = parsePartialGenuiSpec(raw)
|
|
130
|
-
let spec = parsed === null ? null :
|
|
154
|
+
let spec = parsed === null ? null : repairRenderableSpec(parsed)
|
|
131
155
|
if (spec === null) {
|
|
132
156
|
const repaired = repairFenceJson(raw)
|
|
133
157
|
if (repaired !== null) {
|
|
134
158
|
const reparsed = parsePartialGenuiSpec(repaired.text)
|
|
135
|
-
spec = reparsed === null ? null :
|
|
159
|
+
spec = reparsed === null ? null : repairRenderableSpec(reparsed)
|
|
136
160
|
}
|
|
137
161
|
if (spec === null && context?.source !== undefined) {
|
|
138
162
|
const completed = completeFenceJson(raw)
|
|
139
163
|
if (completed !== null) {
|
|
140
164
|
const reparsed = parsePartialGenuiSpec(completed.text)
|
|
141
|
-
spec = reparsed === null ? null :
|
|
165
|
+
spec = reparsed === null ? null : repairRenderableSpec(reparsed)
|
|
142
166
|
}
|
|
143
167
|
}
|
|
144
168
|
}
|
package/src/client/gallery.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GenUI gallery:
|
|
3
|
-
*
|
|
4
|
-
* component family appears; docs and demos reuse it as the "show everything"
|
|
5
|
-
* fence).
|
|
2
|
+
* GenUI gallery: canonical broad-coverage sample of built-in node types.
|
|
3
|
+
* Docs, demos, and renderer tests reuse it as the component gallery fence.
|
|
6
4
|
*/
|
|
7
5
|
import type { GenuiSpec } from './spec.ts'
|
|
8
6
|
|
|
9
|
-
/**
|
|
7
|
+
/** Canonical gallery spec for the built-in GenUI component vocabulary. */
|
|
10
8
|
export const gallerySpec: GenuiSpec = {
|
|
11
9
|
title: 'GenUI · 组件画廊',
|
|
12
10
|
gap: 14,
|
|
@@ -27,6 +25,7 @@ export const gallerySpec: GenuiSpec = {
|
|
|
27
25
|
{ type: 'link', label: '详情链接' },
|
|
28
26
|
], wrap: true },
|
|
29
27
|
{ type: 'divider' },
|
|
28
|
+
{ type: 'image', src: '/demo-image.png', alt: '图片展示演示' },
|
|
30
29
|
{ type: 'audio', src: '/demo-audio.mp3', alt: '音频播放器演示' },
|
|
31
30
|
{ type: 'video', src: '/demo-video.mp4', alt: '视频播放器演示', poster: '/demo-video.jpg', aspectRatio: '16:9' },
|
|
32
31
|
{ type: 'grid', cols: 3, items: [
|
package/src/client/guard.ts
CHANGED
|
@@ -283,6 +283,14 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
283
283
|
if (label === undefined) return null
|
|
284
284
|
return { type: 'link', label, ...opt('href', safeHref(v.href)) }
|
|
285
285
|
}
|
|
286
|
+
case 'image': {
|
|
287
|
+
const src = safeMediaSrc(v.src)
|
|
288
|
+
if (src === undefined) return null
|
|
289
|
+
return {
|
|
290
|
+
type: 'image', src,
|
|
291
|
+
...opt('alt', str(v.alt, GENUI_LIMITS.maxString)),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
286
294
|
case 'audio': {
|
|
287
295
|
const src = safeMediaSrc(v.src)
|
|
288
296
|
if (src === undefined) return null
|
|
@@ -1179,61 +1187,96 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
|
|
|
1179
1187
|
* `"type"` strings (e.g. file-tree's `{type:'file'}` children). */
|
|
1180
1188
|
export const GENUI_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
1181
1189
|
'accordion', 'audio', 'avatar', 'badge', 'breadcrumb', 'button', 'callout', 'card', 'chart',
|
|
1182
|
-
'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'input', 'json',
|
|
1190
|
+
'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'image', 'input', 'json',
|
|
1183
1191
|
'keyvalue', 'link', 'list', 'mermaid', 'plot', 'progress', 'quiz', 'radio', 'row', 'scene3d',
|
|
1184
1192
|
'select', 'slider', 'spacer', 'stat', 'steps', 'submit', 'switch', 'table', 'tabs', 'text',
|
|
1185
1193
|
'textarea', 'timeline', 'video', 'echart', 'diagram',
|
|
1186
1194
|
])
|
|
1187
1195
|
|
|
1188
1196
|
/**
|
|
1189
|
-
*
|
|
1197
|
+
* Visit and count declared nodes in a raw spec tree: objects whose `type` is a
|
|
1190
1198
|
* white-listed string, descending the same containers `countGenuiNodes`
|
|
1191
|
-
* walks.
|
|
1192
|
-
*
|
|
1193
|
-
* #42) instead of reporting a green check on a half-empty tree.
|
|
1199
|
+
* walks. Callers can inspect field semantics or compare the count with the
|
|
1200
|
+
* repaired tree without maintaining another traversal.
|
|
1194
1201
|
*/
|
|
1195
|
-
|
|
1202
|
+
function visitDeclaredGenuiNodes(
|
|
1203
|
+
value: unknown,
|
|
1204
|
+
cap: number,
|
|
1205
|
+
visit: (node: Record<string, unknown>, at: string) => void,
|
|
1206
|
+
): number {
|
|
1196
1207
|
let count = 0
|
|
1197
1208
|
const declared = (candidate: unknown): boolean => {
|
|
1198
1209
|
const o = obj(candidate)
|
|
1199
1210
|
return o !== undefined && typeof o.type === 'string' && GENUI_NODE_TYPES.has(o.type)
|
|
1200
1211
|
}
|
|
1201
|
-
|
|
1212
|
+
function walk(list: unknown, path: string): void {
|
|
1202
1213
|
if (!Array.isArray(list)) return
|
|
1203
|
-
for (
|
|
1214
|
+
for (let index = 0; index < list.length; index++) {
|
|
1215
|
+
walkNode(list[index], `${path}[${index}]`)
|
|
1204
1216
|
if (count >= cap) return
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
function walkNode(item: unknown, at: string): void {
|
|
1220
|
+
if (count >= cap || !declared(item)) return
|
|
1221
|
+
const v = obj(item)
|
|
1222
|
+
if (v === undefined) return
|
|
1223
|
+
count += 1
|
|
1224
|
+
visit(v, at)
|
|
1225
|
+
if (v.type === 'tabs' && Array.isArray(v.tabs)) {
|
|
1226
|
+
for (let tab = 0; tab < v.tabs.length; tab++) {
|
|
1227
|
+
walkItemsOf(v.tabs[tab], `${at}.tabs[${tab}]`)
|
|
1228
|
+
}
|
|
1229
|
+
} else if (v.type === 'accordion' && Array.isArray(v.items)) {
|
|
1230
|
+
for (let row = 0; row < v.items.length; row++) {
|
|
1231
|
+
walkItemsOf(v.items[row], `${at}.items[${row}]`)
|
|
1232
|
+
}
|
|
1233
|
+
} else if ((v.type === 'row' || v.type === 'col' || v.type === 'grid' || v.type === 'card') && Array.isArray(v.items)) {
|
|
1234
|
+
walk(v.items, `${at}.items`)
|
|
1235
|
+
} else if (v.type === 'list' && Array.isArray(v.items)) {
|
|
1236
|
+
for (let row = 0; row < v.items.length; row++) {
|
|
1237
|
+
walkNode(v.items[row], `${at}.items[${row}]`)
|
|
1219
1238
|
}
|
|
1220
1239
|
}
|
|
1221
1240
|
}
|
|
1222
|
-
|
|
1241
|
+
function walkItemsOf(holder: unknown, path: string): void {
|
|
1223
1242
|
const o = obj(holder)
|
|
1224
1243
|
if (o === undefined) return
|
|
1225
1244
|
const items = o.items !== undefined ? o.items : o.content
|
|
1226
|
-
if (Array.isArray(items)) walk(items)
|
|
1227
|
-
else
|
|
1245
|
+
if (Array.isArray(items)) walk(items, `${path}.items`)
|
|
1246
|
+
else walkNode(items, `${path}.items`)
|
|
1228
1247
|
}
|
|
1229
1248
|
const root = obj(value)
|
|
1230
1249
|
if (root === undefined) return count
|
|
1231
1250
|
// Single-component root (no items array): the root itself is the declared node.
|
|
1232
|
-
if (!Array.isArray(root.items) && declared(value))
|
|
1233
|
-
else walk(root.items)
|
|
1251
|
+
if (!Array.isArray(root.items) && declared(value)) walkNode(value, 'spec')
|
|
1252
|
+
else walk(root.items, 'items')
|
|
1234
1253
|
return count
|
|
1235
1254
|
}
|
|
1236
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* Count white-listed nodes declared by a raw spec before repair drops invalid entries.
|
|
1258
|
+
* @param value - raw GenUI spec.
|
|
1259
|
+
* @param cap - traversal ceiling.
|
|
1260
|
+
* @returns declared node count up to the ceiling.
|
|
1261
|
+
*/
|
|
1262
|
+
export function countDeclaredGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
|
|
1263
|
+
return visitDeclaredGenuiNodes(value, cap, () => {})
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Return field-level chart errors without changing other repairable component families.
|
|
1268
|
+
* @param value - raw GenUI spec.
|
|
1269
|
+
* @returns chart semantic errors in tree order.
|
|
1270
|
+
*/
|
|
1271
|
+
export function validateGenuiChartSemantics(value: unknown): string[] {
|
|
1272
|
+
const errors: string[] = []
|
|
1273
|
+
visitDeclaredGenuiNodes(value, GENUI_LIMITS.maxNodes + 1, (node, at) => {
|
|
1274
|
+
if (node.type !== 'chart') return
|
|
1275
|
+
validateChartNode(node, at, errors)
|
|
1276
|
+
})
|
|
1277
|
+
return errors
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1237
1280
|
/**
|
|
1238
1281
|
* Validate a raw spec value against the white list and limits, collecting
|
|
1239
1282
|
* human-readable problems. Unlike repair this never mutates: it is a
|
|
@@ -1281,6 +1324,59 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
|
|
|
1281
1324
|
|
|
1282
1325
|
type Walker = (list: unknown, depth: number, path: string) => void
|
|
1283
1326
|
|
|
1327
|
+
function validateChartData(value: unknown, at: string, errors: string[]): void {
|
|
1328
|
+
if (!Array.isArray(value)) return
|
|
1329
|
+
for (let index = 0; index < value.length; index++) {
|
|
1330
|
+
const datum = obj(value[index])
|
|
1331
|
+
const path = `${at}[${index}]`
|
|
1332
|
+
if (datum === undefined) {
|
|
1333
|
+
errors.push(`${path} must be an object`)
|
|
1334
|
+
continue
|
|
1335
|
+
}
|
|
1336
|
+
if (typeof datum.label !== 'string') errors.push(`${path}.label must be a string`)
|
|
1337
|
+
if (typeof datum.value !== 'number' || !Number.isFinite(datum.value)) {
|
|
1338
|
+
errors.push(`${path}.value must be a finite number`)
|
|
1339
|
+
}
|
|
1340
|
+
if (datum.color !== undefined && typeof datum.color !== 'string') {
|
|
1341
|
+
errors.push(`${path}.color must be a string`)
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function validateChartSeries(value: unknown, at: string, errors: string[]): void {
|
|
1347
|
+
if (!Array.isArray(value)) return
|
|
1348
|
+
for (let index = 0; index < value.length; index++) {
|
|
1349
|
+
const series = obj(value[index])
|
|
1350
|
+
const path = `${at}[${index}]`
|
|
1351
|
+
if (series === undefined) {
|
|
1352
|
+
errors.push(`${path} must be an object`)
|
|
1353
|
+
continue
|
|
1354
|
+
}
|
|
1355
|
+
if (typeof series.label !== 'string') errors.push(`${path}.label must be a string`)
|
|
1356
|
+
if (series.color !== undefined && typeof series.color !== 'string') {
|
|
1357
|
+
errors.push(`${path}.color must be a string`)
|
|
1358
|
+
}
|
|
1359
|
+
if (!Array.isArray(series.data)) errors.push(`${path}.data must be an array`)
|
|
1360
|
+
validateChartData(series.data, `${path}.data`, errors)
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function validateChartNode(v: Record<string, unknown>, at: string, errors: string[]): void {
|
|
1365
|
+
if (!Array.isArray(v.data) && !Array.isArray(v.series)) {
|
|
1366
|
+
errors.push(`${at}: type 'chart' requires data or series (array)`)
|
|
1367
|
+
}
|
|
1368
|
+
if (v.variant !== undefined) errors.push(`${at}.variant is unsupported; use kind`)
|
|
1369
|
+
if (v.data !== undefined && !Array.isArray(v.data)) errors.push(`${at}.data must be an array`)
|
|
1370
|
+
if (v.series !== undefined && !Array.isArray(v.series)) errors.push(`${at}.series must be an array`)
|
|
1371
|
+
if (v.kind !== undefined
|
|
1372
|
+
&& (typeof v.kind !== 'string'
|
|
1373
|
+
|| !CHART_KINDS.includes(v.kind as typeof CHART_KINDS[number]))) {
|
|
1374
|
+
errors.push(`${at}.kind must be bars, line, or donut`)
|
|
1375
|
+
}
|
|
1376
|
+
validateChartData(v.data, `${at}.data`, errors)
|
|
1377
|
+
validateChartSeries(v.series, `${at}.series`, errors)
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1284
1380
|
function validateNode(value: unknown, depth: number, at: string, errors: string[], walk: Walker): void {
|
|
1285
1381
|
if (depth > GENUI_LIMITS.maxDepth) {
|
|
1286
1382
|
errors.push(`${at}: exceeds max depth ${GENUI_LIMITS.maxDepth}`)
|
|
@@ -1315,7 +1411,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1315
1411
|
if (typeof v.label !== 'string') errors.push(`${at}: type '${type}' requires label (string)`)
|
|
1316
1412
|
isStr('label')
|
|
1317
1413
|
break
|
|
1318
|
-
case 'audio': case 'video':
|
|
1414
|
+
case 'image': case 'audio': case 'video':
|
|
1319
1415
|
if (typeof v.src !== 'string') errors.push(`${at}: type '${type}' requires src (string)`)
|
|
1320
1416
|
isStr('src')
|
|
1321
1417
|
isStr('alt')
|
|
@@ -1378,7 +1474,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1378
1474
|
if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
|
|
1379
1475
|
break
|
|
1380
1476
|
case 'chart':
|
|
1381
|
-
|
|
1477
|
+
validateChartNode(v, at, errors)
|
|
1382
1478
|
break
|
|
1383
1479
|
case 'tabs': {
|
|
1384
1480
|
if (!Array.isArray(v.tabs)) errors.push(`${at}: type 'tabs' requires tabs (array)`)
|