@changfenhuang/dsh-genui 0.9.3 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +7 -18
- package/README.zh-CN.md +13 -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/TemplateDrawer.d.ts +7 -0
- package/lib/types/client/achievement-store.d.ts +35 -0
- package/lib/types/client/achievement-toast.d.ts +2 -0
- package/lib/types/client/achievements.d.ts +48 -0
- 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/panel.d.ts +4 -1
- package/lib/types/client/spec.d.ts +8 -1
- package/lib/types/client/templates.d.ts +22 -0
- package/lib/types/plugin/chart-contract.d.ts +7 -0
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +9 -8
- package/src/client/GenuiBlock.module.css +141 -10
- package/src/client/GenuiBlock.tsx +53 -25
- package/src/client/PlotBlock.tsx +25 -10
- package/src/client/TemplateDrawer.module.css +152 -0
- package/src/client/TemplateDrawer.tsx +137 -0
- package/src/client/achievement-store.ts +151 -0
- package/src/client/achievement-toast.tsx +68 -0
- package/src/client/achievements.ts +111 -0
- 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/index.tsx +13 -0
- package/src/client/panel.tsx +109 -10
- 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/client/templates.ts +280 -0
- package/src/plugin/chart-contract.ts +103 -0
- package/src/plugin/index.ts +48 -2
- package/src/plugin/tool.ts +25 -4
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* achievements.ts — GenUI 探索成就(0.9.5)。
|
|
3
|
+
*
|
|
4
|
+
* 轻量本地成就:只统计「使用事件计数」(渲染过的 fence、面板出现、
|
|
5
|
+
* 交互回传、模板试用),绝不读消息/内容;状态存 localStorage,跨会话
|
|
6
|
+
* 保留。解锁时进 toast 队列(achievement-toast 消费),面板「成就」
|
|
7
|
+
* tab 用 dsh-ui 渲染自己的成就页(dogfooding)。
|
|
8
|
+
*
|
|
9
|
+
* 设计与 dsh-achievements 的分层快照思路一致,但零宿主改动:埋点全部
|
|
10
|
+
* 在本包的渲染/交互路径上(GenuiBlock/TemplateDrawer/GenuiPanel)。
|
|
11
|
+
*/
|
|
12
|
+
import type { GenuiSpec } from './spec.ts';
|
|
13
|
+
/** 累积使用计数(成就的输入)。 */
|
|
14
|
+
export interface AchieveState {
|
|
15
|
+
/** 渲染过的不重复 fence 数。 */
|
|
16
|
+
fences: number;
|
|
17
|
+
/** 面板(panel dock)出现过的会话数。 */
|
|
18
|
+
panels: number;
|
|
19
|
+
/** 交互组件动作回传次数(去抖后)。 */
|
|
20
|
+
interactions: number;
|
|
21
|
+
/** 模板试用次数。 */
|
|
22
|
+
templates: number;
|
|
23
|
+
/** 图表节点(chart/plot/echart)出现过的 fence 数。 */
|
|
24
|
+
charts: number;
|
|
25
|
+
/** 高级节点(scene3d/mermaid/diagram)出现过的 fence 数。 */
|
|
26
|
+
advanced: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function emptyState(): AchieveState;
|
|
29
|
+
export interface AchievementDef {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
/** 解锁前隐藏名称/描述(彩蛋)。 */
|
|
34
|
+
hidden?: boolean;
|
|
35
|
+
/** 稀有度。 */
|
|
36
|
+
rarity: 'common' | 'rare' | 'legendary';
|
|
37
|
+
check: (s: AchieveState) => boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare const ACHIEVEMENTS: readonly AchievementDef[];
|
|
40
|
+
/** 统计一个 spec 里出现的相关节点类别(与 guard 同口径遍历)。 */
|
|
41
|
+
export declare function countSpecKinds(spec: GenuiSpec): {
|
|
42
|
+
charts: number;
|
|
43
|
+
advanced: number;
|
|
44
|
+
};
|
|
45
|
+
/** 生成成就页 spec(dsh-ui 渲染):进度 stat + 解锁列表 + 稀有度徽标。 */
|
|
46
|
+
export declare function buildAchievementsSpec(state: AchieveState, unlocked: Record<string, number>): GenuiSpec;
|
|
47
|
+
/** 检查给定状态下的新解锁(不受 hidden 限制——规则只管阈值)。 */
|
|
48
|
+
export declare function checkAchievements(state: AchieveState, unlocked: Record<string, number>): AchievementDef[];
|
|
@@ -101,4 +101,4 @@ export declare const BreadcrumbNode: import("react").NamedExoticComponent<{
|
|
|
101
101
|
* names stay independent. The model round-trip takes seconds, so a few
|
|
102
102
|
* hundred ms of trailing delay is imperceptible — and it stops bursts of
|
|
103
103
|
* queued user turns.
|
|
104
|
-
*/
|
|
104
|
+
*/
|
|
@@ -19,6 +19,8 @@ export interface GenuiBlockProps {
|
|
|
19
19
|
* v2.7: durable-state key (session + slot + content fingerprint). When set,
|
|
20
20
|
* interaction state (radio answers, submit lock, field values) persists to
|
|
21
21
|
* localStorage and restores on refresh / re-render of the same content.
|
|
22
|
+
* Changing this key also starts a fresh in-memory interaction-state lifetime;
|
|
23
|
+
* callers only provide stateKey — GenuiBlock owns the matching React identity.
|
|
22
24
|
*/
|
|
23
25
|
stateKey?: string | undefined;
|
|
24
26
|
}
|
|
@@ -39,6 +39,9 @@ export interface GenuiFenceContext {
|
|
|
39
39
|
* - Tier-2 completion (missing quotes/brackets): settled renders only —
|
|
40
40
|
* `context.source` exists exclusively once the message finished, so
|
|
41
41
|
* streaming halves are never completed early.
|
|
42
|
+
* - Native chart semantics are checked before repair on every candidate so
|
|
43
|
+
* aliases, empty collections, or line/donut series cannot repair into a
|
|
44
|
+
* default/blank chart.
|
|
42
45
|
*/
|
|
43
46
|
export declare function resolveGenuiSpec(raw: string, context?: GenuiFenceContext): GenuiSpec | null;
|
|
44
47
|
/**
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GenUI gallery:
|
|
3
|
-
*
|
|
4
|
-
* component family appears; docs and demos reuse it as the "show everything"
|
|
5
|
-
* fence).
|
|
2
|
+
* GenUI gallery: canonical broad-coverage sample of built-in node types.
|
|
3
|
+
* Docs, demos, and renderer tests reuse it as the component gallery fence.
|
|
6
4
|
*/
|
|
7
5
|
import type { GenuiSpec } from './spec.ts';
|
|
8
|
-
/**
|
|
6
|
+
/** Canonical gallery spec for the built-in GenUI component vocabulary. */
|
|
9
7
|
export declare const gallerySpec: GenuiSpec;
|
|
@@ -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
|
|
@@ -4,6 +4,9 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
|
4
4
|
export interface GenuiPanelInjected {
|
|
5
5
|
sessionId: string;
|
|
6
6
|
sendGenuiAction: GenuiActionHandler;
|
|
7
|
+
/** Template center "try it": insert the template instruction into the
|
|
8
|
+
* current composer draft (standard conversation.input.for channel). */
|
|
9
|
+
insertTemplate: (text: string) => void;
|
|
7
10
|
}
|
|
8
11
|
export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPanelInjected;
|
|
9
12
|
/**
|
|
@@ -12,4 +15,4 @@ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPan
|
|
|
12
15
|
* default so the dock never steals the message flow's scroll room; the
|
|
13
16
|
* header always shows the current panel title.
|
|
14
17
|
*/
|
|
15
|
-
export declare function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps): import("react").JSX.Element | null;
|
|
18
|
+
export declare function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: GenuiPanelProps): import("react").JSX.Element | null;
|
|
@@ -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,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* templates.ts — GenUI 模板中心数据(0.9.4)。
|
|
3
|
+
*
|
|
4
|
+
* 每个模板 = 一段自然语言 `instruction`(试用时插入输入框,模型按 genui
|
|
5
|
+
* skill 生成对应界面)+ 一份**合法**的 `demo` spec(预览时由 GenuiBlock
|
|
6
|
+
* 直接渲染,用户先看到效果再决定试用)。字段对齐 spec.ts / guard.ts 的
|
|
7
|
+
* 实际 schema(tests/templates.spec.ts 用 validateGenuiSpec 逐条校验)。
|
|
8
|
+
*
|
|
9
|
+
* 模板即说明书:覆盖布局/数据/图表/交互/测验/高级各能力面。
|
|
10
|
+
*/
|
|
11
|
+
import type { GenuiSpec } from './spec.ts';
|
|
12
|
+
export interface GenuiTemplate {
|
|
13
|
+
id: string;
|
|
14
|
+
category: '仪表盘' | '数据' | '流程' | '图表' | '交互' | '测验' | '高级';
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
/** 试用时插入输入框的自然语言指令。 */
|
|
18
|
+
instruction: string;
|
|
19
|
+
/** 预览用的合法 spec(≤200 节点、嵌套 ≤8 层)。 */
|
|
20
|
+
demo: GenuiSpec;
|
|
21
|
+
}
|
|
22
|
+
export declare const GENUI_TEMPLATES: readonly GenuiTemplate[];
|
|
@@ -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.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
|
-
"
|
|
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
|
+
}
|
|
@@ -16,8 +16,13 @@
|
|
|
16
16
|
literals here are only last-resort fallbacks for hosts without the token
|
|
17
17
|
sheets. */
|
|
18
18
|
|
|
19
|
-
.block
|
|
20
|
-
|
|
19
|
+
.block,
|
|
20
|
+
.panel {
|
|
21
|
+
/* ── design tokens (design system v2) ──
|
|
22
|
+
Declared on both the inline fence root and the session panel root: the
|
|
23
|
+
panel header sits OUTSIDE the inner .block, so without the shared
|
|
24
|
+
declaration its --dsl-g-* references resolved to undefined (the badge
|
|
25
|
+
gap/padding collapsed to zero). */
|
|
21
26
|
--dsl-g-radius-surface: 12px;
|
|
22
27
|
--dsl-g-radius-control: 8px;
|
|
23
28
|
--dsl-g-radius-pill: 999px;
|
|
@@ -46,7 +51,9 @@
|
|
|
46
51
|
--dsl-g-tint: 8%;
|
|
47
52
|
--dsl-g-tint-strong: 14%;
|
|
48
53
|
--dsl-g-font-mono: var(--ds-font-family-code, ui-monospace, 'SF Mono', monospace);
|
|
54
|
+
}
|
|
49
55
|
|
|
56
|
+
.block {
|
|
50
57
|
/* Inline in the conversation: no canvas, no border, no padding — the
|
|
51
58
|
components themselves (card/quiz/plot/…) carry the surfaces. A slab
|
|
52
59
|
around every fence was tried and rejected: a lone stat or text node
|
|
@@ -343,7 +350,6 @@
|
|
|
343
350
|
line-height: 1.5;
|
|
344
351
|
}
|
|
345
352
|
.table tr:last-child td { border-bottom: none; }
|
|
346
|
-
.table tbody tr { transition: background 0.12s; }
|
|
347
353
|
.table tbody tr:hover { background: var(--dsl-g-hover); }
|
|
348
354
|
/* Numeric columns (auto-detected) right-align with tabular numerals — the
|
|
349
355
|
table's data voice. */
|
|
@@ -956,8 +962,10 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
|
|
|
956
962
|
}
|
|
957
963
|
|
|
958
964
|
/* "✓ 已触发" confirmation chip: appears next to the button without layout
|
|
959
|
-
shift risk (it inherits the flex row's gap).
|
|
960
|
-
|
|
965
|
+
shift risk (it inherits the flex row's gap). `backwards`, not `both`: the
|
|
966
|
+
chip's natural (post-animation) style is already visible/no-transform, so
|
|
967
|
+
pinning the end keyframe would only retain an unnecessary transform. */
|
|
968
|
+
.btnSent { animation: genuiPopIn 0.2s ease backwards; }
|
|
961
969
|
|
|
962
970
|
/* 尊重用户的减弱动效偏好 */
|
|
963
971
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -1149,9 +1157,16 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
|
|
|
1149
1157
|
.panel {
|
|
1150
1158
|
/* Distinct from ordinary inline fences: accent border + badge header so a
|
|
1151
1159
|
session panel is recognizable at a glance. */
|
|
1152
|
-
|
|
1160
|
+
/* Align with the host's composer width axis (same geometry as the TodoDock
|
|
1161
|
+
cards it shares conversation.input.dock with): centered, inset by the
|
|
1162
|
+
side clearance and the dock inset chain, capped at the composer card
|
|
1163
|
+
width. Falls back to safe literals when the host does not define the
|
|
1164
|
+
--dsh-composer-* tokens. */
|
|
1165
|
+
margin: 10px auto 2px;
|
|
1166
|
+
width: calc(100% - var(--dsh-composer-side-clearance, 16px) * 2 - var(--dsh-composer-dock-inset, 8px) * 4);
|
|
1167
|
+
max-width: calc(var(--dsh-composer-card-max-width, 780px) - var(--dsh-composer-dock-inset, 8px) * 4);
|
|
1153
1168
|
border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 35%, transparent);
|
|
1154
|
-
border-radius:
|
|
1169
|
+
border-radius: 12px;
|
|
1155
1170
|
background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.04));
|
|
1156
1171
|
overflow: hidden;
|
|
1157
1172
|
}
|
|
@@ -1159,12 +1174,18 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
|
|
|
1159
1174
|
.panelHeader {
|
|
1160
1175
|
display: flex;
|
|
1161
1176
|
align-items: center;
|
|
1177
|
+
/* Header/body divider so the panel reads as a card with a title bar, not a
|
|
1178
|
+
slab with text glued to it (user report: "body 缺少内边框"). */
|
|
1179
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, 0.25));
|
|
1162
1180
|
}
|
|
1163
1181
|
|
|
1164
1182
|
.panelToggle {
|
|
1165
1183
|
display: flex;
|
|
1166
1184
|
align-items: center;
|
|
1167
|
-
gap:
|
|
1185
|
+
/* 10px matches the TodoDock header rhythm (gap: 10px) — the badge and the
|
|
1186
|
+
title no longer collapse together once the shared --dsl-g-gap-sm token
|
|
1187
|
+
reaches this element. */
|
|
1188
|
+
gap: 10px;
|
|
1168
1189
|
flex: 1 1 auto;
|
|
1169
1190
|
min-width: 0;
|
|
1170
1191
|
padding: 9px 14px;
|
|
@@ -1211,6 +1232,112 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
|
|
|
1211
1232
|
opacity: 1;
|
|
1212
1233
|
}
|
|
1213
1234
|
|
|
1235
|
+
/* ---- 0.9.4 template center & onboarding ---- */
|
|
1236
|
+
.panelTpl {
|
|
1237
|
+
flex: 0 0 auto;
|
|
1238
|
+
height: 26px;
|
|
1239
|
+
margin-right: 4px;
|
|
1240
|
+
padding: 0 10px;
|
|
1241
|
+
display: flex;
|
|
1242
|
+
align-items: center;
|
|
1243
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
1244
|
+
border-radius: 8px;
|
|
1245
|
+
background: transparent;
|
|
1246
|
+
color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
|
|
1247
|
+
font-size: 12px;
|
|
1248
|
+
line-height: 1.5;
|
|
1249
|
+
cursor: pointer;
|
|
1250
|
+
opacity: 0.85;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
.panelTpl:hover {
|
|
1254
|
+
background: rgba(255, 255, 255, 0.08);
|
|
1255
|
+
opacity: 1;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
.panelTplActive {
|
|
1259
|
+
border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
1260
|
+
color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
.panelOnboard .panelHeader {
|
|
1264
|
+
cursor: default;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
.panelOnboardText {
|
|
1268
|
+
flex: 1;
|
|
1269
|
+
min-width: 0;
|
|
1270
|
+
padding: 0 8px;
|
|
1271
|
+
font-size: 12px;
|
|
1272
|
+
line-height: 1.5;
|
|
1273
|
+
color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
.panelFlash {
|
|
1277
|
+
padding: 6px 12px;
|
|
1278
|
+
border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
1279
|
+
font-size: 12px;
|
|
1280
|
+
line-height: 1.5;
|
|
1281
|
+
color: var(--dsw-alias-state-success-primary, #6ccb5f);
|
|
1282
|
+
background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #6ccb5f) 8%, transparent);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.panelHint {
|
|
1286
|
+
padding: 6px 14px;
|
|
1287
|
+
border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
1288
|
+
font-size: 12px;
|
|
1289
|
+
line-height: 1.5;
|
|
1290
|
+
color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
1291
|
+
background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 8%, transparent);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/* ---- 0.9.5 achievement toasts ---- */
|
|
1295
|
+
.achToasts {
|
|
1296
|
+
position: fixed;
|
|
1297
|
+
right: 16px;
|
|
1298
|
+
bottom: 16px;
|
|
1299
|
+
display: flex;
|
|
1300
|
+
flex-direction: column;
|
|
1301
|
+
gap: 8px;
|
|
1302
|
+
z-index: 9999;
|
|
1303
|
+
pointer-events: none;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
.achToast {
|
|
1307
|
+
display: flex;
|
|
1308
|
+
align-items: flex-start;
|
|
1309
|
+
gap: 10px;
|
|
1310
|
+
width: 300px;
|
|
1311
|
+
padding: 10px 12px;
|
|
1312
|
+
border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 35%, transparent);
|
|
1313
|
+
border-radius: var(--dsl-g-radius-surface, 12px);
|
|
1314
|
+
background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.85));
|
|
1315
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
.achToastBadge {
|
|
1319
|
+
flex: none;
|
|
1320
|
+
font-size: 18px;
|
|
1321
|
+
line-height: 1.2;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
.achToastBody {
|
|
1325
|
+
min-width: 0;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
.achToastName {
|
|
1329
|
+
font-size: 13px;
|
|
1330
|
+
font-weight: 600;
|
|
1331
|
+
color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
.achToastDesc {
|
|
1335
|
+
margin-top: 2px;
|
|
1336
|
+
font-size: 12px;
|
|
1337
|
+
line-height: 1.5;
|
|
1338
|
+
color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1214
1341
|
.panelBadge {
|
|
1215
1342
|
flex: none;
|
|
1216
1343
|
font-size: 11px;
|
|
@@ -1235,9 +1362,13 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
|
|
|
1235
1362
|
}
|
|
1236
1363
|
|
|
1237
1364
|
.panelChevron {
|
|
1365
|
+
/* Same glyph cell + color as the TodoDock header chevron (grid-centered,
|
|
1366
|
+
label-tertiary), so the arrow reads as a host control, not typed text. */
|
|
1367
|
+
display: grid;
|
|
1368
|
+
place-items: center;
|
|
1369
|
+
flex: none;
|
|
1238
1370
|
margin-left: auto;
|
|
1239
|
-
color: var(--dsw-alias-label-
|
|
1240
|
-
font-size: var(--dsl-g-font-meta);
|
|
1371
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1241
1372
|
}
|
|
1242
1373
|
|
|
1243
1374
|
.panelBody {
|
|
@@ -10,6 +10,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
10
10
|
import { useGenuiAction } from './action-context.ts'
|
|
11
11
|
import css from './GenuiBlock.module.css'
|
|
12
12
|
import { loadBlockState, saveBlockState } from './interaction-store.ts'
|
|
13
|
+
import { recordFence, recordInteraction } from './achievement-store.ts'
|
|
13
14
|
import { renderNode } from './blocks/render-node.tsx'
|
|
14
15
|
import type { AnswersState, GenuiBlockProps, QuestionMeta } from './blocks/state.ts'
|
|
15
16
|
import type { GenuiSpec } from './spec.ts'
|
|
@@ -20,31 +21,32 @@ export const GENUI_ACTION_DEBOUNCE_MS = 300
|
|
|
20
21
|
* Wrap the harness action callback with the per-action trailing debounce.
|
|
21
22
|
* Absent provider = v1 behavior (components are display-only, callback
|
|
22
23
|
* stays undefined). Pending timers are cleared on unmount so a click that
|
|
23
|
-
* 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.
|
|
24
27
|
*/
|
|
25
28
|
function useDebouncedAction(onAction: GenuiBlockProps['onAction'] | undefined): GenuiBlockProps['onAction'] {
|
|
26
|
-
const pending = useRef
|
|
29
|
+
const pending = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
|
30
|
+
const actionRef = useRef(onAction)
|
|
31
|
+
actionRef.current = onAction
|
|
32
|
+
|
|
27
33
|
useEffect(() => {
|
|
28
34
|
return () => {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
for (const timer of timers.values()) clearTimeout(timer)
|
|
32
|
-
timers.clear()
|
|
35
|
+
for (const timer of pending.current.values()) clearTimeout(timer)
|
|
36
|
+
pending.current.clear()
|
|
33
37
|
}
|
|
34
38
|
}, [])
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}, [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
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -65,11 +67,11 @@ function specEquivalent(a: GenuiSpec, b: GenuiSpec): boolean {
|
|
|
65
67
|
return JSON.stringify(a) === JSON.stringify(b)
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*/
|
|
72
|
-
|
|
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) {
|
|
73
75
|
const gap = spec.gap ?? 16
|
|
74
76
|
const onAction = useDebouncedAction(useGenuiAction())
|
|
75
77
|
// v2.5/v2.6 answers registry: grouped radios record selections + question
|
|
@@ -127,6 +129,15 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
|
|
|
127
129
|
}),
|
|
128
130
|
[answers, fields, secretFields, meta, locked, round, setAnswer, setField, registerSecretField, registerMeta, clear],
|
|
129
131
|
)
|
|
132
|
+
// Achievement telemetry: every emitted action counts as one interaction
|
|
133
|
+
// (the debounced emit fires once per real user action).
|
|
134
|
+
const trackedAction = useMemo(() => {
|
|
135
|
+
if (onAction === undefined) return undefined
|
|
136
|
+
return (action: string, payload: Record<string, unknown>): void => {
|
|
137
|
+
recordInteraction()
|
|
138
|
+
onAction(action, payload)
|
|
139
|
+
}
|
|
140
|
+
}, [onAction])
|
|
130
141
|
// Durable save (debounced 300ms — typing in a field fires per keystroke).
|
|
131
142
|
// Secret field values are stripped before writing: passwords never persist.
|
|
132
143
|
useEffect(() => {
|
|
@@ -143,6 +154,11 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
|
|
|
143
154
|
}, 300)
|
|
144
155
|
return () => clearTimeout(timer)
|
|
145
156
|
}, [stateKey, answers, locked, fields, secretFields])
|
|
157
|
+
// Achievement telemetry (0.9.5): the store dedupes by spec fingerprint, so
|
|
158
|
+
// streaming re-renders and replays count once per distinct content.
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
recordFence(spec)
|
|
161
|
+
}, [spec])
|
|
146
162
|
return (
|
|
147
163
|
<div className={css.block} data-genui>
|
|
148
164
|
{spec.title !== undefined && <div className={css.banner}>{spec.title}</div>}
|
|
@@ -157,10 +173,22 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
|
|
|
157
173
|
className={css.reveal}
|
|
158
174
|
style={{ animationDelay: `${Math.min(i * 90, 720)}ms` }}
|
|
159
175
|
>
|
|
160
|
-
{renderNode(c, i,
|
|
176
|
+
{renderNode(c, i, trackedAction, 0, answersState)}
|
|
161
177
|
</div>
|
|
162
178
|
))}
|
|
163
179
|
</div>
|
|
164
180
|
</div>
|
|
165
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} />
|
|
166
194
|
}, (prev, next) => prev.stateKey === next.stateKey && specEquivalent(prev.spec, next.spec))
|