@goodandready/dsh-cost-meter 0.7.9 → 0.8.1

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,15 +1,7 @@
1
- // dsh-cost-meter — browser half.
2
- //
3
- // One chip in `conversation.session.header.utilities` (kind: list, scope:
4
- // session). Session-scoped slots hand the component `useProjection`, so token
5
- // counts come from dsh-token-meter's `tokenUsage` projection and move while a
6
- // turn streams. The tariff schedule arrives from the host over GET
7
- // /dsh-cost-meter/state — a base rate card plus UTC discount windows.
8
- //
9
- // The client owns only the clock: which window is active now, when it flips,
10
- // and which variant of the day is the cheap one.
11
- //
12
- // Not ESM: the shell loads this file as a lazy CJS factory. No import, no JSX.
1
+ // dsh-cost-meter — client half.
2
+ // Self-registering browser module for DeepSeek Harness.
3
+ // Unified design system aligned with @goodandready/dsh-clinebot.
4
+
13
5
  window.__ModuleLoader__.load({
14
6
  id: '@goodandready/dsh-cost-meter',
15
7
  factory: (require) => {
@@ -17,133 +9,123 @@ window.__ModuleLoader__.load({
17
9
  const React = require('react')
18
10
  const h = React.createElement
19
11
 
12
+ const NS = 'dsh-cost-meter'
20
13
  const STATE_PATH = '/dsh-cost-meter/state'
14
+ const REFRESH_PATH = '/dsh-cost-meter/refresh'
21
15
  const DAY = 1440
22
- const NS = 'dsh-cost-meter'
23
16
 
24
- const en = {
25
- title: 'Cost Meter',
26
- subtitle: 'Session cost in header, token rates, and per-model breakdown',
27
- chipUnknown: 'tariff ?',
28
- chipRate: 'rate',
29
- offPeak: 'off-peak',
30
- peak: 'peak',
31
- flat: 'flat',
32
- modelsBreakdown: 'Model Breakdown',
33
- sessionTokens: 'Session Tokens',
34
- totalCost: 'Total Cost',
35
- noPriceSpecified: 'rate not specified',
36
- noUsageYet: 'Provider has not reported usage for this session yet.',
37
- routeUnknown: 'route unknown',
38
- catalogUnavailable: 'OpenRouter catalog unavailable: ',
39
- modelNotFound: 'Model not found in OpenRouter catalog. Set manual prices in settings (prices) or map model via modelMap.',
40
- 'settings.loading': 'Loading settings…',
41
- 'settings.unavailable': 'Settings unavailable (namespace is not registered yet).',
42
- 'settings.save': 'Save',
43
- 'settings.saving': 'Saving…',
44
- 'settings.saved': 'Saved',
45
- 'settings.saveFailed': 'Failed to save settings: ',
46
- 'field.currency': 'Currency symbol',
47
- 'field.usdRate': 'USD exchange rate',
48
- 'field.displayTimeZone': 'Display timezone',
49
- 'hint.currency': 'Currency symbol shown in the widget (e.g. $, ₽, €).',
50
- 'hint.usdRate': 'Exchange rate: units of currency per 1 USD (e.g. 80 for ₽).',
51
- 'hint.displayTimeZone': 'IANA timezone used for tariff peak/off-peak schedule.',
52
- 'field.useOpenRouter': 'Use OpenRouter Catalog',
53
- 'hint.useOpenRouter': 'Pull rates and discount windows automatically from the OpenRouter catalog.',
54
- 'field.refreshHours': 'Catalog Refresh Interval (hours)',
55
- 'hint.refreshHours': 'How often to fetch fresh model rates from OpenRouter (default: 24).',
56
- }
17
+ // ----------------------------------------------------------- localization
57
18
 
58
19
  const ru = {
59
- title: 'Счётчик расходов (Cost Meter)',
60
- subtitle: 'Стоимость сессии в шапке, тарифы токенов и детализация по моделям',
61
- chipUnknown: 'тариф ?',
62
- chipRate: 'тариф',
63
- offPeak: 'не-пик',
64
- peak: 'пик',
65
- flat: 'единый',
66
- modelsBreakdown: 'Расход по моделям',
67
- sessionTokens: 'Токены сессии',
68
- totalCost: 'Итого за сессию',
69
- noPriceSpecified: 'не указана стоимость',
70
- noUsageYet: 'Провайдер ещё не сообщил usage для этой сессии.',
71
- routeUnknown: 'маршрут неизвестен',
72
- catalogUnavailable: 'Каталог OpenRouter недоступен: ',
73
- modelNotFound: 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.',
74
- 'settings.loading': 'Загрузка настроек…',
75
- 'settings.unavailable': 'Настройки недоступны (пространство ещё не зарегистрировано).',
76
- 'settings.save': 'Сохранить',
77
- 'settings.saving': 'Сохранение…',
78
- 'settings.saved': 'Сохранено',
79
- 'settings.saveFailed': 'Не удалось сохранить настройки: ',
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': 'Источник тарифа',
80
40
  'field.currency': 'Символ валюты',
41
+ 'hint.currency': 'Символ валюты для отображения расходов (например, $, ₽, €).',
81
42
  'field.usdRate': 'Курс валюты к USD',
82
- 'field.displayTimeZone': 'Часовой пояс отображения',
83
- 'hint.currency': 'Символ валюты, отображаемый в виджете (например, ₽, $, €).',
84
- 'hint.usdRate': 'Сколько единиц валюты в 1 долларе США (например, 80 для ₽).',
85
- 'hint.displayTimeZone': 'IANA таймзона для расчёта часов пика и не-пика (например, Europe/Moscow).',
86
- 'field.useOpenRouter': 'Каталог OpenRouter',
87
- 'hint.useOpenRouter': 'Автоматически загружать тарифы и скидочные окна из каталога OpenRouter.',
88
- 'field.refreshHours': 'Интервал обновления каталога (часы)',
89
- 'hint.refreshHours': 'Как часто обновлять ставки из OpenRouter (по умолчанию: 24).',
90
- }
91
-
92
- let ChevronIcon = null
93
- try {
94
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
95
- ChevronIcon = primitives && primitives.IconChevronDownOutline14
96
- } catch {
97
- ChevronIcon = null
98
- }
99
-
100
- function Chevron(props) {
101
- if (ChevronIcon) {
102
- return React.createElement(ChevronIcon, {
103
- className: 'dcm-chev' + (props.open ? ' dcm-chev-open' : ''),
104
- style: {
105
- marginLeft: 'auto', flex: 'none', color: 'var(--dsw-alias-label-tertiary)',
106
- transition: 'transform .16s', transform: props.open ? 'rotate(180deg)' : 'none',
107
- },
108
- })
109
- }
110
- return React.createElement('svg', {
111
- className: 'dcm-chev' + (props.open ? ' dcm-chev-open' : ''),
112
- style: {
113
- marginLeft: 'auto', flex: 'none', color: 'var(--dsw-alias-label-tertiary)',
114
- transition: 'transform .16s', transform: props.open ? 'rotate(180deg)' : 'none',
115
- },
116
- width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true',
117
- }, React.createElement('path', {
118
- d: 'M3.5 5.25L7 8.75L10.5 5.25', stroke: 'currentColor', strokeWidth: 1.5,
119
- strokeLinecap: 'round', strokeLinejoin: 'round',
120
- }))
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: 'не-пик',
121
73
  }
122
74
 
123
- const cardCss = '.dcm-card { border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-3); border-radius:12px; list-style:none } ' +
124
- '.dcm-head { appearance:none; width:100%; font:inherit; color:inherit; text-align:left; cursor:pointer; background:0 0; border:0; border-radius:12px; display:flex; align-items:center; gap:12px; padding:14px 16px } ' +
125
- '.dcm-title { color:var(--dsw-alias-label-primary); font-size:15px; font-weight:600; line-height:1.4 } ' +
126
- '.dcm-sub { color:var(--dsw-alias-label-secondary); font-size:13px } ' +
127
- '.dcm-body { border-top:1px solid var(--dsw-alias-border-l2); margin:0 16px; padding-bottom:8px } ' +
128
- '.dcm-field { display:flex; flex-direction:column; gap:6px; padding:12px 0 } ' +
129
- '.dcm-row-check { display:flex; align-items:center; gap:8px; padding:12px 0 } ' +
130
- '.dcm-check { width:16px; height:16px; accent-color:var(--dsw-alias-brand-primary, #6366f1); cursor:pointer } ' +
131
- '.dcm-label { color:var(--dsw-alias-label-primary); font-size:13px; font-weight:500 } ' +
132
- '.dcm-hint { color:var(--dsw-alias-label-secondary); font-size:12px } ' +
133
- '.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 } ' +
134
- '.dcm-foot { border-top:1px solid var(--dsw-alias-border-l2); display:flex; justify-content:flex-end; align-items:center; gap:8px; padding:12px 0 4px } ' +
135
- '.dcm-save { appearance:none; font:inherit; cursor:pointer; border:1px solid transparent; border-radius:8px; padding:5px 14px; font-size:13px; background:var(--dsw-alias-label-primary); color:var(--dsw-alias-bg-layer-3) } ' +
136
- '.dcm-msg-ok { color:var(--dsw-alias-state-success-primary); font-size:12px } ' +
137
- '.dcm-msg-err { color:var(--dsw-alias-state-danger-primary, #e53935); font-size:12px }';
138
-
139
- function ensureCardStyles() {
140
- if (typeof document === 'undefined') return
141
- if (document.getElementById('dsh-cost-meter-card-styles')) return
142
- const el = document.createElement('style')
143
- el.id = 'dsh-cost-meter-card-styles'
144
- el.dataset.dshPlugin = 'dsh-cost-meter'
145
- el.textContent = cardCss
146
- document.head.appendChild(el)
75
+ const en = {
76
+ title: 'Cost Meter',
77
+ subtitle: 'Model rates, peak discount windows and session spend',
78
+ 'header.title': 'Cost Meter',
79
+ 'header.sub': 'Token spend monitoring, automatic tariff resolution and discount windows',
80
+ 'badge.catalog_ok': 'Catalog: {count} models',
81
+ 'badge.catalog_empty': 'Catalog: loading…',
82
+ 'badge.catalog_off': 'Catalog: disabled',
83
+ 'badge.catalog_err': 'Catalog: error',
84
+ 'badge.catalog_stale': 'Cache: offline',
85
+ 'badge.deepseek': 'DeepSeek tariff: active',
86
+ 'section.general': 'General Parameters',
87
+ 'section.general_desc': 'Display currency, conversion rate, budget threshold and time zone configuration',
88
+ 'section.openrouter': 'OpenRouter Pricing Catalog',
89
+ 'section.openrouter_desc': 'Public catalog for dynamic model pricing and discount windows',
90
+ 'section.diagnostics': 'Diagnostics & Status',
91
+ 'section.diagnostics_desc': 'Current state of background synchronization and tariff cache',
92
+ 'diag.models_count': 'Catalog models',
93
+ 'diag.last_sync': 'Last sync',
94
+ 'diag.route': 'Active route',
95
+ 'diag.tariff_source': 'Tariff source',
96
+ 'field.currency': 'Currency Symbol',
97
+ 'hint.currency': 'Currency symbol shown in the UI (e.g. $, ₽, €).',
98
+ 'field.usdRate': 'USD Exchange Rate',
99
+ 'hint.usdRate': 'Units of currency per 1 USD (1 keeps USD).',
100
+ 'field.budgetThreshold': 'Session Budget Threshold (USD)',
101
+ 'hint.budgetThreshold': 'Warn via chip color when session spend exceeds this amount (0 disables).',
102
+ 'field.displayTimeZone': 'Time Zone (IANA)',
103
+ 'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, UTC).',
104
+ 'field.useOpenRouter': 'Use OpenRouter Catalog',
105
+ 'hint.useOpenRouter': 'Pull rates and discount windows dynamically from public OpenRouter API.',
106
+ 'field.refreshHours': 'Catalog Refresh Interval (hours)',
107
+ 'hint.refreshHours': 'Background catalog fetch interval in hours (1 to 168).',
108
+ 'action.sync_now': 'Sync Now',
109
+ 'action.syncing': 'Syncing…',
110
+ 'action.sync_ok': 'Catalog updated',
111
+ 'action.sync_err': 'Sync failed',
112
+ 'action.sync_rate_limited': 'Wait {seconds}s',
113
+ 'action.copy_report': 'Copy Summary',
114
+ 'action.copied': 'Copied!',
115
+ 'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
116
+ 'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
117
+ 'settings.save': 'Save Settings',
118
+ 'settings.saving': 'Saving…',
119
+ 'settings.saved': 'Settings saved successfully',
120
+ 'settings.saveFailed': 'Failed to save settings: ',
121
+ 'settings.loading': 'Loading settings…',
122
+ 'settings.unavailable': 'Settings temporarily unavailable',
123
+ 'settings.retry': 'Retry',
124
+ chipUnknown: 'Unknown tariff',
125
+ routeUnknown: 'Unknown route',
126
+ catalogUnavailable: 'Catalog unavailable: ',
127
+ peak: 'peak',
128
+ offpeak: 'off-peak',
147
129
  }
148
130
 
149
131
  function makeT(dict, fallback) {
@@ -167,6 +149,192 @@ window.__ModuleLoader__.load({
167
149
  )
168
150
  }
169
151
 
152
+ // ----------------------------------------------------------------- styles
153
+
154
+ const CSS = `
155
+ .dcm-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 12px; list-style: none; margin-bottom: 12px; }
156
+ .dcm-head { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
157
+ .dcm-title { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
158
+ .dcm-sub { color: var(--dsw-alias-label-secondary); font-size: 13px; }
159
+ .dcm-body { border-top: 1px solid var(--dsw-alias-border-l2); margin: 0 16px; padding: 16px 0 12px; display: flex; flex-direction: column; gap: 14px; }
160
+
161
+ .dcm-section-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
162
+ .dcm-section-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); display: flex; align-items: center; justify-content: space-between; }
163
+ .dcm-section-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: -4px; line-height: 1.4; }
164
+
165
+ .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
166
+ .dcm-field { display: flex; flex-direction: column; gap: 6px; }
167
+ .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; }
169
+ .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
170
+ .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
171
+
172
+ .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); }
175
+
176
+ .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
177
+ .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); }
181
+
182
+ .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
+ .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
184
+ .dcm-btn-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
185
+ .dcm-btn-primary:hover:not(:disabled) { opacity: 0.9; }
186
+ .dcm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
187
+ .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
188
+
189
+ .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; }
193
+
194
+ .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
+ .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
196
+ .dcm-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-secondary); }
197
+
198
+ .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
199
+
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); } }
204
+
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; }
207
+ .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
208
+ .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; }
212
+ .dcm-th { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; }
213
+ .dcm-th-num { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; text-align: right; font-size: 11px; }
214
+ .dcm-td { padding: 3px 0; }
215
+ .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; }
218
+ .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; }
220
+ `
221
+
222
+ function ensureStyles() {
223
+ if (typeof document === 'undefined') return
224
+ if (document.getElementById('dsh-cost-meter-unified-css')) return
225
+ const el = document.createElement('style')
226
+ el.id = 'dsh-cost-meter-unified-css'
227
+ el.dataset.dshPlugin = NS
228
+ el.textContent = CSS
229
+ document.head.appendChild(el)
230
+ }
231
+
232
+ function createErrorBoundary() {
233
+ if (!React || typeof React.Component !== 'function') {
234
+ return function NoopBoundary(props) { return props?.children || null }
235
+ }
236
+ return class ErrorBoundary extends React.Component {
237
+ constructor(props) {
238
+ super(props)
239
+ this.state = { hasError: false, error: null }
240
+ }
241
+ static getDerivedStateFromError(error) {
242
+ return { hasError: true, error }
243
+ }
244
+ componentDidCatch(error, errorInfo) {
245
+ console.error('[dsh-cost-meter] React error caught:', error, errorInfo)
246
+ }
247
+ render() {
248
+ if (this.state.hasError) {
249
+ return React.createElement(
250
+ 'div',
251
+ { className: 'dcm-alert dcm-alert-err', style: { margin: '8px 0' } },
252
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } }, '⚠️ Cost Meter UI Error:'),
253
+ React.createElement('div', { style: { fontSize: 12, wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
254
+ React.createElement(
255
+ 'button',
256
+ {
257
+ type: 'button',
258
+ className: 'dcm-btn',
259
+ style: { marginTop: 8, fontSize: 12, padding: '3px 10px' },
260
+ onClick: () => this.setState({ hasError: false, error: null }),
261
+ },
262
+ 'Retry'
263
+ )
264
+ )
265
+ }
266
+ return this.props?.children || null
267
+ }
268
+ }
269
+ }
270
+ const ErrorBoundary = createErrorBoundary()
271
+
272
+ function refreshMirrorUntilVisible(ctx) {
273
+ const visible = () => {
274
+ try {
275
+ const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
276
+ const view = s?.describe?.()?.getSnapshot?.()?.view
277
+ return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
278
+ } catch (_) {
279
+ return false
280
+ }
281
+ }
282
+ if (visible()) return () => {}
283
+ let tries = 0
284
+ const timer = setInterval(() => {
285
+ if (visible() || tries >= 15) { clearInterval(timer); return }
286
+ tries += 1
287
+ try {
288
+ const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
289
+ s?.describe?.()?.load?.()
290
+ } catch (_) {}
291
+ }, 1000)
292
+ return () => clearInterval(timer)
293
+ }
294
+
295
+ function Chevron(props) {
296
+ return React.createElement(
297
+ 'svg',
298
+ {
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
+ },
306
+ },
307
+ React.createElement('path', {
308
+ d: 'M4 6L8 10L12 6',
309
+ stroke: 'currentColor',
310
+ strokeWidth: 1.6,
311
+ strokeLinecap: 'round',
312
+ strokeLinejoin: 'round',
313
+ }),
314
+ )
315
+ }
316
+
317
+ function isValidTimeZone(tz) {
318
+ if (!tz || typeof tz !== 'string') return false
319
+ try {
320
+ new Intl.DateTimeFormat(undefined, { timeZone: tz })
321
+ return true
322
+ } catch {
323
+ return false
324
+ }
325
+ }
326
+
327
+ function ago(timestamp) {
328
+ if (!timestamp) return 'нет данных'
329
+ const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
330
+ if (minutes < 1) return 'только что'
331
+ if (minutes < 60) return minutes + ' мин назад'
332
+ const hours = Math.round(minutes / 60)
333
+ return hours < 48 ? hours + ' ч назад' : Math.round(hours / 24) + ' дн назад'
334
+ }
335
+
336
+ // ---------------------------------------------------------------- card
337
+
170
338
  function CostMeterCard(props) {
171
339
  const ctx = props.ctx
172
340
  const locale = useActiveLocale(ctx)
@@ -176,6 +344,11 @@ window.__ModuleLoader__.load({
176
344
  const [saving, setSaving] = React.useState(false)
177
345
  const [err, setErr] = React.useState('')
178
346
  const [saved, setSaved] = React.useState(false)
347
+ const [diagState, setDiagState] = React.useState(null)
348
+
349
+ // Manual sync button state
350
+ const [syncing, setSyncing] = React.useState(false)
351
+ const [syncMsg, setSyncMsg] = React.useState('')
179
352
 
180
353
  const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
181
354
  const scope = React.useMemo(
@@ -189,7 +362,19 @@ window.__ModuleLoader__.load({
189
362
  React.useCallback(() => ({ status: 'loading' }), []),
190
363
  )
191
364
 
192
- React.useEffect(() => { ensureCardStyles() }, [])
365
+ React.useEffect(() => { ensureStyles() }, [])
366
+
367
+ const fetchDiag = React.useCallback(() => {
368
+ fetch(STATE_PATH, { headers: { accept: 'application/json' } })
369
+ .then((r) => (r.ok ? r.json() : null))
370
+ .then((data) => { if (data) setDiagState(data) })
371
+ .catch(() => {})
372
+ }, [])
373
+
374
+ React.useEffect(() => {
375
+ if (!open) return
376
+ fetchDiag()
377
+ }, [open, fetchDiag])
193
378
 
194
379
  const status = (snapshot && snapshot.status) || 'loading'
195
380
  const stored = (snapshot && snapshot.value) || {}
@@ -199,6 +384,7 @@ window.__ModuleLoader__.load({
199
384
  setDraft({
200
385
  currency: stored.currency !== undefined ? String(stored.currency) : '$',
201
386
  usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
387
+ budgetThreshold: stored.budgetThreshold !== undefined ? String(stored.budgetThreshold) : '0',
202
388
  displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
203
389
  useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
204
390
  refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
@@ -206,10 +392,40 @@ window.__ModuleLoader__.load({
206
392
  }
207
393
  }, [status, stored, draft])
208
394
 
395
+ const tzValid = draft ? isValidTimeZone(draft.displayTimeZone) : true
396
+ const rateValid = draft ? (!isNaN(Number(draft.usdRate)) && Number(draft.usdRate) > 0) : true
397
+ const budgetValid = draft ? (!isNaN(Number(draft.budgetThreshold)) && Number(draft.budgetThreshold) >= 0) : true
398
+ const hoursValid = draft ? (!isNaN(Number(draft.refreshHours)) && Number(draft.refreshHours) >= 1 && Number(draft.refreshHours) <= 168) : true
399
+
400
+ const handleManualSync = async () => {
401
+ if (syncing) return
402
+ setSyncing(true)
403
+ setSyncMsg('')
404
+ try {
405
+ const res = await fetch(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
406
+ if (res.status === 429) {
407
+ const data = await res.json().catch(() => ({}))
408
+ const retryAfter = data.retryAfter || 30
409
+ setSyncMsg(t('action.sync_rate_limited', { seconds: retryAfter }))
410
+ } else if (res.ok) {
411
+ setSyncMsg(t('action.sync_ok'))
412
+ fetchDiag()
413
+ } else {
414
+ setSyncMsg(t('action.sync_err'))
415
+ }
416
+ } catch (_) {
417
+ setSyncMsg(t('action.sync_err'))
418
+ } finally {
419
+ setSyncing(false)
420
+ setTimeout(() => setSyncMsg(''), 4000)
421
+ }
422
+ }
423
+
209
424
  const save = async () => {
210
425
  if (!scope || !draft) return
211
426
  setSaving(true); setErr(''); setSaved(false)
212
427
  const broken = []
428
+
213
429
  try {
214
430
  await scope.set('currency', String(draft.currency || '$'))
215
431
  } catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
@@ -223,6 +439,15 @@ window.__ModuleLoader__.load({
223
439
  } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
224
440
  }
225
441
 
442
+ const budgetNum = Number(String(draft.budgetThreshold).trim())
443
+ if (!Number.isFinite(budgetNum) || budgetNum < 0) {
444
+ broken.push('budgetThreshold: must be non-negative')
445
+ } else {
446
+ try {
447
+ await scope.set('budgetThreshold', budgetNum)
448
+ } catch (e) { broken.push('budgetThreshold: ' + (e && e.message || String(e))) }
449
+ }
450
+
226
451
  const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
227
452
  if (!isValidTimeZone(tzVal)) {
228
453
  broken.push('displayTimeZone: invalid IANA time zone')
@@ -237,8 +462,8 @@ window.__ModuleLoader__.load({
237
462
  } catch (e) { broken.push('useOpenRouter: ' + (e && e.message || String(e))) }
238
463
 
239
464
  const hoursNum = Number(String(draft.refreshHours).trim())
240
- if (!Number.isFinite(hoursNum) || hoursNum <= 0) {
241
- broken.push('refreshHours: must be a positive number')
465
+ if (!Number.isFinite(hoursNum) || hoursNum < 1 || hoursNum > 168) {
466
+ broken.push('refreshHours: must be between 1 and 168 hours')
242
467
  } else {
243
468
  try {
244
469
  await scope.set('refreshHours', hoursNum)
@@ -254,81 +479,176 @@ window.__ModuleLoader__.load({
254
479
  setTimeout(() => setSaved(false), 2500)
255
480
  }
256
481
 
482
+ const modelsCount = (diagState && typeof diagState.catalogModelsCount === 'number')
483
+ ? diagState.catalogModelsCount
484
+ : ((diagState && diagState.schedules) ? Object.keys(diagState.schedules).length : null)
485
+ const catalogHasErr = diagState && Boolean(diagState.catalogError)
486
+ const openRouterEnabled = draft ? Boolean(draft.useOpenRouter) : true
487
+
257
488
  return React.createElement('li', { className: 'dcm-card' },
258
489
  React.createElement('button', {
259
490
  type: 'button', className: 'dcm-head', 'aria-expanded': open,
260
491
  onClick: () => setOpen((v) => !v),
261
492
  },
262
- React.createElement('span', { style: { display: 'flex', flexDirection: 'column' } },
263
- React.createElement('span', { className: 'dcm-title' }, t('title')),
264
- React.createElement('span', { className: 'dcm-sub' }, t('subtitle')),
493
+ React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
494
+ React.createElement('span', { className: 'dcm-title' }, t('header.title')),
495
+ React.createElement('span', { className: 'dcm-sub' }, t('header.sub')),
496
+ React.createElement('div', { className: 'dcm-badges' },
497
+ React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.deepseek')),
498
+ openRouterEnabled
499
+ ? (catalogHasErr
500
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-bad' }, t('badge.catalog_err'))
501
+ : (modelsCount !== null && modelsCount > 0
502
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.catalog_ok', { count: modelsCount }))
503
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_empty'))))
504
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_off')),
505
+ catalogHasErr && modelsCount ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_stale')) : null,
506
+ draft && draft.displayTimeZone
507
+ ? React.createElement('span', { className: 'dcm-badge' }, draft.displayTimeZone)
508
+ : null,
509
+ ),
265
510
  ),
266
511
  React.createElement(Chevron, { open }),
267
512
  ),
268
513
  open ? React.createElement('div', { className: 'dcm-body' },
269
514
  status === 'loading'
270
- ? React.createElement('p', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
515
+ ? React.createElement('div', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
271
516
  : status !== 'ready'
272
- ? React.createElement('p', { className: 'dcm-msg-err', style: { padding: '12px 0' } }, t('settings.unavailable'))
273
- : React.createElement('div', null,
274
- React.createElement('div', { className: 'dcm-field' },
275
- React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
276
- React.createElement('input', {
277
- className: 'dcm-input',
278
- value: (draft && draft.currency) !== undefined ? draft.currency : '',
279
- onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
280
- }),
281
- React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
517
+ ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
518
+ : React.createElement(React.Fragment, null,
519
+ // Section 1: General Parameters
520
+ React.createElement('div', { className: 'dcm-section-card' },
521
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
522
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.general_desc')),
523
+ React.createElement('div', { className: 'dcm-grid-2' },
524
+ React.createElement('div', { className: 'dcm-field' },
525
+ React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
526
+ React.createElement('input', {
527
+ className: 'dcm-input',
528
+ value: (draft && draft.currency) !== undefined ? draft.currency : '',
529
+ onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
530
+ }),
531
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
532
+ ),
533
+ React.createElement('div', { className: 'dcm-field' },
534
+ React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
535
+ React.createElement('input', {
536
+ className: 'dcm-input' + (rateValid ? '' : ' dcm-input-err'),
537
+ type: 'number',
538
+ step: 'any',
539
+ value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
540
+ onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
541
+ }),
542
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
543
+ ),
544
+ ),
545
+ React.createElement('div', { className: 'dcm-grid-2' },
546
+ React.createElement('div', { className: 'dcm-field' },
547
+ React.createElement('label', { className: 'dcm-label' }, t('field.budgetThreshold')),
548
+ React.createElement('input', {
549
+ className: 'dcm-input' + (budgetValid ? '' : ' dcm-input-err'),
550
+ type: 'number',
551
+ step: 'any',
552
+ min: 0,
553
+ value: (draft && draft.budgetThreshold) !== undefined ? draft.budgetThreshold : '',
554
+ onChange: (e) => setDraft({ ...draft, budgetThreshold: e.target.value }),
555
+ }),
556
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.budgetThreshold')),
557
+ ),
558
+ React.createElement('div', { className: 'dcm-field' },
559
+ React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
560
+ React.createElement('input', {
561
+ className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
562
+ value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
563
+ onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
564
+ }),
565
+ React.createElement('span', { className: 'dcm-hint' },
566
+ tzValid ? t('hint.displayTimeZone') : 'Некорректный идентификатор временной зоны IANA'
567
+ ),
568
+ ),
569
+ ),
282
570
  ),
283
- React.createElement('div', { className: 'dcm-field' },
284
- React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
285
- React.createElement('input', {
286
- className: 'dcm-input',
287
- type: 'number',
288
- step: 'any',
289
- value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
290
- onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
291
- }),
292
- React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
293
- ),
294
- React.createElement('div', { className: 'dcm-field' },
295
- React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
296
- React.createElement('input', {
297
- className: 'dcm-input',
298
- value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
299
- onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
300
- }),
301
- React.createElement('span', { className: 'dcm-hint' }, t('hint.displayTimeZone')),
302
- ),
303
- React.createElement('div', { className: 'dcm-row-check' },
304
- React.createElement('input', {
305
- id: 'dcm-use-openrouter',
306
- type: 'checkbox',
307
- className: 'dcm-check',
308
- checked: draft ? Boolean(draft.useOpenRouter) : true,
309
- onChange: (e) => setDraft({ ...draft, useOpenRouter: e.target.checked }),
310
- }),
311
- React.createElement('label', { htmlFor: 'dcm-use-openrouter', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.useOpenRouter')),
571
+
572
+ // Section 2: OpenRouter Pricing Catalog
573
+ React.createElement('div', { className: 'dcm-section-card' },
574
+ React.createElement('div', { className: 'dcm-section-title' },
575
+ React.createElement('span', null, t('section.openrouter')),
576
+ openRouterEnabled
577
+ ? React.createElement('button', {
578
+ type: 'button',
579
+ className: 'dcm-btn dcm-btn-sm',
580
+ disabled: syncing,
581
+ onClick: handleManualSync,
582
+ }, syncing ? t('action.syncing') : t('action.sync_now'))
583
+ : null
584
+ ),
585
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.openrouter_desc')),
586
+ syncMsg ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok', style: { padding: '6px 10px', fontSize: 12 } }, syncMsg) : null,
587
+ React.createElement('div', { className: 'dcm-row-check' },
588
+ React.createElement('input', {
589
+ id: 'dcm-use-openrouter',
590
+ type: 'checkbox',
591
+ className: 'dcm-check',
592
+ checked: draft ? Boolean(draft.useOpenRouter) : true,
593
+ onChange: (e) => setDraft({ ...draft, useOpenRouter: e.target.checked }),
594
+ }),
595
+ React.createElement('label', { htmlFor: 'dcm-use-openrouter', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.useOpenRouter')),
596
+ ),
597
+ React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.useOpenRouter')),
598
+ React.createElement('div', { className: 'dcm-field', style: { marginTop: 4 } },
599
+ React.createElement('label', { className: 'dcm-label' }, t('field.refreshHours')),
600
+ React.createElement('input', {
601
+ className: 'dcm-input' + (hoursValid ? '' : ' dcm-input-err'),
602
+ type: 'number',
603
+ min: 1,
604
+ max: 168,
605
+ value: (draft && draft.refreshHours) !== undefined ? draft.refreshHours : '',
606
+ onChange: (e) => setDraft({ ...draft, refreshHours: e.target.value }),
607
+ }),
608
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.refreshHours')),
609
+ ),
312
610
  ),
313
- React.createElement('span', { className: 'dcm-hint', style: { marginTop: -4, display: 'block', paddingBottom: 10 } }, t('hint.useOpenRouter')),
314
- React.createElement('div', { className: 'dcm-field' },
315
- React.createElement('label', { className: 'dcm-label' }, t('field.refreshHours')),
316
- React.createElement('input', {
317
- className: 'dcm-input',
318
- type: 'number',
319
- step: 'any',
320
- value: (draft && draft.refreshHours) !== undefined ? draft.refreshHours : '',
321
- onChange: (e) => setDraft({ ...draft, refreshHours: e.target.value }),
322
- }),
323
- React.createElement('span', { className: 'dcm-hint' }, t('hint.refreshHours')),
611
+
612
+ // Section 3: Live Diagnostics
613
+ React.createElement('div', { className: 'dcm-section-card' },
614
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.diagnostics')),
615
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.diagnostics_desc')),
616
+ React.createElement('div', { className: 'dcm-grid-2' },
617
+ React.createElement('div', { className: 'dcm-stat-box' },
618
+ React.createElement('span', { className: 'dcm-stat-val' },
619
+ modelsCount !== null ? String(modelsCount) : '—'
620
+ ),
621
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.models_count')),
622
+ ),
623
+ React.createElement('div', { className: 'dcm-stat-box' },
624
+ React.createElement('span', { className: 'dcm-stat-val' },
625
+ diagState ? ago(diagState.fetchedAt) : '—'
626
+ ),
627
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.last_sync')),
628
+ ),
629
+ React.createElement('div', { className: 'dcm-stat-box' },
630
+ React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
631
+ diagState && diagState.route ? (diagState.route.provider + '/' + diagState.route.model) : 'default'
632
+ ),
633
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.route')),
634
+ ),
635
+ React.createElement('div', { className: 'dcm-stat-box' },
636
+ React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
637
+ diagState && diagState.source ? String(diagState.source).toUpperCase() : 'DEEPSEEK'
638
+ ),
639
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.tariff_source')),
640
+ ),
641
+ ),
324
642
  ),
325
- err ? React.createElement('div', { className: 'dcm-msg-err', style: { padding: '4px 0' } }, err) : null,
326
- saved ? React.createElement('div', { className: 'dcm-msg-ok', style: { padding: '4px 0' } }, t('settings.saved')) : null,
643
+
644
+ // Alerts & Action Footer
645
+ err ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, err) : null,
646
+ saved ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, t('settings.saved')) : null,
327
647
  React.createElement('div', { className: 'dcm-foot' },
328
648
  React.createElement('button', {
329
649
  type: 'button',
330
- className: 'dcm-save',
331
- disabled: saving,
650
+ className: 'dcm-btn dcm-btn-primary',
651
+ disabled: saving || !tzValid || !rateValid || !budgetValid || !hoursValid,
332
652
  onClick: save,
333
653
  }, saving ? t('settings.saving') : t('settings.save')),
334
654
  ),
@@ -337,46 +657,69 @@ window.__ModuleLoader__.load({
337
657
  )
338
658
  }
339
659
 
340
-
341
660
  // ---------------------------------------------------------------- tariff
342
661
 
343
662
  function inWindow(minute, w) {
344
663
  return w.start <= w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
345
664
  }
346
665
 
347
- /** Rates in force at a given UTC minute: the last matching window, else base. */
348
666
  function ratesAt(schedule, minute) {
349
667
  const windows = (schedule && schedule.windows) || []
350
668
  for (const w of windows) if (inWindow(minute, w)) return w.rates
351
669
  return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
352
670
  }
353
671
 
354
- /** Minutes until the rate card changes, or null when it never does. */
672
+ /**
673
+ * Fast O(K) lookup of next tariff rate boundary instead of a 1440-minute loop.
674
+ */
355
675
  function minutesUntilChange(schedule, minute) {
676
+ const windows = (schedule && schedule.windows) || []
677
+ if (!windows.length) return null
356
678
  const now = ratesAt(schedule, minute)
357
- for (let step = 1; step <= DAY; step += 1) {
358
- const probe = ratesAt(schedule, (minute + step) % DAY)
359
- if (probe.output !== now.output || probe.input !== now.input) return step
679
+ // Transition points can only occur at window start or window end
680
+ const transitions = new Set()
681
+ for (const w of windows) {
682
+ transitions.add(w.start % DAY)
683
+ transitions.add(w.end % DAY)
684
+ }
685
+ let minDiff = null
686
+ for (const t of transitions) {
687
+ let diff = (t - minute + DAY) % DAY
688
+ if (diff === 0) diff = DAY
689
+ const probe = ratesAt(schedule, (minute + diff) % DAY)
690
+ if (probe.output !== now.output || probe.input !== now.input) {
691
+ if (minDiff === null || diff < minDiff) minDiff = diff
692
+ }
360
693
  }
361
- return null
694
+ return minDiff
362
695
  }
363
696
 
364
697
  /**
365
- * The day's two extremes, by output rate: what the widget labels
366
- * "не-пик" and "пик". Equal extremes mean a flat tariff.
698
+ * Memoized calculation of low and high rate extremes for a schedule.
367
699
  */
700
+ const extremesMemo = new WeakMap()
368
701
  function extremes(schedule) {
369
- let low = null
370
- let high = null
371
- for (let minute = 0; minute < DAY; minute += 1) {
372
- const r = ratesAt(schedule, minute)
373
- if (!low || r.output < low.output) low = r
374
- if (!high || r.output > high.output) high = r
702
+ if (!schedule) return { low: {}, high: {}, flat: true }
703
+ if (extremesMemo.has(schedule)) return extremesMemo.get(schedule)
704
+
705
+ const base = schedule.base || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
706
+ const candidates = [base]
707
+ for (const w of (schedule.windows || [])) {
708
+ if (w.rates) candidates.push(w.rates)
709
+ }
710
+
711
+ let low = candidates[0]
712
+ let high = candidates[0]
713
+ for (const r of candidates) {
714
+ if (r.output < low.output) low = r
715
+ if (r.output > high.output) high = r
375
716
  }
376
- return { low: low || {}, high: high || {}, flat: !low || !high || low.output === high.output }
717
+
718
+ const res = { low, high, flat: low.output === high.output }
719
+ extremesMemo.set(schedule, res)
720
+ return res
377
721
  }
378
722
 
379
- /** Total tokens across every half-hour slot of one model. */
380
723
  function sumSlots(slots) {
381
724
  const total = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
382
725
  for (const key of Object.keys(slots || {})) {
@@ -386,11 +729,6 @@ window.__ModuleLoader__.load({
386
729
  return total
387
730
  }
388
731
 
389
- /**
390
- * Cost of one model, each half-hour slot charged at the rate that applied
391
- * in that slot. Slot 14 is 07:00–07:30 UTC, so its midpoint (07:15) picks
392
- * the window without landing on a boundary.
393
- */
394
732
  function spendBySlots(slots, schedule) {
395
733
  let usd = 0
396
734
  for (const key of Object.keys(slots || {})) {
@@ -400,7 +738,6 @@ window.__ModuleLoader__.load({
400
738
  return usd
401
739
  }
402
740
 
403
- /** Session spend in USD for one rate card. */
404
741
  function spendUsd(usage, rates) {
405
742
  const u = usage || {}
406
743
  const perMillion =
@@ -411,16 +748,15 @@ window.__ModuleLoader__.load({
411
748
  return perMillion / 1e6
412
749
  }
413
750
 
414
- // --------------------------------------------------------------- format
415
-
416
751
  function rate(value) {
417
752
  if (!value) return '—'
418
753
  const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
419
- return '$' + text.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
754
+ return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
420
755
  }
421
756
 
422
757
  function money(value, currency) {
423
- return currency + value.toFixed(Math.abs(value) >= 100 ? 0 : 2)
758
+ const n = Number(value) || 0
759
+ return currency + n.toFixed(Math.abs(n) >= 100 ? 0 : 2)
424
760
  }
425
761
 
426
762
  function tokens(value) {
@@ -439,20 +775,6 @@ window.__ModuleLoader__.load({
439
775
  return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
440
776
  }
441
777
 
442
- function isValidTimeZone(tz) {
443
- if (!tz || typeof tz !== 'string') return false
444
- try {
445
- new Intl.DateTimeFormat(undefined, { timeZone: tz })
446
- return true
447
- } catch {
448
- return false
449
- }
450
- }
451
-
452
- /**
453
- * Render a UTC minute-of-day in the display zone. Built from a real Date so
454
- * the zone's current offset (and any DST) comes from Intl, not arithmetic.
455
- */
456
778
  function hhmmInZone(minute, zone, reference) {
457
779
  const d = new Date(Date.UTC(
458
780
  reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
@@ -466,7 +788,6 @@ window.__ModuleLoader__.load({
466
788
  }
467
789
  }
468
790
 
469
- /** Short zone label for the panel: "МСК" for Moscow, else the city part. */
470
791
  function zoneLabel(zone) {
471
792
  if (!zone) return 'UTC'
472
793
  if (zone === 'Europe/Moscow') return 'МСК'
@@ -474,56 +795,7 @@ window.__ModuleLoader__.load({
474
795
  return city.replace(/_/g, ' ')
475
796
  }
476
797
 
477
- function ago(timestamp) {
478
- if (!timestamp) return 'нет данных'
479
- const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
480
- if (minutes < 60) return minutes + ' мин назад'
481
- const hours = Math.round(minutes / 60)
482
- return hours < 48 ? hours + ' ч назад' : Math.round(hours / 24) + ' дн назад'
483
- }
484
-
485
- // ---------------------------------------------------------------- styles
486
-
487
- const C = {
488
- label: 'var(--dsw-alias-label-primary)',
489
- muted: 'var(--dsw-alias-label-tertiary)',
490
- line: 'var(--dsw-alias-border-l2)',
491
- panelBg: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base))',
492
- okFg: 'var(--dsw-alias-state-success-primary)',
493
- okBg: 'var(--dsw-alias-state-success-tertiary)',
494
- warnFg: 'var(--dsw-alias-state-warn-primary)',
495
- warnBg: 'var(--dsw-alias-state-warn-tertiary)',
496
- }
497
-
498
- const S = {
499
- chip: {
500
- display: 'inline-flex', alignItems: 'center', gap: 6, height: 22, padding: '0 8px',
501
- borderRadius: 6, border: 'none', cursor: 'pointer', font: 'inherit', fontSize: 12,
502
- fontVariantNumeric: 'tabular-nums', lineHeight: '22px',
503
- },
504
- panel: {
505
- position: 'absolute', top: 28, right: 0, zIndex: 40, width: 320, padding: '12px 14px',
506
- borderRadius: 10, border: '1px solid ' + C.line, background: C.panelBg,
507
- boxShadow: '0 10px 30px rgba(0,0,0,.28)', fontSize: 12, lineHeight: 1.5,
508
- color: C.label, textAlign: 'left', fontVariantNumeric: 'tabular-nums',
509
- },
510
- title: { fontWeight: 600, paddingBottom: 4 },
511
- note: { color: C.muted },
512
- hr: { height: 1, background: C.line, margin: '10px -14px' },
513
- grid: { display: 'grid', gridTemplateColumns: '1fr 60px 60px', columnGap: 6, alignItems: 'center' },
514
- gridFlat: { display: 'grid', gridTemplateColumns: '1fr 72px', columnGap: 6, alignItems: 'center' },
515
- grid3: { display: 'grid', gridTemplateColumns: '1fr 58px 72px', columnGap: 6, alignItems: 'center' },
516
- th: { color: C.muted, paddingBottom: 4 },
517
- thNum: { color: C.muted, paddingBottom: 4, textAlign: 'right' },
518
- td: { padding: '3px 0' },
519
- cellIdle: { textAlign: 'right', padding: '3px 6px', borderRadius: 4, color: C.muted },
520
- num: { textAlign: 'right', color: C.muted, padding: '3px 0' },
521
- cost: { textAlign: 'right', padding: '3px 0' },
522
- section: { color: C.muted, paddingBottom: 4 },
523
- total: { display: 'flex', justifyContent: 'space-between', fontWeight: 600 },
524
- }
525
-
526
- // ------------------------------------------------------------ component
798
+ // ------------------------------------------------------------ CostMeter
527
799
 
528
800
  function CostMeter(props) {
529
801
  const ctx = props.ctx
@@ -535,7 +807,11 @@ window.__ModuleLoader__.load({
535
807
  const [state, setState] = React.useState(null)
536
808
  const [open, setOpen] = React.useState(false)
537
809
  const [now, setNow] = React.useState(() => new Date())
810
+ const [copied, setCopied] = React.useState(false)
538
811
  const box = React.useRef(null)
812
+
813
+ React.useEffect(() => { ensureStyles() }, [])
814
+
539
815
  const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
540
816
  const settingsSnap = React.useSyncExternalStore
541
817
  ? React.useSyncExternalStore(
@@ -545,10 +821,6 @@ window.__ModuleLoader__.load({
545
821
  )
546
822
  : null
547
823
 
548
- // Every model this session actually billed, so each part is priced on its
549
- // own tariff instead of the current model's. Usage arrives split into
550
- // half-hour slots of the UTC day — spend is charged at the rate that was
551
- // in force then, so a tariff switch never reprices what is already spent.
552
824
  const used = (byModel && Array.isArray(byModel.models) ? byModel.models : [])
553
825
  .map((m) => ({ ...m, usage: sumSlots(m && m.slots) }))
554
826
  .filter((m) => m.usage.uncachedInputTokens || m.usage.outputTokens ||
@@ -567,8 +839,7 @@ window.__ModuleLoader__.load({
567
839
  })
568
840
  .catch(() => {})
569
841
  }, [])
570
- // Refetch when the set of billed models changes — a new model needs its
571
- // own tariff before its tokens can be priced.
842
+
572
843
  React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
573
844
  React.useEffect(() => { if (open) load(usedKeys) }, [open, load, usedKeys])
574
845
  React.useEffect(() => {
@@ -587,22 +858,23 @@ window.__ModuleLoader__.load({
587
858
  const stored = (settingsSnap && settingsSnap.value) || {}
588
859
  const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
589
860
  const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
861
+ const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
590
862
  const schedule = state.schedule
591
863
  const hasUsage = usage !== undefined && usage !== null
592
864
  const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
593
865
 
594
- // No rate card for this route: say so instead of showing a fake zero.
595
866
  if (!schedule) {
596
867
  return h('div', { ref: box, style: { position: 'relative' } }, [
597
868
  h('button', {
598
869
  key: 'chip', type: 'button',
599
- style: Object.assign({}, S.chip, { background: 'transparent', color: C.muted }),
870
+ className: 'dcm-chip',
871
+ style: { background: 'transparent', color: 'var(--dsw-alias-label-tertiary)' },
600
872
  onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
601
873
  }, 'тариф ?'),
602
- open ? h('div', { key: 'panel', style: S.panel }, [
603
- h('div', { key: 't', style: S.title },
874
+ open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
875
+ h('div', { key: 't', className: 'dcm-panel-title' },
604
876
  state.route ? state.route.provider + ' / ' + state.route.model : t('routeUnknown')),
605
- h('div', { key: 'n', style: S.note },
877
+ h('div', { key: 'n', className: 'dcm-panel-note' },
606
878
  state.catalogError
607
879
  ? t('catalogUnavailable') + state.catalogError
608
880
  : 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.'),
@@ -613,11 +885,9 @@ window.__ModuleLoader__.load({
613
885
  const active = ratesAt(schedule, minute)
614
886
  const { low, high, flat } = extremes(schedule)
615
887
  const cheapNow = !flat && active.output === low.output
616
- const left = clock(minutesUntilChange(schedule, minute))
888
+ const leftMinutes = minutesUntilChange(schedule, minute)
889
+ const left = clock(leftMinutes)
617
890
 
618
- // Per-model spend: each model on its own tariff, at that tariff's rate
619
- // for the current moment. Falls back to the session-wide totals when the
620
- // per-model projection is unavailable (older harness, headless).
621
891
  const schedules = state.schedules || {}
622
892
  const perModel = used.map((m) => {
623
893
  const key = m.provider + '/' + m.model
@@ -635,27 +905,30 @@ window.__ModuleLoader__.load({
635
905
  const total = perModel.length
636
906
  ? perModel.reduce((sum, m) => sum + m.cost, 0)
637
907
  : spendUsd(usage, active) * fx
638
- const unpriced = perModel.filter((m) => !m.known)
639
908
 
640
- const fg = cheapNow ? C.okFg : C.warnFg
641
- const bg = cheapNow ? C.okBg : C.warnBg
909
+ // Check budget threshold in USD
910
+ const totalUsd = fx > 0 ? total / fx : total
911
+ const budgetExceeded = budgetThreshold > 0 && totalUsd >= budgetThreshold
912
+
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)')
642
919
  const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: bg, color: fg, fontWeight: 600 }
643
920
 
644
- // Two tiers: cheap and full side by side, the active one filled.
645
- // One tier: a single plain column, no highlight to explain.
646
921
  const rateRow = (label, key) => flat
647
922
  ? [
648
- h('div', { key: label + '-l', style: S.td }, label),
649
- h('div', { key: label + '-v', style: S.cost }, rate(active[key])),
923
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
924
+ h('div', { key: label + '-v', className: 'dcm-cost' }, rate(active[key])),
650
925
  ]
651
926
  : [
652
- h('div', { key: label + '-l', style: S.td }, label),
653
- h('div', { key: label + '-lo', style: cheapNow ? activeCell : S.cellIdle }, rate(low[key])),
654
- h('div', { key: label + '-hi', style: cheapNow ? S.cellIdle : activeCell }, rate(high[key])),
927
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
928
+ h('div', { key: label + '-lo', style: cheapNow ? activeCell : undefined, className: cheapNow ? '' : 'dcm-cell-idle' }, rate(low[key])),
929
+ h('div', { key: label + '-hi', style: cheapNow ? undefined : activeCell, className: cheapNow ? 'dcm-cell-idle' : '' }, rate(high[key])),
655
930
  ]
656
931
 
657
- // Breakdown row for the single-model case: tokens in the bucket and what
658
- // they actually accrued, slot by slot, at the rate of each slot.
659
932
  const soleSlots = perModel.length === 1 ? used[0].slots : null
660
933
  const spentRow = (label, bucketField, rateField) => {
661
934
  const n = Number(usage && usage[bucketField]) || 0
@@ -669,9 +942,9 @@ window.__ModuleLoader__.load({
669
942
  usd = (n / 1e6) * (active[rateField] || 0)
670
943
  }
671
944
  return [
672
- h('div', { key: label + '-l', style: S.td }, label),
673
- h('div', { key: label + '-n', style: S.num }, tokens(n)),
674
- h('div', { key: label + '-v', style: S.cost }, money(usd * fx, currency)),
945
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
946
+ h('div', { key: label + '-n', className: 'dcm-num' }, tokens(n)),
947
+ h('div', { key: label + '-v', className: 'dcm-cost' }, money(usd * fx, currency)),
675
948
  ]
676
949
  }
677
950
 
@@ -683,23 +956,67 @@ window.__ModuleLoader__.load({
683
956
  .join(', ')
684
957
  const nowLocal = hhmmInZone(minute, zone, now)
685
958
 
959
+ // Copy textual report to clipboard
960
+ const copyReport = () => {
961
+ const lines = [
962
+ '=== ' + (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),
966
+ ]
967
+ if (hasUsage && usage) {
968
+ lines.push('Токены: вход ' + tokens(usage.inputTokens) + ', выход ' + tokens(usage.outputTokens) + ', кэш-хит ' + tokens(usage.cacheReadTokens))
969
+ }
970
+ const text = lines.join('\n')
971
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
972
+ navigator.clipboard.writeText(text).then(() => {
973
+ setCopied(true)
974
+ setTimeout(() => setCopied(false), 2000)
975
+ }).catch(() => {})
976
+ } else {
977
+ setCopied(true)
978
+ setTimeout(() => setCopied(false), 2000)
979
+ }
980
+ }
981
+
982
+ // Calculate upcoming discount hint
983
+ let discountHint = null
984
+ if (!flat && !cheapNow && leftMinutes) {
985
+ const nextTimeMinutes = (minute + leftMinutes) % DAY
986
+ const clockTime = hhmmInZone(nextTimeMinutes, zone, now)
987
+ discountHint = t('savings.hint', {
988
+ time: left,
989
+ clock: clockTime,
990
+ zone: zl,
991
+ })
992
+ }
993
+
686
994
  return h('div', { ref: box, style: { position: 'relative' } }, [
687
995
  h('button', {
688
996
  key: 'chip', type: 'button',
689
- style: Object.assign({}, S.chip, { background: bg, color: fg }),
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
+ },
690
1005
  onClick: () => setOpen((v) => !v),
691
- title: flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф',
1006
+ title: budgetExceeded
1007
+ ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1008
+ : (flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф'),
692
1009
  }, [
693
1010
  h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
694
1011
  left ? h('span', { key: 'left', style: { opacity: 0.75 } }, left) : null,
695
1012
  ]),
696
1013
 
697
- open ? h('div', { key: 'panel', style: S.panel }, [
698
- h('div', { key: 'title', style: S.title },
1014
+ open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1015
+ h('div', { key: 'title', className: 'dcm-panel-title' },
699
1016
  (state.route ? state.route.model : 'модель') +
700
1017
  (flat ? '' : ' — сейчас ' + (cheapNow ? 'не-пик' : t('peak')))),
701
1018
 
702
- h('div', { key: 'note', style: S.note },
1019
+ h('div', { key: 'note', className: 'dcm-panel-note' },
703
1020
  flat
704
1021
  ? 'Единая ставка, от времени суток не зависит'
705
1022
  : (cheapNow ? 'Действует льготная ставка' : 'Действует полная ставка') +
@@ -707,40 +1024,45 @@ window.__ModuleLoader__.load({
707
1024
 
708
1025
  flat
709
1026
  ? null
710
- : h('div', { key: 'clock', style: S.note },
1027
+ : h('div', { key: 'clock', className: 'dcm-panel-note' },
711
1028
  'Пик: ' + windowText + ' ' + zl + ' · сейчас ' + nowLocal + ' ' + zl),
712
1029
 
713
- h('div', { key: 'hr1', style: S.hr }),
1030
+ discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
1031
+
1032
+ budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1033
+ t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
1034
+
1035
+ h('hr', { key: 'hr1', className: 'dcm-hr' }),
714
1036
 
715
- h('div', { key: 'rates', style: flat ? S.gridFlat : S.grid }, [
716
- h('div', { key: 'h0', style: S.th }, '1M токенов, $'),
717
- flat ? null : h('div', { key: 'h1', style: S.thNum }, 'не-пик'),
718
- h('div', { key: 'h2', style: S.thNum }, flat ? 'ставка' : 'пик'),
1037
+ 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 ? 'ставка' : 'пик'),
719
1041
  rateRow('Вход (кэш-хит)', 'cacheHit'),
720
1042
  rateRow('Вход (мимо кэша)', 'input'),
721
1043
  showCacheWrite ? rateRow('Вход (запись в кэш)', 'cacheWrite') : null,
722
1044
  rateRow('Выход', 'output'),
723
1045
  ]),
724
1046
 
725
- h('div', { key: 'hr2', style: S.hr }),
1047
+ h('hr', { key: 'hr2', className: 'dcm-hr' }),
726
1048
 
727
1049
  multiModel
728
1050
  ? null
729
1051
  : [
730
- h('div', { key: 'btitle', style: S.section }, 'Токены сессии'),
731
- h('div', { key: 'breakdown', style: S.grid3 }, [
732
- h('div', { key: 'b0', style: S.th }, 'токенов'),
733
- h('div', { key: 'b1', style: S.thNum }, ''),
734
- h('div', { key: 'b2', style: S.thNum }, currency),
1052
+ h('div', { key: 'btitle', className: 'dcm-section-lbl' }, 'Токены сессии'),
1053
+ h('div', { key: 'breakdown', className: 'dcm-panel-grid-3' }, [
1054
+ h('div', { key: 'b0', className: 'dcm-th' }, 'токенов'),
1055
+ h('div', { key: 'b1', className: 'dcm-th-num' }, ''),
1056
+ h('div', { key: 'b2', className: 'dcm-th-num' }, currency),
735
1057
  spentRow('Вход (кэш-хит)', 'cacheReadTokens', 'cacheHit'),
736
1058
  spentRow('Вход (мимо кэша)', 'uncachedInputTokens', 'input'),
737
1059
  showCacheWrite ? spentRow('Вход (запись в кэш)', 'cacheWriteTokens', 'cacheWrite') : null,
738
1060
  spentRow('Выход', 'outputTokens', 'output'),
739
1061
  ]),
740
- h('div', { key: 'hr-models', style: S.hr }),
1062
+ h('hr', { key: 'hr-models', className: 'dcm-hr' }),
741
1063
  ],
742
1064
 
743
- h('div', { key: 'mtitle', style: S.section }, 'Расход по моделям'),
1065
+ h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, 'Расход по моделям'),
744
1066
  h(
745
1067
  'div',
746
1068
  { key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
@@ -777,7 +1099,7 @@ window.__ModuleLoader__.load({
777
1099
  textAlign: 'right',
778
1100
  fontVariantNumeric: 'tabular-nums',
779
1101
  whiteSpace: 'nowrap',
780
- color: m.known ? C.label : C.muted,
1102
+ color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
781
1103
  },
782
1104
  },
783
1105
  m.known ? (hasUsage ? money(m.cost, currency) : '—') : 'не указана стоимость',
@@ -787,15 +1109,23 @@ window.__ModuleLoader__.load({
787
1109
  ),
788
1110
  ),
789
1111
 
790
- h('div', { key: 'hr3', style: S.hr }),
1112
+ h('hr', { key: 'hr3', className: 'dcm-hr' }),
791
1113
 
792
- h('div', { key: 'total', style: S.total }, [
1114
+ h('div', { key: 'total', className: 'dcm-total' }, [
793
1115
  h('span', { key: 'l' }, 'Сессия:'),
794
1116
  h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : 'нет данных'),
795
1117
  ]),
796
1118
 
797
- hasUsage ? null : h('div', { key: 'nodata', style: Object.assign({}, S.note, { paddingTop: 6 }) },
1119
+ hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
798
1120
  'Провайдер ещё не сообщил usage для этой сессии.'),
1121
+
1122
+ h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
1123
+ h('button', {
1124
+ type: 'button',
1125
+ className: 'dcm-btn dcm-btn-sm',
1126
+ onClick: copyReport,
1127
+ }, copied ? t('action.copied') : t('action.copy_report'))
1128
+ ),
799
1129
  ]) : null,
800
1130
  ])
801
1131
  }
@@ -805,22 +1135,36 @@ window.__ModuleLoader__.load({
805
1135
  try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
806
1136
  }
807
1137
 
808
- ctx.slots.inject('conversation.session.header.utilities', () =>
809
- ctx.slots.register(
810
- { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
811
- (p) => React.createElement(CostMeter, { ...p, ctx }),
812
- ),
813
- )
1138
+ refreshMirrorUntilVisible(ctx)
814
1139
 
815
- ctx.slots.inject('settings.plugin.item', () =>
816
- ctx.slots.register(
817
- { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
818
- (p) => React.createElement(CostMeterCard, { ...p, ctx }),
819
- ),
820
- )
1140
+ if (ctx.slots && typeof ctx.slots.inject === 'function') {
1141
+ ctx.slots.inject('conversation.session.header.utilities', () =>
1142
+ ctx.slots.register(
1143
+ { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
1144
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeter, { ...p, ctx: (p && p.ctx) || ctx })),
1145
+ ),
1146
+ )
1147
+
1148
+ ctx.slots.inject('settings.plugin.item', () =>
1149
+ ctx.slots.register(
1150
+ { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
1151
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
1152
+ ),
1153
+ )
1154
+ }
821
1155
  }
822
1156
 
823
- module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
1157
+ module.exports = {
1158
+ apply,
1159
+ inject: ['slots', 'locale', 'settingsScope'],
1160
+ CostMeterCard,
1161
+ CostMeter,
1162
+ ErrorBoundary,
1163
+ ensureStyles,
1164
+ minutesUntilChange,
1165
+ extremes,
1166
+ ratesAt,
1167
+ }
824
1168
  return module.exports
825
1169
  },
826
1170
  })