@goodandready/dsh-clinebot 0.5.16 → 0.5.18
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 +20 -0
- package/lib/client.js +69 -44
- package/lib/index.js +5 -9
- package/lib/provider-sync.js +2 -5
- package/lib/routes/proxy.js +24 -21
- package/lib/slash-command.js +4 -4
- package/lib/stats-storage.js +16 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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.5.18] - 2026-10-06
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Canonical Core CSS Theme Tokens Replacement (Issue #204)**: Replaced 6 non-existent legacy theme tokens across `src/client/` (`state-warning-primary`, `state-brand-primary`, `bg-hover`, `border`, `bg-layer-4`, `status-danger`) with canonical DSH 0.2.0-rc.2 core alias tokens (`state-warn-primary`, `brand-primary`, `interactive-bg-hover`, `border-l2`, `state-error-primary`), restoring color accents to warning badges, progress bars, warning banners, input focus states, button hover states, and critical quota warnings. Added automated test verifying all `--dsw-alias-*` tokens against canonical core allowlist.
|
|
12
|
+
- **Client Fetch Abort Timeout Protection (Issue #205)**: Implemented modular client helper `fetchWithTimeout(url, opts, ms = 30000)` using `AbortController` in `src/client/api.js`. Wrapped all 18 client-side `fetch` calls in `src/client/settings-page.js` to prevent infinite loading/busy button lockups on unresponsive networks or stalled endpoints, unlocking UI states after 30 seconds with informative timeout messaging.
|
|
13
|
+
- **Explicit Warning and Local Fallback for Missing ESLint (Issue #206)**: Updated `scripts/build-client.js` to look for local `node_modules/.bin/eslint` before fallback and emit an explicit stdout warning when eslint is not installed rather than silently suppressing ENOENT. Added `AbortController` to global identifier declarations.
|
|
14
|
+
- **Build Drift Verification via --check Flag (Issue #207)**: Added `--check` mode to `scripts/build-client.js` to verify in memory whether `lib/client.js` is in sync with `src/client/` source modules without rewriting the bundle. Integrated `--check` validation into `test/decomposition.test.js` to prevent uncommitted client bundle drift.
|
|
15
|
+
- **Strict Canonical Row Slot Key Registration (Issue #208)**: Removed redundant loop in `src/client/entry.js` registering inert legacy key `dsh-clinebot#dsh-clinebot`. Retained strictly canonical row seat registration under `ROW_CONFIG_KEY` (`@goodandready/dsh-clinebot#dsh-clinebot`).
|
|
16
|
+
- **Locale Dictionary Registration Error Logging (Issue #209)**: In `src/client/entry.js`, added explicit `console.warn` diagnostics reporting namespace and locale details upon `ctx.locale.register` failure instead of silently swallowing dictionary errors.
|
|
17
|
+
|
|
18
|
+
## [0.5.17] - 2026-10-05
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Cosmokit Volatile Getters Unwrapping in Live Config (Issues #194, #200)**: Resolved issue where `live()` returned raw Cosmokit `Volatile` `{ get }` object boxes for volatile schema fields, causing downstream boolean checks (such as `!pub.enabled` in `syncProviderState`) to always evaluate as truthy objects regardless of actual boolean value. Fully unwrapped `live()` return values via `plainConfig(Config(plainConfig(...)))` and guaranteed robust fallback defaults when `apply()` receives null/undefined config.
|
|
22
|
+
- **Proxy Body Read Timeout & Payload Size Bounds (Issues #198, #202)**: Switched non-streaming upstream body read timeouts from `streamIdleTimeoutMs` (30s) to `bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)`. Added strict guard against unbounded payload responses (`MAX_BODY_BYTES = 10 * 1024 * 1024`) returning 502 Bad Gateway if upstream response exceeds 10MB.
|
|
23
|
+
- **Dead Configuration Keys & Timeout Cleanup (Issue #197)**: Removed non-existent configuration keys (`streamTotalTimeoutMs`, `connectTimeoutMs`, `idleTimeoutMs`) from `lib/routes/proxy.js` and eliminated dead timer logic.
|
|
24
|
+
- **DSH Non-Existent Service API Calls Removal (Issue #199)**: Removed dead `settings?.get` and `settingsApi.watch` calls on DSH services that lack those methods.
|
|
25
|
+
- **Corrupted Stats Backup Pruning (Issue #201)**: Added automatic rotation and pruning for `.corrupt.*` backup files in `lib/stats-storage.js`, retaining at most the 3 latest diagnostic copies to prevent unbounded disk usage.
|
|
26
|
+
- **Version Literals & Slash Command Cleanup (Issue #202)**: Synchronized hardcoded fallback version literals to `'0.5.17'`, cleaned up unused `invocation` argument in `slash-command.js`, and updated fallback port detection (`ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080`).
|
|
27
|
+
|
|
8
28
|
## [0.5.16] - 2026-10-04
|
|
9
29
|
|
|
10
30
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -44,6 +44,7 @@ const en = {
|
|
|
44
44
|
'settings.loading': 'Loading ClineBot settings…',
|
|
45
45
|
'settings.retry': 'Retry',
|
|
46
46
|
'settings.unavailable': 'ClineBot settings unavailable (host namespace is not ready).',
|
|
47
|
+
'api.timeout': 'Request timed out ({seconds}s)',
|
|
47
48
|
'header.title': 'ClineBot (ClinePass) Provider',
|
|
48
49
|
'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
|
|
49
50
|
'badge.online': 'Host online ({latency} ms)',
|
|
@@ -197,6 +198,7 @@ const en = {
|
|
|
197
198
|
'settings.loading': '正在加载 ClineBot 设置…',
|
|
198
199
|
'settings.retry': '重试',
|
|
199
200
|
'settings.unavailable': 'ClineBot 设置不可用(宿主命名空间尚未就绪)。',
|
|
201
|
+
'api.timeout': '请求超时({seconds}秒)',
|
|
200
202
|
'header.title': 'ClineBot (ClinePass) 模型服务商',
|
|
201
203
|
'header.sub': '通过 ClinePass 订阅连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
|
|
202
204
|
'badge.online': '服务在线 ({latency} ms)',
|
|
@@ -406,15 +408,15 @@ function ensureCss() {
|
|
|
406
408
|
|
|
407
409
|
.cb-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
|
|
408
410
|
.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}
|
|
409
|
-
.cb-badge-warn{border-color:var(--dsw-alias-state-
|
|
411
|
+
.cb-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent)}
|
|
410
412
|
.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}
|
|
411
413
|
|
|
412
414
|
.cb-input{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;width:100%;box-sizing:border-box}
|
|
413
|
-
.cb-input:focus{outline:none;border-color:var(--dsw-alias-
|
|
415
|
+
.cb-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
|
414
416
|
.cb-input-group{display:flex;gap:8px;align-items:center}
|
|
415
417
|
|
|
416
418
|
.cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 14px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}
|
|
417
|
-
.cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-
|
|
419
|
+
.cb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
|
|
418
420
|
.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
|
|
419
421
|
.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88;visibility:visible !important}
|
|
420
422
|
.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}
|
|
@@ -426,8 +428,8 @@ function ensureCss() {
|
|
|
426
428
|
.cb-progress-label{font-weight:500;white-space:nowrap}
|
|
427
429
|
.cb-progress-meta, .cb-bar-meta{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;margin-left:auto}
|
|
428
430
|
.cb-progress-track, .cb-bar-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
|
|
429
|
-
.cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-
|
|
430
|
-
.cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-
|
|
431
|
+
.cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-brand-primary, var(--dsw-alias-state-success-primary))}
|
|
432
|
+
.cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warn-primary)}
|
|
431
433
|
.cb-progress-bar-bad{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-error-primary)}
|
|
432
434
|
|
|
433
435
|
.cb-table{width:100%;border-collapse:collapse;margin-top:8px}
|
|
@@ -438,7 +440,7 @@ function ensureCss() {
|
|
|
438
440
|
.cb-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px}
|
|
439
441
|
.cb-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
|
|
440
442
|
.cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
|
|
441
|
-
.cb-banner-warning{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-
|
|
443
|
+
.cb-banner-warning{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
|
|
442
444
|
.cb-banner-exhausted{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:600}
|
|
443
445
|
.cb-stat-box{padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:4px}
|
|
444
446
|
.cb-stat-val{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary)}
|
|
@@ -448,6 +450,29 @@ function ensureCss() {
|
|
|
448
450
|
document.head.appendChild(style)
|
|
449
451
|
}
|
|
450
452
|
|
|
453
|
+
// --- src/client/api.js ---
|
|
454
|
+
async function fetchWithTimeout(url, opts = {}, ms = 30000) {
|
|
455
|
+
const controller = new AbortController()
|
|
456
|
+
const timer = setTimeout(() => {
|
|
457
|
+
controller.abort()
|
|
458
|
+
}, ms)
|
|
459
|
+
const signal = opts.signal || controller.signal
|
|
460
|
+
try {
|
|
461
|
+
const res = await fetch(url, { ...opts, signal })
|
|
462
|
+
return res
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (err && (err.name === 'AbortError' || controller.signal.aborted)) {
|
|
465
|
+
const sec = Math.round(ms / 1000)
|
|
466
|
+
const tFn = typeof opts.t === 'function' ? opts.t : null
|
|
467
|
+
const msg = tFn ? tFn('api.timeout', { seconds: sec }) : ('Request timed out (' + sec + 's)')
|
|
468
|
+
throw new Error(msg)
|
|
469
|
+
}
|
|
470
|
+
throw err
|
|
471
|
+
} finally {
|
|
472
|
+
clearTimeout(timer)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
451
476
|
// --- src/client/error-boundary.js ---
|
|
452
477
|
function createErrorBoundary() {
|
|
453
478
|
try {
|
|
@@ -549,7 +574,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
549
574
|
gap: '8px',
|
|
550
575
|
padding: '12px 14px',
|
|
551
576
|
borderRadius: '8px',
|
|
552
|
-
border: isBad ? '1px solid var(--dsw-alias-
|
|
577
|
+
border: isBad ? '1px solid var(--dsw-alias-state-error-primary)' : '1px solid var(--dsw-alias-border-l2)',
|
|
553
578
|
background: 'var(--dsw-alias-bg-layer-2)',
|
|
554
579
|
},
|
|
555
580
|
},
|
|
@@ -569,7 +594,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
569
594
|
'span',
|
|
570
595
|
{ className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
|
|
571
596
|
label,
|
|
572
|
-
isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-
|
|
597
|
+
isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' } }, '⚠️ <10%') : null
|
|
573
598
|
),
|
|
574
599
|
React.createElement(
|
|
575
600
|
'span',
|
|
@@ -577,7 +602,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
577
602
|
className: 'cb-progress-meta cb-bar-meta',
|
|
578
603
|
style: {
|
|
579
604
|
fontSize: '12px',
|
|
580
|
-
color: isBad ? 'var(--dsw-alias-
|
|
605
|
+
color: isBad ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
581
606
|
whiteSpace: 'nowrap',
|
|
582
607
|
marginLeft: 'auto',
|
|
583
608
|
},
|
|
@@ -764,7 +789,7 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
|
|
|
764
789
|
href: 'https://app.cline.bot/settings/api-keys',
|
|
765
790
|
target: '_blank',
|
|
766
791
|
rel: 'noreferrer',
|
|
767
|
-
style: { color: 'var(--dsw-alias-
|
|
792
|
+
style: { color: 'var(--dsw-alias-brand-primary)' },
|
|
768
793
|
},
|
|
769
794
|
t('key.get_key')
|
|
770
795
|
)
|
|
@@ -850,8 +875,8 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
|
|
|
850
875
|
{
|
|
851
876
|
onSubmit: onSubmitAdd,
|
|
852
877
|
style: {
|
|
853
|
-
background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
|
|
854
|
-
border: '1px solid var(--dsw-alias-border, color-mix(in srgb, currentColor 12%, transparent))',
|
|
878
|
+
background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
|
|
879
|
+
border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
|
|
855
880
|
borderRadius: '8px',
|
|
856
881
|
padding: '12px',
|
|
857
882
|
marginBottom: '14px',
|
|
@@ -1522,7 +1547,7 @@ function ModelsSection({
|
|
|
1522
1547
|
display: 'flex',
|
|
1523
1548
|
flexDirection: 'column',
|
|
1524
1549
|
gap: '6px',
|
|
1525
|
-
background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
|
|
1550
|
+
background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
|
|
1526
1551
|
padding: '6px',
|
|
1527
1552
|
borderRadius: '6px',
|
|
1528
1553
|
border: '1px solid var(--dsw-alias-border-l2)',
|
|
@@ -1985,8 +2010,8 @@ function SettingsPage(props) {
|
|
|
1985
2010
|
const load = React.useCallback(async () => {
|
|
1986
2011
|
setErr('')
|
|
1987
2012
|
const [res, statsRes] = await Promise.all([
|
|
1988
|
-
|
|
1989
|
-
|
|
2013
|
+
fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
|
|
2014
|
+
fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' }).catch(() => null),
|
|
1990
2015
|
])
|
|
1991
2016
|
const data = await res.json().catch(() => ({}))
|
|
1992
2017
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
@@ -2001,7 +2026,7 @@ function SettingsPage(props) {
|
|
|
2001
2026
|
const checkUpdate = React.useCallback(async () => {
|
|
2002
2027
|
setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
|
|
2003
2028
|
try {
|
|
2004
|
-
const res = await
|
|
2029
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/update`)
|
|
2005
2030
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
2006
2031
|
const data = await res.json().catch(() => ({}))
|
|
2007
2032
|
if (data.latestCheckFailed) {
|
|
@@ -2045,7 +2070,7 @@ function SettingsPage(props) {
|
|
|
2045
2070
|
if (updateState.updating) return
|
|
2046
2071
|
setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
|
|
2047
2072
|
try {
|
|
2048
|
-
const res = await
|
|
2073
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/update`, {
|
|
2049
2074
|
method: 'POST',
|
|
2050
2075
|
headers: { 'x-dsh-plugin-update': '1' },
|
|
2051
2076
|
})
|
|
@@ -2089,7 +2114,7 @@ function SettingsPage(props) {
|
|
|
2089
2114
|
const keyVal = String(apiKeyInput || '').trim()
|
|
2090
2115
|
if (!keyVal) { setErr(t('key.empty_err')); return }
|
|
2091
2116
|
await performAction('save-key', async () => {
|
|
2092
|
-
const res = await
|
|
2117
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/save-key`, {
|
|
2093
2118
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2094
2119
|
body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: draft?.apiKeyEnv }),
|
|
2095
2120
|
})
|
|
@@ -2103,7 +2128,7 @@ function SettingsPage(props) {
|
|
|
2103
2128
|
|
|
2104
2129
|
async function handleRefreshQuota() {
|
|
2105
2130
|
await performAction('refresh-quota', async () => {
|
|
2106
|
-
const res = await
|
|
2131
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/usage`)
|
|
2107
2132
|
const data = await res.json().catch(() => ({}))
|
|
2108
2133
|
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
2109
2134
|
setStatus((prev) => (prev ? { ...prev, usage: data } : prev))
|
|
@@ -2113,7 +2138,7 @@ function SettingsPage(props) {
|
|
|
2113
2138
|
|
|
2114
2139
|
async function handleUpdateModelContext(payload) {
|
|
2115
2140
|
await performAction('update-context', async () => {
|
|
2116
|
-
const res = await
|
|
2141
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/context`, {
|
|
2117
2142
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2118
2143
|
body: JSON.stringify(payload),
|
|
2119
2144
|
})
|
|
@@ -2126,7 +2151,7 @@ function SettingsPage(props) {
|
|
|
2126
2151
|
|
|
2127
2152
|
async function handleSyncPlanModels() {
|
|
2128
2153
|
await performAction('sync-models', async () => {
|
|
2129
|
-
const res = await
|
|
2154
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
|
|
2130
2155
|
const data = await res.json().catch(() => ({}))
|
|
2131
2156
|
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
2132
2157
|
setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
|
|
@@ -2136,7 +2161,7 @@ function SettingsPage(props) {
|
|
|
2136
2161
|
|
|
2137
2162
|
async function handleRegister() {
|
|
2138
2163
|
await performAction('register', async () => {
|
|
2139
|
-
const res = await
|
|
2164
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/register`, { method: 'POST' })
|
|
2140
2165
|
const data = await res.json().catch(() => ({}))
|
|
2141
2166
|
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
2142
2167
|
const count = status?.availableModels?.filter((m) => !(draft?.disabledModels || []).includes(m.id)).length || 0
|
|
@@ -2147,7 +2172,7 @@ function SettingsPage(props) {
|
|
|
2147
2172
|
|
|
2148
2173
|
async function handleUnregister() {
|
|
2149
2174
|
await performAction('unregister', async () => {
|
|
2150
|
-
const res = await
|
|
2175
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
|
|
2151
2176
|
const data = await res.json().catch(() => ({}))
|
|
2152
2177
|
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
2153
2178
|
setMsg(t('diag.unregistered_msg'))
|
|
@@ -2158,7 +2183,7 @@ function SettingsPage(props) {
|
|
|
2158
2183
|
async function handleSmoke() {
|
|
2159
2184
|
setSmokeResult(null)
|
|
2160
2185
|
await performAction('smoke', async () => {
|
|
2161
|
-
const res = await
|
|
2186
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/smoke`, {
|
|
2162
2187
|
method: 'POST',
|
|
2163
2188
|
headers: { 'Content-Type': 'application/json' },
|
|
2164
2189
|
body: JSON.stringify({ model: draft?.defaultModel }),
|
|
@@ -2182,7 +2207,7 @@ function SettingsPage(props) {
|
|
|
2182
2207
|
verifyTimerRef.current = setTimeout(async () => {
|
|
2183
2208
|
setVerifyStatus({ state: 'verifying', email: '', plan: '', error: '' })
|
|
2184
2209
|
try {
|
|
2185
|
-
const res = await
|
|
2210
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/key/verify`, {
|
|
2186
2211
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2187
2212
|
body: JSON.stringify({ key: raw }),
|
|
2188
2213
|
})
|
|
@@ -2207,7 +2232,7 @@ function SettingsPage(props) {
|
|
|
2207
2232
|
|
|
2208
2233
|
async function handlePinAccount(accountEnv) {
|
|
2209
2234
|
await performAction('pin-account', async () => {
|
|
2210
|
-
const res = await
|
|
2235
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
|
|
2211
2236
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2212
2237
|
body: JSON.stringify({ account: accountEnv }),
|
|
2213
2238
|
})
|
|
@@ -2219,7 +2244,7 @@ function SettingsPage(props) {
|
|
|
2219
2244
|
|
|
2220
2245
|
async function handleAddAccount({ label, apiKeyEnv, apiKey }) {
|
|
2221
2246
|
await performAction('add-account', async () => {
|
|
2222
|
-
const res = await
|
|
2247
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts`, {
|
|
2223
2248
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2224
2249
|
body: JSON.stringify({ label, apiKeyEnv, apiKey }),
|
|
2225
2250
|
})
|
|
@@ -2232,7 +2257,7 @@ function SettingsPage(props) {
|
|
|
2232
2257
|
|
|
2233
2258
|
async function handleDeleteAccount(accountEnv, deleteSecret = true) {
|
|
2234
2259
|
await performAction('delete-account', async () => {
|
|
2235
|
-
const res = await
|
|
2260
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
|
|
2236
2261
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2237
2262
|
body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
|
|
2238
2263
|
})
|
|
@@ -2245,7 +2270,7 @@ function SettingsPage(props) {
|
|
|
2245
2270
|
async function handleConfigPatch(patch) {
|
|
2246
2271
|
setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
|
|
2247
2272
|
await performAction('patch-config', async () => {
|
|
2248
|
-
const res = await
|
|
2273
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/config`, {
|
|
2249
2274
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
2250
2275
|
body: JSON.stringify({ config: patch }),
|
|
2251
2276
|
})
|
|
@@ -2257,7 +2282,7 @@ function SettingsPage(props) {
|
|
|
2257
2282
|
|
|
2258
2283
|
async function handleResetStats() {
|
|
2259
2284
|
await performAction('reset-stats', async () => {
|
|
2260
|
-
const res = await
|
|
2285
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats/reset`, { method: 'POST' })
|
|
2261
2286
|
const data = await res.json().catch(() => ({}))
|
|
2262
2287
|
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
2263
2288
|
await load()
|
|
@@ -2272,7 +2297,7 @@ function SettingsPage(props) {
|
|
|
2272
2297
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
2273
2298
|
debounceTimerRef.current = setTimeout(async () => {
|
|
2274
2299
|
try {
|
|
2275
|
-
const res = await
|
|
2300
|
+
const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
|
|
2276
2301
|
method: 'POST',
|
|
2277
2302
|
headers: { 'Content-Type': 'application/json' },
|
|
2278
2303
|
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
@@ -2527,7 +2552,8 @@ function apply(ctx) {
|
|
|
2527
2552
|
const addLocale = (locale, dictionary) => {
|
|
2528
2553
|
try {
|
|
2529
2554
|
return ctx.locale.register(NS, locale, dictionary)
|
|
2530
|
-
} catch (
|
|
2555
|
+
} catch (err) {
|
|
2556
|
+
console.warn(`[dsh-clinebot] Failed to register locale dictionary for ns "${NS}", locale "${locale}":`, err)
|
|
2531
2557
|
return () => {}
|
|
2532
2558
|
}
|
|
2533
2559
|
}
|
|
@@ -2597,19 +2623,18 @@ function apply(ctx) {
|
|
|
2597
2623
|
// The core keys the seat as `<bundle name>#<row id>`; `bundle.name` may be the
|
|
2598
2624
|
// package name or the short bundle name depending on the manager's view, so both
|
|
2599
2625
|
// spellings are registered — the unused one is inert.
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
)
|
|
2626
|
+
// Row seat: key = ROW_CONFIG_KEY ('@goodandready/dsh-clinebot#dsh-clinebot').
|
|
2627
|
+
registerSlotWhenReady('plugins.row.config', () =>
|
|
2628
|
+
ctx.slots.register(
|
|
2629
|
+
{
|
|
2630
|
+
name: 'plugins.row.config',
|
|
2631
|
+
key: ROW_CONFIG_KEY,
|
|
2632
|
+
locale: NS,
|
|
2633
|
+
inject: () => ({ ctx }),
|
|
2634
|
+
},
|
|
2635
|
+
(props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
|
|
2611
2636
|
)
|
|
2612
|
-
|
|
2637
|
+
)
|
|
2613
2638
|
|
|
2614
2639
|
registerSlotWhenReady('settings.plugin.item', () =>
|
|
2615
2640
|
ctx.slots.register(
|
package/lib/index.js
CHANGED
|
@@ -37,7 +37,7 @@ export function configReader(config) {
|
|
|
37
37
|
export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
|
|
38
38
|
|
|
39
39
|
export function apply(ctx, config) {
|
|
40
|
-
let currentConfig = config
|
|
40
|
+
let currentConfig = config || {}
|
|
41
41
|
const overrides = {}
|
|
42
42
|
const getConfig = () => {
|
|
43
43
|
if (!currentConfig) return currentConfig
|
|
@@ -73,7 +73,7 @@ export function apply(ctx, config) {
|
|
|
73
73
|
}
|
|
74
74
|
return currentConfig
|
|
75
75
|
}
|
|
76
|
-
const live = () => (
|
|
76
|
+
const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
|
|
77
77
|
let settingsApi
|
|
78
78
|
|
|
79
79
|
const syncProviderState = async (cfg) => {
|
|
@@ -219,7 +219,7 @@ export function apply(ctx, config) {
|
|
|
219
219
|
applyConfigMutations(currentConfig, parsed)
|
|
220
220
|
syncStatsStorage(live())
|
|
221
221
|
await syncProviderState(live())
|
|
222
|
-
return parsed
|
|
222
|
+
return plainConfig(parsed)
|
|
223
223
|
},
|
|
224
224
|
update: async (patch) => {
|
|
225
225
|
const svc = ctx?.get?.('settings')
|
|
@@ -248,13 +248,9 @@ export function apply(ctx, config) {
|
|
|
248
248
|
applyConfigMutations(currentConfig, merged)
|
|
249
249
|
syncStatsStorage(live())
|
|
250
250
|
await syncProviderState(live())
|
|
251
|
-
return merged
|
|
252
|
-
},
|
|
253
|
-
watch: (cb) => {
|
|
254
|
-
const svc = ctx?.get?.('settings')
|
|
255
|
-
if (typeof svc?.watch === 'function') return svc.watch(cb)
|
|
256
|
-
return () => {}
|
|
251
|
+
return plainConfig(merged)
|
|
257
252
|
},
|
|
253
|
+
|
|
258
254
|
}
|
|
259
255
|
|
|
260
256
|
if (typeof ctx.effect === 'function') {
|
package/lib/provider-sync.js
CHANGED
|
@@ -56,10 +56,7 @@ export async function checkRegisteredInPiAi(ctx) {
|
|
|
56
56
|
const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
|
|
57
57
|
if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
const piAi = settings.get(LLM_PI_AI_NS)
|
|
61
|
-
if (piAi?.providers?.[PROVIDER_ID]) return true
|
|
62
|
-
}
|
|
59
|
+
|
|
63
60
|
const llm = ctx?.get?.('llm')
|
|
64
61
|
if (typeof llm?.listProviders === 'function') {
|
|
65
62
|
const list = await llm.listProviders()
|
|
@@ -215,7 +212,7 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
|
215
212
|
|
|
216
213
|
if (isProxy) {
|
|
217
214
|
if (!proxyPort) {
|
|
218
|
-
proxyPort = ctx?.webServer?.port || process.env.PORT || 3080
|
|
215
|
+
proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
|
|
219
216
|
const isFenced = await detectHostWebServerFence(proxyPort)
|
|
220
217
|
if (isFenced) {
|
|
221
218
|
ctx?.logger?.warn?.(
|
package/lib/routes/proxy.js
CHANGED
|
@@ -24,9 +24,9 @@ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
|
|
|
24
24
|
import { getLocalProxyToken } from '../proxy-token.js'
|
|
25
25
|
|
|
26
26
|
const require = createRequire(import.meta.url)
|
|
27
|
-
let packageVersion = '0.5.
|
|
27
|
+
let packageVersion = '0.5.18'
|
|
28
28
|
try {
|
|
29
|
-
packageVersion = require('../../package.json').version || '0.5.
|
|
29
|
+
packageVersion = require('../../package.json').version || '0.5.18'
|
|
30
30
|
} catch (err) {
|
|
31
31
|
/* fallback to default version if manifest is unreadable */
|
|
32
32
|
}
|
|
@@ -161,8 +161,8 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
|
|
|
161
161
|
'User-Agent': USER_AGENT
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
const connectTimeoutMs = Math.max(50, Number(pub.
|
|
165
|
-
const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs
|
|
164
|
+
const connectTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
|
|
165
|
+
const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs) || 30000)
|
|
166
166
|
|
|
167
167
|
const connectAbortCtrl = new AbortController()
|
|
168
168
|
const bodyAbortCtrl = new AbortController()
|
|
@@ -283,16 +283,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
|
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
|
|
288
|
-
const totalMs = Number(pub.streamTotalTimeoutMs)
|
|
289
|
-
totalTimer = setTimeout(() => {
|
|
290
|
-
streamError = new Error(`Stream total timeout after ${totalMs}ms`)
|
|
291
|
-
try {
|
|
292
|
-
reader.cancel('stream total timeout').catch(() => {})
|
|
293
|
-
} catch { /* reader already cancelled or closed */ }
|
|
294
|
-
}, totalMs)
|
|
295
|
-
}
|
|
286
|
+
|
|
296
287
|
|
|
297
288
|
try {
|
|
298
289
|
resetIdleTimer()
|
|
@@ -341,7 +332,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
|
|
|
341
332
|
ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
|
|
342
333
|
} finally {
|
|
343
334
|
clearIdleTimer()
|
|
344
|
-
|
|
335
|
+
|
|
345
336
|
clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
|
|
346
337
|
cleanupClientListeners()
|
|
347
338
|
try {
|
|
@@ -379,17 +370,26 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
|
|
|
379
370
|
return
|
|
380
371
|
}
|
|
381
372
|
|
|
382
|
-
// Non-streaming JSON response with deadline and fail-closed error handling (#185)
|
|
383
|
-
const
|
|
373
|
+
// Non-streaming JSON response with deadline and fail-closed error handling (#185, #198, #202)
|
|
374
|
+
const bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
|
|
384
375
|
let bodyTimer = null
|
|
385
376
|
const bodyTimeoutPromise = new Promise((_, reject) => {
|
|
386
377
|
bodyTimer = setTimeout(() => {
|
|
387
378
|
bodyTimedOut = true
|
|
388
|
-
bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${
|
|
389
|
-
reject(new Error(`Upstream body read timed out after ${
|
|
390
|
-
},
|
|
379
|
+
bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
|
|
380
|
+
reject(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
|
|
381
|
+
}, bodyTimeoutMs)
|
|
391
382
|
})
|
|
392
383
|
|
|
384
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
385
|
+
const contentLength = Number(upstreamRes.headers.get('content-length'))
|
|
386
|
+
if (contentLength > MAX_BODY_BYTES) {
|
|
387
|
+
clearTimeout(bodyTimer)
|
|
388
|
+
cleanupClientListeners()
|
|
389
|
+
writeJson(res, 502, { ok: false, error: 'Upstream response exceeded maximum allowed size' })
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
393
|
let resText = ''
|
|
394
394
|
let readError = null
|
|
395
395
|
try {
|
|
@@ -397,6 +397,9 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
|
|
|
397
397
|
upstreamRes.text(),
|
|
398
398
|
bodyTimeoutPromise,
|
|
399
399
|
])
|
|
400
|
+
if (resText.length > MAX_BODY_BYTES) {
|
|
401
|
+
throw new Error('Upstream response exceeded maximum allowed size')
|
|
402
|
+
}
|
|
400
403
|
} catch (err) {
|
|
401
404
|
readError = err
|
|
402
405
|
} finally {
|
|
@@ -419,7 +422,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
|
|
|
419
422
|
})
|
|
420
423
|
const statusCode = isTimeout ? 504 : 502
|
|
421
424
|
const errMessage = isTimeout
|
|
422
|
-
? `Gateway Timeout: upstream body read timed out after ${
|
|
425
|
+
? `Gateway Timeout: upstream body read timed out after ${bodyTimeoutMs}ms`
|
|
423
426
|
: `Bad Gateway: upstream response body read error: ${readError?.message || readError}`
|
|
424
427
|
return writeJson(res, statusCode, {
|
|
425
428
|
error: {
|
package/lib/slash-command.js
CHANGED
|
@@ -26,7 +26,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
26
26
|
const commands = cmdCtx.commands
|
|
27
27
|
if (typeof commands?.register !== 'function') return
|
|
28
28
|
|
|
29
|
-
const executeCommand = async (rawArgs
|
|
29
|
+
const executeCommand = async (rawArgs) => {
|
|
30
30
|
try {
|
|
31
31
|
const pub = publicConfig(live())
|
|
32
32
|
const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
|
|
@@ -102,12 +102,12 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
102
102
|
if (settingsApi?.replace) {
|
|
103
103
|
const curr = plainConfig(live())
|
|
104
104
|
const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
|
|
105
|
-
const next = Config({
|
|
105
|
+
const next = plainConfig(Config({
|
|
106
106
|
...curr,
|
|
107
107
|
activeAccount: param,
|
|
108
108
|
dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
|
|
109
109
|
planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
|
|
110
|
-
})
|
|
110
|
+
}))
|
|
111
111
|
await settingsApi.replace(next)
|
|
112
112
|
await syncProviderState(next)
|
|
113
113
|
return `Active account switched to ${param}`
|
|
@@ -276,7 +276,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
276
276
|
? invocation
|
|
277
277
|
: (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
|
|
278
278
|
try {
|
|
279
|
-
const output = await executeCommand(rawArgs
|
|
279
|
+
const output = await executeCommand(rawArgs)
|
|
280
280
|
return {
|
|
281
281
|
kind: 'success',
|
|
282
282
|
text: output,
|
package/lib/stats-storage.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
@@ -104,11 +104,25 @@ export function loadStats(customPath) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
} catch (err) {
|
|
107
|
-
// If reading fails or file is corrupted, preserve corrupted file for diagnostics
|
|
107
|
+
// If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
|
|
108
108
|
try {
|
|
109
109
|
if (existsSync(filePath)) {
|
|
110
110
|
const corruptBackup = `${filePath}.corrupt.${Date.now()}`
|
|
111
111
|
copyFileSync(filePath, corruptBackup)
|
|
112
|
+
const dir = path.dirname(filePath)
|
|
113
|
+
const base = path.basename(filePath)
|
|
114
|
+
const prefix = `${base}.corrupt.`
|
|
115
|
+
const existing = readdirSync(dir)
|
|
116
|
+
.filter((name) => name.startsWith(prefix))
|
|
117
|
+
.sort()
|
|
118
|
+
while (existing.length > 3) {
|
|
119
|
+
const oldest = existing.shift()
|
|
120
|
+
try {
|
|
121
|
+
unlinkSync(path.join(dir, oldest))
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore deletion failure */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
112
126
|
}
|
|
113
127
|
} catch {
|
|
114
128
|
/* ignore backup failure */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.18",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|