dsh-token-bill 0.0.0-stage → 2.0.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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.en.md +169 -0
- package/README.md +168 -2
- package/client.js +522 -0
- package/cordis.patch.yml +16 -0
- package/docs/panel-dark-en.png +0 -0
- package/docs/panel-dark.png +0 -0
- package/docs/panel-light-en.png +0 -0
- package/docs/panel-light.png +0 -0
- package/docs/sidebar-en.png +0 -0
- package/docs/sidebar.png +0 -0
- package/icon.svg +6 -0
- package/index.js +25 -0
- package/lib/fold.js +281 -0
- package/lib/pricing.js +330 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +73 -4
package/client.js
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of dsh-token-bill.
|
|
3
|
+
*
|
|
4
|
+
* - `conversation.composer.dock`: a cost badge beside the built-in context
|
|
5
|
+
* ring. Clicking it opens a breakdown panel: cost per token bucket, per
|
|
6
|
+
* model, last turn, compaction, subagents, and off-peak savings.
|
|
7
|
+
* - `sidebar.session.row.trailing`: every conversation's cost next to its
|
|
8
|
+
* time in the sidebar list, read from the Session summary rows.
|
|
9
|
+
*
|
|
10
|
+
* Plain JS on purpose, so the package needs no build step. It requires only
|
|
11
|
+
* modules from the shell's shared table (react, react-dom, ui-primitives);
|
|
12
|
+
* when ui-primitives is missing or older, small local stand-ins take over.
|
|
13
|
+
*/
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: 'dsh-token-bill',
|
|
16
|
+
factory(require) {
|
|
17
|
+
const React = require('react')
|
|
18
|
+
const ReactDOM = require('react-dom')
|
|
19
|
+
const h = React.createElement
|
|
20
|
+
|
|
21
|
+
let primitives = {}
|
|
22
|
+
try {
|
|
23
|
+
primitives = require('@deepseek-ai/dsh-client-ui-primitives') || {}
|
|
24
|
+
} catch (_missing) {
|
|
25
|
+
primitives = {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Projection key written by the Host half (`lib/fold.js`). */
|
|
29
|
+
const KEY = 'dshTokenBill'
|
|
30
|
+
/** Locale namespace owned by this plugin. */
|
|
31
|
+
const NS = 'dshTokenBill'
|
|
32
|
+
const STYLE_KEY = 'dsh-token-bill/style'
|
|
33
|
+
|
|
34
|
+
const zh = {
|
|
35
|
+
'badge.tooltip': '本会话估算费用 {cost},点击查看明细',
|
|
36
|
+
'badge.unpriced': '本会话所用模型未配置价格,点击查看用量',
|
|
37
|
+
'panel.title': '本会话费用(估算)',
|
|
38
|
+
'bucket.input': '输入 · 未命中缓存',
|
|
39
|
+
'bucket.cacheRead': '输入 · 命中缓存',
|
|
40
|
+
'bucket.cacheWrite': '缓存写入',
|
|
41
|
+
'bucket.output': '输出',
|
|
42
|
+
'stat.cacheHit': '缓存命中率',
|
|
43
|
+
'stat.calls': '模型调用',
|
|
44
|
+
'stat.callsValue': '{n} 次',
|
|
45
|
+
'stat.reasoning': '其中思考',
|
|
46
|
+
'stat.turn': '最近一轮',
|
|
47
|
+
'stat.compaction': '上下文压缩({n} 次)',
|
|
48
|
+
'stat.saved': '空闲时段节省',
|
|
49
|
+
'stat.subagents': '子代理({n} 个)',
|
|
50
|
+
'stat.grand': '合计(含子代理)',
|
|
51
|
+
'models.title': '按模型',
|
|
52
|
+
'models.unpriced': '未定价',
|
|
53
|
+
// U+2060 WORD JOINER keeps "9–12" on one line.
|
|
54
|
+
'note.tiered': 'DeepSeek 官方模型按时段计价:北京时间工作日 9–12、14–18 点为高峰,其余时段半价。',
|
|
55
|
+
'note.flat': '不区分时段,统一按配置价格计算。',
|
|
56
|
+
'note.unpriced': '未定价的模型不计入总额,可在插件配置的 models 中补充价格。',
|
|
57
|
+
'note.bill': '估算值,以实际账单为准。',
|
|
58
|
+
'note.join': '',
|
|
59
|
+
'row.title': '估算费用 {cost}',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const en = {
|
|
63
|
+
'badge.tooltip': 'Estimated cost of this session: {cost}. Click for details',
|
|
64
|
+
'badge.unpriced': "This session's models have no price configured. Click for usage",
|
|
65
|
+
'panel.title': 'Session cost (estimate)',
|
|
66
|
+
'bucket.input': 'Input · cache miss',
|
|
67
|
+
'bucket.cacheRead': 'Input · cache hit',
|
|
68
|
+
'bucket.cacheWrite': 'Cache write',
|
|
69
|
+
'bucket.output': 'Output',
|
|
70
|
+
'stat.cacheHit': 'Cache hit rate',
|
|
71
|
+
'stat.calls': 'Model calls',
|
|
72
|
+
'stat.callsValue': '{n}',
|
|
73
|
+
'stat.reasoning': 'Thinking (in output)',
|
|
74
|
+
'stat.turn': 'Last turn',
|
|
75
|
+
'stat.compaction': 'Compaction ({n}×)',
|
|
76
|
+
'stat.saved': 'Off-peak savings',
|
|
77
|
+
'stat.subagents': 'Subagents ({n})',
|
|
78
|
+
'stat.grand': 'Total incl. subagents',
|
|
79
|
+
'models.title': 'By model',
|
|
80
|
+
'models.unpriced': 'no price',
|
|
81
|
+
'note.tiered': 'Official DeepSeek models are priced by time: peak on Beijing weekdays 09–12 and 14–18, half price otherwise.',
|
|
82
|
+
'note.flat': 'All hours are priced at the configured rates.',
|
|
83
|
+
'note.unpriced': 'Models without a price are left out; add them under `models` in the plugin config.',
|
|
84
|
+
'note.bill': 'Estimate only; your bill is authoritative.',
|
|
85
|
+
// Separator between note sentences: none after a Chinese full stop, a space in English.
|
|
86
|
+
'note.join': ' ',
|
|
87
|
+
'row.title': 'Estimated cost {cost}',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function interpolate(template, params) {
|
|
91
|
+
if (!params) return template
|
|
92
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Used only when a slot renders without a locale seat. */
|
|
96
|
+
function fallbackT(key, params) {
|
|
97
|
+
return interpolate(en[key] !== undefined ? en[key] : key, params)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function translator(t) {
|
|
101
|
+
return typeof t === 'function' ? t : fallbackT
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isView(value) {
|
|
105
|
+
return value !== null && typeof value === 'object' && value.v === 2 && typeof value.cost === 'number'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- formatting --------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Money text. `badge` and `row` stay short; `panel` keeps cents above 100.
|
|
112
|
+
* @param {number} cost - amount.
|
|
113
|
+
* @param {string} symbol - currency symbol.
|
|
114
|
+
* @param {'badge' | 'row' | 'panel'} mode - where it renders.
|
|
115
|
+
* @returns {string} formatted amount.
|
|
116
|
+
*/
|
|
117
|
+
function formatMoney(cost, symbol, mode) {
|
|
118
|
+
const s = typeof symbol === 'string' ? symbol : ''
|
|
119
|
+
if (typeof cost !== 'number' || !Number.isFinite(cost) || cost <= 0) return `${s}0`
|
|
120
|
+
const floor = mode === 'row' ? 0.001 : 0.0001
|
|
121
|
+
if (cost < floor) return `<${s}${floor}`
|
|
122
|
+
let digits
|
|
123
|
+
if (cost < 0.01) digits = mode === 'row' ? 3 : 4
|
|
124
|
+
else if (cost < 1) digits = 3
|
|
125
|
+
else if (cost < 100 || mode === 'panel') digits = 2
|
|
126
|
+
else digits = 0
|
|
127
|
+
return `${s}${cost.toFixed(digits)}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatTokens(value) {
|
|
131
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '0'
|
|
132
|
+
const scaled = n => (n >= 100 ? String(Math.round(n)) : String(Math.round(n * 10) / 10))
|
|
133
|
+
if (value < 1e3) return String(value)
|
|
134
|
+
if (value < 1e6) return `${scaled(value / 1e3)}k`
|
|
135
|
+
return `${scaled(value / 1e6)}M`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatPercent(rate) {
|
|
139
|
+
return `${Math.round(rate * 100)}%`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function badgeText(view) {
|
|
143
|
+
if (view.cost <= 0 && !view.complete) return `${view.symbol}?`
|
|
144
|
+
return formatMoney(view.cost, view.symbol, 'badge') + (view.complete ? '' : '+')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- ui-primitives with local stand-ins ---------------------------------
|
|
148
|
+
|
|
149
|
+
function TooltipStandIn(props) {
|
|
150
|
+
return React.cloneElement(props.children, { title: props.label })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function useAnchoredStandIn(options) {
|
|
154
|
+
const { open, anchorRef, panelRef } = options
|
|
155
|
+
const gap = options.gap === undefined ? 8 : options.gap
|
|
156
|
+
const margin = options.margin === undefined ? 12 : options.margin
|
|
157
|
+
const [position, setPosition] = React.useState(null)
|
|
158
|
+
React.useLayoutEffect(() => {
|
|
159
|
+
if (!open) {
|
|
160
|
+
setPosition(null)
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
const place = () => {
|
|
164
|
+
const anchor = anchorRef.current
|
|
165
|
+
const panel = panelRef.current
|
|
166
|
+
if (!anchor || !panel) return
|
|
167
|
+
const a = anchor.getBoundingClientRect()
|
|
168
|
+
const p = panel.getBoundingClientRect()
|
|
169
|
+
const left = Math.max(margin, Math.min(a.left + a.width / 2 - p.width / 2, window.innerWidth - margin - p.width))
|
|
170
|
+
const top = Math.max(margin, a.top - gap - p.height)
|
|
171
|
+
setPosition({ left, top })
|
|
172
|
+
}
|
|
173
|
+
place()
|
|
174
|
+
window.addEventListener('resize', place)
|
|
175
|
+
window.addEventListener('scroll', place, true)
|
|
176
|
+
return () => {
|
|
177
|
+
window.removeEventListener('resize', place)
|
|
178
|
+
window.removeEventListener('scroll', place, true)
|
|
179
|
+
}
|
|
180
|
+
}, [open, anchorRef, panelRef, gap, margin])
|
|
181
|
+
return position
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function useDismissStandIn(rootRef, open, setOpen, panelRef) {
|
|
185
|
+
React.useEffect(() => {
|
|
186
|
+
if (!open) return undefined
|
|
187
|
+
const onPointerDown = (event) => {
|
|
188
|
+
const target = event.target
|
|
189
|
+
if (rootRef.current && rootRef.current.contains(target)) return
|
|
190
|
+
if (panelRef && panelRef.current && panelRef.current.contains(target)) return
|
|
191
|
+
setOpen(false)
|
|
192
|
+
}
|
|
193
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
194
|
+
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
195
|
+
}, [open, rootRef, panelRef, setOpen])
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Chosen once, so every render calls the same hooks in the same order.
|
|
199
|
+
const Tooltip = primitives.Tooltip != null ? primitives.Tooltip : TooltipStandIn
|
|
200
|
+
const useAnchoredPosition = typeof primitives.useAnchoredPosition === 'function'
|
|
201
|
+
? primitives.useAnchoredPosition
|
|
202
|
+
: useAnchoredStandIn
|
|
203
|
+
const useDismissOnOutsidePointer = typeof primitives.useDismissOnOutsidePointer === 'function'
|
|
204
|
+
? primitives.useDismissOnOutsidePointer
|
|
205
|
+
: useDismissStandIn
|
|
206
|
+
|
|
207
|
+
// ---- session-list reads -------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/** This plugin's view of one Session from the session-list store. */
|
|
210
|
+
function viewOf(list, sessionId) {
|
|
211
|
+
if (!list || typeof sessionId !== 'string') return undefined
|
|
212
|
+
const row = list.byId ? list.byId[sessionId] : undefined
|
|
213
|
+
const fromRow = row && row.projectionValues ? row.projectionValues[KEY] : undefined
|
|
214
|
+
if (fromRow !== undefined) return fromRow
|
|
215
|
+
const loaded = list.projectionsBySession ? list.projectionsBySession[sessionId] : undefined
|
|
216
|
+
return loaded && loaded.values ? loaded.values[KEY] : undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function catalogOf(list, sessionId) {
|
|
220
|
+
const row = list && list.byId ? list.byId[sessionId] : undefined
|
|
221
|
+
const fromRow = row && row.projectionValues ? row.projectionValues.subagentCatalog : undefined
|
|
222
|
+
if (fromRow !== undefined) return fromRow
|
|
223
|
+
const loaded = list && list.projectionsBySession ? list.projectionsBySession[sessionId] : undefined
|
|
224
|
+
return loaded && loaded.values ? loaded.values.subagentCatalog : undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Local child Session ids of a `subagentCatalog` view; external executions have no Session. */
|
|
228
|
+
function childIds(catalog) {
|
|
229
|
+
if (!Array.isArray(catalog)) return []
|
|
230
|
+
const ids = []
|
|
231
|
+
for (const child of catalog) {
|
|
232
|
+
if (child && typeof child.id === 'string' && child.mode !== 'external') ids.push(child.id)
|
|
233
|
+
}
|
|
234
|
+
return ids
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Sum the cost views of a Session's subagents, nested ones included. Returns
|
|
239
|
+
* a string so the store selector result compares by value.
|
|
240
|
+
*/
|
|
241
|
+
function summarizeChildren(list, key, rootId) {
|
|
242
|
+
if (key === '') return ''
|
|
243
|
+
// The root is pre-seeded so a malformed catalog can never count it as its own child.
|
|
244
|
+
const seen = new Set(typeof rootId === 'string' ? [rootId] : [])
|
|
245
|
+
let count = 0
|
|
246
|
+
let known = 0
|
|
247
|
+
let cost = 0
|
|
248
|
+
let complete = true
|
|
249
|
+
const visit = (id, depth) => {
|
|
250
|
+
if (seen.has(id) || depth > 8) return
|
|
251
|
+
seen.add(id)
|
|
252
|
+
count += 1
|
|
253
|
+
const view = viewOf(list, id)
|
|
254
|
+
if (isView(view)) {
|
|
255
|
+
known += 1
|
|
256
|
+
cost += view.cost
|
|
257
|
+
if (!view.complete) complete = false
|
|
258
|
+
}
|
|
259
|
+
for (const nested of childIds(catalogOf(list, id))) visit(nested, depth + 1)
|
|
260
|
+
}
|
|
261
|
+
for (const id of key.split('\n')) visit(id, 0)
|
|
262
|
+
return `${count}|${known}|${cost}|${complete ? 1 : 0}`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseChildren(summary) {
|
|
266
|
+
if (typeof summary !== 'string' || summary === '') return null
|
|
267
|
+
const [count, known, cost, complete] = summary.split('|')
|
|
268
|
+
const n = Number(count)
|
|
269
|
+
if (!(n > 0)) return null
|
|
270
|
+
return { count: n, cost: Number(cost), complete: complete === '1' && Number(known) === n }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- composer badge and panel --------------------------------------------
|
|
274
|
+
|
|
275
|
+
const BUCKETS = [
|
|
276
|
+
{ key: 'input', tone: 'dshtb-tone-input' },
|
|
277
|
+
{ key: 'cacheRead', tone: 'dshtb-tone-cache' },
|
|
278
|
+
{ key: 'cacheWrite', tone: 'dshtb-tone-write' },
|
|
279
|
+
{ key: 'output', tone: 'dshtb-tone-output' },
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
function CostPanel({ view, t, panelRef, position, subagents }) {
|
|
283
|
+
const money = value => formatMoney(value, view.symbol, 'panel')
|
|
284
|
+
const buckets = BUCKETS.filter(bucket => bucket.key !== 'cacheWrite' || view.tokens.cacheWrite > 0)
|
|
285
|
+
const segments = view.cost > 0
|
|
286
|
+
? buckets.map(bucket => ({ ...bucket, width: view.costs[bucket.key] / view.cost * 100 })).filter(s => s.width > 0)
|
|
287
|
+
: []
|
|
288
|
+
|
|
289
|
+
const stats = []
|
|
290
|
+
if (view.cacheHitRate !== null) stats.push(['hit', t('stat.cacheHit'), formatPercent(view.cacheHitRate)])
|
|
291
|
+
stats.push(['calls', t('stat.calls'), t('stat.callsValue', { n: view.calls })])
|
|
292
|
+
if (view.tokens.reasoning > 0) stats.push(['reasoning', t('stat.reasoning'), `${formatTokens(view.tokens.reasoning)} tokens`])
|
|
293
|
+
if (view.turn !== null && view.turn.cost !== view.cost) {
|
|
294
|
+
stats.push(['turn', t('stat.turn'), money(view.turn.cost) + (view.turn.complete ? '' : '+')])
|
|
295
|
+
}
|
|
296
|
+
if (view.compaction !== null) {
|
|
297
|
+
stats.push(['compaction', t('stat.compaction', { n: view.compaction.calls }), money(view.compaction.cost)])
|
|
298
|
+
}
|
|
299
|
+
if (view.tiered && view.saved > 0) stats.push(['saved', t('stat.saved'), money(view.saved)])
|
|
300
|
+
if (subagents !== null) {
|
|
301
|
+
stats.push(['subagents', t('stat.subagents', { n: subagents.count }), money(subagents.cost) + (subagents.complete ? '' : '+')])
|
|
302
|
+
stats.push(['grand', t('stat.grand'), money(view.cost + subagents.cost) + (subagents.complete && view.complete ? '' : '+')])
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const repeated = new Set()
|
|
306
|
+
const seenModels = new Set()
|
|
307
|
+
for (const row of view.models) {
|
|
308
|
+
if (seenModels.has(row.model)) repeated.add(row.model)
|
|
309
|
+
seenModels.add(row.model)
|
|
310
|
+
}
|
|
311
|
+
const showModels = view.models.length > 1 || view.models.some(row => row.cost === null)
|
|
312
|
+
|
|
313
|
+
const notes = [view.tiered ? t('note.tiered') : t('note.flat')]
|
|
314
|
+
if (!view.complete) notes.push(t('note.unpriced'))
|
|
315
|
+
notes.push(t('note.bill'))
|
|
316
|
+
|
|
317
|
+
return h('div', {
|
|
318
|
+
ref: panelRef,
|
|
319
|
+
className: 'dshtb-panel',
|
|
320
|
+
style: position || { visibility: 'hidden', left: 0, top: 0 },
|
|
321
|
+
role: 'dialog',
|
|
322
|
+
'aria-label': t('panel.title'),
|
|
323
|
+
},
|
|
324
|
+
h('div', { className: 'dshtb-head' },
|
|
325
|
+
h('span', { className: 'dshtb-title' }, t('panel.title')),
|
|
326
|
+
h('span', { className: 'dshtb-total' }, view.cost <= 0 && !view.complete
|
|
327
|
+
? t('models.unpriced')
|
|
328
|
+
: money(view.cost) + (view.complete ? '' : '+'))),
|
|
329
|
+
segments.length > 0
|
|
330
|
+
? h('div', { className: 'dshtb-bar', 'aria-hidden': true },
|
|
331
|
+
segments.map(segment => h('div', {
|
|
332
|
+
key: segment.key,
|
|
333
|
+
className: `dshtb-seg ${segment.tone}`,
|
|
334
|
+
style: { width: `${segment.width}%` },
|
|
335
|
+
})))
|
|
336
|
+
: h('div', { className: 'dshtb-gap' }),
|
|
337
|
+
h('dl', { className: 'dshtb-grid' },
|
|
338
|
+
buckets.map(bucket => h(React.Fragment, { key: bucket.key },
|
|
339
|
+
h('dt', null, h('span', { className: `dshtb-swatch ${bucket.tone}`, 'aria-hidden': true }), t(`bucket.${bucket.key}`)),
|
|
340
|
+
h('dd', null, formatTokens(view.tokens[bucket.key])),
|
|
341
|
+
h('dd', { className: 'dshtb-money' }, view.cost > 0 || view.complete ? money(view.costs[bucket.key]) : '—')))),
|
|
342
|
+
h('div', { className: 'dshtb-sep' }),
|
|
343
|
+
h('dl', { className: 'dshtb-grid dshtb-stats' },
|
|
344
|
+
stats.map(([key, label, value]) => h(React.Fragment, { key },
|
|
345
|
+
h('dt', null, label),
|
|
346
|
+
h('dd', { className: key === 'grand' ? 'dshtb-money dshtb-strong' : 'dshtb-money' }, value)))),
|
|
347
|
+
showModels
|
|
348
|
+
? h(React.Fragment, null,
|
|
349
|
+
h('div', { className: 'dshtb-sep' }),
|
|
350
|
+
h('div', { className: 'dshtb-section' }, t('models.title')),
|
|
351
|
+
h('dl', { className: 'dshtb-grid' },
|
|
352
|
+
view.models.map(row => h(React.Fragment, { key: `${row.provider}\n${row.model}` },
|
|
353
|
+
h('dt', { title: row.provider ? `${row.provider} / ${row.model}` : row.model },
|
|
354
|
+
row.model || '?',
|
|
355
|
+
repeated.has(row.model) && row.provider ? h('span', { className: 'dshtb-provider' }, row.provider) : null),
|
|
356
|
+
h('dd', null, formatTokens(row.tokens)),
|
|
357
|
+
h('dd', { className: row.cost === null ? 'dshtb-muted' : 'dshtb-money' },
|
|
358
|
+
row.cost === null ? t('models.unpriced') : money(row.cost))))))
|
|
359
|
+
: null,
|
|
360
|
+
h('p', { className: 'dshtb-note' }, notes.join(t('note.join'))))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function useSubagents(props) {
|
|
364
|
+
const read = props.useProjection
|
|
365
|
+
const catalog = typeof read === 'function' ? read('subagentCatalog') : undefined
|
|
366
|
+
const key = childIds(catalog).join('\n')
|
|
367
|
+
const useSessions = props.useSessions
|
|
368
|
+
const rootId = props.sessionId
|
|
369
|
+
const summary = typeof useSessions === 'function' ? useSessions(list => summarizeChildren(list, key, rootId)) : ''
|
|
370
|
+
return parseChildren(summary)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function CostBadge(props) {
|
|
374
|
+
const t = translator(props.t)
|
|
375
|
+
const read = props.useProjection
|
|
376
|
+
const view = typeof read === 'function' ? read(KEY) : undefined
|
|
377
|
+
const subagents = useSubagents(props)
|
|
378
|
+
const [open, setOpen] = React.useState(false)
|
|
379
|
+
const rootRef = React.useRef(null)
|
|
380
|
+
const panelRef = React.useRef(null)
|
|
381
|
+
const available = isView(view) && (view.calls > 0 || view.compaction !== null)
|
|
382
|
+
// Re-measure when the panel gains or loses rows while it is open.
|
|
383
|
+
const layoutKey = available
|
|
384
|
+
? [view.models.length, view.complete, view.compaction !== null, view.turn !== null, view.saved > 0,
|
|
385
|
+
view.tokens.reasoning > 0, view.tokens.cacheWrite > 0, subagents === null ? 0 : subagents.count].join('|')
|
|
386
|
+
: ''
|
|
387
|
+
const position = useAnchoredPosition({
|
|
388
|
+
open: open && available,
|
|
389
|
+
anchorRef: rootRef,
|
|
390
|
+
panelRef,
|
|
391
|
+
side: 'top',
|
|
392
|
+
gap: 8,
|
|
393
|
+
margin: 12,
|
|
394
|
+
layoutKey,
|
|
395
|
+
})
|
|
396
|
+
useDismissOnOutsidePointer(rootRef, open && available, setOpen, panelRef)
|
|
397
|
+
React.useEffect(() => {
|
|
398
|
+
if (!available && open) setOpen(false)
|
|
399
|
+
}, [available, open])
|
|
400
|
+
React.useEffect(() => {
|
|
401
|
+
if (!open) return undefined
|
|
402
|
+
const onKeyDown = (event) => {
|
|
403
|
+
if (event.key === 'Escape') setOpen(false)
|
|
404
|
+
}
|
|
405
|
+
document.addEventListener('keydown', onKeyDown)
|
|
406
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
407
|
+
}, [open])
|
|
408
|
+
if (!available) return null
|
|
409
|
+
|
|
410
|
+
const label = view.cost <= 0 && !view.complete
|
|
411
|
+
? t('badge.unpriced')
|
|
412
|
+
: t('badge.tooltip', { cost: formatMoney(view.cost, view.symbol, 'panel') + (view.complete ? '' : '+') })
|
|
413
|
+
return h('span', { ref: rootRef, className: 'dshtb-root' },
|
|
414
|
+
h(Tooltip, { label, side: 'top', delayMs: 200, disabled: open, portal: true },
|
|
415
|
+
h('button', {
|
|
416
|
+
type: 'button',
|
|
417
|
+
className: 'dshtb-trigger',
|
|
418
|
+
'aria-label': label,
|
|
419
|
+
'aria-haspopup': 'dialog',
|
|
420
|
+
'aria-expanded': open,
|
|
421
|
+
onClick: () => setOpen(!open),
|
|
422
|
+
}, badgeText(view))),
|
|
423
|
+
open && typeof document !== 'undefined'
|
|
424
|
+
? ReactDOM.createPortal(h(CostPanel, { view, t, panelRef, position, subagents }), document.body)
|
|
425
|
+
: null)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---- sidebar session rows ---------------------------------------------
|
|
429
|
+
|
|
430
|
+
function SessionRowCost(props) {
|
|
431
|
+
const useSessions = props.useSessions
|
|
432
|
+
const sessionId = props.sessionId
|
|
433
|
+
const view = typeof useSessions === 'function' ? useSessions(list => viewOf(list, sessionId)) : undefined
|
|
434
|
+
if (!isView(view) || view.showInList === false || !(view.cost > 0)) return null
|
|
435
|
+
const t = translator(props.t)
|
|
436
|
+
const more = view.complete ? '' : '+'
|
|
437
|
+
return h('span', {
|
|
438
|
+
className: 'dshtb-row',
|
|
439
|
+
title: t('row.title', { cost: formatMoney(view.cost, view.symbol, 'panel') + more }),
|
|
440
|
+
}, formatMoney(view.cost, view.symbol, 'row') + more)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---- styles ---------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
// Theme tokens only, so light, dark, and skins follow the host. The panel
|
|
446
|
+
// mirrors the built-in context meter's panel.
|
|
447
|
+
const CSS = [
|
|
448
|
+
'.dshtb-root{flex:none;display:inline-flex}',
|
|
449
|
+
'.dshtb-trigger{border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:var(--dsh-content-font-size-secondary,13px);font-variant-numeric:tabular-nums;line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));white-space:nowrap;cursor:pointer;background:0 0;border:none;flex:none;align-items:center;gap:6px;padding:1px 8px;display:inline-flex}',
|
|
450
|
+
'.dshtb-trigger[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
|
|
451
|
+
'@media (hover:hover){.dshtb-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}}',
|
|
452
|
+
'.dshtb-panel{z-index:1100;box-sizing:border-box;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);width:min(300px,100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);cursor:default;max-width:var(--dsh-anchored-max-width,none);max-height:var(--dsh-anchored-max-height,none);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;padding:12px;font-size:12px;line-height:20px;position:fixed;overflow:hidden auto}',
|
|
453
|
+
'.dshtb-head{display:flex;align-items:baseline;gap:6px}',
|
|
454
|
+
'.dshtb-title{color:var(--dsw-alias-label-tertiary)}',
|
|
455
|
+
'.dshtb-total{margin-left:auto;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;font-variant-numeric:tabular-nums}',
|
|
456
|
+
'.dshtb-bar{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;gap:1px;height:4px;margin:10px 0;display:flex;overflow:hidden}',
|
|
457
|
+
'.dshtb-gap{height:8px}',
|
|
458
|
+
'.dshtb-seg{background:var(--dshtb-tint);border-radius:1px;flex:none;min-width:2px;height:100%}',
|
|
459
|
+
'.dshtb-swatch{background:var(--dshtb-tint);vertical-align:baseline;border-radius:2px;width:8px;height:8px;margin-right:6px;display:inline-block}',
|
|
460
|
+
'.dshtb-tone-input{--dshtb-tint:var(--dsw-static-blue-450,#4d6bfe)}',
|
|
461
|
+
'.dshtb-tone-cache{--dshtb-tint:var(--dsw-static-neutral-bluish-400,#8a94a6)}',
|
|
462
|
+
'.dshtb-tone-write{--dshtb-tint:#f59e0b}',
|
|
463
|
+
'.dshtb-tone-output{--dshtb-tint:#a78bfa}',
|
|
464
|
+
'.dshtb-grid{display:grid;grid-template-columns:minmax(0,1fr) auto auto;column-gap:12px;row-gap:2px;align-items:baseline;margin:0}',
|
|
465
|
+
'.dshtb-grid dt{color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
466
|
+
'.dshtb-grid dd{margin:0;text-align:right;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
|
|
467
|
+
'.dshtb-grid dd.dshtb-money{color:var(--dsw-alias-label-primary)}',
|
|
468
|
+
'.dshtb-grid dd.dshtb-muted{color:var(--dsw-alias-label-tertiary)}',
|
|
469
|
+
'.dshtb-stats{grid-template-columns:minmax(0,1fr) auto}',
|
|
470
|
+
'.dshtb-strong{font-weight:500}',
|
|
471
|
+
'.dshtb-provider{margin-left:6px;color:var(--dsw-alias-label-tertiary)}',
|
|
472
|
+
'.dshtb-sep{border-top:.5px solid var(--dsw-alias-border-l2);margin:8px 0}',
|
|
473
|
+
'.dshtb-section{color:var(--dsw-alias-label-tertiary);margin-bottom:2px}',
|
|
474
|
+
'.dshtb-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
|
|
475
|
+
'.dshtb-row{flex:none;color:inherit;font-size:10px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap}',
|
|
476
|
+
].join('')
|
|
477
|
+
|
|
478
|
+
/** Install (or refresh) this plugin's one stylesheet. */
|
|
479
|
+
function ensureStyles() {
|
|
480
|
+
if (typeof document === 'undefined') return null
|
|
481
|
+
let tag = document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_KEY)}]`)
|
|
482
|
+
if (tag === null) {
|
|
483
|
+
tag = document.createElement('style')
|
|
484
|
+
tag.dataset.plugin = 'dsh-token-bill'
|
|
485
|
+
tag.dataset.pluginCss = STYLE_KEY
|
|
486
|
+
document.head.appendChild(tag)
|
|
487
|
+
}
|
|
488
|
+
tag.textContent = CSS
|
|
489
|
+
return tag
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
name: 'dsh-token-bill',
|
|
494
|
+
inject: ['slots', 'locale'],
|
|
495
|
+
/**
|
|
496
|
+
* Register the stylesheet, the dictionaries, and both slot entries. Each
|
|
497
|
+
* rides this plugin's fiber, so turning the plugin off removes them all.
|
|
498
|
+
* @param ctx - client plugin context.
|
|
499
|
+
*/
|
|
500
|
+
apply(ctx) {
|
|
501
|
+
ctx.effect(() => {
|
|
502
|
+
const tag = ensureStyles()
|
|
503
|
+
return () => {
|
|
504
|
+
if (tag !== null) tag.remove()
|
|
505
|
+
}
|
|
506
|
+
}, 'dsh-token-bill: stylesheet')
|
|
507
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-token-bill: dictionaries')
|
|
508
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
509
|
+
name: 'conversation.composer.dock',
|
|
510
|
+
id: 'dsh-token-bill',
|
|
511
|
+
order: -1,
|
|
512
|
+
locale: NS,
|
|
513
|
+
}, CostBadge))
|
|
514
|
+
ctx.slots.inject('sidebar.session.row.trailing', () => ctx.slots.register({
|
|
515
|
+
name: 'sidebar.session.row.trailing',
|
|
516
|
+
id: 'dsh-token-bill',
|
|
517
|
+
locale: NS,
|
|
518
|
+
}, SessionRowCost))
|
|
519
|
+
},
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Inserts this bundle's row into the active DSH profile.
|
|
2
|
+
#
|
|
3
|
+
# Prices are per one million tokens, in `currency`. The official DeepSeek
|
|
4
|
+
# models (deepseek-flash, deepseek-v4-pro) are priced from the built-in table,
|
|
5
|
+
# peak and off-peak separately; add any other model under `models`.
|
|
6
|
+
# Override fields from your profile's cordis.patch.yml — see README.md.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-token-bill
|
|
9
|
+
name: dsh-token-bill
|
|
10
|
+
config:
|
|
11
|
+
# CNY or USD use the built-in DeepSeek tables; any other code needs `models`.
|
|
12
|
+
currency: CNY
|
|
13
|
+
# DeepSeek peak / off-peak pricing (off-peak is half price).
|
|
14
|
+
timeOfDayPricing: true
|
|
15
|
+
# Show each conversation's cost in the sidebar list.
|
|
16
|
+
showInSessionList: true
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/sidebar.png
ADDED
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<circle cx="32" cy="32" r="26" fill="#247bbf"/>
|
|
3
|
+
<circle cx="32" cy="32" r="26" fill="none" stroke="#0f2f4f" stroke-opacity="0.18" stroke-width="2"/>
|
|
4
|
+
<circle cx="32" cy="32" r="19" fill="none" stroke="#ffffff" stroke-opacity="0.4" stroke-width="3"/>
|
|
5
|
+
<text x="32" y="40" font-family="ui-sans-serif, system-ui, sans-serif" font-size="22" font-weight="700" fill="#ffffff" text-anchor="middle">¥</text>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-token-bill: registers the `dshTokenBill` session projection.
|
|
3
|
+
*
|
|
4
|
+
* The bundle has no runtime dependencies on purpose — config is read from the
|
|
5
|
+
* Loader row as-is (no Config schema) — so it installs straight from a Git
|
|
6
|
+
* URL or a linked folder without a registry round trip. See README.md for the
|
|
7
|
+
* config fields; `lib/pricing.js` resolves them and never throws on bad input.
|
|
8
|
+
*/
|
|
9
|
+
import { createTokenCostProjection } from './lib/fold.js'
|
|
10
|
+
import { resolvePricing } from './lib/pricing.js'
|
|
11
|
+
|
|
12
|
+
export const name = 'dsh-token-bill'
|
|
13
|
+
|
|
14
|
+
export const inject = ['sessionProjections']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register the projection under this plugin's fiber; unloading the plugin
|
|
18
|
+
* releases it. A config edit restarts the plugin, which re-prices every view.
|
|
19
|
+
* @param {object} ctx - Cordis context with the `sessionProjections` service.
|
|
20
|
+
* @param {unknown} config - raw row config from `cordis.patch.yml`.
|
|
21
|
+
*/
|
|
22
|
+
export async function apply(ctx, config) {
|
|
23
|
+
const pricing = resolvePricing(config)
|
|
24
|
+
await ctx.sessionProjections.register(createTokenCostProjection(pricing))
|
|
25
|
+
}
|