@changfenhuang/dsh-genui 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `render_ui` tool: a model-facing channel that renders a GenUI spec as
3
+ * an interactive card in the conversation TOOL ROW (route A of the design
4
+ * doc). The ```dsh-ui fence channel renders inline in the reply; this tool
5
+ * renders in the tool row and rides the harness's result `meta` projection:
6
+ * `presentationMeta` stores the repaired spec, the browser toolview
7
+ * (`src/client/toolview.tsx`) reads it from the result node and renders.
8
+ *
9
+ * Zero runtime harness imports, deliberately: an external plugin's node half
10
+ * must not depend on the harness module graph at runtime (the profile
11
+ * resolves only the plugin package itself). The definition is therefore a
12
+ * plain `ToolDefinition` object — the exact shape `defineTool` returns — with
13
+ * the arguments schema authored as JSON Schema (the harness validates args
14
+ * and output with the same JSON Schema validator defineTool uses). Deep
15
+ * validation, deterministic repair, and resource limits live in the shared
16
+ * guard (`src/client/guard.ts`), which the schema deliberately stays loose
17
+ * enough to reach.
18
+ * @module @changfenhuang/dsh-genui/plugin/tool
19
+ */
20
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
21
+ /**
22
+ * Build the render_ui tool definition. Registered by the plugin node half;
23
+ * `ctx.tools.register` consumes it exactly like a `defineTool` result.
24
+ */
25
+ export declare function createRenderUiTool(): ToolDefinition;
26
+ /** Build the validate_dsh_ui tool definition (registered alongside render_ui). */
27
+ export declare function createValidateDshUiTool(): ToolDefinition;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared fence-body JSON repair — pure string functions, no DOM, no I/O.
3
+ * Used by BOTH the client fence renderer (tier-1/tier-2 auto-repair before
4
+ * rendering) and the node-side validate_dsh_ui tool (which returns the
5
+ * repaired JSON to the model instead of making it re-author the fix).
6
+ *
7
+ * Two tiers, deliberately gated differently by the callers:
8
+ * - Tier-1 (`repairFenceJson`): heals the most common model JSON typos that
9
+ * do NOT change the body's structure — unescaped half-width quotes inside
10
+ * string values and trailing commas. Safe at any time (streaming included),
11
+ * adopted only when the WHOLE body parses afterwards.
12
+ * - Tier-2 (`completeFenceJson`): heals structural incompleteness — missing
13
+ * closing quotes/brackets — by appending the missing terminators, and
14
+ * skips mismatched closers (a `]` mistyped as `}`, duplicated terminators).
15
+ * SETTLED MESSAGES ONLY: a streaming half must never be adopted as a
16
+ * finished prefix.
17
+ * @module @changfenhuang/dsh-genui/shared/fence-repair
18
+ */
19
+ /** A fence body counts as complete when it parses as a whole JSON value. */
20
+ export declare function isCompleteJson(raw: string): boolean;
21
+ /** Short human-readable reason for a body that fails whole-JSON parsing, or
22
+ * null when it parses. Positions come from the host's JSON.parse error. */
23
+ export declare function describeJsonFailure(raw: string): string | null;
24
+ /**
25
+ * Tier-1 repair — SAFE AT ANY TIME (streaming included): heals the most
26
+ * common model JSON typos that do NOT change the body's structure, and only
27
+ * when the whole body parses afterwards (so a still-growing streaming half
28
+ * can never be adopted):
29
+ *
30
+ * 1. Unescaped half-width `"` inside a string value — Chinese text quoted
31
+ * with ASCII quotes (e.g. `对"别名路径"判定失败`), which makes JSON.parse
32
+ * fail near that quote with "Expected ',' or ']'...".
33
+ * 2. Trailing commas before `}` / `]` or at end of input.
34
+ *
35
+ * The state-machine scan walks the raw body tracking string-open state:
36
+ * - inside a string, a quote whose next non-space char is NOT one of `, ] } :`
37
+ * (or end of input) cannot legally close the string → escape it as `\"`;
38
+ * - a `,` whose next non-space char is `}` / `]` / end of input is a trailing
39
+ * comma → drop it.
40
+ *
41
+ * Returns `{ text, repairs }` on success, or null when nothing needed fixing
42
+ * or the body still does not parse (callers fall through to tier-2 / banner).
43
+ */
44
+ export declare function repairFenceJson(raw: string): {
45
+ text: string;
46
+ repairs: number;
47
+ } | null;
48
+ /**
49
+ * Tier-2 repair — SETTLED MESSAGES ONLY (never while streaming): heals
50
+ * structural incompleteness — missing closing quotes/brackets — by appending
51
+ * the missing terminators, and heals stray closers — a `]` mistyped as `}` or
52
+ * a duplicated terminator — by skipping closers that do not match the open
53
+ * stack (they cannot be legal JSON). Callers gate it on settled messages (the
54
+ * client uses the host-provided fence source; the validate tool is by
55
+ * definition pre-emission), so a streaming half can never flash premature UI.
56
+ *
57
+ * ONE unified scan: the tier-1 fixes (quote escaping + trailing-comma drops)
58
+ * are folded into the same pass, so bodies that combine BOTH defect classes
59
+ * (a trailing comma AND a missing closer) heal in one shot — the old
60
+ * two-phase chain lost tier-1's partial work when its whole-body parse
61
+ * failed, and re-scanning the raw text could not compose the repairs.
62
+ * Adopted only when the completed body parses as whole JSON.
63
+ */
64
+ export declare function completeFenceJson(raw: string): {
65
+ text: string;
66
+ repairs: number;
67
+ } | null;
package/package.json ADDED
@@ -0,0 +1,121 @@
1
+ {
2
+ "name": "@changfenhuang/dsh-genui",
3
+ "description": "GenUI for DeepSeek Harness: interactive UI components rendered inline in assistant replies via the ```dsh-ui fence — layout, charts, plots, forms, quizzes, mermaid, 3D scenes, and an action event loop back to the model. Ships the fence-teaching host plugin, the browser renderer (client half), and the genui skill.",
4
+ "version": "0.9.1",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/plugin/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/plugin/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./invariant": {
14
+ "types": "./lib/types/plugin/invariant.d.ts",
15
+ "default": "./lib/invariant.js"
16
+ },
17
+ "./client": {
18
+ "types": "./lib/types/client/index.d.ts",
19
+ "default": "./lib/client.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "lib/index.js",
25
+ "lib/invariant.js",
26
+ "lib/client.js",
27
+ "lib/assets/mermaid.js",
28
+ "lib/assets/three.js",
29
+ "lib/assets/echarts.js",
30
+ "lib/types/plugin/index.d.ts",
31
+ "lib/types/plugin/invariant.d.ts",
32
+ "lib/types/client/index.d.ts",
33
+ "SKILL.md",
34
+ "README.md",
35
+ "README.zh-CN.md",
36
+ "CHANGELOG.md",
37
+ "demo-prompts.md",
38
+ "cordis.patch.yml",
39
+ "lib",
40
+ "src",
41
+ "tsconfig.json",
42
+ "tsdown.config.ts"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/omdsh-dev/dsh-genui.git"
51
+ },
52
+ "homepage": "https://github.com/omdsh-dev/dsh-genui",
53
+ "bugs": {
54
+ "url": "https://github.com/omdsh-dev/dsh-genui/issues"
55
+ },
56
+ "keywords": [
57
+ "dsh",
58
+ "deepseek-harness",
59
+ "generative-ui",
60
+ "plugin",
61
+ "ui"
62
+ ],
63
+ "dependencies": {
64
+ "react": "^18.3.1"
65
+ },
66
+ "peerDependencies": {
67
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
68
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
69
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
70
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
71
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
72
+ "@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
73
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
74
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
75
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
76
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
77
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
78
+ "@deepseek-ai/cordis": "^4.0.1",
79
+ "react": "^18.0.0 || ^19.0.0"
80
+ },
81
+ "devDependencies": {
82
+ "@testing-library/dom": "^10.4.1",
83
+ "@testing-library/react": "^16.0.0",
84
+ "@types/node": "^22.19.0",
85
+ "@types/react": "^18.0.0",
86
+ "@types/react-dom": "^18.0.0",
87
+ "@types/three": "^0.185.4",
88
+ "jsdom": "^25.0.0",
89
+ "lightningcss": "^1.25.0",
90
+ "tsdown": "^0.22.2",
91
+ "tsx": "^4.0.0",
92
+ "typescript": "^5.6.0",
93
+ "vite": "^6.0.0",
94
+ "vitest": "^3.0.0",
95
+ "echarts": "^5.6.0",
96
+ "yaml": "^2.4.2",
97
+ "mermaid": "11.16.0",
98
+ "three": "^0.180.0",
99
+ "react-dom": "^18.3.1"
100
+ },
101
+ "scripts": {
102
+ "build": "rm -rf lib && tsc -p tsconfig.json && tsdown",
103
+ "test": "vitest run",
104
+ "check": "rm -rf lib && tsc -p tsconfig.json && vitest run && tsdown",
105
+ "prepack": "node scripts/prepack.mjs"
106
+ },
107
+ "dsh": {
108
+ "bundle": {
109
+ "patch": "./cordis.patch.yml"
110
+ },
111
+ "client": {
112
+ "inject": [],
113
+ "platform": "web"
114
+ }
115
+ },
116
+ "packageManager": "pnpm@11.7.0",
117
+ "engines": {
118
+ "node": "^22.19.0 || >=24.0.0",
119
+ "pnpm": ">=11.7.0 <12"
120
+ }
121
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * ECharts node: renders a full ECharts chart from a declarative option
3
+ * object. The echarts engine is lazy-loaded (lib/assets/echarts.js) only when
4
+ * an `echart` node appears — the main client bundle never carries the engine.
5
+ *
6
+ * The `option` field accepts a standard ECharts `EChartsCoreOption`. For
7
+ * simple use cases the `preset` + `data` shorthand builds the option
8
+ * automatically: `preset: 'bar' | 'line' | 'pie' | 'scatter' | 'area'` maps
9
+ * to a themed option template that reads the same `data`/`series` shape as
10
+ * the `chart` node, so a model can upgrade a `chart` to ECharts by changing
11
+ * `type` to `echart` and adding `preset`.
12
+ * @module @changfenhuang/dsh-genui/client/EChartNode
13
+ */
14
+ import { useEffect, useRef, useState } from 'react'
15
+ import css from './GenuiBlock.module.css'
16
+ import { createChart as lazyCreateChart, type EChartsInstance } from './echarts-lazy.ts'
17
+ import { CHART_COLORS } from './blocks/charts.tsx'
18
+ import type { GenuiEChart } from './spec.ts'
19
+
20
+ /** Read a CSS custom property from the document root (host theme token). */
21
+ function readToken(name: string, fallback: string): string {
22
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
23
+ return v || fallback
24
+ }
25
+
26
+ /** Resolve the host accent and label colors for ECharts theming. */
27
+ function themeColors(): {
28
+ accent: string
29
+ labelPrimary: string
30
+ labelSecondary: string
31
+ labelTertiary: string
32
+ border: string
33
+ bgLayer1: string
34
+ } {
35
+ return {
36
+ accent: readToken('--dsw-alias-state-business-primary', '#4f8ef7'),
37
+ labelPrimary: readToken('--dsw-alias-label-primary', '#e6e6e6'),
38
+ labelSecondary: readToken('--dsw-alias-label-secondary', '#a0a0a0'),
39
+ labelTertiary: readToken('--dsw-alias-label-tertiary', '#6b6b6b'),
40
+ border: readToken('--dsw-alias-border-l1', 'rgba(255,255,255,0.12)'),
41
+ bgLayer1: readToken('--dsw-alias-bg-layer-1', '#1a1a1e'),
42
+ }
43
+ }
44
+
45
+ /** Build a full ECharts option from a preset + the simple data/series shape. */
46
+ function presetOption(node: GenuiEChart): Record<string, unknown> {
47
+ const t = themeColors()
48
+ const colors = CHART_COLORS.map(c => readToken(c.replace('var(', '').replace(')', ''), t.accent))
49
+ const data = node.data ?? []
50
+ const series = node.series
51
+
52
+ // Shared tooltip base: renderMode 'richText' prevents ECharts from writing
53
+ // tooltip content via innerHTML — labels/formatters are model output and
54
+ // must never reach the HTML parser.
55
+ const tt = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
56
+ renderMode: 'richText',
57
+ backgroundColor: t.bgLayer1,
58
+ borderColor: t.border,
59
+ textStyle: { color: t.labelPrimary },
60
+ ...extra,
61
+ })
62
+
63
+ const base = {
64
+ color: colors,
65
+ textStyle: { color: t.labelSecondary, fontFamily: 'inherit' },
66
+ backgroundColor: 'transparent',
67
+ grid: { left: 48, right: 16, top: 24, bottom: 32 },
68
+ tooltip: tt({ trigger: 'item' }),
69
+ }
70
+
71
+ switch (node.preset) {
72
+ case 'pie': {
73
+ return {
74
+ ...base,
75
+ tooltip: tt({ trigger: 'item', formatter: '{b}: {c} ({d}%)' }),
76
+ legend: { bottom: 0, textStyle: { color: t.labelTertiary } },
77
+ series: [{
78
+ type: 'pie',
79
+ radius: ['40%', '70%'],
80
+ avoidLabelOverlap: true,
81
+ itemStyle: { borderRadius: 6, borderColor: t.bgLayer1, borderWidth: 2 },
82
+ label: { color: t.labelSecondary },
83
+ data: data.map(d => ({ name: d.label, value: d.value })),
84
+ }],
85
+ }
86
+ }
87
+ case 'scatter': {
88
+ // xAxis is 'category' so string labels (e.g. 「一月」) render correctly;
89
+ // the previous `type: 'value'` xAxis could not plot non-numeric labels.
90
+ return {
91
+ ...base,
92
+ tooltip: tt({ trigger: 'item' }),
93
+ xAxis: { type: 'category', data: data.map(d => d.label), axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary }, splitLine: { lineStyle: { color: t.border, opacity: 0.5 } } },
94
+ yAxis: { type: 'value', axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary }, splitLine: { lineStyle: { color: t.border, opacity: 0.5 } } },
95
+ series: [{
96
+ type: 'scatter',
97
+ symbolSize: 10,
98
+ data: data.map(d => d.value),
99
+ }],
100
+ }
101
+ }
102
+ case 'area': {
103
+ return {
104
+ ...base,
105
+ tooltip: tt({ trigger: 'axis' }),
106
+ xAxis: { type: 'category', data: data.map(d => d.label), axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary } },
107
+ yAxis: { type: 'value', axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary }, splitLine: { lineStyle: { color: t.border, opacity: 0.5 } } },
108
+ series: (series ?? [{ label: '', data }]).map((s, i) => ({
109
+ name: s.label,
110
+ type: 'line',
111
+ smooth: true,
112
+ areaStyle: { opacity: 0.15 },
113
+ data: s.data.map(d => d.value),
114
+ ...optItemStyleColor(s.color, i, series),
115
+ })),
116
+ legend: series !== undefined ? { bottom: 0, textStyle: { color: t.labelTertiary } } : undefined,
117
+ }
118
+ }
119
+ case 'line': {
120
+ return {
121
+ ...base,
122
+ tooltip: tt({ trigger: 'axis' }),
123
+ xAxis: { type: 'category', data: data.map(d => d.label), axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary } },
124
+ yAxis: { type: 'value', axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary }, splitLine: { lineStyle: { color: t.border, opacity: 0.5 } } },
125
+ series: (series ?? [{ label: '', data }]).map((s, i) => ({
126
+ name: s.label,
127
+ type: 'line',
128
+ smooth: true,
129
+ showSymbol: true,
130
+ symbolSize: 6,
131
+ data: s.data.map(d => d.value),
132
+ ...optItemStyleColor(s.color, i, series),
133
+ })),
134
+ legend: series !== undefined ? { bottom: 0, textStyle: { color: t.labelTertiary } } : undefined,
135
+ }
136
+ }
137
+ default: {
138
+ // 'bar' or unspecified
139
+ return {
140
+ ...base,
141
+ tooltip: tt({ trigger: 'axis', axisPointer: { type: 'shadow' } }),
142
+ xAxis: { type: 'category', data: data.map(d => d.label), axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary } },
143
+ yAxis: { type: 'value', axisLine: { lineStyle: { color: t.border } }, axisLabel: { color: t.labelTertiary }, splitLine: { lineStyle: { color: t.border, opacity: 0.5 } } },
144
+ series: (series ?? [{ label: '', data }]).map(s => ({
145
+ name: s.label,
146
+ type: 'bar',
147
+ barMaxWidth: 40,
148
+ itemStyle: { borderRadius: [4, 4, 2, 2], ...(s.color !== undefined ? { color: s.color } : {}) },
149
+ data: s.data.map(d => d.value),
150
+ })),
151
+ legend: series !== undefined ? { bottom: 0, textStyle: { color: t.labelTertiary } } : undefined,
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ /** Per-series itemStyle.color override when the preset series declares one
158
+ * (aligns with the `chart` node which respects `series[].color`). */
159
+ function optItemStyleColor(color: string | undefined, _i: number, _series: unknown): Record<string, unknown> {
160
+ return color !== undefined ? { itemStyle: { color } } : {}
161
+ }
162
+
163
+ export function EChartNode({ node }: { node: GenuiEChart }) {
164
+ const ref = useRef<HTMLDivElement | null>(null)
165
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
166
+ const instanceRef = useRef<EChartsInstance | null>(null)
167
+
168
+ useEffect(() => {
169
+ let alive = true
170
+ const el = ref.current
171
+ if (el === null) return
172
+
173
+ // Full `option` wins over preset shorthand.
174
+ const option = node.option ?? presetOption(node)
175
+
176
+ void lazyCreateChart(el, option, { height: node.height ?? 300 }).then((inst) => {
177
+ if (!alive) {
178
+ inst.dispose()
179
+ return
180
+ }
181
+ instanceRef.current = inst
182
+ setStatus('ready')
183
+ }).catch(() => {
184
+ if (alive) setStatus('error')
185
+ })
186
+
187
+ return () => {
188
+ alive = false
189
+ instanceRef.current?.dispose()
190
+ instanceRef.current = null
191
+ }
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
+ }, [])
194
+
195
+ // Resize observer: keep the chart responsive.
196
+ useEffect(() => {
197
+ if (status !== 'ready') return
198
+ const el = ref.current
199
+ if (el === null) return
200
+ const ro = new ResizeObserver(() => {
201
+ instanceRef.current?.resize()
202
+ })
203
+ ro.observe(el)
204
+ return () => { ro.disconnect() }
205
+ }, [status])
206
+
207
+ // Update option when the node changes (model re-render). `status` is in
208
+ // deps so that when the engine finishes loading (status: 'loading' →
209
+ // 'ready'), this effect re-runs and applies the LATEST option — without
210
+ // it, a spec update that arrived during engine load would be lost forever
211
+ // (the mount effect captured the old option, and this effect would have
212
+ // returned early when status was 'loading' and never re-run).
213
+ useEffect(() => {
214
+ if (status !== 'ready' || instanceRef.current === null) return
215
+ const option = node.option ?? presetOption(node)
216
+ instanceRef.current.setOption(option, true)
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ }, [node, status])
219
+
220
+ if (status === 'error') {
221
+ return (
222
+ <div className={css.echartFallback} data-genui-echart>
223
+ <div className={css.echartErr}>ECharts 渲染失败</div>
224
+ {node.title !== undefined && <div className={css.echartHint}>{node.title}</div>}
225
+ </div>
226
+ )
227
+ }
228
+
229
+ return (
230
+ <div className={css.echartWrap} data-genui-echart>
231
+ {node.title !== undefined && <div className={css.echartTitle}>{node.title}</div>}
232
+ <div
233
+ ref={ref}
234
+ className={css.echartCanvas}
235
+ style={{ height: `${node.height ?? 300}px` }}
236
+ role="img"
237
+ aria-label={node.title ?? 'ECharts chart'}
238
+ />
239
+ {status === 'loading' && <div className={css.echartHint}>加载图表…</div>}
240
+ </div>
241
+ )
242
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Rendering error boundary: a component render failure inside one GenUI
3
+ * block must never take down the whole chat surface (pre-2026-08-09 builds
4
+ * crashed the entire conversation tree on a missing API). Every fence,
5
+ * toolview card and panel body renders under this boundary; on error the
6
+ * block degrades to a compact inline alert instead of unmounting the tree.
7
+ * @module @changfenhuang/dsh-genui/client
8
+ */
9
+
10
+ import { Component, type ErrorInfo, type ReactNode } from 'react'
11
+
12
+ interface ErrorBoundaryProps {
13
+ /** What the block is, shown in the fallback alert (fence / tool / panel). */
14
+ label?: string
15
+ children: ReactNode
16
+ }
17
+
18
+ interface ErrorBoundaryState {
19
+ error: Error | null
20
+ }
21
+
22
+ const fallbackStyle: Record<string, string | number> = {
23
+ display: 'flex',
24
+ flexDirection: 'column',
25
+ gap: '4px',
26
+ padding: '8px 12px',
27
+ margin: '4px 0',
28
+ borderRadius: '8px',
29
+ border: '1px solid rgba(127,127,127,0.35)',
30
+ background: 'rgba(127,127,127,0.08)',
31
+ color: 'inherit',
32
+ fontSize: '12px',
33
+ lineHeight: 1.5,
34
+ fontFamily: 'inherit',
35
+ }
36
+
37
+ /**
38
+ * Class boundary because function components cannot catch their own subtree
39
+ * errors. `getDerivedStateFromError` flips to the fallback render; the error
40
+ * itself is logged (and its message surfaced in the alert) so the user sees
41
+ * a hint instead of a silent white region.
42
+ */
43
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
44
+ override state: ErrorBoundaryState = { error: null }
45
+
46
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
47
+ return { error }
48
+ }
49
+
50
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
51
+ console.error('[dsh-genui] render failed:', error, info.componentStack ?? '')
52
+ }
53
+
54
+ override render(): ReactNode {
55
+ const { error } = this.state
56
+ if (error === null) return this.props.children
57
+ return (
58
+ <div style={fallbackStyle} role="alert" data-genui-error>
59
+ <span style={{ fontWeight: 600 }}>
60
+ ⚠️ {this.props.label ?? '此界面'}渲染失败(已隔离,不影响其他内容)
61
+ </span>
62
+ <span style={{ opacity: 0.75, overflowWrap: 'anywhere' }}>{error.message}</span>
63
+ </div>
64
+ )
65
+ }
66
+ }