@goodandready/dsh-cost-meter 0.7.9 → 0.8.0

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,98 @@ 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'
21
14
  const DAY = 1440
22
- const NS = 'dsh-cost-meter'
23
15
 
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
- }
16
+ // ----------------------------------------------------------- localization
57
17
 
58
18
  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': 'Не удалось сохранить настройки: ',
19
+ title: 'Учёт расходов',
20
+ subtitle: 'Тариф моделей, окна пиковых цен и стоимость сессии',
21
+ 'header.title': 'Учёт расходов (Cost Meter)',
22
+ 'header.sub': 'Мониторинг расходов токенов, автоматическое сопоставление тарифов и скидочных окон',
23
+ 'badge.catalog_ok': 'Каталог: {count} моделей',
24
+ 'badge.catalog_empty': 'Каталог: загрузка…',
25
+ 'badge.catalog_off': 'Каталог: выключен',
26
+ 'badge.catalog_err': 'Каталог: ошибка',
27
+ 'badge.deepseek': 'Тариф DeepSeek: активен',
28
+ 'section.general': 'Основные параметры',
29
+ 'section.general_desc': 'Отображение валюты, курса пересчёта и часового пояса в интерфейсе',
30
+ 'section.openrouter': 'Каталог цен OpenRouter',
31
+ 'section.openrouter_desc': 'Публичный каталог моделей для автоматического определения ставок и окон скидок',
32
+ 'section.diagnostics': 'Диагностика и статус',
33
+ 'section.diagnostics_desc': 'Текущее состояние фоновой синхронизации и кэша тарифов',
34
+ 'diag.models_count': 'Моделей в каталоге',
35
+ 'diag.last_sync': 'Последняя синхронизация',
36
+ 'diag.route': 'Активный маршрут',
37
+ 'diag.tariff_source': 'Источник тарифа',
80
38
  'field.currency': 'Символ валюты',
39
+ 'hint.currency': 'Символ валюты для отображения расходов (например, $, ₽, €).',
81
40
  '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
- }))
41
+ 'hint.usdRate': 'Количество единиц вашей валюты за 1 USD (1 для долларов).',
42
+ 'field.displayTimeZone': 'Временная зона (IANA)',
43
+ 'hint.displayTimeZone': 'Временная зона для отображения пиковых окон (например, Europe/Moscow, UTC).',
44
+ 'field.useOpenRouter': 'Использовать каталог OpenRouter',
45
+ 'hint.useOpenRouter': 'Автоматически загружать ставки и скидочные окна для моделей через OpenRouter API.',
46
+ 'field.refreshHours': 'Период обновления каталога (часов)',
47
+ 'hint.refreshHours': 'Интервал фонового обновления каталога в часах (от 1 до 168).',
48
+ 'settings.save': 'Сохранить настройки',
49
+ 'settings.saving': 'Сохранение…',
50
+ 'settings.saved': 'Настройки успешно сохранены',
51
+ 'settings.saveFailed': 'Ошибка сохранения: ',
52
+ 'settings.loading': 'Загрузка настроек…',
53
+ 'settings.unavailable': 'Настройки временно недоступны',
54
+ 'settings.retry': 'Повторить попытку',
55
+ chipUnknown: 'Тариф не определён',
56
+ routeUnknown: 'Маршрут не определён',
57
+ catalogUnavailable: 'Каталог недоступен: ',
58
+ peak: 'пик',
59
+ offpeak: 'не-пик',
121
60
  }
122
61
 
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)
62
+ const en = {
63
+ title: 'Cost Meter',
64
+ subtitle: 'Model rates, peak discount windows and session spend',
65
+ 'header.title': 'Cost Meter',
66
+ 'header.sub': 'Token spend monitoring, automatic tariff resolution and discount windows',
67
+ 'badge.catalog_ok': 'Catalog: {count} models',
68
+ 'badge.catalog_empty': 'Catalog: loading…',
69
+ 'badge.catalog_off': 'Catalog: disabled',
70
+ 'badge.catalog_err': 'Catalog: error',
71
+ 'badge.deepseek': 'DeepSeek tariff: active',
72
+ 'section.general': 'General Parameters',
73
+ 'section.general_desc': 'Display currency, conversion rate and time zone configuration',
74
+ 'section.openrouter': 'OpenRouter Pricing Catalog',
75
+ 'section.openrouter_desc': 'Public catalog for dynamic model pricing and discount windows',
76
+ 'section.diagnostics': 'Diagnostics & Status',
77
+ 'section.diagnostics_desc': 'Current state of background synchronization and tariff cache',
78
+ 'diag.models_count': 'Catalog models',
79
+ 'diag.last_sync': 'Last sync',
80
+ 'diag.route': 'Active route',
81
+ 'diag.tariff_source': 'Tariff source',
82
+ 'field.currency': 'Currency Symbol',
83
+ 'hint.currency': 'Currency symbol shown in the UI (e.g. $, ₽, €).',
84
+ 'field.usdRate': 'USD Exchange Rate',
85
+ 'hint.usdRate': 'Units of currency per 1 USD (1 keeps USD).',
86
+ 'field.displayTimeZone': 'Time Zone (IANA)',
87
+ 'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, UTC).',
88
+ 'field.useOpenRouter': 'Use OpenRouter Catalog',
89
+ 'hint.useOpenRouter': 'Pull rates and discount windows dynamically from public OpenRouter API.',
90
+ 'field.refreshHours': 'Catalog Refresh Interval (hours)',
91
+ 'hint.refreshHours': 'Background catalog fetch interval in hours (1 to 168).',
92
+ 'settings.save': 'Save Settings',
93
+ 'settings.saving': 'Saving…',
94
+ 'settings.saved': 'Settings saved successfully',
95
+ 'settings.saveFailed': 'Failed to save settings: ',
96
+ 'settings.loading': 'Loading settings…',
97
+ 'settings.unavailable': 'Settings temporarily unavailable',
98
+ 'settings.retry': 'Retry',
99
+ chipUnknown: 'Unknown tariff',
100
+ routeUnknown: 'Unknown route',
101
+ catalogUnavailable: 'Catalog unavailable: ',
102
+ peak: 'peak',
103
+ offpeak: 'off-peak',
147
104
  }
148
105
 
149
106
  function makeT(dict, fallback) {
@@ -167,6 +124,189 @@ window.__ModuleLoader__.load({
167
124
  )
168
125
  }
169
126
 
127
+ // ----------------------------------------------------------------- styles
128
+
129
+ const CSS = `
130
+ .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; }
131
+ .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; }
132
+ .dcm-title { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
133
+ .dcm-sub { color: var(--dsw-alias-label-secondary); font-size: 13px; }
134
+ .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; }
135
+
136
+ .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; }
137
+ .dcm-section-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); display: flex; align-items: center; justify-content: space-between; }
138
+ .dcm-section-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: -4px; line-height: 1.4; }
139
+
140
+ .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
141
+ .dcm-field { display: flex; flex-direction: column; gap: 6px; }
142
+ .dcm-row-check { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
143
+ .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary, #6366f1); cursor: pointer; }
144
+ .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
145
+ .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
146
+
147
+ .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; }
148
+ .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary, #6366f1); }
149
+ .dcm-input-err { border-color: var(--dsw-alias-state-error-primary, #ef4444); }
150
+
151
+ .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
152
+ .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; }
153
+ .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); }
154
+ .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); }
155
+ .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); }
156
+
157
+ .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; }
158
+ .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
159
+ .dcm-btn-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
160
+ .dcm-btn-primary:hover:not(:disabled) { opacity: 0.9; }
161
+ .dcm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
162
+
163
+ .dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
164
+ .dcm-alert-ok { background: rgba(16,185,129,0.1); color: var(--dsw-alias-state-success-primary); }
165
+ .dcm-alert-err { background: rgba(239,68,68,0.1); color: var(--dsw-alias-state-error-primary); }
166
+ .dcm-banner-warn { padding: 10px 14px; border-radius: 8px; background: rgba(245,158,11,0.1); color: var(--dsw-alias-state-warning-primary); font-size: 13px; }
167
+
168
+ .dcm-stat-box { padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); display: flex; flex-direction: column; gap: 2px; }
169
+ .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
170
+ .dcm-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-secondary); }
171
+
172
+ .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
173
+
174
+ .dcm-chip { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 6px; border: 1px solid transparent; cursor: pointer; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; line-height: 22px; transition: opacity .15s ease; }
175
+ .dcm-chip:hover { opacity: 0.88; }
176
+
177
+ .dcm-panel { position: absolute; top: 28px; right: 0; z-index: 40; width: 330px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); box-shadow: 0 10px 30px rgba(0,0,0,.28); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; font-variant-numeric: tabular-nums; }
178
+ .dcm-panel-title { font-weight: 600; padding-bottom: 4px; font-size: 13px; }
179
+ .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
180
+ .dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
181
+ .dcm-panel-grid { display: grid; grid-template-columns: 1fr 60px 60px; column-gap: 6px; align-items: center; }
182
+ .dcm-panel-grid-flat { display: grid; grid-template-columns: 1fr 72px; column-gap: 6px; align-items: center; }
183
+ .dcm-panel-grid-3 { display: grid; grid-template-columns: 1fr 58px 72px; column-gap: 6px; align-items: center; }
184
+ .dcm-th { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; }
185
+ .dcm-th-num { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; text-align: right; font-size: 11px; }
186
+ .dcm-td { padding: 3px 0; }
187
+ .dcm-cell-idle { text-align: right; padding: 3px 6px; border-radius: 4px; color: var(--dsw-alias-label-tertiary); }
188
+ .dcm-num { text-align: right; color: var(--dsw-alias-label-tertiary); padding: 3px 0; }
189
+ .dcm-cost { text-align: right; padding: 3px 0; }
190
+ .dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
191
+ .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13px; }
192
+ `
193
+
194
+ function ensureStyles() {
195
+ if (typeof document === 'undefined') return
196
+ if (document.getElementById('dsh-cost-meter-unified-css')) return
197
+ const el = document.createElement('style')
198
+ el.id = 'dsh-cost-meter-unified-css'
199
+ el.dataset.dshPlugin = NS
200
+ el.textContent = CSS
201
+ document.head.appendChild(el)
202
+ }
203
+
204
+ function createErrorBoundary() {
205
+ if (!React || typeof React.Component !== 'function') {
206
+ return function NoopBoundary(props) { return props?.children || null }
207
+ }
208
+ return class ErrorBoundary extends React.Component {
209
+ constructor(props) {
210
+ super(props)
211
+ this.state = { hasError: false, error: null }
212
+ }
213
+ static getDerivedStateFromError(error) {
214
+ return { hasError: true, error }
215
+ }
216
+ componentDidCatch(error, errorInfo) {
217
+ console.error('[dsh-cost-meter] React error caught:', error, errorInfo)
218
+ }
219
+ render() {
220
+ if (this.state.hasError) {
221
+ return React.createElement(
222
+ 'div',
223
+ { className: 'dcm-alert dcm-alert-err', style: { margin: '8px 0' } },
224
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } }, '⚠️ Cost Meter UI Error:'),
225
+ React.createElement('div', { style: { fontSize: 12, wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
226
+ React.createElement(
227
+ 'button',
228
+ {
229
+ type: 'button',
230
+ className: 'dcm-btn',
231
+ style: { marginTop: 8, fontSize: 12, padding: '3px 10px' },
232
+ onClick: () => this.setState({ hasError: false, error: null }),
233
+ },
234
+ 'Retry'
235
+ )
236
+ )
237
+ }
238
+ return this.props?.children || null
239
+ }
240
+ }
241
+ }
242
+ const ErrorBoundary = createErrorBoundary()
243
+
244
+ function refreshMirrorUntilVisible(ctx) {
245
+ const visible = () => {
246
+ try {
247
+ const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
248
+ const view = s?.describe?.()?.getSnapshot?.()?.view
249
+ return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
250
+ } catch (_) {
251
+ return false
252
+ }
253
+ }
254
+ if (visible()) return () => {}
255
+ let tries = 0
256
+ const timer = setInterval(() => {
257
+ if (visible() || tries >= 15) { clearInterval(timer); return }
258
+ tries += 1
259
+ try {
260
+ const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
261
+ s?.describe?.()?.load?.()
262
+ } catch (_) {}
263
+ }, 1000)
264
+ return () => clearInterval(timer)
265
+ }
266
+
267
+ function Chevron(props) {
268
+ return React.createElement(
269
+ 'svg',
270
+ {
271
+ width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
272
+ style: {
273
+ marginLeft: 'auto',
274
+ transform: props.open ? 'rotate(180deg)' : 'none',
275
+ transition: 'transform .18s ease',
276
+ color: 'var(--dsw-alias-label-secondary)',
277
+ },
278
+ },
279
+ React.createElement('path', {
280
+ d: 'M4 6L8 10L12 6',
281
+ stroke: 'currentColor',
282
+ strokeWidth: 1.6,
283
+ strokeLinecap: 'round',
284
+ strokeLinejoin: 'round',
285
+ }),
286
+ )
287
+ }
288
+
289
+ function isValidTimeZone(tz) {
290
+ if (!tz || typeof tz !== 'string') return false
291
+ try {
292
+ new Intl.DateTimeFormat(undefined, { timeZone: tz })
293
+ return true
294
+ } catch {
295
+ return false
296
+ }
297
+ }
298
+
299
+ function ago(timestamp) {
300
+ if (!timestamp) return 'нет данных'
301
+ const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
302
+ if (minutes < 1) return 'только что'
303
+ if (minutes < 60) return minutes + ' мин назад'
304
+ const hours = Math.round(minutes / 60)
305
+ return hours < 48 ? hours + ' ч назад' : Math.round(hours / 24) + ' дн назад'
306
+ }
307
+
308
+ // ---------------------------------------------------------------- card
309
+
170
310
  function CostMeterCard(props) {
171
311
  const ctx = props.ctx
172
312
  const locale = useActiveLocale(ctx)
@@ -176,6 +316,7 @@ window.__ModuleLoader__.load({
176
316
  const [saving, setSaving] = React.useState(false)
177
317
  const [err, setErr] = React.useState('')
178
318
  const [saved, setSaved] = React.useState(false)
319
+ const [diagState, setDiagState] = React.useState(null)
179
320
 
180
321
  const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
181
322
  const scope = React.useMemo(
@@ -189,7 +330,16 @@ window.__ModuleLoader__.load({
189
330
  React.useCallback(() => ({ status: 'loading' }), []),
190
331
  )
191
332
 
192
- React.useEffect(() => { ensureCardStyles() }, [])
333
+ React.useEffect(() => { ensureStyles() }, [])
334
+
335
+ // Poll state for live diagnostics
336
+ React.useEffect(() => {
337
+ if (!open) return
338
+ fetch(STATE_PATH, { headers: { accept: 'application/json' } })
339
+ .then((r) => (r.ok ? r.json() : null))
340
+ .then((data) => { if (data) setDiagState(data) })
341
+ .catch(() => {})
342
+ }, [open])
193
343
 
194
344
  const status = (snapshot && snapshot.status) || 'loading'
195
345
  const stored = (snapshot && snapshot.value) || {}
@@ -206,10 +356,15 @@ window.__ModuleLoader__.load({
206
356
  }
207
357
  }, [status, stored, draft])
208
358
 
359
+ const tzValid = draft ? isValidTimeZone(draft.displayTimeZone) : true
360
+ const rateValid = draft ? (!isNaN(Number(draft.usdRate)) && Number(draft.usdRate) > 0) : true
361
+ const hoursValid = draft ? (!isNaN(Number(draft.refreshHours)) && Number(draft.refreshHours) >= 1 && Number(draft.refreshHours) <= 168) : true
362
+
209
363
  const save = async () => {
210
364
  if (!scope || !draft) return
211
365
  setSaving(true); setErr(''); setSaved(false)
212
366
  const broken = []
367
+
213
368
  try {
214
369
  await scope.set('currency', String(draft.currency || '$'))
215
370
  } catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
@@ -237,8 +392,8 @@ window.__ModuleLoader__.load({
237
392
  } catch (e) { broken.push('useOpenRouter: ' + (e && e.message || String(e))) }
238
393
 
239
394
  const hoursNum = Number(String(draft.refreshHours).trim())
240
- if (!Number.isFinite(hoursNum) || hoursNum <= 0) {
241
- broken.push('refreshHours: must be a positive number')
395
+ if (!Number.isFinite(hoursNum) || hoursNum < 1 || hoursNum > 168) {
396
+ broken.push('refreshHours: must be between 1 and 168 hours')
242
397
  } else {
243
398
  try {
244
399
  await scope.set('refreshHours', hoursNum)
@@ -254,81 +409,152 @@ window.__ModuleLoader__.load({
254
409
  setTimeout(() => setSaved(false), 2500)
255
410
  }
256
411
 
412
+ const modelsCount = (diagState && typeof diagState.catalogModelsCount === 'number')
413
+ ? diagState.catalogModelsCount
414
+ : ((diagState && diagState.schedules) ? Object.keys(diagState.schedules).length : null)
415
+ const catalogHasErr = diagState && Boolean(diagState.catalogError)
416
+ const openRouterEnabled = draft ? Boolean(draft.useOpenRouter) : true
417
+
257
418
  return React.createElement('li', { className: 'dcm-card' },
258
419
  React.createElement('button', {
259
420
  type: 'button', className: 'dcm-head', 'aria-expanded': open,
260
421
  onClick: () => setOpen((v) => !v),
261
422
  },
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')),
423
+ React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
424
+ React.createElement('span', { className: 'dcm-title' }, t('header.title')),
425
+ React.createElement('span', { className: 'dcm-sub' }, t('header.sub')),
426
+ React.createElement('div', { className: 'dcm-badges' },
427
+ React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.deepseek')),
428
+ openRouterEnabled
429
+ ? (catalogHasErr
430
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-bad' }, t('badge.catalog_err'))
431
+ : (modelsCount !== null && modelsCount > 0
432
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.catalog_ok', { count: modelsCount }))
433
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_empty'))))
434
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_off')),
435
+ draft && draft.displayTimeZone
436
+ ? React.createElement('span', { className: 'dcm-badge' }, draft.displayTimeZone)
437
+ : null,
438
+ ),
265
439
  ),
266
440
  React.createElement(Chevron, { open }),
267
441
  ),
268
442
  open ? React.createElement('div', { className: 'dcm-body' },
269
443
  status === 'loading'
270
- ? React.createElement('p', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
444
+ ? React.createElement('div', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
271
445
  : 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')),
282
- ),
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')),
446
+ ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
447
+ : React.createElement(React.Fragment, null,
448
+ // Section 1: General Parameters
449
+ React.createElement('div', { className: 'dcm-section-card' },
450
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
451
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.general_desc')),
452
+ React.createElement('div', { className: 'dcm-grid-2' },
453
+ React.createElement('div', { className: 'dcm-field' },
454
+ React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
455
+ React.createElement('input', {
456
+ className: 'dcm-input',
457
+ value: (draft && draft.currency) !== undefined ? draft.currency : '',
458
+ onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
459
+ }),
460
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
461
+ ),
462
+ React.createElement('div', { className: 'dcm-field' },
463
+ React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
464
+ React.createElement('input', {
465
+ className: 'dcm-input' + (rateValid ? '' : ' dcm-input-err'),
466
+ type: 'number',
467
+ step: 'any',
468
+ value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
469
+ onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
470
+ }),
471
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
472
+ ),
473
+ ),
474
+ React.createElement('div', { className: 'dcm-field' },
475
+ React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
476
+ React.createElement('input', {
477
+ className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
478
+ value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
479
+ onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
480
+ }),
481
+ React.createElement('span', { className: 'dcm-hint' },
482
+ tzValid
483
+ ? t('hint.displayTimeZone')
484
+ : 'Некорректный идентификатор временной зоны IANA'
485
+ ),
486
+ ),
293
487
  ),
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')),
488
+
489
+ // Section 2: OpenRouter Pricing Catalog
490
+ React.createElement('div', { className: 'dcm-section-card' },
491
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.openrouter')),
492
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.openrouter_desc')),
493
+ React.createElement('div', { className: 'dcm-row-check' },
494
+ React.createElement('input', {
495
+ id: 'dcm-use-openrouter',
496
+ type: 'checkbox',
497
+ className: 'dcm-check',
498
+ checked: draft ? Boolean(draft.useOpenRouter) : true,
499
+ onChange: (e) => setDraft({ ...draft, useOpenRouter: e.target.checked }),
500
+ }),
501
+ React.createElement('label', { htmlFor: 'dcm-use-openrouter', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.useOpenRouter')),
502
+ ),
503
+ React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.useOpenRouter')),
504
+ React.createElement('div', { className: 'dcm-field', style: { marginTop: 4 } },
505
+ React.createElement('label', { className: 'dcm-label' }, t('field.refreshHours')),
506
+ React.createElement('input', {
507
+ className: 'dcm-input' + (hoursValid ? '' : ' dcm-input-err'),
508
+ type: 'number',
509
+ min: 1,
510
+ max: 168,
511
+ value: (draft && draft.refreshHours) !== undefined ? draft.refreshHours : '',
512
+ onChange: (e) => setDraft({ ...draft, refreshHours: e.target.value }),
513
+ }),
514
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.refreshHours')),
515
+ ),
312
516
  ),
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')),
517
+
518
+ // Section 3: Live Diagnostics
519
+ React.createElement('div', { className: 'dcm-section-card' },
520
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.diagnostics')),
521
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.diagnostics_desc')),
522
+ React.createElement('div', { className: 'dcm-grid-2' },
523
+ React.createElement('div', { className: 'dcm-stat-box' },
524
+ React.createElement('span', { className: 'dcm-stat-val' },
525
+ modelsCount !== null ? String(modelsCount) : '—'
526
+ ),
527
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.models_count')),
528
+ ),
529
+ React.createElement('div', { className: 'dcm-stat-box' },
530
+ React.createElement('span', { className: 'dcm-stat-val' },
531
+ diagState ? ago(diagState.fetchedAt) : '—'
532
+ ),
533
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.last_sync')),
534
+ ),
535
+ React.createElement('div', { className: 'dcm-stat-box' },
536
+ React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
537
+ diagState && diagState.route ? (diagState.route.provider + '/' + diagState.route.model) : 'default'
538
+ ),
539
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.route')),
540
+ ),
541
+ React.createElement('div', { className: 'dcm-stat-box' },
542
+ React.createElement('span', { className: 'dcm-stat-val', style: { fontSize: 12 } },
543
+ diagState && diagState.source ? String(diagState.source).toUpperCase() : 'DEEPSEEK'
544
+ ),
545
+ React.createElement('span', { className: 'dcm-stat-lbl' }, t('diag.tariff_source')),
546
+ ),
547
+ ),
324
548
  ),
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,
549
+
550
+ // Alerts & Action Footer
551
+ err ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, err) : null,
552
+ saved ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, t('settings.saved')) : null,
327
553
  React.createElement('div', { className: 'dcm-foot' },
328
554
  React.createElement('button', {
329
555
  type: 'button',
330
- className: 'dcm-save',
331
- disabled: saving,
556
+ className: 'dcm-btn dcm-btn-primary',
557
+ disabled: saving || !tzValid || !rateValid || !hoursValid,
332
558
  onClick: save,
333
559
  }, saving ? t('settings.saving') : t('settings.save')),
334
560
  ),
@@ -337,21 +563,18 @@ window.__ModuleLoader__.load({
337
563
  )
338
564
  }
339
565
 
340
-
341
566
  // ---------------------------------------------------------------- tariff
342
567
 
343
568
  function inWindow(minute, w) {
344
569
  return w.start <= w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
345
570
  }
346
571
 
347
- /** Rates in force at a given UTC minute: the last matching window, else base. */
348
572
  function ratesAt(schedule, minute) {
349
573
  const windows = (schedule && schedule.windows) || []
350
574
  for (const w of windows) if (inWindow(minute, w)) return w.rates
351
575
  return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
352
576
  }
353
577
 
354
- /** Minutes until the rate card changes, or null when it never does. */
355
578
  function minutesUntilChange(schedule, minute) {
356
579
  const now = ratesAt(schedule, minute)
357
580
  for (let step = 1; step <= DAY; step += 1) {
@@ -361,10 +584,6 @@ window.__ModuleLoader__.load({
361
584
  return null
362
585
  }
363
586
 
364
- /**
365
- * The day's two extremes, by output rate: what the widget labels
366
- * "не-пик" and "пик". Equal extremes mean a flat tariff.
367
- */
368
587
  function extremes(schedule) {
369
588
  let low = null
370
589
  let high = null
@@ -376,7 +595,6 @@ window.__ModuleLoader__.load({
376
595
  return { low: low || {}, high: high || {}, flat: !low || !high || low.output === high.output }
377
596
  }
378
597
 
379
- /** Total tokens across every half-hour slot of one model. */
380
598
  function sumSlots(slots) {
381
599
  const total = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
382
600
  for (const key of Object.keys(slots || {})) {
@@ -386,11 +604,6 @@ window.__ModuleLoader__.load({
386
604
  return total
387
605
  }
388
606
 
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
607
  function spendBySlots(slots, schedule) {
395
608
  let usd = 0
396
609
  for (const key of Object.keys(slots || {})) {
@@ -400,7 +613,6 @@ window.__ModuleLoader__.load({
400
613
  return usd
401
614
  }
402
615
 
403
- /** Session spend in USD for one rate card. */
404
616
  function spendUsd(usage, rates) {
405
617
  const u = usage || {}
406
618
  const perMillion =
@@ -411,16 +623,15 @@ window.__ModuleLoader__.load({
411
623
  return perMillion / 1e6
412
624
  }
413
625
 
414
- // --------------------------------------------------------------- format
415
-
416
626
  function rate(value) {
417
627
  if (!value) return '—'
418
628
  const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
419
- return '$' + text.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
629
+ return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
420
630
  }
421
631
 
422
632
  function money(value, currency) {
423
- return currency + value.toFixed(Math.abs(value) >= 100 ? 0 : 2)
633
+ const n = Number(value) || 0
634
+ return currency + n.toFixed(Math.abs(n) >= 100 ? 0 : 2)
424
635
  }
425
636
 
426
637
  function tokens(value) {
@@ -439,20 +650,6 @@ window.__ModuleLoader__.load({
439
650
  return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
440
651
  }
441
652
 
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
653
  function hhmmInZone(minute, zone, reference) {
457
654
  const d = new Date(Date.UTC(
458
655
  reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
@@ -466,7 +663,6 @@ window.__ModuleLoader__.load({
466
663
  }
467
664
  }
468
665
 
469
- /** Short zone label for the panel: "МСК" for Moscow, else the city part. */
470
666
  function zoneLabel(zone) {
471
667
  if (!zone) return 'UTC'
472
668
  if (zone === 'Europe/Moscow') return 'МСК'
@@ -474,56 +670,7 @@ window.__ModuleLoader__.load({
474
670
  return city.replace(/_/g, ' ')
475
671
  }
476
672
 
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
673
+ // ------------------------------------------------------------ CostMeter
527
674
 
528
675
  function CostMeter(props) {
529
676
  const ctx = props.ctx
@@ -536,6 +683,9 @@ window.__ModuleLoader__.load({
536
683
  const [open, setOpen] = React.useState(false)
537
684
  const [now, setNow] = React.useState(() => new Date())
538
685
  const box = React.useRef(null)
686
+
687
+ React.useEffect(() => { ensureStyles() }, [])
688
+
539
689
  const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
540
690
  const settingsSnap = React.useSyncExternalStore
541
691
  ? React.useSyncExternalStore(
@@ -545,10 +695,6 @@ window.__ModuleLoader__.load({
545
695
  )
546
696
  : null
547
697
 
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
698
  const used = (byModel && Array.isArray(byModel.models) ? byModel.models : [])
553
699
  .map((m) => ({ ...m, usage: sumSlots(m && m.slots) }))
554
700
  .filter((m) => m.usage.uncachedInputTokens || m.usage.outputTokens ||
@@ -567,8 +713,7 @@ window.__ModuleLoader__.load({
567
713
  })
568
714
  .catch(() => {})
569
715
  }, [])
570
- // Refetch when the set of billed models changes — a new model needs its
571
- // own tariff before its tokens can be priced.
716
+
572
717
  React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
573
718
  React.useEffect(() => { if (open) load(usedKeys) }, [open, load, usedKeys])
574
719
  React.useEffect(() => {
@@ -591,18 +736,18 @@ window.__ModuleLoader__.load({
591
736
  const hasUsage = usage !== undefined && usage !== null
592
737
  const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
593
738
 
594
- // No rate card for this route: say so instead of showing a fake zero.
595
739
  if (!schedule) {
596
740
  return h('div', { ref: box, style: { position: 'relative' } }, [
597
741
  h('button', {
598
742
  key: 'chip', type: 'button',
599
- style: Object.assign({}, S.chip, { background: 'transparent', color: C.muted }),
743
+ className: 'dcm-chip',
744
+ style: { background: 'transparent', color: 'var(--dsw-alias-label-tertiary)' },
600
745
  onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
601
746
  }, 'тариф ?'),
602
- open ? h('div', { key: 'panel', style: S.panel }, [
603
- h('div', { key: 't', style: S.title },
747
+ open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
748
+ h('div', { key: 't', className: 'dcm-panel-title' },
604
749
  state.route ? state.route.provider + ' / ' + state.route.model : t('routeUnknown')),
605
- h('div', { key: 'n', style: S.note },
750
+ h('div', { key: 'n', className: 'dcm-panel-note' },
606
751
  state.catalogError
607
752
  ? t('catalogUnavailable') + state.catalogError
608
753
  : 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.'),
@@ -615,9 +760,6 @@ window.__ModuleLoader__.load({
615
760
  const cheapNow = !flat && active.output === low.output
616
761
  const left = clock(minutesUntilChange(schedule, minute))
617
762
 
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
763
  const schedules = state.schedules || {}
622
764
  const perModel = used.map((m) => {
623
765
  const key = m.provider + '/' + m.model
@@ -635,27 +777,22 @@ window.__ModuleLoader__.load({
635
777
  const total = perModel.length
636
778
  ? perModel.reduce((sum, m) => sum + m.cost, 0)
637
779
  : spendUsd(usage, active) * fx
638
- const unpriced = perModel.filter((m) => !m.known)
639
780
 
640
- const fg = cheapNow ? C.okFg : C.warnFg
641
- const bg = cheapNow ? C.okBg : C.warnBg
781
+ const fg = cheapNow ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warning-primary)'
782
+ const bg = cheapNow ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)'
642
783
  const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: bg, color: fg, fontWeight: 600 }
643
784
 
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
785
  const rateRow = (label, key) => flat
647
786
  ? [
648
- h('div', { key: label + '-l', style: S.td }, label),
649
- h('div', { key: label + '-v', style: S.cost }, rate(active[key])),
787
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
788
+ h('div', { key: label + '-v', className: 'dcm-cost' }, rate(active[key])),
650
789
  ]
651
790
  : [
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])),
791
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
792
+ h('div', { key: label + '-lo', style: cheapNow ? activeCell : undefined, className: cheapNow ? '' : 'dcm-cell-idle' }, rate(low[key])),
793
+ h('div', { key: label + '-hi', style: cheapNow ? undefined : activeCell, className: cheapNow ? 'dcm-cell-idle' : '' }, rate(high[key])),
655
794
  ]
656
795
 
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
796
  const soleSlots = perModel.length === 1 ? used[0].slots : null
660
797
  const spentRow = (label, bucketField, rateField) => {
661
798
  const n = Number(usage && usage[bucketField]) || 0
@@ -669,9 +806,9 @@ window.__ModuleLoader__.load({
669
806
  usd = (n / 1e6) * (active[rateField] || 0)
670
807
  }
671
808
  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)),
809
+ h('div', { key: label + '-l', className: 'dcm-td' }, label),
810
+ h('div', { key: label + '-n', className: 'dcm-num' }, tokens(n)),
811
+ h('div', { key: label + '-v', className: 'dcm-cost' }, money(usd * fx, currency)),
675
812
  ]
676
813
  }
677
814
 
@@ -686,7 +823,8 @@ window.__ModuleLoader__.load({
686
823
  return h('div', { ref: box, style: { position: 'relative' } }, [
687
824
  h('button', {
688
825
  key: 'chip', type: 'button',
689
- style: Object.assign({}, S.chip, { background: bg, color: fg }),
826
+ className: 'dcm-chip',
827
+ style: { background: bg, color: fg, borderColor: cheapNow ? 'rgba(16,185,129,0.3)' : 'rgba(245,158,11,0.3)' },
690
828
  onClick: () => setOpen((v) => !v),
691
829
  title: flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф',
692
830
  }, [
@@ -694,12 +832,12 @@ window.__ModuleLoader__.load({
694
832
  left ? h('span', { key: 'left', style: { opacity: 0.75 } }, left) : null,
695
833
  ]),
696
834
 
697
- open ? h('div', { key: 'panel', style: S.panel }, [
698
- h('div', { key: 'title', style: S.title },
835
+ open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
836
+ h('div', { key: 'title', className: 'dcm-panel-title' },
699
837
  (state.route ? state.route.model : 'модель') +
700
838
  (flat ? '' : ' — сейчас ' + (cheapNow ? 'не-пик' : t('peak')))),
701
839
 
702
- h('div', { key: 'note', style: S.note },
840
+ h('div', { key: 'note', className: 'dcm-panel-note' },
703
841
  flat
704
842
  ? 'Единая ставка, от времени суток не зависит'
705
843
  : (cheapNow ? 'Действует льготная ставка' : 'Действует полная ставка') +
@@ -707,40 +845,40 @@ window.__ModuleLoader__.load({
707
845
 
708
846
  flat
709
847
  ? null
710
- : h('div', { key: 'clock', style: S.note },
848
+ : h('div', { key: 'clock', className: 'dcm-panel-note' },
711
849
  'Пик: ' + windowText + ' ' + zl + ' · сейчас ' + nowLocal + ' ' + zl),
712
850
 
713
- h('div', { key: 'hr1', style: S.hr }),
851
+ h('hr', { key: 'hr1', className: 'dcm-hr' }),
714
852
 
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 ? 'ставка' : 'пик'),
853
+ h('div', { key: 'rates', className: flat ? 'dcm-panel-grid-flat' : 'dcm-panel-grid' }, [
854
+ h('div', { key: 'h0', className: 'dcm-th' }, '1M токенов, $'),
855
+ flat ? null : h('div', { key: 'h1', className: 'dcm-th-num' }, 'не-пик'),
856
+ h('div', { key: 'h2', className: 'dcm-th-num' }, flat ? 'ставка' : 'пик'),
719
857
  rateRow('Вход (кэш-хит)', 'cacheHit'),
720
858
  rateRow('Вход (мимо кэша)', 'input'),
721
859
  showCacheWrite ? rateRow('Вход (запись в кэш)', 'cacheWrite') : null,
722
860
  rateRow('Выход', 'output'),
723
861
  ]),
724
862
 
725
- h('div', { key: 'hr2', style: S.hr }),
863
+ h('hr', { key: 'hr2', className: 'dcm-hr' }),
726
864
 
727
865
  multiModel
728
866
  ? null
729
867
  : [
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),
868
+ h('div', { key: 'btitle', className: 'dcm-section-lbl' }, 'Токены сессии'),
869
+ h('div', { key: 'breakdown', className: 'dcm-panel-grid-3' }, [
870
+ h('div', { key: 'b0', className: 'dcm-th' }, 'токенов'),
871
+ h('div', { key: 'b1', className: 'dcm-th-num' }, ''),
872
+ h('div', { key: 'b2', className: 'dcm-th-num' }, currency),
735
873
  spentRow('Вход (кэш-хит)', 'cacheReadTokens', 'cacheHit'),
736
874
  spentRow('Вход (мимо кэша)', 'uncachedInputTokens', 'input'),
737
875
  showCacheWrite ? spentRow('Вход (запись в кэш)', 'cacheWriteTokens', 'cacheWrite') : null,
738
876
  spentRow('Выход', 'outputTokens', 'output'),
739
877
  ]),
740
- h('div', { key: 'hr-models', style: S.hr }),
878
+ h('hr', { key: 'hr-models', className: 'dcm-hr' }),
741
879
  ],
742
880
 
743
- h('div', { key: 'mtitle', style: S.section }, 'Расход по моделям'),
881
+ h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, 'Расход по моделям'),
744
882
  h(
745
883
  'div',
746
884
  { key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
@@ -777,7 +915,7 @@ window.__ModuleLoader__.load({
777
915
  textAlign: 'right',
778
916
  fontVariantNumeric: 'tabular-nums',
779
917
  whiteSpace: 'nowrap',
780
- color: m.known ? C.label : C.muted,
918
+ color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
781
919
  },
782
920
  },
783
921
  m.known ? (hasUsage ? money(m.cost, currency) : '—') : 'не указана стоимость',
@@ -787,14 +925,14 @@ window.__ModuleLoader__.load({
787
925
  ),
788
926
  ),
789
927
 
790
- h('div', { key: 'hr3', style: S.hr }),
928
+ h('hr', { key: 'hr3', className: 'dcm-hr' }),
791
929
 
792
- h('div', { key: 'total', style: S.total }, [
930
+ h('div', { key: 'total', className: 'dcm-total' }, [
793
931
  h('span', { key: 'l' }, 'Сессия:'),
794
932
  h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : 'нет данных'),
795
933
  ]),
796
934
 
797
- hasUsage ? null : h('div', { key: 'nodata', style: Object.assign({}, S.note, { paddingTop: 6 }) },
935
+ hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
798
936
  'Провайдер ещё не сообщил usage для этой сессии.'),
799
937
  ]) : null,
800
938
  ])
@@ -805,22 +943,26 @@ window.__ModuleLoader__.load({
805
943
  try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
806
944
  }
807
945
 
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
- )
946
+ refreshMirrorUntilVisible(ctx)
814
947
 
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
- )
948
+ if (ctx.slots && typeof ctx.slots.inject === 'function') {
949
+ ctx.slots.inject('conversation.session.header.utilities', () =>
950
+ ctx.slots.register(
951
+ { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
952
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeter, { ...p, ctx: (p && p.ctx) || ctx })),
953
+ ),
954
+ )
955
+
956
+ ctx.slots.inject('settings.plugin.item', () =>
957
+ ctx.slots.register(
958
+ { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
959
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
960
+ ),
961
+ )
962
+ }
821
963
  }
822
964
 
823
- module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
965
+ module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], CostMeterCard, CostMeter, ErrorBoundary, ensureStyles }
824
966
  return module.exports
825
967
  },
826
968
  })