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/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
+ })
@@ -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
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">&#165;</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
+ }