dsh-cline-pass 0.1.0 → 0.2.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/README.en.md +1 -1
- package/README.md +5 -1
- package/lib/adapter.js +270 -22
- package/lib/catalog.js +13 -5
- package/lib/client.js +1026 -190
- package/lib/cline.js +170 -5
- package/lib/compat.js +154 -0
- package/lib/engine.js +153 -28
- package/lib/index.js +172 -27
- package/lib/panel.js +162 -85
- package/lib/protocol.js +215 -25
- package/lib/store.js +17 -2
- package/lib/tools.js +155 -14
- package/package.json +3 -2
package/lib/client.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* exports as an ordinary plugin (`apply` + `inject`). Plain CJS with no build
|
|
7
7
|
* step, so `require` reaches only the shell's platform seed table — `react`.
|
|
8
8
|
*
|
|
9
|
-
* It adds a Cline Pass
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* It adds a folded Cline Pass card to the Plugins page and a compact key card
|
|
10
|
+
* on the Models page. Every read and write goes to the authenticated
|
|
11
|
+
* `/api/cline-pass` route published by `lib/panel.js`, so the panel and the
|
|
12
|
+
* `cline_pass_*` tools share one source of truth.
|
|
13
13
|
*
|
|
14
14
|
* @module dsh-cline-pass/client
|
|
15
15
|
*/
|
|
@@ -23,20 +23,52 @@ window.__ModuleLoader__.load({
|
|
|
23
23
|
|
|
24
24
|
const React = require('react')
|
|
25
25
|
const h = React.createElement
|
|
26
|
+
// The platform seed table publishes the icon set, so this bundle draws its
|
|
27
|
+
// disclosure chevrons with the very component the host's own cards use.
|
|
28
|
+
//
|
|
29
|
+
// That export was renamed between host releases: the 0.1.2–0.1.5 line ships
|
|
30
|
+
// `IconChevronDownOutline14`, while 0.1.6+ ships the artwork / regular /
|
|
31
|
+
// medium triple and the unsuffixed name. Destructuring a single name yields
|
|
32
|
+
// `undefined` on the other host, and React throws on an undefined element
|
|
33
|
+
// type — which takes the whole panel down, not just the chevron. Resolve
|
|
34
|
+
// whichever this host publishes, newest first, and let `Chevron` fall back
|
|
35
|
+
// to an inline glyph so an unknown host still draws the disclosure.
|
|
36
|
+
const PRIMITIVES_SPECIFIER = '@deepseek-ai/dsh-client-ui-primitives'
|
|
37
|
+
const primitives = require(PRIMITIVES_SPECIFIER)
|
|
38
|
+
const HostChevron = primitives.IconChevronDownOutline
|
|
39
|
+
?? primitives.IconChevronDownOutlineRegular
|
|
40
|
+
?? primitives.IconChevronDownOutlineArtwork
|
|
41
|
+
?? primitives.IconChevronDownOutlineMedium
|
|
42
|
+
?? primitives.IconChevronDownOutline14
|
|
43
|
+
?? null
|
|
26
44
|
|
|
27
45
|
/** Locale namespace this card owns. */
|
|
28
46
|
const NS = 'cline-pass'
|
|
29
47
|
/** Exact `/api` Fetch route the host half publishes. */
|
|
30
48
|
const PANEL_PATH = '/api/cline-pass'
|
|
31
49
|
/** Human label for the upstream verdict vocabulary. */
|
|
32
|
-
const VERDICT = { ok: 'available', limited: 'rate-limited', bad: 'not-pinnable', auth: 'auth-failed', unknown: 'unknown' }
|
|
50
|
+
const VERDICT = { ok: 'available', limited: 'rate-limited', bad: 'not-pinnable', auth: 'auth-failed', 'not-adopted': 'not-adopted', unknown: 'unknown' }
|
|
51
|
+
/**
|
|
52
|
+
* The quota windows this bundle can name, in display order.
|
|
53
|
+
*
|
|
54
|
+
* A window the gateway reports that is missing here still renders — under the
|
|
55
|
+
* gateway's own type string — so an added window needs no release.
|
|
56
|
+
*/
|
|
57
|
+
const USAGE_WINDOWS = [['five_hour', 'usageFiveHour'], ['weekly', 'usageWeekly'], ['monthly', 'usageMonthly']]
|
|
58
|
+
/**
|
|
59
|
+
* How long a token reading stays fresh.
|
|
60
|
+
*
|
|
61
|
+
* Counting walks the account's usage history, so an automatic re-read on
|
|
62
|
+
* every panel open would spend requests for a number that moves slowly.
|
|
63
|
+
*/
|
|
64
|
+
const TOKEN_TTL_MS = 5 * 60_000
|
|
33
65
|
|
|
34
66
|
//#region styles
|
|
35
67
|
|
|
36
68
|
const CSS = [
|
|
37
69
|
'.cp-panel{max-width:820px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:14px;font-size:13px}',
|
|
38
70
|
'.cp-title{margin:0;font-size:18px;font-weight:600}',
|
|
39
|
-
'.cp-card{border:0.5px solid var(--dsw-alias-border-
|
|
71
|
+
'.cp-card{border:0.5px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}',
|
|
40
72
|
'.cp-card-title{font-weight:600;font-size:13px;display:flex;align-items:center;gap:8px;justify-content:space-between}',
|
|
41
73
|
'.cp-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
42
74
|
'.cp-grow{flex:1;min-width:120px}',
|
|
@@ -51,7 +83,29 @@ window.__ModuleLoader__.load({
|
|
|
51
83
|
'.cp-btn-danger{color:var(--dsw-alias-state-error-primary)}',
|
|
52
84
|
'.cp-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none}',
|
|
53
85
|
'.cp-table{display:flex;flex-direction:column;gap:6px}',
|
|
54
|
-
'.cp-
|
|
86
|
+
'.cp-model{border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:8px}',
|
|
87
|
+
// The account table is a real table for the same reason the history one
|
|
88
|
+
// is: per-row grids size their `auto` tracks from their own content, so a
|
|
89
|
+
// header cell never lines up with the data under it.
|
|
90
|
+
'.cp-acct-table{width:100%;border-collapse:collapse;table-layout:fixed}',
|
|
91
|
+
'.cp-acct-table th{text-align:center;font-weight:400;font-size:11px;color:var(--dsw-alias-label-tertiary);padding:0 0 6px;border-bottom:0.5px solid var(--dsw-alias-border-l3)}',
|
|
92
|
+
'.cp-acct-table td{padding:6px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2);font-size:12px;vertical-align:middle;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
93
|
+
'.cp-acct-table tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
|
|
94
|
+
'.cp-acct-col-dot{width:52px}',
|
|
95
|
+
'.cp-acct-state{display:flex;align-items:center;gap:8px}',
|
|
96
|
+
'.cp-acct-row-off .cp-acct-id,.cp-acct-row-off .cp-acct-hint{opacity:.55}',
|
|
97
|
+
'.cp-acct-col-hint{width:104px}',
|
|
98
|
+
'.cp-acct-col-actions{width:190px}',
|
|
99
|
+
'.cp-acct-id{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:0}',
|
|
100
|
+
'.cp-acct-name{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
101
|
+
'.cp-acct-ref,.cp-acct-hint{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
102
|
+
'.cp-acct-ref{color:var(--dsw-alias-label-tertiary)}',
|
|
103
|
+
'.cp-acct-hint{color:var(--dsw-alias-label-secondary);font-size:12px}',
|
|
104
|
+
// Wrapping is the fallback for a panel narrower than the three buttons:
|
|
105
|
+
// a taller row beats a clipped button.
|
|
106
|
+
'.cp-acct-actions{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap}',
|
|
107
|
+
'.cp-acct-newname{flex:0 0 150px}',
|
|
108
|
+
'.cp-acct-add,.cp-acct-key{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px;margin-top:2px}',
|
|
55
109
|
'.cp-chips{display:flex;flex-wrap:wrap;gap:6px}',
|
|
56
110
|
'.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
111
|
'.cp-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
@@ -60,10 +114,86 @@ window.__ModuleLoader__.load({
|
|
|
60
114
|
'.cp-msg-ok{background:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-bg-base)}',
|
|
61
115
|
'.cp-msg-err{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-base)}',
|
|
62
116
|
'.cp-msg-warn{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base)}',
|
|
63
|
-
'.cp-history{
|
|
64
|
-
|
|
117
|
+
'.cp-history{max-height:280px;overflow:auto;padding:0 4px 2px}',
|
|
118
|
+
// A real table, not a grid per row: with one grid per row every `auto`
|
|
119
|
+
// track is resolved from that row's own content, so a row whose upstream
|
|
120
|
+
// is a long pill and a row whose upstream is "(auto)" end up with
|
|
121
|
+
// different column widths — and nothing lines up with the header.
|
|
122
|
+
'.cp-history-table{width:100%;border-collapse:collapse;table-layout:fixed}',
|
|
123
|
+
// Header and data share one alignment: a table whose labels sit left of
|
|
124
|
+
// their values is the thing this panel keeps getting asked to fix.
|
|
125
|
+
'.cp-history-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-1);text-align:center;font-weight:400;font-size:11px;color:var(--dsw-alias-label-tertiary);padding:2px 0 6px;border-bottom:0.5px solid var(--dsw-alias-border-l3)}',
|
|
126
|
+
'.cp-history-table td{padding:6px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2);font-size:12px;vertical-align:middle;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
127
|
+
'.cp-history-table tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
|
|
128
|
+
'.cp-history-col-dot{width:18px}',
|
|
129
|
+
'.cp-history-col-when{width:62px}',
|
|
130
|
+
'.cp-history-col-tokens{width:120px}',
|
|
131
|
+
'.cp-history-col-load{width:108px}',
|
|
132
|
+
'.cp-history-when{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
|
|
133
|
+
'.cp-history-model-cell{min-width:0}',
|
|
134
|
+
// The flex stack lives INSIDE the cell, never on the `<td>` itself: a td
|
|
135
|
+
// with `display:flex` stops being a table-cell, drops out of the row's
|
|
136
|
+
// height equalisation, and the row's two bottom borders land at different
|
|
137
|
+
// y positions — which is what makes the rule look broken.
|
|
138
|
+
'.cp-history-model-stack{display:flex;flex-direction:column;gap:2px;min-width:0;align-items:center}',
|
|
139
|
+
'.cp-history-model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
140
|
+
// Selector must out-specify `.cp-history-table td` (0,1,1), whose nowrap +
|
|
141
|
+
// ellipsis would otherwise clip the counts with no way to read them.
|
|
142
|
+
'.cp-history-table td.cp-history-tokens{color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip;line-height:1.5}',
|
|
143
|
+
// Three stacked figures read like a request log: label left, value right.
|
|
144
|
+
'.cp-history-table td.cp-history-load{font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip}',
|
|
145
|
+
'.cp-history-load-row{display:flex;justify-content:space-between;gap:6px;font-size:11.5px;line-height:1.5}',
|
|
146
|
+
'.cp-history-meta{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden}',
|
|
147
|
+
// A failure gets its own row under the entry rather than widening it: the
|
|
148
|
+
// gateway's messages are long enough to wreck a one-line layout.
|
|
149
|
+
'.cp-history-error{color:var(--dsw-alias-state-error-primary);font-size:11.5px}',
|
|
150
|
+
'.cp-tag{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
151
|
+
'.cp-tag-quiet{background:0 0;border-color:transparent;color:var(--dsw-alias-label-tertiary);flex:none}',
|
|
65
152
|
'.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
153
|
'.cp-inline{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
154
|
+
'.cp-grow-end{margin-left:auto}',
|
|
155
|
+
// Plugins-page card chrome, mirrored token for token from the host's own
|
|
156
|
+
// `PluginCard`: that tab hands a plugin the whole card, so matching it —
|
|
157
|
+
// rather than inventing a second look — is this bundle's job.
|
|
158
|
+
'.cp-set-card{border:0.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}',
|
|
159
|
+
'.cp-set-card:hover{border-color:var(--dsw-alias-label-dimmed)}',
|
|
160
|
+
'.cp-set-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
|
|
161
|
+
'.cp-set-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}',
|
|
162
|
+
'.cp-set-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
|
|
163
|
+
'.cp-set-head-text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}',
|
|
164
|
+
'.cp-set-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
|
|
165
|
+
'.cp-set-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
|
|
166
|
+
'.cp-set-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
|
|
167
|
+
'.cp-set-chevron-open{transform:rotate(180deg)}',
|
|
168
|
+
'.cp-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;display:inline-flex}',
|
|
169
|
+
'details[open]>summary .cp-chevron{transform:rotate(180deg)}',
|
|
170
|
+
// A summary laid out as a flex row keeps no native affordance, so the
|
|
171
|
+
// pointer and the hover tone are what say "this opens".
|
|
172
|
+
'.cp-fold{cursor:pointer}',
|
|
173
|
+
'.cp-fold:hover .cp-chevron{color:var(--dsw-alias-label-primary)}',
|
|
174
|
+
'.cp-set-body{border-top:0.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px}',
|
|
175
|
+
'.cp-usage-groups{display:flex;flex-direction:column;gap:12px}',
|
|
176
|
+
'.cp-usage-group+.cp-usage-group{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px}',
|
|
177
|
+
'.cp-usage-account{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}',
|
|
178
|
+
'.cp-usage{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}',
|
|
179
|
+
'.cp-usage-item{border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}',
|
|
180
|
+
'.cp-usage-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}',
|
|
181
|
+
'.cp-usage-label{font-weight:600}',
|
|
182
|
+
'.cp-usage-value{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}',
|
|
183
|
+
'.cp-usage-track{height:6px;border-radius:999px;background:var(--dsw-alias-border-l2);overflow:hidden}',
|
|
184
|
+
'.cp-usage-fill{height:100%;border-radius:999px;transition:width .3s}',
|
|
185
|
+
'.cp-usage-note{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
186
|
+
'.cp-usage-tokens{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}',
|
|
187
|
+
'.cp-usage-models{margin-top:2px}',
|
|
188
|
+
'.cp-usage-models-sum{cursor:pointer;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
189
|
+
'.cp-usage-models-sum:hover{color:var(--dsw-alias-label-primary)}',
|
|
190
|
+
'.cp-usage-model-rows{display:flex;flex-direction:column;gap:2px;margin-top:6px}',
|
|
191
|
+
'.cp-usage-model-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:10px;align-items:baseline;font-size:11.5px}',
|
|
192
|
+
'.cp-usage-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
|
|
193
|
+
'.cp-usage-model-cost{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}',
|
|
194
|
+
'.cp-usage-model-tokens{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);min-width:52px;text-align:right}',
|
|
195
|
+
'.cp-check{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}',
|
|
196
|
+
'.cp-model-hidden .cp-mono,.cp-model-hidden .cp-check{opacity:.55}',
|
|
67
197
|
].join('')
|
|
68
198
|
|
|
69
199
|
const CSS_TAG = '@deepseek-ai/dsh-cline-pass/client.css'
|
|
@@ -81,6 +211,10 @@ window.__ModuleLoader__.load({
|
|
|
81
211
|
|
|
82
212
|
const ZH = {
|
|
83
213
|
title: 'Cline Pass',
|
|
214
|
+
description: '订阅模型的渠道钉住与账号池配置',
|
|
215
|
+
panelUnavailable: '浏览器通道不可用。',
|
|
216
|
+
expand: '展开设置',
|
|
217
|
+
collapse: '收起设置',
|
|
84
218
|
keyOk: 'API Key 已配置',
|
|
85
219
|
keyMissing: '尚未配置 API Key',
|
|
86
220
|
keyValue: 'API Key',
|
|
@@ -90,6 +224,18 @@ window.__ModuleLoader__.load({
|
|
|
90
224
|
test: '测试',
|
|
91
225
|
remove: '删除',
|
|
92
226
|
accounts: '账号',
|
|
227
|
+
accountsCount: '{n} 个账号',
|
|
228
|
+
accountsActive: '当前',
|
|
229
|
+
accountsSetActive: '设为当前',
|
|
230
|
+
accountsKey: '密钥',
|
|
231
|
+
accountsActions: '操作',
|
|
232
|
+
accountsEnabled: '启用',
|
|
233
|
+
accountsOff: '已停用',
|
|
234
|
+
accountsPoolHint: '单账号:优先用「当前」账号;轮询:每次请求依次使用已启用账号。任一账号额度耗尽或被拒时,会自动改用池内其他账号,失败账号冷却一段时间。',
|
|
235
|
+
accountsFirstRun: '配置 API Key 后,才会显示官方额度、订阅模型与请求记录。',
|
|
236
|
+
accountsFor: '为「{name}」保存密钥',
|
|
237
|
+
accountsFirst: '(首个可用)',
|
|
238
|
+
keyNone: '未配置',
|
|
93
239
|
accountMode: '账号模式',
|
|
94
240
|
modeSingle: '单账号',
|
|
95
241
|
modeRoundrobin: '轮询',
|
|
@@ -98,11 +244,38 @@ window.__ModuleLoader__.load({
|
|
|
98
244
|
accountName: '账号名',
|
|
99
245
|
models: '订阅模型',
|
|
100
246
|
modelsCount: '{n} 个模型',
|
|
247
|
+
hiddenModels: '{n} 个已从选择列表隐藏',
|
|
248
|
+
visible: '在模型选择列表中显示',
|
|
249
|
+
hiddenTag: '已隐藏',
|
|
250
|
+
selectAll: '全选',
|
|
251
|
+
invertSelection: '反选',
|
|
252
|
+
usageTitle: '官方额度',
|
|
253
|
+
usageFiveHour: '5 小时',
|
|
254
|
+
usageWeekly: '本周',
|
|
255
|
+
usageMonthly: '本月',
|
|
256
|
+
usageResetsIn: '{t}后重置',
|
|
257
|
+
usageDays: '{n} 天',
|
|
258
|
+
usageHours: '{n} 小时',
|
|
259
|
+
usageMinutes: '{n} 分钟',
|
|
260
|
+
usageRefresh: '刷新',
|
|
261
|
+
usageLoading: '正在读取官方额度…',
|
|
262
|
+
usageUnavailable: '暂时读不到官方额度。',
|
|
263
|
+
usageEmpty: '官方未返回额度窗口。',
|
|
264
|
+
usageAccount: '账号 {name}',
|
|
265
|
+
historyWhen: '时间',
|
|
266
|
+
historyModel: '模型',
|
|
267
|
+
historyUpstream: '上游',
|
|
268
|
+
historyLoad: '延迟',
|
|
269
|
+
historyTokens: 'TOKEN',
|
|
270
|
+
historyFirstChunk: '首字',
|
|
271
|
+
historyTotal: '总耗时',
|
|
272
|
+
historyRate: '输出速率',
|
|
273
|
+
historyTokensHint: '↓输入 ↑输出 ⚡缓存 🧠推理;— 表示网关本次未返回用量',
|
|
274
|
+
historyEffort: '推理强度',
|
|
275
|
+
historyAuto: '(自动)',
|
|
101
276
|
noChannels: '未探测渠道',
|
|
102
277
|
pinned: '已钉住',
|
|
103
278
|
auto: '自动路由',
|
|
104
|
-
autoSetup: '一键配置',
|
|
105
|
-
autoSetupHint: '探测 → 校验全部渠道 → 钉住可用渠道 → 真实请求验证',
|
|
106
279
|
probe: '探测',
|
|
107
280
|
validate: '校验',
|
|
108
281
|
testPin: '测试',
|
|
@@ -112,15 +285,15 @@ window.__ModuleLoader__.load({
|
|
|
112
285
|
history: '最近请求',
|
|
113
286
|
noHistory: '暂无记录。',
|
|
114
287
|
busy: '处理中…',
|
|
115
|
-
notReady: '先配置 API Key。',
|
|
116
288
|
settingsUnavailable: '设置服务不可用,改动无法保存。',
|
|
117
|
-
panelUnavailable: '浏览器通道不可用。',
|
|
118
289
|
retry: '重试',
|
|
119
|
-
autoDone: '已钉住 {n} 个渠道。',
|
|
120
|
-
autoKept: '无可用渠道,保持自动路由。',
|
|
121
290
|
}
|
|
122
291
|
const EN = {
|
|
123
292
|
title: 'Cline Pass',
|
|
293
|
+
description: 'Channel pins and the account pool for the subscription models',
|
|
294
|
+
panelUnavailable: 'The browser channel is unavailable.',
|
|
295
|
+
expand: 'Show settings',
|
|
296
|
+
collapse: 'Hide settings',
|
|
124
297
|
keyOk: 'API key configured',
|
|
125
298
|
keyMissing: 'No API key',
|
|
126
299
|
keyValue: 'API key',
|
|
@@ -130,6 +303,18 @@ window.__ModuleLoader__.load({
|
|
|
130
303
|
test: 'Test',
|
|
131
304
|
remove: 'Remove',
|
|
132
305
|
accounts: 'Accounts',
|
|
306
|
+
accountsCount: '{n} account(s)',
|
|
307
|
+
accountsActive: 'active',
|
|
308
|
+
accountsSetActive: 'Use',
|
|
309
|
+
accountsKey: 'Key',
|
|
310
|
+
accountsActions: 'Actions',
|
|
311
|
+
accountsEnabled: 'Enabled',
|
|
312
|
+
accountsOff: 'Disabled',
|
|
313
|
+
accountsPoolHint: 'Single: prefers the "current" account; round-robin: each request uses the next enabled account. When one is out of quota or refused, the pool switches to another and stands the failed one down for a while.',
|
|
314
|
+
accountsFirstRun: 'Quota, subscription models and request history appear once a key is stored.',
|
|
315
|
+
accountsFor: 'Store a key for "{name}"',
|
|
316
|
+
accountsFirst: '(first enabled)',
|
|
317
|
+
keyNone: 'not set',
|
|
133
318
|
accountMode: 'Mode',
|
|
134
319
|
modeSingle: 'Single',
|
|
135
320
|
modeRoundrobin: 'Round robin',
|
|
@@ -138,11 +323,38 @@ window.__ModuleLoader__.load({
|
|
|
138
323
|
accountName: 'Name',
|
|
139
324
|
models: 'Models',
|
|
140
325
|
modelsCount: '{n} model(s)',
|
|
326
|
+
hiddenModels: '{n} hidden from the pickers',
|
|
327
|
+
visible: 'Show in the model pickers',
|
|
328
|
+
hiddenTag: 'Hidden',
|
|
329
|
+
selectAll: 'Check all',
|
|
330
|
+
invertSelection: 'Invert',
|
|
331
|
+
usageTitle: 'Official usage limits',
|
|
332
|
+
usageFiveHour: '5-hour',
|
|
333
|
+
usageWeekly: 'Weekly',
|
|
334
|
+
usageMonthly: 'Monthly',
|
|
335
|
+
usageResetsIn: 'resets in {t}',
|
|
336
|
+
usageDays: '{n}d',
|
|
337
|
+
usageHours: '{n}h',
|
|
338
|
+
usageMinutes: '{n}m',
|
|
339
|
+
usageRefresh: 'Refresh',
|
|
340
|
+
usageLoading: 'Reading the official quota…',
|
|
341
|
+
usageUnavailable: 'The official quota is unavailable right now.',
|
|
342
|
+
usageEmpty: 'The gateway reported no quota windows.',
|
|
343
|
+
usageAccount: 'account {name}',
|
|
344
|
+
historyWhen: 'Time',
|
|
345
|
+
historyModel: 'Model',
|
|
346
|
+
historyUpstream: 'Upstream',
|
|
347
|
+
historyLoad: 'Latency',
|
|
348
|
+
historyTokens: 'Tokens',
|
|
349
|
+
historyFirstChunk: 'first',
|
|
350
|
+
historyTotal: 'total',
|
|
351
|
+
historyRate: 'rate',
|
|
352
|
+
historyTokensHint: '↓in ↑out ⚡cache 🧠reasoning; a dash means the gateway reported no usage',
|
|
353
|
+
historyEffort: 'effort',
|
|
354
|
+
historyAuto: '(auto)',
|
|
141
355
|
noChannels: 'No channels probed',
|
|
142
356
|
pinned: 'Pinned',
|
|
143
357
|
auto: 'Auto',
|
|
144
|
-
autoSetup: 'Auto-configure',
|
|
145
|
-
autoSetupHint: 'probe → validate every channel → pin the healthy ones → verify with a real request',
|
|
146
358
|
probe: 'Probe',
|
|
147
359
|
validate: 'Validate',
|
|
148
360
|
testPin: 'Test',
|
|
@@ -152,12 +364,8 @@ window.__ModuleLoader__.load({
|
|
|
152
364
|
history: 'Recent requests',
|
|
153
365
|
noHistory: 'Nothing recorded.',
|
|
154
366
|
busy: 'Working…',
|
|
155
|
-
notReady: 'Configure the API key first.',
|
|
156
367
|
settingsUnavailable: 'Settings are read-only; changes will not persist.',
|
|
157
|
-
panelUnavailable: 'The browser channel is unavailable.',
|
|
158
368
|
retry: 'Retry',
|
|
159
|
-
autoDone: 'Pinned {n} channel(s).',
|
|
160
|
-
autoKept: 'No usable channel; kept automatic routing.',
|
|
161
369
|
}
|
|
162
370
|
|
|
163
371
|
//#endregion
|
|
@@ -197,11 +405,18 @@ window.__ModuleLoader__.load({
|
|
|
197
405
|
return n >= 1000 ? (n / 1000).toFixed(1) + 's' : Math.round(n) + 'ms'
|
|
198
406
|
}
|
|
199
407
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
408
|
+
/**
|
|
409
|
+
* A timestamp as a history row reads it: clock time for today, and the date
|
|
410
|
+
* in front of it once an entry is no longer from today — a long-lived host
|
|
411
|
+
* process would otherwise show times that look like they were all this hour.
|
|
412
|
+
*/
|
|
413
|
+
function stamp(value) {
|
|
414
|
+
const at = new Date(Number(value ?? 0))
|
|
415
|
+
if (!Number.isFinite(at.getTime())) return '-'
|
|
416
|
+
const pad = (part) => String(part).padStart(2, '0')
|
|
417
|
+
const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
|
|
418
|
+
if (at.toDateString() === new Date().toDateString()) return clock
|
|
419
|
+
return `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${clock.slice(0, 5)}`
|
|
205
420
|
}
|
|
206
421
|
|
|
207
422
|
/** Colour a channel verdict. */
|
|
@@ -217,6 +432,145 @@ window.__ModuleLoader__.load({
|
|
|
217
432
|
return String(template).replace(/\{(\w+)\}/g, (whole, key) => (params?.[key] === undefined ? whole : String(params[key])))
|
|
218
433
|
}
|
|
219
434
|
|
|
435
|
+
/**
|
|
436
|
+
* The three-step tone ramp a quota bar uses.
|
|
437
|
+
*
|
|
438
|
+
* A percentage is only alarming near the cap, so the ramp keeps the ordinary
|
|
439
|
+
* reading green and reserves colour for the two states worth reacting to.
|
|
440
|
+
*/
|
|
441
|
+
function usageColor(percent) {
|
|
442
|
+
if (percent >= 90) return 'var(--dsw-alias-state-error-primary)'
|
|
443
|
+
if (percent >= 70) return 'var(--dsw-alias-state-warn-primary)'
|
|
444
|
+
return 'var(--dsw-alias-state-success-primary)'
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* One token count, bounded in width for the request table.
|
|
450
|
+
*
|
|
451
|
+
* Exact with separators while that still fits a narrow column, then one
|
|
452
|
+
* decimal of k/M — the convention the reference request log uses too
|
|
453
|
+
* ("4,239" beside "205.6K"). Every field is therefore at most six characters,
|
|
454
|
+
* so four of them plus their markers cannot push a row taller than the three
|
|
455
|
+
* latency lines already do. The exact digits stay in the tooltip.
|
|
456
|
+
*/
|
|
457
|
+
function formatTokenCount(value) {
|
|
458
|
+
const n = Math.max(0, Number(value ?? 0))
|
|
459
|
+
if (n < 100_000) return n.toLocaleString('en-US')
|
|
460
|
+
if (n < 1_000_000) {
|
|
461
|
+
const k = (n / 1000).toFixed(1)
|
|
462
|
+
// 999,999 rounds to "1000.0k" — a value the next unit already names.
|
|
463
|
+
return Number(k) >= 1000 ? `${(n / 1_000_000).toFixed(1)}M` : `${k}k`
|
|
464
|
+
}
|
|
465
|
+
return `${(n / 1_000_000).toFixed(1)}M`
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The gateway states money in MICRO-USD, not dollars.
|
|
470
|
+
*
|
|
471
|
+
* Discovered by cross-check: the five-hour reading summed to 83,653,988 while
|
|
472
|
+
* the plan's cap for the same window is 1,000,000,000 and the official
|
|
473
|
+
* percentage was 8% — and 83.6M/1e9 = 8.4%. The weekly (288.8M / 2.5e9 =
|
|
474
|
+
* 11.6% vs 11%) and monthly (288.8M / 5e9 = 5.8% vs 5%) readings agree, so
|
|
475
|
+
* both the per-row `costUsd` and the caps are 1e-6 USD: $83.65 against a
|
|
476
|
+
* $1000 window. The division happens here, at the one place money becomes
|
|
477
|
+
* text, which leaves the transport a faithful copy of what was sent.
|
|
478
|
+
*/
|
|
479
|
+
const MICRO_USD = 1_000_000
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* A USD figure, already in dollars.
|
|
484
|
+
*
|
|
485
|
+
* A nonzero amount under a cent is shown as `<$0.01` rather than rounded to
|
|
486
|
+
* zero: "no usage" and "a sliver of usage" are different answers.
|
|
487
|
+
*/
|
|
488
|
+
function formatUsd(value) {
|
|
489
|
+
const n = Number(value ?? 0)
|
|
490
|
+
if (!Number.isFinite(n) || n <= 0) return '$0.00'
|
|
491
|
+
if (n < 0.01) return '<$0.01'
|
|
492
|
+
if (n < 1000) return `$${n.toFixed(2)}`
|
|
493
|
+
// A cap is read as a figure, so thousands get separators rather than a "k"
|
|
494
|
+
// suffix that hides the digits making it recognizable.
|
|
495
|
+
return `$${Math.round(n).toLocaleString('en-US')}`
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A request's three latency figures, the way a request log reads them.
|
|
500
|
+
*
|
|
501
|
+
* - `first`: what the caller waited before anything appeared.
|
|
502
|
+
* - `total`: what the whole call cost.
|
|
503
|
+
* - `rate`: output tokens per second AFTER the first chunk — the speed of the
|
|
504
|
+
* answer itself. Dividing the total instead would fold the model's thinking
|
|
505
|
+
* into a "speed" and report a slow answer for a fast one that thought long.
|
|
506
|
+
*
|
|
507
|
+
* A moment that never came is a dash, not a zero: zero would read as "instant".
|
|
508
|
+
*/
|
|
509
|
+
function latency(entry) {
|
|
510
|
+
const stamp = (value) => (Number(value ?? 0) > 0 ? ms(value) : '—')
|
|
511
|
+
const first = Number(entry.ttft ?? 0)
|
|
512
|
+
const total = Number(entry.ms ?? 0)
|
|
513
|
+
const out = entry.usageReported === true ? Number(entry.usage?.outputTokens ?? 0) : 0
|
|
514
|
+
const streaming = total - first
|
|
515
|
+
const rate = first > 0 && out > 0 && streaming > 0
|
|
516
|
+
? `${(out / (streaming / 1000)).toFixed(1)} t/s`
|
|
517
|
+
: '—'
|
|
518
|
+
return { first: stamp(first), total: stamp(total), rate }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The four token counts of a request, or `null` when the gateway sent no
|
|
523
|
+
* usage frame — which is not the same as a call that cost nothing.
|
|
524
|
+
*
|
|
525
|
+
* Returned as parts rather than a string so the same numbers can be rendered
|
|
526
|
+
* two ways: bounded for the narrow cell, exact for its tooltip.
|
|
527
|
+
*/
|
|
528
|
+
function tokenParts(entry) {
|
|
529
|
+
if (entry.usageReported !== true) return null
|
|
530
|
+
const usage = entry.usage
|
|
531
|
+
const parts = [
|
|
532
|
+
{ key: 'in', icon: '↓', value: Number(usage.inputTokens ?? 0) },
|
|
533
|
+
{ key: 'out', icon: '↑', value: Number(usage.outputTokens ?? 0) },
|
|
534
|
+
]
|
|
535
|
+
if (Number(usage.cacheReadTokens ?? 0) > 0) parts.push({ key: 'cache', icon: '⚡', value: Number(usage.cacheReadTokens) })
|
|
536
|
+
if (Number(usage.reasoningTokens ?? 0) > 0) parts.push({ key: 'think', icon: '🧠', value: Number(usage.reasoningTokens) })
|
|
537
|
+
return parts
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The cell's rendering: width-bounded, so no count can reshape the row. */
|
|
541
|
+
function tokenSummary(entry) {
|
|
542
|
+
const parts = tokenParts(entry)
|
|
543
|
+
if (parts === null) return '—'
|
|
544
|
+
return parts.map((part) => `${part.icon}${formatTokenCount(part.value)}`).join(' ')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** The tooltip's rendering: every digit, so nothing is only approximately known. */
|
|
548
|
+
function tokenSummaryExact(entry) {
|
|
549
|
+
const parts = tokenParts(entry)
|
|
550
|
+
if (parts === null) return ''
|
|
551
|
+
return parts.map((part) => `${part.icon}${part.value.toLocaleString('en-US')}`).join(' ')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* A coarse "3 天 4 小时" from an ISO instant.
|
|
556
|
+
*
|
|
557
|
+
* Coarse on purpose: a quota reset is read at a glance, and seconds of
|
|
558
|
+
* precision would only make the panel re-render for nothing.
|
|
559
|
+
* @returns the duration, or '' when the instant is absent or already past.
|
|
560
|
+
*/
|
|
561
|
+
function countdown(t, iso) {
|
|
562
|
+
const at = Date.parse(String(iso ?? ''))
|
|
563
|
+
if (!Number.isFinite(at)) return ''
|
|
564
|
+
const minutes = Math.round((at - Date.now()) / 60000)
|
|
565
|
+
if (minutes <= 0) return ''
|
|
566
|
+
const days = Math.floor(minutes / 1440)
|
|
567
|
+
const hours = Math.floor((minutes % 1440) / 60)
|
|
568
|
+
const mins = minutes % 60
|
|
569
|
+
if (days > 0) return `${fill(t('usageDays'), { n: days })} ${fill(t('usageHours'), { n: hours })}`
|
|
570
|
+
if (hours > 0) return `${fill(t('usageHours'), { n: hours })} ${fill(t('usageMinutes'), { n: mins })}`
|
|
571
|
+
return fill(t('usageMinutes'), { n: Math.max(1, mins) })
|
|
572
|
+
}
|
|
573
|
+
|
|
220
574
|
//#endregion
|
|
221
575
|
|
|
222
576
|
//#region controller
|
|
@@ -237,6 +591,13 @@ window.__ModuleLoader__.load({
|
|
|
237
591
|
this.t = this.makeTranslate()
|
|
238
592
|
}
|
|
239
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Load the in-process request history.
|
|
596
|
+
*
|
|
597
|
+
* The payload is returned rather than advertised through `action`: `run`
|
|
598
|
+
* publishes its own result there, so a second publish only wrote a shape
|
|
599
|
+
* nothing read. The caller stores what it gets back.
|
|
600
|
+
*/
|
|
240
601
|
/**
|
|
241
602
|
* Bind this panel's copy to the active locale.
|
|
242
603
|
*
|
|
@@ -280,13 +641,16 @@ window.__ModuleLoader__.load({
|
|
|
280
641
|
addAccount: (payload) => controller.addAccount(payload),
|
|
281
642
|
removeAccount: (name) => controller.removeAccount(name),
|
|
282
643
|
setAccountMode: (patch) => controller.setAccountMode(patch),
|
|
644
|
+
setAccountEnabled: (name, enabled) => controller.setAccountEnabled(name, enabled),
|
|
283
645
|
pinModel: (payload) => controller.pinModel(payload),
|
|
284
|
-
|
|
646
|
+
setModelVisible: (model, visible) => controller.setModelVisible(model, visible),
|
|
647
|
+
setModelsVisibility: (mode) => controller.setModelsVisibility(mode),
|
|
285
648
|
probeModel: (model) => controller.probeModel(model),
|
|
286
649
|
validateModel: (model) => controller.validateModel(model),
|
|
287
650
|
testModel: (model) => controller.testModel(model),
|
|
288
651
|
resetModel: (model) => controller.resetModel(model),
|
|
289
652
|
refreshModels: () => controller.refreshModels(),
|
|
653
|
+
loadUsage: (explicit) => controller.loadUsage(explicit),
|
|
290
654
|
loadHistory: (limit) => controller.loadHistory(limit),
|
|
291
655
|
clearNotice: () => controller.clearNotice(),
|
|
292
656
|
}
|
|
@@ -337,6 +701,30 @@ window.__ModuleLoader__.load({
|
|
|
337
701
|
}
|
|
338
702
|
}
|
|
339
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Run one automatic read: the busy flag, and nothing else.
|
|
706
|
+
*
|
|
707
|
+
* These run on mount, unprompted, and they fail for reasons the user
|
|
708
|
+
* cannot act on (a stale host, a flaky gateway, a missing endpoint). The
|
|
709
|
+
* card's own "no reading yet" state already says what is known, and their
|
|
710
|
+
* retry buttons are right there, so a panel-level warning on every open
|
|
711
|
+
* would be noise the user cannot clear. A user-triggered read goes through
|
|
712
|
+
* {@link run} instead, where the banner IS the answer they asked for.
|
|
713
|
+
*/
|
|
714
|
+
async readQuietly(label, action) {
|
|
715
|
+
this.publish({ busy: label, notice: null })
|
|
716
|
+
try {
|
|
717
|
+
const value = await action()
|
|
718
|
+
this.publish({ busy: null, error: null })
|
|
719
|
+
return value
|
|
720
|
+
} catch (error) {
|
|
721
|
+
// Visible only to whoever is debugging the panel itself.
|
|
722
|
+
console.warn('[cline-pass] automatic read failed:', String(error?.message ?? error))
|
|
723
|
+
this.publish({ busy: null })
|
|
724
|
+
return undefined
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
340
728
|
/** Merge a fresh host state into the snapshot. */
|
|
341
729
|
applyState(value) {
|
|
342
730
|
const data = value?.accounts === undefined && value?.provider === undefined ? this.store.getSnapshot().data : value
|
|
@@ -399,6 +787,13 @@ window.__ModuleLoader__.load({
|
|
|
399
787
|
if (result !== undefined) this.applyState(result)
|
|
400
788
|
}
|
|
401
789
|
|
|
790
|
+
/** Take one account in or out of the pool. */
|
|
791
|
+
async setAccountEnabled(name, enabled) {
|
|
792
|
+
const result = await this.run('account', async () => await this.call('account.enable', { name, enabled }))
|
|
793
|
+
if (result !== undefined) this.applyState(result)
|
|
794
|
+
return result
|
|
795
|
+
}
|
|
796
|
+
|
|
402
797
|
/** Switch account mode or the active account. */
|
|
403
798
|
async setAccountMode(patch) {
|
|
404
799
|
const result = await this.run('account', async () => await this.call('account.mode', patch))
|
|
@@ -412,6 +807,29 @@ window.__ModuleLoader__.load({
|
|
|
412
807
|
return result
|
|
413
808
|
}
|
|
414
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Show or hide one model in every picker this route feeds.
|
|
812
|
+
*
|
|
813
|
+
* Hiding never touches the subscription list, so the checkbox in the panel
|
|
814
|
+
* is the whole undo.
|
|
815
|
+
*/
|
|
816
|
+
async setModelVisible(model, visible) {
|
|
817
|
+
const result = await this.run('visibility', async () => await this.call('model.visibility', { model, visible }))
|
|
818
|
+
if (result !== undefined) this.applyState(result)
|
|
819
|
+
return result
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Show, hide or swap every subscription model at once.
|
|
824
|
+
*
|
|
825
|
+
* One write for the whole set, so a bulk change cannot half-apply.
|
|
826
|
+
*/
|
|
827
|
+
async setModelsVisibility(mode) {
|
|
828
|
+
const result = await this.run('visibility', async () => await this.call('models.visibility', { mode }))
|
|
829
|
+
if (result !== undefined) this.applyState(result)
|
|
830
|
+
return result
|
|
831
|
+
}
|
|
832
|
+
|
|
415
833
|
/** Probe one model and surface the discovered channels. */
|
|
416
834
|
async probeModel(model) {
|
|
417
835
|
const result = await this.run('probe', async () => await this.call('model.probe', { model }))
|
|
@@ -459,32 +877,15 @@ window.__ModuleLoader__.load({
|
|
|
459
877
|
return result
|
|
460
878
|
}
|
|
461
879
|
|
|
462
|
-
/**
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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')}` } })
|
|
880
|
+
/**
|
|
881
|
+
* Read the official quota. Quiet by default: the card asks on mount, and a
|
|
882
|
+
* gateway that cannot answer leaves the last good reading in place rather
|
|
883
|
+
* than turning the section into an error.
|
|
884
|
+
*/
|
|
885
|
+
async loadUsage(explicit = false) {
|
|
886
|
+
const call = async () => await this.call('usage', {})
|
|
887
|
+
const result = explicit === true ? await this.run('usage', call) : await this.readQuietly('usage', call)
|
|
888
|
+
if (result !== undefined) this.applyState(result)
|
|
488
889
|
return result
|
|
489
890
|
}
|
|
490
891
|
|
|
@@ -498,13 +899,15 @@ window.__ModuleLoader__.load({
|
|
|
498
899
|
return result
|
|
499
900
|
}
|
|
500
901
|
|
|
501
|
-
/**
|
|
902
|
+
/** Drop every pin for one model, returning it to automatic routing. */
|
|
903
|
+
async resetModel(model) {
|
|
904
|
+
const result = await this.pinModel({ model, upstreams: [], exclude: [], sort: 'none' })
|
|
905
|
+
if (result !== undefined) this.publish({ notice: { kind: 'ok', text: `${model} · ${this.t('auto')}` } })
|
|
906
|
+
return result
|
|
907
|
+
}
|
|
908
|
+
|
|
502
909
|
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
|
-
})
|
|
910
|
+
return await this.run('history', async () => await this.call('history', { limit: limit ?? 25 }))
|
|
508
911
|
}
|
|
509
912
|
}
|
|
510
913
|
|
|
@@ -524,44 +927,77 @@ window.__ModuleLoader__.load({
|
|
|
524
927
|
return h('span', { className: 'cp-dot', style: { background: props.color }, title: props.title })
|
|
525
928
|
}
|
|
526
929
|
|
|
527
|
-
/**
|
|
930
|
+
/**
|
|
931
|
+
* The disclosure chevron, drawn with whichever host icon this release
|
|
932
|
+
* publishes.
|
|
933
|
+
*
|
|
934
|
+
* The host 0.1.2–0.1.5 line exports `IconChevronDownOutline14`; 0.1.6+
|
|
935
|
+
* exports the artwork / regular / medium triple. When neither is present —
|
|
936
|
+
* an unrecognised host, or a seed table that omits the module — an inline
|
|
937
|
+
* SVG keeps the affordance instead of letting `h(undefined)` throw.
|
|
938
|
+
*/
|
|
939
|
+
function Chevron(props) {
|
|
940
|
+
if (HostChevron !== null) return h(HostChevron, props)
|
|
941
|
+
return h('svg', {
|
|
942
|
+
className: props.className,
|
|
943
|
+
width: 14,
|
|
944
|
+
height: 14,
|
|
945
|
+
viewBox: '0 0 16 16',
|
|
946
|
+
fill: 'none',
|
|
947
|
+
'aria-hidden': 'true',
|
|
948
|
+
focusable: 'false',
|
|
949
|
+
}, h('path', {
|
|
950
|
+
d: 'M4 6.5 8 10.5 12 6.5',
|
|
951
|
+
stroke: 'currentColor',
|
|
952
|
+
strokeWidth: 1.4,
|
|
953
|
+
strokeLinecap: 'round',
|
|
954
|
+
strokeLinejoin: 'round',
|
|
955
|
+
}))
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* The API-key field, shared by the panel and the Models card.
|
|
960
|
+
*
|
|
961
|
+
* `inline` drops the card wrapper for callers that already sit inside one —
|
|
962
|
+
* the accounts card draws the field as the last row of its own table.
|
|
963
|
+
*/
|
|
528
964
|
function KeyField(props) {
|
|
529
|
-
const { t, account, disabled, onSave, onTest, onSaveTest, busy } = props
|
|
965
|
+
const { t, account, disabled, onSave, onTest, onSaveTest, busy, inline } = props
|
|
530
966
|
const [value, setValue] = React.useState('')
|
|
531
967
|
const entered = value.trim().length > 0
|
|
532
|
-
|
|
533
|
-
h('
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
]),
|
|
968
|
+
const row = h('div', { key: 'row', className: 'cp-row' }, [
|
|
969
|
+
h('input', {
|
|
970
|
+
key: 'input',
|
|
971
|
+
className: 'cp-input cp-grow',
|
|
972
|
+
type: 'password',
|
|
973
|
+
autoComplete: 'off',
|
|
974
|
+
spellCheck: false,
|
|
975
|
+
value,
|
|
976
|
+
placeholder: t('keyPlaceholder'),
|
|
977
|
+
'aria-label': t('keyValue'),
|
|
978
|
+
disabled,
|
|
979
|
+
onChange: (event) => setValue(event.target.value),
|
|
980
|
+
}),
|
|
981
|
+
// Least to most committal: check the value, store it as-is, or store it
|
|
982
|
+
// only once a real request has proved it works.
|
|
983
|
+
h('button', {
|
|
984
|
+
key: 'test', type: 'button', className: 'cp-btn',
|
|
985
|
+
disabled: disabled || !entered,
|
|
986
|
+
onClick: () => onTest(value.trim()),
|
|
987
|
+
}, t('test')),
|
|
988
|
+
h('button', {
|
|
989
|
+
key: 'saveOnly', type: 'button', className: 'cp-btn',
|
|
990
|
+
disabled: disabled || !entered,
|
|
991
|
+
onClick: () => { onSave(value.trim()); setValue('') },
|
|
992
|
+
}, t('save')),
|
|
993
|
+
h('button', {
|
|
994
|
+
key: 'save', type: 'button', className: 'cp-btn cp-btn-primary',
|
|
995
|
+
disabled: disabled || !entered,
|
|
996
|
+
onClick: () => { onSaveTest(value.trim()); setValue('') },
|
|
997
|
+
}, t('saveTest')),
|
|
998
|
+
busy ? h('span', { key: 'b', className: 'cp-muted' }, t('busy')) : null,
|
|
564
999
|
])
|
|
1000
|
+
return inline === true ? row : h('div', { className: 'cp-card' }, [row])
|
|
565
1001
|
}
|
|
566
1002
|
|
|
567
1003
|
/** The channel chooser for one model. */
|
|
@@ -612,26 +1048,40 @@ window.__ModuleLoader__.load({
|
|
|
612
1048
|
/**
|
|
613
1049
|
* One model row.
|
|
614
1050
|
*
|
|
615
|
-
* The default view is deliberately just identity
|
|
616
|
-
*
|
|
617
|
-
* channel list live behind the expander.
|
|
1051
|
+
* The default view is deliberately just identity, the checkbox that decides
|
|
1052
|
+
* whether any picker offers this model, and the one action that matters. The
|
|
1053
|
+
* finer pin actions and the channel list live behind the expander.
|
|
1054
|
+
*
|
|
1055
|
+
* The expander holds the three steps that are not the one-click path:
|
|
1056
|
+
* discovering channels (probe), verifying what actually serves the stored pin
|
|
1057
|
+
* (test), and dropping it (reset). Validating every channel is what
|
|
1058
|
+
* auto-configure already does before it pins, so it is not a separate button
|
|
1059
|
+
* here — running it alone costs one real request per channel to re-learn
|
|
1060
|
+
* verdicts the same click would have produced.
|
|
618
1061
|
*/
|
|
619
1062
|
function ModelRow(props) {
|
|
620
|
-
const { t, model, disabled,
|
|
1063
|
+
const { t, model, disabled, actions } = props
|
|
621
1064
|
const [open, setOpen] = React.useState(false)
|
|
622
|
-
const
|
|
1065
|
+
const hidden = model.hidden === true
|
|
623
1066
|
const pinnedLabel = model.pinned.length === 0 ? t('auto') : model.pinned.join(' › ')
|
|
624
1067
|
const action = (key, label, run) => h('button', {
|
|
625
1068
|
key, type: 'button', className: 'cp-btn', disabled, onClick: () => run(model.id),
|
|
626
1069
|
}, label)
|
|
627
|
-
return h('div', { className: 'cp-model' }, [
|
|
1070
|
+
return h('div', { className: `cp-model${hidden ? ' cp-model-hidden' : ''}` }, [
|
|
628
1071
|
h('div', { key: 'head', className: 'cp-row' }, [
|
|
1072
|
+
h('input', {
|
|
1073
|
+
key: 'vis',
|
|
1074
|
+
type: 'checkbox',
|
|
1075
|
+
className: 'cp-check',
|
|
1076
|
+
checked: !hidden,
|
|
1077
|
+
disabled,
|
|
1078
|
+
title: t('visible'),
|
|
1079
|
+
'aria-label': `${t('visible')}: ${model.id}`,
|
|
1080
|
+
onChange: (event) => actions.setModelVisible(model.id, event.target.checked),
|
|
1081
|
+
}),
|
|
629
1082
|
h('span', { key: 'name', className: 'cp-mono cp-grow' }, model.id),
|
|
1083
|
+
hidden ? h('span', { key: 'off', className: 'cp-muted' }, t('hiddenTag')) : null,
|
|
630
1084
|
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
1085
|
h('button', {
|
|
636
1086
|
key: 'more', type: 'button', className: 'cp-btn', title: t('channels'),
|
|
637
1087
|
onClick: () => setOpen(!open),
|
|
@@ -640,7 +1090,6 @@ window.__ModuleLoader__.load({
|
|
|
640
1090
|
open ? h('div', { key: 'detail', className: 'cp-panel' }, [
|
|
641
1091
|
h('div', { key: 'actions', className: 'cp-row' }, [
|
|
642
1092
|
action('probe', t('probe'), actions.probeModel),
|
|
643
|
-
action('validate', t('validate'), actions.validateModel),
|
|
644
1093
|
action('test', t('testPin'), actions.testModel),
|
|
645
1094
|
action('reset', t('reset'), actions.resetModel),
|
|
646
1095
|
]),
|
|
@@ -651,63 +1100,166 @@ window.__ModuleLoader__.load({
|
|
|
651
1100
|
])
|
|
652
1101
|
}
|
|
653
1102
|
|
|
654
|
-
/**
|
|
1103
|
+
/**
|
|
1104
|
+
* One account row.
|
|
1105
|
+
*
|
|
1106
|
+
* Identity stacks name over credential reference: the reference is what a
|
|
1107
|
+
* user needs when the key is missing ("store it at …") and what tells two
|
|
1108
|
+
* accounts apart, but it is never the first thing being read.
|
|
1109
|
+
*/
|
|
1110
|
+
function AccountRow(props) {
|
|
1111
|
+
const { t, account, disabled, active, pooled, removable, actions } = props
|
|
1112
|
+
const off = account.enabled === false
|
|
1113
|
+
return h('tr', { className: `cp-acct-row${off ? ' cp-acct-row-off' : ''}` }, [
|
|
1114
|
+
h('td', { key: 'state' }, h('span', { className: 'cp-acct-state' }, [
|
|
1115
|
+
// The checkbox is what decides whether the router may use this account;
|
|
1116
|
+
// the dot beside it still says whether its key is stored.
|
|
1117
|
+
h('input', {
|
|
1118
|
+
key: 'on',
|
|
1119
|
+
type: 'checkbox',
|
|
1120
|
+
className: 'cp-check',
|
|
1121
|
+
checked: !off,
|
|
1122
|
+
disabled,
|
|
1123
|
+
title: t('accountsEnabled'),
|
|
1124
|
+
'aria-label': `${t('accountsEnabled')}: ${account.key}`,
|
|
1125
|
+
onChange: (event) => actions.setAccountEnabled(account.key, event.target.checked),
|
|
1126
|
+
}),
|
|
1127
|
+
h(Dot, {
|
|
1128
|
+
key: 'dot',
|
|
1129
|
+
color: account.keyConfigured ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)',
|
|
1130
|
+
title: account.keyConfigured ? t('keyOk') : t('keyMissing'),
|
|
1131
|
+
}),
|
|
1132
|
+
])),
|
|
1133
|
+
h('td', { key: 'id' }, h('span', { className: 'cp-acct-id' }, [
|
|
1134
|
+
h('span', { key: 'name', className: 'cp-acct-name' }, [
|
|
1135
|
+
h('span', { key: 'text' }, account.displayName),
|
|
1136
|
+
off ? h('span', { key: 'off', className: 'cp-tag' }, t('accountsOff')) : null,
|
|
1137
|
+
active ? h('span', { key: 'tag', className: 'cp-tag' }, t('accountsActive')) : null,
|
|
1138
|
+
]),
|
|
1139
|
+
h('span', { key: 'ref', className: 'cp-acct-ref', title: account.apiKeyEnv }, account.apiKeyEnv),
|
|
1140
|
+
])),
|
|
1141
|
+
h('td', { key: 'hint', className: 'cp-acct-hint', title: account.keyHint }, account.keyConfigured ? account.keyHint : t('keyNone')),
|
|
1142
|
+
h('td', { key: 'actions' }, h('span', { className: 'cp-acct-actions' }, [
|
|
1143
|
+
h('button', {
|
|
1144
|
+
key: 'test', type: 'button', className: 'cp-btn', disabled,
|
|
1145
|
+
onClick: () => actions.testKey(account.apiKeyEnv, ''),
|
|
1146
|
+
}, t('test')),
|
|
1147
|
+
active || !pooled ? null : h('button', {
|
|
1148
|
+
key: 'use', type: 'button', className: 'cp-btn',
|
|
1149
|
+
onClick: () => actions.setAccountMode({ active: account.key }),
|
|
1150
|
+
}, t('accountsSetActive')),
|
|
1151
|
+
h('button', {
|
|
1152
|
+
key: 'remove', type: 'button', className: 'cp-btn cp-btn-danger',
|
|
1153
|
+
// Only an explicit account can be removed: a lone implicit account is
|
|
1154
|
+
// the top-level key and its `apiKeyEnv`, so there is no entry to
|
|
1155
|
+
// delete. Gating on the pool size instead left this dead for the
|
|
1156
|
+
// common single-account case.
|
|
1157
|
+
disabled: disabled || !removable,
|
|
1158
|
+
onClick: () => actions.removeAccount(account.key),
|
|
1159
|
+
}, t('remove')),
|
|
1160
|
+
])),
|
|
1161
|
+
])
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* The accounts card.
|
|
1166
|
+
*
|
|
1167
|
+
* One table: the key field belongs to the account a request would actually
|
|
1168
|
+
* use, instead of to whichever account happened to be first, and the pool
|
|
1169
|
+
* controls sit in the section body where they apply to the whole list.
|
|
1170
|
+
*
|
|
1171
|
+
* It folds like every other section, with one exception: before an account is
|
|
1172
|
+
* usable this is the only thing left to act on, so it opens itself. A card
|
|
1173
|
+
* whose every section is folded and empty is a dead end.
|
|
1174
|
+
*/
|
|
655
1175
|
function AccountsCard(props) {
|
|
656
|
-
const { t, data, disabled, actions } = props
|
|
1176
|
+
const { t, data, disabled, ready, actions } = props
|
|
657
1177
|
const [name, setName] = React.useState('')
|
|
658
1178
|
const [key, setKey] = React.useState('')
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
]))),
|
|
1179
|
+
const [open, setOpen] = React.useState(ready !== true)
|
|
1180
|
+
const pooled = data.accounts.length > 1
|
|
1181
|
+
const single = data.accountMode !== 'roundrobin'
|
|
1182
|
+
const fallback = data.accounts.find((account) => account.enabled)?.key ?? ''
|
|
1183
|
+
const activeKey = data.activeAccount === '' ? fallback : data.activeAccount
|
|
1184
|
+
// The key field follows the account requests will actually use.
|
|
1185
|
+
const target = data.accounts.find((account) => account.key === activeKey) ?? data.accounts[0]
|
|
1186
|
+
// A row can be deleted only when an explicit `accounts` entry stands behind
|
|
1187
|
+
// it; a lone implicit account is the top-level key, so deleting it would
|
|
1188
|
+
// mean deleting the route's own configuration.
|
|
1189
|
+
const removable = (account) => account.declared !== false
|
|
1190
|
+
return h('details', {
|
|
1191
|
+
className: 'cp-card',
|
|
1192
|
+
open,
|
|
1193
|
+
onToggle: (event) => setOpen(event.currentTarget.open),
|
|
1194
|
+
}, [
|
|
1195
|
+
foldHeader(`${t('accounts')} · ${fill(t('accountsCount'), { n: data.accounts.length })}`),
|
|
1196
|
+
// Everything else in this card is downstream of a stored key, so say so
|
|
1197
|
+
// here rather than leaving an unconfigured panel looking broken.
|
|
1198
|
+
ready === true ? null : h('div', { key: 'firstrun', className: 'cp-muted' }, t('accountsFirstRun')),
|
|
690
1199
|
// A pool of one account needs none of this; it appears with the second.
|
|
691
|
-
|
|
692
|
-
h('span', { key: '
|
|
1200
|
+
pooled ? h('div', { key: 'mode', className: 'cp-row' }, [
|
|
1201
|
+
h('span', { key: 'ml', className: 'cp-muted' }, t('accountMode')),
|
|
693
1202
|
h('select', {
|
|
694
|
-
key: '
|
|
1203
|
+
key: 'ms', className: 'cp-select', value: data.accountMode, disabled,
|
|
1204
|
+
'aria-label': t('accountMode'),
|
|
695
1205
|
onChange: (event) => actions.setAccountMode({ mode: event.target.value }),
|
|
696
1206
|
}, [
|
|
697
1207
|
h('option', { key: 'single', value: 'single' }, t('modeSingle')),
|
|
698
1208
|
h('option', { key: 'rr', value: 'roundrobin' }, t('modeRoundrobin')),
|
|
699
1209
|
]),
|
|
700
|
-
h('span', { key: 'al', className: 'cp-muted' }, t('active')),
|
|
701
|
-
h('select', {
|
|
1210
|
+
single ? h('span', { key: 'al', className: 'cp-muted' }, t('active')) : null,
|
|
1211
|
+
single ? h('select', {
|
|
702
1212
|
key: 'as', className: 'cp-select', value: data.activeAccount, disabled,
|
|
1213
|
+
'aria-label': t('active'),
|
|
703
1214
|
onChange: (event) => actions.setAccountMode({ active: event.target.value }),
|
|
704
|
-
}, [h('option', { key: '', value: '' },
|
|
1215
|
+
}, [h('option', { key: '', value: '' }, t('accountsFirst'))].concat(
|
|
705
1216
|
data.accounts.map((account) => h('option', { key: account.key, value: account.key }, account.key)),
|
|
706
|
-
)),
|
|
1217
|
+
)) : null,
|
|
1218
|
+
// How the pool picks an account is not visible from the row, so it is
|
|
1219
|
+
// written down rather than left to be inferred from the mode name.
|
|
1220
|
+
h('div', { key: 'hint', className: 'cp-muted' }, t('accountsPoolHint')),
|
|
1221
|
+
]) : null,
|
|
1222
|
+
h('table', { key: 'table', className: 'cp-acct-table' }, [
|
|
1223
|
+
h('colgroup', { key: 'cols' }, [
|
|
1224
|
+
h('col', { key: 'dot', className: 'cp-acct-col-dot' }),
|
|
1225
|
+
h('col', { key: 'id' }),
|
|
1226
|
+
h('col', { key: 'hint', className: 'cp-acct-col-hint' }),
|
|
1227
|
+
h('col', { key: 'actions', className: 'cp-acct-col-actions' }),
|
|
1228
|
+
]),
|
|
1229
|
+
h('thead', { key: 'head' }, h('tr', { key: 'r' }, [
|
|
1230
|
+
h('th', { key: 'dot' }, ''),
|
|
1231
|
+
h('th', { key: 'name' }, t('accountName')),
|
|
1232
|
+
h('th', { key: 'key' }, t('accountsKey')),
|
|
1233
|
+
h('th', { key: 'actions' }, t('accountsActions')),
|
|
1234
|
+
])),
|
|
1235
|
+
h('tbody', { key: 'body' }, data.accounts.map((account) => h(AccountRow, {
|
|
1236
|
+
key: account.key,
|
|
1237
|
+
t,
|
|
1238
|
+
account,
|
|
1239
|
+
disabled,
|
|
1240
|
+
pooled,
|
|
1241
|
+
removable: removable(account),
|
|
1242
|
+
active: single && account.key === activeKey,
|
|
1243
|
+
actions,
|
|
1244
|
+
}))),
|
|
707
1245
|
]),
|
|
708
|
-
h('div', { key: '
|
|
1246
|
+
target === undefined ? null : h('div', { key: 'key', className: 'cp-row cp-acct-key' }, [
|
|
1247
|
+
h('span', { key: 'l', className: 'cp-muted' }, fill(t('accountsFor'), { name: target.displayName })),
|
|
1248
|
+
h(KeyField, {
|
|
1249
|
+
key: 'field',
|
|
1250
|
+
t,
|
|
1251
|
+
account: target,
|
|
1252
|
+
disabled,
|
|
1253
|
+
busy: false,
|
|
1254
|
+
inline: true,
|
|
1255
|
+
onSave: (value) => actions.setKey(target.apiKeyEnv, value),
|
|
1256
|
+
onTest: (value) => actions.testKey(target.apiKeyEnv, value),
|
|
1257
|
+
onSaveTest: (value) => actions.saveAndTest(target.apiKeyEnv, value),
|
|
1258
|
+
}),
|
|
1259
|
+
]),
|
|
1260
|
+
h('div', { key: 'add', className: 'cp-row cp-acct-add' }, [
|
|
709
1261
|
h('input', {
|
|
710
|
-
key: 'n', className: 'cp-input', placeholder: t('accountName'), value: name, disabled,
|
|
1262
|
+
key: 'n', className: 'cp-input cp-acct-newname', placeholder: t('accountName'), value: name, disabled,
|
|
711
1263
|
'aria-label': t('accountName'),
|
|
712
1264
|
onChange: (event) => setName(event.target.value),
|
|
713
1265
|
}),
|
|
@@ -726,14 +1278,45 @@ window.__ModuleLoader__.load({
|
|
|
726
1278
|
])
|
|
727
1279
|
}
|
|
728
1280
|
|
|
729
|
-
/**
|
|
1281
|
+
/**
|
|
1282
|
+
* One fold-away section header.
|
|
1283
|
+
*
|
|
1284
|
+
* The chevron is explicit because the summary is laid out as a flex row,
|
|
1285
|
+
* which drops the browser's own disclosure marker — without it a folded
|
|
1286
|
+
* section looks like a plain card title, not something to open.
|
|
1287
|
+
*/
|
|
1288
|
+
function foldHeader(title) {
|
|
1289
|
+
return h('summary', { key: 'sum', className: 'cp-card-title cp-fold' }, [
|
|
1290
|
+
h('span', { key: 'l' }, title),
|
|
1291
|
+
h(Chevron, { key: 'chevron', className: 'cp-chevron' }),
|
|
1292
|
+
])
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* The models card: a fold-away section, like the request history.
|
|
1297
|
+
*
|
|
1298
|
+
* It is the longest block in the panel and the one a user comes back to
|
|
1299
|
+
* least, so it folds; the count in its header is what keeps a folded section
|
|
1300
|
+
* informative.
|
|
1301
|
+
*/
|
|
730
1302
|
function ModelsCard(props) {
|
|
731
1303
|
const { t, data, disabled, busy, actions } = props
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1304
|
+
const hidden = Number(data.hiddenModels ?? 0)
|
|
1305
|
+
return h('details', { className: 'cp-card' }, [
|
|
1306
|
+
foldHeader(`${t('models')} · ${fill(t('modelsCount'), { n: data.models.length })}${hidden === 0 ? '' : ` · ${fill(t('hiddenModels'), { n: hidden })}`}`),
|
|
1307
|
+
// The refresh and the bulk checkbox actions sit in the body, not the
|
|
1308
|
+
// header: a button inside a summary would also toggle the fold.
|
|
1309
|
+
h('div', { key: 'bar', className: 'cp-row' }, [
|
|
1310
|
+
h('button', {
|
|
1311
|
+
key: 'all', type: 'button', className: 'cp-btn', disabled,
|
|
1312
|
+
onClick: () => actions.setModelsVisibility('all'),
|
|
1313
|
+
}, t('selectAll')),
|
|
735
1314
|
h('button', {
|
|
736
|
-
key: '
|
|
1315
|
+
key: 'invert', type: 'button', className: 'cp-btn', disabled,
|
|
1316
|
+
onClick: () => actions.setModelsVisibility('invert'),
|
|
1317
|
+
}, t('invertSelection')),
|
|
1318
|
+
h('button', {
|
|
1319
|
+
key: 'r', type: 'button', className: 'cp-btn cp-grow-end', disabled,
|
|
737
1320
|
onClick: () => actions.refreshModels(),
|
|
738
1321
|
}, t('refresh')),
|
|
739
1322
|
]),
|
|
@@ -743,29 +1326,199 @@ window.__ModuleLoader__.load({
|
|
|
743
1326
|
])
|
|
744
1327
|
}
|
|
745
1328
|
|
|
1329
|
+
/**
|
|
1330
|
+
* The rows one history entry contributes: the request itself, plus the
|
|
1331
|
+
* failure line when there was one.
|
|
1332
|
+
*
|
|
1333
|
+
* The columns are the four facts a user asks in order ("did the request I
|
|
1334
|
+
* just made work?"), and the whole row is a tooltip so a truncated model id
|
|
1335
|
+
* or error is still recoverable.
|
|
1336
|
+
*
|
|
1337
|
+
* @returns one `<tr>`, or two when the request failed.
|
|
1338
|
+
*/
|
|
1339
|
+
function historyRows(t, entry, showAccount) {
|
|
1340
|
+
const failed = String(entry.error ?? '') !== ''
|
|
1341
|
+
const upstream = `${entry.provider || t('historyAuto')}${showAccount && entry.account !== '' ? ` · ${entry.account}` : ''}`
|
|
1342
|
+
// The route prefix is constant across every row of this list, so it is
|
|
1343
|
+
// carried in the tooltip instead of eating the width the model name needs
|
|
1344
|
+
// — centred text that overflows loses BOTH ends, not just the tail.
|
|
1345
|
+
const label = String(entry.model).replace(/^cline-pass\//, '')
|
|
1346
|
+
const figures = latency(entry)
|
|
1347
|
+
const row = h('tr', {
|
|
1348
|
+
key: 'row',
|
|
1349
|
+
// The whole row restates itself on hover, including the figures the three
|
|
1350
|
+
// stacked lines already show, so a screenshot or a copy keeps the facts.
|
|
1351
|
+
title: [
|
|
1352
|
+
entry.model,
|
|
1353
|
+
`${upstream} · ${tokenSummary(entry)}`,
|
|
1354
|
+
`${t('historyFirstChunk')} ${figures.first} · ${t('historyTotal')} ${figures.total} · ${t('historyRate')} ${figures.rate}`,
|
|
1355
|
+
entry.effort === '' ? '' : `${t('historyEffort')} ${entry.effort}`,
|
|
1356
|
+
failed ? entry.error : '',
|
|
1357
|
+
].filter((line) => line !== '').join('\n'),
|
|
1358
|
+
}, [
|
|
1359
|
+
h('td', { key: 'dot' }, h(Dot, {
|
|
1360
|
+
color: failed ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)',
|
|
1361
|
+
title: failed ? entry.error : 'ok',
|
|
1362
|
+
})),
|
|
1363
|
+
h('td', { key: 'when', className: 'cp-history-when' }, stamp(entry.ts)),
|
|
1364
|
+
// Model and its serving channel share a cell: the panel is ~480px wide and
|
|
1365
|
+
// a column of its own squeezed the model id — the one identifier a reader
|
|
1366
|
+
// scans for — down to a truncated stub.
|
|
1367
|
+
h('td', { key: 'model', className: 'cp-history-model-cell' }, h('div', { className: 'cp-history-model-stack' }, [
|
|
1368
|
+
h('span', { key: 'name', className: 'cp-history-model', title: entry.model }, label),
|
|
1369
|
+
h('span', { key: 'upstream', className: 'cp-history-meta' }, [
|
|
1370
|
+
h('span', { key: 'provider', className: 'cp-tag' }, entry.provider || t('historyAuto')),
|
|
1371
|
+
showAccount && entry.account !== '' ? h('span', { key: 'account', className: 'cp-tag cp-tag-quiet' }, entry.account) : null,
|
|
1372
|
+
]),
|
|
1373
|
+
])),
|
|
1374
|
+
// The tooltip carries the EXACT figures, not the bounded rendering: the cell
|
|
1375
|
+
// rounds "236,288" to "236.3k" to keep its width, so the tooltip is the
|
|
1376
|
+
// only place the real digits survive.
|
|
1377
|
+
h('td', {
|
|
1378
|
+
key: 'tokens',
|
|
1379
|
+
className: 'cp-history-tokens',
|
|
1380
|
+
title: `${tokenSummaryExact(entry)}\n${t('historyTokensHint')}`,
|
|
1381
|
+
}, tokenSummary(entry)),
|
|
1382
|
+
h('td', { key: 'load', className: 'cp-history-load' }, [
|
|
1383
|
+
h('span', { key: 'first', className: 'cp-history-load-row' }, [
|
|
1384
|
+
h('span', { key: 'k', className: 'cp-muted' }, t('historyFirstChunk')),
|
|
1385
|
+
h('span', { key: 'v' }, latency(entry).first),
|
|
1386
|
+
]),
|
|
1387
|
+
h('span', { key: 'total', className: 'cp-history-load-row' }, [
|
|
1388
|
+
h('span', { key: 'k', className: 'cp-muted' }, t('historyTotal')),
|
|
1389
|
+
h('span', { key: 'v' }, latency(entry).total),
|
|
1390
|
+
]),
|
|
1391
|
+
h('span', { key: 'rate', className: 'cp-history-load-row' }, [
|
|
1392
|
+
h('span', { key: 'k', className: 'cp-muted' }, t('historyRate')),
|
|
1393
|
+
h('span', { key: 'v' }, latency(entry).rate),
|
|
1394
|
+
]),
|
|
1395
|
+
]),
|
|
1396
|
+
])
|
|
1397
|
+
if (!failed) return [row]
|
|
1398
|
+
// Two empty cells keep the message starting under the model column.
|
|
1399
|
+
return [row, h('tr', { key: 'error' }, [
|
|
1400
|
+
h('td', { key: 'a' }, ''),
|
|
1401
|
+
h('td', { key: 'b' }, ''),
|
|
1402
|
+
// From the model column to the end: model, upstream, tokens, latency.
|
|
1403
|
+
h('td', { key: 'message', className: 'cp-history-error', colSpan: 3, title: entry.error }, entry.error),
|
|
1404
|
+
])]
|
|
1405
|
+
}
|
|
1406
|
+
|
|
746
1407
|
/** The request-history body, rendered inside the panel's fold-away section. */
|
|
747
1408
|
function HistoryCard(props) {
|
|
748
|
-
const { t, entries, disabled, actions } = props
|
|
1409
|
+
const { t, entries, disabled, actions, showAccount } = props
|
|
749
1410
|
return h('div', { className: 'cp-panel' }, [
|
|
750
1411
|
h('div', { key: 'bar', className: 'cp-row' }, [
|
|
751
1412
|
h('button', { key: 'r', type: 'button', className: 'cp-btn', disabled, onClick: () => actions.loadHistory(25) }, t('refresh')),
|
|
752
1413
|
]),
|
|
753
1414
|
entries === null
|
|
754
1415
|
? h('div', { key: 'empty', className: 'cp-muted' }, t('noHistory'))
|
|
755
|
-
: h('div', { key: '
|
|
756
|
-
h('
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1416
|
+
: h('div', { key: 'table', className: 'cp-history' }, h('table', { className: 'cp-history-table' }, [
|
|
1417
|
+
h('colgroup', { key: 'cols' }, [
|
|
1418
|
+
h('col', { key: 'dot', className: 'cp-history-col-dot' }),
|
|
1419
|
+
h('col', { key: 'when', className: 'cp-history-col-when' }),
|
|
1420
|
+
h('col', { key: 'model' }),
|
|
1421
|
+
h('col', { key: 'tokens', className: 'cp-history-col-tokens' }),
|
|
1422
|
+
h('col', { key: 'load', className: 'cp-history-col-load' }),
|
|
1423
|
+
]),
|
|
1424
|
+
h('thead', { key: 'head' }, h('tr', { key: 'r' }, [
|
|
1425
|
+
h('th', { key: 'dot' }, ''),
|
|
1426
|
+
h('th', { key: 'when' }, t('historyWhen')),
|
|
1427
|
+
h('th', { key: 'model' }, `${t('historyModel')} / ${t('historyUpstream')}`),
|
|
1428
|
+
h('th', { key: 'tokens', className: 'cp-history-tokens' }, t('historyTokens')),
|
|
1429
|
+
h('th', { key: 'load', className: 'cp-history-load' }, t('historyLoad')),
|
|
1430
|
+
])),
|
|
1431
|
+
h('tbody', { key: 'body' }, entries.flatMap((entry) => historyRows(t, entry, showAccount))),
|
|
1432
|
+
])),
|
|
761
1433
|
])
|
|
762
1434
|
}
|
|
763
1435
|
|
|
764
|
-
/**
|
|
1436
|
+
/**
|
|
1437
|
+
* The official quota card.
|
|
1438
|
+
*
|
|
1439
|
+
* The window list is whatever the gateway reported, in its order, so a window
|
|
1440
|
+
* Cline adds later shows up here without this file naming it — the three
|
|
1441
|
+
* known windows only contribute their localized labels.
|
|
1442
|
+
*
|
|
1443
|
+
* One reading per window: the official percentage, plus when it resets.
|
|
1444
|
+
*/
|
|
1445
|
+
function UsageCard(props) {
|
|
1446
|
+
const { t, usage, disabled, actions, unavailable } = props
|
|
1447
|
+
const fetched = Number(usage?.fetchedAt ?? 0) > 0
|
|
1448
|
+
const accounts = usage?.accounts ?? []
|
|
1449
|
+
const anyWindow = accounts.some((entry) => (entry.limits ?? []).length > 0)
|
|
1450
|
+
const windowRow = (type, key, limit) => {
|
|
1451
|
+
const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
|
|
1452
|
+
const left = countdown(t, limit.resetsAt)
|
|
1453
|
+
// The percentage is the whole reading: it comes from the official quota
|
|
1454
|
+
// endpoint, is always populated, and needs no interpretation. The money
|
|
1455
|
+
// and token figures that used to sit under it were derived, unverifiable
|
|
1456
|
+
// and different from the gateway's own accounting, so they are gone.
|
|
1457
|
+
return h('div', { key: type, className: 'cp-usage-item' }, [
|
|
1458
|
+
h('div', { key: 'head', className: 'cp-usage-head' }, [
|
|
1459
|
+
h('span', { key: 'l', className: 'cp-usage-label' }, key === '' ? type : t(key)),
|
|
1460
|
+
h('span', { key: 'v', className: 'cp-usage-value' }, `${Math.round(percent)}%`),
|
|
1461
|
+
]),
|
|
1462
|
+
h('div', { key: 'track', className: 'cp-usage-track' },
|
|
1463
|
+
h('div', { key: 'fill', className: 'cp-usage-fill', style: { width: `${percent}%`, background: usageColor(percent) } })),
|
|
1464
|
+
left === '' ? null : h('div', { key: 'note', className: 'cp-usage-note' }, fill(t('usageResetsIn'), { t: left })),
|
|
1465
|
+
])
|
|
1466
|
+
}
|
|
1467
|
+
/** One account's block: its name when there is more than one, then its windows. */
|
|
1468
|
+
const accountGroup = (account) => {
|
|
1469
|
+
const limits = account.limits ?? []
|
|
1470
|
+
const known = new Map(limits.map((limit) => [String(limit.type), limit]))
|
|
1471
|
+
const windows = [
|
|
1472
|
+
...USAGE_WINDOWS.filter(([type]) => known.has(type)).map(([type, key]) => [type, key, known.get(type)]),
|
|
1473
|
+
...limits
|
|
1474
|
+
.filter((limit) => !USAGE_WINDOWS.some(([type]) => type === String(limit.type)))
|
|
1475
|
+
.map((limit) => [String(limit.type), '', limit]),
|
|
1476
|
+
]
|
|
1477
|
+
const inner = account.ok !== true
|
|
1478
|
+
? h('div', { key: 'err', className: 'cp-muted' }, `${t('usageUnavailable')}${account.error ? ` ${account.error}` : ''}`)
|
|
1479
|
+
: windows.length === 0
|
|
1480
|
+
? h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
|
|
1481
|
+
: h('div', { key: 'bars', className: 'cp-usage' }, windows.map(([type, key, limit]) => windowRow(type, key, limit)))
|
|
1482
|
+
return h('div', { key: account.account, className: 'cp-usage-group' }, [
|
|
1483
|
+
accounts.length > 1 ? h('div', { key: 'name', className: 'cp-usage-account' }, account.account) : null,
|
|
1484
|
+
inner,
|
|
1485
|
+
])
|
|
1486
|
+
}
|
|
1487
|
+
let body
|
|
1488
|
+
if (!fetched) body = h('div', { key: 'wait', className: 'cp-muted' }, unavailable === true ? t('usageUnavailable') : t('usageLoading'))
|
|
1489
|
+
else if (accounts.length === 0) body = h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
|
|
1490
|
+
else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accounts.map(accountGroup))
|
|
1491
|
+
const title = accounts.length > 1
|
|
1492
|
+
? `${t('usageTitle')} · ${fill(t('accountsCount'), { n: accounts.length })}`
|
|
1493
|
+
: `${t('usageTitle')}${accounts[0] ? ` · ${fill(t('usageAccount'), { name: accounts[0].account })}` : ''}`
|
|
1494
|
+
return h('div', { className: 'cp-card' }, [
|
|
1495
|
+
h('div', { key: 'title', className: 'cp-card-title' }, [
|
|
1496
|
+
h('span', { key: 'l' }, title),
|
|
1497
|
+
h('span', { key: 'btns', className: 'cp-inline' }, [
|
|
1498
|
+
h('button', { key: 'r', type: 'button', className: 'cp-btn', disabled, onClick: () => actions.loadUsage(true) }, t('usageRefresh')),
|
|
1499
|
+
]),
|
|
1500
|
+
]),
|
|
1501
|
+
body,
|
|
1502
|
+
])
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* The full panel: the Settings page body, and the collapsed card's content.
|
|
1507
|
+
*
|
|
1508
|
+
* `hideTitle` drops the panel's own heading where the surrounding chrome
|
|
1509
|
+
* already names the provider — the Plugins card's header does.
|
|
1510
|
+
*/
|
|
765
1511
|
function Panel(props) {
|
|
766
1512
|
const t = props.t
|
|
767
1513
|
const state = props.useClinePass((snapshot) => snapshot)
|
|
1514
|
+
/** The last history read: `{ entries, total }`, or null before the first. */
|
|
768
1515
|
const [history, setHistory] = React.useState(null)
|
|
1516
|
+
const usageAsked = React.useRef(false)
|
|
1517
|
+
const planAsked = React.useRef(false)
|
|
1518
|
+
/** Set once an automatic quota read has failed, so the card stops saying "loading". */
|
|
1519
|
+
const [readFailed, setReadFailed] = React.useState(false)
|
|
1520
|
+
/** The window measurement already requested in this mount: one attempt each. */
|
|
1521
|
+
const windowsAsked = React.useRef(-1)
|
|
769
1522
|
|
|
770
1523
|
// Read once on mount, while there is nothing to render. The dependency is
|
|
771
1524
|
// the loaded data itself, not the status: a failed read leaves `data`
|
|
@@ -775,9 +1528,34 @@ window.__ModuleLoader__.load({
|
|
|
775
1528
|
if (state.data === null) props.refresh()
|
|
776
1529
|
}, [state.data])
|
|
777
1530
|
|
|
1531
|
+
// Quota is one request per mount, not per render: the reading is cached
|
|
1532
|
+
// host-side, so reopening the card shows the last one immediately. A route
|
|
1533
|
+
// with no usable key behind it has nothing to read, so it is not asked.
|
|
1534
|
+
React.useEffect(() => {
|
|
1535
|
+
if (usageAsked.current || state.data === null || state.data.ready !== true) return
|
|
1536
|
+
usageAsked.current = true
|
|
1537
|
+
void props.loadUsage().then((result) => setReadFailed(result === undefined))
|
|
1538
|
+
}, [state.data])
|
|
1539
|
+
|
|
1540
|
+
// The history section follows the host's retained count: every panel read
|
|
1541
|
+
// carries it, so the rows are re-read whenever new requests landed instead
|
|
1542
|
+
// of going stale the moment the first read completed.
|
|
778
1543
|
React.useEffect(() => {
|
|
779
|
-
if (state.
|
|
780
|
-
|
|
1544
|
+
if (state.data === null) return
|
|
1545
|
+
let cancelled = false
|
|
1546
|
+
void props.loadHistory(25).then((result) => {
|
|
1547
|
+
if (cancelled || result === undefined) return
|
|
1548
|
+
setHistory({ entries: result.entries ?? [], total: Number(result.total ?? 0) })
|
|
1549
|
+
})
|
|
1550
|
+
return () => { cancelled = true }
|
|
1551
|
+
}, [state.data?.historySize])
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
const rememberHistory = (result) => {
|
|
1556
|
+
if (result !== undefined) setHistory({ entries: result.entries ?? [], total: Number(result.total ?? 0) })
|
|
1557
|
+
return result
|
|
1558
|
+
}
|
|
781
1559
|
|
|
782
1560
|
const actions = {
|
|
783
1561
|
refresh: props.refresh,
|
|
@@ -787,25 +1565,26 @@ window.__ModuleLoader__.load({
|
|
|
787
1565
|
addAccount: props.addAccount,
|
|
788
1566
|
removeAccount: props.removeAccount,
|
|
789
1567
|
setAccountMode: props.setAccountMode,
|
|
1568
|
+
setAccountEnabled: props.setAccountEnabled,
|
|
790
1569
|
pinModel: props.pinModel,
|
|
791
|
-
|
|
1570
|
+
setModelVisible: props.setModelVisible,
|
|
1571
|
+
setModelsVisibility: props.setModelsVisibility,
|
|
792
1572
|
probeModel: props.probeModel,
|
|
793
1573
|
validateModel: props.validateModel,
|
|
794
1574
|
testModel: props.testModel,
|
|
795
1575
|
resetModel: props.resetModel,
|
|
796
1576
|
refreshModels: props.refreshModels,
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
if (result !== undefined) setHistory(result.entries ?? [])
|
|
800
|
-
},
|
|
1577
|
+
loadUsage: (explicit) => props.loadUsage(explicit),
|
|
1578
|
+
loadHistory: async (limit) => rememberHistory(await props.loadHistory(limit)),
|
|
801
1579
|
}
|
|
802
1580
|
|
|
803
1581
|
const data = state.data
|
|
804
1582
|
const disabled = state.busy !== null || (data !== null && data.settingsAvailable === false)
|
|
805
1583
|
const ready = data?.ready === true
|
|
1584
|
+
const total = Number(data?.historySize ?? history?.total ?? 0)
|
|
806
1585
|
|
|
807
1586
|
return h('div', { className: 'cp-panel' }, [
|
|
808
|
-
h('h2', { key: 'title', className: 'cp-title' }, t('title')),
|
|
1587
|
+
props.hideTitle === true ? null : h('h2', { key: 'title', className: 'cp-title' }, t('title')),
|
|
809
1588
|
// One status line replaces the old route/baseURL block: the endpoint is
|
|
810
1589
|
// not something a user sets, so showing it was pure noise.
|
|
811
1590
|
h('div', { key: 'status', className: 'cp-row cp-muted' }, [
|
|
@@ -820,15 +1599,73 @@ window.__ModuleLoader__.load({
|
|
|
820
1599
|
state.status === 'loading' && data === null
|
|
821
1600
|
? h('div', { key: 'loading', className: 'cp-muted' }, t('busy'))
|
|
822
1601
|
: null,
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1602
|
+
// Accounts first, quota second: the account is what has to be set up
|
|
1603
|
+
// before any window reading means anything, and a quota card with no key
|
|
1604
|
+
// behind it has nothing to say.
|
|
1605
|
+
data === null ? null : h(AccountsCard, { key: 'accounts', t, data, disabled, ready, actions }),
|
|
1606
|
+
data === null || !ready ? null : h(UsageCard, {
|
|
1607
|
+
key: 'usage',
|
|
1608
|
+
t,
|
|
1609
|
+
usage: data.usage,
|
|
1610
|
+
unavailable: readFailed,
|
|
1611
|
+
disabled,
|
|
1612
|
+
actions,
|
|
1613
|
+
}),
|
|
1614
|
+
// Everything below the accounts card reads through the key that card
|
|
1615
|
+
// stores, so none of it exists until there is one.
|
|
1616
|
+
data === null || !ready ? null : h(ModelsCard, { key: 'models', t, data, disabled, busy: state.busy, actions }),
|
|
827
1617
|
// Diagnostic detail, so it stays folded away until asked for.
|
|
828
|
-
h('details', { key: 'history', className: 'cp-card' }, [
|
|
829
|
-
|
|
830
|
-
h(HistoryCard, {
|
|
1618
|
+
data === null || !ready ? null : h('details', { key: 'history', className: 'cp-card' }, [
|
|
1619
|
+
foldHeader(`${t('history')} · ${total}`),
|
|
1620
|
+
h(HistoryCard, {
|
|
1621
|
+
key: 'body',
|
|
1622
|
+
t,
|
|
1623
|
+
entries: history?.entries ?? null,
|
|
1624
|
+
disabled: state.busy !== null,
|
|
1625
|
+
// Which account served a request only means something in a pool.
|
|
1626
|
+
showAccount: Number(data?.accounts?.length ?? 0) > 1,
|
|
1627
|
+
actions,
|
|
1628
|
+
}),
|
|
1629
|
+
]),
|
|
1630
|
+
])
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* The Plugins settings tab: the same panel, folded away behind a header row.
|
|
1635
|
+
*
|
|
1636
|
+
* The section that owns `settings.plugins.tab` mounts each contribution
|
|
1637
|
+
* inside a `div[role=tabpanel]` rather than a card list, so this renders a
|
|
1638
|
+
* `div` — an `li` outside a `ul` would be invalid there.
|
|
1639
|
+
*
|
|
1640
|
+
* Disclosure is card-local state, re-read on every mount, exactly like the
|
|
1641
|
+
* host's own cards — and because the panel only mounts once opened, a folded
|
|
1642
|
+
* card costs no round trip.
|
|
1643
|
+
*/
|
|
1644
|
+
function PluginSettingsCard(props) {
|
|
1645
|
+
const t = props.t
|
|
1646
|
+
const [open, setOpen] = React.useState(false)
|
|
1647
|
+
const title = t('title')
|
|
1648
|
+
return h('div', { className: `cp-set-card${open ? ' cp-set-card-open' : ''}` }, [
|
|
1649
|
+
h('button', {
|
|
1650
|
+
key: 'head',
|
|
1651
|
+
type: 'button',
|
|
1652
|
+
className: 'cp-set-header',
|
|
1653
|
+
'aria-expanded': open,
|
|
1654
|
+
'aria-label': `${t(open ? 'collapse' : 'expand')}: ${title}`,
|
|
1655
|
+
onClick: () => setOpen(!open),
|
|
1656
|
+
}, [
|
|
1657
|
+
h('span', { key: 'text', className: 'cp-set-head-text' }, [
|
|
1658
|
+
h('span', { key: 'name', className: 'cp-set-name' }, title),
|
|
1659
|
+
h('span', { key: 'desc', className: 'cp-set-desc' }, t('description')),
|
|
1660
|
+
]),
|
|
1661
|
+
h(Chevron, {
|
|
1662
|
+
key: 'chevron',
|
|
1663
|
+
className: `cp-set-chevron${open ? ' cp-set-chevron-open' : ''}`,
|
|
1664
|
+
}),
|
|
831
1665
|
]),
|
|
1666
|
+
open
|
|
1667
|
+
? h('div', { key: 'body', className: 'cp-set-body' }, h(Panel, { ...props, hideTitle: true }))
|
|
1668
|
+
: null,
|
|
832
1669
|
])
|
|
833
1670
|
}
|
|
834
1671
|
|
|
@@ -902,29 +1739,28 @@ window.__ModuleLoader__.load({
|
|
|
902
1739
|
return controller.t(key, params)
|
|
903
1740
|
}
|
|
904
1741
|
|
|
905
|
-
const
|
|
1742
|
+
const renderPluginCard = (props) => h(PluginSettingsCard, { ...props, ...face, t: translate })
|
|
906
1743
|
const renderQuick = (props) => h(QuickCard, { ...props, ...face, t: translate })
|
|
907
1744
|
|
|
908
|
-
// 1.
|
|
909
|
-
|
|
910
|
-
|
|
1745
|
+
// 1. The one configuration surface: a tab inside the Plugins settings
|
|
1746
|
+
// section, which already enumerates this namespace. No
|
|
1747
|
+
// `settings.section` alongside it — a second page in the Settings nav
|
|
1748
|
+
// would only duplicate this panel, and the nav is for surfaces that are
|
|
1749
|
+
// not already listed there.
|
|
1750
|
+
//
|
|
1751
|
+
// `settings.plugins.tab` is a list slot: its key is `id`, its position
|
|
1752
|
+
// is `order` (the host's own inventory tab sits at 10), and its tab
|
|
1753
|
+
// label is a thunk so it follows the active locale.
|
|
1754
|
+
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
1755
|
+
name: 'settings.plugins.tab',
|
|
911
1756
|
id: NS,
|
|
912
|
-
order:
|
|
1757
|
+
order: 20,
|
|
913
1758
|
label: () => translate('title'),
|
|
914
1759
|
locale: NS,
|
|
915
1760
|
inject: face,
|
|
916
|
-
},
|
|
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))
|
|
1761
|
+
}, renderPluginCard))
|
|
926
1762
|
|
|
927
|
-
//
|
|
1763
|
+
// 2. A compact key card on the Models page, right where the missing key
|
|
928
1764
|
// for this route is reported.
|
|
929
1765
|
ctx.slots.inject('settings.models.provider-card', () => ctx.slots.register({
|
|
930
1766
|
name: 'settings.models.provider-card',
|