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/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
+ })