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.
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/client/client.js +523 -0
- package/cordis.patch.yml +4 -0
- package/lib/index.js +165 -0
- package/lib/pricing.js +184 -0
- package/lib/session-logs.js +197 -0
- package/lib/summary.js +209 -0
- package/package.json +58 -4
- package/tests/balance-probe.mjs +132 -0
- package/tests/client-harness-test.mjs +378 -0
package/client/client.js
ADDED
|
@@ -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
|
+
})
|
package/cordis.patch.yml
ADDED