@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,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
|
+
}
|