@goodandready/dsh-cost-meter 0.8.0 → 0.8.2

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