@goodandready/dsh-cost-meter 0.8.24 → 0.8.26

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,1697 +1,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.copy_report': 'Copy Summary',
71
- 'action.copied': 'Copied!',
72
- 'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
73
- 'savings.cache_saved': '⚡ Context cache saved: ≈ {amount} ({percent}%)',
74
- 'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
75
- 'models.show_more': 'Show all {count} models',
76
- 'models.show_less': 'Show fewer',
77
- 'settings.save': 'Save Settings',
78
- 'settings.saving': 'Saving…',
79
- 'settings.saved': 'Settings saved successfully',
80
- 'settings.saveFailed': 'Failed to save settings: ',
81
- 'settings.loading': 'Loading settings…',
82
- 'settings.unavailable': 'Settings temporarily unavailable',
83
- 'settings.retry': 'Retry',
84
- chipUnknown: 'Unknown tariff',
85
- routeUnknown: 'Unknown route',
86
- catalogUnavailable: 'Catalog unavailable: ',
87
- peak: 'peak',
88
- offpeak: 'off-peak',
89
- 'rate.per_1m': '1M tokens, $',
90
- 'rate.cache_hit': 'Input (cache hit)',
91
- 'rate.input': 'Input (cache miss)',
92
- 'rate.cache_write': 'Input (cache write)',
93
- 'rate.output': 'Output',
94
- 'tokens.session': 'Session Tokens',
95
- 'tokens.tokens_col': 'Tokens',
96
- 'tokens.models_spent': 'Spend by Model',
97
- 'tokens.session_total': 'Session total:',
98
- 'tokens.no_usage_yet': 'Provider has not reported usage for this session yet.',
99
- 'tokens.not_priced': 'Not priced',
100
- 'tz.invalid': 'Invalid IANA time zone identifier',
101
- 'sparkline.title': 'Turn dynamics',
102
- 'sparkline.steps': 'steps',
103
- 'projection.burn_rate': 'Burn rate:',
104
- 'projection.projected': 'Projected:',
105
- 'projection.per_hour': '/hr',
106
- 'field.displayMode': 'Display mode',
107
- 'hint.displayMode': 'Widget display mode: auto (collapses on mobile <= 768px), full, or always compact.',
108
- 'mode.auto': 'Auto (responsive)',
109
- 'mode.full': 'Full',
110
- 'mode.compact': 'Compact pill',
111
- }
112
-
113
- const zh = {
114
- title: '成本计量器',
115
- subtitle: '模型费率、高峰优惠窗口与会话消费统计',
116
- 'header.title': '成本计量器 (Cost Meter)',
117
- 'header.sub': 'Token 消费监控、费率自动匹配与折扣窗口管理',
118
- 'update.checking': '正在检查更新…',
119
- 'update.up_to_date': '已是最新版本',
120
- 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
121
- 'update.btn': '立即更新',
122
- 'update.updating': '正在更新…',
123
- 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
124
- 'update.failed': '更新失败:{error}',
125
- 'badge.catalog_ok': '目录:{count} 个模型',
126
- 'badge.catalog_empty': '目录:加载中…',
127
- 'badge.catalog_off': '目录:已禁用',
128
- 'badge.catalog_err': '目录:错误',
129
- 'badge.catalog_stale': '缓存:离线',
130
- 'badge.deepseek': 'DeepSeek 费率:生效中',
131
- 'section.general': '基础参数',
132
- 'section.general_desc': '显示货币、汇率转换、预算阈值与时区设置',
133
- 'section.openrouter': 'OpenRouter 定价目录',
134
- 'section.openrouter_desc': '用于自动获取模型价格与折扣窗口的公开价格目录',
135
- 'section.diagnostics': '诊断与状态',
136
- 'section.diagnostics_desc': '后台同步与费率缓存当前状态',
137
- 'diag.models_count': '目录中模型数',
138
- 'diag.last_sync': '上次同步',
139
- 'diag.route': '当前路由',
140
- 'diag.tariff_source': '费率来源',
141
- 'field.currency': '货币符号',
142
- 'hint.currency': '界面中显示的货币符号(例如:$、¥、€)。',
143
- 'field.usdRate': 'USD 汇率',
144
- 'hint.usdRate': '每 1 美元对应的本币金额(1 为美元)。',
145
- 'field.budgetThreshold': '会话预算阈值 (USD)',
146
- 'hint.budgetThreshold': '当会话消费超出该金额时在状态指示器报警(0 表示禁用)。',
147
- 'field.displayTimeZone': '时区 (IANA)',
148
- 'hint.displayTimeZone': '显示高峰时段的 IANA 时区(例如:Asia/Shanghai、UTC)。',
149
- 'field.useOpenRouter': '使用 OpenRouter 目录',
150
- 'hint.useOpenRouter': '通过公开的 OpenRouter API 自动拉取费率与折扣窗口。',
151
- 'field.refreshHours': '目录刷新周期 (小时)',
152
- 'hint.refreshHours': '后台刷新价格目录的时间间隔(1 至 168 小时)。',
153
- 'action.sync_now': '立即同步',
154
- 'action.syncing': '同步中…',
155
- 'action.sync_ok': '目录已更新',
156
- 'action.sync_err': '同步失败',
157
- 'action.sync_rate_limited': '请等待 {seconds} 秒',
158
- 'action.copy_report': '复制消费报告',
159
- 'action.copied': '已复制!',
160
- 'savings.hint': '💡 5 折优惠将在 {time} 后生效 ({clock} {zone})',
161
- 'savings.cache_saved': '⚡ 上下文缓存节省:≈ {amount} ({percent}%)',
162
- 'budget.exceeded': '⚠️ 已超出预算上限:{limit}',
163
- 'models.show_more': '查看全部 {count} 个模型',
164
- 'models.show_less': '收起',
165
- 'settings.save': '保存设置',
166
- 'settings.saving': '保存中…',
167
- 'settings.saved': '设置已成功保存',
168
- 'settings.saveFailed': '保存设置失败:',
169
- 'settings.loading': '加载设置中…',
170
- 'settings.unavailable': '设置暂时不可用',
171
- 'settings.retry': '重试',
172
- chipUnknown: '费率未知',
173
- routeUnknown: '路由未知',
174
- catalogUnavailable: '价格目录不可用:',
175
- peak: '高峰',
176
- offpeak: '低谷',
177
- 'rate.per_1m': '每 1M tokens ($)',
178
- 'rate.cache_hit': '输入 (命中缓存)',
179
- 'rate.input': '输入 (未命中)',
180
- 'rate.cache_write': '输入 (写入缓存)',
181
- 'rate.output': '输出',
182
- 'tokens.session': '会话 Tokens',
183
- 'tokens.tokens_col': 'Tokens 数',
184
- 'tokens.models_spent': '各模型消费',
185
- 'tokens.session_total': '会话总计:',
186
- 'tokens.no_usage_yet': '提供商尚未报告当前会话的用量数据。',
187
- 'tokens.not_priced': '未定价',
188
- 'tz.invalid': '无效的 IANA 时区标识',
189
- 'sparkline.title': '步骤动态',
190
- 'sparkline.steps': '步',
191
- 'projection.burn_rate': '消耗速率:',
192
- 'projection.projected': '预估总计:',
193
- 'projection.per_hour': '/小时',
194
- 'field.displayMode': '显示模式',
195
- 'hint.displayMode': '计量器外观模式:自动(<=768px 移动端自适应折叠)、完整或始终紧凑。',
196
- 'mode.auto': '自动自适应',
197
- 'mode.full': '完整显示',
198
- 'mode.compact': '紧凑药丸',
199
- }
200
-
201
- function makeT(dict, fallback) {
202
- return function t(key, vars) {
203
- let val = (dict && dict[key]) || (fallback && fallback[key]) || key
204
- if (vars && typeof val === 'string') {
205
- for (const k of Object.keys(vars)) {
206
- val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
207
- }
208
- }
209
- return val
210
- }
211
- }
212
-
213
- function useActiveLocale(ctx) {
214
- const localeService = (ctx && typeof ctx.get === 'function' ? ctx.get('locale') : null) || (ctx && ctx.locale) || (ctx && ctx.services && ctx.services.locale)
215
- return React.useSyncExternalStore(
216
- React.useMemo(() => (cb) => (localeService && typeof localeService.subscribe === 'function' ? localeService.subscribe(cb) : () => {}), [localeService]),
217
- React.useCallback(() => {
218
- const snap = localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot() : null
219
- const active = snap ? (snap.active || snap.locale || 'en') : 'en'
220
- return String(active).startsWith('zh') ? 'zh' : 'en'
221
- }, [localeService]),
222
- React.useCallback(() => 'en', []),
223
- )
224
- }
225
-
226
- // ----------------------------------------------------------------- styles
227
-
228
- const CSS = `
229
- .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; }
230
- .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; }
231
- .dcm-title { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
232
- .dcm-sub { color: var(--dsw-alias-label-secondary); font-size: 13px; }
233
- .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; }
234
-
235
- .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; }
236
- .dcm-section-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); display: flex; align-items: center; justify-content: space-between; }
237
- .dcm-section-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: -4px; line-height: 1.4; }
238
-
239
- .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
240
- .dcm-field { display: flex; flex-direction: column; gap: 6px; }
241
- .dcm-row-check { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
242
- .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary); cursor: pointer; }
243
- .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
244
- .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
245
-
246
- .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; }
247
- .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary); }
248
- .dcm-input-err { border-color: var(--dsw-alias-state-error-primary); }
249
-
250
- .dcm-chevron { display: inline-flex; align-items: center; justify-content: center; transition: transform .16s ease; color: var(--dsw-alias-label-tertiary); }
251
- .dcm-chevron-open { transform: rotate(180deg); }
252
-
253
- .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
254
- .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; }
255
- .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); }
256
- .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); }
257
- .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); }
258
-
259
- .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; }
260
- .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
261
- .dcm-btn-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
262
- .dcm-btn-primary:hover:not(:disabled) { opacity: 0.9; }
263
- .dcm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
264
- .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
265
-
266
- .dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
267
- .dcm-alert-ok { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
268
- .dcm-alert-err { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
269
- .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; }
270
- .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; }
271
-
272
- .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; }
273
- .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
274
- .dcm-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-secondary); }
275
-
276
- .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
277
-
278
- /* Native dot-indicator chip */
279
- .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; }
280
- .dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3); }
281
- .dcm-dot { width: 6.5px; height: 6.5px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
282
- .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); }
283
- .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); }
284
- .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; }
285
- .dcm-dot-idle { background: var(--dsw-alias-label-tertiary); }
286
-
287
- .dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
288
- @media (max-width: 768px) {
289
- .dcm-chip-timer { display: none; }
290
- .dcm-panel { width: min(320px, calc(100vw - 32px)); right: 0; }
291
- }
292
- .dcm-chip-compact .dcm-chip-timer { display: none !important; }
293
-
294
- @keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
295
-
296
- .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; }
297
- .dcm-panel-title { font-weight: 600; padding-bottom: 2px; font-size: 13.5px; display: flex; justify-content: space-between; align-items: baseline; }
298
- .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
299
- .dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
300
- .dcm-panel-grid { display: grid; grid-template-columns: 1fr 62px 62px; column-gap: 6px; align-items: center; }
301
- .dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr 76px; column-gap: 6px; align-items: center; }
302
- .dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr 60px 74px; column-gap: 6px; align-items: center; }
303
- .dcm-th { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; }
304
- .dcm-th-num { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; text-align: right; font-size: 11px; }
305
- .dcm-td { padding: 3.5px 0; color: var(--dsw-alias-label-secondary); }
306
- .dcm-cell-idle { text-align: right; padding: 3px 6px; border-radius: 4px; color: var(--dsw-alias-label-tertiary); }
307
- .dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding: 3.5px 0; }
308
- .dcm-cost { text-align: right; padding: 3.5px 0; font-weight: 500; color: var(--dsw-alias-label-primary); }
309
- .dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
310
- .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
311
- .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; }
312
- .dcm-accordion-btn:hover { text-decoration: underline; }
313
- .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); }
314
- .dcm-sparkline-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
315
- .dcm-sparkline-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
316
- .dcm-sparkline-svg { width: 100%; height: 36px; display: block; overflow: visible; }
317
- .dcm-spark-bar { fill: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 45%, transparent); transition: fill .15s ease; cursor: pointer; }
318
- .dcm-spark-bar:hover { fill: var(--dsw-alias-state-brand-primary); }
319
- .dcm-spark-peak { fill: var(--dsw-alias-state-brand-primary); }
320
- .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; }
321
- .dcm-proj-item { display: inline-flex; align-items: center; gap: 4px; }
322
- .dcm-proj-val { font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
323
- `
324
-
325
-
326
-
327
- function ensureStyles() {
328
- if (typeof document === 'undefined') return
329
- if (document.getElementById('dsh-cost-meter-unified-css')) return
330
- const el = document.createElement('style')
331
- el.id = 'dsh-cost-meter-unified-css'
332
- el.dataset.dshPlugin = NS
333
- el.textContent = CSS
334
- document.head.appendChild(el)
335
- }
336
-
337
- function createErrorBoundary() {
338
- if (!React || typeof React.Component !== 'function') {
339
- return function NoopBoundary(props) { return props?.children || null }
340
- }
341
- return class ErrorBoundary extends React.Component {
342
- constructor(props) {
343
- super(props)
344
- this.state = { hasError: false, error: null }
345
- }
346
- static getDerivedStateFromError(error) {
347
- return { hasError: true, error }
348
- }
349
- componentDidCatch(error, errorInfo) {
350
- console.error('[dsh-cost-meter] React error caught:', error, errorInfo)
351
- }
352
- render() {
353
- if (this.state.hasError) {
354
- return React.createElement(
355
- 'div',
356
- { className: 'dcm-alert dcm-alert-err', style: { margin: '8px 0' } },
357
- React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } }, '⚠️ Cost Meter UI Error:'),
358
- React.createElement('div', { style: { fontSize: 12, wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
359
- React.createElement(
360
- 'button',
361
- {
362
- type: 'button',
363
- className: 'dcm-btn',
364
- style: { marginTop: 8, fontSize: 12, padding: '3px 10px' },
365
- onClick: () => this.setState({ hasError: false, error: null }),
366
- },
367
- 'Retry'
368
- )
369
- )
370
- }
371
- return this.props?.children || null
372
- }
373
- }
374
- }
375
- const ErrorBoundary = createErrorBoundary()
376
-
377
- function getSettingsScope(ctx) {
378
- return (ctx && typeof ctx.get === 'function' ? ctx.get('configForms') : null) || (ctx && ctx.configForms) || (ctx && ctx.services && ctx.services.configForms) || null
379
- }
380
-
381
- function refreshMirrorUntilVisible(ctx) {
382
- const s = getSettingsScope(ctx)
383
- const visible = () => {
384
- try {
385
- const view = s?.describe?.()?.getSnapshot?.()?.view
386
- return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
387
- } catch (e) {
388
- if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] mirror check error: ' + (e?.message || e))
389
- return false
390
- }
391
- }
392
- if (!s || visible()) return () => {}
393
- let tries = 0
394
- const timer = setInterval(() => {
395
- if (visible() || tries >= 15) {
396
- if (!visible() && tries >= 15 && ctx?.logger?.warn) {
397
- ctx.logger.warn('[dsh-cost-meter] settings mirror did not appear after 15 attempts')
398
- }
399
- clearInterval(timer)
400
- return
401
- }
402
- tries += 1
403
- try {
404
- s?.describe?.()?.load?.()
405
- } catch (e) {
406
- if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] settings load error: ' + (e?.message || e))
407
- }
408
- }, 1000)
409
- return () => clearInterval(timer)
410
- }
411
-
412
- let ChevronIcon = null
413
- try {
414
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
415
- ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
416
- } catch (e) {
417
- ChevronIcon = null
418
- }
419
-
420
- function FallbackChevron(props) {
421
- return React.createElement(
422
- 'svg',
423
- {
424
- width: 14,
425
- height: 14,
426
- viewBox: '0 0 14 14',
427
- fill: 'none',
428
- 'aria-hidden': true,
429
- },
430
- React.createElement('path', {
431
- d: 'M3.5 5.25L7 8.75L10.5 5.25',
432
- stroke: 'currentColor',
433
- strokeWidth: 1.5,
434
- strokeLinecap: 'round',
435
- strokeLinejoin: 'round',
436
- }),
437
- )
438
- }
439
-
440
- function Chevron(props) {
441
- const icon = ChevronIcon
442
- ? React.createElement(ChevronIcon, { 'aria-hidden': true })
443
- : React.createElement(FallbackChevron, null)
444
- return React.createElement(
445
- 'span',
446
- {
447
- className: 'dcm-chevron' + (props.open ? ' dcm-chevron-open' : ''),
448
- style: { marginLeft: 'auto' },
449
- },
450
- icon,
451
- )
452
- }
453
-
454
- function isValidTimeZone(tz) {
455
- if (!tz || typeof tz !== 'string') return false
456
- try {
457
- new Intl.DateTimeFormat(undefined, { timeZone: tz })
458
- return true
459
- } catch {
460
- return false
461
- }
462
- }
463
-
464
- function ago(timestamp, locale) {
465
- if (!timestamp) return '—'
466
- const isZh = locale === 'zh'
467
- const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
468
- if (minutes < 1) return isZh ? '刚刚' : 'just now'
469
- if (minutes < 60) return minutes + (isZh ? ' 分钟前' : ' min ago')
470
- return hours < 48 ? hours + (isZh ? ' 小时前' : ' hrs ago') : Math.round(hours / 24) + (isZh ? ' 天前' : ' days ago')
471
- }
472
-
473
- function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
474
- const { signal, ...rest } = options
475
- const timer = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
476
- ? AbortSignal.timeout(timeoutMs)
477
- : null
478
- let combined = signal
479
- if (timer) {
480
- if (signal && typeof AbortSignal.any === 'function') {
481
- combined = AbortSignal.any([signal, timer])
482
- } else if (!signal) {
483
- combined = timer
484
- }
485
- }
486
- return fetch(url, { ...rest, ...(combined ? { signal: combined } : {}) })
487
- }
488
-
489
- // ---------------------------------------------------------------- card
490
-
491
- function CostMeterCard(props) {
492
- const ctx = props.ctx
493
- const locale = useActiveLocale(ctx)
494
- const t = props.t || makeT(locale === 'zh' ? zh : en, en)
495
- const page = !!(props && props.view === 'page')
496
- const [open, setOpen] = React.useState(!!page)
497
- const [draft, setDraft] = React.useState(null)
498
- const [saving, setSaving] = React.useState(false)
499
- const [err, setErr] = React.useState('')
500
- const [saved, setSaved] = React.useState(false)
501
- const [diagState, setDiagState] = React.useState(null)
502
-
503
- // Manual sync button state
504
- const [syncing, setSyncing] = React.useState(false)
505
- const [syncMsg, setSyncMsg] = React.useState('')
506
-
507
- // Plugin in-app updater state
508
- const [updateState, setUpdateState] = React.useState({
509
- checking: false,
510
- updating: false,
511
- currentVersion: '',
512
- latestVersion: '',
513
- updateAvailable: false,
514
- canAutoUpdate: true,
515
- error: '',
516
- notice: '',
517
- })
518
-
519
- const checkUpdate = React.useCallback(async (signal) => {
520
- setUpdateState((s) => ({ ...s, checking: true, error: '' }))
521
- try {
522
- const res = await fetchWithTimeout(UPDATE_PATH, { signal })
523
- if (!res.ok) throw new Error('HTTP ' + res.status)
524
- const data = await res.json().catch(() => ({}))
525
- setUpdateState((s) => ({
526
- ...s,
527
- checking: false,
528
- currentVersion: data.currentVersion || s.currentVersion,
529
- latestVersion: data.latestVersion || '',
530
- updateAvailable: Boolean(data.updateAvailable),
531
- canAutoUpdate: data.canAutoUpdate !== false,
532
- }))
533
- } catch (e) {
534
- if (e?.name !== 'AbortError') {
535
- setUpdateState((s) => ({ ...s, checking: false }))
536
- }
537
- }
538
- }, [])
539
-
540
- React.useEffect(() => {
541
- if (!open) return
542
- const controller = new AbortController()
543
- checkUpdate(controller.signal)
544
- return () => controller.abort()
545
- }, [open, checkUpdate])
546
-
547
- async function handleTriggerUpdate() {
548
- if (updateState.updating) return
549
- setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
550
- try {
551
- const res = await fetchWithTimeout(UPDATE_PATH, {
552
- method: 'POST',
553
- headers: { 'x-dsh-plugin-update': '1' },
554
- }, 30000)
555
- const data = await res.json().catch(() => ({}))
556
- if (!res.ok || data.ok === false || data.error) {
557
- throw new Error(data.error || ('HTTP ' + res.status))
558
- }
559
- const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
560
- setUpdateState((s) => ({
561
- ...s,
562
- updating: false,
563
- updateAvailable: false,
564
- currentVersion: newVer,
565
- notice: t('update.done', { version: newVer }),
566
- }))
567
- setTimeout(() => checkUpdate(), 2000)
568
- } catch (err) {
569
- setUpdateState((s) => ({
570
- ...s,
571
- updating: false,
572
- error: t('update.failed', { error: String(err.message || err) }),
573
- }))
574
- }
575
- }
576
-
577
- const configForms = getSettingsScope(ctx)
578
- const scope = React.useMemo(
579
- () => (configForms ? configForms.get(NS) : undefined),
580
- [configForms],
581
- )
582
-
583
- const snapshot = React.useSyncExternalStore(
584
- React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
585
- React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
586
- React.useCallback(() => ({ status: 'loading' }), []),
587
- )
588
-
589
- React.useEffect(() => { ensureStyles() }, [])
590
-
591
- const fetchDiag = React.useCallback((signal) => {
592
- fetchWithTimeout(STATE_PATH, { signal, headers: { accept: 'application/json' } })
593
- .then((r) => (r.ok ? r.json() : null))
594
- .then((data) => { if (data) setDiagState(data) })
595
- .catch(() => {})
596
- }, [])
597
-
598
- React.useEffect(() => {
599
- if (!open) return
600
- const controller = new AbortController()
601
- fetchDiag(controller.signal)
602
- return () => controller.abort()
603
- }, [open, fetchDiag])
604
-
605
- const status = (snapshot && snapshot.status) || 'loading'
606
- const stored = (snapshot && snapshot.value) || {}
607
-
608
- React.useEffect(() => {
609
- if (status === 'ready' && draft === null) {
610
- setDraft({
611
- currency: stored.currency !== undefined ? String(stored.currency) : '$',
612
- usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
613
- budgetThreshold: stored.budgetThreshold !== undefined ? String(stored.budgetThreshold) : '0',
614
- displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
615
- useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
616
- refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
617
- })
618
- }
619
- }, [status, stored, draft])
620
-
621
- const tzValid = draft ? isValidTimeZone(draft.displayTimeZone) : true
622
- const rateValid = draft ? (!isNaN(Number(draft.usdRate)) && Number(draft.usdRate) > 0) : true
623
- const budgetValid = draft ? (!isNaN(Number(draft.budgetThreshold)) && Number(draft.budgetThreshold) >= 0) : true
624
- const hoursValid = draft ? (!isNaN(Number(draft.refreshHours)) && Number(draft.refreshHours) >= 1 && Number(draft.refreshHours) <= 168) : true
625
-
626
- const handleManualSync = async () => {
627
- if (syncing) return
628
- setSyncing(true)
629
- setSyncMsg('')
630
- try {
631
- const res = await fetchWithTimeout(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
632
- if (res.status === 429) {
633
- const data = await res.json().catch(() => ({}))
634
- const retryAfter = data.retryAfter || 30
635
- setSyncMsg(t('action.sync_rate_limited', { seconds: retryAfter }))
636
- } else if (res.ok) {
637
- setSyncMsg(t('action.sync_ok'))
638
- fetchDiag()
639
- } else {
640
- setSyncMsg(t('action.sync_err'))
641
- }
642
- } catch (_) {
643
- setSyncMsg(t('action.sync_err'))
644
- } finally {
645
- setSyncing(false)
646
- setTimeout(() => setSyncMsg(''), 4000)
647
- }
648
- }
649
-
650
- const save = async () => {
651
- if (!scope || !draft) return
652
- setSaving(true); setErr(''); setSaved(false)
653
- const broken = []
654
-
655
- try {
656
- await scope.set('displayMode', (draft.displayMode === 'compact' || draft.displayMode === 'full') ? draft.displayMode : 'auto')
657
- } catch (e) { broken.push('displayMode: ' + (e && e.message || String(e))) }
658
-
659
- try {
660
- await scope.set('currency', String(draft.currency || '$'))
661
- } catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
662
-
663
- const rateNum = Number(String(draft.usdRate).trim())
664
- if (!Number.isFinite(rateNum) || rateNum <= 0) {
665
- broken.push('usdRate: must be a positive number')
666
- } else {
667
- try {
668
- await scope.set('usdRate', rateNum)
669
- } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
670
- }
671
-
672
- const budgetNum = Number(String(draft.budgetThreshold).trim())
673
- if (!Number.isFinite(budgetNum) || budgetNum < 0) {
674
- broken.push('budgetThreshold: must be non-negative')
675
- } else {
676
- try {
677
- await scope.set('budgetThreshold', budgetNum)
678
- } catch (e) { broken.push('budgetThreshold: ' + (e && e.message || String(e))) }
679
- }
680
-
681
- const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
682
- if (!isValidTimeZone(tzVal)) {
683
- broken.push('displayTimeZone: invalid IANA time zone')
684
- } else {
685
- try {
686
- await scope.set('displayTimeZone', tzVal)
687
- } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
688
- }
689
-
690
- try {
691
- await scope.set('useOpenRouter', Boolean(draft.useOpenRouter))
692
- } catch (e) { broken.push('useOpenRouter: ' + (e && e.message || String(e))) }
693
-
694
- const hoursNum = Number(String(draft.refreshHours).trim())
695
- if (!Number.isFinite(hoursNum) || hoursNum < 1 || hoursNum > 168) {
696
- broken.push('refreshHours: must be between 1 and 168 hours')
697
- } else {
698
- try {
699
- await scope.set('refreshHours', hoursNum)
700
- } catch (e) { broken.push('refreshHours: ' + (e && e.message || String(e))) }
701
- }
702
-
703
- setSaving(false)
704
- if (broken.length) {
705
- setErr(t('settings.saveFailed') + broken.join('; '))
706
- return
707
- }
708
- setSaved(true)
709
- setTimeout(() => setSaved(false), 2500)
710
- }
711
-
712
- const modelsCount = (diagState && typeof diagState.catalogModelsCount === 'number')
713
- ? diagState.catalogModelsCount
714
- : ((diagState && diagState.schedules) ? Object.keys(diagState.schedules).length : null)
715
- const catalogHasErr = diagState && Boolean(diagState.catalogError)
716
- const openRouterEnabled = draft ? Boolean(draft.useOpenRouter) : true
717
-
718
- // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
719
- // padding, so the summary is a one-liner and the page drops our card chrome.
720
- if (props && props.view === 'summary') {
721
- return React.createElement('span', { className: 'dcm-sub' }, t('header.sub'))
722
- }
723
-
724
- return React.createElement(page ? 'div' : 'li', { className: page ? 'dcm-page' : 'dcm-card' },
725
- React.createElement('button', {
726
- type: 'button', className: 'dcm-head', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open,
727
- onClick: () => setOpen((v) => !v),
728
- },
729
- React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
730
- React.createElement('span', { className: 'dcm-title' }, t('header.title')),
731
- React.createElement('span', { className: 'dcm-sub' }, t('header.sub')),
732
- React.createElement('div', { className: 'dcm-badges' },
733
- React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.deepseek')),
734
- openRouterEnabled
735
- ? (catalogHasErr
736
- ? React.createElement('span', { className: 'dcm-badge dcm-badge-bad' }, t('badge.catalog_err'))
737
- : (modelsCount !== null && modelsCount > 0
738
- ? React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.catalog_ok', { count: modelsCount }))
739
- : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_empty'))))
740
- : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_off')),
741
- catalogHasErr && modelsCount ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_stale')) : null,
742
- draft && draft.displayTimeZone
743
- ? React.createElement('span', { className: 'dcm-badge' }, draft.displayTimeZone)
744
- : null,
745
- ),
746
- ),
747
- React.createElement(Chevron, { open }),
748
- ),
749
- (page || open) ? React.createElement('div', { className: 'dcm-body' },
750
- status === 'loading'
751
- ? React.createElement('div', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
752
- : status !== 'ready'
753
- ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
754
- : React.createElement(React.Fragment, null,
755
- // In-app Update Bar
756
- React.createElement('div', {
757
- className: 'dcm-section-card',
758
- style: { padding: '10px 14px', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
759
- },
760
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 } },
761
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
762
- 'v' + updateState.currentVersion
763
- ),
764
- updateState.checking
765
- ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 } }, t('update.checking'))
766
- : updateState.updateAvailable
767
- ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' },
768
- t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
769
- )
770
- : React.createElement('span', { className: 'dcm-badge dcm-badge-ok' },
771
- '✓ ' + t('update.up_to_date')
772
- )
773
- ),
774
- updateState.updateAvailable
775
- ? React.createElement('button', {
776
- type: 'button',
777
- className: 'dcm-btn dcm-btn-primary',
778
- disabled: updateState.updating,
779
- onClick: handleTriggerUpdate,
780
- style: { padding: '4px 10px', fontSize: 12 },
781
- }, updateState.updating ? t('update.updating') : t('update.btn'))
782
- : null
783
- ),
784
- updateState.notice ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, '✓ ' + updateState.notice) : null,
785
- updateState.error ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, updateState.error) : null,
786
-
787
- // Section 1: General Parameters
788
- React.createElement('div', { className: 'dcm-section-card' },
789
- React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
790
- React.createElement('div', { className: 'dcm-section-desc' }, t('section.general_desc')),
791
- React.createElement('div', { className: 'dcm-grid-2' },
792
- React.createElement('div', { className: 'dcm-field' },
793
- React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
794
- React.createElement('input', {
795
- className: 'dcm-input',
796
- value: (draft && draft.currency) !== undefined ? draft.currency : '',
797
- onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
798
- }),
799
- React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
800
- ),
801
- React.createElement('div', { className: 'dcm-field' },
802
- React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
803
- React.createElement('input', {
804
- className: 'dcm-input' + (rateValid ? '' : ' dcm-input-err'),
805
- type: 'number',
806
- step: 'any',
807
- value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
808
- onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
809
- }),
810
- React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
811
- ),
812
- ),
813
- React.createElement('div', { className: 'dcm-grid-2' },
814
- React.createElement('div', { className: 'dcm-field' },
815
- React.createElement('label', { className: 'dcm-label' }, t('field.budgetThreshold')),
816
- React.createElement('input', {
817
- className: 'dcm-input' + (budgetValid ? '' : ' dcm-input-err'),
818
- type: 'number',
819
- step: 'any',
820
- min: 0,
821
- value: (draft && draft.budgetThreshold) !== undefined ? draft.budgetThreshold : '',
822
- onChange: (e) => setDraft({ ...draft, budgetThreshold: e.target.value }),
823
- }),
824
- React.createElement('span', { className: 'dcm-hint' }, t('hint.budgetThreshold')),
825
- ),
826
- React.createElement('div', { className: 'dcm-field' },
827
- React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
828
- React.createElement('input', {
829
- className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
830
- value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
831
- onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
832
- }),
833
- React.createElement('span', { className: 'dcm-hint' },
834
- tzValid ? t('hint.displayTimeZone') : t('tz.invalid')
835
- ),
836
- ),
837
- ),
838
- React.createElement('div', { className: 'dcm-field', style: { marginTop: 6 } },
839
- React.createElement('label', { className: 'dcm-label' }, t('field.displayMode')),
840
- React.createElement('select', {
841
- className: 'dcm-input',
842
- value: (draft && draft.displayMode) || 'auto',
843
- onChange: (e) => setDraft({ ...draft, displayMode: e.target.value }),
844
- }, [
845
- React.createElement('option', { key: 'auto', value: 'auto' }, t('mode.auto')),
846
- React.createElement('option', { key: 'full', value: 'full' }, t('mode.full')),
847
- React.createElement('option', { key: 'compact', value: 'compact' }, t('mode.compact')),
848
- ]),
849
- React.createElement('span', { className: 'dcm-hint' }, t('hint.displayMode')),
850
- ),
851
- ),
852
-
853
- // Section 2: OpenRouter Pricing Catalog
854
- React.createElement('div', { className: 'dcm-section-card' },
855
- React.createElement('div', { className: 'dcm-section-title' },
856
- React.createElement('span', null, t('section.openrouter')),
857
- openRouterEnabled
858
- ? React.createElement('button', {
859
- type: 'button',
860
- className: 'dcm-btn dcm-btn-sm',
861
- disabled: syncing,
862
- onClick: handleManualSync,
863
- }, syncing ? t('action.syncing') : t('action.sync_now'))
864
- : null
865
- ),
866
- React.createElement('div', { className: 'dcm-section-desc' }, t('section.openrouter_desc')),
867
- syncMsg ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok', style: { padding: '6px 10px', fontSize: 12 } }, syncMsg) : null,
868
- React.createElement('div', { className: 'dcm-row-check' },
869
- React.createElement('input', {
870
- id: 'dcm-use-openrouter',
871
- type: 'checkbox',
872
- className: 'dcm-check',
873
- checked: draft ? Boolean(draft.useOpenRouter) : true,
874
- onChange: (e) => setDraft({ ...draft, useOpenRouter: e.target.checked }),
875
- }),
876
- React.createElement('label', { htmlFor: 'dcm-use-openrouter', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.useOpenRouter')),
877
- ),
878
- React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.useOpenRouter')),
879
- React.createElement('div', { className: 'dcm-field', style: { marginTop: 4 } },
880
- React.createElement('label', { className: 'dcm-label' }, t('field.refreshHours')),
881
- React.createElement('input', {
882
- className: 'dcm-input' + (hoursValid ? '' : ' dcm-input-err'),
883
- type: 'number',
884
- min: 1,
885
- max: 168,
886
- value: (draft && draft.refreshHours) !== undefined ? draft.refreshHours : '',
887
- onChange: (e) => setDraft({ ...draft, refreshHours: e.target.value }),
888
- }),
889
- React.createElement('span', { className: 'dcm-hint' }, t('hint.refreshHours')),
890
- ),
891
- ),
892
-
893
- // Section 3: Live Diagnostics
894
- React.createElement('div', { className: 'dcm-section-card' },
895
- React.createElement('div', { className: 'dcm-section-title' }, t('section.diagnostics')),
896
- React.createElement('div', { className: 'dcm-section-desc' }, t('section.diagnostics_desc')),
897
- React.createElement('div', { className: 'dcm-grid-2' },
898
- React.createElement('div', { className: 'dcm-stat-box' },
899
- React.createElement('span', { className: 'dcm-stat-val' },
900
- modelsCount !== null ? String(modelsCount) : '—'
901
- ),
902
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.models_count')),
903
- ),
904
- React.createElement('div', { className: 'dcm-stat-box' },
905
- React.createElement('span', { className: 'dcm-stat-val' },
906
- diagState ? ago(diagState.fetchedAt, locale) : '—'
907
- ),
908
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.last_sync')),
909
- ),
910
- React.createElement('div', { className: 'dcm-stat-box' },
911
- React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
912
- diagState && diagState.route ? (diagState.route.provider + '/' + diagState.route.model) : 'default'
913
- ),
914
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.route')),
915
- ),
916
- React.createElement('div', { className: 'dcm-stat-box' },
917
- React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
918
- diagState && diagState.source ? String(diagState.source).toUpperCase() : 'DEEPSEEK'
919
- ),
920
- React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.tariff_source')),
921
- ),
922
- ),
923
- ),
924
-
925
- // Alerts & Action Footer
926
- err ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, err) : null,
927
- saved ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, t('settings.saved')) : null,
928
- React.createElement('div', { className: 'dcm-foot' },
929
- React.createElement('button', {
930
- type: 'button',
931
- className: 'dcm-btn dcm-btn-primary',
932
- disabled: saving || !tzValid || !rateValid || !budgetValid || !hoursValid,
933
- onClick: save,
934
- }, saving ? t('settings.saving') : t('settings.save')),
935
- ),
936
- )
937
- ) : null,
938
- )
939
- }
940
-
941
- // ---------------------------------------------------------------- tariff
942
-
943
- function inWindow(minute, w) {
944
- return w.start <= w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
945
- }
946
-
947
- function ratesAt(schedule, minute) {
948
- const windows = (schedule && schedule.windows) || []
949
- for (const w of windows) if (inWindow(minute, w)) return w.rates
950
- return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
951
- }
952
-
953
- function minutesUntilChange(schedule, minute) {
954
- const windows = (schedule && schedule.windows) || []
955
- if (!windows.length) return null
956
- const now = ratesAt(schedule, minute)
957
- const transitions = new Set()
958
- for (const w of windows) {
959
- transitions.add(w.start % DAY)
960
- transitions.add(w.end % DAY)
961
- }
962
- let minDiff = null
963
- for (const t of transitions) {
964
- let diff = (t - minute + DAY) % DAY
965
- if (diff === 0) diff = DAY
966
- const probe = ratesAt(schedule, (minute + diff) % DAY)
967
- if (probe.output !== now.output || probe.input !== now.input) {
968
- if (minDiff === null || diff < minDiff) minDiff = diff
969
- }
970
- }
971
- return minDiff
972
- }
973
-
974
- const extremesMemo = new WeakMap()
975
- function extremes(schedule) {
976
- if (!schedule) return { low: {}, high: {}, flat: true }
977
- if (extremesMemo.has(schedule)) return extremesMemo.get(schedule)
978
-
979
- const base = schedule.base || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
980
- const candidates = [base]
981
- for (const w of (schedule.windows || [])) {
982
- if (w.rates) candidates.push(w.rates)
983
- }
984
-
985
- let low = candidates[0]
986
- let high = candidates[0]
987
- for (const r of candidates) {
988
- if (r.output < low.output) low = r
989
- if (r.output > high.output) high = r
990
- }
991
-
992
- const res = { low, high, flat: low.output === high.output }
993
- extremesMemo.set(schedule, res)
994
- return res
995
- }
996
-
997
- function sumSlots(slots) {
998
- const total = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
999
- for (const key of Object.keys(slots || {})) {
1000
- const b = slots[key] || {}
1001
- for (const field of Object.keys(total)) total[field] += Number(b[field]) || 0
1002
- }
1003
- return total
1004
- }
1005
-
1006
- function spendBySlots(slots, schedule) {
1007
- let usd = 0
1008
- for (const key of Object.keys(slots || {})) {
1009
- const minute = (Number(key) * 30 + 15) % 1440
1010
- usd += spendUsd(slots[key], ratesAt(schedule, minute))
1011
- }
1012
- return usd
1013
- }
1014
-
1015
- function cacheSavingsBySlots(slots, schedule) {
1016
- let savedUsd = 0
1017
- let missedUsd = 0
1018
- let readTokens = 0
1019
- for (const key of Object.keys(slots || {})) {
1020
- const minute = (Number(key) * 30 + 15) % 1440
1021
- const rates = ratesAt(schedule, minute)
1022
- const u = slots[key] || {}
1023
- const cRead = Number(u.cacheReadTokens) || 0
1024
- if (cRead > 0 && rates && rates.input > rates.cacheHit) {
1025
- readTokens += cRead
1026
- const missed = (cRead / 1e6) * (rates.input || 0)
1027
- const hit = (cRead / 1e6) * (rates.cacheHit || 0)
1028
- savedUsd += Math.max(0, missed - hit)
1029
- missedUsd += missed
1030
- }
1031
- }
1032
- const percent = missedUsd > 0 ? Math.round((savedUsd / missedUsd) * 100) : 0
1033
- return { savedUsd, missedUsd, readTokens, percent }
1034
- }
1035
-
1036
-
1037
- function spendUsd(usage, rates) {
1038
- const u = usage || {}
1039
- const perMillion =
1040
- (Number(u.cacheReadTokens) || 0) * (rates.cacheHit || 0) +
1041
- (Number(u.uncachedInputTokens) || 0) * (rates.input || 0) +
1042
- (Number(u.cacheWriteTokens) || 0) * (rates.cacheWrite || 0) +
1043
- (Number(u.outputTokens) || 0) * (rates.output || 0)
1044
- return perMillion / 1e6
1045
- }
1046
-
1047
- function rate(value) {
1048
- if (!value) return '—'
1049
- const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
1050
- return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
1051
- }
1052
-
1053
- function money(value, currency) {
1054
- const n = Number(value) || 0
1055
- return currency + n.toFixed(Math.abs(n) >= 100 ? 0 : 2)
1056
- }
1057
-
1058
- function tokens(value) {
1059
- const n = Number(value) || 0
1060
- if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
1061
- if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
1062
- return String(n)
1063
- }
1064
-
1065
- function clock(minutes) {
1066
- if (minutes === null) return null
1067
- return Math.floor(minutes / 60) + ':' + String(minutes % 60).padStart(2, '0')
1068
- }
1069
-
1070
- function hhmm(minute) {
1071
- return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
1072
- }
1073
-
1074
- function hhmmInZone(minute, zone, reference) {
1075
- const d = new Date(Date.UTC(
1076
- reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
1077
- ))
1078
- try {
1079
- return new Intl.DateTimeFormat('en-GB', {
1080
- hour: '2-digit', minute: '2-digit', hour12: false, timeZone: zone,
1081
- }).format(d)
1082
- } catch {
1083
- return hhmm(minute)
1084
- }
1085
- }
1086
-
1087
- function zoneLabel(zone) {
1088
- if (!zone) return 'UTC'
1089
- const city = zone.includes('/') ? zone.slice(zone.lastIndexOf('/') + 1) : zone
1090
- return city.replace(/_/g, ' ')
1091
- }
1092
-
1093
-
1094
-
1095
- function calculateBurnRateAndProjection(turns, totalCostUsd, options = {}) {
1096
- const currentTotal = Number(totalCostUsd) > 0 ? Number(totalCostUsd) : 0
1097
- if (!Array.isArray(turns) || turns.length < 2) {
1098
- return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1099
- }
1100
- const now = typeof options.now === 'number' ? options.now : Date.now()
1101
- const windowMs = 10 * 60 * 1000
1102
- const sorted = turns.filter((t) => t && typeof t.timestamp === 'number').sort((a, b) => a.timestamp - b.timestamp)
1103
- if (sorted.length < 2) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1104
- let sample = sorted.filter((t) => t.timestamp >= now - windowMs)
1105
- if (sample.length < 2) sample = sorted.slice(-Math.min(sorted.length, 10))
1106
- const durationMs = sample[sample.length - 1].timestamp - sample[0].timestamp
1107
- if (durationMs < 5000) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
1108
- let costInSample = sample.reduce((acc, t) => acc + (Number(t.costDeltaUsd) || 0), 0)
1109
- if (costInSample <= 0 && currentTotal > 0) {
1110
- const totalTokens = sorted.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
1111
- const sampleTokens = sample.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
1112
- costInSample = totalTokens > 0 ? (sampleTokens / totalTokens) * currentTotal : currentTotal
1113
- }
1114
- const hours = durationMs / (1000 * 60 * 60)
1115
- const rawBurnRate = hours > 0 ? (costInSample / hours) : 0
1116
- const burnRatePerHourUsd = (Number.isFinite(rawBurnRate) && rawBurnRate >= 0) ? Math.round(rawBurnRate * 10000) / 10000 : 0
1117
- const stepCount = sorted.length
1118
- const avgCostPerStep = currentTotal / stepCount
1119
- const projectedExtra = Math.min(currentTotal * 2, Math.max(avgCostPerStep * 3, burnRatePerHourUsd * 0.25))
1120
- const projectedCostUsd = Math.round((currentTotal + (projectedExtra > 0 ? projectedExtra : 0)) * 10000) / 10000
1121
- return { burnRatePerHourUsd, projectedCostUsd }
1122
- }
1123
-
1124
- function renderSparkline(turns, options) {
1125
- if (!Array.isArray(turns) || turns.length === 0) return null
1126
- const { h, currency, fx, activeRate, t } = options
1127
-
1128
- const maxTokens = Math.max(1, ...turns.map((item) => Number(item.tokens) || 0))
1129
- const height = 36
1130
- const width = 240
1131
- const count = turns.length
1132
- const gap = count > 15 ? 2 : 4
1133
- const barWidth = Math.max(3, Math.min(16, Math.floor((width - (count - 1) * gap) / count)))
1134
- const totalBarSpace = count * barWidth + (count - 1) * gap
1135
- const startX = Math.max(0, Math.floor((width - totalBarSpace) / 2))
1136
-
1137
- const bars = turns.map((item, idx) => {
1138
- const tok = Number(item.tokens) || 0
1139
- const barHeight = Math.max(3, Math.round((tok / maxTokens) * (height - 6)))
1140
- const x = startX + idx * (barWidth + gap)
1141
- const y = height - barHeight
1142
- const isPeak = tok === maxTokens && tok > 0
1143
- const stepCost = (item.costDeltaUsd && item.costDeltaUsd > 0)
1144
- ? item.costDeltaUsd * fx
1145
- : (activeRate ? (tok / 1e6) * ((activeRate.input + activeRate.output) / 2) * fx : 0)
1146
-
1147
- const tip = (t ? t('sparkline.steps') : 'step') + ' ' + (item.turn || idx + 1) +
1148
- (item.step ? '.' + item.step : '') + ': ' + tokens(tok) +
1149
- (stepCost > 0 ? ' (≈ ' + money(stepCost, currency) + ')' : '')
1150
-
1151
- return h('rect', {
1152
- key: 'bar-' + idx,
1153
- x,
1154
- y,
1155
- width: barWidth,
1156
- height: barHeight,
1157
- rx: 2,
1158
- ry: 2,
1159
- className: isPeak ? 'dcm-spark-bar dcm-spark-peak' : 'dcm-spark-bar',
1160
- }, h('title', null, tip))
1161
- })
1162
-
1163
- return h('div', { key: 'sparkline', className: 'dcm-sparkline-box' }, [
1164
- h('div', { key: 'spark-hdr', className: 'dcm-sparkline-header' }, [
1165
- h('span', { key: 'lbl', className: 'dcm-section-lbl' }, t ? t('sparkline.title') : 'Turn dynamics'),
1166
- h('span', { key: 'cnt', className: 'dcm-sparkline-count' }, count + ' ' + (t ? t('sparkline.steps') : 'steps')),
1167
- ]),
1168
- h('svg', {
1169
- key: 'spark-svg',
1170
- viewBox: '0 0 ' + width + ' ' + height,
1171
- className: 'dcm-sparkline-svg',
1172
- preserveAspectRatio: 'xMidYMid meet',
1173
- }, bars),
1174
- ])
1175
- }
1176
-
1177
- // ------------------------------------------------------------ CostMeter
1178
-
1179
-
1180
- function CostMeter(props) {
1181
- const ctx = props.ctx
1182
- const locale = useActiveLocale(ctx)
1183
- const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1184
- const useProjection = props.useProjection
1185
- const usage = useProjection ? useProjection('tokenUsage') : undefined
1186
- const byModel = useProjection ? useProjection('costByModel') : undefined
1187
- const [state, setState] = React.useState(null)
1188
- const [open, setOpen] = React.useState(false)
1189
- const [now, setNow] = React.useState(() => new Date())
1190
- const [copied, setCopied] = React.useState(false)
1191
- const [showAllModels, setShowAllModels] = React.useState(false)
1192
- const box = React.useRef(null)
1193
-
1194
- React.useEffect(() => { ensureStyles() }, [])
1195
-
1196
- const configForms = getSettingsScope(ctx)
1197
- const scope = React.useMemo(
1198
- () => (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : undefined),
1199
- [configForms],
1200
- )
1201
- const settingsSnap = React.useSyncExternalStore
1202
- ? React.useSyncExternalStore(
1203
- React.useMemo(() => (cb) => (scope && typeof scope.subscribe === 'function' ? scope.subscribe(cb) : () => {}), [scope]),
1204
- React.useCallback(() => (scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : null)), [scope, configForms]),
1205
- React.useCallback(() => null, []),
1206
- )
1207
- : null
1208
-
1209
- const used = (byModel && Array.isArray(byModel.models) ? byModel.models : [])
1210
- .map((m) => ({ ...m, usage: sumSlots(m && m.slots) }))
1211
- .filter((m) => m.usage.uncachedInputTokens || m.usage.outputTokens ||
1212
- m.usage.cacheReadTokens || m.usage.cacheWriteTokens)
1213
- const usedKeys = used.map((m) => m.provider + '/' + m.model).sort().join(',')
1214
-
1215
- const reqSeq = React.useRef(0)
1216
-
1217
- const load = React.useCallback((keys, signal) => {
1218
- const seq = ++reqSeq.current
1219
- const query = keys ? '?models=' + encodeURIComponent(keys) : ''
1220
- fetchWithTimeout(STATE_PATH + query, { signal, headers: { accept: 'application/json' } })
1221
- .then((r) => (r.ok ? r.json() : null))
1222
- .then((data) => {
1223
- if (data && seq === reqSeq.current) setState(data)
1224
- })
1225
- .catch(() => {})
1226
- }, [])
1227
-
1228
- React.useEffect(() => {
1229
- const controller = new AbortController()
1230
- load(usedKeys, controller.signal)
1231
- return () => controller.abort()
1232
- }, [load, usedKeys])
1233
- React.useEffect(() => {
1234
- if (!open) return
1235
- const controller = new AbortController()
1236
- load(usedKeys, controller.signal)
1237
- return () => controller.abort()
1238
- }, [open, load, usedKeys])
1239
- React.useEffect(() => {
1240
- const id = setInterval(() => setNow(new Date()), 15000)
1241
- return () => clearInterval(id)
1242
- }, [])
1243
- React.useEffect(() => {
1244
- if (!open) return undefined
1245
- const onDown = (e) => { if (box.current && !box.current.contains(e.target)) setOpen(false) }
1246
- document.addEventListener('mousedown', onDown)
1247
- return () => document.removeEventListener('mousedown', onDown)
1248
- }, [open])
1249
-
1250
- if (!state) return null
1251
-
1252
- const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1253
- const stored = (settingsSnap && settingsSnap.value) || {}
1254
- const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
1255
- const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
1256
- const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
1257
- const displayMode = stored.displayMode !== undefined ? stored.displayMode : (state.displayMode || 'auto')
1258
- const schedule = state.schedule
1259
- const hasUsage = usage !== undefined && usage !== null
1260
- const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
1261
-
1262
- if (!schedule) {
1263
- return h('div', { ref: box, style: { position: 'relative' } }, [
1264
- h('button', {
1265
- key: 'chip', type: 'button',
1266
- className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : ''),
1267
- onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
1268
- }, [
1269
- h('span', { key: 'dot', className: 'dcm-dot dcm-dot-idle' }),
1270
- h('span', { key: 'txt' }, t('chipUnknown')),
1271
- ]),
1272
- open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1273
- h('div', { key: 't', className: 'dcm-panel-title' },
1274
- state.route ? state.route.provider + ' / ' + state.route.model : t('routeUnknown')),
1275
- h('div', { key: 'n', className: 'dcm-panel-note' },
1276
- state.catalogError
1277
- ? t('catalogUnavailable') + state.catalogError
1278
- : t('tokens.not_priced')),
1279
- ]) : null,
1280
- ])
1281
- }
1282
-
1283
- const active = ratesAt(schedule, minute)
1284
- const { low, high, flat } = extremes(schedule)
1285
- const cheapNow = !flat && active.output === low.output
1286
- const leftMinutes = minutesUntilChange(schedule, minute)
1287
- const left = clock(leftMinutes)
1288
-
1289
- const schedules = state.schedules || {}
1290
- const perModel = used.map((m) => {
1291
- const key = m.provider + '/' + m.model
1292
- const own = schedules[key]
1293
- const ownSchedule = own && own.schedule
1294
- return {
1295
- key,
1296
- model: m.model,
1297
- usage: m.usage,
1298
- known: Boolean(ownSchedule),
1299
- cost: ownSchedule ? spendBySlots(m.slots, ownSchedule) * fx : 0,
1300
- }
1301
- })
1302
- const multiModel = perModel.length > 1
1303
- const total = perModel.length
1304
- ? perModel.reduce((sum, m) => sum + m.cost, 0)
1305
- : spendUsd(usage, active) * fx
1306
-
1307
- // Check budget threshold in USD
1308
- const totalUsd = fx > 0 ? total / fx : total
1309
- const budgetExceeded = budgetThreshold > 0 && totalUsd >= budgetThreshold
1310
-
1311
- // Dot indicator status class
1312
- const dotClass = budgetExceeded
1313
- ? 'dcm-dot dcm-dot-err'
1314
- : (flat ? 'dcm-dot dcm-dot-idle' : (cheapNow ? 'dcm-dot dcm-dot-ok' : 'dcm-dot dcm-dot-warn'))
1315
-
1316
- 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 }
1317
-
1318
- const rateRow = (label, key) => flat
1319
- ? [
1320
- h('div', { key: label + '-l', className: 'dcm-td' }, label),
1321
- h('div', { key: label + '-v', className: 'dcm-cost' }, rate(active[key])),
1322
- ]
1323
- : [
1324
- h('div', { key: label + '-l', className: 'dcm-td' }, label),
1325
- h('div', { key: label + '-lo', style: cheapNow ? activeCell : undefined, className: cheapNow ? '' : 'dcm-cell-idle' }, rate(low[key])),
1326
- h('div', { key: label + '-hi', style: cheapNow ? undefined : activeCell, className: cheapNow ? 'dcm-cell-idle' : '' }, rate(high[key])),
1327
- ]
1328
-
1329
- const soleSlots = perModel.length === 1 ? used[0].slots : null
1330
- const spentRow = (label, bucketField, rateField) => {
1331
- const n = Number(usage && usage[bucketField]) || 0
1332
- let usd = 0
1333
- if (soleSlots) {
1334
- for (const key of Object.keys(soleSlots)) {
1335
- const rates = ratesAt(schedule, (Number(key) * 30 + 15) % 1440)
1336
- usd += ((Number(soleSlots[key][bucketField]) || 0) / 1e6) * (rates[rateField] || 0)
1337
- }
1338
- } else {
1339
- usd = (n / 1e6) * (active[rateField] || 0)
1340
- }
1341
- return [
1342
- h('div', { key: label + '-l', className: 'dcm-td' }, label),
1343
- h('div', { key: label + '-n', className: 'dcm-num' }, tokens(n)),
1344
- h('div', { key: label + '-v', className: 'dcm-cost' }, money(usd * fx, currency)),
1345
- ]
1346
- }
1347
-
1348
- const showCacheWrite = Number(usage && usage.cacheWriteTokens) > 0
1349
- const zone = state.displayTimeZone || 'Europe/Moscow'
1350
- const zl = zoneLabel(zone)
1351
- const windowText = (schedule.windows || [])
1352
- .map((w) => hhmmInZone(w.start, zone, now) + '–' + hhmmInZone(w.end, zone, now))
1353
- .join(', ')
1354
- const nowLocal = hhmmInZone(minute, zone, now)
1355
-
1356
- // Calculate cache savings amount across all used models (#92)
1357
- let cacheSavedAmount = 0
1358
- let cacheSavedPercent = 0
1359
- if (hasUsage) {
1360
- if (perModel.length > 0) {
1361
- let totalSavedUsd = 0
1362
- let totalMissedUsd = 0
1363
- for (const m of perModel) {
1364
- const own = schedules[m.key]
1365
- const sched = own && own.schedule ? own.schedule : schedule
1366
- if (sched) {
1367
- const res = cacheSavingsBySlots(m.slots || (m.usage ? { 0: m.usage } : {}), sched)
1368
- totalSavedUsd += res.savedUsd
1369
- totalMissedUsd += res.missedUsd
1370
- }
1371
- }
1372
- if (totalSavedUsd > 0) {
1373
- cacheSavedAmount = totalSavedUsd * fx
1374
- cacheSavedPercent = totalMissedUsd > 0 ? Math.round((totalSavedUsd / totalMissedUsd) * 100) : 0
1375
- }
1376
- } else if (usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
1377
- const tokensRead = Number(usage.cacheReadTokens)
1378
- const missedCost = (tokensRead / 1e6) * active.input
1379
- const hitCost = (tokensRead / 1e6) * active.cacheHit
1380
- cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
1381
- cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
1382
- }
1383
- }
1384
-
1385
-
1386
- // Copy summary report to clipboard
1387
- const copyReport = () => {
1388
- const lines = [
1389
- '=== ' + (state.route ? state.route.model : 'Cost Meter Report') + ' ===',
1390
- t('tokens.session_total') + ' ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
1391
- 'Tier: ' + (flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak'))),
1392
- 'Rates (1M): input ' + rate(active.input) + ', output ' + rate(active.output) + ', cache ' + rate(active.cacheHit),
1393
- ]
1394
- if (cacheSavedAmount > 0) {
1395
- lines.push('Cache saved: ≈ ' + money(cacheSavedAmount, currency) + ' (' + cacheSavedPercent + '%)')
1396
- }
1397
- if (hasUsage && usage) {
1398
- lines.push('Tokens: in ' + tokens(usage.inputTokens) + ', out ' + tokens(usage.outputTokens) + ', cache hit ' + tokens(usage.cacheReadTokens))
1399
- }
1400
- const text = lines.join('\n')
1401
- if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
1402
- navigator.clipboard.writeText(text).then(() => {
1403
- setCopied(true)
1404
- setTimeout(() => setCopied(false), 2000)
1405
- }).catch(() => {})
1406
- } else {
1407
- setCopied(true)
1408
- setTimeout(() => setCopied(false), 2000)
1409
- }
1410
- }
1411
-
1412
- // Calculate upcoming discount hint
1413
- let discountHint = null
1414
- if (!flat && !cheapNow && leftMinutes) {
1415
- const nextTimeMinutes = (minute + leftMinutes) % DAY
1416
- const clockTime = hhmmInZone(nextTimeMinutes, zone, now)
1417
- discountHint = t('savings.hint', {
1418
- time: left,
1419
- clock: clockTime,
1420
- zone: zl,
1421
- })
1422
- }
1423
-
1424
- // Display models with collapse support if > 3 models
1425
- const visibleModels = (perModel.length
1426
- ? perModel
1427
- : (state.route ? [{ key: state.route.provider + '/' + state.route.model, model: state.route.model, known: Boolean(schedule), cost: 0 }] : [])
1428
- )
1429
- const shouldCollapse = visibleModels.length > 3 && !showAllModels
1430
- const renderedModels = shouldCollapse ? visibleModels.slice(0, 3) : visibleModels
1431
-
1432
- return h('div', { ref: box, style: { position: 'relative' } }, [
1433
- h('button', {
1434
- key: 'chip', type: 'button',
1435
- className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : ''),
1436
- onClick: () => setOpen((v) => !v),
1437
- title: (budgetExceeded
1438
- ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1439
- : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))) +
1440
- (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }) : ''),
1441
-
1442
- }, [
1443
- h('span', { key: 'dot', className: dotClass }),
1444
- h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1445
- left ? h('span', { key: 'left', className: 'dcm-chip-timer' }, left) : null,
1446
- ]),
1447
-
1448
- open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1449
- h('div', { key: 'title', className: 'dcm-panel-title' }, [
1450
- h('span', { key: 'model-name' }, state.route ? state.route.model : 'model'),
1451
- flat ? null : h('span', { key: 'tier', style: { fontSize: 11.5, opacity: 0.85 } }, cheapNow ? t('offpeak') : t('peak')),
1452
- ]),
1453
-
1454
- h('div', { key: 'note', className: 'dcm-panel-note' },
1455
- flat
1456
- ? 'Flat rate schedule'
1457
- : (cheapNow ? 'Discounted rate active' : 'Full rate active') +
1458
- (left ? ' · change in ' + left : '')),
1459
-
1460
- flat
1461
- ? null
1462
- : h('div', { key: 'clock', className: 'dcm-panel-note' },
1463
- t('peak') + ': ' + windowText + ' ' + zl + ' · now ' + nowLocal + ' ' + zl),
1464
-
1465
- discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
1466
-
1467
- cacheSavedAmount > 0
1468
- ? h('div', { key: 'cache-saved-box', className: 'dcm-cache-box', style: { marginTop: 6 } },
1469
- t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }))
1470
- : null,
1471
-
1472
- budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1473
- t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
1474
-
1475
- renderSparkline(turns, {
1476
- h,
1477
- currency,
1478
- fx,
1479
- activeRate: active,
1480
- t,
1481
- }),
1482
-
1483
- (() => {
1484
- const proj = calculateBurnRateAndProjection(turns, total / fx)
1485
- const burnRate = proj.burnRatePerHourUsd * fx
1486
- const projectedTotal = proj.projectedCostUsd * fx
1487
- if (burnRate <= 0) return null
1488
- return h('div', { key: 'proj-box', className: 'dcm-proj-box' }, [
1489
- h('span', { key: 'burn', className: 'dcm-proj-item' }, [
1490
- h('span', null, t('projection.burn_rate') + ' '),
1491
- h('span', { className: 'dcm-proj-val' }, money(burnRate, currency) + t('projection.per_hour')),
1492
- ]),
1493
- h('span', { key: 'proj', className: 'dcm-proj-item' }, [
1494
- h('span', null, t('projection.projected') + ' '),
1495
- h('span', { className: 'dcm-proj-val' }, '≈ ' + money(projectedTotal, currency)),
1496
- ]),
1497
- ])
1498
- })(),
1499
-
1500
- h('hr', { key: 'hr1', className: 'dcm-hr' }),
1501
-
1502
- h('div', { key: 'rates', className: flat ? 'dcm-panel-grid-flat' : 'dcm-panel-grid' }, [
1503
- h('div', { key: 'h0', className: 'dcm-th' }, t('rate.per_1m')),
1504
- flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, t('offpeak')),
1505
- h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? 'rate' : t('peak')),
1506
- rateRow(t('rate.cache_hit'), 'cacheHit'),
1507
- rateRow(t('rate.input'), 'input'),
1508
- showCacheWrite ? rateRow(t('rate.cache_write'), 'cacheWrite') : null,
1509
- rateRow(t('rate.output'), 'output'),
1510
- ]),
1511
-
1512
- h('hr', { key: 'hr2', className: 'dcm-hr' }),
1513
-
1514
- multiModel
1515
- ? null
1516
- : [
1517
- h('div', { key: 'btitle', className: 'dcm-section-lbl' }, t('tokens.session')),
1518
- h('div', { key: 'breakdown', className: 'dcm-panel-grid-3' }, [
1519
- h('div', { key: 'b0', className: 'dcm-th' }, t('tokens.tokens_col')),
1520
- h('div', { key: 'b1', className: 'dcm-th-num' }, ''),
1521
- h('div', { key: 'b2', className: 'dcm-th-num' }, currency),
1522
- spentRow(t('rate.cache_hit'), 'cacheReadTokens', 'cacheHit'),
1523
- spentRow(t('rate.input'), 'uncachedInputTokens', 'input'),
1524
- showCacheWrite ? spentRow(t('rate.cache_write'), 'cacheWriteTokens', 'cacheWrite') : null,
1525
- spentRow(t('rate.output'), 'outputTokens', 'output'),
1526
- ]),
1527
- h('hr', { key: 'hr-models', className: 'dcm-hr' }),
1528
- ],
1529
-
1530
- h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, t('tokens.models_spent')),
1531
- h(
1532
- 'div',
1533
- { key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
1534
- renderedModels.map((m) =>
1535
- h(
1536
- 'div',
1537
- {
1538
- key: m.key,
1539
- style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '2px 0' },
1540
- },
1541
- [
1542
- h(
1543
- 'span',
1544
- {
1545
- key: 'name',
1546
- title: m.key,
1547
- style: {
1548
- overflow: 'hidden',
1549
- textOverflow: 'ellipsis',
1550
- whiteSpace: 'nowrap',
1551
- maxWidth: m.known ? 200 : 130,
1552
- },
1553
- },
1554
- m.model,
1555
- ),
1556
- h(
1557
- 'span',
1558
- {
1559
- key: 'val',
1560
- style: {
1561
- textAlign: 'right',
1562
- fontVariantNumeric: 'tabular-nums',
1563
- whiteSpace: 'nowrap',
1564
- color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
1565
- },
1566
- },
1567
- m.known ? (hasUsage ? money(m.cost, currency) : '—') : t('tokens.not_priced'),
1568
- ),
1569
- ],
1570
- ),
1571
- ),
1572
- ),
1573
-
1574
- visibleModels.length > 3
1575
- ? h('button', {
1576
- key: 'toggle-models',
1577
- type: 'button',
1578
- className: 'dcm-accordion-btn',
1579
- onClick: () => setShowAllModels((v) => !v),
1580
- }, shouldCollapse ? t('models.show_more', { count: visibleModels.length }) : t('models.show_less'))
1581
- : null,
1582
-
1583
- h('hr', { key: 'hr3', className: 'dcm-hr' }),
1584
-
1585
- h('div', { key: 'total', className: 'dcm-total' }, [
1586
- h('span', { key: 'l' }, t('tokens.session_total')),
1587
- h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1588
- ]),
1589
-
1590
- hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
1591
- t('tokens.no_usage_yet')),
1592
-
1593
- h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
1594
- h('button', {
1595
- type: 'button',
1596
- className: 'dcm-btn dcm-btn-sm',
1597
- onClick: copyReport,
1598
- }, copied ? t('action.copied') : t('action.copy_report'))
1599
- ),
1600
- ]) : null,
1601
- ])
1602
- }
1603
-
1604
- function apply(ctx) {
1605
- const registerDictionaries = () => {
1606
- try {
1607
- // ctx.locale.register(NS, { en, zh }) is canonical form in DSH
1608
- return ctx.locale.register(NS, { en, zh })
1609
- } catch (err) {
1610
- if (ctx?.logger?.warn) {
1611
- ctx.logger.warn(`[dsh-cost-meter] locale register failed: ${err?.message || err}`)
1612
- }
1613
- return () => {}
1614
- }
1615
- }
1616
-
1617
- if (ctx.locale && typeof ctx.locale.register === 'function') {
1618
- if (typeof ctx.effect === 'function') {
1619
- ctx.effect(() => {
1620
- const undo = registerDictionaries()
1621
- return () => {
1622
- try {
1623
- if (typeof undo === 'function') undo()
1624
- } catch (e) {
1625
- if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] locale undo error: ' + (e?.message || e))
1626
- }
1627
- }
1628
- }, 'dsh-cost-meter: locale dictionaries')
1629
- } else {
1630
- registerDictionaries()
1631
- }
1632
- }
1633
-
1634
- if (typeof ctx.effect === 'function') {
1635
- ctx.effect(
1636
- () => refreshMirrorUntilVisible(ctx),
1637
- 'dsh-cost-meter: refresh settings mirror',
1638
- )
1639
- } else {
1640
- refreshMirrorUntilVisible(ctx)
1641
- }
1642
-
1643
- if (ctx.slots && typeof ctx.slots.inject === 'function') {
1644
- ctx.slots.inject('conversation.session.header.utilities', () =>
1645
- ctx.slots.register(
1646
- { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
1647
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeter, { ...p, ctx: (p && p.ctx) || ctx })),
1648
- ),
1649
- )
1650
-
1651
- // List seat (plugins.item): the seat the Plugins page renders as the plugin's
1652
- // own page with its configuration. The label is a static string on purpose —
1653
- // it is resolved while the page renders, and a locale lookup there would take
1654
- // the whole client batch down with it.
1655
- ctx.slots.inject('plugins.item', () =>
1656
- ctx.slots.register(
1657
- { name: 'plugins.item', id: ROW_ID, order: 20, label: () => 'Cost Meter', locale: NS, inject: () => ({ ctx }) },
1658
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
1659
- ),
1660
- )
1661
-
1662
- // Row seat and the legacy seat kept as fallbacks.
1663
- ctx.slots.inject('plugins.row.config', () =>
1664
- ctx.slots.register(
1665
- { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, order: 20, inject: () => ({ ctx }) },
1666
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
1667
- ),
1668
- )
1669
-
1670
- ctx.slots.inject('settings.plugin.item', () =>
1671
- ctx.slots.register(
1672
- { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
1673
- (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
1674
- ),
1675
- )
1676
- }
1677
- }
1678
-
1679
- module.exports = {
1680
- apply,
1681
- inject: ['slots', 'locale', 'configForms'],
1682
- CostMeterCard,
1683
- CostMeter,
1684
- ErrorBoundary,
1685
- ensureStyles,
1686
- minutesUntilChange,
1687
- extremes,
1688
- ratesAt,
1689
- spendBySlots,
1690
- cacheSavingsBySlots,
1691
- fetchWithTimeout,
1692
- }
1693
-
1694
- return module.exports
1695
- },
1696
- })
1697
-
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
+