@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,641 @@
1
+ /**
2
+ * DOM render channel: pure-plugin fence rendering for pristine hosts.
3
+ *
4
+ * Stock DSH renders every fenced code block through the shared CodeBlock
5
+ * surface (stable class `md-code-block`, language label rendered as the
6
+ * banner's childless label div). This channel observes the conversation DOM,
7
+ * finds blocks labelled `dsh-ui`, parses the raw fence body and mounts the
8
+ * plugin's own React tree next to the (hidden) stock block:
9
+ *
10
+ * Fence discovery is **multi-surface** (issue #6): besides `md-code-block`,
11
+ * the channel also matches the deepsuite-style surfaces some host builds
12
+ * render instead (`.code-block` / `.code-block-small`), and — as the
13
+ * structural backstop — ANY element whose banner labels it `dsh-ui` and
14
+ * which contains a `<pre>` body. The only invariants are the language label
15
+ * (a leaf element with the exact text `dsh-ui`, outside the code body) and
16
+ * the `<pre>`, so a host DOM drift degrades to a rendered fence, never a
17
+ * silently skipped one:
18
+ *
19
+ * - **Streaming takeover**: the channel takes over a dsh-ui block as soon as
20
+ * ONE finished component parses (the partial parser), and re-renders the
21
+ * root as the body grows — the UI assembles top-down while the reply
22
+ * streams, no settled marker required. A body with no finished component
23
+ * yet stays a stock code block (partial JSON must never look broken).
24
+ * - **Pre-paint surgery repair**: the host's React re-renders during
25
+ * streaming can wipe our foreign container or reset the hide. A repair
26
+ * pass in the MutationObserver microtask re-applies the surgery before
27
+ * paint (same pattern the annotation plugin proved on this host), and the
28
+ * 1s sweep is the backstop.
29
+ * - **Settled transition**: when `[data-streaming]` leaves the row, the
30
+ * mount re-renders with the stable source identity — the moment panels
31
+ * publish and durable state keys in (mirrors the registry channel's
32
+ * settled-source semantics; streaming renders are identity-less).
33
+ * - Stable identity: the owning row's `data-chat-anchor-key` (session-stable,
34
+ * seq-derived) + the fence's ordinal among settled dsh-ui blocks in that
35
+ * row. `sourceId = dom:<anchor>:<ordinal>` feeds panel dedup and durable
36
+ * state.
37
+ * - Actions ride the plugin-owned GenuiActionContext provider: every tree
38
+ * this channel mounts is wrapped with a handler that relays
39
+ * `[genui-action]` through the scoped conversation send — no host plumbing.
40
+ * - Removal (branch switch, unload): each mount is unmounted with its root,
41
+ * and the stock block is restored.
42
+ *
43
+ * Security posture matches the registry channel: only code shipped in this
44
+ * plugin's browser bundle mounts React roots, the model can only author
45
+ * fence text, and unrepairable bodies stay stock code blocks.
46
+ */
47
+ import { Fragment, isValidElement, type Key, type ReactNode } from 'react'
48
+ import { createRoot, type Root } from 'react-dom/client'
49
+ import type { Context } from '@deepseek-ai/cordis'
50
+ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
51
+ import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
52
+ import { renderResolvedFenceNode, type GenuiFenceContext } from './fence-render.tsx'
53
+
54
+ /** Fence surfaces the channel can take over, newest host first: the shared
55
+ * CodeBlock surface every rc.6+ markdown fence renders through
56
+ * (`.md-code-block`) and the deepsuite-style surfaces some host builds
57
+ * render instead (`.code-block` / `.code-block-small`). Surfaces with an
58
+ * unlisted class are still found by the structural backstop (label + `<pre>`),
59
+ * so this list is an optimization, not a hard contract. */
60
+ const CODE_BLOCK_SELECTORS = '.md-code-block, .code-block, .code-block-small'
61
+ /** Marker attribute set on blocks this channel has taken over. */
62
+ const PROCESSED = 'data-genui-rendered'
63
+ /** The settled marker on AssistantMarkdown (absent = settled). */
64
+ const STREAMING = '[data-streaming]'
65
+ /** Container class for the plugin-owned root. */
66
+ const CONTAINER_CLASS = 'genui-dom-fence'
67
+ /** Slow sweep interval: the observer catches everything, this is the 1s
68
+ * belt-and-braces pass (history loads, missed attribute batches). */
69
+ const SWEEP_MS = 1000
70
+
71
+ /** Max ancestors walked from a `<pre>` to its fence surface root (banner +
72
+ * pre holder). Most hosts put the pre directly under the surface; some wrap
73
+ * it in a content div. */
74
+ const SURFACE_HOPS = 4
75
+
76
+ /** Block-level content that never belongs to a code-block surface: a real
77
+ * fence surface is "banner chrome + ONE code body". An element that also
78
+ * contains paragraphs/lists/headings/tables (or several `<pre>` bodies) is a
79
+ * message-level container, not a fence — taking it over would hide the whole
80
+ * final answer (issue #19's residual variant of the issue #13 class). */
81
+ const BLOCK_CONTENT_SELECTOR = 'p, ul, ol, dl, table, h1, h2, h3, h4, h5, h6, blockquote, hr, img, figure'
82
+
83
+ /** Does this element look like a single code-block surface rather than a
84
+ * message container? The only allowed non-code content is banner chrome. */
85
+ function isPlausibleFenceSurface(candidate: Element): boolean {
86
+ const pres = candidate.querySelectorAll('pre')
87
+ if (pres.length > 1) return false
88
+ const pre = pres[0] ?? null
89
+ for (const el of candidate.querySelectorAll(BLOCK_CONTENT_SELECTOR)) {
90
+ if (pre !== null && pre.contains(el)) continue
91
+ return false
92
+ }
93
+ return true
94
+ }
95
+
96
+ /** `renderResolvedFenceNode` returns a bare Fragment for `panel:true` fences
97
+ * (the publisher renders nothing); every inline fence mounts a real tree.
98
+ * The DOM channel uses this to tell an empty container that was WIPED by a
99
+ * host re-render apart from an intentionally empty panel root. */
100
+ function isPanelRoot(node: ReactNode): boolean {
101
+ return isValidElement(node) && node.type === Fragment
102
+ }
103
+
104
+ interface Mount {
105
+ root: Root
106
+ container: HTMLElement
107
+ block: HTMLElement
108
+ lastRaw: string
109
+ lastSettled: boolean
110
+ lastNode: ReactNode
111
+ }
112
+
113
+ function isTextNode(node: Node): node is Text {
114
+ return node.nodeType === Node.TEXT_NODE
115
+ }
116
+
117
+ /** The banner's language label: a leaf element whose text is exactly the
118
+ * lang. CodeBlock renders the label as a childless div; deepsuite-style
119
+ * surfaces use a span; the ONLY structural invariants across hosts are "a
120
+ * leaf element holds exactly the lang text" and "it lives outside the code
121
+ * body" — a fence whose code literally contains the text `dsh-ui` must not
122
+ * self-identify through its body. A container holding SEVERAL code blocks
123
+ * must not self-identify through a nested block's label either (issue #13:
124
+ * the shared markdown root was mistaken for a dsh-ui fence and hid the whole
125
+ * message, losing every other code block). */
126
+ function infostringOf(block: Element): string | null {
127
+ const pre = block.querySelector('pre')
128
+ for (const el of block.querySelectorAll('*')) {
129
+ if (el.childElementCount !== 0) continue
130
+ if (el.textContent !== 'dsh-ui') continue
131
+ if (pre !== null && pre.contains(el)) continue
132
+ // A leaf label that belongs to a NESTED known code surface is that
133
+ // surface's banner, not `block`'s own banner. Only accept labels whose
134
+ // nearest known surface is `block` itself (or none — unknown surfaces
135
+ // stay supported by the structural backstop).
136
+ const owner = el.closest(CODE_BLOCK_SELECTORS)
137
+ if (owner !== null && owner !== block) continue
138
+ return 'dsh-ui'
139
+ }
140
+ return null
141
+ }
142
+
143
+ /** The banner label's raw text (empty while streaming — the host renders the
144
+ * language label only once the reply settles). Returns the first leaf
145
+ * outside the code body (banners always lead with the language), so a
146
+ * span-label host reads identically to the div-label host. */
147
+ function labelTextOf(block: Element): string {
148
+ const pre = block.querySelector('pre')
149
+ for (const el of block.querySelectorAll('*')) {
150
+ if (el.childElementCount !== 0) continue
151
+ if (pre !== null && pre.contains(el)) continue
152
+ return el.textContent ?? ''
153
+ }
154
+ return ''
155
+ }
156
+
157
+ /** Raw fence body from the stock block's code surface. */
158
+ function rawOf(block: Element): string {
159
+ const pre = block.querySelector('pre')
160
+ if (pre === null) return ''
161
+ let text = ''
162
+ for (const node of pre.childNodes) {
163
+ if (isTextNode(node)) text += node.textContent ?? ''
164
+ else text += node.textContent ?? ''
165
+ }
166
+ return text
167
+ }
168
+
169
+ /** Settled gate: no streaming marker on any ancestor. */
170
+ function isSettled(block: Element): boolean {
171
+ return block.closest(STREAMING) === null
172
+ }
173
+
174
+ /** Walk up from a `<pre>` to its fence surface root — the ancestor that
175
+ * carries the banner label AND the pre. Returns null when no ancestor within
176
+ * `SURFACE_HOPS` (or the scope boundary) labels itself `dsh-ui`, or when the
177
+ * labeled ancestor is a message-level container rather than a code surface
178
+ * (see {@link isPlausibleFenceSurface}). */
179
+ function surfaceOf(pre: HTMLElement, scope: ParentNode = document): HTMLElement | null {
180
+ let el: HTMLElement | null = pre.parentElement
181
+ for (let hops = 0; el !== null && el !== scope && hops < SURFACE_HOPS; hops += 1, el = el.parentElement) {
182
+ if (infostringOf(el) !== 'dsh-ui') continue
183
+ if (!isPlausibleFenceSurface(el)) return null
184
+ return el
185
+ }
186
+ return null
187
+ }
188
+
189
+ /** The labeled-but-implausible ancestor `surfaceOf` just rejected, if any:
190
+ * diagnostics for the issue #19 guard so a skipped fence is never silent. */
191
+ function implausibleLabeledAncestorOf(pre: HTMLElement, scope: ParentNode = document): HTMLElement | null {
192
+ let el: HTMLElement | null = pre.parentElement
193
+ for (let hops = 0; el !== null && el !== scope && hops < SURFACE_HOPS; hops += 1, el = el.parentElement) {
194
+ if (infostringOf(el) === 'dsh-ui' && !isPlausibleFenceSurface(el)) return el
195
+ }
196
+ return null
197
+ }
198
+
199
+ /**
200
+ * Every dsh-ui fence surface under `scope`, outer-most first, deduped.
201
+ * Known surface classes first (cheap, ordered), then a structural sweep —
202
+ * every `<pre>` whose banner labels it `dsh-ui` — so a host with an
203
+ * unlisted surface shape still renders. The label + `<pre>` gates make the
204
+ * structural pass false-positive-free: a random code surface without the
205
+ * exact `dsh-ui` label is never taken over.
206
+ */
207
+ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
208
+ const seen = new Set<HTMLElement>()
209
+ const out: HTMLElement[] = []
210
+ for (const el of scope.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS)) {
211
+ // Modifier classes can sit inside a surface (e.g. a `code-block-small`
212
+ // child of `code-block`): only the outermost matching element is a
213
+ // candidate, so a fence is never double-counted or taken over twice.
214
+ if (el.parentElement !== null && el.parentElement.closest(CODE_BLOCK_SELECTORS) !== null) continue
215
+ if (seen.has(el)) continue
216
+ // Message-level containers that happen to carry a surface class must
217
+ // not be taken over: hiding them hides the whole answer (issue #19).
218
+ if (!isPlausibleFenceSurface(el)) continue
219
+ out.push(el)
220
+ seen.add(el)
221
+ }
222
+ for (const pre of scope.querySelectorAll<HTMLElement>('pre')) {
223
+ // `<pre>` bodies inside a known surface were already handled by the
224
+ // selector pass. Walking up from them again would climb PAST their own
225
+ // (non-dsh-ui or dsh-ui) surface into a shared container — e.g. a
226
+ // markdown root holding both a dsh-ui fence and a python block — and
227
+ // the backstop would mislabel that whole container as a fence, hiding
228
+ // every other code block with it (issue #13).
229
+ if (pre.closest(CODE_BLOCK_SELECTORS) !== null) continue
230
+ const surface = surfaceOf(pre, scope)
231
+ if (surface === null) {
232
+ // Diagnose the issue #19 guard: a labeled ancestor that is NOT a code
233
+ // surface (prose/multiple code bodies) was skipped on purpose.
234
+ if (implausibleLabeledAncestorOf(pre, scope) !== null && !plausibilityWarned) {
235
+ plausibilityWarned = true
236
+ console.warn('[dsh-genui] 跳过带 dsh-ui 标签但疑似消息容器的节点(含段落或多个代码体)——防止 DOM 通道隐藏整条消息(issue #19)')
237
+ }
238
+ continue
239
+ }
240
+ if (seen.has(surface)) continue
241
+ // Host DOM drift diagnostic: the fence renders (structural backstop),
242
+ // but the surface class is unknown to this build — warn once per
243
+ // renderer install so future drift is never silent again.
244
+ if (!driftWarned) {
245
+ driftWarned = true
246
+ console.warn('[dsh-genui] 围栏表面类名未被已知选择器命中(宿主 DOM 漂移),已按 label+pre 结构识别 dsh-ui 围栏')
247
+ }
248
+ out.push(surface)
249
+ seen.add(surface)
250
+ }
251
+ return out
252
+ }
253
+
254
+ /** One-time-per-install drift diagnostic flag (reset per install, so tests
255
+ * and hot re-installs each get a fresh warning budget). */
256
+ let driftWarned = false
257
+ /** One-time-per-install issue #19 guard diagnostic (same budget). */
258
+ let plausibilityWarned = false
259
+
260
+ /** Root factory seam (tests / tuning): the DOM channel creates one React root
261
+ * per taken-over fence through this indirection so mount-failure cleanup is
262
+ * reachable in jsdom without mocking the react-dom module. */
263
+ let domRootFactory: (container: HTMLElement) => Root = createRoot
264
+
265
+ /** Override the React root factory (tests / tuning). */
266
+ export function setDomRootFactory(factory: (container: HTMLElement) => Root): void {
267
+ domRootFactory = factory
268
+ }
269
+
270
+ /**
271
+ * The owning conversation row (stable per-message identity).
272
+ *
273
+ * The host renders `data-chat-anchor-key` from a React key that is OMITTED
274
+ * when the routed node's key is undefined — observed on Safari (and any
275
+ * fallback render path), where every fence row lacks the attribute while
276
+ * Chrome's identical page has it. Fences must not silently die there, so the
277
+ * lookup walks down a fallback chain and never gives up:
278
+ *
279
+ * 1. `[data-chat-anchor-key]` — the canonical stable row anchor;
280
+ * 2. `[data-chat-flow-key]` / `[data-chat-flow-kind]` — the same row div
281
+ * rendered by the host (both carry the routing key/kind, and the kind is
282
+ * a separate value that survives an undefined React key);
283
+ * 3. the code block itself — last resort; identity degrades to
284
+ * `dom:unknown:<ordinal>` (see `fenceIndexOf`/`contextOf`).
285
+ */
286
+ const FLOW_ROW = '[data-chat-flow-key], [data-chat-flow-kind]'
287
+ function rowOf(block: Element): Element {
288
+ return block.closest('[data-chat-anchor-key]') ?? block.closest(FLOW_ROW) ?? block
289
+ }
290
+
291
+ /** 1-based ordinal of this block among the row's settled dsh-ui blocks
292
+ * (document order). Streaming candidates are skipped, so the ordinal stays
293
+ * stable while the block itself is still streaming. When the fallback chain
294
+ * bottoms out at the block itself (no owning row in the DOM at all), the
295
+ * ordinal falls back to document order among ALL settled dsh-ui blocks so
296
+ * sibling fences never collide on the same `dom:unknown:N` identity. */
297
+ function fenceIndexOf(row: Element, block: Element): number {
298
+ const scope = row === block ? document : row
299
+ let index = 0
300
+ for (const candidate of findFenceCandidates(scope)) {
301
+ if (candidate.closest(STREAMING) !== null) continue
302
+ if (infostringOf(candidate) === null) continue
303
+ index += 1
304
+ if (candidate === block) return index
305
+ }
306
+ return index + 1
307
+ }
308
+
309
+ /**
310
+ * messageSeq estimate from the row's anchor key.
311
+ *
312
+ * The host's context key is `<kindLen>:<kind><id>` (e.g.
313
+ * `14:assistant-step3:0`); the id of an assistant step is `<turn>:<step>` —
314
+ * the ONLY per-message monotonic counter the host exposes in the DOM. Turn
315
+ * and step strictly increase with message order, so a turn-based seq keeps
316
+ * growing across page reloads: the panel store's persisted replay barrier
317
+ * (hydration: replays at/below the persisted max seq are dead) depends on
318
+ * this monotonicity. Without it every assistant step yields the SAME
319
+ * constant (the kind-length prefix), so after a refresh the barrier equals
320
+ * that constant and silently rejects every new panel fence (issue #4).
321
+ *
322
+ * Fallback (non-assistant rows, anchor-less Safari rows): the row's
323
+ * document-order index among chat rows — monotonic within the current
324
+ * render window, degraded across reloads.
325
+ */
326
+ function anchorSeqOf(row: Element): number {
327
+ const key = row.getAttribute('data-chat-anchor-key') ?? ''
328
+ const turnStep = /assistant-step(\d+):(\d+)$/.exec(key)
329
+ if (turnStep !== null) {
330
+ const turn = Number(turnStep[1])
331
+ const step = Number(turnStep[2])
332
+ if (Number.isFinite(turn) && Number.isFinite(step)) return turn * 1000 + step
333
+ }
334
+ const rows = document.querySelectorAll(`[data-chat-anchor-key], ${FLOW_ROW}`)
335
+ for (let i = 0; i < rows.length; i += 1) {
336
+ if (rows[i] === row) return i
337
+ }
338
+ return 0
339
+ }
340
+
341
+ /**
342
+ * Install the DOM render channel. Returns a disposer that restores every
343
+ * taken-over block and disconnects the observers.
344
+ *
345
+ * @param ctx - the client context (sessions service for the current session).
346
+ * @param sendAction - plugin-owned relay: (sessionId, action, payload) → the
347
+ * scoped conversation send carrying the `[genui-action]` prompt.
348
+ */
349
+ export function installDomFenceRenderer(
350
+ ctx: Context,
351
+ sendAction: (sessionId: SessionId, action: string, payload: Record<string, unknown>) => void,
352
+ ): () => void {
353
+ if (typeof document === 'undefined') return () => {}
354
+ driftWarned = false
355
+ plausibilityWarned = false
356
+ const mounts = new Map<HTMLElement, Mount>()
357
+
358
+ const sessionIdOf = (): SessionId | undefined => {
359
+ try {
360
+ return ctx.sessions.list.getSnapshot().current
361
+ } catch {
362
+ return undefined
363
+ }
364
+ }
365
+
366
+ /** Render context for a block: session always; the stable source identity
367
+ * only once settled — streaming renders are identity-less (no panel
368
+ * publish, no durable state), mirroring the registry channel. */
369
+ function contextOf(row: Element, block: Element, settled: boolean): { key: Key; context: GenuiFenceContext } {
370
+ if (settled && row.getAttribute('data-chat-anchor-key') === null) {
371
+ // Safari / fallback render path: the host omitted the row anchor (the
372
+ // attribute is a React key that React drops when undefined). Fences
373
+ // still render with the degraded `dom:unknown:N` identity — warn once
374
+ // per block so the degraded path is visible in the console.
375
+ warnOnce(block, 'no [data-chat-anchor-key] ancestor for a dsh-ui fence (host render path without row anchor — e.g. Safari); using fallback identity dom:unknown:N')
376
+ }
377
+ const fenceIndex = fenceIndexOf(row, block)
378
+ const anchorKey = row.getAttribute('data-chat-anchor-key') ?? 'unknown'
379
+ const key = `dom:${anchorKey}:${fenceIndex}` as Key
380
+ const sessionId = sessionIdOf()
381
+ const context: GenuiFenceContext = {
382
+ ...(sessionId === undefined ? {} : { sessionId }),
383
+ ...(settled ? { source: { id: key as string, order: [anchorSeqOf(row), 0, fenceIndex] as const } } : {}),
384
+ }
385
+ return { key, context }
386
+ }
387
+
388
+ function unmountBlock(block: HTMLElement): void {
389
+ const mount = mounts.get(block)
390
+ if (mount === undefined) return
391
+ mounts.delete(block)
392
+ mount.root.unmount()
393
+ mount.container.remove()
394
+ block.style.display = ''
395
+ block.removeAttribute(PROCESSED)
396
+ }
397
+
398
+ /** One-time-per-block diagnostics: silent returns must be diagnosable
399
+ * (the 1s sweep would otherwise spam the console every pass). */
400
+ const warned = new WeakSet<Element>()
401
+ function warnOnce(block: Element, message: string): void {
402
+ if (warned.has(block)) return
403
+ warned.add(block)
404
+ console.warn(`[dsh-genui] ${message}`)
405
+ }
406
+
407
+ function renderBlock(block: HTMLElement): void {
408
+ if (block.hasAttribute(PROCESSED)) return
409
+ const row = rowOf(block)
410
+ const settled = isSettled(block)
411
+ // Settled blocks must carry the dsh-ui label. Streaming blocks cannot:
412
+ // the host renders the language label only once the reply settles
413
+ // (MarkdownText passes `lang={streaming ? undefined : lang}`), so during
414
+ // streaming the fence is identified by CONTENT — a partial parse that
415
+ // yields a GenUI node. A misidentified fence (e.g. a ```json block that
416
+ // happens to parse) is reverted at the settle transition below.
417
+ if (settled && infostringOf(block) === null) return
418
+ const raw = rawOf(block)
419
+ if (raw.trim() === '') {
420
+ if (settled) warnOnce(block, 'settled dsh-ui fence has an empty body; keeping the code block')
421
+ return
422
+ }
423
+ const { key, context } = contextOf(row, block, settled)
424
+ const node: ReactNode | null = renderResolvedFenceNode(raw, key, context)
425
+ // Null = no finished component yet (streaming half) or unrepairable:
426
+ // the stock code block stays visible until something renders. A settled
427
+ // unrepairable body warns once (the DOM channel has no visible
428
+ // diagnostic of its own — the stock block keeps the raw content).
429
+ if (node === null) {
430
+ if (settled) warnOnce(block, 'settled dsh-ui fence body does not parse; keeping the code block')
431
+ return
432
+ }
433
+ // Mount FIRST, hide AFTER (issue #19): the stock block is only ever
434
+ // hidden once a successfully mounted replacement stands next to it. A
435
+ // mount failure leaves the original code block untouched — the final
436
+ // answer can never be blanked by a half-completed takeover.
437
+ const container = document.createElement('div')
438
+ container.className = CONTAINER_CLASS
439
+ block.after(container)
440
+ let root: Root
441
+ try {
442
+ root = domRootFactory(container)
443
+ } catch (error) {
444
+ container.remove()
445
+ warnOnce(block, `failed to create a React root for a dsh-ui fence (${error instanceof Error ? error.message : String(error)}); keeping the stock code block visible`)
446
+ return
447
+ }
448
+ try {
449
+ const handler: GenuiActionHandler = (action, payload) => {
450
+ const sid = sessionIdOf()
451
+ if (sid === undefined) return
452
+ sendAction(sid, action, payload)
453
+ }
454
+ root.render(<GenuiActionContext.Provider value={handler}>{node}</GenuiActionContext.Provider>)
455
+ } catch (error) {
456
+ try {
457
+ root.unmount()
458
+ } catch {
459
+ // Best-effort cleanup; removing the container below restores the DOM.
460
+ }
461
+ container.remove()
462
+ warnOnce(block, `failed to mount a dsh-ui fence (${error instanceof Error ? error.message : String(error)}); keeping the stock code block visible`)
463
+ return
464
+ }
465
+ block.style.display = 'none'
466
+ block.setAttribute(PROCESSED, '')
467
+ mounts.set(block, { root, container, block, lastRaw: raw, lastSettled: settled, lastNode: node })
468
+ }
469
+
470
+ /** Pre-paint repair: the host's React re-renders during streaming can wipe
471
+ * our foreign container or reset the hide. Re-apply the surgery in the
472
+ * observer microtask (before paint) so raw JSON never flashes between
473
+ * chunks; the rAF sweep re-renders React state at its own pace. */
474
+ function repairSurgery(): void {
475
+ for (const mount of Array.from(mounts.values())) {
476
+ const block = mount.block
477
+ // The host replaced the row: the stock block is gone but our foreign
478
+ // container may still be attached. Drop the mount NOW (removes the
479
+ // orphan container) instead of waiting for the sweep — the new block
480
+ // will be taken over by the sweep's discovery pass.
481
+ if (!block.isConnected) {
482
+ if (mount.container.isConnected) unmountBlock(block)
483
+ continue
484
+ }
485
+ // Re-attach the container BEFORE re-hiding (issue #19: never hide the
486
+ // original while no mounted replacement stands next to it).
487
+ if (mount.container.parentElement !== block.parentElement
488
+ || mount.container.previousElementSibling !== block) {
489
+ block.after(mount.container)
490
+ }
491
+ if (!mount.container.isConnected) {
492
+ // Insertion failed (pathological host re-parent): surrender the
493
+ // takeover and keep the raw stock block visible rather than leaving
494
+ // it hidden with no replacement.
495
+ block.style.display = ''
496
+ block.removeAttribute(PROCESSED)
497
+ mounts.delete(block)
498
+ try {
499
+ mount.root.unmount()
500
+ } catch {
501
+ // Best-effort; the detached container gets removed below.
502
+ }
503
+ mount.container.remove()
504
+ warnOnce(block, 'dsh-ui replacement container could not be re-attached after a host re-render; restoring the stock code block')
505
+ continue
506
+ }
507
+ if (block.style.display !== 'none') block.style.display = 'none'
508
+ if (!block.hasAttribute(PROCESSED)) block.setAttribute(PROCESSED, '')
509
+ }
510
+ }
511
+
512
+ /** Sweep: drop dead mounts, re-render changed bodies (streaming growth and
513
+ * the streaming→settled transition), repair surgery, then take over every
514
+ * new dsh-ui block — settled or still streaming. */
515
+ function sweep(): void {
516
+ for (const [block, mount] of mounts) {
517
+ if (!block.isConnected) {
518
+ unmountBlock(block)
519
+ continue
520
+ }
521
+ const raw = rawOf(block)
522
+ const settled = isSettled(block)
523
+ // Settle transition label re-verification: a streaming block was taken
524
+ // over by content, not by label. If the now-visible label exists and is
525
+ // NOT dsh-ui (a ```json fence that happened to parse), restore the
526
+ // stock block and drop the mount.
527
+ if (settled && !mount.lastSettled) {
528
+ const labelText = labelTextOf(block)
529
+ if (labelText !== '' && labelText !== 'dsh-ui') {
530
+ // A content-identified fence settled as another language (e.g. a
531
+ // ```json block that happened to parse): restore the stock block.
532
+ unmountBlock(block)
533
+ continue
534
+ }
535
+ }
536
+ // A host re-render can also wipe the CONTENT of our container while
537
+ // leaving the node in place. An inline mount whose container came back
538
+ // empty (and was not empty by design — panel roots are) must be
539
+ // re-rendered, otherwise the block stays hidden behind an empty box.
540
+ const contentWiped = !isPanelRoot(mount.lastNode) && mount.container.childElementCount === 0
541
+ if (mount.lastRaw !== raw || mount.lastSettled !== settled || contentWiped) {
542
+ const anchor = rowOf(block)
543
+ const { key, context } = contextOf(anchor, block, settled)
544
+ const node = renderResolvedFenceNode(raw, key, context)
545
+ if (node === null) {
546
+ unmountBlock(block)
547
+ continue
548
+ }
549
+ if (contentWiped) {
550
+ // The old root's fiber bookkeeping points at children the host
551
+ // already removed — re-rendering it can throw removeChild errors
552
+ // on missing nodes. Rebuild the mount in place: fresh container +
553
+ // fresh root, block re-hidden only after the replacement exists.
554
+ try {
555
+ mount.root.unmount()
556
+ } catch {
557
+ // The host's wipe already invalidated the tree; the fresh root
558
+ // below is the recovery, not the old one.
559
+ }
560
+ const fresh = document.createElement('div')
561
+ fresh.className = CONTAINER_CLASS
562
+ mount.container.remove()
563
+ block.after(fresh)
564
+ try {
565
+ const freshRoot = domRootFactory(fresh)
566
+ const handler: GenuiActionHandler = (action, payload) => {
567
+ const sid = sessionIdOf()
568
+ if (sid !== undefined) sendAction(sid, action, payload)
569
+ }
570
+ freshRoot.render(<GenuiActionContext.Provider value={handler}>{node}</GenuiActionContext.Provider>)
571
+ mount.root = freshRoot
572
+ mount.container = fresh
573
+ } catch (error) {
574
+ // Never leave the stock block hidden behind a broken root:
575
+ // restore the raw code block and drop the mount (issue #19).
576
+ fresh.remove()
577
+ block.style.display = ''
578
+ block.removeAttribute(PROCESSED)
579
+ mounts.delete(block)
580
+ warnOnce(block, `failed to rebuild a wiped dsh-ui mount (${error instanceof Error ? error.message : String(error)}); restoring the stock code block`)
581
+ continue
582
+ }
583
+ } else {
584
+ try {
585
+ mount.root.render(<GenuiActionContext.Provider value={(action, payload) => {
586
+ const sid = sessionIdOf()
587
+ if (sid !== undefined) sendAction(sid, action, payload)
588
+ }}>{node}</GenuiActionContext.Provider>)
589
+ } catch (error) {
590
+ // Never leave the stock block hidden behind a broken root: restore
591
+ // the raw code block and drop the mount (issue #19).
592
+ unmountBlock(block)
593
+ warnOnce(block, `failed to re-render a dsh-ui fence (${error instanceof Error ? error.message : String(error)}); restoring the stock code block`)
594
+ continue
595
+ }
596
+ }
597
+ mount.lastRaw = raw
598
+ mount.lastSettled = settled
599
+ mount.lastNode = node
600
+ }
601
+ }
602
+ repairSurgery()
603
+ for (const block of findFenceCandidates()) {
604
+ renderBlock(block)
605
+ }
606
+ }
607
+
608
+ let scheduled = false
609
+ const schedule = (): void => {
610
+ if (scheduled) return
611
+ scheduled = true
612
+ requestAnimationFrame(() => {
613
+ scheduled = false
614
+ sweep()
615
+ })
616
+ }
617
+
618
+ const observer = new MutationObserver(() => {
619
+ // Pre-paint pass: surgery repair only (cheap DOM ops); the React
620
+ // re-render goes through the rAF-scheduled sweep.
621
+ repairSurgery()
622
+ schedule()
623
+ })
624
+ observer.observe(document.body, {
625
+ childList: true,
626
+ subtree: true,
627
+ attributes: true,
628
+ attributeFilter: ['data-streaming'],
629
+ // React streams tokens as text-node updates: without characterData the
630
+ // observer would only fire on structural changes and miss body growth.
631
+ characterData: true,
632
+ })
633
+ const interval = window.setInterval(sweep, SWEEP_MS)
634
+ sweep()
635
+
636
+ return () => {
637
+ observer.disconnect()
638
+ window.clearInterval(interval)
639
+ for (const block of Array.from(mounts.keys())) unmountBlock(block)
640
+ }
641
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Runtime loader for the ECharts engine. The heavy echarts bundle ships as a
3
+ * separate asset (`lib/assets/echarts.js`, served by the plugin's own HTTP
4
+ * route) and is fetched ONLY when a spec contains an `echart` node — the
5
+ * main client bundle stays small and most conversations never download
6
+ * echarts at all. On a host that does not serve the asset the load rejects
7
+ * and the EChartNode shows its fallback.
8
+ * @module @changfenhuang/dsh-genui/client/echarts-lazy
9
+ */
10
+ import { loadGenuiAsset } from './asset-loader.ts'
11
+
12
+ /** The ECharts instance surface (the subset the component uses). */
13
+ export interface EChartsInstance {
14
+ setOption: (opt: unknown, notMerge?: boolean) => void
15
+ resize: () => void
16
+ dispose: () => void
17
+ }
18
+
19
+ /** The engine surface registered by the echarts asset bundle. */
20
+ interface EChartsAssetApi {
21
+ createChart: (el: HTMLElement, option: unknown, opts?: { height?: number }) => EChartsInstance
22
+ }
23
+
24
+ /**
25
+ * Create an ECharts instance on `el` with the given option (engine loaded on
26
+ * demand). The caller owns the returned instance and must dispose it.
27
+ * @param el - the DOM node to host the chart canvas.
28
+ * @param option - the ECharts option object.
29
+ * @param opts - optional height override.
30
+ * @returns the ECharts instance (setOption/resize/dispose).
31
+ */
32
+ export async function createChart(el: HTMLElement, option: unknown, opts?: { height?: number }): Promise<EChartsInstance> {
33
+ const api = await loadGenuiAsset<EChartsAssetApi>('echarts')
34
+ return api.createChart(el, option, opts)
35
+ }