dsh-cline-pass 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.en.md +124 -0
- package/README.md +124 -0
- package/cordis.patch.yml +27 -0
- package/lib/adapter.js +620 -0
- package/lib/catalog.js +193 -0
- package/lib/client.js +945 -0
- package/lib/cline.js +194 -0
- package/lib/engine.js +405 -0
- package/lib/index.js +409 -0
- package/lib/panel.js +464 -0
- package/lib/protocol.js +226 -0
- package/lib/store.js +92 -0
- package/lib/tools.js +756 -0
- package/package.json +72 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cline-pass — browser half.
|
|
3
|
+
*
|
|
4
|
+
* A hand-written dsh client bundle: the module system loads it with
|
|
5
|
+
* `window.__ModuleLoader__.load`, and the Cordis Loader treats the returned
|
|
6
|
+
* exports as an ordinary plugin (`apply` + `inject`). Plain CJS with no build
|
|
7
|
+
* step, so `require` reaches only the shell's platform seed table — `react`.
|
|
8
|
+
*
|
|
9
|
+
* It adds a Cline Pass page in Settings (the same card on the Plugins page) and
|
|
10
|
+
* a compact key card on the Models page. Every read and write goes to the
|
|
11
|
+
* authenticated `/api/cline-pass` route published by `lib/panel.js`, so the
|
|
12
|
+
* panel and the `cline_pass_*` tools share one source of truth.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-cline-pass/client
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: 'dsh-cline-pass',
|
|
19
|
+
factory: (require) => {
|
|
20
|
+
var module = { exports: {} }
|
|
21
|
+
var exports = module.exports
|
|
22
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
23
|
+
|
|
24
|
+
const React = require('react')
|
|
25
|
+
const h = React.createElement
|
|
26
|
+
|
|
27
|
+
/** Locale namespace this card owns. */
|
|
28
|
+
const NS = 'cline-pass'
|
|
29
|
+
/** Exact `/api` Fetch route the host half publishes. */
|
|
30
|
+
const PANEL_PATH = '/api/cline-pass'
|
|
31
|
+
/** Human label for the upstream verdict vocabulary. */
|
|
32
|
+
const VERDICT = { ok: 'available', limited: 'rate-limited', bad: 'not-pinnable', auth: 'auth-failed', unknown: 'unknown' }
|
|
33
|
+
|
|
34
|
+
//#region styles
|
|
35
|
+
|
|
36
|
+
const CSS = [
|
|
37
|
+
'.cp-panel{max-width:820px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:14px;font-size:13px}',
|
|
38
|
+
'.cp-title{margin:0;font-size:18px;font-weight:600}',
|
|
39
|
+
'.cp-card{border:0.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}',
|
|
40
|
+
'.cp-card-title{font-weight:600;font-size:13px;display:flex;align-items:center;gap:8px;justify-content:space-between}',
|
|
41
|
+
'.cp-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
42
|
+
'.cp-grow{flex:1;min-width:120px}',
|
|
43
|
+
'.cp-muted{color:var(--dsw-alias-label-tertiary)}',
|
|
44
|
+
'.cp-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}',
|
|
45
|
+
'.cp-input,.cp-select{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:0.5px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px;font-size:13px;min-width:0}',
|
|
46
|
+
'.cp-input:focus-visible,.cp-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
47
|
+
'.cp-btn{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer}',
|
|
48
|
+
'.cp-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary)}',
|
|
49
|
+
'.cp-btn:disabled{opacity:.5;cursor:default}',
|
|
50
|
+
'.cp-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base);font-weight:600}',
|
|
51
|
+
'.cp-btn-danger{color:var(--dsw-alias-state-error-primary)}',
|
|
52
|
+
'.cp-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none}',
|
|
53
|
+
'.cp-table{display:flex;flex-direction:column;gap:6px}',
|
|
54
|
+
'.cp-account,.cp-model{border:0.5px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:8px}',
|
|
55
|
+
'.cp-chips{display:flex;flex-wrap:wrap;gap:6px}',
|
|
56
|
+
'.cp-chip{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:2px 8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}',
|
|
57
|
+
'.cp-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
58
|
+
'.cp-chip-excluded{text-decoration:line-through;color:var(--dsw-alias-label-tertiary)}',
|
|
59
|
+
'.cp-msg{border-radius:6px;padding:6px 8px;font-size:12px;line-height:17px;white-space:pre-wrap;word-break:break-word}',
|
|
60
|
+
'.cp-msg-ok{background:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-bg-base)}',
|
|
61
|
+
'.cp-msg-err{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-base)}',
|
|
62
|
+
'.cp-msg-warn{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base)}',
|
|
63
|
+
'.cp-history{display:flex;flex-direction:column;gap:4px;max-height:240px;overflow:auto}',
|
|
64
|
+
'.cp-history-row{display:flex;gap:8px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;border-bottom:0.5px solid var(--dsw-alias-border-l1);padding-bottom:3px}',
|
|
65
|
+
'.cp-trace{font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary);display:flex;flex-direction:column;gap:2px}',
|
|
66
|
+
'.cp-inline{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
67
|
+
].join('')
|
|
68
|
+
|
|
69
|
+
const CSS_TAG = '@deepseek-ai/dsh-cline-pass/client.css'
|
|
70
|
+
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_TAG) + ']') === null) {
|
|
71
|
+
const tag = document.createElement('style')
|
|
72
|
+
tag.dataset.plugin = 'dsh-cline-pass'
|
|
73
|
+
tag.dataset.pluginCss = CSS_TAG
|
|
74
|
+
tag.textContent = CSS
|
|
75
|
+
document.head.appendChild(tag)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
|
|
80
|
+
//#region copy
|
|
81
|
+
|
|
82
|
+
const ZH = {
|
|
83
|
+
title: 'Cline Pass',
|
|
84
|
+
keyOk: 'API Key 已配置',
|
|
85
|
+
keyMissing: '尚未配置 API Key',
|
|
86
|
+
keyValue: 'API Key',
|
|
87
|
+
keyPlaceholder: 'sk_…',
|
|
88
|
+
save: '保存',
|
|
89
|
+
saveTest: '保存并测试',
|
|
90
|
+
test: '测试',
|
|
91
|
+
remove: '删除',
|
|
92
|
+
accounts: '账号',
|
|
93
|
+
accountMode: '账号模式',
|
|
94
|
+
modeSingle: '单账号',
|
|
95
|
+
modeRoundrobin: '轮询',
|
|
96
|
+
active: '当前账号',
|
|
97
|
+
addAccount: '添加',
|
|
98
|
+
accountName: '账号名',
|
|
99
|
+
models: '订阅模型',
|
|
100
|
+
modelsCount: '{n} 个模型',
|
|
101
|
+
noChannels: '未探测渠道',
|
|
102
|
+
pinned: '已钉住',
|
|
103
|
+
auto: '自动路由',
|
|
104
|
+
autoSetup: '一键配置',
|
|
105
|
+
autoSetupHint: '探测 → 校验全部渠道 → 钉住可用渠道 → 真实请求验证',
|
|
106
|
+
probe: '探测',
|
|
107
|
+
validate: '校验',
|
|
108
|
+
testPin: '测试',
|
|
109
|
+
reset: '恢复自动',
|
|
110
|
+
refresh: '刷新',
|
|
111
|
+
channels: '渠道(点选=钉住顺序,⊘=排除)',
|
|
112
|
+
history: '最近请求',
|
|
113
|
+
noHistory: '暂无记录。',
|
|
114
|
+
busy: '处理中…',
|
|
115
|
+
notReady: '先配置 API Key。',
|
|
116
|
+
settingsUnavailable: '设置服务不可用,改动无法保存。',
|
|
117
|
+
panelUnavailable: '浏览器通道不可用。',
|
|
118
|
+
retry: '重试',
|
|
119
|
+
autoDone: '已钉住 {n} 个渠道。',
|
|
120
|
+
autoKept: '无可用渠道,保持自动路由。',
|
|
121
|
+
}
|
|
122
|
+
const EN = {
|
|
123
|
+
title: 'Cline Pass',
|
|
124
|
+
keyOk: 'API key configured',
|
|
125
|
+
keyMissing: 'No API key',
|
|
126
|
+
keyValue: 'API key',
|
|
127
|
+
keyPlaceholder: 'sk_…',
|
|
128
|
+
save: 'Save',
|
|
129
|
+
saveTest: 'Save and test',
|
|
130
|
+
test: 'Test',
|
|
131
|
+
remove: 'Remove',
|
|
132
|
+
accounts: 'Accounts',
|
|
133
|
+
accountMode: 'Mode',
|
|
134
|
+
modeSingle: 'Single',
|
|
135
|
+
modeRoundrobin: 'Round robin',
|
|
136
|
+
active: 'Active',
|
|
137
|
+
addAccount: 'Add',
|
|
138
|
+
accountName: 'Name',
|
|
139
|
+
models: 'Models',
|
|
140
|
+
modelsCount: '{n} model(s)',
|
|
141
|
+
noChannels: 'No channels probed',
|
|
142
|
+
pinned: 'Pinned',
|
|
143
|
+
auto: 'Auto',
|
|
144
|
+
autoSetup: 'Auto-configure',
|
|
145
|
+
autoSetupHint: 'probe → validate every channel → pin the healthy ones → verify with a real request',
|
|
146
|
+
probe: 'Probe',
|
|
147
|
+
validate: 'Validate',
|
|
148
|
+
testPin: 'Test',
|
|
149
|
+
reset: 'Back to auto',
|
|
150
|
+
refresh: 'Refresh',
|
|
151
|
+
channels: 'Channels (click = pin order, ⊘ = exclude)',
|
|
152
|
+
history: 'Recent requests',
|
|
153
|
+
noHistory: 'Nothing recorded.',
|
|
154
|
+
busy: 'Working…',
|
|
155
|
+
notReady: 'Configure the API key first.',
|
|
156
|
+
settingsUnavailable: 'Settings are read-only; changes will not persist.',
|
|
157
|
+
panelUnavailable: 'The browser channel is unavailable.',
|
|
158
|
+
retry: 'Retry',
|
|
159
|
+
autoDone: 'Pinned {n} channel(s).',
|
|
160
|
+
autoKept: 'No usable channel; kept automatic routing.',
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//#endregion
|
|
164
|
+
|
|
165
|
+
//#region store
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A minimal uSES-safe external store.
|
|
169
|
+
*
|
|
170
|
+
* `getSnapshot` keeps returning the same reference until `set` replaces it,
|
|
171
|
+
* which is what the slot hook contract requires.
|
|
172
|
+
*/
|
|
173
|
+
function createStore(initial) {
|
|
174
|
+
let value = initial
|
|
175
|
+
const listeners = new Set()
|
|
176
|
+
return {
|
|
177
|
+
getSnapshot: () => value,
|
|
178
|
+
subscribe: (listener) => {
|
|
179
|
+
listeners.add(listener)
|
|
180
|
+
return () => { listeners.delete(listener) }
|
|
181
|
+
},
|
|
182
|
+
set: (next) => {
|
|
183
|
+
value = next
|
|
184
|
+
for (const listener of [...listeners]) listener()
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
|
|
191
|
+
//#region helpers
|
|
192
|
+
|
|
193
|
+
/** Format a byte-free duration for display. */
|
|
194
|
+
function ms(value) {
|
|
195
|
+
const n = Number(value ?? 0)
|
|
196
|
+
if (!Number.isFinite(n) || n <= 0) return '-'
|
|
197
|
+
return n >= 1000 ? (n / 1000).toFixed(1) + 's' : Math.round(n) + 'ms'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Format a timestamp as local time, or '-' when absent. */
|
|
201
|
+
function time(value) {
|
|
202
|
+
const n = Number(value ?? 0)
|
|
203
|
+
if (!Number.isFinite(n) || n <= 0) return '-'
|
|
204
|
+
try { return new Date(n).toLocaleTimeString() } catch { return '-' }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Colour a channel verdict. */
|
|
208
|
+
function verdictColor(status) {
|
|
209
|
+
if (status === 'ok') return 'var(--dsw-alias-state-success-primary)'
|
|
210
|
+
if (status === 'limited') return 'var(--dsw-alias-state-warn-primary)'
|
|
211
|
+
if (status === 'unknown') return 'var(--dsw-alias-label-tertiary)'
|
|
212
|
+
return 'var(--dsw-alias-state-error-primary)'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Interpolate `{n}`-style placeholders. */
|
|
216
|
+
function fill(template, params) {
|
|
217
|
+
return String(template).replace(/\{(\w+)\}/g, (whole, key) => (params?.[key] === undefined ? whole : String(params[key])))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
|
|
222
|
+
//#region controller
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The panel's state and actions.
|
|
226
|
+
*
|
|
227
|
+
* One instance is shared by every registration this bundle makes, so the
|
|
228
|
+
* Settings page, the Plugins card and the Models card all agree.
|
|
229
|
+
*/
|
|
230
|
+
class PanelController {
|
|
231
|
+
constructor(ctx) {
|
|
232
|
+
this.ctx = ctx
|
|
233
|
+
this.store = createStore({ status: 'loading', error: null, data: null, busy: null, notice: null, action: null })
|
|
234
|
+
this.disposed = false
|
|
235
|
+
this.localeDispose = undefined
|
|
236
|
+
this.unsubscribe = undefined
|
|
237
|
+
this.t = this.makeTranslate()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Bind this panel's copy to the active locale.
|
|
242
|
+
*
|
|
243
|
+
* The dictionaries register as soon as the locale service exists, but the
|
|
244
|
+
* ACTIVE locale is read on every call rather than captured here: this
|
|
245
|
+
* plugin does not declare `locale` as a dependency, so that service can
|
|
246
|
+
* legitimately activate after this one, and a value captured at
|
|
247
|
+
* construction would freeze the panel on its fallback language.
|
|
248
|
+
*/
|
|
249
|
+
makeTranslate() {
|
|
250
|
+
const locale = this.ctx.get('locale')
|
|
251
|
+
if (locale !== undefined) {
|
|
252
|
+
try { locale.register(NS, 'zh', ZH) } catch { /* a duplicate registration is harmless */ }
|
|
253
|
+
try { locale.register(NS, 'en', EN) } catch { /* see above */ }
|
|
254
|
+
this.localeDispose = locale.subscribe(() => this.publish())
|
|
255
|
+
}
|
|
256
|
+
return (key, params) => {
|
|
257
|
+
let active
|
|
258
|
+
try { active = this.ctx.get('locale')?.getLocale?.()?.active } catch { /* keep the default */ }
|
|
259
|
+
const dict = active === 'en' ? EN : ZH
|
|
260
|
+
return fill(dict[key] ?? EN[key] ?? key, params)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Stop observing. */
|
|
265
|
+
dispose() {
|
|
266
|
+
this.disposed = true
|
|
267
|
+
this.unsubscribe?.()
|
|
268
|
+
this.localeDispose?.()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The face injected into a slot registration. */
|
|
272
|
+
inject() {
|
|
273
|
+
const controller = this
|
|
274
|
+
return {
|
|
275
|
+
hooks: { clinePass: controller.store },
|
|
276
|
+
refresh: () => controller.refresh(),
|
|
277
|
+
setKey: (ref, value) => controller.setKey(ref, value),
|
|
278
|
+
testKey: (ref, value) => controller.testKey(ref, value),
|
|
279
|
+
saveAndTest: (ref, value) => controller.saveAndTest(ref, value),
|
|
280
|
+
addAccount: (payload) => controller.addAccount(payload),
|
|
281
|
+
removeAccount: (name) => controller.removeAccount(name),
|
|
282
|
+
setAccountMode: (patch) => controller.setAccountMode(patch),
|
|
283
|
+
pinModel: (payload) => controller.pinModel(payload),
|
|
284
|
+
setupModel: (model) => controller.setupModel(model),
|
|
285
|
+
probeModel: (model) => controller.probeModel(model),
|
|
286
|
+
validateModel: (model) => controller.validateModel(model),
|
|
287
|
+
testModel: (model) => controller.testModel(model),
|
|
288
|
+
resetModel: (model) => controller.resetModel(model),
|
|
289
|
+
refreshModels: () => controller.refreshModels(),
|
|
290
|
+
loadHistory: (limit) => controller.loadHistory(limit),
|
|
291
|
+
clearNotice: () => controller.clearNotice(),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Publish the current snapshot. */
|
|
296
|
+
publish(patch = {}) {
|
|
297
|
+
if (this.disposed) return
|
|
298
|
+
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One panel round trip; rejects with the host's own diagnostic.
|
|
303
|
+
*
|
|
304
|
+
* A plain same-origin `fetch` to the exact route Connection already
|
|
305
|
+
* guards: the session cookie rides along automatically, and the host
|
|
306
|
+
* answers every action as `{ ok, value? , error? }` so one code path
|
|
307
|
+
* covers success and refusal.
|
|
308
|
+
*/
|
|
309
|
+
async call(endpoint, payload) {
|
|
310
|
+
if (this.ctx.get('connection') === undefined) throw new Error(this.t('panelUnavailable'))
|
|
311
|
+
let response
|
|
312
|
+
try {
|
|
313
|
+
response = await fetch(PANEL_PATH, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: { 'content-type': 'application/json' },
|
|
316
|
+
body: JSON.stringify({ endpoint, payload: payload ?? {} }),
|
|
317
|
+
})
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw new Error(`${this.t('panelUnavailable')} (${String(error?.message ?? error)})`)
|
|
320
|
+
}
|
|
321
|
+
if (!response.ok) throw new Error(`the Cline Pass panel route answered HTTP ${response.status}`)
|
|
322
|
+
const result = await response.json().catch(() => null)
|
|
323
|
+
if (result?.ok === true) return result.value
|
|
324
|
+
throw new Error(result?.error?.message ?? 'the Cline Pass panel refused this request')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Run one action with busy/notice bookkeeping. */
|
|
328
|
+
async run(label, action) {
|
|
329
|
+
this.publish({ busy: label, notice: null })
|
|
330
|
+
try {
|
|
331
|
+
const value = await action()
|
|
332
|
+
this.publish({ busy: null, error: null, action: value ?? this.store.getSnapshot().action })
|
|
333
|
+
return value
|
|
334
|
+
} catch (error) {
|
|
335
|
+
this.publish({ busy: null, error: String(error?.message ?? error), action: null })
|
|
336
|
+
return undefined
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Merge a fresh host state into the snapshot. */
|
|
341
|
+
applyState(value) {
|
|
342
|
+
const data = value?.accounts === undefined && value?.provider === undefined ? this.store.getSnapshot().data : value
|
|
343
|
+
if (data !== undefined && data !== null && data.provider !== undefined) this.publish({ data, status: 'ready', error: null })
|
|
344
|
+
return value
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Re-read the whole panel state. */
|
|
348
|
+
async refresh() {
|
|
349
|
+
try {
|
|
350
|
+
const value = await this.call('state', {})
|
|
351
|
+
this.applyState(value)
|
|
352
|
+
} catch (error) {
|
|
353
|
+
this.publish({ status: 'error', error: String(error?.message ?? error) })
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Clear the transient notice. */
|
|
358
|
+
clearNotice() {
|
|
359
|
+
this.publish({ notice: null, error: null, action: null })
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Store the key. The status line and masked hint are the confirmation. */
|
|
363
|
+
async setKey(ref, value) {
|
|
364
|
+
const result = await this.run('key', async () => await this.call('key.set', { ref, value }))
|
|
365
|
+
if (result !== undefined) this.applyState(result)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Test a key without storing it. */
|
|
369
|
+
async testKey(ref, value) {
|
|
370
|
+
return await this.run('key', async () => {
|
|
371
|
+
const result = await this.call('key.test', { ref, value })
|
|
372
|
+
this.publish({
|
|
373
|
+
notice: result.ok === true
|
|
374
|
+
? { kind: 'ok', text: `${result.label} · ok · ${ms(result.ms)}` }
|
|
375
|
+
: { kind: 'err', text: `${result.label} · ${result.error || 'failed'}` },
|
|
376
|
+
})
|
|
377
|
+
return result
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Save a key and test it in one gesture. */
|
|
382
|
+
async saveAndTest(ref, value) {
|
|
383
|
+
const probe = await this.testKey(ref, value)
|
|
384
|
+
if (probe?.ok === true) await this.setKey(ref, value)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Add or update an account. */
|
|
388
|
+
async addAccount(payload) {
|
|
389
|
+
const result = await this.run('account', async () => await this.call('account.add', payload))
|
|
390
|
+
if (result !== undefined) {
|
|
391
|
+
this.applyState(result)
|
|
392
|
+
this.publish({ notice: { kind: 'ok', text: `${result.name} · ${result.apiKeyEnv}${result.keyStored ? ' · key stored' : ''}` } })
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Remove an account. */
|
|
397
|
+
async removeAccount(name) {
|
|
398
|
+
const result = await this.run('account', async () => await this.call('account.remove', { name }))
|
|
399
|
+
if (result !== undefined) this.applyState(result)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Switch account mode or the active account. */
|
|
403
|
+
async setAccountMode(patch) {
|
|
404
|
+
const result = await this.run('account', async () => await this.call('account.mode', patch))
|
|
405
|
+
if (result !== undefined) this.applyState(result)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Persist one model's pin. */
|
|
409
|
+
async pinModel(payload) {
|
|
410
|
+
const result = await this.run('pin', async () => await this.call('model.pin', payload))
|
|
411
|
+
if (result !== undefined) this.applyState(result)
|
|
412
|
+
return result
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Probe one model and surface the discovered channels. */
|
|
416
|
+
async probeModel(model) {
|
|
417
|
+
const result = await this.run('probe', async () => await this.call('model.probe', { model }))
|
|
418
|
+
await this.refresh()
|
|
419
|
+
if (result !== undefined) {
|
|
420
|
+
this.publish({
|
|
421
|
+
action: { kind: 'probe', model, data: result.result },
|
|
422
|
+
notice: result.result?.ok === true
|
|
423
|
+
? { kind: 'ok', text: `${model} · ${result.result.pipeline || 'pipeline?'} · ${(result.result.upstreams ?? []).length} channel(s)` }
|
|
424
|
+
: { kind: 'err', text: `${model} · ${result.result?.error || 'probe failed'}` },
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
return result
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Validate every known channel of one model. */
|
|
431
|
+
async validateModel(model) {
|
|
432
|
+
const result = await this.run('validate', async () => await this.call('model.validate', { model }))
|
|
433
|
+
if (result !== undefined) {
|
|
434
|
+
this.applyState(result)
|
|
435
|
+
const summary = result.summary ?? {}
|
|
436
|
+
this.publish({
|
|
437
|
+
action: { kind: 'validate', model, data: result },
|
|
438
|
+
notice: {
|
|
439
|
+
kind: (summary.ok ?? 0) > 0 ? 'ok' : 'warn',
|
|
440
|
+
text: `${model} · available=${summary.ok ?? 0} rate-limited=${summary.limited ?? 0} not-pinnable=${summary.bad ?? 0} auth-failed=${summary.auth ?? 0}`,
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
return result
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Try the stored (or a supplied) pin once, without persisting it. */
|
|
448
|
+
async testModel(model, upstreams) {
|
|
449
|
+
const result = await this.run('test', async () => await this.call('model.test', { model, upstreams }))
|
|
450
|
+
await this.refresh()
|
|
451
|
+
if (result !== undefined) {
|
|
452
|
+
this.publish({
|
|
453
|
+
action: { kind: 'test', model, data: result },
|
|
454
|
+
notice: result.ok === true
|
|
455
|
+
? { kind: 'ok', text: `${model} · served by ${result.actual || '(auto)'} · ${ms(result.ms)}` }
|
|
456
|
+
: { kind: 'err', text: `${model} · ${result.error || 'failed'}` },
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
return result
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The one-click path for one model. */
|
|
463
|
+
async setupModel(model) {
|
|
464
|
+
this.publish({ busy: 'setup:' + model, notice: null, error: null })
|
|
465
|
+
try {
|
|
466
|
+
const result = await this.call('setup.auto', { model })
|
|
467
|
+
const pinned = (result?.pinned ?? []).length
|
|
468
|
+
this.publish({
|
|
469
|
+
busy: null,
|
|
470
|
+
action: { kind: 'setup', model, data: result },
|
|
471
|
+
notice: result?.ok === true
|
|
472
|
+
? { kind: 'ok', text: fill(this.t('autoDone'), { n: pinned }) + (result.actual ? ` (${result.actual})` : '') }
|
|
473
|
+
: { kind: 'warn', text: `${model} · ${result?.error || this.t('autoKept')}` },
|
|
474
|
+
})
|
|
475
|
+
return result
|
|
476
|
+
} catch (error) {
|
|
477
|
+
this.publish({ busy: null, error: String(error?.message ?? error) })
|
|
478
|
+
return undefined
|
|
479
|
+
} finally {
|
|
480
|
+
await this.refresh()
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Return one model to automatic routing. */
|
|
485
|
+
async resetModel(model) {
|
|
486
|
+
const result = await this.pinModel({ model, upstreams: [], exclude: [], sort: 'none' })
|
|
487
|
+
if (result !== undefined) this.publish({ notice: { kind: 'ok', text: `${model} · ${this.t('auto')}` } })
|
|
488
|
+
return result
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Rescan the official subscription list. */
|
|
492
|
+
async refreshModels() {
|
|
493
|
+
const result = await this.run('models', async () => await this.call('models.refresh', {}))
|
|
494
|
+
if (result !== undefined) {
|
|
495
|
+
this.applyState(result)
|
|
496
|
+
this.publish({ notice: { kind: 'ok', text: `+${(result.added ?? []).length} · ${(result.sources ?? []).join(', ')}` } })
|
|
497
|
+
}
|
|
498
|
+
return result
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Load the in-process request history. */
|
|
502
|
+
async loadHistory(limit) {
|
|
503
|
+
return await this.run('history', async () => {
|
|
504
|
+
const result = await this.call('history', { limit: limit ?? 25 })
|
|
505
|
+
this.publish({ action: { kind: 'history', data: result } })
|
|
506
|
+
return result
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
//#endregion
|
|
512
|
+
|
|
513
|
+
//#region components
|
|
514
|
+
|
|
515
|
+
/** One inline notice line. */
|
|
516
|
+
function Notice(props) {
|
|
517
|
+
if (props.notice === undefined || props.notice === null) return null
|
|
518
|
+
const kind = props.notice.kind === 'ok' ? 'cp-msg-ok' : props.notice.kind === 'warn' ? 'cp-msg-warn' : 'cp-msg-err'
|
|
519
|
+
return h('div', { className: `cp-msg ${kind}`, role: 'status' }, props.notice.text)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** A dependency-free coloured dot. */
|
|
523
|
+
function Dot(props) {
|
|
524
|
+
return h('span', { className: 'cp-dot', style: { background: props.color }, title: props.title })
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The API-key field, shared by the panel and the Models card. */
|
|
528
|
+
function KeyField(props) {
|
|
529
|
+
const { t, account, disabled, onSave, onTest, onSaveTest, busy } = props
|
|
530
|
+
const [value, setValue] = React.useState('')
|
|
531
|
+
const entered = value.trim().length > 0
|
|
532
|
+
return h('div', { className: 'cp-card' }, [
|
|
533
|
+
h('div', { key: 'row', className: 'cp-row' }, [
|
|
534
|
+
h('span', { key: 'l', className: 'cp-muted' }, t('keyValue')),
|
|
535
|
+
h('input', {
|
|
536
|
+
key: 'input',
|
|
537
|
+
className: 'cp-input cp-grow',
|
|
538
|
+
type: 'password',
|
|
539
|
+
autoComplete: 'off',
|
|
540
|
+
spellCheck: false,
|
|
541
|
+
value,
|
|
542
|
+
placeholder: t('keyPlaceholder'),
|
|
543
|
+
'aria-label': t('keyValue'),
|
|
544
|
+
disabled,
|
|
545
|
+
onChange: (event) => setValue(event.target.value),
|
|
546
|
+
}),
|
|
547
|
+
h('button', {
|
|
548
|
+
key: 'save', type: 'button', className: 'cp-btn cp-btn-primary',
|
|
549
|
+
disabled: disabled || !entered,
|
|
550
|
+
onClick: () => { onSaveTest(value.trim()); setValue('') },
|
|
551
|
+
}, t('saveTest')),
|
|
552
|
+
h('button', {
|
|
553
|
+
key: 'test', type: 'button', className: 'cp-btn',
|
|
554
|
+
disabled: disabled || !entered,
|
|
555
|
+
onClick: () => onTest(value.trim()),
|
|
556
|
+
}, t('test')),
|
|
557
|
+
h('button', {
|
|
558
|
+
key: 'saveOnly', type: 'button', className: 'cp-btn',
|
|
559
|
+
disabled: disabled || !entered,
|
|
560
|
+
onClick: () => { onSave(value.trim()); setValue('') },
|
|
561
|
+
}, t('save')),
|
|
562
|
+
busy ? h('span', { key: 'b', className: 'cp-muted' }, t('busy')) : null,
|
|
563
|
+
]),
|
|
564
|
+
])
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** The channel chooser for one model. */
|
|
568
|
+
function ChannelPicker(props) {
|
|
569
|
+
const { t, model, disabled, onPin } = props
|
|
570
|
+
if (model.upstreams.length === 0) return h('div', { className: 'cp-muted' }, t('noChannels'))
|
|
571
|
+
const verdicts = new Map((model.upstreamStatus ?? []).map((entry) => [entry.upstream, entry]))
|
|
572
|
+
return h('div', { className: 'cp-row' }, [
|
|
573
|
+
h('span', { key: 'label', className: 'cp-muted' }, t('channels')),
|
|
574
|
+
h('span', { key: 'chips', className: 'cp-chips' }, model.upstreams.map((upstream) => {
|
|
575
|
+
const pinned = model.pinned.includes(upstream)
|
|
576
|
+
const excluded = model.excluded.includes(upstream)
|
|
577
|
+
const verdict = verdicts.get(upstream)
|
|
578
|
+
const detail = verdict === undefined
|
|
579
|
+
? ''
|
|
580
|
+
: ` · ${VERDICT[verdict.status] ?? verdict.status}${verdict.note ? `\n${verdict.note}` : ''}`
|
|
581
|
+
return h('span', { key: upstream, className: `cp-chip${pinned ? ' cp-chip-on' : ''}${excluded ? ' cp-chip-excluded' : ''}` }, [
|
|
582
|
+
h('span', {
|
|
583
|
+
key: 'pin',
|
|
584
|
+
role: 'button',
|
|
585
|
+
tabIndex: 0,
|
|
586
|
+
title: `${upstream}${detail}`,
|
|
587
|
+
onClick: () => {
|
|
588
|
+
if (disabled) return
|
|
589
|
+
const next = pinned ? model.pinned.filter((name) => name !== upstream) : [...model.pinned, upstream]
|
|
590
|
+
onPin({ model: model.id, upstreams: next })
|
|
591
|
+
},
|
|
592
|
+
}, [
|
|
593
|
+
h('span', { key: 'dot', style: { marginRight: 5 } }, h(Dot, { color: verdictColor(verdict?.status ?? 'unknown'), title: VERDICT[verdict?.status ?? 'unknown'] })),
|
|
594
|
+
upstream,
|
|
595
|
+
]),
|
|
596
|
+
h('span', {
|
|
597
|
+
key: 'ex',
|
|
598
|
+
role: 'button',
|
|
599
|
+
tabIndex: 0,
|
|
600
|
+
title: excluded ? 'un-exclude' : 'exclude',
|
|
601
|
+
onClick: () => {
|
|
602
|
+
if (disabled) return
|
|
603
|
+
const next = excluded ? model.excluded.filter((name) => name !== upstream) : [...model.excluded, upstream]
|
|
604
|
+
onPin({ model: model.id, exclude: next })
|
|
605
|
+
},
|
|
606
|
+
}, '⊘'),
|
|
607
|
+
])
|
|
608
|
+
})),
|
|
609
|
+
])
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* One model row.
|
|
614
|
+
*
|
|
615
|
+
* The default view is deliberately just identity plus the one action that
|
|
616
|
+
* matters, so a page of models stays readable; the finer pin actions and the
|
|
617
|
+
* channel list live behind the expander.
|
|
618
|
+
*/
|
|
619
|
+
function ModelRow(props) {
|
|
620
|
+
const { t, model, disabled, busy, actions } = props
|
|
621
|
+
const [open, setOpen] = React.useState(false)
|
|
622
|
+
const working = busy === 'setup:' + model.id
|
|
623
|
+
const pinnedLabel = model.pinned.length === 0 ? t('auto') : model.pinned.join(' › ')
|
|
624
|
+
const action = (key, label, run) => h('button', {
|
|
625
|
+
key, type: 'button', className: 'cp-btn', disabled, onClick: () => run(model.id),
|
|
626
|
+
}, label)
|
|
627
|
+
return h('div', { className: 'cp-model' }, [
|
|
628
|
+
h('div', { key: 'head', className: 'cp-row' }, [
|
|
629
|
+
h('span', { key: 'name', className: 'cp-mono cp-grow' }, model.id),
|
|
630
|
+
h('span', { key: 'pin', className: 'cp-muted cp-mono' }, `${t('pinned')}: ${pinnedLabel}`),
|
|
631
|
+
h('button', {
|
|
632
|
+
key: 'auto', type: 'button', className: 'cp-btn cp-btn-primary',
|
|
633
|
+
disabled, title: t('autoSetupHint'), onClick: () => actions.setupModel(model.id),
|
|
634
|
+
}, working ? t('busy') : t('autoSetup')),
|
|
635
|
+
h('button', {
|
|
636
|
+
key: 'more', type: 'button', className: 'cp-btn', title: t('channels'),
|
|
637
|
+
onClick: () => setOpen(!open),
|
|
638
|
+
}, open ? '▾' : '▸'),
|
|
639
|
+
]),
|
|
640
|
+
open ? h('div', { key: 'detail', className: 'cp-panel' }, [
|
|
641
|
+
h('div', { key: 'actions', className: 'cp-row' }, [
|
|
642
|
+
action('probe', t('probe'), actions.probeModel),
|
|
643
|
+
action('validate', t('validate'), actions.validateModel),
|
|
644
|
+
action('test', t('testPin'), actions.testModel),
|
|
645
|
+
action('reset', t('reset'), actions.resetModel),
|
|
646
|
+
]),
|
|
647
|
+
model.upstreams.length > 0
|
|
648
|
+
? h(ChannelPicker, { key: 'channels', t, model, disabled, onPin: (payload) => actions.pinModel(payload) })
|
|
649
|
+
: h('div', { key: 'none', className: 'cp-muted' }, t('noChannels')),
|
|
650
|
+
]) : null,
|
|
651
|
+
])
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The accounts card. */
|
|
655
|
+
function AccountsCard(props) {
|
|
656
|
+
const { t, data, disabled, actions } = props
|
|
657
|
+
const [name, setName] = React.useState('')
|
|
658
|
+
const [key, setKey] = React.useState('')
|
|
659
|
+
const primary = data.accounts[0]
|
|
660
|
+
return h('div', { className: 'cp-card' }, [
|
|
661
|
+
h('div', { key: 'title', className: 'cp-card-title' }, [h('span', { key: 'l' }, t('accounts'))]),
|
|
662
|
+
primary !== undefined
|
|
663
|
+
? h(KeyField, {
|
|
664
|
+
key: 'key',
|
|
665
|
+
t,
|
|
666
|
+
account: primary,
|
|
667
|
+
disabled,
|
|
668
|
+
busy: false,
|
|
669
|
+
onSave: (value) => actions.setKey(primary.apiKeyEnv, value),
|
|
670
|
+
onTest: (value) => actions.testKey(primary.apiKeyEnv, value),
|
|
671
|
+
onSaveTest: (value) => actions.saveAndTest(primary.apiKeyEnv, value),
|
|
672
|
+
})
|
|
673
|
+
: null,
|
|
674
|
+
h('div', { key: 'list', className: 'cp-table' }, data.accounts.map((account) => h('div', { key: account.key, className: 'cp-account' }, [
|
|
675
|
+
h('div', { key: 'row', className: 'cp-row' }, [
|
|
676
|
+
h(Dot, { key: 'd', color: account.keyConfigured ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)', title: account.keyConfigured ? t('keyOk') : t('keyMissing') }),
|
|
677
|
+
h('span', { key: 'n', className: 'cp-grow' }, account.displayName),
|
|
678
|
+
h('span', { key: 'k', className: 'cp-muted cp-mono' }, account.keyConfigured ? account.keyHint : '—'),
|
|
679
|
+
h('button', {
|
|
680
|
+
key: 't', type: 'button', className: 'cp-btn', disabled,
|
|
681
|
+
onClick: () => actions.testKey(account.apiKeyEnv, ''),
|
|
682
|
+
}, t('test')),
|
|
683
|
+
h('button', {
|
|
684
|
+
key: 'x', type: 'button', className: 'cp-btn cp-btn-danger',
|
|
685
|
+
disabled: disabled || data.accounts.length <= 1,
|
|
686
|
+
onClick: () => actions.removeAccount(account.key),
|
|
687
|
+
}, t('remove')),
|
|
688
|
+
]),
|
|
689
|
+
]))),
|
|
690
|
+
// A pool of one account needs none of this; it appears with the second.
|
|
691
|
+
data.accounts.length <= 1 ? null : h('div', { key: 'mode', className: 'cp-row' }, [
|
|
692
|
+
h('span', { key: 'l', className: 'cp-muted' }, t('accountMode')),
|
|
693
|
+
h('select', {
|
|
694
|
+
key: 's', className: 'cp-select', value: data.accountMode, disabled,
|
|
695
|
+
onChange: (event) => actions.setAccountMode({ mode: event.target.value }),
|
|
696
|
+
}, [
|
|
697
|
+
h('option', { key: 'single', value: 'single' }, t('modeSingle')),
|
|
698
|
+
h('option', { key: 'rr', value: 'roundrobin' }, t('modeRoundrobin')),
|
|
699
|
+
]),
|
|
700
|
+
h('span', { key: 'al', className: 'cp-muted' }, t('active')),
|
|
701
|
+
h('select', {
|
|
702
|
+
key: 'as', className: 'cp-select', value: data.activeAccount, disabled,
|
|
703
|
+
onChange: (event) => actions.setAccountMode({ active: event.target.value }),
|
|
704
|
+
}, [h('option', { key: '', value: '' }, '(first enabled)')].concat(
|
|
705
|
+
data.accounts.map((account) => h('option', { key: account.key, value: account.key }, account.key)),
|
|
706
|
+
)),
|
|
707
|
+
]),
|
|
708
|
+
h('div', { key: 'add', className: 'cp-row' }, [
|
|
709
|
+
h('input', {
|
|
710
|
+
key: 'n', className: 'cp-input', placeholder: t('accountName'), value: name, disabled,
|
|
711
|
+
'aria-label': t('accountName'),
|
|
712
|
+
onChange: (event) => setName(event.target.value),
|
|
713
|
+
}),
|
|
714
|
+
h('input', {
|
|
715
|
+
key: 'k', className: 'cp-input cp-grow', type: 'password', autoComplete: 'off',
|
|
716
|
+
placeholder: t('keyPlaceholder'), value: key, disabled,
|
|
717
|
+
'aria-label': t('keyValue'),
|
|
718
|
+
onChange: (event) => setKey(event.target.value),
|
|
719
|
+
}),
|
|
720
|
+
h('button', {
|
|
721
|
+
key: 'add', type: 'button', className: 'cp-btn',
|
|
722
|
+
disabled: disabled || name.trim().length === 0,
|
|
723
|
+
onClick: () => { actions.addAccount({ name: name.trim(), key: key.trim() }); setName(''); setKey('') },
|
|
724
|
+
}, t('addAccount')),
|
|
725
|
+
]),
|
|
726
|
+
])
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** The models card. */
|
|
730
|
+
function ModelsCard(props) {
|
|
731
|
+
const { t, data, disabled, busy, actions } = props
|
|
732
|
+
return h('div', { className: 'cp-card' }, [
|
|
733
|
+
h('div', { key: 'title', className: 'cp-card-title' }, [
|
|
734
|
+
h('span', { key: 'l' }, `${t('models')} · ${fill(t('modelsCount'), { n: data.models.length })}`),
|
|
735
|
+
h('button', {
|
|
736
|
+
key: 'r', type: 'button', className: 'cp-btn', disabled,
|
|
737
|
+
onClick: () => actions.refreshModels(),
|
|
738
|
+
}, t('refresh')),
|
|
739
|
+
]),
|
|
740
|
+
h('div', { key: 'rows', className: 'cp-table' }, data.models.map((model) => h(ModelRow, {
|
|
741
|
+
key: model.id, t, model, disabled, busy, actions,
|
|
742
|
+
}))),
|
|
743
|
+
])
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** The request-history body, rendered inside the panel's fold-away section. */
|
|
747
|
+
function HistoryCard(props) {
|
|
748
|
+
const { t, entries, disabled, actions } = props
|
|
749
|
+
return h('div', { className: 'cp-panel' }, [
|
|
750
|
+
h('div', { key: 'bar', className: 'cp-row' }, [
|
|
751
|
+
h('button', { key: 'r', type: 'button', className: 'cp-btn', disabled, onClick: () => actions.loadHistory(25) }, t('refresh')),
|
|
752
|
+
]),
|
|
753
|
+
entries === null
|
|
754
|
+
? h('div', { key: 'empty', className: 'cp-muted' }, t('noHistory'))
|
|
755
|
+
: h('div', { key: 'rows', className: 'cp-history' }, entries.map((entry, index) => h('div', { key: `${entry.ts}-${index}`, className: 'cp-history-row' }, [
|
|
756
|
+
h('span', { key: 'm', className: 'cp-grow' }, entry.model),
|
|
757
|
+
h('span', { key: 'p' }, entry.provider || '(auto)'),
|
|
758
|
+
h('span', { key: 'ms', className: 'cp-muted' }, ms(entry.ms)),
|
|
759
|
+
entry.error ? h('span', { key: 'e', style: { color: 'var(--dsw-alias-state-error-primary)' } }, entry.error) : null,
|
|
760
|
+
]))),
|
|
761
|
+
])
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** The full panel: what the Settings page and the Plugins card render. */
|
|
765
|
+
function Panel(props) {
|
|
766
|
+
const t = props.t
|
|
767
|
+
const state = props.useClinePass((snapshot) => snapshot)
|
|
768
|
+
const [history, setHistory] = React.useState(null)
|
|
769
|
+
|
|
770
|
+
// Read once on mount, while there is nothing to render. The dependency is
|
|
771
|
+
// the loaded data itself, not the status: a failed read leaves `data`
|
|
772
|
+
// null, so the effect does not re-run in a loop, and a successful one
|
|
773
|
+
// stops asking. A later failure keeps the last good snapshot on screen.
|
|
774
|
+
React.useEffect(() => {
|
|
775
|
+
if (state.data === null) props.refresh()
|
|
776
|
+
}, [state.data])
|
|
777
|
+
|
|
778
|
+
React.useEffect(() => {
|
|
779
|
+
if (state.action?.kind === 'history') setHistory(state.action.data.entries ?? [])
|
|
780
|
+
}, [state.action])
|
|
781
|
+
|
|
782
|
+
const actions = {
|
|
783
|
+
refresh: props.refresh,
|
|
784
|
+
setKey: props.setKey,
|
|
785
|
+
testKey: props.testKey,
|
|
786
|
+
saveAndTest: props.saveAndTest,
|
|
787
|
+
addAccount: props.addAccount,
|
|
788
|
+
removeAccount: props.removeAccount,
|
|
789
|
+
setAccountMode: props.setAccountMode,
|
|
790
|
+
pinModel: props.pinModel,
|
|
791
|
+
setupModel: props.setupModel,
|
|
792
|
+
probeModel: props.probeModel,
|
|
793
|
+
validateModel: props.validateModel,
|
|
794
|
+
testModel: props.testModel,
|
|
795
|
+
resetModel: props.resetModel,
|
|
796
|
+
refreshModels: props.refreshModels,
|
|
797
|
+
loadHistory: async (limit) => {
|
|
798
|
+
const result = await props.loadHistory(limit)
|
|
799
|
+
if (result !== undefined) setHistory(result.entries ?? [])
|
|
800
|
+
},
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const data = state.data
|
|
804
|
+
const disabled = state.busy !== null || (data !== null && data.settingsAvailable === false)
|
|
805
|
+
const ready = data?.ready === true
|
|
806
|
+
|
|
807
|
+
return h('div', { className: 'cp-panel' }, [
|
|
808
|
+
h('h2', { key: 'title', className: 'cp-title' }, t('title')),
|
|
809
|
+
// One status line replaces the old route/baseURL block: the endpoint is
|
|
810
|
+
// not something a user sets, so showing it was pure noise.
|
|
811
|
+
h('div', { key: 'status', className: 'cp-row cp-muted' }, [
|
|
812
|
+
h(Dot, { key: 'd', color: ready ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)', title: ready ? t('keyOk') : t('keyMissing') }),
|
|
813
|
+
h('span', { key: 's' }, ready ? t('keyOk') : t('keyMissing')),
|
|
814
|
+
]),
|
|
815
|
+
data !== null && data.settingsAvailable === false
|
|
816
|
+
? h('div', { key: 'unavailable', className: 'cp-msg cp-msg-warn' }, t('settingsUnavailable'))
|
|
817
|
+
: null,
|
|
818
|
+
state.error !== null ? h('div', { key: 'err', className: 'cp-msg cp-msg-err' }, state.error) : null,
|
|
819
|
+
h(Notice, { key: 'notice', notice: state.notice }),
|
|
820
|
+
state.status === 'loading' && data === null
|
|
821
|
+
? h('div', { key: 'loading', className: 'cp-muted' }, t('busy'))
|
|
822
|
+
: null,
|
|
823
|
+
data === null ? null : h(AccountsCard, { key: 'accounts', t, data, disabled, actions }),
|
|
824
|
+
data === null ? null : (ready
|
|
825
|
+
? h(ModelsCard, { key: 'models', t, data, disabled, busy: state.busy, actions })
|
|
826
|
+
: h('div', { key: 'notready', className: 'cp-msg cp-msg-warn' }, t('notReady'))),
|
|
827
|
+
// Diagnostic detail, so it stays folded away until asked for.
|
|
828
|
+
h('details', { key: 'history', className: 'cp-card' }, [
|
|
829
|
+
h('summary', { key: 'sum', className: 'cp-card-title' }, t('history')),
|
|
830
|
+
h(HistoryCard, { key: 'body', t, entries: history, disabled: state.busy !== null, actions }),
|
|
831
|
+
]),
|
|
832
|
+
])
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** The compact card the Models page renders for this provider. */
|
|
836
|
+
function QuickCard(props) {
|
|
837
|
+
const t = props.t
|
|
838
|
+
const state = props.useClinePass((snapshot) => snapshot)
|
|
839
|
+
React.useEffect(() => {
|
|
840
|
+
if (state.data === null) props.refresh()
|
|
841
|
+
}, [state.data])
|
|
842
|
+
const data = state.data
|
|
843
|
+
const account = data?.accounts?.[0]
|
|
844
|
+
return h('div', { className: 'cp-panel' }, [
|
|
845
|
+
h('div', { key: 'head', className: 'cp-row' }, [
|
|
846
|
+
h(Dot, { key: 'd', color: data?.ready === true ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)', title: data?.ready === true ? t('keyOk') : t('keyMissing') }),
|
|
847
|
+
h('span', { key: 'n', className: 'cp-grow' }, data?.ready === true ? t('keyOk') : t('keyMissing')),
|
|
848
|
+
]),
|
|
849
|
+
account === undefined ? null : h(KeyField, {
|
|
850
|
+
key: 'key',
|
|
851
|
+
t,
|
|
852
|
+
account,
|
|
853
|
+
disabled: state.busy !== null || data?.settingsAvailable === false,
|
|
854
|
+
busy: state.busy !== null,
|
|
855
|
+
onSave: (value) => props.setKey(account.apiKeyEnv, value),
|
|
856
|
+
onTest: (value) => props.testKey(account.apiKeyEnv, value),
|
|
857
|
+
onSaveTest: (value) => props.saveAndTest(account.apiKeyEnv, value),
|
|
858
|
+
}),
|
|
859
|
+
h(Notice, { key: 'notice', notice: state.notice }),
|
|
860
|
+
state.error !== null ? h('div', { key: 'err', className: 'cp-msg cp-msg-err' }, state.error) : null,
|
|
861
|
+
state.data === null
|
|
862
|
+
? h('div', { key: 'retry', className: 'cp-row' }, [
|
|
863
|
+
h('button', { key: 'b', type: 'button', className: 'cp-btn', onClick: () => props.refresh() }, t('retry')),
|
|
864
|
+
])
|
|
865
|
+
: null,
|
|
866
|
+
])
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
//#endregion
|
|
870
|
+
|
|
871
|
+
//#region plugin
|
|
872
|
+
|
|
873
|
+
/** The slot registry and the browser RPC transport. */
|
|
874
|
+
const inject = ['slots', 'connection']
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Mount the Cline Pass configuration surface.
|
|
878
|
+
* @param ctx - browser plugin context.
|
|
879
|
+
*/
|
|
880
|
+
function apply(ctx) {
|
|
881
|
+
const controller = new PanelController(ctx)
|
|
882
|
+
ctx.effect(() => () => controller.dispose(), 'cline-pass: panel controller')
|
|
883
|
+
const face = () => controller.inject()
|
|
884
|
+
/**
|
|
885
|
+
* The translate function every registration and component uses.
|
|
886
|
+
*
|
|
887
|
+
* The shared locale registry is preferred, but a namespace lookup that
|
|
888
|
+
* resolves nothing ECHOES THE KEY BACK instead of throwing, so a bare
|
|
889
|
+
* `try/catch` around it is not enough — an unresolved key would render as
|
|
890
|
+
* `keyMissing` rather than as copy. Any result that merely repeats the
|
|
891
|
+
* key therefore falls through to this bundle's own dictionary, which is
|
|
892
|
+
* resolved against the active locale and always has the key.
|
|
893
|
+
*/
|
|
894
|
+
const translate = (key, params) => {
|
|
895
|
+
const locale = ctx.get('locale')
|
|
896
|
+
if (locale !== undefined) {
|
|
897
|
+
try {
|
|
898
|
+
const translated = locale.bind(NS)(key, params)
|
|
899
|
+
if (typeof translated === 'string' && translated !== key) return translated
|
|
900
|
+
} catch { /* fall through to the bundled dictionary */ }
|
|
901
|
+
}
|
|
902
|
+
return controller.t(key, params)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const renderPanel = (props) => h(Panel, { ...props, ...face, t: translate })
|
|
906
|
+
const renderQuick = (props) => h(QuickCard, { ...props, ...face, t: translate })
|
|
907
|
+
|
|
908
|
+
// 1. A dedicated settings page: the full configuration surface.
|
|
909
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
910
|
+
name: 'settings.section',
|
|
911
|
+
id: NS,
|
|
912
|
+
order: 12,
|
|
913
|
+
label: () => translate('title'),
|
|
914
|
+
locale: NS,
|
|
915
|
+
inject: face,
|
|
916
|
+
}, renderPanel))
|
|
917
|
+
|
|
918
|
+
// 2. A card on the Plugins page, which already enumerates this
|
|
919
|
+
// namespace, so the panel is also where users look for plugin config.
|
|
920
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
921
|
+
name: 'settings.plugin.item',
|
|
922
|
+
key: NS,
|
|
923
|
+
locale: NS,
|
|
924
|
+
inject: face,
|
|
925
|
+
}, renderPanel))
|
|
926
|
+
|
|
927
|
+
// 3. A compact key card on the Models page, right where the missing key
|
|
928
|
+
// for this route is reported.
|
|
929
|
+
ctx.slots.inject('settings.models.provider-card', () => ctx.slots.register({
|
|
930
|
+
name: 'settings.models.provider-card',
|
|
931
|
+
key: NS,
|
|
932
|
+
locale: NS,
|
|
933
|
+
inject: face,
|
|
934
|
+
}, renderQuick))
|
|
935
|
+
|
|
936
|
+
// The first read is lazy (each component asks for it), because the RPC
|
|
937
|
+
// transport only exists once Connection has activated.
|
|
938
|
+
void controller.refresh()
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
exports.apply = apply
|
|
942
|
+
exports.inject = inject
|
|
943
|
+
return module.exports
|
|
944
|
+
},
|
|
945
|
+
})
|