@goodandready/dsh-clinebot 0.4.7 → 0.4.8
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 +9 -0
- package/lib/client.js +411 -53
- package/lib/config.js +21 -3
- package/lib/index.js +35 -0
- package/lib/models.js +31 -6
- package/lib/provider-sync.js +50 -25
- package/lib/routes/proxy.js +262 -0
- package/lib/routes/settings.js +34 -0
- package/lib/session-router.js +188 -0
- package/lib/stats-storage.js +217 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.8] - 2026-09-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Transparent Loopback Proxy (#127)**: High-performance streaming proxy endpoints at `/dsh-clinebot/v1/chat/completions` and `/dsh-clinebot/v1/models` compatible with OpenAI spec. Features zero-downtime retry on HTTP 429 before stream commits, live SSE token tracking, and direct binding into DSH `llm-pi-ai` provider.
|
|
12
|
+
- **Sticky Session Least-Used Routing (#128)**: Session-aware routing selecting account with maximum quota remaining (`remainingPercent`) at session inception. Pins active session to selected account and triggers seamless failover on quota exhaustion or HTTP 429, with automatic cooldown expiration recovery.
|
|
13
|
+
- **Persistent JSON Token Analytics (#129)**: Atomic file-based telemetry storage at `~/.dsh/clinebot-stats.json` tracking cumulative prompt/completion tokens, requests, and rate limits across models and accounts with debounced write operations. Accessible via `GET /dsh-clinebot/stats` and resettable via `POST /dsh-clinebot/stats/reset`.
|
|
14
|
+
- **Per-Model Reasoning Effort Defaults & Custom Models (#130)**: Fine-grained `reasoning_effort` defaults (`low`, `medium`, `high`, `max`) per reasoning model in configuration and UI. Added support for user-defined custom models (`config.customModels`) integrated into dynamic catalog.
|
|
15
|
+
- **Quota UX & Proactive Monitoring (#131)**: Human-readable reset countdown timer (`formatResetCountdown`) for rolling weekly and monthly quotas, proactive warning banners in settings and progress bars when remaining quota drops below 10%, and monthly billing cycle visibility.
|
|
16
|
+
|
|
8
17
|
## [0.4.7] - 2026-09-27
|
|
9
18
|
|
|
10
19
|
### Fixed & Cleaned
|
package/lib/client.js
CHANGED
|
@@ -122,6 +122,17 @@ const en = {
|
|
|
122
122
|
'stats.tokens': 'Estimated Session Tokens',
|
|
123
123
|
'stats.latency': 'Last Request Latency',
|
|
124
124
|
'stats.last_req': 'Last Request Time',
|
|
125
|
+
'models.custom_add_title': 'Custom Models',
|
|
126
|
+
'models.custom_id': 'Model ID (e.g. anthropic/claude-3-7-sonnet)',
|
|
127
|
+
'models.custom_name': 'Display Name (optional)',
|
|
128
|
+
'models.custom_btn_add': 'Add Model',
|
|
129
|
+
'models.custom_btn_del': 'Delete',
|
|
130
|
+
'models.effort_auto': 'Auto Effort',
|
|
131
|
+
'stats.errors429': '429 Rate Limits (Failover)',
|
|
132
|
+
'stats.top_models': 'Top Models by Token Usage',
|
|
133
|
+
'stats.reset_btn': 'Reset Stats',
|
|
134
|
+
'stats.confirm_reset': 'Confirm Reset?',
|
|
135
|
+
'stats.cancel_reset': 'Cancel',
|
|
125
136
|
'diag.title': '⚡ Diagnostics & Auto-Registration',
|
|
126
137
|
'diag.desc': 'ClineBot registers into DSH Models automatically when key is configured. You can run connection smoke test or force re-sync.',
|
|
127
138
|
'diag.smoke_btn': 'Run Smoke Test (Ping)',
|
|
@@ -238,6 +249,17 @@ const en = {
|
|
|
238
249
|
'stats.tokens': '会话累计 Token 估算',
|
|
239
250
|
'stats.latency': '最近一次请求延迟',
|
|
240
251
|
'stats.last_req': '最近请求时间',
|
|
252
|
+
'models.custom_add_title': '自定义模型',
|
|
253
|
+
'models.custom_id': '模型 ID (例如 anthropic/claude-3-7-sonnet)',
|
|
254
|
+
'models.custom_name': '显示名称 (可选)',
|
|
255
|
+
'models.custom_btn_add': '添加模型',
|
|
256
|
+
'models.custom_btn_del': '删除',
|
|
257
|
+
'models.effort_auto': '自动思考强度',
|
|
258
|
+
'stats.errors429': '429 限流保护次数',
|
|
259
|
+
'stats.top_models': 'Token 消耗最高模型',
|
|
260
|
+
'stats.reset_btn': '重置统计',
|
|
261
|
+
'stats.confirm_reset': '确认重置?',
|
|
262
|
+
'stats.cancel_reset': '取消',
|
|
241
263
|
'diag.title': '⚡ 诊断工具与服务接入',
|
|
242
264
|
'diag.desc': '配置 API 密钥后,ClineBot 会自动向 DSH 注册模型服务。您可以在此进行连通性测试或强制重新同步。',
|
|
243
265
|
'diag.smoke_btn': '执行连通性冒烟测试 (Ping)',
|
|
@@ -404,6 +426,29 @@ function createErrorBoundary() {
|
|
|
404
426
|
const ErrorBoundary = createErrorBoundary()
|
|
405
427
|
|
|
406
428
|
// --- src/client/progress-bar.js ---
|
|
429
|
+
function formatResetCountdown(isoString) {
|
|
430
|
+
if (!isoString) return ''
|
|
431
|
+
try {
|
|
432
|
+
const d = new Date(isoString)
|
|
433
|
+
const diff = d.getTime() - Date.now()
|
|
434
|
+
if (Number.isNaN(diff)) return ''
|
|
435
|
+
if (diff <= 0) return 'resetting'
|
|
436
|
+
const totalHours = Math.floor(diff / (1000 * 60 * 60))
|
|
437
|
+
const days = Math.floor(totalHours / 24)
|
|
438
|
+
const hours = totalHours % 24
|
|
439
|
+
if (days > 0) {
|
|
440
|
+
return `${days}d ${hours}h`
|
|
441
|
+
}
|
|
442
|
+
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
443
|
+
if (hours > 0) {
|
|
444
|
+
return `${hours}h ${minutes}m`
|
|
445
|
+
}
|
|
446
|
+
return `${Math.max(1, minutes)}m`
|
|
447
|
+
} catch {
|
|
448
|
+
return ''
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
407
452
|
function formatResetTime(isoString) {
|
|
408
453
|
if (!isoString) return ''
|
|
409
454
|
try {
|
|
@@ -417,10 +462,11 @@ function formatResetTime(isoString) {
|
|
|
417
462
|
|
|
418
463
|
function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
419
464
|
const pct = Math.max(0, Math.min(100, Math.round(percentUsed || 0)))
|
|
420
|
-
const isWarn = pct >= 80 && pct <
|
|
421
|
-
const isBad = pct >=
|
|
465
|
+
const isWarn = pct >= 80 && pct < 90
|
|
466
|
+
const isBad = pct >= 90
|
|
422
467
|
const barClass = isBad ? 'cb-progress-bar-bad' : isWarn ? 'cb-progress-bar-warn' : 'cb-progress-bar-fill'
|
|
423
|
-
const
|
|
468
|
+
const resetCountdown = formatResetCountdown(resetsAt)
|
|
469
|
+
const resetClock = formatResetTime(resetsAt)
|
|
424
470
|
|
|
425
471
|
return React.createElement(
|
|
426
472
|
'div',
|
|
@@ -432,7 +478,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
432
478
|
gap: '8px',
|
|
433
479
|
padding: '12px 14px',
|
|
434
480
|
borderRadius: '8px',
|
|
435
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
481
|
+
border: isBad ? '1px solid var(--dsw-alias-error, #f87171)' : '1px solid var(--dsw-alias-border-l2)',
|
|
436
482
|
background: 'var(--dsw-alias-bg-layer-2)',
|
|
437
483
|
},
|
|
438
484
|
},
|
|
@@ -448,20 +494,25 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
448
494
|
width: '100%',
|
|
449
495
|
},
|
|
450
496
|
},
|
|
451
|
-
React.createElement(
|
|
497
|
+
React.createElement(
|
|
498
|
+
'span',
|
|
499
|
+
{ className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
|
|
500
|
+
label,
|
|
501
|
+
isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-error, #f87171)' } }, '⚠️ <10%') : null
|
|
502
|
+
),
|
|
452
503
|
React.createElement(
|
|
453
504
|
'span',
|
|
454
505
|
{
|
|
455
506
|
className: 'cb-progress-meta cb-bar-meta',
|
|
456
507
|
style: {
|
|
457
508
|
fontSize: '12px',
|
|
458
|
-
color: 'var(--dsw-alias-label-secondary)',
|
|
509
|
+
color: isBad ? 'var(--dsw-alias-error, #f87171)' : 'var(--dsw-alias-label-secondary)',
|
|
459
510
|
whiteSpace: 'nowrap',
|
|
460
511
|
marginLeft: 'auto',
|
|
461
512
|
},
|
|
462
513
|
},
|
|
463
514
|
t('quota.used', { pct }),
|
|
464
|
-
|
|
515
|
+
resetCountdown ? ` · ${t('quota.countdown', { time: resetCountdown })}` : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
|
|
465
516
|
)
|
|
466
517
|
),
|
|
467
518
|
React.createElement(
|
|
@@ -868,6 +919,12 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
|
|
|
868
919
|
|
|
869
920
|
// --- src/client/components/quota-section.js ---
|
|
870
921
|
function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }) {
|
|
922
|
+
const isLowQuota = Boolean(
|
|
923
|
+
(usage?.windows?.weekly && typeof usage.windows.weekly.remainingPercent === 'number' && usage.windows.weekly.remainingPercent <= 10) ||
|
|
924
|
+
(usage?.windows?.monthly && typeof usage.windows.monthly.remainingPercent === 'number' && usage.windows.monthly.remainingPercent <= 10) ||
|
|
925
|
+
(usage?.windows?.fiveHour && typeof usage.windows.fiveHour.remainingPercent === 'number' && usage.windows.fiveHour.remainingPercent <= 10)
|
|
926
|
+
)
|
|
927
|
+
|
|
871
928
|
return React.createElement(
|
|
872
929
|
React.Fragment,
|
|
873
930
|
null,
|
|
@@ -889,7 +946,14 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
|
|
|
889
946
|
: null
|
|
890
947
|
)
|
|
891
948
|
)
|
|
892
|
-
:
|
|
949
|
+
: (isLowQuota
|
|
950
|
+
? React.createElement(
|
|
951
|
+
'div',
|
|
952
|
+
{ className: 'cb-banner-warning' },
|
|
953
|
+
React.createElement('span', { style: { fontSize: '16px' } }, '⚠️'),
|
|
954
|
+
React.createElement('div', { style: { flex: 1 } }, t('quota.warning_low'))
|
|
955
|
+
)
|
|
956
|
+
: null),
|
|
893
957
|
keyPresent
|
|
894
958
|
? React.createElement(
|
|
895
959
|
'div',
|
|
@@ -918,27 +982,183 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
|
|
|
918
982
|
),
|
|
919
983
|
React.createElement(
|
|
920
984
|
'div',
|
|
921
|
-
{ className: 'cb-grid-2' },
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
985
|
+
{ className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '10px' } },
|
|
986
|
+
usage?.windows?.fiveHour
|
|
987
|
+
? React.createElement(ProgressBar, {
|
|
988
|
+
label: t('quota.window_5h'),
|
|
989
|
+
percentUsed: usage.windows.fiveHour.percentUsed || 0,
|
|
990
|
+
remainingPercent: usage.windows.fiveHour.remainingPercent || 100,
|
|
991
|
+
resetsAt: usage.windows.fiveHour.resetsAt,
|
|
992
|
+
t,
|
|
993
|
+
})
|
|
994
|
+
: null,
|
|
929
995
|
React.createElement(ProgressBar, {
|
|
930
996
|
label: t('quota.window_weekly'),
|
|
931
997
|
percentUsed: usage?.windows?.weekly?.percentUsed || 0,
|
|
932
998
|
remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
|
|
933
999
|
resetsAt: usage?.windows?.weekly?.resetsAt,
|
|
934
1000
|
t,
|
|
935
|
-
})
|
|
1001
|
+
}),
|
|
1002
|
+
usage?.windows?.monthly
|
|
1003
|
+
? React.createElement(ProgressBar, {
|
|
1004
|
+
label: t('quota.window_monthly'),
|
|
1005
|
+
percentUsed: usage.windows.monthly.percentUsed || 0,
|
|
1006
|
+
remainingPercent: usage.windows.monthly.remainingPercent || 100,
|
|
1007
|
+
resetsAt: usage.windows.monthly.resetsAt,
|
|
1008
|
+
t,
|
|
1009
|
+
})
|
|
1010
|
+
: null
|
|
936
1011
|
)
|
|
937
1012
|
)
|
|
938
1013
|
: null
|
|
939
1014
|
)
|
|
940
1015
|
}
|
|
941
1016
|
|
|
1017
|
+
// --- src/client/components/custom-models.js ---
|
|
1018
|
+
function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
|
|
1019
|
+
const [newId, setNewId] = React.useState('')
|
|
1020
|
+
const [newName, setNewName] = React.useState('')
|
|
1021
|
+
const [isExpanded, setIsExpanded] = React.useState(false)
|
|
1022
|
+
|
|
1023
|
+
function handleAdd() {
|
|
1024
|
+
const trimmedId = newId.trim()
|
|
1025
|
+
const trimmedName = newName.trim() || trimmedId
|
|
1026
|
+
if (!trimmedId) return
|
|
1027
|
+
const nextList = [...customModels.filter((m) => m.id !== trimmedId), {
|
|
1028
|
+
id: trimmedId,
|
|
1029
|
+
name: trimmedName,
|
|
1030
|
+
contextLength: 128000,
|
|
1031
|
+
maxTokens: 8192,
|
|
1032
|
+
category: 'general',
|
|
1033
|
+
isCustom: true,
|
|
1034
|
+
}]
|
|
1035
|
+
if (typeof onConfigUpdate === 'function') {
|
|
1036
|
+
onConfigUpdate({ customModels: nextList })
|
|
1037
|
+
}
|
|
1038
|
+
setNewId('')
|
|
1039
|
+
setNewName('')
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function handleDelete(id) {
|
|
1043
|
+
const nextList = customModels.filter((m) => m.id !== id)
|
|
1044
|
+
if (typeof onConfigUpdate === 'function') {
|
|
1045
|
+
onConfigUpdate({ customModels: nextList })
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return React.createElement(
|
|
1050
|
+
'div',
|
|
1051
|
+
{
|
|
1052
|
+
style: {
|
|
1053
|
+
marginTop: '16px',
|
|
1054
|
+
paddingTop: '14px',
|
|
1055
|
+
borderTop: '1px solid var(--dsw-alias-border-l2)',
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
React.createElement(
|
|
1059
|
+
'div',
|
|
1060
|
+
{
|
|
1061
|
+
style: {
|
|
1062
|
+
display: 'flex',
|
|
1063
|
+
justifyContent: 'space-between',
|
|
1064
|
+
alignItems: 'center',
|
|
1065
|
+
cursor: 'pointer',
|
|
1066
|
+
},
|
|
1067
|
+
onClick: () => setIsExpanded(!isExpanded),
|
|
1068
|
+
},
|
|
1069
|
+
React.createElement(
|
|
1070
|
+
'span',
|
|
1071
|
+
{ style: { fontWeight: 500, fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } },
|
|
1072
|
+
`➕ ${t('models.custom_add_title')} (${customModels.length})`
|
|
1073
|
+
),
|
|
1074
|
+
React.createElement(
|
|
1075
|
+
'span',
|
|
1076
|
+
{ style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
1077
|
+
isExpanded ? '▲' : '▼'
|
|
1078
|
+
)
|
|
1079
|
+
),
|
|
1080
|
+
isExpanded
|
|
1081
|
+
? React.createElement(
|
|
1082
|
+
'div',
|
|
1083
|
+
{ style: { marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '10px' } },
|
|
1084
|
+
React.createElement(
|
|
1085
|
+
'div',
|
|
1086
|
+
{ style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
|
|
1087
|
+
React.createElement('input', {
|
|
1088
|
+
type: 'text',
|
|
1089
|
+
className: 'cb-input',
|
|
1090
|
+
style: { flex: '1 1 180px' },
|
|
1091
|
+
placeholder: t('models.custom_id'),
|
|
1092
|
+
value: newId,
|
|
1093
|
+
disabled: !!busy,
|
|
1094
|
+
onChange: (e) => setNewId(e.target.value),
|
|
1095
|
+
}),
|
|
1096
|
+
React.createElement('input', {
|
|
1097
|
+
type: 'text',
|
|
1098
|
+
className: 'cb-input',
|
|
1099
|
+
style: { flex: '1 1 180px' },
|
|
1100
|
+
placeholder: t('models.custom_name'),
|
|
1101
|
+
value: newName,
|
|
1102
|
+
disabled: !!busy,
|
|
1103
|
+
onChange: (e) => setNewName(e.target.value),
|
|
1104
|
+
}),
|
|
1105
|
+
React.createElement(
|
|
1106
|
+
'button',
|
|
1107
|
+
{
|
|
1108
|
+
type: 'button',
|
|
1109
|
+
className: 'cb-btn cb-btn-primary',
|
|
1110
|
+
disabled: !newId.trim() || !!busy,
|
|
1111
|
+
onClick: handleAdd,
|
|
1112
|
+
},
|
|
1113
|
+
t('models.custom_btn_add')
|
|
1114
|
+
)
|
|
1115
|
+
),
|
|
1116
|
+
customModels.length > 0
|
|
1117
|
+
? React.createElement(
|
|
1118
|
+
'div',
|
|
1119
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '6px' } },
|
|
1120
|
+
customModels.map((cm) =>
|
|
1121
|
+
React.createElement(
|
|
1122
|
+
'div',
|
|
1123
|
+
{
|
|
1124
|
+
key: cm.id,
|
|
1125
|
+
style: {
|
|
1126
|
+
display: 'flex',
|
|
1127
|
+
justifyContent: 'space-between',
|
|
1128
|
+
alignItems: 'center',
|
|
1129
|
+
padding: '6px 10px',
|
|
1130
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
1131
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1132
|
+
borderRadius: '6px',
|
|
1133
|
+
fontSize: '12px',
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
React.createElement(
|
|
1137
|
+
'div',
|
|
1138
|
+
null,
|
|
1139
|
+
React.createElement('span', { style: { fontWeight: 500 } }, cm.name || cm.id),
|
|
1140
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', marginLeft: '8px' } }, `(${cm.id})`)
|
|
1141
|
+
),
|
|
1142
|
+
React.createElement(
|
|
1143
|
+
'button',
|
|
1144
|
+
{
|
|
1145
|
+
type: 'button',
|
|
1146
|
+
className: 'cb-btn',
|
|
1147
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
1148
|
+
disabled: !!busy,
|
|
1149
|
+
onClick: () => handleDelete(cm.id),
|
|
1150
|
+
},
|
|
1151
|
+
t('models.custom_btn_del')
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1154
|
+
)
|
|
1155
|
+
)
|
|
1156
|
+
: null
|
|
1157
|
+
)
|
|
1158
|
+
: null
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
942
1162
|
// --- src/client/components/models-section.js ---
|
|
943
1163
|
function ModelsSection({
|
|
944
1164
|
modelsList,
|
|
@@ -953,6 +1173,10 @@ function ModelsSection({
|
|
|
953
1173
|
planSynced,
|
|
954
1174
|
planSyncedAt,
|
|
955
1175
|
defaultModelWarning,
|
|
1176
|
+
customModels,
|
|
1177
|
+
modelReasoningDefaults,
|
|
1178
|
+
onConfigUpdate,
|
|
1179
|
+
onUpdateReasoningEffort,
|
|
956
1180
|
t,
|
|
957
1181
|
}) {
|
|
958
1182
|
const syncDateStr = planSyncedAt ? new Date(planSyncedAt).toLocaleDateString() : ''
|
|
@@ -1337,7 +1561,30 @@ function ModelsSection({
|
|
|
1337
1561
|
? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, 'Vision')
|
|
1338
1562
|
: null,
|
|
1339
1563
|
hasReasoning
|
|
1340
|
-
? React.createElement(
|
|
1564
|
+
? React.createElement(
|
|
1565
|
+
React.Fragment,
|
|
1566
|
+
null,
|
|
1567
|
+
React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning'),
|
|
1568
|
+
React.createElement(
|
|
1569
|
+
'select',
|
|
1570
|
+
{
|
|
1571
|
+
className: 'cb-select',
|
|
1572
|
+
style: { marginLeft: '4px', fontSize: '10px', padding: '1px 3px' },
|
|
1573
|
+
value: (modelReasoningDefaults && modelReasoningDefaults[m.id]) || '',
|
|
1574
|
+
disabled: !!busy,
|
|
1575
|
+
onChange: (e) => {
|
|
1576
|
+
if (typeof onUpdateReasoningEffort === 'function') {
|
|
1577
|
+
onUpdateReasoningEffort(m.id, e.target.value)
|
|
1578
|
+
}
|
|
1579
|
+
},
|
|
1580
|
+
},
|
|
1581
|
+
React.createElement('option', { value: '' }, t('models.effort_auto')),
|
|
1582
|
+
React.createElement('option', { value: 'low' }, 'Low'),
|
|
1583
|
+
React.createElement('option', { value: 'medium' }, 'Medium'),
|
|
1584
|
+
React.createElement('option', { value: 'high' }, 'High'),
|
|
1585
|
+
React.createElement('option', { value: 'max' }, 'Max')
|
|
1586
|
+
)
|
|
1587
|
+
)
|
|
1341
1588
|
: null
|
|
1342
1589
|
)
|
|
1343
1590
|
})()
|
|
@@ -1345,45 +1592,139 @@ function ModelsSection({
|
|
|
1345
1592
|
)
|
|
1346
1593
|
})
|
|
1347
1594
|
)
|
|
1348
|
-
)
|
|
1595
|
+
),
|
|
1596
|
+
typeof CustomModelsSub === 'function'
|
|
1597
|
+
? React.createElement(CustomModelsSub, {
|
|
1598
|
+
customModels: customModels || [],
|
|
1599
|
+
onConfigUpdate: onConfigUpdate,
|
|
1600
|
+
busy: busy,
|
|
1601
|
+
t: t,
|
|
1602
|
+
})
|
|
1603
|
+
: null
|
|
1349
1604
|
)
|
|
1350
1605
|
}
|
|
1351
1606
|
|
|
1352
1607
|
// --- src/client/components/stats-section.js ---
|
|
1353
|
-
function StatsSection({ status, t }) {
|
|
1608
|
+
function StatsSection({ status, onResetStats, busy, t }) {
|
|
1609
|
+
const [confirmReset, setConfirmReset] = React.useState(false)
|
|
1610
|
+
|
|
1611
|
+
const s = status?.statsSummary || {}
|
|
1612
|
+
const sess = status?.sessionStats || {}
|
|
1613
|
+
|
|
1614
|
+
const topModels = Object.entries(s.byModel || {})
|
|
1615
|
+
.sort((a, b) => (b[1].tokens || 0) - (a[1].tokens || 0))
|
|
1616
|
+
.slice(0, 5)
|
|
1617
|
+
|
|
1354
1618
|
return React.createElement(
|
|
1355
1619
|
'div',
|
|
1356
1620
|
{ className: 'cb-section-card' },
|
|
1357
|
-
React.createElement(
|
|
1621
|
+
React.createElement(
|
|
1622
|
+
'div',
|
|
1623
|
+
{ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' } },
|
|
1624
|
+
React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
|
|
1625
|
+
React.createElement(
|
|
1626
|
+
'div',
|
|
1627
|
+
null,
|
|
1628
|
+
confirmReset
|
|
1629
|
+
? React.createElement(
|
|
1630
|
+
React.Fragment,
|
|
1631
|
+
null,
|
|
1632
|
+
React.createElement(
|
|
1633
|
+
'button',
|
|
1634
|
+
{
|
|
1635
|
+
type: 'button',
|
|
1636
|
+
className: 'cb-btn cb-btn-warn',
|
|
1637
|
+
style: { padding: '2px 8px', fontSize: '11px', marginRight: '6px' },
|
|
1638
|
+
disabled: !!busy,
|
|
1639
|
+
onClick: () => {
|
|
1640
|
+
setConfirmReset(false)
|
|
1641
|
+
if (typeof onResetStats === 'function') onResetStats()
|
|
1642
|
+
},
|
|
1643
|
+
},
|
|
1644
|
+
t('stats.confirm_reset')
|
|
1645
|
+
),
|
|
1646
|
+
React.createElement(
|
|
1647
|
+
'button',
|
|
1648
|
+
{
|
|
1649
|
+
type: 'button',
|
|
1650
|
+
className: 'cb-btn',
|
|
1651
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
1652
|
+
onClick: () => setConfirmReset(false),
|
|
1653
|
+
},
|
|
1654
|
+
t('stats.cancel_reset')
|
|
1655
|
+
)
|
|
1656
|
+
)
|
|
1657
|
+
: React.createElement(
|
|
1658
|
+
'button',
|
|
1659
|
+
{
|
|
1660
|
+
type: 'button',
|
|
1661
|
+
className: 'cb-btn',
|
|
1662
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
1663
|
+
disabled: !!busy,
|
|
1664
|
+
onClick: () => setConfirmReset(true),
|
|
1665
|
+
},
|
|
1666
|
+
t('stats.reset_btn')
|
|
1667
|
+
)
|
|
1668
|
+
)
|
|
1669
|
+
),
|
|
1358
1670
|
React.createElement('div', { className: 'cb-section-desc' }, t('stats.desc')),
|
|
1359
1671
|
React.createElement(
|
|
1360
1672
|
'div',
|
|
1361
|
-
{ className: 'cb-grid-2' },
|
|
1673
|
+
{ className: 'cb-grid-2', style: { marginTop: '12px' } },
|
|
1362
1674
|
React.createElement(
|
|
1363
1675
|
'div',
|
|
1364
1676
|
{ className: 'cb-stat-box' },
|
|
1365
|
-
React.createElement('div', { className: 'cb-stat-val' }, `${
|
|
1677
|
+
React.createElement('div', { className: 'cb-stat-val' }, `${s.requests || 0} (${sess.successfulRequests || 0} sess)`),
|
|
1366
1678
|
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
|
|
1367
1679
|
),
|
|
1368
1680
|
React.createElement(
|
|
1369
1681
|
'div',
|
|
1370
1682
|
{ className: 'cb-stat-box' },
|
|
1371
|
-
React.createElement('div', { className: 'cb-stat-val' },
|
|
1683
|
+
React.createElement('div', { className: 'cb-stat-val' }, `${Number(s.totalTokens || 0).toLocaleString()}`),
|
|
1372
1684
|
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
|
|
1373
1685
|
),
|
|
1374
1686
|
React.createElement(
|
|
1375
1687
|
'div',
|
|
1376
1688
|
{ className: 'cb-stat-box' },
|
|
1377
|
-
React.createElement('div', { className: 'cb-stat-val' },
|
|
1378
|
-
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.
|
|
1689
|
+
React.createElement('div', { className: 'cb-stat-val' }, `${s.errors429 || 0}`),
|
|
1690
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.errors429'))
|
|
1379
1691
|
),
|
|
1380
1692
|
React.createElement(
|
|
1381
1693
|
'div',
|
|
1382
1694
|
{ className: 'cb-stat-box' },
|
|
1383
|
-
React.createElement('div', { className: 'cb-stat-val' },
|
|
1384
|
-
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.
|
|
1695
|
+
React.createElement('div', { className: 'cb-stat-val' }, sess.lastLatencyMs ? `${sess.lastLatencyMs} ms` : '—'),
|
|
1696
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.latency'))
|
|
1385
1697
|
)
|
|
1386
|
-
)
|
|
1698
|
+
),
|
|
1699
|
+
topModels.length > 0
|
|
1700
|
+
? React.createElement(
|
|
1701
|
+
'div',
|
|
1702
|
+
{ style: { marginTop: '14px' } },
|
|
1703
|
+
React.createElement('div', { style: { fontSize: '12px', fontWeight: 500, marginBottom: '6px', color: 'var(--dsw-alias-label-secondary)' } }, t('stats.top_models')),
|
|
1704
|
+
React.createElement(
|
|
1705
|
+
'div',
|
|
1706
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
1707
|
+
topModels.map(([modId, data]) =>
|
|
1708
|
+
React.createElement(
|
|
1709
|
+
'div',
|
|
1710
|
+
{
|
|
1711
|
+
key: modId,
|
|
1712
|
+
style: {
|
|
1713
|
+
display: 'flex',
|
|
1714
|
+
justifyContent: 'space-between',
|
|
1715
|
+
fontSize: '11px',
|
|
1716
|
+
padding: '4px 8px',
|
|
1717
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
1718
|
+
borderRadius: '4px',
|
|
1719
|
+
},
|
|
1720
|
+
},
|
|
1721
|
+
React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
|
|
1722
|
+
React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.tokens || 0).toLocaleString()} tok`)
|
|
1723
|
+
)
|
|
1724
|
+
)
|
|
1725
|
+
)
|
|
1726
|
+
)
|
|
1727
|
+
: null
|
|
1387
1728
|
)
|
|
1388
1729
|
}
|
|
1389
1730
|
|
|
@@ -1469,14 +1810,8 @@ function SettingsPage(props) {
|
|
|
1469
1810
|
const [smokeResult, setSmokeResult] = React.useState(null)
|
|
1470
1811
|
|
|
1471
1812
|
const [updateState, setUpdateState] = React.useState({
|
|
1472
|
-
checking: false,
|
|
1473
|
-
|
|
1474
|
-
currentVersion: '',
|
|
1475
|
-
latestVersion: '',
|
|
1476
|
-
updateAvailable: false,
|
|
1477
|
-
canAutoUpdate: true,
|
|
1478
|
-
error: '',
|
|
1479
|
-
notice: '',
|
|
1813
|
+
checking: false, updating: false, currentVersion: '', latestVersion: '',
|
|
1814
|
+
updateAvailable: false, canAutoUpdate: true, error: '', notice: '',
|
|
1480
1815
|
})
|
|
1481
1816
|
|
|
1482
1817
|
const [apiKeyInput, setApiKeyInput] = React.useState('')
|
|
@@ -1486,10 +1821,14 @@ function SettingsPage(props) {
|
|
|
1486
1821
|
|
|
1487
1822
|
const load = React.useCallback(async () => {
|
|
1488
1823
|
setErr('')
|
|
1489
|
-
const res = await
|
|
1824
|
+
const [res, statsRes] = await Promise.all([
|
|
1825
|
+
fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
|
|
1826
|
+
fetch(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' }).catch(() => null),
|
|
1827
|
+
])
|
|
1490
1828
|
const data = await res.json().catch(() => ({}))
|
|
1491
1829
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
1492
|
-
|
|
1830
|
+
const statsData = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
|
|
1831
|
+
setStatus({ ...data, statsSummary: statsData || data.statsSummary })
|
|
1493
1832
|
setDraft(data.config || {})
|
|
1494
1833
|
}, [])
|
|
1495
1834
|
|
|
@@ -1718,6 +2057,28 @@ function SettingsPage(props) {
|
|
|
1718
2057
|
})
|
|
1719
2058
|
}
|
|
1720
2059
|
|
|
2060
|
+
async function handleConfigPatch(patch) {
|
|
2061
|
+
await performAction('patch-config', async () => {
|
|
2062
|
+
const res = await fetch(`${ROUTE_PREFIX}/config`, {
|
|
2063
|
+
method: 'POST',
|
|
2064
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2065
|
+
body: JSON.stringify(patch),
|
|
2066
|
+
})
|
|
2067
|
+
const data = await res.json().catch(() => ({}))
|
|
2068
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
2069
|
+
await load()
|
|
2070
|
+
})
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
async function handleResetStats() {
|
|
2074
|
+
await performAction('reset-stats', async () => {
|
|
2075
|
+
const res = await fetch(`${ROUTE_PREFIX}/stats/reset`, { method: 'POST' })
|
|
2076
|
+
const data = await res.json().catch(() => ({}))
|
|
2077
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
2078
|
+
await load()
|
|
2079
|
+
})
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1721
2082
|
const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
|
|
1722
2083
|
|
|
1723
2084
|
React.useEffect(() => {
|
|
@@ -1787,19 +2148,7 @@ function SettingsPage(props) {
|
|
|
1787
2148
|
'div',
|
|
1788
2149
|
{ className: 'cb-page' },
|
|
1789
2150
|
React.createElement('div', { className: 'cb-alert cb-alert-err' }, `${t('settings.loading')}: ${err}`),
|
|
1790
|
-
React.createElement(
|
|
1791
|
-
'button',
|
|
1792
|
-
{
|
|
1793
|
-
type: 'button',
|
|
1794
|
-
className: 'cb-btn',
|
|
1795
|
-
style: { marginTop: '12px', alignSelf: 'flex-start' },
|
|
1796
|
-
onClick: () => {
|
|
1797
|
-
setErr('')
|
|
1798
|
-
load().catch((e) => setErr(String(e.message || e)))
|
|
1799
|
-
},
|
|
1800
|
-
},
|
|
1801
|
-
t('settings.retry')
|
|
1802
|
-
)
|
|
2151
|
+
React.createElement('button', { type: 'button', className: 'cb-btn', style: { marginTop: '12px' }, onClick: () => { setErr(''); load().catch((e) => setErr(String(e.message || e))) } }, t('settings.retry'))
|
|
1803
2152
|
)
|
|
1804
2153
|
}
|
|
1805
2154
|
return React.createElement('div', { className: 'cb-page' }, t('settings.loading'))
|
|
@@ -1898,11 +2247,20 @@ function SettingsPage(props) {
|
|
|
1898
2247
|
planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
|
|
1899
2248
|
planSyncedAt: status?.config?.planSyncedAt || 0,
|
|
1900
2249
|
defaultModelWarning: status?.config?.defaultModelWarning || '',
|
|
2250
|
+
customModels: draft?.customModels || [],
|
|
2251
|
+
modelReasoningDefaults: draft?.modelReasoningDefaults || {},
|
|
2252
|
+
onConfigUpdate: handleConfigPatch,
|
|
2253
|
+
onUpdateReasoningEffort: (modelId, effort) => {
|
|
2254
|
+
const next = { ...(draft?.modelReasoningDefaults || {}) }
|
|
2255
|
+
if (!effort) delete next[modelId]
|
|
2256
|
+
else next[modelId] = effort
|
|
2257
|
+
handleConfigPatch({ modelReasoningDefaults: next })
|
|
2258
|
+
},
|
|
1901
2259
|
t,
|
|
1902
2260
|
}),
|
|
1903
2261
|
|
|
1904
2262
|
// Card 4: Session Metrics & Usage Tracking
|
|
1905
|
-
React.createElement(StatsSection, { status, t }),
|
|
2263
|
+
React.createElement(StatsSection, { status, onResetStats: handleResetStats, busy, t }),
|
|
1906
2264
|
|
|
1907
2265
|
// Card 5: Diagnostics & Sync with DSH
|
|
1908
2266
|
React.createElement(DiagSection, {
|