@goodandready/dsh-cost-meter 0.8.1 → 0.8.3

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',
@@ -12,71 +13,23 @@ window.__ModuleLoader__.load({
12
13
  const NS = 'dsh-cost-meter'
13
14
  const STATE_PATH = '/dsh-cost-meter/state'
14
15
  const REFRESH_PATH = '/dsh-cost-meter/refresh'
16
+ const UPDATE_PATH = '/dsh-cost-meter/update'
15
17
  const DAY = 1440
16
18
 
17
19
  // ----------------------------------------------------------- localization
18
20
 
19
- const ru = {
20
- title: 'Учёт расходов',
21
- subtitle: 'Тариф моделей, окна пиковых цен и стоимость сессии',
22
- 'header.title': 'Учёт расходов (Cost Meter)',
23
- 'header.sub': 'Мониторинг расходов токенов, автоматическое сопоставление тарифов и скидочных окон',
24
- 'badge.catalog_ok': 'Каталог: {count} моделей',
25
- 'badge.catalog_empty': 'Каталог: загрузка…',
26
- 'badge.catalog_off': 'Каталог: выключен',
27
- 'badge.catalog_err': 'Каталог: ошибка',
28
- 'badge.catalog_stale': 'Кэш: офлайн',
29
- 'badge.deepseek': 'Тариф DeepSeek: активен',
30
- 'section.general': 'Основные параметры',
31
- 'section.general_desc': 'Отображение валюты, курса пересчёта, лимита бюджета и часового пояса',
32
- 'section.openrouter': 'Каталог цен OpenRouter',
33
- 'section.openrouter_desc': 'Публичный каталог моделей для автоматического определения ставок и окон скидок',
34
- 'section.diagnostics': 'Диагностика и статус',
35
- 'section.diagnostics_desc': 'Текущее состояние фоновой синхронизации и кэша тарифов',
36
- 'diag.models_count': 'Моделей в каталоге',
37
- 'diag.last_sync': 'Последняя синхронизация',
38
- 'diag.route': 'Активный маршрут',
39
- 'diag.tariff_source': 'Источник тарифа',
40
- 'field.currency': 'Символ валюты',
41
- 'hint.currency': 'Символ валюты для отображения расходов (например, $, ₽, €).',
42
- 'field.usdRate': 'Курс валюты к USD',
43
- 'hint.usdRate': 'Количество единиц вашей валюты за 1 USD (1 для долларов).',
44
- 'field.budgetThreshold': 'Порог бюджета сессии (USD)',
45
- 'hint.budgetThreshold': 'Оповещать цветом чипа при превышении суммы за сессию (0 — выключено).',
46
- 'field.displayTimeZone': 'Временная зона (IANA)',
47
- 'hint.displayTimeZone': 'Временная зона для отображения пиковых окон (например, Europe/Moscow, UTC).',
48
- 'field.useOpenRouter': 'Использовать каталог OpenRouter',
49
- 'hint.useOpenRouter': 'Автоматически загружать ставки и скидочные окна для моделей через OpenRouter API.',
50
- 'field.refreshHours': 'Период обновления каталога (часов)',
51
- 'hint.refreshHours': 'Интервал фонового обновления каталога в часах (от 1 до 168).',
52
- 'action.sync_now': 'Синхронизировать сейчас',
53
- 'action.syncing': 'Синхронизация…',
54
- 'action.sync_ok': 'Каталог обновлён',
55
- 'action.sync_err': 'Ошибка синхронизации',
56
- 'action.sync_rate_limited': 'Подождите {seconds} сек',
57
- 'action.copy_report': 'Скопировать сводку',
58
- 'action.copied': 'Скопировано!',
59
- 'savings.hint': '💡 Скидка 50% начнётся через {time} ({clock} {zone})',
60
- 'budget.exceeded': '⚠️ Превышен лимит сессии: {limit}',
61
- 'settings.save': 'Сохранить настройки',
62
- 'settings.saving': 'Сохранение…',
63
- 'settings.saved': 'Настройки успешно сохранены',
64
- 'settings.saveFailed': 'Ошибка сохранения: ',
65
- 'settings.loading': 'Загрузка настроек…',
66
- 'settings.unavailable': 'Настройки временно недоступны',
67
- 'settings.retry': 'Повторить попытку',
68
- chipUnknown: 'Тариф не определён',
69
- routeUnknown: 'Маршрут не определён',
70
- catalogUnavailable: 'Каталог недоступен: ',
71
- peak: 'пик',
72
- offpeak: 'не-пик',
73
- }
74
-
75
21
  const en = {
76
22
  title: 'Cost Meter',
77
23
  subtitle: 'Model rates, peak discount windows and session spend',
78
24
  'header.title': 'Cost Meter',
79
25
  'header.sub': 'Token spend monitoring, automatic tariff resolution and discount windows',
26
+ 'update.checking': 'Checking for updates…',
27
+ 'update.up_to_date': 'Up to date',
28
+ 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
29
+ 'update.btn': 'Update Now',
30
+ 'update.updating': 'Updating…',
31
+ 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
32
+ 'update.failed': 'Update failed: {error}',
80
33
  'badge.catalog_ok': 'Catalog: {count} models',
81
34
  'badge.catalog_empty': 'Catalog: loading…',
82
35
  'badge.catalog_off': 'Catalog: disabled',
@@ -94,13 +47,13 @@ window.__ModuleLoader__.load({
94
47
  'diag.route': 'Active route',
95
48
  'diag.tariff_source': 'Tariff source',
96
49
  'field.currency': 'Currency Symbol',
97
- 'hint.currency': 'Currency symbol shown in the UI (e.g. $, ₽, €).',
50
+ 'hint.currency': 'Currency symbol shown in the UI (e.g. $, ¥, €).',
98
51
  'field.usdRate': 'USD Exchange Rate',
99
52
  'hint.usdRate': 'Units of currency per 1 USD (1 keeps USD).',
100
53
  'field.budgetThreshold': 'Session Budget Threshold (USD)',
101
54
  'hint.budgetThreshold': 'Warn via chip color when session spend exceeds this amount (0 disables).',
102
55
  'field.displayTimeZone': 'Time Zone (IANA)',
103
- 'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, UTC).',
56
+ 'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, Asia/Shanghai, UTC).',
104
57
  'field.useOpenRouter': 'Use OpenRouter Catalog',
105
58
  'hint.useOpenRouter': 'Pull rates and discount windows dynamically from public OpenRouter API.',
106
59
  'field.refreshHours': 'Catalog Refresh Interval (hours)',
@@ -113,7 +66,10 @@ window.__ModuleLoader__.load({
113
66
  'action.copy_report': 'Copy Summary',
114
67
  'action.copied': 'Copied!',
115
68
  'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
69
+ 'savings.cache_saved': '⚡ Context cache saved: ≈ {amount} ({percent}%)',
116
70
  'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
71
+ 'models.show_more': 'Show all {count} models',
72
+ 'models.show_less': 'Show fewer',
117
73
  'settings.save': 'Save Settings',
118
74
  'settings.saving': 'Saving…',
119
75
  'settings.saved': 'Settings saved successfully',
@@ -126,6 +82,96 @@ window.__ModuleLoader__.load({
126
82
  catalogUnavailable: 'Catalog unavailable: ',
127
83
  peak: 'peak',
128
84
  offpeak: 'off-peak',
85
+ 'rate.per_1m': '1M tokens, $',
86
+ 'rate.cache_hit': 'Input (cache hit)',
87
+ 'rate.input': 'Input (cache miss)',
88
+ 'rate.cache_write': 'Input (cache write)',
89
+ 'rate.output': 'Output',
90
+ 'tokens.session': 'Session Tokens',
91
+ 'tokens.tokens_col': 'Tokens',
92
+ 'tokens.models_spent': 'Spend by Model',
93
+ 'tokens.session_total': 'Session total:',
94
+ 'tokens.no_usage_yet': 'Provider has not reported usage for this session yet.',
95
+ 'tokens.not_priced': 'Not priced',
96
+ 'tz.invalid': 'Invalid IANA time zone identifier',
97
+ }
98
+
99
+ const zh = {
100
+ title: '成本计量器',
101
+ subtitle: '模型费率、高峰优惠窗口与会话消费统计',
102
+ 'header.title': '成本计量器 (Cost Meter)',
103
+ 'header.sub': 'Token 消费监控、费率自动匹配与折扣窗口管理',
104
+ 'update.checking': '正在检查更新…',
105
+ 'update.up_to_date': '已是最新版本',
106
+ 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
107
+ 'update.btn': '立即更新',
108
+ 'update.updating': '正在更新…',
109
+ 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
110
+ 'update.failed': '更新失败:{error}',
111
+ 'badge.catalog_ok': '目录:{count} 个模型',
112
+ 'badge.catalog_empty': '目录:加载中…',
113
+ 'badge.catalog_off': '目录:已禁用',
114
+ 'badge.catalog_err': '目录:错误',
115
+ 'badge.catalog_stale': '缓存:离线',
116
+ 'badge.deepseek': 'DeepSeek 费率:生效中',
117
+ 'section.general': '基础参数',
118
+ 'section.general_desc': '显示货币、汇率转换、预算阈值与时区设置',
119
+ 'section.openrouter': 'OpenRouter 定价目录',
120
+ 'section.openrouter_desc': '用于自动获取模型价格与折扣窗口的公开价格目录',
121
+ 'section.diagnostics': '诊断与状态',
122
+ 'section.diagnostics_desc': '后台同步与费率缓存当前状态',
123
+ 'diag.models_count': '目录中模型数',
124
+ 'diag.last_sync': '上次同步',
125
+ 'diag.route': '当前路由',
126
+ 'diag.tariff_source': '费率来源',
127
+ 'field.currency': '货币符号',
128
+ 'hint.currency': '界面中显示的货币符号(例如:$、¥、€)。',
129
+ 'field.usdRate': 'USD 汇率',
130
+ 'hint.usdRate': '每 1 美元对应的本币金额(1 为美元)。',
131
+ 'field.budgetThreshold': '会话预算阈值 (USD)',
132
+ 'hint.budgetThreshold': '当会话消费超出该金额时在状态指示器报警(0 表示禁用)。',
133
+ 'field.displayTimeZone': '时区 (IANA)',
134
+ 'hint.displayTimeZone': '显示高峰时段的 IANA 时区(例如:Asia/Shanghai、UTC)。',
135
+ 'field.useOpenRouter': '使用 OpenRouter 目录',
136
+ 'hint.useOpenRouter': '通过公开的 OpenRouter API 自动拉取费率与折扣窗口。',
137
+ 'field.refreshHours': '目录刷新周期 (小时)',
138
+ 'hint.refreshHours': '后台刷新价格目录的时间间隔(1 至 168 小时)。',
139
+ 'action.sync_now': '立即同步',
140
+ 'action.syncing': '同步中…',
141
+ 'action.sync_ok': '目录已更新',
142
+ 'action.sync_err': '同步失败',
143
+ 'action.sync_rate_limited': '请等待 {seconds} 秒',
144
+ 'action.copy_report': '复制消费报告',
145
+ 'action.copied': '已复制!',
146
+ 'savings.hint': '💡 5 折优惠将在 {time} 后生效 ({clock} {zone})',
147
+ 'savings.cache_saved': '⚡ 上下文缓存节省:≈ {amount} ({percent}%)',
148
+ 'budget.exceeded': '⚠️ 已超出预算上限:{limit}',
149
+ 'models.show_more': '查看全部 {count} 个模型',
150
+ 'models.show_less': '收起',
151
+ 'settings.save': '保存设置',
152
+ 'settings.saving': '保存中…',
153
+ 'settings.saved': '设置已成功保存',
154
+ 'settings.saveFailed': '保存设置失败:',
155
+ 'settings.loading': '加载设置中…',
156
+ 'settings.unavailable': '设置暂时不可用',
157
+ 'settings.retry': '重试',
158
+ chipUnknown: '费率未知',
159
+ routeUnknown: '路由未知',
160
+ catalogUnavailable: '价格目录不可用:',
161
+ peak: '高峰',
162
+ offpeak: '低谷',
163
+ 'rate.per_1m': '每 1M tokens ($)',
164
+ 'rate.cache_hit': '输入 (命中缓存)',
165
+ 'rate.input': '输入 (未命中)',
166
+ 'rate.cache_write': '输入 (写入缓存)',
167
+ 'rate.output': '输出',
168
+ 'tokens.session': '会话 Tokens',
169
+ 'tokens.tokens_col': 'Tokens 数',
170
+ 'tokens.models_spent': '各模型消费',
171
+ 'tokens.session_total': '会话总计:',
172
+ 'tokens.no_usage_yet': '提供商尚未报告当前会话的用量数据。',
173
+ 'tokens.not_priced': '未定价',
174
+ 'tz.invalid': '无效的 IANA 时区标识',
129
175
  }
130
176
 
131
177
  function makeT(dict, fallback) {
@@ -144,8 +190,12 @@ window.__ModuleLoader__.load({
144
190
  const localeService = (ctx && typeof ctx.get === 'function' ? ctx.get('locale') : null) || (ctx && ctx.locale) || (ctx && ctx.services && ctx.services.locale)
145
191
  return React.useSyncExternalStore(
146
192
  React.useMemo(() => (cb) => (localeService && typeof localeService.subscribe === 'function' ? localeService.subscribe(cb) : () => {}), [localeService]),
147
- React.useCallback(() => (localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot().active : 'ru'), [localeService]),
148
- React.useCallback(() => 'ru', []),
193
+ React.useCallback(() => {
194
+ const snap = localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot() : null
195
+ const active = snap ? (snap.active || snap.locale || 'en') : 'en'
196
+ return String(active).startsWith('zh') ? 'zh' : 'en'
197
+ }, [localeService]),
198
+ React.useCallback(() => 'en', []),
149
199
  )
150
200
  }
151
201
 
@@ -165,19 +215,22 @@ window.__ModuleLoader__.load({
165
215
  .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
166
216
  .dcm-field { display: flex; flex-direction: column; gap: 6px; }
167
217
  .dcm-row-check { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
168
- .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary, #6366f1); cursor: pointer; }
218
+ .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary); cursor: pointer; }
169
219
  .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
170
220
  .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
171
221
 
172
222
  .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; }
173
- .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary, #6366f1); }
174
- .dcm-input-err { border-color: var(--dsw-alias-state-error-primary, #ef4444); }
223
+ .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary); }
224
+ .dcm-input-err { border-color: var(--dsw-alias-state-error-primary); }
225
+
226
+ .dcm-chevron { display: inline-flex; align-items: center; justify-content: center; transition: transform .16s ease; color: var(--dsw-alias-label-tertiary); }
227
+ .dcm-chevron-open { transform: rotate(180deg); }
175
228
 
176
229
  .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
177
230
  .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; }
178
- .dcm-badge-ok { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: rgba(16,185,129,0.08); }
179
- .dcm-badge-warn { border-color: var(--dsw-alias-state-warning-primary); color: var(--dsw-alias-state-warning-primary); background: rgba(245,158,11,0.08); }
180
- .dcm-badge-bad { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); background: rgba(239,68,68,0.08); }
231
+ .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); }
232
+ .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); }
233
+ .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); }
181
234
 
182
235
  .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; }
183
236
  .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
@@ -187,9 +240,10 @@ window.__ModuleLoader__.load({
187
240
  .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
188
241
 
189
242
  .dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
190
- .dcm-alert-ok { background: rgba(16,185,129,0.1); color: var(--dsw-alias-state-success-primary); }
191
- .dcm-alert-err { background: rgba(239,68,68,0.1); color: var(--dsw-alias-state-error-primary); }
192
- .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; }
243
+ .dcm-alert-ok { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
244
+ .dcm-alert-err { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
245
+ .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; }
246
+ .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; }
193
247
 
194
248
  .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; }
195
249
  .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
@@ -197,26 +251,39 @@ window.__ModuleLoader__.load({
197
251
 
198
252
  .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
199
253
 
200
- .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; }
201
- .dcm-chip:hover { opacity: 0.88; }
202
- .dcm-chip-budget-warn { animation: dcm-pulse 2s infinite ease-in-out; }
203
- @keyframes dcm-pulse { 0%, 100% { border-color: rgba(239,68,68,0.4); } 50% { border-color: rgba(239,68,68,0.9); } }
254
+ /* Native dot-indicator chip */
255
+ .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; }
256
+ .dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3); }
257
+ .dcm-dot { width: 6.5px; height: 6.5px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
258
+ .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); }
259
+ .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); }
260
+ .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; }
261
+ .dcm-dot-idle { background: var(--dsw-alias-label-tertiary); }
262
+
263
+ .dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
264
+ @media (max-width: 680px) {
265
+ .dcm-chip-timer { display: none; }
266
+ }
204
267
 
205
- .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; }
206
- .dcm-panel-title { font-weight: 600; padding-bottom: 4px; font-size: 13px; }
268
+ @keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
269
+
270
+ .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; }
271
+ .dcm-panel-title { font-weight: 600; padding-bottom: 2px; font-size: 13.5px; display: flex; justify-content: space-between; align-items: baseline; }
207
272
  .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
208
273
  .dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
209
- .dcm-panel-grid { display: grid; grid-template-columns: 1fr 60px 60px; column-gap: 6px; align-items: center; }
210
- .dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr 72px; column-gap: 6px; align-items: center; }
211
- .dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr 58px 72px; column-gap: 6px; align-items: center; }
274
+ .dcm-panel-grid { display: grid; grid-template-columns: 1fr 62px 62px; column-gap: 6px; align-items: center; }
275
+ .dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr 76px; column-gap: 6px; align-items: center; }
276
+ .dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr 60px 74px; column-gap: 6px; align-items: center; }
212
277
  .dcm-th { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; }
213
278
  .dcm-th-num { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; text-align: right; font-size: 11px; }
214
- .dcm-td { padding: 3px 0; }
279
+ .dcm-td { padding: 3.5px 0; color: var(--dsw-alias-label-secondary); }
215
280
  .dcm-cell-idle { text-align: right; padding: 3px 6px; border-radius: 4px; color: var(--dsw-alias-label-tertiary); }
216
- .dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding: 3px 0; }
217
- .dcm-cost { text-align: right; padding: 3px 0; }
281
+ .dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding: 3.5px 0; }
282
+ .dcm-cost { text-align: right; padding: 3.5px 0; font-weight: 500; color: var(--dsw-alias-label-primary); }
218
283
  .dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
219
- .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13px; }
284
+ .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
285
+ .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; }
286
+ .dcm-accordion-btn:hover { text-decoration: underline; }
220
287
  `
221
288
 
222
289
  function ensureStyles() {
@@ -269,51 +336,83 @@ window.__ModuleLoader__.load({
269
336
  }
270
337
  const ErrorBoundary = createErrorBoundary()
271
338
 
339
+ function getSettingsScope(ctx) {
340
+ return (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope) || null
341
+ }
342
+
272
343
  function refreshMirrorUntilVisible(ctx) {
344
+ const s = getSettingsScope(ctx)
273
345
  const visible = () => {
274
346
  try {
275
- const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
276
347
  const view = s?.describe?.()?.getSnapshot?.()?.view
277
348
  return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
278
- } catch (_) {
349
+ } catch (e) {
350
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] mirror check error: ' + (e?.message || e))
279
351
  return false
280
352
  }
281
353
  }
282
- if (visible()) return () => {}
354
+ if (!s || visible()) return () => {}
283
355
  let tries = 0
284
356
  const timer = setInterval(() => {
285
- if (visible() || tries >= 15) { clearInterval(timer); return }
357
+ if (visible() || tries >= 15) {
358
+ if (!visible() && tries >= 15 && ctx?.logger?.warn) {
359
+ ctx.logger.warn('[dsh-cost-meter] settings mirror did not appear after 15 attempts')
360
+ }
361
+ clearInterval(timer)
362
+ return
363
+ }
286
364
  tries += 1
287
365
  try {
288
- const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
289
366
  s?.describe?.()?.load?.()
290
- } catch (_) {}
367
+ } catch (e) {
368
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] settings load error: ' + (e?.message || e))
369
+ }
291
370
  }, 1000)
292
371
  return () => clearInterval(timer)
293
372
  }
294
373
 
295
- function Chevron(props) {
374
+ let ChevronIcon = null
375
+ try {
376
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
377
+ ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
378
+ } catch (e) {
379
+ ChevronIcon = null
380
+ }
381
+
382
+ function FallbackChevron(props) {
296
383
  return React.createElement(
297
384
  'svg',
298
385
  {
299
- width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
300
- style: {
301
- marginLeft: 'auto',
302
- transform: props.open ? 'rotate(180deg)' : 'none',
303
- transition: 'transform .18s ease',
304
- color: 'var(--dsw-alias-label-secondary)',
305
- },
386
+ width: 14,
387
+ height: 14,
388
+ viewBox: '0 0 14 14',
389
+ fill: 'none',
390
+ 'aria-hidden': true,
306
391
  },
307
392
  React.createElement('path', {
308
- d: 'M4 6L8 10L12 6',
393
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
309
394
  stroke: 'currentColor',
310
- strokeWidth: 1.6,
395
+ strokeWidth: 1.5,
311
396
  strokeLinecap: 'round',
312
397
  strokeLinejoin: 'round',
313
398
  }),
314
399
  )
315
400
  }
316
401
 
402
+ function Chevron(props) {
403
+ const icon = ChevronIcon
404
+ ? React.createElement(ChevronIcon, { 'aria-hidden': true })
405
+ : React.createElement(FallbackChevron, null)
406
+ return React.createElement(
407
+ 'span',
408
+ {
409
+ className: 'dcm-chevron' + (props.open ? ' dcm-chevron-open' : ''),
410
+ style: { marginLeft: 'auto' },
411
+ },
412
+ icon,
413
+ )
414
+ }
415
+
317
416
  function isValidTimeZone(tz) {
318
417
  if (!tz || typeof tz !== 'string') return false
319
418
  try {
@@ -324,13 +423,14 @@ window.__ModuleLoader__.load({
324
423
  }
325
424
  }
326
425
 
327
- function ago(timestamp) {
328
- if (!timestamp) return 'нет данных'
426
+ function ago(timestamp, locale) {
427
+ if (!timestamp) return '—'
428
+ const isZh = locale === 'zh'
329
429
  const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
330
- if (minutes < 1) return 'только что'
331
- if (minutes < 60) return minutes + ' мин назад'
430
+ if (minutes < 1) return isZh ? '刚刚' : 'just now'
431
+ if (minutes < 60) return minutes + (isZh ? ' 分钟前' : ' min ago')
332
432
  const hours = Math.round(minutes / 60)
333
- return hours < 48 ? hours + ' ч назад' : Math.round(hours / 24) + ' дн назад'
433
+ return hours < 48 ? hours + (isZh ? ' 小时前' : ' hrs ago') : Math.round(hours / 24) + (isZh ? ' 天前' : ' days ago')
334
434
  }
335
435
 
336
436
  // ---------------------------------------------------------------- card
@@ -338,7 +438,7 @@ window.__ModuleLoader__.load({
338
438
  function CostMeterCard(props) {
339
439
  const ctx = props.ctx
340
440
  const locale = useActiveLocale(ctx)
341
- const t = props.t || makeT(locale === 'ru' ? ru : en, en)
441
+ const t = props.t || makeT(locale === 'zh' ? zh : en, en)
342
442
  const [open, setOpen] = React.useState(false)
343
443
  const [draft, setDraft] = React.useState(null)
344
444
  const [saving, setSaving] = React.useState(false)
@@ -350,7 +450,72 @@ window.__ModuleLoader__.load({
350
450
  const [syncing, setSyncing] = React.useState(false)
351
451
  const [syncMsg, setSyncMsg] = React.useState('')
352
452
 
353
- const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
453
+ // Plugin in-app updater state
454
+ const [updateState, setUpdateState] = React.useState({
455
+ checking: false,
456
+ updating: false,
457
+ currentVersion: '0.8.3',
458
+ latestVersion: '',
459
+ updateAvailable: false,
460
+ canAutoUpdate: true,
461
+ error: '',
462
+ notice: '',
463
+ })
464
+
465
+ const checkUpdate = React.useCallback(async () => {
466
+ setUpdateState((s) => ({ ...s, checking: true, error: '' }))
467
+ try {
468
+ const res = await fetch(UPDATE_PATH)
469
+ if (!res.ok) throw new Error('HTTP ' + res.status)
470
+ const data = await res.json().catch(() => ({}))
471
+ setUpdateState((s) => ({
472
+ ...s,
473
+ checking: false,
474
+ currentVersion: data.currentVersion || s.currentVersion,
475
+ latestVersion: data.latestVersion || '',
476
+ updateAvailable: Boolean(data.updateAvailable),
477
+ canAutoUpdate: data.canAutoUpdate !== false,
478
+ }))
479
+ } catch (e) {
480
+ setUpdateState((s) => ({ ...s, checking: false }))
481
+ }
482
+ }, [])
483
+
484
+ React.useEffect(() => {
485
+ if (open) checkUpdate()
486
+ }, [open, checkUpdate])
487
+
488
+ async function handleTriggerUpdate() {
489
+ if (updateState.updating) return
490
+ setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
491
+ try {
492
+ const res = await fetch(UPDATE_PATH, {
493
+ method: 'POST',
494
+ headers: { 'x-dsh-plugin-update': '1' },
495
+ })
496
+ const data = await res.json().catch(() => ({}))
497
+ if (!res.ok || data.ok === false || data.error) {
498
+ throw new Error(data.error || ('HTTP ' + res.status))
499
+ }
500
+ const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
501
+ setUpdateState((s) => ({
502
+ ...s,
503
+ updating: false,
504
+ updateAvailable: false,
505
+ currentVersion: newVer,
506
+ notice: t('update.done', { version: newVer }),
507
+ }))
508
+ setTimeout(() => checkUpdate(), 2000)
509
+ } catch (err) {
510
+ setUpdateState((s) => ({
511
+ ...s,
512
+ updating: false,
513
+ error: t('update.failed', { error: String(err.message || err) }),
514
+ }))
515
+ }
516
+ }
517
+
518
+ const settingsScope = getSettingsScope(ctx)
354
519
  const scope = React.useMemo(
355
520
  () => (settingsScope ? settingsScope.bind({ namespace: NS }) : undefined),
356
521
  [settingsScope],
@@ -516,6 +681,38 @@ window.__ModuleLoader__.load({
516
681
  : status !== 'ready'
517
682
  ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
518
683
  : React.createElement(React.Fragment, null,
684
+ // In-app Update Bar
685
+ React.createElement('div', {
686
+ className: 'dcm-section-card',
687
+ style: { padding: '10px 14px', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
688
+ },
689
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 } },
690
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
691
+ 'v' + updateState.currentVersion
692
+ ),
693
+ updateState.checking
694
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 } }, t('update.checking'))
695
+ : updateState.updateAvailable
696
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' },
697
+ t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
698
+ )
699
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-ok' },
700
+ '✓ ' + t('update.up_to_date')
701
+ )
702
+ ),
703
+ updateState.updateAvailable
704
+ ? React.createElement('button', {
705
+ type: 'button',
706
+ className: 'dcm-btn dcm-btn-primary',
707
+ disabled: updateState.updating,
708
+ onClick: handleTriggerUpdate,
709
+ style: { padding: '4px 10px', fontSize: 12 },
710
+ }, updateState.updating ? t('update.updating') : t('update.btn'))
711
+ : null
712
+ ),
713
+ updateState.notice ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, '✓ ' + updateState.notice) : null,
714
+ updateState.error ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, updateState.error) : null,
715
+
519
716
  // Section 1: General Parameters
520
717
  React.createElement('div', { className: 'dcm-section-card' },
521
718
  React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
@@ -563,7 +760,7 @@ window.__ModuleLoader__.load({
563
760
  onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
564
761
  }),
565
762
  React.createElement('span', { className: 'dcm-hint' },
566
- tzValid ? t('hint.displayTimeZone') : 'Некорректный идентификатор временной зоны IANA'
763
+ tzValid ? t('hint.displayTimeZone') : t('tz.invalid')
567
764
  ),
568
765
  ),
569
766
  ),
@@ -622,7 +819,7 @@ window.__ModuleLoader__.load({
622
819
  ),
623
820
  React.createElement('div', { className: 'dcm-stat-box' },
624
821
  React.createElement('span', { className: 'dcm-stat-val' },
625
- diagState ? ago(diagState.fetchedAt) : '—'
822
+ diagState ? ago(diagState.fetchedAt, locale) : '—'
626
823
  ),
627
824
  React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.last_sync')),
628
825
  ),
@@ -669,14 +866,10 @@ window.__ModuleLoader__.load({
669
866
  return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
670
867
  }
671
868
 
672
- /**
673
- * Fast O(K) lookup of next tariff rate boundary instead of a 1440-minute loop.
674
- */
675
869
  function minutesUntilChange(schedule, minute) {
676
870
  const windows = (schedule && schedule.windows) || []
677
871
  if (!windows.length) return null
678
872
  const now = ratesAt(schedule, minute)
679
- // Transition points can only occur at window start or window end
680
873
  const transitions = new Set()
681
874
  for (const w of windows) {
682
875
  transitions.add(w.start % DAY)
@@ -694,9 +887,6 @@ window.__ModuleLoader__.load({
694
887
  return minDiff
695
888
  }
696
889
 
697
- /**
698
- * Memoized calculation of low and high rate extremes for a schedule.
699
- */
700
890
  const extremesMemo = new WeakMap()
701
891
  function extremes(schedule) {
702
892
  if (!schedule) return { low: {}, high: {}, flat: true }
@@ -780,7 +970,7 @@ window.__ModuleLoader__.load({
780
970
  reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
781
971
  ))
782
972
  try {
783
- return new Intl.DateTimeFormat('ru-RU', {
973
+ return new Intl.DateTimeFormat('en-GB', {
784
974
  hour: '2-digit', minute: '2-digit', hour12: false, timeZone: zone,
785
975
  }).format(d)
786
976
  } catch {
@@ -790,7 +980,6 @@ window.__ModuleLoader__.load({
790
980
 
791
981
  function zoneLabel(zone) {
792
982
  if (!zone) return 'UTC'
793
- if (zone === 'Europe/Moscow') return 'МСК'
794
983
  const city = zone.includes('/') ? zone.slice(zone.lastIndexOf('/') + 1) : zone
795
984
  return city.replace(/_/g, ' ')
796
985
  }
@@ -800,7 +989,7 @@ window.__ModuleLoader__.load({
800
989
  function CostMeter(props) {
801
990
  const ctx = props.ctx
802
991
  const locale = useActiveLocale(ctx)
803
- const t = props.t || makeT(locale === 'ru' ? ru : en, en)
992
+ const t = props.t || makeT(locale === 'zh' ? zh : en, en)
804
993
  const useProjection = props.useProjection
805
994
  const usage = useProjection ? useProjection('tokenUsage') : undefined
806
995
  const byModel = useProjection ? useProjection('costByModel') : undefined
@@ -808,11 +997,12 @@ window.__ModuleLoader__.load({
808
997
  const [open, setOpen] = React.useState(false)
809
998
  const [now, setNow] = React.useState(() => new Date())
810
999
  const [copied, setCopied] = React.useState(false)
1000
+ const [showAllModels, setShowAllModels] = React.useState(false)
811
1001
  const box = React.useRef(null)
812
1002
 
813
1003
  React.useEffect(() => { ensureStyles() }, [])
814
1004
 
815
- const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
1005
+ const settingsScope = getSettingsScope(ctx)
816
1006
  const settingsSnap = React.useSyncExternalStore
817
1007
  ? React.useSyncExternalStore(
818
1008
  (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}),
@@ -868,16 +1058,18 @@ window.__ModuleLoader__.load({
868
1058
  h('button', {
869
1059
  key: 'chip', type: 'button',
870
1060
  className: 'dcm-chip',
871
- style: { background: 'transparent', color: 'var(--dsw-alias-label-tertiary)' },
872
1061
  onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
873
- }, 'тариф ?'),
1062
+ }, [
1063
+ h('span', { key: 'dot', className: 'dcm-dot dcm-dot-idle' }),
1064
+ h('span', { key: 'txt' }, t('chipUnknown')),
1065
+ ]),
874
1066
  open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
875
1067
  h('div', { key: 't', className: 'dcm-panel-title' },
876
1068
  state.route ? state.route.provider + ' / ' + state.route.model : t('routeUnknown')),
877
1069
  h('div', { key: 'n', className: 'dcm-panel-note' },
878
1070
  state.catalogError
879
1071
  ? t('catalogUnavailable') + state.catalogError
880
- : 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.'),
1072
+ : t('tokens.not_priced')),
881
1073
  ]) : null,
882
1074
  ])
883
1075
  }
@@ -910,13 +1102,12 @@ window.__ModuleLoader__.load({
910
1102
  const totalUsd = fx > 0 ? total / fx : total
911
1103
  const budgetExceeded = budgetThreshold > 0 && totalUsd >= budgetThreshold
912
1104
 
913
- const fg = budgetExceeded
914
- ? 'var(--dsw-alias-state-error-primary)'
915
- : (cheapNow ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warning-primary)')
916
- const bg = budgetExceeded
917
- ? 'rgba(239,68,68,0.14)'
918
- : (cheapNow ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)')
919
- const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: bg, color: fg, fontWeight: 600 }
1105
+ // Dot indicator status class
1106
+ const dotClass = budgetExceeded
1107
+ ? 'dcm-dot dcm-dot-err'
1108
+ : (flat ? 'dcm-dot dcm-dot-idle' : (cheapNow ? 'dcm-dot dcm-dot-ok' : 'dcm-dot dcm-dot-warn'))
1109
+
1110
+ 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 }
920
1111
 
921
1112
  const rateRow = (label, key) => flat
922
1113
  ? [
@@ -956,16 +1147,30 @@ window.__ModuleLoader__.load({
956
1147
  .join(', ')
957
1148
  const nowLocal = hhmmInZone(minute, zone, now)
958
1149
 
959
- // Copy textual report to clipboard
1150
+ // Calculate cache savings amount
1151
+ let cacheSavedAmount = 0
1152
+ let cacheSavedPercent = 0
1153
+ if (hasUsage && usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
1154
+ const tokensRead = Number(usage.cacheReadTokens)
1155
+ const missedCost = (tokensRead / 1e6) * active.input
1156
+ const hitCost = (tokensRead / 1e6) * active.cacheHit
1157
+ cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
1158
+ cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
1159
+ }
1160
+
1161
+ // Copy summary report to clipboard
960
1162
  const copyReport = () => {
961
1163
  const lines = [
962
1164
  '=== ' + (state.route ? state.route.model : 'Cost Meter Report') + ' ===',
963
- 'Сессия: ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (порог ' + money(budgetThreshold * fx, currency) + ')' : ''),
964
- 'Тариф: ' + (cheapNow ? 'Льготный (не-пик)' : 'Полный (пик)'),
965
- 'Ставки (1M): вход ' + rate(active.input) + ', выход ' + rate(active.output) + ', кэш ' + rate(active.cacheHit),
1165
+ t('tokens.session_total') + ' ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
1166
+ 'Tier: ' + (flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak'))),
1167
+ 'Rates (1M): input ' + rate(active.input) + ', output ' + rate(active.output) + ', cache ' + rate(active.cacheHit),
966
1168
  ]
1169
+ if (cacheSavedAmount > 0) {
1170
+ lines.push('Cache saved: ≈ ' + money(cacheSavedAmount, currency) + ' (' + cacheSavedPercent + '%)')
1171
+ }
967
1172
  if (hasUsage && usage) {
968
- lines.push('Токены: вход ' + tokens(usage.inputTokens) + ', выход ' + tokens(usage.outputTokens) + ', кэш-хит ' + tokens(usage.cacheReadTokens))
1173
+ lines.push('Tokens: in ' + tokens(usage.inputTokens) + ', out ' + tokens(usage.outputTokens) + ', cache hit ' + tokens(usage.cacheReadTokens))
969
1174
  }
970
1175
  const text = lines.join('\n')
971
1176
  if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
@@ -991,57 +1196,65 @@ window.__ModuleLoader__.load({
991
1196
  })
992
1197
  }
993
1198
 
1199
+ // Display models with collapse support if > 3 models
1200
+ const visibleModels = (perModel.length
1201
+ ? perModel
1202
+ : (state.route ? [{ key: state.route.provider + '/' + state.route.model, model: state.route.model, known: Boolean(schedule), cost: 0 }] : [])
1203
+ )
1204
+ const shouldCollapse = visibleModels.length > 3 && !showAllModels
1205
+ const renderedModels = shouldCollapse ? visibleModels.slice(0, 3) : visibleModels
1206
+
994
1207
  return h('div', { ref: box, style: { position: 'relative' } }, [
995
1208
  h('button', {
996
1209
  key: 'chip', type: 'button',
997
- className: 'dcm-chip' + (budgetExceeded ? ' dcm-chip-budget-warn' : ''),
998
- style: {
999
- background: bg,
1000
- color: fg,
1001
- borderColor: budgetExceeded
1002
- ? 'rgba(239,68,68,0.5)'
1003
- : (cheapNow ? 'rgba(16,185,129,0.3)' : 'rgba(245,158,11,0.3)'),
1004
- },
1210
+ className: 'dcm-chip',
1005
1211
  onClick: () => setOpen((v) => !v),
1006
1212
  title: budgetExceeded
1007
1213
  ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1008
- : (flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф'),
1214
+ : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak')),
1009
1215
  }, [
1216
+ h('span', { key: 'dot', className: dotClass }),
1010
1217
  h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1011
- left ? h('span', { key: 'left', style: { opacity: 0.75 } }, left) : null,
1218
+ left ? h('span', { key: 'left', className: 'dcm-chip-timer' }, left) : null,
1012
1219
  ]),
1013
1220
 
1014
1221
  open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1015
- h('div', { key: 'title', className: 'dcm-panel-title' },
1016
- (state.route ? state.route.model : 'модель') +
1017
- (flat ? '' : ' — сейчас ' + (cheapNow ? 'не-пик' : t('peak')))),
1222
+ h('div', { key: 'title', className: 'dcm-panel-title' }, [
1223
+ h('span', { key: 'model-name' }, state.route ? state.route.model : 'model'),
1224
+ flat ? null : h('span', { key: 'tier', style: { fontSize: 11.5, opacity: 0.85 } }, cheapNow ? t('offpeak') : t('peak')),
1225
+ ]),
1018
1226
 
1019
1227
  h('div', { key: 'note', className: 'dcm-panel-note' },
1020
1228
  flat
1021
- ? 'Единая ставка, от времени суток не зависит'
1022
- : (cheapNow ? 'Действует льготная ставка' : 'Действует полная ставка') +
1023
- (left ? ' · смена через ' + left : '')),
1229
+ ? 'Flat rate schedule'
1230
+ : (cheapNow ? 'Discounted rate active' : 'Full rate active') +
1231
+ (left ? ' · change in ' + left : '')),
1024
1232
 
1025
1233
  flat
1026
1234
  ? null
1027
1235
  : h('div', { key: 'clock', className: 'dcm-panel-note' },
1028
- 'Пик: ' + windowText + ' ' + zl + ' · сейчас ' + nowLocal + ' ' + zl),
1236
+ t('peak') + ': ' + windowText + ' ' + zl + ' · now ' + nowLocal + ' ' + zl),
1029
1237
 
1030
1238
  discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
1031
1239
 
1240
+ cacheSavedAmount > 0
1241
+ ? h('div', { key: 'cache-saved-box', className: 'dcm-cache-box', style: { marginTop: 6 } },
1242
+ t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }))
1243
+ : null,
1244
+
1032
1245
  budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1033
1246
  t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
1034
1247
 
1035
1248
  h('hr', { key: 'hr1', className: 'dcm-hr' }),
1036
1249
 
1037
1250
  h('div', { key: 'rates', className: flat ? 'dcm-panel-grid-flat' : 'dcm-panel-grid' }, [
1038
- h('div', { key: 'h0', className: 'dcm-th' }, '1M токенов, $'),
1039
- flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, 'не-пик'),
1040
- h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? 'ставка' : 'пик'),
1041
- rateRow('Вход (кэш-хит)', 'cacheHit'),
1042
- rateRow('Вход (мимо кэша)', 'input'),
1043
- showCacheWrite ? rateRow('Вход (запись в кэш)', 'cacheWrite') : null,
1044
- rateRow('Выход', 'output'),
1251
+ h('div', { key: 'h0', className: 'dcm-th' }, t('rate.per_1m')),
1252
+ flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, t('offpeak')),
1253
+ h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? 'rate' : t('peak')),
1254
+ rateRow(t('rate.cache_hit'), 'cacheHit'),
1255
+ rateRow(t('rate.input'), 'input'),
1256
+ showCacheWrite ? rateRow(t('rate.cache_write'), 'cacheWrite') : null,
1257
+ rateRow(t('rate.output'), 'output'),
1045
1258
  ]),
1046
1259
 
1047
1260
  h('hr', { key: 'hr2', className: 'dcm-hr' }),
@@ -1049,27 +1262,24 @@ window.__ModuleLoader__.load({
1049
1262
  multiModel
1050
1263
  ? null
1051
1264
  : [
1052
- h('div', { key: 'btitle', className: 'dcm-section-lbl' }, 'Токены сессии'),
1265
+ h('div', { key: 'btitle', className: 'dcm-section-lbl' }, t('tokens.session')),
1053
1266
  h('div', { key: 'breakdown', className: 'dcm-panel-grid-3' }, [
1054
- h('div', { key: 'b0', className: 'dcm-th' }, 'токенов'),
1267
+ h('div', { key: 'b0', className: 'dcm-th' }, t('tokens.tokens_col')),
1055
1268
  h('div', { key: 'b1', className: 'dcm-th-num' }, ''),
1056
1269
  h('div', { key: 'b2', className: 'dcm-th-num' }, currency),
1057
- spentRow('Вход (кэш-хит)', 'cacheReadTokens', 'cacheHit'),
1058
- spentRow('Вход (мимо кэша)', 'uncachedInputTokens', 'input'),
1059
- showCacheWrite ? spentRow('Вход (запись в кэш)', 'cacheWriteTokens', 'cacheWrite') : null,
1060
- spentRow('Выход', 'outputTokens', 'output'),
1270
+ spentRow(t('rate.cache_hit'), 'cacheReadTokens', 'cacheHit'),
1271
+ spentRow(t('rate.input'), 'uncachedInputTokens', 'input'),
1272
+ showCacheWrite ? spentRow(t('rate.cache_write'), 'cacheWriteTokens', 'cacheWrite') : null,
1273
+ spentRow(t('rate.output'), 'outputTokens', 'output'),
1061
1274
  ]),
1062
1275
  h('hr', { key: 'hr-models', className: 'dcm-hr' }),
1063
1276
  ],
1064
1277
 
1065
- h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, 'Расход по моделям'),
1278
+ h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, t('tokens.models_spent')),
1066
1279
  h(
1067
1280
  'div',
1068
1281
  { key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
1069
- (perModel.length
1070
- ? perModel
1071
- : (state.route ? [{ key: state.route.provider + '/' + state.route.model, model: state.route.model, known: Boolean(schedule), cost: 0 }] : [])
1072
- ).map((m) =>
1282
+ renderedModels.map((m) =>
1073
1283
  h(
1074
1284
  'div',
1075
1285
  {
@@ -1102,22 +1312,31 @@ window.__ModuleLoader__.load({
1102
1312
  color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
1103
1313
  },
1104
1314
  },
1105
- m.known ? (hasUsage ? money(m.cost, currency) : '—') : 'не указана стоимость',
1315
+ m.known ? (hasUsage ? money(m.cost, currency) : '—') : t('tokens.not_priced'),
1106
1316
  ),
1107
1317
  ],
1108
1318
  ),
1109
1319
  ),
1110
1320
  ),
1111
1321
 
1322
+ visibleModels.length > 3
1323
+ ? h('button', {
1324
+ key: 'toggle-models',
1325
+ type: 'button',
1326
+ className: 'dcm-accordion-btn',
1327
+ onClick: () => setShowAllModels((v) => !v),
1328
+ }, shouldCollapse ? t('models.show_more', { count: visibleModels.length }) : t('models.show_less'))
1329
+ : null,
1330
+
1112
1331
  h('hr', { key: 'hr3', className: 'dcm-hr' }),
1113
1332
 
1114
1333
  h('div', { key: 'total', className: 'dcm-total' }, [
1115
- h('span', { key: 'l' }, 'Сессия:'),
1116
- h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : 'нет данных'),
1334
+ h('span', { key: 'l' }, t('tokens.session_total')),
1335
+ h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1117
1336
  ]),
1118
1337
 
1119
1338
  hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
1120
- 'Провайдер ещё не сообщил usage для этой сессии.'),
1339
+ t('tokens.no_usage_yet')),
1121
1340
 
1122
1341
  h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
1123
1342
  h('button', {
@@ -1131,11 +1350,43 @@ window.__ModuleLoader__.load({
1131
1350
  }
1132
1351
 
1133
1352
  function apply(ctx) {
1134
- if (ctx.locale && ctx.locale.register) {
1135
- try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
1353
+ const registerDictionaries = () => {
1354
+ try {
1355
+ // ctx.locale.register(NS, { en, zh }) is canonical form in DSH
1356
+ return ctx.locale.register(NS, { en, zh })
1357
+ } catch (err) {
1358
+ if (ctx?.logger?.warn) {
1359
+ ctx.logger.warn(`[dsh-cost-meter] locale register failed: ${err?.message || err}`)
1360
+ }
1361
+ return () => {}
1362
+ }
1136
1363
  }
1137
1364
 
1138
- refreshMirrorUntilVisible(ctx)
1365
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
1366
+ if (typeof ctx.effect === 'function') {
1367
+ ctx.effect(() => {
1368
+ const undo = registerDictionaries()
1369
+ return () => {
1370
+ try {
1371
+ if (typeof undo === 'function') undo()
1372
+ } catch (e) {
1373
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] locale undo error: ' + (e?.message || e))
1374
+ }
1375
+ }
1376
+ }, 'dsh-cost-meter: locale dictionaries')
1377
+ } else {
1378
+ registerDictionaries()
1379
+ }
1380
+ }
1381
+
1382
+ if (typeof ctx.effect === 'function') {
1383
+ ctx.effect(
1384
+ () => refreshMirrorUntilVisible(ctx),
1385
+ 'dsh-cost-meter: refresh settings mirror',
1386
+ )
1387
+ } else {
1388
+ refreshMirrorUntilVisible(ctx)
1389
+ }
1139
1390
 
1140
1391
  if (ctx.slots && typeof ctx.slots.inject === 'function') {
1141
1392
  ctx.slots.inject('conversation.session.header.utilities', () =>