dsh-plugin-workspace-spend 0.0.0-stage → 1.0.0

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,640 @@
1
+ /**
2
+ * Browser half of `dsh-plugin-workspace-spend`.
3
+ *
4
+ * Adds one settings page ("支出统计" / "Spend") under Settings. The page reads
5
+ * the Host half's authenticated `GET /api/workspace-spend` route and renders
6
+ * per-workspace estimated spend, token totals, model breakdown, and the price
7
+ * table the Host applied. All visible text goes through the Client locale
8
+ * service; styling uses only `--dsw-alias-*` theme tokens.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: 'dsh-plugin-workspace-spend',
12
+ factory(require) {
13
+ const React = require('react')
14
+ const h = React.createElement
15
+
16
+ const NS = 'workspaceSpend'
17
+
18
+ const zh = {
19
+ navLabel: '支出统计',
20
+ title: '工作区支出',
21
+ intro:
22
+ '按工作区汇总本机所有会话的模型调用费用:Token 用量取自会话日志,金额按下方价格表估算。',
23
+ refresh: '刷新',
24
+ refreshing: '刷新中…',
25
+ loading: '正在统计…(首次读取需要解析历史会话日志,请稍候)',
26
+ retry: '重试',
27
+ loadError: '统计失败',
28
+ rangeAll: '全部',
29
+ range30: '近 30 天',
30
+ range7: '近 7 天',
31
+ rangeToday: '今天',
32
+ totalLabel: '估算总花费',
33
+ totalNote: '峰值价 + 空闲时段折扣,仅作估算',
34
+ statWorkspaces: '工作区',
35
+ statSessions: '会话',
36
+ statRequests: '模型请求',
37
+ statMessages: '助手消息',
38
+ tokensLine: '输入 {input} · 输出 {output} · 缓存命中 {cache}',
39
+ unattributed: '未归入工作区',
40
+ byModel: '按模型',
41
+ modelUnpriced: '未配置价格',
42
+ metaSessions: '{n} 个会话',
43
+ metaMessages: '{n} 条消息',
44
+ metaRequests: '{n} 次请求',
45
+ emptyTitle: '所选范围内没有用量',
46
+ emptyBody: '运行一个会话后再回来看,或切换到「全部」。',
47
+ unpricedWarn: '以下模型没有配置价格,Token 已计入但不计价:',
48
+ pricingTitle: '价格表与计费规则(配置项,不代表已使用)',
49
+ pricingNote:
50
+ '单价为 {currency}/百万 tokens 的峰值价;空闲时段按峰值价的 {discount} 倍计费。高峰时段:北京时间周一至周五 9:00–12:00、14:00–18:00;周末与 {holidays} 个配置的节假日全天为空闲时段。下面列出的是已配置的模型单价,未使用的模型金额为 0。',
51
+ pricingInput: '输入',
52
+ pricingCache: '缓存命中',
53
+ pricingOutput: '输出',
54
+ pricingPerCall: '每次调用',
55
+ usedBadge: '本机已使用',
56
+ unusedBadge: '未使用',
57
+ updatedAt: '数据截至 {time}',
58
+ partial: '{failed} 个会话读取失败,统计可能偏低',
59
+ scanNote: '扫描 {n} 个历史会话(全部时间)',
60
+ reconcileNote:
61
+ '与 DeepSeek 平台「用量信息」对账时请对齐同一时刻:平台用量数据通常有 5 分钟以上延迟,而本页是实时读取本地会话日志的;本页时间按北京时间自然日切分。',
62
+ note:
63
+ '这是按本机日志与配置价格计算的估算值,不等同于账单;未记录用量的调用(例如自动生成标题)不计入。若要调整价格,请在 profile 的 cordis.patch.yml 中覆盖 workspace-spend 行的 config。',
64
+ refund: '免费额度 / 赠送余额不在此统计范围内。',
65
+ }
66
+
67
+ const en = {
68
+ navLabel: 'Spend',
69
+ title: 'Workspace spend',
70
+ intro:
71
+ 'Estimated model spend per workspace across every session on this machine: token usage is folded from session logs and priced with the table below.',
72
+ refresh: 'Refresh',
73
+ refreshing: 'Refreshing…',
74
+ loading: 'Computing… (the first load replays stored session logs)',
75
+ retry: 'Retry',
76
+ loadError: 'Failed to load',
77
+ rangeAll: 'All time',
78
+ range30: '30 days',
79
+ range7: '7 days',
80
+ rangeToday: 'Today',
81
+ totalLabel: 'Estimated total',
82
+ totalNote: 'Peak prices with off-peak discount; an estimate only',
83
+ statWorkspaces: 'Workspaces',
84
+ statSessions: 'Sessions',
85
+ statRequests: 'Requests',
86
+ statMessages: 'Messages',
87
+ tokensLine: 'Input {input} · Output {output} · Cache hits {cache}',
88
+ unattributed: 'No workspace',
89
+ byModel: 'By model',
90
+ modelUnpriced: 'no price',
91
+ metaSessions: '{n} sessions',
92
+ metaMessages: '{n} messages',
93
+ metaRequests: '{n} requests',
94
+ emptyTitle: 'No usage in this range',
95
+ emptyBody: 'Run a session and come back, or switch to "All time".',
96
+ unpricedWarn: 'These models have no configured price; their tokens are counted but not priced:',
97
+ pricingTitle: 'Prices and rules (configured rates, not usage)',
98
+ pricingNote:
99
+ 'Rates are peak {currency} per million tokens. Off-peak time is billed at {discount}×. Peak: Beijing time Mon–Fri 09:00–12:00 and 14:00–18:00; weekends and {holidays} configured holidays are off-peak all day. These are configured rates only; models not used here cost 0.',
100
+ pricingInput: 'Input',
101
+ pricingCache: 'Cache hit',
102
+ pricingOutput: 'Output',
103
+ pricingPerCall: 'per call',
104
+ usedBadge: 'used',
105
+ unusedBadge: 'not used',
106
+ updatedAt: 'Data through {time}',
107
+ partial: '{failed} session(s) could not be read; totals may be low',
108
+ scanNote: '{n} sessions scanned (all time)',
109
+ reconcileNote:
110
+ 'To reconcile with the DeepSeek usage page, compare the same moment: platform usage data usually lags by several minutes, while this page reads local session logs live. Ranges use Beijing-time calendar days.',
111
+ note:
112
+ 'This is an estimate from local logs and configured prices, not a bill. Calls that log no usage (for example automatic title generation) are not included. To change prices, override the config of the workspace-spend row in the profile cordis.patch.yml.',
113
+ refund: 'Free quota and bonus balance are outside this estimate.',
114
+ }
115
+
116
+ const CSS = `
117
+ .dws-root{display:flex;flex-direction:column;gap:14px;width:100%;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}
118
+ .dws-head{display:flex;align-items:flex-start;gap:10px;flex-wrap:wrap}
119
+ .dws-headText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 220px}
120
+ .dws-title{margin:0;font-size:15px;line-height:24px;font-weight:600}
121
+ .dws-intro{margin:0;color:var(--dsw-alias-label-secondary)}
122
+ .dws-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
123
+ .dws-ranges{display:flex;gap:2px;padding:2px;border-radius:9px;background:var(--dsw-alias-bg-layer-2)}
124
+ .dws-range{border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;padding:3px 9px;border-radius:7px;cursor:pointer;white-space:nowrap}
125
+ .dws-range:hover{color:var(--dsw-alias-label-primary)}
126
+ .dws-range[data-active="true"]{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-weight:600;box-shadow:0 0 0 .5px var(--dsw-alias-border-l1)}
127
+ .dws-range:focus-visible,.dws-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
128
+ .dws-btn{border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;padding:5px 11px;border-radius:8px;cursor:pointer;white-space:nowrap}
129
+ .dws-btn:hover{background:var(--dsw-alias-bg-layer-2)}
130
+ .dws-btn[disabled]{opacity:.5;cursor:default}
131
+ .dws-total{border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 14px;background:var(--dsw-alias-bg-layer-1);display:flex;flex-direction:column;gap:10px}
132
+ .dws-totalTop{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
133
+ .dws-totalLabel{color:var(--dsw-alias-label-secondary);font-size:12px}
134
+ .dws-totalValue{font-size:26px;line-height:34px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.2px}
135
+ .dws-totalNote{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px}
136
+ .dws-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:8px;margin:0;padding:0}
137
+ .dws-stat{margin:0;min-width:0}
138
+ .dws-stat dt{margin:0;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px}
139
+ .dws-stat dd{margin:1px 0 0;font-size:16px;line-height:22px;font-weight:600;font-variant-numeric:tabular-nums}
140
+ .dws-tokens{margin:0;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:18px;font-variant-numeric:tabular-nums}
141
+ .dws-list{display:flex;flex-direction:column;gap:8px;margin:0;padding:0;list-style:none}
142
+ .dws-row{border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;padding:10px 12px;background:var(--dsw-alias-bg-layer-1);display:flex;flex-direction:column;gap:7px;min-width:0}
143
+ .dws-rowHead{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
144
+ .dws-rowName{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;min-width:0;flex:1 1 180px}
145
+ .dws-rowTitle{font-weight:600;font-size:13.5px;overflow-wrap:anywhere}
146
+ .dws-rowPath{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;overflow-wrap:anywhere}
147
+ .dws-badge{border:.5px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);border-radius:999px;padding:0 7px;font-size:10.5px;line-height:16px;white-space:nowrap}
148
+ .dws-rowRight{display:flex;align-items:baseline;gap:8px;margin-left:auto;font-variant-numeric:tabular-nums}
149
+ .dws-rowCost{font-size:15px;font-weight:600}
150
+ .dws-rowShare{color:var(--dsw-alias-label-secondary);font-size:11px;min-width:38px;text-align:right}
151
+ .dws-bar{height:4px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
152
+ .dws-barFill{display:block;height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary);min-width:2px}
153
+ .dws-rowMeta{color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:18px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
154
+ .dws-models{display:flex;gap:5px;flex-wrap:wrap}
155
+ .dws-chip{border:.5px solid var(--dsw-alias-border-l1);border-radius:7px;padding:0 6px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;white-space:nowrap}
156
+ .dws-chip[data-unpriced="true"]{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary)}
157
+ .dws-section{display:flex;flex-direction:column;gap:8px}
158
+ .dws-sectionTitle{margin:0;font-size:12.5px;line-height:18px;font-weight:600}
159
+ .dws-modelList{display:flex;flex-direction:column;gap:5px;margin:0;padding:0;list-style:none}
160
+ .dws-modelRow{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;font-size:12px;line-height:18px}
161
+ .dws-modelName{font-weight:600;overflow-wrap:anywhere}
162
+ .dws-modelMeta{color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums}
163
+ .dws-modelCost{margin-left:auto;font-variant-numeric:tabular-nums}
164
+ .dws-muted{margin:0;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:18px}
165
+ .dws-status{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;margin:0}
166
+ .dws-error{border:.5px solid var(--dsw-alias-state-error-primary);border-radius:10px;padding:10px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
167
+ .dws-error p{margin:0;color:var(--dsw-alias-state-error-primary)}
168
+ .dws-warn{border:.5px solid var(--dsw-alias-state-warn-primary);border-radius:10px;padding:8px 11px;display:flex;flex-direction:column;gap:4px}
169
+ .dws-warn p{margin:0;font-size:12px;line-height:18px}
170
+ .dws-warnModels{display:flex;gap:5px;flex-wrap:wrap}
171
+ .dws-empty{display:flex;flex-direction:column;gap:4px;padding:10px 2px}
172
+ .dws-empty p{margin:0}
173
+ .dws-details{border-top:.5px solid var(--dsw-alias-border-l1);padding-top:9px}
174
+ .dws-details summary{cursor:pointer;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
175
+ .dws-details summary:hover{color:var(--dsw-alias-label-primary)}
176
+ .dws-detailsBody{display:flex;flex-direction:column;gap:6px;padding-top:8px}
177
+ .dws-rateList{display:flex;flex-direction:column;gap:4px;margin:0;padding:0;list-style:none}
178
+ .dws-rateRow{display:flex;gap:10px;flex-wrap:wrap;font-size:11.5px;line-height:17px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
179
+ .dws-rateModel{color:var(--dsw-alias-label-primary);font-weight:600}
180
+ .dws-rateBadge{border:.5px solid var(--dsw-alias-border-l1);border-radius:999px;padding:0 6px;font-size:10.5px;line-height:15px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
181
+ .dws-rateBadge[data-used="true"]{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}
182
+ .dws-foot{margin:0;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px}
183
+ `
184
+
185
+ const RANGES = [
186
+ { days: 0, key: 'rangeAll' },
187
+ { days: 30, key: 'range30' },
188
+ { days: 7, key: 'range7' },
189
+ { days: 1, key: 'rangeToday' },
190
+ ]
191
+
192
+ function fill(template, values) {
193
+ let text = String(template)
194
+ for (const [key, value] of Object.entries(values)) text = text.split(`{${key}}`).join(String(value))
195
+ return text
196
+ }
197
+
198
+ function groupDigits(text) {
199
+ const [whole, fraction] = text.split('.')
200
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
201
+ return fraction === undefined ? grouped : `${grouped}.${fraction}`
202
+ }
203
+
204
+ function formatCost(value, symbol) {
205
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
206
+ if (value > 0 && value < 0.01) return `<${symbol}0.01`
207
+ return `${symbol}${groupDigits(value.toFixed(2))}`
208
+ }
209
+
210
+ function formatTokens(value) {
211
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
212
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
213
+ if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`
214
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
215
+ return String(Math.round(n))
216
+ }
217
+
218
+ function formatRate(value) {
219
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '0'
220
+ if (Number.isInteger(value)) return String(value)
221
+ return String(Number(value.toFixed(4)))
222
+ }
223
+
224
+ function shareOf(cost, total) {
225
+ if (typeof cost !== 'number' || !Number.isFinite(cost) || total <= 0) return 0
226
+ return Math.min(100, Math.max(0, (cost / total) * 100))
227
+ }
228
+
229
+ function formatClock(timeMs) {
230
+ try {
231
+ return new Date(timeMs).toLocaleTimeString(undefined, { hour12: false })
232
+ } catch {
233
+ return ''
234
+ }
235
+ }
236
+
237
+ async function fetchReport(days, signal) {
238
+ if (typeof fetch !== 'function') throw new Error('fetch unavailable in this page')
239
+ const response = await fetch(`api/workspace-spend?days=${encodeURIComponent(String(days))}`, {
240
+ method: 'GET',
241
+ headers: { accept: 'application/json' },
242
+ cache: 'no-store',
243
+ credentials: 'same-origin',
244
+ signal,
245
+ })
246
+ let payload
247
+ try {
248
+ payload = await response.json()
249
+ } catch {
250
+ payload = undefined
251
+ }
252
+ if (!response.ok || payload === undefined || payload.ok !== true) {
253
+ const message = payload?.error?.message
254
+ throw new Error(message === undefined ? `HTTP ${response.status}` : `${payload.error?.code ?? 'error'}: ${message}`)
255
+ }
256
+ return payload.report
257
+ }
258
+
259
+ function useLocaleRevision(locale) {
260
+ const [revision, setRevision] = React.useState(0)
261
+ React.useEffect(() => {
262
+ if (locale === undefined || typeof locale.subscribe !== 'function') return undefined
263
+ return locale.subscribe(() => setRevision((value) => value + 1))
264
+ }, [locale])
265
+ return revision
266
+ }
267
+
268
+ function SpendSection(props) {
269
+ const t = props.t
270
+ const locale = props.locale
271
+ useLocaleRevision(locale)
272
+ const [rangeDays, setRangeDays] = React.useState(0)
273
+ const [attempt, setAttempt] = React.useState(0)
274
+ const [state, setState] = React.useState({ status: 'loading', report: undefined, error: '' })
275
+
276
+ React.useEffect(() => {
277
+ const controller = new AbortController()
278
+ let live = true
279
+ setState((previous) => ({ status: 'loading', report: previous.report, error: '' }))
280
+ fetchReport(rangeDays, controller.signal)
281
+ .then((report) => {
282
+ if (!live) return
283
+ setState({ status: 'ready', report, error: '' })
284
+ })
285
+ .catch((error) => {
286
+ if (!live || controller.signal.aborted) return
287
+ setState((previous) => ({
288
+ status: 'error',
289
+ report: previous.report,
290
+ error: String(error?.message ?? error),
291
+ }))
292
+ })
293
+ return () => {
294
+ live = false
295
+ controller.abort()
296
+ }
297
+ }, [rangeDays, attempt])
298
+
299
+ const report = state.report
300
+ const busy = state.status === 'loading'
301
+ const currencySymbol = report?.currencySymbol ?? '¥'
302
+ const refresh = () => setAttempt((value) => value + 1)
303
+
304
+ return h(
305
+ React.Fragment,
306
+ null,
307
+ h('style', { key: 'dws-style' }, CSS),
308
+ h(
309
+ 'div',
310
+ { className: 'dws-root' },
311
+ h(
312
+ 'header',
313
+ { className: 'dws-head' },
314
+ h(
315
+ 'div',
316
+ { className: 'dws-headText' },
317
+ h('h4', { className: 'dws-title' }, t('title')),
318
+ h('p', { className: 'dws-intro' }, t('intro')),
319
+ ),
320
+ h(
321
+ 'div',
322
+ { className: 'dws-actions' },
323
+ h(
324
+ 'div',
325
+ { className: 'dws-ranges', role: 'group' },
326
+ RANGES.map((range) =>
327
+ h(
328
+ 'button',
329
+ {
330
+ key: range.key,
331
+ type: 'button',
332
+ className: 'dws-range',
333
+ 'data-active': rangeDays === range.days ? 'true' : 'false',
334
+ 'aria-pressed': rangeDays === range.days,
335
+ disabled: busy,
336
+ onClick: () => setRangeDays(range.days),
337
+ },
338
+ t(range.key),
339
+ ),
340
+ ),
341
+ ),
342
+ h(
343
+ 'button',
344
+ { type: 'button', className: 'dws-btn', disabled: busy, onClick: refresh },
345
+ busy ? t('refreshing') : t('refresh'),
346
+ ),
347
+ ),
348
+ ),
349
+ state.status === 'error'
350
+ ? h(
351
+ 'div',
352
+ { className: 'dws-error', role: 'alert' },
353
+ h('p', null, `${t('loadError')} (${state.error})`),
354
+ h('button', { type: 'button', className: 'dws-btn', onClick: refresh }, t('retry')),
355
+ )
356
+ : null,
357
+ busy && report === undefined ? h('p', { className: 'dws-status' }, t('loading')) : null,
358
+ report !== undefined ? h(ReportBody, { report, t, busy, currencySymbol }) : null,
359
+ ),
360
+ )
361
+ }
362
+
363
+ function ReportBody(props) {
364
+ const { report, t, busy, currencySymbol } = props
365
+ const totals = report.totals ?? {}
366
+ const workspaces = Array.isArray(report.workspaces) ? report.workspaces : []
367
+ const models = Array.isArray(report.models) ? report.models : []
368
+ const usedModels = new Set(
369
+ models
370
+ .filter((model) => typeof model.model === 'string' && !model.model.startsWith('tool:'))
371
+ .map((model) => model.model),
372
+ )
373
+ const unpricedModels = Array.isArray(report.unpricedModels) ? report.unpricedModels : []
374
+ const scanned = report.scanned ?? { sessions: 0, failed: 0 }
375
+ const totalCost = typeof totals.cost === 'number' ? totals.cost : 0
376
+ const tokens = totals.tokens ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
377
+ const hasUnpriced = unpricedModels.length > 0
378
+ const valueText =
379
+ totalCost === 0 && hasUnpriced ? '—' : `≈ ${formatCost(totalCost, currencySymbol)}`
380
+
381
+ return h(
382
+ 'div',
383
+ { className: 'dws-section' },
384
+ h(
385
+ 'div',
386
+ { className: 'dws-total' },
387
+ h(
388
+ 'div',
389
+ { className: 'dws-totalTop' },
390
+ h('span', { className: 'dws-totalLabel' }, t('totalLabel')),
391
+ h('span', { className: 'dws-totalValue' }, valueText),
392
+ h('span', { className: 'dws-totalNote' }, t('totalNote')),
393
+ ),
394
+ h('p', { className: 'dws-tokens' }, fill(t('tokensLine'), {
395
+ input: formatTokens(tokens.input),
396
+ output: formatTokens(tokens.output),
397
+ cache: formatTokens(tokens.cacheRead),
398
+ })),
399
+ h(
400
+ 'dl',
401
+ { className: 'dws-stats' },
402
+ stat(t('statWorkspaces'), String(workspaces.length)),
403
+ stat(t('statSessions'), String(totals.sessions ?? 0)),
404
+ stat(t('statRequests'), String(totals.requests ?? 0)),
405
+ stat(t('statMessages'), String(totals.messages ?? 0)),
406
+ ),
407
+ ),
408
+ h(
409
+ 'p',
410
+ { className: 'dws-muted' },
411
+ [
412
+ fill(t('updatedAt'), { time: formatClock(report.generatedAt) }),
413
+ fill(t('scanNote'), { n: scanned.sessions ?? 0 }),
414
+ scanned.failed > 0 ? fill(t('partial'), { failed: scanned.failed }) : '',
415
+ ]
416
+ .filter((part) => part.length > 0)
417
+ .join(' · '),
418
+ ),
419
+ hasUnpriced
420
+ ? h(
421
+ 'div',
422
+ { className: 'dws-warn' },
423
+ h('p', null, t('unpricedWarn')),
424
+ h(
425
+ 'div',
426
+ { className: 'dws-warnModels' },
427
+ unpricedModels.map((model) => h('span', { key: model, className: 'dws-chip', 'data-unpriced': 'true' }, model)),
428
+ ),
429
+ )
430
+ : null,
431
+ workspaces.length === 0
432
+ ? h(
433
+ 'div',
434
+ { className: 'dws-empty' },
435
+ h('p', { className: 'dws-rowTitle' }, t('emptyTitle')),
436
+ h('p', { className: 'dws-muted' }, t('emptyBody')),
437
+ )
438
+ : h(
439
+ 'ul',
440
+ { className: 'dws-list' },
441
+ workspaces.map((workspace) =>
442
+ h(WorkspaceRow, { key: workspace.key, workspace, totalCost, t, currencySymbol }),
443
+ ),
444
+ ),
445
+ models.length > 0
446
+ ? h(
447
+ 'div',
448
+ { className: 'dws-section' },
449
+ h('h5', { className: 'dws-sectionTitle' }, t('byModel')),
450
+ h(
451
+ 'ul',
452
+ { className: 'dws-modelList' },
453
+ models.map((model) =>
454
+ h(
455
+ 'li',
456
+ { key: model.key, className: 'dws-modelRow' },
457
+ h('span', { className: 'dws-modelName' }, model.model),
458
+ h(
459
+ 'span',
460
+ { className: 'dws-modelMeta' },
461
+ `${formatTokens(
462
+ (model.tokens?.input ?? 0) + (model.tokens?.output ?? 0) + (model.tokens?.cacheRead ?? 0),
463
+ )} tokens · ${
464
+ model.calls > 0 ? fill(t('metaRequests'), { n: model.requests ?? 0 }) : fill(t('metaMessages'), { n: model.messages ?? 0 })
465
+ }`,
466
+ ),
467
+ h(
468
+ 'span',
469
+ { className: 'dws-modelCost' },
470
+ model.priced === false
471
+ ? t('modelUnpriced')
472
+ : formatCost(model.cost ?? 0, currencySymbol),
473
+ ),
474
+ ),
475
+ ),
476
+ ),
477
+ )
478
+ : null,
479
+ h(PriceDetails, { pricing: report.pricing, t, usedModels }),
480
+ h('p', { className: 'dws-foot' }, t('reconcileNote')),
481
+ h('p', { className: 'dws-foot' }, t('note')),
482
+ )
483
+ }
484
+
485
+ function stat(label, value) {
486
+ return h('div', { className: 'dws-stat', key: label }, h('dt', null, label), h('dd', null, value))
487
+ }
488
+
489
+ function WorkspaceRow(props) {
490
+ const { workspace, totalCost, t, currencySymbol } = props
491
+ const tokens = workspace.tokens ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
492
+ const share = shareOf(workspace.cost, totalCost)
493
+ const models = Array.isArray(workspace.models) ? workspace.models : []
494
+ return h(
495
+ 'li',
496
+ { className: 'dws-row' },
497
+ h(
498
+ 'div',
499
+ { className: 'dws-rowHead' },
500
+ h(
501
+ 'div',
502
+ { className: 'dws-rowName' },
503
+ h('span', { className: 'dws-rowTitle' }, workspace.title),
504
+ workspace.unattributed === true
505
+ ? h('span', { className: 'dws-badge' }, t('unattributed'))
506
+ : null,
507
+ workspace.path !== undefined && workspace.path !== workspace.title
508
+ ? h('span', { className: 'dws-rowPath' }, workspace.path)
509
+ : null,
510
+ ),
511
+ h(
512
+ 'div',
513
+ { className: 'dws-rowRight' },
514
+ h(
515
+ 'span',
516
+ { className: 'dws-rowCost' },
517
+ workspace.cost === 0 && workspace.unpricedTokens > 0 ? '—' : formatCost(workspace.cost ?? 0, currencySymbol),
518
+ ),
519
+ h('span', { className: 'dws-rowShare' }, totalCost > 0 ? `${share.toFixed(share >= 10 ? 0 : 1)}%` : ''),
520
+ ),
521
+ ),
522
+ h(
523
+ 'div',
524
+ { className: 'dws-bar', 'aria-hidden': true },
525
+ h('span', { className: 'dws-barFill', style: { width: `${share === 0 ? 0 : Math.max(2, share)}%` } }),
526
+ ),
527
+ h(
528
+ 'p',
529
+ { className: 'dws-rowMeta' },
530
+ [
531
+ fill(t('metaSessions'), { n: workspace.sessions ?? 0 }),
532
+ fill(t('metaMessages'), { n: workspace.messages ?? 0 }),
533
+ fill(t('tokensLine'), {
534
+ input: formatTokens(tokens.input),
535
+ output: formatTokens(tokens.output),
536
+ cache: formatTokens(tokens.cacheRead),
537
+ }),
538
+ ].join(' · '),
539
+ ),
540
+ models.length > 0
541
+ ? h(
542
+ 'div',
543
+ { className: 'dws-models' },
544
+ models.map((model) =>
545
+ h(
546
+ 'span',
547
+ {
548
+ key: model.key,
549
+ className: 'dws-chip',
550
+ 'data-unpriced': model.priced === false ? 'true' : 'false',
551
+ title: model.priced === false ? t('modelUnpriced') : `${model.model} ${formatCost(model.cost, currencySymbol)}`,
552
+ },
553
+ model.priced === false
554
+ ? `${model.model} · ${t('modelUnpriced')}`
555
+ : `${model.model}${model.cost > 0 ? ` ${formatCost(model.cost, currencySymbol)}` : ''}`,
556
+ ),
557
+ ),
558
+ )
559
+ : null,
560
+ )
561
+ }
562
+
563
+ function PriceDetails(props) {
564
+ const { pricing, t, usedModels } = props
565
+ if (pricing === undefined || pricing === null) return null
566
+ const rates = Array.isArray(pricing.rates) ? pricing.rates : []
567
+ const fees = Array.isArray(pricing.perCallFees) ? pricing.perCallFees : []
568
+ const used = usedModels instanceof Set ? usedModels : new Set()
569
+ return h(
570
+ 'details',
571
+ { className: 'dws-details' },
572
+ h('summary', null, t('pricingTitle')),
573
+ h(
574
+ 'div',
575
+ { className: 'dws-detailsBody' },
576
+ h(
577
+ 'p',
578
+ { className: 'dws-muted' },
579
+ fill(t('pricingNote'), {
580
+ currency: pricing.currency,
581
+ discount: pricing.offPeakDiscount,
582
+ holidays: pricing.holidayCount ?? 0,
583
+ }),
584
+ ),
585
+ h(
586
+ 'ul',
587
+ { className: 'dws-rateList' },
588
+ rates.map((rate) =>
589
+ h(
590
+ 'li',
591
+ { key: rate.model, className: 'dws-rateRow' },
592
+ h('span', { className: 'dws-rateModel' }, rate.model),
593
+ h(
594
+ 'span',
595
+ { className: 'dws-rateBadge', 'data-used': used.has(rate.model) ? 'true' : 'false' },
596
+ used.has(rate.model) ? t('usedBadge') : t('unusedBadge'),
597
+ ),
598
+ h('span', null, `${t('pricingInput')} ${formatRate(rate.input)}`),
599
+ h('span', null, `${t('pricingCache')} ${formatRate(rate.cacheRead)}`),
600
+ h('span', null, `${t('pricingOutput')} ${formatRate(rate.output)}`),
601
+ ),
602
+ ),
603
+ fees.map((fee) =>
604
+ h(
605
+ 'li',
606
+ { key: fee.tool, className: 'dws-rateRow' },
607
+ h('span', { className: 'dws-rateModel' }, fee.tool),
608
+ h('span', { className: 'dws-rateBadge', 'data-used': 'false' }, `${formatRate(fee.fee)} ${pricing.currency} ${t('pricingPerCall')}`),
609
+ ),
610
+ ),
611
+ ),
612
+ h('p', { className: 'dws-foot' }, t('refund')),
613
+ ),
614
+ )
615
+ }
616
+
617
+ function apply(ctx) {
618
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'workspace-spend: dictionaries')
619
+ const t = ctx.locale.bind(NS)
620
+ const locale = ctx.locale
621
+ const Section = (props) => h(SpendSection, { ...props, t, locale })
622
+ ctx.slots.inject('settings.section', () =>
623
+ ctx.slots.register(
624
+ {
625
+ name: 'settings.section',
626
+ id: 'workspace-spend',
627
+ order: 30,
628
+ label: () => t('navLabel'),
629
+ },
630
+ Section,
631
+ ),
632
+ )
633
+ }
634
+
635
+ return {
636
+ inject: ['slots', 'locale'],
637
+ apply,
638
+ }
639
+ },
640
+ })
@@ -0,0 +1,14 @@
1
+ # dsh-plugin-workspace-spend bundle patch.
2
+ #
3
+ # One Loader row. The package declares `dsh.client`, so `dsh-client-modules`
4
+ # attaches its `./client` bundle to this row and serves it to the browser,
5
+ # where it registers the 「支出统计」 page into the Settings panel's
6
+ # `settings.section` slot. The Host half folds session logs and serves
7
+ # `GET /api/workspace-spend` for that page.
8
+ #
9
+ # The row is inserted (never overriding a shipped row) so the bundle composes
10
+ # on top of any profile. Override this row's `config` in the profile's
11
+ # cordis.patch.yml to change prices, currency, holidays, or per-call fees.
12
+ - insert:
13
+ - id: workspace-spend
14
+ name: 'dsh-plugin-workspace-spend'
package/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Workspace spend">
2
+ <rect x="1" y="1" width="46" height="46" rx="11" fill="#2f6feb"/>
3
+ <rect x="10" y="27" width="6" height="11" rx="1.5" fill="#ffffff" fill-opacity="0.72"/>
4
+ <rect x="20" y="21" width="6" height="17" rx="1.5" fill="#ffffff" fill-opacity="0.88"/>
5
+ <rect x="30" y="14" width="6" height="24" rx="1.5" fill="#ffffff"/>
6
+ <circle cx="33" cy="12" r="5.5" fill="#ffd166"/>
7
+ <path d="M33 9.4v5.2M31.1 10.6h3.8M31.1 13.4h3.8" stroke="#2b2b2b" stroke-width="1.1" stroke-linecap="round" fill="none"/>
8
+ </svg>