dsh-consumption-badge 1.2.2 → 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.
- package/README.md +173 -7
- package/lib/client.js +858 -95
- package/lib/index.js +170 -10
- package/package.json +1 -1
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
@@ -95,6 +213,177 @@ window.__ModuleLoader__.load({
|
|
|
95
213
|
if (n < 100) return n.toFixed(2)
|
|
96
214
|
return n.toFixed(1)
|
|
97
215
|
}
|
|
216
|
+
|
|
217
|
+
/* ===================== 显示币种(报价表始终是人民币) ===================== */
|
|
218
|
+
const LS_CURRENCY = 'dsh-consumption:currency'
|
|
219
|
+
const RATES_TTL_MS = 6 * 60 * 60 * 1000
|
|
220
|
+
/** 可选币种 + 符号;顺序与 Host 的 CURRENCY_CODES 一致(CNY 永远是第一项)。 */
|
|
221
|
+
const CURRENCIES = [
|
|
222
|
+
{ code: 'CNY', symbol: '¥' },
|
|
223
|
+
{ code: 'USD', symbol: '$' },
|
|
224
|
+
{ code: 'EUR', symbol: '€' },
|
|
225
|
+
{ code: 'GBP', symbol: '£' },
|
|
226
|
+
{ code: 'JPY', symbol: 'JP¥' },
|
|
227
|
+
{ code: 'HKD', symbol: 'HK$' },
|
|
228
|
+
{ code: 'TWD', symbol: 'NT$' },
|
|
229
|
+
{ code: 'KRW', symbol: '₩' },
|
|
230
|
+
{ code: 'SGD', symbol: 'S$' },
|
|
231
|
+
{ code: 'AUD', symbol: 'A$' },
|
|
232
|
+
{ code: 'CAD', symbol: 'C$' },
|
|
233
|
+
{ code: 'CHF', symbol: 'CHF ' },
|
|
234
|
+
{ code: 'INR', symbol: '₹' },
|
|
235
|
+
{ code: 'RUB', symbol: '₽' },
|
|
236
|
+
{ code: 'BRL', symbol: 'R$' },
|
|
237
|
+
{ code: 'THB', symbol: '฿' },
|
|
238
|
+
{ code: 'MXN', symbol: 'MX$' },
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
function currencyMeta(code) {
|
|
242
|
+
for (let i = 0; i < CURRENCIES.length; i += 1) {
|
|
243
|
+
if (CURRENCIES[i].code === code) return CURRENCIES[i]
|
|
244
|
+
}
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
function symbolOf(code) {
|
|
248
|
+
const meta = currencyMeta(code)
|
|
249
|
+
return meta !== null ? meta.symbol : String(code) + ' '
|
|
250
|
+
}
|
|
251
|
+
/** 币种全名:优先用 Intl.DisplayNames(跟随系统语言),取不到就用代码。 */
|
|
252
|
+
function currencyName(code) {
|
|
253
|
+
try {
|
|
254
|
+
if (typeof Intl === 'object' && typeof Intl.DisplayNames === 'function') {
|
|
255
|
+
const names = new Intl.DisplayNames([localeTag()], { type: 'currency' })
|
|
256
|
+
const name = names.of(code)
|
|
257
|
+
if (typeof name === 'string' && name && name !== code) return name
|
|
258
|
+
}
|
|
259
|
+
} catch (e) { /* 回落代码 */ }
|
|
260
|
+
return code
|
|
261
|
+
}
|
|
262
|
+
/** 当前偏好币种(读不到/非法一律人民币)。 */
|
|
263
|
+
function readCurrency() {
|
|
264
|
+
const raw = lsGet(LS_CURRENCY)
|
|
265
|
+
return currencyMeta(raw) !== null ? raw : 'CNY'
|
|
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
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 把人民币金额换算成当前偏好币种。
|
|
275
|
+
* @returns {{value: number, code: string, rate: number, converted: boolean}}
|
|
276
|
+
* `converted: false` 表示汇率还没拿到或该币种没有汇率 → 保持人民币。
|
|
277
|
+
*/
|
|
278
|
+
function convertCny(value) {
|
|
279
|
+
const n = Number(value) || 0
|
|
280
|
+
// `store` 是模块级常量,渲染期一定已初始化;仍然兜一层,避免任何早期调用撞 TDZ。
|
|
281
|
+
let st = null
|
|
282
|
+
try {
|
|
283
|
+
st = store.state
|
|
284
|
+
} catch (e) {
|
|
285
|
+
st = null
|
|
286
|
+
}
|
|
287
|
+
const code = st !== null && typeof st.currency === 'string' ? st.currency : 'CNY'
|
|
288
|
+
if (code === 'CNY') return { value: n, code: 'CNY', rate: 1, converted: false }
|
|
289
|
+
const payload = st !== null ? st.rates : null
|
|
290
|
+
const table = payload && payload.rates && typeof payload.rates === 'object' ? payload.rates : null
|
|
291
|
+
const rate = table !== null && typeof table[code] === 'number' ? table[code] : null
|
|
292
|
+
if (rate === null || !(rate > 0)) return { value: n, code: 'CNY', rate: 1, converted: false }
|
|
293
|
+
return { value: n * rate, code: code, rate: rate, converted: true }
|
|
294
|
+
}
|
|
295
|
+
/** 金额(按偏好币种换算 + 前缀符号)。 */
|
|
296
|
+
function money(value) {
|
|
297
|
+
const r = convertCny(value)
|
|
298
|
+
return symbolOf(r.code) + fmtYuan(r.value)
|
|
299
|
+
}
|
|
300
|
+
/** 估算金额前缀 `≈`。 */
|
|
301
|
+
function moneyApprox(value) {
|
|
302
|
+
return '≈' + money(value)
|
|
303
|
+
}
|
|
304
|
+
/** 账户余额:原生币种是 CNY 就走换算,其它币种按原样显示。 */
|
|
305
|
+
function moneyBalance(amount, nativeCode) {
|
|
306
|
+
const src = typeof nativeCode === 'string' && nativeCode ? nativeCode : 'CNY'
|
|
307
|
+
if (src !== 'CNY') return symbolOf(src) + fmtYuan(Number(amount) || 0)
|
|
308
|
+
return money(amount)
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* 浏览器直连的汇率源(两个都带 `access-control-allow-origin: *`)。
|
|
312
|
+
* 用来兜住「Host 半边还没重启、/rates 404」以及 Host 侧出网被挡的情况。
|
|
313
|
+
*/
|
|
314
|
+
const RATE_ENDPOINTS = [
|
|
315
|
+
{ url: 'https://open.er-api.com/v6/latest/CNY', source: 'open.er-api.com' },
|
|
316
|
+
{ url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json', source: 'currency-api' },
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
/** 把两种公开汇率的响应归一成 `{ok, base, rates, source, fetchedAt}`。 */
|
|
320
|
+
function parseRatesPayload(json, source) {
|
|
321
|
+
if (!json || typeof json !== 'object') return null
|
|
322
|
+
const raw = json.rates && typeof json.rates === 'object' ? json.rates : (json.cny && typeof json.cny === 'object' ? json.cny : null)
|
|
323
|
+
if (raw === null) return null
|
|
324
|
+
const rates = {}
|
|
325
|
+
let found = 0
|
|
326
|
+
for (let i = 0; i < CURRENCIES.length; i += 1) {
|
|
327
|
+
const code = CURRENCIES[i].code
|
|
328
|
+
const v = typeof raw[code] === 'number' ? raw[code] : raw[code.toLowerCase()]
|
|
329
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
|
|
330
|
+
rates[code] = v
|
|
331
|
+
found += 1
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (found === 0) return null
|
|
335
|
+
const stamp = typeof json.time_last_update_unix === 'number'
|
|
336
|
+
? json.time_last_update_unix * 1000
|
|
337
|
+
: (typeof json.date === 'string' ? Date.parse(json.date) : 0)
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
base: 'CNY',
|
|
341
|
+
rates: rates,
|
|
342
|
+
source: source,
|
|
343
|
+
fetchedAt: Number.isFinite(stamp) && stamp > 0 ? stamp : 0,
|
|
344
|
+
stale: false,
|
|
345
|
+
via: 'browser',
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 依次尝试浏览器直连的汇率源;全失败返回 null。 */
|
|
350
|
+
function fetchRatesDirect() {
|
|
351
|
+
if (typeof fetch !== 'function') return Promise.resolve(null)
|
|
352
|
+
let chain = Promise.resolve(null)
|
|
353
|
+
for (let i = 0; i < RATE_ENDPOINTS.length; i += 1) {
|
|
354
|
+
const endpoint = RATE_ENDPOINTS[i]
|
|
355
|
+
chain = chain.then(function (found) {
|
|
356
|
+
if (found !== null) return found
|
|
357
|
+
return fetch(endpoint.url, { headers: { accept: 'application/json' } })
|
|
358
|
+
.then(function (res) {
|
|
359
|
+
if (!res || res.ok === false) return null
|
|
360
|
+
return res.json()
|
|
361
|
+
})
|
|
362
|
+
.then(function (json) { return parseRatesPayload(json, endpoint.source) })
|
|
363
|
+
.catch(function () { return null })
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
return chain
|
|
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
|
+
}
|
|
98
387
|
function bucketOfTurns(turns) {
|
|
99
388
|
const out = { hit: 0, miss: 0, out: 0 }
|
|
100
389
|
for (let i = 0; i < turns.length; i += 1) {
|
|
@@ -138,6 +427,25 @@ window.__ModuleLoader__.load({
|
|
|
138
427
|
'账户余额': 'Account balance',
|
|
139
428
|
'逐轮消耗趋势': 'Per-turn usage trend',
|
|
140
429
|
'命中 {hit} · 未命中 {miss} · 输出 {out}': 'hit {hit} · miss {miss} · output {out}',
|
|
430
|
+
'本轮消耗(点击查看本会话明细)': 'This turn’s usage (click for session details)',
|
|
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.',
|
|
440
|
+
'金额按人民币报价折算显示': 'Amounts are priced in CNY and converted for display',
|
|
441
|
+
'当前以人民币显示(可切换成其它币种)': 'Showing CNY — pick another currency to convert',
|
|
442
|
+
'1 CNY = {rate} {code}': '1 CNY = {rate} {code}',
|
|
443
|
+
'汇率来源 {source}': 'rates from {source}',
|
|
444
|
+
'内置兜底汇率': 'built-in fallback',
|
|
445
|
+
'联网失败时使用内置汇率': 'built-in rates used because the lookup failed',
|
|
446
|
+
'正在获取汇率…': 'Fetching exchange rates…',
|
|
447
|
+
'汇率获取失败,暂时按人民币显示:{msg}': 'Could not fetch exchange rates, showing CNY: {msg}',
|
|
448
|
+
'余额 = {amount}': 'Balance = {amount}',
|
|
141
449
|
'{id}(按 flash 估算)': '{id} (priced as flash)',
|
|
142
450
|
' · 峰时 ×2': ' · peak ×2',
|
|
143
451
|
' · 谷时': ' · off-peak ×1',
|
|
@@ -168,8 +476,8 @@ window.__ModuleLoader__.load({
|
|
|
168
476
|
'余额接口不可用': 'Balance API unavailable',
|
|
169
477
|
'余额不足': 'Insufficient balance',
|
|
170
478
|
'请前往 DeepSeek 开放平台充值': 'Top up at the DeepSeek open platform',
|
|
171
|
-
'赠送
|
|
172
|
-
'充值
|
|
479
|
+
'赠送 {amount}': 'granted {amount}',
|
|
480
|
+
'充值 {amount}': 'topped up {amount}',
|
|
173
481
|
'DeepSeek 账户余额': 'DeepSeek account balance',
|
|
174
482
|
'空响应': 'Empty response',
|
|
175
483
|
'余额获取失败': 'Balance request failed',
|
|
@@ -282,6 +590,15 @@ window.__ModuleLoader__.load({
|
|
|
282
590
|
* Bind the shell's locale service when the composition provides one.
|
|
283
591
|
* Optional dependency on purpose: without the service every string falls
|
|
284
592
|
* back to its Chinese key, so the plugin still works standalone.
|
|
593
|
+
*
|
|
594
|
+
* The dictionary namespace is `consumption-badge`. A **hot reload inside an
|
|
595
|
+
* open page** mounts a second fiber while the previous one's registration may
|
|
596
|
+
* still be alive (the shell re-imports the bundle); the locale service rejects
|
|
597
|
+
* a duplicate `(ns, locale)` pair, and the old dictionary would then stay bound
|
|
598
|
+
* forever — new keys added in a later build would render as their raw Chinese
|
|
599
|
+
* key. So: on a duplicate we register under a suffixed namespace instead of
|
|
600
|
+
* giving up, and we hand the disposer to `ctx.effect` so a clean unload frees
|
|
601
|
+
* the plain namespace again.
|
|
285
602
|
* @param ctx client cordis context.
|
|
286
603
|
*/
|
|
287
604
|
function attachLocale(ctx) {
|
|
@@ -296,15 +613,29 @@ window.__ModuleLoader__.load({
|
|
|
296
613
|
service = null
|
|
297
614
|
}
|
|
298
615
|
if (service === null || typeof service.register !== 'function' || typeof service.bind !== 'function') return
|
|
616
|
+
// Only the `en` side is registered: the key already is the `zh` copy, and
|
|
617
|
+
// the locale service resolves a miss to the key itself.
|
|
618
|
+
let ns = LOCALE_NS
|
|
619
|
+
let dispose = null
|
|
299
620
|
try {
|
|
300
|
-
|
|
301
|
-
// the locale service resolves a miss to the key itself.
|
|
302
|
-
service.register(LOCALE_NS, 'en', DICT_EN)
|
|
621
|
+
dispose = service.register(ns, 'en', DICT_EN)
|
|
303
622
|
} catch (e) {
|
|
304
|
-
|
|
623
|
+
ns = LOCALE_NS + '#' + BUILD_TAG + '-' + Math.random().toString(36).slice(2, 7)
|
|
624
|
+
try {
|
|
625
|
+
dispose = service.register(ns, 'en', DICT_EN)
|
|
626
|
+
console.warn('[consumption-badge] previous dictionary still registered; bound a private locale namespace ' + ns)
|
|
627
|
+
} catch (e2) {
|
|
628
|
+
console.warn('[consumption-badge] locale dictionary registration failed: ' + msgOf(e2))
|
|
629
|
+
return
|
|
630
|
+
}
|
|
305
631
|
}
|
|
306
|
-
boundTranslate = service.bind(
|
|
632
|
+
boundTranslate = service.bind(ns)
|
|
307
633
|
localeService = service
|
|
634
|
+
if (typeof dispose === 'function' && ctx && typeof ctx.effect === 'function') {
|
|
635
|
+
try {
|
|
636
|
+
ctx.effect(function () { return dispose })
|
|
637
|
+
} catch (e) { /* effects unavailable (tests): the dictionary simply stays */ }
|
|
638
|
+
}
|
|
308
639
|
}
|
|
309
640
|
|
|
310
641
|
/**
|
|
@@ -340,6 +671,20 @@ window.__ModuleLoader__.load({
|
|
|
340
671
|
}
|
|
341
672
|
}
|
|
342
673
|
|
|
674
|
+
/** Active locale id ('zh' when the service is absent). */
|
|
675
|
+
function activeLocaleId() {
|
|
676
|
+
try {
|
|
677
|
+
const snapshot = localeService !== null && typeof localeService.getLocale === 'function' ? localeService.getLocale() : null
|
|
678
|
+
if (snapshot && typeof snapshot.active === 'string' && snapshot.active) return snapshot.active
|
|
679
|
+
} catch (e) { /* fall through */ }
|
|
680
|
+
return 'zh'
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** BCP-47 tag for Intl: only zh/en are shipped. */
|
|
684
|
+
function localeTag() {
|
|
685
|
+
return activeLocaleId() === 'en' ? 'en' : 'zh-CN'
|
|
686
|
+
}
|
|
687
|
+
|
|
343
688
|
/* Host error codes (lib/index.js) → the Chinese key tt() translates. */
|
|
344
689
|
const HOST_ERROR_KEYS = {
|
|
345
690
|
'no-credentials': 'credentials 服务不可用',
|
|
@@ -454,10 +799,10 @@ window.__ModuleLoader__.load({
|
|
|
454
799
|
|
|
455
800
|
/* ================= 消耗弹窗背景样式(与个性化插件联动) ================= */
|
|
456
801
|
const MODAL_STYLE_KEY = 'dsh-consumption:modal-style'
|
|
457
|
-
const BUILD_TAG = 'dcb-1.
|
|
802
|
+
const BUILD_TAG = 'dcb-1.3.2'
|
|
458
803
|
// 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
|
|
459
804
|
// 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
|
|
460
|
-
const PLUGIN_VERSION = '1.
|
|
805
|
+
const PLUGIN_VERSION = '1.3.2'
|
|
461
806
|
const PKG_NAME = 'dsh-consumption-badge'
|
|
462
807
|
const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/' + PKG_NAME + '/latest'
|
|
463
808
|
const UPDATE_COMMAND = 'dsh plugin --profile web update ' + PKG_NAME
|
|
@@ -830,6 +1175,11 @@ window.__ModuleLoader__.load({
|
|
|
830
1175
|
version: 0,
|
|
831
1176
|
focusPending: false,
|
|
832
1177
|
turns: [],
|
|
1178
|
+
// 逐条 assistant 消息的用量(messageId → 记录):只用来把胶囊定位到「哪一轮」。
|
|
1179
|
+
messages: [],
|
|
1180
|
+
messageById: new Map(),
|
|
1181
|
+
// turn 号 → /turns 里的折叠记录(胶囊显示的是这一轮的合计)。
|
|
1182
|
+
turnByNumber: new Map(),
|
|
833
1183
|
turnsState: 'idle',
|
|
834
1184
|
turnsError: '',
|
|
835
1185
|
// Error copy is stored language-neutral: the kind names the sentence and
|
|
@@ -841,6 +1191,13 @@ window.__ModuleLoader__.load({
|
|
|
841
1191
|
accountState: 'idle',
|
|
842
1192
|
integrations: null,
|
|
843
1193
|
modalStyle: readModalStyle(),
|
|
1194
|
+
// 显示币种(本地偏好)+ 汇率(Host 路由,6h 缓存):金额展示都走 money()。
|
|
1195
|
+
currency: readCurrency(),
|
|
1196
|
+
// 模型筛选 / 计价兜底('' = 全部模型,不筛选;同时作为查不到模型时的计价基准)。
|
|
1197
|
+
modelFilter: readModelFilter(),
|
|
1198
|
+
rates: null,
|
|
1199
|
+
ratesState: 'idle',
|
|
1200
|
+
ratesError: '',
|
|
844
1201
|
// 已解析的弹窗背景图地址:URL 直接用,本地图片是 IndexedDB 里的 blob: 地址。
|
|
845
1202
|
modalImageUrl: '',
|
|
846
1203
|
// 图片保存过程的可观测状态:'' | 'saving' | 'saved' | 以 'error:' 开头的失败原因
|
|
@@ -856,11 +1213,13 @@ window.__ModuleLoader__.load({
|
|
|
856
1213
|
balanceErrorCode: '',
|
|
857
1214
|
balanceErrorFallback: '',
|
|
858
1215
|
balanceAt: 0,
|
|
1216
|
+
ratesAt: 0,
|
|
859
1217
|
}
|
|
860
1218
|
const listeners = new Set()
|
|
861
1219
|
let turnsInFlight = null
|
|
862
1220
|
let accountInFlight = null
|
|
863
1221
|
let balanceInFlight = null
|
|
1222
|
+
let ratesInFlight = null
|
|
864
1223
|
|
|
865
1224
|
function emit() {
|
|
866
1225
|
state.version += 1
|
|
@@ -899,6 +1258,9 @@ window.__ModuleLoader__.load({
|
|
|
899
1258
|
if (state.sessionId !== sessionId) {
|
|
900
1259
|
state.sessionId = sessionId
|
|
901
1260
|
state.turns = []
|
|
1261
|
+
state.messages = []
|
|
1262
|
+
state.messageById = new Map()
|
|
1263
|
+
state.turnByNumber = new Map()
|
|
902
1264
|
state.turnsState = 'idle'
|
|
903
1265
|
state.turnsError = ''
|
|
904
1266
|
}
|
|
@@ -921,7 +1283,33 @@ window.__ModuleLoader__.load({
|
|
|
921
1283
|
cached: false,
|
|
922
1284
|
})
|
|
923
1285
|
}
|
|
1286
|
+
// 逐条消息:仅当 Host 回了 messages(新增字段)时才覆盖,否则保持空表 ⇒ 徽章回落本会话口径。
|
|
1287
|
+
const rawMessages = res && Array.isArray(res.messages) ? res.messages : []
|
|
1288
|
+
const messages = []
|
|
1289
|
+
const byId = new Map()
|
|
1290
|
+
for (let i = 0; i < rawMessages.length; i += 1) {
|
|
1291
|
+
const m = rawMessages[i]
|
|
1292
|
+
if (!m || typeof m !== 'object') continue
|
|
1293
|
+
const rec = {
|
|
1294
|
+
id: typeof m.id === 'string' ? m.id : '',
|
|
1295
|
+
turn: num(m.turn),
|
|
1296
|
+
step: num(m.step),
|
|
1297
|
+
hit: num(m.hit),
|
|
1298
|
+
miss: num(m.miss),
|
|
1299
|
+
out: num(m.out),
|
|
1300
|
+
time: num(m.time),
|
|
1301
|
+
model: typeof m.model === 'string' ? m.model : '',
|
|
1302
|
+
}
|
|
1303
|
+
messages.push(rec)
|
|
1304
|
+
if (rec.id !== '') byId.set(rec.id, rec)
|
|
1305
|
+
}
|
|
1306
|
+
state.messages = messages
|
|
1307
|
+
state.messageById = byId
|
|
924
1308
|
state.turns = restoreTurns(sessionId, normalized)
|
|
1309
|
+
// 索引必须和 state.turns 用同一份数据(restoreTurns 会补上历史窗口外的轮次)。
|
|
1310
|
+
const turnByNumber = new Map()
|
|
1311
|
+
for (let i = 0; i < state.turns.length; i += 1) turnByNumber.set(state.turns[i].turn, state.turns[i])
|
|
1312
|
+
state.turnByNumber = turnByNumber
|
|
925
1313
|
state.turnsState = res && res.ok === false ? 'error' : 'ready'
|
|
926
1314
|
if (res && res.ok === false) {
|
|
927
1315
|
state.turnsErrorKind = 'host'
|
|
@@ -1008,6 +1396,74 @@ window.__ModuleLoader__.load({
|
|
|
1008
1396
|
function bump() {
|
|
1009
1397
|
emit()
|
|
1010
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* 取汇率(Host 端 6h 缓存;`force` 强制刷新 Host 缓存)。
|
|
1401
|
+
* 只要当前币种不是人民币就需要它,取不到时金额回落为人民币显示。
|
|
1402
|
+
*/
|
|
1403
|
+
function ensureRates(force) {
|
|
1404
|
+
const now = Date.now()
|
|
1405
|
+
const fresh = state.rates && state.rates.ok && now - (state.ratesAt || 0) < RATES_TTL_MS
|
|
1406
|
+
if (!force && fresh) return Promise.resolve(state.rates)
|
|
1407
|
+
if (ratesInFlight) return ratesInFlight
|
|
1408
|
+
state.ratesState = 'loading'
|
|
1409
|
+
emit()
|
|
1410
|
+
ratesInFlight = Promise.resolve()
|
|
1411
|
+
.then(function () { return fetchJson(API + '/rates' + (force ? '?force=1' : '')) })
|
|
1412
|
+
.catch(function () { return null })
|
|
1413
|
+
.then(function (res) {
|
|
1414
|
+
if (res && typeof res === 'object' && res.ok !== false && res.rates && typeof res.rates === 'object') {
|
|
1415
|
+
return Object.assign({ via: 'host' }, res)
|
|
1416
|
+
}
|
|
1417
|
+
// Host 路由还没就绪(例如还没重启)或出网被挡:浏览器直连公开汇率源。
|
|
1418
|
+
return fetchRatesDirect()
|
|
1419
|
+
})
|
|
1420
|
+
.then(function (res) {
|
|
1421
|
+
if (res && typeof res === 'object' && res.ok !== false && res.rates && typeof res.rates === 'object') {
|
|
1422
|
+
state.rates = res
|
|
1423
|
+
state.ratesAt = Date.now()
|
|
1424
|
+
state.ratesState = 'ready'
|
|
1425
|
+
state.ratesError = ''
|
|
1426
|
+
} else {
|
|
1427
|
+
state.rates = null
|
|
1428
|
+
state.ratesState = 'error'
|
|
1429
|
+
state.ratesError = res && typeof res.error === 'string' ? res.error : 'rates unavailable'
|
|
1430
|
+
}
|
|
1431
|
+
emit()
|
|
1432
|
+
})
|
|
1433
|
+
.catch(function (e) {
|
|
1434
|
+
state.rates = null
|
|
1435
|
+
state.ratesState = 'error'
|
|
1436
|
+
state.ratesError = msgOf(e)
|
|
1437
|
+
emit()
|
|
1438
|
+
})
|
|
1439
|
+
.then(function () { ratesInFlight = null })
|
|
1440
|
+
return ratesInFlight
|
|
1441
|
+
}
|
|
1442
|
+
/** 切换显示币种:落本地偏好 → 需要汇率时顺手取一次 → 通知所有订阅者(即时重绘)。 */
|
|
1443
|
+
function setCurrency(code) {
|
|
1444
|
+
const next = currencyMeta(code) !== null ? code : 'CNY'
|
|
1445
|
+
if (state.currency === next) return
|
|
1446
|
+
state.currency = next
|
|
1447
|
+
lsSet(LS_CURRENCY, next)
|
|
1448
|
+
emit()
|
|
1449
|
+
if (next !== 'CNY') ensureRates(false)
|
|
1450
|
+
}
|
|
1451
|
+
/** 强制刷新汇率(设置页的「刷新汇率」按钮)。 */
|
|
1452
|
+
function refreshRates() {
|
|
1453
|
+
state.rates = null
|
|
1454
|
+
return ensureRates(true)
|
|
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
|
+
}
|
|
1011
1467
|
/** 更新弹窗背景配置:合并 → 落本地偏好 → 通知所有订阅者(弹层即时重绘)。 */
|
|
1012
1468
|
function setModalStyle(patch) {
|
|
1013
1469
|
const prev = state.modalStyle
|
|
@@ -1167,6 +1623,10 @@ window.__ModuleLoader__.load({
|
|
|
1167
1623
|
ensureAccount: ensureAccount,
|
|
1168
1624
|
ensureTurns: ensureTurns,
|
|
1169
1625
|
ensureBalance: ensureBalance,
|
|
1626
|
+
ensureRates: ensureRates,
|
|
1627
|
+
setCurrency: setCurrency,
|
|
1628
|
+
refreshRates: refreshRates,
|
|
1629
|
+
setModelFilter: setModelFilter,
|
|
1170
1630
|
openPanel: openPanel,
|
|
1171
1631
|
closePanel: closePanel,
|
|
1172
1632
|
setModalStyle: setModalStyle,
|
|
@@ -1199,7 +1659,8 @@ window.__ModuleLoader__.load({
|
|
|
1199
1659
|
const bucket = { hit: num(t.hit), miss: num(t.miss), out: num(t.out) }
|
|
1200
1660
|
const at = t.time > 0 ? t.time : nowMs
|
|
1201
1661
|
const peak = isPeakAt(at)
|
|
1202
|
-
|
|
1662
|
+
// 每轮按**它自己用的模型**计价(多模型混跑时不会串价)。
|
|
1663
|
+
const price = priceFor(t.model, modelFallback)
|
|
1203
1664
|
series.push({
|
|
1204
1665
|
turn: t.turn,
|
|
1205
1666
|
hit: bucket.hit,
|
|
@@ -1208,7 +1669,12 @@ window.__ModuleLoader__.load({
|
|
|
1208
1669
|
tokens: bucket.hit + bucket.miss + bucket.out,
|
|
1209
1670
|
cost: costOf(bucket, price.table, peak),
|
|
1210
1671
|
peak: peak,
|
|
1672
|
+
peakPricing: price.peakPricing,
|
|
1211
1673
|
model: price.id,
|
|
1674
|
+
rawModel: price.raw,
|
|
1675
|
+
version: price.version,
|
|
1676
|
+
label: price.label,
|
|
1677
|
+
color: price.color,
|
|
1212
1678
|
known: price.known,
|
|
1213
1679
|
cached: !!t.cached,
|
|
1214
1680
|
time: at,
|
|
@@ -1241,10 +1707,8 @@ window.__ModuleLoader__.load({
|
|
|
1241
1707
|
function yCost(v) { return CHART_PAD.t + innerH - (v / topCost) * innerH }
|
|
1242
1708
|
|
|
1243
1709
|
const tokenPts = []
|
|
1244
|
-
const costPts = []
|
|
1245
1710
|
for (let i = 0; i < n; i += 1) {
|
|
1246
1711
|
tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(series[i].tokens).toFixed(2))
|
|
1247
|
-
costPts.push(xAt(i).toFixed(2) + ',' + yCost(series[i].cost).toFixed(2))
|
|
1248
1712
|
}
|
|
1249
1713
|
const grid = []
|
|
1250
1714
|
const leftLabels = []
|
|
@@ -1254,7 +1718,7 @@ window.__ModuleLoader__.load({
|
|
|
1254
1718
|
const y = CHART_PAD.t + innerH * ratio
|
|
1255
1719
|
grid.push(h('line', { key: 'g' + g, x1: CHART_PAD.l, x2: CHART_PAD.l + innerW, y1: y, y2: y, className: 'dcb-grid-line' }))
|
|
1256
1720
|
leftLabels.push(h('text', { key: 'lt' + g, x: CHART_PAD.l - 8, y: y + 3.5, textAnchor: 'end', className: 'dcb-axis-label' }, fmtTokens(topToken * (1 - ratio))))
|
|
1257
|
-
rightLabels.push(h('text', { key: 'rt' + g, x: CHART_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' },
|
|
1721
|
+
rightLabels.push(h('text', { key: 'rt' + g, x: CHART_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, money(topCost * (1 - ratio))))
|
|
1258
1722
|
}
|
|
1259
1723
|
const step = n <= 8 ? 1 : Math.ceil(n / 8)
|
|
1260
1724
|
const xLabels = []
|
|
@@ -1262,11 +1726,49 @@ window.__ModuleLoader__.load({
|
|
|
1262
1726
|
if (i % step !== 0 && i !== n - 1) continue
|
|
1263
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)))
|
|
1264
1728
|
}
|
|
1265
|
-
|
|
1729
|
+
// 每个模型一条金额折线(各自颜色);该模型没有数据的轮次断开,不做插值连线。
|
|
1730
|
+
const byModel = {}
|
|
1731
|
+
const modelOrder = []
|
|
1266
1732
|
for (let i = 0; i < n; i += 1) {
|
|
1267
|
-
const
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
+
}
|
|
1270
1772
|
}
|
|
1271
1773
|
const hovered = hover >= 0 && hover < n ? series[hover] : null
|
|
1272
1774
|
const crosshair = hovered
|
|
@@ -1288,19 +1790,30 @@ window.__ModuleLoader__.load({
|
|
|
1288
1790
|
? h('div', { className: 'dcb-tooltip', style: { left: String((xAt(hover) / CHART_W) * 100) + '%' } },
|
|
1289
1791
|
h('div', { className: 'dcb-tooltip-title' },
|
|
1290
1792
|
'#' + String(hovered.turn),
|
|
1291
|
-
h('span', { className: 'dcb-tooltip-model'
|
|
1292
|
-
|
|
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 ? '峰时' : '谷时'))),
|
|
1293
1799
|
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入命中')), h('b', null, fmtTokens(hovered.hit))),
|
|
1294
1800
|
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入未命中')), h('b', null, fmtTokens(hovered.miss))),
|
|
1295
1801
|
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输出')), h('b', null, fmtTokens(hovered.out))),
|
|
1296
1802
|
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('本轮合计')), h('b', null, fmtTokens(hovered.tokens))),
|
|
1297
|
-
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('估算金额')), h('b', null,
|
|
1803
|
+
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('估算金额')), h('b', null, moneyApprox(hovered.cost))))
|
|
1298
1804
|
: null
|
|
1299
1805
|
|
|
1300
1806
|
return h('div', { className: 'dcb-chart-wrap' },
|
|
1301
1807
|
h('div', { className: 'dcb-chart-legend' },
|
|
1302
|
-
h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot' }), tt('每轮 Token')),
|
|
1303
|
-
|
|
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('每轮估算金额'))),
|
|
1304
1817
|
h('div', { className: 'dcb-chart-body' },
|
|
1305
1818
|
h('svg', {
|
|
1306
1819
|
className: 'dcb-chart-svg',
|
|
@@ -1311,8 +1824,8 @@ window.__ModuleLoader__.load({
|
|
|
1311
1824
|
},
|
|
1312
1825
|
h('g', null, grid),
|
|
1313
1826
|
crosshair,
|
|
1314
|
-
h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }),
|
|
1315
|
-
h('
|
|
1827
|
+
h('polyline', { className: 'dcb-line dcb-line-token', points: tokenPts.join(' ') }),
|
|
1828
|
+
h('g', null, modelLines),
|
|
1316
1829
|
h('g', null, dots),
|
|
1317
1830
|
h('g', null, leftLabels),
|
|
1318
1831
|
h('g', null, rightLabels),
|
|
@@ -1357,6 +1870,26 @@ window.__ModuleLoader__.load({
|
|
|
1357
1870
|
props.hint ? h('div', { className: 'dcb-card-hint' }, props.hint) : null)
|
|
1358
1871
|
}
|
|
1359
1872
|
|
|
1873
|
+
/**
|
|
1874
|
+
* 从 DOM 里读出「自己在第几轮」:外壳把 assistant-actions 这个槽位渲染在
|
|
1875
|
+
* `<div data-turn-tail="<turn>">` 里面。这是给「Host 还没重启、没回 messageId→turn 映射」
|
|
1876
|
+
* 时用的兜底通道 —— 有 Host 映射时优先用 Host 的。
|
|
1877
|
+
* @param el 胶囊自身的 DOM 节点。
|
|
1878
|
+
* @returns 轮次号,取不到返回 undefined。
|
|
1879
|
+
*/
|
|
1880
|
+
function turnFromElement(el) {
|
|
1881
|
+
if (!el || typeof el.closest !== 'function') return undefined
|
|
1882
|
+
let node = null
|
|
1883
|
+
try {
|
|
1884
|
+
node = el.closest('[data-turn-tail]')
|
|
1885
|
+
} catch (e) {
|
|
1886
|
+
node = null
|
|
1887
|
+
}
|
|
1888
|
+
if (!node || typeof node.getAttribute !== 'function') return undefined
|
|
1889
|
+
const n = Number(node.getAttribute('data-turn-tail'))
|
|
1890
|
+
return Number.isFinite(n) && n > 0 ? n : undefined
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1360
1893
|
function ConsumptionBadge(props) {
|
|
1361
1894
|
const state = store.state
|
|
1362
1895
|
const tick = React.useState(0)
|
|
@@ -1365,35 +1898,88 @@ window.__ModuleLoader__.load({
|
|
|
1365
1898
|
return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
|
|
1366
1899
|
}, [])
|
|
1367
1900
|
const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : ''
|
|
1901
|
+
// 槽位 owner 传的是这条 finalized assistant 消息的身份;用它把自己的用量挑出来。
|
|
1902
|
+
const messageId = props && typeof props.messageId === 'string' ? props.messageId : ''
|
|
1903
|
+
const rootRef = React.useRef(null)
|
|
1904
|
+
const domTurnState = React.useState(undefined)
|
|
1905
|
+
const domTurn = domTurnState[0]
|
|
1906
|
+
const setDomTurn = domTurnState[1]
|
|
1907
|
+
React.useEffect(function () {
|
|
1908
|
+
// 每次渲染后都对一遍(槽位可能在 DOM 变动后被重新挂载)。
|
|
1909
|
+
const found = turnFromElement(rootRef.current)
|
|
1910
|
+
if (found !== undefined && found !== domTurn) setDomTurn(found)
|
|
1911
|
+
})
|
|
1368
1912
|
const projection = props && typeof props.useProjection === 'function' ? props.useProjection('tokenUsage') : undefined
|
|
1369
1913
|
React.useEffect(function () {
|
|
1370
1914
|
store.ensureAccount()
|
|
1371
1915
|
store.ensureTurns(sessionId)
|
|
1916
|
+
// 胶囊里也要显示余额:走同一份 60s 缓存,不额外压测接口。
|
|
1917
|
+
store.ensureBalance(false)
|
|
1918
|
+
// 非人民币显示时才需要汇率(Host 端 6h 缓存)。
|
|
1919
|
+
if (store.state.currency !== 'CNY') store.ensureRates(false)
|
|
1372
1920
|
}, [sessionId, projection])
|
|
1373
1921
|
React.useEffect(function () {
|
|
1374
|
-
return everyTick(60000, function () {
|
|
1922
|
+
return everyTick(60000, function () {
|
|
1923
|
+
store.bump()
|
|
1924
|
+
store.ensureBalance(false)
|
|
1925
|
+
})
|
|
1375
1926
|
}, [])
|
|
1376
1927
|
|
|
1928
|
+
// 槽位是按「轮」渲染的(TurnTailNodeView 用该轮的 finalNode.messageId 调 useSlots),
|
|
1929
|
+
// 所以这里要的是**这一轮**的合计,而不是某一步、更不是整个会话:
|
|
1930
|
+
// messageId → turn 号(Host 回映射)→ 该 turn 的折叠记录(/turns 本来就有)。
|
|
1931
|
+
const mapped = messageId !== '' ? state.messageById.get(messageId) : undefined
|
|
1932
|
+
// 优先 Host 的 messageId→turn;Host 旧版本没回 messages 时退回 DOM 上的轮次号。
|
|
1933
|
+
const turnNo = mapped !== undefined ? mapped.turn : domTurn
|
|
1934
|
+
const ownTurn = typeof turnNo === 'number' ? state.turnByNumber.get(turnNo) : undefined
|
|
1377
1935
|
const live = projection && typeof projection === 'object'
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
:
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1936
|
+
// 三级口径:本轮合计 → 本会话(投影 / 折叠后的逐轮)→ 还没有数据。
|
|
1937
|
+
let bucket = null
|
|
1938
|
+
let scope = 'turn'
|
|
1939
|
+
let bucketModel = ''
|
|
1940
|
+
let bucketTime = 0
|
|
1941
|
+
if (ownTurn !== undefined) {
|
|
1942
|
+
bucket = { hit: ownTurn.hit, miss: ownTurn.miss, out: ownTurn.out }
|
|
1943
|
+
bucketModel = ownTurn.model
|
|
1944
|
+
bucketTime = ownTurn.time
|
|
1945
|
+
} else if (live) {
|
|
1946
|
+
scope = 'session'
|
|
1947
|
+
bucket = {
|
|
1948
|
+
hit: num(projection.cacheReadTokens),
|
|
1949
|
+
miss: num(projection.uncachedInputTokens) + num(projection.cacheWriteTokens),
|
|
1950
|
+
out: num(projection.outputTokens),
|
|
1951
|
+
}
|
|
1952
|
+
} else if (state.turns.length > 0) {
|
|
1953
|
+
scope = 'session'
|
|
1954
|
+
bucket = bucketOfTurns(state.turns)
|
|
1955
|
+
}
|
|
1956
|
+
// 还没有任何数字时显示占位符,而不是误导性的 0。
|
|
1957
|
+
const pending = bucket === null && state.turnsState !== 'ready'
|
|
1958
|
+
// 计价兜底模型:会话日志里查不到模型时用它(设置页下拉选的那个 → 账号当前模型 → 默认)。
|
|
1959
|
+
const model = bucketModel
|
|
1960
|
+
|| state.modelFilter
|
|
1961
|
+
|| (state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL)
|
|
1962
|
+
const at = bucketTime > 0 ? bucketTime : Date.now()
|
|
1963
|
+
const peak = isPeakAt(at)
|
|
1964
|
+
const tokens = bucket === null ? 0 : bucket.hit + bucket.miss + bucket.out
|
|
1965
|
+
const cost = bucket === null ? 0 : costOf(bucket, priceFor(bucketModel, state.modelFilter).table, peak)
|
|
1966
|
+
|
|
1967
|
+
// 账户余额:成功 → ¥金额;读取中 → …;失败/未配置 → —(原因放 title,不挤占胶囊宽度)。
|
|
1968
|
+
const balance = state.balance
|
|
1969
|
+
let balanceValue = '…'
|
|
1970
|
+
if (balance && balance.ok === true && typeof balance.total === 'number') {
|
|
1971
|
+
balanceValue = moneyBalance(balance.total, balance.currency)
|
|
1972
|
+
} else if (state.balanceState === 'ready' || state.balanceState === 'error') {
|
|
1973
|
+
balanceValue = '—'
|
|
1974
|
+
}
|
|
1975
|
+
const balanceTitle = balanceErrorText() || tt('账户余额')
|
|
1392
1976
|
|
|
1393
1977
|
return h('button', {
|
|
1394
1978
|
type: 'button',
|
|
1395
1979
|
className: 'dcb-pill',
|
|
1396
|
-
|
|
1980
|
+
ref: function (el) { rootRef.current = el || null },
|
|
1981
|
+
title: (scope === 'turn' ? tt('本轮消耗(点击查看本会话明细)') : tt('本会话消耗(点击查看逐轮明细)'))
|
|
1982
|
+
+ ' · ' + balanceTitle + ': ' + balanceValue,
|
|
1397
1983
|
onClick: function (event) {
|
|
1398
1984
|
try {
|
|
1399
1985
|
event.preventDefault()
|
|
@@ -1405,9 +1991,11 @@ window.__ModuleLoader__.load({
|
|
|
1405
1991
|
h('span', { className: 'dcb-pill-glyph' }, '⛽'),
|
|
1406
1992
|
h('span', null, pending ? '…' : fmtTokens(tokens)),
|
|
1407
1993
|
h('span', { className: 'dcb-pill-sep' }, '|'),
|
|
1408
|
-
h('span', null, pending ? '…' :
|
|
1994
|
+
h('span', null, pending ? '…' : moneyApprox(cost)),
|
|
1995
|
+
h('span', { className: 'dcb-pill-sep' }, '|'),
|
|
1996
|
+
h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(peak ? '峰' : '谷')),
|
|
1409
1997
|
h('span', { className: 'dcb-pill-sep' }, '|'),
|
|
1410
|
-
h('span', { className:
|
|
1998
|
+
h('span', { className: 'dcb-pill-balance' }, tt('余额 = {amount}', { amount: balanceValue })))
|
|
1411
1999
|
}
|
|
1412
2000
|
|
|
1413
2001
|
function ConsumptionPanel() {
|
|
@@ -1432,19 +2020,33 @@ window.__ModuleLoader__.load({
|
|
|
1432
2020
|
if (!open) return undefined
|
|
1433
2021
|
store.ensureTurns(state.sessionId)
|
|
1434
2022
|
store.ensureBalance(false)
|
|
2023
|
+
if (store.state.currency !== 'CNY') store.ensureRates(false)
|
|
1435
2024
|
return everyTick(60000, function () { store.ensureBalance(false) })
|
|
1436
2025
|
}, [open, state.sessionId])
|
|
1437
2026
|
if (!open) return null
|
|
1438
2027
|
|
|
1439
2028
|
const now = Date.now()
|
|
1440
2029
|
const surface = modalSurface(state.modalStyle, state.modalImageUrl)
|
|
1441
|
-
const
|
|
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
|
|
1442
2033
|
const price = priceFor(model)
|
|
1443
2034
|
const peak = isPeakAt(now)
|
|
1444
|
-
const
|
|
1445
|
-
|
|
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
|
+
}
|
|
1446
2046
|
const tokens = aggregated.hit + aggregated.miss + aggregated.out
|
|
1447
|
-
const
|
|
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] })
|
|
1448
2050
|
const balance = state.balance
|
|
1449
2051
|
const loading = state.balanceState === 'loading'
|
|
1450
2052
|
const balanceErr = balanceErrorText()
|
|
@@ -1463,12 +2065,18 @@ window.__ModuleLoader__.load({
|
|
|
1463
2065
|
balanceHint = tt('请前往 DeepSeek 开放平台充值')
|
|
1464
2066
|
balanceClass = 'dcb-card-value dcb-bad'
|
|
1465
2067
|
} else if (balance) {
|
|
1466
|
-
|
|
1467
|
-
balanceValue = symbol + fmtYuan(balance.total)
|
|
2068
|
+
balanceValue = moneyBalance(balance.total, balance.currency)
|
|
1468
2069
|
const parts = []
|
|
1469
|
-
if (typeof balance.granted === 'number') parts.push(tt('赠送
|
|
1470
|
-
if (typeof balance.toppedUp === 'number') parts.push(tt('充值
|
|
2070
|
+
if (typeof balance.granted === 'number') parts.push(tt('赠送 {amount}', { amount: moneyBalance(balance.granted, balance.currency) }))
|
|
2071
|
+
if (typeof balance.toppedUp === 'number') parts.push(tt('充值 {amount}', { amount: moneyBalance(balance.toppedUp, balance.currency) }))
|
|
1471
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
|
+
}
|
|
1472
2080
|
}
|
|
1473
2081
|
|
|
1474
2082
|
function close() {
|
|
@@ -1502,11 +2110,31 @@ window.__ModuleLoader__.load({
|
|
|
1502
2110
|
h('div', { className: 'dcb-head' },
|
|
1503
2111
|
h('div', { className: 'dcb-head-title' }, tt('⛽ 本会话消耗')),
|
|
1504
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)),
|
|
1505
2125
|
h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, tt('刷新余额')),
|
|
1506
2126
|
h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: tt('Esc 关闭') }, '✕'))),
|
|
1507
2127
|
h('div', { className: 'dcb-cards' },
|
|
1508
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) }) }),
|
|
1509
|
-
h(Card, {
|
|
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
|
+
}),
|
|
1510
2138
|
h(Card, { label: tt('当前时段'), value: tt(peak ? '高峰' : '空闲'), hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00') }),
|
|
1511
2139
|
h('div', { className: 'dcb-card' },
|
|
1512
2140
|
h('div', { className: 'dcb-card-label' }, tt('账户余额')),
|
|
@@ -1516,7 +2144,7 @@ window.__ModuleLoader__.load({
|
|
|
1516
2144
|
h(UsageChart, { series: series }),
|
|
1517
2145
|
h('div', { className: 'dcb-foot' },
|
|
1518
2146
|
state.turnsError ? h('span', { className: 'dcb-foot-warn' }, turnsErrorText() + ' ') : null,
|
|
1519
|
-
tt('
|
|
2147
|
+
tt('金额按各模型官方价估算(DeepSeek 区分峰谷 ×1/×2,其它厂商按 USD 价 ×7.1 折人民币),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
|
|
1520
2148
|
}
|
|
1521
2149
|
|
|
1522
2150
|
/* ===================== settings section: history page ===================== */
|
|
@@ -1631,9 +2259,11 @@ window.__ModuleLoader__.load({
|
|
|
1631
2259
|
}
|
|
1632
2260
|
|
|
1633
2261
|
/**
|
|
1634
|
-
* 纯聚合:时间过滤 →
|
|
2262
|
+
* 纯聚合:时间过滤 → 工作区/模型过滤 → 按小时/日/月分桶(空桶补 0)→ 峰谷与金额拆分 →
|
|
2263
|
+
* 每个桶再按模型拆一份 `byModel`(折线图一个模型一条线就靠它)。
|
|
1635
2264
|
* 刻意自包含(只读 input,不引用闭包变量),所以能直接 String() 进 Web Worker。
|
|
1636
|
-
* @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` 表示该模型参与峰谷计时。
|
|
1637
2267
|
*/
|
|
1638
2268
|
function computeConsumptionStats(input) {
|
|
1639
2269
|
const records = input.records || []
|
|
@@ -1643,6 +2273,7 @@ window.__ModuleLoader__.load({
|
|
|
1643
2273
|
const from = typeof input.from === 'number' ? input.from : 0
|
|
1644
2274
|
const to = typeof input.to === 'number' ? input.to : 0
|
|
1645
2275
|
const wid = typeof input.wid === 'string' ? input.wid : ''
|
|
2276
|
+
const modelFilter = typeof input.model === 'string' ? input.model : ''
|
|
1646
2277
|
const granularity = input.granularity === 'month' ? 'month' : (input.granularity === 'hour' ? 'hour' : 'day')
|
|
1647
2278
|
// noFill:只要合计不要时间轴(设置页顶部的“累计”卡片用),跳过分桶补齐。
|
|
1648
2279
|
const noFill = input.noFill === true
|
|
@@ -1674,7 +2305,10 @@ window.__ModuleLoader__.load({
|
|
|
1674
2305
|
function priceOf(model) {
|
|
1675
2306
|
const id = typeof model === 'string' ? model : ''
|
|
1676
2307
|
if (prices[id]) return prices[id]
|
|
1677
|
-
|
|
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
|
+
}
|
|
1678
2312
|
return prices['deepseek-flash']
|
|
1679
2313
|
}
|
|
1680
2314
|
function keyOf(ms) {
|
|
@@ -1702,14 +2336,17 @@ window.__ModuleLoader__.load({
|
|
|
1702
2336
|
const produced = n(r.out)
|
|
1703
2337
|
const tokens = hit + miss + produced
|
|
1704
2338
|
if (tokens === 0) continue
|
|
2339
|
+
const rowModel = typeof r.model === 'string' && r.model ? r.model : ''
|
|
2340
|
+
if (modelFilter && rowModel !== modelFilter) continue
|
|
1705
2341
|
const peak = isPeak(t)
|
|
1706
|
-
const table = priceOf(
|
|
1707
|
-
|
|
2342
|
+
const table = priceOf(rowModel)
|
|
2343
|
+
// 只有 `peak: true` 的模型(DeepSeek)才乘峰时系数。
|
|
2344
|
+
const k = peak && table.peak === true ? 2 : 1
|
|
1708
2345
|
const cost = ((hit / 1e6) * table.hit + (miss / 1e6) * table.miss + (produced / 1e6) * table.out) * k
|
|
1709
2346
|
const key = keyOf(t)
|
|
1710
2347
|
let b = buckets[key]
|
|
1711
2348
|
if (!b) {
|
|
1712
|
-
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: {} }
|
|
1713
2350
|
buckets[key] = b
|
|
1714
2351
|
}
|
|
1715
2352
|
b.tokens += tokens
|
|
@@ -1733,13 +2370,22 @@ window.__ModuleLoader__.load({
|
|
|
1733
2370
|
}
|
|
1734
2371
|
const model = typeof r.model === 'string' && r.model ? r.model : 'unknown'
|
|
1735
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
|
|
1736
2382
|
count += 1
|
|
1737
2383
|
}
|
|
1738
2384
|
|
|
1739
2385
|
const points = []
|
|
1740
2386
|
function push(key, ms) {
|
|
1741
2387
|
const b = buckets[key]
|
|
1742
|
-
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: {} }
|
|
1743
2389
|
if (granularity === 'month') point.label = key
|
|
1744
2390
|
else if (granularity === 'hour') point.label = key.slice(11) + ':00'
|
|
1745
2391
|
else point.label = key.slice(5)
|
|
@@ -1876,10 +2522,8 @@ window.__ModuleLoader__.load({
|
|
|
1876
2522
|
function yToken(v) { return HIST_PAD.t + innerH - (v / topToken) * innerH }
|
|
1877
2523
|
function yCost(v) { return HIST_PAD.t + innerH - (v / topCost) * innerH }
|
|
1878
2524
|
|
|
1879
|
-
const costPts = []
|
|
1880
2525
|
const tokenPts = []
|
|
1881
2526
|
for (let i = 0; i < n; i += 1) {
|
|
1882
|
-
costPts.push(xAt(i).toFixed(2) + ',' + yCost(points[i].cost).toFixed(2))
|
|
1883
2527
|
tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(points[i].tokens).toFixed(2))
|
|
1884
2528
|
}
|
|
1885
2529
|
|
|
@@ -1890,7 +2534,7 @@ window.__ModuleLoader__.load({
|
|
|
1890
2534
|
const ratio = g / 4
|
|
1891
2535
|
const y = HIST_PAD.t + innerH * ratio
|
|
1892
2536
|
grid.push(h('line', { key: 'hg' + g, x1: HIST_PAD.l, x2: HIST_PAD.l + innerW, y1: y, y2: y, className: 'dcb-grid-line' }))
|
|
1893
|
-
rightLabels.push(h('text', { key: 'hr' + g, x: HIST_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' },
|
|
2537
|
+
rightLabels.push(h('text', { key: 'hr' + g, x: HIST_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, money(topCost * (1 - ratio))))
|
|
1894
2538
|
if (showTokens) {
|
|
1895
2539
|
leftLabels.push(h('text', { key: 'hl' + g, x: HIST_PAD.l - 8, y: y + 3.5, textAnchor: 'end', className: 'dcb-axis-label' }, fmtTokens(topToken * (1 - ratio))))
|
|
1896
2540
|
}
|
|
@@ -1904,13 +2548,47 @@ window.__ModuleLoader__.load({
|
|
|
1904
2548
|
}
|
|
1905
2549
|
|
|
1906
2550
|
const dots = []
|
|
2551
|
+
// 每个模型一条金额折线(各自颜色):该桶里没有这个模型就断线。
|
|
2552
|
+
const modelUsage = {}
|
|
2553
|
+
const modelOrder = []
|
|
1907
2554
|
for (let i = 0; i < n; i += 1) {
|
|
1908
|
-
const
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
+
}))
|
|
1911
2587
|
}
|
|
2588
|
+
}
|
|
2589
|
+
for (let i = 0; i < n; i += 1) {
|
|
1912
2590
|
if (showTokens && points[i].records > 0) {
|
|
1913
|
-
dots.push(h('circle', { key: 'ht' + i, cx: xAt(i), cy: yToken(points[i].tokens), r:
|
|
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' }))
|
|
1914
2592
|
}
|
|
1915
2593
|
}
|
|
1916
2594
|
|
|
@@ -1935,11 +2613,13 @@ window.__ModuleLoader__.load({
|
|
|
1935
2613
|
h('div', { className: 'dcb-tooltip-title' },
|
|
1936
2614
|
hovered.label,
|
|
1937
2615
|
h('span', { className: 'dcb-tooltip-model' }, tt('{n} 轮', { n: hovered.records }))),
|
|
1938
|
-
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('当日合计')), h('b', null,
|
|
2616
|
+
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('当日合计')), h('b', null, moneyApprox(hovered.cost))),
|
|
1939
2617
|
h('div', { className: 'dcb-tooltip-row' }, h('span', null, 'Token'), h('b', null, fmtTokens(hovered.tokens))),
|
|
1940
|
-
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null,
|
|
1941
|
-
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null,
|
|
1942
|
-
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('模型')), h('b', null, hovered.models.length
|
|
2618
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null, moneyApprox(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
|
|
2619
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null, moneyApprox(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
|
|
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
|
+
: '—')))
|
|
1943
2623
|
: null
|
|
1944
2624
|
|
|
1945
2625
|
return h('div', { className: 'dcb-chart-body' },
|
|
@@ -1952,8 +2632,8 @@ window.__ModuleLoader__.load({
|
|
|
1952
2632
|
},
|
|
1953
2633
|
h('g', null, grid),
|
|
1954
2634
|
crosshair,
|
|
1955
|
-
showTokens ? h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }) : null,
|
|
1956
|
-
h('
|
|
2635
|
+
showTokens ? h('polyline', { className: 'dcb-line dcb-line-token', points: tokenPts.join(' ') }) : null,
|
|
2636
|
+
h('g', null, modelLines),
|
|
1957
2637
|
h('g', null, dots),
|
|
1958
2638
|
h('g', null, leftLabels),
|
|
1959
2639
|
h('g', null, rightLabels),
|
|
@@ -1990,6 +2670,8 @@ window.__ModuleLoader__.load({
|
|
|
1990
2670
|
const s8 = React.useState(null)
|
|
1991
2671
|
const allStats = s8[0]
|
|
1992
2672
|
const setAllStats = s8[1]
|
|
2673
|
+
// 「模型」下拉的状态放在 store 里(弹层与设置页共享同一个选择)。
|
|
2674
|
+
const modelFilter = store.state.modelFilter
|
|
1993
2675
|
const s9 = React.useState(0)
|
|
1994
2676
|
const setStoreTick = s9[1]
|
|
1995
2677
|
|
|
@@ -2002,6 +2684,10 @@ window.__ModuleLoader__.load({
|
|
|
2002
2684
|
store.ensureBalance(false)
|
|
2003
2685
|
return everyTick(60000, function () { store.ensureBalance(false) })
|
|
2004
2686
|
}, [])
|
|
2687
|
+
// 非人民币显示时才需要汇率(Host 端 6h 缓存,切币种时 setCurrency 已经会取一次)。
|
|
2688
|
+
React.useEffect(function () {
|
|
2689
|
+
if (store.state.currency !== 'CNY') store.ensureRates(false)
|
|
2690
|
+
}, [])
|
|
2005
2691
|
|
|
2006
2692
|
// 工作区签名:id + 标题 + 归属会话(WorkspaceView.sessionIds 即归属账本)。
|
|
2007
2693
|
const wsSig = props && typeof props.useWorkspaces === 'function'
|
|
@@ -2036,6 +2722,7 @@ window.__ModuleLoader__.load({
|
|
|
2036
2722
|
from: range.from,
|
|
2037
2723
|
to: range.to,
|
|
2038
2724
|
wid: wid,
|
|
2725
|
+
model: modelFilter,
|
|
2039
2726
|
granularity: range.granularity,
|
|
2040
2727
|
prices: PRICES,
|
|
2041
2728
|
holidays: CN_FIXED_HOLIDAYS,
|
|
@@ -2051,7 +2738,7 @@ window.__ModuleLoader__.load({
|
|
|
2051
2738
|
setStats(fallback)
|
|
2052
2739
|
})
|
|
2053
2740
|
return undefined
|
|
2054
|
-
}, [records, rangeKey, wid, calFrom, calTo, wsSig])
|
|
2741
|
+
}, [records, rangeKey, wid, calFrom, calTo, wsSig, modelFilter])
|
|
2055
2742
|
|
|
2056
2743
|
// 顶部“累计”卡片:全部历史合计(不受筛选影响),只算合计不铺时间轴。
|
|
2057
2744
|
React.useEffect(function () {
|
|
@@ -2084,8 +2771,32 @@ window.__ModuleLoader__.load({
|
|
|
2084
2771
|
}, [records, wsSig])
|
|
2085
2772
|
|
|
2086
2773
|
const st = store.state
|
|
2774
|
+
// 显示币种 + 汇率提示(报价表恒为人民币,这里只是展示换算)。
|
|
2775
|
+
const currency = typeof st.currency === 'string' ? st.currency : 'CNY'
|
|
2776
|
+
const rates = st.rates
|
|
2777
|
+
const rateValue = rates && rates.rates && typeof rates.rates[currency] === 'number' ? rates.rates[currency] : null
|
|
2778
|
+
let rateNote = tt('金额按人民币报价折算显示')
|
|
2779
|
+
if (currency === 'CNY') {
|
|
2780
|
+
rateNote = tt('当前以人民币显示(可切换成其它币种)')
|
|
2781
|
+
} else if (rateValue !== null) {
|
|
2782
|
+
const when = rates && rates.fetchedAt ? new Date(rates.fetchedAt) : null
|
|
2783
|
+
const stamp = when !== null && !Number.isNaN(when.getTime())
|
|
2784
|
+
? when.toLocaleString(localeTag())
|
|
2785
|
+
: ''
|
|
2786
|
+
rateNote = tt('1 CNY = {rate} {code}', { rate: String(Number(rateValue.toFixed(6))), code: currency })
|
|
2787
|
+
+ ' · ' + tt('汇率来源 {source}', { source: rates.stale === true ? tt('内置兜底汇率') : String(rates.source || '') })
|
|
2788
|
+
+ (stamp ? ' · ' + stamp : '')
|
|
2789
|
+
+ (rates.stale === true ? ' · ' + tt('联网失败时使用内置汇率') : '')
|
|
2790
|
+
} else if (st.ratesState === 'loading') {
|
|
2791
|
+
rateNote = tt('正在获取汇率…')
|
|
2792
|
+
} else if (st.ratesState === 'error') {
|
|
2793
|
+
rateNote = tt('汇率获取失败,暂时按人民币显示:{msg}', { msg: String(st.ratesError || '') })
|
|
2794
|
+
} else {
|
|
2795
|
+
rateNote = tt('金额按人民币报价折算显示')
|
|
2796
|
+
}
|
|
2087
2797
|
const accountModel = st.account && typeof st.account.model === 'string' && st.account.model ? st.account.model : DEFAULT_MODEL
|
|
2088
|
-
|
|
2798
|
+
// 「模型」下拉选了具体模型时,它同时是计价兜底(日志里查不到模型时用它)。
|
|
2799
|
+
const accountPrice = priceFor(modelFilter || accountModel)
|
|
2089
2800
|
const nowPeak = isPeakAt(Date.now())
|
|
2090
2801
|
const balance = st.balance
|
|
2091
2802
|
const balanceLoading = st.balanceState === 'loading'
|
|
@@ -2105,17 +2816,25 @@ window.__ModuleLoader__.load({
|
|
|
2105
2816
|
balanceHint = tt('请前往 DeepSeek 开放平台充值')
|
|
2106
2817
|
balanceClass = 'dcb-card-value dcb-bad'
|
|
2107
2818
|
} else if (balance) {
|
|
2108
|
-
|
|
2109
|
-
balanceValue = symbol + fmtYuan(balance.total)
|
|
2819
|
+
balanceValue = moneyBalance(balance.total, balance.currency)
|
|
2110
2820
|
const parts = []
|
|
2111
|
-
if (typeof balance.granted === 'number') parts.push(tt('赠送
|
|
2112
|
-
if (typeof balance.toppedUp === 'number') parts.push(tt('充值
|
|
2821
|
+
if (typeof balance.granted === 'number') parts.push(tt('赠送 {amount}', { amount: moneyBalance(balance.granted, balance.currency) }))
|
|
2822
|
+
if (typeof balance.toppedUp === 'number') parts.push(tt('充值 {amount}', { amount: moneyBalance(balance.toppedUp, balance.currency) }))
|
|
2113
2823
|
balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
|
|
2114
2824
|
}
|
|
2115
2825
|
|
|
2116
2826
|
const index = buildWorkspaceIndex(wsSig)
|
|
2117
2827
|
const points = stats ? stats.points : []
|
|
2118
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
|
+
}
|
|
2119
2838
|
const rangeOptions = [
|
|
2120
2839
|
{ id: 'today', label: tt('今天') },
|
|
2121
2840
|
{ id: 'week', label: tt('近一周') },
|
|
@@ -2138,8 +2857,8 @@ window.__ModuleLoader__.load({
|
|
|
2138
2857
|
}),
|
|
2139
2858
|
h(Card, {
|
|
2140
2859
|
label: tt('估算金额'),
|
|
2141
|
-
value: allStats ?
|
|
2142
|
-
hint: (accountPrice.known ?
|
|
2860
|
+
value: allStats ? moneyApprox(allStats.totalCost) : '—',
|
|
2861
|
+
hint: (accountPrice.known ? modelDetail(modelFilter || accountModel) : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
|
|
2143
2862
|
}),
|
|
2144
2863
|
h(Card, {
|
|
2145
2864
|
label: tt('当前时段'),
|
|
@@ -2197,10 +2916,41 @@ window.__ModuleLoader__.load({
|
|
|
2197
2916
|
: null)),
|
|
2198
2917
|
h('div', { className: 'dcb-field' },
|
|
2199
2918
|
h('span', { className: 'dcb-field-label' }, tt('数据')),
|
|
2200
|
-
h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新')))
|
|
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))),
|
|
2931
|
+
// 显示币种:报价表永远是人民币,这里只决定展示时换算成什么货币。
|
|
2932
|
+
h('div', { className: 'dcb-field' },
|
|
2933
|
+
h('label', { className: 'dcb-field-label', htmlFor: 'dcb-currency-select' }, tt('货币')),
|
|
2934
|
+
h('select', {
|
|
2935
|
+
id: 'dcb-currency-select',
|
|
2936
|
+
className: 'dcb-select',
|
|
2937
|
+
value: currency,
|
|
2938
|
+
onChange: function (event) { store.setCurrency(event.target.value) },
|
|
2939
|
+
},
|
|
2940
|
+
CURRENCIES.map(function (c) {
|
|
2941
|
+
return h('option', { key: c.code, value: c.code }, c.code + ' ' + c.symbol.trim() + ' · ' + currencyName(c.code))
|
|
2942
|
+
})))),
|
|
2943
|
+
h('div', { className: 'dcb-set-note dcb-rate-note' }, rateNote),
|
|
2201
2944
|
h('div', { className: 'dcb-set-body' },
|
|
2202
2945
|
h('div', { className: 'dcb-hist-legend' },
|
|
2203
|
-
|
|
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('估算金额(右轴)')),
|
|
2204
2954
|
h('button', {
|
|
2205
2955
|
type: 'button',
|
|
2206
2956
|
className: showTokens ? 'dcb-tog is-on' : 'dcb-tog',
|
|
@@ -2211,11 +2961,11 @@ window.__ModuleLoader__.load({
|
|
|
2211
2961
|
: h('div', { className: 'dcb-hist-empty' }, tt('该范围内暂无消耗记录'))),
|
|
2212
2962
|
hasData
|
|
2213
2963
|
? h('div', { className: 'dcb-sum' },
|
|
2214
|
-
h('span', null, tt('总消耗 '), h('b', null,
|
|
2964
|
+
h('span', null, tt('总消耗 '), h('b', null, moneyApprox(stats.totalCost))),
|
|
2215
2965
|
h('span', null, tt('总 Token '), h('b', null, fmtTokens(stats.totalTokens))),
|
|
2216
2966
|
h('span', null, tt('记录 '), h('b', null, String(stats.count)), tt(' 条')),
|
|
2217
|
-
h('span', null, tt('峰时 '), h('b', null,
|
|
2218
|
-
h('span', null, tt('谷时 '), h('b', null,
|
|
2967
|
+
h('span', null, tt('峰时 '), h('b', null, moneyApprox(stats.peakCost))),
|
|
2968
|
+
h('span', null, tt('谷时 '), h('b', null, moneyApprox(stats.valleyCost))),
|
|
2219
2969
|
h('span', null, tt('粒度 '), h('b', null, tt(stats.granularity === 'month' ? '按月' : (stats.granularity === 'hour' ? '按小时' : '按日')))))
|
|
2220
2970
|
: null,
|
|
2221
2971
|
h('div', { className: 'dcb-hist-aside' },
|
|
@@ -2620,6 +3370,7 @@ window.__ModuleLoader__.load({
|
|
|
2620
3370
|
.dcb-pill:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
|
|
2621
3371
|
.dcb-pill-glyph{font-size:11px;line-height:1}
|
|
2622
3372
|
.dcb-pill-sep{opacity:.35}
|
|
3373
|
+
.dcb-pill-balance{font-variant-numeric:tabular-nums;max-width:160px;overflow:hidden;text-overflow:ellipsis}
|
|
2623
3374
|
.dcb-tag{font-weight:600}
|
|
2624
3375
|
.dcb-tag.is-peak{color:var(--dsw-alias-state-error-primary)}
|
|
2625
3376
|
.dcb-tag.is-valley{color:var(--dsw-alias-state-success-primary)}
|
|
@@ -2660,6 +3411,14 @@ window.__ModuleLoader__.load({
|
|
|
2660
3411
|
.dcb-legend-item{display:inline-flex;align-items:center;gap:6px}
|
|
2661
3412
|
.dcb-legend-dot{display:inline-block;width:14px;height:3px;border-radius:2px;background:var(--dsw-alias-brand-primary)}
|
|
2662
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}
|
|
2663
3422
|
.dcb-chart-body{position:relative}
|
|
2664
3423
|
.dcb-chart-svg{display:block;width:100%;height:216px}
|
|
2665
3424
|
.dcb-grid-line{stroke:var(--dsw-alias-border-l1);stroke-width:1;opacity:.7;vector-effect:non-scaling-stroke}
|
|
@@ -2690,6 +3449,10 @@ window.__ModuleLoader__.load({
|
|
|
2690
3449
|
.dcb-field-label{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
2691
3450
|
.dcb-select,.dcb-date{height:28px;padding:0 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;min-width:0}
|
|
2692
3451
|
.dcb-select{min-width:132px;cursor:pointer}
|
|
3452
|
+
/* 币种下拉要放「代码 + 符号 + 全名」,给它多一点宽度 */
|
|
3453
|
+
#dcb-currency-select{min-width:196px;max-width:300px}
|
|
3454
|
+
/* 汇率说明行会显示汇率 / 来源 / 时间戳,允许换行 */
|
|
3455
|
+
.dcb-rate-note{white-space:normal;line-height:16px;margin:-6px 0 12px}
|
|
2693
3456
|
.dcb-date{min-width:126px}
|
|
2694
3457
|
.dcb-select:focus,.dcb-date:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
|
2695
3458
|
.dcb-range{display:flex;align-items:center;gap:6px}
|