@goodandready/dsh-clinebot 0.3.0 → 0.3.2
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 +17 -0
- package/docs/design/DESIGN.md +3 -1
- package/lib/client.js +232 -85
- package/lib/index.js +106 -30
- package/lib/models.js +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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.3.2] - 2026-09-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Automatic Plan Models Discovery**: Dynamic subscription plan models are parsed and registered automatically upon plugin startup and key configuration without requiring manual sync clicks.
|
|
12
|
+
- **`disabledModels` Selection Model**: Migrated model activation state to `disabledModels`. Any newly added models in the ClinePass subscription plan are enabled automatically by default, while user exclusions are reliably preserved.
|
|
13
|
+
- **Instant Non-Blocking Settings Status**: Limited background health check and quota ping timeout to 2500 ms in `buildStatus()`, rendering settings immediately and avoiding UI freezes on cold start or network hiccups.
|
|
14
|
+
- **Full DSH English & Russian Localization**: Complete translation dictionary coverage (`en` canonical and `ru`) across headers, badges, quotas, models table, metrics, and diagnostics.
|
|
15
|
+
- **Dynamic New Model Badges**: Newly discovered models received directly from the user's subscription plan now feature an informative `New` badge in the model picker.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **DSH Registration Automation**: Provider registration in `llm-pi-ai` is now fully declarative and synchronized automatically when API credentials or model choices change. Removed redundant manual registration requirement.
|
|
19
|
+
|
|
20
|
+
## [0.3.1] - 2026-09-07
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Style Isolation Attribute**: Added `data-dsh-plugin="dsh-clinebot"` (`style.dataset.dshPlugin`) to dynamically injected CSS tag in `lib/client.js`, protecting styles from cleanup during neighbor plugin HMR and profile updates.
|
|
24
|
+
|
|
8
25
|
## [0.3.0] - 2026-09-05
|
|
9
26
|
|
|
10
27
|
### Added
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -9,7 +9,8 @@ The plugin consists of two runtime boundaries conforming to DSH authoring standa
|
|
|
9
9
|
### 2.1 Host Runtime (`lib/index.js`, `lib/cline-client.js`, `lib/models.js`, `lib/http.js`)
|
|
10
10
|
* **Cordis Service Registration**: Injects `['settings', 'webServer', 'credentials']`.
|
|
11
11
|
* **Credential Isolation**: The plugin NEVER stores plain API keys in its configuration. The setting `apiKeyEnv` holds the credential identifier (default: `CLINEBOT_API_KEY`), resolved via `ctx.credentials.resolve()` or `process.env`.
|
|
12
|
-
* **State Synchronization**: Mutates the core `llm-pi-ai` settings space (`op: 'set', path: ['providers', 'clinebot']`) when enabled or
|
|
12
|
+
* **State Synchronization & Auto-Registration**: Mutates the core `llm-pi-ai` settings space (`op: 'set', path: ['providers', 'clinebot']`) declaratively and automatically when enabled or key is saved.
|
|
13
|
+
* **Auto-Discovery & `disabledModels`**: Features automatic background polling of subscription plan models (`GET /api/v1/users/me/plan`). User preferences are tracked via `disabledModels: []`, ensuring newly added plan models appear enabled by default in the DSH chat picker without manual re-synchronization.
|
|
13
14
|
|
|
14
15
|
### 2.2 Client Runtime (`lib/client.js`)
|
|
15
16
|
* Self-registering module via `window.__ModuleLoader__.load({ id: '@goodandready/dsh-clinebot', factory })`.
|
|
@@ -18,6 +19,7 @@ The plugin consists of two runtime boundaries conforming to DSH authoring standa
|
|
|
18
19
|
* Registers localized `en` and `ru` dictionaries via `ctx.locale.register()`.
|
|
19
20
|
* Reactive binding via `ctx.settingsScope.bind({ namespace: NS })` with `useSyncExternalStore` guarding against `unavailable` / `loading` snapshot states.
|
|
20
21
|
* Uses native design tokens (`--dsw-alias-...`) with full dark/light theme support.
|
|
22
|
+
* Injects isolated style tag tagged with `data-dsh-plugin="dsh-clinebot"`.
|
|
21
23
|
|
|
22
24
|
```mermaid
|
|
23
25
|
graph LR
|
package/lib/client.js
CHANGED
|
@@ -14,14 +14,144 @@ window.__ModuleLoader__.load({
|
|
|
14
14
|
title: 'ClineBot',
|
|
15
15
|
subtitle: 'ClinePass subscription ($9.99/mo) with curated & custom models.',
|
|
16
16
|
'settings.loading': 'Loading ClineBot settings…',
|
|
17
|
+
'settings.retry': 'Retry',
|
|
17
18
|
'settings.unavailable': 'ClineBot settings unavailable (host namespace is not ready).',
|
|
19
|
+
'header.title': 'ClineBot (ClinePass) Provider',
|
|
20
|
+
'header.sub': 'Connect models under ClinePass subscription ($9.99/mo). OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
|
|
21
|
+
'badge.online': 'Host online ({latency} ms)',
|
|
22
|
+
'badge.offline': 'Host unreachable',
|
|
23
|
+
'badge.key_ok': 'Key ✓ ({source})',
|
|
24
|
+
'badge.key_missing': 'Key missing',
|
|
25
|
+
'badge.registered': 'DSH Registered ({count} models)',
|
|
26
|
+
'badge.not_registered': 'Not registered',
|
|
27
|
+
'key.title': '🔑 Authorization & API Key',
|
|
28
|
+
'key.desc': 'The key is saved directly to DSH Credentials storage (~/.dsh/.credentials.yaml) and never exposed in plaintext configuration files.',
|
|
29
|
+
'key.placeholder_has': '••••••••••••••••••••••••',
|
|
30
|
+
'key.placeholder_empty': 'Paste ClinePass key (cline_...)',
|
|
31
|
+
'key.show': 'Show',
|
|
32
|
+
'key.hide': 'Hide',
|
|
33
|
+
'key.save': 'Save Key',
|
|
34
|
+
'key.saving': 'Saving…',
|
|
35
|
+
'key.env_label': 'Credential environment name: ',
|
|
36
|
+
'key.get_key': 'Get key in app.cline.bot console ↗',
|
|
37
|
+
'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
|
|
38
|
+
'key.empty_err': 'Please enter an API key before saving',
|
|
39
|
+
'quota.title': '📊 ClinePass Quota & Rate Limits',
|
|
40
|
+
'quota.desc': 'Official rolling window request limits from ClinePass',
|
|
41
|
+
'quota.account': 'Account: {email} · Plan: {plan}',
|
|
42
|
+
'quota.refresh': 'Refresh Quotas',
|
|
43
|
+
'quota.refreshing': 'Refreshing…',
|
|
44
|
+
'quota.refreshed_msg': 'Subscription quotas refreshed',
|
|
45
|
+
'quota.window_5h': '⏱ 5-Hour Rolling Limit',
|
|
46
|
+
'quota.window_weekly': '📅 Weekly Window',
|
|
47
|
+
'quota.used': '{pct}% used',
|
|
48
|
+
'quota.remaining': 'Remaining: {pct}%',
|
|
49
|
+
'quota.reset_at': 'Reset: {time}',
|
|
50
|
+
'models.title': '🎯 Model Picker in DSH ({enabled} of {total} active)',
|
|
51
|
+
'models.desc': 'Catalog synchronizes directly from ClinePass subscription. New plan models are enabled automatically. Uncheck models to hide them from DSH chat picker.',
|
|
52
|
+
'models.sync': '🔄 Check Plan Models',
|
|
53
|
+
'models.syncing': 'Checking…',
|
|
54
|
+
'models.synced_msg': 'Synchronized subscription models: {total} ({discovered} discovered from ClinePass)',
|
|
55
|
+
'models.all': 'All',
|
|
56
|
+
'models.vision': 'Vision Only',
|
|
57
|
+
'models.coding': 'Coding Only',
|
|
58
|
+
'models.recommended': 'Recommended',
|
|
59
|
+
'models.th_active': 'On',
|
|
60
|
+
'models.th_name': 'Model Name',
|
|
61
|
+
'models.th_id': 'Model ID',
|
|
62
|
+
'models.th_ctx': 'Context',
|
|
63
|
+
'models.th_caps': 'Capabilities',
|
|
64
|
+
'models.star': 'Star',
|
|
65
|
+
'models.auto_new': 'New',
|
|
66
|
+
'stats.title': '📈 Current DSH Session Telemetry',
|
|
67
|
+
'stats.desc': 'Local monitoring of requests, network latency, and estimated token usage via ClinePass.',
|
|
68
|
+
'stats.requests': 'Successful / Total Requests',
|
|
69
|
+
'stats.tokens': 'Estimated Session Tokens',
|
|
70
|
+
'stats.latency': 'Last Request Latency',
|
|
71
|
+
'stats.last_req': 'Last Request Time',
|
|
72
|
+
'diag.title': '⚡ Diagnostics & Auto-Registration',
|
|
73
|
+
'diag.desc': 'ClineBot registers into DSH Models automatically when key is configured. You can run connection smoke test or force re-sync.',
|
|
74
|
+
'diag.smoke_btn': 'Run Smoke Test (Ping)',
|
|
75
|
+
'diag.smoke_testing': 'Testing…',
|
|
76
|
+
'diag.smoke_ok': 'Smoke test passed! Latency: {latency} ms',
|
|
77
|
+
'diag.resync_btn': 'Force Re-sync to DSH',
|
|
78
|
+
'diag.resyncing': 'Syncing…',
|
|
79
|
+
'diag.resynced_msg': 'Provider re-synced to DSH Models ({count} models)',
|
|
80
|
+
'diag.unregister_btn': 'Unregister from DSH',
|
|
81
|
+
'diag.unregistering': 'Removing…',
|
|
82
|
+
'diag.unregistered_msg': 'Provider removed from DSH Models',
|
|
18
83
|
}
|
|
19
84
|
|
|
20
85
|
const ru = {
|
|
21
86
|
title: 'ClineBot',
|
|
22
87
|
subtitle: 'Подписка ClinePass ($9.99/мес) с каталогом проверенных моделей.',
|
|
23
88
|
'settings.loading': 'Загрузка настроек ClineBot…',
|
|
89
|
+
'settings.retry': 'Повторить попытку',
|
|
24
90
|
'settings.unavailable': 'Настройки ClineBot недоступны (пространство хоста ещё не готово).',
|
|
91
|
+
'header.title': 'Провайдер ClineBot (ClinePass)',
|
|
92
|
+
'header.sub': 'Подключение моделей по подписке ClinePass ($9.99/мес). OpenAI API совместимый, автоматическое отслеживание лимитов и моментальная авторегистрация в DSH.',
|
|
93
|
+
'badge.online': 'Сервер доступен ({latency} мс)',
|
|
94
|
+
'badge.offline': 'Недоступен',
|
|
95
|
+
'badge.key_ok': 'Ключ ✓ ({source})',
|
|
96
|
+
'badge.key_missing': 'Ключ отсутствует',
|
|
97
|
+
'badge.registered': 'Зарегистрирован в DSH ({count} мод.)',
|
|
98
|
+
'badge.not_registered': 'Не зарегистрирован',
|
|
99
|
+
'key.title': '🔑 Авторизация и API-ключ',
|
|
100
|
+
'key.desc': 'Ключ сохраняется в системное хранилище DSH Credentials (~/.dsh/.credentials.yaml) и никогда не попадает в открытые файлы настроек.',
|
|
101
|
+
'key.placeholder_has': '••••••••••••••••••••••••',
|
|
102
|
+
'key.placeholder_empty': 'Вставьте ключ ClinePass (cline_...)',
|
|
103
|
+
'key.show': 'Показать',
|
|
104
|
+
'key.hide': 'Скрыть',
|
|
105
|
+
'key.save': 'Сохранить ключ',
|
|
106
|
+
'key.saving': 'Сохранение…',
|
|
107
|
+
'key.env_label': 'Переменная учётных данных: ',
|
|
108
|
+
'key.get_key': 'Получить ключ в консоли app.cline.bot ↗',
|
|
109
|
+
'key.saved_msg': 'Ключ сохранён в DSH Credentials. Валидация: {status}',
|
|
110
|
+
'key.empty_err': 'Введите API-ключ перед сохранением',
|
|
111
|
+
'quota.title': '📊 Остаток лимитов подписки (ClinePass Quota)',
|
|
112
|
+
'quota.desc': 'Официальные лимиты скользящих окон запросов ClinePass',
|
|
113
|
+
'quota.account': 'Аккаунт: {email} · Тариф: {plan}',
|
|
114
|
+
'quota.refresh': 'Обновить квоты',
|
|
115
|
+
'quota.refreshing': 'Обновление…',
|
|
116
|
+
'quota.refreshed_msg': 'Лимиты подписки обновлены',
|
|
117
|
+
'quota.window_5h': '⏱ 5-часовое скользящее окно (Rolling Limit)',
|
|
118
|
+
'quota.window_weekly': '📅 Недельное окно (Weekly Window)',
|
|
119
|
+
'quota.used': '{pct}% использовано',
|
|
120
|
+
'quota.remaining': 'Осталось: {pct}%',
|
|
121
|
+
'quota.reset_at': 'Сброс: {time}',
|
|
122
|
+
'models.title': '🎯 Модели в пикере DSH ({enabled} из {total} включено)',
|
|
123
|
+
'models.desc': 'Список синхронизируется напрямую из подписки ClinePass. Новые модели появляются автоматически. Снимите галочку, чтобы скрыть ненужную модель из чата.',
|
|
124
|
+
'models.sync': '🔄 Проверить модели плана',
|
|
125
|
+
'models.syncing': 'Проверка…',
|
|
126
|
+
'models.synced_msg': 'Синхронизировано моделей подписки: {total} ({discovered} получено напрямую из плана)',
|
|
127
|
+
'models.all': 'Все',
|
|
128
|
+
'models.vision': 'Только Vision',
|
|
129
|
+
'models.coding': 'Только Кодинг',
|
|
130
|
+
'models.recommended': 'Рекомендованные',
|
|
131
|
+
'models.th_active': 'Вкл',
|
|
132
|
+
'models.th_name': 'Название модели',
|
|
133
|
+
'models.th_id': 'Model ID',
|
|
134
|
+
'models.th_ctx': 'Контекст',
|
|
135
|
+
'models.th_caps': 'Возможности',
|
|
136
|
+
'models.star': 'Star',
|
|
137
|
+
'models.auto_new': 'Новая',
|
|
138
|
+
'stats.title': '📈 Статистика текущей сессии DSH',
|
|
139
|
+
'stats.desc': 'Локальный мониторинг запросов, задержек сети и ориентировочного расхода токенов через ClinePass.',
|
|
140
|
+
'stats.requests': 'Успешных запросов / Всего',
|
|
141
|
+
'stats.tokens': 'Оценка токенов сессии',
|
|
142
|
+
'stats.latency': 'Последняя задержка (Latency)',
|
|
143
|
+
'stats.last_req': 'Время последнего запроса',
|
|
144
|
+
'diag.title': '⚡ Диагностика и авторегистрация в DSH',
|
|
145
|
+
'diag.desc': 'ClineBot автоматически регистрируется в DSH Models при наличии ключа. Вы можете запустить проверку связи или принудительно пересинхронизировать модели.',
|
|
146
|
+
'diag.smoke_btn': 'Запустить Smoke Test (Ping)',
|
|
147
|
+
'diag.smoke_testing': 'Тестирование…',
|
|
148
|
+
'diag.smoke_ok': 'Smoke тест пройден! Задержка: {latency} мс',
|
|
149
|
+
'diag.resync_btn': 'Принудительно обновить в DSH',
|
|
150
|
+
'diag.resyncing': 'Обновление…',
|
|
151
|
+
'diag.resynced_msg': 'Провайдер обновлен в DSH Models ({count} моделей)',
|
|
152
|
+
'diag.unregister_btn': 'Удалить из DSH Models',
|
|
153
|
+
'diag.unregistering': 'Удаление…',
|
|
154
|
+
'diag.unregistered_msg': 'Провайдер удален из DSH Models',
|
|
25
155
|
}
|
|
26
156
|
|
|
27
157
|
function makeT(dict, fallback) {
|
|
@@ -56,6 +186,7 @@ window.__ModuleLoader__.load({
|
|
|
56
186
|
if (document.getElementById('dsh-clinebot-full-css')) return
|
|
57
187
|
const style = document.createElement('style')
|
|
58
188
|
style.id = 'dsh-clinebot-full-css'
|
|
189
|
+
style.dataset.dshPlugin = NS
|
|
59
190
|
style.textContent = `
|
|
60
191
|
.cb-page{display:flex;flex-direction:column;gap:20px;padding:8px 0 32px;max-width:960px}
|
|
61
192
|
.cb-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
|
@@ -84,7 +215,7 @@ window.__ModuleLoader__.load({
|
|
|
84
215
|
.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}
|
|
85
216
|
.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}
|
|
86
217
|
.cb-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12) !important;border-color:rgba(239,68,68,0.5)}
|
|
87
|
-
.cb-btn
|
|
218
|
+
.cb-btn-disabled{opacity:0.5;cursor:not-allowed}
|
|
88
219
|
|
|
89
220
|
.cb-bar-container{display:flex;flex-direction:column;gap:6px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
|
|
90
221
|
.cb-bar-head{display:flex;justify-content:space-between;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
|
|
@@ -110,7 +241,7 @@ window.__ModuleLoader__.load({
|
|
|
110
241
|
document.head.appendChild(style)
|
|
111
242
|
}
|
|
112
243
|
|
|
113
|
-
function ProgressBar({ label, percentUsed, remainingPercent, resetsAt }) {
|
|
244
|
+
function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
114
245
|
const used = Math.max(0, Math.min(100, percentUsed || 0))
|
|
115
246
|
let fillColor = 'var(--dsw-alias-state-success-primary)'
|
|
116
247
|
if (used > 75) fillColor = 'var(--dsw-alias-state-warning-primary)'
|
|
@@ -125,7 +256,7 @@ window.__ModuleLoader__.load({
|
|
|
125
256
|
'div',
|
|
126
257
|
{ className: 'cb-bar-head' },
|
|
127
258
|
React.createElement('span', null, label),
|
|
128
|
-
React.createElement('span', { style: { fontWeight: 600 } },
|
|
259
|
+
React.createElement('span', { style: { fontWeight: 600 } }, t('quota.used', { pct: used }))
|
|
129
260
|
),
|
|
130
261
|
React.createElement(
|
|
131
262
|
'div',
|
|
@@ -138,8 +269,8 @@ window.__ModuleLoader__.load({
|
|
|
138
269
|
React.createElement(
|
|
139
270
|
'div',
|
|
140
271
|
{ className: 'cb-bar-meta' },
|
|
141
|
-
React.createElement('span', null,
|
|
142
|
-
React.createElement('span', null,
|
|
272
|
+
React.createElement('span', null, t('quota.remaining', { pct: remainingPercent ?? (100 - used) })),
|
|
273
|
+
React.createElement('span', null, t('quota.reset_at', { time: resetStr }))
|
|
143
274
|
)
|
|
144
275
|
)
|
|
145
276
|
}
|
|
@@ -191,7 +322,7 @@ window.__ModuleLoader__.load({
|
|
|
191
322
|
// Save Key handler
|
|
192
323
|
async function handleSaveKey() {
|
|
193
324
|
if (!apiKeyInput.trim()) {
|
|
194
|
-
setErr('
|
|
325
|
+
setErr(t('key.empty_err'))
|
|
195
326
|
return
|
|
196
327
|
}
|
|
197
328
|
setBusy('save-key')
|
|
@@ -208,7 +339,7 @@ window.__ModuleLoader__.load({
|
|
|
208
339
|
})
|
|
209
340
|
const data = await res.json().catch(() => ({}))
|
|
210
341
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
211
|
-
setMsg(
|
|
342
|
+
setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : 'Error' }))
|
|
212
343
|
setApiKeyInput('')
|
|
213
344
|
await load()
|
|
214
345
|
} catch (e) {
|
|
@@ -228,7 +359,7 @@ window.__ModuleLoader__.load({
|
|
|
228
359
|
const data = await res.json().catch(() => ({}))
|
|
229
360
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
230
361
|
setStatus((prev) => ({ ...prev, usage: data }))
|
|
231
|
-
setMsg('
|
|
362
|
+
setMsg(t('quota.refreshed_msg'))
|
|
232
363
|
} catch (e) {
|
|
233
364
|
setErr(String(e.message || e))
|
|
234
365
|
} finally {
|
|
@@ -245,7 +376,7 @@ window.__ModuleLoader__.load({
|
|
|
245
376
|
const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
|
|
246
377
|
const data = await res.json().catch(() => ({}))
|
|
247
378
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
248
|
-
setMsg(
|
|
379
|
+
setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
|
|
249
380
|
await load()
|
|
250
381
|
} catch (e) {
|
|
251
382
|
setErr(String(e.message || e))
|
|
@@ -254,7 +385,7 @@ window.__ModuleLoader__.load({
|
|
|
254
385
|
}
|
|
255
386
|
}
|
|
256
387
|
|
|
257
|
-
// Register/
|
|
388
|
+
// Register/Force Re-sync Provider in DSH
|
|
258
389
|
async function handleRegister() {
|
|
259
390
|
setBusy('register')
|
|
260
391
|
setErr('')
|
|
@@ -267,7 +398,7 @@ window.__ModuleLoader__.load({
|
|
|
267
398
|
})
|
|
268
399
|
const data = await res.json().catch(() => ({}))
|
|
269
400
|
if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
270
|
-
setMsg(
|
|
401
|
+
setMsg(t('diag.resynced_msg', { count: draft.enabledModels?.length || 0 }))
|
|
271
402
|
await load()
|
|
272
403
|
} catch (e) {
|
|
273
404
|
setErr(String(e.message || e))
|
|
@@ -285,7 +416,7 @@ window.__ModuleLoader__.load({
|
|
|
285
416
|
const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
|
|
286
417
|
const data = await res.json().catch(() => ({}))
|
|
287
418
|
if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
288
|
-
setMsg('
|
|
419
|
+
setMsg(t('diag.unregistered_msg'))
|
|
289
420
|
await load()
|
|
290
421
|
} catch (e) {
|
|
291
422
|
setErr(String(e.message || e))
|
|
@@ -309,7 +440,7 @@ window.__ModuleLoader__.load({
|
|
|
309
440
|
const data = await res.json().catch(() => ({}))
|
|
310
441
|
if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
311
442
|
setSmokeResult(data)
|
|
312
|
-
setMsg(
|
|
443
|
+
setMsg(t('diag.smoke_ok', { latency: data.latencyMs }))
|
|
313
444
|
await load()
|
|
314
445
|
} catch (e) {
|
|
315
446
|
setErr(String(e.message || e))
|
|
@@ -318,26 +449,31 @@ window.__ModuleLoader__.load({
|
|
|
318
449
|
}
|
|
319
450
|
}
|
|
320
451
|
|
|
321
|
-
// Toggle model
|
|
452
|
+
// Toggle model exclusion (disabledModels logic)
|
|
322
453
|
async function handleToggleModel(id) {
|
|
323
454
|
if (!draft) return
|
|
324
|
-
const
|
|
325
|
-
if (
|
|
326
|
-
|
|
455
|
+
const currentDisabled = new Set(draft.disabledModels || [])
|
|
456
|
+
if (currentDisabled.has(id)) {
|
|
457
|
+
currentDisabled.delete(id) // Re-enable
|
|
327
458
|
} else {
|
|
328
|
-
|
|
459
|
+
currentDisabled.add(id) // Disable
|
|
329
460
|
}
|
|
330
|
-
const
|
|
331
|
-
|
|
461
|
+
const nextDisabled = Array.from(currentDisabled)
|
|
462
|
+
|
|
463
|
+
// Compute next enabled models
|
|
464
|
+
const allIds = (status?.availableModels || []).map((m) => m.id)
|
|
465
|
+
const nextEnabled = allIds.filter((mId) => !currentDisabled.has(mId))
|
|
466
|
+
|
|
467
|
+
setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
332
468
|
|
|
333
469
|
if (scope && snapshotStatus === 'ready') {
|
|
334
|
-
try { await scope.set('
|
|
470
|
+
try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
|
|
335
471
|
}
|
|
336
472
|
try {
|
|
337
473
|
await fetch(`${ROUTE_PREFIX}/models/toggle`, {
|
|
338
474
|
method: 'POST',
|
|
339
475
|
headers: { 'Content-Type': 'application/json' },
|
|
340
|
-
body: JSON.stringify({
|
|
476
|
+
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
341
477
|
})
|
|
342
478
|
} catch {}
|
|
343
479
|
}
|
|
@@ -345,26 +481,30 @@ window.__ModuleLoader__.load({
|
|
|
345
481
|
// Select all / filter models
|
|
346
482
|
async function handleSetModelsFilter(type) {
|
|
347
483
|
if (!status?.availableModels) return
|
|
348
|
-
|
|
484
|
+
const all = status.availableModels
|
|
485
|
+
let allowed = new Set()
|
|
349
486
|
if (type === 'all') {
|
|
350
|
-
|
|
487
|
+
allowed = new Set(all.map((m) => m.id))
|
|
351
488
|
} else if (type === 'vision') {
|
|
352
|
-
|
|
489
|
+
allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
|
|
353
490
|
} else if (type === 'coding') {
|
|
354
|
-
|
|
491
|
+
allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
|
|
355
492
|
} else if (type === 'recommended') {
|
|
356
|
-
|
|
493
|
+
allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
|
|
357
494
|
}
|
|
358
495
|
|
|
359
|
-
|
|
496
|
+
const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
|
|
497
|
+
const nextEnabled = Array.from(allowed)
|
|
498
|
+
|
|
499
|
+
setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
360
500
|
if (scope && snapshotStatus === 'ready') {
|
|
361
|
-
try { await scope.set('
|
|
501
|
+
try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
|
|
362
502
|
}
|
|
363
503
|
try {
|
|
364
504
|
await fetch(`${ROUTE_PREFIX}/models/toggle`, {
|
|
365
505
|
method: 'POST',
|
|
366
506
|
headers: { 'Content-Type': 'application/json' },
|
|
367
|
-
body: JSON.stringify({
|
|
507
|
+
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
368
508
|
})
|
|
369
509
|
} catch {}
|
|
370
510
|
}
|
|
@@ -382,30 +522,31 @@ window.__ModuleLoader__.load({
|
|
|
382
522
|
return React.createElement(
|
|
383
523
|
'div',
|
|
384
524
|
{ className: 'cb-page' },
|
|
385
|
-
React.createElement('div', { className: 'cb-alert cb-alert-err' },
|
|
525
|
+
React.createElement('div', { className: 'cb-alert cb-alert-err' }, `${t('settings.loading')}: ${err}`),
|
|
386
526
|
React.createElement(
|
|
387
527
|
'button',
|
|
388
528
|
{
|
|
389
529
|
type: 'button',
|
|
390
|
-
className: 'cb-btn
|
|
530
|
+
className: 'cb-btn',
|
|
391
531
|
style: { marginTop: '12px', alignSelf: 'flex-start' },
|
|
392
532
|
onClick: () => {
|
|
393
533
|
setErr('')
|
|
394
534
|
load().catch((e) => setErr(String(e.message || e)))
|
|
395
535
|
},
|
|
396
536
|
},
|
|
397
|
-
'
|
|
537
|
+
t('settings.retry')
|
|
398
538
|
)
|
|
399
539
|
)
|
|
400
540
|
}
|
|
401
|
-
return React.createElement('div', { className: 'cb-page' }, '
|
|
541
|
+
return React.createElement('div', { className: 'cb-page' }, t('settings.loading'))
|
|
402
542
|
}
|
|
403
543
|
|
|
404
544
|
const healthOk = !!status.health?.ok
|
|
405
545
|
const keyPresent = !!status.key?.present
|
|
406
546
|
const isRegistered = !!status.isRegistered
|
|
407
547
|
const modelsList = status.availableModels || []
|
|
408
|
-
const
|
|
548
|
+
const disabledSet = new Set(draft.disabledModels || [])
|
|
549
|
+
const enabledCount = modelsList.filter((m) => !disabledSet.has(m.id)).length
|
|
409
550
|
const usage = status.usage
|
|
410
551
|
|
|
411
552
|
return React.createElement(
|
|
@@ -419,27 +560,27 @@ window.__ModuleLoader__.load({
|
|
|
419
560
|
React.createElement(
|
|
420
561
|
'div',
|
|
421
562
|
{ className: 'cb-page-title' },
|
|
422
|
-
|
|
563
|
+
`🤖 ${t('header.title')}`,
|
|
423
564
|
React.createElement(
|
|
424
565
|
'span',
|
|
425
566
|
{ className: `cb-badge ${healthOk ? 'cb-badge-ok' : 'cb-badge-bad'}` },
|
|
426
|
-
healthOk ?
|
|
567
|
+
healthOk ? t('badge.online', { latency: status.health?.latencyMs }) : t('badge.offline')
|
|
427
568
|
),
|
|
428
569
|
React.createElement(
|
|
429
570
|
'span',
|
|
430
571
|
{ className: `cb-badge ${keyPresent ? 'cb-badge-ok' : 'cb-badge-warn'}` },
|
|
431
|
-
keyPresent ?
|
|
572
|
+
keyPresent ? t('badge.key_ok', { source: status.key?.source }) : t('badge.key_missing')
|
|
432
573
|
),
|
|
433
574
|
React.createElement(
|
|
434
575
|
'span',
|
|
435
576
|
{ className: `cb-badge ${isRegistered ? 'cb-badge-ok' : 'cb-badge-warn'}` },
|
|
436
|
-
isRegistered ? '
|
|
577
|
+
isRegistered ? t('badge.registered', { count: enabledCount }) : t('badge.not_registered')
|
|
437
578
|
)
|
|
438
579
|
),
|
|
439
580
|
React.createElement(
|
|
440
581
|
'div',
|
|
441
582
|
{ className: 'cb-page-sub' },
|
|
442
|
-
'
|
|
583
|
+
t('header.sub')
|
|
443
584
|
)
|
|
444
585
|
),
|
|
445
586
|
|
|
@@ -454,12 +595,12 @@ window.__ModuleLoader__.load({
|
|
|
454
595
|
React.createElement(
|
|
455
596
|
'div',
|
|
456
597
|
{ className: 'cb-section-title' },
|
|
457
|
-
'
|
|
598
|
+
t('key.title')
|
|
458
599
|
),
|
|
459
600
|
React.createElement(
|
|
460
601
|
'div',
|
|
461
602
|
{ className: 'cb-section-desc' },
|
|
462
|
-
'
|
|
603
|
+
t('key.desc')
|
|
463
604
|
),
|
|
464
605
|
React.createElement(
|
|
465
606
|
'div',
|
|
@@ -467,7 +608,7 @@ window.__ModuleLoader__.load({
|
|
|
467
608
|
React.createElement('input', {
|
|
468
609
|
className: 'cb-input',
|
|
469
610
|
type: showKey ? 'text' : 'password',
|
|
470
|
-
placeholder: keyPresent ? '
|
|
611
|
+
placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
|
|
471
612
|
value: apiKeyInput,
|
|
472
613
|
onChange: (e) => setApiKeyInput(e.target.value),
|
|
473
614
|
}),
|
|
@@ -478,7 +619,7 @@ window.__ModuleLoader__.load({
|
|
|
478
619
|
className: 'cb-btn',
|
|
479
620
|
onClick: () => setShowKey((v) => !v),
|
|
480
621
|
},
|
|
481
|
-
showKey ? '
|
|
622
|
+
showKey ? t('key.hide') : t('key.show')
|
|
482
623
|
),
|
|
483
624
|
React.createElement(
|
|
484
625
|
'button',
|
|
@@ -488,13 +629,13 @@ window.__ModuleLoader__.load({
|
|
|
488
629
|
disabled: !!busy || !apiKeyInput.trim(),
|
|
489
630
|
onClick: handleSaveKey,
|
|
490
631
|
},
|
|
491
|
-
busy === 'save-key' ? '
|
|
632
|
+
busy === 'save-key' ? t('key.saving') : t('key.save')
|
|
492
633
|
)
|
|
493
634
|
),
|
|
494
635
|
React.createElement(
|
|
495
636
|
'div',
|
|
496
637
|
{ className: 'cb-row', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
497
|
-
React.createElement('span', null,
|
|
638
|
+
React.createElement('span', null, t('key.env_label')),
|
|
498
639
|
React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
|
|
499
640
|
React.createElement('span', null, ` · `),
|
|
500
641
|
React.createElement(
|
|
@@ -505,7 +646,7 @@ window.__ModuleLoader__.load({
|
|
|
505
646
|
rel: 'noreferrer',
|
|
506
647
|
style: { color: 'var(--dsw-alias-state-brand-primary)' },
|
|
507
648
|
},
|
|
508
|
-
'
|
|
649
|
+
t('key.get_key')
|
|
509
650
|
)
|
|
510
651
|
)
|
|
511
652
|
),
|
|
@@ -524,7 +665,7 @@ window.__ModuleLoader__.load({
|
|
|
524
665
|
? React.createElement(
|
|
525
666
|
'div',
|
|
526
667
|
{ style: { fontSize: '11px', opacity: 0.9, marginTop: '2px' } },
|
|
527
|
-
|
|
668
|
+
t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
|
|
528
669
|
)
|
|
529
670
|
: null
|
|
530
671
|
)
|
|
@@ -539,7 +680,7 @@ window.__ModuleLoader__.load({
|
|
|
539
680
|
React.createElement(
|
|
540
681
|
'div',
|
|
541
682
|
{ className: 'cb-section-title' },
|
|
542
|
-
'
|
|
683
|
+
t('quota.title'),
|
|
543
684
|
React.createElement(
|
|
544
685
|
'button',
|
|
545
686
|
{
|
|
@@ -548,43 +689,45 @@ window.__ModuleLoader__.load({
|
|
|
548
689
|
disabled: !!busy,
|
|
549
690
|
onClick: handleRefreshQuota,
|
|
550
691
|
},
|
|
551
|
-
busy === 'refresh-quota' ? '
|
|
692
|
+
busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
|
|
552
693
|
)
|
|
553
694
|
),
|
|
554
695
|
React.createElement(
|
|
555
696
|
'div',
|
|
556
697
|
{ className: 'cb-section-desc' },
|
|
557
698
|
usage?.user?.email
|
|
558
|
-
?
|
|
559
|
-
: '
|
|
699
|
+
? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass ($9.99/mo)' })
|
|
700
|
+
: t('quota.desc')
|
|
560
701
|
),
|
|
561
702
|
React.createElement(
|
|
562
703
|
'div',
|
|
563
704
|
{ className: 'cb-grid-2' },
|
|
564
705
|
React.createElement(ProgressBar, {
|
|
565
|
-
label: '
|
|
706
|
+
label: t('quota.window_5h'),
|
|
566
707
|
percentUsed: usage?.windows?.fiveHour?.percentUsed || 0,
|
|
567
708
|
remainingPercent: usage?.windows?.fiveHour?.remainingPercent || 100,
|
|
568
709
|
resetsAt: usage?.windows?.fiveHour?.resetsAt,
|
|
710
|
+
t,
|
|
569
711
|
}),
|
|
570
712
|
React.createElement(ProgressBar, {
|
|
571
|
-
label: '
|
|
713
|
+
label: t('quota.window_weekly'),
|
|
572
714
|
percentUsed: usage?.windows?.weekly?.percentUsed || 0,
|
|
573
715
|
remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
|
|
574
716
|
resetsAt: usage?.windows?.weekly?.resetsAt,
|
|
717
|
+
t,
|
|
575
718
|
})
|
|
576
719
|
)
|
|
577
720
|
)
|
|
578
721
|
: null,
|
|
579
722
|
|
|
580
|
-
// Card 3: Model Picker Management with Official Plan Sync
|
|
723
|
+
// Card 3: Model Picker Management with Official Plan Sync & Auto-Enable
|
|
581
724
|
React.createElement(
|
|
582
725
|
'div',
|
|
583
726
|
{ className: 'cb-section-card' },
|
|
584
727
|
React.createElement(
|
|
585
728
|
'div',
|
|
586
729
|
{ className: 'cb-section-title' },
|
|
587
|
-
|
|
730
|
+
t('models.title', { enabled: enabledCount, total: modelsList.length }),
|
|
588
731
|
React.createElement(
|
|
589
732
|
'div',
|
|
590
733
|
{ className: 'cb-row' },
|
|
@@ -596,34 +739,34 @@ window.__ModuleLoader__.load({
|
|
|
596
739
|
disabled: !!busy || !keyPresent,
|
|
597
740
|
onClick: handleSyncPlanModels,
|
|
598
741
|
},
|
|
599
|
-
busy === 'sync-models' ? '
|
|
742
|
+
busy === 'sync-models' ? t('models.syncing') : t('models.sync')
|
|
600
743
|
),
|
|
601
744
|
React.createElement(
|
|
602
745
|
'button',
|
|
603
746
|
{ type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('all') },
|
|
604
|
-
'
|
|
747
|
+
t('models.all')
|
|
605
748
|
),
|
|
606
749
|
React.createElement(
|
|
607
750
|
'button',
|
|
608
751
|
{ type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('vision') },
|
|
609
|
-
'
|
|
752
|
+
t('models.vision')
|
|
610
753
|
),
|
|
611
754
|
React.createElement(
|
|
612
755
|
'button',
|
|
613
756
|
{ type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('coding') },
|
|
614
|
-
'
|
|
757
|
+
t('models.coding')
|
|
615
758
|
),
|
|
616
759
|
React.createElement(
|
|
617
760
|
'button',
|
|
618
761
|
{ type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('recommended') },
|
|
619
|
-
'
|
|
762
|
+
t('models.recommended')
|
|
620
763
|
)
|
|
621
764
|
)
|
|
622
765
|
),
|
|
623
766
|
React.createElement(
|
|
624
767
|
'div',
|
|
625
768
|
{ className: 'cb-section-desc' },
|
|
626
|
-
'
|
|
769
|
+
t('models.desc')
|
|
627
770
|
),
|
|
628
771
|
React.createElement(
|
|
629
772
|
'table',
|
|
@@ -634,18 +777,19 @@ window.__ModuleLoader__.load({
|
|
|
634
777
|
React.createElement(
|
|
635
778
|
'tr',
|
|
636
779
|
null,
|
|
637
|
-
React.createElement('th', { style: { width: '40px' } }, '
|
|
638
|
-
React.createElement('th', null, '
|
|
639
|
-
React.createElement('th', null, '
|
|
640
|
-
React.createElement('th', null, '
|
|
641
|
-
React.createElement('th', null, '
|
|
780
|
+
React.createElement('th', { style: { width: '40px' } }, t('models.th_active')),
|
|
781
|
+
React.createElement('th', null, t('models.th_name')),
|
|
782
|
+
React.createElement('th', null, t('models.th_id')),
|
|
783
|
+
React.createElement('th', null, t('models.th_ctx')),
|
|
784
|
+
React.createElement('th', null, t('models.th_caps'))
|
|
642
785
|
)
|
|
643
786
|
),
|
|
644
787
|
React.createElement(
|
|
645
788
|
'tbody',
|
|
646
789
|
null,
|
|
647
|
-
modelsList.map((m) =>
|
|
648
|
-
|
|
790
|
+
modelsList.map((m) => {
|
|
791
|
+
const isEnabled = !disabledSet.has(m.id)
|
|
792
|
+
return React.createElement(
|
|
649
793
|
'tr',
|
|
650
794
|
{ key: m.id },
|
|
651
795
|
React.createElement(
|
|
@@ -653,7 +797,7 @@ window.__ModuleLoader__.load({
|
|
|
653
797
|
null,
|
|
654
798
|
React.createElement('input', {
|
|
655
799
|
type: 'checkbox',
|
|
656
|
-
checked:
|
|
800
|
+
checked: isEnabled,
|
|
657
801
|
onChange: () => handleToggleModel(m.id),
|
|
658
802
|
})
|
|
659
803
|
),
|
|
@@ -662,7 +806,10 @@ window.__ModuleLoader__.load({
|
|
|
662
806
|
null,
|
|
663
807
|
React.createElement('strong', null, m.name),
|
|
664
808
|
m.recommended
|
|
665
|
-
? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, '
|
|
809
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('models.star'))
|
|
810
|
+
: null,
|
|
811
|
+
m.isCustom
|
|
812
|
+
? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px', opacity: 0.8 } }, t('models.auto_new'))
|
|
666
813
|
: null
|
|
667
814
|
),
|
|
668
815
|
React.createElement('td', null, React.createElement('code', null, m.id)),
|
|
@@ -676,7 +823,7 @@ window.__ModuleLoader__.load({
|
|
|
676
823
|
: null
|
|
677
824
|
)
|
|
678
825
|
)
|
|
679
|
-
)
|
|
826
|
+
})
|
|
680
827
|
)
|
|
681
828
|
)
|
|
682
829
|
),
|
|
@@ -688,12 +835,12 @@ window.__ModuleLoader__.load({
|
|
|
688
835
|
React.createElement(
|
|
689
836
|
'div',
|
|
690
837
|
{ className: 'cb-section-title' },
|
|
691
|
-
'
|
|
838
|
+
t('stats.title')
|
|
692
839
|
),
|
|
693
840
|
React.createElement(
|
|
694
841
|
'div',
|
|
695
842
|
{ className: 'cb-section-desc' },
|
|
696
|
-
'
|
|
843
|
+
t('stats.desc')
|
|
697
844
|
),
|
|
698
845
|
React.createElement(
|
|
699
846
|
'div',
|
|
@@ -702,25 +849,25 @@ window.__ModuleLoader__.load({
|
|
|
702
849
|
'div',
|
|
703
850
|
{ className: 'cb-stat-box' },
|
|
704
851
|
React.createElement('div', { className: 'cb-stat-val' }, `${status.sessionStats?.successfulRequests || 0} / ${status.sessionStats?.totalRequests || 0}`),
|
|
705
|
-
React.createElement('div', { className: 'cb-stat-lbl' }, '
|
|
852
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
|
|
706
853
|
),
|
|
707
854
|
React.createElement(
|
|
708
855
|
'div',
|
|
709
856
|
{ className: 'cb-stat-box' },
|
|
710
857
|
React.createElement('div', { className: 'cb-stat-val' }, `~${status.sessionStats?.totalTokensEst || 0}`),
|
|
711
|
-
React.createElement('div', { className: 'cb-stat-lbl' }, '
|
|
858
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
|
|
712
859
|
),
|
|
713
860
|
React.createElement(
|
|
714
861
|
'div',
|
|
715
862
|
{ className: 'cb-stat-box' },
|
|
716
|
-
React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs}
|
|
717
|
-
React.createElement('div', { className: 'cb-stat-lbl' },
|
|
863
|
+
React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs} ms` : '—'),
|
|
864
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.latency'))
|
|
718
865
|
),
|
|
719
866
|
React.createElement(
|
|
720
867
|
'div',
|
|
721
868
|
{ className: 'cb-stat-box' },
|
|
722
869
|
React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastRequestAt ? new Date(status.sessionStats.lastRequestAt).toLocaleTimeString() : '—'),
|
|
723
|
-
React.createElement('div', { className: 'cb-stat-lbl' }, '
|
|
870
|
+
React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.last_req'))
|
|
724
871
|
)
|
|
725
872
|
)
|
|
726
873
|
),
|
|
@@ -732,12 +879,12 @@ window.__ModuleLoader__.load({
|
|
|
732
879
|
React.createElement(
|
|
733
880
|
'div',
|
|
734
881
|
{ className: 'cb-section-title' },
|
|
735
|
-
'
|
|
882
|
+
t('diag.title')
|
|
736
883
|
),
|
|
737
884
|
React.createElement(
|
|
738
885
|
'div',
|
|
739
886
|
{ className: 'cb-section-desc' },
|
|
740
|
-
'
|
|
887
|
+
t('diag.desc')
|
|
741
888
|
),
|
|
742
889
|
React.createElement(
|
|
743
890
|
'div',
|
|
@@ -750,7 +897,7 @@ window.__ModuleLoader__.load({
|
|
|
750
897
|
disabled: !!busy || !keyPresent,
|
|
751
898
|
onClick: handleSmoke,
|
|
752
899
|
},
|
|
753
|
-
busy === 'smoke' ? '
|
|
900
|
+
busy === 'smoke' ? t('diag.smoke_testing') : t('diag.smoke_btn')
|
|
754
901
|
),
|
|
755
902
|
isRegistered
|
|
756
903
|
? React.createElement(
|
|
@@ -761,7 +908,7 @@ window.__ModuleLoader__.load({
|
|
|
761
908
|
disabled: !!busy,
|
|
762
909
|
onClick: handleUnregister,
|
|
763
910
|
},
|
|
764
|
-
busy === 'unregister' ? '
|
|
911
|
+
busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
|
|
765
912
|
)
|
|
766
913
|
: null,
|
|
767
914
|
React.createElement(
|
|
@@ -773,14 +920,14 @@ window.__ModuleLoader__.load({
|
|
|
773
920
|
disabled: !!busy,
|
|
774
921
|
onClick: handleRegister,
|
|
775
922
|
},
|
|
776
|
-
busy === 'register' ? '
|
|
923
|
+
busy === 'register' ? t('diag.resyncing') : t('diag.resync_btn')
|
|
777
924
|
)
|
|
778
925
|
),
|
|
779
926
|
smokeResult
|
|
780
927
|
? React.createElement(
|
|
781
928
|
'div',
|
|
782
929
|
{ className: 'cb-preview' },
|
|
783
|
-
`✅
|
|
930
|
+
`✅ Latency: ${smokeResult.latencyMs} ms | Model: ${smokeResult.model}\nResponse: ${smokeResult.preview || '(empty)'}`
|
|
784
931
|
)
|
|
785
932
|
: null
|
|
786
933
|
)
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PROVIDER_DISPLAY_NAME,
|
|
9
9
|
getAllModels,
|
|
10
10
|
getDefaultModelIds,
|
|
11
|
+
getActiveModelIds,
|
|
11
12
|
parsePlanIncludedModels,
|
|
12
13
|
} from './models.js'
|
|
13
14
|
import {
|
|
@@ -44,8 +45,10 @@ export const Config = z.object({
|
|
|
44
45
|
.description('Credential / env name containing the ClinePass API key (never store key directly here).'),
|
|
45
46
|
defaultModel: z.string().default(DEFAULT_MODEL_ID)
|
|
46
47
|
.description('Default model ID for chat and smoke tests.'),
|
|
47
|
-
|
|
48
|
-
.description('List of model IDs
|
|
48
|
+
disabledModels: z.array(z.string()).default([])
|
|
49
|
+
.description('List of model IDs explicitly disabled by the user (new models are enabled automatically).'),
|
|
50
|
+
enabledModels: z.array(z.string()).default([])
|
|
51
|
+
.description('Deprecated: preserved for backwards compatibility with earlier versions.'),
|
|
49
52
|
dynamicModels: z.array(z.object({
|
|
50
53
|
id: z.string(),
|
|
51
54
|
name: z.string(),
|
|
@@ -65,16 +68,26 @@ export const Config = z.object({
|
|
|
65
68
|
|
|
66
69
|
function publicConfig(cfg) {
|
|
67
70
|
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
68
|
-
const
|
|
71
|
+
const allModels = getAllModels(dynamic)
|
|
72
|
+
const allDefaultIds = allModels.map((m) => m.id)
|
|
73
|
+
|
|
74
|
+
// Migration / compatibility: if disabledModels was not yet set, but enabledModels was provided
|
|
75
|
+
let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
|
|
76
|
+
if (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
|
|
77
|
+
const enabledSet = new Set(cfg.enabledModels)
|
|
78
|
+
disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const activeIds = getActiveModelIds(allDefaultIds, disabledList)
|
|
82
|
+
|
|
69
83
|
return {
|
|
70
84
|
enabled: !!cfg?.enabled,
|
|
71
85
|
baseUrl: normalizeBaseUrl(cfg?.baseUrl),
|
|
72
86
|
apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
73
87
|
defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
|
|
74
88
|
dynamicModels: dynamic,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
: allDefaultIds,
|
|
89
|
+
disabledModels: disabledList,
|
|
90
|
+
enabledModels: activeIds,
|
|
78
91
|
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
|
79
92
|
smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
|
|
80
93
|
}
|
|
@@ -115,13 +128,16 @@ async function checkRegisteredInPiAi(ctx) {
|
|
|
115
128
|
async function buildStatus(ctx, cfg) {
|
|
116
129
|
const pub = publicConfig(cfg)
|
|
117
130
|
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
118
|
-
|
|
131
|
+
// Non-blocking quick health probe with low timeout so settings page loads instantly
|
|
132
|
+
const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
|
|
133
|
+
const health = await probeHealth(pub.baseUrl, { timeoutMs: probeTimeout })
|
|
119
134
|
const isRegistered = await checkRegisteredInPiAi(ctx)
|
|
120
135
|
const allModels = getAllModels(pub.dynamicModels)
|
|
121
136
|
|
|
122
137
|
let usage = null
|
|
123
138
|
if (key.value) {
|
|
124
|
-
|
|
139
|
+
// Uses 60s cache; if cache miss, times out quickly
|
|
140
|
+
usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: probeTimeout }).catch(() => null)
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
// Evaluate warning state
|
|
@@ -162,7 +178,7 @@ async function buildStatus(ctx, cfg) {
|
|
|
162
178
|
}
|
|
163
179
|
}
|
|
164
180
|
|
|
165
|
-
async function upsertPiAiProvider(ctx, cfg,
|
|
181
|
+
async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
166
182
|
const settings = ctx?.get?.('settings')
|
|
167
183
|
if (!settings?.mutate) {
|
|
168
184
|
throw new Error('DSH settings service unavailable')
|
|
@@ -170,8 +186,8 @@ async function upsertPiAiProvider(ctx, cfg, dynamicModelIds) {
|
|
|
170
186
|
|
|
171
187
|
const pub = publicConfig(cfg)
|
|
172
188
|
const allModels = getAllModels(pub.dynamicModels)
|
|
173
|
-
const
|
|
174
|
-
const modelsToRegister = allModels.filter((m) =>
|
|
189
|
+
const allowedSet = new Set(activeModelIds || pub.enabledModels)
|
|
190
|
+
const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
|
|
175
191
|
|
|
176
192
|
const providerObj = buildPiAiProvider({
|
|
177
193
|
baseUrl: pub.baseUrl,
|
|
@@ -218,6 +234,56 @@ export function apply(ctx, config) {
|
|
|
218
234
|
let liveCfg = Config(structuredClone(config || {}))
|
|
219
235
|
let settingsApi
|
|
220
236
|
|
|
237
|
+
// Declarative sync helper: auto-registers or unregisters provider based on config & key availability
|
|
238
|
+
const syncProviderState = async (cfg) => {
|
|
239
|
+
try {
|
|
240
|
+
const pub = publicConfig(cfg)
|
|
241
|
+
if (!pub.enabled) {
|
|
242
|
+
if (await checkRegisteredInPiAi(ctx)) {
|
|
243
|
+
await removePiAiProvider(ctx)
|
|
244
|
+
}
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
248
|
+
if (key.value) {
|
|
249
|
+
await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
/* ignore transient settings unavailable */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Background check for subscription plan models (runs once on startup or key save)
|
|
257
|
+
const autoDiscoverPlanModels = async (cfg) => {
|
|
258
|
+
try {
|
|
259
|
+
const pub = publicConfig(cfg)
|
|
260
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
261
|
+
if (!key.value) return
|
|
262
|
+
|
|
263
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
|
|
264
|
+
timeoutMs: Math.min(pub.timeoutMs, 5000),
|
|
265
|
+
bypassCache: true,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
|
|
269
|
+
const existingDynamic = pub.dynamicModels || []
|
|
270
|
+
const existingIds = new Set(existingDynamic.map((m) => m.id))
|
|
271
|
+
const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
|
|
272
|
+
|
|
273
|
+
if (hasNew && settingsApi?.replace) {
|
|
274
|
+
const next = Config({
|
|
275
|
+
...liveCfg,
|
|
276
|
+
dynamicModels: usageData.dynamicModels,
|
|
277
|
+
})
|
|
278
|
+
await settingsApi.replace(next)
|
|
279
|
+
await syncProviderState(next)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
/* best-effort discovery */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
221
287
|
const settingsService = ctx.get('settings')
|
|
222
288
|
if (typeof settingsService?.register === 'function') {
|
|
223
289
|
const scope = settingsService.register(NS, Config, { base: config })
|
|
@@ -225,11 +291,17 @@ export function apply(ctx, config) {
|
|
|
225
291
|
liveCfg = Config(scope.get() ?? config)
|
|
226
292
|
ctx.effect(() => scope.watch((next) => {
|
|
227
293
|
liveCfg = Config(next ?? config)
|
|
294
|
+
syncProviderState(liveCfg)
|
|
228
295
|
}), 'dsh-clinebot: settings')
|
|
229
296
|
}
|
|
230
297
|
|
|
231
298
|
const live = () => liveCfg
|
|
232
299
|
|
|
300
|
+
// On startup: ensure provider is synced to llm-pi-ai if key is present
|
|
301
|
+
syncProviderState(liveCfg)
|
|
302
|
+
// Background discover plan models on startup
|
|
303
|
+
setTimeout(() => autoDiscoverPlanModels(liveCfg), 500)
|
|
304
|
+
|
|
233
305
|
// Web server HTTP route handlers
|
|
234
306
|
if (ctx.webServer?.register) {
|
|
235
307
|
// 1. GET /dsh-clinebot/status
|
|
@@ -274,6 +346,7 @@ export function apply(ctx, config) {
|
|
|
274
346
|
try {
|
|
275
347
|
const parsed = Config({ ...publicConfig(live()), ...payload })
|
|
276
348
|
await settingsApi.replace(parsed)
|
|
349
|
+
await syncProviderState(parsed)
|
|
277
350
|
writeJson(res, 200, { ok: true, config: publicConfig(live()) })
|
|
278
351
|
} catch (e) {
|
|
279
352
|
writeJson(res, 400, { ok: false, error: String(e?.message || e) })
|
|
@@ -304,6 +377,10 @@ export function apply(ctx, config) {
|
|
|
304
377
|
const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
|
|
305
378
|
await saveCredentialKey(ctx, targetEnvName, apiKey)
|
|
306
379
|
|
|
380
|
+
// Auto-sync provider to DSH Models and discover plan models
|
|
381
|
+
await syncProviderState(live())
|
|
382
|
+
autoDiscoverPlanModels(live())
|
|
383
|
+
|
|
307
384
|
// Run validation probe with the newly saved key
|
|
308
385
|
const validation = await smokeChat(pub.baseUrl, apiKey, {
|
|
309
386
|
model: pub.defaultModel,
|
|
@@ -359,7 +436,8 @@ export function apply(ctx, config) {
|
|
|
359
436
|
const bodyBuf = await readBody(req)
|
|
360
437
|
let body = {}
|
|
361
438
|
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
362
|
-
const
|
|
439
|
+
const activeModels = body.models || publicConfig(live()).enabledModels
|
|
440
|
+
const result = await upsertPiAiProvider(ctx, live(), activeModels)
|
|
363
441
|
writeJson(res, 200, { ok: true, provider: result })
|
|
364
442
|
} catch (err) {
|
|
365
443
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
@@ -457,24 +535,14 @@ export function apply(ctx, config) {
|
|
|
457
535
|
|
|
458
536
|
const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
|
|
459
537
|
const allModels = getAllModels(dynamicModels)
|
|
460
|
-
const allIds = allModels.map((m) => m.id)
|
|
461
|
-
|
|
462
|
-
// Preserve currently enabled models, plus add any newly discovered ones
|
|
463
|
-
const existingEnabled = new Set(live().enabledModels || getDefaultModelIds())
|
|
464
|
-
for (const m of allModels) {
|
|
465
|
-
existingEnabled.add(m.id)
|
|
466
|
-
}
|
|
467
538
|
|
|
468
539
|
if (settingsApi?.replace) {
|
|
469
540
|
const next = Config({
|
|
470
541
|
...live(),
|
|
471
542
|
dynamicModels,
|
|
472
|
-
enabledModels: Array.from(existingEnabled),
|
|
473
543
|
})
|
|
474
544
|
await settingsApi.replace(next)
|
|
475
|
-
|
|
476
|
-
await upsertPiAiProvider(ctx, next, Array.from(existingEnabled))
|
|
477
|
-
}
|
|
545
|
+
await syncProviderState(next)
|
|
478
546
|
}
|
|
479
547
|
|
|
480
548
|
return writeJson(res, 200, {
|
|
@@ -490,7 +558,7 @@ export function apply(ctx, config) {
|
|
|
490
558
|
},
|
|
491
559
|
}), 'dsh-clinebot: /models/sync')
|
|
492
560
|
|
|
493
|
-
// 9. POST /dsh-clinebot/models/toggle — toggle enabled status in picker
|
|
561
|
+
// 9. POST /dsh-clinebot/models/toggle — toggle disabled/enabled status in picker
|
|
494
562
|
ctx.effect(() => ctx.webServer.register({
|
|
495
563
|
kind: 'exact',
|
|
496
564
|
path: '/dsh-clinebot/models/toggle',
|
|
@@ -504,16 +572,24 @@ export function apply(ctx, config) {
|
|
|
504
572
|
let body = {}
|
|
505
573
|
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
506
574
|
|
|
507
|
-
if (
|
|
508
|
-
const patch = {
|
|
575
|
+
if (settingsApi?.replace) {
|
|
576
|
+
const patch = {}
|
|
577
|
+
if (Array.isArray(body.disabledModels)) {
|
|
578
|
+
patch.disabledModels = body.disabledModels
|
|
579
|
+
} else if (Array.isArray(body.enabledModels)) {
|
|
580
|
+
// Convert legacy enabledModels toggle to disabledModels
|
|
581
|
+
const allModels = getAllModels(live().dynamicModels)
|
|
582
|
+
const enabledSet = new Set(body.enabledModels)
|
|
583
|
+
patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
|
|
584
|
+
}
|
|
509
585
|
if (body.defaultModel) patch.defaultModel = body.defaultModel
|
|
510
586
|
const next = Config({ ...live(), ...patch })
|
|
511
587
|
await settingsApi.replace(next)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
588
|
+
await syncProviderState(next)
|
|
589
|
+
writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
|
|
590
|
+
} else {
|
|
591
|
+
writeJson(res, 200, { ok: true })
|
|
515
592
|
}
|
|
516
|
-
writeJson(res, 200, { ok: true, enabledModels: body.enabledModels })
|
|
517
593
|
} catch (err) {
|
|
518
594
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
519
595
|
}
|
package/lib/models.js
CHANGED
|
@@ -218,3 +218,14 @@ export function getDefaultModelIds(dynamicModels = []) {
|
|
|
218
218
|
return getAllModels(dynamicModels).map((m) => m.id)
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Filter out models explicitly disabled by user.
|
|
223
|
+
* Any newly introduced model (not in disabledModelIds) is active by default.
|
|
224
|
+
*/
|
|
225
|
+
export function getActiveModelIds(allModels = [], disabledModelIds = []) {
|
|
226
|
+
const disabledSet = new Set(Array.isArray(disabledModelIds) ? disabledModelIds : [])
|
|
227
|
+
return (Array.isArray(allModels) ? allModels : [])
|
|
228
|
+
.map((m) => (typeof m === 'string' ? m : m?.id))
|
|
229
|
+
.filter((id) => Boolean(id && !disabledSet.has(id)))
|
|
230
|
+
}
|
|
231
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|