@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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session GenUI panel: the dock surface that renders the latest `render_ui`
|
|
3
|
+
* spec IN PLACE. The toolview publishes every new result into the panel
|
|
4
|
+
* store; this component subscribes and re-renders the same block, so a
|
|
5
|
+
* refresh (model calls render_ui again) updates the panel instead of the
|
|
6
|
+
* message flow accumulating new UI per round.
|
|
7
|
+
*
|
|
8
|
+
* Mounted through the 'conversation.input.dock' list slot (TodoDock posture)
|
|
9
|
+
* — a session-scoped, always-present seat above the composer — so the panel
|
|
10
|
+
* coexists with the message flow on one screen. Absent spec = no panel.
|
|
11
|
+
*
|
|
12
|
+
* The dock is COLLAPSIBLE (TodoDock pattern), defaulting to a one-line
|
|
13
|
+
* header: a tall always-open panel pins above the composer and crushes the
|
|
14
|
+
* message flow's visible area, making history feel unreachable. Collapsed,
|
|
15
|
+
* it takes a single row; expanded, the body caps its height and scrolls
|
|
16
|
+
* internally, so the conversation stays scrollable either way.
|
|
17
|
+
*
|
|
18
|
+
* The expanded body is vertically RESIZABLE: a drag strip between the header
|
|
19
|
+
* and the body adjusts the body height (clamped to sane bounds); the custom
|
|
20
|
+
* height survives expand/collapse cycles for the session's life.
|
|
21
|
+
*
|
|
22
|
+
* Actions ride the same GenuiActionContext contract as inline fences: the
|
|
23
|
+
* sendGenuiAction injection (built from the scoped conversation service in
|
|
24
|
+
* apply) queues a [genui-action] user message back to the model.
|
|
25
|
+
*/
|
|
26
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
|
28
|
+
import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
|
|
29
|
+
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
30
|
+
import { GenuiBlock } from './GenuiBlock.tsx'
|
|
31
|
+
import { ErrorBoundary } from './ErrorBoundary.tsx'
|
|
32
|
+
import { panelStateKey } from './interaction-store.ts'
|
|
33
|
+
import { clearSessionPanel, getPanelExpandToken, getPanelSpec, setLocalPanel, subscribePanel, subscribePanelExpand } from './panel-store.ts'
|
|
34
|
+
import css from './GenuiBlock.module.css'
|
|
35
|
+
|
|
36
|
+
/** Resize bounds for the panel body, in px. */
|
|
37
|
+
const PANEL_HEIGHT_MIN = 120
|
|
38
|
+
const PANEL_HEIGHT_MAX = 600
|
|
39
|
+
|
|
40
|
+
/** Injection face built per session in apply (scoped conversation send). */
|
|
41
|
+
export interface GenuiPanelInjected {
|
|
42
|
+
sessionId: string
|
|
43
|
+
sendGenuiAction: GenuiActionHandler
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPanelInjected
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Panel dock entry. Renders nothing until the session's toolview published a
|
|
50
|
+
* spec; afterwards the SAME block re-renders on every publish. Collapsed by
|
|
51
|
+
* default so the dock never steals the message flow's scroll room; the
|
|
52
|
+
* header always shows the current panel title.
|
|
53
|
+
*/
|
|
54
|
+
export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
|
|
55
|
+
const spec = useSyncExternalStore(subscribePanel, () => getPanelSpec(sessionId))
|
|
56
|
+
const expandToken = useSyncExternalStore(subscribePanelExpand, () => getPanelExpandToken(sessionId))
|
|
57
|
+
const [collapsed, setCollapsed] = useState(true)
|
|
58
|
+
// Custom body height from the resize drag; null = the CSS default (360px).
|
|
59
|
+
const [bodyHeight, setBodyHeight] = useState<number | null>(null)
|
|
60
|
+
const [resizing, setResizing] = useState(false)
|
|
61
|
+
const dragStart = useRef<{ y: number; height: number } | null>(null)
|
|
62
|
+
const bodyRef = useRef<HTMLDivElement | null>(null)
|
|
63
|
+
|
|
64
|
+
// Explicit expand requests (the /panel command) open the dock even when the
|
|
65
|
+
// user collapsed it manually: the monotone token guarantees a fresh request
|
|
66
|
+
// is never mistaken for the previous one.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (expandToken > 0) setCollapsed(false)
|
|
69
|
+
}, [expandToken])
|
|
70
|
+
|
|
71
|
+
// Session teardown: the dock is session-scoped, so unmount means the host
|
|
72
|
+
// pruned or destroyed the session's scope. Release the session's panel
|
|
73
|
+
// state (snapshot, ops, overflow diagnostics, expand token) — without this
|
|
74
|
+
// the module-level store grows one entry per session for the app's whole
|
|
75
|
+
// lifetime. A reopen lazily rebuilds from history replays, which is the
|
|
76
|
+
// host's own lifecycle contract.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => {
|
|
79
|
+
clearSessionPanel(sessionId)
|
|
80
|
+
}
|
|
81
|
+
}, [sessionId])
|
|
82
|
+
|
|
83
|
+
// Cleanup safety net: if the component unmounts mid-drag (spec cleared),
|
|
84
|
+
// the window listeners must not outlive it.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!resizing) return
|
|
87
|
+
return () => {
|
|
88
|
+
dragStart.current = null
|
|
89
|
+
}
|
|
90
|
+
}, [resizing])
|
|
91
|
+
|
|
92
|
+
/** Start a vertical resize drag on the panel's top edge. Dragging UP grows
|
|
93
|
+
* the panel (the edge moves toward the message flow); dragging down shrinks
|
|
94
|
+
* it. Pointer capture binds move/up/cancel to the HANDLE, so dragging past
|
|
95
|
+
* the element edge keeps tracking and nothing global is registered — no
|
|
96
|
+
* window listeners, nothing to leak on unmount. */
|
|
97
|
+
const startResize = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
const handle = e.currentTarget
|
|
100
|
+
// Track from the current committed height (state mirrors the drag, the
|
|
101
|
+
// CSS default otherwise) — no DOM measurement needed.
|
|
102
|
+
const startHeight = bodyHeight ?? 360
|
|
103
|
+
dragStart.current = { y: e.clientY, height: startHeight }
|
|
104
|
+
setResizing(true)
|
|
105
|
+
try {
|
|
106
|
+
handle.setPointerCapture(e.pointerId)
|
|
107
|
+
} catch {
|
|
108
|
+
// jsdom / capture unsupported: listeners below still track the drag.
|
|
109
|
+
}
|
|
110
|
+
const onMove = (ev: PointerEvent): void => {
|
|
111
|
+
const start = dragStart.current
|
|
112
|
+
if (start === null) return
|
|
113
|
+
// Upward drag (clientY decreasing) grows the body height.
|
|
114
|
+
const next = Math.min(PANEL_HEIGHT_MAX, Math.max(PANEL_HEIGHT_MIN, start.height + (start.y - ev.clientY)))
|
|
115
|
+
setBodyHeight(next)
|
|
116
|
+
}
|
|
117
|
+
const onUp = (): void => {
|
|
118
|
+
dragStart.current = null
|
|
119
|
+
setResizing(false)
|
|
120
|
+
handle.removeEventListener('pointermove', onMove)
|
|
121
|
+
handle.removeEventListener('pointerup', onUp)
|
|
122
|
+
handle.removeEventListener('pointercancel', onUp)
|
|
123
|
+
}
|
|
124
|
+
handle.addEventListener('pointermove', onMove)
|
|
125
|
+
handle.addEventListener('pointerup', onUp)
|
|
126
|
+
handle.addEventListener('pointercancel', onUp)
|
|
127
|
+
}, [bodyHeight])
|
|
128
|
+
|
|
129
|
+
if (spec === null || spec.items.length === 0) return null
|
|
130
|
+
return (
|
|
131
|
+
<div className={css.panel} data-genui-panel>
|
|
132
|
+
{/* Resize grip on the panel's TOP EDGE (above the header): the drag
|
|
133
|
+
target the user described — the outermost top border, not a strip
|
|
134
|
+
under the title. */}
|
|
135
|
+
{!collapsed && (
|
|
136
|
+
<div
|
|
137
|
+
role="separator"
|
|
138
|
+
aria-orientation="horizontal"
|
|
139
|
+
aria-label="调整面板高度"
|
|
140
|
+
className={`${css.panelResizeHandle}${resizing ? ` ${css.panelResizeHandleActive}` : ''}`}
|
|
141
|
+
onPointerDown={startResize}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
<div className={css.panelHeader}>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
className={css.panelToggle}
|
|
148
|
+
aria-expanded={!collapsed}
|
|
149
|
+
onClick={() => setCollapsed(c => !c)}
|
|
150
|
+
>
|
|
151
|
+
<span className={css.panelBadge}>面板</span>
|
|
152
|
+
<span className={css.panelTitle}>{spec.title ?? 'GenUI 面板'}</span>
|
|
153
|
+
<span className={css.panelChevron} aria-hidden>{collapsed ? '▸' : '▾'}</span>
|
|
154
|
+
</button>
|
|
155
|
+
{/* In-place dismiss (issue #23): the same local override `/panel
|
|
156
|
+
clear` applies — persists to localStorage, notifies subscribers,
|
|
157
|
+
unmounts the dock without any navigation or reload. */}
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className={css.panelClose}
|
|
161
|
+
aria-label="关闭面板"
|
|
162
|
+
title="关闭面板"
|
|
163
|
+
onClick={() => setLocalPanel(sessionId, null)}
|
|
164
|
+
>
|
|
165
|
+
<span aria-hidden>✕</span>
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
{!collapsed && (
|
|
169
|
+
<div
|
|
170
|
+
ref={bodyRef}
|
|
171
|
+
className={css.panelBody}
|
|
172
|
+
data-genui-panel-body
|
|
173
|
+
style={bodyHeight === null ? undefined : { height: bodyHeight }}
|
|
174
|
+
>
|
|
175
|
+
<GenuiActionContext.Provider value={sendGenuiAction}>
|
|
176
|
+
<ErrorBoundary label="面板">
|
|
177
|
+
{/* content-fingerprinted: same panel spec re-published restores its state */}
|
|
178
|
+
<GenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
|
|
179
|
+
</ErrorBoundary>
|
|
180
|
+
</GenuiActionContext.Provider>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Partial GenUI spec parsing: extract the components that are already
|
|
3
|
+
* complete from a still-growing ```dsh-ui fence body, so the UI can render
|
|
4
|
+
* top-down as the model writes — each finished component appears the moment
|
|
5
|
+
* its JSON object closes, instead of the whole block waiting for the fence.
|
|
6
|
+
*
|
|
7
|
+
* Strategy (tolerant, white-list-agnostic, BOUNDED):
|
|
8
|
+
* 1. Full parse first (the common settled case) — at most one.
|
|
9
|
+
* 2. ONE left-to-right scan collects the repair candidates: every position
|
|
10
|
+
* where the prefix is bracket-balanced (trailing comma / fence tail) and
|
|
11
|
+
* every `}` object close within the depth budget (an unfinished trailing
|
|
12
|
+
* element, closed by appending the remaining brackets). The ring buffer
|
|
13
|
+
* keeps only the longest-direction candidates (≤ MAX_PARTIAL_REPAIR_ATTEMPTS),
|
|
14
|
+
* so the work is O(n) with a hard parse-attempt cap — a pathological
|
|
15
|
+
* input can never re-scan prefixes or burn seconds in JSON.parse.
|
|
16
|
+
* 3. Candidates are tried longest first; the first that parses as a GenUI
|
|
17
|
+
* spec wins. Nothing parses → null (streaming partial, wait for more).
|
|
18
|
+
*
|
|
19
|
+
* The result is only ever a PREFIX of the intended spec, so it is always
|
|
20
|
+
* safe to render: components already present are complete and valid.
|
|
21
|
+
*
|
|
22
|
+
* `ponytail:` the 32-candidate / 33-parse budget is the protection ceiling
|
|
23
|
+
* for depth 8 / 200 nodes; only real streaming samples proving a recovery
|
|
24
|
+
* shortfall justify switching to a tokenizing parser.
|
|
25
|
+
*/
|
|
26
|
+
import { GENUI_LIMITS } from './guard.ts'
|
|
27
|
+
import { isGenuiSpec, type GenuiSpec } from './spec.ts'
|
|
28
|
+
import { wrapSingleComponentRoot } from './spec.ts'
|
|
29
|
+
|
|
30
|
+
/** Default repair-candidate budget (adjustable; see the design doc). */
|
|
31
|
+
export const MAX_PARTIAL_REPAIR_ATTEMPTS = 32
|
|
32
|
+
|
|
33
|
+
let repairAttemptsLimit = MAX_PARTIAL_REPAIR_ATTEMPTS
|
|
34
|
+
|
|
35
|
+
/** Override the repair-candidate budget (tests / tuning). */
|
|
36
|
+
export function setMaxPartialRepairAttempts(n: number): void {
|
|
37
|
+
repairAttemptsLimit = n
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One repair candidate: a balanced prefix of the body ending at `end`,
|
|
41
|
+
* plus the closing brackets to append (empty when already balanced). */
|
|
42
|
+
export interface PartialCandidate {
|
|
43
|
+
end: number
|
|
44
|
+
closingSuffix: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Single left-to-right pass over the raw body. Tracks the bracket stack
|
|
48
|
+
* (skipping strings/escapes correctly) and records:
|
|
49
|
+
* - every position where the prefix is fully balanced (trailing comma or
|
|
50
|
+
* fence tail) — candidate with an empty closing suffix;
|
|
51
|
+
* - every `}` object close whose remaining depth fits the spec budget —
|
|
52
|
+
* candidate with the remaining brackets closed (an unfinished trailing
|
|
53
|
+
* element).
|
|
54
|
+
* Candidates are ring-buffered to the attempts budget (the LAST pushes are
|
|
55
|
+
* the longest), then returned longest-first, deduplicated by end. The scan
|
|
56
|
+
* stops at the first unbalanced close — earlier balanced prefixes remain
|
|
57
|
+
* valid candidates.
|
|
58
|
+
*
|
|
59
|
+
* Exposed for tests (the `scannedChars` diagnostic); not exported from the
|
|
60
|
+
* package entry. The parser calls this exactly once per parse.
|
|
61
|
+
*/
|
|
62
|
+
export function collectPartialCandidates(raw: string): { candidates: PartialCandidate[]; scannedChars: number } {
|
|
63
|
+
const stack: string[] = []
|
|
64
|
+
const candidates: PartialCandidate[] = []
|
|
65
|
+
const push = (c: PartialCandidate): void => {
|
|
66
|
+
if (candidates.length >= repairAttemptsLimit) candidates.shift()
|
|
67
|
+
candidates.push(c)
|
|
68
|
+
}
|
|
69
|
+
let inString = false
|
|
70
|
+
let escaped = false
|
|
71
|
+
let scanned = 0
|
|
72
|
+
for (; scanned < raw.length; scanned++) {
|
|
73
|
+
const ch = raw[scanned]!
|
|
74
|
+
if (inString) {
|
|
75
|
+
if (escaped) escaped = false
|
|
76
|
+
else if (ch === '\\') escaped = true
|
|
77
|
+
else if (ch === '"') inString = false
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (ch === '"') {
|
|
81
|
+
inString = true
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (ch === '{' || ch === '[') {
|
|
85
|
+
stack.push(ch)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (ch === '}' || ch === ']') {
|
|
89
|
+
const open = stack.pop()
|
|
90
|
+
const expects = ch === '}' ? '{' : '['
|
|
91
|
+
if (open !== expects) {
|
|
92
|
+
// Unbalanced (mismatched or stray close): the tail is unusable, but
|
|
93
|
+
// balanced prefixes recorded earlier stay valid. Stop scanning.
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
if (ch === '}' && stack.length <= GENUI_LIMITS.maxDepth) {
|
|
97
|
+
// A finished object element: closing the remaining brackets yields
|
|
98
|
+
// the longest legal prefix (an unfinished trailing element drops).
|
|
99
|
+
push({ end: scanned + 1, closingSuffix: closeSuffix(stack) })
|
|
100
|
+
}
|
|
101
|
+
if (stack.length === 0) {
|
|
102
|
+
// Whole prefix balanced: a complete-JSON candidate (trailing comma /
|
|
103
|
+
// fence tail cases).
|
|
104
|
+
push({ end: scanned + 1, closingSuffix: '' })
|
|
105
|
+
}
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
candidates.sort((a, b) => b.end - a.end)
|
|
110
|
+
const deduped: PartialCandidate[] = []
|
|
111
|
+
for (const c of candidates) {
|
|
112
|
+
if (deduped.length === 0 || deduped[deduped.length - 1]!.end !== c.end) deduped.push(c)
|
|
113
|
+
}
|
|
114
|
+
return { candidates: deduped.slice(0, repairAttemptsLimit), scannedChars: scanned }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Closing brackets for the remaining open stack (top first). */
|
|
118
|
+
function closeSuffix(stack: string[]): string {
|
|
119
|
+
let suffix = ''
|
|
120
|
+
for (let i = stack.length - 1; i >= 0; i--) suffix += stack[i] === '{' ? '}' : ']'
|
|
121
|
+
return suffix
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Try to parse a candidate as a GenuiSpec. */
|
|
125
|
+
function trySpec(candidate: string): GenuiSpec | null {
|
|
126
|
+
try {
|
|
127
|
+
const value: unknown = JSON.parse(candidate)
|
|
128
|
+
if (isGenuiSpec(value)) return value
|
|
129
|
+
// Single-component roots are part of the documented fence vocabulary
|
|
130
|
+
// (e.g. a bare {"type":"callout",…} body) — wrap into a col so the
|
|
131
|
+
// items-gated pipeline renders them (panel/append hoisted).
|
|
132
|
+
return wrapSingleComponentRoot(value)
|
|
133
|
+
} catch {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parse a possibly incomplete genui spec body.
|
|
140
|
+
* @param raw - the fence body as accumulated so far.
|
|
141
|
+
* @returns a spec containing only finished components, or null when nothing
|
|
142
|
+
* usable has been written yet.
|
|
143
|
+
*/
|
|
144
|
+
export function parsePartialGenuiSpec(raw: string): GenuiSpec | null {
|
|
145
|
+
const text = raw.trim()
|
|
146
|
+
if (text === '') return null
|
|
147
|
+
|
|
148
|
+
// 1. Full parse (settled / already-complete body).
|
|
149
|
+
const full = trySpec(text)
|
|
150
|
+
if (full !== null) return full
|
|
151
|
+
|
|
152
|
+
// 2. Bounded repair: ONE forward scan, at most `repairAttemptsLimit`
|
|
153
|
+
// candidates, longest first — never a re-scan per `}`.
|
|
154
|
+
const { candidates } = collectPartialCandidates(text)
|
|
155
|
+
for (const candidate of candidates) {
|
|
156
|
+
const spec = trySpec(text.slice(0, candidate.end) + candidate.closingSuffix)
|
|
157
|
+
if (spec !== null) return spec
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 3. Not even one complete element yet (e.g. `{"items":[{"type":"tex`).
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeMath: a restricted math expression evaluator for GenUI plot specs.
|
|
3
|
+
*
|
|
4
|
+
* The model supplies an expression string like "sin(x) * 2 + 1". It is parsed
|
|
5
|
+
* and evaluated here WITHOUT eval / new Function: a hand-written recursive
|
|
6
|
+
* descent parser over a white-listed function/constant vocabulary. Unknown
|
|
7
|
+
* identifiers, object property access, and call syntax other than the
|
|
8
|
+
* white-listed functions are parse errors, so an adversarial expression
|
|
9
|
+
* cannot reach globals, prototypes, or side effects.
|
|
10
|
+
*
|
|
11
|
+
* Supported grammar:
|
|
12
|
+
* expr := term (('+' | '-') term)*
|
|
13
|
+
* term := unary (('*' | '/' | '%') unary)*
|
|
14
|
+
* unary := ('-' | '+') unary | power
|
|
15
|
+
* power := atom ('^' atom)?
|
|
16
|
+
* atom := number | variable | constant | func '(' expr ')' | '(' expr ')'
|
|
17
|
+
* variable: 'x' (and any single-letter variable present in vars)
|
|
18
|
+
* constant: 'pi' | 'e' | 'tau'
|
|
19
|
+
* func : sin cos tan asin acos atan sqrt cbrt exp log ln abs floor ceil round min max pow
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface SafeMathOptions {
|
|
23
|
+
/** Variable values; only names present here may be referenced as variables. */
|
|
24
|
+
vars?: Record<string, number>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CONSTANTS: Record<string, number> = {
|
|
28
|
+
pi: Math.PI,
|
|
29
|
+
e: Math.E,
|
|
30
|
+
tau: Math.PI * 2,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FUNCTIONS: Record<string, (...args: number[]) => number> = {
|
|
34
|
+
sin: Math.sin, cos: Math.cos, tan: Math.tan,
|
|
35
|
+
asin: Math.asin, acos: Math.acos, atan: Math.atan,
|
|
36
|
+
sqrt: Math.sqrt, cbrt: Math.cbrt,
|
|
37
|
+
exp: Math.exp, log: Math.log10, ln: Math.log,
|
|
38
|
+
abs: Math.abs, floor: Math.floor, ceil: Math.ceil, round: Math.round,
|
|
39
|
+
min: Math.min, max: Math.max,
|
|
40
|
+
pow: (a, b) => Math.pow(a, b),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class ParseError extends Error {
|
|
44
|
+
constructor(message: string, readonly pos: number) {
|
|
45
|
+
super(`SafeMath parse error at ${pos}: ${message}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class SafeMathParser {
|
|
50
|
+
private i = 0
|
|
51
|
+
constructor(private readonly src: string, private readonly vars: Record<string, number>) {}
|
|
52
|
+
|
|
53
|
+
parse(): (x: number) => number {
|
|
54
|
+
const node = this.parseExpr()
|
|
55
|
+
this.skipWs()
|
|
56
|
+
if (this.i < this.src.length) throw new ParseError('unexpected trailing input', this.i)
|
|
57
|
+
return this.evalNode(node)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private evalNode(node: number | ((x: number) => number)): (x: number) => number {
|
|
61
|
+
if (typeof node === 'number') return () => node
|
|
62
|
+
return node
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private parseExpr(): number | ((x: number) => number) {
|
|
66
|
+
let left = this.parseTerm()
|
|
67
|
+
for (;;) {
|
|
68
|
+
this.skipWs()
|
|
69
|
+
const c = this.peek()
|
|
70
|
+
if (c === '+' || c === '-') {
|
|
71
|
+
this.i++
|
|
72
|
+
const right = this.parseTerm()
|
|
73
|
+
const op = c
|
|
74
|
+
const prev = left
|
|
75
|
+
left = op === '+'
|
|
76
|
+
? (x) => this.asNum(prev, x) + this.asNum(right, x)
|
|
77
|
+
: (x) => this.asNum(prev, x) - this.asNum(right, x)
|
|
78
|
+
} else return left
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private parseTerm(): number | ((x: number) => number) {
|
|
83
|
+
let left = this.parseUnary()
|
|
84
|
+
for (;;) {
|
|
85
|
+
this.skipWs()
|
|
86
|
+
const c = this.peek()
|
|
87
|
+
if (c === '*' || c === '/' || c === '%') {
|
|
88
|
+
this.i++
|
|
89
|
+
const right = this.parseUnary()
|
|
90
|
+
const op = c
|
|
91
|
+
const prev = left
|
|
92
|
+
left = op === '*'
|
|
93
|
+
? (x) => this.asNum(prev, x) * this.asNum(right, x)
|
|
94
|
+
: op === '/'
|
|
95
|
+
? (x) => this.asNum(prev, x) / this.asNum(right, x)
|
|
96
|
+
: (x) => this.asNum(prev, x) % this.asNum(right, x)
|
|
97
|
+
} else return left
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private parseUnary(): number | ((x: number) => number) {
|
|
102
|
+
this.skipWs()
|
|
103
|
+
const c = this.char()
|
|
104
|
+
if (c === '-' || c === '+') {
|
|
105
|
+
this.i++
|
|
106
|
+
const operand = this.parseUnary()
|
|
107
|
+
return c === '-' ? (x) => -this.asNum(operand, x) : (x) => this.asNum(operand, x)
|
|
108
|
+
}
|
|
109
|
+
return this.parsePower()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private parsePower(): number | ((x: number) => number) {
|
|
113
|
+
const base = this.parseAtom()
|
|
114
|
+
this.skipWs()
|
|
115
|
+
if (this.peek() === '^') {
|
|
116
|
+
this.i++
|
|
117
|
+
const exp = this.parsePower()
|
|
118
|
+
const prev = base
|
|
119
|
+
return (x) => Math.pow(this.asNum(prev, x), this.asNum(exp, x))
|
|
120
|
+
}
|
|
121
|
+
return base
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private parseAtom(): number | ((x: number) => number) {
|
|
125
|
+
this.skipWs()
|
|
126
|
+
const c = this.char()
|
|
127
|
+
if (c === '(') {
|
|
128
|
+
this.i++
|
|
129
|
+
const inner = this.parseExpr()
|
|
130
|
+
this.skipWs()
|
|
131
|
+
if (this.peek() !== ')') throw new ParseError('expected )', this.i)
|
|
132
|
+
this.i++
|
|
133
|
+
return inner
|
|
134
|
+
}
|
|
135
|
+
if (c >= '0' && c <= '9' || c === '.') return this.parseNumber()
|
|
136
|
+
if (this.isIdentStart(c)) return this.parseIdent()
|
|
137
|
+
throw new ParseError(`unexpected character '${c}'`, this.i)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private parseNumber(): number {
|
|
141
|
+
const start = this.i
|
|
142
|
+
while (this.i < this.src.length && /[0-9.eE+\-]/.test(this.src[this.i]!)) this.i++
|
|
143
|
+
const text = this.src.slice(start, this.i)
|
|
144
|
+
const value = Number(text)
|
|
145
|
+
if (Number.isNaN(value)) throw new ParseError(`invalid number '${text}'`, start)
|
|
146
|
+
return value
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private parseIdent(): number | ((x: number) => number) {
|
|
150
|
+
const start = this.i
|
|
151
|
+
while (this.i < this.src.length && this.isIdentChar(this.src[this.i]!)) this.i++
|
|
152
|
+
const name = this.src.slice(start, this.i)
|
|
153
|
+
this.skipWs()
|
|
154
|
+
if (this.peek() === '(') {
|
|
155
|
+
// Function call.
|
|
156
|
+
const fn = FUNCTIONS[name]
|
|
157
|
+
if (fn === undefined) throw new ParseError(`unknown function '${name}'`, start)
|
|
158
|
+
this.i++
|
|
159
|
+
const args: Array<number | ((x: number) => number)> = []
|
|
160
|
+
this.skipWs()
|
|
161
|
+
if (this.peek() === ')') {
|
|
162
|
+
this.i++
|
|
163
|
+
} else {
|
|
164
|
+
for (;;) {
|
|
165
|
+
args.push(this.parseExpr())
|
|
166
|
+
this.skipWs()
|
|
167
|
+
const sep = this.peek()
|
|
168
|
+
if (sep === ',') { this.i++; continue }
|
|
169
|
+
if (sep === ')') { this.i++; break }
|
|
170
|
+
throw new ParseError('expected , or ) in call', this.i)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return (x) => fn(...args.map(a => this.asNum(a, x)))
|
|
174
|
+
}
|
|
175
|
+
// Own-property checks only: `in` would match inherited members like
|
|
176
|
+
// `constructor`/`toString` on the vars object.
|
|
177
|
+
if (Object.hasOwn(CONSTANTS, name)) return CONSTANTS[name]!
|
|
178
|
+
if (name === 'x') {
|
|
179
|
+
// The sampling variable resolves against the evaluation argument.
|
|
180
|
+
return (value: number) => value
|
|
181
|
+
}
|
|
182
|
+
if (Object.hasOwn(this.vars, name)) {
|
|
183
|
+
// A declared parameter (a/b/c…) resolves against its captured value,
|
|
184
|
+
// which the caller changes by recompiling with new vars.
|
|
185
|
+
const captured = this.vars[name]!
|
|
186
|
+
return () => captured
|
|
187
|
+
}
|
|
188
|
+
// Implicit parameters: an undeclared single-lowercase-letter identifier
|
|
189
|
+
// (a/b/k/m/n…) is a parameter that defaults to 1, so a model expression
|
|
190
|
+
// like "a*sin(b*x)" without a params declaration still renders instead
|
|
191
|
+
// of failing as "unknown identifier". This is safe: only [a-z] matches,
|
|
192
|
+
// so injection names (constructor, window, process…) still parse-error.
|
|
193
|
+
if (/^[a-z]$/.test(name)) return () => 1
|
|
194
|
+
throw new ParseError(`unknown identifier '${name}'`, start)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private asNum(node: number | ((x: number) => number), x: number): number {
|
|
198
|
+
return typeof node === 'number' ? node : node(x)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private skipWs(): void {
|
|
202
|
+
while (this.i < this.src.length && /\s/.test(this.src[this.i]!)) this.i++
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private peek(): string | undefined {
|
|
206
|
+
return this.src[this.i]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private char(): string {
|
|
210
|
+
const c = this.peek()
|
|
211
|
+
if (c === undefined) throw new ParseError('unexpected end of input', this.i)
|
|
212
|
+
return c
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private isIdentStart(c: string | undefined): boolean {
|
|
216
|
+
return c !== undefined && /[a-zA-Z_]/.test(c)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private isIdentChar(c: string): boolean {
|
|
220
|
+
return /[a-zA-Z0-9_]/.test(c)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Compile an expression into an evaluable function.
|
|
226
|
+
* @param expr - the math expression. `x` is the sampling variable; any other
|
|
227
|
+
* name in `vars` is a parameter whose captured value the evaluator returns
|
|
228
|
+
* (recompile with new vars to change a parameter — expressions are short).
|
|
229
|
+
* @param options - parameter values; `x` is reserved and ignored here.
|
|
230
|
+
* @returns evaluator, or null when the expression is invalid.
|
|
231
|
+
*/
|
|
232
|
+
export function compileMathExpr(expr: string, options: SafeMathOptions = {}): ((x: number) => number) | null {
|
|
233
|
+
try {
|
|
234
|
+
// `x` is the sampling variable and must never be shadowed by a param.
|
|
235
|
+
const vars: Record<string, number> = { ...options.vars }
|
|
236
|
+
delete vars.x
|
|
237
|
+
return new SafeMathParser(expr, vars).parse()
|
|
238
|
+
} catch {
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Sample an expression over a range.
|
|
245
|
+
* @param expr - math expression in x, with optional parameters.
|
|
246
|
+
* @param xMin - inclusive start.
|
|
247
|
+
* @param xMax - inclusive end.
|
|
248
|
+
* @param samples - number of samples (>= 2).
|
|
249
|
+
* @param params - parameter values (e.g. { a: 2, b: 3 }); recompile per change.
|
|
250
|
+
* @returns [x, y] pairs; non-finite samples are dropped.
|
|
251
|
+
*/
|
|
252
|
+
export function sampleExpr(
|
|
253
|
+
expr: string,
|
|
254
|
+
xMin: number,
|
|
255
|
+
xMax: number,
|
|
256
|
+
samples = 200,
|
|
257
|
+
params: Record<string, number> = {},
|
|
258
|
+
): Array<[number, number]> {
|
|
259
|
+
const fn = compileMathExpr(expr, { vars: params })
|
|
260
|
+
if (fn === null) return []
|
|
261
|
+
if (!Number.isFinite(xMin) || !Number.isFinite(xMax) || xMax <= xMin || samples < 2) return []
|
|
262
|
+
const points: Array<[number, number]> = []
|
|
263
|
+
const step = (xMax - xMin) / (samples - 1)
|
|
264
|
+
for (let i = 0; i < samples; i++) {
|
|
265
|
+
const x = xMin + step * i
|
|
266
|
+
const y = fn(x)
|
|
267
|
+
if (Number.isFinite(y)) points.push([x, y])
|
|
268
|
+
}
|
|
269
|
+
return points
|
|
270
|
+
}
|