@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,195 @@
1
+ /**
2
+ * dsh-genui browser half: the ```dsh-ui fence renderer, the keyed toolview
3
+ * for the `render_ui` tool, and the session panel dock.
4
+ *
5
+ * Fence rendering is dual-mode, chosen at boot:
6
+ * - **Registry channel** (contract hosts): the host's MarkdownText resolves
7
+ * ```dsh-ui fences through the fence-registry extension point; this package
8
+ * registers `renderGenuiFence`. Action callbacks ride the host-installed
9
+ * GenuiActionContext.
10
+ * - **DOM channel** (pristine hosts): no extension point exists, so
11
+ * `dom-fence.ts` observes the conversation DOM, finds settled stock code
12
+ * blocks labelled `dsh-ui`, and mounts the same render pipeline in its own
13
+ * React roots — wrapped in the plugin-owned GenuiActionContext that relays
14
+ * `[genui-action]` through the scoped conversation send. Either way the
15
+ * deployment is the STOCK DSH snapshot plus this plugin.
16
+ *
17
+ * The renderer parses the fence body with the partial parser: while the reply
18
+ * streams, every FINISHED component appears the moment its JSON object
19
+ * closes, so the UI assembles top-down before the fence (or reply) completes.
20
+ * A body with no finished component yet falls back to a plain code block.
21
+ * @module @changfenhuang/dsh-genui/client
22
+ */
23
+
24
+ import type { Context } from '@deepseek-ai/cordis'
25
+ import type {} from '@deepseek-ai/dsh-client-runtime/client'
26
+ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
27
+ import type { IConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
28
+ import type { Key, ReactNode } from 'react'
29
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
30
+ import { installDomFenceRenderer } from './dom-fence.tsx'
31
+ import { renderGenuiFence, type GenuiFenceContext } from './fence-render.tsx'
32
+ import { createPanelSlashSource } from './panel-command.ts'
33
+ import { GenuiPanel, type GenuiPanelInjected } from './panel.tsx'
34
+ import { GenuiToolView } from './toolview.tsx'
35
+ import type { InputTriggerServiceContract } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
36
+ import { assetUrl } from './asset-loader.ts'
37
+
38
+ /** Host extension surface the registry channel needs (absent on pristine). */
39
+ type HostFenceExt = {
40
+ registerFenceRenderer?: (lang: string, renderer: (raw: string, key: Key, context?: GenuiFenceContext) => ReactNode) => () => void
41
+ }
42
+
43
+ /** Add low-priority prefetch links for the lazy engine assets (mermaid/three).
44
+ * Browser-dependent: some engines ignore `<link rel=prefetch>`; harmless
45
+ * either way — the on-demand loader still covers a cache miss. Exported for
46
+ * tests. */
47
+ export function prefetchGenuiAssets(): void {
48
+ if (typeof document === 'undefined') return
49
+ for (const file of ['mermaid.js', 'three.js']) {
50
+ if (document.head.querySelector(`link[rel="prefetch"][href="${assetUrl(file)}"]`) !== null) continue
51
+ const link = document.createElement('link')
52
+ link.rel = 'prefetch'
53
+ link.as = 'script'
54
+ link.href = assetUrl(file)
55
+ document.head.appendChild(link)
56
+ }
57
+ }
58
+
59
+ /** Session panel action loop: same [genui-action] contract as inline fences,
60
+ * routed through the scoped conversation send (queued user message). The
61
+ * prompt asks the model to re-run render_ui so the panel updates in place. */
62
+ function panelActionSend(ctx: Context, sessionId: SessionId): GenuiPanelInjected {
63
+ const scoped = ctx.sessions.scope(sessionId)
64
+ const conversation = scoped?.get('conversation') as IConversation | undefined
65
+ return {
66
+ sessionId,
67
+ sendGenuiAction: (action, payload) => {
68
+ if (conversation === undefined) return
69
+ const payloadText = Object.keys(payload).length === 0
70
+ ? ''
71
+ : ` 组件数据: ${JSON.stringify(payload)}`
72
+ void conversation.send(`[genui-action] ${action}。用户刚刚在面板中触发了动作 "${action}",请根据组件数据执行相应操作,只输出一个 panel:true 的 dsh-ui 围栏来更新面板,回复文本至多一行 10 字以内的确认(如"已刷新"),不要解释、不要普通围栏。${payloadText}`).catch((err: unknown) => {
73
+ // A failed prompt (session gone, agent busy) drops the action; the
74
+ // panel stays interactive — the component is not disabled. Log the
75
+ // failure WITHOUT the action payload or any secret values (the
76
+ // message may contain field content).
77
+ console.warn(`[genui] 面板动作 "${action}" 发送失败(session ${sessionId}):`, err instanceof Error ? err.message : String(err))
78
+ })
79
+ },
80
+ }
81
+ }
82
+
83
+ /** Relay a `/panel <指令>` instruction to the model: the scoped conversation
84
+ * send with an explicit panel-only directive, so the model replaces the
85
+ * default panel with content tailored to the request. */
86
+ function sendPanelInstruction(ctx: Context, sessionId: SessionId, instruction: string): void {
87
+ const scoped = ctx.sessions.scope(sessionId)
88
+ const conversation = scoped?.get('conversation') as IConversation | undefined
89
+ if (conversation === undefined) return
90
+ void conversation.send(`用户执行了 /panel 并请求:${instruction}。请只输出一个 panel:true 的 dsh-ui 围栏来更新会话面板,内容按请求定制;回复文本至多一行 10 字以内的确认(如"已更新"),不要解释、不要普通围栏。`).catch((err: unknown) => {
91
+ // A failed prompt drops the instruction; the default panel stays
92
+ // visible. Log without the instruction text (may contain secrets).
93
+ console.warn(`[genui] /panel 指令发送失败(session ${sessionId}):`, err instanceof Error ? err.message : String(err))
94
+ })
95
+ }
96
+
97
+ /**
98
+ * Inline-fence action relay (DOM channel): the same message template the
99
+ * contract host's sendGenuiAction uses, so the model sees one identical
100
+ * [genui-action] contract on both channels.
101
+ */
102
+ function sendInlineGenuiAction(ctx: Context, sessionId: SessionId, action: string, payload: Record<string, unknown>): void {
103
+ const scoped = ctx.sessions.scope(sessionId)
104
+ const conversation = scoped?.get('conversation') as IConversation | undefined
105
+ if (conversation === undefined) return
106
+ const payloadText = Object.keys(payload).length === 0
107
+ ? ''
108
+ : ` 组件数据: ${JSON.stringify(payload)}`
109
+ void conversation.send(`[genui-action] ${action}。用户刚刚在界面中触发了动作 "${action}",请根据组件数据执行相应操作,并用 dsh-ui 输出更新后的界面。${payloadText}`).catch(() => {
110
+ // A failed prompt (session gone, agent busy) drops the action;
111
+ // the UI stays interactive — the component is not disabled.
112
+ })
113
+ }
114
+
115
+ /** Cordis client entry: register the fence renderer on boot, the keyed
116
+ * toolview for the render_ui tool, and the session panel dock; returning the
117
+ * disposers lets cordis tear all registrations down on plugin unload. */
118
+ export function apply(ctx: Context): () => void {
119
+ // Fence channel selection: the registry extension point when the host
120
+ // ships it (contract line), the DOM observer otherwise (pristine line).
121
+ // One plugin build serves both deployments.
122
+ const registerFn = (primitives as unknown as HostFenceExt).registerFenceRenderer
123
+ const channel = typeof registerFn === 'function' ? 'registry' : 'dom'
124
+ console.info(`[genui] client active; fence-channel=${channel}`)
125
+ const disposers: Array<() => void> = typeof registerFn === 'function'
126
+ ? [registerFn('dsh-ui', renderGenuiFence)]
127
+ : [installDomFenceRenderer(ctx, (sessionId, action, payload) => sendInlineGenuiAction(ctx, sessionId, action, payload))]
128
+ // Idle prefetch of the lazy engine assets: the browser downloads them at
129
+ // LOW priority whenever the page is idle, so the first mermaid/3D node in
130
+ // a session usually hits a warm cache instead of paying the fetch on first
131
+ // use. Never blocks first paint; the loader still handles a miss.
132
+ prefetchGenuiAssets()
133
+ // Keyed toolview: the harness dispatches 'tool.call.toolview' by wire tool
134
+ // name; registering under 'render_ui' gives the tool's result card the
135
+ // GenUI renderer (reading the repaired spec from result meta). The toolview
136
+ // also publishes every settled spec to the session panel store.
137
+ disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
138
+ name: 'tool.call.toolview',
139
+ key: 'render_ui',
140
+ }, GenuiToolView)))
141
+ // Session panel dock: a session-scoped, always-present seat above the
142
+ // composer (TodoDock posture). Renders the session's latest render_ui
143
+ // spec in place; absent spec = no panel.
144
+ disposers.push(ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
145
+ name: 'conversation.input.dock',
146
+ id: 'genui-panel',
147
+ order: 50,
148
+ inject: (sessionId: SessionId): GenuiPanelInjected => panelActionSend(ctx, sessionId),
149
+ }, GenuiPanel)))
150
+ // /panel slash command: a deterministic, client-side entry point that
151
+ // opens the panel dock (publishes the default spec + expand request),
152
+ // clears it (/panel clear), or relays an instruction to the model
153
+ // (/panel <指令>) so the panel gets tailored content.
154
+ //
155
+ // inputTriggers is subscribed via cordis OPTIONAL injection (ctx.inject),
156
+ // NOT a one-shot ctx.get() at apply time: the service is typically
157
+ // provided by a different bundle than slots/sessions, so it can arrive
158
+ // AFTER apply() runs — a one-shot lookup would silently disable /panel
159
+ // even on hosts that DO ship the service (service arrival order race).
160
+ // ctx.inject activates the callback only when the service arrives (any
161
+ // order) and disposes with the subscription fiber; hosts without the
162
+ // service simply never register /panel, and rendering is unaffected
163
+ // either way.
164
+ ctx.inject(['inputTriggers'], (scope) => {
165
+ const slash = scope.get('inputTriggers') as InputTriggerServiceContract | undefined
166
+ if (slash === undefined) return
167
+ scope.effect(() => slash.registerSource(
168
+ createPanelSlashSource((sessionId, instruction) => sendPanelInstruction(ctx, sessionId, instruction)),
169
+ ), 'genui: /panel')
170
+ })
171
+ return () => {
172
+ for (const dispose of disposers) dispose()
173
+ }
174
+ }
175
+
176
+ // Browser services the client entry needs: the slots registry (toolview +
177
+ // dock) and sessions (scoped conversation send behind actions). This
178
+ // declaration is what the host's fiber inject waiting uses — without it
179
+ // apply() runs before the services bind and the whole plugin tree fails the
180
+ // boot sweep.
181
+ //
182
+ // inputTriggers (the /panel command source) is deliberately NOT declared:
183
+ // cordis `inject` is a hard activation gate — a declared service that is
184
+ // never provided (pristine DSH shells ship no inputTriggers provider) parks
185
+ // the fiber in waiting forever and apply() never runs, silently killing all
186
+ // GenUI rendering. Instead the apply() body subscribes via ctx.inject
187
+ // (optional injection): hosts with the service get /panel registered in any
188
+ // arrival order, hosts without it never register /panel — and the renderer
189
+ // itself never depends on the service either way.
190
+ export const inject = ['slots', 'sessions']
191
+
192
+ // Re-export the registry renderer for the test suite (setup.ts registers it
193
+ // exactly like apply() does on contract hosts).
194
+ export { renderGenuiFence }
195
+ export type { GenuiFenceContext } from './fence-render.tsx'
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Interaction-state store: durable per-block GenUI interaction state
3
+ * (radio answers, submit lock, input/textarea values) in localStorage.
4
+ *
5
+ * LOCAL-FIRST persistence: a ```dsh-ui block's interactive state survives
6
+ * page refresh and session reopen because it is keyed by
7
+ * `session + block slot + content fingerprint` — replaying the same message
8
+ * (same content) restores the exact state, while NEW content (换题, edited
9
+ * spec) gets a fresh key and thus a clean slate. Different messages never
10
+ * share state because their content fingerprints differ.
11
+ *
12
+ * Bounded: at most MAX_BLOCKS entries, LRU-evicted on write; each block's
13
+ * payload is small (answers map + a few field values).
14
+ * @module @changfenhuang/dsh-genui/client/interaction-store
15
+ */
16
+
17
+ /** Durable state of one UI block. */
18
+ export interface BlockInteractionState {
19
+ /** group → chosen option label (radio aggregation answers). */
20
+ answers?: Record<string, string>
21
+ /** True after a local grading: the paper stays graded across refresh. */
22
+ locked?: boolean
23
+ /** field id → current value (input/textarea with an `id`). */
24
+ fields?: Record<string, string>
25
+ }
26
+
27
+ const STORE_KEY = 'dsh.genui.interaction'
28
+ /** Max tracked blocks; LRU eviction keeps the store bounded. */
29
+ const MAX_BLOCKS = 200
30
+
31
+ interface StoreShape {
32
+ /** Block keys in most-recently-written order (index 0 = newest). */
33
+ order: string[]
34
+ blocks: Record<string, BlockInteractionState>
35
+ }
36
+
37
+ function emptyStore(): StoreShape {
38
+ return { order: [], blocks: {} }
39
+ }
40
+
41
+ function readStore(): StoreShape {
42
+ try {
43
+ const raw = localStorage.getItem(STORE_KEY)
44
+ if (raw === null) return emptyStore()
45
+ const parsed = JSON.parse(raw) as Partial<StoreShape>
46
+ if (!Array.isArray(parsed.order) || typeof parsed.blocks !== 'object' || parsed.blocks === null) {
47
+ return emptyStore()
48
+ }
49
+ return { order: parsed.order.filter(k => typeof k === 'string'), blocks: parsed.blocks as Record<string, BlockInteractionState> }
50
+ } catch {
51
+ return emptyStore()
52
+ }
53
+ }
54
+
55
+ function writeStore(store: StoreShape): void {
56
+ try {
57
+ localStorage.setItem(STORE_KEY, JSON.stringify(store))
58
+ } catch {
59
+ // Quota / privacy-mode failures are non-fatal: interaction stays live
60
+ // in memory, only the persistence is lost.
61
+ }
62
+ }
63
+
64
+ /** Load a block's durable state, or null when absent/corrupt. */
65
+ export function loadBlockState(stateKey: string): BlockInteractionState | null {
66
+ if (stateKey === '') return null
67
+ const store = readStore()
68
+ return store.blocks[stateKey] ?? null
69
+ }
70
+
71
+ /** Save a block's durable state (LRU: touched keys move to the front). */
72
+ export function saveBlockState(stateKey: string, state: BlockInteractionState): void {
73
+ if (stateKey === '') return
74
+ const store = readStore()
75
+ const order = store.order.filter(k => k !== stateKey)
76
+ order.unshift(stateKey)
77
+ const blocks = { ...store.blocks, [stateKey]: state }
78
+ while (order.length > MAX_BLOCKS) {
79
+ const evicted = order.pop()
80
+ if (evicted !== undefined) delete blocks[evicted]
81
+ }
82
+ writeStore({ order, blocks })
83
+ }
84
+
85
+ /** Forget a block's durable state (e.g. after a reset-to-empty). */
86
+ export function clearBlockState(stateKey: string): void {
87
+ if (stateKey === '') return
88
+ const store = readStore()
89
+ if (!(stateKey in store.blocks)) return
90
+ const blocks = { ...store.blocks }
91
+ delete blocks[stateKey]
92
+ writeStore({ order: store.order.filter(k => k !== stateKey), blocks })
93
+ }
94
+
95
+ /**
96
+ * Deterministic content fingerprint (djb2) for a block's raw fence body.
97
+ * Two render passes of the SAME content share a fingerprint (state restores);
98
+ * edited content gets a new one (fresh state). Not a security hash — the
99
+ * store only uses it for equality/identity.
100
+ */
101
+ export function fingerprint(raw: string): string {
102
+ let h = 5381
103
+ for (let i = 0; i < raw.length; i++) {
104
+ h = ((h << 5) + h + raw.charCodeAt(i)) >>> 0
105
+ }
106
+ return h.toString(36)
107
+ }
108
+
109
+ /** Build the durable state key for a fence block: session + fence slot + content fingerprint. */
110
+ export function fenceStateKey(sessionId: string, fenceKey: number | string, raw: string): string {
111
+ return `f:${sessionId}:${String(fenceKey)}:${fingerprint(raw)}`
112
+ }
113
+
114
+ /** Build the durable state key for a panel publish (content-keyed). */
115
+ export function panelStateKey(sessionId: string, raw: string): string {
116
+ return `p:${sessionId}:${fingerprint(raw)}`
117
+ }
118
+
119
+ /** Build the durable state key for a render_ui tool card (call-keyed). */
120
+ export function toolStateKey(sessionId: string, callId: string): string {
121
+ return `t:${sessionId}:${callId}`
122
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Mermaid render engine — the HEAVY half. This module imports mermaid and is
3
+ * bundled ONLY into the lazy asset bundle `lib/assets/mermaid.js`, loaded on
4
+ * demand by the mermaid-lazy loader when a spec contains a `mermaid` node;
5
+ * the main client bundle stays small.
6
+ *
7
+ * Whitelist: only a fixed set of diagram kinds render; anything else throws
8
+ * so the caller shows its fallback — except undeclared flowchart bodies
9
+ * (model-generated sources missing the `graph TD` header line), which get
10
+ * the declaration prepended via ensureFlowchartKind and render normally.
11
+ * mermaid runs client-side with its own sanitizer; we additionally refuse `securityLevel: 'loose'`-style inputs by
12
+ * only initializing with the strict default, AND we re-check the rendered SVG
13
+ * before it is injected (see `assertSafeSvg` in mermaid-safe): the injection
14
+ * point is the only place in GenUI that uses `dangerouslySetInnerHTML`, so
15
+ * the last line of defense lives here, not inside mermaid.
16
+ * @module @changfenhuang/dsh-genui/client/mermaid-core
17
+ */
18
+ import { assertSafeSvg, ensureFlowchartKind, repairMermaidSource } from './mermaid-safe.ts'
19
+
20
+ let mermaidPromise: Promise<typeof import('mermaid')> | null = null
21
+
22
+ /** Monotonic render id (replaces Math.random): no collisions, no entropy. */
23
+ let renderSeq = 0
24
+
25
+ function loadMermaid(): Promise<typeof import('mermaid')> {
26
+ mermaidPromise ??= import('mermaid').then(async m => {
27
+ const api = m.default
28
+ // Follow the host theme: boot-theme sets colorScheme on <html>; a
29
+ // dark-forced diagram on a light chat looked broken.
30
+ const dark = typeof document !== 'undefined'
31
+ && document.documentElement.style.colorScheme === 'dark'
32
+ api.initialize({
33
+ startOnLoad: false,
34
+ // Strict default: mermaid escapes/sanitizes; we never enable htmlLabels.
35
+ securityLevel: 'strict',
36
+ theme: dark ? 'dark' : 'neutral',
37
+ // Fail loudly: with suppressErrorRendering false (the default) mermaid
38
+ // renders an "error" diagram on parse/draw failure — the caller then
39
+ // receives a normal-looking SVG whose text is the raw engine error
40
+ // ("Syntax error in text / mermaid version …"), which lands on the page
41
+ // with no exception ever thrown. Suppressing the error diagram makes
42
+ // every failure throw so the caller shows its own fallback instead.
43
+ suppressErrorRendering: true,
44
+ })
45
+ return m
46
+ })
47
+ return mermaidPromise
48
+ }
49
+
50
+ /** Diagram kinds allowed through to the renderer. */
51
+ const ALLOWED_KINDS = [
52
+ 'flowchart', 'graph', 'sequenceDiagram', 'classDiagram', 'stateDiagram',
53
+ 'gantt', 'pie', 'erDiagram', 'journey', 'gitGraph',
54
+ ]
55
+
56
+ /** One render attempt into a private container. mermaid ≥ 11.16 resolves the
57
+ * diagram element via `document.body` while drawing flowcharts
58
+ * (`getDiagramElement`), so a DETACHED container makes every flowchart/graph
59
+ * render throw on an empty d3 selection ("Cannot read properties of null").
60
+ * The container is therefore mounted off-screen for the duration of the
61
+ * render — NOT `display:none`, which zeroes label measurements and makes
62
+ * dagre fail with "Could not find a suitable point" — and removed when done. */
63
+ async function renderInto(m: typeof import('mermaid'), id: string, code: string, container: HTMLDivElement): Promise<string> {
64
+ container.style.position = 'fixed'
65
+ container.style.left = '-100000px'
66
+ container.style.top = '0'
67
+ container.style.margin = '0'
68
+ document.body?.appendChild(container)
69
+ try {
70
+ const { svg } = await m.default.render(id, code, container)
71
+ assertSafeSvg(svg)
72
+ return svg
73
+ } finally {
74
+ container.remove()
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Render mermaid source to an SVG string.
80
+ * @param code - the mermaid diagram source.
81
+ * @returns the rendered SVG markup (verified free of script/event handlers).
82
+ * @throws when the kind is not whitelisted, rendering fails, or the output
83
+ * fails the sanitization check.
84
+ */
85
+ export async function renderMermaid(code: string): Promise<string> {
86
+ let trimmed = code.trim()
87
+ const firstLine = trimmed.split('\n', 1)[0] ?? ''
88
+ let kind = /^([A-Za-z]+)/.exec(firstLine)?.[1] ?? ''
89
+ if (!ALLOWED_KINDS.includes(kind)) {
90
+ // Lenient: model-generated sources frequently omit the mandatory
91
+ // diagram-type declaration line (`A --> B` with no leading
92
+ // `graph TD`). Detect a flowchart body by its edge arrows and prepend
93
+ // the declaration instead of throwing straight to the raw-source
94
+ // fallback; genuinely unclassifiable input still fails as before.
95
+ const lenient = ensureFlowchartKind(trimmed)
96
+ if (lenient !== trimmed) {
97
+ trimmed = lenient
98
+ kind = 'graph'
99
+ }
100
+ }
101
+ if (!ALLOWED_KINDS.includes(kind)) {
102
+ throw new Error(`mermaid kind '${kind}' is not allowed`)
103
+ }
104
+ const m = await loadMermaid()
105
+ const container = document.createElement('div')
106
+ try {
107
+ try {
108
+ return await renderInto(m, `genui-mermaid-${renderSeq++}`, trimmed, container)
109
+ } catch (error) {
110
+ // Retry once with the label-repair pass; a repaired source rendering
111
+ // successfully beats a syntax-error fallback for the same content.
112
+ const repaired = repairMermaidSource(trimmed)
113
+ if (repaired !== trimmed) {
114
+ return await renderInto(m, `genui-mermaid-${renderSeq++}`, repaired, container)
115
+ }
116
+ throw error
117
+ }
118
+ } finally {
119
+ container.remove()
120
+ }
121
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Runtime loader for the mermaid engine. The heavy mermaid bundle ships as a
3
+ * separate asset (`lib/assets/mermaid.js`, served by the plugin's own HTTP
4
+ * route) and is fetched ONLY when a spec contains a `mermaid` node — the
5
+ * main client bundle stays small and most conversations never download
6
+ * mermaid at all. On a host that does not serve the asset (older harness) the
7
+ * load rejects and the MermaidNode shows its plain-source fallback.
8
+ *
9
+ * The pure source utilities stay statically exported from mermaid-safe so
10
+ * tests (and any consumer) can use them without the engine.
11
+ * @module @changfenhuang/dsh-genui/client/mermaid-lazy
12
+ */
13
+ import { loadGenuiAsset } from './asset-loader.ts'
14
+ export { assertSafeSvg, ensureFlowchartKind, repairMermaidSource } from './mermaid-safe.ts'
15
+
16
+ /** The engine surface registered by the mermaid asset bundle. */
17
+ interface MermaidAssetApi {
18
+ renderMermaid: (code: string) => Promise<string>
19
+ }
20
+
21
+ /**
22
+ * Render mermaid source to an SVG string (engine loaded on demand).
23
+ * @param code - the mermaid diagram source.
24
+ * @returns the rendered SVG markup (verified free of script/event handlers).
25
+ * @throws when the asset cannot load, the kind is not whitelisted, rendering
26
+ * fails, or the output fails the sanitization check.
27
+ */
28
+ export async function renderMermaid(code: string): Promise<string> {
29
+ const api = await loadGenuiAsset<MermaidAssetApi>('mermaid')
30
+ return api.renderMermaid(code)
31
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Pure mermaid source utilities — no mermaid import, so this module can ship
3
+ * in the main client bundle while the heavy mermaid engine lives in a lazy
4
+ * asset bundle (`lib/assets/mermaid.js`, loaded on demand by mermaid-lazy).
5
+ * @module @changfenhuang/dsh-genui/client/mermaid-safe
6
+ */
7
+
8
+ /** Reject any rendered SVG that carries script, event-handler attributes, or
9
+ * `javascript:` URIs. Under mermaid's strict security level a legitimate
10
+ * diagram never contains these, so a hit means the sanitizer failed (a
11
+ * mermaid regression or a bypass): throw and let the caller show the plain
12
+ * source fallback instead of injecting the markup. Cheap linear scan over
13
+ * the SVG string; happens once per diagram.
14
+ */
15
+ const SVG_INJECTION = /<script|[\s"']on[a-z]+\s*=|javascript:/i
16
+
17
+ /** Throws when `svg` carries script, event-handler attributes, or
18
+ * `javascript:` URIs. Exported for tests; `renderMermaid` is the only caller
19
+ * in production. */
20
+ export function assertSafeSvg(svg: string): void {
21
+ if (SVG_INJECTION.test(svg)) {
22
+ throw new Error('mermaid output failed the sanitization check; refusing to render')
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Best-effort repair of common model-authored label mistakes in graph /
28
+ * flowchart sources, used only when the original fails to render:
29
+ * - drop backticks — lexically illegal in mermaid labels (models embed
30
+ * ```dsh-ui style fences, which break the parser with "Lexical error");
31
+ * - strip `<br/>` tags, which require htmlLabels (never enabled here);
32
+ * - quote unquoted node labels containing CJK, spaces, or other characters
33
+ * mermaid's bare-label grammar rejects (observed live: `A[模型生成 spec]`).
34
+ * Conservative by design: already-quoted labels, plain ASCII labels without
35
+ * spaces, and non-flowchart kinds are left untouched (apart from the
36
+ * backtick/`<br/>` sanitation above, which is harmless everywhere in a
37
+ * flowchart).
38
+ *
39
+ * Labeled-edge spans (`-- 文本 -->`, `== 文本 ==>`, `-. 文本 .->`) are free
40
+ * text and must never be quoted: wrapping them breaks the parser
41
+ * ("Expecting 'LINK' … got 'STR'"), observed live with
42
+ * `H -- 否(流式中) --> J`. Each span is swapped for a bracket-free
43
+ * placeholder before quoting and restored verbatim afterwards, so the quote
44
+ * pass can neither corrupt it nor be thrown off by inserted quotes
45
+ * elsewhere on the line.
46
+ *
47
+ * Pipe-style edge labels (`-->|文本|`) are the opposite: an UNQUOTED pipe
48
+ * label containing `[` or `]` breaks the parser ("Parse error", observed
49
+ * live with `-->|6. 用户交互 → [genui-action]|`) because the brackets are
50
+ * node grammar. Quoting the label text is display-neutral (mermaid renders
51
+ * `-->|"文本"|` without the quotes) and parses. These spans are masked too,
52
+ * so the node-label quote pass below cannot reach inside them and produce
53
+ * nested quotes, and restored last.
54
+ */
55
+ export function repairMermaidSource(code: string): string {
56
+ const kind = /^([A-Za-z]+)/.exec(code.trim())?.[1] ?? ''
57
+ if (kind !== 'graph' && kind !== 'flowchart') return code
58
+ const cleaned = code.replace(/`/g, '').replace(/<br\s*\/?>/gi, ' ')
59
+ // A labeled edge is `--`/`==`/`-.` (not immediately `-->`/`==>`), a label
60
+ // made of non-dash chars or single dashes (never `--`, which would start a
61
+ // new edge), then the arrow. `[ \t]*` — never newlines — keeps the span on
62
+ // one line.
63
+ const EDGE = /(--|==|-\.)(?!>)[ \t]*([^-\n]|-(?!--))*?[ \t]*(-->|==>|\.->)/g
64
+ const spans: string[] = []
65
+ const masked = cleaned.replace(EDGE, (span) => {
66
+ spans.push(span)
67
+ return `\uE000${spans.length - 1}\uE001`
68
+ })
69
+ // Pipe label spans whose text carries at least one bracket char. The
70
+ // bracket requirement keeps legitimate pipe characters inside node labels
71
+ // (e.g. `A[a | b | c]`) untouched. Already-quoted labels (any `"` in the
72
+ // text) are left alone. The placeholder must be bracket-free so the
73
+ // node-label quote pass below cannot match inside it.
74
+ const PIPE = /\|([^|\n]*[\[\]][^|\n]*)\|/g
75
+ const pipeSpans: string[] = []
76
+ const maskedPipes = masked.replace(PIPE, (whole, text: string) => {
77
+ const trimmed = text.trim()
78
+ if (trimmed.includes('"')) return whole
79
+ pipeSpans.push(trimmed)
80
+ return `\uE002${pipeSpans.length - 1}\uE003`
81
+ })
82
+ const quoted = maskedPipes.replace(
83
+ /([\[\(])([^\[\]\(\)\{\}"'\n]*)([\]\)])/g,
84
+ (whole, open: string, label: string, close: string) => {
85
+ const trimmed = label.trim()
86
+ if (trimmed === '') return whole
87
+ if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(trimmed) || /\s/.test(trimmed)) {
88
+ return `${open}"${trimmed}"${close}`
89
+ }
90
+ return whole
91
+ },
92
+ )
93
+ return quoted
94
+ .replace(/\uE000(\d+)\uE001/g, (whole, i: string) => spans[Number(i)] ?? whole)
95
+ .replace(/\uE002(\d+)\uE003/g, (whole, i: string) => {
96
+ const text = pipeSpans[Number(i)]
97
+ return text === undefined ? whole : `|"${text}"|`
98
+ })
99
+ }
100
+
101
+ /** Flowchart edge arrows. The negative lookahead on `-->(?!>)` keeps
102
+ * sequence-diagram message arrows (`-->>`) from matching. */
103
+ const FLOWCHART_EDGE = /-->(?!>)|==>|-\.->/
104
+
105
+ /**
106
+ * Lenient repair for sources that omit the mandatory diagram-type
107
+ * declaration line — the #1 failure mode of model-generated mermaid (chat
108
+ * assistants routinely emit `A --> B` without the leading `graph TD`).
109
+ *
110
+ * When the first token is not an already-declared `graph`/`flowchart` kind
111
+ * but the body contains flowchart edge arrows, prepend `graph TD` so the
112
+ * diagram renders instead of tripping the kind gate. Anything else — an
113
+ * explicitly declared kind, a sequence/gantt/pie body (no flowchart edges),
114
+ * or plain text — is returned unchanged: we never guess at the diagram type.
115
+ */
116
+ export function ensureFlowchartKind(code: string): string {
117
+ const trimmed = code.trim()
118
+ const kind = /^([A-Za-z]+)/.exec(trimmed)?.[1] ?? ''
119
+ if (kind === 'graph' || kind === 'flowchart') return code
120
+ if (!FLOWCHART_EDGE.test(trimmed)) return code
121
+ return `graph TD\n${trimmed}`
122
+ }