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