@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
|
@@ -75,7 +75,20 @@ export const DIAGRAM_NODE_TYPES = ['focal', 'backend', 'store', 'external', 'inp
|
|
|
75
75
|
export const DIAGRAM_VARIANTS = ['light', 'dark', 'editorial'] as const
|
|
76
76
|
export const DIAGRAM_EDGE_KINDS = ['solid', 'dashed', 'accent', 'link'] as const
|
|
77
77
|
export const DIAGRAM_ROUTES = ['auto', 'orthogonal', 'straight'] as const
|
|
78
|
-
export const ECHART_PRESETS = [
|
|
78
|
+
export const ECHART_PRESETS = [
|
|
79
|
+
'bar', 'line', 'area', 'pie', 'scatter',
|
|
80
|
+
'radar', 'gauge', 'funnel', 'treemap', 'sankey', 'graph', 'heatmap', 'bigline',
|
|
81
|
+
] as const
|
|
82
|
+
/** Oversized single-number stat (one per fence as the visual anchor). */
|
|
83
|
+
export const STAT_SIZES = ['hero'] as const
|
|
84
|
+
/** Progress shapes: a track (default) or a circular gauge. */
|
|
85
|
+
export const PROGRESS_VARIANTS = ['bar', 'ring'] as const
|
|
86
|
+
/** Semantic card surfaces. */
|
|
87
|
+
export const CARD_TONES = ['info', 'success', 'warning', 'danger'] as const
|
|
88
|
+
/** Hero cover tones. */
|
|
89
|
+
export const HERO_TONES = ['accent', 'success', 'warning', 'danger'] as const
|
|
90
|
+
/** Table cell renderers (`table.types`, one entry per column). */
|
|
91
|
+
export const TABLE_CELL_TYPES = ['text', 'num', 'delta', 'bar', 'badge', 'spark', 'ring', 'index', 'group'] as const
|
|
79
92
|
|
|
80
93
|
const schema = (
|
|
81
94
|
required: readonly string[],
|
|
@@ -119,7 +132,7 @@ const recordSchema = (
|
|
|
119
132
|
enums: Readonly<Record<string, readonly string[]>> = {},
|
|
120
133
|
): ComponentRecordSchema => ({ required, fields, enums, nested })
|
|
121
134
|
|
|
122
|
-
const nodeFields = { type: 'string' } as const
|
|
135
|
+
const nodeFields = { type: 'string', span: 'number' } as const
|
|
123
136
|
|
|
124
137
|
const chartDatumSchema = recordSchema(['label', 'value'], {
|
|
125
138
|
label: 'string',
|
|
@@ -233,11 +246,21 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
|
|
|
233
246
|
breadcrumb: schema(['items'], { ...nodeFields, items: 'array' }),
|
|
234
247
|
button: schema(['label'], { ...nodeFields, label: 'string', tone: 'string', full: 'boolean', small: 'boolean', icon: 'string', action: 'string' }, {}, { enums: { tone: BUTTON_TONES } }),
|
|
235
248
|
callout: schema(['content'], { ...nodeFields, title: 'string', content: 'string', tone: 'string' }, { kind: 'tone' }, { enums: { tone: CALLOUT_TONES } }),
|
|
236
|
-
card: schema(['items'], { ...nodeFields, title: 'string', items: 'nodes' }, { label: 'title', content: 'items' }),
|
|
237
|
-
chart: schema([], {
|
|
249
|
+
card: schema(['items'], { ...nodeFields, title: 'string', items: 'nodes', tone: 'string', accent: 'string' }, { label: 'title', content: 'items' }, { enums: { tone: CARD_TONES } }),
|
|
250
|
+
chart: schema([], {
|
|
251
|
+
...nodeFields,
|
|
252
|
+
kind: 'string',
|
|
253
|
+
data: 'array',
|
|
254
|
+
series: 'array',
|
|
255
|
+
horizontal: 'boolean',
|
|
256
|
+
stacked: 'boolean',
|
|
257
|
+
filter: 'string',
|
|
258
|
+
palette: 'array',
|
|
259
|
+
}, {}, {
|
|
238
260
|
oneOfRequired: [['data', 'series']],
|
|
261
|
+
// `line` may carry its points in `series` (multi-series line); only the
|
|
262
|
+
// donut is a single-series shape that always needs `data`.
|
|
239
263
|
conditionalRequired: [
|
|
240
|
-
{ kind: 'required-if', when: { field: 'kind', equals: 'line' }, required: ['data'] },
|
|
241
264
|
{ kind: 'required-if', when: { field: 'kind', equals: 'donut' }, required: ['data'] },
|
|
242
265
|
],
|
|
243
266
|
nested: { data: chartDatumSchema, series: chartSeriesSchema },
|
|
@@ -254,8 +277,9 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
|
|
|
254
277
|
}),
|
|
255
278
|
diff: schema(['diffs'], { ...nodeFields, diffs: 'array' }, {}, { nested: { diffs: diffRecordSchema } }),
|
|
256
279
|
divider: schema([], nodeFields),
|
|
257
|
-
echart: schema([], { ...nodeFields, title: 'string', height: 'number', preset: 'string', data: 'array', series: 'array', option: 'object' }, {}, {
|
|
258
|
-
|
|
280
|
+
echart: schema([], { ...nodeFields, title: 'string', height: 'number', preset: 'string', data: 'array', series: 'array', links: 'array', palette: 'array', option: 'object' }, {}, {
|
|
281
|
+
// `links` alone is valid: the sankey/graph presets are edge-driven.
|
|
282
|
+
oneOfRequired: [['option', 'data', 'series', 'links']],
|
|
259
283
|
enums: { preset: ECHART_PRESETS },
|
|
260
284
|
}),
|
|
261
285
|
'file-tree': schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: fileTreeNodeSchema } }),
|
|
@@ -265,10 +289,10 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
|
|
|
265
289
|
json: schema(['value'], { ...nodeFields, value: 'unknown' }),
|
|
266
290
|
keyvalue: schema(['pairs'], { ...nodeFields, pairs: 'array' }, {}, { nested: { pairs: keyValueRecordSchema } }),
|
|
267
291
|
link: schema(['label'], { ...nodeFields, label: 'string', href: 'string' }),
|
|
268
|
-
list: schema(['items'], { ...nodeFields, items: 'array' }),
|
|
292
|
+
list: schema(['items'], { ...nodeFields, items: 'array', filter: 'string' }),
|
|
269
293
|
mermaid: schema(['code'], { ...nodeFields, code: 'string' }),
|
|
270
294
|
plot: schema(['series'], { ...nodeFields, series: 'array', xMin: 'number', xMax: 'number', yMin: 'number', yMax: 'number', title: 'string' }, {}, { nested: { series: plotSeriesSchema } }),
|
|
271
|
-
progress: schema(['value'], { ...nodeFields, value: 'number', label: 'string', valueLabel: 'string' }),
|
|
295
|
+
progress: schema(['value'], { ...nodeFields, value: 'number', label: 'string', valueLabel: 'string', variant: 'string', target: 'number' }, {}, { enums: { variant: PROGRESS_VARIANTS } }),
|
|
272
296
|
quiz: schema(['question', 'options'], { ...nodeFields, question: 'string', options: 'array', explanation: 'string', id: 'string', action: 'string' }),
|
|
273
297
|
radio: schema(['options'], { ...nodeFields, label: 'string', options: 'array', selected: 'number', action: 'string', group: 'string', answer: 'unknown', explanation: 'string' }),
|
|
274
298
|
row: schema(['items'], { ...nodeFields, items: 'nodes', wrap: 'boolean', spacer: 'boolean' }),
|
|
@@ -276,11 +300,21 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
|
|
|
276
300
|
select: schema(['options'], { ...nodeFields, label: 'string', options: 'array', action: 'string', selected: 'number', id: 'string' }),
|
|
277
301
|
slider: schema([], { ...nodeFields, label: 'string', min: 'number', max: 'number', step: 'number', value: 'number', action: 'string', id: 'string' }),
|
|
278
302
|
spacer: schema([], nodeFields),
|
|
279
|
-
|
|
303
|
+
hero: schema(['title'], {
|
|
304
|
+
...nodeFields,
|
|
305
|
+
title: 'string',
|
|
306
|
+
subtitle: 'string',
|
|
307
|
+
value: 'string',
|
|
308
|
+
label: 'string',
|
|
309
|
+
delta: 'string',
|
|
310
|
+
spark: 'array',
|
|
311
|
+
tone: 'string',
|
|
312
|
+
}, {}, { enums: { tone: HERO_TONES } }),
|
|
313
|
+
stat: schema(['label', 'value'], { ...nodeFields, label: 'string', value: 'string', delta: 'string', spark: 'array', size: 'string' }, {}, { enums: { size: STAT_SIZES } }),
|
|
280
314
|
steps: schema(['steps'], { ...nodeFields, steps: 'array', current: 'number' }, { items: 'steps' }, { nested: { steps: stepsRecordSchema } }),
|
|
281
315
|
submit: schema(['label'], { ...nodeFields, label: 'string', action: 'string', resetAction: 'string', groups: 'array' }),
|
|
282
316
|
switch: schema(['label'], { ...nodeFields, label: 'string', checked: 'boolean', action: 'string' }),
|
|
283
|
-
table: schema(['columns', 'rows'], { ...nodeFields, columns: 'array', rows: 'array' }, { headers: 'columns', data: 'rows' }),
|
|
317
|
+
table: schema(['columns', 'rows'], { ...nodeFields, columns: 'array', rows: 'array', types: 'array', total: 'boolean', details: 'array', filter: 'string', filterColumn: 'number', sortField: 'string', export: 'boolean' }, { headers: 'columns', data: 'rows' }),
|
|
284
318
|
tabs: schema(['tabs'], { ...nodeFields, tabs: 'array' }, {}, { nested: { tabs: tabHolderSchema } }),
|
|
285
319
|
text: schema(['content'], { ...nodeFields, content: 'string', size: 'string', center: 'boolean' }, { text: 'content' }, { enums: { size: TEXT_SIZES } }),
|
|
286
320
|
textarea: schema([], { ...nodeFields, label: 'string', placeholder: 'string', rows: 'number', value: 'string', action: 'string', id: 'string' }),
|
package/src/client/guard.ts
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
import type { GenuiFileTreeNode, GenuiList, GenuiNode, GenuiPlot, GenuiPlotSeries, GenuiScene3D, GenuiSpec, GenuiDiagram, GenuiDiagramTheme, GenuiDiagramKind } from './spec.ts'
|
|
22
22
|
import { wrapSingleComponentRoot } from './spec.ts'
|
|
23
23
|
import {
|
|
24
|
-
BADGE_TONES, BUTTON_TONES, CALLOUT_TONES, CHART_KINDS, COMPONENT_SCHEMAS,
|
|
25
|
-
DIAGRAM_KINDS, DIAGRAM_NODE_TYPES, DIAGRAM_ROUTES, DIAGRAM_VARIANTS,
|
|
26
|
-
|
|
27
|
-
TEXT_SIZES,
|
|
24
|
+
BADGE_TONES, BUTTON_TONES, CALLOUT_TONES, CARD_TONES, CHART_KINDS, COMPONENT_SCHEMAS, HERO_TONES,
|
|
25
|
+
DIAGRAM_EDGE_KINDS, DIAGRAM_KINDS, DIAGRAM_NODE_TYPES, DIAGRAM_ROUTES, DIAGRAM_VARIANTS,
|
|
26
|
+
ECHART_PRESETS, FILE_TYPES, GENUI_NATIVE_TYPES, GENUI_SPEC_SCHEMA, INPUT_TYPES,
|
|
27
|
+
MEDIA_ASPECT_RATIOS, MESH_SHAPES, PLOT_KINDS, PROGRESS_VARIANTS, TABLE_CELL_TYPES, TEXT_SIZES,
|
|
28
28
|
} from './genui-runtime/schema.ts'
|
|
29
29
|
import type { ComponentFieldKind, ComponentRecordSchema, ComponentSchema } from './genui-runtime/schema.ts'
|
|
30
30
|
import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
|
|
@@ -209,7 +209,41 @@ function repairItems(list: unknown, ctx: RepairCtx, depth: number): GenuiNode[]
|
|
|
209
209
|
return out
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/** Optional explicit palette: up to 12 validated colour values. */
|
|
213
|
+
function paletteValues(v: unknown): string[] | undefined {
|
|
214
|
+
if (!Array.isArray(v)) return undefined
|
|
215
|
+
const out: string[] = []
|
|
216
|
+
for (const item of v.slice(0, 12)) {
|
|
217
|
+
const value = color(item)
|
|
218
|
+
if (value === undefined) continue
|
|
219
|
+
out.push(value)
|
|
220
|
+
}
|
|
221
|
+
return out.length > 0 ? out : undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Optional `stat.spark` series: finite numbers only, 2..60 points. */
|
|
225
|
+
function sparkValues(v: unknown): number[] | undefined {
|
|
226
|
+
if (!Array.isArray(v)) return undefined
|
|
227
|
+
const out: number[] = []
|
|
228
|
+
for (const item of v.slice(0, 60)) {
|
|
229
|
+
const n = typeof item === 'number' ? item : Number(item)
|
|
230
|
+
if (!Number.isFinite(n)) return undefined
|
|
231
|
+
out.push(n)
|
|
232
|
+
}
|
|
233
|
+
return out.length >= 2 ? out : undefined
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Layout hints are component-agnostic: `span` survives repair on ANY node
|
|
237
|
+
* (the renderer applies it inside a grid). Kept out of the per-case switches
|
|
238
|
+
* so a new component type cannot forget it. */
|
|
212
239
|
function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode | null {
|
|
240
|
+
const node = repairNodeFields(value, ctx, depth)
|
|
241
|
+
if (node === null) return null
|
|
242
|
+
const span = int(obj(value)?.span, 2, 12)
|
|
243
|
+
return span === undefined ? node : ({ ...node, span } as GenuiNode)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiNode | null {
|
|
213
247
|
if (depth > GENUI_LIMITS.maxDepth) return null
|
|
214
248
|
const v = obj(value)
|
|
215
249
|
if (v === undefined) return null
|
|
@@ -231,7 +265,13 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
231
265
|
return { type: 'grid', cols: int(v.cols, 1, GENUI_LIMITS.maxGridCols) ?? 1, items: repairItems(v.items, ctx, depth + 1) }
|
|
232
266
|
}
|
|
233
267
|
case 'card': {
|
|
234
|
-
return {
|
|
268
|
+
return {
|
|
269
|
+
type: 'card',
|
|
270
|
+
items: repairItems(v.items, ctx, depth + 1),
|
|
271
|
+
...opt('title', str(v.title, GENUI_LIMITS.maxString)),
|
|
272
|
+
...opt('tone', enu(v.tone, CARD_TONES)),
|
|
273
|
+
...opt('accent', color(v.accent)),
|
|
274
|
+
}
|
|
235
275
|
}
|
|
236
276
|
case 'button': {
|
|
237
277
|
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
@@ -316,16 +356,40 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
316
356
|
if (label === undefined) return null
|
|
317
357
|
return { type: 'badge', label, ...opt('tone', enu(v.tone, BADGE_TONES)), ...opt('icon', str(v.icon, 64)) }
|
|
318
358
|
}
|
|
359
|
+
case 'hero': {
|
|
360
|
+
const title = str(v.title, GENUI_LIMITS.maxString)
|
|
361
|
+
if (title === undefined) return null
|
|
362
|
+
return {
|
|
363
|
+
type: 'hero', title,
|
|
364
|
+
...opt('subtitle', str(v.subtitle, GENUI_LIMITS.maxString)),
|
|
365
|
+
...opt('value', str(v.value, 128)),
|
|
366
|
+
...opt('label', str(v.label, GENUI_LIMITS.maxString)),
|
|
367
|
+
...opt('delta', str(v.delta, 64)),
|
|
368
|
+
...opt('spark', sparkValues(v.spark)),
|
|
369
|
+
...opt('tone', enu(v.tone, HERO_TONES)),
|
|
370
|
+
}
|
|
371
|
+
}
|
|
319
372
|
case 'stat': {
|
|
320
373
|
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
321
374
|
const value = str(v.value, 128)
|
|
322
375
|
if (label === undefined || value === undefined) return null
|
|
323
|
-
return {
|
|
376
|
+
return {
|
|
377
|
+
type: 'stat', label, value,
|
|
378
|
+
...opt('delta', str(v.delta, 64)),
|
|
379
|
+
...opt('spark', sparkValues(v.spark)),
|
|
380
|
+
...opt('size', v.size === 'hero' ? 'hero' as const : undefined),
|
|
381
|
+
}
|
|
324
382
|
}
|
|
325
383
|
case 'progress': {
|
|
326
384
|
const value = num(v.value, 0, 100)
|
|
327
385
|
if (value === undefined) return null
|
|
328
|
-
return {
|
|
386
|
+
return {
|
|
387
|
+
type: 'progress', value,
|
|
388
|
+
...opt('label', str(v.label, GENUI_LIMITS.maxString)),
|
|
389
|
+
...opt('valueLabel', str(v.valueLabel, 64)),
|
|
390
|
+
...opt('variant', enu(v.variant, PROGRESS_VARIANTS)),
|
|
391
|
+
...opt('target', num(v.target, 0, 100)),
|
|
392
|
+
}
|
|
329
393
|
}
|
|
330
394
|
case 'divider': return { type: 'divider' }
|
|
331
395
|
case 'spacer': return { type: 'spacer' }
|
|
@@ -337,7 +401,7 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
337
401
|
case 'list': {
|
|
338
402
|
const items = repairListItems(v.items, GENUI_LIMITS.maxListItems, ctx, depth + 1)
|
|
339
403
|
if (items === undefined) return null
|
|
340
|
-
return { type: 'list', items }
|
|
404
|
+
return { type: 'list', items, ...opt('filter', str(v.filter, 64)) }
|
|
341
405
|
}
|
|
342
406
|
case 'table': {
|
|
343
407
|
let rawCols = v.columns as unknown
|
|
@@ -359,7 +423,36 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
359
423
|
const columns = repairStrings(rawCols, GENUI_LIMITS.maxTableCols, 128)
|
|
360
424
|
const rows = repairRows(rawRows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)
|
|
361
425
|
if (columns === undefined || rows === undefined) return null
|
|
362
|
-
|
|
426
|
+
// Optional per-column cell types; unknown entries degrade to 'text'.
|
|
427
|
+
const rawTypes = Array.isArray(v.types) ? v.types : undefined
|
|
428
|
+
const types = rawTypes === undefined
|
|
429
|
+
? undefined
|
|
430
|
+
: columns.map((_c, i) => {
|
|
431
|
+
const raw = rawTypes[i]
|
|
432
|
+
return typeof raw === 'string' && (TABLE_CELL_TYPES as readonly string[]).includes(raw)
|
|
433
|
+
? raw as typeof TABLE_CELL_TYPES[number]
|
|
434
|
+
: 'text'
|
|
435
|
+
})
|
|
436
|
+
// Master-detail payload: positionally aligned with `rows`. Entries that
|
|
437
|
+
// repair to nothing stay null so the renderer never shows an empty
|
|
438
|
+
// expander; extra entries beyond the row count are dropped.
|
|
439
|
+
const rawDetails = Array.isArray(v.details) ? v.details : undefined
|
|
440
|
+
const details = rawDetails === undefined
|
|
441
|
+
? undefined
|
|
442
|
+
: rows.map((_row, i) => {
|
|
443
|
+
const entry = repairItems(rawDetails[i], ctx, depth + 1)
|
|
444
|
+
return entry.length === 0 ? null : entry
|
|
445
|
+
})
|
|
446
|
+
return {
|
|
447
|
+
type: 'table', columns, rows,
|
|
448
|
+
...opt('types', types),
|
|
449
|
+
...opt('total', v.total === true ? true : undefined),
|
|
450
|
+
...opt('export', v.export === true ? true : undefined),
|
|
451
|
+
...opt('filter', str(v.filter, 64)),
|
|
452
|
+
...opt('filterColumn', int(v.filterColumn, 0, GENUI_LIMITS.maxTableCols - 1)),
|
|
453
|
+
...opt('sortField', str(v.sortField, 64)),
|
|
454
|
+
...opt('details', details !== undefined && details.some(d => d !== null) ? details : undefined),
|
|
455
|
+
}
|
|
363
456
|
}
|
|
364
457
|
case 'chart': {
|
|
365
458
|
const data = repairChartData(v.data, GENUI_LIMITS.maxChartPoints)
|
|
@@ -368,7 +461,15 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
368
461
|
// alone; a series-only chart gets an empty data array (the renderer
|
|
369
462
|
// reads `series` in that case).
|
|
370
463
|
if (data === undefined && series === undefined) return null
|
|
371
|
-
return {
|
|
464
|
+
return {
|
|
465
|
+
type: 'chart', data: data ?? [],
|
|
466
|
+
...opt('kind', enu(v.kind, CHART_KINDS)),
|
|
467
|
+
...opt('series', series),
|
|
468
|
+
...opt('horizontal', v.horizontal === true ? true : undefined),
|
|
469
|
+
...opt('stacked', v.stacked === true ? true : undefined),
|
|
470
|
+
...opt('filter', str(v.filter, 64)),
|
|
471
|
+
...opt('palette', paletteValues(v.palette)),
|
|
472
|
+
}
|
|
372
473
|
}
|
|
373
474
|
case 'tabs': {
|
|
374
475
|
const tabs = repairTabs(v.tabs, ctx, depth)
|
|
@@ -541,6 +642,16 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
541
642
|
const series = v.series !== undefined && Array.isArray(v.series)
|
|
542
643
|
? repairSeries(v.series, GENUI_LIMITS.maxPlotSeries, GENUI_LIMITS.maxChartPoints)
|
|
543
644
|
: undefined
|
|
645
|
+
// sankey / graph edges: `from`/`to` must be strings, `value` a number.
|
|
646
|
+
const links = Array.isArray(v.links)
|
|
647
|
+
? v.links.slice(0, GENUI_LIMITS.maxChartPoints).flatMap(entry => {
|
|
648
|
+
const e = obj(entry)
|
|
649
|
+
const from = e === undefined ? undefined : str(e.from, 64)
|
|
650
|
+
const to = e === undefined ? undefined : str(e.to, 64)
|
|
651
|
+
if (from === undefined || to === undefined) return []
|
|
652
|
+
return [{ from, to, ...opt('value', num(e!.value, 0, 1e9)) }]
|
|
653
|
+
})
|
|
654
|
+
: undefined
|
|
544
655
|
// Full option: depth-bounded pass-through (the model writes the ECharts
|
|
545
656
|
// option object; the guard walks it to cap nesting but does not
|
|
546
657
|
// validate ECharts semantics — that is echarts' own job).
|
|
@@ -553,8 +664,9 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
553
664
|
sanitized === undefined || typeof sanitized !== 'object' || sanitized === null || Array.isArray(sanitized)
|
|
554
665
|
? undefined
|
|
555
666
|
: sanitized as Record<string, unknown>
|
|
556
|
-
// At least one of preset+data or option must be present.
|
|
557
|
-
if (option === undefined && data === undefined && series === undefined
|
|
667
|
+
// At least one of preset+data, links or option must be present.
|
|
668
|
+
if (option === undefined && data === undefined && series === undefined
|
|
669
|
+
&& (links === undefined || links.length === 0)) return null
|
|
558
670
|
return {
|
|
559
671
|
type: 'echart',
|
|
560
672
|
...opt('title', str(v.title, GENUI_LIMITS.maxString)),
|
|
@@ -562,6 +674,8 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
|
|
|
562
674
|
...opt('preset', enu(v.preset, ECHART_PRESETS)),
|
|
563
675
|
...opt('data', data),
|
|
564
676
|
...opt('series', series),
|
|
677
|
+
...opt('links', links !== undefined && links.length > 0 ? links : undefined),
|
|
678
|
+
...opt('palette', paletteValues(v.palette)),
|
|
565
679
|
...opt('option', option),
|
|
566
680
|
}
|
|
567
681
|
}
|
|
@@ -1480,19 +1594,29 @@ function validateChartNode(v: Record<string, unknown>, at: string, errors: strin
|
|
|
1480
1594
|
errors.push(`${at}.kind must be bars, line, or donut`)
|
|
1481
1595
|
}
|
|
1482
1596
|
const kind = v.kind === undefined ? 'bars' : v.kind
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1597
|
+
const series = Array.isArray(v.series) ? v.series : undefined
|
|
1598
|
+
// Grouped bars legitimately carry `data: []` and keep every point in
|
|
1599
|
+
// `series`; only an empty data array with NO series is undrawable.
|
|
1600
|
+
if (Array.isArray(v.data) && v.data.length === 0 && (series === undefined || series.length === 0)) {
|
|
1601
|
+
errors.push(`${at}.data must not be empty`)
|
|
1602
|
+
}
|
|
1603
|
+
if (series !== undefined) {
|
|
1604
|
+
if (series.length === 0) errors.push(`${at}.series must not be empty`)
|
|
1605
|
+
// A line chart accepts `series` (one line per entry); the donut is a
|
|
1606
|
+
// share-of-total shape and stays single-series.
|
|
1607
|
+
if (kind === 'donut') errors.push(`${at}.series is only supported for bars and line`)
|
|
1608
|
+
for (let index = 0; index < series.length; index++) {
|
|
1609
|
+
const entry = obj(series[index])
|
|
1610
|
+
if (entry !== undefined && Array.isArray(entry.data) && entry.data.length === 0) {
|
|
1490
1611
|
errors.push(`${at}.series[${index}].data must not be empty`)
|
|
1491
1612
|
}
|
|
1492
1613
|
}
|
|
1493
1614
|
}
|
|
1494
|
-
if (
|
|
1495
|
-
errors.push(`${at}.data is required for
|
|
1615
|
+
if (kind === 'donut' && v.data === undefined) {
|
|
1616
|
+
errors.push(`${at}.data is required for donut`)
|
|
1617
|
+
}
|
|
1618
|
+
if (kind === 'line' && v.data === undefined && (series === undefined || series.length === 0)) {
|
|
1619
|
+
errors.push(`${at}.data is required for line (or provide series)`)
|
|
1496
1620
|
}
|
|
1497
1621
|
validateChartData(v.data, `${at}.data`, errors)
|
|
1498
1622
|
validateChartSeries(v.series, `${at}.series`, errors)
|
|
@@ -1581,6 +1705,10 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1581
1705
|
isStr('text')
|
|
1582
1706
|
isStr('value')
|
|
1583
1707
|
break
|
|
1708
|
+
case 'hero':
|
|
1709
|
+
if (typeof v.title !== 'string') errors.push(`${at}: type 'hero' requires title (string)`)
|
|
1710
|
+
isStr('subtitle')
|
|
1711
|
+
break
|
|
1584
1712
|
case 'stat':
|
|
1585
1713
|
if (typeof v.label !== 'string') errors.push(`${at}: type 'stat' requires label (string)`)
|
|
1586
1714
|
if (typeof v.value !== 'string') errors.push(`${at}: type 'stat' requires value (string)`)
|
|
@@ -1612,6 +1740,12 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1612
1740
|
case 'table':
|
|
1613
1741
|
if (!Array.isArray(v.columns)) errors.push(`${at}: type 'table' requires columns (array)`)
|
|
1614
1742
|
if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
|
|
1743
|
+
if (v.types !== undefined && !Array.isArray(v.types)) {
|
|
1744
|
+
errors.push(`${at}.types must be an array of column cell types`)
|
|
1745
|
+
}
|
|
1746
|
+
if (v.details !== undefined && !Array.isArray(v.details)) {
|
|
1747
|
+
errors.push(`${at}.details must be an array aligned with rows`)
|
|
1748
|
+
}
|
|
1615
1749
|
validateTableRows(v.rows, `${at}.rows`, errors)
|
|
1616
1750
|
break
|
|
1617
1751
|
case 'chart':
|
|
@@ -1690,8 +1824,12 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1690
1824
|
break
|
|
1691
1825
|
|
|
1692
1826
|
case 'echart':
|
|
1693
|
-
|
|
1694
|
-
|
|
1827
|
+
// `links` alone is a valid payload: the sankey/graph presets are driven
|
|
1828
|
+
// by edges only. Missing it here rejected the whole fence as
|
|
1829
|
+
// un-renderable (the render gate turns any error into "render nothing").
|
|
1830
|
+
if (v.option === undefined && v.data === undefined && v.series === undefined
|
|
1831
|
+
&& (!Array.isArray(v.links) || v.links.length === 0)) {
|
|
1832
|
+
errors.push(`${at}: type 'echart' requires option, data, series, or links`)
|
|
1695
1833
|
}
|
|
1696
1834
|
isNum('height')
|
|
1697
1835
|
break
|
package/src/client/index.tsx
CHANGED
|
@@ -124,6 +124,16 @@ function sendInlineGenuiAction(ctx: Context, sessionId: SessionId, action: strin
|
|
|
124
124
|
})
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Visual-regression flag: `scripts/e2e-visual.mts` sets
|
|
128
|
+
* `window.__DSH_GENUI_E2E__ = true` via an init script BEFORE this bundle
|
|
129
|
+
* boots, which pins the DOM channel even on hosts that ship the registry
|
|
130
|
+
* extension point. The DOM channel is what lets the harness inject a gallery
|
|
131
|
+
* fence into an empty scratch profile and screenshot the real React tree
|
|
132
|
+
* without a model round trip; production pages never set the flag. */
|
|
133
|
+
function forcedDomChannel(): boolean {
|
|
134
|
+
return (globalThis as { __DSH_GENUI_E2E__?: boolean }).__DSH_GENUI_E2E__ === true
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
/** Cordis client entry: register the fence renderer on boot, the keyed
|
|
128
138
|
* toolview for the render_ui tool, and the session panel dock; returning the
|
|
129
139
|
* disposers lets cordis tear all registrations down on plugin unload. */
|
|
@@ -132,10 +142,11 @@ export function apply(ctx: Context): () => void {
|
|
|
132
142
|
// ships it (contract line), the DOM observer otherwise (pristine line).
|
|
133
143
|
// One plugin build serves both deployments.
|
|
134
144
|
const registerFn = (primitives as unknown as HostFenceExt).registerFenceRenderer
|
|
135
|
-
const
|
|
145
|
+
const useRegistry = typeof registerFn === 'function' && !forcedDomChannel()
|
|
146
|
+
const channel = useRegistry ? 'registry' : 'dom'
|
|
136
147
|
console.info(`[genui] client active; fence-channel=${channel}`)
|
|
137
|
-
const disposers: Array<() => void> =
|
|
138
|
-
? [registerFn('dsh-ui', renderGenuiFence)]
|
|
148
|
+
const disposers: Array<() => void> = useRegistry
|
|
149
|
+
? [registerFn!('dsh-ui', renderGenuiFence)]
|
|
139
150
|
: [installDomFenceRenderer(ctx, (sessionId, action, payload) => sendInlineGenuiAction(ctx, sessionId, action, payload))]
|
|
140
151
|
// Idle prefetch of the lazy engine assets: the browser downloads them at
|
|
141
152
|
// LOW priority whenever the page is idle, so the first mermaid/3D node in
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal inline markup for text-bearing fields.
|
|
3
|
+
*
|
|
4
|
+
* Prose-heavy answers need emphasis INSIDE a sentence: a command, a term, a key
|
|
5
|
+
* phrase, a link. Until now every field was a plain string, so emphasis meant
|
|
6
|
+
* splitting one sentence into several nodes and the paragraph read as broken.
|
|
7
|
+
*
|
|
8
|
+
* Supported (deliberately tiny, no nesting):
|
|
9
|
+
* `code` inline code chip
|
|
10
|
+
* **bold** emphasis that does not wrap or become a block
|
|
11
|
+
* ==mark== highlight
|
|
12
|
+
* [text](https://…) inline link (http/https/mailto only)
|
|
13
|
+
*
|
|
14
|
+
* Safety: this NEVER produces HTML. Each token becomes a React element, so the
|
|
15
|
+
* host's HTML parser is never involved (the whole client builds elements, the
|
|
16
|
+
* only innerHTML in the project is mermaid's sanitized SVG). An unterminated or
|
|
17
|
+
* unknown marker is rendered literally rather than erroring, and a link whose
|
|
18
|
+
* href fails {@link safeHref} degrades to its label text.
|
|
19
|
+
* @module @changfenhuang/dsh-genui/client/inline
|
|
20
|
+
*/
|
|
21
|
+
import { createElement, type ReactNode } from 'react'
|
|
22
|
+
import css from './GenuiBlock.module.css'
|
|
23
|
+
import { safeHref } from './genui-runtime/value-utils.ts'
|
|
24
|
+
|
|
25
|
+
/** One pass, no nesting: each alternative is a self-contained token. */
|
|
26
|
+
// Links match ANY target here and are validated by safeHref afterwards, so a
|
|
27
|
+
// `javascript:` target degrades to its label text instead of showing as raw
|
|
28
|
+
// markup the reader has to parse themselves.
|
|
29
|
+
const INLINE = /`[^`\n]+`|\*\*[^*\n]+\*\*|==[^=\n]+==|\[[^\]\n]+\]\([^)\s]+\)/g
|
|
30
|
+
|
|
31
|
+
/** True when the string contains anything the parser understands. */
|
|
32
|
+
export function hasInlineMarkup(text: string): boolean {
|
|
33
|
+
return typeof text === 'string' && /[`*=]|\[/.test(text)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render one string as React nodes with inline markup applied.
|
|
38
|
+
* @param text - the raw field value.
|
|
39
|
+
* @returns the original string when nothing matched, else the node list.
|
|
40
|
+
*/
|
|
41
|
+
export function renderInline(text: string): ReactNode {
|
|
42
|
+
if (typeof text !== 'string' || text === '' || !hasInlineMarkup(text)) return text
|
|
43
|
+
const out: ReactNode[] = []
|
|
44
|
+
let last = 0
|
|
45
|
+
let key = 0
|
|
46
|
+
for (const match of text.matchAll(INLINE)) {
|
|
47
|
+
const index = match.index ?? 0
|
|
48
|
+
const token = match[0]
|
|
49
|
+
if (index > last) out.push(text.slice(last, index))
|
|
50
|
+
if (token.startsWith('`')) {
|
|
51
|
+
out.push(createElement('code', { key: key++, className: css.inlineCode }, token.slice(1, -1)))
|
|
52
|
+
} else if (token.startsWith('**')) {
|
|
53
|
+
out.push(createElement('strong', { key: key++, className: css.inlineStrong }, token.slice(2, -2)))
|
|
54
|
+
} else if (token.startsWith('==')) {
|
|
55
|
+
out.push(createElement('mark', { key: key++, className: css.inlineMark }, token.slice(2, -2)))
|
|
56
|
+
} else {
|
|
57
|
+
const parts = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/.exec(token)
|
|
58
|
+
if (parts === null) {
|
|
59
|
+
out.push(token)
|
|
60
|
+
} else {
|
|
61
|
+
const href = safeHref(parts[2])
|
|
62
|
+
out.push(href === undefined
|
|
63
|
+
? parts[1]
|
|
64
|
+
: createElement('a', {
|
|
65
|
+
key: key++, className: css.inlineLink, href, target: '_blank', rel: 'noreferrer noopener',
|
|
66
|
+
}, parts[1]))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
last = index + token.length
|
|
70
|
+
}
|
|
71
|
+
if (last < text.length) out.push(text.slice(last))
|
|
72
|
+
return out.length === 0 ? text : out
|
|
73
|
+
}
|
|
@@ -25,15 +25,52 @@ let renderSeq = 0
|
|
|
25
25
|
function loadMermaid(): Promise<typeof import('mermaid')> {
|
|
26
26
|
mermaidPromise ??= import('mermaid').then(async m => {
|
|
27
27
|
const api = m.default
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// Theme the diagram from the HOST tokens instead of a built-in mermaid
|
|
29
|
+
// palette: the stock themes render grey boxes with sharp corners, dark
|
|
30
|
+
// strokes and their own font, which sat next to our components as a
|
|
31
|
+
// visibly foreign object. `base` + themeVariables is the supported way to
|
|
32
|
+
// drive every colour (the dark/light branch is gone with it).
|
|
33
|
+
const token = (name: string, fallback: string): string => {
|
|
34
|
+
if (typeof document === 'undefined') return fallback
|
|
35
|
+
const value = getComputedStyle(document.body).getPropertyValue(name).trim()
|
|
36
|
+
return value === '' ? fallback : value
|
|
37
|
+
}
|
|
32
38
|
api.initialize({
|
|
33
39
|
startOnLoad: false,
|
|
34
40
|
// Strict default: mermaid escapes/sanitizes; we never enable htmlLabels.
|
|
35
41
|
securityLevel: 'strict',
|
|
36
|
-
theme:
|
|
42
|
+
theme: 'base',
|
|
43
|
+
themeVariables: {
|
|
44
|
+
background: 'transparent',
|
|
45
|
+
fontFamily: 'inherit',
|
|
46
|
+
fontSize: '13px',
|
|
47
|
+
primaryColor: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
|
|
48
|
+
primaryTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
|
|
49
|
+
primaryBorderColor: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
|
|
50
|
+
secondaryColor: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
|
|
51
|
+
secondaryTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
|
|
52
|
+
secondaryBorderColor: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
|
|
53
|
+
tertiaryColor: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
|
|
54
|
+
tertiaryTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
|
|
55
|
+
tertiaryBorderColor: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
|
|
56
|
+
mainBkg: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
|
|
57
|
+
nodeBorder: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
|
|
58
|
+
nodeTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
|
|
59
|
+
lineColor: token('--dsw-alias-label-tertiary', '#8a8a92'),
|
|
60
|
+
textColor: token('--dsw-alias-label-primary', '#e6e6e6'),
|
|
61
|
+
edgeLabelBackground: 'transparent',
|
|
62
|
+
clusterBkg: 'transparent',
|
|
63
|
+
clusterBorder: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
|
|
64
|
+
titleColor: token('--dsw-alias-label-primary', '#e6e6e6'),
|
|
65
|
+
},
|
|
66
|
+
// Sharp corners and heavy strokes were the loudest mismatch; the radius
|
|
67
|
+
// is set here because mermaid emits plain <rect> geometry.
|
|
68
|
+
themeCSS: [
|
|
69
|
+
'.node rect, .node polygon, .node circle, .node ellipse { rx: 8px; ry: 8px; stroke-width: 1px; }',
|
|
70
|
+
'.cluster rect { rx: 10px; ry: 10px; }',
|
|
71
|
+
'.edgePath .path { stroke-width: 1.4px; }',
|
|
72
|
+
'.label { color: inherit; }',
|
|
73
|
+
].join(' '),
|
|
37
74
|
// Fail loudly: with suppressErrorRendering false (the default) mermaid
|
|
38
75
|
// renders an "error" diagram on parse/draw failure — the caller then
|
|
39
76
|
// receives a normal-looking SVG whose text is the raw engine error
|