@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.
- package/CHANGELOG.md +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- 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
|
+
}
|