@changfenhuang/dsh-genui 0.9.6 → 0.9.8
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 +15 -0
- package/README.md +7 -18
- package/README.zh-CN.md +7 -18
- package/SKILL.md +4 -2
- package/lib/assets/three.js +1 -1
- package/lib/client.js +3 -3
- package/lib/index.js +273 -61
- package/lib/types/client/GenuiBlock.d.ts +5 -2
- package/lib/types/client/blocks/advanced.d.ts +1 -1
- package/lib/types/client/blocks/image.d.ts +6 -0
- package/lib/types/client/blocks/state.d.ts +2 -0
- package/lib/types/client/fence-render.d.ts +3 -0
- package/lib/types/client/gallery.d.ts +3 -5
- package/lib/types/client/guard.d.ts +10 -5
- package/lib/types/client/parse-partial.d.ts +29 -3
- package/lib/types/client/spec.d.ts +8 -1
- package/lib/types/plugin/chart-contract.d.ts +7 -0
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +18 -17
- package/src/client/GenuiBlock.module.css +1 -3
- package/src/client/GenuiBlock.tsx +37 -24
- package/src/client/PlotBlock.tsx +25 -10
- package/src/client/blocks/advanced.tsx +34 -25
- package/src/client/blocks/charts.tsx +4 -4
- package/src/client/blocks/image.tsx +25 -0
- package/src/client/blocks/render-node.tsx +3 -0
- package/src/client/blocks/state.ts +2 -0
- package/src/client/dom-fence.tsx +12 -5
- package/src/client/fence-render.tsx +35 -11
- package/src/client/gallery.ts +4 -5
- package/src/client/guard.ts +125 -29
- package/src/client/panel-command.ts +14 -5
- package/src/client/parse-partial.ts +19 -25
- package/src/client/safe-math.ts +29 -1
- package/src/client/scene3d-core.ts +5 -2
- package/src/client/spec.ts +9 -1
- package/src/plugin/chart-contract.ts +103 -0
- package/src/plugin/index.ts +48 -2
- package/src/plugin/tool.ts +25 -4
|
@@ -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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Partial GenUI spec parsing: extract the components that are already
|
|
3
|
+
* complete from a still-growing ```dsh-ui fence body, so the UI can render
|
|
4
|
+
* top-down as the model writes — each finished component appears the moment
|
|
5
|
+
* its JSON object closes, instead of the whole block waiting for the fence.
|
|
6
|
+
*
|
|
7
|
+
* Strategy (tolerant, white-list-agnostic, BOUNDED):
|
|
8
|
+
* 1. Full parse first (the common settled case) — at most one.
|
|
9
|
+
* 2. ONE left-to-right scan collects the repair candidates: every position
|
|
10
|
+
* where the prefix is bracket-balanced (trailing comma / fence tail) and
|
|
11
|
+
* every top-level `items[]` component close (an unfinished trailing
|
|
12
|
+
* component is dropped by closing the root array/object). Partial candidates
|
|
13
|
+
* must parse as a spec root; bare components wait for their own root close.
|
|
14
|
+
* The ring buffer keeps only the longest-direction candidates (≤ MAX_PARTIAL_REPAIR_ATTEMPTS),
|
|
15
|
+
* so the work is O(n) with a hard parse-attempt cap — a pathological
|
|
16
|
+
* input can never re-scan prefixes or burn seconds in JSON.parse.
|
|
17
|
+
* 3. Candidates are tried longest first; the first that parses as a GenUI
|
|
18
|
+
* spec wins. Nothing parses → null (streaming partial, wait for more).
|
|
19
|
+
*
|
|
20
|
+
* The result is only ever a PREFIX of the intended spec, so it is always
|
|
21
|
+
* safe to render: components already present are complete and valid.
|
|
22
|
+
*
|
|
23
|
+
* `ponytail:` the 32-candidate / 33-parse budget is the protection ceiling;
|
|
24
|
+
* only real streaming samples proving a recovery shortfall justify switching
|
|
25
|
+
* to a tokenizing parser.
|
|
26
|
+
*/
|
|
1
27
|
import { type GenuiSpec } from './spec.ts';
|
|
2
28
|
/** Default repair-candidate budget (adjustable; see the design doc). */
|
|
3
29
|
export declare const MAX_PARTIAL_REPAIR_ATTEMPTS = 32;
|
|
@@ -13,9 +39,9 @@ export interface PartialCandidate {
|
|
|
13
39
|
* (skipping strings/escapes correctly) and records:
|
|
14
40
|
* - every position where the prefix is fully balanced (trailing comma or
|
|
15
41
|
* fence tail) — candidate with an empty closing suffix;
|
|
16
|
-
* - every `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
42
|
+
* - every top-level `items[]` component close — candidate with the root
|
|
43
|
+
* array/object closed (an unfinished trailing component is dropped). Nested
|
|
44
|
+
* object closes are not candidates because their component is still partial.
|
|
19
45
|
* Candidates are ring-buffered to the attempts budget (the LAST pushes are
|
|
20
46
|
* the longest), then returned longest-first, deduplicated by end. The scan
|
|
21
47
|
* stops at the first unbalanced close — earlier balanced prefixes remain
|
|
@@ -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
|
|
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.
|
|
4
|
+
"version": "0.9.8",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/plugin/index.d.ts",
|
|
@@ -64,18 +64,19 @@
|
|
|
64
64
|
"react": "^18.3.1"
|
|
65
65
|
},
|
|
66
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
67
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
68
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
74
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
75
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
76
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
77
|
+
"@deepseek-ai/dsh-skill": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
78
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
|
|
79
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.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
|
-
"
|
|
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",
|
|
@@ -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
|
|
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
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*/
|
|
73
|
-
|
|
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))
|
package/src/client/PlotBlock.tsx
CHANGED
|
@@ -194,29 +194,44 @@ export const PlotBlock = memo(function PlotBlock({
|
|
|
194
194
|
|
|
195
195
|
useEffect(() => {
|
|
196
196
|
if (!playing || animParam === null) return
|
|
197
|
-
const
|
|
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
|
-
|
|
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 -
|
|
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 =
|
|
205
|
-
setParams(prev => ({ ...prev, [
|
|
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
|
-
|
|
211
|
+
cycleFrom = animParam.param.value
|
|
212
|
+
cycleStart = now
|
|
213
|
+
setParams(prev => ({ ...prev, [key]: cycleFrom }))
|
|
211
214
|
setAnimProgress(0)
|
|
212
|
-
animRef.current = requestAnimationFrame(
|
|
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 () => {
|
|
219
|
-
|
|
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
|
|
124
|
+
const safeActive = list.length === 0 ? 0 : Math.min(active, list.length - 1)
|
|
125
|
+
const current = list[safeActive]
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (safeActive !== active) setActive(safeActive)
|
|
128
|
+
}, [active, safeActive])
|
|
125
129
|
const move = (next: number): void => {
|
|
130
|
+
if (list.length === 0) return
|
|
126
131
|
const n = (next + list.length) % list.length
|
|
127
132
|
setActive(n)
|
|
128
133
|
document.getElementById(`${uid}-tab-${n}`)?.focus()
|
|
@@ -134,8 +139,8 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
|
|
|
134
139
|
role="tablist"
|
|
135
140
|
aria-orientation="horizontal"
|
|
136
141
|
onKeyDown={e => {
|
|
137
|
-
if (e.key === 'ArrowRight') { e.preventDefault(); move(
|
|
138
|
-
else if (e.key === 'ArrowLeft') { e.preventDefault(); move(
|
|
142
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); move(safeActive + 1) }
|
|
143
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); move(safeActive - 1) }
|
|
139
144
|
else if (e.key === 'Home') { e.preventDefault(); move(0) }
|
|
140
145
|
else if (e.key === 'End') { e.preventDefault(); move(list.length - 1) }
|
|
141
146
|
}}
|
|
@@ -146,10 +151,10 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
|
|
|
146
151
|
id={`${uid}-tab-${i}`}
|
|
147
152
|
type="button"
|
|
148
153
|
role="tab"
|
|
149
|
-
aria-selected={i ===
|
|
154
|
+
aria-selected={i === safeActive}
|
|
150
155
|
aria-controls={`${uid}-panel-${i}`}
|
|
151
|
-
tabIndex={i ===
|
|
152
|
-
className={`${css.tab} ${i ===
|
|
156
|
+
tabIndex={i === safeActive ? 0 : -1}
|
|
157
|
+
className={`${css.tab} ${i === safeActive ? css.tabActive : ''}`}
|
|
153
158
|
onClick={() => setActive(i)}
|
|
154
159
|
>
|
|
155
160
|
{tab.label}
|
|
@@ -157,7 +162,7 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
|
|
|
157
162
|
))}
|
|
158
163
|
</div>
|
|
159
164
|
{current !== undefined && (
|
|
160
|
-
<div className={css.col} role="tabpanel" id={`${uid}-panel-${
|
|
165
|
+
<div className={css.col} role="tabpanel" id={`${uid}-panel-${safeActive}`} aria-labelledby={`${uid}-tab-${safeActive}`}>
|
|
161
166
|
{current.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
|
|
162
167
|
</div>
|
|
163
168
|
)}
|
|
@@ -264,26 +269,32 @@ export const CopyNode = memo(function CopyNode({ node }: { node: GenuiCopy }) {
|
|
|
264
269
|
)
|
|
265
270
|
})
|
|
266
271
|
|
|
272
|
+
type MermaidRenderState =
|
|
273
|
+
| { status: 'loading' }
|
|
274
|
+
| { status: 'ready'; html: string }
|
|
275
|
+
| { status: 'error' }
|
|
276
|
+
|
|
267
277
|
/** Mermaid: lazily loaded diagram renderer. */
|
|
268
278
|
export const MermaidNode = memo(function MermaidNode({ node }: { node: GenuiMermaid }) {
|
|
269
|
-
const [
|
|
270
|
-
const [failed, setFailed] = useState(false)
|
|
279
|
+
const [state, setState] = useState<MermaidRenderState>({ status: 'loading' })
|
|
271
280
|
const code = node.code.slice(0, GENUI_LIMITS.maxMermaid)
|
|
272
281
|
useEffect(() => {
|
|
273
282
|
let alive = true
|
|
274
|
-
|
|
283
|
+
setState({ status: 'loading' })
|
|
284
|
+
void (async () => {
|
|
275
285
|
try {
|
|
286
|
+
const m = await import('../mermaid-lazy.ts')
|
|
276
287
|
const svg = await m.renderMermaid(code)
|
|
277
|
-
if (alive)
|
|
288
|
+
if (alive) setState({ status: 'ready', html: svg })
|
|
278
289
|
} catch {
|
|
279
|
-
if (alive)
|
|
290
|
+
if (alive) setState({ status: 'error' })
|
|
280
291
|
}
|
|
281
|
-
})
|
|
292
|
+
})()
|
|
282
293
|
return () => { alive = false }
|
|
283
294
|
}, [code])
|
|
284
|
-
if (
|
|
285
|
-
if (
|
|
286
|
-
return <div className={css.mermaid} dangerouslySetInnerHTML={{ __html: html }} data-genui-mermaid />
|
|
295
|
+
if (state.status === 'error') return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidErr}>图语法有误,已降级显示源码</div></div>
|
|
296
|
+
if (state.status === 'loading') return <div className={css.mermaidFallback}><pre>{code}</pre><div className={css.mermaidHint}>渲染中…</div></div>
|
|
297
|
+
return <div className={css.mermaid} dangerouslySetInnerHTML={{ __html: state.html }} data-genui-mermaid />
|
|
287
298
|
})
|
|
288
299
|
|
|
289
300
|
/** Scene3D: three.js WebGL canvas, lazily imported. */
|
|
@@ -346,7 +357,6 @@ export const TimelineNode = memo(function TimelineNode({ node }: { node: GenuiTi
|
|
|
346
357
|
* — click a dir to fold/unfold, default fully open. Zero model round trip. */
|
|
347
358
|
export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFileTree }) {
|
|
348
359
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
349
|
-
const pathKey = (depth: number, i: number): string => `${depth}-${i}`
|
|
350
360
|
const toggle = (k: string): void => {
|
|
351
361
|
setCollapsed(prev => {
|
|
352
362
|
const next = new Set(prev)
|
|
@@ -355,27 +365,26 @@ export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFi
|
|
|
355
365
|
return next
|
|
356
366
|
})
|
|
357
367
|
}
|
|
358
|
-
const renderNode = (n: GenuiFileTreeNode, depth: number,
|
|
368
|
+
const renderNode = (n: GenuiFileTreeNode, depth: number, path: string): ReactNode => {
|
|
359
369
|
if (depth > GENUI_LIMITS.maxTreeDepth) return null
|
|
360
370
|
const isDir = n.type === 'dir' || (n.children !== undefined && n.children.length > 0)
|
|
361
|
-
const
|
|
362
|
-
const folded = isDir && collapsed.has(k)
|
|
371
|
+
const folded = isDir && collapsed.has(path)
|
|
363
372
|
return (
|
|
364
|
-
<div key={
|
|
373
|
+
<div key={path} className={css.ftRow} style={{ paddingLeft: `${depth * 16}px` }}>
|
|
365
374
|
<button
|
|
366
375
|
type="button"
|
|
367
376
|
className={css.ftNameBtn}
|
|
368
377
|
aria-expanded={isDir ? !folded : undefined}
|
|
369
|
-
onClick={isDir ? () => toggle(
|
|
378
|
+
onClick={isDir ? () => toggle(path) : undefined}
|
|
370
379
|
>
|
|
371
380
|
<span className={`${css.ftIcon} ${isDir ? css.ftIconDir : ''}`} aria-hidden>{isDir ? (folded ? '▸' : '▾') : '·'}</span>
|
|
372
381
|
<span className={`${css.ftName} ${isDir ? css.ftDir : ''}`}>{n.name}</span>
|
|
373
382
|
</button>
|
|
374
|
-
{isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, ci))}
|
|
383
|
+
{isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, `${path}/${ci}`))}
|
|
375
384
|
</div>
|
|
376
385
|
)
|
|
377
386
|
}
|
|
378
|
-
return <div className={css.fileTree}>{node.items.slice(0, GENUI_LIMITS.maxListItems).map((n, i) => renderNode(n, 0, i))}</div>
|
|
387
|
+
return <div className={css.fileTree}>{node.items.slice(0, GENUI_LIMITS.maxListItems).map((n, i) => renderNode(n, 0, String(i)))}</div>
|
|
379
388
|
})
|
|
380
389
|
|
|
381
390
|
/** Quiz: a self-contained teaching question. Selecting an option marks it
|
|
@@ -464,4 +473,4 @@ export const BreadcrumbNode = memo(function BreadcrumbNode({ node }: { node: Gen
|
|
|
464
473
|
* names stay independent. The model round-trip takes seconds, so a few
|
|
465
474
|
* hundred ms of trailing delay is imperceptible — and it stops bursts of
|
|
466
475
|
* queued user turns.
|
|
467
|
-
*/
|
|
476
|
+
*/
|
|
@@ -138,7 +138,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
|
|
|
138
138
|
const labels = grouped[0]!.data.map(d => d.label)
|
|
139
139
|
const max = Math.max(...grouped.flatMap(s => s.data.map(d => Number(d.value) || 0)), 1)
|
|
140
140
|
return (
|
|
141
|
-
<div className={css.chart}>
|
|
141
|
+
<div className={css.chart} data-genui-chart="bars">
|
|
142
142
|
<div className={css.chartPlot}>
|
|
143
143
|
{[0, 25, 50, 75].map(p => (
|
|
144
144
|
<span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
|
|
@@ -181,7 +181,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
|
|
|
181
181
|
// and used to collapse the bar entirely.
|
|
182
182
|
const max = Math.max(...data.map(d => Number(d.value) || 0), 1)
|
|
183
183
|
return (
|
|
184
|
-
<div className={css.chart}>
|
|
184
|
+
<div className={css.chart} data-genui-chart="bars">
|
|
185
185
|
<div className={css.chartPlot}>
|
|
186
186
|
{[0, 25, 50, 75].map(p => (
|
|
187
187
|
<span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
|
|
@@ -233,7 +233,7 @@ export const LineChartNode = memo(function LineChartNode({ chart }: { chart: Gen
|
|
|
233
233
|
return t.toFixed(1)
|
|
234
234
|
}
|
|
235
235
|
return (
|
|
236
|
-
<div className={css.lineChart}>
|
|
236
|
+
<div className={css.lineChart} data-genui-chart="line">
|
|
237
237
|
<svg width="100%" viewBox={`0 0 ${W} ${H}`}>
|
|
238
238
|
{ticks.map((t, i) => {
|
|
239
239
|
const y = padT + (1 - (t - min) / span) * (H - padT - padB)
|
|
@@ -277,7 +277,7 @@ export const DonutNode = memo(function DonutNode({ chart }: { chart: GenuiChart
|
|
|
277
277
|
const C = 2 * Math.PI * R
|
|
278
278
|
let offset = 0
|
|
279
279
|
return (
|
|
280
|
-
<div className={css.donut}>
|
|
280
|
+
<div className={css.donut} data-genui-chart="donut">
|
|
281
281
|
<svg width="120" height="120" viewBox="0 0 120 120">
|
|
282
282
|
<circle cx="60" cy="60" r={R} fill="none" strokeWidth="14" className={css.donutTrack} />
|
|
283
283
|
{clamped.map((d, i) => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import css from '../GenuiBlock.module.css'
|
|
3
|
+
import type { GenuiImage } from '../spec.ts'
|
|
4
|
+
|
|
5
|
+
/** Native image display; source safety is enforced by repairGenuiSpec/safeMediaSrc. */
|
|
6
|
+
export function ImageNode({ node }: { node: GenuiImage }): ReactNode {
|
|
7
|
+
const [failed, setFailed] = useState(false)
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<figure className={css.media}>
|
|
11
|
+
{node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
|
|
12
|
+
{failed
|
|
13
|
+
? <div className={css.mediaError} role="alert">图片无法加载</div>
|
|
14
|
+
: <img
|
|
15
|
+
className={css.mediaPlayer}
|
|
16
|
+
src={node.src}
|
|
17
|
+
alt={node.alt ?? '图片'}
|
|
18
|
+
loading="lazy"
|
|
19
|
+
decoding="async"
|
|
20
|
+
style={{ maxHeight: 'min(70vh, 720px)', objectFit: 'contain' }}
|
|
21
|
+
onError={() => setFailed(true)}
|
|
22
|
+
/>}
|
|
23
|
+
</figure>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
MermaidNode, PlotNode, QuizNode, Scene3DNode, StepsNode, TabsNode, TimelineNode,
|
|
21
21
|
} from './advanced.tsx'
|
|
22
22
|
import { DiagramNode } from './diagram/index.tsx'
|
|
23
|
+
import { ImageNode } from './image.tsx'
|
|
23
24
|
|
|
24
25
|
import { EChartNode } from '../EChartNode.tsx'
|
|
25
26
|
|
|
@@ -58,6 +59,7 @@ export function renderNode(
|
|
|
58
59
|
// against the same limit; this is the belt-and-suspenders for direct
|
|
59
60
|
// GenuiBlock use and plugin-registered custom renderers.
|
|
60
61
|
if (depth > GENUI_LIMITS.maxDepth) return null
|
|
62
|
+
|
|
61
63
|
switch (node.type) {
|
|
62
64
|
case 'text': {
|
|
63
65
|
const size = node.size ?? 'body'
|
|
@@ -143,6 +145,7 @@ export function renderNode(
|
|
|
143
145
|
? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{node.label}</a>
|
|
144
146
|
: <span key={key} className={css.linkText}>{node.label}</span>
|
|
145
147
|
}
|
|
148
|
+
case 'image': return <ImageNode key={`${key}:${node.src}`} node={node} />
|
|
146
149
|
case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
|
|
147
150
|
case 'video': return <VideoNode key={`${key}:${node.src}`} node={node} />
|
|
148
151
|
case 'badge': {
|
|
@@ -20,6 +20,8 @@ export interface GenuiBlockProps {
|
|
|
20
20
|
* v2.7: durable-state key (session + slot + content fingerprint). When set,
|
|
21
21
|
* interaction state (radio answers, submit lock, field values) persists to
|
|
22
22
|
* localStorage and restores on refresh / re-render of the same content.
|
|
23
|
+
* Changing this key also starts a fresh in-memory interaction-state lifetime;
|
|
24
|
+
* callers only provide stateKey — GenuiBlock owns the matching React identity.
|
|
23
25
|
*/
|
|
24
26
|
stateKey?: string | undefined
|
|
25
27
|
}
|
package/src/client/dom-fence.tsx
CHANGED
|
@@ -354,6 +354,8 @@ export function installDomFenceRenderer(
|
|
|
354
354
|
driftWarned = false
|
|
355
355
|
plausibilityWarned = false
|
|
356
356
|
const mounts = new Map<HTMLElement, Mount>()
|
|
357
|
+
let disposed = false
|
|
358
|
+
let rafId: number | null = null
|
|
357
359
|
|
|
358
360
|
const sessionIdOf = (): SessionId | undefined => {
|
|
359
361
|
try {
|
|
@@ -513,6 +515,7 @@ export function installDomFenceRenderer(
|
|
|
513
515
|
* the streaming→settled transition), repair surgery, then take over every
|
|
514
516
|
* new dsh-ui block — settled or still streaming. */
|
|
515
517
|
function sweep(): void {
|
|
518
|
+
if (disposed) return
|
|
516
519
|
for (const [block, mount] of mounts) {
|
|
517
520
|
if (!block.isConnected) {
|
|
518
521
|
unmountBlock(block)
|
|
@@ -605,12 +608,11 @@ export function installDomFenceRenderer(
|
|
|
605
608
|
}
|
|
606
609
|
}
|
|
607
610
|
|
|
608
|
-
let scheduled = false
|
|
609
611
|
const schedule = (): void => {
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
if (disposed || rafId !== null) return
|
|
613
|
+
rafId = requestAnimationFrame(() => {
|
|
614
|
+
rafId = null
|
|
615
|
+
if (disposed) return
|
|
614
616
|
sweep()
|
|
615
617
|
})
|
|
616
618
|
}
|
|
@@ -634,8 +636,13 @@ export function installDomFenceRenderer(
|
|
|
634
636
|
sweep()
|
|
635
637
|
|
|
636
638
|
return () => {
|
|
639
|
+
disposed = true
|
|
637
640
|
observer.disconnect()
|
|
638
641
|
window.clearInterval(interval)
|
|
642
|
+
if (rafId !== null) {
|
|
643
|
+
cancelAnimationFrame(rafId)
|
|
644
|
+
rafId = null
|
|
645
|
+
}
|
|
639
646
|
for (const block of Array.from(mounts.keys())) unmountBlock(block)
|
|
640
647
|
}
|
|
641
648
|
}
|