@goodandready/dsh-cost-meter 0.7.8 → 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,123 +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
- }
16
+ // ----------------------------------------------------------- localization
53
17
 
54
18
  const ru = {
55
- title: 'Счётчик расходов (Cost Meter)',
56
- subtitle: 'Стоимость сессии в шапке, тарифы токенов и детализация по моделям',
57
- chipUnknown: 'тариф ?',
58
- chipRate: 'тариф',
59
- offPeak: 'не-пик',
60
- peak: 'пик',
61
- flat: 'единый',
62
- modelsBreakdown: 'Расход по моделям',
63
- sessionTokens: 'Токены сессии',
64
- totalCost: 'Итого за сессию',
65
- noPriceSpecified: 'не указана стоимость',
66
- noUsageYet: 'Провайдер ещё не сообщил usage для этой сессии.',
67
- routeUnknown: 'маршрут неизвестен',
68
- catalogUnavailable: 'Каталог OpenRouter недоступен: ',
69
- modelNotFound: 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.',
70
- 'settings.loading': 'Загрузка настроек…',
71
- 'settings.unavailable': 'Настройки недоступны (пространство ещё не зарегистрировано).',
72
- 'settings.save': 'Сохранить',
73
- 'settings.saving': 'Сохранение…',
74
- 'settings.saved': 'Сохранено',
75
- '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': 'Источник тарифа',
76
38
  'field.currency': 'Символ валюты',
39
+ 'hint.currency': 'Символ валюты для отображения расходов (например, $, ₽, €).',
77
40
  'field.usdRate': 'Курс валюты к USD',
78
- 'field.displayTimeZone': 'Часовой пояс отображения',
79
- 'hint.currency': 'Символ валюты, отображаемый в виджете (например, ₽, $, €).',
80
- 'hint.usdRate': 'Сколько единиц валюты в 1 долларе США (например, 80 для ₽).',
81
- 'hint.displayTimeZone': 'IANA таймзона для расчёта часов пика и не-пика (например, Europe/Moscow).',
82
- }
83
-
84
- let ChevronIcon = null
85
- try {
86
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
87
- ChevronIcon = primitives && primitives.IconChevronDownOutline14
88
- } catch {
89
- ChevronIcon = null
90
- }
91
-
92
- function Chevron(props) {
93
- if (ChevronIcon) {
94
- return React.createElement(ChevronIcon, {
95
- className: 'dcm-chev' + (props.open ? ' dcm-chev-open' : ''),
96
- style: {
97
- marginLeft: 'auto', flex: 'none', color: 'var(--dsw-alias-label-tertiary)',
98
- transition: 'transform .16s', transform: props.open ? 'rotate(180deg)' : 'none',
99
- },
100
- })
101
- }
102
- return React.createElement('svg', {
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
- width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true',
109
- }, React.createElement('path', {
110
- d: 'M3.5 5.25L7 8.75L10.5 5.25', stroke: 'currentColor', strokeWidth: 1.5,
111
- strokeLinecap: 'round', strokeLinejoin: 'round',
112
- }))
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: 'не-пик',
113
60
  }
114
61
 
115
- 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 } ' +
116
- '.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 } ' +
117
- '.dcm-title { color:var(--dsw-alias-label-primary); font-size:15px; font-weight:600; line-height:1.4 } ' +
118
- '.dcm-sub { color:var(--dsw-alias-label-secondary); font-size:13px } ' +
119
- '.dcm-body { border-top:1px solid var(--dsw-alias-border-l2); margin:0 16px; padding-bottom:8px } ' +
120
- '.dcm-field { display:flex; flex-direction:column; gap:6px; padding:12px 0 } ' +
121
- '.dcm-label { color:var(--dsw-alias-label-primary); font-size:13px; font-weight:500 } ' +
122
- '.dcm-hint { color:var(--dsw-alias-label-secondary); font-size:12px } ' +
123
- '.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 } ' +
124
- '.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 } ' +
125
- '.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) } ' +
126
- '.dcm-msg-ok { color:var(--dsw-alias-state-success-primary); font-size:12px } ' +
127
- '.dcm-msg-err { color:var(--dsw-alias-state-danger-primary, #e53935); font-size:12px }';
128
-
129
- function ensureCardStyles() {
130
- if (typeof document === 'undefined') return
131
- if (document.getElementById('dsh-cost-meter-card-styles')) return
132
- const el = document.createElement('style')
133
- el.id = 'dsh-cost-meter-card-styles'
134
- el.dataset.dshPlugin = 'dsh-cost-meter'
135
- el.textContent = cardCss
136
- 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',
137
104
  }
138
105
 
139
106
  function makeT(dict, fallback) {
@@ -149,13 +116,197 @@ window.__ModuleLoader__.load({
149
116
  }
150
117
 
151
118
  function useActiveLocale(ctx) {
119
+ const localeService = (ctx && typeof ctx.get === 'function' ? ctx.get('locale') : null) || (ctx && ctx.locale) || (ctx && ctx.services && ctx.services.locale)
152
120
  return React.useSyncExternalStore(
153
- React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
154
- React.useCallback(() => (ctx && ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot().active : 'ru'), [ctx]),
121
+ React.useMemo(() => (cb) => (localeService && typeof localeService.subscribe === 'function' ? localeService.subscribe(cb) : () => {}), [localeService]),
122
+ React.useCallback(() => (localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot().active : 'ru'), [localeService]),
155
123
  React.useCallback(() => 'ru', []),
156
124
  )
157
125
  }
158
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
+
159
310
  function CostMeterCard(props) {
160
311
  const ctx = props.ctx
161
312
  const locale = useActiveLocale(ctx)
@@ -165,10 +316,12 @@ window.__ModuleLoader__.load({
165
316
  const [saving, setSaving] = React.useState(false)
166
317
  const [err, setErr] = React.useState('')
167
318
  const [saved, setSaved] = React.useState(false)
319
+ const [diagState, setDiagState] = React.useState(null)
168
320
 
321
+ const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
169
322
  const scope = React.useMemo(
170
- () => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
171
- [ctx],
323
+ () => (settingsScope ? settingsScope.bind({ namespace: NS }) : undefined),
324
+ [settingsScope],
172
325
  )
173
326
 
174
327
  const snapshot = React.useSyncExternalStore(
@@ -177,7 +330,16 @@ window.__ModuleLoader__.load({
177
330
  React.useCallback(() => ({ status: 'loading' }), []),
178
331
  )
179
332
 
180
- 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])
181
343
 
182
344
  const status = (snapshot && snapshot.status) || 'loading'
183
345
  const stored = (snapshot && snapshot.value) || {}
@@ -188,14 +350,21 @@ window.__ModuleLoader__.load({
188
350
  currency: stored.currency !== undefined ? String(stored.currency) : '$',
189
351
  usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
190
352
  displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
353
+ useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
354
+ refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
191
355
  })
192
356
  }
193
357
  }, [status, stored, draft])
194
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
+
195
363
  const save = async () => {
196
364
  if (!scope || !draft) return
197
365
  setSaving(true); setErr(''); setSaved(false)
198
366
  const broken = []
367
+
199
368
  try {
200
369
  await scope.set('currency', String(draft.currency || '$'))
201
370
  } catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
@@ -218,6 +387,19 @@ window.__ModuleLoader__.load({
218
387
  } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
219
388
  }
220
389
 
390
+ try {
391
+ await scope.set('useOpenRouter', Boolean(draft.useOpenRouter))
392
+ } catch (e) { broken.push('useOpenRouter: ' + (e && e.message || String(e))) }
393
+
394
+ const hoursNum = Number(String(draft.refreshHours).trim())
395
+ if (!Number.isFinite(hoursNum) || hoursNum < 1 || hoursNum > 168) {
396
+ broken.push('refreshHours: must be between 1 and 168 hours')
397
+ } else {
398
+ try {
399
+ await scope.set('refreshHours', hoursNum)
400
+ } catch (e) { broken.push('refreshHours: ' + (e && e.message || String(e))) }
401
+ }
402
+
221
403
  setSaving(false)
222
404
  if (broken.length) {
223
405
  setErr(t('settings.saveFailed') + broken.join('; '))
@@ -227,59 +409,152 @@ window.__ModuleLoader__.load({
227
409
  setTimeout(() => setSaved(false), 2500)
228
410
  }
229
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
+
230
418
  return React.createElement('li', { className: 'dcm-card' },
231
419
  React.createElement('button', {
232
420
  type: 'button', className: 'dcm-head', 'aria-expanded': open,
233
421
  onClick: () => setOpen((v) => !v),
234
422
  },
235
- React.createElement('span', { style: { display: 'flex', flexDirection: 'column' } },
236
- React.createElement('span', { className: 'dcm-title' }, t('title')),
237
- 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
+ ),
238
439
  ),
239
440
  React.createElement(Chevron, { open }),
240
441
  ),
241
442
  open ? React.createElement('div', { className: 'dcm-body' },
242
443
  status === 'loading'
243
- ? 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'))
244
445
  : status !== 'ready'
245
- ? React.createElement('p', { className: 'dcm-msg-err', style: { padding: '12px 0' } }, t('settings.unavailable'))
246
- : React.createElement('div', null,
247
- React.createElement('div', { className: 'dcm-field' },
248
- React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
249
- React.createElement('input', {
250
- className: 'dcm-input',
251
- value: (draft && draft.currency) !== undefined ? draft.currency : '',
252
- onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
253
- }),
254
- React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
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
+ ),
255
487
  ),
256
- React.createElement('div', { className: 'dcm-field' },
257
- React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
258
- React.createElement('input', {
259
- className: 'dcm-input',
260
- type: 'number',
261
- step: 'any',
262
- value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
263
- onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
264
- }),
265
- React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
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
+ ),
266
516
  ),
267
- React.createElement('div', { className: 'dcm-field' },
268
- React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
269
- React.createElement('input', {
270
- className: 'dcm-input',
271
- value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
272
- onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
273
- }),
274
- React.createElement('span', { className: 'dcm-hint' }, t('hint.displayTimeZone')),
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
+ ),
275
548
  ),
276
- err ? React.createElement('div', { className: 'dcm-msg-err', style: { padding: '4px 0' } }, err) : null,
277
- 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,
278
553
  React.createElement('div', { className: 'dcm-foot' },
279
554
  React.createElement('button', {
280
555
  type: 'button',
281
- className: 'dcm-save',
282
- disabled: saving,
556
+ className: 'dcm-btn dcm-btn-primary',
557
+ disabled: saving || !tzValid || !rateValid || !hoursValid,
283
558
  onClick: save,
284
559
  }, saving ? t('settings.saving') : t('settings.save')),
285
560
  ),
@@ -288,21 +563,18 @@ window.__ModuleLoader__.load({
288
563
  )
289
564
  }
290
565
 
291
-
292
566
  // ---------------------------------------------------------------- tariff
293
567
 
294
568
  function inWindow(minute, w) {
295
569
  return w.start <= w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
296
570
  }
297
571
 
298
- /** Rates in force at a given UTC minute: the last matching window, else base. */
299
572
  function ratesAt(schedule, minute) {
300
573
  const windows = (schedule && schedule.windows) || []
301
574
  for (const w of windows) if (inWindow(minute, w)) return w.rates
302
575
  return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
303
576
  }
304
577
 
305
- /** Minutes until the rate card changes, or null when it never does. */
306
578
  function minutesUntilChange(schedule, minute) {
307
579
  const now = ratesAt(schedule, minute)
308
580
  for (let step = 1; step <= DAY; step += 1) {
@@ -312,10 +584,6 @@ window.__ModuleLoader__.load({
312
584
  return null
313
585
  }
314
586
 
315
- /**
316
- * The day's two extremes, by output rate: what the widget labels
317
- * "не-пик" and "пик". Equal extremes mean a flat tariff.
318
- */
319
587
  function extremes(schedule) {
320
588
  let low = null
321
589
  let high = null
@@ -327,7 +595,6 @@ window.__ModuleLoader__.load({
327
595
  return { low: low || {}, high: high || {}, flat: !low || !high || low.output === high.output }
328
596
  }
329
597
 
330
- /** Total tokens across every half-hour slot of one model. */
331
598
  function sumSlots(slots) {
332
599
  const total = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
333
600
  for (const key of Object.keys(slots || {})) {
@@ -337,11 +604,6 @@ window.__ModuleLoader__.load({
337
604
  return total
338
605
  }
339
606
 
340
- /**
341
- * Cost of one model, each half-hour slot charged at the rate that applied
342
- * in that slot. Slot 14 is 07:00–07:30 UTC, so its midpoint (07:15) picks
343
- * the window without landing on a boundary.
344
- */
345
607
  function spendBySlots(slots, schedule) {
346
608
  let usd = 0
347
609
  for (const key of Object.keys(slots || {})) {
@@ -351,7 +613,6 @@ window.__ModuleLoader__.load({
351
613
  return usd
352
614
  }
353
615
 
354
- /** Session spend in USD for one rate card. */
355
616
  function spendUsd(usage, rates) {
356
617
  const u = usage || {}
357
618
  const perMillion =
@@ -362,16 +623,15 @@ window.__ModuleLoader__.load({
362
623
  return perMillion / 1e6
363
624
  }
364
625
 
365
- // --------------------------------------------------------------- format
366
-
367
626
  function rate(value) {
368
627
  if (!value) return '—'
369
628
  const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
370
- return '$' + text.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
629
+ return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
371
630
  }
372
631
 
373
632
  function money(value, currency) {
374
- 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)
375
635
  }
376
636
 
377
637
  function tokens(value) {
@@ -390,20 +650,6 @@ window.__ModuleLoader__.load({
390
650
  return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
391
651
  }
392
652
 
393
- function isValidTimeZone(tz) {
394
- if (!tz || typeof tz !== 'string') return false
395
- try {
396
- new Intl.DateTimeFormat(undefined, { timeZone: tz })
397
- return true
398
- } catch {
399
- return false
400
- }
401
- }
402
-
403
- /**
404
- * Render a UTC minute-of-day in the display zone. Built from a real Date so
405
- * the zone's current offset (and any DST) comes from Intl, not arithmetic.
406
- */
407
653
  function hhmmInZone(minute, zone, reference) {
408
654
  const d = new Date(Date.UTC(
409
655
  reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
@@ -417,7 +663,6 @@ window.__ModuleLoader__.load({
417
663
  }
418
664
  }
419
665
 
420
- /** Short zone label for the panel: "МСК" for Moscow, else the city part. */
421
666
  function zoneLabel(zone) {
422
667
  if (!zone) return 'UTC'
423
668
  if (zone === 'Europe/Moscow') return 'МСК'
@@ -425,56 +670,7 @@ window.__ModuleLoader__.load({
425
670
  return city.replace(/_/g, ' ')
426
671
  }
427
672
 
428
- function ago(timestamp) {
429
- if (!timestamp) return 'нет данных'
430
- const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
431
- if (minutes < 60) return minutes + ' мин назад'
432
- const hours = Math.round(minutes / 60)
433
- return hours < 48 ? hours + ' ч назад' : Math.round(hours / 24) + ' дн назад'
434
- }
435
-
436
- // ---------------------------------------------------------------- styles
437
-
438
- const C = {
439
- label: 'var(--dsw-alias-label-primary)',
440
- muted: 'var(--dsw-alias-label-tertiary)',
441
- line: 'var(--dsw-alias-border-l2)',
442
- panelBg: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base))',
443
- okFg: 'var(--dsw-alias-state-success-primary)',
444
- okBg: 'var(--dsw-alias-state-success-tertiary)',
445
- warnFg: 'var(--dsw-alias-state-warn-primary)',
446
- warnBg: 'var(--dsw-alias-state-warn-tertiary)',
447
- }
448
-
449
- const S = {
450
- chip: {
451
- display: 'inline-flex', alignItems: 'center', gap: 6, height: 22, padding: '0 8px',
452
- borderRadius: 6, border: 'none', cursor: 'pointer', font: 'inherit', fontSize: 12,
453
- fontVariantNumeric: 'tabular-nums', lineHeight: '22px',
454
- },
455
- panel: {
456
- position: 'absolute', top: 28, right: 0, zIndex: 40, width: 320, padding: '12px 14px',
457
- borderRadius: 10, border: '1px solid ' + C.line, background: C.panelBg,
458
- boxShadow: '0 10px 30px rgba(0,0,0,.28)', fontSize: 12, lineHeight: 1.5,
459
- color: C.label, textAlign: 'left', fontVariantNumeric: 'tabular-nums',
460
- },
461
- title: { fontWeight: 600, paddingBottom: 4 },
462
- note: { color: C.muted },
463
- hr: { height: 1, background: C.line, margin: '10px -14px' },
464
- grid: { display: 'grid', gridTemplateColumns: '1fr 60px 60px', columnGap: 6, alignItems: 'center' },
465
- gridFlat: { display: 'grid', gridTemplateColumns: '1fr 72px', columnGap: 6, alignItems: 'center' },
466
- grid3: { display: 'grid', gridTemplateColumns: '1fr 58px 72px', columnGap: 6, alignItems: 'center' },
467
- th: { color: C.muted, paddingBottom: 4 },
468
- thNum: { color: C.muted, paddingBottom: 4, textAlign: 'right' },
469
- td: { padding: '3px 0' },
470
- cellIdle: { textAlign: 'right', padding: '3px 6px', borderRadius: 4, color: C.muted },
471
- num: { textAlign: 'right', color: C.muted, padding: '3px 0' },
472
- cost: { textAlign: 'right', padding: '3px 0' },
473
- section: { color: C.muted, paddingBottom: 4 },
474
- total: { display: 'flex', justifyContent: 'space-between', fontWeight: 600 },
475
- }
476
-
477
- // ------------------------------------------------------------ component
673
+ // ------------------------------------------------------------ CostMeter
478
674
 
479
675
  function CostMeter(props) {
480
676
  const ctx = props.ctx
@@ -487,7 +683,10 @@ window.__ModuleLoader__.load({
487
683
  const [open, setOpen] = React.useState(false)
488
684
  const [now, setNow] = React.useState(() => new Date())
489
685
  const box = React.useRef(null)
490
- const settingsScope = ctx.settingsScope || (ctx.services && ctx.services.settingsScope)
686
+
687
+ React.useEffect(() => { ensureStyles() }, [])
688
+
689
+ const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
491
690
  const settingsSnap = React.useSyncExternalStore
492
691
  ? React.useSyncExternalStore(
493
692
  (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}),
@@ -496,10 +695,6 @@ window.__ModuleLoader__.load({
496
695
  )
497
696
  : null
498
697
 
499
- // Every model this session actually billed, so each part is priced on its
500
- // own tariff instead of the current model's. Usage arrives split into
501
- // half-hour slots of the UTC day — spend is charged at the rate that was
502
- // in force then, so a tariff switch never reprices what is already spent.
503
698
  const used = (byModel && Array.isArray(byModel.models) ? byModel.models : [])
504
699
  .map((m) => ({ ...m, usage: sumSlots(m && m.slots) }))
505
700
  .filter((m) => m.usage.uncachedInputTokens || m.usage.outputTokens ||
@@ -518,8 +713,7 @@ window.__ModuleLoader__.load({
518
713
  })
519
714
  .catch(() => {})
520
715
  }, [])
521
- // Refetch when the set of billed models changes — a new model needs its
522
- // own tariff before its tokens can be priced.
716
+
523
717
  React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
524
718
  React.useEffect(() => { if (open) load(usedKeys) }, [open, load, usedKeys])
525
719
  React.useEffect(() => {
@@ -542,18 +736,18 @@ window.__ModuleLoader__.load({
542
736
  const hasUsage = usage !== undefined && usage !== null
543
737
  const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
544
738
 
545
- // No rate card for this route: say so instead of showing a fake zero.
546
739
  if (!schedule) {
547
740
  return h('div', { ref: box, style: { position: 'relative' } }, [
548
741
  h('button', {
549
742
  key: 'chip', type: 'button',
550
- style: Object.assign({}, S.chip, { background: 'transparent', color: C.muted }),
743
+ className: 'dcm-chip',
744
+ style: { background: 'transparent', color: 'var(--dsw-alias-label-tertiary)' },
551
745
  onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
552
746
  }, 'тариф ?'),
553
- open ? h('div', { key: 'panel', style: S.panel }, [
554
- 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' },
555
749
  state.route ? state.route.provider + ' / ' + state.route.model : t('routeUnknown')),
556
- h('div', { key: 'n', style: S.note },
750
+ h('div', { key: 'n', className: 'dcm-panel-note' },
557
751
  state.catalogError
558
752
  ? t('catalogUnavailable') + state.catalogError
559
753
  : 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.'),
@@ -566,9 +760,6 @@ window.__ModuleLoader__.load({
566
760
  const cheapNow = !flat && active.output === low.output
567
761
  const left = clock(minutesUntilChange(schedule, minute))
568
762
 
569
- // Per-model spend: each model on its own tariff, at that tariff's rate
570
- // for the current moment. Falls back to the session-wide totals when the
571
- // per-model projection is unavailable (older harness, headless).
572
763
  const schedules = state.schedules || {}
573
764
  const perModel = used.map((m) => {
574
765
  const key = m.provider + '/' + m.model
@@ -586,27 +777,22 @@ window.__ModuleLoader__.load({
586
777
  const total = perModel.length
587
778
  ? perModel.reduce((sum, m) => sum + m.cost, 0)
588
779
  : spendUsd(usage, active) * fx
589
- const unpriced = perModel.filter((m) => !m.known)
590
780
 
591
- const fg = cheapNow ? C.okFg : C.warnFg
592
- 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)'
593
783
  const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: bg, color: fg, fontWeight: 600 }
594
784
 
595
- // Two tiers: cheap and full side by side, the active one filled.
596
- // One tier: a single plain column, no highlight to explain.
597
785
  const rateRow = (label, key) => flat
598
786
  ? [
599
- h('div', { key: label + '-l', style: S.td }, label),
600
- 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])),
601
789
  ]
602
790
  : [
603
- h('div', { key: label + '-l', style: S.td }, label),
604
- h('div', { key: label + '-lo', style: cheapNow ? activeCell : S.cellIdle }, rate(low[key])),
605
- 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])),
606
794
  ]
607
795
 
608
- // Breakdown row for the single-model case: tokens in the bucket and what
609
- // they actually accrued, slot by slot, at the rate of each slot.
610
796
  const soleSlots = perModel.length === 1 ? used[0].slots : null
611
797
  const spentRow = (label, bucketField, rateField) => {
612
798
  const n = Number(usage && usage[bucketField]) || 0
@@ -620,9 +806,9 @@ window.__ModuleLoader__.load({
620
806
  usd = (n / 1e6) * (active[rateField] || 0)
621
807
  }
622
808
  return [
623
- h('div', { key: label + '-l', style: S.td }, label),
624
- h('div', { key: label + '-n', style: S.num }, tokens(n)),
625
- 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)),
626
812
  ]
627
813
  }
628
814
 
@@ -637,7 +823,8 @@ window.__ModuleLoader__.load({
637
823
  return h('div', { ref: box, style: { position: 'relative' } }, [
638
824
  h('button', {
639
825
  key: 'chip', type: 'button',
640
- 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)' },
641
828
  onClick: () => setOpen((v) => !v),
642
829
  title: flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф',
643
830
  }, [
@@ -645,12 +832,12 @@ window.__ModuleLoader__.load({
645
832
  left ? h('span', { key: 'left', style: { opacity: 0.75 } }, left) : null,
646
833
  ]),
647
834
 
648
- open ? h('div', { key: 'panel', style: S.panel }, [
649
- 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' },
650
837
  (state.route ? state.route.model : 'модель') +
651
838
  (flat ? '' : ' — сейчас ' + (cheapNow ? 'не-пик' : t('peak')))),
652
839
 
653
- h('div', { key: 'note', style: S.note },
840
+ h('div', { key: 'note', className: 'dcm-panel-note' },
654
841
  flat
655
842
  ? 'Единая ставка, от времени суток не зависит'
656
843
  : (cheapNow ? 'Действует льготная ставка' : 'Действует полная ставка') +
@@ -658,40 +845,40 @@ window.__ModuleLoader__.load({
658
845
 
659
846
  flat
660
847
  ? null
661
- : h('div', { key: 'clock', style: S.note },
848
+ : h('div', { key: 'clock', className: 'dcm-panel-note' },
662
849
  'Пик: ' + windowText + ' ' + zl + ' · сейчас ' + nowLocal + ' ' + zl),
663
850
 
664
- h('div', { key: 'hr1', style: S.hr }),
851
+ h('hr', { key: 'hr1', className: 'dcm-hr' }),
665
852
 
666
- h('div', { key: 'rates', style: flat ? S.gridFlat : S.grid }, [
667
- h('div', { key: 'h0', style: S.th }, '1M токенов, $'),
668
- flat ? null : h('div', { key: 'h1', style: S.thNum }, 'не-пик'),
669
- 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 ? 'ставка' : 'пик'),
670
857
  rateRow('Вход (кэш-хит)', 'cacheHit'),
671
858
  rateRow('Вход (мимо кэша)', 'input'),
672
859
  showCacheWrite ? rateRow('Вход (запись в кэш)', 'cacheWrite') : null,
673
860
  rateRow('Выход', 'output'),
674
861
  ]),
675
862
 
676
- h('div', { key: 'hr2', style: S.hr }),
863
+ h('hr', { key: 'hr2', className: 'dcm-hr' }),
677
864
 
678
865
  multiModel
679
866
  ? null
680
867
  : [
681
- h('div', { key: 'btitle', style: S.section }, 'Токены сессии'),
682
- h('div', { key: 'breakdown', style: S.grid3 }, [
683
- h('div', { key: 'b0', style: S.th }, 'токенов'),
684
- h('div', { key: 'b1', style: S.thNum }, ''),
685
- 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),
686
873
  spentRow('Вход (кэш-хит)', 'cacheReadTokens', 'cacheHit'),
687
874
  spentRow('Вход (мимо кэша)', 'uncachedInputTokens', 'input'),
688
875
  showCacheWrite ? spentRow('Вход (запись в кэш)', 'cacheWriteTokens', 'cacheWrite') : null,
689
876
  spentRow('Выход', 'outputTokens', 'output'),
690
877
  ]),
691
- h('div', { key: 'hr-models', style: S.hr }),
878
+ h('hr', { key: 'hr-models', className: 'dcm-hr' }),
692
879
  ],
693
880
 
694
- h('div', { key: 'mtitle', style: S.section }, 'Расход по моделям'),
881
+ h('div', { key: 'mtitle', className: 'dcm-section-lbl' }, 'Расход по моделям'),
695
882
  h(
696
883
  'div',
697
884
  { key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
@@ -728,7 +915,7 @@ window.__ModuleLoader__.load({
728
915
  textAlign: 'right',
729
916
  fontVariantNumeric: 'tabular-nums',
730
917
  whiteSpace: 'nowrap',
731
- color: m.known ? C.label : C.muted,
918
+ color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
732
919
  },
733
920
  },
734
921
  m.known ? (hasUsage ? money(m.cost, currency) : '—') : 'не указана стоимость',
@@ -738,14 +925,14 @@ window.__ModuleLoader__.load({
738
925
  ),
739
926
  ),
740
927
 
741
- h('div', { key: 'hr3', style: S.hr }),
928
+ h('hr', { key: 'hr3', className: 'dcm-hr' }),
742
929
 
743
- h('div', { key: 'total', style: S.total }, [
930
+ h('div', { key: 'total', className: 'dcm-total' }, [
744
931
  h('span', { key: 'l' }, 'Сессия:'),
745
932
  h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : 'нет данных'),
746
933
  ]),
747
934
 
748
- 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 } },
749
936
  'Провайдер ещё не сообщил usage для этой сессии.'),
750
937
  ]) : null,
751
938
  ])
@@ -756,22 +943,26 @@ window.__ModuleLoader__.load({
756
943
  try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
757
944
  }
758
945
 
759
- ctx.slots.inject('conversation.session.header.utilities', () =>
760
- ctx.slots.register(
761
- { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
762
- (p) => React.createElement(CostMeter, { ...p, ctx }),
763
- ),
764
- )
946
+ refreshMirrorUntilVisible(ctx)
765
947
 
766
- ctx.slots.inject('settings.plugin.item', () =>
767
- ctx.slots.register(
768
- { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
769
- (p) => React.createElement(CostMeterCard, { ...p, ctx }),
770
- ),
771
- )
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
+ }
772
963
  }
773
964
 
774
- module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
965
+ module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], CostMeterCard, CostMeter, ErrorBoundary, ensureStyles }
775
966
  return module.exports
776
967
  },
777
968
  })