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