@changfenhuang/dsh-genui 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,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
+ }