@changfenhuang/dsh-genui 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * GenuiBlock: renders a declarative GenUI spec (from a ```dsh-ui fence in an
3
+ * assistant reply) as real interactive components inline in the conversation.
4
+ * The component tree is white-listed and mapped to DOM directly — no raw HTML.
5
+ * The block shell holds the shared interaction state (answers registry,
6
+ * durable localStorage persistence, action debounce); the per-family
7
+ * components live in src/client/blocks/*.
8
+ */
9
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
10
+ import { useGenuiAction } from './action-context.ts'
11
+ import css from './GenuiBlock.module.css'
12
+ import { loadBlockState, saveBlockState } from './interaction-store.ts'
13
+ import { renderNode } from './blocks/render-node.tsx'
14
+ import type { AnswersState, GenuiBlockProps, QuestionMeta } from './blocks/state.ts'
15
+ import type { GenuiSpec } from './spec.ts'
16
+
17
+ export const GENUI_ACTION_DEBOUNCE_MS = 300
18
+
19
+ /**
20
+ * Wrap the harness action callback with the per-action trailing debounce.
21
+ * Absent provider = v1 behavior (components are display-only, callback
22
+ * stays undefined). Pending timers are cleared on unmount so a click that
23
+ * never fired does not leak into the next mount.
24
+ */
25
+ function useDebouncedAction(onAction: GenuiBlockProps['onAction'] | undefined): GenuiBlockProps['onAction'] {
26
+ const pending = useRef<Map<string, ReturnType<typeof setTimeout>> | null>(null)
27
+ useEffect(() => {
28
+ return () => {
29
+ const timers = pending.current
30
+ if (timers === null) return
31
+ for (const timer of timers.values()) clearTimeout(timer)
32
+ timers.clear()
33
+ }
34
+ }, [])
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])
48
+ }
49
+
50
+ /**
51
+ * Structural spec equality for the memo comparator: the fence path re-parses
52
+ * the body on every streaming chunk and produces a FRESH object even when the
53
+ * repaired content is unchanged (a chunk that closed no new component). The
54
+ * default shallow memo would then re-render the whole tree per chunk — up to
55
+ * ~200 full-tree renders for a max-size fence. Stringify equality makes the
56
+ * memo skip renders whose content did not actually change; the cost is one
57
+ * JSON.stringify per chunk (≤200 nodes, negligible next to a React tree
58
+ * reconciliation). `stateKey` already embeds the content fingerprint, so when
59
+ * both keys are equal and non-undefined the specs necessarily stringify
60
+ * equal — the stringify branch matters for the streaming path (stateKey
61
+ * undefined).
62
+ */
63
+ function specEquivalent(a: GenuiSpec, b: GenuiSpec): boolean {
64
+ if (a === b) return true
65
+ return JSON.stringify(a) === JSON.stringify(b)
66
+ }
67
+
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) {
73
+ const gap = spec.gap ?? 16
74
+ const onAction = useDebouncedAction(useGenuiAction())
75
+ // v2.5/v2.6 answers registry: grouped radios record selections + question
76
+ // metadata here; `submit` nodes grade locally (locked until 重新作答) or
77
+ // collect into one action. Block-local state survives re-renders (streaming
78
+ // settle, panel updates) — selections persist while the block is mounted.
79
+ // v2.7 durability: with a stateKey the state ALSO survives refresh/reopen —
80
+ // loaded once at mount (seed for re-renders of the same content) and saved
81
+ // on every change.
82
+ const [persisted] = useState(() => (stateKey === undefined ? null : loadBlockState(stateKey)))
83
+ const [answers, setAnswers] = useState<Record<string, string>>(persisted?.answers ?? {})
84
+ const [fields, setFields] = useState<Record<string, string>>(persisted?.fields ?? {})
85
+ const [meta, setMeta] = useState<Record<string, QuestionMeta>>({})
86
+ const [locked, setLocked] = useState(persisted?.locked === true)
87
+ const [round, setRound] = useState(0)
88
+ // Secret (password) field ids: their values never persist and never join
89
+ // submit collection — the input itself stays masked and its own action
90
+ // still delivers the value on explicit user submit.
91
+ const [secretFields, setSecretFields] = useState<ReadonlySet<string>>(new Set())
92
+ const setAnswer = useCallback((group: string, choice: string) => {
93
+ setAnswers(prev => (prev[group] === choice ? prev : { ...prev, [group]: choice }))
94
+ }, [])
95
+ const setField = useCallback((id: string, value: string) => {
96
+ // Field invariant: a blank (trim-empty) value leaves the shared registry.
97
+ setFields(prev => {
98
+ if (value.trim() === '') {
99
+ if (!(id in prev)) return prev
100
+ const next = { ...prev }
101
+ delete next[id]
102
+ return next
103
+ }
104
+ return prev[id] === value ? prev : { ...prev, [id]: value }
105
+ })
106
+ }, [])
107
+ const registerSecretField = useCallback((id: string) => {
108
+ setSecretFields(prev => (prev.has(id) ? prev : new Set(prev).add(id)))
109
+ }, [])
110
+ const registerMeta = useCallback((group: string, m: QuestionMeta) => {
111
+ setMeta(prev => {
112
+ const existing = prev[group]
113
+ if (existing !== undefined && existing.label === m.label && existing.answer === m.answer
114
+ && existing.explanation === m.explanation) return prev
115
+ return { ...prev, [group]: m }
116
+ })
117
+ }, [])
118
+ const clear = useCallback(() => {
119
+ setAnswers({})
120
+ setLocked(false)
121
+ setRound(r => r + 1) // radios remount (key carries the round) with clean selections
122
+ }, [])
123
+ const answersState = useMemo<AnswersState>(
124
+ () => ({
125
+ answers, fields, secretFields, meta, locked, round,
126
+ setAnswer, setField, registerSecretField, registerMeta, clear, setLocked,
127
+ }),
128
+ [answers, fields, secretFields, meta, locked, round, setAnswer, setField, registerSecretField, registerMeta, clear],
129
+ )
130
+ // Durable save (debounced 300ms — typing in a field fires per keystroke).
131
+ // Secret field values are stripped before writing: passwords never persist.
132
+ useEffect(() => {
133
+ if (stateKey === undefined) return
134
+ const timer = setTimeout(() => {
135
+ const safeFields = Object.fromEntries(
136
+ Object.entries(fields).filter(([id]) => !secretFields.has(id)),
137
+ )
138
+ saveBlockState(stateKey, {
139
+ answers,
140
+ locked,
141
+ ...(Object.keys(safeFields).length > 0 ? { fields: safeFields } : {}),
142
+ })
143
+ }, 300)
144
+ return () => clearTimeout(timer)
145
+ }, [stateKey, answers, locked, fields, secretFields])
146
+ return (
147
+ <div className={css.block} data-genui>
148
+ {spec.title !== undefined && <div className={css.banner}>{spec.title}</div>}
149
+ <div className={css.col} style={{ gap: `${gap}px` }}>
150
+ {spec.items.map((c, i) => (
151
+ // Staggered reveal: each root item fades/slides in after its
152
+ // predecessors, so the block assembles piece by piece instead of
153
+ // popping in as one slab. Delay capped so long specs still settle
154
+ // quickly; prefers-reduced-motion disables it (see CSS).
155
+ <div
156
+ key={i}
157
+ className={css.reveal}
158
+ style={{ animationDelay: `${Math.min(i * 90, 720)}ms` }}
159
+ >
160
+ {renderNode(c, i, onAction, 0, answersState)}
161
+ </div>
162
+ ))}
163
+ </div>
164
+ </div>
165
+ )
166
+ }, (prev, next) => prev.stateKey === next.stateKey && specEquivalent(prev.spec, next.spec))
@@ -0,0 +1,155 @@
1
+ /* PlotBlock: SVG function plot inside a genui block. Design system v2
2
+ (0.5.0): same token vocabulary as GenuiBlock.module.css — this root
3
+ carries its own copy because the plot can also mount standalone. */
4
+
5
+ .block {
6
+ --dsl-g-radius-surface: 12px;
7
+ --dsl-g-radius-control: 8px;
8
+
9
+ --dsl-g-font-title: 13px;
10
+ --dsl-g-font-meta: 12px;
11
+ --dsl-g-font-data: 11px;
12
+
13
+ --dsl-g-gap-md: 12px;
14
+ --dsl-g-gap-sm: 8px;
15
+ --dsl-g-gap-xs: 4px;
16
+
17
+ --dsl-g-border: var(--dsw-alias-border-l1, var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12)));
18
+ --dsl-g-accent: var(--dsw-alias-state-business-primary, #4f8ef7);
19
+ --dsl-g-accent-soft: color-mix(in srgb, var(--dsl-g-accent) var(--dsl-g-tint), transparent);
20
+ --dsl-g-tint: 8%;
21
+ --dsl-g-tint-strong: 14%;
22
+ --dsl-g-font-mono: var(--ds-font-family-code, ui-monospace, 'SF Mono', monospace);
23
+
24
+ padding: 14px 16px;
25
+ background: var(--dsw-alias-bg-layer-1, var(--dsw-alias-markdown-code-block));
26
+ border: 1px solid var(--dsl-g-border);
27
+ border-radius: var(--dsl-g-radius-surface);
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: var(--dsl-g-gap-sm);
31
+ }
32
+
33
+ .title {
34
+ font-size: var(--dsl-g-font-title);
35
+ font-weight: 600;
36
+ letter-spacing: 0.01em;
37
+ color: var(--dsw-alias-label-primary);
38
+ }
39
+
40
+ .gridLine {
41
+ stroke: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.07));
42
+ stroke-width: 1;
43
+ }
44
+
45
+ .axis {
46
+ stroke: var(--dsw-alias-label-tertiary);
47
+ stroke-width: 1;
48
+ }
49
+
50
+ .tick {
51
+ fill: var(--dsw-alias-label-tertiary);
52
+ font-size: var(--dsl-g-font-data);
53
+ font-family: var(--dsl-g-font-mono);
54
+ }
55
+
56
+ .line {
57
+ fill: none;
58
+ stroke: var(--dsl-g-accent);
59
+ stroke-width: 2;
60
+ stroke-linejoin: round;
61
+ stroke-linecap: round;
62
+ }
63
+
64
+ /* v2.9 draw shapes */
65
+ .area {
66
+ stroke: none;
67
+ fill-opacity: 0.16;
68
+ }
69
+ .scatterDot {
70
+ stroke: var(--dsw-alias-bg-base, #17171a);
71
+ stroke-width: 1;
72
+ }
73
+
74
+ .legend {
75
+ display: flex;
76
+ flex-wrap: wrap;
77
+ gap: var(--dsl-g-gap-md);
78
+ }
79
+
80
+ .legendItem {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: var(--dsl-g-gap-xs);
84
+ font-size: var(--dsl-g-font-meta);
85
+ color: var(--dsw-alias-label-secondary);
86
+ }
87
+
88
+ .legendSwatch {
89
+ width: 10px;
90
+ height: 3px;
91
+ border-radius: 2px;
92
+ background: var(--dsl-g-accent);
93
+ }
94
+
95
+ .empty {
96
+ padding: 14px 0;
97
+ color: var(--dsw-alias-label-tertiary);
98
+ font-size: var(--dsl-g-font-title);
99
+ display: flex;
100
+ flex-direction: column;
101
+ gap: var(--dsl-g-gap-xs);
102
+ }
103
+
104
+ .emptyRow {
105
+ font-family: var(--dsl-g-font-mono);
106
+ }
107
+
108
+ .surface { cursor: grab; touch-action: none; }
109
+ .surface:active { cursor: grabbing; }
110
+
111
+ .sliders { display: flex; flex-direction: column; gap: var(--dsl-g-gap-sm); }
112
+ .sliderRow { display: flex; align-items: center; gap: var(--dsl-g-gap-sm); font-size: var(--dsl-g-font-meta); }
113
+ .sliderName { color: var(--dsw-alias-label-secondary); min-width: 90px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
114
+ .slider { flex: 1; accent-color: var(--dsl-g-accent); height: 4px; cursor: pointer; }
115
+ .sliderValue { color: var(--dsw-alias-label-primary); font-family: var(--dsl-g-font-mono); min-width: 34px; text-align: right; }
116
+
117
+ /* ---------- animation bar ---------- */
118
+
119
+ .animBar { display: flex; align-items: center; gap: var(--dsl-g-gap-md); }
120
+ .playBtn {
121
+ width: fit-content;
122
+ background: var(--dsl-g-accent-soft);
123
+ border: 1px solid color-mix(in srgb, var(--dsl-g-accent) 30%, transparent);
124
+ color: var(--dsl-g-accent);
125
+ border-radius: var(--dsl-g-radius-control);
126
+ padding: 6px 14px;
127
+ font-size: var(--dsl-g-font-title);
128
+ font-weight: 600;
129
+ font-family: inherit;
130
+ cursor: pointer;
131
+ }
132
+ .playBtn:hover { filter: brightness(1.1); }
133
+ .animTrack {
134
+ flex: 1;
135
+ height: 5px;
136
+ border-radius: 999px;
137
+ background: var(--dsw-alias-bg-layer-1);
138
+ box-shadow: inset 0 0 0 1px var(--dsl-g-border);
139
+ overflow: hidden;
140
+ }
141
+ .animFill { height: 100%; border-radius: 999px; background: var(--dsl-g-accent); }
142
+ .resetBtn {
143
+ width: fit-content;
144
+ background: transparent;
145
+ border: 1px solid var(--dsl-g-border);
146
+ color: var(--dsw-alias-label-secondary);
147
+ border-radius: var(--dsl-g-radius-control);
148
+ padding: 6px 14px;
149
+ font-size: var(--dsl-g-font-title);
150
+ font-family: inherit;
151
+ cursor: pointer;
152
+ }
153
+ .resetBtn:hover { border-color: var(--dsl-g-accent); color: var(--dsl-g-accent); }
154
+ .slidersHead { display: flex; align-items: center; justify-content: space-between; }
155
+ .slidersTitle { font-size: var(--dsl-g-font-meta); color: var(--dsw-alias-label-tertiary); }