dsh-consumption-badge 1.3.1 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +53 -6
  2. package/lib/client.js +403 -52
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  A **persistent** DSH (DeepSeek Harness) Web UI plugin that puts a live consumption badge
4
4
  next to every assistant message, and opens a detail panel with the session's spend curve.
5
- The UI is **bilingual** (follows the shell language, switches live) and amounts can be shown in
6
- **any of 17 major currencies** — pick one in Settings → Usage.
5
+ The UI is **bilingual** (follows the shell language, switches live), costs are estimated for
6
+ **33 model entries across 10 providers**, amounts can be shown in **any of 17 major
7
+ currencies**, and one dropdown picks the **model** you want to look at. All of it lives in
8
+ Settings → Usage.
7
9
 
8
10
  ```
9
11
  ⛽ 3.2k | ≈¥0.004 | Off-peak | Balance = ¥42.50
@@ -26,9 +28,11 @@ The UI is **bilingual** (follows the shell language, switches live) and amounts
26
28
  whole session, unchanged.
27
29
  - **Detail modal** (centered, blurred backdrop, `Esc` or backdrop-click to close):
28
30
  cumulative tokens with hit/miss/output split, estimated cost, current pricing tier,
29
- DeepSeek account balance, and a **per-turn dual-axis line chart**
30
- (left axis: tokens, right axis: ¥) with a hover tooltip showing the model,
31
- input-hit / input-miss / output tokens, and the turn's peak/off-peak flag.
31
+ DeepSeek account balance, a **model dropdown** in the title bar, and a **per-turn dual-axis line
32
+ chart** (left axis: tokens, right axis: money) where **each model gets its own colored line** and
33
+ the legend names them; the hover tooltip shows the model (with its color dot), input-hit /
34
+ input-miss / output tokens, the turn's cost and its peak/off-peak flag. Picking a model filters the
35
+ cards + chart to that model and turns the balance hint into "about N tokens left at these prices".
32
36
  - **Settings page · Usage** (`settings.section`, id `balance-dsh-consumption`, order 26):
33
37
  a top row of four cards — **Total tokens** (with hit/miss/output split, all-history totals),
34
38
  **Estimated cost**, **Current window** (peak/off-peak) and **Account balance** (reusing the
@@ -84,6 +88,49 @@ The UI is **bilingual** (follows the shell language, switches live) and amounts
84
88
 
85
89
  Everything is an **estimate** for reference only — this plugin is not a billing system of record.
86
90
 
91
+ ## Models and pricing
92
+
93
+ Costs are estimated from a **flat price table of 31 model entries** (current generations first,
94
+ legacy entries last so old logs still resolve). Unknown ids fall back to a family guess
95
+ (`pro`/`opus`/`max` → the stronger tier, everything else → DeepSeek V3) and are marked as estimates.
96
+
97
+ | Provider | Current | Legacy (kept for old logs) |
98
+ | --- | --- | --- |
99
+ | DeepSeek | `deepseek-flash` = **DeepSeek-V4.1-Flash-1017**, `deepseek-v4-pro` = **DeepSeek-V4-Pro-0813** (official ids/versions) | DeepSeek V3.2 |
100
+ | OpenAI | GPT-5.6 Sol, GPT-5.6 Terra, GPT-5.6 Luna, o3 | GPT-4o, GPT-4o mini |
101
+ | Anthropic | Claude Opus 5.5, Sonnet 5.5, Haiku 5.5 | Claude Sonnet 4.6, Opus 4.x |
102
+ | Google | Gemini 3.8 Flash, Gemini 3.1 Pro, Gemini 3 Flash | Gemini 2.5 Flash |
103
+ | Alibaba | Qwen3.8 Max, Qwen3.8 Flash | Qwen3.7 Plus |
104
+ | Zhipu | GLM-5.3, GLM-5.1 | GLM-4.6 |
105
+ | Moonshot | Kimi K3.1 Pro, Kimi K3 | Kimi K2.5 |
106
+ | xAI | Grok 4.7 | Grok 4.3 |
107
+ | Meta | Llama 4 Maverick | — |
108
+ | Mistral | Mistral Large 4 | — |
109
+
110
+ Rules that follow from the table:
111
+
112
+ - **Every turn is priced with the model it actually used** — a session that mixed models is not
113
+ priced with a single rate. Sessions, buckets and charts all keep the per-model split.
114
+ - **Only DeepSeek has peak/off-peak pricing** (×2 in the Beijing-time peak windows). Other vendors
115
+ are flat; their card hint says so.
116
+ - **Prices are CNY per 1M tokens.** Non-DeepSeek entries are the vendor's USD list price × 7.1
117
+ (input-cache-read / input / output), taken from OpenRouter's public model catalogue; the two
118
+ harness-native DeepSeek rows keep DeepSeek's official CNY table. Edit `MODELS` at the top of
119
+ `lib/client.js` — each entry carries `price {hit, miss, out}`, `peakPricing`, `legacy` and a chart
120
+ `color`.
121
+ - **Model ids are resolved transparently.** `deepseek-flash` is the id DeepSeek's API currently
122
+ routes to **DeepSeek-V4.1-Flash-1017** and `deepseek-v4-pro` to **DeepSeek-V4-Pro-0813** (per
123
+ `api-docs.deepseek.com`), and deprecated ids (`deepseek-v4-flash*`, `deepseek-chat`) route to the
124
+ same families. Every model label in the UI can therefore be checked against the log: the settings
125
+ cost card, the modal cost card, the legend tooltip and the per-turn tooltip all print
126
+ `Family name (Official MODEL VERSION · log id <id>)`, so a mismatch is visible instead of hidden.
127
+ - The **Model** dropdown (panel title bar *and* Settings → Usage, sharing one preference stored in
128
+ `localStorage: dsh-consumption:model-filter`) lists models **grouped by provider** in
129
+ `<optgroup>`s; it filters the statistics **and** acts as the pricing fallback for turns whose log
130
+ entry carries no model. `All models` turns both behaviours off.
131
+ - Charts draw **one line per model** in that model's color (33 distinct colors), with a legend of
132
+ model chips; the token axis stays a single dashed neutral line.
133
+
87
134
  ## Currency
88
135
 
89
136
  The price table is always **CNY per 1M tokens** (that is what DeepSeek bills in); the currency
@@ -374,7 +421,7 @@ plugin by "running" it — a session-scoped dynamic plugin always dies with the
374
421
  - Do not hard-code user-visible English or Chinese in new JSX: call `tt('…')` with the Chinese copy
375
422
  as the key and add the English entry to `DICT_EN`. `check.mjs` fails if any `tt()` key has no
376
423
  English translation or if a translation still contains Chinese.
377
- - Self-check without booting DSH: `node check.mjs` (273 assertions over the route contract, the
424
+ - Self-check without booting DSH: `node check.mjs` (320 assertions over the route contract, the
378
425
  client bundle contract, the pricing/aggregation maths and the bilingual copy). It lives outside
379
426
  the published `files` list.
380
427
 
package/lib/client.js CHANGED
@@ -20,9 +20,63 @@ window.__ModuleLoader__.load({
20
20
 
21
21
  const API = '/api/consumption'
22
22
  const CSS_ID = 'dsh-consumption-badge'
23
- const PRICES = {
24
- 'deepseek-flash': { hit: 0.02, miss: 1, out: 4 },
25
- 'deepseek-v4-pro': { hit: 0.15, miss: 4.5, out: 13.5 },
23
+
24
+ /**
25
+ * 主流大模型价目表(**统一折算成人民币 / 1M tokens**,仅供估算)。
26
+ * - `hit` = 输入命中缓存、`miss` = 输入未命中、`out` = 输出;
27
+ * - 生成顺序:每家厂商**当前版本在前、legacy 在后**;`legacy: true` 只用于承接历史日志里的旧 id;
28
+ * - 非人民币原生价一律按官方 **USD 价 × 7.1** 折算(表里已经是折好的人民币,改价直接改这张表);
29
+ * - `peakPricing: true` 的模型才有 DeepSeek 那种「峰时 ×2」(其它厂商不区分峰谷);
30
+ * - `match` 是大小写无关的子串匹配,并且**最长命中优先**,所以 `gpt-5.6-terra-pro`、
31
+ * `claude-sonnet-4-5`、`deepseek-v4.1-flash-1017` 这类带后缀/日期的 id 都能落到正确家族。
32
+ *
33
+ * 当前版本(2026-11 口径):DeepSeek V4.1 Flash / V4 Pro(0813) / V3.2、GPT-5.6 Sol/Terra/Luna、
34
+ * Claude Opus·Sonnet·Haiku 5.5、Gemini 3.8 Flash / 3.1 Pro / 3 Flash、Qwen3.8 Max/Flash、
35
+ * GLM-5.3 / 5.1、Kimi K3.1 Pro / K3、Grok 4.7、Llama 4 Maverick、Mistral Large 4。
36
+ */
37
+ const MODELS = [
38
+ // 官方:MODEL deepseek-flash → MODEL VERSION DeepSeek-V4.1-Flash-1017(旧 id deepseek-v4-flash / -vision-exp 仍可调用并按 Flash 计费)
39
+ { id: 'deepseek-flash', provider: 'DeepSeek', label: 'DeepSeek V4.1 Flash', version: 'DeepSeek-V4.1-Flash-1017', legacy: false, match: ['deepseek-v4.1-flash', 'deepseek-v4-flash', 'deepseek-flash', 'deepseek-chat'], price: { hit: 0.02, miss: 1, out: 4 }, peakPricing: true, color: '#4d6bfe' },
40
+ // 官方:MODEL deepseek-v4-pro → MODEL VERSION DeepSeek-V4-Pro-0813
41
+ { id: 'deepseek-v4-pro', provider: 'DeepSeek', label: 'DeepSeek V4 Pro', version: 'DeepSeek-V4-Pro-0813', legacy: false, match: ['deepseek-v4-pro-0813', 'deepseek-v4-pro-0423', 'deepseek-v4-pro', 'deepseek-reasoner', 'deepseek-r1'], price: { hit: 0.15, miss: 4.5, out: 13.5 }, peakPricing: true, color: '#8b5cf6' },
42
+ // 老一代:只在历史日志里出现,按第三方零售价折算,仅作兜底估算
43
+ { id: 'deepseek-v3.2', provider: 'DeepSeek', label: 'DeepSeek V3.2 (legacy)', version: 'DeepSeek-V3.2', legacy: true, match: ['deepseek-v3.2', 'deepseek-v3'], price: { hit: 0.923, miss: 1.846, out: 2.698 }, peakPricing: true, color: '#818cf8' },
44
+ { id: 'gpt-5.6-sol', provider: 'OpenAI', label: 'GPT-5.6 Sol', legacy: false, match: ['gpt-5.6-sol'], price: { hit: 2.84, miss: 28.4, out: 142 }, peakPricing: false, color: '#0f766e' },
45
+ { id: 'gpt-5.6-terra', provider: 'OpenAI', label: 'GPT-5.6 Terra', legacy: false, match: ['gpt-5.6-terra'], price: { hit: 1.42, miss: 14.2, out: 85.2 }, peakPricing: false, color: '#10a37f' },
46
+ { id: 'gpt-5.6-luna', provider: 'OpenAI', label: 'GPT-5.6 Luna', legacy: false, match: ['gpt-5.6-luna'], price: { hit: 0.142, miss: 1.42, out: 8.52 }, peakPricing: false, color: '#34d399' },
47
+ { id: 'o3', provider: 'OpenAI', label: 'o3 / o3-pro', legacy: false, match: ['o3', 'o1', 'o4-mini'], price: { hit: 3.55, miss: 14.2, out: 56.8 }, peakPricing: false, color: '#059669' },
48
+ { id: 'gpt-4o', provider: 'OpenAI', label: 'GPT-4o (legacy)', legacy: true, match: ['gpt-4o'], price: { hit: 8.88, miss: 17.75, out: 71 }, peakPricing: false, color: '#22c55e' },
49
+ { id: 'gpt-4o-mini', provider: 'OpenAI', label: 'GPT-4o mini (legacy)', legacy: true, match: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4-mini'], price: { hit: 0.53, miss: 1.07, out: 4.26 }, peakPricing: false, color: '#86efac' },
50
+ { id: 'claude-opus-5.5', provider: 'Anthropic', label: 'Claude Opus 5.5', legacy: false, match: ['claude-opus-5', 'claude-opus'], price: { hit: 1.42, miss: 28.4, out: 142 }, peakPricing: false, color: '#c2410c' },
51
+ { id: 'claude-sonnet-5.5', provider: 'Anthropic', label: 'Claude Sonnet 5.5', legacy: false, match: ['claude-sonnet-5', 'claude-sonnet'], price: { hit: 0.71, miss: 14.2, out: 71 }, peakPricing: false, color: '#d97757' },
52
+ { id: 'claude-haiku-5.5', provider: 'Anthropic', label: 'Claude Haiku 5.5', legacy: false, match: ['claude-haiku-5', 'claude-haiku'], price: { hit: 0.071, miss: 0.71, out: 3.55 }, peakPricing: false, color: '#fb923c' },
53
+ { id: 'claude-sonnet-4.6', provider: 'Anthropic', label: 'Claude Sonnet 4.6 (legacy)', legacy: true, match: ['claude-sonnet-4'], price: { hit: 2.13, miss: 21.3, out: 106.5 }, peakPricing: false, color: '#f59e0b' },
54
+ { id: 'claude-opus-4', provider: 'Anthropic', label: 'Claude Opus 4.x (legacy)', legacy: true, match: ['claude-opus-4', 'opus-4', 'opus-3'], price: { hit: 3.55, miss: 35.5, out: 177.5 }, peakPricing: false, color: '#b45309' },
55
+ { id: 'gemini-3.8-flash', provider: 'Google', label: 'Gemini 3.8 Flash', legacy: false, match: ['gemini-3.8-flash', 'gemini-3.7-flash', 'gemini-3.6-flash'], price: { hit: 0.5858, miss: 5.8575, out: 29.2875 }, peakPricing: false, color: '#0ea5e9' },
56
+ { id: 'gemini-3.1-pro', provider: 'Google', label: 'Gemini 3.1 Pro', legacy: false, match: ['gemini-3.1-pro', 'gemini-3-pro', 'gemini-pro'], price: { hit: 1.42, miss: 14.2, out: 85.2 }, peakPricing: false, color: '#4285f4' },
57
+ { id: 'gemini-3-flash', provider: 'Google', label: 'Gemini 3 Flash', legacy: false, match: ['gemini-3-flash', 'gemini-flash'], price: { hit: 0.355, miss: 3.55, out: 21.3 }, peakPricing: false, color: '#38bdf8' },
58
+ { id: 'gemini-2.5-flash', provider: 'Google', label: 'Gemini 2.5 Flash (legacy)', legacy: true, match: ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5'], price: { hit: 0.213, miss: 2.13, out: 17.75 }, peakPricing: false, color: '#7dd3fc' },
59
+ { id: 'qwen3.8-max', provider: 'Alibaba', label: 'Qwen3.8 Max', legacy: false, match: ['qwen3.8-max', 'qwen3-max', 'qwen-max', 'qwen-max-latest'], price: { hit: 1.775, miss: 14.2, out: 42.6 }, peakPricing: false, color: '#615ced' },
60
+ { id: 'qwen3.8-flash', provider: 'Alibaba', label: 'Qwen3.8 Flash', legacy: false, match: ['qwen3.8-flash', 'qwen3.7-flash', 'qwen-flash'], price: { hit: 0.1136, miss: 1.065, out: 3.337 }, peakPricing: false, color: '#a78bfa' },
61
+ { id: 'qwen3.7-plus', provider: 'Alibaba', label: 'Qwen3.7 Plus (legacy)', legacy: true, match: ['qwen3.7-plus', 'qwen-plus', 'qwen-turbo', 'qwen3'], price: { hit: 0.4544, miss: 2.272, out: 9.088 }, peakPricing: false, color: '#c4b5fd' },
62
+ { id: 'glm-5.3', provider: 'Zhipu', label: 'GLM-5.3', legacy: false, match: ['glm-5.3', 'glm-5'], price: { hit: 0.5396, miss: 0.568, out: 25.56 }, peakPricing: false, color: '#0d9488' },
63
+ { id: 'glm-5.1', provider: 'Zhipu', label: 'GLM-5.1', legacy: false, match: ['glm-5.1'], price: { hit: 1.846, miss: 9.94, out: 31.24 }, peakPricing: false, color: '#2dd4bf' },
64
+ { id: 'glm-4.6', provider: 'Zhipu', label: 'GLM-4.6 (legacy)', legacy: true, match: ['glm-4.7', 'glm-4.6', 'glm-4-plus', 'glm-4.5', 'glm-4-air'], price: { hit: 0.71, miss: 3.55, out: 14.2 }, peakPricing: false, color: '#99f6e4' },
65
+ { id: 'kimi-k3.1-pro', provider: 'Moonshot', label: 'Kimi K3.1 Pro', legacy: false, match: ['kimi-k3.1-pro'], price: { hit: 1.42, miss: 14.2, out: 71 }, peakPricing: false, color: '#db2777' },
66
+ { id: 'kimi-k3', provider: 'Moonshot', label: 'Kimi K3', legacy: false, match: ['kimi-k3', 'kimi'], price: { hit: 1.2922, miss: 1.3632, out: 106.5 }, peakPricing: false, color: '#f472b6' },
67
+ { id: 'kimi-k2.5', provider: 'Moonshot', label: 'Kimi K2.5 (legacy)', legacy: true, match: ['kimi-k2', 'moonshot-v1'], price: { hit: 1.065, miss: 3.55, out: 17.75 }, peakPricing: false, color: '#fbcfe8' },
68
+ { id: 'grok-4.7', provider: 'xAI', label: 'Grok 4.7', legacy: false, match: ['grok-4.7', 'grok-4.6', 'grok-4.5', 'grok-4'], price: { hit: 3.55, miss: 14.2, out: 42.6 }, peakPricing: false, color: '#64748b' },
69
+ { id: 'grok-4.3', provider: 'xAI', label: 'Grok 4.3 (legacy)', legacy: true, match: ['grok-4.3', 'grok-4.20', 'grok-3', 'grok-2'], price: { hit: 1.42, miss: 8.875, out: 17.75 }, peakPricing: false, color: '#94a3b8' },
70
+ { id: 'llama-4-maverick', provider: 'Meta', label: 'Llama 4 Maverick', legacy: false, match: ['llama-4', 'llama-3', 'llama3'], price: { hit: 0.355, miss: 1.3312, out: 4.6328 }, peakPricing: false, color: '#06b6d4' },
71
+ { id: 'mistral-large-4', provider: 'Mistral', label: 'Mistral Large 4', legacy: false, match: ['mistral-large-4', 'mistral-large', 'mistral-medium', 'mistral'], price: { hit: 0.497, miss: 4.97, out: 14.91 }, peakPricing: false, color: '#f97316' },
72
+ ]
73
+ /** 兼容旧版本:按 id 建索引;`PRICES[id]` 额外带 `peak` 标记,Worker 里自包含的聚合函数直接读它。 */
74
+ const PRICES = {}
75
+ const MODEL_BY_ID = {}
76
+ for (let mi = 0; mi < MODELS.length; mi += 1) {
77
+ const entry = MODELS[mi]
78
+ PRICES[entry.id] = { hit: entry.price.hit, miss: entry.price.miss, out: entry.price.out, peak: entry.peakPricing === true }
79
+ MODEL_BY_ID[entry.id] = entry
26
80
  }
27
81
  const DEFAULT_MODEL = 'deepseek-flash'
28
82
  const PEAK_MULTIPLIER = 2
@@ -65,18 +119,82 @@ window.__ModuleLoader__.load({
65
119
  }
66
120
  return false
67
121
  }
68
- // Unknown model ids still price as flash (or pro for pro-like ids) and are flagged.
69
- function priceFor(model) {
70
- const id = typeof model === 'string' ? model : ''
71
- if (PRICES[id]) return { id: id, table: PRICES[id], known: true }
72
- if (id.toLowerCase().indexOf('pro') >= 0) return { id: id, table: PRICES['deepseek-v4-pro'], known: false }
73
- return { id: id || DEFAULT_MODEL, table: PRICES[DEFAULT_MODEL], known: false }
122
+ /**
123
+ * 把任意模型 id 归到价目表里的一个家族。
124
+ * 先精确命中 id,再按 `match` 子串大小写无关匹配;都不中就用兜底模型(并标 `known:false`,
125
+ * UI 会把金额标成估算)。`pro`/`large`/`opus` 这类更强的名字在没有精确信息时会落到更贵的一档。
126
+ */
127
+ function resolveModel(model, fallbackId) {
128
+ const raw = typeof model === 'string' ? model.trim() : ''
129
+ const lower = raw.toLowerCase()
130
+ const fallback = typeof fallbackId === 'string' && MODEL_BY_ID[fallbackId] ? MODEL_BY_ID[fallbackId] : MODEL_BY_ID[DEFAULT_MODEL]
131
+ if (raw !== '') {
132
+ if (MODEL_BY_ID[raw]) return { id: raw, entry: MODEL_BY_ID[raw], known: true, raw: raw }
133
+ // 取**最长命中**的家族:`gpt-4o-mini-2026` 必须落到 gpt-4o-mini,而不是被 gpt-4o 抢走。
134
+ let best = null
135
+ let bestLen = 0
136
+ for (let i = 0; i < MODELS.length; i += 1) {
137
+ const entry = MODELS[i]
138
+ for (let k = 0; k < entry.match.length; k += 1) {
139
+ const needle = entry.match[k]
140
+ if (needle.length > bestLen && lower.indexOf(needle) >= 0) {
141
+ best = entry
142
+ bestLen = needle.length
143
+ }
144
+ }
145
+ }
146
+ if (best !== null) return { id: best.id, entry: best, known: true, raw: raw }
147
+ if (lower.indexOf('pro') >= 0 || lower.indexOf('opus') >= 0 || lower.indexOf('max') >= 0) {
148
+ return { id: 'deepseek-v4-pro', entry: MODEL_BY_ID['deepseek-v4-pro'], known: false, raw: raw }
149
+ }
150
+ }
151
+ return { id: fallback.id, entry: fallback, known: raw === '', raw: raw }
74
152
  }
153
+ /**
154
+ * 模型的可解释文案:`DeepSeek V4.1 Flash(DeepSeek-V4.1-Flash-1017 · 日志 id deepseek-flash)`
155
+ * 先显示价目表族名,再显示官方 MODEL VERSION 和日志里真实的 id,避免「明明用 V4.1 却显示 V3」这种误会。
156
+ */
157
+ function modelDetail(model) {
158
+ const info = priceFor(model)
159
+ const bits = []
160
+ if (info.version) bits.push(info.version)
161
+ const raw = String(info.raw || '')
162
+ if (raw && raw !== info.id) bits.push(tt('日志 id {id}', { id: raw }))
163
+ else if (raw) bits.push(tt('日志 id {id}', { id: raw }))
164
+ return bits.length > 0 ? info.label + '(' + bits.join(' · ') + ')' : info.label
165
+ }
166
+ function modelColor(model) {
167
+ return resolveModel(model).entry.color
168
+ }
169
+ function modelLabel(model) {
170
+ const r = resolveModel(model)
171
+ return r.known || r.raw === '' ? r.entry.label : r.raw
172
+ }
173
+ /** 计价入口:返回价目表条目(含 `peak` 标记)+ 该模型的展示信息。 */
174
+ function priceFor(model, fallbackId) {
175
+ const r = resolveModel(model, fallbackId)
176
+ return {
177
+ id: r.id,
178
+ table: PRICES[r.id],
179
+ known: r.known,
180
+ peakPricing: r.entry.peakPricing === true,
181
+ label: r.entry.label,
182
+ version: r.entry.version || '',
183
+ provider: r.entry.provider,
184
+ color: r.entry.color,
185
+ raw: r.raw,
186
+ }
187
+ }
188
+ /** 按模型计价:只有标了 `peak` 的模型(DeepSeek)才乘峰时系数,其它厂商不区分峰谷。 */
75
189
  function costOf(bucket, table, peak) {
76
- const k = peak ? PEAK_MULTIPLIER : 1
190
+ const k = peak && table && table.peak === true ? PEAK_MULTIPLIER : 1
77
191
  const cost = (bucket.hit / 1e6) * table.hit + (bucket.miss / 1e6) * table.miss + (bucket.out / 1e6) * table.out
78
192
  return cost * k
79
193
  }
194
+ /** 按「记录自己的模型」计价(多模型混跑时逐条算,不混价)。 */
195
+ function costOfModel(bucket, model, peak, fallbackId) {
196
+ return costOf(bucket, priceFor(model, fallbackId).table, peak)
197
+ }
80
198
  function fmtTokens(v) {
81
199
  const n = Math.max(0, Math.round(Number(v) || 0))
82
200
  if (n < 1000) return String(n)
@@ -146,6 +264,12 @@ window.__ModuleLoader__.load({
146
264
  const raw = lsGet(LS_CURRENCY)
147
265
  return currencyMeta(raw) !== null ? raw : 'CNY'
148
266
  }
267
+ const LS_MODEL_FILTER = 'dsh-consumption:model-filter'
268
+ /** 当前「模型」下拉的选择('' = 全部模型)。非法值一律当全部。 */
269
+ function readModelFilter() {
270
+ const raw = lsGet(LS_MODEL_FILTER)
271
+ return typeof raw === 'string' && MODEL_BY_ID[raw] ? raw : ''
272
+ }
149
273
  /**
150
274
  * 把人民币金额换算成当前偏好币种。
151
275
  * @returns {{value: number, code: string, rate: number, converted: boolean}}
@@ -241,6 +365,25 @@ window.__ModuleLoader__.load({
241
365
  }
242
366
  return chain
243
367
  }
368
+ /** 把模型列表渲染成按厂商分组的 <optgroup>(设置页与弹层共用)。 */
369
+ function modelOptionNodes(list) {
370
+ const order = []
371
+ const byProvider = {}
372
+ for (let i = 0; i < list.length; i += 1) {
373
+ const m = list[i]
374
+ if (!byProvider[m.provider]) {
375
+ byProvider[m.provider] = []
376
+ order.push(m.provider)
377
+ }
378
+ byProvider[m.provider].push(m)
379
+ }
380
+ return order.map(function (provider) {
381
+ return h('optgroup', { key: provider, label: provider },
382
+ byProvider[provider].map(function (m) {
383
+ return h('option', { key: m.id, value: m.id }, m.label)
384
+ }))
385
+ })
386
+ }
244
387
  function bucketOfTurns(turns) {
245
388
  const out = { hit: 0, miss: 0, out: 0 }
246
389
  for (let i = 0; i < turns.length; i += 1) {
@@ -286,6 +429,14 @@ window.__ModuleLoader__.load({
286
429
  '命中 {hit} · 未命中 {miss} · 输出 {out}': 'hit {hit} · miss {miss} · output {out}',
287
430
  '本轮消耗(点击查看本会话明细)': 'This turn’s usage (click for session details)',
288
431
  '货币': 'Currency',
432
+ '模型': 'Model',
433
+ '全部模型': 'All models',
434
+ '按模型筛选;同时作为计价兜底': 'Filter by model; also the pricing fallback',
435
+ '仅 {model}': 'only {model}',
436
+ '日志 id {id}': 'log id {id}',
437
+ '{count} 个模型各自计价': '{count} models priced separately',
438
+ '按 {model} 约还可跑 {tokens} tokens': 'about {tokens} tokens left at {model} prices',
439
+ '金额按各模型官方价估算(DeepSeek 区分峰谷 ×1/×2,其它厂商按 USD 价 ×7.1 折人民币),仅供参考;数据源为本会话日志,刷新后自动恢复。': 'Costs are estimated from each model’s list price (DeepSeek applies peak/off-peak ×1/×2; other vendors are converted from USD at 7.1), for reference only; the source is this session log, restored automatically after a refresh.',
289
440
  '金额按人民币报价折算显示': 'Amounts are priced in CNY and converted for display',
290
441
  '当前以人民币显示(可切换成其它币种)': 'Showing CNY — pick another currency to convert',
291
442
  '1 CNY = {rate} {code}': '1 CNY = {rate} {code}',
@@ -648,10 +799,10 @@ window.__ModuleLoader__.load({
648
799
 
649
800
  /* ================= 消耗弹窗背景样式(与个性化插件联动) ================= */
650
801
  const MODAL_STYLE_KEY = 'dsh-consumption:modal-style'
651
- const BUILD_TAG = 'dcb-1.3.1'
802
+ const BUILD_TAG = 'dcb-1.3.2'
652
803
  // 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
653
804
  // 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
654
- const PLUGIN_VERSION = '1.3.1'
805
+ const PLUGIN_VERSION = '1.3.2'
655
806
  const PKG_NAME = 'dsh-consumption-badge'
656
807
  const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/' + PKG_NAME + '/latest'
657
808
  const UPDATE_COMMAND = 'dsh plugin --profile web update ' + PKG_NAME
@@ -1042,6 +1193,8 @@ window.__ModuleLoader__.load({
1042
1193
  modalStyle: readModalStyle(),
1043
1194
  // 显示币种(本地偏好)+ 汇率(Host 路由,6h 缓存):金额展示都走 money()。
1044
1195
  currency: readCurrency(),
1196
+ // 模型筛选 / 计价兜底('' = 全部模型,不筛选;同时作为查不到模型时的计价基准)。
1197
+ modelFilter: readModelFilter(),
1045
1198
  rates: null,
1046
1199
  ratesState: 'idle',
1047
1200
  ratesError: '',
@@ -1300,6 +1453,17 @@ window.__ModuleLoader__.load({
1300
1453
  state.rates = null
1301
1454
  return ensureRates(true)
1302
1455
  }
1456
+ /**
1457
+ * 切换「模型」下拉:'' = 全部模型(不筛选);
1458
+ * 选了具体模型时,它同时是**统计筛选条件**和**计价兜底模型**。
1459
+ */
1460
+ function setModelFilter(id) {
1461
+ const next = typeof id === 'string' && MODEL_BY_ID[id] ? id : ''
1462
+ if (state.modelFilter === next) return
1463
+ state.modelFilter = next
1464
+ lsSet(LS_MODEL_FILTER, next)
1465
+ emit()
1466
+ }
1303
1467
  /** 更新弹窗背景配置:合并 → 落本地偏好 → 通知所有订阅者(弹层即时重绘)。 */
1304
1468
  function setModalStyle(patch) {
1305
1469
  const prev = state.modalStyle
@@ -1462,6 +1626,7 @@ window.__ModuleLoader__.load({
1462
1626
  ensureRates: ensureRates,
1463
1627
  setCurrency: setCurrency,
1464
1628
  refreshRates: refreshRates,
1629
+ setModelFilter: setModelFilter,
1465
1630
  openPanel: openPanel,
1466
1631
  closePanel: closePanel,
1467
1632
  setModalStyle: setModalStyle,
@@ -1494,7 +1659,8 @@ window.__ModuleLoader__.load({
1494
1659
  const bucket = { hit: num(t.hit), miss: num(t.miss), out: num(t.out) }
1495
1660
  const at = t.time > 0 ? t.time : nowMs
1496
1661
  const peak = isPeakAt(at)
1497
- const price = priceFor(t.model || modelFallback)
1662
+ // 每轮按**它自己用的模型**计价(多模型混跑时不会串价)。
1663
+ const price = priceFor(t.model, modelFallback)
1498
1664
  series.push({
1499
1665
  turn: t.turn,
1500
1666
  hit: bucket.hit,
@@ -1503,7 +1669,12 @@ window.__ModuleLoader__.load({
1503
1669
  tokens: bucket.hit + bucket.miss + bucket.out,
1504
1670
  cost: costOf(bucket, price.table, peak),
1505
1671
  peak: peak,
1672
+ peakPricing: price.peakPricing,
1506
1673
  model: price.id,
1674
+ rawModel: price.raw,
1675
+ version: price.version,
1676
+ label: price.label,
1677
+ color: price.color,
1507
1678
  known: price.known,
1508
1679
  cached: !!t.cached,
1509
1680
  time: at,
@@ -1536,10 +1707,8 @@ window.__ModuleLoader__.load({
1536
1707
  function yCost(v) { return CHART_PAD.t + innerH - (v / topCost) * innerH }
1537
1708
 
1538
1709
  const tokenPts = []
1539
- const costPts = []
1540
1710
  for (let i = 0; i < n; i += 1) {
1541
1711
  tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(series[i].tokens).toFixed(2))
1542
- costPts.push(xAt(i).toFixed(2) + ',' + yCost(series[i].cost).toFixed(2))
1543
1712
  }
1544
1713
  const grid = []
1545
1714
  const leftLabels = []
@@ -1557,11 +1726,49 @@ window.__ModuleLoader__.load({
1557
1726
  if (i % step !== 0 && i !== n - 1) continue
1558
1727
  xLabels.push(h('text', { key: 'x' + i, x: xAt(i), y: CHART_H - 6, textAnchor: 'middle', className: 'dcb-axis-label' }, '#' + String(series[i].turn)))
1559
1728
  }
1560
- const dots = []
1729
+ // 每个模型一条金额折线(各自颜色);该模型没有数据的轮次断开,不做插值连线。
1730
+ const byModel = {}
1731
+ const modelOrder = []
1561
1732
  for (let i = 0; i < n; i += 1) {
1562
- const active = hover === i
1563
- dots.push(h('circle', { key: 'dt' + i, cx: xAt(i), cy: yToken(series[i].tokens), r: active ? 4 : 2.2, className: 'dcb-dot' }))
1564
- dots.push(h('circle', { key: 'dc' + i, cx: xAt(i), cy: yCost(series[i].cost), r: active ? 4 : 2.2, className: 'dcb-dot dcb-dot-cost' }))
1733
+ const key = series[i].model || DEFAULT_MODEL
1734
+ if (byModel[key] === undefined) {
1735
+ byModel[key] = []
1736
+ modelOrder.push(key)
1737
+ }
1738
+ byModel[key].push(i)
1739
+ }
1740
+ const modelLines = []
1741
+ const dots = []
1742
+ for (let m = 0; m < modelOrder.length; m += 1) {
1743
+ const key = modelOrder[m]
1744
+ const idxs = byModel[key]
1745
+ const color = series[idxs[0]].color || '#4d6bfe'
1746
+ let d = ''
1747
+ let run = false
1748
+ for (let k = 0; k < n; k += 1) {
1749
+ const idx = byModel[key].indexOf(k)
1750
+ if (idx < 0) {
1751
+ run = false
1752
+ continue
1753
+ }
1754
+ const x = xAt(k)
1755
+ const y = yCost(series[k].cost)
1756
+ d += (run ? 'L' : 'M') + x.toFixed(2) + ' ' + y.toFixed(2) + ' '
1757
+ run = true
1758
+ }
1759
+ modelLines.push(h('path', {
1760
+ key: 'ml' + key,
1761
+ d: d.trim(),
1762
+ className: 'dcb-line dcb-line-cost',
1763
+ stroke: color,
1764
+ fill: 'none',
1765
+ }))
1766
+ for (let k = 0; k < idxs.length; k += 1) {
1767
+ const i2 = idxs[k]
1768
+ const active = hover === i2
1769
+ dots.push(h('circle', { key: 'dt' + i2, cx: xAt(i2), cy: yToken(series[i2].tokens), r: active ? 4 : 2.2, className: 'dcb-dot' }))
1770
+ dots.push(h('circle', { key: 'dc' + i2, cx: xAt(i2), cy: yCost(series[i2].cost), r: active ? 4 : 2.2, className: 'dcb-dot dcb-dot-cost', fill: color }))
1771
+ }
1565
1772
  }
1566
1773
  const hovered = hover >= 0 && hover < n ? series[hover] : null
1567
1774
  const crosshair = hovered
@@ -1583,8 +1790,12 @@ window.__ModuleLoader__.load({
1583
1790
  ? h('div', { className: 'dcb-tooltip', style: { left: String((xAt(hover) / CHART_W) * 100) + '%' } },
1584
1791
  h('div', { className: 'dcb-tooltip-title' },
1585
1792
  '#' + String(hovered.turn),
1586
- h('span', { className: 'dcb-tooltip-model' }, hovered.model || DEFAULT_MODEL),
1587
- h('span', { className: hovered.peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(hovered.peak ? '峰时' : '谷时'))),
1793
+ h('span', { className: 'dcb-tooltip-model', title: modelDetail(hovered.rawModel || hovered.model) },
1794
+ h('i', { className: 'dcb-model-dot', style: { background: hovered.color } }),
1795
+ (hovered.label || hovered.model || DEFAULT_MODEL)
1796
+ + (hovered.version ? ' · ' + hovered.version : '')
1797
+ + (hovered.rawModel && hovered.rawModel !== hovered.model ? ' · ' + hovered.rawModel : '')),
1798
+ h('span', { className: hovered.peak && hovered.peakPricing !== false ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(hovered.peak ? '峰时' : '谷时'))),
1588
1799
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入命中')), h('b', null, fmtTokens(hovered.hit))),
1589
1800
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入未命中')), h('b', null, fmtTokens(hovered.miss))),
1590
1801
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输出')), h('b', null, fmtTokens(hovered.out))),
@@ -1594,8 +1805,15 @@ window.__ModuleLoader__.load({
1594
1805
 
1595
1806
  return h('div', { className: 'dcb-chart-wrap' },
1596
1807
  h('div', { className: 'dcb-chart-legend' },
1597
- h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot' }), tt('每轮 Token')),
1598
- h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-cost' }), tt('每轮估算金额'))),
1808
+ h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-token' }), tt('每轮 Token')),
1809
+ modelOrder.length > 1
1810
+ ? modelOrder.map(function (key) {
1811
+ const first = series[byModel[key][0]]
1812
+ return h('span', { key: 'lg' + key, className: 'dcb-legend-item' },
1813
+ h('i', { className: 'dcb-legend-dot', style: { background: first.color } }),
1814
+ first.label || key)
1815
+ })
1816
+ : h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-cost' }), tt('每轮估算金额'))),
1599
1817
  h('div', { className: 'dcb-chart-body' },
1600
1818
  h('svg', {
1601
1819
  className: 'dcb-chart-svg',
@@ -1606,8 +1824,8 @@ window.__ModuleLoader__.load({
1606
1824
  },
1607
1825
  h('g', null, grid),
1608
1826
  crosshair,
1609
- h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }),
1610
- h('polyline', { className: 'dcb-line dcb-line-cost', points: costPts.join(' ') }),
1827
+ h('polyline', { className: 'dcb-line dcb-line-token', points: tokenPts.join(' ') }),
1828
+ h('g', null, modelLines),
1611
1829
  h('g', null, dots),
1612
1830
  h('g', null, leftLabels),
1613
1831
  h('g', null, rightLabels),
@@ -1737,12 +1955,14 @@ window.__ModuleLoader__.load({
1737
1955
  }
1738
1956
  // 还没有任何数字时显示占位符,而不是误导性的 0。
1739
1957
  const pending = bucket === null && state.turnsState !== 'ready'
1958
+ // 计价兜底模型:会话日志里查不到模型时用它(设置页下拉选的那个 → 账号当前模型 → 默认)。
1740
1959
  const model = bucketModel
1960
+ || state.modelFilter
1741
1961
  || (state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL)
1742
1962
  const at = bucketTime > 0 ? bucketTime : Date.now()
1743
1963
  const peak = isPeakAt(at)
1744
1964
  const tokens = bucket === null ? 0 : bucket.hit + bucket.miss + bucket.out
1745
- const cost = bucket === null ? 0 : costOf(bucket, priceFor(model).table, peak)
1965
+ const cost = bucket === null ? 0 : costOf(bucket, priceFor(bucketModel, state.modelFilter).table, peak)
1746
1966
 
1747
1967
  // 账户余额:成功 → ¥金额;读取中 → …;失败/未配置 → —(原因放 title,不挤占胶囊宽度)。
1748
1968
  const balance = state.balance
@@ -1807,13 +2027,26 @@ window.__ModuleLoader__.load({
1807
2027
 
1808
2028
  const now = Date.now()
1809
2029
  const surface = modalSurface(state.modalStyle, state.modalImageUrl)
1810
- const model = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
2030
+ const accountModel = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
2031
+ // 面板里的「模型」下拉 = 计价兜底模型(日志查不到模型时用它)+ 图例口径。
2032
+ const model = state.modelFilter || accountModel
1811
2033
  const price = priceFor(model)
1812
2034
  const peak = isPeakAt(now)
1813
- const series = buildSeries(state.turns, model, now)
1814
- const aggregated = bucketOfTurns(state.turns)
2035
+ const allSeries = buildSeries(state.turns, model, now)
2036
+ // 只看选中模型时,卡片/图表都按该模型过滤(选「全部模型」时不过滤)。
2037
+ const series = state.modelFilter ? allSeries.filter(function (s) { return s.model === state.modelFilter }) : allSeries
2038
+ const aggregated = { hit: 0, miss: 0, out: 0 }
2039
+ let totalCost = 0
2040
+ for (let i = 0; i < series.length; i += 1) {
2041
+ aggregated.hit += series[i].hit
2042
+ aggregated.miss += series[i].miss
2043
+ aggregated.out += series[i].out
2044
+ totalCost += series[i].cost
2045
+ }
1815
2046
  const tokens = aggregated.hit + aggregated.miss + aggregated.out
1816
- const totalCost = costOf(aggregated, price.table, peak)
2047
+ const modelSet = {}
2048
+ for (let i = 0; i < allSeries.length; i += 1) modelSet[allSeries[i].model] = allSeries[i]
2049
+ const modelsUsed = Object.keys(modelSet).map(function (k) { return modelSet[k] })
1817
2050
  const balance = state.balance
1818
2051
  const loading = state.balanceState === 'loading'
1819
2052
  const balanceErr = balanceErrorText()
@@ -1837,6 +2070,13 @@ window.__ModuleLoader__.load({
1837
2070
  if (typeof balance.granted === 'number') parts.push(tt('赠送 {amount}', { amount: moneyBalance(balance.granted, balance.currency) }))
1838
2071
  if (typeof balance.toppedUp === 'number') parts.push(tt('充值 {amount}', { amount: moneyBalance(balance.toppedUp, balance.currency) }))
1839
2072
  balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
2073
+ // 选了模型时,把余额换算成「按该模型还能跑多少 token」——这就是余额显示与模型的联动。
2074
+ const perMillion = price.table.hit * 0.5 + price.table.miss * 0.3 + price.table.out * 0.2
2075
+ if (typeof balance.total === 'number' && perMillion > 0 && balance.currency === 'CNY') {
2076
+ const affordable = (balance.total / perMillion) * 1e6
2077
+ balanceHint = (parts.length ? parts.join(' · ') + ' · ' : '')
2078
+ + tt('按 {model} 约还可跑 {tokens} tokens', { model: price.label, tokens: fmtTokens(affordable) })
2079
+ }
1840
2080
  }
1841
2081
 
1842
2082
  function close() {
@@ -1870,11 +2110,31 @@ window.__ModuleLoader__.load({
1870
2110
  h('div', { className: 'dcb-head' },
1871
2111
  h('div', { className: 'dcb-head-title' }, tt('⛽ 本会话消耗')),
1872
2112
  h('div', { className: 'dcb-head-actions' },
2113
+ // 模型下拉:只看某个模型,同时作为「查不到模型时」的计价基准。
2114
+ h('select', {
2115
+ className: 'dcb-select dcb-select-inline',
2116
+ 'aria-label': tt('模型'),
2117
+ title: tt('按模型筛选;同时作为计价兜底'),
2118
+ value: state.modelFilter,
2119
+ onChange: function (event) { store.setModelFilter(event.target.value) },
2120
+ },
2121
+ h('option', { key: 'all', value: '' }, tt('全部模型')),
2122
+ modelOptionNodes(modelsUsed.length > 0
2123
+ ? modelsUsed.map(function (m) { return MODEL_BY_ID[m.model] || { id: m.model, label: m.label, provider: '—' } })
2124
+ : MODELS)),
1873
2125
  h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, tt('刷新余额')),
1874
2126
  h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: tt('Esc 关闭') }, '✕'))),
1875
2127
  h('div', { className: 'dcb-cards' },
1876
2128
  h(Card, { label: tt('累计 Token'), value: fmtTokens(tokens), hint: tt('命中 {hit} · 未命中 {miss} · 输出 {out}', { hit: fmtTokens(aggregated.hit), miss: fmtTokens(aggregated.miss), out: fmtTokens(aggregated.out) }) }),
1877
- h(Card, { label: tt('估算金额'), value: moneyApprox(totalCost), hint: (price.known ? price.id : tt('{id}(按 flash 估算)', { id: price.id })) + tt(peak ? ' · 峰时 ×2' : ' · 谷时') }),
2129
+ h(Card, {
2130
+ label: tt('估算金额'),
2131
+ value: moneyApprox(totalCost),
2132
+ hint: (state.modelFilter
2133
+ ? tt('仅 {model}', { model: modelDetail(state.modelFilter) })
2134
+ : tt('{count} 个模型各自计价', { count: String(modelsUsed.length || 1) })
2135
+ + (modelsUsed.length === 1 ? ' · ' + modelDetail(modelsUsed[0].model) : ''))
2136
+ + (price.peakPricing ? tt(peak ? ' · 峰时 ×2' : ' · 谷时') : ''),
2137
+ }),
1878
2138
  h(Card, { label: tt('当前时段'), value: tt(peak ? '高峰' : '空闲'), hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00') }),
1879
2139
  h('div', { className: 'dcb-card' },
1880
2140
  h('div', { className: 'dcb-card-label' }, tt('账户余额')),
@@ -1884,7 +2144,7 @@ window.__ModuleLoader__.load({
1884
2144
  h(UsageChart, { series: series }),
1885
2145
  h('div', { className: 'dcb-foot' },
1886
2146
  state.turnsError ? h('span', { className: 'dcb-foot-warn' }, turnsErrorText() + ' ') : null,
1887
- tt('金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
2147
+ tt('金额按各模型官方价估算(DeepSeek 区分峰谷 ×1/×2,其它厂商按 USD 价 ×7.1 折人民币),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
1888
2148
  }
1889
2149
 
1890
2150
  /* ===================== settings section: history page ===================== */
@@ -1999,9 +2259,11 @@ window.__ModuleLoader__.load({
1999
2259
  }
2000
2260
 
2001
2261
  /**
2002
- * 纯聚合:时间过滤 → 工作区过滤 → 按小时/日/月分桶(空桶补 0)→ 峰谷与金额拆分。
2262
+ * 纯聚合:时间过滤 → 工作区/模型过滤 → 按小时/日/月分桶(空桶补 0)→ 峰谷与金额拆分 →
2263
+ * 每个桶再按模型拆一份 `byModel`(折线图一个模型一条线就靠它)。
2003
2264
  * 刻意自包含(只读 input,不引用闭包变量),所以能直接 String() 进 Web Worker。
2004
- * @param input {records, from, to, wid, granularity: 'hour'|'day'|'month', prices, holidays, windows}
2265
+ * @param input {records, from, to, wid, model, granularity: 'hour'|'day'|'month', prices, holidays, windows}
2266
+ * `model` 非空时只统计该模型;`prices[id]` 里的 `peak: true` 表示该模型参与峰谷计时。
2005
2267
  */
2006
2268
  function computeConsumptionStats(input) {
2007
2269
  const records = input.records || []
@@ -2011,6 +2273,7 @@ window.__ModuleLoader__.load({
2011
2273
  const from = typeof input.from === 'number' ? input.from : 0
2012
2274
  const to = typeof input.to === 'number' ? input.to : 0
2013
2275
  const wid = typeof input.wid === 'string' ? input.wid : ''
2276
+ const modelFilter = typeof input.model === 'string' ? input.model : ''
2014
2277
  const granularity = input.granularity === 'month' ? 'month' : (input.granularity === 'hour' ? 'hour' : 'day')
2015
2278
  // noFill:只要合计不要时间轴(设置页顶部的“累计”卡片用),跳过分桶补齐。
2016
2279
  const noFill = input.noFill === true
@@ -2042,7 +2305,10 @@ window.__ModuleLoader__.load({
2042
2305
  function priceOf(model) {
2043
2306
  const id = typeof model === 'string' ? model : ''
2044
2307
  if (prices[id]) return prices[id]
2045
- if (id.toLowerCase().indexOf('pro') >= 0) return prices['deepseek-v4-pro'] || prices['deepseek-flash']
2308
+ // 兼容历史 id:pro/opus/max 归到更贵的一档,其余按 flash。
2309
+ if (id.toLowerCase().indexOf('pro') >= 0 || id.toLowerCase().indexOf('opus') >= 0 || id.toLowerCase().indexOf('max') >= 0) {
2310
+ return prices['deepseek-v4-pro'] || prices['deepseek-flash']
2311
+ }
2046
2312
  return prices['deepseek-flash']
2047
2313
  }
2048
2314
  function keyOf(ms) {
@@ -2070,14 +2336,17 @@ window.__ModuleLoader__.load({
2070
2336
  const produced = n(r.out)
2071
2337
  const tokens = hit + miss + produced
2072
2338
  if (tokens === 0) continue
2339
+ const rowModel = typeof r.model === 'string' && r.model ? r.model : ''
2340
+ if (modelFilter && rowModel !== modelFilter) continue
2073
2341
  const peak = isPeak(t)
2074
- const table = priceOf(r.model)
2075
- const k = peak ? 2 : 1
2342
+ const table = priceOf(rowModel)
2343
+ // 只有 `peak: true` 的模型(DeepSeek)才乘峰时系数。
2344
+ const k = peak && table.peak === true ? 2 : 1
2076
2345
  const cost = ((hit / 1e6) * table.hit + (miss / 1e6) * table.miss + (produced / 1e6) * table.out) * k
2077
2346
  const key = keyOf(t)
2078
2347
  let b = buckets[key]
2079
2348
  if (!b) {
2080
- b = { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0 }
2349
+ b = { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0, byModel: {} }
2081
2350
  buckets[key] = b
2082
2351
  }
2083
2352
  b.tokens += tokens
@@ -2101,13 +2370,22 @@ window.__ModuleLoader__.load({
2101
2370
  }
2102
2371
  const model = typeof r.model === 'string' && r.model ? r.model : 'unknown'
2103
2372
  if (b.models.indexOf(model) < 0) b.models.push(model)
2373
+ // 桶内按模型拆分:cost/tokens 给折线图每个模型一条线用。
2374
+ let slot = b.byModel[model]
2375
+ if (!slot) {
2376
+ slot = { cost: 0, tokens: 0, records: 0 }
2377
+ b.byModel[model] = slot
2378
+ }
2379
+ slot.cost += cost
2380
+ slot.tokens += tokens
2381
+ slot.records += 1
2104
2382
  count += 1
2105
2383
  }
2106
2384
 
2107
2385
  const points = []
2108
2386
  function push(key, ms) {
2109
2387
  const b = buckets[key]
2110
- const point = b || { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0 }
2388
+ const point = b || { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0, byModel: {} }
2111
2389
  if (granularity === 'month') point.label = key
2112
2390
  else if (granularity === 'hour') point.label = key.slice(11) + ':00'
2113
2391
  else point.label = key.slice(5)
@@ -2244,10 +2522,8 @@ window.__ModuleLoader__.load({
2244
2522
  function yToken(v) { return HIST_PAD.t + innerH - (v / topToken) * innerH }
2245
2523
  function yCost(v) { return HIST_PAD.t + innerH - (v / topCost) * innerH }
2246
2524
 
2247
- const costPts = []
2248
2525
  const tokenPts = []
2249
2526
  for (let i = 0; i < n; i += 1) {
2250
- costPts.push(xAt(i).toFixed(2) + ',' + yCost(points[i].cost).toFixed(2))
2251
2527
  tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(points[i].tokens).toFixed(2))
2252
2528
  }
2253
2529
 
@@ -2272,13 +2548,47 @@ window.__ModuleLoader__.load({
2272
2548
  }
2273
2549
 
2274
2550
  const dots = []
2551
+ // 每个模型一条金额折线(各自颜色):该桶里没有这个模型就断线。
2552
+ const modelUsage = {}
2553
+ const modelOrder = []
2275
2554
  for (let i = 0; i < n; i += 1) {
2276
- const active = hover === i
2277
- if (points[i].records > 0) {
2278
- dots.push(h('circle', { key: 'hc' + i, cx: xAt(i), cy: yCost(points[i].cost), r: active ? 4 : 2.2, className: 'dcb-dot dcb-dot-cost' }))
2555
+ const byModel = points[i].byModel || {}
2556
+ for (const key in byModel) {
2557
+ if (!Object.prototype.hasOwnProperty.call(byModel, key)) continue
2558
+ if (modelUsage[key] === undefined) {
2559
+ modelUsage[key] = []
2560
+ modelOrder.push(key)
2561
+ }
2562
+ modelUsage[key].push(i)
2563
+ }
2564
+ }
2565
+ const modelLines = []
2566
+ for (let m = 0; m < modelOrder.length; m += 1) {
2567
+ const key = modelOrder[m]
2568
+ const rows = modelUsage[key]
2569
+ const color = modelColor(key)
2570
+ let d = ''
2571
+ for (let k = 0; k < rows.length; k += 1) {
2572
+ const idx = rows[k]
2573
+ const seg = points[idx].byModel[key]
2574
+ d += (k === 0 || rows[k - 1] !== idx - 1 ? 'M' : 'L') + xAt(idx).toFixed(2) + ' ' + yCost(seg.cost).toFixed(2) + ' '
2575
+ }
2576
+ modelLines.push(h('path', { key: 'hml' + key, d: d.trim(), className: 'dcb-line dcb-line-cost', stroke: color, fill: 'none' }))
2577
+ for (let k = 0; k < rows.length; k += 1) {
2578
+ const idx = rows[k]
2579
+ dots.push(h('circle', {
2580
+ key: 'hc' + key + idx,
2581
+ cx: xAt(idx),
2582
+ cy: yCost(points[idx].byModel[key].cost),
2583
+ r: hover === idx ? 4 : 2.2,
2584
+ className: 'dcb-dot dcb-dot-cost',
2585
+ fill: color,
2586
+ }))
2279
2587
  }
2588
+ }
2589
+ for (let i = 0; i < n; i += 1) {
2280
2590
  if (showTokens && points[i].records > 0) {
2281
- dots.push(h('circle', { key: 'ht' + i, cx: xAt(i), cy: yToken(points[i].tokens), r: active ? 4 : 2.2, className: 'dcb-dot' }))
2591
+ dots.push(h('circle', { key: 'ht' + i, cx: xAt(i), cy: yToken(points[i].tokens), r: hover === i ? 4 : 2.2, className: 'dcb-dot' }))
2282
2592
  }
2283
2593
  }
2284
2594
 
@@ -2307,7 +2617,9 @@ window.__ModuleLoader__.load({
2307
2617
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, 'Token'), h('b', null, fmtTokens(hovered.tokens))),
2308
2618
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null, moneyApprox(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
2309
2619
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null, moneyApprox(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
2310
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('模型')), h('b', null, hovered.models.length ? hovered.models.join(', ') : '—')))
2620
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('模型')), h('b', null, hovered.models.length
2621
+ ? hovered.models.map(function (m) { return modelLabel(m) }).join(', ')
2622
+ : '—')))
2311
2623
  : null
2312
2624
 
2313
2625
  return h('div', { className: 'dcb-chart-body' },
@@ -2320,8 +2632,8 @@ window.__ModuleLoader__.load({
2320
2632
  },
2321
2633
  h('g', null, grid),
2322
2634
  crosshair,
2323
- showTokens ? h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }) : null,
2324
- h('polyline', { className: 'dcb-line dcb-line-cost', points: costPts.join(' ') }),
2635
+ showTokens ? h('polyline', { className: 'dcb-line dcb-line-token', points: tokenPts.join(' ') }) : null,
2636
+ h('g', null, modelLines),
2325
2637
  h('g', null, dots),
2326
2638
  h('g', null, leftLabels),
2327
2639
  h('g', null, rightLabels),
@@ -2358,6 +2670,8 @@ window.__ModuleLoader__.load({
2358
2670
  const s8 = React.useState(null)
2359
2671
  const allStats = s8[0]
2360
2672
  const setAllStats = s8[1]
2673
+ // 「模型」下拉的状态放在 store 里(弹层与设置页共享同一个选择)。
2674
+ const modelFilter = store.state.modelFilter
2361
2675
  const s9 = React.useState(0)
2362
2676
  const setStoreTick = s9[1]
2363
2677
 
@@ -2408,6 +2722,7 @@ window.__ModuleLoader__.load({
2408
2722
  from: range.from,
2409
2723
  to: range.to,
2410
2724
  wid: wid,
2725
+ model: modelFilter,
2411
2726
  granularity: range.granularity,
2412
2727
  prices: PRICES,
2413
2728
  holidays: CN_FIXED_HOLIDAYS,
@@ -2423,7 +2738,7 @@ window.__ModuleLoader__.load({
2423
2738
  setStats(fallback)
2424
2739
  })
2425
2740
  return undefined
2426
- }, [records, rangeKey, wid, calFrom, calTo, wsSig])
2741
+ }, [records, rangeKey, wid, calFrom, calTo, wsSig, modelFilter])
2427
2742
 
2428
2743
  // 顶部“累计”卡片:全部历史合计(不受筛选影响),只算合计不铺时间轴。
2429
2744
  React.useEffect(function () {
@@ -2480,7 +2795,8 @@ window.__ModuleLoader__.load({
2480
2795
  rateNote = tt('金额按人民币报价折算显示')
2481
2796
  }
2482
2797
  const accountModel = st.account && typeof st.account.model === 'string' && st.account.model ? st.account.model : DEFAULT_MODEL
2483
- const accountPrice = priceFor(accountModel)
2798
+ // 「模型」下拉选了具体模型时,它同时是计价兜底(日志里查不到模型时用它)。
2799
+ const accountPrice = priceFor(modelFilter || accountModel)
2484
2800
  const nowPeak = isPeakAt(Date.now())
2485
2801
  const balance = st.balance
2486
2802
  const balanceLoading = st.balanceState === 'loading'
@@ -2510,6 +2826,15 @@ window.__ModuleLoader__.load({
2510
2826
  const index = buildWorkspaceIndex(wsSig)
2511
2827
  const points = stats ? stats.points : []
2512
2828
  const hasData = !!stats && stats.count > 0
2829
+ // 图例:这一区间里出现过的模型(按聚合结果的 byModel 收集,颜色与折线一致)。
2830
+ const legendModels = []
2831
+ for (let pi = 0; pi < points.length; pi += 1) {
2832
+ const byModel = points[pi].byModel || {}
2833
+ for (const key in byModel) {
2834
+ if (!Object.prototype.hasOwnProperty.call(byModel, key)) continue
2835
+ if (legendModels.indexOf(key) < 0) legendModels.push(key)
2836
+ }
2837
+ }
2513
2838
  const rangeOptions = [
2514
2839
  { id: 'today', label: tt('今天') },
2515
2840
  { id: 'week', label: tt('近一周') },
@@ -2533,7 +2858,7 @@ window.__ModuleLoader__.load({
2533
2858
  h(Card, {
2534
2859
  label: tt('估算金额'),
2535
2860
  value: allStats ? moneyApprox(allStats.totalCost) : '—',
2536
- hint: (accountPrice.known ? accountPrice.id : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
2861
+ hint: (accountPrice.known ? modelDetail(modelFilter || accountModel) : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
2537
2862
  }),
2538
2863
  h(Card, {
2539
2864
  label: tt('当前时段'),
@@ -2592,6 +2917,17 @@ window.__ModuleLoader__.load({
2592
2917
  h('div', { className: 'dcb-field' },
2593
2918
  h('span', { className: 'dcb-field-label' }, tt('数据')),
2594
2919
  h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新'))),
2920
+ // 模型筛选:只看某个模型的消耗;同时作为「日志查不到模型」时的计价基准。
2921
+ h('div', { className: 'dcb-field' },
2922
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-model-select' }, tt('模型')),
2923
+ h('select', {
2924
+ id: 'dcb-model-select',
2925
+ className: 'dcb-select',
2926
+ value: modelFilter,
2927
+ onChange: function (event) { store.setModelFilter(event.target.value) },
2928
+ },
2929
+ h('option', { key: 'all', value: '' }, tt('全部模型')),
2930
+ modelOptionNodes(MODELS))),
2595
2931
  // 显示币种:报价表永远是人民币,这里只决定展示时换算成什么货币。
2596
2932
  h('div', { className: 'dcb-field' },
2597
2933
  h('label', { className: 'dcb-field-label', htmlFor: 'dcb-currency-select' }, tt('货币')),
@@ -2607,7 +2943,14 @@ window.__ModuleLoader__.load({
2607
2943
  h('div', { className: 'dcb-set-note dcb-rate-note' }, rateNote),
2608
2944
  h('div', { className: 'dcb-set-body' },
2609
2945
  h('div', { className: 'dcb-hist-legend' },
2610
- h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
2946
+ // 每个模型一枚颜色 chip(与折线同色),右侧是 Token 轴开关。
2947
+ legendModels.length > 0
2948
+ ? legendModels.map(function (key) {
2949
+ return h('span', { key: 'lg' + key, className: 'dcb-legend-chip', title: modelDetail(key) },
2950
+ h('i', { className: 'dcb-legend-dot', style: { background: modelColor(key) } }),
2951
+ modelLabel(key))
2952
+ })
2953
+ : h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
2611
2954
  h('button', {
2612
2955
  type: 'button',
2613
2956
  className: showTokens ? 'dcb-tog is-on' : 'dcb-tog',
@@ -3068,6 +3411,14 @@ window.__ModuleLoader__.load({
3068
3411
  .dcb-legend-item{display:inline-flex;align-items:center;gap:6px}
3069
3412
  .dcb-legend-dot{display:inline-block;width:14px;height:3px;border-radius:2px;background:var(--dsw-alias-brand-primary)}
3070
3413
  .dcb-legend-dot.dcb-dot-cost{background:var(--dsw-alias-state-success-primary)}
3414
+ /* 图例里的模型 chip(与折线同色) */
3415
+ .dcb-legend-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
3416
+ .dcb-legend-chip .dcb-legend-dot{width:9px;height:9px;border-radius:50%}
3417
+ /* 弹层标题栏里的模型下拉 */
3418
+ .dcb-select-inline{height:22px;font-size:11px;min-width:118px;max-width:190px;padding:0 6px;border-radius:6px}
3419
+ .dcb-select-inline:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
3420
+ /* tooltip 里的模型色点 */
3421
+ .dcb-model-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;vertical-align:middle}
3071
3422
  .dcb-chart-body{position:relative}
3072
3423
  .dcb-chart-svg{display:block;width:100%;height:216px}
3073
3424
  .dcb-grid-line{stroke:var(--dsw-alias-border-l1);stroke-width:1;opacity:.7;vector-effect:non-scaling-stroke}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-consumption-badge",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "DSH Web UI plugin: a per-message consumption badge (tokens | estimated cost | peak/valley) plus a session detail modal with cumulative tokens, estimated cost, Beijing-time peak/off-peak pricing, a per-turn dual-axis chart, and the DeepSeek account balance",
5
5
  "keywords": [
6
6
  "deepseek-harness",