@goodandready/dsh-cost-meter 0.8.26 → 0.8.27

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/lib/client.js CHANGED
@@ -1,2058 +1,2329 @@
1
- // dsh-cost-meter — client half.
2
- // Self-registering browser module for DeepSeek Harness.
3
- // Unified design system aligned with @goodandready/dsh-clinebot and DSH tokens.
4
- // Canonical languages: English (en) and Chinese (zh).
5
-
6
- window.__ModuleLoader__.load({
7
- id: '@goodandready/dsh-cost-meter',
8
- factory: (require) => {
9
- var module = { exports: {} }
10
- const React = require('react')
11
- const h = React.createElement
12
-
13
- const NS = 'dsh-cost-meter'
14
- // Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
15
- const PKG = '@goodandready/dsh-cost-meter'
16
- const ROW_ID = 'dsh-cost-meter'
17
- const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
18
- const STATE_PATH = '/dsh-cost-meter/state'
19
- const REFRESH_PATH = '/dsh-cost-meter/refresh'
20
- const UPDATE_PATH = '/dsh-cost-meter/update'
21
- const DAY = 1440
22
-
23
- // ----------------------------------------------------------- localization
24
-
25
- const en = {
26
- title: 'Cost Meter',
27
- subtitle: 'Model rates, peak discount windows and session spend',
28
- 'header.title': 'Cost Meter',
29
- 'header.sub': 'Token spend monitoring, automatic tariff resolution and discount windows',
30
- 'update.checking': 'Checking for updates…',
31
- 'update.up_to_date': 'Up to date',
32
- 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
33
- 'update.btn': 'Update Now',
34
- 'update.updating': 'Updating…',
35
- 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
36
- 'update.failed': 'Update failed: {error}',
37
- 'badge.catalog_ok': 'Catalog: {count} models',
38
- 'badge.catalog_empty': 'Catalog: loading…',
39
- 'badge.catalog_off': 'Catalog: disabled',
40
- 'badge.catalog_err': 'Catalog: error',
41
- 'badge.catalog_stale': 'Cache: offline',
42
- 'badge.deepseek': 'DeepSeek tariff: active',
43
- 'section.general': 'General Parameters',
44
- 'section.general_desc': 'Display currency, conversion rate, budget threshold and time zone configuration',
45
- 'section.openrouter': 'OpenRouter Pricing Catalog',
46
- 'section.openrouter_desc': 'Public catalog for dynamic model pricing and discount windows',
47
- 'section.diagnostics': 'Diagnostics & Status',
48
- 'section.diagnostics_desc': 'Current state of background synchronization and tariff cache',
49
- 'diag.models_count': 'Catalog models',
50
- 'diag.last_sync': 'Last sync',
51
- 'diag.route': 'Active route',
52
- 'diag.tariff_source': 'Tariff source',
53
- 'field.currency': 'Currency Symbol',
54
- 'hint.currency': 'Currency symbol shown in the UI (e.g. $, ¥, €).',
55
- 'field.usdRate': 'USD Exchange Rate',
56
- 'hint.usdRate': 'Units of currency per 1 USD (1 keeps USD).',
57
- 'field.budgetThreshold': 'Session Budget Threshold (USD)',
58
- 'hint.budgetThreshold': 'Warn via chip color when session spend exceeds this amount (0 disables).',
59
- 'field.displayTimeZone': 'Time Zone (IANA)',
60
- 'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, Asia/Shanghai, UTC).',
61
- 'field.useOpenRouter': 'Use OpenRouter Catalog',
62
- 'hint.useOpenRouter': 'Pull rates and discount windows dynamically from public OpenRouter API.',
63
- 'field.refreshHours': 'Catalog Refresh Interval (hours)',
64
- 'hint.refreshHours': 'Background catalog fetch interval in hours (1 to 168).',
65
- 'action.sync_now': 'Sync Now',
66
- 'action.syncing': 'Syncing…',
67
- 'action.sync_ok': 'Catalog updated',
68
- 'action.sync_err': 'Sync failed',
69
- 'action.sync_rate_limited': 'Wait {seconds}s',
70
- 'action.copy_report': 'Copy Summary',
71
- 'action.copied': 'Copied!',
72
- 'alert.pre_switch_peak': '⚠️ Peak tariff starts in {time} (higher rates)',
73
- 'alert.pre_switch_offpeak': '🎉 Off-peak discount starts in {time} (-50% savings)',
74
- 'section.alerts': 'Tariff Transition Alerts',
75
- 'section.alerts_desc': 'Visual pulse and pre-switch alerts before peak and off-peak rate changes',
76
- 'field.alertBeforeSwitch': 'Pre-Switch Tariff Alerts',
77
- 'hint.alertBeforeSwitch': 'Show visual pulse on chip and alert banner before rate transitions.',
78
- 'field.alertLeadMinutes': 'Alert Lead Time (minutes)',
79
- 'hint.alertLeadMinutes': 'Minutes before tariff switch to activate pre-switch alert (1 to 30).',
80
- 'field.browserNotifications': 'Desktop Notifications',
81
- 'hint.browserNotifications': 'Send HTML5 browser notification on tariff transitions when tab is in background.',
82
- 'action.test_notification': 'Test Notification',
83
- 'action.tested_notification': 'Notification Sent!',
84
- 'turn.action_btn': 'Turn tokens',
85
- 'turn.modal_title': 'Message Token & Cost Inspector',
86
- 'turn.tokens_total': 'Turn Tokens',
87
- 'turn.cost_title': 'Turn Cost',
88
- 'turn.cache_hit_rate': 'Cache Hit',
89
- 'turn.duration': 'Duration',
90
- 'turn.uncached_in': 'Uncached Input',
91
- 'turn.cache_read': 'Cache Read',
92
- 'turn.output': 'Output',
93
- 'turn.history_title': 'Turn History & Inspector',
94
- 'turn.show_history': 'Show turn history',
95
- 'turn.hide_history': 'Hide turn history',
96
- 'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
97
- 'savings.cache_saved': '⚡ Context cache saved: ≈ {amount} ({percent}%)',
98
- 'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
99
- 'models.show_more': 'Show all {count} models',
100
- 'models.show_less': 'Show fewer',
101
- 'settings.save': 'Save Settings',
102
- 'settings.saving': 'Saving…',
103
- 'settings.saved': 'Settings saved successfully',
104
- 'settings.saveFailed': 'Failed to save settings: ',
105
- 'settings.loading': 'Loading settings…',
106
- 'settings.unavailable': 'Settings temporarily unavailable',
107
- 'settings.retry': 'Retry',
108
- chipUnknown: 'Unknown tariff',
109
- routeUnknown: 'Unknown route',
110
- catalogUnavailable: 'Catalog unavailable: ',
111
- peak: 'peak',
112
- offpeak: 'off-peak',
113
- 'rate.per_1m': '1M tokens, $',
114
- 'rate.cache_hit': 'Input (cache hit)',
115
- 'rate.input': 'Input (cache miss)',
116
- 'rate.cache_write': 'Input (cache write)',
117
- 'rate.output': 'Output',
118
- 'tokens.session': 'Session Tokens',
119
- 'tokens.tokens_col': 'Tokens',
120
- 'tokens.models_spent': 'Spend by Model',
121
- 'tokens.session_total': 'Session total:',
122
- 'tokens.no_usage_yet': 'Provider has not reported usage for this session yet.',
123
- 'tokens.not_priced': 'Not priced',
124
- 'tz.invalid': 'Invalid IANA time zone identifier',
125
- 'sparkline.title': 'Turn dynamics',
126
- 'sparkline.steps': 'steps',
127
- 'projection.burn_rate': 'Burn rate:',
128
- 'projection.projected': 'Projected:',
129
- 'projection.per_hour': '/hr',
130
- 'field.displayMode': 'Display mode',
131
- 'hint.displayMode': 'Widget display mode: auto (collapses on mobile <= 768px), full, or always compact.',
132
- 'mode.auto': 'Auto (responsive)',
133
- 'mode.full': 'Full',
134
- 'mode.compact': 'Compact pill',
135
- }
136
-
137
- const zh = {
138
- title: '成本计量器',
139
- subtitle: '模型费率、高峰优惠窗口与会话消费统计',
140
- 'header.title': '成本计量器 (Cost Meter)',
141
- 'header.sub': 'Token 消费监控、费率自动匹配与折扣窗口管理',
142
- 'update.checking': '正在检查更新…',
143
- 'update.up_to_date': '已是最新版本',
144
- 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
145
- 'update.btn': '立即更新',
146
- 'update.updating': '正在更新…',
147
- 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
148
- 'update.failed': '更新失败:{error}',
149
- 'badge.catalog_ok': '目录:{count} 个模型',
150
- 'badge.catalog_empty': '目录:加载中…',
151
- 'badge.catalog_off': '目录:已禁用',
152
- 'badge.catalog_err': '目录:错误',
153
- 'badge.catalog_stale': '缓存:离线',
154
- 'badge.deepseek': 'DeepSeek 费率:生效中',
155
- 'section.general': '基础参数',
156
- 'section.general_desc': '显示货币、汇率转换、预算阈值与时区设置',
157
- 'section.openrouter': 'OpenRouter 定价目录',
158
- 'section.openrouter_desc': '用于自动获取模型价格与折扣窗口的公开价格目录',
159
- 'section.diagnostics': '诊断与状态',
160
- 'section.diagnostics_desc': '后台同步与费率缓存当前状态',
161
- 'diag.models_count': '目录中模型数',
162
- 'diag.last_sync': '上次同步',
163
- 'diag.route': '当前路由',
164
- 'diag.tariff_source': '费率来源',
165
- 'field.currency': '货币符号',
166
- 'hint.currency': '界面中显示的货币符号(例如:$、¥、€)。',
167
- 'field.usdRate': 'USD 汇率',
168
- 'hint.usdRate': '每 1 美元对应的本币金额(1 为美元)。',
169
- 'field.budgetThreshold': '会话预算阈值 (USD)',
170
- 'hint.budgetThreshold': '当会话消费超出该金额时在状态指示器报警(0 表示禁用)。',
171
- 'field.displayTimeZone': '时区 (IANA)',
172
- 'hint.displayTimeZone': '显示高峰时段的 IANA 时区(例如:Asia/Shanghai、UTC)。',
173
- 'field.useOpenRouter': '使用 OpenRouter 目录',
174
- 'hint.useOpenRouter': '通过公开的 OpenRouter API 自动拉取费率与折扣窗口。',
175
- 'field.refreshHours': '目录刷新周期 (小时)',
176
- 'hint.refreshHours': '后台刷新价格目录的时间间隔(1 至 168 小时)。',
177
- 'action.sync_now': '立即同步',
178
- 'action.syncing': '同步中…',
179
- 'action.sync_ok': '目录已更新',
180
- 'action.sync_err': '同步失败',
181
- 'action.sync_rate_limited': '请等待 {seconds} 秒',
182
- 'action.copy_report': '复制消费报告',
183
- 'action.copied': '已复制!',
184
- 'alert.pre_switch_peak': '⚠️ 高峰期计费将在 {time} 后开始(标准费率)',
185
- 'alert.pre_switch_offpeak': '🎉 特惠期将在 {time} 后开始(5折优惠)',
186
- 'section.alerts': '费率切换预警',
187
- 'section.alerts_desc': '在高峰期与特惠期费率切换前显示脉冲提醒与横幅',
188
- 'field.alertBeforeSwitch': '费率切换预警',
189
- 'hint.alertBeforeSwitch': '在费率切换前在状态芯片上显示脉冲光晕与提示横幅。',
190
- 'field.alertLeadMinutes': '预警提前时间(分钟)',
191
- 'hint.alertLeadMinutes': '在费率切换前提前多少分钟发出预警(1 至 30 分钟)。',
192
- 'field.browserNotifications': '桌面系统通知',
193
- 'hint.browserNotifications': '在费率切换时发送 HTML5 桌面通知(后台标签页亦有效)。',
194
- 'action.test_notification': '测试通知',
195
- 'action.tested_notification': '通知已发送!',
196
- 'turn.action_btn': 'Token 详情',
197
- 'turn.modal_title': '单次回复 Token 与成本详情',
198
- 'turn.tokens_total': '本轮 Token 总量',
199
- 'turn.cost_title': '单次成本',
200
- 'turn.cache_hit_rate': '缓存命中率',
201
- 'turn.duration': '耗时',
202
- 'turn.uncached_in': '未命中输入',
203
- 'turn.cache_read': '命中缓存读取',
204
- 'turn.output': '模型输出',
205
- 'turn.history_title': '轮次明细与检查器',
206
- 'turn.show_history': '展开轮次明细',
207
- 'turn.hide_history': '收起轮次明细',
208
- 'savings.hint': '💡 5 折优惠将在 {time} 后生效 ({clock} {zone})',
209
- 'savings.cache_saved': '⚡ 上下文缓存节省:≈ {amount} ({percent}%)',
210
- 'budget.exceeded': '⚠️ 已超出预算上限:{limit}',
211
- 'models.show_more': '查看全部 {count} 个模型',
212
- 'models.show_less': '收起',
213
- 'settings.save': '保存设置',
214
- 'settings.saving': '保存中…',
215
- 'settings.saved': '设置已成功保存',
216
- 'settings.saveFailed': '保存设置失败:',
217
- 'settings.loading': '加载设置中…',
218
- 'settings.unavailable': '设置暂时不可用',
219
- 'settings.retry': '重试',
220
- chipUnknown: '费率未知',
221
- routeUnknown: '路由未知',
222
- catalogUnavailable: '价格目录不可用:',
223
- peak: '高峰',
224
- offpeak: '低谷',
225
- 'rate.per_1m': '每 1M tokens ($)',
226
- 'rate.cache_hit': '输入 (命中缓存)',
227
- 'rate.input': '输入 (未命中)',
228
- 'rate.cache_write': '输入 (写入缓存)',
229
- 'rate.output': '输出',
230
- 'tokens.session': '会话 Tokens',
231
- 'tokens.tokens_col': 'Tokens 数',
232
- 'tokens.models_spent': '各模型消费',
233
- 'tokens.session_total': '会话总计:',
234
- 'tokens.no_usage_yet': '提供商尚未报告当前会话的用量数据。',
235
- 'tokens.not_priced': '未定价',
236
- 'tz.invalid': '无效的 IANA 时区标识',
237
- 'sparkline.title': '步骤动态',
238
- 'sparkline.steps': '步',
239
- 'projection.burn_rate': '消耗速率:',
240
- 'projection.projected': '预估总计:',
241
- 'projection.per_hour': '/小时',
242
- 'field.displayMode': '显示模式',
243
- 'hint.displayMode': '计量器外观模式:自动(<=768px 移动端自适应折叠)、完整或始终紧凑。',
244
- 'mode.auto': '自动自适应',
245
- 'mode.full': '完整显示',
246
- 'mode.compact': '紧凑药丸',
247
- }
248
-
249
- function makeT(dict, fallback) {
250
- return function t(key, vars) {
251
- let val = (dict && dict[key]) || (fallback && fallback[key]) || key
252
- if (vars && typeof val === 'string') {
253
- for (const k of Object.keys(vars)) {
254
- val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
255
- }
256
- }
257
- return val
258
- }
259
- }
260
-
261
- function useActiveLocale(ctx) {
262
- const localeService = (ctx && typeof ctx.get === 'function' ? ctx.get('locale') : null) || (ctx && ctx.locale) || (ctx && ctx.services && ctx.services.locale)
263
- return React.useSyncExternalStore(
264
- React.useMemo(() => (cb) => (localeService && typeof localeService.subscribe === 'function' ? localeService.subscribe(cb) : () => {}), [localeService]),
265
- React.useCallback(() => {
266
- const snap = localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot() : null
267
- const active = snap ? (snap.active || snap.locale || 'en') : 'en'
268
- return String(active).startsWith('zh') ? 'zh' : 'en'
269
- }, [localeService]),
270
- React.useCallback(() => 'en', []),
271
- )
272
- }
273
-
274
- // ----------------------------------------------------------------- styles
275
-
276
- const CSS = `
277
- .dcm-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 12px; list-style: none; margin-bottom: 12px; }
278
- .dcm-head { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
279
- .dcm-title { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
280
- .dcm-sub { color: var(--dsw-alias-label-secondary); font-size: 13px; }
281
- .dcm-body { border-top: 1px solid var(--dsw-alias-border-l2); margin: 0 16px; padding: 16px 0 12px; display: flex; flex-direction: column; gap: 14px; }
282
-
283
- .dcm-section-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
284
- .dcm-section-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); display: flex; align-items: center; justify-content: space-between; }
285
- .dcm-section-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: -4px; line-height: 1.4; }
286
-
287
- .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
288
- .dcm-field { display: flex; flex-direction: column; gap: 6px; }
289
- .dcm-row-check { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
290
- .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary); cursor: pointer; }
291
- .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
292
- .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
293
-
294
- .dcm-input { height: 34px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 0 12px; font-size: 13px; width: 100%; box-sizing: border-box; }
295
- .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary); }
296
- .dcm-input-err { border-color: var(--dsw-alias-state-error-primary); }
297
-
298
- .dcm-chevron { display: inline-flex; align-items: center; justify-content: center; transition: transform .16s ease; color: var(--dsw-alias-label-tertiary); }
299
- .dcm-chevron-open { transform: rotate(180deg); }
300
-
301
- .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
302
- .dcm-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2); display: inline-flex; align-items: center; gap: 4px; font-weight: 500; }
303
- .dcm-badge-ok { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); }
304
- .dcm-badge-warn { border-color: var(--dsw-alias-state-warning-primary); color: var(--dsw-alias-state-warning-primary); background: color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent); }
305
- .dcm-badge-bad { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
306
-
307
- .dcm-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 14px; font-size: 13px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: all .15s ease; }
308
- .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
309
- .dcm-btn-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
310
- .dcm-btn-primary:hover:not(:disabled) { opacity: 0.9; }
311
- .dcm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
312
- .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
313
-
314
- .dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
315
- .dcm-alert-ok { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
316
- .dcm-alert-err { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
317
- .dcm-tip-box { padding: 8px 12px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 8%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-brand-primary) 20%, transparent); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
318
- .dcm-cache-box { padding: 7px 11px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 22%, transparent); color: var(--dsw-alias-state-success-primary); font-size: 11.5px; font-weight: 500; line-height: 1.4; font-variant-numeric: tabular-nums; }
319
-
320
- .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; }
321
- .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
322
- .dcm-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-secondary); }
323
-
324
- .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
325
-
326
- /* Native dot-indicator chip */
327
- .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); 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; }
328
- .dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3); }
329
- .dcm-dot { width: 6.5px; height: 6.5px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
330
- .dcm-dot-ok { background: var(--dsw-alias-state-success-primary); box-shadow: 0 0 5px color-mix(in srgb, var(--dsw-alias-state-success-primary) 50%, transparent); }
331
- .dcm-dot-warn { background: var(--dsw-alias-state-warning-primary); box-shadow: 0 0 5px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 40%, transparent); }
332
- .dcm-dot-err { background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 6px color-mix(in srgb, var(--dsw-alias-state-error-primary) 70%, transparent); animation: dcm-pulse 1.8s infinite ease-in-out; }
333
- .dcm-dot-idle { background: var(--dsw-alias-label-tertiary); }
334
-
335
- .dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
336
- @media (max-width: 768px) {
337
- .dcm-chip-timer { display: none; }
338
- .dcm-panel { width: min(320px, calc(100vw - 32px)); right: 0; }
339
- }
340
- .dcm-chip-compact .dcm-chip-timer { display: none !important; }
341
-
342
- @keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
343
-
344
- .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: var(--dsw-alias-shadow-layer-3, 0 10px 32px var(--dsw-alias-bg-base)); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; font-variant-numeric: tabular-nums; }
345
- .dcm-panel-title { font-weight: 600; padding-bottom: 2px; font-size: 13.5px; display: flex; justify-content: space-between; align-items: baseline; }
346
- .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
347
- .dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
348
- .dcm-panel-grid { display: grid; grid-template-columns: 1fr 62px 62px; column-gap: 6px; align-items: center; }
349
- .dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr 76px; column-gap: 6px; align-items: center; }
350
- .dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr 60px 74px; column-gap: 6px; align-items: center; }
351
- .dcm-th { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; }
352
- .dcm-th-num { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; text-align: right; font-size: 11px; }
353
- .dcm-td { padding: 3.5px 0; color: var(--dsw-alias-label-secondary); }
354
- .dcm-cell-idle { text-align: right; padding: 3px 6px; border-radius: 4px; color: var(--dsw-alias-label-tertiary); }
355
- .dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding: 3.5px 0; }
356
- .dcm-cost { text-align: right; padding: 3.5px 0; font-weight: 500; color: var(--dsw-alias-label-primary); }
357
- .dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
358
- .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
359
- .dcm-accordion-btn { appearance: none; background: transparent; border: 0; color: var(--dsw-alias-state-brand-primary); font: inherit; font-size: 11.5px; cursor: pointer; padding: 2px 0; text-align: left; }
360
- .dcm-accordion-btn:hover { text-decoration: underline; }
361
- .dcm-sparkline-box { margin: 6px 0; padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 60%, transparent); border: 1px solid var(--dsw-alias-border-l2); }
362
- .dcm-sparkline-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
363
- .dcm-sparkline-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
364
- .dcm-sparkline-svg { width: 100%; height: 36px; display: block; overflow: visible; }
365
- .dcm-spark-bar { fill: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 45%, transparent); transition: fill .15s ease; cursor: pointer; }
366
- .dcm-spark-bar:hover { fill: var(--dsw-alias-state-brand-primary); }
367
- .dcm-spark-peak { fill: var(--dsw-alias-state-brand-primary); }
368
- .dcm-proj-box { margin: 6px 0; padding: 6px 10px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 6%, transparent); display: flex; justify-content: space-between; align-items: center; font-size: 11px; }
369
- .dcm-proj-item { display: inline-flex; align-items: center; gap: 4px; }
370
- .dcm-proj-val { font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
371
-
372
- .dcm-chip-alert-peak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 50%, transparent); animation: dcm-pulse-peak 2s infinite ease-in-out; }
373
- .dcm-chip-alert-offpeak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary) 50%, transparent); animation: dcm-pulse-offpeak 2s infinite ease-in-out; }
374
- @keyframes dcm-pulse-peak { 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 30%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 70%, transparent); } }
375
- @keyframes dcm-pulse-offpeak { 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-success-primary) 30%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-state-success-primary) 70%, transparent); } }
376
- .dcm-alert-banner { display: flex; align-items: center; gap: 6px; border-radius: 6px; padding: 6px 10px; font-size: 11.5px; line-height: 1.35; margin-top: 6px; }
377
- .dcm-alert-banner-peak { background: color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent); color: var(--dsw-alias-state-warning-primary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 28%, transparent); }
378
- .dcm-alert-banner-offpeak { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); color: var(--dsw-alias-state-success-primary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
379
- .dcm-msg-action-btn { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 1px solid var(--dsw-alias-border-l2); padding: 2px 7px; border-radius: 5px; font-size: 11px; cursor: pointer; color: var(--dsw-alias-label-secondary); transition: all .15s ease; user-select: none; }
380
- .dcm-msg-action-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-state-brand-primary); background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 8%, transparent); }
381
- .dcm-inspector-modal { position: fixed; inset: 0; z-index: 99999; background: color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent); display: flex; align-items: center; justify-content: center; padding: 16px; backdrop-filter: blur(2px); }
382
- .dcm-inspector-card { background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; max-width: 420px; width: 100%; box-shadow: var(--dsw-alias-shadow-layer-3, 0 16px 36px var(--dsw-alias-bg-base)); padding: 16px; font-size: 12px; }
383
- .dcm-inspector-title { font-size: 14px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
384
- .dcm-inspector-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
385
- .dcm-inspector-stat { padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); }
386
- .dcm-inspector-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-tertiary); margin-bottom: 2px; }
387
- .dcm-inspector-stat-val { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
388
- .dcm-turn-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; max-height: 180px; overflow-y: auto; }
389
- .dcm-turn-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-3) 60%, transparent); font-size: 11px; }
390
- `
391
-
392
-
393
-
394
- function ensureStyles() {
395
- if (typeof document === 'undefined') return
396
- if (document.getElementById('dsh-cost-meter-unified-css')) return
397
- const el = document.createElement('style')
398
- el.id = 'dsh-cost-meter-unified-css'
399
- el.dataset.dshPlugin = NS
400
- el.textContent = CSS
401
- document.head.appendChild(el)
402
- }
403
-
404
- function createErrorBoundary() {
405
- if (!React || typeof React.Component !== 'function') {
406
- return function NoopBoundary(props) { return props?.children || null }
407
- }
408
- return class ErrorBoundary extends React.Component {
409
- constructor(props) {
410
- super(props)
411
- this.state = { hasError: false, error: null }
412
- }
413
- static getDerivedStateFromError(error) {
414
- return { hasError: true, error }
415
- }
416
- componentDidCatch(error, errorInfo) {
417
- console.error('[dsh-cost-meter] React error caught:', error, errorInfo)
418
- }
419
- render() {
420
- if (this.state.hasError) {
421
- return React.createElement(
422
- 'div',
423
- { className: 'dcm-alert dcm-alert-err', style: { margin: '8px 0' } },
424
- React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } }, '⚠️ Cost Meter UI Error:'),
425
- React.createElement('div', { style: { fontSize: 12, wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
426
- React.createElement(
427
- 'button',
428
- {
429
- type: 'button',
430
- className: 'dcm-btn',
431
- style: { marginTop: 8, fontSize: 12, padding: '3px 10px' },
432
- onClick: () => this.setState({ hasError: false, error: null }),
433
- },
434
- 'Retry'
435
- )
436
- )
437
- }
438
- return this.props?.children || null
439
- }
440
- }
441
- }
442
- const ErrorBoundary = createErrorBoundary()
443
-
444
- function getSettingsScope(ctx) {
445
- return (ctx && typeof ctx.get === 'function' ? ctx.get('configForms') : null) || (ctx && ctx.configForms) || (ctx && ctx.services && ctx.services.configForms) || null
446
- }
447
-
448
- function refreshMirrorUntilVisible(ctx) {
449
- const s = getSettingsScope(ctx)
450
- const visible = () => {
451
- try {
452
- const view = s?.describe?.()?.getSnapshot?.()?.view
453
- return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
454
- } catch (e) {
455
- if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] mirror check error: ' + (e?.message || e))
456
- return false
457
- }
458
- }
459
- if (!s || visible()) return () => {}
460
- let tries = 0
461
- const timer = setInterval(() => {
462
- if (visible() || tries >= 15) {
463
- if (!visible() && tries >= 15 && ctx?.logger?.warn) {
464
- ctx.logger.warn('[dsh-cost-meter] settings mirror did not appear after 15 attempts')
465
- }
466
- clearInterval(timer)
467
- return
468
- }
469
- tries += 1
470
- try {
471
- s?.describe?.()?.load?.()
472
- } catch (e) {
473
- if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] settings load error: ' + (e?.message || e))
474
- }
475
- }, 1000)
476
- return () => clearInterval(timer)
477
- }
478
-
479
- let ChevronIcon = null
480
- try {
481
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
482
- ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
483
- } catch (e) {
484
- ChevronIcon = null
485
- }
486
-
487
- function FallbackChevron(props) {
488
- return React.createElement(
489
- 'svg',
490
- {
491
- width: 14,
492
- height: 14,
493
- viewBox: '0 0 14 14',
494
- fill: 'none',
495
- 'aria-hidden': true,
496
- },
497
- React.createElement('path', {
498
- d: 'M3.5 5.25L7 8.75L10.5 5.25',
499
- stroke: 'currentColor',
500
- strokeWidth: 1.5,
501
- strokeLinecap: 'round',
502
- strokeLinejoin: 'round',
503
- }),
504
- )
505
- }
506
-
507
- function Chevron(props) {
508
- const icon = ChevronIcon
509
- ? React.createElement(ChevronIcon, { 'aria-hidden': true })
510
- : React.createElement(FallbackChevron, null)
511
- return React.createElement(
512
- 'span',
513
- {
514
- className: 'dcm-chevron' + (props.open ? ' dcm-chevron-open' : ''),
515
- style: { marginLeft: 'auto' },
516
- },
517
- icon,
518
- )
519
- }
520
-
521
- function isValidTimeZone(tz) {
522
- if (!tz || typeof tz !== 'string') return false
523
- try {
524
- new Intl.DateTimeFormat(undefined, { timeZone: tz })
525
- return true
526
- } catch {
527
- return false
528
- }
529
- }
530
-
531
- function ago(timestamp, locale) {
532
- if (!timestamp) return '—'
533
- const isZh = locale === 'zh'
534
- const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
535
- if (minutes < 1) return isZh ? '刚刚' : 'just now'
536
- if (minutes < 60) return minutes + (isZh ? ' 分钟前' : ' min ago')
537
- return hours < 48 ? hours + (isZh ? ' 小时前' : ' hrs ago') : Math.round(hours / 24) + (isZh ? ' 天前' : ' days ago')
538
- }
539
-
540
- function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
541
- const { signal, ...rest } = options
542
- const timer = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
543
- ? AbortSignal.timeout(timeoutMs)
544
- : null
545
- let combined = signal
546
- if (timer) {
547
- if (signal && typeof AbortSignal.any === 'function') {
548
- combined = AbortSignal.any([signal, timer])
549
- } else if (!signal) {
550
- combined = timer
551
- }
552
- }
553
- return fetch(url, { ...rest, ...(combined ? { signal: combined } : {}) })
554
- }
555
-
556
- // ---------------------------------------------------------------- card
557
-
558
- function CostMeterCard(props) {
559
- const ctx = props.ctx
560
- const locale = useActiveLocale(ctx)
561
- const t = props.t || makeT(locale === 'zh' ? zh : en, en)
562
- const page = !!(props && props.view === 'page')
563
- const [open, setOpen] = React.useState(!!page)
564
- const [draft, setDraft] = React.useState(null)
565
- const [saving, setSaving] = React.useState(false)
566
- const [err, setErr] = React.useState('')
567
- const [saved, setSaved] = React.useState(false)
568
- const [diagState, setDiagState] = React.useState(null)
569
-
570
- // Manual sync button state
571
- const [syncing, setSyncing] = React.useState(false)
572
- const [syncMsg, setSyncMsg] = React.useState('')
573
-
574
- // Plugin in-app updater state
575
- const [updateState, setUpdateState] = React.useState({
576
- checking: false,
577
- updating: false,
578
- currentVersion: '',
579
- latestVersion: '',
580
- updateAvailable: false,
581
- canAutoUpdate: true,
582
- error: '',
583
- notice: '',
584
- })
585
-
586
- const checkUpdate = React.useCallback(async (signal) => {
587
- setUpdateState((s) => ({ ...s, checking: true, error: '' }))
588
- try {
589
- const res = await fetchWithTimeout(UPDATE_PATH, { signal })
590
- if (!res.ok) throw new Error('HTTP ' + res.status)
591
- const data = await res.json().catch(() => ({}))
592
- setUpdateState((s) => ({
593
- ...s,
594
- checking: false,
595
- currentVersion: data.currentVersion || s.currentVersion,
596
- latestVersion: data.latestVersion || '',
597
- updateAvailable: Boolean(data.updateAvailable),
598
- canAutoUpdate: data.canAutoUpdate !== false,
599
- }))
600
- } catch (e) {
601
- if (e?.name !== 'AbortError') {
602
- setUpdateState((s) => ({ ...s, checking: false }))
603
- }
604
- }
605
- }, [])
606
-
607
- React.useEffect(() => {
608
- if (!open) return
609
- const controller = new AbortController()
610
- checkUpdate(controller.signal)
611
- return () => controller.abort()
612
- }, [open, checkUpdate])
613
-
614
- async function handleTriggerUpdate() {
615
- if (updateState.updating) return
616
- setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
617
- try {
618
- const res = await fetchWithTimeout(UPDATE_PATH, {
619
- method: 'POST',
620
- headers: { 'x-dsh-plugin-update': '1' },
621
- }, 30000)
622
- const data = await res.json().catch(() => ({}))
623
- if (!res.ok || data.ok === false || data.error) {
624
- throw new Error(data.error || ('HTTP ' + res.status))
625
- }
626
- const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
627
- setUpdateState((s) => ({
628
- ...s,
629
- updating: false,
630
- updateAvailable: false,
631
- currentVersion: newVer,
632
- notice: t('update.done', { version: newVer }),
633
- }))
634
- setTimeout(() => checkUpdate(), 2000)
635
- } catch (err) {
636
- setUpdateState((s) => ({
637
- ...s,
638
- updating: false,
639
- error: t('update.failed', { error: String(err.message || err) }),
640
- }))
641
- }
642
- }
643
-
644
- const configForms = getSettingsScope(ctx)
645
- const scope = React.useMemo(
646
- () => (configForms ? configForms.get(NS) : undefined),
647
- [configForms],
648
- )
649
-
650
- const snapshot = React.useSyncExternalStore(
651
- React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
652
- React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
653
- React.useCallback(() => ({ status: 'loading' }), []),
654
- )
655
-
656
- React.useEffect(() => { ensureStyles() }, [])
657
-
658
- const fetchDiag = React.useCallback((signal) => {
659
- fetchWithTimeout(STATE_PATH, { signal, headers: { accept: 'application/json' } })
660
- .then((r) => (r.ok ? r.json() : null))
661
- .then((data) => { if (data) setDiagState(data) })
662
- .catch(() => {})
663
- }, [])
664
-
665
- React.useEffect(() => {
666
- if (!open) return
667
- const controller = new AbortController()
668
- fetchDiag(controller.signal)
669
- return () => controller.abort()
670
- }, [open, fetchDiag])
671
-
672
- const status = (snapshot && snapshot.status) || 'loading'
673
- const stored = (snapshot && snapshot.value) || {}
674
-
675
- React.useEffect(() => {
676
- if (status === 'ready' && draft === null) {
677
- setDraft({
678
- currency: stored.currency !== undefined ? String(stored.currency) : '$',
679
- usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
680
- budgetThreshold: stored.budgetThreshold !== undefined ? String(stored.budgetThreshold) : '0',
681
- displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
682
- alertBeforeSwitch: stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : true,
683
- alertLeadMinutes: stored.alertLeadMinutes !== undefined ? String(stored.alertLeadMinutes) : '5',
684
- browserNotifications: stored.browserNotifications !== undefined ? Boolean(stored.browserNotifications) : false,
685
- useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
686
- refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
687
- })
688
- }
689
- }, [status, stored, draft])
690
-
691
- const tzValid = draft ? isValidTimeZone(draft.displayTimeZone) : true
692
- const rateValid = draft ? (!isNaN(Number(draft.usdRate)) && Number(draft.usdRate) > 0) : true
693
- const budgetValid = draft ? (!isNaN(Number(draft.budgetThreshold)) && Number(draft.budgetThreshold) >= 0) : true
694
- const hoursValid = draft ? (!isNaN(Number(draft.refreshHours)) && Number(draft.refreshHours) >= 1 && Number(draft.refreshHours) <= 168) : true
695
-
696
- const handleManualSync = async () => {
697
- if (syncing) return
698
- setSyncing(true)
699
- setSyncMsg('')
700
- try {
701
- const res = await fetchWithTimeout(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
702
- if (res.status === 429) {
703
- const data = await res.json().catch(() => ({}))
704
- const retryAfter = data.retryAfter || 30
705
- setSyncMsg(t('action.sync_rate_limited', { seconds: retryAfter }))
706
- } else if (res.ok) {
707
- setSyncMsg(t('action.sync_ok'))
708
- fetchDiag()
709
- } else {
710
- setSyncMsg(t('action.sync_err'))
711
- }
712
- } catch (_) {
713
- setSyncMsg(t('action.sync_err'))
714
- } finally {
715
- setSyncing(false)
716
- setTimeout(() => setSyncMsg(''), 4000)
717
- }
718
- }
719
-
720
- const save = async () => {
721
- if (!scope || !draft) return
722
- setSaving(true); setErr(''); setSaved(false)
723
- const broken = []
724
-
725
- try {
726
- await scope.set('displayMode', (draft.displayMode === 'compact' || draft.displayMode === 'full') ? draft.displayMode : 'auto')
727
- } catch (e) { broken.push('displayMode: ' + (e && e.message || String(e))) }
728
-
729
- try {
730
- await scope.set('currency', String(draft.currency || '$'))
731
- } catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
732
-
733
- const rateNum = Number(String(draft.usdRate).trim())
734
- if (!Number.isFinite(rateNum) || rateNum <= 0) {
735
- broken.push('usdRate: must be a positive number')
736
- } else {
737
- try {
738
- await scope.set('usdRate', rateNum)
739
- } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
740
- }
741
-
742
- const budgetNum = Number(String(draft.budgetThreshold).trim())
743
- if (!Number.isFinite(budgetNum) || budgetNum < 0) {
744
- broken.push('budgetThreshold: must be non-negative')
745
- } else {
746
- try {
747
- await scope.set('budgetThreshold', budgetNum)
748
- } catch (e) { broken.push('budgetThreshold: ' + (e && e.message || String(e))) }
749
- }
750
-
751
- const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
752
- if (!isValidTimeZone(tzVal)) {
753
- broken.push('displayTimeZone: invalid IANA time zone')
754
- } else {
755
- try {
756
- await scope.set('displayTimeZone', tzVal)
757
- } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
758
- }
759
-
760
- try {
761
- await scope.set('useOpenRouter', Boolean(draft.useOpenRouter))
762
- } catch (e) { broken.push('useOpenRouter: ' + (e && e.message || String(e))) }
763
-
764
- const hoursNum = Number(String(draft.refreshHours).trim())
765
- if (!Number.isFinite(hoursNum) || hoursNum < 1 || hoursNum > 168) {
766
- broken.push('refreshHours: must be between 1 and 168 hours')
767
- } else {
768
- try {
769
- await scope.set('refreshHours', hoursNum)
770
- } catch (e) { broken.push('refreshHours: ' + (e && e.message || String(e))) }
771
- }
772
-
773
- setSaving(false)
774
- if (broken.length) {
775
- setErr(t('settings.saveFailed') + broken.join('; '))
776
- return
777
- }
778
- setSaved(true)
779
- setTimeout(() => setSaved(false), 2500)
780
- }
781
-
782
- const modelsCount = (diagState && typeof diagState.catalogModelsCount === 'number')
783
- ? diagState.catalogModelsCount
784
- : ((diagState && diagState.schedules) ? Object.keys(diagState.schedules).length : null)
785
- const catalogHasErr = diagState && Boolean(diagState.catalogError)
786
- const openRouterEnabled = draft ? Boolean(draft.useOpenRouter) : true
787
-
788
- // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
789
- // padding, so the summary is a one-liner and the page drops our card chrome.
790
- if (props && props.view === 'summary') {
791
- return React.createElement('span', { className: 'dcm-sub' }, t('header.sub'))
792
- }
793
-
794
- return React.createElement(page ? 'div' : 'li', { className: page ? 'dcm-page' : 'dcm-card' },
795
- React.createElement('button', {
796
- type: 'button', className: 'dcm-head', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open,
797
- onClick: () => setOpen((v) => !v),
798
- },
799
- React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
800
- React.createElement('span', { className: 'dcm-title' }, t('header.title')),
801
- React.createElement('span', { className: 'dcm-sub' }, t('header.sub')),
802
- React.createElement('div', { className: 'dcm-badges' },
803
- React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.deepseek')),
804
- openRouterEnabled
805
- ? (catalogHasErr
806
- ? React.createElement('span', { className: 'dcm-badge dcm-badge-bad' }, t('badge.catalog_err'))
807
- : (modelsCount !== null && modelsCount > 0
808
- ? React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.catalog_ok', { count: modelsCount }))
809
- : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_empty'))))
810
- : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_off')),
811
- catalogHasErr && modelsCount ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_stale')) : null,
812
- draft && draft.displayTimeZone
813
- ? React.createElement('span', { className: 'dcm-badge' }, draft.displayTimeZone)
814
- : null,
815
- ),
816
- ),
817
- React.createElement(Chevron, { open }),
818
- ),
819
- (page || open) ? React.createElement('div', { className: 'dcm-body' },
820
- status === 'loading'
821
- ? React.createElement('div', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
822
- : status !== 'ready'
823
- ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
824
- : React.createElement(React.Fragment, null,
825
- // In-app Update Bar
826
- React.createElement('div', {
827
- className: 'dcm-section-card',
828
- style: { padding: '10px 14px', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
829
- },
830
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 } },
831
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
832
- 'v' + updateState.currentVersion
833
- ),
834
- updateState.checking
835
- ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 } }, t('update.checking'))
836
- : updateState.updateAvailable
837
- ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' },
838
- t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
839
- )
840
- : React.createElement('span', { className: 'dcm-badge dcm-badge-ok' },
841
- '✓ ' + t('update.up_to_date')
842
- )
843
- ),
844
- updateState.updateAvailable
845
- ? React.createElement('button', {
846
- type: 'button',
847
- className: 'dcm-btn dcm-btn-primary',
848
- disabled: updateState.updating,
849
- onClick: handleTriggerUpdate,
850
- style: { padding: '4px 10px', fontSize: 12 },
851
- }, updateState.updating ? t('update.updating') : t('update.btn'))
852
- : null
853
- ),
854
- updateState.notice ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, '✓ ' + updateState.notice) : null,
855
- updateState.error ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, updateState.error) : null,
856
-
857
- // Section 1: General Parameters
858
- React.createElement('div', { className: 'dcm-section-card' },
859
- React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
860
- React.createElement('div', { className: 'dcm-section-desc' }, t('section.general_desc')),
861
- React.createElement('div', { className: 'dcm-grid-2' },
862
- React.createElement('div', { className: 'dcm-field' },
863
- React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
864
- React.createElement('input', {
865
- className: 'dcm-input',
866
- value: (draft && draft.currency) !== undefined ? draft.currency : '',
867
- onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
868
- }),
869
- React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
870
- ),
871
- React.createElement('div', { className: 'dcm-field' },
872
- React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
873
- React.createElement('input', {
874
- className: 'dcm-input' + (rateValid ? '' : ' dcm-input-err'),
875
- type: 'number',
876
- step: 'any',
877
- value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
878
- onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
879
- }),
880
- React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
881
- ),
882
- ),
883
- React.createElement('div', { className: 'dcm-grid-2' },
884
- React.createElement('div', { className: 'dcm-field' },
885
- React.createElement('label', { className: 'dcm-label' }, t('field.budgetThreshold')),
886
- React.createElement('input', {
887
- className: 'dcm-input' + (budgetValid ? '' : ' dcm-input-err'),
888
- type: 'number',
889
- step: 'any',
890
- min: 0,
891
- value: (draft && draft.budgetThreshold) !== undefined ? draft.budgetThreshold : '',
892
- onChange: (e) => setDraft({ ...draft, budgetThreshold: e.target.value }),
893
- }),
894
- React.createElement('span', { className: 'dcm-hint' }, t('hint.budgetThreshold')),
895
- ),
896
- React.createElement('div', { className: 'dcm-field' },
897
- React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
898
- React.createElement('input', {
899
- className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
900
- value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
901
- onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
902
- }),
903
- React.createElement('span', { className: 'dcm-hint' },
904
- tzValid ? t('hint.displayTimeZone') : t('tz.invalid')
905
- ),
906
- ),
907
- ),
908
- React.createElement('div', { className: 'dcm-field', style: { marginTop: 6 } },
909
- React.createElement('label', { className: 'dcm-label' }, t('field.displayMode')),
910
- React.createElement('select', {
911
- className: 'dcm-input',
912
- value: (draft && draft.displayMode) || 'auto',
913
- onChange: (e) => setDraft({ ...draft, displayMode: e.target.value }),
914
- }, [
915
- React.createElement('option', { key: 'auto', value: 'auto' }, t('mode.auto')),
916
- React.createElement('option', { key: 'full', value: 'full' }, t('mode.full')),
917
- React.createElement('option', { key: 'compact', value: 'compact' }, t('mode.compact')),
918
- ]),
919
- React.createElement('span', { className: 'dcm-hint' }, t('hint.displayMode')),
920
- ),
921
- ),
922
-
923
- // Section: Tariff Transition Alerts (Issue #117)
924
- React.createElement('div', { className: 'dcm-section-card' },
925
- React.createElement('div', { className: 'dcm-section-title' }, t('section.alerts')),
926
- React.createElement('div', { className: 'dcm-section-desc' }, t('section.alerts_desc')),
927
- React.createElement('div', { className: 'dcm-row-check' },
928
- React.createElement('input', {
929
- id: 'dcm-alert-switch',
930
- type: 'checkbox',
931
- className: 'dcm-check',
932
- checked: draft ? Boolean(draft.alertBeforeSwitch) : true,
933
- onChange: (e) => setDraft({ ...draft, alertBeforeSwitch: e.target.checked }),
934
- }),
935
- React.createElement('label', { htmlFor: 'dcm-alert-switch', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.alertBeforeSwitch')),
936
- ),
937
- React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.alertBeforeSwitch')),
938
- React.createElement('div', { className: 'dcm-grid-2', style: { marginTop: 6 } },
939
- React.createElement('div', { className: 'dcm-field' },
940
- React.createElement('label', { className: 'dcm-label' }, t('field.alertLeadMinutes')),
941
- React.createElement('select', {
942
- className: 'dcm-input',
943
- value: (draft && draft.alertLeadMinutes) || '5',
944
- onChange: (e) => setDraft({ ...draft, alertLeadMinutes: e.target.value }),
945
- }, [
946
- React.createElement('option', { key: '1', value: '1' }, '1 min'),
947
- React.createElement('option', { key: '2', value: '2' }, '2 min'),
948
- React.createElement('option', { key: '5', value: '5' }, '5 min'),
949
- React.createElement('option', { key: '10', value: '10' }, '10 min'),
950
- ]),
951
- React.createElement('span', { className: 'dcm-hint' }, t('hint.alertLeadMinutes')),
952
- ),
953
- React.createElement('div', { className: 'dcm-field' },
954
- React.createElement('label', { className: 'dcm-label' }, t('field.browserNotifications')),
955
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, height: 34 } },
956
- React.createElement('input', {
957
- id: 'dcm-browser-notif',
958
- type: 'checkbox',
959
- className: 'dcm-check',
960
- checked: draft ? Boolean(draft.browserNotifications) : false,
961
- onChange: (e) => {
962
- const checked = e.target.checked
963
- setDraft({ ...draft, browserNotifications: checked })
964
- if (checked && typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
965
- Notification.requestPermission()
966
- }
967
- },
968
- }),
969
- React.createElement('button', {
970
- type: 'button',
971
- className: 'dcm-btn dcm-btn-sm',
972
- onClick: () => {
973
- if (typeof Notification === 'undefined') return
974
- Notification.requestPermission().then((p) => {
975
- if (p === 'granted') {
976
- new Notification('DeepSeek Tariff Alert (Test)', {
977
- body: t('alert.pre_switch_offpeak', { time: '5m' }),
978
- })
979
- }
980
- })
981
- },
982
- }, t('action.test_notification')),
983
- ),
984
- React.createElement('span', { className: 'dcm-hint' }, t('hint.browserNotifications')),
985
- ),
986
- ),
987
- ),
988
-
989
- // Section 2: OpenRouter Pricing Catalog
990
- React.createElement('div', { className: 'dcm-section-card' },
991
- React.createElement('div', { className: 'dcm-section-title' },
992
- React.createElement('span', null, t('section.openrouter')),
993
- openRouterEnabled
994
- ? React.createElement('button', {
995
- type: 'button',
996
- className: 'dcm-btn dcm-btn-sm',
997
- disabled: syncing,
998
- onClick: handleManualSync,
999
- }, syncing ? t('action.syncing') : t('action.sync_now'))
1000
- : null
1001
- ),
1002
- React.createElement('div', { className: 'dcm-section-desc' }, t('section.openrouter_desc')),
1003
- syncMsg ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok', style: { padding: '6px 10px', fontSize: 12 } }, syncMsg) : null,
1004
- React.createElement('div', { className: 'dcm-row-check' },
1005
- React.createElement('input', {
1006
- id: 'dcm-use-openrouter',
1007
- type: 'checkbox',
1008
- className: 'dcm-check',
1009
- checked: draft ? Boolean(draft.useOpenRouter) : true,
1010
- onChange: (e) => setDraft({ ...draft, useOpenRouter: e.target.checked }),
1011
- }),
1012
- React.createElement('label', { htmlFor: 'dcm-use-openrouter', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.useOpenRouter')),
1013
- ),
1014
- React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.useOpenRouter')),
1015
- React.createElement('div', { className: 'dcm-field', style: { marginTop: 4 } },
1016
- React.createElement('label', { className: 'dcm-label' }, t('field.refreshHours')),
1017
- React.createElement('input', {
1018
- className: 'dcm-input' + (hoursValid ? '' : ' dcm-input-err'),
1019
- type: 'number',
1020
- min: 1,
1021
- max: 168,
1022
- value: (draft && draft.refreshHours) !== undefined ? draft.refreshHours : '',
1023
- onChange: (e) => setDraft({ ...draft, refreshHours: e.target.value }),
1024
- }),
1025
- React.createElement('span', { className: 'dcm-hint' }, t('hint.refreshHours')),
1026
- ),
1027
- ),
1028
-
1029
- // Section 3: Live Diagnostics
1030
- React.createElement('div', { className: 'dcm-section-card' },
1031
- React.createElement('div', { className: 'dcm-section-title' }, t('section.diagnostics')),
1032
- React.createElement('div', { className: 'dcm-section-desc' }, t('section.diagnostics_desc')),
1033
- React.createElement('div', { className: 'dcm-grid-2' },
1034
- React.createElement('div', { className: 'dcm-stat-box' },
1035
- React.createElement('span', { className: 'dcm-stat-val' },
1036
- modelsCount !== null ? String(modelsCount) : '—'
1037
- ),
1038
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.models_count')),
1039
- ),
1040
- React.createElement('div', { className: 'dcm-stat-box' },
1041
- React.createElement('span', { className: 'dcm-stat-val' },
1042
- diagState ? ago(diagState.fetchedAt, locale) : '—'
1043
- ),
1044
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.last_sync')),
1045
- ),
1046
- React.createElement('div', { className: 'dcm-stat-box' },
1047
- React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
1048
- diagState && diagState.route ? (diagState.route.model || diagState.route.provider) : 'default'
1049
- ),
1050
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.route')),
1051
- ),
1052
- React.createElement('div', { className: 'dcm-stat-box' },
1053
- React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
1054
- diagState && diagState.source ? String(diagState.source).toUpperCase() : 'DEEPSEEK'
1055
- ),
1056
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.tariff_source')),
1057
- ),
1058
- ),
1059
- ),
1060
-
1061
- // Alerts & Action Footer
1062
- err ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, err) : null,
1063
- saved ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, t('settings.saved')) : null,
1064
- React.createElement('div', { className: 'dcm-foot' },
1065
- React.createElement('button', {
1066
- type: 'button',
1067
- className: 'dcm-btn dcm-btn-primary',
1068
- disabled: saving || !tzValid || !rateValid || !budgetValid || !hoursValid,
1069
- onClick: save,
1070
- }, saving ? t('settings.saving') : t('settings.save')),
1071
- ),
1072
- )
1073
- ) : null,
1074
- )
1075
- }
1076
-
1077
- // ---------------------------------------------------------------- tariff
1078
-
1079
- function inWindow(minute, w) {
1080
- return w.start <= w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
1081
- }
1082
-
1083
- function ratesAt(schedule, minute) {
1084
- const windows = (schedule && schedule.windows) || []
1085
- for (const w of windows) if (inWindow(minute, w)) return w.rates
1086
- return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
1087
- }
1088
-
1089
- function minutesUntilChange(schedule, minute) {
1090
- const windows = (schedule && schedule.windows) || []
1091
- if (!windows.length) return null
1092
- const now = ratesAt(schedule, minute)
1093
- const transitions = new Set()
1094
- for (const w of windows) {
1095
- transitions.add(w.start % DAY)
1096
- transitions.add(w.end % DAY)
1097
- }
1098
- let minDiff = null
1099
- for (const t of transitions) {
1100
- let diff = (t - minute + DAY) % DAY
1101
- if (diff === 0) diff = DAY
1102
- const probe = ratesAt(schedule, (minute + diff) % DAY)
1103
- if (probe.output !== now.output || probe.input !== now.input) {
1104
- if (minDiff === null || diff < minDiff) minDiff = diff
1105
- }
1106
- }
1107
- return minDiff
1108
- }
1109
-
1110
- const extremesMemo = new WeakMap()
1111
- function extremes(schedule) {
1112
- if (!schedule) return { low: {}, high: {}, flat: true }
1113
- if (extremesMemo.has(schedule)) return extremesMemo.get(schedule)
1114
-
1115
- const base = schedule.base || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
1116
- const candidates = [base]
1117
- for (const w of (schedule.windows || [])) {
1118
- if (w.rates) candidates.push(w.rates)
1119
- }
1120
-
1121
- let low = candidates[0]
1122
- let high = candidates[0]
1123
- for (const r of candidates) {
1124
- if (r.output < low.output) low = r
1125
- if (r.output > high.output) high = r
1126
- }
1127
-
1128
- const res = { low, high, flat: low.output === high.output }
1129
- extremesMemo.set(schedule, res)
1130
- return res
1131
- }
1132
-
1133
- function sumSlots(slots) {
1134
- const total = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
1135
- for (const key of Object.keys(slots || {})) {
1136
- const b = slots[key] || {}
1137
- for (const field of Object.keys(total)) total[field] += Number(b[field]) || 0
1138
- }
1139
- return total
1140
- }
1141
-
1142
- function spendBySlots(slots, schedule) {
1143
- let usd = 0
1144
- for (const key of Object.keys(slots || {})) {
1145
- const minute = (Number(key) * 30 + 15) % 1440
1146
- usd += spendUsd(slots[key], ratesAt(schedule, minute))
1147
- }
1148
- return usd
1149
- }
1150
-
1151
- function cacheSavingsBySlots(slots, schedule) {
1152
- let savedUsd = 0
1153
- let missedUsd = 0
1154
- let readTokens = 0
1155
- for (const key of Object.keys(slots || {})) {
1156
- const minute = (Number(key) * 30 + 15) % 1440
1157
- const rates = ratesAt(schedule, minute)
1158
- const u = slots[key] || {}
1159
- const cRead = Number(u.cacheReadTokens) || 0
1160
- if (cRead > 0 && rates && rates.input > rates.cacheHit) {
1161
- readTokens += cRead
1162
- const missed = (cRead / 1e6) * (rates.input || 0)
1163
- const hit = (cRead / 1e6) * (rates.cacheHit || 0)
1164
- savedUsd += Math.max(0, missed - hit)
1165
- missedUsd += missed
1166
- }
1167
- }
1168
- const percent = missedUsd > 0 ? Math.round((savedUsd / missedUsd) * 100) : 0
1169
- return { savedUsd, missedUsd, readTokens, percent }
1170
- }
1171
-
1172
-
1173
- function spendUsd(usage, rates) {
1174
- const u = usage || {}
1175
- const perMillion =
1176
- (Number(u.cacheReadTokens) || 0) * (rates.cacheHit || 0) +
1177
- (Number(u.uncachedInputTokens) || 0) * (rates.input || 0) +
1178
- (Number(u.cacheWriteTokens) || 0) * (rates.cacheWrite || 0) +
1179
- (Number(u.outputTokens) || 0) * (rates.output || 0)
1180
- return perMillion / 1e6
1181
- }
1182
-
1183
- function rate(value) {
1184
- if (!value) return '—'
1185
- const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
1186
- return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
1187
- }
1188
-
1189
- function money(value, currency) {
1190
- const n = Number(value) || 0
1191
- return currency + n.toFixed(Math.abs(n) >= 100 ? 0 : 2)
1192
- }
1193
-
1194
- function tokens(value) {
1195
- const n = Number(value) || 0
1196
- if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
1197
- if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
1198
- return String(n)
1199
- }
1200
-
1201
- function clock(minutes) {
1202
- if (minutes === null) return null
1203
- return Math.floor(minutes / 60) + ':' + String(minutes % 60).padStart(2, '0')
1204
- }
1205
-
1206
- function hhmm(minute) {
1207
- return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
1208
- }
1209
-
1210
- function hhmmInZone(minute, zone, reference) {
1211
- const d = new Date(Date.UTC(
1212
- reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
1213
- ))
1214
- try {
1215
- return new Intl.DateTimeFormat('en-GB', {
1216
- hour: '2-digit', minute: '2-digit', hour12: false, timeZone: zone,
1217
- }).format(d)
1218
- } catch {
1219
- return hhmm(minute)
1220
- }
1221
- }
1222
-
1223
- function zoneLabel(zone) {
1224
- if (!zone) return 'UTC'
1225
- const city = zone.includes('/') ? zone.slice(zone.lastIndexOf('/') + 1) : zone
1226
- return city.replace(/_/g, ' ')
1227
- }
1228
-
1229
-
1230
-
1231
- function calculateBurnRateAndProjection(turns, totalCostUsd, options = {}) {
1232
- const currentTotal = Number(totalCostUsd) > 0 ? Number(totalCostUsd) : 0
1233
- if (!Array.isArray(turns) || turns.length < 2) {
1234
- return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1235
- }
1236
- const now = typeof options.now === 'number' ? options.now : Date.now()
1237
- const windowMs = 10 * 60 * 1000
1238
- const sorted = turns.filter((t) => t && typeof t.timestamp === 'number').sort((a, b) => a.timestamp - b.timestamp)
1239
- if (sorted.length < 2) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1240
- let sample = sorted.filter((t) => t.timestamp >= now - windowMs)
1241
- if (sample.length < 2) sample = sorted.slice(-Math.min(sorted.length, 10))
1242
- const durationMs = sample[sample.length - 1].timestamp - sample[0].timestamp
1243
- if (durationMs < 5000) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1244
- let costInSample = sample.reduce((acc, t) => acc + (Number(t.costDeltaUsd) || 0), 0)
1245
- if (costInSample <= 0 && currentTotal > 0) {
1246
- const totalTokens = sorted.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
1247
- const sampleTokens = sample.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
1248
- costInSample = totalTokens > 0 ? (sampleTokens / totalTokens) * currentTotal : currentTotal
1249
- }
1250
- const hours = durationMs / (1000 * 60 * 60)
1251
- const rawBurnRate = hours > 0 ? (costInSample / hours) : 0
1252
- const burnRatePerHourUsd = (Number.isFinite(rawBurnRate) && rawBurnRate >= 0) ? Math.round(rawBurnRate * 10000) / 10000 : 0
1253
- const stepCount = sorted.length
1254
- const avgCostPerStep = currentTotal / stepCount
1255
- const projectedExtra = Math.min(currentTotal * 2, Math.max(avgCostPerStep * 3, burnRatePerHourUsd * 0.25))
1256
- const projectedCostUsd = Math.round((currentTotal + (projectedExtra > 0 ? projectedExtra : 0)) * 10000) / 10000
1257
- return { burnRatePerHourUsd, projectedCostUsd }
1258
- }
1259
-
1260
- function renderSparkline(turns, options) {
1261
- if (!Array.isArray(turns) || turns.length === 0) return null
1262
- const { h, currency, fx, activeRate, t } = options
1263
-
1264
- const maxTokens = Math.max(1, ...turns.map((item) => Number(item.tokens) || 0))
1265
- const height = 36
1266
- const width = 240
1267
- const count = turns.length
1268
- const gap = count > 15 ? 2 : 4
1269
- const barWidth = Math.max(3, Math.min(16, Math.floor((width - (count - 1) * gap) / count)))
1270
- const totalBarSpace = count * barWidth + (count - 1) * gap
1271
- const startX = Math.max(0, Math.floor((width - totalBarSpace) / 2))
1272
-
1273
- const bars = turns.map((item, idx) => {
1274
- const tok = Number(item.tokens) || 0
1275
- const barHeight = Math.max(3, Math.round((tok / maxTokens) * (height - 6)))
1276
- const x = startX + idx * (barWidth + gap)
1277
- const y = height - barHeight
1278
- const isPeak = tok === maxTokens && tok > 0
1279
- const stepCost = (item.costDeltaUsd && item.costDeltaUsd > 0)
1280
- ? item.costDeltaUsd * fx
1281
- : (activeRate ? (tok / 1e6) * ((activeRate.input + activeRate.output) / 2) * fx : 0)
1282
-
1283
- const tip = (t ? t('sparkline.steps') : 'step') + ' ' + (item.turn || idx + 1) +
1284
- (item.step ? '.' + item.step : '') + ': ' + tokens(tok) +
1285
- (stepCost > 0 ? ' (≈ ' + money(stepCost, currency) + ')' : '')
1286
-
1287
- return h('rect', {
1288
- key: 'bar-' + idx,
1289
- x,
1290
- y,
1291
- width: barWidth,
1292
- height: barHeight,
1293
- rx: 2,
1294
- ry: 2,
1295
- className: isPeak ? 'dcm-spark-bar dcm-spark-peak' : 'dcm-spark-bar',
1296
- }, h('title', null, tip))
1297
- })
1298
-
1299
- return h('div', { key: 'sparkline', className: 'dcm-sparkline-box' }, [
1300
- h('div', { key: 'spark-hdr', className: 'dcm-sparkline-header' }, [
1301
- h('span', { key: 'lbl', className: 'dcm-section-lbl' }, t ? t('sparkline.title') : 'Turn dynamics'),
1302
- h('span', { key: 'cnt', className: 'dcm-sparkline-count' }, count + ' ' + (t ? t('sparkline.steps') : 'steps')),
1303
- ]),
1304
- h('svg', {
1305
- key: 'spark-svg',
1306
- viewBox: '0 0 ' + width + ' ' + height,
1307
- className: 'dcm-sparkline-svg',
1308
- preserveAspectRatio: 'xMidYMid meet',
1309
- }, bars),
1310
- ])
1311
- }
1312
-
1313
-
1314
- // --------------------------------------------------- MessageTokenAction (Issue #120)
1315
-
1316
- function MessageTokenAction(props) {
1317
- const ctx = props.ctx
1318
- const locale = useActiveLocale(ctx)
1319
- const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1320
- const [open, setOpen] = React.useState(false)
1321
- const useProjection = props.useProjection || (ctx && ctx.useProjection)
1322
- const byModel = useProjection ? useProjection('costByModel') : undefined
1323
-
1324
- const turns = (byModel && Array.isArray(byModel.turns) && byModel.turns.length > 0)
1325
- ? byModel.turns
1326
- : []
1327
-
1328
- // Match turn number if passed by slot or use latest turn
1329
- const msgTurn = typeof props.turn === 'number'
1330
- ? props.turn
1331
- : (props.message && typeof props.message.turn === 'number' ? props.message.turn : null)
1332
-
1333
- const tr = msgTurn !== null
1334
- ? (turns.find((x) => x.turn === msgTurn) || turns[turns.length - 1])
1335
- : (turns.length > 0 ? turns[turns.length - 1] : null)
1336
-
1337
- if (!tr) return null
1338
-
1339
- const tok = Number(tr.tokens) || 0
1340
- const uncached = Number(tr.uncachedInputTokens) || 0
1341
- const hit = Number(tr.cacheReadTokens) || 0
1342
- const out = Number(tr.outputTokens) || 0
1343
- const hitRate = tr.cacheHitRate !== undefined
1344
- ? tr.cacheHitRate
1345
- : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1346
- const durMs = Number(tr.durationMs) || 0
1347
- const cost = Number(tr.costDeltaUsd) || 0
1348
-
1349
- const fmtDur = (ms) => {
1350
- if (!ms || ms <= 0) return '0s'
1351
- const s = Math.round(ms / 1000)
1352
- const m = Math.floor(s / 60)
1353
- const r = s % 60
1354
- return m > 0 ? (m + 'm ' + r + 's') : (r + 's')
1355
- }
1356
-
1357
- return React.createElement(React.Fragment, null,
1358
- React.createElement('button', {
1359
- key: 'btn',
1360
- type: 'button',
1361
- className: 'dcm-msg-action-btn',
1362
- title: t('turn.modal_title'),
1363
- onClick: () => setOpen(true),
1364
- }, [
1365
- React.createElement('span', { key: 'icon' }, '⚡'),
1366
- React.createElement('span', { key: 't' }, tokens(tok)),
1367
- ]),
1368
- open ? React.createElement('div', {
1369
- key: 'modal',
1370
- className: 'dcm-inspector-modal',
1371
- onClick: (e) => { if (e.target === e.currentTarget) setOpen(false) },
1372
- },
1373
- React.createElement('div', { className: 'dcm-inspector-card' }, [
1374
- React.createElement('div', { key: 'head', className: 'dcm-inspector-title' }, [
1375
- React.createElement('span', { key: 't' }, t('turn.modal_title')),
1376
- React.createElement('button', {
1377
- key: 'close',
1378
- type: 'button',
1379
- className: 'dcm-btn dcm-btn-sm',
1380
- onClick: () => setOpen(false),
1381
- }, '✕'),
1382
- ]),
1383
- React.createElement('div', { key: 'sub', style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11, marginBottom: 10 } },
1384
- t('turn.turn_step', { turn: tr.turn, step: tr.step || 0 })
1385
- ),
1386
- React.createElement('div', { key: 'grid', className: 'dcm-inspector-grid' }, [
1387
- React.createElement('div', { key: 's1', className: 'dcm-inspector-stat' }, [
1388
- React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.tokens_total')),
1389
- React.createElement('div', { className: 'dcm-inspector-stat-val' }, tokens(tok)),
1390
- ]),
1391
- React.createElement('div', { key: 's2', className: 'dcm-inspector-stat' }, [
1392
- React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cache_hit_rate')),
1393
- React.createElement('div', { className: 'dcm-inspector-stat-val' }, hitRate + '%'),
1394
- ]),
1395
- React.createElement('div', { key: 's3', className: 'dcm-inspector-stat' }, [
1396
- React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.duration')),
1397
- React.createElement('div', { className: 'dcm-inspector-stat-val' }, fmtDur(durMs)),
1398
- ]),
1399
- React.createElement('div', { key: 's4', className: 'dcm-inspector-stat' }, [
1400
- React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cost_title')),
1401
- React.createElement('div', { className: 'dcm-inspector-stat-val' }, cost > 0 ? ('$' + cost.toFixed(4)) : '—'),
1402
- ]),
1403
- ]),
1404
- React.createElement('div', { key: 'breakdown', style: { display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11.5, marginTop: 8 } }, [
1405
- React.createElement('div', { key: 'b1', style: { display: 'flex', justifyContent: 'space-between' } }, [
1406
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.uncached_in')),
1407
- React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(uncached)),
1408
- ]),
1409
- React.createElement('div', { key: 'b2', style: { display: 'flex', justifyContent: 'space-between' } }, [
1410
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.cache_read')),
1411
- React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(hit)),
1412
- ]),
1413
- React.createElement('div', { key: 'b3', style: { display: 'flex', justifyContent: 'space-between' } }, [
1414
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.output')),
1415
- React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(out)),
1416
- ]),
1417
- ]),
1418
- ])
1419
- ) : null
1420
- )
1421
- }
1422
-
1423
-
1424
- // ------------------------------------------------------------ CostMeter
1425
-
1426
-
1427
- function CostMeter(props) {
1428
- const ctx = props.ctx
1429
- const locale = useActiveLocale(ctx)
1430
- const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1431
- const useProjection = props.useProjection
1432
- const usage = useProjection ? useProjection('tokenUsage') : undefined
1433
- const byModel = useProjection ? useProjection('costByModel') : undefined
1434
- const modelSelection = useProjection ? useProjection('modelSelection') : undefined
1435
- const [state, setState] = React.useState(null)
1436
- const [open, setOpen] = React.useState(false)
1437
- const [now, setNow] = React.useState(() => new Date())
1438
- const [copied, setCopied] = React.useState(false)
1439
- const [showAllModels, setShowAllModels] = React.useState(false)
1440
- const [showTurnInspector, setShowTurnInspector] = React.useState(false)
1441
- const lastAlertRef = React.useRef(null)
1442
- const box = React.useRef(null)
1443
-
1444
- React.useEffect(() => { ensureStyles() }, [])
1445
-
1446
- const configForms = getSettingsScope(ctx)
1447
- const scope = React.useMemo(
1448
- () => (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : undefined),
1449
- [configForms],
1450
- )
1451
- const settingsSnap = React.useSyncExternalStore
1452
- ? React.useSyncExternalStore(
1453
- React.useMemo(() => (cb) => (scope && typeof scope.subscribe === 'function' ? scope.subscribe(cb) : () => {}), [scope]),
1454
- React.useCallback(() => (scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : null)), [scope, configForms]),
1455
- React.useCallback(() => null, []),
1456
- )
1457
- : null
1458
-
1459
- const used = (byModel && Array.isArray(byModel.models) ? byModel.models : [])
1460
- .map((m) => ({ ...m, usage: sumSlots(m && m.slots) }))
1461
- .filter((m) => m.usage.uncachedInputTokens || m.usage.outputTokens ||
1462
- m.usage.cacheReadTokens || m.usage.cacheWriteTokens)
1463
- const usedKeys = used.map((m) => m.provider + '/' + m.model).sort().join(',')
1464
-
1465
- // Determine active model from session modelSelection, committed route, or used models
1466
- const activeSelection = modelSelection?.next || modelSelection?.lastUsed
1467
- const activeModelName = activeSelection?.model
1468
- || byModel?.route?.model
1469
- || (used.length > 0 ? used[used.length - 1].model : undefined)
1470
- || (state?.route?.model)
1471
- || ''
1472
-
1473
- const activeProvider = activeSelection?.provider
1474
- || byModel?.route?.provider
1475
- || (used.length > 0 ? used[used.length - 1].provider : undefined)
1476
- || (state?.route?.provider)
1477
- || ''
1478
-
1479
- const activeRouteKey = activeModelName
1480
- ? (activeProvider ? activeProvider + '/' + activeModelName : activeModelName)
1481
- : ''
1482
-
1483
- const reqSeq = React.useRef(0)
1484
-
1485
- const load = React.useCallback((keys, currentRoute, signal) => {
1486
- const seq = ++reqSeq.current
1487
- const params = new URLSearchParams()
1488
- if (currentRoute) {
1489
- params.set('currentRoute', currentRoute)
1490
- params.append('route', currentRoute)
1491
- }
1492
- if (keys) {
1493
- params.set('models', keys)
1494
- keys.split(',').forEach((k) => { if (k) params.append('route', k) })
1495
- }
1496
- const qs = params.toString()
1497
- const query = qs ? '?' + qs : ''
1498
- fetchWithTimeout(STATE_PATH + query, { signal, headers: { accept: 'application/json' } })
1499
- .then((r) => (r.ok ? r.json() : null))
1500
- .then((data) => {
1501
- if (data && seq === reqSeq.current) setState(data)
1502
- })
1503
- .catch(() => {})
1504
- }, [])
1505
-
1506
- React.useEffect(() => {
1507
- const controller = new AbortController()
1508
- load(usedKeys, activeRouteKey, controller.signal)
1509
- return () => controller.abort()
1510
- }, [load, usedKeys, activeRouteKey])
1511
- React.useEffect(() => {
1512
- if (!open) return
1513
- const controller = new AbortController()
1514
- load(usedKeys, activeRouteKey, controller.signal)
1515
- return () => controller.abort()
1516
- }, [open, load, usedKeys, activeRouteKey])
1517
- React.useEffect(() => {
1518
- const id = setInterval(() => setNow(new Date()), 15000)
1519
- return () => clearInterval(id)
1520
- }, [])
1521
- React.useEffect(() => {
1522
- if (!open) return undefined
1523
- const onDown = (e) => { if (box.current && !box.current.contains(e.target)) setOpen(false) }
1524
- document.addEventListener('mousedown', onDown)
1525
- return () => document.removeEventListener('mousedown', onDown)
1526
- }, [open])
1527
-
1528
- if (!state) return null
1529
-
1530
- const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1531
- const stored = (settingsSnap && settingsSnap.value) || {}
1532
- const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
1533
- const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
1534
- const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
1535
- const displayMode = stored.displayMode !== undefined ? stored.displayMode : (state.displayMode || 'auto')
1536
- const schedules = state.schedules || {}
1537
- const schedule = (activeRouteKey && schedules[activeRouteKey])
1538
- || (activeModelName && schedules[activeModelName])
1539
- || state.schedule
1540
- const hasUsage = usage !== undefined && usage !== null
1541
- const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
1542
-
1543
- if (!schedule) {
1544
- return h('div', { ref: box, style: { position: 'relative' } }, [
1545
- h('button', {
1546
- key: 'chip', type: 'button',
1547
- className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : ''),
1548
- onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
1549
- }, [
1550
- h('span', { key: 'dot', className: 'dcm-dot dcm-dot-idle' }),
1551
- h('span', { key: 'txt' }, t('chipUnknown')),
1552
- ]),
1553
- open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1554
- h('div', { key: 't', className: 'dcm-panel-title' },
1555
- activeModelName || (state.route ? state.route.model : t('routeUnknown'))),
1556
- h('div', { key: 'n', className: 'dcm-panel-note' },
1557
- state.catalogError
1558
- ? t('catalogUnavailable') + state.catalogError
1559
- : t('tokens.not_priced')),
1560
- ]) : null,
1561
- ])
1562
- }
1563
-
1564
- const active = ratesAt(schedule, minute)
1565
- const { low, high, flat } = extremes(schedule)
1566
- const cheapNow = !flat && active.output === low.output
1567
- const leftMinutes = minutesUntilChange(schedule, minute)
1568
- const left = clock(leftMinutes)
1569
-
1570
- const alertBeforeSwitch = stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : (state.alertBeforeSwitch !== false)
1571
- const alertLeadMinutes = Number(stored.alertLeadMinutes || state.alertLeadMinutes || 5)
1572
- const browserNotifications = Boolean(stored.browserNotifications || state.browserNotifications || false)
1573
-
1574
- let alertActive = false
1575
- let alertDirection = null
1576
- let alertBanner = null
1577
-
1578
- if (!flat && alertBeforeSwitch && leftMinutes > 0 && leftMinutes <= alertLeadMinutes) {
1579
- alertActive = true
1580
- alertDirection = cheapNow ? 'peak' : 'offpeak'
1581
- const timeStr = left || (leftMinutes + 'm')
1582
- alertBanner = alertDirection === 'peak'
1583
- ? t('alert.pre_switch_peak', { time: timeStr })
1584
- : t('alert.pre_switch_offpeak', { time: timeStr })
1585
- }
1586
-
1587
- // Desktop HTML5 notification trigger (Issue #117)
1588
- React.useEffect(() => {
1589
- if (!alertActive || !browserNotifications || !alertBanner) return
1590
- const transKey = Math.floor(minute / 30) + ':' + alertDirection
1591
- if (lastAlertRef.current === transKey) return
1592
- lastAlertRef.current = transKey
1593
- if (typeof Notification !== 'undefined') {
1594
- if (Notification.permission === 'granted') {
1595
- try { new Notification('DeepSeek Tariff Alert', { body: alertBanner }) } catch (_) {}
1596
- } else if (Notification.permission !== 'denied') {
1597
- Notification.requestPermission().then((p) => {
1598
- if (p === 'granted') {
1599
- try { new Notification('DeepSeek Tariff Alert', { body: alertBanner }) } catch (_) {}
1600
- }
1601
- })
1602
- }
1603
- }
1604
- }, [alertActive, browserNotifications, alertBanner, minute, alertDirection])
1605
-
1606
- const perModel = used.map((m) => {
1607
- const key = m.provider + '/' + m.model
1608
- const own = schedules[key] || schedules[m.model]
1609
- const ownSchedule = own && (own.schedule || own)
1610
- return {
1611
- key,
1612
- model: m.model,
1613
- usage: m.usage,
1614
- known: Boolean(ownSchedule),
1615
- cost: ownSchedule ? spendBySlots(m.slots, ownSchedule) * fx : 0,
1616
- }
1617
- })
1618
- const multiModel = perModel.length > 1
1619
- const total = perModel.length
1620
- ? perModel.reduce((sum, m) => sum + m.cost, 0)
1621
- : spendUsd(usage, active) * fx
1622
-
1623
- // Check budget threshold in USD
1624
- const totalUsd = fx > 0 ? total / fx : total
1625
- const budgetExceeded = budgetThreshold > 0 && totalUsd >= budgetThreshold
1626
-
1627
- // Dot indicator status class
1628
- const dotClass = budgetExceeded
1629
- ? 'dcm-dot dcm-dot-err'
1630
- : (flat ? 'dcm-dot dcm-dot-idle' : (cheapNow ? 'dcm-dot dcm-dot-ok' : 'dcm-dot dcm-dot-warn'))
1631
-
1632
- const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--dsw-alias-state-brand-primary) 12%, transparent)', color: 'var(--dsw-alias-state-brand-primary)', fontWeight: 600 }
1633
-
1634
- const rateRow = (label, key) => flat
1635
- ? [
1636
- h('div', { key: label + '-l', className: 'dcm-td' }, label),
1637
- h('div', { key: label + '-v', className: 'dcm-cost' }, rate(active[key])),
1638
- ]
1639
- : [
1640
- h('div', { key: label + '-l', className: 'dcm-td' }, label),
1641
- h('div', { key: label + '-lo', style: cheapNow ? activeCell : undefined, className: cheapNow ? '' : 'dcm-cell-idle' }, rate(low[key])),
1642
- h('div', { key: label + '-hi', style: cheapNow ? undefined : activeCell, className: cheapNow ? 'dcm-cell-idle' : '' }, rate(high[key])),
1643
- ]
1644
-
1645
- const soleSlots = perModel.length === 1 ? used[0].slots : null
1646
- const spentRow = (label, bucketField, rateField) => {
1647
- const n = Number(usage && usage[bucketField]) || 0
1648
- let usd = 0
1649
- if (soleSlots) {
1650
- for (const key of Object.keys(soleSlots)) {
1651
- const rates = ratesAt(schedule, (Number(key) * 30 + 15) % 1440)
1652
- usd += ((Number(soleSlots[key][bucketField]) || 0) / 1e6) * (rates[rateField] || 0)
1653
- }
1654
- } else {
1655
- usd = (n / 1e6) * (active[rateField] || 0)
1656
- }
1657
- return [
1658
- h('div', { key: label + '-l', className: 'dcm-td' }, label),
1659
- h('div', { key: label + '-n', className: 'dcm-num' }, tokens(n)),
1660
- h('div', { key: label + '-v', className: 'dcm-cost' }, money(usd * fx, currency)),
1661
- ]
1662
- }
1663
-
1664
- const showCacheWrite = Number(usage && usage.cacheWriteTokens) > 0
1665
- const zone = state.displayTimeZone || 'Europe/Moscow'
1666
- const zl = zoneLabel(zone)
1667
- const windowText = (schedule.windows || [])
1668
- .map((w) => hhmmInZone(w.start, zone, now) + '–' + hhmmInZone(w.end, zone, now))
1669
- .join(', ')
1670
- const nowLocal = hhmmInZone(minute, zone, now)
1671
-
1672
- // Calculate cache savings amount across all used models (#92)
1673
- let cacheSavedAmount = 0
1674
- let cacheSavedPercent = 0
1675
- if (hasUsage) {
1676
- if (perModel.length > 0) {
1677
- let totalSavedUsd = 0
1678
- let totalMissedUsd = 0
1679
- for (const m of perModel) {
1680
- const own = schedules[m.key] || schedules[m.model]
1681
- const sched = own && (own.schedule || own) ? (own.schedule || own) : schedule
1682
- if (sched) {
1683
- const res = cacheSavingsBySlots(m.slots || (m.usage ? { 0: m.usage } : {}), sched)
1684
- totalSavedUsd += res.savedUsd
1685
- totalMissedUsd += res.missedUsd
1686
- }
1687
- }
1688
- if (totalSavedUsd > 0) {
1689
- cacheSavedAmount = totalSavedUsd * fx
1690
- cacheSavedPercent = totalMissedUsd > 0 ? Math.round((totalSavedUsd / totalMissedUsd) * 100) : 0
1691
- }
1692
- } else if (usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
1693
- const tokensRead = Number(usage.cacheReadTokens)
1694
- const missedCost = (tokensRead / 1e6) * active.input
1695
- const hitCost = (tokensRead / 1e6) * active.cacheHit
1696
- cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
1697
- cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
1698
- }
1699
- }
1700
-
1701
-
1702
- // Copy summary report to clipboard
1703
- const copyReport = () => {
1704
- const lines = [
1705
- '=== ' + (activeModelName || (state.route ? state.route.model : 'Cost Meter Report')) + ' ===',
1706
- t('tokens.session_total') + ' ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
1707
- 'Tier: ' + (flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak'))),
1708
- 'Rates (1M): input ' + rate(active.input) + ', output ' + rate(active.output) + ', cache ' + rate(active.cacheHit),
1709
- ]
1710
- if (cacheSavedAmount > 0) {
1711
- lines.push('Cache saved: ≈ ' + money(cacheSavedAmount, currency) + ' (' + cacheSavedPercent + '%)')
1712
- }
1713
- if (hasUsage && usage) {
1714
- lines.push('Tokens: in ' + tokens(usage.inputTokens) + ', out ' + tokens(usage.outputTokens) + ', cache hit ' + tokens(usage.cacheReadTokens))
1715
- }
1716
- const text = lines.join('\n')
1717
- if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
1718
- navigator.clipboard.writeText(text).then(() => {
1719
- setCopied(true)
1720
- setTimeout(() => setCopied(false), 2000)
1721
- }).catch(() => {})
1722
- } else {
1723
- setCopied(true)
1724
- setTimeout(() => setCopied(false), 2000)
1725
- }
1726
- }
1727
-
1728
- // Calculate upcoming discount hint
1729
- let discountHint = null
1730
- if (!flat && !cheapNow && leftMinutes) {
1731
- const nextTimeMinutes = (minute + leftMinutes) % DAY
1732
- const clockTime = hhmmInZone(nextTimeMinutes, zone, now)
1733
- discountHint = t('savings.hint', {
1734
- time: left,
1735
- clock: clockTime,
1736
- zone: zl,
1737
- })
1738
- }
1739
-
1740
- // Display models with collapse support if > 3 models
1741
- const visibleModels = (perModel.length
1742
- ? perModel
1743
- : (activeModelName ? [{ key: activeRouteKey || activeModelName, model: activeModelName, known: Boolean(schedule), cost: 0 }] : [])
1744
- )
1745
- const shouldCollapse = visibleModels.length > 3 && !showAllModels
1746
- const renderedModels = shouldCollapse ? visibleModels.slice(0, 3) : visibleModels
1747
-
1748
- return h('div', { ref: box, style: { position: 'relative' } }, [
1749
- h('button', {
1750
- key: 'chip', type: 'button',
1751
- className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : '') + (alertActive ? (alertDirection === 'peak' ? ' dcm-chip-alert-peak' : ' dcm-chip-alert-offpeak') : ''),
1752
- onClick: () => setOpen((v) => !v),
1753
- title: (budgetExceeded
1754
- ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1755
- : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))) +
1756
- (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }) : ''),
1757
-
1758
- }, [
1759
- h('span', { key: 'dot', className: dotClass }),
1760
- h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1761
- left ? h('span', { key: 'left', className: 'dcm-chip-timer' }, left) : null,
1762
- ]),
1763
-
1764
- open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1765
- h('div', { key: 'title', className: 'dcm-panel-title' }, [
1766
- h('span', { key: 'model-name' }, activeModelName || (state.route ? state.route.model : 'model')),
1767
- flat ? null : h('span', { key: 'tier', style: { fontSize: 11.5, opacity: 0.85 } }, cheapNow ? t('offpeak') : t('peak')),
1768
- ]),
1769
-
1770
- h('div', { key: 'note', className: 'dcm-panel-note' },
1771
- flat
1772
- ? 'Flat rate schedule'
1773
- : (cheapNow ? 'Discounted rate active' : 'Full rate active') +
1774
- (left ? ' · change in ' + left : '')),
1775
-
1776
- flat
1777
- ? null
1778
- : h('div', { key: 'clock', className: 'dcm-panel-note' },
1779
- t('peak') + ': ' + windowText + ' ' + zl + ' · now ' + nowLocal + ' ' + zl),
1780
-
1781
- alertBanner ? h('div', {
1782
- key: 'preswitch-banner',
1783
- className: 'dcm-alert-banner ' + (alertDirection === 'peak' ? 'dcm-alert-banner-peak' : 'dcm-alert-banner-offpeak'),
1784
- }, alertBanner) : null,
1785
-
1786
- discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
1787
-
1788
- cacheSavedAmount > 0
1789
- ? h('div', { key: 'cache-saved-box', className: 'dcm-cache-box', style: { marginTop: 6 } },
1790
- t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }))
1791
- : null,
1792
-
1793
- budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1794
- t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
1795
-
1796
- renderSparkline(turns, {
1797
- h,
1798
- currency,
1799
- fx,
1800
- activeRate: active,
1801
- t,
1802
- }),
1803
-
1804
- turns.length > 0 ? [
1805
- h('div', {
1806
- key: 'turns-header',
1807
- style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
1808
- }, [
1809
- h('span', { key: 'th-lbl', className: 'dcm-section-lbl' }, t('turn.history_title')),
1810
- h('button', {
1811
- key: 'th-btn',
1812
- type: 'button',
1813
- className: 'dcm-accordion-btn',
1814
- onClick: () => setShowTurnInspector((v) => !v),
1815
- }, showTurnInspector ? t('turn.hide_history') : t('turn.show_history')),
1816
- ]),
1817
- showTurnInspector ? h('div', { key: 'turn-list', className: 'dcm-turn-list' },
1818
- turns.slice(-10).reverse().map((tr, idx) => {
1819
- const tok = Number(tr.tokens) || 0
1820
- const hit = Number(tr.cacheReadTokens) || 0
1821
- const uncached = Number(tr.uncachedInputTokens) || 0
1822
- const hitRate = tr.cacheHitRate !== undefined
1823
- ? tr.cacheHitRate
1824
- : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1825
- const cost = Number(tr.costDeltaUsd) || 0
1826
- return h('div', { key: 'tr-' + idx, className: 'dcm-turn-row' }, [
1827
- h('span', { key: 'tn', style: { fontWeight: 500 } }, 'T#' + tr.turn + (tr.step ? '.' + tr.step : '')),
1828
- h('span', { key: 'tok', style: { color: 'var(--dsw-alias-label-secondary)' } }, tokens(tok) + ' tok (' + hitRate + '% hit)'),
1829
- h('span', { key: 'c', style: { fontWeight: 600 } }, cost > 0 ? money(cost * fx, currency) : '—'),
1830
- ])
1831
- })
1832
- ) : null,
1833
- ] : null,
1834
-
1835
- (() => {
1836
- const proj = calculateBurnRateAndProjection(turns, total / fx)
1837
- const burnRate = proj.burnRatePerHourUsd * fx
1838
- const projectedTotal = proj.projectedCostUsd * fx
1839
- if (burnRate <= 0) return null
1840
- return h('div', { key: 'proj-box', className: 'dcm-proj-box' }, [
1841
- h('span', { key: 'burn', className: 'dcm-proj-item' }, [
1842
- h('span', null, t('projection.burn_rate') + ' '),
1843
- h('span', { className: 'dcm-proj-val' }, money(burnRate, currency) + t('projection.per_hour')),
1844
- ]),
1845
- h('span', { key: 'proj', className: 'dcm-proj-item' }, [
1846
- h('span', null, t('projection.projected') + ' '),
1847
- h('span', { className: 'dcm-proj-val' }, '≈ ' + money(projectedTotal, currency)),
1848
- ]),
1849
- ])
1850
- })(),
1851
-
1852
- h('hr', { key: 'hr1', className: 'dcm-hr' }),
1853
-
1854
- h('div', { key: 'rates', className: flat ? 'dcm-panel-grid-flat' : 'dcm-panel-grid' }, [
1855
- h('div', { key: 'h0', className: 'dcm-th' }, t('rate.per_1m')),
1856
- flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, t('offpeak')),
1857
- h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? 'rate' : t('peak')),
1858
- rateRow(t('rate.cache_hit'), 'cacheHit'),
1859
- rateRow(t('rate.input'), 'input'),
1860
- showCacheWrite ? rateRow(t('rate.cache_write'), 'cacheWrite') : null,
1861
- rateRow(t('rate.output'), 'output'),
1862
- ]),
1863
-
1864
- h('hr', { key: 'hr2', className: 'dcm-hr' }),
1865
-
1866
- multiModel
1867
- ? null
1868
- : [
1869
- h('div', { key: 'btitle', className: 'dcm-section-lbl' }, t('tokens.session')),
1870
- h('div', { key: 'breakdown', className: 'dcm-panel-grid-3' }, [
1871
- h('div', { key: 'b0', className: 'dcm-th' }, t('tokens.tokens_col')),
1872
- h('div', { key: 'b1', className: 'dcm-th-num' }, ''),
1873
- h('div', { key: 'b2', className: 'dcm-th-num' }, currency),
1874
- spentRow(t('rate.cache_hit'), 'cacheReadTokens', 'cacheHit'),
1875
- spentRow(t('rate.input'), 'uncachedInputTokens', 'input'),
1876
- showCacheWrite ? spentRow(t('rate.cache_write'), 'cacheWriteTokens', 'cacheWrite') : null,
1877
- spentRow(t('rate.output'), 'outputTokens', 'output'),
1878
- ]),
1879
- h('hr', { key: 'hr-models', className: 'dcm-hr' }),
1880
- ],
1881
-
1882
- h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, t('tokens.models_spent')),
1883
- h(
1884
- 'div',
1885
- { key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
1886
- renderedModels.map((m) =>
1887
- h(
1888
- 'div',
1889
- {
1890
- key: m.key,
1891
- style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '2px 0' },
1892
- },
1893
- [
1894
- h(
1895
- 'span',
1896
- {
1897
- key: 'name',
1898
- title: m.key,
1899
- style: {
1900
- overflow: 'hidden',
1901
- textOverflow: 'ellipsis',
1902
- whiteSpace: 'nowrap',
1903
- maxWidth: m.known ? 200 : 130,
1904
- },
1905
- },
1906
- m.model,
1907
- ),
1908
- h(
1909
- 'span',
1910
- {
1911
- key: 'val',
1912
- style: {
1913
- textAlign: 'right',
1914
- fontVariantNumeric: 'tabular-nums',
1915
- whiteSpace: 'nowrap',
1916
- color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
1917
- },
1918
- },
1919
- m.known ? (hasUsage ? money(m.cost, currency) : '—') : t('tokens.not_priced'),
1920
- ),
1921
- ],
1922
- ),
1923
- ),
1924
- ),
1925
-
1926
- visibleModels.length > 3
1927
- ? h('button', {
1928
- key: 'toggle-models',
1929
- type: 'button',
1930
- className: 'dcm-accordion-btn',
1931
- onClick: () => setShowAllModels((v) => !v),
1932
- }, shouldCollapse ? t('models.show_more', { count: visibleModels.length }) : t('models.show_less'))
1933
- : null,
1934
-
1935
- h('hr', { key: 'hr3', className: 'dcm-hr' }),
1936
-
1937
- h('div', { key: 'total', className: 'dcm-total' }, [
1938
- h('span', { key: 'l' }, t('tokens.session_total')),
1939
- h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1940
- ]),
1941
-
1942
- hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
1943
- t('tokens.no_usage_yet')),
1944
-
1945
- h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
1946
- h('button', {
1947
- type: 'button',
1948
- className: 'dcm-btn dcm-btn-sm',
1949
- onClick: copyReport,
1950
- }, copied ? t('action.copied') : t('action.copy_report'))
1951
- ),
1952
- ]) : null,
1953
- ])
1954
- }
1955
-
1956
- function apply(ctx) {
1957
- const registerDictionaries = () => {
1958
- try {
1959
- // ctx.locale.register(NS, { en, zh }) is canonical form in DSH
1960
- return ctx.locale.register(NS, { en, zh })
1961
- } catch (err) {
1962
- if (ctx?.logger?.warn) {
1963
- ctx.logger.warn(`[dsh-cost-meter] locale register failed: ${err?.message || err}`)
1964
- }
1965
- return () => {}
1966
- }
1967
- }
1968
-
1969
- if (ctx.locale && typeof ctx.locale.register === 'function') {
1970
- if (typeof ctx.effect === 'function') {
1971
- ctx.effect(() => {
1972
- const undo = registerDictionaries()
1973
- return () => {
1974
- try {
1975
- if (typeof undo === 'function') undo()
1976
- } catch (e) {
1977
- if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] locale undo error: ' + (e?.message || e))
1978
- }
1979
- }
1980
- }, 'dsh-cost-meter: locale dictionaries')
1981
- } else {
1982
- registerDictionaries()
1983
- }
1984
- }
1985
-
1986
- if (typeof ctx.effect === 'function') {
1987
- ctx.effect(
1988
- () => refreshMirrorUntilVisible(ctx),
1989
- 'dsh-cost-meter: refresh settings mirror',
1990
- )
1991
- } else {
1992
- refreshMirrorUntilVisible(ctx)
1993
- }
1994
-
1995
- if (ctx.slots && typeof ctx.slots.inject === 'function') {
1996
- ctx.slots.inject('conversation.session.header.utilities', () =>
1997
- ctx.slots.register(
1998
- { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
1999
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeter, { ...p, ctx: (p && p.ctx) || ctx })),
2000
- ),
2001
- )
2002
-
2003
- // Message action bar token & cost inspector (Issue #120)
2004
- ctx.slots.inject('conversation.chat.assistant-actions', () =>
2005
- ctx.slots.register(
2006
- { name: 'conversation.chat.assistant-actions', id: 'dsh-cost-meter-token-inspector', order: 40, inject: () => ({ ctx }) },
2007
- (p) => React.createElement(ErrorBoundary, null, React.createElement(MessageTokenAction, { ...p, ctx: (p && p.ctx) || ctx })),
2008
- ),
2009
- )
2010
-
2011
- // List seat (plugins.item): the seat the Plugins page renders as the plugin's
2012
- // own page with its configuration. The label is a static string on purpose —
2013
- // it is resolved while the page renders, and a locale lookup there would take
2014
- // the whole client batch down with it.
2015
- ctx.slots.inject('plugins.item', () =>
2016
- ctx.slots.register(
2017
- { name: 'plugins.item', id: ROW_ID, order: 20, label: () => 'Cost Meter', locale: NS, inject: () => ({ ctx }) },
2018
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
2019
- ),
2020
- )
2021
-
2022
- // Row seat and the legacy seat kept as fallbacks.
2023
- ctx.slots.inject('plugins.row.config', () =>
2024
- ctx.slots.register(
2025
- { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, order: 20, inject: () => ({ ctx }) },
2026
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
2027
- ),
2028
- )
2029
-
2030
- ctx.slots.inject('settings.plugin.item', () =>
2031
- ctx.slots.register(
2032
- { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
2033
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
2034
- ),
2035
- )
2036
- }
2037
- }
2038
-
2039
- module.exports = {
2040
- apply,
2041
- inject: ['slots', 'locale', 'configForms'],
2042
- CostMeterCard,
2043
- CostMeter,
2044
- MessageTokenAction,
2045
- ErrorBoundary,
2046
- ensureStyles,
2047
- minutesUntilChange,
2048
- extremes,
2049
- ratesAt,
2050
- spendBySlots,
2051
- cacheSavingsBySlots,
2052
- fetchWithTimeout,
2053
- }
2054
-
2055
- return module.exports
2056
- },
2057
- })
2058
-
1
+ // dsh-cost-meter — client half.
2
+ // Self-registering browser module for DeepSeek Harness.
3
+ // Unified design system aligned with @goodandready/dsh-clinebot and DSH tokens.
4
+ // Canonical languages: English (en) and Chinese (zh).
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: '@goodandready/dsh-cost-meter',
8
+ factory: (require) => {
9
+ var module = { exports: {} }
10
+ const React = require('react')
11
+ const h = React.createElement
12
+
13
+ const NS = 'dsh-cost-meter'
14
+ // Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
15
+ const PKG = '@goodandready/dsh-cost-meter'
16
+ const ROW_ID = 'dsh-cost-meter'
17
+ const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
18
+ const STATE_PATH = '/dsh-cost-meter/state'
19
+ const REFRESH_PATH = '/dsh-cost-meter/refresh'
20
+ const UPDATE_PATH = '/dsh-cost-meter/update'
21
+ const DAY = 1440
22
+
23
+ // ----------------------------------------------------------- localization
24
+
25
+ const en = {
26
+ title: 'Cost Meter',
27
+ subtitle: 'Model rates, peak discount windows and session spend',
28
+ 'header.title': 'Cost Meter',
29
+ 'header.sub': 'Token spend monitoring, automatic tariff resolution and discount windows',
30
+ 'update.checking': 'Checking for updates…',
31
+ 'update.up_to_date': 'Up to date',
32
+ 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
33
+ 'update.btn': 'Update Now',
34
+ 'update.updating': 'Updating…',
35
+ 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
36
+ 'update.failed': 'Update failed: {error}',
37
+ 'badge.catalog_ok': 'Catalog: {count} models',
38
+ 'badge.catalog_empty': 'Catalog: loading…',
39
+ 'badge.catalog_off': 'Catalog: disabled',
40
+ 'badge.catalog_err': 'Catalog: error',
41
+ 'badge.catalog_stale': 'Cache: offline',
42
+ 'badge.deepseek': 'DeepSeek tariff: active',
43
+ 'section.general': 'General Parameters',
44
+ 'section.general_desc': 'Display currency, conversion rate, budget threshold and time zone configuration',
45
+ 'section.openrouter': 'OpenRouter Pricing Catalog',
46
+ 'section.openrouter_desc': 'Public catalog for dynamic model pricing and discount windows',
47
+ 'section.diagnostics': 'Diagnostics & Status',
48
+ 'section.diagnostics_desc': 'Current state of background synchronization and tariff cache',
49
+ 'diag.models_count': 'Catalog models',
50
+ 'diag.last_sync': 'Last sync',
51
+ 'diag.route': 'Active route',
52
+ 'diag.tariff_source': 'Tariff source',
53
+ 'field.currency': 'Currency Symbol',
54
+ 'hint.currency': 'Currency symbol shown in the UI (e.g. $, ¥, €).',
55
+ 'field.usdRate': 'USD Exchange Rate',
56
+ 'hint.usdRate': 'Units of currency per 1 USD (1 keeps USD).',
57
+ 'field.budgetThreshold': 'Session Budget Threshold (USD)',
58
+ 'hint.budgetThreshold': 'Warn via chip color when session spend exceeds this amount (0 disables).',
59
+ 'field.displayTimeZone': 'Time Zone (IANA)',
60
+ 'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, Asia/Shanghai, UTC).',
61
+ 'field.useOpenRouter': 'Use OpenRouter Catalog',
62
+ 'hint.useOpenRouter': 'Pull rates and discount windows dynamically from public OpenRouter API.',
63
+ 'field.refreshHours': 'Catalog Refresh Interval (hours)',
64
+ 'hint.refreshHours': 'Background catalog fetch interval in hours (1 to 168).',
65
+ 'action.sync_now': 'Sync Now',
66
+ 'action.syncing': 'Syncing…',
67
+ 'action.sync_ok': 'Catalog updated',
68
+ 'action.sync_err': 'Sync failed',
69
+ 'action.sync_rate_limited': 'Wait {seconds}s',
70
+ 'action.export_csv': 'Export CSV',
71
+ 'action.export_json': 'Export JSON',
72
+ 'action.export_png': 'PNG Card',
73
+ 'action.exporting_png': 'Generating…',
74
+ 'action.exported': 'Downloaded!',
75
+ 'curr.select': 'Currency',
76
+ 'action.copy_report': 'Copy Summary',
77
+ 'action.copied': 'Copied!',
78
+ 'alert.pre_switch_peak': '⚠️ Peak tariff starts in {time} (higher rates)',
79
+ 'alert.pre_switch_offpeak': '🎉 Off-peak discount starts in {time} (-50% savings)',
80
+ 'section.alerts': 'Tariff Transition Alerts',
81
+ 'section.alerts_desc': 'Visual pulse and pre-switch alerts before peak and off-peak rate changes',
82
+ 'field.alertBeforeSwitch': 'Pre-Switch Tariff Alerts',
83
+ 'hint.alertBeforeSwitch': 'Show visual pulse on chip and alert banner before rate transitions.',
84
+ 'field.alertLeadMinutes': 'Alert Lead Time (minutes)',
85
+ 'hint.alertLeadMinutes': 'Minutes before tariff switch to activate pre-switch alert (1 to 30).',
86
+ 'field.browserNotifications': 'Desktop Notifications',
87
+ 'hint.browserNotifications': 'Send HTML5 browser notification on tariff transitions when tab is in background.',
88
+ 'action.test_notification': 'Test Notification',
89
+ 'action.tested_notification': 'Notification Sent!',
90
+ 'turn.action_btn': 'Turn tokens',
91
+ 'turn.modal_title': 'Message Token & Cost Inspector',
92
+ 'turn.tokens_total': 'Turn Tokens',
93
+ 'turn.cost_title': 'Turn Cost',
94
+ 'turn.cache_hit_rate': 'Cache Hit',
95
+ 'turn.duration': 'Duration',
96
+ 'turn.uncached_in': 'Uncached Input',
97
+ 'turn.cache_read': 'Cache Read',
98
+ 'turn.output': 'Output',
99
+ 'turn.history_title': 'Turn History & Inspector',
100
+ 'turn.show_history': 'Show turn history',
101
+ 'turn.hide_history': 'Hide turn history',
102
+ 'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
103
+ 'savings.cache_saved': '⚡ Context cache saved: ≈ {amount} ({percent}%)',
104
+ 'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
105
+ 'models.show_more': 'Show all {count} models',
106
+ 'models.show_less': 'Show fewer',
107
+ 'settings.save': 'Save Settings',
108
+ 'settings.saving': 'Saving…',
109
+ 'settings.saved': 'Settings saved successfully',
110
+ 'settings.saveFailed': 'Failed to save settings: ',
111
+ 'settings.loading': 'Loading settings…',
112
+ 'settings.unavailable': 'Settings temporarily unavailable',
113
+ 'settings.retry': 'Retry',
114
+ chipUnknown: 'Unknown tariff',
115
+ routeUnknown: 'Unknown route',
116
+ catalogUnavailable: 'Catalog unavailable: ',
117
+ peak: 'peak',
118
+ offpeak: 'off-peak',
119
+ 'rate.per_1m': '1M tokens, $',
120
+ 'rate.cache_hit': 'Input (cache hit)',
121
+ 'rate.input': 'Input (cache miss)',
122
+ 'rate.cache_write': 'Input (cache write)',
123
+ 'rate.output': 'Output',
124
+ 'tokens.session': 'Session Tokens',
125
+ 'tokens.tokens_col': 'Tokens',
126
+ 'tokens.models_spent': 'Spend by Model',
127
+ 'tokens.session_total': 'Session total:',
128
+ 'tokens.no_usage_yet': 'Provider has not reported usage for this session yet.',
129
+ 'tokens.not_priced': 'Not priced',
130
+ 'tz.invalid': 'Invalid IANA time zone identifier',
131
+ 'sparkline.title': 'Turn dynamics',
132
+ 'sparkline.steps': 'steps',
133
+ 'projection.burn_rate': 'Burn rate:',
134
+ 'projection.projected': 'Projected:',
135
+ 'projection.per_hour': '/hr',
136
+ 'field.displayMode': 'Display mode',
137
+ 'hint.displayMode': 'Widget display mode: auto (collapses on mobile <= 768px), full, or always compact.',
138
+ 'mode.auto': 'Auto (responsive)',
139
+ 'mode.full': 'Full',
140
+ 'mode.compact': 'Compact pill',
141
+ }
142
+
143
+ const zh = {
144
+ title: '成本计量器',
145
+ subtitle: '模型费率、高峰优惠窗口与会话消费统计',
146
+ 'header.title': '成本计量器 (Cost Meter)',
147
+ 'header.sub': 'Token 消费监控、费率自动匹配与折扣窗口管理',
148
+ 'update.checking': '正在检查更新…',
149
+ 'update.up_to_date': '已是最新版本',
150
+ 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
151
+ 'update.btn': '立即更新',
152
+ 'update.updating': '正在更新…',
153
+ 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
154
+ 'update.failed': '更新失败:{error}',
155
+ 'badge.catalog_ok': '目录:{count} 个模型',
156
+ 'badge.catalog_empty': '目录:加载中…',
157
+ 'badge.catalog_off': '目录:已禁用',
158
+ 'badge.catalog_err': '目录:错误',
159
+ 'badge.catalog_stale': '缓存:离线',
160
+ 'badge.deepseek': 'DeepSeek 费率:生效中',
161
+ 'section.general': '基础参数',
162
+ 'section.general_desc': '显示货币、汇率转换、预算阈值与时区设置',
163
+ 'section.openrouter': 'OpenRouter 定价目录',
164
+ 'section.openrouter_desc': '用于自动获取模型价格与折扣窗口的公开价格目录',
165
+ 'section.diagnostics': '诊断与状态',
166
+ 'section.diagnostics_desc': '后台同步与费率缓存当前状态',
167
+ 'diag.models_count': '目录中模型数',
168
+ 'diag.last_sync': '上次同步',
169
+ 'diag.route': '当前路由',
170
+ 'diag.tariff_source': '费率来源',
171
+ 'field.currency': '货币符号',
172
+ 'hint.currency': '界面中显示的货币符号(例如:$、¥、€)。',
173
+ 'field.usdRate': 'USD 汇率',
174
+ 'hint.usdRate': '每 1 美元对应的本币金额(1 为美元)。',
175
+ 'field.budgetThreshold': '会话预算阈值 (USD)',
176
+ 'hint.budgetThreshold': '当会话消费超出该金额时在状态指示器报警(0 表示禁用)。',
177
+ 'field.displayTimeZone': '时区 (IANA)',
178
+ 'hint.displayTimeZone': '显示高峰时段的 IANA 时区(例如:Asia/Shanghai、UTC)。',
179
+ 'field.useOpenRouter': '使用 OpenRouter 目录',
180
+ 'hint.useOpenRouter': '通过公开的 OpenRouter API 自动拉取费率与折扣窗口。',
181
+ 'field.refreshHours': '目录刷新周期 (小时)',
182
+ 'hint.refreshHours': '后台刷新价格目录的时间间隔(1 至 168 小时)。',
183
+ 'action.sync_now': '立即同步',
184
+ 'action.syncing': '同步中…',
185
+ 'action.sync_ok': '目录已更新',
186
+ 'action.sync_err': '同步失败',
187
+ 'action.sync_rate_limited': '请等待 {seconds} 秒',
188
+ 'action.export_csv': '导出 CSV',
189
+ 'action.export_json': '导出 JSON',
190
+ 'action.export_png': '生成图片卡片',
191
+ 'action.exporting_png': '生成中…',
192
+ 'action.exported': '已下载!',
193
+ 'curr.select': '货币',
194
+ 'action.copy_report': '复制消费报告',
195
+ 'action.copied': '已复制!',
196
+ 'alert.pre_switch_peak': '⚠️ 高峰期计费将在 {time} 后开始(标准费率)',
197
+ 'alert.pre_switch_offpeak': '🎉 特惠期将在 {time} 后开始(5折优惠)',
198
+ 'section.alerts': '费率切换预警',
199
+ 'section.alerts_desc': '在高峰期与特惠期费率切换前显示脉冲提醒与横幅',
200
+ 'field.alertBeforeSwitch': '费率切换预警',
201
+ 'hint.alertBeforeSwitch': '在费率切换前在状态芯片上显示脉冲光晕与提示横幅。',
202
+ 'field.alertLeadMinutes': '预警提前时间(分钟)',
203
+ 'hint.alertLeadMinutes': '在费率切换前提前多少分钟发出预警(1 至 30 分钟)。',
204
+ 'field.browserNotifications': '桌面系统通知',
205
+ 'hint.browserNotifications': '在费率切换时发送 HTML5 桌面通知(后台标签页亦有效)。',
206
+ 'action.test_notification': '测试通知',
207
+ 'action.tested_notification': '通知已发送!',
208
+ 'turn.action_btn': 'Token 详情',
209
+ 'turn.modal_title': '单次回复 Token 与成本详情',
210
+ 'turn.tokens_total': '本轮 Token 总量',
211
+ 'turn.cost_title': '单次成本',
212
+ 'turn.cache_hit_rate': '缓存命中率',
213
+ 'turn.duration': '耗时',
214
+ 'turn.uncached_in': '未命中输入',
215
+ 'turn.cache_read': '命中缓存读取',
216
+ 'turn.output': '模型输出',
217
+ 'turn.history_title': '轮次明细与检查器',
218
+ 'turn.show_history': '展开轮次明细',
219
+ 'turn.hide_history': '收起轮次明细',
220
+ 'savings.hint': '💡 5 折优惠将在 {time} 后生效 ({clock} {zone})',
221
+ 'savings.cache_saved': '⚡ 上下文缓存节省:≈ {amount} ({percent}%)',
222
+ 'budget.exceeded': '⚠️ 已超出预算上限:{limit}',
223
+ 'models.show_more': '查看全部 {count} 个模型',
224
+ 'models.show_less': '收起',
225
+ 'settings.save': '保存设置',
226
+ 'settings.saving': '保存中…',
227
+ 'settings.saved': '设置已成功保存',
228
+ 'settings.saveFailed': '保存设置失败:',
229
+ 'settings.loading': '加载设置中…',
230
+ 'settings.unavailable': '设置暂时不可用',
231
+ 'settings.retry': '重试',
232
+ chipUnknown: '费率未知',
233
+ routeUnknown: '路由未知',
234
+ catalogUnavailable: '价格目录不可用:',
235
+ peak: '高峰',
236
+ offpeak: '低谷',
237
+ 'rate.per_1m': '每 1M tokens ($)',
238
+ 'rate.cache_hit': '输入 (命中缓存)',
239
+ 'rate.input': '输入 (未命中)',
240
+ 'rate.cache_write': '输入 (写入缓存)',
241
+ 'rate.output': '输出',
242
+ 'tokens.session': '会话 Tokens',
243
+ 'tokens.tokens_col': 'Tokens 数',
244
+ 'tokens.models_spent': '各模型消费',
245
+ 'tokens.session_total': '会话总计:',
246
+ 'tokens.no_usage_yet': '提供商尚未报告当前会话的用量数据。',
247
+ 'tokens.not_priced': '未定价',
248
+ 'tz.invalid': '无效的 IANA 时区标识',
249
+ 'sparkline.title': '步骤动态',
250
+ 'sparkline.steps': '步',
251
+ 'projection.burn_rate': '消耗速率:',
252
+ 'projection.projected': '预估总计:',
253
+ 'projection.per_hour': '/小时',
254
+ 'field.displayMode': '显示模式',
255
+ 'hint.displayMode': '计量器外观模式:自动(<=768px 移动端自适应折叠)、完整或始终紧凑。',
256
+ 'mode.auto': '自动自适应',
257
+ 'mode.full': '完整显示',
258
+ 'mode.compact': '紧凑药丸',
259
+ }
260
+
261
+ function makeT(dict, fallback) {
262
+ return function t(key, vars) {
263
+ let val = (dict && dict[key]) || (fallback && fallback[key]) || key
264
+ if (vars && typeof val === 'string') {
265
+ for (const k of Object.keys(vars)) {
266
+ val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
267
+ }
268
+ }
269
+ return val
270
+ }
271
+ }
272
+
273
+ function useActiveLocale(ctx) {
274
+ const localeService = (ctx && typeof ctx.get === 'function' ? ctx.get('locale') : null) || (ctx && ctx.locale) || (ctx && ctx.services && ctx.services.locale)
275
+ return React.useSyncExternalStore(
276
+ React.useMemo(() => (cb) => (localeService && typeof localeService.subscribe === 'function' ? localeService.subscribe(cb) : () => {}), [localeService]),
277
+ React.useCallback(() => {
278
+ const snap = localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot() : null
279
+ const active = snap ? (snap.active || snap.locale || 'en') : 'en'
280
+ return String(active).startsWith('zh') ? 'zh' : 'en'
281
+ }, [localeService]),
282
+ React.useCallback(() => 'en', []),
283
+ )
284
+ }
285
+
286
+ // ----------------------------------------------------------------- styles
287
+
288
+ const CSS = `
289
+ .dcm-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 12px; list-style: none; margin-bottom: 12px; }
290
+ .dcm-head { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
291
+ .dcm-title { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
292
+ .dcm-sub { color: var(--dsw-alias-label-secondary); font-size: 13px; }
293
+ .dcm-body { border-top: 1px solid var(--dsw-alias-border-l2); margin: 0 16px; padding: 16px 0 12px; display: flex; flex-direction: column; gap: 14px; }
294
+
295
+ .dcm-section-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
296
+ .dcm-section-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); display: flex; align-items: center; justify-content: space-between; }
297
+ .dcm-section-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: -4px; line-height: 1.4; }
298
+
299
+ .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
300
+ .dcm-field { display: flex; flex-direction: column; gap: 6px; }
301
+ .dcm-row-check { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
302
+ .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary); cursor: pointer; }
303
+ .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
304
+ .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
305
+
306
+ .dcm-input { height: 34px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 0 12px; font-size: 13px; width: 100%; box-sizing: border-box; }
307
+ .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary); }
308
+ .dcm-input-err { border-color: var(--dsw-alias-state-error-primary); }
309
+
310
+ .dcm-chevron { display: inline-flex; align-items: center; justify-content: center; transition: transform .16s ease; color: var(--dsw-alias-label-tertiary); }
311
+ .dcm-chevron-open { transform: rotate(180deg); }
312
+
313
+ .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
314
+ .dcm-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2); display: inline-flex; align-items: center; gap: 4px; font-weight: 500; }
315
+ .dcm-badge-ok { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); }
316
+ .dcm-badge-warn { border-color: var(--dsw-alias-state-warning-primary); color: var(--dsw-alias-state-warning-primary); background: color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent); }
317
+ .dcm-badge-bad { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
318
+
319
+ .dcm-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 14px; font-size: 13px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: all .15s ease; }
320
+ .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
321
+ .dcm-btn-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
322
+ .dcm-btn-primary:hover:not(:disabled) { opacity: 0.9; }
323
+ .dcm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
324
+ .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
325
+
326
+ .dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
327
+ .dcm-alert-ok { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
328
+ .dcm-alert-err { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
329
+ .dcm-tip-box { padding: 8px 12px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 8%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-brand-primary) 20%, transparent); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
330
+ .dcm-cache-box { padding: 7px 11px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 22%, transparent); color: var(--dsw-alias-state-success-primary); font-size: 11.5px; font-weight: 500; line-height: 1.4; font-variant-numeric: tabular-nums; }
331
+
332
+ .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; }
333
+ .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
334
+ .dcm-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-secondary); }
335
+
336
+ .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
337
+
338
+ /* Native dot-indicator chip */
339
+ .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); 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; }
340
+ .dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3); }
341
+ .dcm-dot { width: 6.5px; height: 6.5px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
342
+ .dcm-dot-ok { background: var(--dsw-alias-state-success-primary); box-shadow: 0 0 5px color-mix(in srgb, var(--dsw-alias-state-success-primary) 50%, transparent); }
343
+ .dcm-dot-warn { background: var(--dsw-alias-state-warning-primary); box-shadow: 0 0 5px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 40%, transparent); }
344
+ .dcm-dot-err { background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 6px color-mix(in srgb, var(--dsw-alias-state-error-primary) 70%, transparent); animation: dcm-pulse 1.8s infinite ease-in-out; }
345
+ .dcm-dot-idle { background: var(--dsw-alias-label-tertiary); }
346
+
347
+ .dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
348
+ @media (max-width: 768px) {
349
+ .dcm-chip-timer { display: none; }
350
+ .dcm-panel { width: min(320px, calc(100vw - 32px)); right: 0; }
351
+ }
352
+ .dcm-chip-compact .dcm-chip-timer { display: none !important; }
353
+
354
+ @keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
355
+
356
+ .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: var(--dsw-alias-shadow-layer-3, 0 10px 32px var(--dsw-alias-bg-base)); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; font-variant-numeric: tabular-nums; }
357
+ .dcm-panel-title { font-weight: 600; padding-bottom: 2px; font-size: 13.5px; display: flex; justify-content: space-between; align-items: baseline; }
358
+ .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
359
+ .dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
360
+ .dcm-panel-grid { display: grid; grid-template-columns: 1fr 62px 62px; column-gap: 6px; align-items: center; }
361
+ .dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr 76px; column-gap: 6px; align-items: center; }
362
+ .dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr 60px 74px; column-gap: 6px; align-items: center; }
363
+ .dcm-th { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; }
364
+ .dcm-th-num { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; text-align: right; font-size: 11px; }
365
+ .dcm-td { padding: 3.5px 0; color: var(--dsw-alias-label-secondary); }
366
+ .dcm-cell-idle { text-align: right; padding: 3px 6px; border-radius: 4px; color: var(--dsw-alias-label-tertiary); }
367
+ .dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding: 3.5px 0; }
368
+ .dcm-cost { text-align: right; padding: 3.5px 0; font-weight: 500; color: var(--dsw-alias-label-primary); }
369
+ .dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
370
+ .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
371
+ .dcm-accordion-btn { appearance: none; background: transparent; border: 0; color: var(--dsw-alias-state-brand-primary); font: inherit; font-size: 11.5px; cursor: pointer; padding: 2px 0; text-align: left; }
372
+ .dcm-accordion-btn:hover { text-decoration: underline; }
373
+ .dcm-sparkline-box { margin: 6px 0; padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 60%, transparent); border: 1px solid var(--dsw-alias-border-l2); }
374
+ .dcm-sparkline-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
375
+ .dcm-sparkline-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
376
+ .dcm-sparkline-svg { width: 100%; height: 36px; display: block; overflow: visible; }
377
+ .dcm-spark-bar { fill: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 45%, transparent); transition: fill .15s ease; cursor: pointer; }
378
+ .dcm-spark-bar:hover { fill: var(--dsw-alias-state-brand-primary); }
379
+ .dcm-spark-peak { fill: var(--dsw-alias-state-brand-primary); }
380
+ .dcm-proj-box { margin: 6px 0; padding: 6px 10px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 6%, transparent); display: flex; justify-content: space-between; align-items: center; font-size: 11px; }
381
+ .dcm-proj-item { display: inline-flex; align-items: center; gap: 4px; }
382
+ .dcm-proj-val { font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
383
+
384
+ .dcm-chip-alert-peak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 50%, transparent); animation: dcm-pulse-peak 2s infinite ease-in-out; }
385
+ .dcm-chip-alert-offpeak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary) 50%, transparent); animation: dcm-pulse-offpeak 2s infinite ease-in-out; }
386
+ @keyframes dcm-pulse-peak { 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 30%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 70%, transparent); } }
387
+ @keyframes dcm-pulse-offpeak { 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-success-primary) 30%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-state-success-primary) 70%, transparent); } }
388
+ .dcm-alert-banner { display: flex; align-items: center; gap: 6px; border-radius: 6px; padding: 6px 10px; font-size: 11.5px; line-height: 1.35; margin-top: 6px; }
389
+ .dcm-alert-banner-peak { background: color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent); color: var(--dsw-alias-state-warning-primary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 28%, transparent); }
390
+ .dcm-alert-banner-offpeak { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); color: var(--dsw-alias-state-success-primary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
391
+ .dcm-msg-action-btn { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 1px solid var(--dsw-alias-border-l2); padding: 2px 7px; border-radius: 5px; font-size: 11px; cursor: pointer; color: var(--dsw-alias-label-secondary); transition: all .15s ease; user-select: none; }
392
+ .dcm-msg-action-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-state-brand-primary); background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 8%, transparent); }
393
+ .dcm-inspector-modal { position: fixed; inset: 0; z-index: 99999; background: color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent); display: flex; align-items: center; justify-content: center; padding: 16px; backdrop-filter: blur(2px); }
394
+ .dcm-inspector-card { background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; max-width: 420px; width: 100%; box-shadow: var(--dsw-alias-shadow-layer-3, 0 16px 36px var(--dsw-alias-bg-base)); padding: 16px; font-size: 12px; }
395
+ .dcm-inspector-title { font-size: 14px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
396
+ .dcm-inspector-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
397
+ .dcm-inspector-stat { padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); }
398
+ .dcm-inspector-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-tertiary); margin-bottom: 2px; }
399
+ .dcm-inspector-stat-val { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
400
+ .dcm-turn-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; max-height: 180px; overflow-y: auto; }
401
+ .dcm-turn-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-3) 60%, transparent); font-size: 11px; }
402
+ .dcm-curr-switcher { display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-3) 80%, transparent); border: 1px solid var(--dsw-alias-border-l2); }
403
+ .dcm-curr-btn { appearance: none; background: transparent; border: 0; padding: 2px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 500; color: var(--dsw-alias-label-secondary); cursor: pointer; transition: all .15s ease; user-select: none; }
404
+ .dcm-curr-btn:hover { color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent); }
405
+ .dcm-curr-btn-active { background: var(--dsw-alias-label-primary) !important; color: var(--dsw-alias-bg-layer-3) !important; font-weight: 600; }
406
+ .dcm-export-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 10px; justify-content: flex-end; }
407
+
408
+ `
409
+
410
+
411
+
412
+ function ensureStyles() {
413
+ if (typeof document === 'undefined') return
414
+ if (document.getElementById('dsh-cost-meter-unified-css')) return
415
+ const el = document.createElement('style')
416
+ el.id = 'dsh-cost-meter-unified-css'
417
+ el.dataset.dshPlugin = NS
418
+ el.textContent = CSS
419
+ document.head.appendChild(el)
420
+ }
421
+
422
+ function createErrorBoundary() {
423
+ if (!React || typeof React.Component !== 'function') {
424
+ return function NoopBoundary(props) { return props?.children || null }
425
+ }
426
+ return class ErrorBoundary extends React.Component {
427
+ constructor(props) {
428
+ super(props)
429
+ this.state = { hasError: false, error: null }
430
+ }
431
+ static getDerivedStateFromError(error) {
432
+ return { hasError: true, error }
433
+ }
434
+ componentDidCatch(error, errorInfo) {
435
+ console.error('[dsh-cost-meter] React error caught:', error, errorInfo)
436
+ }
437
+ render() {
438
+ if (this.state.hasError) {
439
+ return React.createElement(
440
+ 'div',
441
+ { className: 'dcm-alert dcm-alert-err', style: { margin: '8px 0' } },
442
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } }, '⚠️ Cost Meter UI Error:'),
443
+ React.createElement('div', { style: { fontSize: 12, wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
444
+ React.createElement(
445
+ 'button',
446
+ {
447
+ type: 'button',
448
+ className: 'dcm-btn',
449
+ style: { marginTop: 8, fontSize: 12, padding: '3px 10px' },
450
+ onClick: () => this.setState({ hasError: false, error: null }),
451
+ },
452
+ 'Retry'
453
+ )
454
+ )
455
+ }
456
+ return this.props?.children || null
457
+ }
458
+ }
459
+ }
460
+ const ErrorBoundary = createErrorBoundary()
461
+
462
+ function getSettingsScope(ctx) {
463
+ return (ctx && typeof ctx.get === 'function' ? ctx.get('configForms') : null) || (ctx && ctx.configForms) || (ctx && ctx.services && ctx.services.configForms) || null
464
+ }
465
+
466
+ function refreshMirrorUntilVisible(ctx) {
467
+ const s = getSettingsScope(ctx)
468
+ const visible = () => {
469
+ try {
470
+ const view = s?.describe?.()?.getSnapshot?.()?.view
471
+ return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
472
+ } catch (e) {
473
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] mirror check error: ' + (e?.message || e))
474
+ return false
475
+ }
476
+ }
477
+ if (!s || visible()) return () => {}
478
+ let tries = 0
479
+ const timer = setInterval(() => {
480
+ if (visible() || tries >= 15) {
481
+ if (!visible() && tries >= 15 && ctx?.logger?.warn) {
482
+ ctx.logger.warn('[dsh-cost-meter] settings mirror did not appear after 15 attempts')
483
+ }
484
+ clearInterval(timer)
485
+ return
486
+ }
487
+ tries += 1
488
+ try {
489
+ s?.describe?.()?.load?.()
490
+ } catch (e) {
491
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] settings load error: ' + (e?.message || e))
492
+ }
493
+ }, 1000)
494
+ return () => clearInterval(timer)
495
+ }
496
+
497
+ let ChevronIcon = null
498
+ try {
499
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
500
+ ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
501
+ } catch (e) {
502
+ ChevronIcon = null
503
+ }
504
+
505
+ function FallbackChevron(props) {
506
+ return React.createElement(
507
+ 'svg',
508
+ {
509
+ width: 14,
510
+ height: 14,
511
+ viewBox: '0 0 14 14',
512
+ fill: 'none',
513
+ 'aria-hidden': true,
514
+ },
515
+ React.createElement('path', {
516
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
517
+ stroke: 'currentColor',
518
+ strokeWidth: 1.5,
519
+ strokeLinecap: 'round',
520
+ strokeLinejoin: 'round',
521
+ }),
522
+ )
523
+ }
524
+
525
+ function Chevron(props) {
526
+ const icon = ChevronIcon
527
+ ? React.createElement(ChevronIcon, { 'aria-hidden': true })
528
+ : React.createElement(FallbackChevron, null)
529
+ return React.createElement(
530
+ 'span',
531
+ {
532
+ className: 'dcm-chevron' + (props.open ? ' dcm-chevron-open' : ''),
533
+ style: { marginLeft: 'auto' },
534
+ },
535
+ icon,
536
+ )
537
+ }
538
+
539
+ function isValidTimeZone(tz) {
540
+ if (!tz || typeof tz !== 'string') return false
541
+ try {
542
+ new Intl.DateTimeFormat(undefined, { timeZone: tz })
543
+ return true
544
+ } catch {
545
+ return false
546
+ }
547
+ }
548
+
549
+ function ago(timestamp, locale) {
550
+ if (!timestamp) return '—'
551
+ const isZh = locale === 'zh'
552
+ const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
553
+ if (minutes < 1) return isZh ? '刚刚' : 'just now'
554
+ if (minutes < 60) return minutes + (isZh ? ' 分钟前' : ' min ago')
555
+ return hours < 48 ? hours + (isZh ? ' 小时前' : ' hrs ago') : Math.round(hours / 24) + (isZh ? ' 天前' : ' days ago')
556
+ }
557
+
558
+ function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
559
+ const { signal, ...rest } = options
560
+ const timer = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
561
+ ? AbortSignal.timeout(timeoutMs)
562
+ : null
563
+ let combined = signal
564
+ if (timer) {
565
+ if (signal && typeof AbortSignal.any === 'function') {
566
+ combined = AbortSignal.any([signal, timer])
567
+ } else if (!signal) {
568
+ combined = timer
569
+ }
570
+ }
571
+ return fetch(url, { ...rest, ...(combined ? { signal: combined } : {}) })
572
+ }
573
+
574
+ // ---------------------------------------------------------------- card
575
+
576
+ function CostMeterCard(props) {
577
+ const ctx = props.ctx
578
+ const locale = useActiveLocale(ctx)
579
+ const t = props.t || makeT(locale === 'zh' ? zh : en, en)
580
+ const page = !!(props && props.view === 'page')
581
+ const [open, setOpen] = React.useState(!!page)
582
+ const [draft, setDraft] = React.useState(null)
583
+ const [saving, setSaving] = React.useState(false)
584
+ const [err, setErr] = React.useState('')
585
+ const [saved, setSaved] = React.useState(false)
586
+ const [diagState, setDiagState] = React.useState(null)
587
+
588
+ // Manual sync button state
589
+ const [syncing, setSyncing] = React.useState(false)
590
+ const [syncMsg, setSyncMsg] = React.useState('')
591
+
592
+ // Plugin in-app updater state
593
+ const [updateState, setUpdateState] = React.useState({
594
+ checking: false,
595
+ updating: false,
596
+ currentVersion: '',
597
+ latestVersion: '',
598
+ updateAvailable: false,
599
+ canAutoUpdate: true,
600
+ error: '',
601
+ notice: '',
602
+ })
603
+
604
+ const checkUpdate = React.useCallback(async (signal) => {
605
+ setUpdateState((s) => ({ ...s, checking: true, error: '' }))
606
+ try {
607
+ const res = await fetchWithTimeout(UPDATE_PATH, { signal })
608
+ if (!res.ok) throw new Error('HTTP ' + res.status)
609
+ const data = await res.json().catch(() => ({}))
610
+ setUpdateState((s) => ({
611
+ ...s,
612
+ checking: false,
613
+ currentVersion: data.currentVersion || s.currentVersion,
614
+ latestVersion: data.latestVersion || '',
615
+ updateAvailable: Boolean(data.updateAvailable),
616
+ canAutoUpdate: data.canAutoUpdate !== false,
617
+ }))
618
+ } catch (e) {
619
+ if (e?.name !== 'AbortError') {
620
+ setUpdateState((s) => ({ ...s, checking: false }))
621
+ }
622
+ }
623
+ }, [])
624
+
625
+ React.useEffect(() => {
626
+ if (!open) return
627
+ const controller = new AbortController()
628
+ checkUpdate(controller.signal)
629
+ return () => controller.abort()
630
+ }, [open, checkUpdate])
631
+
632
+ async function handleTriggerUpdate() {
633
+ if (updateState.updating) return
634
+ setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
635
+ try {
636
+ const res = await fetchWithTimeout(UPDATE_PATH, {
637
+ method: 'POST',
638
+ headers: { 'x-dsh-plugin-update': '1' },
639
+ }, 30000)
640
+ const data = await res.json().catch(() => ({}))
641
+ if (!res.ok || data.ok === false || data.error) {
642
+ throw new Error(data.error || ('HTTP ' + res.status))
643
+ }
644
+ const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
645
+ setUpdateState((s) => ({
646
+ ...s,
647
+ updating: false,
648
+ updateAvailable: false,
649
+ currentVersion: newVer,
650
+ notice: t('update.done', { version: newVer }),
651
+ }))
652
+ setTimeout(() => checkUpdate(), 2000)
653
+ } catch (err) {
654
+ setUpdateState((s) => ({
655
+ ...s,
656
+ updating: false,
657
+ error: t('update.failed', { error: String(err.message || err) }),
658
+ }))
659
+ }
660
+ }
661
+
662
+ const configForms = getSettingsScope(ctx)
663
+ const scope = React.useMemo(
664
+ () => (configForms ? configForms.get(NS) : undefined),
665
+ [configForms],
666
+ )
667
+
668
+ const snapshot = React.useSyncExternalStore(
669
+ React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
670
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
671
+ React.useCallback(() => ({ status: 'loading' }), []),
672
+ )
673
+
674
+ React.useEffect(() => { ensureStyles() }, [])
675
+
676
+ const fetchDiag = React.useCallback((signal) => {
677
+ fetchWithTimeout(STATE_PATH, { signal, headers: { accept: 'application/json' } })
678
+ .then((r) => (r.ok ? r.json() : null))
679
+ .then((data) => { if (data) setDiagState(data) })
680
+ .catch(() => {})
681
+ }, [])
682
+
683
+ React.useEffect(() => {
684
+ if (!open) return
685
+ const controller = new AbortController()
686
+ fetchDiag(controller.signal)
687
+ return () => controller.abort()
688
+ }, [open, fetchDiag])
689
+
690
+ const status = (snapshot && snapshot.status) || 'loading'
691
+ const stored = (snapshot && snapshot.value) || {}
692
+
693
+ React.useEffect(() => {
694
+ if (status === 'ready' && draft === null) {
695
+ setDraft({
696
+ currency: stored.currency !== undefined ? String(stored.currency) : '$',
697
+ usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
698
+ budgetThreshold: stored.budgetThreshold !== undefined ? String(stored.budgetThreshold) : '0',
699
+ displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
700
+ alertBeforeSwitch: stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : true,
701
+ alertLeadMinutes: stored.alertLeadMinutes !== undefined ? String(stored.alertLeadMinutes) : '5',
702
+ browserNotifications: stored.browserNotifications !== undefined ? Boolean(stored.browserNotifications) : false,
703
+ useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
704
+ refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
705
+ })
706
+ }
707
+ }, [status, stored, draft])
708
+
709
+ const tzValid = draft ? isValidTimeZone(draft.displayTimeZone) : true
710
+ const rateValid = draft ? (!isNaN(Number(draft.usdRate)) && Number(draft.usdRate) > 0) : true
711
+ const budgetValid = draft ? (!isNaN(Number(draft.budgetThreshold)) && Number(draft.budgetThreshold) >= 0) : true
712
+ const hoursValid = draft ? (!isNaN(Number(draft.refreshHours)) && Number(draft.refreshHours) >= 1 && Number(draft.refreshHours) <= 168) : true
713
+
714
+ const handleManualSync = async () => {
715
+ if (syncing) return
716
+ setSyncing(true)
717
+ setSyncMsg('')
718
+ try {
719
+ const res = await fetchWithTimeout(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
720
+ if (res.status === 429) {
721
+ const data = await res.json().catch(() => ({}))
722
+ const retryAfter = data.retryAfter || 30
723
+ setSyncMsg(t('action.sync_rate_limited', { seconds: retryAfter }))
724
+ } else if (res.ok) {
725
+ setSyncMsg(t('action.sync_ok'))
726
+ fetchDiag()
727
+ } else {
728
+ setSyncMsg(t('action.sync_err'))
729
+ }
730
+ } catch (_) {
731
+ setSyncMsg(t('action.sync_err'))
732
+ } finally {
733
+ setSyncing(false)
734
+ setTimeout(() => setSyncMsg(''), 4000)
735
+ }
736
+ }
737
+
738
+ const save = async () => {
739
+ if (!scope || !draft) return
740
+ setSaving(true); setErr(''); setSaved(false)
741
+ const broken = []
742
+
743
+ try {
744
+ await scope.set('displayMode', (draft.displayMode === 'compact' || draft.displayMode === 'full') ? draft.displayMode : 'auto')
745
+ } catch (e) { broken.push('displayMode: ' + (e && e.message || String(e))) }
746
+
747
+ try {
748
+ await scope.set('currency', String(draft.currency || '$'))
749
+ } catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
750
+
751
+ const rateNum = Number(String(draft.usdRate).trim())
752
+ if (!Number.isFinite(rateNum) || rateNum <= 0) {
753
+ broken.push('usdRate: must be a positive number')
754
+ } else {
755
+ try {
756
+ await scope.set('usdRate', rateNum)
757
+ } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
758
+ }
759
+
760
+ const budgetNum = Number(String(draft.budgetThreshold).trim())
761
+ if (!Number.isFinite(budgetNum) || budgetNum < 0) {
762
+ broken.push('budgetThreshold: must be non-negative')
763
+ } else {
764
+ try {
765
+ await scope.set('budgetThreshold', budgetNum)
766
+ } catch (e) { broken.push('budgetThreshold: ' + (e && e.message || String(e))) }
767
+ }
768
+
769
+ const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
770
+ if (!isValidTimeZone(tzVal)) {
771
+ broken.push('displayTimeZone: invalid IANA time zone')
772
+ } else {
773
+ try {
774
+ await scope.set('displayTimeZone', tzVal)
775
+ } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
776
+ }
777
+
778
+ try {
779
+ await scope.set('useOpenRouter', Boolean(draft.useOpenRouter))
780
+ } catch (e) { broken.push('useOpenRouter: ' + (e && e.message || String(e))) }
781
+
782
+ const hoursNum = Number(String(draft.refreshHours).trim())
783
+ if (!Number.isFinite(hoursNum) || hoursNum < 1 || hoursNum > 168) {
784
+ broken.push('refreshHours: must be between 1 and 168 hours')
785
+ } else {
786
+ try {
787
+ await scope.set('refreshHours', hoursNum)
788
+ } catch (e) { broken.push('refreshHours: ' + (e && e.message || String(e))) }
789
+ }
790
+
791
+ setSaving(false)
792
+ if (broken.length) {
793
+ setErr(t('settings.saveFailed') + broken.join('; '))
794
+ return
795
+ }
796
+ setSaved(true)
797
+ setTimeout(() => setSaved(false), 2500)
798
+ }
799
+
800
+ const modelsCount = (diagState && typeof diagState.catalogModelsCount === 'number')
801
+ ? diagState.catalogModelsCount
802
+ : ((diagState && diagState.schedules) ? Object.keys(diagState.schedules).length : null)
803
+ const catalogHasErr = diagState && Boolean(diagState.catalogError)
804
+ const openRouterEnabled = draft ? Boolean(draft.useOpenRouter) : true
805
+
806
+ // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
807
+ // padding, so the summary is a one-liner and the page drops our card chrome.
808
+ if (props && props.view === 'summary') {
809
+ return React.createElement('span', { className: 'dcm-sub' }, t('header.sub'))
810
+ }
811
+
812
+ return React.createElement(page ? 'div' : 'li', { className: page ? 'dcm-page' : 'dcm-card' },
813
+ React.createElement('button', {
814
+ type: 'button', className: 'dcm-head', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open,
815
+ onClick: () => setOpen((v) => !v),
816
+ },
817
+ React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
818
+ React.createElement('span', { className: 'dcm-title' }, t('header.title')),
819
+ React.createElement('span', { className: 'dcm-sub' }, t('header.sub')),
820
+ React.createElement('div', { className: 'dcm-badges' },
821
+ React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.deepseek')),
822
+ openRouterEnabled
823
+ ? (catalogHasErr
824
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-bad' }, t('badge.catalog_err'))
825
+ : (modelsCount !== null && modelsCount > 0
826
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.catalog_ok', { count: modelsCount }))
827
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_empty'))))
828
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_off')),
829
+ catalogHasErr && modelsCount ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_stale')) : null,
830
+ draft && draft.displayTimeZone
831
+ ? React.createElement('span', { className: 'dcm-badge' }, draft.displayTimeZone)
832
+ : null,
833
+ ),
834
+ ),
835
+ React.createElement(Chevron, { open }),
836
+ ),
837
+ (page || open) ? React.createElement('div', { className: 'dcm-body' },
838
+ status === 'loading'
839
+ ? React.createElement('div', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
840
+ : status !== 'ready'
841
+ ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
842
+ : React.createElement(React.Fragment, null,
843
+ // In-app Update Bar
844
+ React.createElement('div', {
845
+ className: 'dcm-section-card',
846
+ style: { padding: '10px 14px', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
847
+ },
848
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 } },
849
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
850
+ 'v' + updateState.currentVersion
851
+ ),
852
+ updateState.checking
853
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 } }, t('update.checking'))
854
+ : updateState.updateAvailable
855
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' },
856
+ t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
857
+ )
858
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-ok' },
859
+ '✓ ' + t('update.up_to_date')
860
+ )
861
+ ),
862
+ updateState.updateAvailable
863
+ ? React.createElement('button', {
864
+ type: 'button',
865
+ className: 'dcm-btn dcm-btn-primary',
866
+ disabled: updateState.updating,
867
+ onClick: handleTriggerUpdate,
868
+ style: { padding: '4px 10px', fontSize: 12 },
869
+ }, updateState.updating ? t('update.updating') : t('update.btn'))
870
+ : null
871
+ ),
872
+ updateState.notice ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, '✓ ' + updateState.notice) : null,
873
+ updateState.error ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, updateState.error) : null,
874
+
875
+ // Section 1: General Parameters
876
+ React.createElement('div', { className: 'dcm-section-card' },
877
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
878
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.general_desc')),
879
+ React.createElement('div', { className: 'dcm-grid-2' },
880
+ React.createElement('div', { className: 'dcm-field' },
881
+ React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
882
+ React.createElement('input', {
883
+ className: 'dcm-input',
884
+ value: (draft && draft.currency) !== undefined ? draft.currency : '',
885
+ onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
886
+ }),
887
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
888
+ ),
889
+ React.createElement('div', { className: 'dcm-field' },
890
+ React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
891
+ React.createElement('input', {
892
+ className: 'dcm-input' + (rateValid ? '' : ' dcm-input-err'),
893
+ type: 'number',
894
+ step: 'any',
895
+ value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
896
+ onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
897
+ }),
898
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
899
+ ),
900
+ ),
901
+ React.createElement('div', { className: 'dcm-grid-2' },
902
+ React.createElement('div', { className: 'dcm-field' },
903
+ React.createElement('label', { className: 'dcm-label' }, t('field.budgetThreshold')),
904
+ React.createElement('input', {
905
+ className: 'dcm-input' + (budgetValid ? '' : ' dcm-input-err'),
906
+ type: 'number',
907
+ step: 'any',
908
+ min: 0,
909
+ value: (draft && draft.budgetThreshold) !== undefined ? draft.budgetThreshold : '',
910
+ onChange: (e) => setDraft({ ...draft, budgetThreshold: e.target.value }),
911
+ }),
912
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.budgetThreshold')),
913
+ ),
914
+ React.createElement('div', { className: 'dcm-field' },
915
+ React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
916
+ React.createElement('input', {
917
+ className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
918
+ value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
919
+ onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
920
+ }),
921
+ React.createElement('span', { className: 'dcm-hint' },
922
+ tzValid ? t('hint.displayTimeZone') : t('tz.invalid')
923
+ ),
924
+ ),
925
+ ),
926
+ React.createElement('div', { className: 'dcm-field', style: { marginTop: 6 } },
927
+ React.createElement('label', { className: 'dcm-label' }, t('field.displayMode')),
928
+ React.createElement('select', {
929
+ className: 'dcm-input',
930
+ value: (draft && draft.displayMode) || 'auto',
931
+ onChange: (e) => setDraft({ ...draft, displayMode: e.target.value }),
932
+ }, [
933
+ React.createElement('option', { key: 'auto', value: 'auto' }, t('mode.auto')),
934
+ React.createElement('option', { key: 'full', value: 'full' }, t('mode.full')),
935
+ React.createElement('option', { key: 'compact', value: 'compact' }, t('mode.compact')),
936
+ ]),
937
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.displayMode')),
938
+ ),
939
+ ),
940
+
941
+ // Section: Tariff Transition Alerts (Issue #117)
942
+ React.createElement('div', { className: 'dcm-section-card' },
943
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.alerts')),
944
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.alerts_desc')),
945
+ React.createElement('div', { className: 'dcm-row-check' },
946
+ React.createElement('input', {
947
+ id: 'dcm-alert-switch',
948
+ type: 'checkbox',
949
+ className: 'dcm-check',
950
+ checked: draft ? Boolean(draft.alertBeforeSwitch) : true,
951
+ onChange: (e) => setDraft({ ...draft, alertBeforeSwitch: e.target.checked }),
952
+ }),
953
+ React.createElement('label', { htmlFor: 'dcm-alert-switch', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.alertBeforeSwitch')),
954
+ ),
955
+ React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.alertBeforeSwitch')),
956
+ React.createElement('div', { className: 'dcm-grid-2', style: { marginTop: 6 } },
957
+ React.createElement('div', { className: 'dcm-field' },
958
+ React.createElement('label', { className: 'dcm-label' }, t('field.alertLeadMinutes')),
959
+ React.createElement('select', {
960
+ className: 'dcm-input',
961
+ value: (draft && draft.alertLeadMinutes) || '5',
962
+ onChange: (e) => setDraft({ ...draft, alertLeadMinutes: e.target.value }),
963
+ }, [
964
+ React.createElement('option', { key: '1', value: '1' }, '1 min'),
965
+ React.createElement('option', { key: '2', value: '2' }, '2 min'),
966
+ React.createElement('option', { key: '5', value: '5' }, '5 min'),
967
+ React.createElement('option', { key: '10', value: '10' }, '10 min'),
968
+ ]),
969
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.alertLeadMinutes')),
970
+ ),
971
+ React.createElement('div', { className: 'dcm-field' },
972
+ React.createElement('label', { className: 'dcm-label' }, t('field.browserNotifications')),
973
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, height: 34 } },
974
+ React.createElement('input', {
975
+ id: 'dcm-browser-notif',
976
+ type: 'checkbox',
977
+ className: 'dcm-check',
978
+ checked: draft ? Boolean(draft.browserNotifications) : false,
979
+ onChange: (e) => {
980
+ const checked = e.target.checked
981
+ setDraft({ ...draft, browserNotifications: checked })
982
+ if (checked && typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
983
+ Notification.requestPermission()
984
+ }
985
+ },
986
+ }),
987
+ React.createElement('button', {
988
+ type: 'button',
989
+ className: 'dcm-btn dcm-btn-sm',
990
+ onClick: () => {
991
+ if (typeof Notification === 'undefined') return
992
+ Notification.requestPermission().then((p) => {
993
+ if (p === 'granted') {
994
+ new Notification('DeepSeek Tariff Alert (Test)', {
995
+ body: t('alert.pre_switch_offpeak', { time: '5m' }),
996
+ })
997
+ }
998
+ })
999
+ },
1000
+ }, t('action.test_notification')),
1001
+ ),
1002
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.browserNotifications')),
1003
+ ),
1004
+ ),
1005
+ ),
1006
+
1007
+ // Section 2: OpenRouter Pricing Catalog
1008
+ React.createElement('div', { className: 'dcm-section-card' },
1009
+ React.createElement('div', { className: 'dcm-section-title' },
1010
+ React.createElement('span', null, t('section.openrouter')),
1011
+ openRouterEnabled
1012
+ ? React.createElement('button', {
1013
+ type: 'button',
1014
+ className: 'dcm-btn dcm-btn-sm',
1015
+ disabled: syncing,
1016
+ onClick: handleManualSync,
1017
+ }, syncing ? t('action.syncing') : t('action.sync_now'))
1018
+ : null
1019
+ ),
1020
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.openrouter_desc')),
1021
+ syncMsg ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok', style: { padding: '6px 10px', fontSize: 12 } }, syncMsg) : null,
1022
+ React.createElement('div', { className: 'dcm-row-check' },
1023
+ React.createElement('input', {
1024
+ id: 'dcm-use-openrouter',
1025
+ type: 'checkbox',
1026
+ className: 'dcm-check',
1027
+ checked: draft ? Boolean(draft.useOpenRouter) : true,
1028
+ onChange: (e) => setDraft({ ...draft, useOpenRouter: e.target.checked }),
1029
+ }),
1030
+ React.createElement('label', { htmlFor: 'dcm-use-openrouter', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.useOpenRouter')),
1031
+ ),
1032
+ React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.useOpenRouter')),
1033
+ React.createElement('div', { className: 'dcm-field', style: { marginTop: 4 } },
1034
+ React.createElement('label', { className: 'dcm-label' }, t('field.refreshHours')),
1035
+ React.createElement('input', {
1036
+ className: 'dcm-input' + (hoursValid ? '' : ' dcm-input-err'),
1037
+ type: 'number',
1038
+ min: 1,
1039
+ max: 168,
1040
+ value: (draft && draft.refreshHours) !== undefined ? draft.refreshHours : '',
1041
+ onChange: (e) => setDraft({ ...draft, refreshHours: e.target.value }),
1042
+ }),
1043
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.refreshHours')),
1044
+ ),
1045
+ ),
1046
+
1047
+ // Section 3: Live Diagnostics
1048
+ React.createElement('div', { className: 'dcm-section-card' },
1049
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.diagnostics')),
1050
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.diagnostics_desc')),
1051
+ React.createElement('div', { className: 'dcm-grid-2' },
1052
+ React.createElement('div', { className: 'dcm-stat-box' },
1053
+ React.createElement('span', { className: 'dcm-stat-val' },
1054
+ modelsCount !== null ? String(modelsCount) : '—'
1055
+ ),
1056
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.models_count')),
1057
+ ),
1058
+ React.createElement('div', { className: 'dcm-stat-box' },
1059
+ React.createElement('span', { className: 'dcm-stat-val' },
1060
+ diagState ? ago(diagState.fetchedAt, locale) : '—'
1061
+ ),
1062
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.last_sync')),
1063
+ ),
1064
+ React.createElement('div', { className: 'dcm-stat-box' },
1065
+ React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
1066
+ diagState && diagState.route ? (diagState.route.model || diagState.route.provider) : 'default'
1067
+ ),
1068
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.route')),
1069
+ ),
1070
+ React.createElement('div', { className: 'dcm-stat-box' },
1071
+ React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
1072
+ diagState && diagState.source ? String(diagState.source).toUpperCase() : 'DEEPSEEK'
1073
+ ),
1074
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.tariff_source')),
1075
+ ),
1076
+ ),
1077
+ ),
1078
+
1079
+ // Alerts & Action Footer
1080
+ err ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, err) : null,
1081
+ saved ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, t('settings.saved')) : null,
1082
+ React.createElement('div', { className: 'dcm-foot' },
1083
+ React.createElement('button', {
1084
+ type: 'button',
1085
+ className: 'dcm-btn dcm-btn-primary',
1086
+ disabled: saving || !tzValid || !rateValid || !budgetValid || !hoursValid,
1087
+ onClick: save,
1088
+ }, saving ? t('settings.saving') : t('settings.save')),
1089
+ ),
1090
+ )
1091
+ ) : null,
1092
+ )
1093
+ }
1094
+
1095
+ // ---------------------------------------------------------------- tariff
1096
+
1097
+ function inWindow(minute, w) {
1098
+ return w.start <= w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
1099
+ }
1100
+
1101
+ function ratesAt(schedule, minute) {
1102
+ const windows = (schedule && schedule.windows) || []
1103
+ for (const w of windows) if (inWindow(minute, w)) return w.rates
1104
+ return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
1105
+ }
1106
+
1107
+ function minutesUntilChange(schedule, minute) {
1108
+ const windows = (schedule && schedule.windows) || []
1109
+ if (!windows.length) return null
1110
+ const now = ratesAt(schedule, minute)
1111
+ const transitions = new Set()
1112
+ for (const w of windows) {
1113
+ transitions.add(w.start % DAY)
1114
+ transitions.add(w.end % DAY)
1115
+ }
1116
+ let minDiff = null
1117
+ for (const t of transitions) {
1118
+ let diff = (t - minute + DAY) % DAY
1119
+ if (diff === 0) diff = DAY
1120
+ const probe = ratesAt(schedule, (minute + diff) % DAY)
1121
+ if (probe.output !== now.output || probe.input !== now.input) {
1122
+ if (minDiff === null || diff < minDiff) minDiff = diff
1123
+ }
1124
+ }
1125
+ return minDiff
1126
+ }
1127
+
1128
+ const extremesMemo = new WeakMap()
1129
+ function extremes(schedule) {
1130
+ if (!schedule) return { low: {}, high: {}, flat: true }
1131
+ if (extremesMemo.has(schedule)) return extremesMemo.get(schedule)
1132
+
1133
+ const base = schedule.base || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
1134
+ const candidates = [base]
1135
+ for (const w of (schedule.windows || [])) {
1136
+ if (w.rates) candidates.push(w.rates)
1137
+ }
1138
+
1139
+ let low = candidates[0]
1140
+ let high = candidates[0]
1141
+ for (const r of candidates) {
1142
+ if (r.output < low.output) low = r
1143
+ if (r.output > high.output) high = r
1144
+ }
1145
+
1146
+ const res = { low, high, flat: low.output === high.output }
1147
+ extremesMemo.set(schedule, res)
1148
+ return res
1149
+ }
1150
+
1151
+ function sumSlots(slots) {
1152
+ const total = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
1153
+ for (const key of Object.keys(slots || {})) {
1154
+ const b = slots[key] || {}
1155
+ for (const field of Object.keys(total)) total[field] += Number(b[field]) || 0
1156
+ }
1157
+ return total
1158
+ }
1159
+
1160
+ function spendBySlots(slots, schedule) {
1161
+ let usd = 0
1162
+ for (const key of Object.keys(slots || {})) {
1163
+ const minute = (Number(key) * 30 + 15) % 1440
1164
+ usd += spendUsd(slots[key], ratesAt(schedule, minute))
1165
+ }
1166
+ return usd
1167
+ }
1168
+
1169
+ function cacheSavingsBySlots(slots, schedule) {
1170
+ let savedUsd = 0
1171
+ let missedUsd = 0
1172
+ let readTokens = 0
1173
+ for (const key of Object.keys(slots || {})) {
1174
+ const minute = (Number(key) * 30 + 15) % 1440
1175
+ const rates = ratesAt(schedule, minute)
1176
+ const u = slots[key] || {}
1177
+ const cRead = Number(u.cacheReadTokens) || 0
1178
+ if (cRead > 0 && rates && rates.input > rates.cacheHit) {
1179
+ readTokens += cRead
1180
+ const missed = (cRead / 1e6) * (rates.input || 0)
1181
+ const hit = (cRead / 1e6) * (rates.cacheHit || 0)
1182
+ savedUsd += Math.max(0, missed - hit)
1183
+ missedUsd += missed
1184
+ }
1185
+ }
1186
+ const percent = missedUsd > 0 ? Math.round((savedUsd / missedUsd) * 100) : 0
1187
+ return { savedUsd, missedUsd, readTokens, percent }
1188
+ }
1189
+
1190
+
1191
+ function spendUsd(usage, rates) {
1192
+ const u = usage || {}
1193
+ const perMillion =
1194
+ (Number(u.cacheReadTokens) || 0) * (rates.cacheHit || 0) +
1195
+ (Number(u.uncachedInputTokens) || 0) * (rates.input || 0) +
1196
+ (Number(u.cacheWriteTokens) || 0) * (rates.cacheWrite || 0) +
1197
+ (Number(u.outputTokens) || 0) * (rates.output || 0)
1198
+ return perMillion / 1e6
1199
+ }
1200
+
1201
+ function normalizeCurrencyCode(symbolOrCode) {
1202
+ const str = String(symbolOrCode || '').trim().toUpperCase()
1203
+ if (str === '₽' || str === 'RUB' || str === 'РУБ') return 'RUB'
1204
+ if (str === '€' || str === 'EUR') return 'EUR'
1205
+ if (str === '¥' || str === 'CNY' || str === 'RMB') return 'CNY'
1206
+ if (str === '$' || str === 'USD') return 'USD'
1207
+ return str
1208
+ }
1209
+
1210
+ function rate(value) {
1211
+ if (!value) return '—'
1212
+ const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
1213
+ return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
1214
+ }
1215
+
1216
+ function money(value, currency) {
1217
+ const n = Number(value) || 0
1218
+ return currency + n.toFixed(Math.abs(n) >= 100 ? 0 : 2)
1219
+ }
1220
+
1221
+ function tokens(value) {
1222
+ const n = Number(value) || 0
1223
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
1224
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
1225
+ return String(n)
1226
+ }
1227
+
1228
+ function clock(minutes) {
1229
+ if (minutes === null) return null
1230
+ return Math.floor(minutes / 60) + ':' + String(minutes % 60).padStart(2, '0')
1231
+ }
1232
+
1233
+ function hhmm(minute) {
1234
+ return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
1235
+ }
1236
+
1237
+ function hhmmInZone(minute, zone, reference) {
1238
+ const d = new Date(Date.UTC(
1239
+ reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
1240
+ ))
1241
+ try {
1242
+ return new Intl.DateTimeFormat('en-GB', {
1243
+ hour: '2-digit', minute: '2-digit', hour12: false, timeZone: zone,
1244
+ }).format(d)
1245
+ } catch {
1246
+ return hhmm(minute)
1247
+ }
1248
+ }
1249
+
1250
+ function zoneLabel(zone) {
1251
+ if (!zone) return 'UTC'
1252
+ const city = zone.includes('/') ? zone.slice(zone.lastIndexOf('/') + 1) : zone
1253
+ return city.replace(/_/g, ' ')
1254
+ }
1255
+
1256
+
1257
+
1258
+ function calculateBurnRateAndProjection(turns, totalCostUsd, options = {}) {
1259
+ const currentTotal = Number(totalCostUsd) > 0 ? Number(totalCostUsd) : 0
1260
+ if (!Array.isArray(turns) || turns.length < 2) {
1261
+ return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1262
+ }
1263
+ const now = typeof options.now === 'number' ? options.now : Date.now()
1264
+ const windowMs = 10 * 60 * 1000
1265
+ const sorted = turns.filter((t) => t && typeof t.timestamp === 'number').sort((a, b) => a.timestamp - b.timestamp)
1266
+ if (sorted.length < 2) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1267
+ let sample = sorted.filter((t) => t.timestamp >= now - windowMs)
1268
+ if (sample.length < 2) sample = sorted.slice(-Math.min(sorted.length, 10))
1269
+ const durationMs = sample[sample.length - 1].timestamp - sample[0].timestamp
1270
+ if (durationMs < 5000) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1271
+ let costInSample = sample.reduce((acc, t) => acc + (Number(t.costDeltaUsd) || 0), 0)
1272
+ if (costInSample <= 0 && currentTotal > 0) {
1273
+ const totalTokens = sorted.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
1274
+ const sampleTokens = sample.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
1275
+ costInSample = totalTokens > 0 ? (sampleTokens / totalTokens) * currentTotal : currentTotal
1276
+ }
1277
+ const hours = durationMs / (1000 * 60 * 60)
1278
+ const rawBurnRate = hours > 0 ? (costInSample / hours) : 0
1279
+ const burnRatePerHourUsd = (Number.isFinite(rawBurnRate) && rawBurnRate >= 0) ? Math.round(rawBurnRate * 10000) / 10000 : 0
1280
+ const stepCount = sorted.length
1281
+ const avgCostPerStep = currentTotal / stepCount
1282
+ const projectedExtra = Math.min(currentTotal * 2, Math.max(avgCostPerStep * 3, burnRatePerHourUsd * 0.25))
1283
+ const projectedCostUsd = Math.round((currentTotal + (projectedExtra > 0 ? projectedExtra : 0)) * 10000) / 10000
1284
+ return { burnRatePerHourUsd, projectedCostUsd }
1285
+ }
1286
+
1287
+ function renderSparkline(turns, options) {
1288
+ if (!Array.isArray(turns) || turns.length === 0) return null
1289
+ const { h, currency, fx, activeRate, t } = options
1290
+
1291
+ const maxTokens = Math.max(1, ...turns.map((item) => Number(item.tokens) || 0))
1292
+ const height = 36
1293
+ const width = 240
1294
+ const count = turns.length
1295
+ const gap = count > 15 ? 2 : 4
1296
+ const barWidth = Math.max(3, Math.min(16, Math.floor((width - (count - 1) * gap) / count)))
1297
+ const totalBarSpace = count * barWidth + (count - 1) * gap
1298
+ const startX = Math.max(0, Math.floor((width - totalBarSpace) / 2))
1299
+
1300
+ const bars = turns.map((item, idx) => {
1301
+ const tok = Number(item.tokens) || 0
1302
+ const barHeight = Math.max(3, Math.round((tok / maxTokens) * (height - 6)))
1303
+ const x = startX + idx * (barWidth + gap)
1304
+ const y = height - barHeight
1305
+ const isPeak = tok === maxTokens && tok > 0
1306
+ const stepCost = (item.costDeltaUsd && item.costDeltaUsd > 0)
1307
+ ? item.costDeltaUsd * fx
1308
+ : (activeRate ? (tok / 1e6) * ((activeRate.input + activeRate.output) / 2) * fx : 0)
1309
+
1310
+ const tip = (t ? t('sparkline.steps') : 'step') + ' ' + (item.turn || idx + 1) +
1311
+ (item.step ? '.' + item.step : '') + ': ' + tokens(tok) +
1312
+ (stepCost > 0 ? ' (≈ ' + money(stepCost, currency) + ')' : '')
1313
+
1314
+ return h('rect', {
1315
+ key: 'bar-' + idx,
1316
+ x,
1317
+ y,
1318
+ width: barWidth,
1319
+ height: barHeight,
1320
+ rx: 2,
1321
+ ry: 2,
1322
+ className: isPeak ? 'dcm-spark-bar dcm-spark-peak' : 'dcm-spark-bar',
1323
+ }, h('title', null, tip))
1324
+ })
1325
+
1326
+ return h('div', { key: 'sparkline', className: 'dcm-sparkline-box' }, [
1327
+ h('div', { key: 'spark-hdr', className: 'dcm-sparkline-header' }, [
1328
+ h('span', { key: 'lbl', className: 'dcm-section-lbl' }, t ? t('sparkline.title') : 'Turn dynamics'),
1329
+ h('span', { key: 'cnt', className: 'dcm-sparkline-count' }, count + ' ' + (t ? t('sparkline.steps') : 'steps')),
1330
+ ]),
1331
+ h('svg', {
1332
+ key: 'spark-svg',
1333
+ viewBox: '0 0 ' + width + ' ' + height,
1334
+ className: 'dcm-sparkline-svg',
1335
+ preserveAspectRatio: 'xMidYMid meet',
1336
+ }, bars),
1337
+ ])
1338
+ }
1339
+
1340
+
1341
+
1342
+ // --------------------------------------------------- Export helpers (Issue #118)
1343
+
1344
+ function sanitizeCsvCell(value) {
1345
+ if (value === null || value === undefined) return ''
1346
+ let str = String(value)
1347
+ if (/^[=+\-@\t\r]/.test(str)) str = "'" + str
1348
+ if (/[",\n\r]/.test(str)) str = '"' + str.replace(/"/g, '""') + '"'
1349
+ return str
1350
+ }
1351
+
1352
+ function generateSessionCsv(data) {
1353
+ const { model = 'unknown', provider = '', currency = '$', fx = 1, turns = [], totalCostUsd = 0 } = data || {}
1354
+ const headers = ['Timestamp', 'ISO_Time', 'Turn', 'Step', 'Model', 'Uncached_Input', 'Cache_Read', 'Cache_Hit_%', 'Output', 'Total_Tokens', 'Cost_USD', 'Cost_Local', 'Currency']
1355
+ const rows = [headers.map(sanitizeCsvCell).join(',')]
1356
+ let sumIn = 0, sumHit = 0, sumOut = 0, sumTot = 0, sumCost = 0
1357
+ for (const t of turns) {
1358
+ const ts = t.timestamp ? Number(t.timestamp) : Date.now()
1359
+ const iso = new Date(ts).toISOString()
1360
+ const uncached = Number(t.uncachedInputTokens) || 0
1361
+ const hit = Number(t.cacheReadTokens) || 0
1362
+ const out = Number(t.outputTokens) || 0
1363
+ const tot = uncached + hit + out
1364
+ const hitRate = t.cacheHitRate !== undefined ? t.cacheHitRate : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1365
+ const costUsd = Number(t.costDeltaUsd) || 0
1366
+ sumIn += uncached; sumHit += hit; sumOut += out; sumTot += tot; sumCost += costUsd
1367
+ rows.push([ts, iso, t.turn ?? '', t.step ?? 0, provider ? (provider + '/' + model) : model, uncached, hit, hitRate, out, tot, costUsd.toFixed(6), (costUsd * fx).toFixed(4), currency].map(sanitizeCsvCell).join(','))
1368
+ }
1369
+ const overallHit = sumIn + sumHit > 0 ? Math.round((sumHit / (sumIn + sumHit)) * 100) : 0
1370
+ rows.push(['TOTAL', new Date().toISOString(), turns.length, '', provider ? (provider + '/' + model) : model, sumIn, sumHit, overallHit, sumOut, sumTot, (totalCostUsd || sumCost).toFixed(6), ((totalCostUsd || sumCost) * fx).toFixed(4), currency].map(sanitizeCsvCell).join(','))
1371
+ return '\uFEFF' + rows.join('\r\n')
1372
+ }
1373
+
1374
+ function generateSessionJson(data) {
1375
+ const { model = 'unknown', provider = '', currency = '$', fx = 1, turns = [], totalCostUsd = 0, cacheSavingsUsd = 0, cacheSavingsPercent = 0, tier = 'Standard' } = data || {}
1376
+ return {
1377
+ version: 1,
1378
+ generator: '@goodandready/dsh-cost-meter',
1379
+ exportedAt: new Date().toISOString(),
1380
+ session: {
1381
+ model, provider, currency, fxRate: fx, totalCostUsd, totalCostLocal: totalCostUsd * fx,
1382
+ cacheSavings: { savedUsd: cacheSavingsUsd, savedLocal: cacheSavingsUsd * fx, percent: cacheSavingsPercent },
1383
+ tier, turnsCount: turns.length,
1384
+ },
1385
+ turns: turns.map((t) => ({
1386
+ turn: t.turn, step: t.step ?? 0, timestamp: t.timestamp,
1387
+ tokens: { uncachedInput: Number(t.uncachedInputTokens) || 0, cacheRead: Number(t.cacheReadTokens) || 0, output: Number(t.outputTokens) || 0, total: Number(t.tokens) || 0, cacheHitRate: t.cacheHitRate || 0 },
1388
+ costDeltaUsd: Number(t.costDeltaUsd) || 0, costDeltaLocal: (Number(t.costDeltaUsd) || 0) * fx,
1389
+ })),
1390
+ }
1391
+ }
1392
+
1393
+ function renderSummaryCardToCanvas(canvas, data) {
1394
+ if (!canvas || typeof canvas.getContext !== 'function') return null
1395
+ const width = 1200, height = 680
1396
+ canvas.width = width; canvas.height = height
1397
+ const ctx = canvas.getContext('2d')
1398
+ if (!ctx) return null
1399
+ const { model = 'model', provider = '', currency = '$', fx = 1, costUsd = 0, tokens = {}, cacheSavingsUsd = 0, cacheSavingsPercent = 0, tier = 'Standard', turns = [] } = data || {}
1400
+ const totalCost = costUsd * fx
1401
+ const costFmt = totalCost >= 1 ? (currency + totalCost.toFixed(2)) : (totalCost > 0 ? (currency + totalCost.toFixed(4)) : (currency + '0.00'))
1402
+ const uncached = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
1403
+ const hit = Number(tokens.cacheReadTokens) || 0
1404
+ const out = Number(tokens.outputTokens) || 0
1405
+ const totTok = uncached + hit + out
1406
+ const hitRate = uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0
1407
+ const savedAmt = cacheSavingsUsd * fx
1408
+ const savedFmt = savedAmt >= 1 ? (currency + savedAmt.toFixed(2)) : (currency + savedAmt.toFixed(4))
1409
+
1410
+ const bg = ctx.createLinearGradient(0, 0, width, height)
1411
+ bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1e293b')
1412
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, width, height)
1413
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 3; ctx.strokeRect(20, 20, width - 40, height - 40)
1414
+
1415
+ ctx.fillStyle = '#1e293b'; ctx.beginPath(); ctx.roundRect(50, 45, 220, 44, 22); ctx.fill()
1416
+ ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.stroke()
1417
+ ctx.fillStyle = '#38bdf8'; ctx.font = 'bold 20px system-ui, sans-serif'; ctx.fillText('⚡ Cost Meter', 75, 74)
1418
+
1419
+ ctx.fillStyle = '#94a3b8'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'right'
1420
+ ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), width - 60, 74)
1421
+ ctx.textAlign = 'left'
1422
+
1423
+ ctx.fillStyle = '#f8fafc'; ctx.font = 'bold 36px system-ui, sans-serif'
1424
+ ctx.fillText(provider ? (provider + '/' + model) : model, 50, 145)
1425
+
1426
+ ctx.fillStyle = tier.includes('Off-Peak') || tier.includes('低谷') ? '#10b981' : '#f59e0b'
1427
+ ctx.font = 'bold 20px system-ui, sans-serif'; ctx.fillText(tier, 50, 180)
1428
+
1429
+ const tiles = [
1430
+ { label: 'SESSION SPEND', val: costFmt, color: '#38bdf8' },
1431
+ { label: 'TOTAL TOKENS', val: totTok.toLocaleString(), color: '#f8fafc' },
1432
+ { label: 'CACHE HIT RATE', val: hitRate + '%', color: '#34d399' },
1433
+ { label: 'CACHE SAVED', val: '≈ ' + savedFmt + ' (' + cacheSavingsPercent + '%)', color: '#a78bfa' },
1434
+ ]
1435
+ tiles.forEach((t, i) => {
1436
+ const x = 50 + i * 270, y = 215
1437
+ ctx.fillStyle = 'rgba(30, 41, 59, 0.7)'; ctx.beginPath(); ctx.roundRect(x, y, 250, 110, 12); ctx.fill()
1438
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 1.5; ctx.stroke()
1439
+ ctx.fillStyle = '#94a3b8'; ctx.font = 'bold 14px system-ui, sans-serif'; ctx.fillText(t.label, x + 16, y + 36)
1440
+ ctx.fillStyle = t.color; ctx.font = 'bold 26px system-ui, sans-serif'; ctx.fillText(t.val, x + 16, y + 80)
1441
+ })
1442
+
1443
+ const chartY = 360, chartH = 190, chartW = width - 100
1444
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.6)'; ctx.beginPath(); ctx.roundRect(50, chartY, chartW, chartH, 12); ctx.fill()
1445
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 1.5; ctx.stroke()
1446
+ ctx.fillStyle = '#64748b'; ctx.font = 'bold 15px system-ui, sans-serif'; ctx.fillText('TURN HISTORY & STEP DYNAMICS', 70, chartY + 35)
1447
+
1448
+ const activeTurns = turns.slice(-25)
1449
+ if (activeTurns.length > 0) {
1450
+ const maxTok = Math.max(...activeTurns.map((t) => Number(t.tokens) || 1), 100)
1451
+ const barW = Math.min(32, Math.floor((chartW - 60) / activeTurns.length) - 6)
1452
+ const baseY = chartY + chartH - 30
1453
+ activeTurns.forEach((t, i) => {
1454
+ const x = 70 + i * (barW + 8)
1455
+ const tok = Number(t.tokens) || 0
1456
+ const h = Math.max(6, Math.round((tok / maxTok) * 100))
1457
+ ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.roundRect(x, baseY - h, barW, h, 4); ctx.fill()
1458
+ const hitTokens = Number(t.cacheReadTokens) || 0
1459
+ if (hitTokens > 0) {
1460
+ const hitH = Math.round((hitTokens / tok) * h)
1461
+ ctx.fillStyle = '#10b981'; ctx.beginPath(); ctx.roundRect(x, baseY - hitH, barW, hitH, 4); ctx.fill()
1462
+ }
1463
+ })
1464
+ }
1465
+
1466
+ ctx.fillStyle = '#64748b'; ctx.font = '16px system-ui, sans-serif'; ctx.fillText('Generated by @goodandready/dsh-cost-meter', 50, height - 35)
1467
+ return canvas
1468
+ }
1469
+
1470
+ // --------------------------------------------------- MessageTokenAction (Issue #120)
1471
+
1472
+ function MessageTokenAction(props) {
1473
+ const ctx = props.ctx
1474
+ const locale = useActiveLocale(ctx)
1475
+ const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1476
+ const [open, setOpen] = React.useState(false)
1477
+ const useProjection = props.useProjection || (ctx && ctx.useProjection)
1478
+ const byModel = useProjection ? useProjection('costByModel') : undefined
1479
+
1480
+ const turns = (byModel && Array.isArray(byModel.turns) && byModel.turns.length > 0)
1481
+ ? byModel.turns
1482
+ : []
1483
+
1484
+ // Match turn number if passed by slot or use latest turn
1485
+ const msgTurn = typeof props.turn === 'number'
1486
+ ? props.turn
1487
+ : (props.message && typeof props.message.turn === 'number' ? props.message.turn : null)
1488
+
1489
+ const tr = msgTurn !== null
1490
+ ? (turns.find((x) => x.turn === msgTurn) || turns[turns.length - 1])
1491
+ : (turns.length > 0 ? turns[turns.length - 1] : null)
1492
+
1493
+ if (!tr) return null
1494
+
1495
+ const tok = Number(tr.tokens) || 0
1496
+ const uncached = Number(tr.uncachedInputTokens) || 0
1497
+ const hit = Number(tr.cacheReadTokens) || 0
1498
+ const out = Number(tr.outputTokens) || 0
1499
+ const hitRate = tr.cacheHitRate !== undefined
1500
+ ? tr.cacheHitRate
1501
+ : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1502
+ const durMs = Number(tr.durationMs) || 0
1503
+ const cost = Number(tr.costDeltaUsd) || 0
1504
+
1505
+ const fmtDur = (ms) => {
1506
+ if (!ms || ms <= 0) return '0s'
1507
+ const s = Math.round(ms / 1000)
1508
+ const m = Math.floor(s / 60)
1509
+ const r = s % 60
1510
+ return m > 0 ? (m + 'm ' + r + 's') : (r + 's')
1511
+ }
1512
+
1513
+ return React.createElement(React.Fragment, null,
1514
+ React.createElement('button', {
1515
+ key: 'btn',
1516
+ type: 'button',
1517
+ className: 'dcm-msg-action-btn',
1518
+ title: t('turn.modal_title'),
1519
+ onClick: () => setOpen(true),
1520
+ }, [
1521
+ React.createElement('span', { key: 'icon' }, '⚡'),
1522
+ React.createElement('span', { key: 't' }, tokens(tok)),
1523
+ ]),
1524
+ open ? React.createElement('div', {
1525
+ key: 'modal',
1526
+ className: 'dcm-inspector-modal',
1527
+ onClick: (e) => { if (e.target === e.currentTarget) setOpen(false) },
1528
+ },
1529
+ React.createElement('div', { className: 'dcm-inspector-card' }, [
1530
+ React.createElement('div', { key: 'head', className: 'dcm-inspector-title' }, [
1531
+ React.createElement('span', { key: 't' }, t('turn.modal_title')),
1532
+ React.createElement('button', {
1533
+ key: 'close',
1534
+ type: 'button',
1535
+ className: 'dcm-btn dcm-btn-sm',
1536
+ onClick: () => setOpen(false),
1537
+ }, '✕'),
1538
+ ]),
1539
+ React.createElement('div', { key: 'sub', style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11, marginBottom: 10 } },
1540
+ t('turn.turn_step', { turn: tr.turn, step: tr.step || 0 })
1541
+ ),
1542
+ React.createElement('div', { key: 'grid', className: 'dcm-inspector-grid' }, [
1543
+ React.createElement('div', { key: 's1', className: 'dcm-inspector-stat' }, [
1544
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.tokens_total')),
1545
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, tokens(tok)),
1546
+ ]),
1547
+ React.createElement('div', { key: 's2', className: 'dcm-inspector-stat' }, [
1548
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cache_hit_rate')),
1549
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, hitRate + '%'),
1550
+ ]),
1551
+ React.createElement('div', { key: 's3', className: 'dcm-inspector-stat' }, [
1552
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.duration')),
1553
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, fmtDur(durMs)),
1554
+ ]),
1555
+ React.createElement('div', { key: 's4', className: 'dcm-inspector-stat' }, [
1556
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cost_title')),
1557
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, cost > 0 ? ('$' + cost.toFixed(4)) : '—'),
1558
+ ]),
1559
+ ]),
1560
+ React.createElement('div', { key: 'breakdown', style: { display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11.5, marginTop: 8 } }, [
1561
+ React.createElement('div', { key: 'b1', style: { display: 'flex', justifyContent: 'space-between' } }, [
1562
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.uncached_in')),
1563
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(uncached)),
1564
+ ]),
1565
+ React.createElement('div', { key: 'b2', style: { display: 'flex', justifyContent: 'space-between' } }, [
1566
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.cache_read')),
1567
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(hit)),
1568
+ ]),
1569
+ React.createElement('div', { key: 'b3', style: { display: 'flex', justifyContent: 'space-between' } }, [
1570
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.output')),
1571
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(out)),
1572
+ ]),
1573
+ ]),
1574
+ ])
1575
+ ) : null
1576
+ )
1577
+ }
1578
+
1579
+
1580
+ // ------------------------------------------------------------ CostMeter
1581
+
1582
+
1583
+ function CostMeter(props) {
1584
+ const ctx = props.ctx
1585
+ const locale = useActiveLocale(ctx)
1586
+ const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1587
+ const useProjection = props.useProjection
1588
+ const usage = useProjection ? useProjection('tokenUsage') : undefined
1589
+ const byModel = useProjection ? useProjection('costByModel') : undefined
1590
+ const modelSelection = useProjection ? useProjection('modelSelection') : undefined
1591
+ const [state, setState] = React.useState(null)
1592
+ const [open, setOpen] = React.useState(false)
1593
+ const [now, setNow] = React.useState(() => new Date())
1594
+ const [copied, setCopied] = React.useState(false)
1595
+ const [showAllModels, setShowAllModels] = React.useState(false)
1596
+ const [showTurnInspector, setShowTurnInspector] = React.useState(false)
1597
+ const [selectedCurrency, setSelectedCurrency] = React.useState(null)
1598
+ const [exportingPng, setExportingPng] = React.useState(false)
1599
+ const lastAlertRef = React.useRef(null)
1600
+ const box = React.useRef(null)
1601
+
1602
+ React.useEffect(() => { ensureStyles() }, [])
1603
+
1604
+ const configForms = getSettingsScope(ctx)
1605
+ const scope = React.useMemo(
1606
+ () => (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : undefined),
1607
+ [configForms],
1608
+ )
1609
+ const settingsSnap = React.useSyncExternalStore
1610
+ ? React.useSyncExternalStore(
1611
+ React.useMemo(() => (cb) => (scope && typeof scope.subscribe === 'function' ? scope.subscribe(cb) : () => {}), [scope]),
1612
+ React.useCallback(() => (scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : null)), [scope, configForms]),
1613
+ React.useCallback(() => null, []),
1614
+ )
1615
+ : null
1616
+
1617
+ const used = (byModel && Array.isArray(byModel.models) ? byModel.models : [])
1618
+ .map((m) => ({ ...m, usage: sumSlots(m && m.slots) }))
1619
+ .filter((m) => m.usage.uncachedInputTokens || m.usage.outputTokens ||
1620
+ m.usage.cacheReadTokens || m.usage.cacheWriteTokens)
1621
+ const usedKeys = used.map((m) => m.provider + '/' + m.model).sort().join(',')
1622
+
1623
+ // Determine active model from session modelSelection, committed route, or used models
1624
+ const activeSelection = modelSelection?.next || modelSelection?.lastUsed
1625
+ const activeModelName = activeSelection?.model
1626
+ || byModel?.route?.model
1627
+ || (used.length > 0 ? used[used.length - 1].model : undefined)
1628
+ || (state?.route?.model)
1629
+ || ''
1630
+
1631
+ const activeProvider = activeSelection?.provider
1632
+ || byModel?.route?.provider
1633
+ || (used.length > 0 ? used[used.length - 1].provider : undefined)
1634
+ || (state?.route?.provider)
1635
+ || ''
1636
+
1637
+ const activeRouteKey = activeModelName
1638
+ ? (activeProvider ? activeProvider + '/' + activeModelName : activeModelName)
1639
+ : ''
1640
+
1641
+ const reqSeq = React.useRef(0)
1642
+
1643
+ const load = React.useCallback((keys, currentRoute, signal) => {
1644
+ const seq = ++reqSeq.current
1645
+ const params = new URLSearchParams()
1646
+ if (currentRoute) {
1647
+ params.set('currentRoute', currentRoute)
1648
+ params.append('route', currentRoute)
1649
+ }
1650
+ if (keys) {
1651
+ params.set('models', keys)
1652
+ keys.split(',').forEach((k) => { if (k) params.append('route', k) })
1653
+ }
1654
+ const qs = params.toString()
1655
+ const query = qs ? '?' + qs : ''
1656
+ fetchWithTimeout(STATE_PATH + query, { signal, headers: { accept: 'application/json' } })
1657
+ .then((r) => (r.ok ? r.json() : null))
1658
+ .then((data) => {
1659
+ if (data && seq === reqSeq.current) setState(data)
1660
+ })
1661
+ .catch(() => {})
1662
+ }, [])
1663
+
1664
+ React.useEffect(() => {
1665
+ const controller = new AbortController()
1666
+ load(usedKeys, activeRouteKey, controller.signal)
1667
+ return () => controller.abort()
1668
+ }, [load, usedKeys, activeRouteKey])
1669
+ React.useEffect(() => {
1670
+ if (!open) return
1671
+ const controller = new AbortController()
1672
+ load(usedKeys, activeRouteKey, controller.signal)
1673
+ return () => controller.abort()
1674
+ }, [open, load, usedKeys, activeRouteKey])
1675
+ React.useEffect(() => {
1676
+ const id = setInterval(() => setNow(new Date()), 15000)
1677
+ return () => clearInterval(id)
1678
+ }, [])
1679
+ React.useEffect(() => {
1680
+ if (!open) return undefined
1681
+ const onDown = (e) => { if (box.current && !box.current.contains(e.target)) setOpen(false) }
1682
+ document.addEventListener('mousedown', onDown)
1683
+ return () => document.removeEventListener('mousedown', onDown)
1684
+ }, [open])
1685
+
1686
+ if (!state) return null
1687
+
1688
+ const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1689
+ const stored = (settingsSnap && settingsSnap.value) || {}
1690
+ const CURRENCY_LIST = [{ symbol: '$', code: 'USD' }, { symbol: '¥', code: 'CNY' }, { symbol: '₽', code: 'RUB' }, { symbol: '€', code: 'EUR' }]
1691
+ const activeCurrency = selectedCurrency || (stored.currency !== undefined ? String(stored.currency) : (state.currency || '$'))
1692
+ const fxRates = state.fxRates || { USD: 1, RUB: 80, CNY: 7.15, EUR: 0.92 }
1693
+ const normCode = normalizeCurrencyCode(activeCurrency)
1694
+ const fx = (selectedCurrency && fxRates[normCode] !== undefined)
1695
+ ? Number(fxRates[normCode])
1696
+ : (stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1))
1697
+ const currency = activeCurrency
1698
+
1699
+ const handleCurrencySelect = (sym) => {
1700
+ setSelectedCurrency(sym)
1701
+ const code = normalizeCurrencyCode(sym)
1702
+ const newFx = fxRates[code] || 1
1703
+ if (scope && typeof scope.set === 'function') {
1704
+ try {
1705
+ scope.set('currency', sym)
1706
+ scope.set('usdRate', newFx)
1707
+ } catch (_) {}
1708
+ }
1709
+ }
1710
+ const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
1711
+ const displayMode = stored.displayMode !== undefined ? stored.displayMode : (state.displayMode || 'auto')
1712
+ const schedules = state.schedules || {}
1713
+ const schedule = (activeRouteKey && schedules[activeRouteKey])
1714
+ || (activeModelName && schedules[activeModelName])
1715
+ || state.schedule
1716
+ const hasUsage = usage !== undefined && usage !== null
1717
+ const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
1718
+
1719
+ if (!schedule) {
1720
+ return h('div', { ref: box, style: { position: 'relative' } }, [
1721
+ h('button', {
1722
+ key: 'chip', type: 'button',
1723
+ className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : ''),
1724
+ onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
1725
+ }, [
1726
+ h('span', { key: 'dot', className: 'dcm-dot dcm-dot-idle' }),
1727
+ h('span', { key: 'txt' }, t('chipUnknown')),
1728
+ ]),
1729
+ open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1730
+ h('div', { key: 't', className: 'dcm-panel-title' },
1731
+ activeModelName || (state.route ? state.route.model : t('routeUnknown'))),
1732
+ h('div', { key: 'n', className: 'dcm-panel-note' },
1733
+ state.catalogError
1734
+ ? t('catalogUnavailable') + state.catalogError
1735
+ : t('tokens.not_priced')),
1736
+ ]) : null,
1737
+ ])
1738
+ }
1739
+
1740
+ const active = ratesAt(schedule, minute)
1741
+ const { low, high, flat } = extremes(schedule)
1742
+ const cheapNow = !flat && active.output === low.output
1743
+ const leftMinutes = minutesUntilChange(schedule, minute)
1744
+ const left = clock(leftMinutes)
1745
+
1746
+ const alertBeforeSwitch = stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : (state.alertBeforeSwitch !== false)
1747
+ const alertLeadMinutes = Number(stored.alertLeadMinutes || state.alertLeadMinutes || 5)
1748
+ const browserNotifications = Boolean(stored.browserNotifications || state.browserNotifications || false)
1749
+
1750
+ let alertActive = false
1751
+ let alertDirection = null
1752
+ let alertBanner = null
1753
+
1754
+ if (!flat && alertBeforeSwitch && leftMinutes > 0 && leftMinutes <= alertLeadMinutes) {
1755
+ alertActive = true
1756
+ alertDirection = cheapNow ? 'peak' : 'offpeak'
1757
+ const timeStr = left || (leftMinutes + 'm')
1758
+ alertBanner = alertDirection === 'peak'
1759
+ ? t('alert.pre_switch_peak', { time: timeStr })
1760
+ : t('alert.pre_switch_offpeak', { time: timeStr })
1761
+ }
1762
+
1763
+ // Desktop HTML5 notification trigger (Issue #117)
1764
+ React.useEffect(() => {
1765
+ if (!alertActive || !browserNotifications || !alertBanner) return
1766
+ const transKey = Math.floor(minute / 30) + ':' + alertDirection
1767
+ if (lastAlertRef.current === transKey) return
1768
+ lastAlertRef.current = transKey
1769
+ if (typeof Notification !== 'undefined') {
1770
+ if (Notification.permission === 'granted') {
1771
+ try { new Notification('DeepSeek Tariff Alert', { body: alertBanner }) } catch (_) {}
1772
+ } else if (Notification.permission !== 'denied') {
1773
+ Notification.requestPermission().then((p) => {
1774
+ if (p === 'granted') {
1775
+ try { new Notification('DeepSeek Tariff Alert', { body: alertBanner }) } catch (_) {}
1776
+ }
1777
+ })
1778
+ }
1779
+ }
1780
+ }, [alertActive, browserNotifications, alertBanner, minute, alertDirection])
1781
+
1782
+ const perModel = used.map((m) => {
1783
+ const key = m.provider + '/' + m.model
1784
+ const own = schedules[key] || schedules[m.model]
1785
+ const ownSchedule = own && (own.schedule || own)
1786
+ return {
1787
+ key,
1788
+ model: m.model,
1789
+ usage: m.usage,
1790
+ known: Boolean(ownSchedule),
1791
+ cost: ownSchedule ? spendBySlots(m.slots, ownSchedule) * fx : 0,
1792
+ }
1793
+ })
1794
+ const multiModel = perModel.length > 1
1795
+ const total = perModel.length
1796
+ ? perModel.reduce((sum, m) => sum + m.cost, 0)
1797
+ : spendUsd(usage, active) * fx
1798
+
1799
+ // Check budget threshold in USD
1800
+ const totalUsd = fx > 0 ? total / fx : total
1801
+ const budgetExceeded = budgetThreshold > 0 && totalUsd >= budgetThreshold
1802
+
1803
+ // Dot indicator status class
1804
+ const dotClass = budgetExceeded
1805
+ ? 'dcm-dot dcm-dot-err'
1806
+ : (flat ? 'dcm-dot dcm-dot-idle' : (cheapNow ? 'dcm-dot dcm-dot-ok' : 'dcm-dot dcm-dot-warn'))
1807
+
1808
+ const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--dsw-alias-state-brand-primary) 12%, transparent)', color: 'var(--dsw-alias-state-brand-primary)', fontWeight: 600 }
1809
+
1810
+ const rateRow = (label, key) => flat
1811
+ ? [
1812
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
1813
+ h('div', { key: label + '-v', className: 'dcm-cost' }, rate(active[key])),
1814
+ ]
1815
+ : [
1816
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
1817
+ h('div', { key: label + '-lo', style: cheapNow ? activeCell : undefined, className: cheapNow ? '' : 'dcm-cell-idle' }, rate(low[key])),
1818
+ h('div', { key: label + '-hi', style: cheapNow ? undefined : activeCell, className: cheapNow ? 'dcm-cell-idle' : '' }, rate(high[key])),
1819
+ ]
1820
+
1821
+ const soleSlots = perModel.length === 1 ? used[0].slots : null
1822
+ const spentRow = (label, bucketField, rateField) => {
1823
+ const n = Number(usage && usage[bucketField]) || 0
1824
+ let usd = 0
1825
+ if (soleSlots) {
1826
+ for (const key of Object.keys(soleSlots)) {
1827
+ const rates = ratesAt(schedule, (Number(key) * 30 + 15) % 1440)
1828
+ usd += ((Number(soleSlots[key][bucketField]) || 0) / 1e6) * (rates[rateField] || 0)
1829
+ }
1830
+ } else {
1831
+ usd = (n / 1e6) * (active[rateField] || 0)
1832
+ }
1833
+ return [
1834
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
1835
+ h('div', { key: label + '-n', className: 'dcm-num' }, tokens(n)),
1836
+ h('div', { key: label + '-v', className: 'dcm-cost' }, money(usd * fx, currency)),
1837
+ ]
1838
+ }
1839
+
1840
+ const showCacheWrite = Number(usage && usage.cacheWriteTokens) > 0
1841
+ const zone = state.displayTimeZone || 'Europe/Moscow'
1842
+ const zl = zoneLabel(zone)
1843
+ const windowText = (schedule.windows || [])
1844
+ .map((w) => hhmmInZone(w.start, zone, now) + '–' + hhmmInZone(w.end, zone, now))
1845
+ .join(', ')
1846
+ const nowLocal = hhmmInZone(minute, zone, now)
1847
+
1848
+ // Calculate cache savings amount across all used models (#92)
1849
+ let cacheSavedAmount = 0
1850
+ let cacheSavedPercent = 0
1851
+ if (hasUsage) {
1852
+ if (perModel.length > 0) {
1853
+ let totalSavedUsd = 0
1854
+ let totalMissedUsd = 0
1855
+ for (const m of perModel) {
1856
+ const own = schedules[m.key] || schedules[m.model]
1857
+ const sched = own && (own.schedule || own) ? (own.schedule || own) : schedule
1858
+ if (sched) {
1859
+ const res = cacheSavingsBySlots(m.slots || (m.usage ? { 0: m.usage } : {}), sched)
1860
+ totalSavedUsd += res.savedUsd
1861
+ totalMissedUsd += res.missedUsd
1862
+ }
1863
+ }
1864
+ if (totalSavedUsd > 0) {
1865
+ cacheSavedAmount = totalSavedUsd * fx
1866
+ cacheSavedPercent = totalMissedUsd > 0 ? Math.round((totalSavedUsd / totalMissedUsd) * 100) : 0
1867
+ }
1868
+ } else if (usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
1869
+ const tokensRead = Number(usage.cacheReadTokens)
1870
+ const missedCost = (tokensRead / 1e6) * active.input
1871
+ const hitCost = (tokensRead / 1e6) * active.cacheHit
1872
+ cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
1873
+ cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
1874
+ }
1875
+ }
1876
+
1877
+
1878
+ // Copy summary report to clipboard
1879
+ const copyReport = () => {
1880
+ const lines = [
1881
+ '=== ' + (activeModelName || (state.route ? state.route.model : 'Cost Meter Report')) + ' ===',
1882
+ t('tokens.session_total') + ' ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
1883
+ 'Tier: ' + (flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak'))),
1884
+ 'Rates (1M): input ' + rate(active.input) + ', output ' + rate(active.output) + ', cache ' + rate(active.cacheHit),
1885
+ ]
1886
+ if (cacheSavedAmount > 0) {
1887
+ lines.push('Cache saved: ≈ ' + money(cacheSavedAmount, currency) + ' (' + cacheSavedPercent + '%)')
1888
+ }
1889
+ if (hasUsage && usage) {
1890
+ lines.push('Tokens: in ' + tokens(usage.inputTokens) + ', out ' + tokens(usage.outputTokens) + ', cache hit ' + tokens(usage.cacheReadTokens))
1891
+ }
1892
+ const text = lines.join('\n')
1893
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
1894
+ navigator.clipboard.writeText(text).then(() => {
1895
+ setCopied(true)
1896
+ setTimeout(() => setCopied(false), 2000)
1897
+ }).catch(() => {})
1898
+ } else {
1899
+ setCopied(true)
1900
+ setTimeout(() => setCopied(false), 2000)
1901
+ }
1902
+ }
1903
+
1904
+ // Calculate upcoming discount hint
1905
+ let discountHint = null
1906
+ if (!flat && !cheapNow && leftMinutes) {
1907
+ const nextTimeMinutes = (minute + leftMinutes) % DAY
1908
+ const clockTime = hhmmInZone(nextTimeMinutes, zone, now)
1909
+ discountHint = t('savings.hint', {
1910
+ time: left,
1911
+ clock: clockTime,
1912
+ zone: zl,
1913
+ })
1914
+ }
1915
+
1916
+ // Display models with collapse support if > 3 models
1917
+ const visibleModels = (perModel.length
1918
+ ? perModel
1919
+ : (activeModelName ? [{ key: activeRouteKey || activeModelName, model: activeModelName, known: Boolean(schedule), cost: 0 }] : [])
1920
+ )
1921
+ const shouldCollapse = visibleModels.length > 3 && !showAllModels
1922
+ const renderedModels = shouldCollapse ? visibleModels.slice(0, 3) : visibleModels
1923
+
1924
+ return h('div', { ref: box, style: { position: 'relative' } }, [
1925
+ h('button', {
1926
+ key: 'chip', type: 'button',
1927
+ className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : '') + (alertActive ? (alertDirection === 'peak' ? ' dcm-chip-alert-peak' : ' dcm-chip-alert-offpeak') : ''),
1928
+ onClick: () => setOpen((v) => !v),
1929
+ title: (budgetExceeded
1930
+ ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1931
+ : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))) +
1932
+ (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }) : ''),
1933
+
1934
+ }, [
1935
+ h('span', { key: 'dot', className: dotClass }),
1936
+ h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1937
+ left ? h('span', { key: 'left', className: 'dcm-chip-timer' }, left) : null,
1938
+ ]),
1939
+
1940
+ open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1941
+ h('div', { key: 'title', className: 'dcm-panel-title' }, [
1942
+ h('span', { key: 'model-name' }, activeModelName || (state.route ? state.route.model : 'model')),
1943
+ h('div', { key: 'curr-switcher', className: 'dcm-curr-switcher' },
1944
+ CURRENCY_LIST.map((c) =>
1945
+ h('button', {
1946
+ key: c.code,
1947
+ type: 'button',
1948
+ className: 'dcm-curr-btn' + (currency === c.symbol || currency === c.code ? ' dcm-curr-btn-active' : ''),
1949
+ onClick: () => handleCurrencySelect(c.symbol),
1950
+ title: c.code,
1951
+ }, c.symbol)
1952
+ )
1953
+ ),
1954
+ ]),
1955
+
1956
+ h('div', { key: 'note', className: 'dcm-panel-note' },
1957
+ flat
1958
+ ? 'Flat rate schedule'
1959
+ : (cheapNow ? 'Discounted rate active' : 'Full rate active') +
1960
+ (left ? ' · change in ' + left : '')),
1961
+
1962
+ flat
1963
+ ? null
1964
+ : h('div', { key: 'clock', className: 'dcm-panel-note' },
1965
+ t('peak') + ': ' + windowText + ' ' + zl + ' · now ' + nowLocal + ' ' + zl),
1966
+
1967
+ alertBanner ? h('div', {
1968
+ key: 'preswitch-banner',
1969
+ className: 'dcm-alert-banner ' + (alertDirection === 'peak' ? 'dcm-alert-banner-peak' : 'dcm-alert-banner-offpeak'),
1970
+ }, alertBanner) : null,
1971
+
1972
+ discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
1973
+
1974
+ cacheSavedAmount > 0
1975
+ ? h('div', { key: 'cache-saved-box', className: 'dcm-cache-box', style: { marginTop: 6 } },
1976
+ t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }))
1977
+ : null,
1978
+
1979
+ budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1980
+ t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
1981
+
1982
+ renderSparkline(turns, {
1983
+ h,
1984
+ currency,
1985
+ fx,
1986
+ activeRate: active,
1987
+ t,
1988
+ }),
1989
+
1990
+ turns.length > 0 ? [
1991
+ h('div', {
1992
+ key: 'turns-header',
1993
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
1994
+ }, [
1995
+ h('span', { key: 'th-lbl', className: 'dcm-section-lbl' }, t('turn.history_title')),
1996
+ h('button', {
1997
+ key: 'th-btn',
1998
+ type: 'button',
1999
+ className: 'dcm-accordion-btn',
2000
+ onClick: () => setShowTurnInspector((v) => !v),
2001
+ }, showTurnInspector ? t('turn.hide_history') : t('turn.show_history')),
2002
+ ]),
2003
+ showTurnInspector ? h('div', { key: 'turn-list', className: 'dcm-turn-list' },
2004
+ turns.slice(-10).reverse().map((tr, idx) => {
2005
+ const tok = Number(tr.tokens) || 0
2006
+ const hit = Number(tr.cacheReadTokens) || 0
2007
+ const uncached = Number(tr.uncachedInputTokens) || 0
2008
+ const hitRate = tr.cacheHitRate !== undefined
2009
+ ? tr.cacheHitRate
2010
+ : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
2011
+ const cost = Number(tr.costDeltaUsd) || 0
2012
+ return h('div', { key: 'tr-' + idx, className: 'dcm-turn-row' }, [
2013
+ h('span', { key: 'tn', style: { fontWeight: 500 } }, 'T#' + tr.turn + (tr.step ? '.' + tr.step : '')),
2014
+ h('span', { key: 'tok', style: { color: 'var(--dsw-alias-label-secondary)' } }, tokens(tok) + ' tok (' + hitRate + '% hit)'),
2015
+ h('span', { key: 'c', style: { fontWeight: 600 } }, cost > 0 ? money(cost * fx, currency) : '—'),
2016
+ ])
2017
+ })
2018
+ ) : null,
2019
+ ] : null,
2020
+
2021
+ (() => {
2022
+ const proj = calculateBurnRateAndProjection(turns, total / fx)
2023
+ const burnRate = proj.burnRatePerHourUsd * fx
2024
+ const projectedTotal = proj.projectedCostUsd * fx
2025
+ if (burnRate <= 0) return null
2026
+ return h('div', { key: 'proj-box', className: 'dcm-proj-box' }, [
2027
+ h('span', { key: 'burn', className: 'dcm-proj-item' }, [
2028
+ h('span', null, t('projection.burn_rate') + ' '),
2029
+ h('span', { className: 'dcm-proj-val' }, money(burnRate, currency) + t('projection.per_hour')),
2030
+ ]),
2031
+ h('span', { key: 'proj', className: 'dcm-proj-item' }, [
2032
+ h('span', null, t('projection.projected') + ' '),
2033
+ h('span', { className: 'dcm-proj-val' }, '≈ ' + money(projectedTotal, currency)),
2034
+ ]),
2035
+ ])
2036
+ })(),
2037
+
2038
+ h('hr', { key: 'hr1', className: 'dcm-hr' }),
2039
+
2040
+ h('div', { key: 'rates', className: flat ? 'dcm-panel-grid-flat' : 'dcm-panel-grid' }, [
2041
+ h('div', { key: 'h0', className: 'dcm-th' }, t('rate.per_1m')),
2042
+ flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, t('offpeak')),
2043
+ h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? 'rate' : t('peak')),
2044
+ rateRow(t('rate.cache_hit'), 'cacheHit'),
2045
+ rateRow(t('rate.input'), 'input'),
2046
+ showCacheWrite ? rateRow(t('rate.cache_write'), 'cacheWrite') : null,
2047
+ rateRow(t('rate.output'), 'output'),
2048
+ ]),
2049
+
2050
+ h('hr', { key: 'hr2', className: 'dcm-hr' }),
2051
+
2052
+ multiModel
2053
+ ? null
2054
+ : [
2055
+ h('div', { key: 'btitle', className: 'dcm-section-lbl' }, t('tokens.session')),
2056
+ h('div', { key: 'breakdown', className: 'dcm-panel-grid-3' }, [
2057
+ h('div', { key: 'b0', className: 'dcm-th' }, t('tokens.tokens_col')),
2058
+ h('div', { key: 'b1', className: 'dcm-th-num' }, ''),
2059
+ h('div', { key: 'b2', className: 'dcm-th-num' }, currency),
2060
+ spentRow(t('rate.cache_hit'), 'cacheReadTokens', 'cacheHit'),
2061
+ spentRow(t('rate.input'), 'uncachedInputTokens', 'input'),
2062
+ showCacheWrite ? spentRow(t('rate.cache_write'), 'cacheWriteTokens', 'cacheWrite') : null,
2063
+ spentRow(t('rate.output'), 'outputTokens', 'output'),
2064
+ ]),
2065
+ h('hr', { key: 'hr-models', className: 'dcm-hr' }),
2066
+ ],
2067
+
2068
+ h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, t('tokens.models_spent')),
2069
+ h(
2070
+ 'div',
2071
+ { key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
2072
+ renderedModels.map((m) =>
2073
+ h(
2074
+ 'div',
2075
+ {
2076
+ key: m.key,
2077
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '2px 0' },
2078
+ },
2079
+ [
2080
+ h(
2081
+ 'span',
2082
+ {
2083
+ key: 'name',
2084
+ title: m.key,
2085
+ style: {
2086
+ overflow: 'hidden',
2087
+ textOverflow: 'ellipsis',
2088
+ whiteSpace: 'nowrap',
2089
+ maxWidth: m.known ? 200 : 130,
2090
+ },
2091
+ },
2092
+ m.model,
2093
+ ),
2094
+ h(
2095
+ 'span',
2096
+ {
2097
+ key: 'val',
2098
+ style: {
2099
+ textAlign: 'right',
2100
+ fontVariantNumeric: 'tabular-nums',
2101
+ whiteSpace: 'nowrap',
2102
+ color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
2103
+ },
2104
+ },
2105
+ m.known ? (hasUsage ? money(m.cost, currency) : '—') : t('tokens.not_priced'),
2106
+ ),
2107
+ ],
2108
+ ),
2109
+ ),
2110
+ ),
2111
+
2112
+ visibleModels.length > 3
2113
+ ? h('button', {
2114
+ key: 'toggle-models',
2115
+ type: 'button',
2116
+ className: 'dcm-accordion-btn',
2117
+ onClick: () => setShowAllModels((v) => !v),
2118
+ }, shouldCollapse ? t('models.show_more', { count: visibleModels.length }) : t('models.show_less'))
2119
+ : null,
2120
+
2121
+ h('hr', { key: 'hr3', className: 'dcm-hr' }),
2122
+
2123
+ h('div', { key: 'total', className: 'dcm-total' }, [
2124
+ h('span', { key: 'l' }, t('tokens.session_total')),
2125
+ h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
2126
+ ]),
2127
+
2128
+ hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
2129
+ t('tokens.no_usage_yet')),
2130
+
2131
+ h('div', { key: 'footer-actions', className: 'dcm-export-group' }, [
2132
+ h('button', {
2133
+ key: 'btn-csv',
2134
+ type: 'button',
2135
+ className: 'dcm-btn dcm-btn-sm',
2136
+ title: t('action.export_csv'),
2137
+ onClick: () => {
2138
+ const csvData = generateSessionCsv({
2139
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2140
+ provider: activeProvider || '',
2141
+ currency,
2142
+ fx,
2143
+ turns,
2144
+ totalCostUsd: totalUsd,
2145
+ })
2146
+ const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' })
2147
+ const link = document.createElement('a')
2148
+ link.href = URL.createObjectURL(blob)
2149
+ link.download = (activeModelName || 'cost-meter') + '-session.csv'
2150
+ link.click()
2151
+ },
2152
+ }, '📥 CSV'),
2153
+
2154
+ h('button', {
2155
+ key: 'btn-json',
2156
+ type: 'button',
2157
+ className: 'dcm-btn dcm-btn-sm',
2158
+ title: t('action.export_json'),
2159
+ onClick: () => {
2160
+ const jsonData = generateSessionJson({
2161
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2162
+ provider: activeProvider || '',
2163
+ currency,
2164
+ fx,
2165
+ turns,
2166
+ totalCostUsd: totalUsd,
2167
+ cacheSavingsUsd: cacheSavedAmount / fx,
2168
+ cacheSavingsPercent: cacheSavedPercent,
2169
+ tier: flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak')),
2170
+ })
2171
+ const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' })
2172
+ const link = document.createElement('a')
2173
+ link.href = URL.createObjectURL(blob)
2174
+ link.download = (activeModelName || 'cost-meter') + '-session.json'
2175
+ link.click()
2176
+ },
2177
+ }, '📋 JSON'),
2178
+
2179
+ h('button', {
2180
+ key: 'btn-png',
2181
+ type: 'button',
2182
+ className: 'dcm-btn dcm-btn-sm',
2183
+ title: t('action.export_png'),
2184
+ disabled: exportingPng,
2185
+ onClick: () => {
2186
+ setExportingPng(true)
2187
+ try {
2188
+ const canvas = document.createElement('canvas')
2189
+ renderSummaryCardToCanvas(canvas, {
2190
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2191
+ provider: activeProvider || '',
2192
+ currency,
2193
+ fx,
2194
+ costUsd: totalUsd,
2195
+ tokens: usage || {},
2196
+ cacheSavingsUsd: cacheSavedAmount / fx,
2197
+ cacheSavingsPercent: cacheSavedPercent,
2198
+ tier: flat ? 'Flat Tariff' : (cheapNow ? ('Off-Peak (' + t('offpeak') + ')') : ('Peak Tariff (' + t('peak') + ')')),
2199
+ turns,
2200
+ })
2201
+ canvas.toBlob((blob) => {
2202
+ setExportingPng(false)
2203
+ if (blob) {
2204
+ const link = document.createElement('a')
2205
+ link.href = URL.createObjectURL(blob)
2206
+ link.download = (activeModelName || 'cost-meter') + '-summary.png'
2207
+ link.click()
2208
+ }
2209
+ }, 'image/png')
2210
+ } catch (_) {
2211
+ setExportingPng(false)
2212
+ }
2213
+ },
2214
+ }, exportingPng ? t('action.exporting_png') : '🖼️ PNG'),
2215
+
2216
+ h('button', {
2217
+ key: 'btn-copy',
2218
+ type: 'button',
2219
+ className: 'dcm-btn dcm-btn-sm',
2220
+ onClick: copyReport,
2221
+ }, copied ? t('action.copied') : t('action.copy_report')),
2222
+ ]),
2223
+ ]) : null,
2224
+ ])
2225
+ }
2226
+
2227
+ function apply(ctx) {
2228
+ const registerDictionaries = () => {
2229
+ try {
2230
+ // ctx.locale.register(NS, { en, zh }) is canonical form in DSH
2231
+ return ctx.locale.register(NS, { en, zh })
2232
+ } catch (err) {
2233
+ if (ctx?.logger?.warn) {
2234
+ ctx.logger.warn(`[dsh-cost-meter] locale register failed: ${err?.message || err}`)
2235
+ }
2236
+ return () => {}
2237
+ }
2238
+ }
2239
+
2240
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
2241
+ if (typeof ctx.effect === 'function') {
2242
+ ctx.effect(() => {
2243
+ const undo = registerDictionaries()
2244
+ return () => {
2245
+ try {
2246
+ if (typeof undo === 'function') undo()
2247
+ } catch (e) {
2248
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] locale undo error: ' + (e?.message || e))
2249
+ }
2250
+ }
2251
+ }, 'dsh-cost-meter: locale dictionaries')
2252
+ } else {
2253
+ registerDictionaries()
2254
+ }
2255
+ }
2256
+
2257
+ if (typeof ctx.effect === 'function') {
2258
+ ctx.effect(
2259
+ () => refreshMirrorUntilVisible(ctx),
2260
+ 'dsh-cost-meter: refresh settings mirror',
2261
+ )
2262
+ } else {
2263
+ refreshMirrorUntilVisible(ctx)
2264
+ }
2265
+
2266
+ if (ctx.slots && typeof ctx.slots.inject === 'function') {
2267
+ ctx.slots.inject('conversation.session.header.utilities', () =>
2268
+ ctx.slots.register(
2269
+ { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
2270
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeter, { ...p, ctx: (p && p.ctx) || ctx })),
2271
+ ),
2272
+ )
2273
+
2274
+ // Message action bar token & cost inspector (Issue #120)
2275
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
2276
+ ctx.slots.register(
2277
+ { name: 'conversation.chat.assistant-actions', id: 'dsh-cost-meter-token-inspector', order: 40, inject: () => ({ ctx }) },
2278
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(MessageTokenAction, { ...p, ctx: (p && p.ctx) || ctx })),
2279
+ ),
2280
+ )
2281
+
2282
+ // List seat (plugins.item): the seat the Plugins page renders as the plugin's
2283
+ // own page with its configuration. The label is a static string on purpose —
2284
+ // it is resolved while the page renders, and a locale lookup there would take
2285
+ // the whole client batch down with it.
2286
+ ctx.slots.inject('plugins.item', () =>
2287
+ ctx.slots.register(
2288
+ { name: 'plugins.item', id: ROW_ID, order: 20, label: () => 'Cost Meter', locale: NS, inject: () => ({ ctx }) },
2289
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
2290
+ ),
2291
+ )
2292
+
2293
+ // Row seat and the legacy seat kept as fallbacks.
2294
+ ctx.slots.inject('plugins.row.config', () =>
2295
+ ctx.slots.register(
2296
+ { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, order: 20, inject: () => ({ ctx }) },
2297
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
2298
+ ),
2299
+ )
2300
+
2301
+ ctx.slots.inject('settings.plugin.item', () =>
2302
+ ctx.slots.register(
2303
+ { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
2304
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
2305
+ ),
2306
+ )
2307
+ }
2308
+ }
2309
+
2310
+ module.exports = {
2311
+ apply,
2312
+ inject: ['slots', 'locale', 'configForms'],
2313
+ CostMeterCard,
2314
+ CostMeter,
2315
+ MessageTokenAction,
2316
+ ErrorBoundary,
2317
+ ensureStyles,
2318
+ minutesUntilChange,
2319
+ extremes,
2320
+ ratesAt,
2321
+ spendBySlots,
2322
+ cacheSavingsBySlots,
2323
+ fetchWithTimeout,
2324
+ }
2325
+
2326
+ return module.exports
2327
+ },
2328
+ })
2329
+