@changfenhuang/dsh-genui 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -109,13 +109,18 @@ export declare function countGenuiNodes(value: unknown, cap?: number): number;
109
109
  * `"type"` strings (e.g. file-tree's `{type:'file'}` children). */
110
110
  export declare const GENUI_NODE_TYPES: ReadonlySet<string>;
111
111
  /**
112
- * Count DECLARED nodes in a raw spec tree: objects whose `type` is a
113
- * white-listed string, descending the same containers `countGenuiNodes`
114
- * walks. `validate_dsh_ui` compares this with the repaired count to surface
115
- * children the repair silently dropped (blank-render class of bugs, issue
116
- * #42) instead of reporting a green check on a half-empty tree.
112
+ * Count white-listed nodes declared by a raw spec before repair drops invalid entries.
113
+ * @param value - raw GenUI spec.
114
+ * @param cap - traversal ceiling.
115
+ * @returns declared node count up to the ceiling.
117
116
  */
118
117
  export declare function countDeclaredGenuiNodes(value: unknown, cap?: number): number;
118
+ /**
119
+ * Return field-level chart errors without changing other repairable component families.
120
+ * @param value - raw GenUI spec.
121
+ * @returns chart semantic errors in tree order.
122
+ */
123
+ export declare function validateGenuiChartSemantics(value: unknown): string[];
119
124
  /**
120
125
  * Validate a raw spec value against the white list and limits, collecting
121
126
  * human-readable problems. Unlike repair this never mutates: it is a
@@ -9,7 +9,7 @@
9
9
  * are operable, but events do NOT flow back to the model.
10
10
  */
11
11
  /** One node in the component tree. */
12
- export type GenuiNode = GenuiText | GenuiRow | GenuiCol | GenuiGrid | GenuiCard | GenuiButton | GenuiInput | GenuiSelect | GenuiCheckbox | GenuiLink | GenuiAudio | GenuiVideo | GenuiBadge | GenuiStat | GenuiProgress | GenuiDivider | GenuiList | GenuiTable | GenuiChart | GenuiTabs | GenuiAvatar | GenuiSpacer | GenuiPlot | GenuiCallout | GenuiSteps | GenuiKeyValue | GenuiDiff | GenuiJson | GenuiCode | GenuiRadio | GenuiSubmit | GenuiSwitch | GenuiSlider | GenuiTextarea | GenuiAccordion | GenuiCopy | GenuiMermaid | GenuiScene3D | GenuiTimeline | GenuiFileTree | GenuiBreadcrumb | GenuiQuiz | GenuiDiagram | GenuiEChart;
12
+ export type GenuiNode = GenuiText | GenuiRow | GenuiCol | GenuiGrid | GenuiCard | GenuiButton | GenuiInput | GenuiSelect | GenuiCheckbox | GenuiLink | GenuiImage | GenuiAudio | GenuiVideo | GenuiBadge | GenuiStat | GenuiProgress | GenuiDivider | GenuiList | GenuiTable | GenuiChart | GenuiTabs | GenuiAvatar | GenuiSpacer | GenuiPlot | GenuiCallout | GenuiSteps | GenuiKeyValue | GenuiDiff | GenuiJson | GenuiCode | GenuiRadio | GenuiSubmit | GenuiSwitch | GenuiSlider | GenuiTextarea | GenuiAccordion | GenuiCopy | GenuiMermaid | GenuiScene3D | GenuiTimeline | GenuiFileTree | GenuiBreadcrumb | GenuiQuiz | GenuiDiagram | GenuiEChart;
13
13
  export interface GenuiSpec {
14
14
  /** Short title shown as the card banner. */
15
15
  title?: string;
@@ -95,6 +95,13 @@ export interface GenuiLink {
95
95
  */
96
96
  href?: string;
97
97
  }
98
+ /** User-controlled image from a browser-reachable URL. */
99
+ export interface GenuiImage {
100
+ type: 'image';
101
+ src: string;
102
+ /** Visible caption and accessible image description. */
103
+ alt?: string;
104
+ }
98
105
  /** User-controlled audio from a browser-reachable URL. */
99
106
  export interface GenuiAudio {
100
107
  type: 'audio';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Validate the native chart contract all the way to something the renderer
3
+ * can actually draw. The shared guard still owns field/type validation; this
4
+ * layer adds renderer-specific invariants that would otherwise produce an
5
+ * empty line/donut chart or a grouped-bars shell with no points.
6
+ */
7
+ export declare function validateRenderableChartSemantics(value: unknown): string[];
@@ -16,7 +16,7 @@ export declare const GENUI_SECTION_ORDER = 105;
16
16
  * Deliberately slim: the `genui` skill carries the full component→field
17
17
  * mapping; this section keeps only the contract that must always be
18
18
  * present (fence syntax, type whitelist, and critical behavioral rules). */
19
- export declare const GENUI_SECTION_TEXT = "You can render interactive UI components INSIDE your reply \u2014 between paragraphs \u2014 by emitting a fenced block with the language tag `dsh-ui` containing a JSON spec:\n\n```dsh-ui\n{\"title\":\"\u53EF\u9009\u6807\u9898\",\"gap\":14,\"items\":[...]}\n```\n\nThe spec is a white-listed component tree rendered inline where the fence sits. Only these `type` values; the `genui` skill, when available, carries the full content\u2192component mapping and per-component field details:\n\n- \u5E03\u5C40: text \u00B7 row \u00B7 col \u00B7 grid \u00B7 card \u00B7 divider \u00B7 spacer\n- \u5C55\u793A: badge \u00B7 stat \u00B7 progress \u00B7 list \u00B7 table \u00B7 keyvalue \u00B7 avatar \u00B7 audio \u00B7 video \u00B7 timeline \u00B7 file-tree \u00B7 breadcrumb \u00B7 callout \u00B7 steps \u00B7 diff \u00B7 json \u00B7 code \u00B7 copy\n- \u56FE\u8868: chart (bars|line|donut) \u00B7 echart (preset|option) \u00B7 plot (\u51FD\u6570\u56FE)\n- \u4EA4\u4E92: button \u00B7 input \u00B7 textarea \u00B7 select \u00B7 checkbox \u00B7 switch \u00B7 slider \u00B7 radio \u00B7 submit \u00B7 quiz \u00B7 link \u00B7 tabs \u00B7 accordion\n- \u9AD8\u7EA7: mermaid (flowchart/sequence/class/gantt/pie/er/state/journey) \u00B7 diagram (\u7F16\u8F91\u7EA7\u67B6\u6784/\u6D41\u7A0B\u56FE\uFF0C27 \u79CD kind) \u00B7 scene3d (3D WebGL)\n\nRules:\n- \u89E6\u53D1: \u7ED3\u6784\u5316\u8868\u8FBE\u4F18\u4E8E\u7EAF\u6587\u672C\u65F6\u4E3B\u52A8\u7528\uFF08\u8981\u70B9\u3001\u5F3A\u8C03\u3001\u5BF9\u6BD4\u3001\u6D41\u7A0B\u3001\u6B65\u9AA4\u3001\u72B6\u6001\u3001\u6570\u636E\u3001\u6F14\u793A\uFF09\uFF0C\u7EAF\u95EE\u7B54\u4E0E\u4E00\u53E5\u8BDD\u4E0D\u5957 UI\uFF1B\u4E00\u4E2A\u4E3B\u9898\u4E00\u4E2A\u4E3B\u7EC4\u4EF6\uFF0C\u6BCF\u6B21 3\u20138 \u4E2A\u7EC4\u4EF6\uFF0C\u540C\u4E00\u6570\u636E\u4E0D\u91CD\u590D\u51FA\u73B0\u3002\n- JSON \u4E25\u683C: \u574F\u56F4\u680F\u964D\u7EA7\u4E3A\u4EE3\u7801\u5757\uFF1B\u22653 \u8282\u70B9\u6216\u542B table \u7684\u56F4\u680F\u53D1\u51FA\u524D\u8C03\u7528 validate_dsh_ui\uFF0C\u274C \u4FEE\u597D\u518D\u53D1\uFF08\u82E5\u9644\u300C\u5DF2\u81EA\u52A8\u4FEE\u590D\u300DJSON \u7167\u6284\u5373\u53EF\uFF09\u3002\n- \u89C4\u6A21: \u2264200 \u8282\u70B9\u3001\u5D4C\u5957\u22648 \u5C42\uFF08\u8D85\u51FA\u88AB\u622A\u65AD\uFF09\uFF1B3D mesh 1\u20135\uFF1Bplot \u7ED9\u5408\u7406 xMin/xMax\u3002\n- LOCAL-FIRST + actions: UI \u80FD\u81EA\u5DF1\u505A\u7684\u72B6\u6001\u53D8\u5316\uFF08\u5224\u5377\u3001\u5224\u9898\u3001\u91CD\u7F6E\u3001\u5C55\u5F00\u3001\u9009\u4E2D\uFF09\u5C31\u5730\u5B8C\u6210\uFF0C\u96F6\u5F80\u8FD4\uFF1Baction \u53EA\u7528\u4E8E\u5FC5\u987B\u6A21\u578B\u53C2\u4E0E\u7684\u4E8B\u3002\u4EA4\u4E92\u7EC4\u4EF6\u5E26 \"action\":\"name\"\uFF0C\u4EA4\u4E92\u4EE5 [genui-action] name + \u7EC4\u4EF6\u6570\u636E\u56DE\u4F20\uFF0C\u5C4A\u65F6\u91CD\u6E32\u67D3\u66F4\u65B0 UI\uFF1B\u65E0 action \u7684\u6309\u94AE\u7981\u7528\u3002\n- Durable state: \u4EA4\u4E92\u72B6\u6001\u6309\u300C\u4F1A\u8BDD+\u5185\u5BB9\u6307\u7EB9\u300D\u6301\u4E45\u5316\u2014\u2014\u5237\u65B0/\u91CD\u653E\u6062\u590D\uFF1B\u91CD\u6E32\u67D3\u76F8\u540C\u5185\u5BB9\u4FDD\u7559\uFF0C\u65B0\u5185\u5BB9\u91CD\u7F6E\u3002\n- \u5377\u5B50\u6A21\u5F0F: \u6BCF\u9898\u4E00\u4E2A radio\uFF08group+answer+explanation\uFF09+ \u4E00\u4E2A submit\uFF08groups \u5168\u5217\uFF09\uFF0C\u672C\u5730\u5224\u5206\u3002\n- Secrets ban: \u4E0D\u7D22\u53D6\u5BC6\u7801\u3001API Key\u3001Token\u3001\u6062\u590D\u7801\uFF1B\u9700\u8981\u65F6\u62D2\u7EDD\u5E76\u89E3\u91CA\u3002\n- Tool channel: render_ui \u5DE5\u5177\u628A\u540C\u4E00 spec \u6E32\u67D3\u4E3A\u5DE5\u5177\u884C\u5361\u7247\uFF08\u4EA4\u4ED8\u7269\u578B\u754C\u9762\u7528\uFF09\uFF1B\u56F4\u680F\u7528\u4E8E\u56DE\u7B54\u5185\u8054 UI\u3002\n- Panel: \"panel\":true \u53EA\u6E32\u67D3\u8FDB\u4F1A\u8BDD\u9762\u677F dock \u5E76\u539F\u5730\u66F4\u65B0\uFF1B\"append\":true \u8FFD\u52A0\u5408\u5E76\uFF08\u540C\u6807\u7B7E tabs \u8FFD\u52A0/\u65B0\u6807\u7B7E\u52A0\u5165/\u5C3E\u90E8\u8FFD\u52A0\uFF09\uFF1B\u4E0A\u9650 200 \u8282\u70B9/200 \u6B21\u8FFD\u52A0\uFF0C\u6EE1\u4E86\u53D1 replace \u91CD\u5EFA\u3002\u9762\u677F\u7EC4\u4EF6\u6765\u7684 [genui-action] \u53EA\u56DE\u4E00\u4E2A panel:true \u56F4\u680F + \u81F3\u591A\u4E00\u884C 10 \u5B57\u5185\u786E\u8BA4\uFF0C\u4E0D\u89E3\u91CA\u3001\u4E0D\u7528\u666E\u901A\u56F4\u680F\u3002";
19
+ export declare const GENUI_SECTION_TEXT = "You can render interactive UI components INSIDE your reply \u2014 between paragraphs \u2014 by emitting a fenced block with the language tag `dsh-ui` containing a JSON spec:\n\n```dsh-ui\n{\"title\":\"\u53EF\u9009\u6807\u9898\",\"gap\":14,\"items\":[...]}\n```\n\nThe spec is a white-listed component tree rendered inline where the fence sits. Only these `type` values; the `genui` skill, when available, carries the full content\u2192component mapping and per-component field details:\n\n- \u5E03\u5C40: text \u00B7 row \u00B7 col \u00B7 grid \u00B7 card \u00B7 divider \u00B7 spacer\n- \u5C55\u793A: badge \u00B7 stat \u00B7 progress \u00B7 list \u00B7 table \u00B7 keyvalue \u00B7 avatar \u00B7 audio \u00B7 video \u00B7 timeline \u00B7 file-tree \u00B7 breadcrumb \u00B7 callout \u00B7 steps \u00B7 diff \u00B7 json \u00B7 code \u00B7 copy\n- \u56FE\u8868: chart {\"type\":\"chart\",\"kind\":\"bars|line|donut\",\"data\":[{\"label\":\"...\",\"value\":n,\"color\":\"#hex?\"}],\"series\":[...]?}\uFF08series \u4EC5 bars\uFF1B\u672A\u77E5\u6269\u5C55\u5B57\u6BB5\u53EF\u901A\u8FC7\u6821\u9A8C\uFF0C\u4F46\u539F\u751F chart repair/render \u4F1A\u5FFD\u7565\uFF09 \u00B7 echart (preset|option) \u00B7 plot (\u51FD\u6570\u56FE)\n- \u4EA4\u4E92: button \u00B7 input \u00B7 textarea \u00B7 select \u00B7 checkbox \u00B7 switch \u00B7 slider \u00B7 radio \u00B7 submit \u00B7 quiz \u00B7 link \u00B7 tabs \u00B7 accordion\n- \u9AD8\u7EA7: mermaid (flowchart/sequence/class/gantt/pie/er/state/journey) \u00B7 diagram (\u7F16\u8F91\u7EA7\u67B6\u6784/\u6D41\u7A0B\u56FE\uFF0C27 \u79CD kind) \u00B7 scene3d (3D WebGL)\n\nRules:\n- \u89E6\u53D1: \u7ED3\u6784\u5316\u8868\u8FBE\u4F18\u4E8E\u7EAF\u6587\u672C\u65F6\u4E3B\u52A8\u7528\uFF08\u8981\u70B9\u3001\u5F3A\u8C03\u3001\u5BF9\u6BD4\u3001\u6D41\u7A0B\u3001\u6B65\u9AA4\u3001\u72B6\u6001\u3001\u6570\u636E\u3001\u6F14\u793A\uFF09\uFF0C\u7EAF\u95EE\u7B54\u4E0E\u4E00\u53E5\u8BDD\u4E0D\u5957 UI\uFF1B\u4E00\u4E2A\u4E3B\u9898\u4E00\u4E2A\u4E3B\u7EC4\u4EF6\uFF0C\u6BCF\u6B21 3\u20138 \u4E2A\u7EC4\u4EF6\uFF0C\u540C\u4E00\u6570\u636E\u4E0D\u91CD\u590D\u51FA\u73B0\u3002\n- JSON \u4E25\u683C: \u574F\u56F4\u680F\u964D\u7EA7\u4E3A\u4EE3\u7801\u5757\uFF1B\u22653 \u8282\u70B9\u6216\u542B table \u7684\u56F4\u680F\u53D1\u51FA\u524D\u8C03\u7528 validate_dsh_ui\uFF0C\u274C \u4FEE\u597D\u518D\u53D1\uFF08\u82E5\u9644\u300C\u5DF2\u81EA\u52A8\u4FEE\u590D\u300DJSON \u7167\u6284\u5373\u53EF\uFF09\u3002\n- \u89C4\u6A21: \u2264200 \u8282\u70B9\u3001\u5D4C\u5957\u22648 \u5C42\uFF08\u8D85\u51FA\u88AB\u622A\u65AD\uFF09\uFF1B3D mesh 1\u20135\uFF1Bplot \u7ED9\u5408\u7406 xMin/xMax\u3002\n- LOCAL-FIRST + actions: UI \u80FD\u81EA\u5DF1\u505A\u7684\u72B6\u6001\u53D8\u5316\uFF08\u5224\u5377\u3001\u5224\u9898\u3001\u91CD\u7F6E\u3001\u5C55\u5F00\u3001\u9009\u4E2D\uFF09\u5C31\u5730\u5B8C\u6210\uFF0C\u96F6\u5F80\u8FD4\uFF1Baction \u53EA\u7528\u4E8E\u5FC5\u987B\u6A21\u578B\u53C2\u4E0E\u7684\u4E8B\u3002\u4EA4\u4E92\u7EC4\u4EF6\u5E26 \"action\":\"name\"\uFF0C\u4EA4\u4E92\u4EE5 [genui-action] name + \u7EC4\u4EF6\u6570\u636E\u56DE\u4F20\uFF0C\u5C4A\u65F6\u91CD\u6E32\u67D3\u66F4\u65B0 UI\uFF1B\u65E0 action \u7684\u6309\u94AE\u7981\u7528\u3002\n- Durable state: \u4EA4\u4E92\u72B6\u6001\u6309\u300C\u4F1A\u8BDD+\u5185\u5BB9\u6307\u7EB9\u300D\u6301\u4E45\u5316\u2014\u2014\u5237\u65B0/\u91CD\u653E\u6062\u590D\uFF1B\u91CD\u6E32\u67D3\u76F8\u540C\u5185\u5BB9\u4FDD\u7559\uFF0C\u65B0\u5185\u5BB9\u91CD\u7F6E\u3002\n- \u5377\u5B50\u6A21\u5F0F: \u6BCF\u9898\u4E00\u4E2A radio\uFF08group+answer+explanation\uFF09+ \u4E00\u4E2A submit\uFF08groups \u5168\u5217\uFF09\uFF0C\u672C\u5730\u5224\u5206\u3002\n- Secrets ban: \u4E0D\u7D22\u53D6\u5BC6\u7801\u3001API Key\u3001Token\u3001\u6062\u590D\u7801\uFF1B\u9700\u8981\u65F6\u62D2\u7EDD\u5E76\u89E3\u91CA\u3002\n- Tool channel: render_ui \u5DE5\u5177\u628A\u540C\u4E00 spec \u6E32\u67D3\u4E3A\u5DE5\u5177\u884C\u5361\u7247\uFF08\u4EA4\u4ED8\u7269\u578B\u754C\u9762\u7528\uFF09\uFF1B\u56F4\u680F\u7528\u4E8E\u56DE\u7B54\u5185\u8054 UI\u3002\n- Panel: \"panel\":true \u53EA\u6E32\u67D3\u8FDB\u4F1A\u8BDD\u9762\u677F dock \u5E76\u539F\u5730\u66F4\u65B0\uFF1B\"append\":true \u8FFD\u52A0\u5408\u5E76\uFF08\u540C\u6807\u7B7E tabs \u8FFD\u52A0/\u65B0\u6807\u7B7E\u52A0\u5165/\u5C3E\u90E8\u8FFD\u52A0\uFF09\uFF1B\u4E0A\u9650 200 \u8282\u70B9/200 \u6B21\u8FFD\u52A0\uFF0C\u6EE1\u4E86\u53D1 replace \u91CD\u5EFA\u3002\u9762\u677F\u7EC4\u4EF6\u6765\u7684 [genui-action] \u53EA\u56DE\u4E00\u4E2A panel:true \u56F4\u680F + \u81F3\u591A\u4E00\u884C 10 \u5B57\u5185\u786E\u8BA4\uFF0C\u4E0D\u89E3\u91CA\u3001\u4E0D\u7528\u666E\u901A\u56F4\u680F\u3002";
20
20
  /**
21
21
  * Register the GenUI output-language section and the render_ui tool.
22
22
  * @param ctx - cordis context.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@changfenhuang/dsh-genui",
3
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.6",
4
+ "version": "0.9.7",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/plugin/index.d.ts",
@@ -64,6 +64,7 @@
64
64
  "react": "^18.3.1"
65
65
  },
66
66
  "peerDependencies": {
67
+ "@deepseek-ai/cordis": "^4.0.1",
67
68
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
68
69
  "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
69
70
  "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
@@ -73,9 +74,9 @@
73
74
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
74
75
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
75
76
  "@deepseek-ai/dsh-session": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
77
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
76
78
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0",
77
79
  "@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
80
  "react": "^18.0.0 || ^19.0.0"
80
81
  },
81
82
  "devDependencies": {
@@ -85,18 +86,18 @@
85
86
  "@types/react": "^18.0.0",
86
87
  "@types/react-dom": "^18.0.0",
87
88
  "@types/three": "^0.185.4",
89
+ "echarts": "^5.6.0",
88
90
  "jsdom": "^25.0.0",
89
91
  "lightningcss": "^1.25.0",
92
+ "mermaid": "11.16.0",
93
+ "react-dom": "^18.3.1",
94
+ "three": "^0.180.0",
90
95
  "tsdown": "^0.22.2",
91
96
  "tsx": "^4.0.0",
92
97
  "typescript": "^5.6.0",
93
98
  "vite": "^6.0.0",
94
99
  "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
+ "yaml": "^2.4.2"
100
101
  },
101
102
  "scripts": {
102
103
  "build": "rm -rf lib && tsc -p tsconfig.json && tsdown",
@@ -118,4 +119,4 @@
118
119
  "node": "^22.19.0 || >=24.0.0",
119
120
  "pnpm": ">=11.7.0 <12"
120
121
  }
121
- }
122
+ }
@@ -350,7 +350,6 @@
350
350
  line-height: 1.5;
351
351
  }
352
352
  .table tr:last-child td { border-bottom: none; }
353
- .table tbody tr { transition: background 0.12s; }
354
353
  .table tbody tr:hover { background: var(--dsl-g-hover); }
355
354
  /* Numeric columns (auto-detected) right-align with tabular numerals — the
356
355
  table's data voice. */
@@ -965,8 +964,7 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
965
964
  /* "✓ 已触发" confirmation chip: appears next to the button without layout
966
965
  shift risk (it inherits the flex row's gap). `backwards`, not `both`: the
967
966
  chip's natural (post-animation) style is already visible/no-transform, so
968
- pinning the end keyframe would only keep a translateY(0) transform layer
969
- alive forever — the exact mechanism behind issues #55/#69. */
967
+ pinning the end keyframe would only retain an unnecessary transform. */
970
968
  .btnSent { animation: genuiPopIn 0.2s ease backwards; }
971
969
 
972
970
  /* 尊重用户的减弱动效偏好 */
@@ -21,31 +21,32 @@ export const GENUI_ACTION_DEBOUNCE_MS = 300
21
21
  * Wrap the harness action callback with the per-action trailing debounce.
22
22
  * Absent provider = v1 behavior (components are display-only, callback
23
23
  * stays undefined). Pending timers are cleared on unmount so a click that
24
- * never fired does not leak into the next mount.
24
+ * never fired does not leak into the next mount. Timers live in one stable
25
+ * map and read the latest handler through a ref, so provider updates cannot
26
+ * leave stale callbacks behind.
25
27
  */
26
28
  function useDebouncedAction(onAction: GenuiBlockProps['onAction'] | undefined): GenuiBlockProps['onAction'] {
27
- const pending = useRef<Map<string, ReturnType<typeof setTimeout>> | null>(null)
29
+ const pending = useRef(new Map<string, ReturnType<typeof setTimeout>>())
30
+ const actionRef = useRef(onAction)
31
+ actionRef.current = onAction
32
+
28
33
  useEffect(() => {
29
34
  return () => {
30
- const timers = pending.current
31
- if (timers === null) return
32
- for (const timer of timers.values()) clearTimeout(timer)
33
- timers.clear()
35
+ for (const timer of pending.current.values()) clearTimeout(timer)
36
+ pending.current.clear()
34
37
  }
35
38
  }, [])
36
- return useMemo(() => {
37
- if (onAction === undefined) return undefined
38
- const timers = new Map<string, ReturnType<typeof setTimeout>>()
39
- pending.current = timers
40
- return (action: string, payload: Record<string, unknown>): void => {
41
- const existing = timers.get(action)
42
- if (existing !== undefined) clearTimeout(existing)
43
- timers.set(action, setTimeout(() => {
44
- timers.delete(action)
45
- onAction(action, payload)
46
- }, GENUI_ACTION_DEBOUNCE_MS))
47
- }
48
- }, [onAction])
39
+
40
+ const debounced = useCallback((action: string, payload: Record<string, unknown>): void => {
41
+ const existing = pending.current.get(action)
42
+ if (existing !== undefined) clearTimeout(existing)
43
+ pending.current.set(action, setTimeout(() => {
44
+ pending.current.delete(action)
45
+ actionRef.current?.(action, payload)
46
+ }, GENUI_ACTION_DEBOUNCE_MS))
47
+ }, [])
48
+
49
+ return onAction === undefined ? undefined : debounced
49
50
  }
50
51
 
51
52
  /**
@@ -66,11 +67,11 @@ function specEquivalent(a: GenuiSpec, b: GenuiSpec): boolean {
66
67
  return JSON.stringify(a) === JSON.stringify(b)
67
68
  }
68
69
 
69
- /**
70
- * Render a GenUI spec as an inline block. Falls back to nothing when the spec
71
- * carries no items (the fence renderer already refused non-specs before us).
72
- */
73
- export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBlockProps) {
70
+ /** Stateful implementation. Its mount lifetime is the interaction-state
71
+ * lifetime: useState seeds durable data exactly once, and every later save
72
+ * belongs to the same stateKey. The exported shell below owns the React key
73
+ * that enforces this invariant for every caller. */
74
+ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
74
75
  const gap = spec.gap ?? 16
75
76
  const onAction = useDebouncedAction(useGenuiAction())
76
77
  // v2.5/v2.6 answers registry: grouped radios record selections + question
@@ -178,4 +179,16 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
178
179
  </div>
179
180
  </div>
180
181
  )
182
+ }
183
+
184
+ /**
185
+ * Render a GenUI spec as an inline block. `stateKey` is also the durable
186
+ * component identity: changing it remounts the stateful implementation so
187
+ * state loaded for one block can never leak into or be saved under another
188
+ * key. Identity-less streaming renders deliberately share one stable
189
+ * volatile instance, preserving local interaction state as the spec grows.
190
+ */
191
+ export const GenuiBlock = memo(function GenuiBlock(props: GenuiBlockProps) {
192
+ const instanceKey = props.stateKey === undefined ? 'volatile' : `durable:${props.stateKey}`
193
+ return <GenuiBlockInstance key={instanceKey} {...props} />
181
194
  }, (prev, next) => prev.stateKey === next.stateKey && specEquivalent(prev.spec, next.spec))
@@ -194,29 +194,44 @@ export const PlotBlock = memo(function PlotBlock({
194
194
 
195
195
  useEffect(() => {
196
196
  if (!playing || animParam === null) return
197
- const from = params[`${animParam.si}:${animParam.param.name}`] ?? animParam.param.value
197
+ const key = `${animParam.si}:${animParam.param.name}`
198
+ let cycleFrom = params[key] ?? animParam.param.value
198
199
  const to = animParam.param.animateTo!
199
- const duration = animParam.param.durationMs ?? 4000
200
- const start = performance.now()
200
+ const duration = Math.max(0, animParam.param.durationMs ?? 4000)
201
+ let cycleStart = performance.now()
201
202
  const tick = (now: number): void => {
202
- const t = Math.min(1, (now - start) / duration)
203
+ const t = duration === 0 ? 1 : Math.min(1, Math.max(0, (now - cycleStart) / duration))
203
204
  const eased = 1 - Math.pow(1 - t, 3) // easeOutCubic
204
- const value = from + (to - from) * eased
205
- setParams(prev => ({ ...prev, [`${animParam.si}:${animParam.param.name}`]: value }))
205
+ const value = cycleFrom + (to - cycleFrom) * eased
206
+ setParams(prev => ({ ...prev, [key]: value }))
206
207
  setAnimProgress(t)
207
208
  if (t < 1) {
208
209
  animRef.current = requestAnimationFrame(tick)
209
210
  } else if (animParam.param.loop === true) {
210
- setParams(prev => ({ ...prev, [`${animParam.si}:${animParam.param.name}`]: animParam.param.value }))
211
+ cycleFrom = animParam.param.value
212
+ cycleStart = now
213
+ setParams(prev => ({ ...prev, [key]: cycleFrom }))
211
214
  setAnimProgress(0)
212
- animRef.current = requestAnimationFrame(() => setPlaying(p => p))
215
+ animRef.current = requestAnimationFrame(tick)
213
216
  } else {
217
+ animRef.current = null
214
218
  setPlaying(false)
215
219
  }
216
220
  }
217
221
  animRef.current = requestAnimationFrame(tick)
218
- return () => { if (animRef.current !== null) cancelAnimationFrame(animRef.current) }
219
- }, [playing, animParam?.si, animParam?.param.name, animParam?.param.animateTo])
222
+ return () => {
223
+ if (animRef.current !== null) cancelAnimationFrame(animRef.current)
224
+ animRef.current = null
225
+ }
226
+ }, [
227
+ playing,
228
+ animParam?.si,
229
+ animParam?.param.name,
230
+ animParam?.param.value,
231
+ animParam?.param.animateTo,
232
+ animParam?.param.durationMs,
233
+ animParam?.param.loop,
234
+ ])
220
235
 
221
236
  const xMin = view.xMin
222
237
  const xMax = view.xMax
@@ -121,8 +121,13 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
121
121
  const [active, setActive] = useState(0)
122
122
  const uid = useId()
123
123
  const list = tabs.tabs.slice(0, GENUI_LIMITS.maxTabs)
124
- const current = list[active]
124
+ const safeActive = list.length === 0 ? 0 : Math.min(active, list.length - 1)
125
+ const current = list[safeActive]
126
+ useEffect(() => {
127
+ if (safeActive !== active) setActive(safeActive)
128
+ }, [active, safeActive])
125
129
  const move = (next: number): void => {
130
+ if (list.length === 0) return
126
131
  const n = (next + list.length) % list.length
127
132
  setActive(n)
128
133
  document.getElementById(`${uid}-tab-${n}`)?.focus()
@@ -134,8 +139,8 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
134
139
  role="tablist"
135
140
  aria-orientation="horizontal"
136
141
  onKeyDown={e => {
137
- if (e.key === 'ArrowRight') { e.preventDefault(); move(active + 1) }
138
- else if (e.key === 'ArrowLeft') { e.preventDefault(); move(active - 1) }
142
+ if (e.key === 'ArrowRight') { e.preventDefault(); move(safeActive + 1) }
143
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); move(safeActive - 1) }
139
144
  else if (e.key === 'Home') { e.preventDefault(); move(0) }
140
145
  else if (e.key === 'End') { e.preventDefault(); move(list.length - 1) }
141
146
  }}
@@ -146,10 +151,10 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
146
151
  id={`${uid}-tab-${i}`}
147
152
  type="button"
148
153
  role="tab"
149
- aria-selected={i === active}
154
+ aria-selected={i === safeActive}
150
155
  aria-controls={`${uid}-panel-${i}`}
151
- tabIndex={i === active ? 0 : -1}
152
- className={`${css.tab} ${i === active ? css.tabActive : ''}`}
156
+ tabIndex={i === safeActive ? 0 : -1}
157
+ className={`${css.tab} ${i === safeActive ? css.tabActive : ''}`}
153
158
  onClick={() => setActive(i)}
154
159
  >
155
160
  {tab.label}
@@ -157,7 +162,7 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
157
162
  ))}
158
163
  </div>
159
164
  {current !== undefined && (
160
- <div className={css.col} role="tabpanel" id={`${uid}-panel-${active}`} aria-labelledby={`${uid}-tab-${active}`}>
165
+ <div className={css.col} role="tabpanel" id={`${uid}-panel-${safeActive}`} aria-labelledby={`${uid}-tab-${safeActive}`}>
161
166
  {current.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
162
167
  </div>
163
168
  )}
@@ -264,26 +269,32 @@ export const CopyNode = memo(function CopyNode({ node }: { node: GenuiCopy }) {
264
269
  )
265
270
  })
266
271
 
272
+ type MermaidRenderState =
273
+ | { status: 'loading' }
274
+ | { status: 'ready'; html: string }
275
+ | { status: 'error' }
276
+
267
277
  /** Mermaid: lazily loaded diagram renderer. */
268
278
  export const MermaidNode = memo(function MermaidNode({ node }: { node: GenuiMermaid }) {
269
- const [html, setHtml] = useState<string | null>(null)
270
- const [failed, setFailed] = useState(false)
279
+ const [state, setState] = useState<MermaidRenderState>({ status: 'loading' })
271
280
  const code = node.code.slice(0, GENUI_LIMITS.maxMermaid)
272
281
  useEffect(() => {
273
282
  let alive = true
274
- void import('../mermaid-lazy.ts').then(async m => {
283
+ setState({ status: 'loading' })
284
+ void (async () => {
275
285
  try {
286
+ const m = await import('../mermaid-lazy.ts')
276
287
  const svg = await m.renderMermaid(code)
277
- if (alive) setHtml(svg)
288
+ if (alive) setState({ status: 'ready', html: svg })
278
289
  } catch {
279
- if (alive) setFailed(true)
290
+ if (alive) setState({ status: 'error' })
280
291
  }
281
- })
292
+ })()
282
293
  return () => { alive = false }
283
294
  }, [code])
284
- if (failed) return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidErr}>图语法有误,已降级显示源码</div></div>
285
- if (html === null) return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidHint}>渲染中…</div></div>
286
- return <div className={css.mermaid} dangerouslySetInnerHTML={{ __html: html }} data-genui-mermaid />
295
+ if (state.status === 'error') return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidErr}>图语法有误,已降级显示源码</div></div>
296
+ if (state.status === 'loading') return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidHint}>渲染中…</div></div>
297
+ return <div className={css.mermaid} dangerouslySetInnerHTML={{ __html: state.html }} data-genui-mermaid />
287
298
  })
288
299
 
289
300
  /** Scene3D: three.js WebGL canvas, lazily imported. */
@@ -346,7 +357,6 @@ export const TimelineNode = memo(function TimelineNode({ node }: { node: GenuiTi
346
357
  * — click a dir to fold/unfold, default fully open. Zero model round trip. */
347
358
  export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFileTree }) {
348
359
  const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
349
- const pathKey = (depth: number, i: number): string => `${depth}-${i}`
350
360
  const toggle = (k: string): void => {
351
361
  setCollapsed(prev => {
352
362
  const next = new Set(prev)
@@ -355,27 +365,26 @@ export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFi
355
365
  return next
356
366
  })
357
367
  }
358
- const renderNode = (n: GenuiFileTreeNode, depth: number, i: number): ReactNode => {
368
+ const renderNode = (n: GenuiFileTreeNode, depth: number, path: string): ReactNode => {
359
369
  if (depth > GENUI_LIMITS.maxTreeDepth) return null
360
370
  const isDir = n.type === 'dir' || (n.children !== undefined && n.children.length > 0)
361
- const k = pathKey(depth, i)
362
- const folded = isDir && collapsed.has(k)
371
+ const folded = isDir && collapsed.has(path)
363
372
  return (
364
- <div key={k} className={css.ftRow} style={{ paddingLeft: `${depth * 16}px` }}>
373
+ <div key={path} className={css.ftRow} style={{ paddingLeft: `${depth * 16}px` }}>
365
374
  <button
366
375
  type="button"
367
376
  className={css.ftNameBtn}
368
377
  aria-expanded={isDir ? !folded : undefined}
369
- onClick={isDir ? () => toggle(k) : undefined}
378
+ onClick={isDir ? () => toggle(path) : undefined}
370
379
  >
371
380
  <span className={`${css.ftIcon} ${isDir ? css.ftIconDir : ''}`} aria-hidden>{isDir ? (folded ? '▸' : '▾') : '·'}</span>
372
381
  <span className={`${css.ftName} ${isDir ? css.ftDir : ''}`}>{n.name}</span>
373
382
  </button>
374
- {isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, ci))}
383
+ {isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, `${path}/${ci}`))}
375
384
  </div>
376
385
  )
377
386
  }
378
- return <div className={css.fileTree}>{node.items.slice(0, GENUI_LIMITS.maxListItems).map((n, i) => renderNode(n, 0, i))}</div>
387
+ return <div className={css.fileTree}>{node.items.slice(0, GENUI_LIMITS.maxListItems).map((n, i) => renderNode(n, 0, String(i)))}</div>
379
388
  })
380
389
 
381
390
  /** Quiz: a self-contained teaching question. Selecting an option marks it
@@ -464,4 +473,4 @@ export const BreadcrumbNode = memo(function BreadcrumbNode({ node }: { node: Gen
464
473
  * names stay independent. The model round-trip takes seconds, so a few
465
474
  * hundred ms of trailing delay is imperceptible — and it stops bursts of
466
475
  * queued user turns.
467
- */
476
+ */
@@ -138,7 +138,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
138
138
  const labels = grouped[0]!.data.map(d => d.label)
139
139
  const max = Math.max(...grouped.flatMap(s => s.data.map(d => Number(d.value) || 0)), 1)
140
140
  return (
141
- <div className={css.chart}>
141
+ <div className={css.chart} data-genui-chart="bars">
142
142
  <div className={css.chartPlot}>
143
143
  {[0, 25, 50, 75].map(p => (
144
144
  <span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
@@ -181,7 +181,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
181
181
  // and used to collapse the bar entirely.
182
182
  const max = Math.max(...data.map(d => Number(d.value) || 0), 1)
183
183
  return (
184
- <div className={css.chart}>
184
+ <div className={css.chart} data-genui-chart="bars">
185
185
  <div className={css.chartPlot}>
186
186
  {[0, 25, 50, 75].map(p => (
187
187
  <span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
@@ -233,7 +233,7 @@ export const LineChartNode = memo(function LineChartNode({ chart }: { chart: Gen
233
233
  return t.toFixed(1)
234
234
  }
235
235
  return (
236
- <div className={css.lineChart}>
236
+ <div className={css.lineChart} data-genui-chart="line">
237
237
  <svg width="100%" viewBox={`0 0 ${W} ${H}`}>
238
238
  {ticks.map((t, i) => {
239
239
  const y = padT + (1 - (t - min) / span) * (H - padT - padB)
@@ -277,7 +277,7 @@ export const DonutNode = memo(function DonutNode({ chart }: { chart: GenuiChart
277
277
  const C = 2 * Math.PI * R
278
278
  let offset = 0
279
279
  return (
280
- <div className={css.donut}>
280
+ <div className={css.donut} data-genui-chart="donut">
281
281
  <svg width="120" height="120" viewBox="0 0 120 120">
282
282
  <circle cx="60" cy="60" r={R} fill="none" strokeWidth="14" className={css.donutTrack} />
283
283
  {clamped.map((d, i) => {
@@ -0,0 +1,25 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import css from '../GenuiBlock.module.css'
3
+ import type { GenuiImage } from '../spec.ts'
4
+
5
+ /** Native image display; source safety is enforced by repairGenuiSpec/safeMediaSrc. */
6
+ export function ImageNode({ node }: { node: GenuiImage }): ReactNode {
7
+ const [failed, setFailed] = useState(false)
8
+
9
+ return (
10
+ <figure className={css.media}>
11
+ {node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
12
+ {failed
13
+ ? <div className={css.mediaError} role="alert">图片无法加载</div>
14
+ : <img
15
+ className={css.mediaPlayer}
16
+ src={node.src}
17
+ alt={node.alt ?? '图片'}
18
+ loading="lazy"
19
+ decoding="async"
20
+ style={{ maxHeight: 'min(70vh, 720px)', objectFit: 'contain' }}
21
+ onError={() => setFailed(true)}
22
+ />}
23
+ </figure>
24
+ )
25
+ }
@@ -20,6 +20,7 @@ import {
20
20
  MermaidNode, PlotNode, QuizNode, Scene3DNode, StepsNode, TabsNode, TimelineNode,
21
21
  } from './advanced.tsx'
22
22
  import { DiagramNode } from './diagram/index.tsx'
23
+ import { ImageNode } from './image.tsx'
23
24
 
24
25
  import { EChartNode } from '../EChartNode.tsx'
25
26
 
@@ -58,6 +59,7 @@ export function renderNode(
58
59
  // against the same limit; this is the belt-and-suspenders for direct
59
60
  // GenuiBlock use and plugin-registered custom renderers.
60
61
  if (depth > GENUI_LIMITS.maxDepth) return null
62
+
61
63
  switch (node.type) {
62
64
  case 'text': {
63
65
  const size = node.size ?? 'body'
@@ -143,6 +145,7 @@ export function renderNode(
143
145
  ? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{node.label}</a>
144
146
  : <span key={key} className={css.linkText}>{node.label}</span>
145
147
  }
148
+ case 'image': return <ImageNode key={`${key}:${node.src}`} node={node} />
146
149
  case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
147
150
  case 'video': return <VideoNode key={`${key}:${node.src}`} node={node} />
148
151
  case 'badge': {
@@ -20,6 +20,8 @@ export interface GenuiBlockProps {
20
20
  * v2.7: durable-state key (session + slot + content fingerprint). When set,
21
21
  * interaction state (radio answers, submit lock, field values) persists to
22
22
  * localStorage and restores on refresh / re-render of the same content.
23
+ * Changing this key also starts a fresh in-memory interaction-state lifetime;
24
+ * callers only provide stateKey — GenuiBlock owns the matching React identity.
23
25
  */
24
26
  stateKey?: string | undefined
25
27
  }
@@ -354,6 +354,8 @@ export function installDomFenceRenderer(
354
354
  driftWarned = false
355
355
  plausibilityWarned = false
356
356
  const mounts = new Map<HTMLElement, Mount>()
357
+ let disposed = false
358
+ let rafId: number | null = null
357
359
 
358
360
  const sessionIdOf = (): SessionId | undefined => {
359
361
  try {
@@ -513,6 +515,7 @@ export function installDomFenceRenderer(
513
515
  * the streaming→settled transition), repair surgery, then take over every
514
516
  * new dsh-ui block — settled or still streaming. */
515
517
  function sweep(): void {
518
+ if (disposed) return
516
519
  for (const [block, mount] of mounts) {
517
520
  if (!block.isConnected) {
518
521
  unmountBlock(block)
@@ -605,12 +608,11 @@ export function installDomFenceRenderer(
605
608
  }
606
609
  }
607
610
 
608
- let scheduled = false
609
611
  const schedule = (): void => {
610
- if (scheduled) return
611
- scheduled = true
612
- requestAnimationFrame(() => {
613
- scheduled = false
612
+ if (disposed || rafId !== null) return
613
+ rafId = requestAnimationFrame(() => {
614
+ rafId = null
615
+ if (disposed) return
614
616
  sweep()
615
617
  })
616
618
  }
@@ -634,8 +636,13 @@ export function installDomFenceRenderer(
634
636
  sweep()
635
637
 
636
638
  return () => {
639
+ disposed = true
637
640
  observer.disconnect()
638
641
  window.clearInterval(interval)
642
+ if (rafId !== null) {
643
+ cancelAnimationFrame(rafId)
644
+ rafId = null
645
+ }
639
646
  for (const block of Array.from(mounts.keys())) unmountBlock(block)
640
647
  }
641
648
  }