@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +7 -18
  3. package/README.zh-CN.md +13 -18
  4. package/SKILL.md +4 -2
  5. package/lib/assets/three.js +1 -1
  6. package/lib/client.js +3 -3
  7. package/lib/index.js +273 -61
  8. package/lib/types/client/GenuiBlock.d.ts +5 -2
  9. package/lib/types/client/TemplateDrawer.d.ts +7 -0
  10. package/lib/types/client/achievement-store.d.ts +35 -0
  11. package/lib/types/client/achievement-toast.d.ts +2 -0
  12. package/lib/types/client/achievements.d.ts +48 -0
  13. package/lib/types/client/blocks/advanced.d.ts +1 -1
  14. package/lib/types/client/blocks/image.d.ts +6 -0
  15. package/lib/types/client/blocks/state.d.ts +2 -0
  16. package/lib/types/client/fence-render.d.ts +3 -0
  17. package/lib/types/client/gallery.d.ts +3 -5
  18. package/lib/types/client/guard.d.ts +10 -5
  19. package/lib/types/client/panel.d.ts +4 -1
  20. package/lib/types/client/spec.d.ts +8 -1
  21. package/lib/types/client/templates.d.ts +22 -0
  22. package/lib/types/plugin/chart-contract.d.ts +7 -0
  23. package/lib/types/plugin/index.d.ts +1 -1
  24. package/package.json +9 -8
  25. package/src/client/GenuiBlock.module.css +141 -10
  26. package/src/client/GenuiBlock.tsx +53 -25
  27. package/src/client/PlotBlock.tsx +25 -10
  28. package/src/client/TemplateDrawer.module.css +152 -0
  29. package/src/client/TemplateDrawer.tsx +137 -0
  30. package/src/client/achievement-store.ts +151 -0
  31. package/src/client/achievement-toast.tsx +68 -0
  32. package/src/client/achievements.ts +111 -0
  33. package/src/client/blocks/advanced.tsx +34 -25
  34. package/src/client/blocks/charts.tsx +4 -4
  35. package/src/client/blocks/image.tsx +25 -0
  36. package/src/client/blocks/render-node.tsx +3 -0
  37. package/src/client/blocks/state.ts +2 -0
  38. package/src/client/dom-fence.tsx +12 -5
  39. package/src/client/fence-render.tsx +35 -11
  40. package/src/client/gallery.ts +4 -5
  41. package/src/client/guard.ts +125 -29
  42. package/src/client/index.tsx +13 -0
  43. package/src/client/panel.tsx +109 -10
  44. package/src/client/safe-math.ts +29 -1
  45. package/src/client/scene3d-core.ts +5 -2
  46. package/src/client/spec.ts +9 -1
  47. package/src/client/templates.ts +280 -0
  48. package/src/plugin/chart-contract.ts +103 -0
  49. package/src/plugin/index.ts +48 -2
  50. 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
+ */
@@ -0,0 +1,6 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { GenuiImage } from '../spec.ts';
3
+ /** Native image display; source safety is enforced by repairGenuiSpec/safeMediaSrc. */
4
+ export declare function ImageNode({ node }: {
5
+ node: GenuiImage;
6
+ }): ReactNode;
@@ -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: one spec exercising every white-listed node type, used as
3
- * the canonical full-vocabulary sample (tests render it and assert every
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
- /** A single spec covering all 40 node types in the vocabulary. */
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 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
@@ -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 (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.3",
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
+ }
@@ -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
- /* ── design tokens (design system v2) ── */
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
- .btnSent { animation: genuiPopIn 0.2s ease both; }
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
- margin: 10px 14px 2px;
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: 14px;
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: var(--dsl-g-gap-sm);
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-secondary, rgba(255, 255, 255, 0.55));
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<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
+
27
33
  useEffect(() => {
28
34
  return () => {
29
- const timers = pending.current
30
- if (timers === null) return
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
- return useMemo(() => {
36
- if (onAction === undefined) return undefined
37
- const timers = new Map<string, ReturnType<typeof setTimeout>>()
38
- pending.current = timers
39
- return (action: string, payload: Record<string, unknown>): void => {
40
- const existing = timers.get(action)
41
- if (existing !== undefined) clearTimeout(existing)
42
- timers.set(action, setTimeout(() => {
43
- timers.delete(action)
44
- onAction(action, payload)
45
- }, GENUI_ACTION_DEBOUNCE_MS))
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
- * Render a GenUI spec as an inline block. Falls back to nothing when the spec
70
- * carries no items (the fence renderer already refused non-specs before us).
71
- */
72
- 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) {
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, onAction, 0, answersState)}
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))