dsh-spendingframe 0.0.0-stage → 0.2.2

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.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * DSHBalance 客户端半(包名 dsh-spendingframe,手写的 __ModuleLoader__ 工厂包,无需构建步骤)。
3
+ *
4
+ * 作用:在会话输入框下沿的 `conversation.composer.dock`(“上下文占用”那一行)
5
+ * 追加一个条目,排在上下文占用右侧,从左到右显示:
6
+ *
7
+ * 峰 / 谷 + 会话消耗(¥) + 今日消耗(¥)
8
+ *
9
+ * 交互完全照抄同排的「上下文使用情况」(ContextMeter):
10
+ *
11
+ * · 悬停:延迟 200ms 后在正上方浮出一个小气泡(同款 tooltip 底色/圆角/字号,
12
+ * `white-space: pre-line`),里面只有三项——本会话消耗、子智能体消耗、统计更新时间;
13
+ * · 点击:浮出完整信息框(同款 menu 底色、--dsw-radius-lg 圆角、elevation 阴影、
14
+ * 12px/20px 文字),带本会话/子智能体占比条与逐项明细;再点一次、按 Esc、
15
+ * 或点击别处都会收起。
16
+ *
17
+ * 金额、峰谷判定与子智能体汇总都在宿主算,浏览器只负责显示。外观全部取用应用
18
+ * 自己的 token,因此跟随主题与字体设置。
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ id: 'dsh-spendingframe',
22
+ factory: (require) => {
23
+ const module = { exports: {} }
24
+ const exports = module.exports
25
+
26
+ const React = require('react')
27
+ // react-dom 只用来把弹层挂到 body(原生 ContextMeter 也是这么做的)。
28
+ // 取不到时退化为就地渲染,插件仍然能用。
29
+ let reactDom = null
30
+ try {
31
+ reactDom = require('react-dom')
32
+ } catch {
33
+ reactDom = null
34
+ }
35
+
36
+ /** 客户端插件名(与 cordis 的 name 一致)。 */
37
+ const name = 'dsh-spendingframe'
38
+ /** 只依赖插槽服务。 */
39
+ const inject = ['slots']
40
+ /** 刷新间隔:5 分钟。 */
41
+ const REFRESH_MS = 5 * 60 * 1000
42
+ /** 宿主路由。 */
43
+ const ROUTE = '/dsh-spendingframe/summary'
44
+ /** 悬停多久后浮出小气泡(原生 ContextMeter 的 Tooltip 用的是 delayMs: 200)。 */
45
+ const HOVER_DELAY_MS = 200
46
+ /** 距锚点 8px、距视口边缘 12px(原生 useAnchoredPosition 同值)。 */
47
+ const GAP = 8
48
+ const MARGIN = 12
49
+
50
+ /**
51
+ * 相对页面挂载点解析接口路径(与 dsh-market 同样的理由:反向代理把 dsh
52
+ * 挂在子路径下时,根绝对路径会打偏)。
53
+ */
54
+ function api(path) {
55
+ const relative = String(path).replace(/^\/+/, '')
56
+ if (typeof document === 'undefined') return `/${relative}`
57
+ return new URL(relative, document.baseURI).pathname
58
+ }
59
+
60
+ /**
61
+ * 人民币金额:按数量级选择小数位,小额花费也看得见,大额不啰嗦。
62
+ * 小于 1 元保留 3 位(DeepSeek 单次调用常在分级),1 元以上 2 位。
63
+ */
64
+ function money(value) {
65
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '¥0.000'
66
+ if (value >= 1000) return `¥${Math.round(value)}`
67
+ if (value >= 100) return `¥${value.toFixed(1)}`
68
+ if (value >= 1) return `¥${value.toFixed(2)}`
69
+ return `¥${value.toFixed(3)}`
70
+ }
71
+
72
+ /** 北京时间 HH:MM。 */
73
+ function beijingClock(timeMs) {
74
+ if (typeof timeMs !== 'number' || !Number.isFinite(timeMs)) return ''
75
+ const shifted = new Date(timeMs + 8 * 60 * 60 * 1000)
76
+ return shifted.toISOString().slice(11, 16)
77
+ }
78
+
79
+ /** 北京时间 YYYY-MM-DD HH:MM(悬停气泡里的“统计更新时间”)。 */
80
+ function beijingStamp(timeMs) {
81
+ if (typeof timeMs !== 'number' || !Number.isFinite(timeMs)) return '未知'
82
+ const shifted = new Date(timeMs + 8 * 60 * 60 * 1000)
83
+ const iso = shifted.toISOString()
84
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)}`
85
+ }
86
+
87
+ /** 紧凑 token 计数(照原生 ContextMeter 的 formatTokens:K / M,二位有效)。 */
88
+ function compactTokens(value) {
89
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '0'
90
+ const scaled = (candidate) => (candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10))
91
+ if (value < 1e3) return String(value)
92
+ if (value < 1e6) return `${scaled(value / 1e3)}K`
93
+ return `${scaled(value / 1e6)}M`
94
+ }
95
+
96
+ /** `命中 / 未命中 / 输出` 一行的 token 摘要。 */
97
+ function tokenLine(tokens) {
98
+ if (tokens === undefined || tokens === null) return '—'
99
+ return `命中 ${compactTokens(tokens.hit)} / 未命中 ${compactTokens(tokens.miss)} / 输出 ${compactTokens(tokens.out)}`
100
+ }
101
+
102
+ /** 子智能体数量(0 表示这个会话没有派生会话)。 */
103
+ function subagentCount(data) {
104
+ const count = data?.session?.subagents?.sessions
105
+ return typeof count === 'number' && count > 0 ? count : 0
106
+ }
107
+
108
+ /**
109
+ * 悬停气泡:要的就是三项,不多不少——两个消耗加统计时间。
110
+ * 用 `\n` 换行,气泡本身是 `white-space: pre-line`(与原生 Tooltip 一致)。
111
+ */
112
+ function hoverLabel(data, stale) {
113
+ const count = subagentCount(data)
114
+ const suffix = count > 0 ? `(${count} 个会话)` : ''
115
+ return [
116
+ `本会话消耗 ${money(data.session?.own?.cost)}`,
117
+ `子智能体消耗 ${money(data.session?.subagents?.cost)}${suffix}`,
118
+ `统计更新时间 ${beijingStamp(data.at)}${stale === true ? '(刷新失败,显示上次结果)' : ''}`,
119
+ ].join('\n')
120
+ }
121
+
122
+ /** 完整信息框的标题行:当前时段 + 标记 + 会话合计。 */
123
+ function panelHeader(data) {
124
+ const peak = data.peak === true
125
+ return {
126
+ title: peak ? '高峰时段' : '空闲时段',
127
+ mark: peak ? '峰' : '谷',
128
+ figures: `会话合计 ${money(data.session?.cost)}`,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 完整信息框的逐项明细(`dt`/`dd` 成对,顺序即显示顺序)。
134
+ * 前两项带 `tint`:与占比条同色的 8px 小方块(原生 ContextMeter 的 .swatch)。
135
+ */
136
+ function panelRows(data) {
137
+ const session = data.session ?? {}
138
+ const own = session.own ?? {}
139
+ const derived = session.subagents ?? {}
140
+ const today = data.today ?? {}
141
+ const rows = [
142
+ { label: '本会话消耗', value: `${money(own.cost)}(${own.calls ?? 0} 次调用)`, tint: 'var(--dsw-static-neutral-bluish-400)' },
143
+ { label: '子智能体消耗', value: `${money(derived.cost)}(${derived.sessions ?? 0} 个会话 / ${derived.calls ?? 0} 次调用)`, tint: '#a78bfa' },
144
+ { label: '今日消耗', value: `${money(today.cost)}(${today.sessions ?? 0} 个会话 / ${today.calls ?? 0} 次调用)` },
145
+ { label: '本会话 Token', value: tokenLine(own.tokens) },
146
+ { label: '子智能体 Token', value: tokenLine(derived.tokens) },
147
+ ]
148
+ if (Array.isArray(data.models) && data.models.length > 0) {
149
+ rows.push({
150
+ label: '模型',
151
+ value: data.models.map((model) => `${model.id} ${money(model.cost)}(${model.calls} 次)`).join('、'),
152
+ })
153
+ }
154
+ rows.push({ label: '计费时段', value: '工作日 9:00–12:00、14:00–18:00(法定节假日除外)' })
155
+ rows.push({ label: '统计更新时间', value: beijingStamp(data.at) })
156
+ if (Array.isArray(data.assumedPricing) && data.assumedPricing.length > 0) {
157
+ rows.push({ label: '按 flash 价目估算', value: data.assumedPricing.join('、') })
158
+ }
159
+ return rows
160
+ }
161
+
162
+ /** 占比条的分段:本会话 vs 子智能体(都为零时给一段中性色)。 */
163
+ function panelSegments(data) {
164
+ const own = data.session?.own?.cost ?? 0
165
+ const derived = data.session?.subagents?.cost ?? 0
166
+ const total = own + derived
167
+ if (!(total > 0)) return [{ key: 'total', width: 100, tint: 'var(--dsw-alias-label-tertiary)' }]
168
+ return [
169
+ { key: 'own', width: (own / total) * 100, tint: 'var(--dsw-static-neutral-bluish-400)' },
170
+ { key: 'subagents', width: (derived / total) * 100, tint: '#a78bfa' },
171
+ ].filter((part) => part.width > 0)
172
+ }
173
+
174
+ /**
175
+ * 弹层定位(纯函数,便于断言):默认贴锚点上方居中,顶部放不下就翻到下方,
176
+ * 左右夹在视口里 —— 与原生 useAnchoredPosition({ side: 'top', gap: 8, margin: 12 }) 同规则。
177
+ */
178
+ function placement(anchor, panel, viewport, options) {
179
+ const gap = options?.gap ?? GAP
180
+ const margin = options?.margin ?? MARGIN
181
+ const width = Math.min(panel.width, Math.max(0, viewport.width - margin * 2))
182
+ const furthest = Math.max(margin, viewport.width - width - margin)
183
+ const left = Math.min(Math.max(anchor.left + anchor.width / 2 - width / 2, margin), furthest)
184
+ let top = anchor.top - gap - panel.height
185
+ if (top < margin) top = Math.min(anchor.bottom + gap, Math.max(margin, viewport.height - panel.height - margin))
186
+ return { left: Math.round(left), top: Math.round(top), width: Math.round(width) }
187
+ }
188
+
189
+ /**
190
+ * 把弹层贴到锚点上方。首次渲染先隐藏(visibility: hidden),量到尺寸后才定位;
191
+ * 之后跟着窗口尺寸与滚动重新定位 —— 原生 useAnchoredPosition 的做法。
192
+ */
193
+ function useAbovePosition(visible, mode, anchorRef, panelRef) {
194
+ const [position, setPosition] = React.useState(null)
195
+ React.useEffect(() => {
196
+ if (visible !== true || typeof document === 'undefined' || typeof window === 'undefined') return undefined
197
+ const place = () => {
198
+ const anchor = anchorRef.current
199
+ const panel = panelRef.current
200
+ if (anchor === null || anchor === undefined || panel === null || panel === undefined) return
201
+ if (typeof anchor.getBoundingClientRect !== 'function' || typeof panel.getBoundingClientRect !== 'function') return
202
+ setPosition(placement(
203
+ anchor.getBoundingClientRect(),
204
+ panel.getBoundingClientRect(),
205
+ { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight },
206
+ // 小气泡贴得更近一点,与原生 Tooltip 的观感一致。
207
+ { gap: mode === 'panel' ? GAP : 6, margin: MARGIN },
208
+ ))
209
+ }
210
+ place()
211
+ window.addEventListener('resize', place)
212
+ window.addEventListener('scroll', place, true)
213
+ return () => {
214
+ window.removeEventListener('resize', place)
215
+ window.removeEventListener('scroll', place, true)
216
+ }
217
+ }, [visible, mode])
218
+ return visible === true ? position : null
219
+ }
220
+
221
+ /** 无障碍名称与悬停提示同一口径。 */
222
+ function ariaLabel(data) {
223
+ const count = subagentCount(data)
224
+ const scope = count > 0 ? `,含 ${count} 个子智能体会话` : ''
225
+ return `${data.peak === true ? '高峰' : '空闲'}时段,会话消耗 ${money(data.session?.cost)}${scope},今日消耗 ${money(data.today?.cost)}。点击查看明细`
226
+ }
227
+
228
+ // --- 样式:逐条对应原生 ContextMeter / Tooltip / 面板的 token -------------
229
+
230
+ const triggerStyle = (highlighted) => ({
231
+ borderRadius: 'var(--dsw-radius-sm)',
232
+ color: highlighted ? 'var(--dsw-alias-label-secondary)' : 'var(--dsw-alias-label-tertiary)',
233
+ fontFamily: 'inherit',
234
+ fontSize: 'var(--dsh-content-font-size-secondary, 13px)',
235
+ fontVariantNumeric: 'tabular-nums',
236
+ lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
237
+ whiteSpace: 'nowrap',
238
+ cursor: 'pointer',
239
+ background: highlighted ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent',
240
+ border: 'none',
241
+ flex: 'none',
242
+ alignItems: 'center',
243
+ gap: '6px',
244
+ padding: '1px 8px',
245
+ display: 'inline-flex',
246
+ })
247
+
248
+ const rootStyle = {
249
+ // order:1 是「放到上下文占用右边」的唯一办法:这一行的结构是
250
+ // <div class="dock">{renderSlot("conversation.composer.dock")}<ContextMeter/></div>
251
+ // 槽位永远渲染在 ContextMeter 之前;但槽位出口是 display:contents
252
+ // (见 dsh-client-ui-renderer 的 SlotOutlet),我们的根元素因此是 .dock
253
+ // 这个 flex 容器的直接 flex item,用 order 就能排到 order:0 的上下文占用之后。
254
+ order: 1,
255
+ flex: 'none',
256
+ display: 'inline-flex',
257
+ }
258
+
259
+ // 悬停小气泡:原生 Tooltip 的 .bubble
260
+ const bubbleStyle = (position) => ({
261
+ display: 'inline-flex',
262
+ alignItems: 'center',
263
+ gap: '8px',
264
+ position: 'fixed',
265
+ zIndex: 1100,
266
+ width: 'max-content',
267
+ maxWidth: '50vw',
268
+ padding: '3px 7px',
269
+ borderRadius: 'var(--dsw-radius-sm)',
270
+ background: 'var(--dsw-alias-tooltip-bg)',
271
+ color: 'var(--dsw-static-neutral-bluish-00)',
272
+ fontSize: '13px',
273
+ lineHeight: '20px',
274
+ whiteSpace: 'pre-line',
275
+ overflowWrap: 'break-word',
276
+ pointerEvents: 'none',
277
+ ...(position ?? { visibility: 'hidden', left: 0, top: 0 }),
278
+ })
279
+
280
+ // 完整信息框:原生 ContextMeter 的 .panel
281
+ const panelStyle = (position) => ({
282
+ zIndex: 1100,
283
+ boxSizing: 'border-box',
284
+ borderRadius: 'var(--dsw-radius-lg)',
285
+ background: 'var(--dsw-specific-menu)',
286
+ width: 'min(300px, 100vw - 24px)',
287
+ backdropFilter: 'var(--dsw-menu-backdrop-filter)',
288
+ boxShadow: 'var(--dsw-elevation-prominent)',
289
+ color: 'var(--dsw-alias-label-secondary)',
290
+ cursor: 'default',
291
+ border: 0,
292
+ padding: '12px',
293
+ fontSize: '12px',
294
+ lineHeight: '20px',
295
+ position: 'fixed',
296
+ ...(position ?? { visibility: 'hidden', left: 0, top: 0 }),
297
+ })
298
+
299
+ const panelHeaderStyle = { display: 'flex', alignItems: 'center', gap: '6px' }
300
+ const panelHeadlineStyle = { color: 'var(--dsw-alias-label-tertiary)' }
301
+ const panelPercentStyle = { color: 'var(--dsw-alias-label-primary)', fontWeight: 500 }
302
+ const panelFiguresStyle = { fontVariantNumeric: 'tabular-nums', color: 'var(--dsw-alias-label-primary)', marginLeft: 'auto', fontWeight: 500 }
303
+ const panelBarStyle = {
304
+ background: 'var(--dsw-alias-interactive-bg-hover)',
305
+ borderRadius: '999px',
306
+ gap: '1px',
307
+ height: '4px',
308
+ margin: '10px 0 12px',
309
+ display: 'flex',
310
+ overflow: 'hidden',
311
+ }
312
+ const panelRowsStyle = { margin: '6px 0 0' }
313
+ const panelRowStyle = { justifyContent: 'space-between', alignItems: 'center', gap: '12px', padding: '2px 0', display: 'flex' }
314
+ const panelDtStyle = { color: 'var(--dsw-alias-label-secondary)' }
315
+ /** 明细行前的 8px 色块(原生 ContextMeter 的 .swatch,颜色由行数据给)。 */
316
+ const panelSwatchStyle = { verticalAlign: 'baseline', borderRadius: '2px', width: '8px', height: '8px', marginRight: '6px', display: 'inline-block' }
317
+ const panelDdStyle = { fontVariantNumeric: 'tabular-nums', color: 'var(--dsw-alias-label-primary)', margin: 0, textAlign: 'right' }
318
+ const panelFooterStyle = { color: 'var(--dsw-alias-label-tertiary)', marginTop: '8px', textAlign: 'right' }
319
+
320
+ /** 那一行里的一个条目;没有数据时什么都不渲染,避免闪烁或占位。 */
321
+ function BalanceEntry(props) {
322
+ const sessionId = props && props.sessionId ? String(props.sessionId) : ''
323
+ // useState 顺序:[data, stale, hovered, open, position(在 useAbovePosition 里)]
324
+ const [data, setData] = React.useState(null)
325
+ const [stale, setStale] = React.useState(false)
326
+ const [hovered, setHovered] = React.useState(false)
327
+ const [open, setOpen] = React.useState(false)
328
+ const rootRef = React.useRef(null)
329
+ const bubbleRef = React.useRef(null)
330
+ const panelRef = React.useRef(null)
331
+ const hoverTimer = React.useRef(null)
332
+
333
+ React.useEffect(() => {
334
+ let alive = true
335
+ const load = () => {
336
+ const query = sessionId === '' ? '' : `?session=${encodeURIComponent(sessionId)}`
337
+ fetch(api(ROUTE) + query, { cache: 'no-store' })
338
+ .then((response) => {
339
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
340
+ return response.json()
341
+ })
342
+ .then((payload) => {
343
+ if (!alive) return
344
+ if (payload && payload.ok === true) {
345
+ setData(payload)
346
+ setStale(false)
347
+ } else {
348
+ setStale(true)
349
+ }
350
+ })
351
+ .catch((error) => {
352
+ if (!alive) return
353
+ setStale(true)
354
+ console.warn('[DSHBalance] 读取失败:', error)
355
+ })
356
+ }
357
+ load()
358
+ const timer = setInterval(load, REFRESH_MS)
359
+ return () => {
360
+ alive = false
361
+ clearInterval(timer)
362
+ }
363
+ }, [sessionId])
364
+
365
+ // 点击后:Esc 或点击别处收起(原生 useDismissOnOutsidePointer 同样的判断)。
366
+ React.useEffect(() => {
367
+ if (open !== true || typeof document === 'undefined') return undefined
368
+ const onKeyDown = (event) => {
369
+ if (event.key === 'Escape') setOpen(false)
370
+ }
371
+ const onPointerDown = (event) => {
372
+ const root = rootRef.current
373
+ const panel = panelRef.current
374
+ if (root !== null && root !== undefined && typeof root.contains === 'function' && root.contains(event.target)) return
375
+ if (panel !== null && panel !== undefined && typeof panel.contains === 'function' && panel.contains(event.target)) return
376
+ setOpen(false)
377
+ }
378
+ document.addEventListener('keydown', onKeyDown)
379
+ document.addEventListener('pointerdown', onPointerDown, true)
380
+ return () => {
381
+ document.removeEventListener('keydown', onKeyDown)
382
+ document.removeEventListener('pointerdown', onPointerDown, true)
383
+ }
384
+ }, [open])
385
+
386
+ // 卸载时清掉悬停计时器,避免在已消失的组件上 setState。
387
+ React.useEffect(() => () => {
388
+ if (hoverTimer.current !== null) clearTimeout(hoverTimer.current)
389
+ }, [])
390
+
391
+ const visible = open === true || hovered === true
392
+ const mode = open === true ? 'panel' : 'hover'
393
+ const position = useAbovePosition(visible, mode, rootRef, open === true ? panelRef : bubbleRef)
394
+
395
+ if (data === null) return null
396
+
397
+ const mark = data.peak === true ? '峰' : '谷'
398
+
399
+ const trigger = React.createElement(
400
+ 'button',
401
+ {
402
+ type: 'button',
403
+ 'aria-label': ariaLabel(data),
404
+ 'aria-haspopup': 'dialog',
405
+ 'aria-expanded': open === true,
406
+ onClick: () => {
407
+ setHovered(false)
408
+ setOpen(!open)
409
+ },
410
+ onMouseEnter: () => {
411
+ if (open === true) return
412
+ if (hoverTimer.current !== null) clearTimeout(hoverTimer.current)
413
+ hoverTimer.current = setTimeout(() => {
414
+ hoverTimer.current = null
415
+ setHovered(true)
416
+ }, HOVER_DELAY_MS)
417
+ },
418
+ onMouseLeave: () => {
419
+ if (hoverTimer.current !== null) {
420
+ clearTimeout(hoverTimer.current)
421
+ hoverTimer.current = null
422
+ }
423
+ setHovered(false)
424
+ },
425
+ style: triggerStyle(visible),
426
+ },
427
+ React.createElement('span', null, mark),
428
+ React.createElement('span', null, money(data.session?.cost)),
429
+ React.createElement('span', null, money(data.today?.cost)),
430
+ )
431
+
432
+ const hoverBubble = open !== true && hovered === true
433
+ ? React.createElement(
434
+ 'div',
435
+ { ref: bubbleRef, role: 'tooltip', style: bubbleStyle(position) },
436
+ React.createElement('span', { style: { minWidth: 0 } }, hoverLabel(data, stale)),
437
+ )
438
+ : null
439
+
440
+ const header = panelHeader(data)
441
+ const panel = open === true
442
+ ? portal(React.createElement(
443
+ 'div',
444
+ {
445
+ ref: panelRef,
446
+ role: 'dialog',
447
+ 'aria-label': `${header.title}消耗明细`,
448
+ style: panelStyle(position),
449
+ },
450
+ React.createElement(
451
+ 'div',
452
+ { style: panelHeaderStyle },
453
+ React.createElement('span', { style: panelHeadlineStyle }, header.title),
454
+ React.createElement('span', { style: panelPercentStyle }, header.mark),
455
+ React.createElement('span', { style: panelFiguresStyle }, header.figures),
456
+ ),
457
+ React.createElement(
458
+ 'div',
459
+ { style: panelBarStyle },
460
+ panelSegments(data).map((segment) => React.createElement('div', {
461
+ key: segment.key,
462
+ style: { background: segment.tint, borderRadius: '1px', flex: 'none', minWidth: '2px', height: '100%', width: `${segment.width}%` },
463
+ })),
464
+ ),
465
+ React.createElement(
466
+ 'dl',
467
+ { style: panelRowsStyle },
468
+ panelRows(data).map((row) => React.createElement(
469
+ 'div',
470
+ { key: row.label, style: panelRowStyle },
471
+ React.createElement(
472
+ 'dt',
473
+ { style: panelDtStyle },
474
+ row.tint === undefined
475
+ ? null
476
+ : React.createElement('span', { 'aria-hidden': true, style: { ...panelSwatchStyle, background: row.tint } }),
477
+ row.label,
478
+ ),
479
+ React.createElement('dd', { style: panelDdStyle }, row.value),
480
+ )),
481
+ ),
482
+ typeof data.version === 'string' && data.version !== ''
483
+ ? React.createElement('div', { style: panelFooterStyle }, `DSHBalance v${data.version}`)
484
+ : null,
485
+ ))
486
+ : null
487
+
488
+ return React.createElement(
489
+ 'span',
490
+ { ref: rootRef, style: rootStyle },
491
+ trigger,
492
+ hoverBubble,
493
+ panel,
494
+ )
495
+ }
496
+
497
+ /** 有 body 且能 require 到 react-dom 时,弹层挂到 body(原生同款做法)。 */
498
+ function portal(node) {
499
+ if (reactDom !== null && typeof reactDom.createPortal === 'function' && typeof document !== 'undefined' && document.body) {
500
+ return reactDom.createPortal(node, document.body)
501
+ }
502
+ return node
503
+ }
504
+
505
+ function apply(ctx) {
506
+ // 插槽可能比本插件晚出现:inject 会等到它存在再注册。
507
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
508
+ name: 'conversation.composer.dock',
509
+ id: 'balance',
510
+ // 这一行 = [槽位里的条目(官方 stats 是 order 0), 槽位之后渲染的 ContextMeter],
511
+ // 两边的 order 都是 0;我们取 1,于是排在「会话统计」和「上下文使用情况」右侧。
512
+ order: 1,
513
+ }, BalanceEntry))
514
+ }
515
+
516
+ exports.name = name
517
+ exports.inject = inject
518
+ exports.apply = apply
519
+ // 给 tools/client-harness-test.mjs 的纯函数断言留个口子(不参与运行时逻辑)。
520
+ exports.__test = { money, beijingClock, beijingStamp, compactTokens, tokenLine, hoverLabel, panelRows, panelHeader, panelSegments, placement, ariaLabel }
521
+ return module.exports
522
+ },
523
+ })
@@ -0,0 +1,4 @@
1
+ # dsh bundle patch: inserts this plugin into a profile's layer stack.
2
+ - insert:
3
+ - id: dsh-spendingframe
4
+ name: 'dsh-spendingframe'