@goodandready/dsh-cost-meter 0.8.0 → 0.8.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 +25 -2
- package/README.ru.md +156 -0
- package/README.zh.md +144 -0
- package/docs/README.ru.md +23 -1
- package/docs/README.zh.md +17 -1
- package/docs/design/DESIGN.md +15 -0
- package/docs/plans/findings.md +11 -11
- package/docs/plans/progress.md +11 -24
- package/docs/plans/task_plan.md +29 -23
- package/lib/client.js +415 -141
- package/lib/index.js +128 -53
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-cost-meter — client half.
|
|
2
2
|
// Self-registering browser module for DeepSeek Harness.
|
|
3
|
-
// Unified design system aligned with @goodandready/dsh-clinebot.
|
|
3
|
+
// Unified design system aligned with @goodandready/dsh-clinebot and DSH tokens.
|
|
4
|
+
// Canonical languages: English (en) and Chinese (zh).
|
|
4
5
|
|
|
5
6
|
window.__ModuleLoader__.load({
|
|
6
7
|
id: '@goodandready/dsh-cost-meter',
|
|
@@ -11,54 +12,11 @@ window.__ModuleLoader__.load({
|
|
|
11
12
|
|
|
12
13
|
const NS = 'dsh-cost-meter'
|
|
13
14
|
const STATE_PATH = '/dsh-cost-meter/state'
|
|
15
|
+
const REFRESH_PATH = '/dsh-cost-meter/refresh'
|
|
14
16
|
const DAY = 1440
|
|
15
17
|
|
|
16
18
|
// ----------------------------------------------------------- localization
|
|
17
19
|
|
|
18
|
-
const ru = {
|
|
19
|
-
title: 'Учёт расходов',
|
|
20
|
-
subtitle: 'Тариф моделей, окна пиковых цен и стоимость сессии',
|
|
21
|
-
'header.title': 'Учёт расходов (Cost Meter)',
|
|
22
|
-
'header.sub': 'Мониторинг расходов токенов, автоматическое сопоставление тарифов и скидочных окон',
|
|
23
|
-
'badge.catalog_ok': 'Каталог: {count} моделей',
|
|
24
|
-
'badge.catalog_empty': 'Каталог: загрузка…',
|
|
25
|
-
'badge.catalog_off': 'Каталог: выключен',
|
|
26
|
-
'badge.catalog_err': 'Каталог: ошибка',
|
|
27
|
-
'badge.deepseek': 'Тариф DeepSeek: активен',
|
|
28
|
-
'section.general': 'Основные параметры',
|
|
29
|
-
'section.general_desc': 'Отображение валюты, курса пересчёта и часового пояса в интерфейсе',
|
|
30
|
-
'section.openrouter': 'Каталог цен OpenRouter',
|
|
31
|
-
'section.openrouter_desc': 'Публичный каталог моделей для автоматического определения ставок и окон скидок',
|
|
32
|
-
'section.diagnostics': 'Диагностика и статус',
|
|
33
|
-
'section.diagnostics_desc': 'Текущее состояние фоновой синхронизации и кэша тарифов',
|
|
34
|
-
'diag.models_count': 'Моделей в каталоге',
|
|
35
|
-
'diag.last_sync': 'Последняя синхронизация',
|
|
36
|
-
'diag.route': 'Активный маршрут',
|
|
37
|
-
'diag.tariff_source': 'Источник тарифа',
|
|
38
|
-
'field.currency': 'Символ валюты',
|
|
39
|
-
'hint.currency': 'Символ валюты для отображения расходов (например, $, ₽, €).',
|
|
40
|
-
'field.usdRate': 'Курс валюты к USD',
|
|
41
|
-
'hint.usdRate': 'Количество единиц вашей валюты за 1 USD (1 для долларов).',
|
|
42
|
-
'field.displayTimeZone': 'Временная зона (IANA)',
|
|
43
|
-
'hint.displayTimeZone': 'Временная зона для отображения пиковых окон (например, Europe/Moscow, UTC).',
|
|
44
|
-
'field.useOpenRouter': 'Использовать каталог OpenRouter',
|
|
45
|
-
'hint.useOpenRouter': 'Автоматически загружать ставки и скидочные окна для моделей через OpenRouter API.',
|
|
46
|
-
'field.refreshHours': 'Период обновления каталога (часов)',
|
|
47
|
-
'hint.refreshHours': 'Интервал фонового обновления каталога в часах (от 1 до 168).',
|
|
48
|
-
'settings.save': 'Сохранить настройки',
|
|
49
|
-
'settings.saving': 'Сохранение…',
|
|
50
|
-
'settings.saved': 'Настройки успешно сохранены',
|
|
51
|
-
'settings.saveFailed': 'Ошибка сохранения: ',
|
|
52
|
-
'settings.loading': 'Загрузка настроек…',
|
|
53
|
-
'settings.unavailable': 'Настройки временно недоступны',
|
|
54
|
-
'settings.retry': 'Повторить попытку',
|
|
55
|
-
chipUnknown: 'Тариф не определён',
|
|
56
|
-
routeUnknown: 'Маршрут не определён',
|
|
57
|
-
catalogUnavailable: 'Каталог недоступен: ',
|
|
58
|
-
peak: 'пик',
|
|
59
|
-
offpeak: 'не-пик',
|
|
60
|
-
}
|
|
61
|
-
|
|
62
20
|
const en = {
|
|
63
21
|
title: 'Cost Meter',
|
|
64
22
|
subtitle: 'Model rates, peak discount windows and session spend',
|
|
@@ -68,9 +26,10 @@ window.__ModuleLoader__.load({
|
|
|
68
26
|
'badge.catalog_empty': 'Catalog: loading…',
|
|
69
27
|
'badge.catalog_off': 'Catalog: disabled',
|
|
70
28
|
'badge.catalog_err': 'Catalog: error',
|
|
29
|
+
'badge.catalog_stale': 'Cache: offline',
|
|
71
30
|
'badge.deepseek': 'DeepSeek tariff: active',
|
|
72
31
|
'section.general': 'General Parameters',
|
|
73
|
-
'section.general_desc': 'Display currency, conversion rate and time zone configuration',
|
|
32
|
+
'section.general_desc': 'Display currency, conversion rate, budget threshold and time zone configuration',
|
|
74
33
|
'section.openrouter': 'OpenRouter Pricing Catalog',
|
|
75
34
|
'section.openrouter_desc': 'Public catalog for dynamic model pricing and discount windows',
|
|
76
35
|
'section.diagnostics': 'Diagnostics & Status',
|
|
@@ -80,15 +39,29 @@ window.__ModuleLoader__.load({
|
|
|
80
39
|
'diag.route': 'Active route',
|
|
81
40
|
'diag.tariff_source': 'Tariff source',
|
|
82
41
|
'field.currency': 'Currency Symbol',
|
|
83
|
-
'hint.currency': 'Currency symbol shown in the UI (e.g. $,
|
|
42
|
+
'hint.currency': 'Currency symbol shown in the UI (e.g. $, ¥, €).',
|
|
84
43
|
'field.usdRate': 'USD Exchange Rate',
|
|
85
44
|
'hint.usdRate': 'Units of currency per 1 USD (1 keeps USD).',
|
|
45
|
+
'field.budgetThreshold': 'Session Budget Threshold (USD)',
|
|
46
|
+
'hint.budgetThreshold': 'Warn via chip color when session spend exceeds this amount (0 disables).',
|
|
86
47
|
'field.displayTimeZone': 'Time Zone (IANA)',
|
|
87
|
-
'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, UTC).',
|
|
48
|
+
'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, Asia/Shanghai, UTC).',
|
|
88
49
|
'field.useOpenRouter': 'Use OpenRouter Catalog',
|
|
89
50
|
'hint.useOpenRouter': 'Pull rates and discount windows dynamically from public OpenRouter API.',
|
|
90
51
|
'field.refreshHours': 'Catalog Refresh Interval (hours)',
|
|
91
52
|
'hint.refreshHours': 'Background catalog fetch interval in hours (1 to 168).',
|
|
53
|
+
'action.sync_now': 'Sync Now',
|
|
54
|
+
'action.syncing': 'Syncing…',
|
|
55
|
+
'action.sync_ok': 'Catalog updated',
|
|
56
|
+
'action.sync_err': 'Sync failed',
|
|
57
|
+
'action.sync_rate_limited': 'Wait {seconds}s',
|
|
58
|
+
'action.copy_report': 'Copy Summary',
|
|
59
|
+
'action.copied': 'Copied!',
|
|
60
|
+
'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
|
|
61
|
+
'savings.cache_saved': '⚡ Context cache saved: ≈ {amount} ({percent}%)',
|
|
62
|
+
'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
|
|
63
|
+
'models.show_more': 'Show all {count} models',
|
|
64
|
+
'models.show_less': 'Show fewer',
|
|
92
65
|
'settings.save': 'Save Settings',
|
|
93
66
|
'settings.saving': 'Saving…',
|
|
94
67
|
'settings.saved': 'Settings saved successfully',
|
|
@@ -101,6 +74,89 @@ window.__ModuleLoader__.load({
|
|
|
101
74
|
catalogUnavailable: 'Catalog unavailable: ',
|
|
102
75
|
peak: 'peak',
|
|
103
76
|
offpeak: 'off-peak',
|
|
77
|
+
'rate.per_1m': '1M tokens, $',
|
|
78
|
+
'rate.cache_hit': 'Input (cache hit)',
|
|
79
|
+
'rate.input': 'Input (cache miss)',
|
|
80
|
+
'rate.cache_write': 'Input (cache write)',
|
|
81
|
+
'rate.output': 'Output',
|
|
82
|
+
'tokens.session': 'Session Tokens',
|
|
83
|
+
'tokens.tokens_col': 'Tokens',
|
|
84
|
+
'tokens.models_spent': 'Spend by Model',
|
|
85
|
+
'tokens.session_total': 'Session total:',
|
|
86
|
+
'tokens.no_usage_yet': 'Provider has not reported usage for this session yet.',
|
|
87
|
+
'tokens.not_priced': 'Not priced',
|
|
88
|
+
'tz.invalid': 'Invalid IANA time zone identifier',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const zh = {
|
|
92
|
+
title: '成本计量器',
|
|
93
|
+
subtitle: '模型费率、高峰优惠窗口与会话消费统计',
|
|
94
|
+
'header.title': '成本计量器 (Cost Meter)',
|
|
95
|
+
'header.sub': 'Token 消费监控、费率自动匹配与折扣窗口管理',
|
|
96
|
+
'badge.catalog_ok': '目录:{count} 个模型',
|
|
97
|
+
'badge.catalog_empty': '目录:加载中…',
|
|
98
|
+
'badge.catalog_off': '目录:已禁用',
|
|
99
|
+
'badge.catalog_err': '目录:错误',
|
|
100
|
+
'badge.catalog_stale': '缓存:离线',
|
|
101
|
+
'badge.deepseek': 'DeepSeek 费率:生效中',
|
|
102
|
+
'section.general': '基础参数',
|
|
103
|
+
'section.general_desc': '显示货币、汇率转换、预算阈值与时区设置',
|
|
104
|
+
'section.openrouter': 'OpenRouter 定价目录',
|
|
105
|
+
'section.openrouter_desc': '用于自动获取模型价格与折扣窗口的公开价格目录',
|
|
106
|
+
'section.diagnostics': '诊断与状态',
|
|
107
|
+
'section.diagnostics_desc': '后台同步与费率缓存当前状态',
|
|
108
|
+
'diag.models_count': '目录中模型数',
|
|
109
|
+
'diag.last_sync': '上次同步',
|
|
110
|
+
'diag.route': '当前路由',
|
|
111
|
+
'diag.tariff_source': '费率来源',
|
|
112
|
+
'field.currency': '货币符号',
|
|
113
|
+
'hint.currency': '界面中显示的货币符号(例如:$、¥、€)。',
|
|
114
|
+
'field.usdRate': 'USD 汇率',
|
|
115
|
+
'hint.usdRate': '每 1 美元对应的本币金额(1 为美元)。',
|
|
116
|
+
'field.budgetThreshold': '会话预算阈值 (USD)',
|
|
117
|
+
'hint.budgetThreshold': '当会话消费超出该金额时在状态指示器报警(0 表示禁用)。',
|
|
118
|
+
'field.displayTimeZone': '时区 (IANA)',
|
|
119
|
+
'hint.displayTimeZone': '显示高峰时段的 IANA 时区(例如:Asia/Shanghai、UTC)。',
|
|
120
|
+
'field.useOpenRouter': '使用 OpenRouter 目录',
|
|
121
|
+
'hint.useOpenRouter': '通过公开的 OpenRouter API 自动拉取费率与折扣窗口。',
|
|
122
|
+
'field.refreshHours': '目录刷新周期 (小时)',
|
|
123
|
+
'hint.refreshHours': '后台刷新价格目录的时间间隔(1 至 168 小时)。',
|
|
124
|
+
'action.sync_now': '立即同步',
|
|
125
|
+
'action.syncing': '同步中…',
|
|
126
|
+
'action.sync_ok': '目录已更新',
|
|
127
|
+
'action.sync_err': '同步失败',
|
|
128
|
+
'action.sync_rate_limited': '请等待 {seconds} 秒',
|
|
129
|
+
'action.copy_report': '复制消费报告',
|
|
130
|
+
'action.copied': '已复制!',
|
|
131
|
+
'savings.hint': '💡 5 折优惠将在 {time} 后生效 ({clock} {zone})',
|
|
132
|
+
'savings.cache_saved': '⚡ 上下文缓存节省:≈ {amount} ({percent}%)',
|
|
133
|
+
'budget.exceeded': '⚠️ 已超出预算上限:{limit}',
|
|
134
|
+
'models.show_more': '查看全部 {count} 个模型',
|
|
135
|
+
'models.show_less': '收起',
|
|
136
|
+
'settings.save': '保存设置',
|
|
137
|
+
'settings.saving': '保存中…',
|
|
138
|
+
'settings.saved': '设置已成功保存',
|
|
139
|
+
'settings.saveFailed': '保存设置失败:',
|
|
140
|
+
'settings.loading': '加载设置中…',
|
|
141
|
+
'settings.unavailable': '设置暂时不可用',
|
|
142
|
+
'settings.retry': '重试',
|
|
143
|
+
chipUnknown: '费率未知',
|
|
144
|
+
routeUnknown: '路由未知',
|
|
145
|
+
catalogUnavailable: '价格目录不可用:',
|
|
146
|
+
peak: '高峰',
|
|
147
|
+
offpeak: '低谷',
|
|
148
|
+
'rate.per_1m': '每 1M tokens ($)',
|
|
149
|
+
'rate.cache_hit': '输入 (命中缓存)',
|
|
150
|
+
'rate.input': '输入 (未命中)',
|
|
151
|
+
'rate.cache_write': '输入 (写入缓存)',
|
|
152
|
+
'rate.output': '输出',
|
|
153
|
+
'tokens.session': '会话 Tokens',
|
|
154
|
+
'tokens.tokens_col': 'Tokens 数',
|
|
155
|
+
'tokens.models_spent': '各模型消费',
|
|
156
|
+
'tokens.session_total': '会话总计:',
|
|
157
|
+
'tokens.no_usage_yet': '提供商尚未报告当前会话的用量数据。',
|
|
158
|
+
'tokens.not_priced': '未定价',
|
|
159
|
+
'tz.invalid': '无效的 IANA 时区标识',
|
|
104
160
|
}
|
|
105
161
|
|
|
106
162
|
function makeT(dict, fallback) {
|
|
@@ -119,8 +175,12 @@ window.__ModuleLoader__.load({
|
|
|
119
175
|
const localeService = (ctx && typeof ctx.get === 'function' ? ctx.get('locale') : null) || (ctx && ctx.locale) || (ctx && ctx.services && ctx.services.locale)
|
|
120
176
|
return React.useSyncExternalStore(
|
|
121
177
|
React.useMemo(() => (cb) => (localeService && typeof localeService.subscribe === 'function' ? localeService.subscribe(cb) : () => {}), [localeService]),
|
|
122
|
-
React.useCallback(() =>
|
|
123
|
-
|
|
178
|
+
React.useCallback(() => {
|
|
179
|
+
const snap = localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot() : null
|
|
180
|
+
const active = snap ? (snap.active || snap.locale || 'en') : 'en'
|
|
181
|
+
return String(active).startsWith('zh') ? 'zh' : 'en'
|
|
182
|
+
}, [localeService]),
|
|
183
|
+
React.useCallback(() => 'en', []),
|
|
124
184
|
)
|
|
125
185
|
}
|
|
126
186
|
|
|
@@ -159,11 +219,13 @@ window.__ModuleLoader__.load({
|
|
|
159
219
|
.dcm-btn-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
|
|
160
220
|
.dcm-btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
|
161
221
|
.dcm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
222
|
+
.dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
|
|
162
223
|
|
|
163
224
|
.dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
|
|
164
225
|
.dcm-alert-ok { background: rgba(16,185,129,0.1); color: var(--dsw-alias-state-success-primary); }
|
|
165
226
|
.dcm-alert-err { background: rgba(239,68,68,0.1); color: var(--dsw-alias-state-error-primary); }
|
|
166
|
-
.dcm-
|
|
227
|
+
.dcm-tip-box { padding: 8px 12px; border-radius: 8px; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
228
|
+
.dcm-cache-box { padding: 7px 11px; border-radius: 8px; background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.22); color: var(--dsw-alias-state-success-primary); font-size: 11.5px; font-weight: 500; line-height: 1.4; font-variant-numeric: tabular-nums; }
|
|
167
229
|
|
|
168
230
|
.dcm-stat-box { padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); display: flex; flex-direction: column; gap: 2px; }
|
|
169
231
|
.dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
|
|
@@ -171,24 +233,39 @@ window.__ModuleLoader__.load({
|
|
|
171
233
|
|
|
172
234
|
.dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
|
|
173
235
|
|
|
174
|
-
|
|
175
|
-
.dcm-chip
|
|
236
|
+
/* Native dot-indicator chip */
|
|
237
|
+
.dcm-chip { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.05)); color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; line-height: 24px; transition: all .15s ease; user-select: none; }
|
|
238
|
+
.dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,0.08)); }
|
|
239
|
+
.dcm-dot { width: 6.5px; height: 6.5px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
|
240
|
+
.dcm-dot-ok { background: var(--dsw-alias-state-success-primary, #10b981); box-shadow: 0 0 5px rgba(16,185,129,0.5); }
|
|
241
|
+
.dcm-dot-warn { background: var(--dsw-alias-state-warning-primary, #f59e0b); box-shadow: 0 0 5px rgba(245,158,11,0.4); }
|
|
242
|
+
.dcm-dot-err { background: var(--dsw-alias-state-error-primary, #ef4444); box-shadow: 0 0 6px rgba(239,68,68,0.7); animation: dcm-pulse 1.8s infinite ease-in-out; }
|
|
243
|
+
.dcm-dot-idle { background: var(--dsw-alias-label-tertiary, #9ca3af); }
|
|
244
|
+
|
|
245
|
+
.dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
|
|
246
|
+
@media (max-width: 680px) {
|
|
247
|
+
.dcm-chip-timer { display: none; }
|
|
248
|
+
}
|
|
176
249
|
|
|
177
|
-
|
|
178
|
-
|
|
250
|
+
@keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
|
|
251
|
+
|
|
252
|
+
.dcm-panel { position: absolute; top: 30px; right: 0; z-index: 40; width: 336px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); box-shadow: 0 10px 32px rgba(0,0,0,.32); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; font-variant-numeric: tabular-nums; }
|
|
253
|
+
.dcm-panel-title { font-weight: 600; padding-bottom: 2px; font-size: 13.5px; display: flex; justify-content: space-between; align-items: baseline; }
|
|
179
254
|
.dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
|
|
180
255
|
.dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
|
|
181
|
-
.dcm-panel-grid { display: grid; grid-template-columns: 1fr
|
|
182
|
-
.dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr
|
|
183
|
-
.dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr
|
|
256
|
+
.dcm-panel-grid { display: grid; grid-template-columns: 1fr 62px 62px; column-gap: 6px; align-items: center; }
|
|
257
|
+
.dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr 76px; column-gap: 6px; align-items: center; }
|
|
258
|
+
.dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr 60px 74px; column-gap: 6px; align-items: center; }
|
|
184
259
|
.dcm-th { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; }
|
|
185
260
|
.dcm-th-num { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; text-align: right; font-size: 11px; }
|
|
186
|
-
.dcm-td { padding:
|
|
261
|
+
.dcm-td { padding: 3.5px 0; color: var(--dsw-alias-label-secondary); }
|
|
187
262
|
.dcm-cell-idle { text-align: right; padding: 3px 6px; border-radius: 4px; color: var(--dsw-alias-label-tertiary); }
|
|
188
|
-
.dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding:
|
|
189
|
-
.dcm-cost { text-align: right; padding:
|
|
263
|
+
.dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding: 3.5px 0; }
|
|
264
|
+
.dcm-cost { text-align: right; padding: 3.5px 0; font-weight: 500; color: var(--dsw-alias-label-primary); }
|
|
190
265
|
.dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
191
|
-
.dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size:
|
|
266
|
+
.dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
|
|
267
|
+
.dcm-accordion-btn { appearance: none; background: transparent; border: 0; color: var(--dsw-alias-state-brand-primary, #6366f1); font: inherit; font-size: 11.5px; cursor: pointer; padding: 2px 0; text-align: left; }
|
|
268
|
+
.dcm-accordion-btn:hover { text-decoration: underline; }
|
|
192
269
|
`
|
|
193
270
|
|
|
194
271
|
function ensureStyles() {
|
|
@@ -296,13 +373,14 @@ window.__ModuleLoader__.load({
|
|
|
296
373
|
}
|
|
297
374
|
}
|
|
298
375
|
|
|
299
|
-
function ago(timestamp) {
|
|
300
|
-
if (!timestamp) return '
|
|
376
|
+
function ago(timestamp, locale) {
|
|
377
|
+
if (!timestamp) return '—'
|
|
378
|
+
const isZh = locale === 'zh'
|
|
301
379
|
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
|
|
302
|
-
if (minutes < 1) return '
|
|
303
|
-
if (minutes < 60) return minutes + '
|
|
380
|
+
if (minutes < 1) return isZh ? '刚刚' : 'just now'
|
|
381
|
+
if (minutes < 60) return minutes + (isZh ? ' 分钟前' : ' min ago')
|
|
304
382
|
const hours = Math.round(minutes / 60)
|
|
305
|
-
return hours < 48 ? hours + '
|
|
383
|
+
return hours < 48 ? hours + (isZh ? ' 小时前' : ' hrs ago') : Math.round(hours / 24) + (isZh ? ' 天前' : ' days ago')
|
|
306
384
|
}
|
|
307
385
|
|
|
308
386
|
// ---------------------------------------------------------------- card
|
|
@@ -310,7 +388,7 @@ window.__ModuleLoader__.load({
|
|
|
310
388
|
function CostMeterCard(props) {
|
|
311
389
|
const ctx = props.ctx
|
|
312
390
|
const locale = useActiveLocale(ctx)
|
|
313
|
-
const t = props.t || makeT(locale === '
|
|
391
|
+
const t = props.t || makeT(locale === 'zh' ? zh : en, en)
|
|
314
392
|
const [open, setOpen] = React.useState(false)
|
|
315
393
|
const [draft, setDraft] = React.useState(null)
|
|
316
394
|
const [saving, setSaving] = React.useState(false)
|
|
@@ -318,6 +396,10 @@ window.__ModuleLoader__.load({
|
|
|
318
396
|
const [saved, setSaved] = React.useState(false)
|
|
319
397
|
const [diagState, setDiagState] = React.useState(null)
|
|
320
398
|
|
|
399
|
+
// Manual sync button state
|
|
400
|
+
const [syncing, setSyncing] = React.useState(false)
|
|
401
|
+
const [syncMsg, setSyncMsg] = React.useState('')
|
|
402
|
+
|
|
321
403
|
const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
|
|
322
404
|
const scope = React.useMemo(
|
|
323
405
|
() => (settingsScope ? settingsScope.bind({ namespace: NS }) : undefined),
|
|
@@ -332,14 +414,17 @@ window.__ModuleLoader__.load({
|
|
|
332
414
|
|
|
333
415
|
React.useEffect(() => { ensureStyles() }, [])
|
|
334
416
|
|
|
335
|
-
|
|
336
|
-
React.useEffect(() => {
|
|
337
|
-
if (!open) return
|
|
417
|
+
const fetchDiag = React.useCallback(() => {
|
|
338
418
|
fetch(STATE_PATH, { headers: { accept: 'application/json' } })
|
|
339
419
|
.then((r) => (r.ok ? r.json() : null))
|
|
340
420
|
.then((data) => { if (data) setDiagState(data) })
|
|
341
421
|
.catch(() => {})
|
|
342
|
-
}, [
|
|
422
|
+
}, [])
|
|
423
|
+
|
|
424
|
+
React.useEffect(() => {
|
|
425
|
+
if (!open) return
|
|
426
|
+
fetchDiag()
|
|
427
|
+
}, [open, fetchDiag])
|
|
343
428
|
|
|
344
429
|
const status = (snapshot && snapshot.status) || 'loading'
|
|
345
430
|
const stored = (snapshot && snapshot.value) || {}
|
|
@@ -349,6 +434,7 @@ window.__ModuleLoader__.load({
|
|
|
349
434
|
setDraft({
|
|
350
435
|
currency: stored.currency !== undefined ? String(stored.currency) : '$',
|
|
351
436
|
usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
|
|
437
|
+
budgetThreshold: stored.budgetThreshold !== undefined ? String(stored.budgetThreshold) : '0',
|
|
352
438
|
displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
|
|
353
439
|
useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
|
|
354
440
|
refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
|
|
@@ -358,8 +444,33 @@ window.__ModuleLoader__.load({
|
|
|
358
444
|
|
|
359
445
|
const tzValid = draft ? isValidTimeZone(draft.displayTimeZone) : true
|
|
360
446
|
const rateValid = draft ? (!isNaN(Number(draft.usdRate)) && Number(draft.usdRate) > 0) : true
|
|
447
|
+
const budgetValid = draft ? (!isNaN(Number(draft.budgetThreshold)) && Number(draft.budgetThreshold) >= 0) : true
|
|
361
448
|
const hoursValid = draft ? (!isNaN(Number(draft.refreshHours)) && Number(draft.refreshHours) >= 1 && Number(draft.refreshHours) <= 168) : true
|
|
362
449
|
|
|
450
|
+
const handleManualSync = async () => {
|
|
451
|
+
if (syncing) return
|
|
452
|
+
setSyncing(true)
|
|
453
|
+
setSyncMsg('')
|
|
454
|
+
try {
|
|
455
|
+
const res = await fetch(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
|
|
456
|
+
if (res.status === 429) {
|
|
457
|
+
const data = await res.json().catch(() => ({}))
|
|
458
|
+
const retryAfter = data.retryAfter || 30
|
|
459
|
+
setSyncMsg(t('action.sync_rate_limited', { seconds: retryAfter }))
|
|
460
|
+
} else if (res.ok) {
|
|
461
|
+
setSyncMsg(t('action.sync_ok'))
|
|
462
|
+
fetchDiag()
|
|
463
|
+
} else {
|
|
464
|
+
setSyncMsg(t('action.sync_err'))
|
|
465
|
+
}
|
|
466
|
+
} catch (_) {
|
|
467
|
+
setSyncMsg(t('action.sync_err'))
|
|
468
|
+
} finally {
|
|
469
|
+
setSyncing(false)
|
|
470
|
+
setTimeout(() => setSyncMsg(''), 4000)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
363
474
|
const save = async () => {
|
|
364
475
|
if (!scope || !draft) return
|
|
365
476
|
setSaving(true); setErr(''); setSaved(false)
|
|
@@ -378,6 +489,15 @@ window.__ModuleLoader__.load({
|
|
|
378
489
|
} catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
|
|
379
490
|
}
|
|
380
491
|
|
|
492
|
+
const budgetNum = Number(String(draft.budgetThreshold).trim())
|
|
493
|
+
if (!Number.isFinite(budgetNum) || budgetNum < 0) {
|
|
494
|
+
broken.push('budgetThreshold: must be non-negative')
|
|
495
|
+
} else {
|
|
496
|
+
try {
|
|
497
|
+
await scope.set('budgetThreshold', budgetNum)
|
|
498
|
+
} catch (e) { broken.push('budgetThreshold: ' + (e && e.message || String(e))) }
|
|
499
|
+
}
|
|
500
|
+
|
|
381
501
|
const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
|
|
382
502
|
if (!isValidTimeZone(tzVal)) {
|
|
383
503
|
broken.push('displayTimeZone: invalid IANA time zone')
|
|
@@ -432,6 +552,7 @@ window.__ModuleLoader__.load({
|
|
|
432
552
|
? React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.catalog_ok', { count: modelsCount }))
|
|
433
553
|
: React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_empty'))))
|
|
434
554
|
: React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_off')),
|
|
555
|
+
catalogHasErr && modelsCount ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_stale')) : null,
|
|
435
556
|
draft && draft.displayTimeZone
|
|
436
557
|
? React.createElement('span', { className: 'dcm-badge' }, draft.displayTimeZone)
|
|
437
558
|
: null,
|
|
@@ -471,25 +592,48 @@ window.__ModuleLoader__.load({
|
|
|
471
592
|
React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
|
|
472
593
|
),
|
|
473
594
|
),
|
|
474
|
-
React.createElement('div', { className: 'dcm-
|
|
475
|
-
React.createElement('
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
595
|
+
React.createElement('div', { className: 'dcm-grid-2' },
|
|
596
|
+
React.createElement('div', { className: 'dcm-field' },
|
|
597
|
+
React.createElement('label', { className: 'dcm-label' }, t('field.budgetThreshold')),
|
|
598
|
+
React.createElement('input', {
|
|
599
|
+
className: 'dcm-input' + (budgetValid ? '' : ' dcm-input-err'),
|
|
600
|
+
type: 'number',
|
|
601
|
+
step: 'any',
|
|
602
|
+
min: 0,
|
|
603
|
+
value: (draft && draft.budgetThreshold) !== undefined ? draft.budgetThreshold : '',
|
|
604
|
+
onChange: (e) => setDraft({ ...draft, budgetThreshold: e.target.value }),
|
|
605
|
+
}),
|
|
606
|
+
React.createElement('span', { className: 'dcm-hint' }, t('hint.budgetThreshold')),
|
|
607
|
+
),
|
|
608
|
+
React.createElement('div', { className: 'dcm-field' },
|
|
609
|
+
React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
|
|
610
|
+
React.createElement('input', {
|
|
611
|
+
className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
|
|
612
|
+
value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
|
|
613
|
+
onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
|
|
614
|
+
}),
|
|
615
|
+
React.createElement('span', { className: 'dcm-hint' },
|
|
616
|
+
tzValid ? t('hint.displayTimeZone') : t('tz.invalid')
|
|
617
|
+
),
|
|
485
618
|
),
|
|
486
619
|
),
|
|
487
620
|
),
|
|
488
621
|
|
|
489
622
|
// Section 2: OpenRouter Pricing Catalog
|
|
490
623
|
React.createElement('div', { className: 'dcm-section-card' },
|
|
491
|
-
React.createElement('div', { className: 'dcm-section-title' },
|
|
624
|
+
React.createElement('div', { className: 'dcm-section-title' },
|
|
625
|
+
React.createElement('span', null, t('section.openrouter')),
|
|
626
|
+
openRouterEnabled
|
|
627
|
+
? React.createElement('button', {
|
|
628
|
+
type: 'button',
|
|
629
|
+
className: 'dcm-btn dcm-btn-sm',
|
|
630
|
+
disabled: syncing,
|
|
631
|
+
onClick: handleManualSync,
|
|
632
|
+
}, syncing ? t('action.syncing') : t('action.sync_now'))
|
|
633
|
+
: null
|
|
634
|
+
),
|
|
492
635
|
React.createElement('div', { className: 'dcm-section-desc' }, t('section.openrouter_desc')),
|
|
636
|
+
syncMsg ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok', style: { padding: '6px 10px', fontSize: 12 } }, syncMsg) : null,
|
|
493
637
|
React.createElement('div', { className: 'dcm-row-check' },
|
|
494
638
|
React.createElement('input', {
|
|
495
639
|
id: 'dcm-use-openrouter',
|
|
@@ -528,7 +672,7 @@ window.__ModuleLoader__.load({
|
|
|
528
672
|
),
|
|
529
673
|
React.createElement('div', { className: 'dcm-stat-box' },
|
|
530
674
|
React.createElement('span', { className: 'dcm-stat-val' },
|
|
531
|
-
diagState ? ago(diagState.fetchedAt) : '—'
|
|
675
|
+
diagState ? ago(diagState.fetchedAt, locale) : '—'
|
|
532
676
|
),
|
|
533
677
|
React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.last_sync')),
|
|
534
678
|
),
|
|
@@ -554,7 +698,7 @@ window.__ModuleLoader__.load({
|
|
|
554
698
|
React.createElement('button', {
|
|
555
699
|
type: 'button',
|
|
556
700
|
className: 'dcm-btn dcm-btn-primary',
|
|
557
|
-
disabled: saving || !tzValid || !rateValid || !hoursValid,
|
|
701
|
+
disabled: saving || !tzValid || !rateValid || !budgetValid || !hoursValid,
|
|
558
702
|
onClick: save,
|
|
559
703
|
}, saving ? t('settings.saving') : t('settings.save')),
|
|
560
704
|
),
|
|
@@ -576,23 +720,47 @@ window.__ModuleLoader__.load({
|
|
|
576
720
|
}
|
|
577
721
|
|
|
578
722
|
function minutesUntilChange(schedule, minute) {
|
|
723
|
+
const windows = (schedule && schedule.windows) || []
|
|
724
|
+
if (!windows.length) return null
|
|
579
725
|
const now = ratesAt(schedule, minute)
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
726
|
+
const transitions = new Set()
|
|
727
|
+
for (const w of windows) {
|
|
728
|
+
transitions.add(w.start % DAY)
|
|
729
|
+
transitions.add(w.end % DAY)
|
|
583
730
|
}
|
|
584
|
-
|
|
731
|
+
let minDiff = null
|
|
732
|
+
for (const t of transitions) {
|
|
733
|
+
let diff = (t - minute + DAY) % DAY
|
|
734
|
+
if (diff === 0) diff = DAY
|
|
735
|
+
const probe = ratesAt(schedule, (minute + diff) % DAY)
|
|
736
|
+
if (probe.output !== now.output || probe.input !== now.input) {
|
|
737
|
+
if (minDiff === null || diff < minDiff) minDiff = diff
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return minDiff
|
|
585
741
|
}
|
|
586
742
|
|
|
743
|
+
const extremesMemo = new WeakMap()
|
|
587
744
|
function extremes(schedule) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
745
|
+
if (!schedule) return { low: {}, high: {}, flat: true }
|
|
746
|
+
if (extremesMemo.has(schedule)) return extremesMemo.get(schedule)
|
|
747
|
+
|
|
748
|
+
const base = schedule.base || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
|
|
749
|
+
const candidates = [base]
|
|
750
|
+
for (const w of (schedule.windows || [])) {
|
|
751
|
+
if (w.rates) candidates.push(w.rates)
|
|
594
752
|
}
|
|
595
|
-
|
|
753
|
+
|
|
754
|
+
let low = candidates[0]
|
|
755
|
+
let high = candidates[0]
|
|
756
|
+
for (const r of candidates) {
|
|
757
|
+
if (r.output < low.output) low = r
|
|
758
|
+
if (r.output > high.output) high = r
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const res = { low, high, flat: low.output === high.output }
|
|
762
|
+
extremesMemo.set(schedule, res)
|
|
763
|
+
return res
|
|
596
764
|
}
|
|
597
765
|
|
|
598
766
|
function sumSlots(slots) {
|
|
@@ -655,7 +823,7 @@ window.__ModuleLoader__.load({
|
|
|
655
823
|
reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
|
|
656
824
|
))
|
|
657
825
|
try {
|
|
658
|
-
return new Intl.DateTimeFormat('
|
|
826
|
+
return new Intl.DateTimeFormat('en-GB', {
|
|
659
827
|
hour: '2-digit', minute: '2-digit', hour12: false, timeZone: zone,
|
|
660
828
|
}).format(d)
|
|
661
829
|
} catch {
|
|
@@ -665,7 +833,6 @@ window.__ModuleLoader__.load({
|
|
|
665
833
|
|
|
666
834
|
function zoneLabel(zone) {
|
|
667
835
|
if (!zone) return 'UTC'
|
|
668
|
-
if (zone === 'Europe/Moscow') return 'МСК'
|
|
669
836
|
const city = zone.includes('/') ? zone.slice(zone.lastIndexOf('/') + 1) : zone
|
|
670
837
|
return city.replace(/_/g, ' ')
|
|
671
838
|
}
|
|
@@ -675,13 +842,15 @@ window.__ModuleLoader__.load({
|
|
|
675
842
|
function CostMeter(props) {
|
|
676
843
|
const ctx = props.ctx
|
|
677
844
|
const locale = useActiveLocale(ctx)
|
|
678
|
-
const t = props.t || makeT(locale === '
|
|
845
|
+
const t = props.t || makeT(locale === 'zh' ? zh : en, en)
|
|
679
846
|
const useProjection = props.useProjection
|
|
680
847
|
const usage = useProjection ? useProjection('tokenUsage') : undefined
|
|
681
848
|
const byModel = useProjection ? useProjection('costByModel') : undefined
|
|
682
849
|
const [state, setState] = React.useState(null)
|
|
683
850
|
const [open, setOpen] = React.useState(false)
|
|
684
851
|
const [now, setNow] = React.useState(() => new Date())
|
|
852
|
+
const [copied, setCopied] = React.useState(false)
|
|
853
|
+
const [showAllModels, setShowAllModels] = React.useState(false)
|
|
685
854
|
const box = React.useRef(null)
|
|
686
855
|
|
|
687
856
|
React.useEffect(() => { ensureStyles() }, [])
|
|
@@ -732,6 +901,7 @@ window.__ModuleLoader__.load({
|
|
|
732
901
|
const stored = (settingsSnap && settingsSnap.value) || {}
|
|
733
902
|
const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
|
|
734
903
|
const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
|
|
904
|
+
const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
|
|
735
905
|
const schedule = state.schedule
|
|
736
906
|
const hasUsage = usage !== undefined && usage !== null
|
|
737
907
|
const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
|
|
@@ -741,16 +911,18 @@ window.__ModuleLoader__.load({
|
|
|
741
911
|
h('button', {
|
|
742
912
|
key: 'chip', type: 'button',
|
|
743
913
|
className: 'dcm-chip',
|
|
744
|
-
style: { background: 'transparent', color: 'var(--dsw-alias-label-tertiary)' },
|
|
745
914
|
onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
|
|
746
|
-
},
|
|
915
|
+
}, [
|
|
916
|
+
h('span', { key: 'dot', className: 'dcm-dot dcm-dot-idle' }),
|
|
917
|
+
h('span', { key: 'txt' }, t('chipUnknown')),
|
|
918
|
+
]),
|
|
747
919
|
open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
|
|
748
920
|
h('div', { key: 't', className: 'dcm-panel-title' },
|
|
749
921
|
state.route ? state.route.provider + ' / ' + state.route.model : t('routeUnknown')),
|
|
750
922
|
h('div', { key: 'n', className: 'dcm-panel-note' },
|
|
751
923
|
state.catalogError
|
|
752
924
|
? t('catalogUnavailable') + state.catalogError
|
|
753
|
-
: '
|
|
925
|
+
: t('tokens.not_priced')),
|
|
754
926
|
]) : null,
|
|
755
927
|
])
|
|
756
928
|
}
|
|
@@ -758,7 +930,8 @@ window.__ModuleLoader__.load({
|
|
|
758
930
|
const active = ratesAt(schedule, minute)
|
|
759
931
|
const { low, high, flat } = extremes(schedule)
|
|
760
932
|
const cheapNow = !flat && active.output === low.output
|
|
761
|
-
const
|
|
933
|
+
const leftMinutes = minutesUntilChange(schedule, minute)
|
|
934
|
+
const left = clock(leftMinutes)
|
|
762
935
|
|
|
763
936
|
const schedules = state.schedules || {}
|
|
764
937
|
const perModel = used.map((m) => {
|
|
@@ -778,9 +951,16 @@ window.__ModuleLoader__.load({
|
|
|
778
951
|
? perModel.reduce((sum, m) => sum + m.cost, 0)
|
|
779
952
|
: spendUsd(usage, active) * fx
|
|
780
953
|
|
|
781
|
-
|
|
782
|
-
const
|
|
783
|
-
const
|
|
954
|
+
// Check budget threshold in USD
|
|
955
|
+
const totalUsd = fx > 0 ? total / fx : total
|
|
956
|
+
const budgetExceeded = budgetThreshold > 0 && totalUsd >= budgetThreshold
|
|
957
|
+
|
|
958
|
+
// Dot indicator status class
|
|
959
|
+
const dotClass = budgetExceeded
|
|
960
|
+
? 'dcm-dot dcm-dot-err'
|
|
961
|
+
: (flat ? 'dcm-dot dcm-dot-idle' : (cheapNow ? 'dcm-dot dcm-dot-ok' : 'dcm-dot dcm-dot-warn'))
|
|
962
|
+
|
|
963
|
+
const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: 'rgba(99,102,241,0.12)', color: 'var(--dsw-alias-state-brand-primary, #6366f1)', fontWeight: 600 }
|
|
784
964
|
|
|
785
965
|
const rateRow = (label, key) => flat
|
|
786
966
|
? [
|
|
@@ -820,44 +1000,114 @@ window.__ModuleLoader__.load({
|
|
|
820
1000
|
.join(', ')
|
|
821
1001
|
const nowLocal = hhmmInZone(minute, zone, now)
|
|
822
1002
|
|
|
1003
|
+
// Calculate cache savings amount
|
|
1004
|
+
let cacheSavedAmount = 0
|
|
1005
|
+
let cacheSavedPercent = 0
|
|
1006
|
+
if (hasUsage && usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
|
|
1007
|
+
const tokensRead = Number(usage.cacheReadTokens)
|
|
1008
|
+
const missedCost = (tokensRead / 1e6) * active.input
|
|
1009
|
+
const hitCost = (tokensRead / 1e6) * active.cacheHit
|
|
1010
|
+
cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
|
|
1011
|
+
cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Copy summary report to clipboard
|
|
1015
|
+
const copyReport = () => {
|
|
1016
|
+
const lines = [
|
|
1017
|
+
'=== ' + (state.route ? state.route.model : 'Cost Meter Report') + ' ===',
|
|
1018
|
+
t('tokens.session_total') + ' ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
|
|
1019
|
+
'Tier: ' + (flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak'))),
|
|
1020
|
+
'Rates (1M): input ' + rate(active.input) + ', output ' + rate(active.output) + ', cache ' + rate(active.cacheHit),
|
|
1021
|
+
]
|
|
1022
|
+
if (cacheSavedAmount > 0) {
|
|
1023
|
+
lines.push('Cache saved: ≈ ' + money(cacheSavedAmount, currency) + ' (' + cacheSavedPercent + '%)')
|
|
1024
|
+
}
|
|
1025
|
+
if (hasUsage && usage) {
|
|
1026
|
+
lines.push('Tokens: in ' + tokens(usage.inputTokens) + ', out ' + tokens(usage.outputTokens) + ', cache hit ' + tokens(usage.cacheReadTokens))
|
|
1027
|
+
}
|
|
1028
|
+
const text = lines.join('\n')
|
|
1029
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
1030
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
1031
|
+
setCopied(true)
|
|
1032
|
+
setTimeout(() => setCopied(false), 2000)
|
|
1033
|
+
}).catch(() => {})
|
|
1034
|
+
} else {
|
|
1035
|
+
setCopied(true)
|
|
1036
|
+
setTimeout(() => setCopied(false), 2000)
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Calculate upcoming discount hint
|
|
1041
|
+
let discountHint = null
|
|
1042
|
+
if (!flat && !cheapNow && leftMinutes) {
|
|
1043
|
+
const nextTimeMinutes = (minute + leftMinutes) % DAY
|
|
1044
|
+
const clockTime = hhmmInZone(nextTimeMinutes, zone, now)
|
|
1045
|
+
discountHint = t('savings.hint', {
|
|
1046
|
+
time: left,
|
|
1047
|
+
clock: clockTime,
|
|
1048
|
+
zone: zl,
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Display models with collapse support if > 3 models
|
|
1053
|
+
const visibleModels = (perModel.length
|
|
1054
|
+
? perModel
|
|
1055
|
+
: (state.route ? [{ key: state.route.provider + '/' + state.route.model, model: state.route.model, known: Boolean(schedule), cost: 0 }] : [])
|
|
1056
|
+
)
|
|
1057
|
+
const shouldCollapse = visibleModels.length > 3 && !showAllModels
|
|
1058
|
+
const renderedModels = shouldCollapse ? visibleModels.slice(0, 3) : visibleModels
|
|
1059
|
+
|
|
823
1060
|
return h('div', { ref: box, style: { position: 'relative' } }, [
|
|
824
1061
|
h('button', {
|
|
825
1062
|
key: 'chip', type: 'button',
|
|
826
1063
|
className: 'dcm-chip',
|
|
827
|
-
style: { background: bg, color: fg, borderColor: cheapNow ? 'rgba(16,185,129,0.3)' : 'rgba(245,158,11,0.3)' },
|
|
828
1064
|
onClick: () => setOpen((v) => !v),
|
|
829
|
-
title:
|
|
1065
|
+
title: budgetExceeded
|
|
1066
|
+
? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
|
|
1067
|
+
: (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak')),
|
|
830
1068
|
}, [
|
|
1069
|
+
h('span', { key: 'dot', className: dotClass }),
|
|
831
1070
|
h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
|
|
832
|
-
left ? h('span', { key: 'left',
|
|
1071
|
+
left ? h('span', { key: 'left', className: 'dcm-chip-timer' }, left) : null,
|
|
833
1072
|
]),
|
|
834
1073
|
|
|
835
1074
|
open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
|
|
836
|
-
h('div', { key: 'title', className: 'dcm-panel-title' },
|
|
837
|
-
(state.route ? state.route.model : '
|
|
838
|
-
|
|
1075
|
+
h('div', { key: 'title', className: 'dcm-panel-title' }, [
|
|
1076
|
+
h('span', { key: 'model-name' }, state.route ? state.route.model : 'model'),
|
|
1077
|
+
flat ? null : h('span', { key: 'tier', style: { fontSize: 11.5, opacity: 0.85 } }, cheapNow ? t('offpeak') : t('peak')),
|
|
1078
|
+
]),
|
|
839
1079
|
|
|
840
1080
|
h('div', { key: 'note', className: 'dcm-panel-note' },
|
|
841
1081
|
flat
|
|
842
|
-
? '
|
|
843
|
-
: (cheapNow ? '
|
|
844
|
-
(left ? ' ·
|
|
1082
|
+
? 'Flat rate schedule'
|
|
1083
|
+
: (cheapNow ? 'Discounted rate active' : 'Full rate active') +
|
|
1084
|
+
(left ? ' · change in ' + left : '')),
|
|
845
1085
|
|
|
846
1086
|
flat
|
|
847
1087
|
? null
|
|
848
1088
|
: h('div', { key: 'clock', className: 'dcm-panel-note' },
|
|
849
|
-
'
|
|
1089
|
+
t('peak') + ': ' + windowText + ' ' + zl + ' · now ' + nowLocal + ' ' + zl),
|
|
1090
|
+
|
|
1091
|
+
discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
|
|
1092
|
+
|
|
1093
|
+
cacheSavedAmount > 0
|
|
1094
|
+
? h('div', { key: 'cache-saved-box', className: 'dcm-cache-box', style: { marginTop: 6 } },
|
|
1095
|
+
t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }))
|
|
1096
|
+
: null,
|
|
1097
|
+
|
|
1098
|
+
budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
|
|
1099
|
+
t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
|
|
850
1100
|
|
|
851
1101
|
h('hr', { key: 'hr1', className: 'dcm-hr' }),
|
|
852
1102
|
|
|
853
1103
|
h('div', { key: 'rates', className: flat ? 'dcm-panel-grid-flat' : 'dcm-panel-grid' }, [
|
|
854
|
-
h('div', { key: 'h0', className: 'dcm-th' }, '
|
|
855
|
-
flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, '
|
|
856
|
-
h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? '
|
|
857
|
-
rateRow('
|
|
858
|
-
rateRow('
|
|
859
|
-
showCacheWrite ? rateRow('
|
|
860
|
-
rateRow('
|
|
1104
|
+
h('div', { key: 'h0', className: 'dcm-th' }, t('rate.per_1m')),
|
|
1105
|
+
flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, t('offpeak')),
|
|
1106
|
+
h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? 'rate' : t('peak')),
|
|
1107
|
+
rateRow(t('rate.cache_hit'), 'cacheHit'),
|
|
1108
|
+
rateRow(t('rate.input'), 'input'),
|
|
1109
|
+
showCacheWrite ? rateRow(t('rate.cache_write'), 'cacheWrite') : null,
|
|
1110
|
+
rateRow(t('rate.output'), 'output'),
|
|
861
1111
|
]),
|
|
862
1112
|
|
|
863
1113
|
h('hr', { key: 'hr2', className: 'dcm-hr' }),
|
|
@@ -865,27 +1115,24 @@ window.__ModuleLoader__.load({
|
|
|
865
1115
|
multiModel
|
|
866
1116
|
? null
|
|
867
1117
|
: [
|
|
868
|
-
h('div', { key: 'btitle', className: 'dcm-section-lbl' }, '
|
|
1118
|
+
h('div', { key: 'btitle', className: 'dcm-section-lbl' }, t('tokens.session')),
|
|
869
1119
|
h('div', { key: 'breakdown', className: 'dcm-panel-grid-3' }, [
|
|
870
|
-
h('div', { key: 'b0', className: 'dcm-th' }, '
|
|
1120
|
+
h('div', { key: 'b0', className: 'dcm-th' }, t('tokens.tokens_col')),
|
|
871
1121
|
h('div', { key: 'b1', className: 'dcm-th-num' }, ''),
|
|
872
1122
|
h('div', { key: 'b2', className: 'dcm-th-num' }, currency),
|
|
873
|
-
spentRow('
|
|
874
|
-
spentRow('
|
|
875
|
-
showCacheWrite ? spentRow('
|
|
876
|
-
spentRow('
|
|
1123
|
+
spentRow(t('rate.cache_hit'), 'cacheReadTokens', 'cacheHit'),
|
|
1124
|
+
spentRow(t('rate.input'), 'uncachedInputTokens', 'input'),
|
|
1125
|
+
showCacheWrite ? spentRow(t('rate.cache_write'), 'cacheWriteTokens', 'cacheWrite') : null,
|
|
1126
|
+
spentRow(t('rate.output'), 'outputTokens', 'output'),
|
|
877
1127
|
]),
|
|
878
1128
|
h('hr', { key: 'hr-models', className: 'dcm-hr' }),
|
|
879
1129
|
],
|
|
880
1130
|
|
|
881
|
-
h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, '
|
|
1131
|
+
h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, t('tokens.models_spent')),
|
|
882
1132
|
h(
|
|
883
1133
|
'div',
|
|
884
1134
|
{ key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
885
|
-
(
|
|
886
|
-
? perModel
|
|
887
|
-
: (state.route ? [{ key: state.route.provider + '/' + state.route.model, model: state.route.model, known: Boolean(schedule), cost: 0 }] : [])
|
|
888
|
-
).map((m) =>
|
|
1135
|
+
renderedModels.map((m) =>
|
|
889
1136
|
h(
|
|
890
1137
|
'div',
|
|
891
1138
|
{
|
|
@@ -918,29 +1165,46 @@ window.__ModuleLoader__.load({
|
|
|
918
1165
|
color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
|
|
919
1166
|
},
|
|
920
1167
|
},
|
|
921
|
-
m.known ? (hasUsage ? money(m.cost, currency) : '—') : '
|
|
1168
|
+
m.known ? (hasUsage ? money(m.cost, currency) : '—') : t('tokens.not_priced'),
|
|
922
1169
|
),
|
|
923
1170
|
],
|
|
924
1171
|
),
|
|
925
1172
|
),
|
|
926
1173
|
),
|
|
927
1174
|
|
|
1175
|
+
visibleModels.length > 3
|
|
1176
|
+
? h('button', {
|
|
1177
|
+
key: 'toggle-models',
|
|
1178
|
+
type: 'button',
|
|
1179
|
+
className: 'dcm-accordion-btn',
|
|
1180
|
+
onClick: () => setShowAllModels((v) => !v),
|
|
1181
|
+
}, shouldCollapse ? t('models.show_more', { count: visibleModels.length }) : t('models.show_less'))
|
|
1182
|
+
: null,
|
|
1183
|
+
|
|
928
1184
|
h('hr', { key: 'hr3', className: 'dcm-hr' }),
|
|
929
1185
|
|
|
930
1186
|
h('div', { key: 'total', className: 'dcm-total' }, [
|
|
931
|
-
h('span', { key: 'l' }, '
|
|
932
|
-
h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '
|
|
1187
|
+
h('span', { key: 'l' }, t('tokens.session_total')),
|
|
1188
|
+
h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
|
|
933
1189
|
]),
|
|
934
1190
|
|
|
935
1191
|
hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
|
|
936
|
-
'
|
|
1192
|
+
t('tokens.no_usage_yet')),
|
|
1193
|
+
|
|
1194
|
+
h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
|
|
1195
|
+
h('button', {
|
|
1196
|
+
type: 'button',
|
|
1197
|
+
className: 'dcm-btn dcm-btn-sm',
|
|
1198
|
+
onClick: copyReport,
|
|
1199
|
+
}, copied ? t('action.copied') : t('action.copy_report'))
|
|
1200
|
+
),
|
|
937
1201
|
]) : null,
|
|
938
1202
|
])
|
|
939
1203
|
}
|
|
940
1204
|
|
|
941
1205
|
function apply(ctx) {
|
|
942
1206
|
if (ctx.locale && ctx.locale.register) {
|
|
943
|
-
try { ctx.locale.register(NS, { en,
|
|
1207
|
+
try { ctx.locale.register(NS, { en, zh }) } catch (_) {}
|
|
944
1208
|
}
|
|
945
1209
|
|
|
946
1210
|
refreshMirrorUntilVisible(ctx)
|
|
@@ -962,7 +1226,17 @@ window.__ModuleLoader__.load({
|
|
|
962
1226
|
}
|
|
963
1227
|
}
|
|
964
1228
|
|
|
965
|
-
module.exports = {
|
|
1229
|
+
module.exports = {
|
|
1230
|
+
apply,
|
|
1231
|
+
inject: ['slots', 'locale', 'settingsScope'],
|
|
1232
|
+
CostMeterCard,
|
|
1233
|
+
CostMeter,
|
|
1234
|
+
ErrorBoundary,
|
|
1235
|
+
ensureStyles,
|
|
1236
|
+
minutesUntilChange,
|
|
1237
|
+
extremes,
|
|
1238
|
+
ratesAt,
|
|
1239
|
+
}
|
|
966
1240
|
return module.exports
|
|
967
1241
|
},
|
|
968
1242
|
})
|