@goodandready/dsh-clinebot 0.5.32 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +63 -36
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/lib/adapter-messages.js +237 -0
- package/lib/adapter-stream.js +292 -0
- package/lib/adapter.js +616 -0
- package/lib/client.js +2564 -924
- package/lib/cline-client.js +15 -6
- package/lib/config.js +266 -233
- package/lib/credential-refs.js +104 -84
- package/lib/index.js +569 -502
- package/lib/model-prices.js +117 -0
- package/lib/provider-sync.js +404 -387
- package/lib/proxy-token.js +187 -140
- package/lib/routes/accounts.js +523 -403
- package/lib/routes/models.js +2 -1
- package/lib/routes/proxy.js +8 -27
- package/lib/routes/settings.js +63 -2
- package/lib/services.js +55 -0
- package/lib/stats-storage.js +364 -166
- package/lib/transport/cline-transport.js +314 -0
- package/lib/tui-settings.js +158 -0
- package/lib/updater.js +5 -1
- package/package.json +113 -77
package/lib/client.js
CHANGED
|
@@ -51,7 +51,7 @@ const en = {
|
|
|
51
51
|
'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
|
|
52
52
|
'badge.online': 'Host online ({latency} ms)',
|
|
53
53
|
'badge.offline': 'Host unreachable',
|
|
54
|
-
'badge.key_ok': 'Key
|
|
54
|
+
'badge.key_ok': 'Key set ({source})',
|
|
55
55
|
'badge.key_missing': 'Key missing',
|
|
56
56
|
'badge.registered': 'DSH Registered ({count} models)',
|
|
57
57
|
'badge.not_registered': 'Not registered',
|
|
@@ -65,8 +65,8 @@ const en = {
|
|
|
65
65
|
'key.saving': 'Saving…',
|
|
66
66
|
'key.login_fast': 'Open API Keys ↗',
|
|
67
67
|
'key.verifying': 'Validating key with Cline…',
|
|
68
|
-
'key.verified': '
|
|
69
|
-
'key.invalid': '
|
|
68
|
+
'key.verified': 'Valid key for {email} ({plan})',
|
|
69
|
+
'key.invalid': 'Key validation failed: {error}',
|
|
70
70
|
'key.env_label': 'Credential environment name: ',
|
|
71
71
|
'key.get_key': 'Get key in app.cline.bot console ↗',
|
|
72
72
|
'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
|
|
@@ -93,6 +93,11 @@ const en = {
|
|
|
93
93
|
'accounts.route_model_placeholder': 'Model ID (e.g. cline-pass/deepseek-v4-pro)',
|
|
94
94
|
'accounts.route_account_placeholder': 'Target Account',
|
|
95
95
|
'accounts.route_btn_add': 'Add Route',
|
|
96
|
+
'accounts.actions': 'Account actions', 'accounts.configured': 'Configured', 'accounts.missing_key': 'Missing key', 'accounts.source': 'Source: {source}',
|
|
97
|
+
'accounts.quota_5h': '5h {pct}%', 'accounts.rename': 'Rename', 'accounts.rename_save': 'Save name', 'accounts.rename_placeholder': 'Account label',
|
|
98
|
+
'accounts.clear_key': 'Clear stored key', 'accounts.clear_confirm': 'Clear the stored key for {name}? The account row stays.',
|
|
99
|
+
'accounts.remove_confirm': 'Remove {name} from the pool?', 'accounts.confirm': 'Confirm', 'accounts.cancel': 'Cancel', 'accounts.unpin': 'Unpin',
|
|
100
|
+
'accounts.no_accounts': 'No accounts configured yet.', 'accounts.route_remove': 'Remove route',
|
|
96
101
|
'quota.title': 'ClinePass Quota & Rate Limits',
|
|
97
102
|
'quota.desc': 'Official rolling window request limits from ClinePass',
|
|
98
103
|
'quota.account': 'Account: {email} · Plan: {plan}',
|
|
@@ -119,9 +124,9 @@ const en = {
|
|
|
119
124
|
'models.caps_vision': 'Vision',
|
|
120
125
|
'models.caps_general': 'General',
|
|
121
126
|
'models.caps_coding': 'Coding',
|
|
122
|
-
'diag.smoke_result': '
|
|
127
|
+
'diag.smoke_result': 'Latency: {latency} ms | Model: {model}\nResponse: {preview}',
|
|
123
128
|
'diag.empty': '(empty)',
|
|
124
|
-
'ui.error_title': '
|
|
129
|
+
'ui.error_title': 'ClineBot UI Error: ',
|
|
125
130
|
'ui.retry': 'Retry',
|
|
126
131
|
'update.check_failed': 'Failed to check for updates',
|
|
127
132
|
'update.retry': 'Retry check',
|
|
@@ -242,13 +247,86 @@ const en = {
|
|
|
242
247
|
'composer.completion_tok': 'Completion Tokens',
|
|
243
248
|
'composer.reasoning_tok': 'Reasoning Tokens',
|
|
244
249
|
'composer.est_cost': 'Estimated Cost',
|
|
250
|
+
'composer.unpriced': 'unpriced', 'composer.cache_read': 'Cache Read', 'composer.cache_write': 'Cache Write',
|
|
251
|
+
'composer.price_source': 'Price', 'composer.price_estimate': 'estimate', 'composer.price_override': 'override',
|
|
245
252
|
'discovery.title': 'Discovered Local Keys',
|
|
246
253
|
'discovery.import_btn': 'Import from {source}',
|
|
247
254
|
'discovery.imported': 'Imported to accounts pool',
|
|
248
255
|
'discovery.scanning': 'Scanning for local Cline keys…',
|
|
256
|
+
'settings.unsaved': '{count} unsaved change(s)', 'settings.saving': 'Saving…', 'settings.saved': 'Settings saved', 'settings.save_failed': 'Could not save settings', 'settings.save': 'Save', 'settings.discard': 'Discard',
|
|
257
|
+
'settings.save_invalid_base_url': 'Base URL must be https:// or a localhost address.', 'settings.save_invalid_number': 'Timeouts and payload limits must be positive numbers.',
|
|
258
|
+
'settings.models_group': 'Models & context', 'settings.models_group_hint': 'Plan filter, visible models, context windows, output limits and reasoning defaults.',
|
|
259
|
+
'settings.integrations': 'Integrations & display', 'settings.integrations_desc': 'Surfaces outside the chat request that reuse this provider.',
|
|
260
|
+
'settings.web_search': 'Serve web search with ClineBot', 'settings.web_search_hint': 'Use the ClinePass endpoint for the DSH web_search tool.',
|
|
261
|
+
'settings.sidebar_quota': 'Show quota meter in the sidebar', 'settings.sidebar_quota_hint': 'Display the compact plan and rolling-window meter above Settings.',
|
|
262
|
+
'settings.composer_cost': 'Show session cost in the composer', 'settings.composer_cost_hint': 'Display the estimated token cost beside the composer.',
|
|
263
|
+
'settings.proxy_mode': 'Transparent loopback proxy', 'settings.proxy_mode_hint': 'Legacy transport only: route requests through the local failover proxy.',
|
|
264
|
+
'settings.advanced': 'Advanced', 'settings.advanced_desc': 'Connection, timeouts, storage paths and the temporary transport switch.',
|
|
265
|
+
'settings.base_url': 'API base URL', 'settings.base_url_hint': 'ClinePass API root. Remote endpoints must use https://.',
|
|
266
|
+
'settings.timeout': 'Connect timeout (ms)', 'settings.timeout_hint': 'Maximum wait for the upstream response headers.', 'settings.smoke_timeout': 'Smoke-test timeout (ms)', 'settings.smoke_timeout_hint': 'Maximum wait for the diagnostic chat completion.',
|
|
267
|
+
'settings.stream_idle': 'Stream idle timeout (ms)', 'settings.stream_idle_hint': 'Maximum gap between SSE chunks before the stream is treated as dead.', 'settings.max_payload': 'Max multimodal payload (MB)', 'settings.max_payload_hint': 'Oldest images are evicted once the request exceeds this budget.',
|
|
268
|
+
'settings.stats_path': 'Stats file path', 'settings.stats_path_hint': 'Persistent JSON telemetry path (supports ~/).', 'settings.models_cache_path': 'Models cache path', 'settings.models_cache_path_hint': 'Offline model catalogue cache (supports ~/).',
|
|
269
|
+
'settings.transport': 'Transport', 'settings.transport_hint': 'Native ctx.llm adapter (recommended) or the legacy llm-pi-ai + loopback proxy.',
|
|
270
|
+
'providerCard.title': 'ClineBot (ClinePass)',
|
|
271
|
+
'providerCard.hint': 'Paste a ClinePass API key or manage the full account pool in Settings → ClineBot.',
|
|
272
|
+
'providerCard.keyPresent': 'Key configured',
|
|
273
|
+
'providerCard.keyMissing': 'Key missing',
|
|
274
|
+
'providerCard.routeActive': 'Route active',
|
|
275
|
+
'providerCard.placeholder': 'Paste API key',
|
|
276
|
+
'providerCard.replace': 'Replace API key',
|
|
277
|
+
'providerCard.save': 'Save key',
|
|
278
|
+
'providerCard.saving': 'Saving…',
|
|
279
|
+
'providerCard.saved': 'Key saved and validated',
|
|
280
|
+
'providerCard.failed': 'Could not save the key',
|
|
281
|
+
'providerCard.show': 'Show',
|
|
282
|
+
'providerCard.hide': 'Hide',
|
|
283
|
+
'providerCard.openSettings': 'Open Settings → ClineBot for accounts, models and quotas.',
|
|
284
|
+
'dashboard.tab_quota': 'Quota', 'dashboard.tab_analytics': 'Analytics', 'dashboard.auto_account': 'Automatic (least used)',
|
|
285
|
+
'analytics.title': 'Usage analytics', 'analytics.requests': 'Requests', 'analytics.tokens': 'Tokens', 'analytics.cost': 'Estimated cost', 'analytics.success': 'Success rate', 'analytics.ttft': 'Avg TTFT',
|
|
286
|
+
'analytics.loading': 'Loading analytics…', 'analytics.no_data': 'No analytics yet. Send a few messages first.',
|
|
287
|
+
'analytics.unpriced': '{count} request(s) have no price estimate and are excluded from cost totals.',
|
|
288
|
+
'analytics.group_model': 'Models', 'analytics.group_day': 'Days', 'analytics.group_session': 'Sessions',
|
|
289
|
+
'analytics.heatmap': 'Request heatmap', 'analytics.heatmap_hint': 'Requests per hour over the last 7 days.',
|
|
290
|
+
'analytics.heatmap_less': 'Less', 'analytics.heatmap_more': 'More',
|
|
291
|
+
'analytics.heatmap_empty': 'No hourly data yet. Send a message to start the heatmap.',
|
|
292
|
+
'analytics.input': 'Input', 'analytics.output': 'Output', 'analytics.reasoning': 'Reasoning',
|
|
293
|
+
'analytics.cache_read': 'Cache read', 'analytics.cache_write': 'Cache write',
|
|
294
|
+
'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency', 'analytics.errors': 'Errors',
|
|
295
|
+
'analytics.no_key': 'ClinePass key is not configured.',
|
|
296
|
+
'analytics.no_key_hint': 'Open Settings → ClineBot and paste a key, or import a discovered local key.',
|
|
297
|
+
'analytics.health_error': 'ClinePass endpoint is unreachable.',
|
|
298
|
+
'analytics.not_registered': 'ClineBot provider is not registered with DSH.',
|
|
299
|
+
'analytics.not_registered_hint': 'Open Settings → ClineBot and check Diagnostics.',
|
|
300
|
+
'analytics.stale': 'Showing the last successful snapshot.',
|
|
301
|
+
'analytics.range': 'Range', 'analytics.range_7': '7 days', 'analytics.range_30': '30 days', 'analytics.range_90': '90 days',
|
|
302
|
+
'analytics.heatmap_all_models': 'All models', 'analytics.heatmap_peak': 'Peak',
|
|
303
|
+
'analytics.heatmap_by_hour': 'By hour', 'analytics.heatmap_model': 'Heatmap model',
|
|
304
|
+
'quota.no_windows': 'No quota windows reported.',
|
|
305
|
+
'prices.title': 'Price overrides', 'prices.desc': 'USD per 1M tokens. Blank uses the local estimate.',
|
|
306
|
+
'prices.show': 'Show prices', 'prices.hide': 'Hide prices', 'prices.model': 'Model',
|
|
307
|
+
'prices.input': 'Input', 'prices.output': 'Output', 'prices.cache_read': 'Cache read', 'prices.cache_write': 'Cache write',
|
|
308
|
+
'prices.auto': 'auto', 'prices.empty': 'No models available.',
|
|
309
|
+
'prices.hint': 'These rates affect the composer and local analytics only; they are not an invoice.',
|
|
249
310
|
}
|
|
250
311
|
|
|
251
|
-
|
|
312
|
+
function makeT(dict, fallback) {
|
|
313
|
+
return function t(key, vars) {
|
|
314
|
+
if (key === 'quota.countdown' && vars?.time === 'resetting') {
|
|
315
|
+
return (dict && dict['quota.resetting']) || (fallback && fallback['quota.resetting']) || '正在重置'
|
|
316
|
+
}
|
|
317
|
+
let val = (dict && dict[key]) || (fallback && fallback[key]) || key
|
|
318
|
+
if (vars && typeof val === 'string') {
|
|
319
|
+
for (const k of Object.keys(vars)) {
|
|
320
|
+
val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return val
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- src/client/locales-zh.js ---
|
|
328
|
+
// Chinese locale dictionary.
|
|
329
|
+
const zh = {
|
|
252
330
|
nav: 'ClineBot',
|
|
253
331
|
title: 'ClineBot',
|
|
254
332
|
subtitle: 'ClinePass 订阅服务,包含动态套餐模型。',
|
|
@@ -274,8 +352,8 @@ const en = {
|
|
|
274
352
|
'key.saving': '正在保存…',
|
|
275
353
|
'key.login_fast': '打开 API 密钥页 ↗',
|
|
276
354
|
'key.verifying': '正在连接 Cline 验证密钥…',
|
|
277
|
-
'key.verified': '
|
|
278
|
-
'key.invalid': '
|
|
355
|
+
'key.verified': '密钥有效:{email} ({plan})',
|
|
356
|
+
'key.invalid': '密钥验证未通过:{error}',
|
|
279
357
|
'key.env_label': '凭据环境变量名称:',
|
|
280
358
|
'key.get_key': '前往 app.cline.bot 控制台获取密钥 ↗',
|
|
281
359
|
'key.saved_msg': '密钥已成功保存至 DSH 凭据服务。验证状态:{status}',
|
|
@@ -302,6 +380,11 @@ const en = {
|
|
|
302
380
|
'accounts.route_model_placeholder': '模型 ID (例如 cline-pass/deepseek-v4-pro)',
|
|
303
381
|
'accounts.route_account_placeholder': '目标账号',
|
|
304
382
|
'accounts.route_btn_add': '添加路由',
|
|
383
|
+
'accounts.actions': '账户操作', 'accounts.configured': '已配置', 'accounts.missing_key': '缺少密钥', 'accounts.source': '来源:{source}',
|
|
384
|
+
'accounts.quota_5h': '5小时 {pct}%', 'accounts.rename': '重命名', 'accounts.rename_save': '保存名称', 'accounts.rename_placeholder': '账户名称',
|
|
385
|
+
'accounts.clear_key': '清除已存密钥', 'accounts.clear_confirm': '清除 {name} 的已存密钥?账户行会保留。',
|
|
386
|
+
'accounts.remove_confirm': '从池中移除 {name}?', 'accounts.confirm': '确认', 'accounts.cancel': '取消', 'accounts.unpin': '取消固定',
|
|
387
|
+
'accounts.no_accounts': '尚未配置账户。', 'accounts.route_remove': '移除路由',
|
|
305
388
|
'quota.title': 'ClinePass 用量配额与限额监控',
|
|
306
389
|
'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
|
|
307
390
|
'quota.account': '账号:{email} · 套餐:{plan}',
|
|
@@ -328,9 +411,9 @@ const en = {
|
|
|
328
411
|
'models.caps_vision': '视觉',
|
|
329
412
|
'models.caps_general': '通用',
|
|
330
413
|
'models.caps_coding': '编程',
|
|
331
|
-
'diag.smoke_result': '
|
|
414
|
+
'diag.smoke_result': '延迟:{latency} ms | 模型:{model}\n响应:{preview}',
|
|
332
415
|
'diag.empty': '(空)',
|
|
333
|
-
'ui.error_title': '
|
|
416
|
+
'ui.error_title': 'ClineBot 界面错误:',
|
|
334
417
|
'ui.retry': '重试',
|
|
335
418
|
'update.check_failed': '检查更新失败',
|
|
336
419
|
'update.retry': '重试检查',
|
|
@@ -451,25 +534,66 @@ const en = {
|
|
|
451
534
|
'composer.completion_tok': '补全 Token',
|
|
452
535
|
'composer.reasoning_tok': '思考推理 Token',
|
|
453
536
|
'composer.est_cost': '预估花费',
|
|
537
|
+
'composer.unpriced': '无价格', 'composer.cache_read': '缓存读取', 'composer.cache_write': '缓存写入',
|
|
538
|
+
'composer.price_source': '价格来源', 'composer.price_estimate': '估算', 'composer.price_override': '覆盖',
|
|
454
539
|
'discovery.title': '发现本地密钥',
|
|
455
540
|
'discovery.import_btn': '从 {source} 导入',
|
|
456
541
|
'discovery.imported': '已成功导入账户池',
|
|
457
542
|
'discovery.scanning': '正在扫描本地 Cline 密钥…',
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
543
|
+
'settings.unsaved': '{count} 项未保存更改', 'settings.saving': '正在保存…', 'settings.saved': '设置已保存', 'settings.save_failed': '无法保存设置', 'settings.save': '保存', 'settings.discard': '放弃',
|
|
544
|
+
'settings.save_invalid_base_url': 'Base URL 必须使用 https:// 或 localhost。', 'settings.save_invalid_number': '超时和负载限制必须是正数。',
|
|
545
|
+
'settings.models_group': '模型与上下文', 'settings.models_group_hint': '套餐过滤、可见模型、上下文窗口、输出上限和推理默认值。',
|
|
546
|
+
'settings.integrations': '集成与显示', 'settings.integrations_desc': '聊天请求之外复用此服务商的界面入口。',
|
|
547
|
+
'settings.web_search': '使用 ClineBot 提供网页搜索', 'settings.web_search_hint': '为 DSH web_search 工具使用 ClinePass 端点。',
|
|
548
|
+
'settings.sidebar_quota': '在侧边栏显示配额', 'settings.sidebar_quota_hint': '在设置上方显示紧凑的套餐与滚动窗口配额。',
|
|
549
|
+
'settings.composer_cost': '在输入框显示会话成本', 'settings.composer_cost_hint': '在输入框旁显示预估 token 成本。',
|
|
550
|
+
'settings.proxy_mode': '透明回环代理', 'settings.proxy_mode_hint': '仅旧传输:通过本地故障转移代理发送请求。',
|
|
551
|
+
'settings.advanced': '高级', 'settings.advanced_desc': '连接、超时、存储路径和临时传输开关。',
|
|
552
|
+
'settings.base_url': 'API 基础地址', 'settings.base_url_hint': 'ClinePass API 根地址。远程端点必须使用 https://。',
|
|
553
|
+
'settings.timeout': '连接超时(毫秒)', 'settings.timeout_hint': '等待上游响应头的最大时间。', 'settings.smoke_timeout': '烟雾测试超时(毫秒)', 'settings.smoke_timeout_hint': '等待诊断聊天补全的最大时间。',
|
|
554
|
+
'settings.stream_idle': '流空闲超时(毫秒)', 'settings.stream_idle_hint': 'SSE 数据块之间的最大间隔,超过后视为断流。', 'settings.max_payload': '多模态负载上限(MB)', 'settings.max_payload_hint': '请求超过预算时会淘汰最旧的图片。',
|
|
555
|
+
'settings.stats_path': '统计文件路径', 'settings.stats_path_hint': '持久化 JSON 遥测路径(支持 ~/)。', 'settings.models_cache_path': '模型缓存路径', 'settings.models_cache_path_hint': '离线模型目录缓存(支持 ~/)。',
|
|
556
|
+
'settings.transport': '传输方式', 'settings.transport_hint': '原生 ctx.llm 适配器(推荐)或旧版 llm-pi-ai + 回环代理。',
|
|
557
|
+
'providerCard.title': 'ClineBot(ClinePass)',
|
|
558
|
+
'providerCard.hint': '粘贴 ClinePass API 密钥,或前往“设置 → ClineBot”管理完整账户池。',
|
|
559
|
+
'providerCard.keyPresent': '密钥已配置',
|
|
560
|
+
'providerCard.keyMissing': '缺少密钥',
|
|
561
|
+
'providerCard.routeActive': '路由已启用',
|
|
562
|
+
'providerCard.placeholder': '粘贴 API 密钥',
|
|
563
|
+
'providerCard.replace': '替换 API 密钥',
|
|
564
|
+
'providerCard.save': '保存密钥',
|
|
565
|
+
'providerCard.saving': '正在保存…',
|
|
566
|
+
'providerCard.saved': '密钥已保存并验证',
|
|
567
|
+
'providerCard.failed': '无法保存密钥',
|
|
568
|
+
'providerCard.show': '显示',
|
|
569
|
+
'providerCard.hide': '隐藏',
|
|
570
|
+
'providerCard.openSettings': '前往“设置 → ClineBot”管理账户、模型和配额。',
|
|
571
|
+
'dashboard.tab_quota': '配额', 'dashboard.tab_analytics': '分析', 'dashboard.auto_account': '自动(最少使用)',
|
|
572
|
+
'analytics.title': '用量分析', 'analytics.requests': '请求数', 'analytics.tokens': 'Token', 'analytics.cost': '预估成本', 'analytics.success': '成功率', 'analytics.ttft': '平均首字延迟',
|
|
573
|
+
'analytics.loading': '正在加载分析…', 'analytics.no_data': '暂无分析数据,请先发送几条消息。',
|
|
574
|
+
'analytics.unpriced': '{count} 个请求没有价格估算,未计入成本合计。',
|
|
575
|
+
'analytics.group_model': '模型', 'analytics.group_day': '日期', 'analytics.group_session': '会话',
|
|
576
|
+
'analytics.heatmap': '请求热力图', 'analytics.heatmap_hint': '最近 7 天每小时的请求数。',
|
|
577
|
+
'analytics.heatmap_less': '少', 'analytics.heatmap_more': '多',
|
|
578
|
+
'analytics.heatmap_empty': '暂无按小时数据。发送一条消息后开始记录。',
|
|
579
|
+
'analytics.input': '输入', 'analytics.output': '输出', 'analytics.reasoning': '推理',
|
|
580
|
+
'analytics.cache_read': '缓存读取', 'analytics.cache_write': '缓存写入',
|
|
581
|
+
'analytics.avg_ttft': '平均首字延迟', 'analytics.avg_latency': '平均延迟', 'analytics.errors': '错误',
|
|
582
|
+
'analytics.no_key': '尚未配置 ClinePass 密钥。',
|
|
583
|
+
'analytics.no_key_hint': '打开“设置 → ClineBot”粘贴密钥,或导入已发现的本地密钥。',
|
|
584
|
+
'analytics.health_error': 'ClinePass 端点不可达。',
|
|
585
|
+
'analytics.not_registered': 'ClineBot 提供商未在 DSH 中注册。',
|
|
586
|
+
'analytics.not_registered_hint': '打开“设置 → ClineBot”检查诊断。',
|
|
587
|
+
'analytics.stale': '正在显示上次成功获取的数据。',
|
|
588
|
+
'analytics.range': '范围', 'analytics.range_7': '7 天', 'analytics.range_30': '30 天', 'analytics.range_90': '90 天',
|
|
589
|
+
'analytics.heatmap_all_models': '所有模型', 'analytics.heatmap_peak': '峰值',
|
|
590
|
+
'analytics.heatmap_by_hour': '按小时', 'analytics.heatmap_model': '热力图模型',
|
|
591
|
+
'quota.no_windows': '未返回配额窗口。',
|
|
592
|
+
'prices.title': '价格覆盖', 'prices.desc': '每 100 万 token 的美元价格。留空使用本地估算。',
|
|
593
|
+
'prices.show': '显示价格', 'prices.hide': '隐藏价格', 'prices.model': '模型',
|
|
594
|
+
'prices.input': '输入', 'prices.output': '输出', 'prices.cache_read': '缓存读取', 'prices.cache_write': '缓存写入',
|
|
595
|
+
'prices.auto': '自动', 'prices.empty': '暂无模型。',
|
|
596
|
+
'prices.hint': '这些价格只影响输入框成本和本地分析,不是账单。',
|
|
473
597
|
}
|
|
474
598
|
|
|
475
599
|
// --- src/client/primitives.js ---
|
|
@@ -517,6 +641,121 @@ function BooleanControl(props) {
|
|
|
517
641
|
})
|
|
518
642
|
}
|
|
519
643
|
|
|
644
|
+
// --- src/client/icons.js ---
|
|
645
|
+
/**
|
|
646
|
+
* Calm 16px stroke icons. They inherit `currentColor` and never carry emoji or
|
|
647
|
+
* hardcoded palette values, so they follow the host theme in both modes.
|
|
648
|
+
*/
|
|
649
|
+
function CbIcon({ name, size = 16, className }) {
|
|
650
|
+
const common = {
|
|
651
|
+
width: size,
|
|
652
|
+
height: size,
|
|
653
|
+
viewBox: '0 0 16 16',
|
|
654
|
+
fill: 'none',
|
|
655
|
+
stroke: 'currentColor',
|
|
656
|
+
strokeWidth: 1.5,
|
|
657
|
+
strokeLinecap: 'round',
|
|
658
|
+
strokeLinejoin: 'round',
|
|
659
|
+
className: className || 'cb-icon',
|
|
660
|
+
'aria-hidden': 'true',
|
|
661
|
+
focusable: 'false',
|
|
662
|
+
}
|
|
663
|
+
const children = []
|
|
664
|
+
switch (name) {
|
|
665
|
+
case 'search':
|
|
666
|
+
children.push(
|
|
667
|
+
React.createElement('circle', { key: 'c', cx: 7, cy: 7, r: 4.5 }),
|
|
668
|
+
React.createElement('path', { key: 'l', d: 'M10.5 10.5 14 14' })
|
|
669
|
+
)
|
|
670
|
+
break
|
|
671
|
+
case 'plus':
|
|
672
|
+
children.push(
|
|
673
|
+
React.createElement('path', { key: 'v', d: 'M8 3v10' }),
|
|
674
|
+
React.createElement('path', { key: 'h', d: 'M3 8h10' })
|
|
675
|
+
)
|
|
676
|
+
break
|
|
677
|
+
case 'close':
|
|
678
|
+
children.push(React.createElement('path', { key: 'x', d: 'm4 4 8 8M12 4l-8 8' }))
|
|
679
|
+
break
|
|
680
|
+
case 'check':
|
|
681
|
+
children.push(React.createElement('path', { key: 'p', d: 'm3.5 8.5 3 3 6-7' }))
|
|
682
|
+
break
|
|
683
|
+
case 'warning':
|
|
684
|
+
children.push(
|
|
685
|
+
React.createElement('path', { key: 't', d: 'M8 2 14 13H2Z' }),
|
|
686
|
+
React.createElement('path', { key: 'l', d: 'M8 6v3' }),
|
|
687
|
+
React.createElement('circle', { key: 'd', cx: 8, cy: 11.2, r: 0.6, fill: 'currentColor', stroke: 'none' })
|
|
688
|
+
)
|
|
689
|
+
break
|
|
690
|
+
case 'error':
|
|
691
|
+
children.push(
|
|
692
|
+
React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 6 }),
|
|
693
|
+
React.createElement('path', { key: 'l', d: 'M8 4.5v4' }),
|
|
694
|
+
React.createElement('circle', { key: 'd', cx: 8, cy: 11.4, r: 0.6, fill: 'currentColor', stroke: 'none' })
|
|
695
|
+
)
|
|
696
|
+
break
|
|
697
|
+
case 'download':
|
|
698
|
+
children.push(
|
|
699
|
+
React.createElement('path', { key: 'a', d: 'M8 2v7' }),
|
|
700
|
+
React.createElement('path', { key: 'b', d: 'm5 6.5 3 3 3-3' }),
|
|
701
|
+
React.createElement('path', { key: 'c', d: 'M3 12.5h10' })
|
|
702
|
+
)
|
|
703
|
+
break
|
|
704
|
+
case 'chevron':
|
|
705
|
+
children.push(React.createElement('path', { key: 'p', d: 'm6 4 4 4-4 4' }))
|
|
706
|
+
break
|
|
707
|
+
case 'kebab':
|
|
708
|
+
children.push(
|
|
709
|
+
React.createElement('circle', { key: 'a', cx: 8, cy: 3.5, r: 1, fill: 'currentColor', stroke: 'none' }),
|
|
710
|
+
React.createElement('circle', { key: 'b', cx: 8, cy: 8, r: 1, fill: 'currentColor', stroke: 'none' }),
|
|
711
|
+
React.createElement('circle', { key: 'c', cx: 8, cy: 12.5, r: 1, fill: 'currentColor', stroke: 'none' })
|
|
712
|
+
)
|
|
713
|
+
break
|
|
714
|
+
case 'key':
|
|
715
|
+
children.push(
|
|
716
|
+
React.createElement('circle', { key: 'c', cx: 5, cy: 8, r: 2.5 }),
|
|
717
|
+
React.createElement('path', { key: 'a', d: 'M7.5 8H14' }),
|
|
718
|
+
React.createElement('path', { key: 'b', d: 'M11.5 8v2' }),
|
|
719
|
+
React.createElement('path', { key: 'd', d: 'M13.5 8v1.5' })
|
|
720
|
+
)
|
|
721
|
+
break
|
|
722
|
+
case 'edit':
|
|
723
|
+
children.push(
|
|
724
|
+
React.createElement('path', { key: 'a', d: 'M3 13l.5-3L11 2.5 13.5 5 6 12.5Z' }),
|
|
725
|
+
React.createElement('path', { key: 'b', d: 'm9.5 4 2.5 2.5' })
|
|
726
|
+
)
|
|
727
|
+
break
|
|
728
|
+
case 'trash':
|
|
729
|
+
children.push(
|
|
730
|
+
React.createElement('path', { key: 'a', d: 'M3 5h10' }),
|
|
731
|
+
React.createElement('path', { key: 'b', d: 'M6 5V3.5h4V5' }),
|
|
732
|
+
React.createElement('path', { key: 'c', d: 'M4.5 5 5 13h6l.5-8' })
|
|
733
|
+
)
|
|
734
|
+
break
|
|
735
|
+
case 'route':
|
|
736
|
+
children.push(
|
|
737
|
+
React.createElement('path', { key: 'a', d: 'M3 5h6a2 2 0 0 1 0 4H5a2 2 0 0 0 0 4h8' }),
|
|
738
|
+
React.createElement('path', { key: 'b', d: 'm11 10 2 3-2 3' })
|
|
739
|
+
)
|
|
740
|
+
break
|
|
741
|
+
case 'refresh':
|
|
742
|
+
children.push(
|
|
743
|
+
React.createElement('path', { key: 'a', d: 'M13 8a5 5 0 1 1-1.5-3.5' }),
|
|
744
|
+
React.createElement('path', { key: 'b', d: 'M13 2.5V6h-3.5' })
|
|
745
|
+
)
|
|
746
|
+
break
|
|
747
|
+
case 'account':
|
|
748
|
+
children.push(
|
|
749
|
+
React.createElement('circle', { key: 'a', cx: 8, cy: 5.5, r: 2.5 }),
|
|
750
|
+
React.createElement('path', { key: 'b', d: 'M3 13c.6-2.5 2.6-4 5-4s4.4 1.5 5 4' })
|
|
751
|
+
)
|
|
752
|
+
break
|
|
753
|
+
default:
|
|
754
|
+
children.push(React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 6 }))
|
|
755
|
+
}
|
|
756
|
+
return React.createElement('svg', common, children)
|
|
757
|
+
}
|
|
758
|
+
|
|
520
759
|
// --- src/client/styles.js ---
|
|
521
760
|
function ensureCss() {
|
|
522
761
|
if (typeof document === 'undefined') return
|
|
@@ -532,7 +771,7 @@ function ensureCss() {
|
|
|
532
771
|
|
|
533
772
|
.cb-section-card{display:flex;flex-direction:column;gap:12px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}
|
|
534
773
|
.cb-section-title{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 12px;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
|
|
535
|
-
.cb-section-desc{margin-top:-6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
774
|
+
.cb-section-desc{margin-top:-6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
|
|
536
775
|
|
|
537
776
|
.cb-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
|
538
777
|
.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:12px}
|
|
@@ -559,10 +798,13 @@ function ensureCss() {
|
|
|
559
798
|
.cb-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)}
|
|
560
799
|
.cb-btn-disabled{opacity:.5;cursor:not-allowed}
|
|
561
800
|
|
|
562
|
-
.cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
|
|
563
|
-
.cb-progress-
|
|
564
|
-
.cb-progress-label
|
|
565
|
-
.cb-progress-
|
|
801
|
+
.cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;min-width:0;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
|
|
802
|
+
.cb-progress-card-bad{border-color:var(--dsw-alias-state-error-primary)}
|
|
803
|
+
.cb-progress-head, .cb-bar-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:4px 12px;width:100%;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
|
|
804
|
+
.cb-progress-label{flex:0 0 auto;font-weight:500;white-space:nowrap}
|
|
805
|
+
.cb-progress-meta, .cb-bar-meta{flex:1 1 140px;min-width:0;margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;text-align:right;white-space:normal;overflow-wrap:anywhere}
|
|
806
|
+
.cb-progress-meta-bad{color:var(--dsw-alias-state-error-primary)}
|
|
807
|
+
.cb-progress-low{display:inline-flex;align-items:center;gap:4px;margin-left:6px;font-size:11px;color:var(--dsw-alias-state-error-primary)}
|
|
566
808
|
.cb-progress-track, .cb-bar-track{width:100%;height:6px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
|
|
567
809
|
.cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
|
|
568
810
|
.cb-progress-bar-warn{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-state-warn-primary);transition:width .3s ease}
|
|
@@ -594,15 +836,19 @@ function ensureCss() {
|
|
|
594
836
|
.cb-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3)}
|
|
595
837
|
.cb-foot:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
|
|
596
838
|
.cb-footTop{display:flex;align-items:center;gap:8px;min-width:0}
|
|
597
|
-
.cb-footName{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:20px;white-space:nowrap;text-overflow:ellipsis}
|
|
839
|
+
.cb-footName{flex:1 1 auto;min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:20px;white-space:nowrap;text-overflow:ellipsis}
|
|
598
840
|
.cb-footRow{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
599
841
|
.cb-footHead{display:flex;align-items:baseline;gap:8px;min-width:0}
|
|
600
|
-
.cb-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
|
|
601
|
-
.cb-footAmount{flex:
|
|
842
|
+
.cb-footLabel{flex:1;min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px;white-space:nowrap;text-overflow:ellipsis}
|
|
843
|
+
.cb-footAmount{flex:0 1 auto;max-width:68%;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap;text-overflow:ellipsis}
|
|
844
|
+
.cb-footRowMissing .cb-footLabel{opacity:.65}
|
|
845
|
+
.cb-footAmountMissing{color:var(--dsw-alias-label-tertiary)}
|
|
846
|
+
.cb-footMessage{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
|
|
602
847
|
.cb-footBar{display:block;height:4px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
|
|
603
848
|
.cb-footFill{display:block;height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
|
|
604
849
|
.cb-footFillWarn{background:var(--dsw-alias-state-error-primary)}
|
|
605
|
-
.cb-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums}
|
|
850
|
+
.cb-footPct{margin-left:auto;flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;font-weight:600;line-height:16px;font-variant-numeric:tabular-nums}
|
|
851
|
+
.cb-footPctWarn{color:var(--dsw-alias-state-error-primary)}
|
|
606
852
|
.cb-railButton{box-sizing:border-box;flex:none;display:inline-flex;justify-content:center;align-items:center;width:36px;height:36px;margin:0 0 4px;padding:0;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
607
853
|
.cb-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
608
854
|
.cb-railButton:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
|
|
@@ -611,11 +857,261 @@ function ensureCss() {
|
|
|
611
857
|
/* The window chrome and its width belong to the shell's Modal, so the body only
|
|
612
858
|
fills whatever it is given — a fixed width here overflows the panel. */
|
|
613
859
|
.cb-dialog{display:flex;flex-direction:column;gap:12px;width:100%;min-width:0;max-height:min(72vh, 640px);overflow-y:auto;color:var(--dsw-alias-label-primary)}
|
|
860
|
+
.cb-panel{display:flex;flex-direction:column;gap:14px;width:100%;max-width:780px;margin:0 auto;padding:18px;height:100%;max-height:calc(100vh - 56px);min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;color:var(--dsw-alias-label-primary)}
|
|
861
|
+
.cb-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
862
|
+
.cb-panel-identity{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
863
|
+
.cb-panel-title{color:var(--dsw-alias-label-primary);font-size:18px;font-weight:650;line-height:24px}
|
|
864
|
+
.cb-panel-sub{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
865
|
+
.cb-panel-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;flex:none}
|
|
866
|
+
.cb-panel-updated{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
|
|
867
|
+
.cb-panel-close{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:18px;line-height:1;cursor:pointer}
|
|
868
|
+
.cb-panel-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
869
|
+
.cb-notice{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}
|
|
870
|
+
.cb-notice-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px}
|
|
871
|
+
.cb-notice-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
872
|
+
.cb-notice-error{border-color:var(--dsw-alias-state-error-primary)}
|
|
873
|
+
.cb-notice-error .cb-notice-title{color:var(--dsw-alias-state-error-primary)}
|
|
874
|
+
@media(max-width:720px){.cb-panel{padding:12px;max-height:none}}
|
|
614
875
|
.cb-dialogMeta{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
|
|
615
876
|
.cb-dialogCard{display:flex;flex-direction:column;gap:12px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}
|
|
616
877
|
.cb-dialogFallback{position:fixed;inset:0;z-index:900;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1)}
|
|
617
878
|
.cb-dialogFallbackPanel{display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 96px);overflow-y:auto;padding:18px;border-radius:var(--dsw-radius-panel);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
|
|
618
879
|
|
|
880
|
+
/* Provider card inside Settings -> Models. */
|
|
881
|
+
.cb-provider-card{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
|
|
882
|
+
.cb-provider-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
883
|
+
.cb-provider-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:20px}
|
|
884
|
+
.cb-provider-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
885
|
+
|
|
886
|
+
/* Grouped settings, disclosure rows and the staged-save bar. */
|
|
887
|
+
.cb-settings-group{display:flex;flex-direction:column;gap:10px;padding:4px 0}
|
|
888
|
+
.cb-settings-group-divided{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:16px;margin-top:4px}
|
|
889
|
+
.cb-settings-group-head{display:flex;align-items:center;gap:8px;margin:0;padding:0}
|
|
890
|
+
.cb-settings-disclosure{width:100%;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;padding:2px 0}
|
|
891
|
+
.cb-settings-group-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px;font-weight:600}
|
|
892
|
+
.cb-settings-group-action{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
893
|
+
.cb-settings-group-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
894
|
+
.cb-settings-group-body{display:flex;flex-direction:column;gap:2px}
|
|
895
|
+
.cb-settings-spacer{flex:1 1 auto}
|
|
896
|
+
.cb-settings-chevron{display:inline-flex;justify-content:center;align-items:center;width:18px;height:18px;color:var(--dsw-alias-label-tertiary);font-size:18px;line-height:1;transition:transform .16s ease}
|
|
897
|
+
.cb-settings-chevron-open{transform:rotate(90deg)}
|
|
898
|
+
.cb-settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:12px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
899
|
+
.cb-settings-row:last-child{border-bottom:0}
|
|
900
|
+
.cb-settings-row-text{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
901
|
+
.cb-settings-row-title{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;font-weight:500}
|
|
902
|
+
.cb-settings-row-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
903
|
+
.cb-settings-row-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
|
|
904
|
+
.cb-settings-row-control{display:flex;align-items:center;gap:8px;flex:none}
|
|
905
|
+
.cb-settings-input{width:220px;max-width:46vw}
|
|
906
|
+
.cb-savebar{position:sticky;bottom:0;z-index:30;margin:18px -4px -4px;padding:10px 4px 4px;background:linear-gradient(to top,var(--dsw-alias-bg-layer-1) 70%,transparent)}
|
|
907
|
+
.cb-savebar-inner{display:flex;align-items:center;gap:10px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
|
|
908
|
+
.cb-savebar-text{min-width:0;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:nowrap;text-overflow:ellipsis}
|
|
909
|
+
|
|
910
|
+
/* Calm icon and account-pool primitives. */
|
|
911
|
+
.cb-icon{flex:none;display:inline-block;vertical-align:-2px}
|
|
912
|
+
.cb-icon-chevron-open{transform:rotate(90deg)}
|
|
913
|
+
.cb-banner-icon{display:inline-flex;flex:none;align-items:center;justify-content:center;color:inherit}
|
|
914
|
+
.cb-inline-alert{display:flex;align-items:center;gap:6px}
|
|
915
|
+
.cb-mono{font-family:var(--ds-font-family-code,monospace);font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
916
|
+
.cb-muted{color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
917
|
+
.cb-empty{padding:16px 0;color:var(--dsw-alias-label-secondary);font-size:12px;text-align:center}
|
|
918
|
+
.cb-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
919
|
+
.cb-accounts .cb-section-desc{margin-bottom:8px}
|
|
920
|
+
.cb-discovered{display:flex;flex-direction:column;gap:6px;margin:8px 0;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
|
|
921
|
+
.cb-discovered-head{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:12px;font-weight:500}
|
|
922
|
+
.cb-discovered-row{display:flex;align-items:center;gap:8px;font-size:12px}
|
|
923
|
+
.cb-discovered-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
924
|
+
.cb-account-mode{display:flex;align-items:center;gap:8px;margin:8px 0 4px;font-size:12px}
|
|
925
|
+
.cb-seg{display:inline-flex;overflow:hidden;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md)}
|
|
926
|
+
.cb-seg-item{padding:4px 10px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
|
|
927
|
+
.cb-seg-item+.cb-seg-item{border-left:.5px solid var(--dsw-alias-border-l2)}
|
|
928
|
+
.cb-seg-item-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}
|
|
929
|
+
.cb-account-add{display:flex;flex-direction:column;gap:8px;margin:8px 0 12px;padding:12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
|
|
930
|
+
.cb-account-add-row{display:flex;gap:8px;flex-wrap:wrap}
|
|
931
|
+
.cb-account-add-row .cb-input{flex:1 1 180px}
|
|
932
|
+
.cb-account-add-actions{display:flex;justify-content:flex-end;gap:8px}
|
|
933
|
+
.cb-account-list{display:flex;flex-direction:column;margin-top:4px}
|
|
934
|
+
.cb-account-item{position:relative;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
935
|
+
.cb-account-item:last-child{border-bottom:0}
|
|
936
|
+
.cb-account-row{display:flex;align-items:center;gap:10px;padding:10px 0}
|
|
937
|
+
.cb-status-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-border-l3)}
|
|
938
|
+
.cb-status-dot-ok{background:var(--dsw-alias-state-success-primary)}
|
|
939
|
+
.cb-status-dot-warn{background:var(--dsw-alias-state-warn-primary)}
|
|
940
|
+
.cb-status-dot-bad{background:var(--dsw-alias-state-error-primary)}
|
|
941
|
+
.cb-status-dot-idle{background:var(--dsw-alias-border-l3)}
|
|
942
|
+
.cb-account-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
943
|
+
.cb-account-name{display:flex;align-items:baseline;gap:8px;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:18px}
|
|
944
|
+
.cb-account-env{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
945
|
+
.cb-account-meta{min-width:0;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;white-space:nowrap;text-overflow:ellipsis}
|
|
946
|
+
.cb-account-badges{display:flex;flex:none;flex-wrap:wrap;justify-content:flex-end;gap:4px;max-width:240px}
|
|
947
|
+
.cb-account-quota{display:flex;flex:none;align-items:center;justify-content:flex-end;gap:6px;min-width:112px}
|
|
948
|
+
.cb-meter{display:block;width:64px;height:4px;overflow:hidden;border-radius:2px;background:var(--dsw-alias-bg-layer-2)}
|
|
949
|
+
.cb-meter-fill{display:block;height:100%;background:var(--dsw-alias-brand-primary);transition:width .3s ease}
|
|
950
|
+
.cb-meter-fill-warn{background:var(--dsw-alias-state-error-primary)}
|
|
951
|
+
.cb-icon-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
952
|
+
.cb-icon-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
953
|
+
.cb-icon-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
954
|
+
.cb-account-menu{display:flex;flex-direction:column;min-width:180px;margin:0 0 8px 18px;padding:4px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
|
|
955
|
+
.cb-menu-item{padding:6px 8px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;text-align:left;cursor:pointer}
|
|
956
|
+
.cb-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
957
|
+
.cb-menu-item-danger{color:var(--dsw-alias-state-error-primary)}
|
|
958
|
+
.cb-account-inline{display:flex;align-items:center;gap:8px;padding:0 0 10px 18px}
|
|
959
|
+
.cb-account-inline .cb-input{flex:1;min-width:0}
|
|
960
|
+
.cb-account-danger{padding:8px 10px;margin:0 0 8px 18px;border-radius:var(--dsw-radius-md);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent)}
|
|
961
|
+
.cb-account-danger-text{flex:1;min-width:0;color:var(--dsw-alias-state-error-primary);font-size:12px}
|
|
962
|
+
.cb-account-routes{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
|
963
|
+
.cb-account-routes-head{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:12px;font-weight:500}
|
|
964
|
+
.cb-account-route-add{display:flex;gap:8px;flex-wrap:wrap}
|
|
965
|
+
.cb-account-route-add .cb-input{flex:2 1 160px}
|
|
966
|
+
.cb-account-route-add .cb-select{flex:1 1 140px}
|
|
967
|
+
.cb-account-route-list{display:flex;flex-direction:column;gap:4px}
|
|
968
|
+
.cb-account-route-row{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);font-size:12px}
|
|
969
|
+
.cb-account-route-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
970
|
+
@media(max-width:720px){
|
|
971
|
+
.cb-account-row{flex-wrap:wrap}
|
|
972
|
+
.cb-account-badges{justify-content:flex-start;max-width:none}
|
|
973
|
+
.cb-account-quota{min-width:0}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/* Calm section toolbars, stats and banners. */
|
|
977
|
+
.cb-section-actions{display:flex;align-items:center;gap:6px;flex:none}
|
|
978
|
+
.cb-btn-link{display:inline-flex;align-items:center;gap:4px;text-decoration:none}
|
|
979
|
+
.cb-quota-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:10px;margin-top:4px}
|
|
980
|
+
@media(max-width:640px){.cb-quota-grid{grid-template-columns:1fr}}
|
|
981
|
+
.cb-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:4px}
|
|
982
|
+
.cb-model-list{display:flex;flex-direction:column;gap:4px;margin-top:12px}
|
|
983
|
+
.cb-model-list-title{margin-bottom:2px;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500}
|
|
984
|
+
.cb-model-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);font-size:12px}
|
|
985
|
+
.cb-model-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
986
|
+
.cb-banner-body{flex:1;min-width:0}
|
|
987
|
+
.cb-banner-sub{margin-top:2px;font-size:11px;opacity:.9}
|
|
988
|
+
.cb-state-ok{color:var(--dsw-alias-state-success-primary);font-size:12px}
|
|
989
|
+
.cb-hint{margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
990
|
+
.cb-key-status{margin-top:8px;font-size:12px}
|
|
991
|
+
.cb-key-meta{display:flex;align-items:center;gap:6px;margin-top:12px;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
992
|
+
.cb-link{color:var(--dsw-alias-brand-primary);text-decoration:none}
|
|
993
|
+
.cb-link:hover{text-decoration:underline}
|
|
994
|
+
.cb-update-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}
|
|
995
|
+
.cb-update-info{display:flex;align-items:center;gap:8px;min-width:0;font-size:12px}
|
|
996
|
+
.cb-update-version{color:var(--dsw-alias-label-secondary);font-weight:500}
|
|
997
|
+
.cb-row-actions{display:flex;align-items:center;gap:8px;margin-top:4px}
|
|
998
|
+
|
|
999
|
+
/* Composer cost and analytics dialog. */
|
|
1000
|
+
.cb-composer-cost{position:relative;display:inline-flex;align-items:center}
|
|
1001
|
+
.cb-composer-cost-button{display:inline-flex;align-items:center;gap:4px;height:24px;padding:2px 8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer}
|
|
1002
|
+
.cb-composer-cost-button:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
|
|
1003
|
+
.cb-composer-cost-value{color:var(--dsw-alias-label-primary);font-weight:600}
|
|
1004
|
+
.cb-composer-popover{position:absolute;bottom:100%;left:0;z-index:100;display:flex;flex-direction:column;gap:6px;min-width:200px;margin-bottom:6px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:12px}
|
|
1005
|
+
.cb-composer-title{padding-bottom:4px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:600}
|
|
1006
|
+
.cb-composer-row,.cb-composer-total{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
1007
|
+
.cb-composer-total{padding-top:4px;border-top:.5px solid var(--dsw-alias-border-l2);font-weight:600}
|
|
1008
|
+
.cb-composer-value{font-variant-numeric:tabular-nums}
|
|
1009
|
+
.cb-tabs{display:flex;gap:4px;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
1010
|
+
.cb-tab{padding:6px 10px;border:0;border-bottom:1.5px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
|
|
1011
|
+
.cb-tab-active{border-bottom-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:500}
|
|
1012
|
+
.cb-dialogRow{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
1013
|
+
.cb-dialogBadges{display:flex;align-items:center;justify-content:flex-end;gap:4px;flex-wrap:wrap}
|
|
1014
|
+
.cb-analytics-controls{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}
|
|
1015
|
+
.cb-account-tabs{display:flex;flex-wrap:wrap;gap:6px}
|
|
1016
|
+
.cb-account-tab{display:inline-flex;align-items:center;padding:2px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}
|
|
1017
|
+
.cb-account-tab:hover:not(.cb-account-tab-active){color:var(--dsw-alias-label-primary)}
|
|
1018
|
+
.cb-account-tab-active{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
|
|
1019
|
+
.cb-price-section{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
|
1020
|
+
.cb-price-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
1021
|
+
.cb-price-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
1022
|
+
.cb-price-table{display:flex;flex-direction:column;gap:4px;min-width:0;overflow-x:auto}
|
|
1023
|
+
.cb-price-head-row,.cb-price-row{display:grid;grid-template-columns:minmax(160px,1.6fr) repeat(4,minmax(72px,1fr));gap:6px;align-items:center;min-width:520px}
|
|
1024
|
+
.cb-price-head-row{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
|
|
1025
|
+
.cb-price-row .cb-mono{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
1026
|
+
.cb-price-input{width:100%;min-width:0;padding:4px 6px;font-size:12px}
|
|
1027
|
+
.cb-dialogIdentity{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
1028
|
+
.cb-dialogTiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px}
|
|
1029
|
+
.cb-dialogWindows{display:flex;flex-direction:column;gap:12px}
|
|
1030
|
+
.cb-analytics-list{display:flex;flex-direction:column;gap:2px;margin-top:8px}
|
|
1031
|
+
.cb-analytics-head,.cb-analytics-row{display:grid;grid-template-columns:minmax(120px,1fr) 70px 70px 80px 70px;gap:8px;align-items:center;padding:4px 0;font-size:11px}
|
|
1032
|
+
.cb-analytics-head{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}
|
|
1033
|
+
.cb-analytics-row{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
|
|
1034
|
+
.cb-analytics-row .cb-mono{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1035
|
+
.cb-heat-wrap{display:flex;flex-direction:column;gap:8px;min-width:0}
|
|
1036
|
+
.cb-heat-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
1037
|
+
.cb-heat-toolbar .cb-hint{margin:0}
|
|
1038
|
+
.cb-heat-select{max-width:260px;padding:3px 8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
|
|
1039
|
+
.cb-heat-scroll{overflow-x:auto;padding-bottom:4px}
|
|
1040
|
+
.cb-heat{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;align-items:center;min-width:620px}
|
|
1041
|
+
.cb-heat-hour{color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center}
|
|
1042
|
+
.cb-heat-day{color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px;white-space:nowrap}
|
|
1043
|
+
.cb-heat-cell,.cb-heat-swatch{display:block;border-radius:3px;background:var(--dsw-alias-bg-layer-2)}
|
|
1044
|
+
.cb-heat-cell{height:14px}
|
|
1045
|
+
.cb-heat-swatch{width:12px;height:12px;border-radius:2px}
|
|
1046
|
+
.cb-heat-l0{background:var(--dsw-alias-bg-layer-2)}
|
|
1047
|
+
.cb-heat-l1{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-bg-layer-2))}
|
|
1048
|
+
.cb-heat-l2{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 36%,var(--dsw-alias-bg-layer-2))}
|
|
1049
|
+
.cb-heat-l3{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 58%,var(--dsw-alias-bg-layer-2))}
|
|
1050
|
+
.cb-heat-l4{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 80%,var(--dsw-alias-bg-layer-2))}
|
|
1051
|
+
.cb-heat-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}
|
|
1052
|
+
.cb-hour-strip-head{margin:10px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}
|
|
1053
|
+
.cb-hour-strip{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;align-items:end;height:30px;min-width:620px}
|
|
1054
|
+
.cb-hour-col{display:flex;align-items:flex-end;height:100%}
|
|
1055
|
+
.cb-hour-bar{display:block;width:100%;min-height:2px;border-radius:2px 2px 0 0}
|
|
1056
|
+
.cb-hour-labels{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center;min-width:620px}
|
|
1057
|
+
@media(max-width:720px){
|
|
1058
|
+
.cb-analytics-head,.cb-analytics-row{grid-template-columns:1fr 56px 64px}
|
|
1059
|
+
.cb-analytics-head span:nth-child(4),.cb-analytics-head span:nth-child(5),.cb-analytics-row span:nth-child(4),.cb-analytics-row span:nth-child(5){display:none}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/* Opencode-inspired visual language: neutral rgba surfaces, 7-10px radii and
|
|
1063
|
+
the same green/amber/red accents. Text still follows the harness theme. */
|
|
1064
|
+
.cb-page{gap:14px;padding:4px 2px 24px}
|
|
1065
|
+
.cb-page-title{font-size:18px;font-weight:650;line-height:1.4}
|
|
1066
|
+
.cb-panel{max-width:760px;padding:18px}
|
|
1067
|
+
.cb-section-card,.cb-dialogCard,.cb-provider-card,.cb-progress-card,.cb-stat-box,.cb-account-add,.cb-discovered,.cb-update-banner,.cb-notice,.cb-savebar-inner,.cb-account-menu{border:1px solid rgba(127,127,127,.25);border-radius:10px;background:rgba(127,127,127,.06)}
|
|
1068
|
+
.cb-section-card,.cb-dialogCard{padding:12px 14px;gap:10px}
|
|
1069
|
+
.cb-section-title,.cb-settings-group-title{font-size:13px;font-weight:650;line-height:1.5}
|
|
1070
|
+
.cb-section-desc,.cb-settings-group-desc,.cb-settings-row-desc,.cb-hint{opacity:.72;line-height:1.5}
|
|
1071
|
+
.cb-input,.cb-select{height:auto;min-height:32px;padding:6px 8px;border:1px solid rgba(127,127,127,.35);border-radius:7px;background:rgba(127,127,127,.08);font-size:13px;line-height:1.4}
|
|
1072
|
+
.cb-input:focus,.cb-select:focus{border-color:rgba(80,140,255,.6);outline:2px solid rgba(80,140,255,.25);outline-offset:1px}
|
|
1073
|
+
.cb-btn{height:auto;min-height:30px;padding:6px 11px;border:1px solid rgba(127,127,127,.35);border-radius:7px;background:rgba(127,127,127,.1);font-size:13px;line-height:1.4}
|
|
1074
|
+
.cb-btn:hover:not(:disabled){background:rgba(127,127,127,.18)}
|
|
1075
|
+
.cb-btn-primary{border-color:rgba(80,140,255,.6);background:rgba(80,140,255,.18);color:inherit}
|
|
1076
|
+
.cb-btn-primary:hover:not(:disabled){background:rgba(80,140,255,.26)}
|
|
1077
|
+
.cb-btn-danger{border-color:rgba(220,80,80,.5);color:#e07a7a}
|
|
1078
|
+
.cb-badge{padding:2px 7px;border:1px solid rgba(127,127,127,.3);border-radius:999px;background:rgba(127,127,127,.1);font-size:11px;line-height:1.4}
|
|
1079
|
+
.cb-badge-ok{color:#4fbf86;border-color:rgba(60,180,110,.5);background:rgba(60,180,110,.10)}
|
|
1080
|
+
.cb-badge-warn{color:#d9a24a;border-color:rgba(220,160,60,.5);background:rgba(220,160,60,.10)}
|
|
1081
|
+
.cb-badge-bad{color:#e07a7a;border-color:rgba(220,80,80,.5);background:rgba(220,80,80,.10)}
|
|
1082
|
+
.cb-stat-box{padding:8px 10px;border-radius:8px}
|
|
1083
|
+
.cb-stat-val{font-size:15px;font-weight:600;line-height:1.4}
|
|
1084
|
+
.cb-stat-lbl{font-size:11px;line-height:1.5}
|
|
1085
|
+
.cb-table{font-size:12px}
|
|
1086
|
+
.cb-table th{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:11px;font-weight:600;opacity:.8}
|
|
1087
|
+
.cb-table td{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:12px}
|
|
1088
|
+
.cb-progress-track,.cb-bar-track{height:8px;border-radius:999px;background:rgba(127,127,127,.2)}
|
|
1089
|
+
.cb-progress-bar-fill,.cb-bar-fill,.cb-footFill,.cb-meter-fill{background:#4fbf86}
|
|
1090
|
+
.cb-progress-bar-warn{background:#d9a24a}
|
|
1091
|
+
.cb-progress-bar-bad,.cb-footFillWarn,.cb-meter-fill-warn{background:#e07a7a}
|
|
1092
|
+
.cb-progress-meta-bad,.cb-progress-low,.cb-footPctWarn{color:#e07a7a}
|
|
1093
|
+
.cb-footBar,.cb-meter{background:rgba(127,127,127,.2)}
|
|
1094
|
+
.cb-account-tab,.cb-seg{border:1px solid rgba(127,127,127,.3);border-radius:999px;background:rgba(127,127,127,.1)}
|
|
1095
|
+
.cb-seg{border-radius:7px;overflow:hidden}
|
|
1096
|
+
.cb-seg-item{border:0;border-left:1px solid rgba(127,127,127,.2);border-radius:0;background:transparent}
|
|
1097
|
+
.cb-seg-item:first-child{border-left:0}
|
|
1098
|
+
.cb-seg-item-active,.cb-account-tab-active{border-color:rgba(80,140,255,.6);background:rgba(80,140,255,.18);color:inherit}
|
|
1099
|
+
.cb-tab{border:0;border-radius:0;background:transparent;border-bottom:1.5px solid transparent}
|
|
1100
|
+
.cb-tab-active{border-bottom-color:#4fbf86;background:transparent}
|
|
1101
|
+
.cb-heat-l0{background:rgba(127,127,127,.10)}
|
|
1102
|
+
.cb-heat-l1{background:rgba(79,191,134,.234)}
|
|
1103
|
+
.cb-heat-l2{background:rgba(79,191,134,.382)}
|
|
1104
|
+
.cb-heat-l3{background:rgba(79,191,134,.530)}
|
|
1105
|
+
.cb-heat-l4{background:rgba(79,191,134,.826)}
|
|
1106
|
+
.cb-heat-cell,.cb-heat-swatch{border-radius:3px}
|
|
1107
|
+
.cb-analytics-head,.cb-analytics-row{padding:5px 0;border-bottom:1px solid rgba(127,127,127,.18)}
|
|
1108
|
+
.cb-settings-row,.cb-price-section{border-color:rgba(127,127,127,.18)}
|
|
1109
|
+
.cb-alert-ok{background:rgba(60,180,110,.10);color:#4fbf86}
|
|
1110
|
+
.cb-alert-bad,.cb-alert-err{background:rgba(220,80,80,.10);color:#e07a7a}
|
|
1111
|
+
.cb-banner-warning{background:rgba(220,160,60,.10);border-color:rgba(220,160,60,.5);color:#d9a24a}
|
|
1112
|
+
.cb-banner-exhausted{background:rgba(220,80,80,.10);border-color:rgba(220,80,80,.5);color:#e07a7a}
|
|
1113
|
+
.cb-account-route-row,.cb-model-row,.cb-preview,.cb-account-add{background:rgba(127,127,127,.06)}
|
|
1114
|
+
|
|
619
1115
|
`
|
|
620
1116
|
document.head.appendChild(style)
|
|
621
1117
|
}
|
|
@@ -643,6 +1139,257 @@ async function fetchWithTimeout(url, opts = {}, ms = 30000) {
|
|
|
643
1139
|
}
|
|
644
1140
|
}
|
|
645
1141
|
|
|
1142
|
+
// --- src/client/settings-draft.js ---
|
|
1143
|
+
/**
|
|
1144
|
+
* Staged configuration draft for the ClineBot settings page.
|
|
1145
|
+
*
|
|
1146
|
+
* Fields edited in the form are merged into `pending` and written with one
|
|
1147
|
+
* `PUT /config` when the user presses Save. Account operations remain
|
|
1148
|
+
* immediate and are handled by the page's existing handlers.
|
|
1149
|
+
*/
|
|
1150
|
+
function useStagedConfig({ status, draft, setDraft, load, t, pendingRef }) {
|
|
1151
|
+
const [pending, setPending] = React.useState({})
|
|
1152
|
+
const [saving, setSaving] = React.useState(false)
|
|
1153
|
+
const [saveState, setSaveState] = React.useState('idle')
|
|
1154
|
+
const [saveError, setSaveError] = React.useState('')
|
|
1155
|
+
const savedTimer = React.useRef(null)
|
|
1156
|
+
|
|
1157
|
+
React.useEffect(() => () => {
|
|
1158
|
+
if (savedTimer.current) clearTimeout(savedTimer.current)
|
|
1159
|
+
}, [])
|
|
1160
|
+
|
|
1161
|
+
const validationError = React.useMemo(() => {
|
|
1162
|
+
if (Object.hasOwn(pending, 'baseUrl')) {
|
|
1163
|
+
const raw = String(pending.baseUrl || '').trim()
|
|
1164
|
+
try {
|
|
1165
|
+
const url = new URL(raw)
|
|
1166
|
+
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
|
|
1167
|
+
if (url.protocol !== 'https:' && !local) return t('settings.save_invalid_base_url')
|
|
1168
|
+
} catch {
|
|
1169
|
+
return t('settings.save_invalid_base_url')
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
for (const key of ['timeoutMs', 'smokeTimeoutMs', 'streamIdleTimeoutMs', 'maxPayloadMb']) {
|
|
1173
|
+
if (!Object.hasOwn(pending, key)) continue
|
|
1174
|
+
const value = Number(pending[key])
|
|
1175
|
+
if (!Number.isFinite(value) || value <= 0) return t('settings.save_invalid_number')
|
|
1176
|
+
}
|
|
1177
|
+
return ''
|
|
1178
|
+
}, [pending, t])
|
|
1179
|
+
|
|
1180
|
+
const stage = React.useCallback((patch) => {
|
|
1181
|
+
if (!patch || typeof patch !== 'object') return
|
|
1182
|
+
const next = { ...(pendingRef.current || {}), ...patch }
|
|
1183
|
+
pendingRef.current = next
|
|
1184
|
+
setPending(next)
|
|
1185
|
+
setDraft((current) => ({ ...(current || {}), ...patch }))
|
|
1186
|
+
setSaveState('unsaved')
|
|
1187
|
+
setSaveError('')
|
|
1188
|
+
}, [pendingRef, setDraft])
|
|
1189
|
+
|
|
1190
|
+
const discard = React.useCallback(() => {
|
|
1191
|
+
pendingRef.current = {}
|
|
1192
|
+
setPending({})
|
|
1193
|
+
setDraft(status?.config || {})
|
|
1194
|
+
setSaveState('idle')
|
|
1195
|
+
setSaveError('')
|
|
1196
|
+
}, [pendingRef, setDraft, status])
|
|
1197
|
+
|
|
1198
|
+
const save = React.useCallback(async () => {
|
|
1199
|
+
const patch = pendingRef.current || {}
|
|
1200
|
+
if (Object.keys(patch).length === 0 || saving) return
|
|
1201
|
+
if (validationError) {
|
|
1202
|
+
setSaveState('error')
|
|
1203
|
+
setSaveError(validationError)
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
setSaving(true)
|
|
1207
|
+
setSaveState('saving')
|
|
1208
|
+
setSaveError('')
|
|
1209
|
+
try {
|
|
1210
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/config`, {
|
|
1211
|
+
method: 'PUT',
|
|
1212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1213
|
+
body: JSON.stringify({ config: patch }),
|
|
1214
|
+
})
|
|
1215
|
+
const data = await res.json().catch(() => ({}))
|
|
1216
|
+
if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1217
|
+
pendingRef.current = {}
|
|
1218
|
+
setPending({})
|
|
1219
|
+
await load()
|
|
1220
|
+
setSaveState('saved')
|
|
1221
|
+
if (savedTimer.current) clearTimeout(savedTimer.current)
|
|
1222
|
+
savedTimer.current = setTimeout(() => {
|
|
1223
|
+
setSaveState((current) => (current === 'saved' ? 'idle' : current))
|
|
1224
|
+
}, 2500)
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
setSaveState('error')
|
|
1227
|
+
setSaveError(String(error?.message || error))
|
|
1228
|
+
} finally {
|
|
1229
|
+
setSaving(false)
|
|
1230
|
+
}
|
|
1231
|
+
}, [load, pendingRef, saving, validationError])
|
|
1232
|
+
|
|
1233
|
+
return {
|
|
1234
|
+
pending,
|
|
1235
|
+
pendingCount: Object.keys(pending).length,
|
|
1236
|
+
saving,
|
|
1237
|
+
saveState,
|
|
1238
|
+
saveError,
|
|
1239
|
+
validationError,
|
|
1240
|
+
invalid: Boolean(validationError),
|
|
1241
|
+
stage,
|
|
1242
|
+
discard,
|
|
1243
|
+
save,
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// --- src/client/settings-models.js ---
|
|
1248
|
+
/**
|
|
1249
|
+
* Model-picker selection state and debounced persistence.
|
|
1250
|
+
*
|
|
1251
|
+
* Model toggles are immediate by design: the picker is a direct inventory
|
|
1252
|
+
* control, not part of the staged settings form.
|
|
1253
|
+
*/
|
|
1254
|
+
function useModelSelection({ status, draft, setDraft, load, setErr }) {
|
|
1255
|
+
const debounceTimerRef = React.useRef(null)
|
|
1256
|
+
|
|
1257
|
+
React.useEffect(() => () => {
|
|
1258
|
+
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
1259
|
+
}, [])
|
|
1260
|
+
|
|
1261
|
+
function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
|
|
1262
|
+
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
1263
|
+
debounceTimerRef.current = setTimeout(async () => {
|
|
1264
|
+
try {
|
|
1265
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
|
|
1266
|
+
method: 'POST',
|
|
1267
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1268
|
+
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
1269
|
+
})
|
|
1270
|
+
const data = await res.json().catch(() => ({}))
|
|
1271
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1272
|
+
await load()
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
console.warn('[dsh-clinebot] Failed saving disabled models:', error)
|
|
1275
|
+
setErr(String(error?.message || error))
|
|
1276
|
+
setDraft((previous) => {
|
|
1277
|
+
if (!previous) return previous
|
|
1278
|
+
const all = status?.availableModels || []
|
|
1279
|
+
const previousEnabled = all.map((model) => model.id).filter((id) => !previousDisabled.includes(id))
|
|
1280
|
+
return { ...previous, disabledModels: previousDisabled, enabledModels: previousEnabled }
|
|
1281
|
+
})
|
|
1282
|
+
}
|
|
1283
|
+
}, 400)
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function handleToggleModel(id) {
|
|
1287
|
+
const current = draft?.disabledModels || []
|
|
1288
|
+
const next = current.includes(id)
|
|
1289
|
+
? current.filter((entry) => entry !== id)
|
|
1290
|
+
: [...current, id]
|
|
1291
|
+
const all = status?.availableModels || []
|
|
1292
|
+
const enabled = all.map((model) => model.id).filter((modelId) => !next.includes(modelId))
|
|
1293
|
+
setDraft({ ...(draft || {}), disabledModels: next, enabledModels: enabled })
|
|
1294
|
+
debounceSaveDisabledModels(next, current)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function handleSetModelsFilter(type) {
|
|
1298
|
+
const all = status?.availableModels || []
|
|
1299
|
+
let allowed = new Set()
|
|
1300
|
+
if (type === 'all') allowed = new Set(all.map((model) => model.id))
|
|
1301
|
+
else if (type === 'vision') allowed = new Set(all.filter((model) => model.input?.includes('image') || model.input?.includes('vision')).map((model) => model.id))
|
|
1302
|
+
else if (type === 'coding') allowed = new Set(all.filter((model) => model.category === 'coding').map((model) => model.id))
|
|
1303
|
+
else if (type === 'recommended') allowed = new Set(all.filter((model) => model.recommended).map((model) => model.id))
|
|
1304
|
+
|
|
1305
|
+
const current = draft?.disabledModels || []
|
|
1306
|
+
const nextDisabled = all.map((model) => model.id).filter((id) => !allowed.has(id))
|
|
1307
|
+
const nextEnabled = Array.from(allowed)
|
|
1308
|
+
setDraft({ ...(draft || {}), disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
1309
|
+
debounceSaveDisabledModels(nextDisabled, current)
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
return { handleToggleModel, handleSetModelsFilter }
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// --- src/client/settings-accounts.js ---
|
|
1316
|
+
/**
|
|
1317
|
+
* Immediate account-pool operations. These are intentionally outside the
|
|
1318
|
+
* staged settings form: adding, pinning, renaming, clearing and deleting an
|
|
1319
|
+
* account commit on their own.
|
|
1320
|
+
*/
|
|
1321
|
+
function createAccountActions({ performAction, load, t, setMsg }) {
|
|
1322
|
+
async function handlePinAccount(accountEnv) {
|
|
1323
|
+
await performAction('pin-account', async () => {
|
|
1324
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
|
|
1325
|
+
method: 'POST',
|
|
1326
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1327
|
+
body: JSON.stringify({ account: accountEnv }),
|
|
1328
|
+
})
|
|
1329
|
+
const data = await res.json().catch(() => ({}))
|
|
1330
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1331
|
+
await load()
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function handleAddAccount({ label, apiKeyEnv, apiKey, id }) {
|
|
1336
|
+
await performAction('add-account', async () => {
|
|
1337
|
+
const endpoint = id ? `${ROUTE_PREFIX}/keys/discovered/import` : `${ROUTE_PREFIX}/accounts`
|
|
1338
|
+
const payload = id ? { id, label, apiKeyEnv } : { label, apiKeyEnv, apiKey }
|
|
1339
|
+
const res = await fetchWithTimeout(endpoint, {
|
|
1340
|
+
method: 'POST',
|
|
1341
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1342
|
+
body: JSON.stringify(payload),
|
|
1343
|
+
})
|
|
1344
|
+
const data = await res.json().catch(() => ({}))
|
|
1345
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1346
|
+
setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : (data.validationError || 'Notice') }))
|
|
1347
|
+
await load()
|
|
1348
|
+
})
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async function handleDeleteAccount(accountEnv, deleteSecret = true) {
|
|
1352
|
+
await performAction('delete-account', async () => {
|
|
1353
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
|
|
1354
|
+
method: 'POST',
|
|
1355
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1356
|
+
body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
|
|
1357
|
+
})
|
|
1358
|
+
const data = await res.json().catch(() => ({}))
|
|
1359
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1360
|
+
await load()
|
|
1361
|
+
})
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async function handleRenameAccount(accountEnv, label) {
|
|
1365
|
+
await performAction('rename-account', async () => {
|
|
1366
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/rename`, {
|
|
1367
|
+
method: 'POST',
|
|
1368
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1369
|
+
body: JSON.stringify({ apiKeyEnv: accountEnv, label }),
|
|
1370
|
+
})
|
|
1371
|
+
const data = await res.json().catch(() => ({}))
|
|
1372
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1373
|
+
await load()
|
|
1374
|
+
})
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function handleClearKey(accountEnv) {
|
|
1378
|
+
await performAction('clear-key', async () => {
|
|
1379
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/key/clear`, {
|
|
1380
|
+
method: 'POST',
|
|
1381
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1382
|
+
body: JSON.stringify({ apiKeyEnv: accountEnv }),
|
|
1383
|
+
})
|
|
1384
|
+
const data = await res.json().catch(() => ({}))
|
|
1385
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1386
|
+
await load()
|
|
1387
|
+
})
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
return { handlePinAccount, handleAddAccount, handleDeleteAccount, handleRenameAccount, handleClearKey }
|
|
1391
|
+
}
|
|
1392
|
+
|
|
646
1393
|
// --- src/client/error-boundary.js ---
|
|
647
1394
|
function createErrorBoundary() {
|
|
648
1395
|
try {
|
|
@@ -663,7 +1410,7 @@ function createErrorBoundary() {
|
|
|
663
1410
|
render() {
|
|
664
1411
|
if (this.state.hasError) {
|
|
665
1412
|
const t = this.props && typeof this.props.t === 'function' ? this.props.t : null
|
|
666
|
-
const errorTitle = t ? t('ui.error_title') : '
|
|
1413
|
+
const errorTitle = t ? t('ui.error_title') : 'ClineBot UI Error: '
|
|
667
1414
|
const retryLabel = t ? t('ui.retry') : 'Retry'
|
|
668
1415
|
return React.createElement(
|
|
669
1416
|
'div',
|
|
@@ -736,128 +1483,343 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
736
1483
|
|
|
737
1484
|
return React.createElement(
|
|
738
1485
|
'div',
|
|
739
|
-
{
|
|
740
|
-
className: 'cb-progress-card cb-bar-container',
|
|
741
|
-
style: {
|
|
742
|
-
display: 'flex',
|
|
743
|
-
flexDirection: 'column',
|
|
744
|
-
gap: '8px',
|
|
745
|
-
padding: '12px 14px',
|
|
746
|
-
borderRadius: '8px',
|
|
747
|
-
border: isBad ? '1px solid var(--dsw-alias-state-error-primary)' : '1px solid var(--dsw-alias-border-l2)',
|
|
748
|
-
background: 'var(--dsw-alias-bg-layer-2)',
|
|
749
|
-
},
|
|
750
|
-
},
|
|
1486
|
+
{ className: isBad ? 'cb-progress-card cb-progress-card-bad' : 'cb-progress-card' },
|
|
751
1487
|
React.createElement(
|
|
752
1488
|
'div',
|
|
753
|
-
{
|
|
754
|
-
className: 'cb-progress-head cb-bar-head',
|
|
755
|
-
style: {
|
|
756
|
-
display: 'flex',
|
|
757
|
-
justifyContent: 'space-between',
|
|
758
|
-
alignItems: 'baseline',
|
|
759
|
-
gap: '16px',
|
|
760
|
-
width: '100%',
|
|
761
|
-
},
|
|
762
|
-
},
|
|
1489
|
+
{ className: 'cb-progress-head' },
|
|
763
1490
|
React.createElement(
|
|
764
1491
|
'span',
|
|
765
|
-
{ className: 'cb-progress-label'
|
|
1492
|
+
{ className: 'cb-progress-label' },
|
|
766
1493
|
label,
|
|
767
|
-
isBad
|
|
1494
|
+
isBad
|
|
1495
|
+
? React.createElement(
|
|
1496
|
+
'span',
|
|
1497
|
+
{ className: 'cb-progress-low' },
|
|
1498
|
+
React.createElement(CbIcon, { name: 'warning', size: 12 }),
|
|
1499
|
+
'<10%'
|
|
1500
|
+
)
|
|
1501
|
+
: null
|
|
768
1502
|
),
|
|
769
1503
|
React.createElement(
|
|
770
1504
|
'span',
|
|
771
|
-
{
|
|
772
|
-
className: 'cb-progress-meta cb-bar-meta',
|
|
773
|
-
style: {
|
|
774
|
-
fontSize: '12px',
|
|
775
|
-
color: isBad ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
776
|
-
whiteSpace: 'nowrap',
|
|
777
|
-
marginLeft: 'auto',
|
|
778
|
-
},
|
|
779
|
-
},
|
|
1505
|
+
{ className: isBad ? 'cb-progress-meta cb-progress-meta-bad' : 'cb-progress-meta' },
|
|
780
1506
|
t('quota.used', { pct }),
|
|
781
|
-
resetCountdown
|
|
1507
|
+
resetCountdown
|
|
1508
|
+
? ` · ${resetCountdown === 'resetting' ? (t('quota.resetting') || '正在重置') : t('quota.countdown', { time: resetCountdown })}`
|
|
1509
|
+
: (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
|
|
782
1510
|
)
|
|
783
1511
|
),
|
|
784
1512
|
React.createElement(
|
|
785
1513
|
'div',
|
|
786
|
-
{
|
|
787
|
-
|
|
788
|
-
style: {
|
|
789
|
-
width: '100%',
|
|
790
|
-
height: '8px',
|
|
791
|
-
borderRadius: '999px',
|
|
792
|
-
background: 'var(--dsw-alias-bg-layer-1)',
|
|
793
|
-
overflow: 'hidden',
|
|
794
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
795
|
-
},
|
|
796
|
-
},
|
|
797
|
-
React.createElement('div', {
|
|
798
|
-
className: barClass,
|
|
799
|
-
style: { width: `${pct}%`, height: '100%', borderRadius: '999px' },
|
|
800
|
-
})
|
|
1514
|
+
{ className: 'cb-progress-track' },
|
|
1515
|
+
React.createElement('div', { className: barClass, style: { width: `${pct}%` } })
|
|
801
1516
|
)
|
|
802
1517
|
)
|
|
803
1518
|
}
|
|
804
1519
|
|
|
805
|
-
// --- src/client/components/
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1520
|
+
// --- src/client/components/settings-layout.js ---
|
|
1521
|
+
/**
|
|
1522
|
+
* Shared settings layout primitives: a titled group, a foldable group, a
|
|
1523
|
+
* title/description/control row, and the floating staged-save bar.
|
|
1524
|
+
*/
|
|
1525
|
+
function SettingsGroup({ title, description, divided = false, action, children }) {
|
|
1526
|
+
return React.createElement(
|
|
1527
|
+
'section',
|
|
1528
|
+
{ className: divided ? 'cb-settings-group cb-settings-group-divided' : 'cb-settings-group', 'aria-label': title },
|
|
1529
|
+
React.createElement(
|
|
1530
|
+
'div',
|
|
1531
|
+
{ className: 'cb-settings-group-head' },
|
|
1532
|
+
React.createElement('h3', { className: 'cb-settings-group-title' }, title),
|
|
1533
|
+
action ? React.createElement('span', { className: 'cb-settings-group-action' }, action) : null
|
|
1534
|
+
),
|
|
1535
|
+
description ? React.createElement('div', { className: 'cb-settings-group-desc' }, description) : null,
|
|
1536
|
+
React.createElement('div', { className: 'cb-settings-group-body' }, children)
|
|
815
1537
|
)
|
|
1538
|
+
}
|
|
816
1539
|
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
const isCurrent = status === 'current'
|
|
820
|
-
const isError = status === 'error' || updateState.latestCheckFailed
|
|
821
|
-
|
|
1540
|
+
function CollapsibleGroup({ title, description, defaultOpen = false, divided = false, badge, children }) {
|
|
1541
|
+
const [open, setOpen] = React.useState(Boolean(defaultOpen))
|
|
822
1542
|
return React.createElement(
|
|
823
|
-
|
|
824
|
-
|
|
1543
|
+
'section',
|
|
1544
|
+
{ className: divided ? 'cb-settings-group cb-settings-group-divided' : 'cb-settings-group', 'aria-label': title },
|
|
825
1545
|
React.createElement(
|
|
826
|
-
'
|
|
1546
|
+
'button',
|
|
827
1547
|
{
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
833
|
-
background: 'var(--dsw-alias-bg-layer-2)',
|
|
834
|
-
justifyContent: 'space-between',
|
|
835
|
-
}
|
|
1548
|
+
type: 'button',
|
|
1549
|
+
className: 'cb-settings-group-head cb-settings-disclosure',
|
|
1550
|
+
'aria-expanded': open,
|
|
1551
|
+
onClick: () => setOpen((value) => !value),
|
|
836
1552
|
},
|
|
1553
|
+
React.createElement('span', { className: 'cb-settings-group-title' }, title),
|
|
1554
|
+
badge !== undefined && badge !== '' ? React.createElement('span', { className: 'cb-badge' }, badge) : null,
|
|
1555
|
+
React.createElement('span', { className: 'cb-settings-spacer' }),
|
|
1556
|
+
React.createElement('span', { className: open ? 'cb-settings-chevron cb-settings-chevron-open' : 'cb-settings-chevron', 'aria-hidden': 'true' }, '›')
|
|
1557
|
+
),
|
|
1558
|
+
open && description ? React.createElement('div', { className: 'cb-settings-group-desc' }, description) : null,
|
|
1559
|
+
open ? React.createElement('div', { className: 'cb-settings-group-body' }, children) : null
|
|
1560
|
+
)
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function SettingRow({ title, description, control, error, htmlFor }) {
|
|
1564
|
+
return React.createElement(
|
|
1565
|
+
'div',
|
|
1566
|
+
{ className: 'cb-settings-row' },
|
|
1567
|
+
React.createElement(
|
|
1568
|
+
'div',
|
|
1569
|
+
{ className: 'cb-settings-row-text' },
|
|
1570
|
+
htmlFor
|
|
1571
|
+
? React.createElement('label', { className: 'cb-settings-row-title', htmlFor }, title)
|
|
1572
|
+
: React.createElement('span', { className: 'cb-settings-row-title' }, title),
|
|
1573
|
+
error
|
|
1574
|
+
? React.createElement('div', { className: 'cb-settings-row-error' }, error)
|
|
1575
|
+
: (description ? React.createElement('div', { className: 'cb-settings-row-desc' }, description) : null)
|
|
1576
|
+
),
|
|
1577
|
+
control ? React.createElement('div', { className: 'cb-settings-row-control' }, control) : null
|
|
1578
|
+
)
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function SaveBar({ pendingCount, saving, saveState, saveError, invalid, validationError, onSave, onDiscard, t }) {
|
|
1582
|
+
const visible = pendingCount > 0 || saving || saveState === 'saved' || saveState === 'error'
|
|
1583
|
+
if (!visible) return null
|
|
1584
|
+
const message = saveError
|
|
1585
|
+
|| validationError
|
|
1586
|
+
|| (saving
|
|
1587
|
+
? t('settings.saving')
|
|
1588
|
+
: (saveState === 'saved' ? t('settings.saved') : t('settings.unsaved', { count: pendingCount })))
|
|
1589
|
+
return React.createElement(
|
|
1590
|
+
'div',
|
|
1591
|
+
{ className: 'cb-savebar', role: 'status' },
|
|
1592
|
+
React.createElement(
|
|
1593
|
+
'div',
|
|
1594
|
+
{ className: 'cb-savebar-inner' },
|
|
1595
|
+
React.createElement('span', { className: 'cb-savebar-text' }, message),
|
|
1596
|
+
React.createElement('span', { className: 'cb-settings-spacer' }),
|
|
837
1597
|
React.createElement(
|
|
838
|
-
'
|
|
839
|
-
{
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1598
|
+
'button',
|
|
1599
|
+
{
|
|
1600
|
+
type: 'button',
|
|
1601
|
+
className: 'cb-btn',
|
|
1602
|
+
disabled: saving || pendingCount === 0,
|
|
1603
|
+
onClick: onDiscard,
|
|
1604
|
+
},
|
|
1605
|
+
t('settings.discard')
|
|
1606
|
+
),
|
|
1607
|
+
React.createElement(
|
|
1608
|
+
'button',
|
|
1609
|
+
{
|
|
1610
|
+
type: 'button',
|
|
1611
|
+
className: 'cb-btn cb-btn-primary',
|
|
1612
|
+
disabled: saving || pendingCount === 0 || invalid,
|
|
1613
|
+
onClick: onSave,
|
|
1614
|
+
},
|
|
1615
|
+
saving ? t('settings.saving') : t('settings.save')
|
|
1616
|
+
)
|
|
1617
|
+
)
|
|
1618
|
+
)
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// --- src/client/components/integrations-section.js ---
|
|
1622
|
+
/** Integrations & display settings: surfaces outside the chat request itself. */
|
|
1623
|
+
function IntegrationToggle({ id, label, hint, checked, onChange }) {
|
|
1624
|
+
return React.createElement(SettingRow, {
|
|
1625
|
+
title: label,
|
|
1626
|
+
description: hint,
|
|
1627
|
+
htmlFor: id,
|
|
1628
|
+
control: React.createElement(BooleanControl, {
|
|
1629
|
+
checked: !!checked,
|
|
1630
|
+
label,
|
|
1631
|
+
onChange: (next) => onChange(next),
|
|
1632
|
+
}),
|
|
1633
|
+
})
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function IntegrationsSection({ draft, onStage, transport, t }) {
|
|
1637
|
+
const current = draft || {}
|
|
1638
|
+
return React.createElement(
|
|
1639
|
+
SettingsGroup,
|
|
1640
|
+
{
|
|
1641
|
+
title: t('settings.integrations'),
|
|
1642
|
+
description: t('settings.integrations_desc'),
|
|
1643
|
+
divided: true,
|
|
1644
|
+
},
|
|
1645
|
+
React.createElement(IntegrationToggle, {
|
|
1646
|
+
id: 'cb-web-search',
|
|
1647
|
+
label: t('settings.web_search'),
|
|
1648
|
+
hint: t('settings.web_search_hint'),
|
|
1649
|
+
checked: current.webSearch !== false,
|
|
1650
|
+
onChange: (value) => onStage({ webSearch: value }),
|
|
1651
|
+
}),
|
|
1652
|
+
React.createElement(IntegrationToggle, {
|
|
1653
|
+
id: 'cb-sidebar-quota',
|
|
1654
|
+
label: t('settings.sidebar_quota'),
|
|
1655
|
+
hint: t('settings.sidebar_quota_hint'),
|
|
1656
|
+
checked: current.showSidebarQuota !== false,
|
|
1657
|
+
onChange: (value) => onStage({ showSidebarQuota: value }),
|
|
1658
|
+
}),
|
|
1659
|
+
React.createElement(IntegrationToggle, {
|
|
1660
|
+
id: 'cb-composer-cost',
|
|
1661
|
+
label: t('settings.composer_cost'),
|
|
1662
|
+
hint: t('settings.composer_cost_hint'),
|
|
1663
|
+
checked: current.showComposerCost !== false,
|
|
1664
|
+
onChange: (value) => onStage({ showComposerCost: value }),
|
|
1665
|
+
}),
|
|
1666
|
+
transport === 'pi-ai'
|
|
1667
|
+
? React.createElement(IntegrationToggle, {
|
|
1668
|
+
id: 'cb-proxy-mode',
|
|
1669
|
+
label: t('settings.proxy_mode'),
|
|
1670
|
+
hint: t('settings.proxy_mode_hint'),
|
|
1671
|
+
checked: current.proxyMode !== false,
|
|
1672
|
+
onChange: (value) => onStage({ proxyMode: value }),
|
|
1673
|
+
})
|
|
1674
|
+
: null
|
|
1675
|
+
)
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// --- src/client/components/advanced-section.js ---
|
|
1679
|
+
/** Advanced connection, timeout and storage settings. */
|
|
1680
|
+
function AdvancedField({ id, label, hint, value, type, onChange }) {
|
|
1681
|
+
return React.createElement(SettingRow, {
|
|
1682
|
+
title: label,
|
|
1683
|
+
description: hint,
|
|
1684
|
+
htmlFor: id,
|
|
1685
|
+
control: React.createElement('input', {
|
|
1686
|
+
id,
|
|
1687
|
+
className: 'cb-input cb-settings-input',
|
|
1688
|
+
type: type || 'text',
|
|
1689
|
+
value: value === undefined || value === null ? '' : String(value),
|
|
1690
|
+
onChange: (event) => onChange(event.target.value),
|
|
1691
|
+
}),
|
|
1692
|
+
})
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function AdvancedSection({ draft, onStage, transport, t }) {
|
|
1696
|
+
const current = draft || {}
|
|
1697
|
+
const numeric = (key, minimum) => (raw) => {
|
|
1698
|
+
if (raw === '') return
|
|
1699
|
+
const value = Number(raw)
|
|
1700
|
+
if (Number.isFinite(value) && value > minimum) onStage({ [key]: value })
|
|
1701
|
+
}
|
|
1702
|
+
return React.createElement(
|
|
1703
|
+
CollapsibleGroup,
|
|
1704
|
+
{
|
|
1705
|
+
title: t('settings.advanced'),
|
|
1706
|
+
description: t('settings.advanced_desc'),
|
|
1707
|
+
divided: true,
|
|
1708
|
+
defaultOpen: false,
|
|
1709
|
+
},
|
|
1710
|
+
React.createElement(AdvancedField, {
|
|
1711
|
+
id: 'cb-base-url',
|
|
1712
|
+
label: t('settings.base_url'),
|
|
1713
|
+
hint: t('settings.base_url_hint'),
|
|
1714
|
+
value: current.baseUrl,
|
|
1715
|
+
onChange: (value) => onStage({ baseUrl: value }),
|
|
1716
|
+
}),
|
|
1717
|
+
React.createElement(AdvancedField, {
|
|
1718
|
+
id: 'cb-timeout',
|
|
1719
|
+
label: t('settings.timeout'),
|
|
1720
|
+
hint: t('settings.timeout_hint'),
|
|
1721
|
+
value: current.timeoutMs,
|
|
1722
|
+
type: 'number',
|
|
1723
|
+
onChange: numeric('timeoutMs', 0),
|
|
1724
|
+
}),
|
|
1725
|
+
React.createElement(AdvancedField, {
|
|
1726
|
+
id: 'cb-smoke-timeout',
|
|
1727
|
+
label: t('settings.smoke_timeout'),
|
|
1728
|
+
hint: t('settings.smoke_timeout_hint'),
|
|
1729
|
+
value: current.smokeTimeoutMs,
|
|
1730
|
+
type: 'number',
|
|
1731
|
+
onChange: numeric('smokeTimeoutMs', 0),
|
|
1732
|
+
}),
|
|
1733
|
+
React.createElement(AdvancedField, {
|
|
1734
|
+
id: 'cb-stream-idle',
|
|
1735
|
+
label: t('settings.stream_idle'),
|
|
1736
|
+
hint: t('settings.stream_idle_hint'),
|
|
1737
|
+
value: current.streamIdleTimeoutMs,
|
|
1738
|
+
type: 'number',
|
|
1739
|
+
onChange: numeric('streamIdleTimeoutMs', 0),
|
|
1740
|
+
}),
|
|
1741
|
+
React.createElement(AdvancedField, {
|
|
1742
|
+
id: 'cb-max-payload',
|
|
1743
|
+
label: t('settings.max_payload'),
|
|
1744
|
+
hint: t('settings.max_payload_hint'),
|
|
1745
|
+
value: current.maxPayloadMb,
|
|
1746
|
+
type: 'number',
|
|
1747
|
+
onChange: numeric('maxPayloadMb', 0),
|
|
1748
|
+
}),
|
|
1749
|
+
React.createElement(AdvancedField, {
|
|
1750
|
+
id: 'cb-stats-path',
|
|
1751
|
+
label: t('settings.stats_path'),
|
|
1752
|
+
hint: t('settings.stats_path_hint'),
|
|
1753
|
+
value: current.statsPath,
|
|
1754
|
+
onChange: (value) => onStage({ statsPath: value }),
|
|
1755
|
+
}),
|
|
1756
|
+
React.createElement(AdvancedField, {
|
|
1757
|
+
id: 'cb-models-cache-path',
|
|
1758
|
+
label: t('settings.models_cache_path'),
|
|
1759
|
+
hint: t('settings.models_cache_path_hint'),
|
|
1760
|
+
value: current.modelsCachePath,
|
|
1761
|
+
onChange: (value) => onStage({ modelsCachePath: value }),
|
|
1762
|
+
}),
|
|
1763
|
+
React.createElement(SettingRow, {
|
|
1764
|
+
title: t('settings.transport'),
|
|
1765
|
+
description: t('settings.transport_hint'),
|
|
1766
|
+
htmlFor: 'cb-transport',
|
|
1767
|
+
control: React.createElement(
|
|
1768
|
+
'select',
|
|
1769
|
+
{
|
|
1770
|
+
id: 'cb-transport',
|
|
1771
|
+
className: 'cb-select',
|
|
1772
|
+
value: current.transport === 'pi-ai' ? 'pi-ai' : 'adapter',
|
|
1773
|
+
onChange: (event) => onStage({ transport: event.target.value }),
|
|
1774
|
+
},
|
|
1775
|
+
React.createElement('option', { value: 'adapter' }, 'Native adapter'),
|
|
1776
|
+
React.createElement('option', { value: 'pi-ai' }, 'Legacy llm-pi-ai + proxy')
|
|
1777
|
+
),
|
|
1778
|
+
})
|
|
1779
|
+
)
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// --- src/client/components/update-banner.js ---
|
|
1783
|
+
function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }) {
|
|
1784
|
+
const status = updateState.status || (
|
|
1785
|
+
updateState.checking
|
|
1786
|
+
? 'checking'
|
|
1787
|
+
: (updateState.latestCheckFailed
|
|
1788
|
+
? 'error'
|
|
1789
|
+
: (updateState.updateAvailable
|
|
1790
|
+
? 'available'
|
|
1791
|
+
: (updateState.lastCheckedAt ? 'current' : 'idle')))
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1794
|
+
const isChecking = status === 'checking' || updateState.checking
|
|
1795
|
+
const isAvailable = status === 'available' || updateState.updateAvailable
|
|
1796
|
+
const isCurrent = status === 'current'
|
|
1797
|
+
const isError = status === 'error' || updateState.latestCheckFailed
|
|
1798
|
+
|
|
1799
|
+
return React.createElement(
|
|
1800
|
+
React.Fragment,
|
|
1801
|
+
null,
|
|
1802
|
+
React.createElement(
|
|
1803
|
+
'div',
|
|
1804
|
+
{ className: 'cb-update-banner' },
|
|
1805
|
+
React.createElement(
|
|
1806
|
+
'div',
|
|
1807
|
+
{ className: 'cb-update-info' },
|
|
1808
|
+
updateState.currentVersion
|
|
1809
|
+
? React.createElement('span', { className: 'cb-update-version' }, `v${updateState.currentVersion}`)
|
|
844
1810
|
: null,
|
|
845
1811
|
isChecking
|
|
846
|
-
? React.createElement('span', {
|
|
847
|
-
t('update.checking')
|
|
848
|
-
)
|
|
1812
|
+
? React.createElement('span', { className: 'cb-muted' }, t('update.checking'))
|
|
849
1813
|
: isAvailable
|
|
850
|
-
? React.createElement(
|
|
1814
|
+
? React.createElement(
|
|
1815
|
+
'span',
|
|
1816
|
+
{ className: 'cb-badge cb-badge-warn' },
|
|
851
1817
|
t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
|
|
852
1818
|
)
|
|
853
1819
|
: isCurrent
|
|
854
|
-
? React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
855
|
-
'✓ ' + t('update.up_to_date')
|
|
856
|
-
)
|
|
1820
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('update.up_to_date'))
|
|
857
1821
|
: isError
|
|
858
|
-
? React.createElement('span', { className: 'cb-badge cb-badge-bad' },
|
|
859
|
-
t('update.check_failed')
|
|
860
|
-
)
|
|
1822
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-bad' }, t('update.check_failed'))
|
|
861
1823
|
: null
|
|
862
1824
|
),
|
|
863
1825
|
isAvailable
|
|
@@ -868,7 +1830,6 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
|
|
|
868
1830
|
className: 'cb-btn cb-btn-primary',
|
|
869
1831
|
disabled: updateState.updating,
|
|
870
1832
|
onClick: handleTriggerUpdate,
|
|
871
|
-
style: { padding: '5px 12px', fontSize: '12px' }
|
|
872
1833
|
},
|
|
873
1834
|
updateState.updating ? t('update.updating') : t('update.btn')
|
|
874
1835
|
)
|
|
@@ -880,13 +1841,12 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
|
|
|
880
1841
|
className: 'cb-btn',
|
|
881
1842
|
disabled: isChecking,
|
|
882
1843
|
onClick: handleCheckUpdate,
|
|
883
|
-
style: { padding: '5px 12px', fontSize: '12px' }
|
|
884
1844
|
},
|
|
885
1845
|
t('update.retry')
|
|
886
1846
|
)
|
|
887
1847
|
: null
|
|
888
1848
|
),
|
|
889
|
-
updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' },
|
|
1849
|
+
updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, updateState.notice) : null,
|
|
890
1850
|
updateState.error ? React.createElement('div', { className: 'cb-alert-err' }, updateState.error) : null
|
|
891
1851
|
)
|
|
892
1852
|
}
|
|
@@ -894,7 +1854,7 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
|
|
|
894
1854
|
// --- src/client/components/key-section.js ---
|
|
895
1855
|
function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, verifyStatus, t }) {
|
|
896
1856
|
return React.createElement(
|
|
897
|
-
'
|
|
1857
|
+
'section',
|
|
898
1858
|
{ className: 'cb-section-card' },
|
|
899
1859
|
React.createElement('div', { className: 'cb-section-title' }, t('key.title')),
|
|
900
1860
|
React.createElement('div', { className: 'cb-section-desc' }, t('key.desc')),
|
|
@@ -906,15 +1866,11 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
|
|
|
906
1866
|
type: showKey ? 'text' : 'password',
|
|
907
1867
|
placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
|
|
908
1868
|
value: apiKeyInput,
|
|
909
|
-
onChange: (
|
|
1869
|
+
onChange: (event) => setApiKeyInput(event.target.value),
|
|
910
1870
|
}),
|
|
911
1871
|
React.createElement(
|
|
912
1872
|
'button',
|
|
913
|
-
{
|
|
914
|
-
type: 'button',
|
|
915
|
-
className: 'cb-btn',
|
|
916
|
-
onClick: () => setShowKey((v) => !v),
|
|
917
|
-
},
|
|
1873
|
+
{ type: 'button', className: 'cb-btn', onClick: () => setShowKey((value) => !value) },
|
|
918
1874
|
showKey ? t('key.hide') : t('key.show')
|
|
919
1875
|
),
|
|
920
1876
|
React.createElement(
|
|
@@ -929,37 +1885,32 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
|
|
|
929
1885
|
),
|
|
930
1886
|
React.createElement(
|
|
931
1887
|
'button',
|
|
932
|
-
{
|
|
933
|
-
type: 'button',
|
|
934
|
-
className: 'cb-btn',
|
|
935
|
-
disabled: !!busy,
|
|
936
|
-
onClick: handleFastLogin,
|
|
937
|
-
},
|
|
1888
|
+
{ type: 'button', className: 'cb-btn', disabled: !!busy, onClick: handleFastLogin },
|
|
938
1889
|
t('key.login_fast')
|
|
939
1890
|
)
|
|
940
1891
|
),
|
|
941
1892
|
verifyStatus && verifyStatus.state === 'verifying'
|
|
942
|
-
? React.createElement('div', {
|
|
1893
|
+
? React.createElement('div', { className: 'cb-hint' }, t('key.verifying'))
|
|
943
1894
|
: null,
|
|
944
1895
|
verifyStatus && verifyStatus.state === 'valid'
|
|
945
|
-
? React.createElement('div', { className: 'cb-alert-ok
|
|
1896
|
+
? React.createElement('div', { className: 'cb-alert-ok cb-key-status' }, t('key.verified', { email: verifyStatus.email, plan: verifyStatus.plan }))
|
|
946
1897
|
: null,
|
|
947
1898
|
verifyStatus && verifyStatus.state === 'invalid'
|
|
948
|
-
? React.createElement('div', { className: 'cb-alert-bad
|
|
1899
|
+
? React.createElement('div', { className: 'cb-alert-bad cb-key-status' }, t('key.invalid', { error: verifyStatus.error }))
|
|
949
1900
|
: null,
|
|
950
1901
|
React.createElement(
|
|
951
1902
|
'div',
|
|
952
|
-
{ className: 'cb-
|
|
1903
|
+
{ className: 'cb-key-meta' },
|
|
953
1904
|
React.createElement('span', null, t('key.env_label')),
|
|
954
|
-
React.createElement('code',
|
|
955
|
-
React.createElement('span',
|
|
1905
|
+
React.createElement('code', { className: 'cb-mono' }, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
|
|
1906
|
+
React.createElement('span', { className: 'cb-muted' }, '·'),
|
|
956
1907
|
React.createElement(
|
|
957
1908
|
'a',
|
|
958
1909
|
{
|
|
959
1910
|
href: 'https://app.cline.bot/settings/api-keys',
|
|
960
1911
|
target: '_blank',
|
|
961
1912
|
rel: 'noreferrer',
|
|
962
|
-
|
|
1913
|
+
className: 'cb-link',
|
|
963
1914
|
},
|
|
964
1915
|
t('key.get_key')
|
|
965
1916
|
)
|
|
@@ -968,188 +1919,201 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
|
|
|
968
1919
|
}
|
|
969
1920
|
|
|
970
1921
|
// --- src/client/components/accounts-section.js ---
|
|
1922
|
+
/**
|
|
1923
|
+
* Accounts pool: one calm row per account, with live quota, status dot and a
|
|
1924
|
+
* compact action menu. Account mutations commit immediately; the rest of the
|
|
1925
|
+
* settings page uses the staged save bar.
|
|
1926
|
+
*/
|
|
971
1927
|
function AccountsSection({
|
|
972
1928
|
status,
|
|
973
1929
|
busy,
|
|
974
1930
|
handlePinAccount,
|
|
975
1931
|
handleAddAccount,
|
|
976
1932
|
handleDeleteAccount,
|
|
1933
|
+
handleRenameAccount,
|
|
1934
|
+
handleClearKey,
|
|
977
1935
|
accountMode,
|
|
978
1936
|
handleUpdateAccountMode,
|
|
979
1937
|
modelRoutes = {},
|
|
980
1938
|
onUpdateModelRoutes,
|
|
981
1939
|
t,
|
|
982
1940
|
}) {
|
|
983
|
-
if (!status.accounts || !status.accounts.length) return null
|
|
984
|
-
|
|
985
1941
|
const [showAdd, setShowAdd] = React.useState(false)
|
|
986
1942
|
const [newLabel, setNewLabel] = React.useState('')
|
|
987
1943
|
const [newEnv, setNewEnv] = React.useState('')
|
|
988
1944
|
const [newKey, setNewKey] = React.useState('')
|
|
989
1945
|
const [showKey, setShowKey] = React.useState(false)
|
|
990
|
-
const [
|
|
1946
|
+
const [discoveredKeys, setDiscoveredKeys] = React.useState([])
|
|
1947
|
+
const [usageByAccount, setUsageByAccount] = React.useState({})
|
|
1948
|
+
const [menuFor, setMenuFor] = React.useState('')
|
|
1949
|
+
const [renameFor, setRenameFor] = React.useState('')
|
|
1950
|
+
const [renameValue, setRenameValue] = React.useState('')
|
|
1951
|
+
const [confirmAction, setConfirmAction] = React.useState(null)
|
|
991
1952
|
const [routeModel, setRouteModel] = React.useState('')
|
|
992
1953
|
const [routeAccount, setRouteAccount] = React.useState('')
|
|
993
|
-
const [discoveredKeys, setDiscoveredKeys] = React.useState([])
|
|
994
1954
|
|
|
995
1955
|
React.useEffect(() => {
|
|
996
1956
|
let active = true
|
|
997
1957
|
fetchWithTimeout(`${ROUTE_PREFIX}/keys/discovered`, { cache: 'no-store' })
|
|
998
|
-
.then((
|
|
1958
|
+
.then((response) => response.json())
|
|
999
1959
|
.then((data) => {
|
|
1000
|
-
if (active && data?.ok && Array.isArray(data.discovered))
|
|
1001
|
-
setDiscoveredKeys(data.discovered)
|
|
1002
|
-
}
|
|
1960
|
+
if (active && data?.ok && Array.isArray(data.discovered)) setDiscoveredKeys(data.discovered)
|
|
1003
1961
|
})
|
|
1004
1962
|
.catch(() => {})
|
|
1005
1963
|
return () => { active = false }
|
|
1006
1964
|
}, [])
|
|
1007
1965
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
if (
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
if (typeof handleAddAccount === 'function') {
|
|
1019
|
-
await handleAddAccount({
|
|
1020
|
-
label: item.source || targetEnv,
|
|
1021
|
-
apiKeyEnv: targetEnv,
|
|
1022
|
-
id: item.id,
|
|
1966
|
+
const accountSignature = (status?.accounts || []).map((account) => account.apiKeyEnv || account.id).join('|')
|
|
1967
|
+
React.useEffect(() => {
|
|
1968
|
+
let active = true
|
|
1969
|
+
fetchWithTimeout(`${ROUTE_PREFIX}/accounts/usage`, { cache: 'no-store' })
|
|
1970
|
+
.then((response) => response.json())
|
|
1971
|
+
.then((data) => {
|
|
1972
|
+
if (!active || !data?.ok || !Array.isArray(data.accounts)) return
|
|
1973
|
+
const next = {}
|
|
1974
|
+
for (const entry of data.accounts) next[entry.apiKeyEnv] = entry
|
|
1975
|
+
setUsageByAccount(next)
|
|
1023
1976
|
})
|
|
1024
|
-
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1977
|
+
.catch(() => {})
|
|
1978
|
+
return () => { active = false }
|
|
1979
|
+
}, [accountSignature, status?.activeAccount, busy])
|
|
1027
1980
|
|
|
1028
|
-
function
|
|
1981
|
+
function nextEnvName() {
|
|
1029
1982
|
let max = 1
|
|
1030
|
-
for (const
|
|
1031
|
-
const
|
|
1032
|
-
if (
|
|
1033
|
-
const n = parseInt(m[1], 10)
|
|
1034
|
-
if (n >= max) max = n + 1
|
|
1035
|
-
}
|
|
1983
|
+
for (const account of status.accounts || []) {
|
|
1984
|
+
const match = String(account.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
|
|
1985
|
+
if (match) max = Math.max(max, Number(match[1]) + 1)
|
|
1036
1986
|
}
|
|
1037
|
-
|
|
1987
|
+
return `CLINEBOT_API_KEY_${max}`
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function openAdd() {
|
|
1991
|
+
setNewEnv(nextEnvName())
|
|
1038
1992
|
setShowAdd(true)
|
|
1039
1993
|
}
|
|
1040
1994
|
|
|
1041
|
-
async function
|
|
1042
|
-
if (
|
|
1043
|
-
if (!newKey.trim()) return
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
})
|
|
1050
|
-
}
|
|
1995
|
+
async function submitAdd(event) {
|
|
1996
|
+
if (event?.preventDefault) event.preventDefault()
|
|
1997
|
+
if (!newKey.trim() || typeof handleAddAccount !== 'function') return
|
|
1998
|
+
await handleAddAccount({
|
|
1999
|
+
label: newLabel.trim() || newEnv.trim(),
|
|
2000
|
+
apiKeyEnv: newEnv.trim(),
|
|
2001
|
+
apiKey: newKey.trim(),
|
|
2002
|
+
})
|
|
1051
2003
|
setNewLabel('')
|
|
1052
2004
|
setNewEnv('')
|
|
1053
2005
|
setNewKey('')
|
|
1054
2006
|
setShowAdd(false)
|
|
1055
2007
|
}
|
|
1056
2008
|
|
|
1057
|
-
async function
|
|
1058
|
-
if (typeof
|
|
1059
|
-
|
|
2009
|
+
async function importDiscovered(item) {
|
|
2010
|
+
if (typeof handleAddAccount !== 'function') return
|
|
2011
|
+
await handleAddAccount({
|
|
2012
|
+
label: item.source || nextEnvName(),
|
|
2013
|
+
apiKeyEnv: nextEnvName(),
|
|
2014
|
+
id: item.id,
|
|
2015
|
+
})
|
|
2016
|
+
setDiscoveredKeys((previous) => previous.filter((entry) => entry.id !== item.id))
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function openRename(account) {
|
|
2020
|
+
setMenuFor('')
|
|
2021
|
+
setRenameFor(account.apiKeyEnv)
|
|
2022
|
+
setRenameValue(account.label || '')
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
async function submitRename(account) {
|
|
2026
|
+
if (typeof handleRenameAccount === 'function') {
|
|
2027
|
+
await handleRenameAccount(account.apiKeyEnv, renameValue.trim() || account.apiKeyEnv)
|
|
2028
|
+
}
|
|
2029
|
+
setRenameFor('')
|
|
2030
|
+
setRenameValue('')
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
async function runConfirm() {
|
|
2034
|
+
if (!confirmAction) return
|
|
2035
|
+
if (confirmAction.type === 'delete' && typeof handleDeleteAccount === 'function') {
|
|
2036
|
+
await handleDeleteAccount(confirmAction.env, true)
|
|
1060
2037
|
}
|
|
1061
|
-
if (typeof
|
|
1062
|
-
await
|
|
2038
|
+
if (confirmAction.type === 'clear' && typeof handleClearKey === 'function') {
|
|
2039
|
+
await handleClearKey(confirmAction.env)
|
|
1063
2040
|
}
|
|
2041
|
+
setConfirmAction(null)
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function dotClass(account, live) {
|
|
2045
|
+
if (!account.present) return 'cb-status-dot cb-status-dot-warn'
|
|
2046
|
+
if (live?.error) return 'cb-status-dot cb-status-dot-bad'
|
|
2047
|
+
const percent = live?.usage?.windows?.fiveHour?.percentUsed
|
|
2048
|
+
if (typeof percent === 'number' && percent >= 95) return 'cb-status-dot cb-status-dot-bad'
|
|
2049
|
+
if (account.isPinned || status.activeAccount === account.apiKeyEnv) return 'cb-status-dot cb-status-dot-ok'
|
|
2050
|
+
return 'cb-status-dot cb-status-dot-idle'
|
|
1064
2051
|
}
|
|
1065
2052
|
|
|
2053
|
+
const accounts = status.accounts || []
|
|
2054
|
+
|
|
1066
2055
|
return React.createElement(
|
|
1067
|
-
'
|
|
1068
|
-
{ className: 'cb-section-card' },
|
|
2056
|
+
'section',
|
|
2057
|
+
{ className: 'cb-section-card cb-accounts' },
|
|
1069
2058
|
React.createElement(
|
|
1070
2059
|
'div',
|
|
1071
|
-
{ className: 'cb-section-
|
|
1072
|
-
React.createElement('
|
|
2060
|
+
{ className: 'cb-section-head' },
|
|
2061
|
+
React.createElement('div', { className: 'cb-section-title' }, t('accounts.title')),
|
|
1073
2062
|
!showAdd
|
|
1074
2063
|
? React.createElement(
|
|
1075
2064
|
'button',
|
|
1076
|
-
{
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
style: { fontSize: '11px', padding: '4px 10px' },
|
|
1080
|
-
onClick: handleOpenAdd,
|
|
1081
|
-
disabled: !!busy,
|
|
1082
|
-
},
|
|
1083
|
-
t('accounts.add_btn')
|
|
2065
|
+
{ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !!busy, onClick: openAdd },
|
|
2066
|
+
React.createElement(CbIcon, { name: 'plus', size: 13 }),
|
|
2067
|
+
` ${t('accounts.add_btn')}`
|
|
1084
2068
|
)
|
|
1085
2069
|
: null
|
|
1086
2070
|
),
|
|
1087
2071
|
React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
|
|
2072
|
+
|
|
1088
2073
|
discoveredKeys.length > 0
|
|
1089
2074
|
? React.createElement(
|
|
1090
2075
|
'div',
|
|
1091
|
-
{ className: 'cb-
|
|
1092
|
-
React.createElement(
|
|
1093
|
-
|
|
2076
|
+
{ className: 'cb-discovered' },
|
|
2077
|
+
React.createElement(
|
|
2078
|
+
'div',
|
|
2079
|
+
{ className: 'cb-discovered-head' },
|
|
2080
|
+
React.createElement(CbIcon, { name: 'key', size: 14 }),
|
|
2081
|
+
` ${t('discovery.title')}`
|
|
2082
|
+
),
|
|
2083
|
+
discoveredKeys.map((item) => React.createElement(
|
|
2084
|
+
'div',
|
|
2085
|
+
{ className: 'cb-discovered-row', key: item.id },
|
|
2086
|
+
React.createElement('span', { className: 'cb-mono' }, item.maskedKey || item.source),
|
|
2087
|
+
React.createElement('span', { className: 'cb-muted' }, item.source || ''),
|
|
1094
2088
|
React.createElement(
|
|
1095
|
-
'
|
|
1096
|
-
{
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
disabled: !!busy,
|
|
1105
|
-
onClick: () => handleImportDiscovered(item),
|
|
1106
|
-
},
|
|
1107
|
-
t('discovery.import_btn', { source: item.source })
|
|
1108
|
-
)
|
|
2089
|
+
'button',
|
|
2090
|
+
{
|
|
2091
|
+
type: 'button',
|
|
2092
|
+
className: 'cb-btn',
|
|
2093
|
+
disabled: !!busy,
|
|
2094
|
+
onClick: () => importDiscovered(item),
|
|
2095
|
+
},
|
|
2096
|
+
React.createElement(CbIcon, { name: 'download', size: 12 }),
|
|
2097
|
+
` ${t('discovery.import_btn', { source: item.source || '' })}`
|
|
1109
2098
|
)
|
|
1110
|
-
)
|
|
2099
|
+
))
|
|
1111
2100
|
)
|
|
1112
2101
|
: null,
|
|
2102
|
+
|
|
1113
2103
|
React.createElement(
|
|
1114
2104
|
'div',
|
|
1115
|
-
{
|
|
1116
|
-
|
|
1117
|
-
display: 'flex',
|
|
1118
|
-
alignItems: 'center',
|
|
1119
|
-
gap: '8px',
|
|
1120
|
-
margin: '8px 0 12px 0',
|
|
1121
|
-
fontSize: '12px',
|
|
1122
|
-
color: 'var(--dsw-alias-label-secondary)',
|
|
1123
|
-
},
|
|
1124
|
-
},
|
|
1125
|
-
React.createElement('span', { style: { fontWeight: 500 } }, t('accounts.mode_label') + ':'),
|
|
2105
|
+
{ className: 'cb-account-mode' },
|
|
2106
|
+
React.createElement('span', { className: 'cb-muted' }, t('accounts.mode_label')),
|
|
1126
2107
|
React.createElement(
|
|
1127
2108
|
'div',
|
|
1128
|
-
{
|
|
1129
|
-
style: {
|
|
1130
|
-
display: 'inline-flex',
|
|
1131
|
-
borderRadius: '6px',
|
|
1132
|
-
border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
|
|
1133
|
-
overflow: 'hidden',
|
|
1134
|
-
},
|
|
1135
|
-
},
|
|
2109
|
+
{ className: 'cb-seg' },
|
|
1136
2110
|
React.createElement(
|
|
1137
2111
|
'button',
|
|
1138
2112
|
{
|
|
1139
2113
|
type: 'button',
|
|
1140
|
-
className: 'cb-
|
|
1141
|
-
style: {
|
|
1142
|
-
borderRadius: 0,
|
|
1143
|
-
fontSize: '11px',
|
|
1144
|
-
padding: '3px 9px',
|
|
1145
|
-
border: 'none',
|
|
1146
|
-
background: (accountMode || 'least_used') === 'least_used'
|
|
1147
|
-
? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
|
|
1148
|
-
: 'transparent',
|
|
1149
|
-
fontWeight: (accountMode || 'least_used') === 'least_used' ? 600 : 400,
|
|
1150
|
-
},
|
|
1151
|
-
onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('least_used'),
|
|
2114
|
+
className: (accountMode || 'least_used') === 'least_used' ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
|
|
1152
2115
|
disabled: !!busy,
|
|
2116
|
+
onClick: () => handleUpdateAccountMode && handleUpdateAccountMode('least_used'),
|
|
1153
2117
|
},
|
|
1154
2118
|
t('accounts.mode_least_used')
|
|
1155
2119
|
),
|
|
@@ -1157,20 +2121,9 @@ function AccountsSection({
|
|
|
1157
2121
|
'button',
|
|
1158
2122
|
{
|
|
1159
2123
|
type: 'button',
|
|
1160
|
-
className: 'cb-
|
|
1161
|
-
style: {
|
|
1162
|
-
borderRadius: 0,
|
|
1163
|
-
fontSize: '11px',
|
|
1164
|
-
padding: '3px 9px',
|
|
1165
|
-
border: 'none',
|
|
1166
|
-
borderLeft: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
|
|
1167
|
-
background: accountMode === 'round_robin'
|
|
1168
|
-
? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
|
|
1169
|
-
: 'transparent',
|
|
1170
|
-
fontWeight: accountMode === 'round_robin' ? 600 : 400,
|
|
1171
|
-
},
|
|
1172
|
-
onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('round_robin'),
|
|
2124
|
+
className: accountMode === 'round_robin' ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
|
|
1173
2125
|
disabled: !!busy,
|
|
2126
|
+
onClick: () => handleUpdateAccountMode && handleUpdateAccountMode('round_robin'),
|
|
1174
2127
|
},
|
|
1175
2128
|
t('accounts.mode_round_robin')
|
|
1176
2129
|
)
|
|
@@ -1180,72 +2133,47 @@ function AccountsSection({
|
|
|
1180
2133
|
showAdd
|
|
1181
2134
|
? React.createElement(
|
|
1182
2135
|
'form',
|
|
1183
|
-
{
|
|
1184
|
-
onSubmit: onSubmitAdd,
|
|
1185
|
-
style: {
|
|
1186
|
-
background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
|
|
1187
|
-
border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
|
|
1188
|
-
borderRadius: '8px',
|
|
1189
|
-
padding: '12px',
|
|
1190
|
-
marginBottom: '14px',
|
|
1191
|
-
display: 'flex',
|
|
1192
|
-
flexDirection: 'column',
|
|
1193
|
-
gap: '8px',
|
|
1194
|
-
},
|
|
1195
|
-
},
|
|
2136
|
+
{ className: 'cb-account-add', onSubmit: submitAdd },
|
|
1196
2137
|
React.createElement(
|
|
1197
2138
|
'div',
|
|
1198
|
-
{
|
|
2139
|
+
{ className: 'cb-account-add-row' },
|
|
1199
2140
|
React.createElement('input', {
|
|
1200
2141
|
type: 'text',
|
|
1201
2142
|
className: 'cb-input',
|
|
1202
|
-
style: { flex: '1 1 180px' },
|
|
1203
2143
|
placeholder: t('accounts.label_placeholder'),
|
|
1204
2144
|
value: newLabel,
|
|
1205
|
-
onChange: (
|
|
2145
|
+
onChange: (event) => setNewLabel(event.target.value),
|
|
1206
2146
|
}),
|
|
1207
2147
|
React.createElement('input', {
|
|
1208
2148
|
type: 'text',
|
|
1209
2149
|
className: 'cb-input',
|
|
1210
|
-
style: { flex: '1 1 180px' },
|
|
1211
2150
|
placeholder: t('accounts.env_placeholder'),
|
|
1212
2151
|
value: newEnv,
|
|
1213
|
-
onChange: (
|
|
2152
|
+
onChange: (event) => setNewEnv(event.target.value),
|
|
1214
2153
|
})
|
|
1215
2154
|
),
|
|
1216
2155
|
React.createElement(
|
|
1217
2156
|
'div',
|
|
1218
|
-
{
|
|
2157
|
+
{ className: 'cb-account-add-row' },
|
|
1219
2158
|
React.createElement('input', {
|
|
1220
2159
|
type: showKey ? 'text' : 'password',
|
|
1221
2160
|
className: 'cb-input',
|
|
1222
|
-
style: { flex: '1' },
|
|
1223
2161
|
placeholder: t('key.placeholder_empty'),
|
|
1224
2162
|
value: newKey,
|
|
1225
|
-
onChange: (
|
|
2163
|
+
onChange: (event) => setNewKey(event.target.value),
|
|
1226
2164
|
}),
|
|
1227
2165
|
React.createElement(
|
|
1228
2166
|
'button',
|
|
1229
|
-
{
|
|
1230
|
-
type: 'button',
|
|
1231
|
-
className: 'cb-btn',
|
|
1232
|
-
style: { padding: '4px 8px', fontSize: '11px' },
|
|
1233
|
-
onClick: () => setShowKey(!showKey),
|
|
1234
|
-
},
|
|
2167
|
+
{ type: 'button', className: 'cb-btn', onClick: () => setShowKey((value) => !value) },
|
|
1235
2168
|
showKey ? t('key.hide') : t('key.show')
|
|
1236
2169
|
)
|
|
1237
2170
|
),
|
|
1238
2171
|
React.createElement(
|
|
1239
2172
|
'div',
|
|
1240
|
-
{
|
|
2173
|
+
{ className: 'cb-account-add-actions' },
|
|
1241
2174
|
React.createElement(
|
|
1242
2175
|
'button',
|
|
1243
|
-
{
|
|
1244
|
-
type: 'button',
|
|
1245
|
-
className: 'cb-btn',
|
|
1246
|
-
style: { padding: '4px 10px', fontSize: '12px' },
|
|
1247
|
-
onClick: () => setShowAdd(false),
|
|
1248
|
-
},
|
|
2176
|
+
{ type: 'button', className: 'cb-btn', onClick: () => setShowAdd(false) },
|
|
1249
2177
|
t('accounts.cancel_btn')
|
|
1250
2178
|
),
|
|
1251
2179
|
React.createElement(
|
|
@@ -1253,7 +2181,6 @@ function AccountsSection({
|
|
|
1253
2181
|
{
|
|
1254
2182
|
type: 'submit',
|
|
1255
2183
|
className: 'cb-btn cb-btn-primary',
|
|
1256
|
-
style: { padding: '4px 12px', fontSize: '12px' },
|
|
1257
2184
|
disabled: !newKey.trim() || !!busy,
|
|
1258
2185
|
},
|
|
1259
2186
|
busy === 'add-account' ? t('key.saving') : t('accounts.save_btn')
|
|
@@ -1262,147 +2189,186 @@ function AccountsSection({
|
|
|
1262
2189
|
)
|
|
1263
2190
|
: null,
|
|
1264
2191
|
|
|
1265
|
-
|
|
1266
|
-
'
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
2192
|
+
accounts.length === 0
|
|
2193
|
+
? React.createElement('div', { className: 'cb-empty' }, t('accounts.no_accounts'))
|
|
2194
|
+
: React.createElement(
|
|
2195
|
+
'div',
|
|
2196
|
+
{ className: 'cb-account-list' },
|
|
2197
|
+
accounts.map((account) => {
|
|
2198
|
+
const envName = account.apiKeyEnv || account.id || ''
|
|
2199
|
+
const live = usageByAccount[envName]
|
|
2200
|
+
const isActive = status.activeAccount === envName || (!status.activeAccount && account.id === 'default')
|
|
2201
|
+
const isPrimary = account.id === 'default' || envName === status.config?.apiKeyEnv
|
|
2202
|
+
const five = live?.usage?.windows?.fiveHour
|
|
2203
|
+
const percent = typeof five?.percentUsed === 'number'
|
|
2204
|
+
? five.percentUsed
|
|
2205
|
+
: (typeof account.percentUsed === 'number' ? account.percentUsed : null)
|
|
2206
|
+
const plan = live?.usage?.plan || ''
|
|
2207
|
+
const email = live?.usage?.user?.email || ''
|
|
2208
|
+
return React.createElement(
|
|
2209
|
+
'div',
|
|
2210
|
+
{ className: 'cb-account-item', key: envName || account.id },
|
|
2211
|
+
React.createElement(
|
|
2212
|
+
'div',
|
|
2213
|
+
{ className: 'cb-account-row' },
|
|
2214
|
+
React.createElement('span', { className: dotClass(account, live), 'aria-hidden': 'true' }),
|
|
2215
|
+
React.createElement(
|
|
2216
|
+
'div',
|
|
2217
|
+
{ className: 'cb-account-main' },
|
|
2218
|
+
React.createElement(
|
|
2219
|
+
'div',
|
|
2220
|
+
{ className: 'cb-account-name' },
|
|
2221
|
+
account.label || envName,
|
|
2222
|
+
React.createElement('span', { className: 'cb-account-env cb-mono' }, envName)
|
|
2223
|
+
),
|
|
2224
|
+
React.createElement(
|
|
2225
|
+
'div',
|
|
2226
|
+
{ className: 'cb-account-meta' },
|
|
2227
|
+
[plan, email].filter(Boolean).join(' · ') || t('accounts.source', { source: account.source || '—' })
|
|
2228
|
+
)
|
|
2229
|
+
),
|
|
2230
|
+
React.createElement(
|
|
2231
|
+
'div',
|
|
2232
|
+
{ className: 'cb-account-badges' },
|
|
2233
|
+
isActive ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('accounts.active_badge')) : null,
|
|
2234
|
+
account.isPinned ? React.createElement('span', { className: 'cb-badge' }, t('accounts.pinned_badge')) : null,
|
|
2235
|
+
account.present
|
|
2236
|
+
? React.createElement('span', { className: 'cb-badge' }, t('accounts.configured'))
|
|
2237
|
+
: React.createElement('span', { className: 'cb-badge cb-badge-warn' }, t('accounts.missing_key'))
|
|
2238
|
+
),
|
|
2239
|
+
React.createElement(
|
|
2240
|
+
'div',
|
|
2241
|
+
{ className: 'cb-account-quota' },
|
|
2242
|
+
percent === null
|
|
2243
|
+
? null
|
|
2244
|
+
: React.createElement(
|
|
2245
|
+
React.Fragment,
|
|
2246
|
+
null,
|
|
2247
|
+
React.createElement('span', { className: 'cb-muted' }, t('accounts.quota_5h', { pct: Math.round(percent) })),
|
|
2248
|
+
React.createElement(
|
|
2249
|
+
'span',
|
|
2250
|
+
{ className: 'cb-meter' },
|
|
2251
|
+
React.createElement('span', {
|
|
2252
|
+
className: percent >= 95 ? 'cb-meter-fill cb-meter-fill-warn' : 'cb-meter-fill',
|
|
2253
|
+
style: { width: `${Math.max(0, Math.min(100, percent))}%` },
|
|
2254
|
+
})
|
|
2255
|
+
)
|
|
2256
|
+
)
|
|
2257
|
+
),
|
|
2258
|
+
React.createElement(
|
|
2259
|
+
'button',
|
|
2260
|
+
{
|
|
2261
|
+
type: 'button',
|
|
2262
|
+
className: 'cb-icon-btn',
|
|
2263
|
+
'aria-label': t('accounts.actions'),
|
|
2264
|
+
'aria-expanded': menuFor === envName,
|
|
2265
|
+
disabled: !!busy,
|
|
2266
|
+
onClick: () => setMenuFor((current) => (current === envName ? '' : envName)),
|
|
2267
|
+
},
|
|
2268
|
+
React.createElement(CbIcon, { name: 'kebab', size: 16 })
|
|
2269
|
+
)
|
|
2270
|
+
),
|
|
2271
|
+
|
|
2272
|
+
menuFor === envName
|
|
1313
2273
|
? React.createElement(
|
|
1314
|
-
'
|
|
1315
|
-
{
|
|
1316
|
-
|
|
1317
|
-
className: 'cb-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
2274
|
+
'div',
|
|
2275
|
+
{ className: 'cb-account-menu' },
|
|
2276
|
+
isActive
|
|
2277
|
+
? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); handlePinAccount('') } }, t('accounts.unpin'))
|
|
2278
|
+
: account.present
|
|
2279
|
+
? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); handlePinAccount(envName) } }, t('accounts.pin_btn'))
|
|
2280
|
+
: null,
|
|
2281
|
+
!isPrimary
|
|
2282
|
+
? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => openRename(account) }, t('accounts.rename'))
|
|
2283
|
+
: null,
|
|
2284
|
+
account.present
|
|
2285
|
+
? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); setConfirmAction({ type: 'clear', env: envName }) } }, t('accounts.clear_key'))
|
|
2286
|
+
: null,
|
|
2287
|
+
!isPrimary
|
|
2288
|
+
? React.createElement('button', { type: 'button', className: 'cb-menu-item cb-menu-item-danger', onClick: () => { setMenuFor(''); setConfirmAction({ type: 'delete', env: envName }) } }, t('accounts.delete_btn'))
|
|
2289
|
+
: null
|
|
1323
2290
|
)
|
|
1324
2291
|
: null,
|
|
1325
|
-
|
|
2292
|
+
|
|
2293
|
+
renameFor === envName
|
|
1326
2294
|
? React.createElement(
|
|
1327
|
-
'
|
|
1328
|
-
{
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
2295
|
+
'div',
|
|
2296
|
+
{ className: 'cb-account-inline' },
|
|
2297
|
+
React.createElement('input', {
|
|
2298
|
+
type: 'text',
|
|
2299
|
+
className: 'cb-input',
|
|
2300
|
+
value: renameValue,
|
|
2301
|
+
onChange: (event) => setRenameValue(event.target.value),
|
|
2302
|
+
placeholder: t('accounts.rename_placeholder'),
|
|
2303
|
+
}),
|
|
2304
|
+
React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !!busy, onClick: () => submitRename(account) }, t('accounts.rename_save')),
|
|
2305
|
+
React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => setRenameFor('') }, t('accounts.cancel'))
|
|
2306
|
+
)
|
|
2307
|
+
: null,
|
|
2308
|
+
|
|
2309
|
+
confirmAction && confirmAction.env === envName
|
|
2310
|
+
? React.createElement(
|
|
2311
|
+
'div',
|
|
2312
|
+
{ className: 'cb-account-inline cb-account-danger' },
|
|
2313
|
+
React.createElement(
|
|
2314
|
+
'span',
|
|
2315
|
+
{ className: 'cb-account-danger-text' },
|
|
2316
|
+
confirmAction.type === 'delete'
|
|
2317
|
+
? t('accounts.remove_confirm', { name: account.label || envName })
|
|
2318
|
+
: t('accounts.clear_confirm', { name: account.label || envName })
|
|
2319
|
+
),
|
|
2320
|
+
React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-danger', disabled: !!busy, onClick: runConfirm }, t('accounts.confirm')),
|
|
2321
|
+
React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => setConfirmAction(null) }, t('accounts.cancel'))
|
|
1336
2322
|
)
|
|
1337
2323
|
: null
|
|
1338
2324
|
)
|
|
1339
|
-
)
|
|
1340
|
-
})
|
|
1341
|
-
)
|
|
1342
|
-
),
|
|
1343
|
-
status.lastRotation
|
|
1344
|
-
? React.createElement(
|
|
1345
|
-
'div',
|
|
1346
|
-
{ className: 'cb-rotation-info', style: { marginTop: '10px', fontSize: '12px', opacity: 0.85 } },
|
|
1347
|
-
t('accounts.last_failover', {
|
|
1348
|
-
from: status.lastRotation.from,
|
|
1349
|
-
to: status.lastRotation.to,
|
|
1350
|
-
reason: status.lastRotation.reason,
|
|
1351
2325
|
})
|
|
1352
|
-
)
|
|
1353
|
-
|
|
2326
|
+
),
|
|
2327
|
+
|
|
1354
2328
|
React.createElement(
|
|
1355
2329
|
'div',
|
|
1356
|
-
{
|
|
2330
|
+
{ className: 'cb-account-routes' },
|
|
1357
2331
|
React.createElement(
|
|
1358
2332
|
'div',
|
|
1359
|
-
{
|
|
1360
|
-
|
|
2333
|
+
{ className: 'cb-account-routes-head' },
|
|
2334
|
+
React.createElement(CbIcon, { name: 'route', size: 14 }),
|
|
2335
|
+
` ${t('accounts.routes_title')}`
|
|
1361
2336
|
),
|
|
2337
|
+
React.createElement('div', { className: 'cb-section-desc' }, t('accounts.routes_desc')),
|
|
1362
2338
|
React.createElement(
|
|
1363
2339
|
'div',
|
|
1364
|
-
{
|
|
1365
|
-
t('accounts.routes_desc')
|
|
1366
|
-
),
|
|
1367
|
-
React.createElement(
|
|
1368
|
-
'div',
|
|
1369
|
-
{ style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } },
|
|
2340
|
+
{ className: 'cb-account-route-add' },
|
|
1370
2341
|
React.createElement('input', {
|
|
1371
2342
|
type: 'text',
|
|
1372
2343
|
className: 'cb-input',
|
|
1373
|
-
style: { flex: '2 1 140px', height: '26px', fontSize: '11px', padding: '0 6px' },
|
|
1374
2344
|
placeholder: t('accounts.route_model_placeholder'),
|
|
1375
2345
|
value: routeModel,
|
|
1376
|
-
onChange: (
|
|
2346
|
+
onChange: (event) => setRouteModel(event.target.value),
|
|
1377
2347
|
}),
|
|
1378
2348
|
React.createElement(
|
|
1379
2349
|
'select',
|
|
1380
2350
|
{
|
|
1381
2351
|
className: 'cb-select',
|
|
1382
|
-
style: { flex: '1 1 120px', height: '26px', fontSize: '11px', padding: '0 4px' },
|
|
1383
2352
|
value: routeAccount,
|
|
1384
|
-
onChange: (
|
|
2353
|
+
onChange: (event) => setRouteAccount(event.target.value),
|
|
1385
2354
|
},
|
|
1386
2355
|
React.createElement('option', { value: '' }, t('accounts.route_account_placeholder')),
|
|
1387
|
-
|
|
1388
|
-
const
|
|
1389
|
-
return React.createElement('option', { key
|
|
2356
|
+
accounts.map((account) => {
|
|
2357
|
+
const key = account.apiKeyEnv || account.id
|
|
2358
|
+
return React.createElement('option', { key, value: key }, `${account.label || key} (${key})`)
|
|
1390
2359
|
})
|
|
1391
2360
|
),
|
|
1392
2361
|
React.createElement(
|
|
1393
2362
|
'button',
|
|
1394
2363
|
{
|
|
1395
2364
|
type: 'button',
|
|
1396
|
-
className: 'cb-btn
|
|
1397
|
-
style: { padding: '3px 8px', fontSize: '11px' },
|
|
2365
|
+
className: 'cb-btn',
|
|
1398
2366
|
disabled: !routeModel.trim() || !routeAccount.trim() || !!busy,
|
|
1399
2367
|
onClick: () => {
|
|
1400
|
-
if (typeof onUpdateModelRoutes
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
setRouteAccount('')
|
|
1405
|
-
}
|
|
2368
|
+
if (typeof onUpdateModelRoutes !== 'function') return
|
|
2369
|
+
onUpdateModelRoutes({ ...(modelRoutes || {}), [routeModel.trim()]: routeAccount.trim() })
|
|
2370
|
+
setRouteModel('')
|
|
2371
|
+
setRouteAccount('')
|
|
1406
2372
|
},
|
|
1407
2373
|
},
|
|
1408
2374
|
t('accounts.route_btn_add')
|
|
@@ -1411,48 +2377,30 @@ function AccountsSection({
|
|
|
1411
2377
|
Object.keys(modelRoutes || {}).length > 0
|
|
1412
2378
|
? React.createElement(
|
|
1413
2379
|
'div',
|
|
1414
|
-
{
|
|
1415
|
-
Object.entries(modelRoutes || {}).map(([
|
|
2380
|
+
{ className: 'cb-account-route-list' },
|
|
2381
|
+
Object.entries(modelRoutes || {}).map(([modelId, accountKey]) => React.createElement(
|
|
2382
|
+
'div',
|
|
2383
|
+
{ className: 'cb-account-route-row', key: modelId },
|
|
2384
|
+
React.createElement('span', { className: 'cb-mono' }, modelId),
|
|
2385
|
+
React.createElement(CbIcon, { name: 'chevron', size: 12 }),
|
|
2386
|
+
React.createElement('strong', null, accountKey),
|
|
1416
2387
|
React.createElement(
|
|
1417
|
-
'
|
|
2388
|
+
'button',
|
|
1418
2389
|
{
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
2390
|
+
type: 'button',
|
|
2391
|
+
className: 'cb-icon-btn',
|
|
2392
|
+
'aria-label': t('accounts.route_remove'),
|
|
2393
|
+
disabled: !!busy,
|
|
2394
|
+
onClick: () => {
|
|
2395
|
+
if (typeof onUpdateModelRoutes !== 'function') return
|
|
2396
|
+
const next = { ...(modelRoutes || {}) }
|
|
2397
|
+
delete next[modelId]
|
|
2398
|
+
onUpdateModelRoutes(next)
|
|
1428
2399
|
},
|
|
1429
2400
|
},
|
|
1430
|
-
React.createElement(
|
|
1431
|
-
'span',
|
|
1432
|
-
null,
|
|
1433
|
-
React.createElement('code', null, mId),
|
|
1434
|
-
' ➔ ',
|
|
1435
|
-
React.createElement('strong', null, accKey)
|
|
1436
|
-
),
|
|
1437
|
-
React.createElement(
|
|
1438
|
-
'button',
|
|
1439
|
-
{
|
|
1440
|
-
type: 'button',
|
|
1441
|
-
className: 'cb-btn cb-btn-danger',
|
|
1442
|
-
style: { padding: '1px 6px', fontSize: '10px' },
|
|
1443
|
-
disabled: !!busy,
|
|
1444
|
-
onClick: () => {
|
|
1445
|
-
if (typeof onUpdateModelRoutes === 'function') {
|
|
1446
|
-
const next = { ...(modelRoutes || {}) }
|
|
1447
|
-
delete next[mId]
|
|
1448
|
-
onUpdateModelRoutes(next)
|
|
1449
|
-
}
|
|
1450
|
-
},
|
|
1451
|
-
},
|
|
1452
|
-
'✕'
|
|
1453
|
-
)
|
|
2401
|
+
React.createElement(CbIcon, { name: 'close', size: 13 })
|
|
1454
2402
|
)
|
|
1455
|
-
)
|
|
2403
|
+
))
|
|
1456
2404
|
)
|
|
1457
2405
|
: null
|
|
1458
2406
|
)
|
|
@@ -1474,15 +2422,26 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
|
|
|
1474
2422
|
? React.createElement(
|
|
1475
2423
|
'div',
|
|
1476
2424
|
{ className: status.quotaWarning.level === 'exhausted' ? 'cb-banner-exhausted' : 'cb-banner-warning' },
|
|
1477
|
-
React.createElement(
|
|
2425
|
+
React.createElement(
|
|
2426
|
+
'span',
|
|
2427
|
+
{ className: 'cb-banner-icon' },
|
|
2428
|
+
React.createElement(CbIcon, {
|
|
2429
|
+
name: status.quotaWarning.level === 'exhausted' ? 'error' : 'warning',
|
|
2430
|
+
size: 16,
|
|
2431
|
+
})
|
|
2432
|
+
),
|
|
1478
2433
|
React.createElement(
|
|
1479
2434
|
'div',
|
|
1480
|
-
{
|
|
1481
|
-
React.createElement(
|
|
2435
|
+
{ className: 'cb-banner-body' },
|
|
2436
|
+
React.createElement(
|
|
2437
|
+
'div',
|
|
2438
|
+
null,
|
|
2439
|
+
(status.quotaWarning.code && t(status.quotaWarning.code, status.quotaWarning.params)) || status.quotaWarning.message
|
|
2440
|
+
),
|
|
1482
2441
|
status.quotaWarning.resetsAt
|
|
1483
2442
|
? React.createElement(
|
|
1484
2443
|
'div',
|
|
1485
|
-
{
|
|
2444
|
+
{ className: 'cb-banner-sub' },
|
|
1486
2445
|
t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
|
|
1487
2446
|
)
|
|
1488
2447
|
: null
|
|
@@ -1492,26 +2451,25 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
|
|
|
1492
2451
|
? React.createElement(
|
|
1493
2452
|
'div',
|
|
1494
2453
|
{ className: 'cb-banner-warning' },
|
|
1495
|
-
React.createElement(
|
|
1496
|
-
|
|
2454
|
+
React.createElement(
|
|
2455
|
+
'span',
|
|
2456
|
+
{ className: 'cb-banner-icon' },
|
|
2457
|
+
React.createElement(CbIcon, { name: 'warning', size: 16 })
|
|
2458
|
+
),
|
|
2459
|
+
React.createElement('div', { className: 'cb-banner-body' }, t('quota.warning_low'))
|
|
1497
2460
|
)
|
|
1498
2461
|
: null),
|
|
1499
2462
|
keyPresent
|
|
1500
2463
|
? React.createElement(
|
|
1501
|
-
'
|
|
2464
|
+
'section',
|
|
1502
2465
|
{ className: 'cb-section-card' },
|
|
1503
2466
|
React.createElement(
|
|
1504
2467
|
'div',
|
|
1505
|
-
{ className: 'cb-section-
|
|
1506
|
-
t('quota.title'),
|
|
2468
|
+
{ className: 'cb-section-head' },
|
|
2469
|
+
React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
|
|
1507
2470
|
React.createElement(
|
|
1508
2471
|
'button',
|
|
1509
|
-
{
|
|
1510
|
-
type: 'button',
|
|
1511
|
-
className: 'cb-btn',
|
|
1512
|
-
disabled: !!busy,
|
|
1513
|
-
onClick: handleRefreshQuota,
|
|
1514
|
-
},
|
|
2472
|
+
{ type: 'button', className: 'cb-btn', disabled: !!busy, onClick: handleRefreshQuota },
|
|
1515
2473
|
busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
|
|
1516
2474
|
)
|
|
1517
2475
|
),
|
|
@@ -1524,7 +2482,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
|
|
|
1524
2482
|
),
|
|
1525
2483
|
React.createElement(
|
|
1526
2484
|
'div',
|
|
1527
|
-
{ className: 'cb-
|
|
2485
|
+
{ className: 'cb-quota-grid' },
|
|
1528
2486
|
usage?.windows?.fiveHour
|
|
1529
2487
|
? React.createElement(ProgressBar, {
|
|
1530
2488
|
label: t('quota.window_5h'),
|
|
@@ -1551,21 +2509,15 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
|
|
|
1551
2509
|
})
|
|
1552
2510
|
: null
|
|
1553
2511
|
),
|
|
1554
|
-
React.createElement(
|
|
1555
|
-
'
|
|
1556
|
-
|
|
1557
|
-
React.createElement(
|
|
1558
|
-
'div',
|
|
1559
|
-
{ style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
|
|
1560
|
-
React.createElement('span', { style: { fontSize: '14px', lineHeight: '20px', color: 'var(--dsw-alias-label-primary)' } }, t('sidebar.show_quota')),
|
|
1561
|
-
React.createElement('span', { style: { marginTop: '4px', fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' } }, t('sidebar.show_quota_hint'))
|
|
1562
|
-
),
|
|
1563
|
-
React.createElement(BooleanControl, {
|
|
2512
|
+
React.createElement(SettingRow, {
|
|
2513
|
+
title: t('sidebar.show_quota'),
|
|
2514
|
+
description: t('sidebar.show_quota_hint'),
|
|
2515
|
+
control: React.createElement(BooleanControl, {
|
|
1564
2516
|
checked: showSidebarQuota !== false,
|
|
1565
2517
|
label: t('sidebar.show_quota'),
|
|
1566
2518
|
onChange: (next) => onToggleSidebarQuota && onToggleSidebarQuota(next),
|
|
1567
|
-
})
|
|
1568
|
-
)
|
|
2519
|
+
}),
|
|
2520
|
+
})
|
|
1569
2521
|
)
|
|
1570
2522
|
: null
|
|
1571
2523
|
)
|
|
@@ -1632,12 +2584,17 @@ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
|
|
|
1632
2584
|
React.createElement(
|
|
1633
2585
|
'span',
|
|
1634
2586
|
{ style: { fontWeight: 500, fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } },
|
|
1635
|
-
|
|
2587
|
+
React.createElement(CbIcon, { name: 'plus', size: 13 }),
|
|
2588
|
+
` ${t('models.custom_add_title')} (${customModels.length})`
|
|
1636
2589
|
),
|
|
1637
2590
|
React.createElement(
|
|
1638
2591
|
'span',
|
|
1639
2592
|
{ style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
1640
|
-
|
|
2593
|
+
React.createElement(CbIcon, {
|
|
2594
|
+
name: 'chevron',
|
|
2595
|
+
size: 13,
|
|
2596
|
+
className: isExpanded ? 'cb-icon cb-icon-chevron-open' : 'cb-icon',
|
|
2597
|
+
})
|
|
1641
2598
|
)
|
|
1642
2599
|
),
|
|
1643
2600
|
isExpanded
|
|
@@ -1897,7 +2854,12 @@ function VisibleModelsSub({
|
|
|
1897
2854
|
style: { padding: '4px 10px', fontSize: '12px' },
|
|
1898
2855
|
onClick: () => setOpen(!open),
|
|
1899
2856
|
},
|
|
1900
|
-
|
|
2857
|
+
React.createElement(CbIcon, {
|
|
2858
|
+
name: 'chevron',
|
|
2859
|
+
size: 12,
|
|
2860
|
+
className: open ? 'cb-icon cb-icon-chevron-open' : 'cb-icon',
|
|
2861
|
+
}),
|
|
2862
|
+
` ${t('models.models_selected', { selected: count, total: modelsList.length })}`
|
|
1901
2863
|
)
|
|
1902
2864
|
)
|
|
1903
2865
|
),
|
|
@@ -1984,7 +2946,7 @@ function VisibleModelsSub({
|
|
|
1984
2946
|
? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 6px' } }, t('models.in_plan'))
|
|
1985
2947
|
: React.createElement('span', { className: 'cb-badge', style: { fontSize: '10px', padding: '1px 6px', color: 'var(--dsw-alias-label-secondary)' } }, t('models.out_of_plan')),
|
|
1986
2948
|
!m.isContextOverridden
|
|
1987
|
-
? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 5px' } }, '
|
|
2949
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 5px' } }, t('models.ctx_tag_original'))
|
|
1988
2950
|
: null
|
|
1989
2951
|
)
|
|
1990
2952
|
)
|
|
@@ -1995,6 +2957,23 @@ function VisibleModelsSub({
|
|
|
1995
2957
|
)
|
|
1996
2958
|
}
|
|
1997
2959
|
|
|
2960
|
+
// --- src/client/components/provider-card.js ---
|
|
2961
|
+
/**
|
|
2962
|
+
* Provider card inside DSH Settings -> Models.
|
|
2963
|
+
*
|
|
2964
|
+
* The host owns the row chrome: provider name, status dot and the Edit action.
|
|
2965
|
+
* Accounts, the API key, models and quotas live on the full Settings -> ClineBot
|
|
2966
|
+
* page, so this slot intentionally renders nothing instead of duplicating that
|
|
2967
|
+
* form inside the Models page.
|
|
2968
|
+
*/
|
|
2969
|
+
function ProviderCard(props) {
|
|
2970
|
+
const row = props?.provider || {}
|
|
2971
|
+
const providerId = typeof row === 'string' ? row : (row.provider || props?.providerId || '')
|
|
2972
|
+
const settingsNs = row && typeof row === 'object' ? (row.settingsNs || '') : ''
|
|
2973
|
+
if (providerId !== 'clinebot' && settingsNs !== NS) return null
|
|
2974
|
+
return null
|
|
2975
|
+
}
|
|
2976
|
+
|
|
1998
2977
|
// --- src/client/components/models-section.js ---
|
|
1999
2978
|
function ModelsSection({
|
|
2000
2979
|
modelsList,
|
|
@@ -2061,9 +3040,7 @@ function ModelsSection({
|
|
|
2061
3040
|
return React.createElement(
|
|
2062
3041
|
'div',
|
|
2063
3042
|
{ className: 'cb-section-card' },
|
|
2064
|
-
defaultModelWarning
|
|
2065
|
-
? React.createElement('div', { className: 'cb-alert-bad', style: { marginBottom: '12px' } }, `⚠️ ${defaultModelWarning}`)
|
|
2066
|
-
: null,
|
|
3043
|
+
defaultModelWarning ? React.createElement('div', { className: 'cb-alert-bad cb-inline-alert', style: { marginBottom: '12px' } }, React.createElement(CbIcon, { name: 'warning', size: 14 }), ` ${defaultModelWarning}`) : null,
|
|
2067
3044
|
React.createElement(VisibleModelsSub, {
|
|
2068
3045
|
modelsList,
|
|
2069
3046
|
visibleModels,
|
|
@@ -2424,7 +3401,7 @@ function ModelsSection({
|
|
|
2424
3401
|
setEditingModelId(m.id)
|
|
2425
3402
|
},
|
|
2426
3403
|
},
|
|
2427
|
-
'
|
|
3404
|
+
React.createElement(CbIcon, { name: 'edit', size: 13 })
|
|
2428
3405
|
)
|
|
2429
3406
|
)
|
|
2430
3407
|
),
|
|
@@ -2503,6 +3480,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2503
3480
|
const requests = typeof totals.requests === 'number' ? totals.requests : (typeof s.requests === 'number' ? s.requests : 0)
|
|
2504
3481
|
const totalTokens = typeof totals.totalTokens === 'number' ? totals.totalTokens : (typeof s.totalTokens === 'number' ? s.totalTokens : 0)
|
|
2505
3482
|
const errors429 = typeof totals.rateLimited429 === 'number' ? totals.rateLimited429 : (typeof s.errors429 === 'number' ? s.errors429 : (typeof totals.failed === 'number' ? totals.failed : 0))
|
|
3483
|
+
const reasoning = Number(totals.reasoningTokens || sess.reasoningTokensEst || 0)
|
|
2506
3484
|
|
|
2507
3485
|
const topModels = Object.entries(s.byModel || {})
|
|
2508
3486
|
.sort((a, b) => {
|
|
@@ -2513,24 +3491,24 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2513
3491
|
.slice(0, 5)
|
|
2514
3492
|
|
|
2515
3493
|
return React.createElement(
|
|
2516
|
-
'
|
|
3494
|
+
'section',
|
|
2517
3495
|
{ className: 'cb-section-card' },
|
|
2518
3496
|
React.createElement(
|
|
2519
3497
|
'div',
|
|
2520
|
-
{
|
|
3498
|
+
{ className: 'cb-section-head' },
|
|
2521
3499
|
React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
|
|
2522
3500
|
React.createElement(
|
|
2523
3501
|
'div',
|
|
2524
|
-
{
|
|
3502
|
+
{ className: 'cb-section-actions' },
|
|
2525
3503
|
React.createElement(
|
|
2526
3504
|
'a',
|
|
2527
3505
|
{
|
|
2528
3506
|
href: '/dsh-clinebot/stats/export',
|
|
2529
3507
|
download: 'clinebot-stats.csv',
|
|
2530
|
-
className: 'cb-btn',
|
|
2531
|
-
style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
|
|
3508
|
+
className: 'cb-btn cb-btn-link',
|
|
2532
3509
|
},
|
|
2533
|
-
|
|
3510
|
+
React.createElement(CbIcon, { name: 'download', size: 13 }),
|
|
3511
|
+
` ${t('stats.export_csv')}`
|
|
2534
3512
|
),
|
|
2535
3513
|
confirmReset
|
|
2536
3514
|
? React.createElement(
|
|
@@ -2541,7 +3519,6 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2541
3519
|
{
|
|
2542
3520
|
type: 'button',
|
|
2543
3521
|
className: 'cb-btn cb-btn-warn',
|
|
2544
|
-
style: { padding: '2px 8px', fontSize: '11px', marginRight: '6px' },
|
|
2545
3522
|
disabled: !!busy,
|
|
2546
3523
|
onClick: () => {
|
|
2547
3524
|
setConfirmReset(false)
|
|
@@ -2552,12 +3529,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2552
3529
|
),
|
|
2553
3530
|
React.createElement(
|
|
2554
3531
|
'button',
|
|
2555
|
-
{
|
|
2556
|
-
type: 'button',
|
|
2557
|
-
className: 'cb-btn',
|
|
2558
|
-
style: { padding: '2px 8px', fontSize: '11px' },
|
|
2559
|
-
onClick: () => setConfirmReset(false),
|
|
2560
|
-
},
|
|
3532
|
+
{ type: 'button', className: 'cb-btn', onClick: () => setConfirmReset(false) },
|
|
2561
3533
|
t('stats.cancel_reset')
|
|
2562
3534
|
)
|
|
2563
3535
|
)
|
|
@@ -2566,7 +3538,6 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2566
3538
|
{
|
|
2567
3539
|
type: 'button',
|
|
2568
3540
|
className: 'cb-btn',
|
|
2569
|
-
style: { padding: '2px 8px', fontSize: '11px' },
|
|
2570
3541
|
disabled: !!busy,
|
|
2571
3542
|
onClick: () => setConfirmReset(true),
|
|
2572
3543
|
},
|
|
@@ -2577,7 +3548,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2577
3548
|
React.createElement('div', { className: 'cb-section-desc' }, t('stats.desc')),
|
|
2578
3549
|
React.createElement(
|
|
2579
3550
|
'div',
|
|
2580
|
-
{ className: 'cb-grid
|
|
3551
|
+
{ className: 'cb-stats-grid' },
|
|
2581
3552
|
React.createElement(
|
|
2582
3553
|
'div',
|
|
2583
3554
|
{ className: 'cb-stat-box' },
|
|
@@ -2587,16 +3558,12 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2587
3558
|
React.createElement(
|
|
2588
3559
|
'div',
|
|
2589
3560
|
{ className: 'cb-stat-box' },
|
|
2590
|
-
React.createElement(
|
|
2591
|
-
'div',
|
|
2592
|
-
{ className: 'cb-stat-val' },
|
|
2593
|
-
`${Number(totalTokens).toLocaleString()}`
|
|
2594
|
-
),
|
|
3561
|
+
React.createElement('div', { className: 'cb-stat-val' }, Number(totalTokens).toLocaleString()),
|
|
2595
3562
|
React.createElement(
|
|
2596
3563
|
'div',
|
|
2597
3564
|
{ className: 'cb-stat-lbl' },
|
|
2598
|
-
|
|
2599
|
-
? `${t('stats.tokens')} (${
|
|
3565
|
+
reasoning > 0
|
|
3566
|
+
? `${t('stats.tokens')} (${reasoning.toLocaleString()} reasoning)`
|
|
2600
3567
|
: t('stats.tokens')
|
|
2601
3568
|
)
|
|
2602
3569
|
),
|
|
@@ -2616,30 +3583,18 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
2616
3583
|
topModels.length > 0
|
|
2617
3584
|
? React.createElement(
|
|
2618
3585
|
'div',
|
|
2619
|
-
{
|
|
2620
|
-
React.createElement('div', {
|
|
2621
|
-
React.createElement(
|
|
3586
|
+
{ className: 'cb-model-list' },
|
|
3587
|
+
React.createElement('div', { className: 'cb-model-list-title' }, t('stats.top_models')),
|
|
3588
|
+
topModels.map(([modelId, data]) => React.createElement(
|
|
2622
3589
|
'div',
|
|
2623
|
-
{
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
style: {
|
|
2630
|
-
display: 'flex',
|
|
2631
|
-
justifyContent: 'space-between',
|
|
2632
|
-
fontSize: '11px',
|
|
2633
|
-
padding: '4px 8px',
|
|
2634
|
-
background: 'var(--dsw-alias-bg-layer-2)',
|
|
2635
|
-
borderRadius: '4px',
|
|
2636
|
-
},
|
|
2637
|
-
},
|
|
2638
|
-
React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
|
|
2639
|
-
React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok${data.reasoningTokens ? ` (${Number(data.reasoningTokens).toLocaleString()} thk)` : ''}`)
|
|
2640
|
-
)
|
|
3590
|
+
{ className: 'cb-model-row', key: modelId },
|
|
3591
|
+
React.createElement('span', { className: 'cb-mono' }, modelId),
|
|
3592
|
+
React.createElement(
|
|
3593
|
+
'span',
|
|
3594
|
+
{ className: 'cb-muted' },
|
|
3595
|
+
`${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok${data.reasoningTokens ? ` (${Number(data.reasoningTokens).toLocaleString()} reasoning)` : ''}`
|
|
2641
3596
|
)
|
|
2642
|
-
)
|
|
3597
|
+
))
|
|
2643
3598
|
)
|
|
2644
3599
|
: null
|
|
2645
3600
|
)
|
|
@@ -2659,49 +3614,19 @@ function DiagSection({
|
|
|
2659
3614
|
t,
|
|
2660
3615
|
}) {
|
|
2661
3616
|
return React.createElement(
|
|
2662
|
-
'
|
|
3617
|
+
'section',
|
|
2663
3618
|
{ className: 'cb-section-card' },
|
|
2664
3619
|
React.createElement('div', { className: 'cb-section-title' }, t('diag.title')),
|
|
2665
3620
|
React.createElement('div', { className: 'cb-section-desc' }, t('diag.desc')),
|
|
2666
|
-
React.createElement(
|
|
2667
|
-
'
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
justifyContent: 'space-between',
|
|
2673
|
-
padding: '10px 14px',
|
|
2674
|
-
background: 'var(--dsw-alias-bg-layer-2)',
|
|
2675
|
-
borderRadius: '8px',
|
|
2676
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
2677
|
-
},
|
|
2678
|
-
},
|
|
2679
|
-
React.createElement(
|
|
2680
|
-
'div',
|
|
2681
|
-
{ style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
|
|
2682
|
-
React.createElement(
|
|
2683
|
-
'span',
|
|
2684
|
-
{ style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
|
|
2685
|
-
t('diag.proxy_title')
|
|
2686
|
-
),
|
|
2687
|
-
React.createElement(
|
|
2688
|
-
'span',
|
|
2689
|
-
{ style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
2690
|
-
t('diag.proxy_desc')
|
|
2691
|
-
)
|
|
2692
|
-
),
|
|
2693
|
-
React.createElement(
|
|
2694
|
-
'div',
|
|
2695
|
-
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px' } },
|
|
3621
|
+
React.createElement(SettingRow, {
|
|
3622
|
+
title: t('diag.proxy_title'),
|
|
3623
|
+
description: t('diag.proxy_desc'),
|
|
3624
|
+
control: React.createElement(
|
|
3625
|
+
React.Fragment,
|
|
3626
|
+
null,
|
|
2696
3627
|
React.createElement(
|
|
2697
3628
|
'span',
|
|
2698
|
-
{
|
|
2699
|
-
style: {
|
|
2700
|
-
fontSize: '14px',
|
|
2701
|
-
lineHeight: '20px',
|
|
2702
|
-
color: proxyMode ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
2703
|
-
},
|
|
2704
|
-
},
|
|
3629
|
+
{ className: proxyMode ? 'cb-state-ok' : 'cb-muted' },
|
|
2705
3630
|
proxyMode ? t('diag.proxy_enabled') : t('diag.proxy_disabled')
|
|
2706
3631
|
),
|
|
2707
3632
|
React.createElement(BooleanControl, {
|
|
@@ -2710,11 +3635,11 @@ function DiagSection({
|
|
|
2710
3635
|
label: t('diag.proxy_title'),
|
|
2711
3636
|
onChange: (next) => onToggleProxy && onToggleProxy(next),
|
|
2712
3637
|
})
|
|
2713
|
-
)
|
|
2714
|
-
),
|
|
3638
|
+
),
|
|
3639
|
+
}),
|
|
2715
3640
|
React.createElement(
|
|
2716
3641
|
'div',
|
|
2717
|
-
{ className: 'cb-row' },
|
|
3642
|
+
{ className: 'cb-row cb-row-actions' },
|
|
2718
3643
|
React.createElement(
|
|
2719
3644
|
'button',
|
|
2720
3645
|
{
|
|
@@ -2737,12 +3662,12 @@ function DiagSection({
|
|
|
2737
3662
|
busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
|
|
2738
3663
|
)
|
|
2739
3664
|
: null,
|
|
3665
|
+
React.createElement('span', { className: 'cb-settings-spacer' }),
|
|
2740
3666
|
React.createElement(
|
|
2741
3667
|
'button',
|
|
2742
3668
|
{
|
|
2743
3669
|
type: 'button',
|
|
2744
3670
|
className: 'cb-btn cb-btn-primary',
|
|
2745
|
-
style: { marginLeft: 'auto' },
|
|
2746
3671
|
disabled: !!busy,
|
|
2747
3672
|
onClick: handleRegister,
|
|
2748
3673
|
},
|
|
@@ -2802,7 +3727,7 @@ function MiniRing({ percent, size = 18, warn = false }) {
|
|
|
2802
3727
|
)
|
|
2803
3728
|
}
|
|
2804
3729
|
|
|
2805
|
-
function SidebarQuota({ ctx, wide, t: propT }) {
|
|
3730
|
+
function SidebarQuota({ ctx, wide, t: propT, open: openPanel }) {
|
|
2806
3731
|
const t = propT || makeT(en, en)
|
|
2807
3732
|
const [data, setData] = React.useState(null)
|
|
2808
3733
|
const [open, setOpen] = React.useState(false)
|
|
@@ -2839,18 +3764,48 @@ function SidebarQuota({ ctx, wide, t: propT }) {
|
|
|
2839
3764
|
|
|
2840
3765
|
if (!show) return null
|
|
2841
3766
|
|
|
2842
|
-
const
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
3767
|
+
const windowEntries = [
|
|
3768
|
+
{ key: 'fiveHour', label: t('quota.window_5h'), value: data?.windows?.fiveHour },
|
|
3769
|
+
{ key: 'weekly', label: t('quota.window_weekly'), value: data?.windows?.weekly },
|
|
3770
|
+
{ key: 'monthly', label: t('quota.window_monthly'), value: data?.windows?.monthly },
|
|
3771
|
+
]
|
|
3772
|
+
|
|
3773
|
+
const windowPct = (window) => {
|
|
3774
|
+
const value = Number(window.value?.percentUsed)
|
|
3775
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : null
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
const measured = windowEntries
|
|
3779
|
+
.map((window) => ({ ...window, pct: windowPct(window) }))
|
|
3780
|
+
.filter((window) => window.pct !== null)
|
|
3781
|
+
|
|
3782
|
+
const pct = measured.length ? Math.max(...measured.map((window) => window.pct)) : 0
|
|
3783
|
+
const isWarn = measured.some((window) => window.pct >= 85)
|
|
3784
|
+
const planName = String(data?.plan || t('title')).replace(/\s*\(\$[^)]*\)\s*$/, '')
|
|
3785
|
+
const accountLabel = data?.account ? ` · ${data.account}` : ''
|
|
3786
|
+
const tooltip = `${planName}${accountLabel} · ${windowEntries.map((window) => {
|
|
3787
|
+
const value = windowPct(window)
|
|
3788
|
+
return `${window.label} ${value === null ? '—' : `${value}% ${t('dashboard.used')}`}`
|
|
3789
|
+
}).join(' · ')}`
|
|
2848
3790
|
|
|
2849
3791
|
// The window is local to the meter: nothing is selected or navigated, so the
|
|
2850
3792
|
// conversation stays where it is and closing simply drops the window.
|
|
2851
|
-
const handleOpen = () =>
|
|
3793
|
+
const handleOpen = () => {
|
|
3794
|
+
if (typeof openPanel === 'function') {
|
|
3795
|
+
try {
|
|
3796
|
+
if (openPanel() !== false) return
|
|
3797
|
+
} catch {
|
|
3798
|
+
/* fall through to the local window */
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
setOpen(true)
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
// Keep all three rows visible even when the endpoint omits a window for the
|
|
3805
|
+
// current plan; the missing row shows an explicit dash instead of silently
|
|
3806
|
+
// relabelling another window.
|
|
3807
|
+
const rows = windowEntries
|
|
2852
3808
|
|
|
2853
|
-
const tooltip = `${t('sidebar.quota_title')}: ${pct}% ${t('dashboard.used')} (${planName})`
|
|
2854
3809
|
const dialog = React.createElement(DashboardDialog, { t, open, onClose: () => setOpen(false) })
|
|
2855
3810
|
|
|
2856
3811
|
if (!wide) {
|
|
@@ -2891,68 +3846,403 @@ function SidebarQuota({ ctx, wide, t: propT }) {
|
|
|
2891
3846
|
{ className: 'cb-footTop' },
|
|
2892
3847
|
React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 16 }),
|
|
2893
3848
|
React.createElement('span', { className: 'cb-footName' }, planName),
|
|
2894
|
-
React.createElement('span', { className:
|
|
3849
|
+
React.createElement('span', { className: isWarn ? 'cb-footPct cb-footPctWarn' : 'cb-footPct' }, `${pct}%`)
|
|
2895
3850
|
),
|
|
3851
|
+
rows.map((window) => {
|
|
3852
|
+
const value = windowPct(window)
|
|
3853
|
+
const hasData = value !== null
|
|
3854
|
+
const warn = hasData && value >= 85
|
|
3855
|
+
const reset = hasData && typeof formatResetCountdown === 'function'
|
|
3856
|
+
? formatResetCountdown(window.value?.resetsAt)
|
|
3857
|
+
: ''
|
|
3858
|
+
const resetText = reset
|
|
3859
|
+
? ` · ${reset === 'resetting' ? t('quota.resetting') : reset}`
|
|
3860
|
+
: ''
|
|
3861
|
+
return React.createElement(
|
|
3862
|
+
'div',
|
|
3863
|
+
{ className: hasData ? 'cb-footRow' : 'cb-footRow cb-footRowMissing', key: window.key },
|
|
3864
|
+
React.createElement(
|
|
3865
|
+
'div',
|
|
3866
|
+
{ className: 'cb-footHead' },
|
|
3867
|
+
React.createElement('span', { className: 'cb-footLabel' }, window.label),
|
|
3868
|
+
React.createElement(
|
|
3869
|
+
'span',
|
|
3870
|
+
{ className: hasData ? 'cb-footAmount' : 'cb-footAmount cb-footAmountMissing' },
|
|
3871
|
+
hasData ? `${100 - value}% ${t('dashboard.remaining')}${resetText}` : '—'
|
|
3872
|
+
)
|
|
3873
|
+
),
|
|
3874
|
+
React.createElement(
|
|
3875
|
+
'span',
|
|
3876
|
+
{ className: 'cb-footBar' },
|
|
3877
|
+
React.createElement('span', {
|
|
3878
|
+
className: warn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
|
|
3879
|
+
style: { width: hasData ? `${Math.min(100, Math.max(2, value))}%` : '0%' },
|
|
3880
|
+
})
|
|
3881
|
+
)
|
|
3882
|
+
)
|
|
3883
|
+
}),
|
|
3884
|
+
rows.some((window) => windowPct(window) !== null)
|
|
3885
|
+
? null
|
|
3886
|
+
: React.createElement('span', { className: 'cb-footMessage' }, data?.error || t('quota.no_windows'))
|
|
3887
|
+
),
|
|
3888
|
+
dialog
|
|
3889
|
+
)
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
// --- src/client/components/price-section.js ---
|
|
3893
|
+
/**
|
|
3894
|
+
* Per-model price overrides. A blank field falls back to the built-in local
|
|
3895
|
+
* estimate; the values are USD per 1M tokens.
|
|
3896
|
+
*/
|
|
3897
|
+
function PriceSection({ modelsList = [], priceOverrides = {}, onConfigUpdate, t }) {
|
|
3898
|
+
const [expanded, setExpanded] = React.useState(false)
|
|
3899
|
+
const models = Array.isArray(modelsList) ? modelsList : []
|
|
3900
|
+
|
|
3901
|
+
const update = (modelId, field, raw) => {
|
|
3902
|
+
const next = { ...(priceOverrides || {}) }
|
|
3903
|
+
const current = { ...(next[modelId] || {}) }
|
|
3904
|
+
const value = String(raw ?? '').trim()
|
|
3905
|
+
const numeric = Number(value)
|
|
3906
|
+
if (value === '' || !Number.isFinite(numeric) || numeric < 0) delete current[field]
|
|
3907
|
+
else current[field] = numeric
|
|
3908
|
+
if (Object.keys(current).length === 0) delete next[modelId]
|
|
3909
|
+
else next[modelId] = current
|
|
3910
|
+
onConfigUpdate({ priceOverrides: next })
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
return React.createElement(
|
|
3914
|
+
'div',
|
|
3915
|
+
{ className: 'cb-price-section' },
|
|
3916
|
+
React.createElement(
|
|
3917
|
+
'div',
|
|
3918
|
+
{ className: 'cb-price-head' },
|
|
2896
3919
|
React.createElement(
|
|
2897
3920
|
'div',
|
|
2898
|
-
{ className: 'cb-
|
|
2899
|
-
React.createElement(
|
|
3921
|
+
{ className: 'cb-price-copy' },
|
|
3922
|
+
React.createElement('div', { className: 'cb-section-title' }, t('prices.title')),
|
|
3923
|
+
React.createElement('div', { className: 'cb-section-desc' }, t('prices.desc'))
|
|
3924
|
+
),
|
|
3925
|
+
React.createElement(
|
|
3926
|
+
'button',
|
|
3927
|
+
{ type: 'button', className: 'cb-btn', onClick: () => setExpanded((value) => !value) },
|
|
3928
|
+
expanded ? t('prices.hide') : t('prices.show')
|
|
3929
|
+
)
|
|
3930
|
+
),
|
|
3931
|
+
expanded
|
|
3932
|
+
? React.createElement(
|
|
2900
3933
|
'div',
|
|
2901
|
-
{ className: 'cb-
|
|
2902
|
-
React.createElement(
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
3934
|
+
{ className: 'cb-price-table' },
|
|
3935
|
+
React.createElement(
|
|
3936
|
+
'div',
|
|
3937
|
+
{ className: 'cb-price-head-row' },
|
|
3938
|
+
React.createElement('span', null, t('prices.model')),
|
|
3939
|
+
React.createElement('span', null, t('prices.input')),
|
|
3940
|
+
React.createElement('span', null, t('prices.output')),
|
|
3941
|
+
React.createElement('span', null, t('prices.cache_read')),
|
|
3942
|
+
React.createElement('span', null, t('prices.cache_write'))
|
|
3943
|
+
),
|
|
3944
|
+
models.length === 0
|
|
3945
|
+
? React.createElement('div', { className: 'cb-hint' }, t('prices.empty'))
|
|
3946
|
+
: models.map((model) => {
|
|
3947
|
+
const override = priceOverrides?.[model.id] || {}
|
|
3948
|
+
return React.createElement(
|
|
3949
|
+
'div',
|
|
3950
|
+
{ className: 'cb-price-row', key: model.id },
|
|
3951
|
+
React.createElement('span', { className: 'cb-mono', title: model.id }, model.name || model.id),
|
|
3952
|
+
['input', 'output', 'cacheRead', 'cacheWrite'].map((field) => React.createElement('input', {
|
|
3953
|
+
key: field,
|
|
3954
|
+
className: 'cb-input cb-price-input',
|
|
3955
|
+
type: 'number',
|
|
3956
|
+
min: '0',
|
|
3957
|
+
step: '0.001',
|
|
3958
|
+
value: override[field] === undefined ? '' : String(override[field]),
|
|
3959
|
+
placeholder: t('prices.auto'),
|
|
3960
|
+
onChange: (event) => update(model.id, field, event.target.value),
|
|
3961
|
+
}))
|
|
3962
|
+
)
|
|
3963
|
+
}),
|
|
3964
|
+
React.createElement('div', { className: 'cb-hint' }, t('prices.hint'))
|
|
3965
|
+
)
|
|
3966
|
+
: null
|
|
3967
|
+
)
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// --- src/client/components/analytics-heatmap.js ---
|
|
3971
|
+
/**
|
|
3972
|
+
* Weekday × hour request heatmap, matching the reference providers: seven rows
|
|
3973
|
+
* (Mon–Sun), 24 columns, a peak-hour line and a compact by-hour strip. The
|
|
3974
|
+
* model selector re-fetches the same range for one model.
|
|
3975
|
+
*/
|
|
3976
|
+
function heatWeekdayLabels() {
|
|
3977
|
+
return Array.from({ length: 7 }, (_, index) => {
|
|
3978
|
+
const date = new Date(2024, 0, 1 + index)
|
|
3979
|
+
return date.toLocaleDateString(undefined, { weekday: 'short' })
|
|
3980
|
+
})
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
function heatHourLabel(hour) {
|
|
3984
|
+
return hour % 3 === 0 ? String(hour).padStart(2, '0') : ''
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
function HeatmapView({ heatmap, model, onModelChange, t }) {
|
|
3988
|
+
if (!heatmap || !Array.isArray(heatmap.grid)) {
|
|
3989
|
+
return React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
const grid = heatmap.grid
|
|
3993
|
+
const hours = Array.isArray(heatmap.hours) ? heatmap.hours : Array(24).fill(0)
|
|
3994
|
+
const total = Number(heatmap.total) || 0
|
|
3995
|
+
const models = Array.isArray(heatmap.models) ? heatmap.models : []
|
|
3996
|
+
const max = Math.max(1, ...grid.flat())
|
|
3997
|
+
const maxHours = Math.max(1, ...hours)
|
|
3998
|
+
const peakHour = hours.reduce((best, value, hour) => (value > (hours[best] || 0) ? hour : best), 0)
|
|
3999
|
+
const peak = hours[peakHour] || 0
|
|
4000
|
+
const level = (value) => {
|
|
4001
|
+
const requests = Number(value) || 0
|
|
4002
|
+
if (requests <= 0) return 0
|
|
4003
|
+
return Math.min(4, Math.max(1, Math.ceil((requests / max) * 4)))
|
|
4004
|
+
}
|
|
4005
|
+
const weekdays = heatWeekdayLabels()
|
|
4006
|
+
|
|
4007
|
+
return React.createElement(
|
|
4008
|
+
'div',
|
|
4009
|
+
{ className: 'cb-heat-wrap' },
|
|
4010
|
+
React.createElement(
|
|
4011
|
+
'div',
|
|
4012
|
+
{ className: 'cb-heat-toolbar' },
|
|
4013
|
+
React.createElement('span', { className: 'cb-badge' }, `${total} ${t('analytics.requests')}`),
|
|
4014
|
+
models.length > 1
|
|
4015
|
+
? React.createElement(
|
|
4016
|
+
'select',
|
|
4017
|
+
{
|
|
4018
|
+
className: 'cb-heat-select',
|
|
4019
|
+
value: model || '',
|
|
4020
|
+
title: t('analytics.heatmap_model'),
|
|
4021
|
+
onChange: (event) => onModelChange && onModelChange(event.target.value),
|
|
4022
|
+
},
|
|
4023
|
+
React.createElement('option', { value: '' }, `${t('analytics.heatmap_all_models')} (${total})`),
|
|
4024
|
+
models.map((entry) => React.createElement(
|
|
4025
|
+
'option',
|
|
4026
|
+
{ key: entry.model, value: entry.model },
|
|
4027
|
+
`${entry.model} (${entry.total})`
|
|
4028
|
+
))
|
|
4029
|
+
)
|
|
4030
|
+
: null,
|
|
4031
|
+
peak > 0
|
|
4032
|
+
? React.createElement('span', { className: 'cb-hint' }, `${t('analytics.heatmap_peak')}: ${String(peakHour).padStart(2, '0')}:00 · ${peak}`)
|
|
4033
|
+
: null
|
|
4034
|
+
),
|
|
4035
|
+
React.createElement(
|
|
4036
|
+
'div',
|
|
4037
|
+
{ className: 'cb-heat-scroll' },
|
|
4038
|
+
React.createElement(
|
|
4039
|
+
'div',
|
|
4040
|
+
{ className: 'cb-heat' },
|
|
4041
|
+
React.createElement('span', { className: 'cb-heat-corner' }),
|
|
4042
|
+
hours.map((_, hour) => React.createElement(
|
|
4043
|
+
'span',
|
|
4044
|
+
{ className: 'cb-heat-hour', key: `hour-${hour}` },
|
|
4045
|
+
heatHourLabel(hour)
|
|
4046
|
+
)),
|
|
4047
|
+
weekdays.map((label, day) => React.createElement(
|
|
4048
|
+
React.Fragment,
|
|
4049
|
+
{ key: `day-${day}` },
|
|
4050
|
+
React.createElement('span', { className: 'cb-heat-day' }, label),
|
|
4051
|
+
(grid[day] || []).map((value, hour) => React.createElement('span', {
|
|
4052
|
+
key: `cell-${day}-${hour}`,
|
|
4053
|
+
className: `cb-heat-cell cb-heat-l${level(value)}`,
|
|
4054
|
+
title: `${label} ${String(hour).padStart(2, '0')}:00 — ${value} ${t('analytics.requests')}`,
|
|
4055
|
+
}))
|
|
4056
|
+
))
|
|
4057
|
+
),
|
|
4058
|
+
React.createElement('p', { className: 'cb-hour-strip-head' }, t('analytics.heatmap_by_hour')),
|
|
4059
|
+
React.createElement(
|
|
4060
|
+
'div',
|
|
4061
|
+
{ className: 'cb-hour-strip' },
|
|
4062
|
+
React.createElement('span'),
|
|
4063
|
+
hours.map((value, hour) => React.createElement(
|
|
2906
4064
|
'span',
|
|
2907
|
-
{ className: 'cb-
|
|
4065
|
+
{ className: 'cb-hour-col', key: `strip-${hour}`, title: `${String(hour).padStart(2, '0')}:00 — ${value}` },
|
|
2908
4066
|
React.createElement('span', {
|
|
2909
|
-
className:
|
|
2910
|
-
style: {
|
|
4067
|
+
className: `cb-hour-bar cb-heat-l${level(value)}`,
|
|
4068
|
+
style: { height: `${Math.max(2, Math.round(28 * (Number(value) || 0) / maxHours))}px` },
|
|
2911
4069
|
})
|
|
4070
|
+
))
|
|
4071
|
+
),
|
|
4072
|
+
React.createElement(
|
|
4073
|
+
'div',
|
|
4074
|
+
{ className: 'cb-hour-labels' },
|
|
4075
|
+
React.createElement('span'),
|
|
4076
|
+
hours.map((_, hour) => React.createElement('span', { key: `label-${hour}` }, heatHourLabel(hour)))
|
|
4077
|
+
)
|
|
4078
|
+
),
|
|
4079
|
+
React.createElement(
|
|
4080
|
+
'div',
|
|
4081
|
+
{ className: 'cb-heat-legend' },
|
|
4082
|
+
React.createElement('span', null, t('analytics.heatmap_less')),
|
|
4083
|
+
[0, 1, 2, 3, 4].map((bucket) => React.createElement('span', {
|
|
4084
|
+
key: bucket,
|
|
4085
|
+
className: `cb-heat-swatch cb-heat-l${bucket}`,
|
|
4086
|
+
})),
|
|
4087
|
+
React.createElement('span', null, t('analytics.heatmap_more'))
|
|
4088
|
+
),
|
|
4089
|
+
total === 0
|
|
4090
|
+
? React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
|
|
4091
|
+
: null
|
|
4092
|
+
)
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
// --- src/client/components/quota-panel-view.js ---
|
|
4096
|
+
/**
|
|
4097
|
+
* The account/quota cards shared by the center-column panel and the modal
|
|
4098
|
+
* fallback. Account tabs and the selected account's report are passed in by
|
|
4099
|
+
* DashboardDialog; this component only renders the selected snapshot.
|
|
4100
|
+
*/
|
|
4101
|
+
function QuotaPanelView({
|
|
4102
|
+
accountTabs,
|
|
4103
|
+
selectedUsage,
|
|
4104
|
+
accountName,
|
|
4105
|
+
accountEmail,
|
|
4106
|
+
accountPlan,
|
|
4107
|
+
accountBadges = [],
|
|
4108
|
+
activeAcc,
|
|
4109
|
+
routingMode,
|
|
4110
|
+
enabledCount,
|
|
4111
|
+
t,
|
|
4112
|
+
}) {
|
|
4113
|
+
return React.createElement(
|
|
4114
|
+
React.Fragment,
|
|
4115
|
+
null,
|
|
4116
|
+
accountTabs,
|
|
4117
|
+
selectedUsage?.ok === false
|
|
4118
|
+
? React.createElement('div', { className: 'cb-alert-bad' }, selectedUsage.error || t('analytics.no_data'))
|
|
4119
|
+
: null,
|
|
4120
|
+
React.createElement(
|
|
4121
|
+
'div',
|
|
4122
|
+
{ className: 'cb-dialogCard' },
|
|
4123
|
+
React.createElement(
|
|
4124
|
+
'div',
|
|
4125
|
+
{ className: 'cb-dialogRow' },
|
|
4126
|
+
React.createElement(
|
|
4127
|
+
'div',
|
|
4128
|
+
{ className: 'cb-dialogIdentity' },
|
|
4129
|
+
React.createElement('div', { className: 'cb-row-title' }, accountName),
|
|
4130
|
+
React.createElement('div', { className: 'cb-row-desc' }, [accountEmail, accountPlan].filter(Boolean).join(' · '))
|
|
4131
|
+
),
|
|
4132
|
+
React.createElement(
|
|
4133
|
+
'div',
|
|
4134
|
+
{ className: 'cb-dialogBadges' },
|
|
4135
|
+
accountBadges.map((badge, index) => React.createElement('span', { className: badge.className, key: `account-badge-${index}` }, badge.label)),
|
|
4136
|
+
React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('badge.registered', { count: enabledCount }))
|
|
4137
|
+
)
|
|
4138
|
+
),
|
|
4139
|
+
React.createElement(
|
|
4140
|
+
'div',
|
|
4141
|
+
{ className: 'cb-dialogTiles' },
|
|
4142
|
+
React.createElement(
|
|
4143
|
+
'div',
|
|
4144
|
+
{ className: 'cb-stat-box' },
|
|
4145
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
|
|
4146
|
+
React.createElement('div', { className: 'cb-stat-val' }, activeAcc)
|
|
4147
|
+
),
|
|
4148
|
+
React.createElement(
|
|
4149
|
+
'div',
|
|
4150
|
+
{ className: 'cb-stat-box' },
|
|
4151
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
|
|
4152
|
+
React.createElement('div', { className: 'cb-stat-val' }, routingMode)
|
|
2912
4153
|
)
|
|
2913
4154
|
)
|
|
2914
4155
|
),
|
|
2915
|
-
|
|
4156
|
+
React.createElement(
|
|
4157
|
+
'div',
|
|
4158
|
+
{ className: 'cb-dialogCard' },
|
|
4159
|
+
React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
|
|
4160
|
+
React.createElement(
|
|
4161
|
+
'div',
|
|
4162
|
+
{ className: 'cb-dialogWindows' },
|
|
4163
|
+
selectedUsage?.windows?.fiveHour
|
|
4164
|
+
? React.createElement(ProgressBar, {
|
|
4165
|
+
label: t('quota.window_5h'),
|
|
4166
|
+
percentUsed: selectedUsage.windows.fiveHour.percentUsed || 0,
|
|
4167
|
+
remainingPercent: selectedUsage.windows.fiveHour.remainingPercent || 100,
|
|
4168
|
+
resetsAt: selectedUsage.windows.fiveHour.resetsAt,
|
|
4169
|
+
t,
|
|
4170
|
+
})
|
|
4171
|
+
: null,
|
|
4172
|
+
selectedUsage?.windows?.weekly
|
|
4173
|
+
? React.createElement(ProgressBar, {
|
|
4174
|
+
label: t('quota.window_weekly'),
|
|
4175
|
+
percentUsed: selectedUsage.windows.weekly.percentUsed || 0,
|
|
4176
|
+
remainingPercent: selectedUsage.windows.weekly.remainingPercent || 100,
|
|
4177
|
+
resetsAt: selectedUsage.windows.weekly.resetsAt,
|
|
4178
|
+
t,
|
|
4179
|
+
})
|
|
4180
|
+
: null,
|
|
4181
|
+
selectedUsage?.windows?.monthly
|
|
4182
|
+
? React.createElement(ProgressBar, {
|
|
4183
|
+
label: t('quota.window_monthly'),
|
|
4184
|
+
percentUsed: selectedUsage.windows.monthly.percentUsed || 0,
|
|
4185
|
+
remainingPercent: selectedUsage.windows.monthly.remainingPercent || 100,
|
|
4186
|
+
resetsAt: selectedUsage.windows.monthly.resetsAt,
|
|
4187
|
+
t,
|
|
4188
|
+
})
|
|
4189
|
+
: null,
|
|
4190
|
+
!selectedUsage?.windows || (!selectedUsage.windows.fiveHour && !selectedUsage.windows.weekly && !selectedUsage.windows.monthly)
|
|
4191
|
+
? React.createElement('div', { className: 'cb-hint' }, t('quota.no_windows'))
|
|
4192
|
+
: null
|
|
4193
|
+
)
|
|
4194
|
+
)
|
|
2916
4195
|
)
|
|
2917
4196
|
}
|
|
2918
4197
|
|
|
2919
4198
|
// --- src/client/components/dashboard-dialog.js ---
|
|
2920
4199
|
/**
|
|
2921
|
-
* The
|
|
2922
|
-
*
|
|
2923
|
-
*
|
|
2924
|
-
* `layout.selectPanel('clinebot-dashboard')`, which swapped the conversation out
|
|
2925
|
-
* of the centre column for a full-bleed dashboard. A status read-out has no
|
|
2926
|
-
* business replacing the conversation, so it is a window now — the shell's own
|
|
2927
|
-
* `Modal`, which brings the backdrop, the close affordance, Escape and focus
|
|
2928
|
-
* handling with it.
|
|
2929
|
-
*
|
|
2930
|
-
* `open` is owned by the caller (the meter), and the data is fetched on open
|
|
2931
|
-
* rather than on mount, so a closed window costs nothing.
|
|
4200
|
+
* Quota and analytics surfaces. The sidebar card opens this as the layout's
|
|
4201
|
+
* center-column panel (the Command Code / OpenCode shape); when the layout
|
|
4202
|
+
* service is unavailable the same content falls back to the shell's Modal.
|
|
2932
4203
|
*/
|
|
2933
4204
|
function DashboardDialog(props) {
|
|
2934
4205
|
const t = props?.t || makeT(en, en)
|
|
2935
|
-
const
|
|
4206
|
+
const panel = props?.panel === true
|
|
4207
|
+
const open = panel || props?.open !== false
|
|
2936
4208
|
const onClose = typeof props?.onClose === 'function' ? props.onClose : () => {}
|
|
2937
4209
|
|
|
2938
4210
|
const [data, setData] = React.useState(null)
|
|
2939
4211
|
const [status, setStatus] = React.useState(null)
|
|
4212
|
+
const [analytics, setAnalytics] = React.useState(null)
|
|
4213
|
+
const [tab, setTab] = React.useState('quota')
|
|
4214
|
+
const [groupBy, setGroupBy] = React.useState('model')
|
|
2940
4215
|
const [loading, setLoading] = React.useState(false)
|
|
4216
|
+
const [analyticsLoading, setAnalyticsLoading] = React.useState(false)
|
|
2941
4217
|
const [updatedAt, setUpdatedAt] = React.useState('')
|
|
2942
4218
|
const [err, setErr] = React.useState('')
|
|
4219
|
+
const [accountsUsage, setAccountsUsage] = React.useState(null)
|
|
4220
|
+
const [selectedAccount, setSelectedAccount] = React.useState('')
|
|
4221
|
+
const [analyticsDays, setAnalyticsDays] = React.useState(7)
|
|
4222
|
+
const [heatmapModel, setHeatmapModel] = React.useState('')
|
|
2943
4223
|
|
|
2944
4224
|
const loadData = React.useCallback(async () => {
|
|
2945
4225
|
setLoading(true)
|
|
2946
4226
|
setErr('')
|
|
2947
4227
|
try {
|
|
2948
|
-
const [uRes, sRes] = await Promise.all([
|
|
4228
|
+
const [uRes, sRes, aRes] = await Promise.all([
|
|
2949
4229
|
fetchWithTimeout(`${ROUTE_PREFIX}/usage`, { cache: 'no-store' }),
|
|
2950
4230
|
fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
|
|
4231
|
+
fetchWithTimeout(`${ROUTE_PREFIX}/accounts/usage`, { cache: 'no-store' }),
|
|
2951
4232
|
])
|
|
2952
4233
|
const uJson = await uRes.json().catch(() => ({}))
|
|
2953
4234
|
const sJson = await sRes.json().catch(() => ({}))
|
|
4235
|
+
const aJson = await aRes.json().catch(() => ({}))
|
|
2954
4236
|
if (uJson?.ok) setData(uJson)
|
|
2955
4237
|
if (sJson?.ok) setStatus(sJson)
|
|
4238
|
+
if (aJson?.ok) {
|
|
4239
|
+
setAccountsUsage(aJson)
|
|
4240
|
+
setSelectedAccount((current) => {
|
|
4241
|
+
const accounts = Array.isArray(aJson.accounts) ? aJson.accounts : []
|
|
4242
|
+
if (accounts.some((entry) => entry.apiKeyEnv === current)) return current
|
|
4243
|
+
return aJson.activeAccount || accounts[0]?.apiKeyEnv || ''
|
|
4244
|
+
})
|
|
4245
|
+
}
|
|
2956
4246
|
setUpdatedAt(new Date().toLocaleTimeString())
|
|
2957
4247
|
} catch (e) {
|
|
2958
4248
|
setErr(String(e?.message || e))
|
|
@@ -2961,105 +4251,375 @@ function DashboardDialog(props) {
|
|
|
2961
4251
|
}
|
|
2962
4252
|
}, [])
|
|
2963
4253
|
|
|
4254
|
+
const loadAnalytics = React.useCallback(async (group = groupBy, days = analyticsDays, model = heatmapModel) => {
|
|
4255
|
+
setAnalyticsLoading(true)
|
|
4256
|
+
setErr('')
|
|
4257
|
+
try {
|
|
4258
|
+
const from = Date.now() - days * 86_400_000
|
|
4259
|
+
const query = [
|
|
4260
|
+
`groupBy=${encodeURIComponent(group)}`,
|
|
4261
|
+
'limit=20',
|
|
4262
|
+
`from=${from}`,
|
|
4263
|
+
`heatmapDays=${days}`,
|
|
4264
|
+
...(model ? [`heatmapModel=${encodeURIComponent(model)}`] : []),
|
|
4265
|
+
].join('&')
|
|
4266
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/analytics?${query}`, { cache: 'no-store' })
|
|
4267
|
+
const json = await res.json().catch(() => ({}))
|
|
4268
|
+
if (!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`)
|
|
4269
|
+
setAnalytics(json)
|
|
4270
|
+
} catch (e) {
|
|
4271
|
+
setErr(String(e?.message || e))
|
|
4272
|
+
} finally {
|
|
4273
|
+
setAnalyticsLoading(false)
|
|
4274
|
+
}
|
|
4275
|
+
}, [groupBy, analyticsDays, heatmapModel])
|
|
4276
|
+
|
|
2964
4277
|
React.useEffect(() => {
|
|
2965
4278
|
if (open) loadData()
|
|
2966
4279
|
}, [open, loadData])
|
|
2967
4280
|
|
|
4281
|
+
React.useEffect(() => {
|
|
4282
|
+
if (open && (panel || tab === 'analytics')) loadAnalytics(groupBy, analyticsDays, heatmapModel)
|
|
4283
|
+
}, [open, panel, tab, groupBy, analyticsDays, heatmapModel, loadAnalytics])
|
|
4284
|
+
|
|
4285
|
+
React.useEffect(() => {
|
|
4286
|
+
if (!open) return undefined
|
|
4287
|
+
let timer = null
|
|
4288
|
+
const tick = () => {
|
|
4289
|
+
loadData()
|
|
4290
|
+
if (panel || tab === 'analytics') loadAnalytics(groupBy, analyticsDays, heatmapModel)
|
|
4291
|
+
timer = setTimeout(tick, 120000)
|
|
4292
|
+
}
|
|
4293
|
+
timer = setTimeout(tick, 120000)
|
|
4294
|
+
return () => { if (timer) clearTimeout(timer) }
|
|
4295
|
+
}, [open, panel, tab, groupBy, analyticsDays, heatmapModel, loadData, loadAnalytics])
|
|
4296
|
+
|
|
2968
4297
|
if (!open) return null
|
|
2969
4298
|
|
|
4299
|
+
function fmtTokens(value) {
|
|
4300
|
+
const n = Number(value) || 0
|
|
4301
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
4302
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
|
|
4303
|
+
return String(n)
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
function fmtCost(value) {
|
|
4307
|
+
const n = Number(value) || 0
|
|
4308
|
+
if (n === 0) return '$0'
|
|
4309
|
+
if (n < 0.0001) return '< $0.0001'
|
|
4310
|
+
return `$${n.toFixed(4)}`
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
function fmtMs(value) {
|
|
4314
|
+
const n = Number(value) || 0
|
|
4315
|
+
return n > 0 ? `${Math.round(n)} ms` : '—'
|
|
4316
|
+
}
|
|
4317
|
+
|
|
2970
4318
|
const plan = data?.plan || status?.config?.defaultModel || 'ClinePass'
|
|
2971
4319
|
const email = data?.user?.email || ''
|
|
2972
|
-
const activeAcc = status?.config?.activeAccount || '
|
|
4320
|
+
const activeAcc = status?.config?.activeAccount || t('dashboard.auto_account')
|
|
2973
4321
|
const enabledCount = status?.config?.enabledModels?.length || 0
|
|
2974
4322
|
const routingMode = status?.config?.accountMode === 'round_robin' ? t('accounts.mode_round_robin') : t('accounts.mode_least_used')
|
|
4323
|
+
const accountEntries = Array.isArray(accountsUsage?.accounts) ? accountsUsage.accounts : []
|
|
4324
|
+
const selectedEntry = accountEntries.find((entry) => entry.apiKeyEnv === selectedAccount)
|
|
4325
|
+
|| accountEntries.find((entry) => entry.active)
|
|
4326
|
+
|| accountEntries[0]
|
|
4327
|
+
const selectedUsage = selectedEntry?.usage || data
|
|
4328
|
+
const accountPlan = selectedUsage?.plan || plan
|
|
4329
|
+
const accountEmail = selectedUsage?.user?.email || email
|
|
4330
|
+
const accountName = selectedEntry?.label || selectedEntry?.apiKeyEnv || accountPlan
|
|
4331
|
+
const accountBadges = []
|
|
4332
|
+
if (selectedEntry) {
|
|
4333
|
+
accountBadges.push(selectedEntry.configured === false
|
|
4334
|
+
? { className: 'cb-badge cb-badge-warn', label: t('accounts.missing_key') }
|
|
4335
|
+
: { className: 'cb-badge cb-badge-ok', label: t('accounts.configured') })
|
|
4336
|
+
if (selectedEntry.active) accountBadges.push({ className: 'cb-badge cb-badge-ok', label: t('accounts.active_badge') })
|
|
4337
|
+
if (selectedEntry.isPinned) accountBadges.push({ className: 'cb-badge', label: t('accounts.pinned_badge') })
|
|
4338
|
+
}
|
|
4339
|
+
const accountTabs = accountEntries.length > 1
|
|
4340
|
+
? React.createElement(
|
|
4341
|
+
'div',
|
|
4342
|
+
{ className: 'cb-account-tabs' },
|
|
4343
|
+
accountEntries.map((entry) => React.createElement(
|
|
4344
|
+
'button',
|
|
4345
|
+
{
|
|
4346
|
+
type: 'button',
|
|
4347
|
+
key: entry.apiKeyEnv || entry.id,
|
|
4348
|
+
className: entry.apiKeyEnv === selectedEntry?.apiKeyEnv ? 'cb-account-tab cb-account-tab-active' : 'cb-account-tab',
|
|
4349
|
+
onClick: () => setSelectedAccount(entry.apiKeyEnv),
|
|
4350
|
+
},
|
|
4351
|
+
entry.label || entry.apiKeyEnv || entry.id
|
|
4352
|
+
))
|
|
4353
|
+
)
|
|
4354
|
+
: null
|
|
4355
|
+
|
|
4356
|
+
const noticeItems = []
|
|
4357
|
+
if (status?.key && status.key.present === false) {
|
|
4358
|
+
noticeItems.push(React.createElement(
|
|
4359
|
+
'div',
|
|
4360
|
+
{ className: 'cb-notice', key: 'no-key' },
|
|
4361
|
+
React.createElement('div', { className: 'cb-notice-title' }, t('analytics.no_key')),
|
|
4362
|
+
React.createElement('div', { className: 'cb-notice-hint' }, t('analytics.no_key_hint'))
|
|
4363
|
+
))
|
|
4364
|
+
}
|
|
4365
|
+
if (status?.health && status.health.ok === false) {
|
|
4366
|
+
noticeItems.push(React.createElement(
|
|
4367
|
+
'div',
|
|
4368
|
+
{ className: 'cb-notice cb-notice-error', key: 'health' },
|
|
4369
|
+
React.createElement('div', { className: 'cb-notice-title' }, t('analytics.health_error')),
|
|
4370
|
+
React.createElement('div', { className: 'cb-notice-hint' }, status.health.error || '')
|
|
4371
|
+
))
|
|
4372
|
+
}
|
|
4373
|
+
if (status && status.isRegistered === false) {
|
|
4374
|
+
noticeItems.push(React.createElement(
|
|
4375
|
+
'div',
|
|
4376
|
+
{ className: 'cb-notice', key: 'not-registered' },
|
|
4377
|
+
React.createElement('div', { className: 'cb-notice-title' }, t('analytics.not_registered')),
|
|
4378
|
+
React.createElement('div', { className: 'cb-notice-hint' }, t('analytics.not_registered_hint'))
|
|
4379
|
+
))
|
|
4380
|
+
}
|
|
4381
|
+
const notices = noticeItems.length
|
|
4382
|
+
? React.createElement(React.Fragment, null, ...noticeItems)
|
|
4383
|
+
: null
|
|
4384
|
+
const errorNotice = err
|
|
4385
|
+
? React.createElement('div', { className: data ? 'cb-hint' : 'cb-alert-bad' }, data ? `${t('analytics.stale')} ${err}` : err)
|
|
4386
|
+
: null
|
|
4387
|
+
const totals = analytics?.totals
|
|
4388
|
+
const secondaryTiles = totals
|
|
4389
|
+
? [
|
|
4390
|
+
[t('analytics.input'), fmtTokens(totals.promptTokens)],
|
|
4391
|
+
[t('analytics.output'), fmtTokens(totals.completionTokens)],
|
|
4392
|
+
[t('analytics.reasoning'), fmtTokens(totals.reasoningTokens)],
|
|
4393
|
+
[t('analytics.cache_read'), fmtTokens(totals.cacheReadTokens)],
|
|
4394
|
+
[t('analytics.cache_write'), fmtTokens(totals.cacheWriteTokens)],
|
|
4395
|
+
[t('analytics.avg_ttft'), fmtMs(totals.avgTtftMs)],
|
|
4396
|
+
[t('analytics.avg_latency'), fmtMs(totals.avgLatencyMs)],
|
|
4397
|
+
[t('analytics.errors'), String(Math.round(Number(totals.failed) || 0))],
|
|
4398
|
+
]
|
|
4399
|
+
: []
|
|
4400
|
+
|
|
4401
|
+
const tabs = React.createElement(
|
|
4402
|
+
'div',
|
|
4403
|
+
{ className: 'cb-tabs' },
|
|
4404
|
+
React.createElement(
|
|
4405
|
+
'button',
|
|
4406
|
+
{ type: 'button', className: tab === 'quota' ? 'cb-tab cb-tab-active' : 'cb-tab', onClick: () => setTab('quota') },
|
|
4407
|
+
t('dashboard.tab_quota')
|
|
4408
|
+
),
|
|
4409
|
+
React.createElement(
|
|
4410
|
+
'button',
|
|
4411
|
+
{ type: 'button', className: tab === 'analytics' ? 'cb-tab cb-tab-active' : 'cb-tab', onClick: () => setTab('analytics') },
|
|
4412
|
+
t('dashboard.tab_analytics')
|
|
4413
|
+
)
|
|
4414
|
+
)
|
|
4415
|
+
|
|
4416
|
+
const quotaBody = React.createElement(QuotaPanelView, {
|
|
4417
|
+
accountTabs,
|
|
4418
|
+
selectedUsage,
|
|
4419
|
+
accountName,
|
|
4420
|
+
accountEmail,
|
|
4421
|
+
accountPlan,
|
|
4422
|
+
accountBadges,
|
|
4423
|
+
activeAcc,
|
|
4424
|
+
routingMode,
|
|
4425
|
+
enabledCount,
|
|
4426
|
+
t,
|
|
4427
|
+
})
|
|
4428
|
+
|
|
4429
|
+
const analyticsBody = React.createElement(
|
|
4430
|
+
'div',
|
|
4431
|
+
{ className: 'cb-dialogCard' },
|
|
4432
|
+
React.createElement(
|
|
4433
|
+
'div',
|
|
4434
|
+
{ className: 'cb-dialogRow' },
|
|
4435
|
+
React.createElement('div', { className: 'cb-section-title' }, t('analytics.title')),
|
|
4436
|
+
React.createElement(
|
|
4437
|
+
'div',
|
|
4438
|
+
{ className: 'cb-analytics-controls' },
|
|
4439
|
+
React.createElement(
|
|
4440
|
+
'select',
|
|
4441
|
+
{
|
|
4442
|
+
className: 'cb-heat-select',
|
|
4443
|
+
value: String(analyticsDays),
|
|
4444
|
+
title: t('analytics.range'),
|
|
4445
|
+
onChange: (event) => setAnalyticsDays(Number(event.target.value)),
|
|
4446
|
+
},
|
|
4447
|
+
React.createElement('option', { value: '7' }, t('analytics.range_7')),
|
|
4448
|
+
React.createElement('option', { value: '30' }, t('analytics.range_30')),
|
|
4449
|
+
React.createElement('option', { value: '90' }, t('analytics.range_90'))
|
|
4450
|
+
),
|
|
4451
|
+
React.createElement(
|
|
4452
|
+
'div',
|
|
4453
|
+
{ className: 'cb-seg' },
|
|
4454
|
+
['model', 'day', 'session'].map((group) => React.createElement(
|
|
4455
|
+
'button',
|
|
4456
|
+
{
|
|
4457
|
+
key: group,
|
|
4458
|
+
type: 'button',
|
|
4459
|
+
className: groupBy === group ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
|
|
4460
|
+
onClick: () => setGroupBy(group),
|
|
4461
|
+
},
|
|
4462
|
+
t(`analytics.group_${group}`)
|
|
4463
|
+
))
|
|
4464
|
+
)
|
|
4465
|
+
)
|
|
4466
|
+
),
|
|
4467
|
+
analytics?.totals
|
|
4468
|
+
? React.createElement(
|
|
4469
|
+
'div',
|
|
4470
|
+
{ className: 'cb-dialogTiles' },
|
|
4471
|
+
React.createElement(
|
|
4472
|
+
'div',
|
|
4473
|
+
{ className: 'cb-stat-box' },
|
|
4474
|
+
React.createElement('div', { className: 'cb-stat-val' }, String(analytics.totals.requests || 0)),
|
|
4475
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.requests'))
|
|
4476
|
+
),
|
|
4477
|
+
React.createElement(
|
|
4478
|
+
'div',
|
|
4479
|
+
{ className: 'cb-stat-box' },
|
|
4480
|
+
React.createElement('div', { className: 'cb-stat-val' }, fmtTokens(analytics.totals.totalTokens)),
|
|
4481
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.tokens'))
|
|
4482
|
+
),
|
|
4483
|
+
React.createElement(
|
|
4484
|
+
'div',
|
|
4485
|
+
{ className: 'cb-stat-box' },
|
|
4486
|
+
React.createElement('div', { className: 'cb-stat-val' }, `≈ ${fmtCost(analytics.totals.costUsd)}`),
|
|
4487
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.cost'))
|
|
4488
|
+
),
|
|
4489
|
+
React.createElement(
|
|
4490
|
+
'div',
|
|
4491
|
+
{ className: 'cb-stat-box' },
|
|
4492
|
+
React.createElement('div', { className: 'cb-stat-val' }, `${Math.round(analytics.totals.successRate || 0)}%`),
|
|
4493
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.success'))
|
|
4494
|
+
)
|
|
4495
|
+
)
|
|
4496
|
+
: null,
|
|
4497
|
+
secondaryTiles.length
|
|
4498
|
+
? React.createElement(
|
|
4499
|
+
'div',
|
|
4500
|
+
{ className: 'cb-dialogTiles' },
|
|
4501
|
+
secondaryTiles.map(([label, value]) => React.createElement(
|
|
4502
|
+
'div',
|
|
4503
|
+
{ className: 'cb-stat-box', key: label },
|
|
4504
|
+
React.createElement('div', { className: 'cb-stat-val' }, value),
|
|
4505
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, label)
|
|
4506
|
+
))
|
|
4507
|
+
)
|
|
4508
|
+
: null,
|
|
4509
|
+
analyticsLoading
|
|
4510
|
+
? React.createElement('div', { className: 'cb-hint' }, t('analytics.loading'))
|
|
4511
|
+
: analytics?.groups?.length
|
|
4512
|
+
? React.createElement(
|
|
4513
|
+
'div',
|
|
4514
|
+
{ className: 'cb-analytics-list' },
|
|
4515
|
+
React.createElement(
|
|
4516
|
+
'div',
|
|
4517
|
+
{ className: 'cb-analytics-head' },
|
|
4518
|
+
React.createElement('span', null, analytics.groupBy || groupBy),
|
|
4519
|
+
React.createElement('span', null, t('analytics.requests')),
|
|
4520
|
+
React.createElement('span', null, t('analytics.tokens')),
|
|
4521
|
+
React.createElement('span', null, t('analytics.cost')),
|
|
4522
|
+
React.createElement('span', null, t('analytics.ttft'))
|
|
4523
|
+
),
|
|
4524
|
+
analytics.groups.map((group) => React.createElement(
|
|
4525
|
+
'div',
|
|
4526
|
+
{ className: 'cb-analytics-row', key: group.key },
|
|
4527
|
+
React.createElement('span', { className: 'cb-mono' }, group.label || group.key),
|
|
4528
|
+
React.createElement('span', null, String(group.requests || 0)),
|
|
4529
|
+
React.createElement('span', null, fmtTokens(group.totalTokens)),
|
|
4530
|
+
React.createElement('span', null, `≈ ${fmtCost(group.costUsd)}`),
|
|
4531
|
+
React.createElement('span', null, group.avgTtftMs ? `${Math.round(group.avgTtftMs)} ms` : '—')
|
|
4532
|
+
))
|
|
4533
|
+
)
|
|
4534
|
+
: React.createElement('div', { className: 'cb-empty' }, t('analytics.no_data')),
|
|
4535
|
+
analytics?.unpriced > 0
|
|
4536
|
+
? React.createElement('div', { className: 'cb-hint' }, t('analytics.unpriced', { count: analytics.unpriced }))
|
|
4537
|
+
: null
|
|
4538
|
+
)
|
|
2975
4539
|
|
|
2976
|
-
const
|
|
4540
|
+
const heatmapCard = React.createElement(
|
|
2977
4541
|
'div',
|
|
2978
|
-
{ className: 'cb-
|
|
2979
|
-
React.createElement(
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
)
|
|
2989
|
-
),
|
|
2990
|
-
|
|
2991
|
-
err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
|
|
4542
|
+
{ className: 'cb-dialogCard' },
|
|
4543
|
+
React.createElement('div', { className: 'cb-section-title' }, t('analytics.heatmap')),
|
|
4544
|
+
React.createElement('div', { className: 'cb-section-desc' }, t('analytics.heatmap_hint')),
|
|
4545
|
+
React.createElement(HeatmapView, {
|
|
4546
|
+
heatmap: analytics?.heatmap,
|
|
4547
|
+
model: heatmapModel,
|
|
4548
|
+
onModelChange: setHeatmapModel,
|
|
4549
|
+
t,
|
|
4550
|
+
})
|
|
4551
|
+
)
|
|
2992
4552
|
|
|
2993
|
-
|
|
4553
|
+
const panelBody = React.createElement(
|
|
4554
|
+
'div',
|
|
4555
|
+
{ className: 'cb-panel' },
|
|
2994
4556
|
React.createElement(
|
|
2995
4557
|
'div',
|
|
2996
|
-
{ className: 'cb-
|
|
4558
|
+
{ className: 'cb-panel-head' },
|
|
2997
4559
|
React.createElement(
|
|
2998
4560
|
'div',
|
|
2999
|
-
{
|
|
3000
|
-
React.createElement(
|
|
3001
|
-
|
|
3002
|
-
{ style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
|
|
3003
|
-
React.createElement('div', { className: 'cb-row-title' }, plan),
|
|
3004
|
-
email ? React.createElement('div', { className: 'cb-row-desc' }, email) : null
|
|
3005
|
-
),
|
|
3006
|
-
React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('badge.registered', { count: enabledCount }))
|
|
4561
|
+
{ className: 'cb-panel-identity' },
|
|
4562
|
+
React.createElement('div', { className: 'cb-panel-title' }, t('dashboard.title')),
|
|
4563
|
+
React.createElement('div', { className: 'cb-panel-sub' }, t('dashboard.subtitle'))
|
|
3007
4564
|
),
|
|
3008
4565
|
React.createElement(
|
|
3009
4566
|
'div',
|
|
3010
|
-
{ className: 'cb-
|
|
4567
|
+
{ className: 'cb-panel-actions' },
|
|
4568
|
+
updatedAt ? React.createElement('span', { className: 'cb-panel-updated' }, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
|
|
3011
4569
|
React.createElement(
|
|
3012
|
-
'
|
|
3013
|
-
{
|
|
3014
|
-
|
|
3015
|
-
|
|
4570
|
+
'button',
|
|
4571
|
+
{
|
|
4572
|
+
type: 'button',
|
|
4573
|
+
className: 'cb-btn',
|
|
4574
|
+
disabled: loading || analyticsLoading,
|
|
4575
|
+
onClick: () => { loadData(); loadAnalytics(groupBy) },
|
|
4576
|
+
},
|
|
4577
|
+
(loading || analyticsLoading) ? t('dashboard.refreshing') : t('dashboard.refresh')
|
|
3016
4578
|
),
|
|
3017
4579
|
React.createElement(
|
|
3018
|
-
'
|
|
3019
|
-
{
|
|
3020
|
-
|
|
3021
|
-
|
|
4580
|
+
'button',
|
|
4581
|
+
{
|
|
4582
|
+
type: 'button',
|
|
4583
|
+
className: 'cb-panel-close',
|
|
4584
|
+
'aria-label': t('dashboard.close'),
|
|
4585
|
+
title: t('dashboard.close'),
|
|
4586
|
+
onClick: onClose,
|
|
4587
|
+
},
|
|
4588
|
+
'×'
|
|
3022
4589
|
)
|
|
3023
4590
|
)
|
|
3024
4591
|
),
|
|
4592
|
+
notices,
|
|
4593
|
+
errorNotice,
|
|
4594
|
+
quotaBody,
|
|
4595
|
+
analyticsBody,
|
|
4596
|
+
heatmapCard
|
|
4597
|
+
)
|
|
3025
4598
|
|
|
3026
|
-
|
|
4599
|
+
const body = React.createElement(
|
|
4600
|
+
'div',
|
|
4601
|
+
{ className: 'cb-dialog' },
|
|
3027
4602
|
React.createElement(
|
|
3028
4603
|
'div',
|
|
3029
|
-
{ className: 'cb-
|
|
3030
|
-
React.createElement('
|
|
4604
|
+
{ className: 'cb-dialogMeta' },
|
|
4605
|
+
updatedAt ? React.createElement('span', null, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
|
|
4606
|
+
React.createElement('span', { className: 'cb-settings-spacer' }),
|
|
3031
4607
|
React.createElement(
|
|
3032
|
-
'
|
|
3033
|
-
{
|
|
3034
|
-
|
|
3035
|
-
? React.createElement(ProgressBar, {
|
|
3036
|
-
label: t('quota.window_5h'),
|
|
3037
|
-
percentUsed: data.windows.fiveHour.percentUsed || 0,
|
|
3038
|
-
remainingPercent: data.windows.fiveHour.remainingPercent || 100,
|
|
3039
|
-
resetsAt: data.windows.fiveHour.resetsAt,
|
|
3040
|
-
t,
|
|
3041
|
-
})
|
|
3042
|
-
: null,
|
|
3043
|
-
React.createElement(ProgressBar, {
|
|
3044
|
-
label: t('quota.window_weekly'),
|
|
3045
|
-
percentUsed: data?.windows?.weekly?.percentUsed || 0,
|
|
3046
|
-
remainingPercent: data?.windows?.weekly?.remainingPercent || 100,
|
|
3047
|
-
resetsAt: data?.windows?.weekly?.resetsAt,
|
|
3048
|
-
t,
|
|
3049
|
-
}),
|
|
3050
|
-
data?.windows?.monthly
|
|
3051
|
-
? React.createElement(ProgressBar, {
|
|
3052
|
-
label: t('quota.window_monthly'),
|
|
3053
|
-
percentUsed: data.windows.monthly.percentUsed || 0,
|
|
3054
|
-
remainingPercent: data.windows.monthly.remainingPercent || 100,
|
|
3055
|
-
resetsAt: data.windows.monthly.resetsAt,
|
|
3056
|
-
t,
|
|
3057
|
-
})
|
|
3058
|
-
: null
|
|
4608
|
+
'button',
|
|
4609
|
+
{ type: 'button', className: 'cb-btn', disabled: loading || analyticsLoading, onClick: () => (tab === 'quota' ? loadData() : loadAnalytics(groupBy)) },
|
|
4610
|
+
(loading || analyticsLoading) ? t('dashboard.refreshing') : t('dashboard.refresh')
|
|
3059
4611
|
)
|
|
3060
|
-
)
|
|
4612
|
+
),
|
|
4613
|
+
tabs,
|
|
4614
|
+
notices,
|
|
4615
|
+
errorNotice,
|
|
4616
|
+
tab === 'quota'
|
|
4617
|
+
? quotaBody
|
|
4618
|
+
: React.createElement(React.Fragment, null, analyticsBody, heatmapCard)
|
|
3061
4619
|
)
|
|
3062
4620
|
|
|
4621
|
+
if (panel) return panelBody
|
|
4622
|
+
|
|
3063
4623
|
if (Modal) {
|
|
3064
4624
|
return React.createElement(Modal, {
|
|
3065
4625
|
open: true,
|
|
@@ -3071,18 +4631,17 @@ function DashboardDialog(props) {
|
|
|
3071
4631
|
})
|
|
3072
4632
|
}
|
|
3073
4633
|
|
|
3074
|
-
// Older shells without the primitive still get a window, just a plainer one.
|
|
3075
4634
|
return React.createElement(
|
|
3076
4635
|
'div',
|
|
3077
4636
|
{ className: 'cb-dialogFallback', role: 'dialog', 'aria-label': t('dashboard.title'), onClick: onClose },
|
|
3078
4637
|
React.createElement(
|
|
3079
4638
|
'div',
|
|
3080
|
-
{ className: 'cb-dialogFallbackPanel', onClick: (
|
|
4639
|
+
{ className: 'cb-dialogFallbackPanel', onClick: (event) => event.stopPropagation() },
|
|
3081
4640
|
React.createElement(
|
|
3082
4641
|
'div',
|
|
3083
4642
|
{ className: 'cb-dialogMeta' },
|
|
3084
4643
|
React.createElement('span', { className: 'cb-row-title' }, t('dashboard.title')),
|
|
3085
|
-
React.createElement('span', {
|
|
4644
|
+
React.createElement('span', { className: 'cb-settings-spacer' }),
|
|
3086
4645
|
React.createElement(
|
|
3087
4646
|
'button',
|
|
3088
4647
|
{ type: 'button', className: 'cb-btn', 'aria-label': t('dashboard.close'), onClick: onClose },
|
|
@@ -3102,43 +4661,84 @@ function formatTokens(count) {
|
|
|
3102
4661
|
return String(n)
|
|
3103
4662
|
}
|
|
3104
4663
|
|
|
3105
|
-
function
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
4664
|
+
function findPrice(modelId, prices) {
|
|
4665
|
+
if (!modelId || !Array.isArray(prices)) return null
|
|
4666
|
+
const exact = prices.find((entry) => entry && entry.id === modelId)
|
|
4667
|
+
if (exact) return exact
|
|
4668
|
+
return prices.find((entry) => entry && entry.id && (modelId === entry.id || modelId.endsWith(`/${entry.id}`) || modelId.includes(entry.id))) || null
|
|
4669
|
+
}
|
|
3111
4670
|
|
|
4671
|
+
function calculateCost(tokens, modelId, prices) {
|
|
3112
4672
|
const pTok = Number(tokens?.promptTokens || tokens?.inputTokens || 0)
|
|
3113
4673
|
const cTok = Number(tokens?.completionTokens || tokens?.outputTokens || 0)
|
|
3114
4674
|
const rTok = Number(tokens?.reasoningTokens || 0)
|
|
3115
|
-
|
|
3116
|
-
const
|
|
3117
|
-
|
|
4675
|
+
const cacheRead = Number(tokens?.cacheReadTokens || tokens?.cacheRead || 0)
|
|
4676
|
+
const cacheWrite = Number(tokens?.cacheWriteTokens || tokens?.cacheWrite || 0)
|
|
4677
|
+
const total = pTok + cTok + rTok
|
|
4678
|
+
const price = findPrice(modelId, prices)
|
|
4679
|
+
if (!price) return { cost: 0, priced: false, source: null, pTok, cTok, rTok, cacheRead, cacheWrite, total }
|
|
4680
|
+
const uncachedInput = Math.max(0, pTok - cacheRead - cacheWrite)
|
|
4681
|
+
const cost = (
|
|
4682
|
+
uncachedInput * Number(price.input || 0) +
|
|
4683
|
+
cTok * Number(price.output || 0) +
|
|
4684
|
+
cacheRead * Number(price.cacheRead || 0) +
|
|
4685
|
+
cacheWrite * Number(price.cacheWrite || 0)
|
|
4686
|
+
) / 1000000
|
|
4687
|
+
return { cost, priced: true, source: price.source || 'estimate', pTok, cTok, rTok, cacheRead, cacheWrite, total }
|
|
3118
4688
|
}
|
|
3119
4689
|
|
|
4690
|
+
const COST_ANCHOR = '[data-slot="conversation.composer.dock"]'
|
|
4691
|
+
const STATS_ROOT = '[data-composer-stats]'
|
|
4692
|
+
const STATS_USAGE = '[data-composer-stat="usage"]'
|
|
4693
|
+
const STATS_ANY = '[data-composer-stat]'
|
|
4694
|
+
const DIALOG_TRIGGER = 'button[aria-haspopup="dialog"]'
|
|
4695
|
+
const MISSES_BEFORE_WARNING = 3
|
|
4696
|
+
|
|
4697
|
+
/**
|
|
4698
|
+
* Inject the session cost into the native token-usage pill instead of drawing a
|
|
4699
|
+
* second pill. The composer's `conversation.composer.dock` entry is still the
|
|
4700
|
+
* only way to read the session projection; the rendered node is a hidden anchor
|
|
4701
|
+
* the injection locates its own composer from.
|
|
4702
|
+
*/
|
|
3120
4703
|
function ComposerCostDock(props) {
|
|
4704
|
+
if (typeof props?.useProjection !== 'function') return null
|
|
4705
|
+
return React.createElement(ComposerCostEntry, props)
|
|
4706
|
+
}
|
|
4707
|
+
|
|
4708
|
+
function ComposerCostEntry(props) {
|
|
3121
4709
|
const t = props?.t || makeT(en, en)
|
|
4710
|
+
const configSnapshot = useConfigFormsSnapshot(props?.ctx)
|
|
3122
4711
|
const [stats, setStats] = React.useState(null)
|
|
3123
|
-
const [
|
|
4712
|
+
const [prices, setPrices] = React.useState([])
|
|
4713
|
+
const markerRef = React.useRef(null)
|
|
4714
|
+
|
|
4715
|
+
const projectedTokens = props.useProjection('tokenUsage')
|
|
4716
|
+
const selection = props.useProjection('modelSelection')
|
|
4717
|
+
const priceOverridesKey = JSON.stringify(configSnapshot?.value?.priceOverrides || {})
|
|
3124
4718
|
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
4719
|
+
React.useEffect(() => {
|
|
4720
|
+
let active = true
|
|
4721
|
+
fetchWithTimeout(`${ROUTE_PREFIX}/prices`, { cache: 'no-store' })
|
|
4722
|
+
.then((response) => response.json())
|
|
4723
|
+
.then((data) => {
|
|
4724
|
+
if (active && data?.ok && Array.isArray(data.models)) setPrices(data.models)
|
|
4725
|
+
})
|
|
4726
|
+
.catch(() => {})
|
|
4727
|
+
return () => { active = false }
|
|
4728
|
+
}, [priceOverridesKey])
|
|
3128
4729
|
|
|
3129
4730
|
React.useEffect(() => {
|
|
3130
|
-
if (projectedTokens) return
|
|
4731
|
+
if (projectedTokens) return undefined
|
|
3131
4732
|
let active = true
|
|
3132
4733
|
const loadStats = async () => {
|
|
3133
4734
|
try {
|
|
3134
4735
|
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' })
|
|
3135
4736
|
if (!res.ok) return
|
|
3136
4737
|
const data = await res.json().catch(() => null)
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
}
|
|
4738
|
+
const totals = data?.stats?.totals || data?.stats?.summary || null
|
|
4739
|
+
if (active && totals) setStats(totals)
|
|
3140
4740
|
} catch {
|
|
3141
|
-
|
|
4741
|
+
/* quiet */
|
|
3142
4742
|
}
|
|
3143
4743
|
}
|
|
3144
4744
|
loadStats()
|
|
@@ -3146,111 +4746,122 @@ function ComposerCostDock(props) {
|
|
|
3146
4746
|
return () => { active = false; clearTimeout(timer) }
|
|
3147
4747
|
}, [projectedTokens])
|
|
3148
4748
|
|
|
3149
|
-
const
|
|
4749
|
+
const selected = selection?.lastUsed
|
|
4750
|
+
?? selection?.next
|
|
4751
|
+
?? (selection?.provider || selection?.model ? selection : null)
|
|
4752
|
+
const provider = String(selected?.provider || '')
|
|
4753
|
+
const modelId = String(selected?.model || selected?.id || '')
|
|
4754
|
+
const isClineBot = (provider === '' || provider === 'clinebot' || provider === 'llm-clinebot')
|
|
4755
|
+
&& (provider === 'clinebot' || provider === 'llm-clinebot' || modelId.startsWith('cline-pass/'))
|
|
4756
|
+
|
|
4757
|
+
const rawTokens = projectedTokens || {
|
|
3150
4758
|
promptTokens: stats?.promptTokens || 0,
|
|
3151
4759
|
completionTokens: stats?.completionTokens || 0,
|
|
3152
4760
|
reasoningTokens: stats?.reasoningTokens || 0,
|
|
4761
|
+
cacheReadTokens: stats?.cacheReadTokens || 0,
|
|
4762
|
+
cacheWriteTokens: stats?.cacheWriteTokens || 0,
|
|
3153
4763
|
}
|
|
3154
|
-
|
|
3155
|
-
const
|
|
3156
|
-
const
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
4764
|
+
const cacheRead = Number(rawTokens.cacheReadTokens || 0)
|
|
4765
|
+
const cacheWrite = Number(rawTokens.cacheWriteTokens || 0)
|
|
4766
|
+
const uncachedInput = Number(rawTokens.uncachedInputTokens)
|
|
4767
|
+
const promptTokens = Number.isFinite(uncachedInput)
|
|
4768
|
+
? uncachedInput + cacheRead + cacheWrite
|
|
4769
|
+
: Number(rawTokens.promptTokens || rawTokens.inputTokens || 0)
|
|
4770
|
+
const tokens = {
|
|
4771
|
+
promptTokens,
|
|
4772
|
+
completionTokens: Number(rawTokens.completionTokens || rawTokens.outputTokens || 0),
|
|
4773
|
+
reasoningTokens: Number(rawTokens.reasoningTokens || 0),
|
|
4774
|
+
cacheReadTokens: cacheRead,
|
|
4775
|
+
cacheWriteTokens: cacheWrite,
|
|
3160
4776
|
}
|
|
4777
|
+
const { cost, priced, source, pTok, cTok, rTok, total } = calculateCost(tokens, modelId, prices)
|
|
4778
|
+
|
|
4779
|
+
const costValue = priced ? (cost < 0.0001 && cost > 0 ? '< $0.0001' : `$${cost.toFixed(4)}`) : t('composer.unpriced')
|
|
4780
|
+
const costDisplay = priced && source === 'estimate' ? `≈ ${costValue}` : costValue
|
|
4781
|
+
const title = [
|
|
4782
|
+
`${t('composer.cost_title')} ${costDisplay}`,
|
|
4783
|
+
`${t('composer.prompt_tok')} ${formatTokens(pTok)}`,
|
|
4784
|
+
`${t('composer.completion_tok')} ${formatTokens(cTok)}`,
|
|
4785
|
+
...(rTok > 0 ? [`${t('composer.reasoning_tok')} ${formatTokens(rTok)}`] : []),
|
|
4786
|
+
...(cacheRead > 0 ? [`${t('composer.cache_read')} ${formatTokens(cacheRead)}`] : []),
|
|
4787
|
+
...(cacheWrite > 0 ? [`${t('composer.cache_write')} ${formatTokens(cacheWrite)}`] : []),
|
|
4788
|
+
...(priced ? [`${t('composer.price_source')} ${source === 'override' ? t('composer.price_override') : t('composer.price_estimate')}`] : []),
|
|
4789
|
+
].join(' · ')
|
|
4790
|
+
const inject = isClineBot && priced && total > 0
|
|
3161
4791
|
|
|
3162
|
-
|
|
4792
|
+
React.useEffect(() => {
|
|
4793
|
+
if (!inject || typeof document === 'undefined') return undefined
|
|
4794
|
+
const marker = markerRef.current
|
|
4795
|
+
if (!marker) return undefined
|
|
4796
|
+
const scope = marker.closest(COST_ANCHOR)
|
|
4797
|
+
if (!scope) return undefined
|
|
4798
|
+
|
|
4799
|
+
let host = null
|
|
4800
|
+
let root = null
|
|
4801
|
+
let valueNode = null
|
|
4802
|
+
let misses = 0
|
|
4803
|
+
|
|
4804
|
+
const apply = () => {
|
|
4805
|
+
if (!root || root.parentNode === null) {
|
|
4806
|
+
const usageAnchor = scope.querySelector(STATS_USAGE) || scope.querySelector(STATS_ANY)
|
|
4807
|
+
const statsRoot = usageAnchor || scope.querySelector(STATS_ROOT) || scope
|
|
4808
|
+
const triggers = statsRoot.querySelectorAll(DIALOG_TRIGGER)
|
|
4809
|
+
const button = triggers[triggers.length - 1] || usageAnchor
|
|
4810
|
+
if (!button) {
|
|
4811
|
+
misses += 1
|
|
4812
|
+
if (misses === MISSES_BEFORE_WARNING) {
|
|
4813
|
+
console.warn('[dsh-clinebot] no native token-usage pill found for the session cost')
|
|
4814
|
+
}
|
|
4815
|
+
return
|
|
4816
|
+
}
|
|
4817
|
+
misses = 0
|
|
4818
|
+
host = button
|
|
4819
|
+
root = document.createElement('span')
|
|
4820
|
+
root.setAttribute('data-clinebot-session-cost', '')
|
|
4821
|
+
root.style.display = 'inline-flex'
|
|
4822
|
+
root.style.alignItems = 'center'
|
|
4823
|
+
const separator = document.createElement('span')
|
|
4824
|
+
separator.textContent = '·'
|
|
4825
|
+
separator.setAttribute('aria-hidden', 'true')
|
|
4826
|
+
separator.style.color = 'var(--dsw-alias-separator-primary)'
|
|
4827
|
+
separator.style.margin = '0 6px 0 0'
|
|
4828
|
+
valueNode = document.createElement('span')
|
|
4829
|
+
valueNode.style.fontWeight = '500'
|
|
4830
|
+
root.appendChild(separator)
|
|
4831
|
+
root.appendChild(valueNode)
|
|
4832
|
+
button.appendChild(root)
|
|
4833
|
+
}
|
|
4834
|
+
if (root.parentNode !== host) host.appendChild(root)
|
|
4835
|
+
if (valueNode.textContent !== costDisplay) valueNode.textContent = costDisplay
|
|
4836
|
+
if (root.title !== title) root.title = title
|
|
4837
|
+
}
|
|
3163
4838
|
|
|
3164
|
-
|
|
3165
|
-
'
|
|
3166
|
-
|
|
3167
|
-
className: 'cb-composer-cost-dock',
|
|
3168
|
-
'data-composer-session-cost': 'true',
|
|
3169
|
-
style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
|
|
3170
|
-
},
|
|
3171
|
-
React.createElement(
|
|
3172
|
-
'button',
|
|
3173
|
-
{
|
|
3174
|
-
type: 'button',
|
|
3175
|
-
className: 'cb-btn',
|
|
3176
|
-
style: {
|
|
3177
|
-
padding: '2px 8px',
|
|
3178
|
-
fontSize: '11px',
|
|
3179
|
-
height: '24px',
|
|
3180
|
-
borderRadius: '999px',
|
|
3181
|
-
display: 'inline-flex',
|
|
3182
|
-
alignItems: 'center',
|
|
3183
|
-
gap: '4px',
|
|
3184
|
-
color: 'var(--dsw-alias-label-secondary)',
|
|
3185
|
-
background: 'var(--dsw-alias-bg-layer-2)',
|
|
3186
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
3187
|
-
cursor: 'pointer',
|
|
3188
|
-
},
|
|
3189
|
-
title: `${t('composer.cost_title')}: ${costDisplay} (${formatTokens(total)} tokens)`,
|
|
3190
|
-
onClick: () => setShowPopover(!showPopover),
|
|
3191
|
-
},
|
|
3192
|
-
React.createElement('span', { style: { fontSize: '12px' } }, '🪙'),
|
|
3193
|
-
React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, `≈ ${costDisplay}`),
|
|
3194
|
-
React.createElement('span', { style: { opacity: 0.7 } }, `· ${formatTokens(total)} tok`)
|
|
3195
|
-
),
|
|
3196
|
-
showPopover
|
|
3197
|
-
? React.createElement(
|
|
3198
|
-
'div',
|
|
3199
|
-
{
|
|
3200
|
-
className: 'cb-popover',
|
|
3201
|
-
style: {
|
|
3202
|
-
position: 'absolute',
|
|
3203
|
-
bottom: '100%',
|
|
3204
|
-
left: 0,
|
|
3205
|
-
marginBottom: '6px',
|
|
3206
|
-
minWidth: '200px',
|
|
3207
|
-
padding: '10px 12px',
|
|
3208
|
-
background: 'var(--dsw-alias-bg-layer-3)',
|
|
3209
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
3210
|
-
borderRadius: '8px',
|
|
3211
|
-
boxShadow: 'var(--dsw-elevation-prominent)',
|
|
3212
|
-
zIndex: 100,
|
|
3213
|
-
fontSize: '12px',
|
|
3214
|
-
color: 'var(--dsw-alias-label-primary)',
|
|
3215
|
-
display: 'flex',
|
|
3216
|
-
flexDirection: 'column',
|
|
3217
|
-
gap: '6px',
|
|
3218
|
-
},
|
|
3219
|
-
},
|
|
3220
|
-
React.createElement(
|
|
3221
|
-
'div',
|
|
3222
|
-
{ style: { fontWeight: 600, borderBottom: '1px solid var(--dsw-alias-border-l2)', paddingBottom: '4px' } },
|
|
3223
|
-
t('composer.cost_breakdown')
|
|
3224
|
-
),
|
|
3225
|
-
React.createElement(
|
|
3226
|
-
'div',
|
|
3227
|
-
{ style: { display: 'flex', justifyContent: 'space-between' } },
|
|
3228
|
-
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.prompt_tok')),
|
|
3229
|
-
React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(pTok))
|
|
3230
|
-
),
|
|
3231
|
-
React.createElement(
|
|
3232
|
-
'div',
|
|
3233
|
-
{ style: { display: 'flex', justifyContent: 'space-between' } },
|
|
3234
|
-
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.completion_tok')),
|
|
3235
|
-
React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(cTok))
|
|
3236
|
-
),
|
|
3237
|
-
rTok > 0
|
|
3238
|
-
? React.createElement(
|
|
3239
|
-
'div',
|
|
3240
|
-
{ style: { display: 'flex', justifyContent: 'space-between' } },
|
|
3241
|
-
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.reasoning_tok')),
|
|
3242
|
-
React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(rTok))
|
|
3243
|
-
)
|
|
3244
|
-
: null,
|
|
3245
|
-
React.createElement(
|
|
3246
|
-
'div',
|
|
3247
|
-
{ style: { display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '4px', fontWeight: 600 } },
|
|
3248
|
-
React.createElement('span', null, t('composer.est_cost')),
|
|
3249
|
-
React.createElement('span', { style: { color: 'var(--dsw-alias-brand-primary)' } }, `≈ ${costDisplay}`)
|
|
3250
|
-
)
|
|
3251
|
-
)
|
|
4839
|
+
apply()
|
|
4840
|
+
const observer = typeof window !== 'undefined' && typeof window.MutationObserver === 'function'
|
|
4841
|
+
? new window.MutationObserver(apply)
|
|
3252
4842
|
: null
|
|
3253
|
-
|
|
4843
|
+
observer?.observe(document.body, { childList: true })
|
|
4844
|
+
let timer = null
|
|
4845
|
+
const tick = () => {
|
|
4846
|
+
apply()
|
|
4847
|
+
timer = setTimeout(tick, 2000)
|
|
4848
|
+
}
|
|
4849
|
+
timer = setTimeout(tick, 2000)
|
|
4850
|
+
return () => {
|
|
4851
|
+
observer?.disconnect()
|
|
4852
|
+
if (timer) clearTimeout(timer)
|
|
4853
|
+
if (root?.parentNode) root.parentNode.removeChild(root)
|
|
4854
|
+
}
|
|
4855
|
+
}, [inject, costDisplay, title])
|
|
4856
|
+
|
|
4857
|
+
if (configSnapshot?.value?.showComposerCost === false) return null
|
|
4858
|
+
if (total === 0 && !stats) return null
|
|
4859
|
+
if (!inject) return null
|
|
4860
|
+
return React.createElement('span', {
|
|
4861
|
+
ref: markerRef,
|
|
4862
|
+
style: { display: 'none' },
|
|
4863
|
+
'data-clinebot-cost-anchor': '',
|
|
4864
|
+
})
|
|
3254
4865
|
}
|
|
3255
4866
|
|
|
3256
4867
|
// --- src/client/settings-page.js ---
|
|
@@ -3275,6 +4886,7 @@ function SettingsPage(props) {
|
|
|
3275
4886
|
|
|
3276
4887
|
React.useEffect(() => { ensureCss() }, [])
|
|
3277
4888
|
|
|
4889
|
+
const pendingRef = React.useRef({})
|
|
3278
4890
|
const load = React.useCallback(async () => {
|
|
3279
4891
|
setErr('')
|
|
3280
4892
|
const [res, statsRes] = await Promise.all([
|
|
@@ -3286,11 +4898,15 @@ function SettingsPage(props) {
|
|
|
3286
4898
|
const rawStats = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
|
|
3287
4899
|
const statsObj = (rawStats && typeof rawStats === 'object' && rawStats.stats) ? rawStats.stats : (rawStats || data.statsSummary || {})
|
|
3288
4900
|
setStatus({ ...data, statsSummary: statsObj })
|
|
3289
|
-
|
|
4901
|
+
// Preserve unsaved staged fields across an account/model refresh.
|
|
4902
|
+
setDraft({ ...(data.config || {}), ...(pendingRef.current || {}) })
|
|
3290
4903
|
}, [])
|
|
3291
4904
|
|
|
3292
4905
|
React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
|
|
3293
4906
|
|
|
4907
|
+
const staged = useStagedConfig({ status, draft, setDraft, load, t, pendingRef })
|
|
4908
|
+
const modelSelection = useModelSelection({ status, draft, setDraft, load, setErr })
|
|
4909
|
+
|
|
3294
4910
|
const checkUpdate = React.useCallback(async () => {
|
|
3295
4911
|
setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
|
|
3296
4912
|
try {
|
|
@@ -3378,6 +4994,14 @@ function SettingsPage(props) {
|
|
|
3378
4994
|
}
|
|
3379
4995
|
}
|
|
3380
4996
|
|
|
4997
|
+
const {
|
|
4998
|
+
handlePinAccount,
|
|
4999
|
+
handleAddAccount,
|
|
5000
|
+
handleDeleteAccount,
|
|
5001
|
+
handleRenameAccount,
|
|
5002
|
+
handleClearKey,
|
|
5003
|
+
} = createAccountActions({ performAction, load, t, setMsg })
|
|
5004
|
+
|
|
3381
5005
|
async function handleSaveKey() {
|
|
3382
5006
|
const keyVal = String(apiKeyInput || '').trim()
|
|
3383
5007
|
if (!keyVal) { setErr(t('key.empty_err')); return }
|
|
@@ -3498,45 +5122,6 @@ function SettingsPage(props) {
|
|
|
3498
5122
|
window.open('https://app.cline.bot/settings/api-keys', '_blank')
|
|
3499
5123
|
}
|
|
3500
5124
|
|
|
3501
|
-
async function handlePinAccount(accountEnv) {
|
|
3502
|
-
await performAction('pin-account', async () => {
|
|
3503
|
-
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
|
|
3504
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
3505
|
-
body: JSON.stringify({ account: accountEnv }),
|
|
3506
|
-
})
|
|
3507
|
-
const data = await res.json().catch(() => ({}))
|
|
3508
|
-
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
3509
|
-
await load()
|
|
3510
|
-
})
|
|
3511
|
-
}
|
|
3512
|
-
|
|
3513
|
-
async function handleAddAccount({ label, apiKeyEnv, apiKey, id }) {
|
|
3514
|
-
await performAction('add-account', async () => {
|
|
3515
|
-
const endpoint = id ? `${ROUTE_PREFIX}/keys/discovered/import` : `${ROUTE_PREFIX}/accounts`
|
|
3516
|
-
const payload = id ? { id, label, apiKeyEnv } : { label, apiKeyEnv, apiKey }
|
|
3517
|
-
const res = await fetchWithTimeout(endpoint, {
|
|
3518
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
3519
|
-
body: JSON.stringify(payload),
|
|
3520
|
-
})
|
|
3521
|
-
const data = await res.json().catch(() => ({}))
|
|
3522
|
-
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
3523
|
-
setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : (data.validationError || 'Notice') }))
|
|
3524
|
-
await load()
|
|
3525
|
-
})
|
|
3526
|
-
}
|
|
3527
|
-
|
|
3528
|
-
async function handleDeleteAccount(accountEnv, deleteSecret = true) {
|
|
3529
|
-
await performAction('delete-account', async () => {
|
|
3530
|
-
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
|
|
3531
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
3532
|
-
body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
|
|
3533
|
-
})
|
|
3534
|
-
const data = await res.json().catch(() => ({}))
|
|
3535
|
-
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
3536
|
-
await load()
|
|
3537
|
-
})
|
|
3538
|
-
}
|
|
3539
|
-
|
|
3540
5125
|
async function handleConfigPatch(patch) {
|
|
3541
5126
|
setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
|
|
3542
5127
|
await performAction('patch-config', async () => {
|
|
@@ -3559,60 +5144,6 @@ function SettingsPage(props) {
|
|
|
3559
5144
|
})
|
|
3560
5145
|
}
|
|
3561
5146
|
|
|
3562
|
-
const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
|
|
3563
|
-
|
|
3564
|
-
React.useEffect(() => () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) }, [])
|
|
3565
|
-
|
|
3566
|
-
function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
|
|
3567
|
-
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
3568
|
-
debounceTimerRef.current = setTimeout(async () => {
|
|
3569
|
-
try {
|
|
3570
|
-
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
|
|
3571
|
-
method: 'POST',
|
|
3572
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3573
|
-
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
3574
|
-
})
|
|
3575
|
-
const data = await res.json().catch(() => ({}))
|
|
3576
|
-
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
3577
|
-
await load()
|
|
3578
|
-
} catch (err) {
|
|
3579
|
-
console.warn('[dsh-clinebot] Failed saving disabled models:', err)
|
|
3580
|
-
setErr(String(err?.message || err))
|
|
3581
|
-
setDraft((prev) => {
|
|
3582
|
-
if (!prev) return prev
|
|
3583
|
-
const all = status?.availableModels || []
|
|
3584
|
-
const prevEnabled = all.map((m) => m.id).filter((mId) => !previousDisabled.includes(mId))
|
|
3585
|
-
return { ...prev, disabledModels: previousDisabled, enabledModels: prevEnabled }
|
|
3586
|
-
})
|
|
3587
|
-
}
|
|
3588
|
-
}, 400)
|
|
3589
|
-
}
|
|
3590
|
-
|
|
3591
|
-
function handleToggleModel(id) {
|
|
3592
|
-
const curr = draft?.disabledModels || []
|
|
3593
|
-
const isCurrentlyDisabled = curr.includes(id)
|
|
3594
|
-
const next = isCurrentlyDisabled ? curr.filter((x) => x !== id) : [...curr, id]
|
|
3595
|
-
const all = status?.availableModels || []
|
|
3596
|
-
const enabledIds = all.map((m) => m.id).filter((mId) => !next.includes(mId))
|
|
3597
|
-
setDraft({ ...draft, disabledModels: next, enabledModels: enabledIds })
|
|
3598
|
-
debounceSaveDisabledModels(next, curr)
|
|
3599
|
-
}
|
|
3600
|
-
|
|
3601
|
-
function handleSetModelsFilter(type) {
|
|
3602
|
-
const all = status?.availableModels || []
|
|
3603
|
-
let allowed = new Set()
|
|
3604
|
-
if (type === 'all') allowed = new Set(all.map((m) => m.id))
|
|
3605
|
-
else if (type === 'vision') allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
|
|
3606
|
-
else if (type === 'coding') allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
|
|
3607
|
-
else if (type === 'recommended') allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
|
|
3608
|
-
|
|
3609
|
-
const curr = draft?.disabledModels || []
|
|
3610
|
-
const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
|
|
3611
|
-
const nextEnabled = Array.from(allowed)
|
|
3612
|
-
setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
3613
|
-
debounceSaveDisabledModels(nextDisabled, curr)
|
|
3614
|
-
}
|
|
3615
|
-
|
|
3616
5147
|
if (!status || !draft) {
|
|
3617
5148
|
if (err) {
|
|
3618
5149
|
return React.createElement(
|
|
@@ -3693,7 +5224,13 @@ function SettingsPage(props) {
|
|
|
3693
5224
|
|
|
3694
5225
|
// Accounts Pool Card
|
|
3695
5226
|
React.createElement(AccountsSection, {
|
|
3696
|
-
status,
|
|
5227
|
+
status,
|
|
5228
|
+
busy,
|
|
5229
|
+
handlePinAccount,
|
|
5230
|
+
handleAddAccount,
|
|
5231
|
+
handleDeleteAccount,
|
|
5232
|
+
handleRenameAccount,
|
|
5233
|
+
handleClearKey,
|
|
3697
5234
|
accountMode: draft?.accountMode || status?.config?.accountMode || 'least_used',
|
|
3698
5235
|
handleUpdateAccountMode: (mode) => handleConfigPatch({ accountMode: mode }),
|
|
3699
5236
|
modelRoutes: draft?.modelRoutes || status?.config?.modelRoutes || {},
|
|
@@ -3702,39 +5239,72 @@ function SettingsPage(props) {
|
|
|
3702
5239
|
}),
|
|
3703
5240
|
|
|
3704
5241
|
// Quota Warning & Dashboard Card
|
|
3705
|
-
React.createElement(QuotaSection, {
|
|
3706
|
-
|
|
3707
|
-
// Card 3: Model Picker Management
|
|
3708
|
-
React.createElement(ModelsSection, {
|
|
3709
|
-
modelsList,
|
|
3710
|
-
disabledSet,
|
|
3711
|
-
enabledCount,
|
|
5242
|
+
React.createElement(QuotaSection, {
|
|
3712
5243
|
keyPresent,
|
|
5244
|
+
status,
|
|
5245
|
+
usage,
|
|
3713
5246
|
busy,
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
handleToggleModel,
|
|
3718
|
-
planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
|
|
3719
|
-
planSyncedAt: status?.config?.planSyncedAt || 0,
|
|
3720
|
-
defaultModelWarning: status?.config?.defaultModelWarning || '',
|
|
3721
|
-
customModels: draft?.customModels || [],
|
|
3722
|
-
modelReasoningDefaults: draft?.modelReasoningDefaults || {},
|
|
3723
|
-
visibleModels: draft?.visibleModels || [],
|
|
3724
|
-
filterModelsByPlan: draft?.filterModelsByPlan !== false,
|
|
3725
|
-
onConfigUpdate: handleConfigPatch,
|
|
3726
|
-
onUpdateReasoningEffort: (modelId, effort) => {
|
|
3727
|
-
const next = { ...(draft?.modelReasoningDefaults || {}) }
|
|
3728
|
-
if (!effort) delete next[modelId]; else next[modelId] = effort
|
|
3729
|
-
handleConfigPatch({ modelReasoningDefaults: next })
|
|
3730
|
-
},
|
|
5247
|
+
handleRefreshQuota,
|
|
5248
|
+
showSidebarQuota: draft?.showSidebarQuota !== false,
|
|
5249
|
+
onToggleSidebarQuota: (value) => staged.stage({ showSidebarQuota: value }),
|
|
3731
5250
|
t,
|
|
3732
5251
|
}),
|
|
3733
5252
|
|
|
3734
|
-
//
|
|
5253
|
+
// Models & context: a foldable group keeps the long model table out of the
|
|
5254
|
+
// initial page while preserving the picker controls.
|
|
5255
|
+
React.createElement(
|
|
5256
|
+
CollapsibleGroup,
|
|
5257
|
+
{
|
|
5258
|
+
title: t('settings.models_group'),
|
|
5259
|
+
description: t('settings.models_group_hint'),
|
|
5260
|
+
badge: `${enabledCount}/${modelsList.length}`,
|
|
5261
|
+
divided: true,
|
|
5262
|
+
defaultOpen: false,
|
|
5263
|
+
},
|
|
5264
|
+
React.createElement(ModelsSection, {
|
|
5265
|
+
modelsList,
|
|
5266
|
+
disabledSet,
|
|
5267
|
+
enabledCount,
|
|
5268
|
+
keyPresent,
|
|
5269
|
+
busy,
|
|
5270
|
+
handleSyncPlanModels,
|
|
5271
|
+
handleUpdateModelContext,
|
|
5272
|
+
handleSetModelsFilter: modelSelection.handleSetModelsFilter,
|
|
5273
|
+
handleToggleModel: modelSelection.handleToggleModel,
|
|
5274
|
+
planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
|
|
5275
|
+
planSyncedAt: status?.config?.planSyncedAt || 0,
|
|
5276
|
+
defaultModelWarning: status?.config?.defaultModelWarning || '',
|
|
5277
|
+
customModels: draft?.customModels || [],
|
|
5278
|
+
modelReasoningDefaults: draft?.modelReasoningDefaults || {},
|
|
5279
|
+
visibleModels: draft?.visibleModels || [],
|
|
5280
|
+
filterModelsByPlan: draft?.filterModelsByPlan !== false,
|
|
5281
|
+
onConfigUpdate: staged.stage,
|
|
5282
|
+
onUpdateReasoningEffort: (modelId, effort) => {
|
|
5283
|
+
const next = { ...(draft?.modelReasoningDefaults || {}) }
|
|
5284
|
+
if (!effort) delete next[modelId]; else next[modelId] = effort
|
|
5285
|
+
staged.stage({ modelReasoningDefaults: next })
|
|
5286
|
+
},
|
|
5287
|
+
t,
|
|
5288
|
+
}),
|
|
5289
|
+
React.createElement(PriceSection, {
|
|
5290
|
+
modelsList,
|
|
5291
|
+
priceOverrides: draft?.priceOverrides || {},
|
|
5292
|
+
onConfigUpdate: staged.stage,
|
|
5293
|
+
t,
|
|
5294
|
+
})
|
|
5295
|
+
),
|
|
5296
|
+
|
|
5297
|
+
// Session Metrics & Usage Tracking
|
|
3735
5298
|
React.createElement(StatsSection, { status, onResetStats: handleResetStats, busy, t }),
|
|
3736
5299
|
|
|
3737
|
-
|
|
5300
|
+
React.createElement(IntegrationsSection, {
|
|
5301
|
+
draft,
|
|
5302
|
+
onStage: staged.stage,
|
|
5303
|
+
transport: draft?.transport || status?.config?.transport || 'adapter',
|
|
5304
|
+
t,
|
|
5305
|
+
}),
|
|
5306
|
+
|
|
5307
|
+
// Diagnostics & Sync with DSH
|
|
3738
5308
|
React.createElement(DiagSection, {
|
|
3739
5309
|
keyPresent,
|
|
3740
5310
|
isRegistered,
|
|
@@ -3744,7 +5314,26 @@ function SettingsPage(props) {
|
|
|
3744
5314
|
handleUnregister,
|
|
3745
5315
|
handleRegister,
|
|
3746
5316
|
proxyMode: draft?.proxyMode !== false,
|
|
3747
|
-
onToggleProxy: (
|
|
5317
|
+
onToggleProxy: (value) => staged.stage({ proxyMode: value }),
|
|
5318
|
+
t,
|
|
5319
|
+
}),
|
|
5320
|
+
|
|
5321
|
+
React.createElement(AdvancedSection, {
|
|
5322
|
+
draft,
|
|
5323
|
+
onStage: staged.stage,
|
|
5324
|
+
transport: draft?.transport || status?.config?.transport || 'adapter',
|
|
5325
|
+
t,
|
|
5326
|
+
}),
|
|
5327
|
+
|
|
5328
|
+
React.createElement(SaveBar, {
|
|
5329
|
+
pendingCount: staged.pendingCount,
|
|
5330
|
+
saving: staged.saving,
|
|
5331
|
+
saveState: staged.saveState,
|
|
5332
|
+
saveError: staged.saveError,
|
|
5333
|
+
invalid: staged.invalid,
|
|
5334
|
+
validationError: staged.validationError,
|
|
5335
|
+
onSave: staged.save,
|
|
5336
|
+
onDiscard: staged.discard,
|
|
3748
5337
|
t,
|
|
3749
5338
|
})
|
|
3750
5339
|
)
|
|
@@ -3820,6 +5409,24 @@ function apply(ctx) {
|
|
|
3820
5409
|
}
|
|
3821
5410
|
}
|
|
3822
5411
|
|
|
5412
|
+
function selectLayoutPanel(id) {
|
|
5413
|
+
try {
|
|
5414
|
+
const layout = typeof ctx.get === 'function' ? ctx.get('layout') : ctx.layout
|
|
5415
|
+
if (!layout || typeof layout.selectPanel !== 'function') return false
|
|
5416
|
+
layout.selectPanel(id)
|
|
5417
|
+
return true
|
|
5418
|
+
} catch (err) {
|
|
5419
|
+
console.warn('[dsh-clinebot] layout.selectPanel failed:', err)
|
|
5420
|
+
return false
|
|
5421
|
+
}
|
|
5422
|
+
}
|
|
5423
|
+
|
|
5424
|
+
const openPanel = () => selectLayoutPanel('clinebot-panel')
|
|
5425
|
+
const closePanel = () => {
|
|
5426
|
+
if (selectLayoutPanel(null)) return
|
|
5427
|
+
selectLayoutPanel('conversation')
|
|
5428
|
+
}
|
|
5429
|
+
|
|
3823
5430
|
// The plugin's configuration is a section of DSH Settings (`settings.section`),
|
|
3824
5431
|
// a peer of General/Models/Plugins — not a row in the Plugins list and not a tab
|
|
3825
5432
|
// inside the Plugins settings section. The shell owns the modal, the section nav
|
|
@@ -3856,24 +5463,57 @@ function apply(ctx) {
|
|
|
3856
5463
|
id: 'clinebot-quota',
|
|
3857
5464
|
order: 20,
|
|
3858
5465
|
locale: NS,
|
|
3859
|
-
inject: () => ({ ctx }),
|
|
5466
|
+
inject: () => ({ ctx, open: openPanel }),
|
|
3860
5467
|
},
|
|
3861
5468
|
(props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(SidebarQuota, { ...props, ctx: (props && props.ctx) || ctx }))
|
|
3862
5469
|
)
|
|
3863
5470
|
)
|
|
3864
5471
|
|
|
3865
|
-
// The quota &
|
|
3866
|
-
//
|
|
3867
|
-
//
|
|
3868
|
-
|
|
3869
|
-
//
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
5472
|
+
// The quota & usage dashboard opens in the layout's center column, matching
|
|
5473
|
+
// the Command Code / OpenCode provider surfaces. The sidebar card selects it
|
|
5474
|
+
// through `layout.selectPanel`; the panel's Close button selects the
|
|
5475
|
+
// conversation again. When no layout service is available the card falls back
|
|
5476
|
+
// to its local Modal.
|
|
5477
|
+
registerSlotWhenReady('main', () =>
|
|
5478
|
+
ctx.slots.register(
|
|
5479
|
+
{
|
|
5480
|
+
name: 'main',
|
|
5481
|
+
key: 'clinebot-panel',
|
|
5482
|
+
locale: NS,
|
|
5483
|
+
inject: () => ({ ctx, open: openPanel, close: closePanel }),
|
|
5484
|
+
},
|
|
5485
|
+
(props) => React.createElement(
|
|
5486
|
+
ErrorBoundary,
|
|
5487
|
+
{ t: props && props.t },
|
|
5488
|
+
React.createElement(DashboardDialog, {
|
|
5489
|
+
...props,
|
|
5490
|
+
panel: true,
|
|
5491
|
+
ctx: (props && props.ctx) || ctx,
|
|
5492
|
+
onClose: (props && props.close) || closePanel,
|
|
5493
|
+
})
|
|
5494
|
+
)
|
|
5495
|
+
)
|
|
5496
|
+
)
|
|
5497
|
+
|
|
5498
|
+
// Models-page provider card. The seat is keyed by the provider's settings
|
|
5499
|
+
// namespace, which the native adapter registration declares as `dsh-clinebot`
|
|
5500
|
+
// (`settingsNs`). The component returns null for every other provider row, so
|
|
5501
|
+
// it cannot leak onto a sibling's card.
|
|
5502
|
+
registerSlotWhenReady('settings.models.provider-card', () =>
|
|
5503
|
+
ctx.slots.register(
|
|
5504
|
+
{
|
|
5505
|
+
name: 'settings.models.provider-card',
|
|
5506
|
+
key: NS,
|
|
5507
|
+
locale: NS,
|
|
5508
|
+
inject: () => ({ ctx }),
|
|
5509
|
+
},
|
|
5510
|
+
(props) => React.createElement(
|
|
5511
|
+
ErrorBoundary,
|
|
5512
|
+
{ t: props && props.t },
|
|
5513
|
+
React.createElement(ProviderCard, { ...props, ctx: (props && props.ctx) || ctx })
|
|
5514
|
+
)
|
|
5515
|
+
)
|
|
5516
|
+
)
|
|
3877
5517
|
|
|
3878
5518
|
// Composer Session Cost Dock (conversation.composer.dock slot)
|
|
3879
5519
|
registerSlotWhenReady('conversation.composer.dock', () =>
|