@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.
- package/CHANGELOG.md +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- 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
|
+
}
|