@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /panel slash command + the default panel spec.
|
|
3
|
+
*
|
|
4
|
+
* The session panel dock is an always-present seat, but it only becomes
|
|
5
|
+
* visible once a spec has been published — both existing publish paths
|
|
6
|
+
* (render_ui tool result, panel:true fence) are model-driven. The /panel
|
|
7
|
+
* command gives a deterministic, client-side entry point:
|
|
8
|
+
* - `/panel` — publishes the default spec and requests the dock to expand
|
|
9
|
+
* instantly, with zero model round-trip;
|
|
10
|
+
* - `/panel clear` (off/close) — empties the panel so the dock retracts;
|
|
11
|
+
* - `/panel <指令>` — shows the default spec for immediate feedback, then
|
|
12
|
+
* relays the instruction to the model, which replaces the panel with
|
|
13
|
+
* content tailored to the request (panel:true fence).
|
|
14
|
+
* Panel updates afterwards still flow through the model (say "更新面板" or
|
|
15
|
+
* re-run render_ui) or through another /panel.
|
|
16
|
+
*/
|
|
17
|
+
import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
18
|
+
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
19
|
+
import type { GenuiSpec } from './spec.ts'
|
|
20
|
+
import { requestPanelExpand, setLocalPanel } from './panel-store.ts'
|
|
21
|
+
|
|
22
|
+
/** Default panel content published by `/panel`: the component overview. */
|
|
23
|
+
export const DEFAULT_PANEL_SPEC: GenuiSpec = {
|
|
24
|
+
title: 'GenUI 面板',
|
|
25
|
+
items: [
|
|
26
|
+
{ type: 'text', size: 'h3', content: 'GenUI 生成式界面' },
|
|
27
|
+
{ type: 'text', size: 'muted', content: '面板会原地更新:对话里说「更新面板」,或再次执行 /panel 刷新。' },
|
|
28
|
+
{
|
|
29
|
+
type: 'grid', cols: 4, items: [
|
|
30
|
+
{ type: 'stat', label: '组件', value: '38' },
|
|
31
|
+
{ type: 'stat', label: '单个', value: '12' },
|
|
32
|
+
{ type: 'stat', label: '组合', value: '8' },
|
|
33
|
+
{ type: 'stat', label: '高级', value: '18' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: 'list', items: [
|
|
38
|
+
{ title: '单个 ×12', desc: 'text button input select checkbox link badge stat progress divider avatar spacer' },
|
|
39
|
+
{ title: '组合 ×8', desc: 'row col grid card list table chart tabs' },
|
|
40
|
+
{ title: '数据 ×7', desc: 'plot callout steps keyvalue diff json code' },
|
|
41
|
+
{ title: '交互 ×5', desc: 'radio switch textarea accordion copy' },
|
|
42
|
+
{ title: '高级 ×5', desc: 'mermaid scene3d timeline file-tree breadcrumb' },
|
|
43
|
+
{ title: '教学 ×1', desc: 'quiz' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{ type: 'callout', tone: 'info', title: '更新方式', content: '对话说「更新面板」→ 模型输出 panel:true 围栏;/panel clear 清空面板。' },
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Shared command application: apply the local override (default panel +
|
|
51
|
+
* expand, or clear). The override is the fold base and shields against every
|
|
52
|
+
* replay at/below the highest message seq seen so far; the next later real
|
|
53
|
+
* tool/fence operation replaces or merges into it as usual. */
|
|
54
|
+
function applyPanelCommand(sessionId: string, args: string): void {
|
|
55
|
+
const cmd = args.trim().toLowerCase()
|
|
56
|
+
if (cmd === 'clear' || cmd === 'off' || cmd === 'close') {
|
|
57
|
+
setLocalPanel(sessionId, null)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
setLocalPanel(sessionId, DEFAULT_PANEL_SPEC)
|
|
61
|
+
requestPanelExpand(sessionId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the command claim for one session (span CAS is handled by the input).
|
|
66
|
+
* `sendInstruction` relays a /panel instruction to the model when the user
|
|
67
|
+
* typed more than a bare command — otherwise the instruction would be
|
|
68
|
+
* swallowed (the draft is cleared on submit) and the panel would never leave
|
|
69
|
+
* its default content.
|
|
70
|
+
*/
|
|
71
|
+
function panelClaim(sessionId: SessionId, sendInstruction: (sessionId: SessionId, instruction: string) => void) {
|
|
72
|
+
return {
|
|
73
|
+
token: '/panel',
|
|
74
|
+
hint: '开启 GenUI 面板;/panel <指令> 让模型定制;/panel clear 清空',
|
|
75
|
+
submit: async (args: string, _actx: ClientContext) => {
|
|
76
|
+
const instruction = args.trim()
|
|
77
|
+
if (instruction === '' ) {
|
|
78
|
+
applyPanelCommand(sessionId, '')
|
|
79
|
+
} else if (/^(clear|off|close)$/i.test(instruction)) {
|
|
80
|
+
applyPanelCommand(sessionId, instruction)
|
|
81
|
+
} else {
|
|
82
|
+
// Instructed panel: show the default spec instantly for feedback,
|
|
83
|
+
// then let the model replace it with the requested content.
|
|
84
|
+
applyPanelCommand(sessionId, '')
|
|
85
|
+
sendInstruction(sessionId, instruction)
|
|
86
|
+
}
|
|
87
|
+
// Success clears the draft without sending a message (the dock itself
|
|
88
|
+
// is the feedback); no `text` so the input shows no notice either.
|
|
89
|
+
return { kind: 'success' as const }
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The /panel source. Menu group `genui` under the '/' trigger; the panel
|
|
96
|
+
* candidate claims the line so both the menu pick and a bare `/panel` enter
|
|
97
|
+
* resolve to the same command. `matchEnter` is implemented so the command
|
|
98
|
+
* works without opening the menu (leading-token adjudication), and it also
|
|
99
|
+
* catches `/panel clear` style args.
|
|
100
|
+
*/
|
|
101
|
+
export function createPanelSlashSource(sendInstruction: (sessionId: SessionId, instruction: string) => void): InputTriggerSource {
|
|
102
|
+
return {
|
|
103
|
+
trigger: '/',
|
|
104
|
+
name: 'genui',
|
|
105
|
+
order: 60,
|
|
106
|
+
candidates: async (_session, _req) => [{
|
|
107
|
+
name: 'panel',
|
|
108
|
+
description: '开启 GenUI 面板(/panel clear 清空;/panel <指令> 定制内容)',
|
|
109
|
+
hint: '/panel',
|
|
110
|
+
}],
|
|
111
|
+
onPick(pick) {
|
|
112
|
+
return { claim: panelClaim(pick.session.sessionId, sendInstruction) }
|
|
113
|
+
},
|
|
114
|
+
matchEnter: async (session, line, _signal) => {
|
|
115
|
+
if (!/^\/panel(?:\s|$)/.test(line.trim())) return undefined
|
|
116
|
+
return { claim: panelClaim(session.sessionId, sendInstruction) }
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session GenUI panel store: folds stable per-source panel operations into
|
|
3
|
+
* one read-only snapshot per session. The toolview and the settled-fence
|
|
4
|
+
* publisher submit operations (`replace`/`append`) carrying a stable
|
|
5
|
+
* sourceId and a three-part order; the store deduplicates by source, sorts
|
|
6
|
+
* by order, applies a deterministic fold under a node/append budget, and
|
|
7
|
+
* notifies subscribers only when the folded snapshot actually changes.
|
|
8
|
+
*
|
|
9
|
+
* Ordering: no "last caller wins", no Infinity. Operations from DIFFERENT
|
|
10
|
+
* sources always both apply; replays of the same source are idempotent; a
|
|
11
|
+
* later replace resets everything earlier; an append beyond the budget is
|
|
12
|
+
* remembered as an overflow barrier so an out-of-order earlier replace can
|
|
13
|
+
* re-fold deterministically. A local `/panel` override (default panel or
|
|
14
|
+
* clear) is the fold base and blocks every replay at/below the highest
|
|
15
|
+
* message seq seen so far — old history can never resurrect the panel.
|
|
16
|
+
*
|
|
17
|
+
* Scale limits live in `PANEL_LIMITS` (adjustable defaults, not law — see
|
|
18
|
+
* docs/plans/2026-08-12-dsh-genui-design-optimization.md). Budget
|
|
19
|
+
* rejections are DETERMINISTIC (no LRU eviction: eviction would make the
|
|
20
|
+
* fold depend on arrival order).
|
|
21
|
+
*
|
|
22
|
+
*/
|
|
23
|
+
import type { GenuiSpec, GenuiTab } from './spec.ts'
|
|
24
|
+
import { countGenuiNodes } from './guard.ts'
|
|
25
|
+
|
|
26
|
+
/** Panel scale limits — adjustable defaults (design doc: scale limits are
|
|
27
|
+
* configurable, not law). Folds read the CURRENT limits, so tuning applies
|
|
28
|
+
* from the next operation on. */
|
|
29
|
+
export const PANEL_LIMITS = {
|
|
30
|
+
/** Max nodes in the folded panel snapshot. */
|
|
31
|
+
maxNodes: 200,
|
|
32
|
+
/** Max append operations kept after the latest replace. */
|
|
33
|
+
maxAppends: 200,
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
type PanelLimits = { maxNodes: number; maxAppends: number }
|
|
37
|
+
|
|
38
|
+
let limits: PanelLimits = { ...PANEL_LIMITS }
|
|
39
|
+
|
|
40
|
+
/** Override the panel limits (operator tuning / tests). Applied at the next fold. */
|
|
41
|
+
export function setPanelLimits(next: Partial<PanelLimits>): void {
|
|
42
|
+
limits = { ...limits, ...next }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Three-part stable order: [messageSeq, textBlockIndex, fenceIndex]. */
|
|
46
|
+
export type PanelOrder = readonly [number, number, number]
|
|
47
|
+
|
|
48
|
+
/** One persistent panel operation from a settled source. */
|
|
49
|
+
export interface PanelOperation {
|
|
50
|
+
/** Stable source identity (fence source id or `['render_ui', callId]`). */
|
|
51
|
+
sourceId: string
|
|
52
|
+
order: PanelOrder
|
|
53
|
+
mode: 'replace' | 'append'
|
|
54
|
+
spec: GenuiSpec
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Outcome of a fold, committed atomically. */
|
|
58
|
+
interface FoldResult {
|
|
59
|
+
snapshot: GenuiSpec | null
|
|
60
|
+
/** Accepted operations (latest replace + accepted appends after it). */
|
|
61
|
+
kept: PanelOperation[]
|
|
62
|
+
/** First append rejected by the budget, or null. */
|
|
63
|
+
overflow: PanelOperation | null
|
|
64
|
+
/** Highest message seq among the candidate ops. */
|
|
65
|
+
maxSeenSeq: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SessionPanelState {
|
|
69
|
+
ops: Map<string, PanelOperation>
|
|
70
|
+
/** Every sourceId ever processed (kept or dropped) — the replay dedup
|
|
71
|
+
* register. `ops` only keeps the ops in the current fold, so a consumed op
|
|
72
|
+
* dropped by a later replace used to be indistinguishable from a first
|
|
73
|
+
* arrival and re-folded on history scroll; `seen` makes replays idempotent
|
|
74
|
+
* without breaking genuine out-of-order first arrivals. */
|
|
75
|
+
seen: Map<string, PanelOrder>
|
|
76
|
+
overflow: PanelOperation | null
|
|
77
|
+
/** Local /panel override base; null = cleared. */
|
|
78
|
+
local: GenuiSpec | null
|
|
79
|
+
/** Replays at/below this seq are dead (blocked by the local override). */
|
|
80
|
+
localBarrier: number
|
|
81
|
+
/** Replays at/below this seq are dead (persisted-session hydration barrier;
|
|
82
|
+
* live sessions keep -1 — the seen register covers them). */
|
|
83
|
+
replayBarrier: number
|
|
84
|
+
maxSeenSeq: number
|
|
85
|
+
snapshot: GenuiSpec | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sessions = new Map<string, SessionPanelState>()
|
|
89
|
+
const listeners = new Set<() => void>()
|
|
90
|
+
|
|
91
|
+
function emptyState(): SessionPanelState {
|
|
92
|
+
return { ops: new Map(), seen: new Map(), overflow: null, local: null, localBarrier: -1, replayBarrier: -1, maxSeenSeq: -1, snapshot: null }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---------------- localStorage persistence ---------------- */
|
|
96
|
+
|
|
97
|
+
const STORAGE_KEY = 'dsh.genui.panel'
|
|
98
|
+
/** Max persisted sessions; LRU-evicted on write (the deterministic fold is
|
|
99
|
+
* unaffected — hydration only seeds the fold base, history replays rebuild
|
|
100
|
+
* anything evicted). */
|
|
101
|
+
const MAX_PERSISTED_SESSIONS = 50
|
|
102
|
+
|
|
103
|
+
/** What survives a page reload: the folded snapshot, the local /panel
|
|
104
|
+
* override, and the barriers that keep old replays from resurrecting either. */
|
|
105
|
+
interface PersistedPanel {
|
|
106
|
+
snapshot: GenuiSpec | null
|
|
107
|
+
local: GenuiSpec | null
|
|
108
|
+
localBarrier: number
|
|
109
|
+
maxSeenSeq: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface PanelStorageShape {
|
|
113
|
+
order: string[]
|
|
114
|
+
sessions: Record<string, PersistedPanel>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readPanelStorage(): PanelStorageShape {
|
|
118
|
+
try {
|
|
119
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
120
|
+
if (raw === null) return { order: [], sessions: {} }
|
|
121
|
+
const parsed = JSON.parse(raw) as Partial<PanelStorageShape>
|
|
122
|
+
if (!Array.isArray(parsed.order) || typeof parsed.sessions !== 'object' || parsed.sessions === null) {
|
|
123
|
+
return { order: [], sessions: {} }
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
order: parsed.order.filter(k => typeof k === 'string'),
|
|
127
|
+
sessions: parsed.sessions as Record<string, PersistedPanel>,
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
return { order: [], sessions: {} }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writePanelStorage(sessionId: string, entry: PersistedPanel): void {
|
|
135
|
+
try {
|
|
136
|
+
const store = readPanelStorage()
|
|
137
|
+
const order = store.order.filter(k => k !== sessionId)
|
|
138
|
+
order.unshift(sessionId)
|
|
139
|
+
const sessions = { ...store.sessions, [sessionId]: entry }
|
|
140
|
+
while (order.length > MAX_PERSISTED_SESSIONS) {
|
|
141
|
+
const evicted = order.pop()
|
|
142
|
+
if (evicted !== undefined) delete sessions[evicted]
|
|
143
|
+
}
|
|
144
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ order, sessions }))
|
|
145
|
+
} catch {
|
|
146
|
+
// Quota / privacy-mode failures are non-fatal: the panel stays live in
|
|
147
|
+
// memory, only cross-restart persistence is lost.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Lazy session state: hydrate from localStorage once per session when the
|
|
152
|
+
* in-memory record is absent (first access after a reload). The persisted
|
|
153
|
+
* snapshot + barriers restore the panel instantly; replays at/below the
|
|
154
|
+
* persisted maxSeenSeq are dead, so history scrolling can never resurrect or
|
|
155
|
+
* duplicate content. */
|
|
156
|
+
function stateOf(sessionId: string): SessionPanelState {
|
|
157
|
+
const existing = sessions.get(sessionId)
|
|
158
|
+
if (existing !== undefined) return existing
|
|
159
|
+
let state = emptyState()
|
|
160
|
+
try {
|
|
161
|
+
const stored = readPanelStorage().sessions[sessionId]
|
|
162
|
+
if (stored !== undefined) {
|
|
163
|
+
state = {
|
|
164
|
+
ops: new Map(),
|
|
165
|
+
seen: new Map(),
|
|
166
|
+
overflow: null,
|
|
167
|
+
local: stored.local ?? null,
|
|
168
|
+
localBarrier: stored.localBarrier ?? -1,
|
|
169
|
+
replayBarrier: stored.maxSeenSeq ?? -1,
|
|
170
|
+
maxSeenSeq: stored.maxSeenSeq ?? -1,
|
|
171
|
+
snapshot: stored.snapshot ?? null,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// storage unreadable: behave like a fresh session
|
|
176
|
+
}
|
|
177
|
+
sessions.set(sessionId, state)
|
|
178
|
+
return state
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function compareOrder(a: PanelOrder, b: PanelOrder): number {
|
|
182
|
+
for (let i = 0; i < 3; i++) {
|
|
183
|
+
const d = a[i]! - b[i]!
|
|
184
|
+
if (d !== 0) return d
|
|
185
|
+
}
|
|
186
|
+
return 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Deterministic candidate fold. Returns null when the candidate changes
|
|
191
|
+
* nothing (idempotent replay, replay blocked by the local barrier, or an
|
|
192
|
+
* append later than the overflow barrier); otherwise the state to commit.
|
|
193
|
+
* Never mutates the session state.
|
|
194
|
+
*/
|
|
195
|
+
function fold(state: SessionPanelState, extra: PanelOperation | null): FoldResult | null {
|
|
196
|
+
if (extra !== null) {
|
|
197
|
+
if (extra.order[0] <= state.localBarrier || extra.order[0] <= state.replayBarrier) return null // old replay under a barrier
|
|
198
|
+
if (state.seen.has(extra.sourceId)) return null // idempotent replay (kept OR previously consumed)
|
|
199
|
+
if (state.overflow !== null && state.overflow.sourceId === extra.sourceId) return null
|
|
200
|
+
if (extra.mode === 'append' && state.overflow !== null && compareOrder(extra.order, state.overflow.order) > 0) {
|
|
201
|
+
// Later than the overflow barrier: rejected without touching the fold.
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Only live ops (strictly after both barriers) participate.
|
|
206
|
+
const ops = [...state.ops.values()].filter(op => op.order[0] > state.localBarrier && op.order[0] > state.replayBarrier)
|
|
207
|
+
if (extra !== null) ops.push(extra)
|
|
208
|
+
ops.sort((a, b) => compareOrder(a.order, b.order))
|
|
209
|
+
// The latest VALID replace resets everything before it; start the fold
|
|
210
|
+
// there. (A replace beyond the node budget — only reachable with
|
|
211
|
+
// non-default limits — never becomes the base.)
|
|
212
|
+
let start = 0
|
|
213
|
+
for (let i = 0; i < ops.length; i++) {
|
|
214
|
+
const op = ops[i]!
|
|
215
|
+
if (op.mode === 'replace' && countGenuiNodes(op.spec, limits.maxNodes + 1) <= limits.maxNodes) start = i
|
|
216
|
+
}
|
|
217
|
+
let snapshot: GenuiSpec | null = state.local
|
|
218
|
+
let overflow: PanelOperation | null = null
|
|
219
|
+
let appendCount = 0
|
|
220
|
+
const kept: PanelOperation[] = []
|
|
221
|
+
let maxSeenSeq = state.maxSeenSeq
|
|
222
|
+
for (let i = start; i < ops.length; i++) {
|
|
223
|
+
const op = ops[i]!
|
|
224
|
+
if (op.order[0] > maxSeenSeq) maxSeenSeq = op.order[0]
|
|
225
|
+
if (op.mode === 'replace') {
|
|
226
|
+
if (countGenuiNodes(op.spec, limits.maxNodes + 1) > limits.maxNodes) {
|
|
227
|
+
// Pathological replace beyond the node budget: rejected like any
|
|
228
|
+
// over-budget op (remembered as the overflow barrier, fold kept).
|
|
229
|
+
if (overflow === null || compareOrder(op.order, overflow.order) < 0) overflow = op
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
overflow = null
|
|
233
|
+
snapshot = op.spec
|
|
234
|
+
kept.length = 0
|
|
235
|
+
appendCount = 0
|
|
236
|
+
kept.push(op)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
// Each append is gated independently against the current snapshot — a
|
|
240
|
+
// rejection never retroactively drops ops that already fit.
|
|
241
|
+
const merged = mergePanelSpecs(snapshot, op.spec)
|
|
242
|
+
if (appendCount >= limits.maxAppends
|
|
243
|
+
|| countGenuiNodes(merged, limits.maxNodes + 1) > limits.maxNodes) {
|
|
244
|
+
if (overflow === null || compareOrder(op.order, overflow.order) < 0) overflow = op
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
snapshot = merged
|
|
248
|
+
appendCount += 1
|
|
249
|
+
kept.push(op)
|
|
250
|
+
}
|
|
251
|
+
if (extra !== null) {
|
|
252
|
+
const sameSet = kept.length === state.ops.size
|
|
253
|
+
&& kept.every(op => state.ops.get(op.sourceId)?.mode === op.mode)
|
|
254
|
+
&& ((overflow === null && state.overflow === null)
|
|
255
|
+
|| (overflow !== null && state.overflow !== null && overflow.sourceId === state.overflow.sourceId))
|
|
256
|
+
if (sameSet) return null
|
|
257
|
+
}
|
|
258
|
+
return { snapshot, kept, overflow, maxSeenSeq }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function commit(state: SessionPanelState, result: FoldResult, sessionId: string): void {
|
|
262
|
+
state.snapshot = result.snapshot
|
|
263
|
+
state.overflow = result.overflow
|
|
264
|
+
state.maxSeenSeq = result.maxSeenSeq
|
|
265
|
+
state.ops = new Map(result.kept.map(op => [op.sourceId, op]))
|
|
266
|
+
// Every processed op joins the replay dedup register — kept or dropped,
|
|
267
|
+
// accepted or overflow-rejected: a history-scroll replay must never
|
|
268
|
+
// re-fold a source the fold already consumed.
|
|
269
|
+
for (const op of result.kept) state.seen.set(op.sourceId, op.order)
|
|
270
|
+
if (result.overflow !== null) state.seen.set(result.overflow.sourceId, result.overflow.order)
|
|
271
|
+
writePanelStorage(sessionId, {
|
|
272
|
+
snapshot: state.snapshot,
|
|
273
|
+
local: state.local,
|
|
274
|
+
localBarrier: state.localBarrier,
|
|
275
|
+
maxSeenSeq: state.maxSeenSeq,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export type PanelOperationStatus = 'accepted' | 'idempotent' | 'blocked' | 'overflow'
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Submit one panel operation. Idempotent per sourceId; ordering and budget
|
|
283
|
+
* per the module doc. Returns the outcome so callers can diagnose once per
|
|
284
|
+
* source (e.g. "panel at its node/appends budget — send a replace").
|
|
285
|
+
*/
|
|
286
|
+
export function applyPanelOperation(sessionId: string, op: PanelOperation): PanelOperationStatus {
|
|
287
|
+
const state = stateOf(sessionId)
|
|
288
|
+
const result = fold(state, op)
|
|
289
|
+
if (result === null) {
|
|
290
|
+
if (state.seen.has(op.sourceId) || state.overflow?.sourceId === op.sourceId) return 'idempotent'
|
|
291
|
+
// A rejection may come from the replay/local barriers (dead old content
|
|
292
|
+
// — worth one diagnostic) or from the budget overflow barrier (a later
|
|
293
|
+
// append beyond the panel budget — the budget diagnostic covers it).
|
|
294
|
+
const laterThanOverflow = state.overflow !== null
|
|
295
|
+
&& op.mode === 'append'
|
|
296
|
+
&& compareOrder(op.order, state.overflow.order) > 0
|
|
297
|
+
if (!laterThanOverflow) diagnosePanelBlocked(sessionId, op, state)
|
|
298
|
+
return 'blocked'
|
|
299
|
+
}
|
|
300
|
+
const status: PanelOperationStatus = result.overflow !== null && result.overflow.sourceId === op.sourceId
|
|
301
|
+
? 'overflow'
|
|
302
|
+
: 'accepted'
|
|
303
|
+
const changed = state.snapshot !== result.snapshot
|
|
304
|
+
commit(state, result, sessionId)
|
|
305
|
+
if (changed) for (const fn of listeners) fn()
|
|
306
|
+
return status
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* ---------------- barrier-rejection diagnostics ---------------- */
|
|
310
|
+
|
|
311
|
+
/** One diagnostic per blocked source per page session (replays stay quiet). */
|
|
312
|
+
const diagnosedBlocked = new Set<string>()
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Log one barrier-rejection diagnostic. Barrier kills are EXPECTED for
|
|
316
|
+
* history replays (the persisted hydration barrier makes replays at/below
|
|
317
|
+
* the persisted max seq dead), so this stays quiet on repeat visits — but a
|
|
318
|
+
* NEW message's fence being blocked is a real defect (broken seq
|
|
319
|
+
* derivation), and the one-time warning makes it observable instead of a
|
|
320
|
+
* silent dock freeze (issue #4 asked for exactly this).
|
|
321
|
+
*/
|
|
322
|
+
function diagnosePanelBlocked(sessionId: string, op: PanelOperation, state: SessionPanelState): void {
|
|
323
|
+
const key = `${sessionId}\u0000${op.sourceId}`
|
|
324
|
+
if (diagnosedBlocked.has(key)) return
|
|
325
|
+
diagnosedBlocked.add(key)
|
|
326
|
+
console.warn(
|
|
327
|
+
`[genui] 面板操作被重放屏障拒绝(source ${op.sourceId},order[0]=${op.order[0]} ≤ replayBarrier ${state.replayBarrier} / localBarrier ${state.localBarrier})。历史消息重放被拒是预期行为;若是刚发送的新消息,说明消息序号推导异常,请报告。`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ---------------- local /panel override ---------------- */
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Apply the local `/panel` override: `spec` (the default panel) or null
|
|
335
|
+
* (clear) becomes the fold base, and every operation at/below the highest
|
|
336
|
+
* message seq seen so far is dead — old history replays cannot resurrect
|
|
337
|
+
* the panel. A later real operation (order[0] > barrier) replaces or merges
|
|
338
|
+
* into the override as usual.
|
|
339
|
+
*/
|
|
340
|
+
export function setLocalPanel(sessionId: string, spec: GenuiSpec | null): void {
|
|
341
|
+
const state = stateOf(sessionId)
|
|
342
|
+
const barrier = state.maxSeenSeq
|
|
343
|
+
// A clear when local is already null is still a change when the barrier
|
|
344
|
+
// moves: the barrier is what kills old history replays.
|
|
345
|
+
if (state.local === spec && state.localBarrier === barrier) return
|
|
346
|
+
state.local = spec
|
|
347
|
+
state.localBarrier = barrier
|
|
348
|
+
// extra is null here, so fold never rejects the candidate (its null
|
|
349
|
+
// returns live behind the `extra !== null` gate).
|
|
350
|
+
const result = fold(state, null)!
|
|
351
|
+
commit(state, result, sessionId)
|
|
352
|
+
for (const fn of listeners) fn()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** One diagnostic per over-budget source (replays stay silent). */
|
|
356
|
+
const diagnosedOverflow = new Set<string>()
|
|
357
|
+
|
|
358
|
+
/** Log one budget-overflow diagnostic per source (replays stay silent). */
|
|
359
|
+
export function diagnosePanelBudget(sessionId: string, sourceId: string): void {
|
|
360
|
+
const key = `${sessionId}\u0000${sourceId}`
|
|
361
|
+
if (diagnosedOverflow.has(key)) return
|
|
362
|
+
diagnosedOverflow.add(key)
|
|
363
|
+
console.warn(
|
|
364
|
+
`[genui] 面板已到节点/操作上限(${limits.maxNodes} 节点、${limits.maxAppends} 条追加),本次 append 被拒绝;请让模型发送 replace 更新面板。`,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Tear down a session's panel state (session destroy / hard clear). Memory
|
|
369
|
+
* only: drops the in-memory record, the session's expand token, and overflow
|
|
370
|
+
* diagnostics, so a long-lived app never accumulates per-session state for
|
|
371
|
+
* closed sessions. The localStorage entry SURVIVES — reopening the session
|
|
372
|
+
* restores the panel instantly (that is the persistence feature); an explicit
|
|
373
|
+
* user clear goes through setLocalPanel(null), which persists the cleared
|
|
374
|
+
* state + barrier. */
|
|
375
|
+
export function clearSessionPanel(sessionId: string): void {
|
|
376
|
+
const had = sessions.delete(sessionId)
|
|
377
|
+
const hadToken = expandTokens.delete(sessionId)
|
|
378
|
+
const prefix = `${sessionId}\u0000`
|
|
379
|
+
for (const key of diagnosedOverflow) {
|
|
380
|
+
if (key.startsWith(prefix)) diagnosedOverflow.delete(key)
|
|
381
|
+
}
|
|
382
|
+
for (const key of diagnosedBlocked) {
|
|
383
|
+
if (key.startsWith(prefix)) diagnosedBlocked.delete(key)
|
|
384
|
+
}
|
|
385
|
+
if (!had && !hadToken) return
|
|
386
|
+
for (const fn of listeners) fn()
|
|
387
|
+
for (const fn of expandListeners) fn()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ---------------- reads / subscription ---------------- */
|
|
391
|
+
|
|
392
|
+
/** Current folded spec for a session (useSyncExternalStore getSnapshot).
|
|
393
|
+
* Lazily hydrates from localStorage on first access after a reload. */
|
|
394
|
+
export function getPanelSpec(sessionId: string): GenuiSpec | null {
|
|
395
|
+
return stateOf(sessionId).snapshot
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Subscribe to panel changes. Returns the disposer. */
|
|
399
|
+
export function subscribePanel(listener: () => void): () => void {
|
|
400
|
+
listeners.add(listener)
|
|
401
|
+
return () => {
|
|
402
|
+
listeners.delete(listener)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ---------------- incremental append (pure merge) ---------------- */
|
|
407
|
+
|
|
408
|
+
/** If `spec` is a single root `tabs` node, its tabs; otherwise null. */
|
|
409
|
+
function asTabs(spec: GenuiSpec): GenuiTab[] | null {
|
|
410
|
+
if (spec.items.length !== 1) return null
|
|
411
|
+
const only = spec.items[0]
|
|
412
|
+
if (only?.type !== 'tabs' || !Array.isArray(only.tabs)) return null
|
|
413
|
+
return only.tabs
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Merge an `append` spec into the current panel spec:
|
|
418
|
+
* - both sides single-tabs containers → merge BY TAB LABEL: items of
|
|
419
|
+
* same-labelled tabs are appended, new labels are added (order preserved);
|
|
420
|
+
* - otherwise → plain item lists are appended to the tail.
|
|
421
|
+
* The previous title wins unless it was absent. `next` is returned as-is when
|
|
422
|
+
* there is nothing to merge into. Export for tests.
|
|
423
|
+
*/
|
|
424
|
+
export function mergePanelSpecs(prev: GenuiSpec | null, next: GenuiSpec): GenuiSpec {
|
|
425
|
+
if (prev === null || prev.items.length === 0) return next
|
|
426
|
+
const title = prev.title ?? next.title
|
|
427
|
+
const base = { ...prev, ...(title === undefined ? {} : { title }) }
|
|
428
|
+
const prevTabs = asTabs(prev)
|
|
429
|
+
const nextTabs = asTabs(next)
|
|
430
|
+
if (prevTabs !== null && nextTabs !== null) {
|
|
431
|
+
const merged = new Map(prevTabs.map(t => [t.label, { label: t.label, items: [...t.items] }]))
|
|
432
|
+
for (const tab of nextTabs) {
|
|
433
|
+
const existing = merged.get(tab.label)
|
|
434
|
+
if (existing !== undefined) existing.items.push(...tab.items)
|
|
435
|
+
else merged.set(tab.label, { label: tab.label, items: [...tab.items] })
|
|
436
|
+
}
|
|
437
|
+
return { ...base, items: [{ type: 'tabs', tabs: [...merged.values()] }] }
|
|
438
|
+
}
|
|
439
|
+
return { ...base, items: [...prev.items, ...next.items] }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* ---------------- expand requests ---------------- */
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Per-session expand tokens: a monotone counter bumped by every explicit
|
|
446
|
+
* expand request (the /panel slash command). The dock compares the token
|
|
447
|
+
* against its own last-seen value so a fresh request expands the panel even
|
|
448
|
+
* when the user collapsed it manually in between.
|
|
449
|
+
*/
|
|
450
|
+
const expandTokens = new Map<string, number>()
|
|
451
|
+
|
|
452
|
+
const expandListeners = new Set<() => void>()
|
|
453
|
+
|
|
454
|
+
/** Request the panel dock to expand for a session (e.g. the /panel command). */
|
|
455
|
+
export function requestPanelExpand(sessionId: string): void {
|
|
456
|
+
expandTokens.set(sessionId, (expandTokens.get(sessionId) ?? 0) + 1)
|
|
457
|
+
for (const fn of expandListeners) fn()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Current expand token for a session (useSyncExternalStore getSnapshot). */
|
|
461
|
+
export function getPanelExpandToken(sessionId: string): number {
|
|
462
|
+
return expandTokens.get(sessionId) ?? 0
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Subscribe to expand requests. Returns the disposer. */
|
|
466
|
+
export function subscribePanelExpand(listener: () => void): () => void {
|
|
467
|
+
expandListeners.add(listener)
|
|
468
|
+
return () => {
|
|
469
|
+
expandListeners.delete(listener)
|
|
470
|
+
}
|
|
471
|
+
}
|