dsh-qoder-connect 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +147 -0
- package/README.md +148 -0
- package/assets/1.png +0 -0
- package/assets/2.png +0 -0
- package/assets/3.png +0 -0
- package/assets/4.png +0 -0
- package/assets/5.png +0 -0
- package/assets/6.png +0 -0
- package/cordis.patch.yml +6 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.js +291 -0
- package/lib/client.js +3545 -0
- package/lib/index.d.ts +1371 -0
- package/lib/index.js +1961 -0
- package/lib/variants-jbFhMKO8.js +4082 -0
- package/package.json +116 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,3545 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-qoder-connect",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
//#region src/status-paths.ts
|
|
10
|
+
/** Plugin-owned status endpoint consumed by its browser half. */
|
|
11
|
+
const QODER_STATUS_PATH = "/plugins/dsh-qoder-connect/status";
|
|
12
|
+
/**
|
|
13
|
+
* Plugin-owned probe control endpoint.
|
|
14
|
+
*
|
|
15
|
+
* Separate from the status route because it accepts writes: the status route's
|
|
16
|
+
* loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
|
|
17
|
+
* not the same as authorizing a state-changing action. This route therefore
|
|
18
|
+
* also requires the in-process key the browser half receives with the status
|
|
19
|
+
* document.
|
|
20
|
+
*/
|
|
21
|
+
const QODER_PROBE_PATH = "/plugins/dsh-qoder-connect/probe";
|
|
22
|
+
/**
|
|
23
|
+
* Plugin-owned PAT endpoint, one per variant.
|
|
24
|
+
*
|
|
25
|
+
* A POST here saves (validates and persists) a Personal Access Token, or
|
|
26
|
+
* clears the stored one. Unlike the read-only status GET it is a write —
|
|
27
|
+
* it persists a credential — so it also requires the in-process key the
|
|
28
|
+
* browser half receives with the status document.
|
|
29
|
+
*/
|
|
30
|
+
const QODER_AUTH_PATH = "/plugins/dsh-qoder-connect/auth";
|
|
31
|
+
/**
|
|
32
|
+
* The international (Qoder Global) variant's own triple of routes.
|
|
33
|
+
*
|
|
34
|
+
* Kept as separate constants rather than a computed suffix so both halves
|
|
35
|
+
* reference literal strings: the browser bundle and the host bundle are built
|
|
36
|
+
* independently, and a shared expression is one build-config drift away from
|
|
37
|
+
* the desk asking a route the host never mounted.
|
|
38
|
+
*/
|
|
39
|
+
const QODER_GLOBAL_STATUS_PATH = "/plugins/dsh-qoder-connect/global/status";
|
|
40
|
+
const QODER_GLOBAL_PROBE_PATH = "/plugins/dsh-qoder-connect/global/probe";
|
|
41
|
+
const QODER_GLOBAL_AUTH_PATH = "/plugins/dsh-qoder-connect/global/auth";
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/client/status-document.ts
|
|
44
|
+
/**
|
|
45
|
+
* Whether a parsed status response really is a status document.
|
|
46
|
+
*
|
|
47
|
+
* A 200 is not a promise about the body: it may be empty, literal `null`, a
|
|
48
|
+
* non-JSON page from a proxy, or an array. Both halves of the browser plugin
|
|
49
|
+
* read the same route, so both must agree on what is valid — storing an
|
|
50
|
+
* unreadable value puts something in state that the next render dereferences.
|
|
51
|
+
*
|
|
52
|
+
* The check is deliberately limited to the discriminator (plus `error`'s
|
|
53
|
+
* `message`, which the error paragraph renders): validating optional fields
|
|
54
|
+
* here would reject documents the host legitimately omits fields from.
|
|
55
|
+
*/
|
|
56
|
+
function isQoderWebStatus(value) {
|
|
57
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
58
|
+
const wrapped = value;
|
|
59
|
+
const status = wrapped["status"];
|
|
60
|
+
if (status === "signed-out" || status === "signed-in") return true;
|
|
61
|
+
return status === "error" && typeof wrapped["message"] === "string";
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/client/QoderPluginCard.tsx
|
|
65
|
+
/** Qoder status card contributed to Harness Plugin configuration. */
|
|
66
|
+
/** China Qoder; the plugin's primary card and default. */
|
|
67
|
+
const QODER_CN_CARD = {
|
|
68
|
+
id: "qoder",
|
|
69
|
+
titleKey: "title",
|
|
70
|
+
introKey: "intro",
|
|
71
|
+
signedOutKey: "signedOutHint",
|
|
72
|
+
patGuideKey: "patGuide",
|
|
73
|
+
patPlaceholderKey: "patPlaceholder",
|
|
74
|
+
statusPath: QODER_STATUS_PATH,
|
|
75
|
+
probePath: QODER_PROBE_PATH,
|
|
76
|
+
authPath: QODER_AUTH_PATH
|
|
77
|
+
};
|
|
78
|
+
/** Both cards, in display order (China first). */
|
|
79
|
+
const QODER_CARD_VARIANTS = [QODER_CN_CARD, {
|
|
80
|
+
id: "qoder-global",
|
|
81
|
+
titleKey: "titleAI",
|
|
82
|
+
introKey: "introAI",
|
|
83
|
+
signedOutKey: "signedOutHintAI",
|
|
84
|
+
patGuideKey: "patGuideAI",
|
|
85
|
+
patPlaceholderKey: "patPlaceholderAI",
|
|
86
|
+
statusPath: QODER_GLOBAL_STATUS_PATH,
|
|
87
|
+
probePath: QODER_GLOBAL_PROBE_PATH,
|
|
88
|
+
authPath: QODER_GLOBAL_AUTH_PATH
|
|
89
|
+
}];
|
|
90
|
+
const POLL_INTERVAL_MS = 6e4;
|
|
91
|
+
const cardStyle$1 = {
|
|
92
|
+
listStyle: "none",
|
|
93
|
+
borderWidth: "0.5px",
|
|
94
|
+
borderStyle: "solid",
|
|
95
|
+
borderColor: "var(--dsw-alias-border-l4)",
|
|
96
|
+
borderRadius: 16,
|
|
97
|
+
background: "var(--dsw-alias-bg-layer-3)",
|
|
98
|
+
transition: "border-color .16s, background .16s"
|
|
99
|
+
};
|
|
100
|
+
/** Hover, matching the built-in card's `:hover`. Inline styles cannot express a pseudo-class. */
|
|
101
|
+
const cardHoverStyle$1 = { borderColor: "var(--dsw-alias-label-dimmed)" };
|
|
102
|
+
/** Expanded, matching the built-in card's open state. */
|
|
103
|
+
const cardOpenStyle$1 = {
|
|
104
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
105
|
+
borderColor: "var(--dsw-alias-label-dimmed)"
|
|
106
|
+
};
|
|
107
|
+
const headerStyle = {
|
|
108
|
+
boxSizing: "border-box",
|
|
109
|
+
width: "100%",
|
|
110
|
+
display: "flex",
|
|
111
|
+
alignItems: "center",
|
|
112
|
+
gap: 12,
|
|
113
|
+
border: 0,
|
|
114
|
+
borderRadius: 12,
|
|
115
|
+
padding: "14px 16px",
|
|
116
|
+
background: "transparent",
|
|
117
|
+
color: "inherit",
|
|
118
|
+
font: "inherit",
|
|
119
|
+
textAlign: "left",
|
|
120
|
+
cursor: "pointer",
|
|
121
|
+
appearance: "none"
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* The built-in header's keyboard focus ring.
|
|
125
|
+
*
|
|
126
|
+
* `:focus-visible` is what makes the ring appear for keyboard navigation but not
|
|
127
|
+
* for a mouse click, and an inline style cannot express a pseudo-class — so the
|
|
128
|
+
* component tracks it and applies this instead. Without it the header falls back
|
|
129
|
+
* to the browser's own outline, which is the black box that used to appear on
|
|
130
|
+
* focus where the built-in card shows a brand-coloured ring.
|
|
131
|
+
*/
|
|
132
|
+
const headerFocusStyle$1 = {
|
|
133
|
+
outline: "2px solid var(--dsw-alias-brand-primary)",
|
|
134
|
+
outlineOffset: -2
|
|
135
|
+
};
|
|
136
|
+
const headTextStyle$1 = {
|
|
137
|
+
display: "flex",
|
|
138
|
+
flex: 1,
|
|
139
|
+
minWidth: 0,
|
|
140
|
+
flexDirection: "column",
|
|
141
|
+
gap: 4
|
|
142
|
+
};
|
|
143
|
+
const nameStyle = {
|
|
144
|
+
fontSize: 15,
|
|
145
|
+
lineHeight: 1.4,
|
|
146
|
+
fontWeight: 600,
|
|
147
|
+
color: "var(--dsw-alias-label-primary)"
|
|
148
|
+
};
|
|
149
|
+
const descriptionStyle = {
|
|
150
|
+
fontSize: 13,
|
|
151
|
+
lineHeight: 1.5,
|
|
152
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* The disclosure chevron, drawn to match the Settings panel's own card.
|
|
156
|
+
*
|
|
157
|
+
* The built-in card renders `IconChevronDownOutline14` from the client's shared
|
|
158
|
+
* icon catalog, which the shell seeds into the module table. This plugin does
|
|
159
|
+
* not request that catalog, so the same outline is drawn here from the same path
|
|
160
|
+
* data: the text `⌄` glyph this replaces had a different shape, weight, and
|
|
161
|
+
* baseline from the icon the cards beside it use.
|
|
162
|
+
*/
|
|
163
|
+
function ChevronDownIcon$1() {
|
|
164
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
165
|
+
width: 14,
|
|
166
|
+
height: 14,
|
|
167
|
+
viewBox: "0 0 14 14",
|
|
168
|
+
fill: "none",
|
|
169
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
170
|
+
"aria-hidden": "true",
|
|
171
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
172
|
+
d: "M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z",
|
|
173
|
+
fill: "currentColor"
|
|
174
|
+
})
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** The built-in card's chevron rule: tertiary color, and only the rotation animates. */
|
|
178
|
+
const chevronStyle$1 = {
|
|
179
|
+
flex: "none",
|
|
180
|
+
display: "flex",
|
|
181
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
182
|
+
transition: "transform .16s"
|
|
183
|
+
};
|
|
184
|
+
const cardBodyStyle$1 = {
|
|
185
|
+
borderTop: ".5px solid var(--dsw-alias-border-l2)",
|
|
186
|
+
margin: "0 16px",
|
|
187
|
+
padding: "12px 0 8px"
|
|
188
|
+
};
|
|
189
|
+
const bodyStyle = {
|
|
190
|
+
margin: 0,
|
|
191
|
+
fontSize: 13,
|
|
192
|
+
lineHeight: 1.5,
|
|
193
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
194
|
+
};
|
|
195
|
+
const rowStyle$1 = {
|
|
196
|
+
display: "flex",
|
|
197
|
+
alignItems: "center",
|
|
198
|
+
justifyContent: "space-between",
|
|
199
|
+
flexWrap: "wrap",
|
|
200
|
+
gap: 12
|
|
201
|
+
};
|
|
202
|
+
const statusStyle = {
|
|
203
|
+
display: "flex",
|
|
204
|
+
alignItems: "center",
|
|
205
|
+
gap: 8,
|
|
206
|
+
fontSize: 13,
|
|
207
|
+
fontWeight: 500,
|
|
208
|
+
lineHeight: 1.5,
|
|
209
|
+
color: "var(--dsw-alias-label-primary)"
|
|
210
|
+
};
|
|
211
|
+
/** The built-in secondary button: transparent, hairline border, 8px radius. */
|
|
212
|
+
const buttonStyle$1 = {
|
|
213
|
+
boxSizing: "border-box",
|
|
214
|
+
padding: "5px 14px",
|
|
215
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
216
|
+
borderRadius: 8,
|
|
217
|
+
background: "transparent",
|
|
218
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
219
|
+
font: "inherit",
|
|
220
|
+
fontSize: 13,
|
|
221
|
+
lineHeight: 1.5,
|
|
222
|
+
cursor: "pointer"
|
|
223
|
+
};
|
|
224
|
+
const errorStyle = {
|
|
225
|
+
...bodyStyle,
|
|
226
|
+
color: "var(--dsw-alias-state-error-primary)"
|
|
227
|
+
};
|
|
228
|
+
const quotaListStyle = {
|
|
229
|
+
display: "flex",
|
|
230
|
+
flexDirection: "column",
|
|
231
|
+
gap: 18,
|
|
232
|
+
paddingTop: 2
|
|
233
|
+
};
|
|
234
|
+
const quotaGroupStyle = {
|
|
235
|
+
display: "flex",
|
|
236
|
+
flexDirection: "column",
|
|
237
|
+
gap: 10
|
|
238
|
+
};
|
|
239
|
+
const quotaTitleStyle = {
|
|
240
|
+
margin: 0,
|
|
241
|
+
fontSize: 13,
|
|
242
|
+
lineHeight: 1.5,
|
|
243
|
+
fontWeight: 600,
|
|
244
|
+
color: "var(--dsw-alias-label-primary)"
|
|
245
|
+
};
|
|
246
|
+
const quotaLabelStyle = {
|
|
247
|
+
display: "flex",
|
|
248
|
+
justifyContent: "space-between",
|
|
249
|
+
gap: 12,
|
|
250
|
+
fontSize: 13,
|
|
251
|
+
lineHeight: 1.5,
|
|
252
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
253
|
+
};
|
|
254
|
+
const modelRateStyle = {
|
|
255
|
+
fontSize: 12,
|
|
256
|
+
lineHeight: 1.5,
|
|
257
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
258
|
+
};
|
|
259
|
+
const contextPreferenceStyle = {
|
|
260
|
+
display: "flex",
|
|
261
|
+
alignItems: "flex-start",
|
|
262
|
+
gap: 9,
|
|
263
|
+
padding: "10px 12px",
|
|
264
|
+
border: ".5px solid var(--dsw-alias-border-l4)",
|
|
265
|
+
borderRadius: 8,
|
|
266
|
+
background: "var(--dsw-alias-bg-layer-3)",
|
|
267
|
+
color: "var(--dsw-alias-label-primary)",
|
|
268
|
+
fontSize: 13,
|
|
269
|
+
lineHeight: 1.5
|
|
270
|
+
};
|
|
271
|
+
const contextPreferenceCopyStyle = {
|
|
272
|
+
display: "flex",
|
|
273
|
+
flexDirection: "column",
|
|
274
|
+
gap: 2
|
|
275
|
+
};
|
|
276
|
+
/** The right-hand cell of one model row: value and its note on one line. */
|
|
277
|
+
const contextPickerRowStyle = {
|
|
278
|
+
display: "flex",
|
|
279
|
+
alignItems: "center",
|
|
280
|
+
justifyContent: "flex-end",
|
|
281
|
+
gap: 8,
|
|
282
|
+
flexWrap: "wrap"
|
|
283
|
+
};
|
|
284
|
+
const progressTrackStyle = {
|
|
285
|
+
height: 8,
|
|
286
|
+
overflow: "hidden",
|
|
287
|
+
borderRadius: 999,
|
|
288
|
+
background: "var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.08))"
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* The PAT entry row: the password field takes the row's flexible width, the
|
|
292
|
+
* Save button keeps its own, and the whole block sits inside the card body
|
|
293
|
+
* without a nested box (the same rule the settings rows follow).
|
|
294
|
+
*/
|
|
295
|
+
const patRowStyle = {
|
|
296
|
+
display: "flex",
|
|
297
|
+
alignItems: "center",
|
|
298
|
+
gap: 8,
|
|
299
|
+
flexWrap: "wrap"
|
|
300
|
+
};
|
|
301
|
+
const patInputStyle = {
|
|
302
|
+
boxSizing: "border-box",
|
|
303
|
+
flex: 1,
|
|
304
|
+
minWidth: 200,
|
|
305
|
+
padding: "6px 10px",
|
|
306
|
+
borderWidth: "1px",
|
|
307
|
+
borderStyle: "solid",
|
|
308
|
+
borderColor: "var(--dsw-alias-border-l2)",
|
|
309
|
+
borderRadius: 8,
|
|
310
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
311
|
+
color: "var(--dsw-alias-label-primary)",
|
|
312
|
+
font: "inherit",
|
|
313
|
+
fontSize: 13,
|
|
314
|
+
lineHeight: 1.5
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Tab strip for the card body. Kept visually light — a full pill would compete
|
|
318
|
+
* with the section headings, and the card is already the densest surface the
|
|
319
|
+
* plugin owns.
|
|
320
|
+
*/
|
|
321
|
+
const tabBarStyle = {
|
|
322
|
+
display: "flex",
|
|
323
|
+
gap: 4,
|
|
324
|
+
marginTop: 4,
|
|
325
|
+
borderBottom: "1px solid var(--dsw-alias-border-l2)"
|
|
326
|
+
};
|
|
327
|
+
const tabStyle = {
|
|
328
|
+
padding: "6px 12px",
|
|
329
|
+
border: 0,
|
|
330
|
+
borderBottom: "2px solid transparent",
|
|
331
|
+
background: "transparent",
|
|
332
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
333
|
+
font: "inherit",
|
|
334
|
+
fontSize: 13,
|
|
335
|
+
lineHeight: "20px",
|
|
336
|
+
cursor: "pointer"
|
|
337
|
+
};
|
|
338
|
+
const tabActiveStyle = {
|
|
339
|
+
borderBottom: "2px solid var(--dsw-alias-brand-primary)",
|
|
340
|
+
color: "var(--dsw-alias-label-primary)",
|
|
341
|
+
fontWeight: 600
|
|
342
|
+
};
|
|
343
|
+
const tabPanelStyle = {
|
|
344
|
+
display: "flex",
|
|
345
|
+
flexDirection: "column",
|
|
346
|
+
gap: 18,
|
|
347
|
+
paddingTop: 16
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Primary action of the inline confirmation and of the PAT save. Fill and text
|
|
351
|
+
* colour come from the theme as a pair: `brand-primary` is a light accent here,
|
|
352
|
+
* so pairing it with a hardcoded white would render white-on-white.
|
|
353
|
+
*/
|
|
354
|
+
const primaryButtonStyle$1 = {
|
|
355
|
+
...buttonStyle$1,
|
|
356
|
+
border: "1px solid var(--dsw-alias-button-primary-fill)",
|
|
357
|
+
background: "var(--dsw-alias-button-primary-fill)",
|
|
358
|
+
color: "var(--dsw-alias-label-primary-foreground)"
|
|
359
|
+
};
|
|
360
|
+
function progressFillStyle(percent) {
|
|
361
|
+
return {
|
|
362
|
+
width: `${Math.max(0, Math.min(100, percent))}%`,
|
|
363
|
+
height: "100%",
|
|
364
|
+
borderRadius: "inherit",
|
|
365
|
+
background: "var(--dsw-alias-brand-primary, #1677ff)"
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Status dot colour. Takes `'loading'` as well as the document's own states:
|
|
370
|
+
* before the first response the card knows nothing about the account, so it must
|
|
371
|
+
* not borrow the signed-out grey — that would read as "nothing is wrong, nobody
|
|
372
|
+
* is signed in" when the truth is "not read yet".
|
|
373
|
+
*/
|
|
374
|
+
function dotStyle(status) {
|
|
375
|
+
return {
|
|
376
|
+
width: 9,
|
|
377
|
+
height: 9,
|
|
378
|
+
borderRadius: "50%",
|
|
379
|
+
flex: "0 0 auto",
|
|
380
|
+
background: status === "signed-in" ? "var(--dsw-alias-state-success-primary, #22a06b)" : status === "error" ? "var(--dsw-alias-state-error-primary, #d92d20)" : "var(--dsw-alias-label-dimmed, #9aa0a6)"
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function formatNumber(value) {
|
|
384
|
+
return new Intl.NumberFormat(void 0).format(value);
|
|
385
|
+
}
|
|
386
|
+
function formatPercent(value) {
|
|
387
|
+
return new Intl.NumberFormat(void 0, { maximumFractionDigits: 1 }).format(value);
|
|
388
|
+
}
|
|
389
|
+
function formatTime(ms) {
|
|
390
|
+
return new Intl.DateTimeFormat(void 0, {
|
|
391
|
+
dateStyle: "medium",
|
|
392
|
+
timeStyle: "short"
|
|
393
|
+
}).format(new Date(ms));
|
|
394
|
+
}
|
|
395
|
+
function formatCycleReset(time) {
|
|
396
|
+
const parsed = Date.parse(time);
|
|
397
|
+
if (!Number.isNaN(parsed)) return formatTime(parsed);
|
|
398
|
+
return time;
|
|
399
|
+
}
|
|
400
|
+
/** Localize where the credential in effect came from. */
|
|
401
|
+
function patSourceText(source, t) {
|
|
402
|
+
if (source === "env") return t("patSourceEnv");
|
|
403
|
+
if (source === "cli") return t("patSourceCli");
|
|
404
|
+
return t("patSourceCard");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* One billing package as a labeled progress bar.
|
|
408
|
+
*
|
|
409
|
+
* A package whose allowance the upstream never reported (`size` not positive)
|
|
410
|
+
* has no percentage to state. It must not fall back to 100%: the plugin would be
|
|
411
|
+
* claiming a full quota it knows nothing about, which is the opposite of the
|
|
412
|
+
* honest "remaining N" line printed below it. Unknown size therefore renders the
|
|
413
|
+
* percent slot as unknown copy and an unfilled, indeterminate track.
|
|
414
|
+
*/
|
|
415
|
+
function CreditBar({ label, remain, size, unlimited, packageEndTime, t }) {
|
|
416
|
+
const expiry = packageEndTime === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
417
|
+
style: modelRateStyle,
|
|
418
|
+
children: [
|
|
419
|
+
t("quotaExpires"),
|
|
420
|
+
" ",
|
|
421
|
+
formatCycleReset(packageEndTime)
|
|
422
|
+
]
|
|
423
|
+
});
|
|
424
|
+
if (unlimited === true) {
|
|
425
|
+
const quotaText = t("unlimitedQuota");
|
|
426
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
427
|
+
style: quotaGroupStyle,
|
|
428
|
+
children: [
|
|
429
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
430
|
+
style: quotaLabelStyle,
|
|
431
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: quotaText })]
|
|
432
|
+
}),
|
|
433
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
434
|
+
style: progressTrackStyle,
|
|
435
|
+
role: "progressbar",
|
|
436
|
+
"aria-label": label,
|
|
437
|
+
"aria-valuetext": quotaText
|
|
438
|
+
}),
|
|
439
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
440
|
+
style: bodyStyle,
|
|
441
|
+
children: quotaText
|
|
442
|
+
}),
|
|
443
|
+
expiry
|
|
444
|
+
]
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const sizeKnown = size > 0;
|
|
448
|
+
const detail = sizeKnown ? t("exactRemaining", {
|
|
449
|
+
remain: formatNumber(remain),
|
|
450
|
+
size: formatNumber(size)
|
|
451
|
+
}) : t("creditPackageUnknownSize", { remain: formatNumber(remain) });
|
|
452
|
+
const percent = sizeKnown ? remain / size * 100 : void 0;
|
|
453
|
+
const display = percent === void 0 ? t("percentUnknown") : t("percentRemaining", { percent: formatPercent(percent) });
|
|
454
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
455
|
+
style: quotaGroupStyle,
|
|
456
|
+
children: [
|
|
457
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
458
|
+
style: quotaLabelStyle,
|
|
459
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: display })]
|
|
460
|
+
}),
|
|
461
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
462
|
+
style: progressTrackStyle,
|
|
463
|
+
role: "progressbar",
|
|
464
|
+
"aria-label": label,
|
|
465
|
+
...percent === void 0 ? { "aria-valuetext": detail } : {
|
|
466
|
+
"aria-valuemin": 0,
|
|
467
|
+
"aria-valuemax": 100,
|
|
468
|
+
"aria-valuenow": percent
|
|
469
|
+
},
|
|
470
|
+
children: percent === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: progressFillStyle(percent) })
|
|
471
|
+
}),
|
|
472
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
473
|
+
style: bodyStyle,
|
|
474
|
+
children: detail
|
|
475
|
+
}),
|
|
476
|
+
expiry
|
|
477
|
+
]
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/** Largest declared window a model accepts, when it declares alternatives. */
|
|
481
|
+
function maxDeclaredWindow(model) {
|
|
482
|
+
const windows = model.supportedContextWindows ?? [];
|
|
483
|
+
return windows.length > 0 ? Math.max(...windows) : void 0;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Context capacity, listed in full.
|
|
487
|
+
*
|
|
488
|
+
* Every model the upstream reports a capacity for, largest first. A one-line
|
|
489
|
+
* summary with the exceptions on hover was tried and rejected: capacity is
|
|
490
|
+
* reference data you scan by model, and hiding most of it behind a hover made
|
|
491
|
+
* the common case (a model you already have in mind) the hard one to look up.
|
|
492
|
+
*
|
|
493
|
+
* Purely a report of the upstream's own numbers — the middle alternatives the
|
|
494
|
+
* upstream lists between the default and the maximum are display-only (the
|
|
495
|
+
* upstream honours the default and the maximum, nothing between), so the only
|
|
496
|
+
* control is the maximum-window preference below, which flips every eligible
|
|
497
|
+
* model between its default and its largest declared window.
|
|
498
|
+
*/
|
|
499
|
+
function ContextTable({ models, t, useMaximumContextWindow, disabled, onUseMaximumContextWindow }) {
|
|
500
|
+
const known = (models ?? []).filter((model) => model.contextWindow !== void 0).sort((a, b) => b.contextWindow - a.contextWindow);
|
|
501
|
+
const canSelectMaximum = known.some((model) => {
|
|
502
|
+
const max = maxDeclaredWindow(model);
|
|
503
|
+
return max !== void 0 && max > (model.defaultContextWindow ?? model.contextWindow ?? 0);
|
|
504
|
+
});
|
|
505
|
+
const showPreference = onUseMaximumContextWindow !== void 0 && (canSelectMaximum || useMaximumContextWindow === true);
|
|
506
|
+
if (known.length === 0 && !showPreference) return null;
|
|
507
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
508
|
+
style: quotaListStyle,
|
|
509
|
+
children: [
|
|
510
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
511
|
+
style: quotaTitleStyle,
|
|
512
|
+
children: t("contextHeading")
|
|
513
|
+
}),
|
|
514
|
+
showPreference && onUseMaximumContextWindow !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
515
|
+
style: contextPreferenceStyle,
|
|
516
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
517
|
+
type: "checkbox",
|
|
518
|
+
checked: useMaximumContextWindow === true,
|
|
519
|
+
disabled,
|
|
520
|
+
onChange: (event) => {
|
|
521
|
+
onUseMaximumContextWindow(event.currentTarget.checked);
|
|
522
|
+
}
|
|
523
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
524
|
+
style: contextPreferenceCopyStyle,
|
|
525
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("useMaximumContextWindow") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
526
|
+
style: modelRateStyle,
|
|
527
|
+
children: t("useMaximumContextWindowHint")
|
|
528
|
+
})]
|
|
529
|
+
})]
|
|
530
|
+
}) : null,
|
|
531
|
+
known.map((model) => {
|
|
532
|
+
const capacity = model.contextWindow;
|
|
533
|
+
const max = maxDeclaredWindow(model);
|
|
534
|
+
const alternative = max !== void 0 && max > capacity ? max : void 0;
|
|
535
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
536
|
+
style: quotaLabelStyle,
|
|
537
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
538
|
+
style: contextPickerRowStyle,
|
|
539
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTokens(capacity) }), alternative !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
540
|
+
style: modelRateStyle,
|
|
541
|
+
children: t("contextUpTo", { size: formatTokens(alternative) })
|
|
542
|
+
}) : model.defaultContextWindow !== void 0 && model.defaultContextWindow < capacity ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
543
|
+
style: modelRateStyle,
|
|
544
|
+
children: t("contextDefault", { size: formatTokens(model.defaultContextWindow) })
|
|
545
|
+
}) : null]
|
|
546
|
+
})]
|
|
547
|
+
}, model.id);
|
|
548
|
+
})
|
|
549
|
+
]
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Compact token count for display: the catalog's own round numbers (`200000`,
|
|
554
|
+
* `1000000`) read better as `200K` / `1M`, and no precision is lost because
|
|
555
|
+
* these values are always whole thousands.
|
|
556
|
+
*/
|
|
557
|
+
function formatTokens(tokens) {
|
|
558
|
+
if (tokens >= 1e6 && tokens % 1e6 === 0) return `${tokens / 1e6}M`;
|
|
559
|
+
if (tokens >= 1e3 && tokens % 1e3 === 0) return `${tokens / 1e3}K`;
|
|
560
|
+
return String(tokens);
|
|
561
|
+
}
|
|
562
|
+
/** Render Qoder PAT state, quota, catalog, and context capacities as one expandable card. */
|
|
563
|
+
function QoderPluginCard({ t, variant = QODER_CN_CARD }) {
|
|
564
|
+
if (t === void 0) throw new Error("Qoder plugin card requires its translation function");
|
|
565
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
566
|
+
/** Whether the pointer is over the card; drives the same border tint the built-in card gets on hover. */
|
|
567
|
+
const [hovered, setHovered] = (0, react.useState)(false);
|
|
568
|
+
/** Keyboard focus on the header, reproducing the built-in's `:focus-visible` ring. */
|
|
569
|
+
const [headerFocused, setHeaderFocused] = (0, react.useState)(false);
|
|
570
|
+
/**
|
|
571
|
+
* The document to render. `undefined` means *not read yet*, which is a
|
|
572
|
+
* distinct state from "signed out": seeding this with a signed-out document
|
|
573
|
+
* told an already-signed-in user they were signed out for the whole first
|
|
574
|
+
* round trip (and forever, if the read never settled).
|
|
575
|
+
*/
|
|
576
|
+
const [status, setStatus] = (0, react.useState)();
|
|
577
|
+
/**
|
|
578
|
+
* Whether the last **successful** read found a usable credential.
|
|
579
|
+
*
|
|
580
|
+
* Kept apart from `status` because the poll's liveness must depend on what the
|
|
581
|
+
* account actually is, not on what the card last displayed: a failed read
|
|
582
|
+
* leaves this untouched, so a transient failure cannot disarm the interval,
|
|
583
|
+
* while a genuine signed-out answer still stops it.
|
|
584
|
+
*
|
|
585
|
+
* `undefined` therefore means "no successful read yet", which is also the
|
|
586
|
+
* condition that decides whether a failed read has anything to preserve.
|
|
587
|
+
*/
|
|
588
|
+
const [signedIn, setSignedIn] = (0, react.useState)();
|
|
589
|
+
/**
|
|
590
|
+
* Why the most recent read failed, when it did. Rendered as a notice beside
|
|
591
|
+
* whatever document is still on screen, rather than replacing it.
|
|
592
|
+
*/
|
|
593
|
+
const [readFailure, setReadFailure] = (0, react.useState)();
|
|
594
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
595
|
+
/** The PAT draft in the input box. Never persisted; lives only in component state. */
|
|
596
|
+
const [patDraft, setPatDraft] = (0, react.useState)("");
|
|
597
|
+
/** Whether the signed-in card is showing the replace-PAT entry. */
|
|
598
|
+
const [replacing, setReplacing] = (0, react.useState)(false);
|
|
599
|
+
/** Whether a PAT save or clear is in flight. Own flag, so the 60s busy label stays honest. */
|
|
600
|
+
const [patBusy, setPatBusy] = (0, react.useState)(false);
|
|
601
|
+
/** Why the most recent PAT save/clear failed, when it did. */
|
|
602
|
+
const [patError, setPatError] = (0, react.useState)();
|
|
603
|
+
/** The most recent PAT outcome the card should report beside the entry. */
|
|
604
|
+
const [patNotice, setPatNotice] = (0, react.useState)();
|
|
605
|
+
/** The password input, so 「更换 PAT」 can focus and pre-clear it. */
|
|
606
|
+
const patInput = (0, react.useRef)(null);
|
|
607
|
+
const [tab, setTab] = (0, react.useState)("status");
|
|
608
|
+
const mounted = (0, react.useRef)(true);
|
|
609
|
+
/**
|
|
610
|
+
* Identity of the newest read that may write. Assigned when a read *starts*,
|
|
611
|
+
* so a response is superseded by anything begun after it — "the response whose
|
|
612
|
+
* request started last wins". Without this, a slow poll begun before a manual
|
|
613
|
+
* action could settle after the action's own refresh and restore the older
|
|
614
|
+
* document.
|
|
615
|
+
*/
|
|
616
|
+
const readSeq = (0, react.useRef)(0);
|
|
617
|
+
/** Manual requests in flight, so unmount can abort them like the poll's. */
|
|
618
|
+
const manualControllers = (0, react.useRef)(/* @__PURE__ */ new Set());
|
|
619
|
+
(0, react.useEffect)(() => {
|
|
620
|
+
mounted.current = true;
|
|
621
|
+
return () => {
|
|
622
|
+
mounted.current = false;
|
|
623
|
+
for (const controller of manualControllers.current) controller.abort();
|
|
624
|
+
manualControllers.current.clear();
|
|
625
|
+
};
|
|
626
|
+
}, []);
|
|
627
|
+
/** Register a manual request's controller so unmount aborts it. */
|
|
628
|
+
const trackController = (0, react.useCallback)(() => {
|
|
629
|
+
const controller = new AbortController();
|
|
630
|
+
manualControllers.current.add(controller);
|
|
631
|
+
return controller;
|
|
632
|
+
}, []);
|
|
633
|
+
/**
|
|
634
|
+
* The in-process key authorizing this card's PAT writes, or undefined until a
|
|
635
|
+
* document carrying one has been read.
|
|
636
|
+
*
|
|
637
|
+
* Derived once rather than read off each use site: the `error` arm carries no
|
|
638
|
+
* key, and reaching for `status.authKey` in three places is three chances to
|
|
639
|
+
* dereference a state that has none. The constant-time comparison happens on
|
|
640
|
+
* the host; the card only passes the key through.
|
|
641
|
+
*/
|
|
642
|
+
const authKey = status === void 0 || status.status === "error" ? void 0 : status.authKey;
|
|
643
|
+
/**
|
|
644
|
+
* Read the status document and apply it under the two policies the card's
|
|
645
|
+
* correctness rests on:
|
|
646
|
+
*
|
|
647
|
+
* - a non-document body (empty, `null`, a non-JSON page) is a failed read, not
|
|
648
|
+
* something to store and then dereference in the render;
|
|
649
|
+
* - a failed read never discards a document already on screen. It is recorded
|
|
650
|
+
* and shown as a notice beside that document; only when nothing has been
|
|
651
|
+
* read yet does the failure itself become the rendered state.
|
|
652
|
+
*
|
|
653
|
+
* Returns whether this read produced the current document.
|
|
654
|
+
*/
|
|
655
|
+
const refresh = (0, react.useCallback)(async (signal) => {
|
|
656
|
+
const seq = ++readSeq.current;
|
|
657
|
+
const current = () => mounted.current && signal?.aborted !== true && seq === readSeq.current;
|
|
658
|
+
try {
|
|
659
|
+
const response = await fetch(variant.statusPath, {
|
|
660
|
+
headers: { accept: "application/json" },
|
|
661
|
+
credentials: "same-origin",
|
|
662
|
+
...signal === void 0 ? {} : { signal }
|
|
663
|
+
});
|
|
664
|
+
const value = await response.json().catch(() => void 0);
|
|
665
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
666
|
+
if (!isQoderWebStatus(value)) throw new Error(t("statusResponseInvalid"));
|
|
667
|
+
if (!current()) return false;
|
|
668
|
+
setStatus(value);
|
|
669
|
+
if (value.status === "signed-in") setSignedIn(true);
|
|
670
|
+
else if (value.status === "signed-out") setSignedIn(false);
|
|
671
|
+
setReadFailure(void 0);
|
|
672
|
+
return true;
|
|
673
|
+
} catch (error) {
|
|
674
|
+
const message = error instanceof Error ? error.message : t("requestFailed");
|
|
675
|
+
if (current()) {
|
|
676
|
+
setReadFailure(message);
|
|
677
|
+
setStatus((previous) => previous === void 0 ? {
|
|
678
|
+
status: "error",
|
|
679
|
+
message
|
|
680
|
+
} : previous);
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
}, [t, variant.statusPath]);
|
|
685
|
+
(0, react.useEffect)(() => {
|
|
686
|
+
if (!open) return;
|
|
687
|
+
const controller = new AbortController();
|
|
688
|
+
refresh(controller.signal);
|
|
689
|
+
return () => {
|
|
690
|
+
controller.abort();
|
|
691
|
+
};
|
|
692
|
+
}, [open, refresh]);
|
|
693
|
+
(0, react.useEffect)(() => {
|
|
694
|
+
if (!open || signedIn === false) return;
|
|
695
|
+
const controller = new AbortController();
|
|
696
|
+
const timer = window.setInterval(() => {
|
|
697
|
+
refresh(controller.signal);
|
|
698
|
+
}, POLL_INTERVAL_MS);
|
|
699
|
+
return () => {
|
|
700
|
+
window.clearInterval(timer);
|
|
701
|
+
controller.abort();
|
|
702
|
+
};
|
|
703
|
+
}, [
|
|
704
|
+
open,
|
|
705
|
+
refresh,
|
|
706
|
+
signedIn
|
|
707
|
+
]);
|
|
708
|
+
const manualRefresh = async () => {
|
|
709
|
+
setBusy(true);
|
|
710
|
+
const controller = trackController();
|
|
711
|
+
try {
|
|
712
|
+
await refresh(controller.signal);
|
|
713
|
+
} finally {
|
|
714
|
+
manualControllers.current.delete(controller);
|
|
715
|
+
if (mounted.current) setBusy(false);
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
/**
|
|
719
|
+
* Ask the host to re-read the credential and re-fetch this variant's catalog.
|
|
720
|
+
*
|
|
721
|
+
* Shares the probe route's key and guards: it is a write that spends an
|
|
722
|
+
* upstream request, so it does not belong on the read-only status GET. A
|
|
723
|
+
* failure is surfaced through the refreshed document's `catalog.error` rather
|
|
724
|
+
* than thrown away, so the reason survives the round trip.
|
|
725
|
+
*/
|
|
726
|
+
const refreshModels = (0, react.useCallback)(async () => {
|
|
727
|
+
const key = status?.status === "signed-in" ? status.probeKey : void 0;
|
|
728
|
+
if (key === void 0) return;
|
|
729
|
+
setBusy(true);
|
|
730
|
+
const controller = trackController();
|
|
731
|
+
try {
|
|
732
|
+
const response = await fetch(variant.probePath, {
|
|
733
|
+
method: "POST",
|
|
734
|
+
headers: {
|
|
735
|
+
"Content-Type": "application/json",
|
|
736
|
+
"X-Qoder-Probe-Key": key
|
|
737
|
+
},
|
|
738
|
+
credentials: "same-origin",
|
|
739
|
+
signal: controller.signal,
|
|
740
|
+
body: JSON.stringify({ action: "refresh" })
|
|
741
|
+
});
|
|
742
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (mounted.current && controller.signal.aborted !== true) setReadFailure(error instanceof Error ? error.message : t("requestFailed"));
|
|
745
|
+
manualControllers.current.delete(controller);
|
|
746
|
+
return;
|
|
747
|
+
} finally {
|
|
748
|
+
if (mounted.current) setBusy(false);
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
await refresh(controller.signal);
|
|
752
|
+
} finally {
|
|
753
|
+
manualControllers.current.delete(controller);
|
|
754
|
+
}
|
|
755
|
+
}, [
|
|
756
|
+
refresh,
|
|
757
|
+
status,
|
|
758
|
+
t,
|
|
759
|
+
trackController,
|
|
760
|
+
variant.probePath
|
|
761
|
+
]);
|
|
762
|
+
/**
|
|
763
|
+
* Run one probe-route control action and refresh the card's state afterwards.
|
|
764
|
+
*
|
|
765
|
+
* The key travels in a header, not the body: it authorizes the write, and
|
|
766
|
+
* the host never accepts a prompt, a sentinel, or a model outside its own
|
|
767
|
+
* catalog from here.
|
|
768
|
+
*/
|
|
769
|
+
const control = (0, react.useCallback)(async (action) => {
|
|
770
|
+
const key = status?.status === "signed-in" ? status.probeKey : void 0;
|
|
771
|
+
if (key === void 0) return;
|
|
772
|
+
setBusy(true);
|
|
773
|
+
const controller = trackController();
|
|
774
|
+
try {
|
|
775
|
+
const response = await fetch(variant.probePath, {
|
|
776
|
+
method: "POST",
|
|
777
|
+
headers: {
|
|
778
|
+
"Content-Type": "application/json",
|
|
779
|
+
"X-Qoder-Probe-Key": key
|
|
780
|
+
},
|
|
781
|
+
credentials: "same-origin",
|
|
782
|
+
signal: controller.signal,
|
|
783
|
+
body: JSON.stringify(action)
|
|
784
|
+
});
|
|
785
|
+
const value = await response.json().catch(() => void 0);
|
|
786
|
+
if (!response.ok) {
|
|
787
|
+
const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
|
|
788
|
+
throw new Error(message);
|
|
789
|
+
}
|
|
790
|
+
if (action.action === "set-maximum-context-window" && (typeof value !== "object" || value === null || value["state"] !== "updated")) {
|
|
791
|
+
const reason = typeof value === "object" && value !== null && "reason" in value ? String(value["reason"]) : t("requestFailed");
|
|
792
|
+
throw new Error(reason);
|
|
793
|
+
}
|
|
794
|
+
await refresh(controller.signal);
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (mounted.current && controller.signal.aborted !== true) setReadFailure(error instanceof Error ? error.message : t("requestFailed"));
|
|
797
|
+
} finally {
|
|
798
|
+
manualControllers.current.delete(controller);
|
|
799
|
+
if (mounted.current) setBusy(false);
|
|
800
|
+
}
|
|
801
|
+
}, [
|
|
802
|
+
refresh,
|
|
803
|
+
status,
|
|
804
|
+
t,
|
|
805
|
+
trackController,
|
|
806
|
+
variant.probePath
|
|
807
|
+
]);
|
|
808
|
+
/**
|
|
809
|
+
* Validate and store the pasted PAT (or overwrite the stored one).
|
|
810
|
+
*
|
|
811
|
+
* The PAT route answers both outcomes as 200 with `ok` carrying the verdict —
|
|
812
|
+
* a refused token is an answer, not a transport failure — so the card checks
|
|
813
|
+
* `ok` and translates the stable `qoder_invalid_pat` / `qoder_missing_pat`
|
|
814
|
+
* codes into the re-generate prompt. The route is variant-fixed: the token
|
|
815
|
+
* this card saves can only ever be validated and stored for this product.
|
|
816
|
+
*/
|
|
817
|
+
const savePat = (0, react.useCallback)(async () => {
|
|
818
|
+
const key = authKey;
|
|
819
|
+
const pat = patDraft.trim();
|
|
820
|
+
if (key === void 0 || pat === "" || patBusy) return;
|
|
821
|
+
setPatBusy(true);
|
|
822
|
+
setPatError(void 0);
|
|
823
|
+
setPatNotice(void 0);
|
|
824
|
+
const controller = trackController();
|
|
825
|
+
try {
|
|
826
|
+
const response = await fetch(variant.authPath, {
|
|
827
|
+
method: "POST",
|
|
828
|
+
headers: {
|
|
829
|
+
"Content-Type": "application/json",
|
|
830
|
+
"X-Qoder-Auth-Key": key
|
|
831
|
+
},
|
|
832
|
+
credentials: "same-origin",
|
|
833
|
+
signal: controller.signal,
|
|
834
|
+
body: JSON.stringify({
|
|
835
|
+
action: "save-pat",
|
|
836
|
+
pat
|
|
837
|
+
})
|
|
838
|
+
});
|
|
839
|
+
const value = await response.json().catch(() => void 0);
|
|
840
|
+
const record = typeof value === "object" && value !== null ? value : {};
|
|
841
|
+
const detail = typeof record["error"] === "string" ? record["error"] : `HTTP ${response.status}`;
|
|
842
|
+
if (!response.ok) {
|
|
843
|
+
setPatError(t("patSaveFailed", { message: detail }));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (record["ok"] !== true) {
|
|
847
|
+
setPatError(detail === "qoder_invalid_pat" || detail === "qoder_missing_pat" ? t("patInvalid") : t("patSaveFailed", { message: detail }));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
setPatDraft("");
|
|
851
|
+
setReplacing(false);
|
|
852
|
+
setPatNotice(t("patSaved"));
|
|
853
|
+
await refresh(controller.signal);
|
|
854
|
+
} catch (error) {
|
|
855
|
+
if (mounted.current && controller.signal.aborted !== true) setPatError(t("patSaveFailed", { message: error instanceof Error ? error.message : t("requestFailed") }));
|
|
856
|
+
} finally {
|
|
857
|
+
manualControllers.current.delete(controller);
|
|
858
|
+
if (mounted.current) setPatBusy(false);
|
|
859
|
+
}
|
|
860
|
+
}, [
|
|
861
|
+
authKey,
|
|
862
|
+
patBusy,
|
|
863
|
+
patDraft,
|
|
864
|
+
refresh,
|
|
865
|
+
t,
|
|
866
|
+
trackController,
|
|
867
|
+
variant.authPath
|
|
868
|
+
]);
|
|
869
|
+
/** Remove the stored PAT; the host answer is `{ ok: true }`, then the card re-reads. */
|
|
870
|
+
const clearPat = (0, react.useCallback)(async () => {
|
|
871
|
+
const key = authKey;
|
|
872
|
+
if (key === void 0 || patBusy) return;
|
|
873
|
+
setPatBusy(true);
|
|
874
|
+
setPatError(void 0);
|
|
875
|
+
setPatNotice(void 0);
|
|
876
|
+
setReplacing(false);
|
|
877
|
+
const controller = trackController();
|
|
878
|
+
try {
|
|
879
|
+
const response = await fetch(variant.authPath, {
|
|
880
|
+
method: "POST",
|
|
881
|
+
headers: {
|
|
882
|
+
"Content-Type": "application/json",
|
|
883
|
+
"X-Qoder-Auth-Key": key
|
|
884
|
+
},
|
|
885
|
+
credentials: "same-origin",
|
|
886
|
+
signal: controller.signal,
|
|
887
|
+
body: JSON.stringify({ action: "clear" })
|
|
888
|
+
});
|
|
889
|
+
const value = await response.json().catch(() => void 0);
|
|
890
|
+
const record = typeof value === "object" && value !== null ? value : {};
|
|
891
|
+
if (!response.ok || record["ok"] !== true) {
|
|
892
|
+
const detail = typeof record["error"] === "string" ? record["error"] : `HTTP ${response.status}`;
|
|
893
|
+
setPatError(t("patSaveFailed", { message: detail }));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
setPatDraft("");
|
|
897
|
+
await refresh(controller.signal);
|
|
898
|
+
} catch (error) {
|
|
899
|
+
if (mounted.current && controller.signal.aborted !== true) setPatError(t("patSaveFailed", { message: error instanceof Error ? error.message : t("requestFailed") }));
|
|
900
|
+
} finally {
|
|
901
|
+
manualControllers.current.delete(controller);
|
|
902
|
+
if (mounted.current) setPatBusy(false);
|
|
903
|
+
}
|
|
904
|
+
}, [
|
|
905
|
+
authKey,
|
|
906
|
+
patBusy,
|
|
907
|
+
refresh,
|
|
908
|
+
t,
|
|
909
|
+
trackController,
|
|
910
|
+
variant.authPath
|
|
911
|
+
]);
|
|
912
|
+
/**
|
|
913
|
+
* 更换 PAT: open the entry over the signed-in document, pre-cleared and
|
|
914
|
+
* focused. Unlike the removed device-flow switch this needs no sign-out
|
|
915
|
+
* first — saving validates the new token and overwrites the stored one
|
|
916
|
+
* only on success, so a mistyped paste can never strand the working
|
|
917
|
+
* credential.
|
|
918
|
+
*/
|
|
919
|
+
const beginReplace = (0, react.useCallback)(() => {
|
|
920
|
+
setPatDraft("");
|
|
921
|
+
setPatError(void 0);
|
|
922
|
+
setPatNotice(void 0);
|
|
923
|
+
setReplacing(true);
|
|
924
|
+
requestAnimationFrame(() => {
|
|
925
|
+
patInput.current?.focus();
|
|
926
|
+
});
|
|
927
|
+
}, []);
|
|
928
|
+
/** The PAT entry: guide line, password input, Save. Shared by both arms. */
|
|
929
|
+
const patEntry = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
930
|
+
style: {
|
|
931
|
+
display: "flex",
|
|
932
|
+
flexDirection: "column",
|
|
933
|
+
gap: 8
|
|
934
|
+
},
|
|
935
|
+
children: [
|
|
936
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
937
|
+
style: bodyStyle,
|
|
938
|
+
children: t(variant.patGuideKey)
|
|
939
|
+
}),
|
|
940
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
941
|
+
style: patRowStyle,
|
|
942
|
+
children: [
|
|
943
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
944
|
+
ref: patInput,
|
|
945
|
+
type: "password",
|
|
946
|
+
value: patDraft,
|
|
947
|
+
placeholder: t(variant.patPlaceholderKey),
|
|
948
|
+
"aria-label": t("patHeading"),
|
|
949
|
+
disabled: patBusy,
|
|
950
|
+
onChange: (event) => {
|
|
951
|
+
setPatDraft(event.target.value);
|
|
952
|
+
},
|
|
953
|
+
onKeyDown: (event) => {
|
|
954
|
+
if (event.key === "Enter") {
|
|
955
|
+
event.preventDefault();
|
|
956
|
+
savePat();
|
|
957
|
+
}
|
|
958
|
+
},
|
|
959
|
+
style: patInputStyle
|
|
960
|
+
}),
|
|
961
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
962
|
+
type: "button",
|
|
963
|
+
style: primaryButtonStyle$1,
|
|
964
|
+
disabled: patBusy || busy || patDraft.trim() === "",
|
|
965
|
+
onClick: () => {
|
|
966
|
+
savePat();
|
|
967
|
+
},
|
|
968
|
+
children: patBusy ? t("patSaving") : t("patSave")
|
|
969
|
+
}),
|
|
970
|
+
replacing ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
971
|
+
type: "button",
|
|
972
|
+
style: buttonStyle$1,
|
|
973
|
+
disabled: patBusy,
|
|
974
|
+
onClick: () => {
|
|
975
|
+
setReplacing(false);
|
|
976
|
+
setPatDraft("");
|
|
977
|
+
setPatError(void 0);
|
|
978
|
+
},
|
|
979
|
+
children: t("cancel")
|
|
980
|
+
}) : null
|
|
981
|
+
]
|
|
982
|
+
}),
|
|
983
|
+
patError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
984
|
+
style: errorStyle,
|
|
985
|
+
children: patError
|
|
986
|
+
}),
|
|
987
|
+
patNotice === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
988
|
+
style: bodyStyle,
|
|
989
|
+
children: patNotice
|
|
990
|
+
})
|
|
991
|
+
]
|
|
992
|
+
});
|
|
993
|
+
const title = t(variant.titleKey);
|
|
994
|
+
const label = status === void 0 ? t("loading") : status.status === "signed-in" ? t("signedIn") : status.status === "error" ? t("requestFailed") : t("signedOut");
|
|
995
|
+
/** One line summarizing the PAT in effect: source, saved-at, and the redacted tail. */
|
|
996
|
+
const patSummaryLine = (pat) => {
|
|
997
|
+
const parts = [
|
|
998
|
+
patSourceText(pat.source, t),
|
|
999
|
+
pat.savedAtMs === void 0 ? null : t("patSavedAt", { time: formatTime(pat.savedAtMs) }),
|
|
1000
|
+
pat.patTail === void 0 ? null : t("patTail", { tail: `****${pat.patTail}` })
|
|
1001
|
+
].filter((part) => part !== null);
|
|
1002
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1003
|
+
style: bodyStyle,
|
|
1004
|
+
children: parts.join(" · ")
|
|
1005
|
+
});
|
|
1006
|
+
};
|
|
1007
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
1008
|
+
style: {
|
|
1009
|
+
...cardStyle$1,
|
|
1010
|
+
...hovered ? cardHoverStyle$1 : {},
|
|
1011
|
+
...open ? cardOpenStyle$1 : {}
|
|
1012
|
+
},
|
|
1013
|
+
onMouseEnter: () => {
|
|
1014
|
+
setHovered(true);
|
|
1015
|
+
},
|
|
1016
|
+
onMouseLeave: () => {
|
|
1017
|
+
setHovered(false);
|
|
1018
|
+
},
|
|
1019
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1020
|
+
type: "button",
|
|
1021
|
+
style: {
|
|
1022
|
+
...headerStyle,
|
|
1023
|
+
...headerFocused ? headerFocusStyle$1 : {}
|
|
1024
|
+
},
|
|
1025
|
+
"aria-expanded": open,
|
|
1026
|
+
"aria-label": `${t(open ? "collapse" : "expand")}: ${title}`,
|
|
1027
|
+
onClick: () => {
|
|
1028
|
+
setOpen(!open);
|
|
1029
|
+
},
|
|
1030
|
+
onFocus: (event) => {
|
|
1031
|
+
let keyboard = true;
|
|
1032
|
+
try {
|
|
1033
|
+
keyboard = event.currentTarget.matches(":focus-visible");
|
|
1034
|
+
} catch {
|
|
1035
|
+
keyboard = true;
|
|
1036
|
+
}
|
|
1037
|
+
if (keyboard) setHeaderFocused(true);
|
|
1038
|
+
},
|
|
1039
|
+
onBlur: () => {
|
|
1040
|
+
setHeaderFocused(false);
|
|
1041
|
+
},
|
|
1042
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1043
|
+
style: headTextStyle$1,
|
|
1044
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1045
|
+
style: nameStyle,
|
|
1046
|
+
children: title
|
|
1047
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1048
|
+
style: descriptionStyle,
|
|
1049
|
+
children: t(variant.introKey)
|
|
1050
|
+
})]
|
|
1051
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1052
|
+
style: {
|
|
1053
|
+
...chevronStyle$1,
|
|
1054
|
+
transform: open ? "rotate(180deg)" : "none"
|
|
1055
|
+
},
|
|
1056
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDownIcon$1, {})
|
|
1057
|
+
})]
|
|
1058
|
+
}), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1059
|
+
style: cardBodyStyle$1,
|
|
1060
|
+
children: [
|
|
1061
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1062
|
+
style: quotaTitleStyle,
|
|
1063
|
+
children: t("accountHeading")
|
|
1064
|
+
}),
|
|
1065
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1066
|
+
style: rowStyle$1,
|
|
1067
|
+
children: [
|
|
1068
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1069
|
+
style: statusStyle,
|
|
1070
|
+
role: "status",
|
|
1071
|
+
"aria-busy": status === void 0,
|
|
1072
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1073
|
+
"aria-hidden": "true",
|
|
1074
|
+
style: dotStyle(status === void 0 ? "loading" : status.status)
|
|
1075
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
|
|
1076
|
+
}),
|
|
1077
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1078
|
+
type: "button",
|
|
1079
|
+
style: buttonStyle$1,
|
|
1080
|
+
disabled: busy,
|
|
1081
|
+
onClick: () => {
|
|
1082
|
+
manualRefresh();
|
|
1083
|
+
},
|
|
1084
|
+
children: busy ? t("refreshing") : t("refresh")
|
|
1085
|
+
}),
|
|
1086
|
+
status?.status !== "signed-in" || status.authKey === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1087
|
+
type: "button",
|
|
1088
|
+
style: buttonStyle$1,
|
|
1089
|
+
disabled: busy || patBusy,
|
|
1090
|
+
onClick: beginReplace,
|
|
1091
|
+
children: t("patReplace")
|
|
1092
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1093
|
+
type: "button",
|
|
1094
|
+
style: buttonStyle$1,
|
|
1095
|
+
disabled: busy || patBusy,
|
|
1096
|
+
onClick: () => {
|
|
1097
|
+
clearPat();
|
|
1098
|
+
},
|
|
1099
|
+
children: patBusy ? t("patClearing") : t("patClear")
|
|
1100
|
+
})] })
|
|
1101
|
+
]
|
|
1102
|
+
}),
|
|
1103
|
+
readFailure === void 0 || signedIn === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1104
|
+
style: errorStyle,
|
|
1105
|
+
children: t("statusRefreshFailed", { message: readFailure })
|
|
1106
|
+
}),
|
|
1107
|
+
status?.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1108
|
+
status.pat === void 0 ? null : patSummaryLine(status.pat),
|
|
1109
|
+
replacing ? patEntry() : null,
|
|
1110
|
+
status.catalog === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1111
|
+
style: rowStyle$1,
|
|
1112
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1113
|
+
style: bodyStyle,
|
|
1114
|
+
children: status.catalog.source === "live" && status.catalog.fetchedAt !== void 0 ? t("catalogLive", { time: formatTime(status.catalog.fetchedAt) }) : status.catalog.source === "saved" && status.catalog.fetchedAt !== void 0 ? t("catalogSaved", { time: formatTime(status.catalog.fetchedAt) }) : t("catalogFallback")
|
|
1115
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1116
|
+
type: "button",
|
|
1117
|
+
style: buttonStyle$1,
|
|
1118
|
+
disabled: busy,
|
|
1119
|
+
onClick: () => {
|
|
1120
|
+
refreshModels();
|
|
1121
|
+
},
|
|
1122
|
+
children: busy ? t("refreshingModels") : t("refreshModels")
|
|
1123
|
+
})]
|
|
1124
|
+
}),
|
|
1125
|
+
status.catalog?.error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1126
|
+
style: errorStyle,
|
|
1127
|
+
children: t("catalogError", { message: status.catalog.error })
|
|
1128
|
+
}),
|
|
1129
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1130
|
+
role: "tablist",
|
|
1131
|
+
style: tabBarStyle,
|
|
1132
|
+
children: [
|
|
1133
|
+
"status",
|
|
1134
|
+
"context",
|
|
1135
|
+
"details"
|
|
1136
|
+
].map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1137
|
+
type: "button",
|
|
1138
|
+
role: "tab",
|
|
1139
|
+
"aria-selected": tab === id,
|
|
1140
|
+
onClick: () => {
|
|
1141
|
+
setTab(id);
|
|
1142
|
+
},
|
|
1143
|
+
style: {
|
|
1144
|
+
...tabStyle,
|
|
1145
|
+
...tab === id ? tabActiveStyle : {}
|
|
1146
|
+
},
|
|
1147
|
+
children: t(id === "status" ? "tabStatus" : id === "context" ? "tabContext" : "tabDetails")
|
|
1148
|
+
}, id))
|
|
1149
|
+
}),
|
|
1150
|
+
tab === "status" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1151
|
+
style: tabPanelStyle,
|
|
1152
|
+
children: [status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1153
|
+
style: quotaListStyle,
|
|
1154
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1155
|
+
style: rowStyle$1,
|
|
1156
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1157
|
+
style: quotaTitleStyle,
|
|
1158
|
+
children: t("creditsHeading")
|
|
1159
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1160
|
+
style: bodyStyle,
|
|
1161
|
+
children: status.credits.unlimited === true ? t("creditsTotalUnlimited") : t("creditsUsed", { percent: formatPercent(status.credits.total) })
|
|
1162
|
+
})]
|
|
1163
|
+
}), status.credits.cycleResetTime === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1164
|
+
style: descriptionStyle,
|
|
1165
|
+
children: t("cycleResetAt", { time: formatCycleReset(status.credits.cycleResetTime) })
|
|
1166
|
+
})]
|
|
1167
|
+
}), status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1168
|
+
style: errorStyle,
|
|
1169
|
+
children: t("creditsError", { message: status.creditsError })
|
|
1170
|
+
})]
|
|
1171
|
+
}) : tab === "context" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1172
|
+
style: tabPanelStyle,
|
|
1173
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextTable, {
|
|
1174
|
+
models: status.models,
|
|
1175
|
+
t,
|
|
1176
|
+
disabled: busy,
|
|
1177
|
+
...status.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: status.useMaximumContextWindow },
|
|
1178
|
+
onUseMaximumContextWindow: (enabled) => {
|
|
1179
|
+
control({
|
|
1180
|
+
action: "set-maximum-context-window",
|
|
1181
|
+
enabled
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
})
|
|
1185
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1186
|
+
style: tabPanelStyle,
|
|
1187
|
+
children: status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1188
|
+
style: quotaListStyle,
|
|
1189
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1190
|
+
style: quotaTitleStyle,
|
|
1191
|
+
children: t("creditsDetailHeading")
|
|
1192
|
+
}), status.credits.accounts.filter((account) => account.remain > 0 || account.unlimited === true).map((account, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreditBar, {
|
|
1193
|
+
label: account.packageName,
|
|
1194
|
+
remain: account.remain,
|
|
1195
|
+
size: account.size,
|
|
1196
|
+
unlimited: account.unlimited,
|
|
1197
|
+
packageEndTime: account.packageEndTime,
|
|
1198
|
+
t
|
|
1199
|
+
}, `${account.packageName}-${String(index)}`))]
|
|
1200
|
+
})
|
|
1201
|
+
})
|
|
1202
|
+
] }) : null,
|
|
1203
|
+
status?.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1204
|
+
style: status.reason === void 0 ? bodyStyle : errorStyle,
|
|
1205
|
+
children: status.reason ?? t(variant.signedOutKey)
|
|
1206
|
+
}), authKey === void 0 ? null : patEntry()] }) : null,
|
|
1207
|
+
status?.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1208
|
+
style: errorStyle,
|
|
1209
|
+
children: status.message
|
|
1210
|
+
}) : null
|
|
1211
|
+
]
|
|
1212
|
+
}) : null]
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
//#endregion
|
|
1216
|
+
//#region src/client/QoderProbeControl.tsx
|
|
1217
|
+
/**
|
|
1218
|
+
* Per-model reasoning-effort entry beside the Composer's model selector.
|
|
1219
|
+
*
|
|
1220
|
+
* Interaction follows the Fast Mode control `dsh-codex-connect` ships in this
|
|
1221
|
+
* same seat, which is the established shape for composer chrome here:
|
|
1222
|
+
*
|
|
1223
|
+
* - a **static inline label** next to the icon names the feature ("Reasoning
|
|
1224
|
+
* levels"), set smaller and dimmer than the surrounding chrome so it reads as
|
|
1225
|
+
* an annotation on the icon. It never carries state: the verified levels
|
|
1226
|
+
* already appear in the model dropdown (the adapter exposes them as
|
|
1227
|
+
* selectable efforts), so repeating them here would duplicate the real answer
|
|
1228
|
+
* and make the label's width jump as results change.
|
|
1229
|
+
* - a **hover/focus tooltip** carries the state and the click's purpose, the way
|
|
1230
|
+
* Fast Mode's tooltip explains its current speed.
|
|
1231
|
+
* - the **confirmation** is a small bubble anchored to the control, not a
|
|
1232
|
+
* `window.confirm`. Probing spends real credit, so a confirmation stays — but
|
|
1233
|
+
* it belongs next to the thing it acts on, sized to one line plus two small
|
|
1234
|
+
* buttons.
|
|
1235
|
+
*
|
|
1236
|
+
* @module dsh-qoder-connect/client/probe-control
|
|
1237
|
+
*/
|
|
1238
|
+
/**
|
|
1239
|
+
* The card (and therefore the routes) a selected provider belongs to.
|
|
1240
|
+
*
|
|
1241
|
+
* The control serves both Qoder providers from one seat, so the provider id
|
|
1242
|
+
* is what selects the status and probe endpoints. Returning `undefined` for any
|
|
1243
|
+
* other provider is what keeps the icon off every non-Qoder model.
|
|
1244
|
+
*/
|
|
1245
|
+
function cardVariantFor(provider) {
|
|
1246
|
+
return QODER_CARD_VARIANTS.find((card) => card.id === provider);
|
|
1247
|
+
}
|
|
1248
|
+
/** How often the control re-checks state when the window regains focus. */
|
|
1249
|
+
const RECONCILE_MS = 6e4;
|
|
1250
|
+
const wrapperStyle = {
|
|
1251
|
+
display: "inline-flex",
|
|
1252
|
+
position: "relative",
|
|
1253
|
+
alignItems: "center",
|
|
1254
|
+
transform: "translateY(2px)",
|
|
1255
|
+
marginRight: -8
|
|
1256
|
+
};
|
|
1257
|
+
const buttonStyle = {
|
|
1258
|
+
display: "inline-flex",
|
|
1259
|
+
alignItems: "center",
|
|
1260
|
+
justifyContent: "center",
|
|
1261
|
+
gap: 2,
|
|
1262
|
+
height: 30,
|
|
1263
|
+
padding: "0 6px",
|
|
1264
|
+
border: 0,
|
|
1265
|
+
borderRadius: 8,
|
|
1266
|
+
background: "transparent",
|
|
1267
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
1268
|
+
font: "inherit",
|
|
1269
|
+
whiteSpace: "nowrap",
|
|
1270
|
+
cursor: "pointer"
|
|
1271
|
+
};
|
|
1272
|
+
/**
|
|
1273
|
+
* The inline label. Smaller and dimmer than the surrounding chrome on purpose:
|
|
1274
|
+
* it names the feature, so it should read as an annotation attached to the icon
|
|
1275
|
+
* rather than compete with the adjacent model selector.
|
|
1276
|
+
*/
|
|
1277
|
+
const labelStyle$1 = {
|
|
1278
|
+
fontSize: 11,
|
|
1279
|
+
lineHeight: "16px",
|
|
1280
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1281
|
+
};
|
|
1282
|
+
/** Tooltip bubble: the Fast Mode shape (nowrap, one line, above the control). */
|
|
1283
|
+
const tooltipStyle = {
|
|
1284
|
+
position: "absolute",
|
|
1285
|
+
left: "50%",
|
|
1286
|
+
bottom: "calc(100% + 8px)",
|
|
1287
|
+
zIndex: 1e3,
|
|
1288
|
+
transform: "translateX(-50%)",
|
|
1289
|
+
padding: "4px 8px",
|
|
1290
|
+
borderRadius: 6,
|
|
1291
|
+
background: "var(--dsw-specific-tip, #1f2329)",
|
|
1292
|
+
boxShadow: "var(--dsw-shadow-lv2)",
|
|
1293
|
+
color: "var(--dsw-alias-label-primary, #fff)",
|
|
1294
|
+
fontSize: 12,
|
|
1295
|
+
lineHeight: "18px",
|
|
1296
|
+
whiteSpace: "nowrap",
|
|
1297
|
+
pointerEvents: "none"
|
|
1298
|
+
};
|
|
1299
|
+
/** Confirmation bubble: same anchor, but interactive and allowed to wrap. */
|
|
1300
|
+
const confirmStyle = {
|
|
1301
|
+
position: "absolute",
|
|
1302
|
+
right: 0,
|
|
1303
|
+
bottom: "calc(100% + 8px)",
|
|
1304
|
+
zIndex: 1001,
|
|
1305
|
+
display: "flex",
|
|
1306
|
+
flexDirection: "column",
|
|
1307
|
+
gap: 8,
|
|
1308
|
+
width: 260,
|
|
1309
|
+
padding: "10px 12px",
|
|
1310
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1311
|
+
borderRadius: 8,
|
|
1312
|
+
background: "var(--dsw-alias-bg-layer-1, #fff)",
|
|
1313
|
+
boxShadow: "var(--dsw-shadow-lv2)",
|
|
1314
|
+
color: "var(--dsw-alias-label-primary)",
|
|
1315
|
+
fontSize: 12,
|
|
1316
|
+
lineHeight: "18px"
|
|
1317
|
+
};
|
|
1318
|
+
const confirmRowStyle = {
|
|
1319
|
+
display: "flex",
|
|
1320
|
+
justifyContent: "flex-end",
|
|
1321
|
+
gap: 8
|
|
1322
|
+
};
|
|
1323
|
+
const confirmButtonStyle = {
|
|
1324
|
+
padding: "3px 10px",
|
|
1325
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1326
|
+
borderRadius: 6,
|
|
1327
|
+
background: "transparent",
|
|
1328
|
+
color: "inherit",
|
|
1329
|
+
font: "inherit",
|
|
1330
|
+
fontSize: 12,
|
|
1331
|
+
cursor: "pointer"
|
|
1332
|
+
};
|
|
1333
|
+
/**
|
|
1334
|
+
* Primary action inside the confirmation bubble.
|
|
1335
|
+
*
|
|
1336
|
+
* The fill and its text colour must come as a pair: `brand-primary` resolves to
|
|
1337
|
+
* a light accent in this theme, so hardcoding `color: #fff` on top of it renders
|
|
1338
|
+
* white-on-white. `button-primary-fill` + `label-primary-foreground` is the
|
|
1339
|
+
* theme's own pair for exactly this, and is what `dsh-codex-connect` uses for
|
|
1340
|
+
* the same job.
|
|
1341
|
+
*/
|
|
1342
|
+
const primaryButtonStyle = {
|
|
1343
|
+
...confirmButtonStyle,
|
|
1344
|
+
border: "1px solid var(--dsw-alias-button-primary-fill)",
|
|
1345
|
+
background: "var(--dsw-alias-button-primary-fill)",
|
|
1346
|
+
color: "var(--dsw-alias-label-primary-foreground)"
|
|
1347
|
+
};
|
|
1348
|
+
/**
|
|
1349
|
+
* Result note: a single line + a dismiss button, anchored to the control's
|
|
1350
|
+
* right side. Smaller than the confirmation bubble because it carries an
|
|
1351
|
+
* *outcome*, not a *decision* — the work is done, the user only has to read
|
|
1352
|
+
* and dismiss.
|
|
1353
|
+
*/
|
|
1354
|
+
const noteStyle = {
|
|
1355
|
+
position: "absolute",
|
|
1356
|
+
right: 0,
|
|
1357
|
+
bottom: "calc(100% + 8px)",
|
|
1358
|
+
zIndex: 1001,
|
|
1359
|
+
display: "flex",
|
|
1360
|
+
alignItems: "center",
|
|
1361
|
+
gap: 12,
|
|
1362
|
+
padding: "6px 10px",
|
|
1363
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1364
|
+
borderRadius: 8,
|
|
1365
|
+
background: "var(--dsw-alias-bg-layer-1)",
|
|
1366
|
+
boxShadow: "var(--dsw-shadow-lv2)",
|
|
1367
|
+
color: "var(--dsw-alias-label-primary)",
|
|
1368
|
+
fontSize: 12,
|
|
1369
|
+
lineHeight: "18px",
|
|
1370
|
+
whiteSpace: "nowrap"
|
|
1371
|
+
};
|
|
1372
|
+
/**
|
|
1373
|
+
* The note's dismiss action. Outlined rather than bare text: inside an already
|
|
1374
|
+
* bordered bubble, an unbordered word does not read as something you can click.
|
|
1375
|
+
* Matches the outlined pill convention the plugin's other secondary actions use.
|
|
1376
|
+
*/
|
|
1377
|
+
const noteDismissStyle = {
|
|
1378
|
+
padding: "2px 8px",
|
|
1379
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1380
|
+
borderRadius: 6,
|
|
1381
|
+
background: "transparent",
|
|
1382
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
1383
|
+
font: "inherit",
|
|
1384
|
+
fontSize: 12,
|
|
1385
|
+
lineHeight: "18px",
|
|
1386
|
+
cursor: "pointer"
|
|
1387
|
+
};
|
|
1388
|
+
/**
|
|
1389
|
+
* The feature's static inline label. Deliberately not a state readout — see the
|
|
1390
|
+
* module comment.
|
|
1391
|
+
*/
|
|
1392
|
+
function useLabel(t) {
|
|
1393
|
+
return t("probeLabel");
|
|
1394
|
+
}
|
|
1395
|
+
/** Pick the model's recorded observation out of the probe section. */
|
|
1396
|
+
function resultFor(status, model) {
|
|
1397
|
+
if (status.status !== "signed-in") return void 0;
|
|
1398
|
+
return status.probe?.results.find((result) => result.id === model);
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* The one-line tooltip: current state first, then what a click does — the same
|
|
1402
|
+
* two-part shape Fast Mode uses.
|
|
1403
|
+
*
|
|
1404
|
+
* A recorded result outranks a remembered failure. `failed` only means "the last
|
|
1405
|
+
* run from this control did not complete"; the host can record a result for the
|
|
1406
|
+
* same model at any time (a detection started from the settings card, another
|
|
1407
|
+
* conversation, or a finished sweep), and the levels the user paid for are the
|
|
1408
|
+
* more useful answer than the stale failure. Failure copy is what remains when
|
|
1409
|
+
* there is no result to report.
|
|
1410
|
+
*/
|
|
1411
|
+
function tooltipText(t, model, state) {
|
|
1412
|
+
if (state.busy) return t("probeRunning", { model });
|
|
1413
|
+
const result = state.result;
|
|
1414
|
+
if (result !== void 0) {
|
|
1415
|
+
if (result.validation === "validating" && result.efforts.length > 0) return t("probeTooltipVerified", { levels: result.efforts.join(" / ") });
|
|
1416
|
+
if (result.validation === "non-validating") return t("probeTooltipNotValidating");
|
|
1417
|
+
return t("probeTooltipRetry");
|
|
1418
|
+
}
|
|
1419
|
+
if (state.failed) return t("probeTooltipRetry");
|
|
1420
|
+
return t("probeTooltipIdle", { model });
|
|
1421
|
+
}
|
|
1422
|
+
/** Model-independent shell: resolves the selection, then delegates per model. */
|
|
1423
|
+
function QoderProbeControl({ directory, t }) {
|
|
1424
|
+
const subscribe = (0, react.useCallback)((listener) => directory.subscribe(listener), [directory]);
|
|
1425
|
+
const snapshot = (0, react.useCallback)(() => directory.getSnapshot(), [directory]);
|
|
1426
|
+
const selection = (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot).current;
|
|
1427
|
+
const card = selection == null ? void 0 : cardVariantFor(selection.provider);
|
|
1428
|
+
const key = card === void 0 || selection == null ? void 0 : `${card.id}:${selection.model}`;
|
|
1429
|
+
return card === void 0 || selection == null || key === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelProbe, {
|
|
1430
|
+
model: selection.model,
|
|
1431
|
+
card,
|
|
1432
|
+
label: useLabel(t),
|
|
1433
|
+
t
|
|
1434
|
+
}, key);
|
|
1435
|
+
}
|
|
1436
|
+
function ModelProbe({ model, card, label, t }) {
|
|
1437
|
+
const [status, setStatus] = (0, react.useState)();
|
|
1438
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
1439
|
+
const [confirming, setConfirming] = (0, react.useState)(false);
|
|
1440
|
+
const [tooltipVisible, setTooltipVisible] = (0, react.useState)(false);
|
|
1441
|
+
const [failed, setFailed] = (0, react.useState)(false);
|
|
1442
|
+
const [note, setNote] = (0, react.useState)();
|
|
1443
|
+
const inFlight = (0, react.useRef)(false);
|
|
1444
|
+
const mounted = (0, react.useRef)(false);
|
|
1445
|
+
const readSeq = (0, react.useRef)(0);
|
|
1446
|
+
const tooltipId = (0, react.useId)();
|
|
1447
|
+
const refresh = (0, react.useCallback)(async (signal) => {
|
|
1448
|
+
const seq = ++readSeq.current;
|
|
1449
|
+
const response = await fetch(card.statusPath, {
|
|
1450
|
+
credentials: "same-origin",
|
|
1451
|
+
headers: { accept: "application/json" },
|
|
1452
|
+
...signal === void 0 ? {} : { signal }
|
|
1453
|
+
});
|
|
1454
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1455
|
+
const value = await response.json().catch(() => void 0);
|
|
1456
|
+
if (!isQoderWebStatus(value)) throw new Error(t("statusResponseInvalid"));
|
|
1457
|
+
if (mounted.current && !signal?.aborted && seq === readSeq.current) setStatus(value);
|
|
1458
|
+
}, [card.statusPath, t]);
|
|
1459
|
+
(0, react.useEffect)(() => {
|
|
1460
|
+
mounted.current = true;
|
|
1461
|
+
const controller = new AbortController();
|
|
1462
|
+
const load = () => {
|
|
1463
|
+
refresh(controller.signal).catch(() => {});
|
|
1464
|
+
};
|
|
1465
|
+
load();
|
|
1466
|
+
const timer = window.setInterval(load, RECONCILE_MS);
|
|
1467
|
+
window.addEventListener("focus", load);
|
|
1468
|
+
return () => {
|
|
1469
|
+
mounted.current = false;
|
|
1470
|
+
controller.abort();
|
|
1471
|
+
window.clearInterval(timer);
|
|
1472
|
+
window.removeEventListener("focus", load);
|
|
1473
|
+
};
|
|
1474
|
+
}, [refresh]);
|
|
1475
|
+
const probe = status?.status === "signed-in" ? status.probe : void 0;
|
|
1476
|
+
const key = status?.status === "signed-in" ? status.probeKey : void 0;
|
|
1477
|
+
const result = status === void 0 ? void 0 : resultFor(status, model);
|
|
1478
|
+
const visible = probe?.candidates.includes(model) === true || result !== void 0;
|
|
1479
|
+
(0, react.useEffect)(() => {
|
|
1480
|
+
if (result !== void 0) setFailed(false);
|
|
1481
|
+
}, [result]);
|
|
1482
|
+
(0, react.useEffect)(() => {
|
|
1483
|
+
setConfirming(false);
|
|
1484
|
+
setNote(void 0);
|
|
1485
|
+
}, [model]);
|
|
1486
|
+
const detect = async () => {
|
|
1487
|
+
if (key === void 0 || inFlight.current || probe?.running === true) return;
|
|
1488
|
+
inFlight.current = true;
|
|
1489
|
+
setNote(void 0);
|
|
1490
|
+
setConfirming(false);
|
|
1491
|
+
setBusy(true);
|
|
1492
|
+
setFailed(false);
|
|
1493
|
+
try {
|
|
1494
|
+
const response = await fetch(card.probePath, {
|
|
1495
|
+
method: "POST",
|
|
1496
|
+
credentials: "same-origin",
|
|
1497
|
+
headers: {
|
|
1498
|
+
"Content-Type": "application/json",
|
|
1499
|
+
"X-Qoder-Probe-Key": key
|
|
1500
|
+
},
|
|
1501
|
+
body: JSON.stringify({
|
|
1502
|
+
action: "probe",
|
|
1503
|
+
model
|
|
1504
|
+
})
|
|
1505
|
+
});
|
|
1506
|
+
const body = await response.json();
|
|
1507
|
+
if (!response.ok || body.state !== "ok" || body.validation !== "validating" && body.validation !== "non-validating" || !Array.isArray(body.efforts) || !body.efforts.every((effort) => typeof effort === "string")) throw new Error("probe failed");
|
|
1508
|
+
if (mounted.current) {
|
|
1509
|
+
const completed = {
|
|
1510
|
+
id: model,
|
|
1511
|
+
name: model,
|
|
1512
|
+
validation: body.validation,
|
|
1513
|
+
efforts: body.efforts,
|
|
1514
|
+
probedAt: Date.now()
|
|
1515
|
+
};
|
|
1516
|
+
setNote(completed);
|
|
1517
|
+
}
|
|
1518
|
+
refresh().catch(() => {});
|
|
1519
|
+
} catch {
|
|
1520
|
+
if (mounted.current) setFailed(true);
|
|
1521
|
+
} finally {
|
|
1522
|
+
inFlight.current = false;
|
|
1523
|
+
if (mounted.current) setBusy(false);
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
if (!visible) return null;
|
|
1527
|
+
const text = tooltipText(t, model, {
|
|
1528
|
+
busy,
|
|
1529
|
+
result,
|
|
1530
|
+
failed
|
|
1531
|
+
});
|
|
1532
|
+
const disabled = busy || probe?.running === true || key === void 0;
|
|
1533
|
+
const showTooltip = tooltipVisible && !confirming && note === void 0;
|
|
1534
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1535
|
+
style: wrapperStyle,
|
|
1536
|
+
onMouseEnter: () => {
|
|
1537
|
+
setTooltipVisible(true);
|
|
1538
|
+
},
|
|
1539
|
+
onMouseLeave: () => {
|
|
1540
|
+
setTooltipVisible(false);
|
|
1541
|
+
},
|
|
1542
|
+
children: [
|
|
1543
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1544
|
+
type: "button",
|
|
1545
|
+
"aria-label": text,
|
|
1546
|
+
"aria-describedby": showTooltip ? tooltipId : void 0,
|
|
1547
|
+
"aria-busy": busy,
|
|
1548
|
+
"aria-expanded": confirming,
|
|
1549
|
+
disabled,
|
|
1550
|
+
onClick: () => {
|
|
1551
|
+
setConfirming(true);
|
|
1552
|
+
},
|
|
1553
|
+
onFocus: () => {
|
|
1554
|
+
setTooltipVisible(true);
|
|
1555
|
+
},
|
|
1556
|
+
onBlur: () => {
|
|
1557
|
+
setTooltipVisible(false);
|
|
1558
|
+
},
|
|
1559
|
+
style: {
|
|
1560
|
+
...buttonStyle,
|
|
1561
|
+
opacity: disabled && !confirming ? .6 : 1,
|
|
1562
|
+
cursor: disabled ? "default" : "pointer"
|
|
1563
|
+
},
|
|
1564
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
1565
|
+
width: "16",
|
|
1566
|
+
height: "16",
|
|
1567
|
+
viewBox: "0 0 24 24",
|
|
1568
|
+
fill: "none",
|
|
1569
|
+
stroke: "currentColor",
|
|
1570
|
+
strokeWidth: "1.6",
|
|
1571
|
+
"aria-hidden": "true",
|
|
1572
|
+
focusable: "false",
|
|
1573
|
+
children: [
|
|
1574
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1575
|
+
cx: "12",
|
|
1576
|
+
cy: "12",
|
|
1577
|
+
r: "9"
|
|
1578
|
+
}),
|
|
1579
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1580
|
+
cx: "12",
|
|
1581
|
+
cy: "12",
|
|
1582
|
+
r: "4"
|
|
1583
|
+
}),
|
|
1584
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 12 20 4" }),
|
|
1585
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1586
|
+
cx: "12",
|
|
1587
|
+
cy: "12",
|
|
1588
|
+
r: "1"
|
|
1589
|
+
})
|
|
1590
|
+
]
|
|
1591
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1592
|
+
style: labelStyle$1,
|
|
1593
|
+
children: label
|
|
1594
|
+
})]
|
|
1595
|
+
}),
|
|
1596
|
+
showTooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1597
|
+
id: tooltipId,
|
|
1598
|
+
role: "tooltip",
|
|
1599
|
+
style: tooltipStyle,
|
|
1600
|
+
children: text
|
|
1601
|
+
}),
|
|
1602
|
+
confirming && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1603
|
+
style: confirmStyle,
|
|
1604
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("probeBubbleBody") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1605
|
+
style: confirmRowStyle,
|
|
1606
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1607
|
+
type: "button",
|
|
1608
|
+
style: confirmButtonStyle,
|
|
1609
|
+
onClick: () => {
|
|
1610
|
+
setConfirming(false);
|
|
1611
|
+
},
|
|
1612
|
+
children: t("cancel")
|
|
1613
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1614
|
+
type: "button",
|
|
1615
|
+
style: primaryButtonStyle,
|
|
1616
|
+
onClick: () => {
|
|
1617
|
+
detect();
|
|
1618
|
+
},
|
|
1619
|
+
children: t("probeConfirmAction")
|
|
1620
|
+
})]
|
|
1621
|
+
})]
|
|
1622
|
+
}),
|
|
1623
|
+
note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1624
|
+
role: "status",
|
|
1625
|
+
"aria-live": "polite",
|
|
1626
|
+
style: noteStyle,
|
|
1627
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: noteText(t, note) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1628
|
+
type: "button",
|
|
1629
|
+
style: noteDismissStyle,
|
|
1630
|
+
onClick: () => {
|
|
1631
|
+
setNote(void 0);
|
|
1632
|
+
},
|
|
1633
|
+
children: t("probeNoteDismiss")
|
|
1634
|
+
})]
|
|
1635
|
+
})
|
|
1636
|
+
]
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
/** Compose the one-line outcome string the note bubble shows. */
|
|
1640
|
+
function noteText(t, result) {
|
|
1641
|
+
if (result.validation === "validating" && result.efforts.length > 0) return t("probeNoteVerified", { levels: result.efforts.join(" / ") });
|
|
1642
|
+
if (result.validation === "non-validating") return t("probeNoteNotValidating");
|
|
1643
|
+
return t("probeNoteUnknown");
|
|
1644
|
+
}
|
|
1645
|
+
//#endregion
|
|
1646
|
+
//#region src/client/quota-settings-store.ts
|
|
1647
|
+
let pollIntervalMs = 3e5;
|
|
1648
|
+
const toggles = {
|
|
1649
|
+
cn: false,
|
|
1650
|
+
global: false
|
|
1651
|
+
};
|
|
1652
|
+
const signIn = {
|
|
1653
|
+
cn: false,
|
|
1654
|
+
global: false
|
|
1655
|
+
};
|
|
1656
|
+
let revision = 0;
|
|
1657
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1658
|
+
function bump() {
|
|
1659
|
+
revision += 1;
|
|
1660
|
+
for (const listener of listeners) listener();
|
|
1661
|
+
}
|
|
1662
|
+
/** Update the shared poll interval (from the settings document). */
|
|
1663
|
+
function setQuotaPollMs(ms) {
|
|
1664
|
+
if (Number.isFinite(ms) && ms >= 6e4 && pollIntervalMs !== ms) {
|
|
1665
|
+
pollIntervalMs = ms;
|
|
1666
|
+
bump();
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
/** Read the configured poll interval. */
|
|
1670
|
+
function quotaPollMs() {
|
|
1671
|
+
return pollIntervalMs;
|
|
1672
|
+
}
|
|
1673
|
+
/** Update both sidebar toggles (from the settings document). */
|
|
1674
|
+
function setQuotaToggles(cn, global) {
|
|
1675
|
+
if (toggles.cn !== cn || toggles.global !== global) {
|
|
1676
|
+
toggles.cn = cn;
|
|
1677
|
+
toggles.global = global;
|
|
1678
|
+
bump();
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
/** Read the current toggles. */
|
|
1682
|
+
function quotaToggles() {
|
|
1683
|
+
return { ...toggles };
|
|
1684
|
+
}
|
|
1685
|
+
/** Record a variant's sign-in state from any successful status poll. */
|
|
1686
|
+
function noteQuotaSignIn(variantId, signedIn) {
|
|
1687
|
+
if (variantId === "qoder" && signIn.cn !== signedIn) {
|
|
1688
|
+
signIn.cn = signedIn;
|
|
1689
|
+
bump();
|
|
1690
|
+
} else if (variantId === "qoder-global" && signIn.global !== signedIn) {
|
|
1691
|
+
signIn.global = signedIn;
|
|
1692
|
+
bump();
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
/** Read the cached sign-in state. */
|
|
1696
|
+
function quotaSignInState() {
|
|
1697
|
+
return { ...signIn };
|
|
1698
|
+
}
|
|
1699
|
+
/** Subscribe to any flag change; returns the disposer. */
|
|
1700
|
+
function onQuotaSettingsChange(listener) {
|
|
1701
|
+
listeners.add(listener);
|
|
1702
|
+
return () => {
|
|
1703
|
+
listeners.delete(listener);
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
/** The current revision — the useSyncExternalStore snapshot value. */
|
|
1707
|
+
function quotaSettingsRevision() {
|
|
1708
|
+
return revision;
|
|
1709
|
+
}
|
|
1710
|
+
/** Which variant a status route belongs to, from the route path. */
|
|
1711
|
+
function variantOfStatusPath(statusPath) {
|
|
1712
|
+
return statusPath.includes("/global/") ? "qoder-global" : "qoder";
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* The last status document per variant, shared by EVERY quota surface.
|
|
1716
|
+
*
|
|
1717
|
+
* The sidebar cards and the dashboard each used to fetch independently, so a
|
|
1718
|
+
* dashboard refresh updated the panel while the sidebar card kept showing its
|
|
1719
|
+
* previous read until its own next tick — two different numbers for one
|
|
1720
|
+
* account on one screen. One store, one write path
|
|
1721
|
+
* ({@link noteQuotaStatus}), and every subscriber re-renders through the
|
|
1722
|
+
* same revision: whatever surface refreshed last, all of them show it.
|
|
1723
|
+
*
|
|
1724
|
+
* Documents are kept by identity (never mutated), so reference comparisons
|
|
1725
|
+
* in useSyncExternalStore selectors stay cheap and stable.
|
|
1726
|
+
*/
|
|
1727
|
+
const statusDocuments = {
|
|
1728
|
+
cn: void 0,
|
|
1729
|
+
global: void 0
|
|
1730
|
+
};
|
|
1731
|
+
/**
|
|
1732
|
+
* Publish one variant's freshly fetched status document. Downstream
|
|
1733
|
+
* subscribers (both sidebar cards and the dashboard, through whichever
|
|
1734
|
+
* observable wraps this store) re-render on the revision bump.
|
|
1735
|
+
*/
|
|
1736
|
+
function noteQuotaStatus(variantId, status) {
|
|
1737
|
+
if (variantId === "qoder" && statusDocuments.cn !== status) {
|
|
1738
|
+
statusDocuments.cn = status;
|
|
1739
|
+
statusFetchedAt.cn = Date.now();
|
|
1740
|
+
bump();
|
|
1741
|
+
} else if (variantId === "qoder-global" && statusDocuments.global !== status) {
|
|
1742
|
+
statusDocuments.global = status;
|
|
1743
|
+
statusFetchedAt.global = Date.now();
|
|
1744
|
+
bump();
|
|
1745
|
+
}
|
|
1746
|
+
noteQuotaSignIn(variantId, status.status === "signed-in");
|
|
1747
|
+
}
|
|
1748
|
+
/** Read one variant's latest status document. */
|
|
1749
|
+
function quotaStatus(variantId) {
|
|
1750
|
+
return variantId === "qoder" ? statusDocuments.cn : statusDocuments.global;
|
|
1751
|
+
}
|
|
1752
|
+
/** When each variant's document was last fetched (per publish, not per read). */
|
|
1753
|
+
const statusFetchedAt = {
|
|
1754
|
+
cn: void 0,
|
|
1755
|
+
global: void 0
|
|
1756
|
+
};
|
|
1757
|
+
/** Read the time a variant's current document was fetched, if any. */
|
|
1758
|
+
function quotaStatusFetchedAt(variantId) {
|
|
1759
|
+
return variantId === "qoder" ? statusFetchedAt.cn : statusFetchedAt.global;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Whether ONE variant's shared document is fresh enough to skip a fetch:
|
|
1763
|
+
* the user's rule — a click/mount/interval tick within the configured
|
|
1764
|
+
* interval of the last successful read reuses the cached document, and only
|
|
1765
|
+
* a variant with NO result yet (or a failed read that never landed one)
|
|
1766
|
+
* forces the upstream call. The interval is a cache lifetime, not a metronome.
|
|
1767
|
+
*
|
|
1768
|
+
* A `maxAgeMs` of the poll interval comes from the settings document; a
|
|
1769
|
+
* failed last read is NOT tracked here (callers gate failures themselves),
|
|
1770
|
+
* because "the last read failed" still means "no usable result".
|
|
1771
|
+
*
|
|
1772
|
+
* @param variantId - which variant's freshness to test.
|
|
1773
|
+
* @param maxAgeMs - the configured poll interval (cache lifetime).
|
|
1774
|
+
*/
|
|
1775
|
+
function quotaStatusIsFresh(variantId, maxAgeMs) {
|
|
1776
|
+
const fetchedAt = variantId === "qoder" ? statusFetchedAt.cn : statusFetchedAt.global;
|
|
1777
|
+
const document = variantId === "qoder" ? statusDocuments.cn : statusDocuments.global;
|
|
1778
|
+
if (fetchedAt === void 0 || document === void 0) return false;
|
|
1779
|
+
return Date.now() - fetchedAt < maxAgeMs;
|
|
1780
|
+
}
|
|
1781
|
+
//#endregion
|
|
1782
|
+
//#region src/client/QuotaSettingsCard.tsx
|
|
1783
|
+
/**
|
|
1784
|
+
* The shared quota-settings card: one card above the two variant cards that
|
|
1785
|
+
* configures both sidebar quota widgets.
|
|
1786
|
+
*
|
|
1787
|
+
* Like the built-in plugin cards, it registers into `settings.plugin.item`
|
|
1788
|
+
* keyed by its own settings namespace (`qoder-quota`), binds that
|
|
1789
|
+
* namespace through the client settings scope, and writes through the scope's
|
|
1790
|
+
* revision-fenced `set` — the same durable-write path every preference row
|
|
1791
|
+
* uses. A toggle commits on click: each click is one explicit user choice,
|
|
1792
|
+
* and the scope's ordering makes the last one win, so no staged-draft form is
|
|
1793
|
+
* needed for two booleans and a number.
|
|
1794
|
+
*
|
|
1795
|
+
* The two toggles gate the CN and international sidebar cards respectively;
|
|
1796
|
+
* the interval is one shared poll period. Toggles are disabled while their
|
|
1797
|
+
* variant is signed out: a quota card for an account nobody is signed into
|
|
1798
|
+
* would render an error forever, so the setting waits for a session.
|
|
1799
|
+
*/
|
|
1800
|
+
/** The default poll interval shown before a value is stored. */
|
|
1801
|
+
const POLL_DEFAULT_MS = 3e5;
|
|
1802
|
+
/** Floor the schema also enforces; mirrored here for immediate UI feedback. */
|
|
1803
|
+
const POLL_MIN_MS = 6e4;
|
|
1804
|
+
/** Read the section values out of a scope snapshot (defaults when absent). */
|
|
1805
|
+
function project(scope) {
|
|
1806
|
+
if (scope === void 0) return {
|
|
1807
|
+
status: "unavailable",
|
|
1808
|
+
writable: false,
|
|
1809
|
+
values: {
|
|
1810
|
+
sidebarQuotaCN: false,
|
|
1811
|
+
sidebarQuotaGlobal: false,
|
|
1812
|
+
quotaPollMs: POLL_DEFAULT_MS
|
|
1813
|
+
}
|
|
1814
|
+
};
|
|
1815
|
+
const snapshot = scope.getSnapshot();
|
|
1816
|
+
const value = snapshot.value ?? {};
|
|
1817
|
+
return {
|
|
1818
|
+
status: snapshot.status,
|
|
1819
|
+
writable: snapshot.writable,
|
|
1820
|
+
values: {
|
|
1821
|
+
sidebarQuotaCN: value.sidebarQuotaCN === true,
|
|
1822
|
+
sidebarQuotaGlobal: value.sidebarQuotaGlobal === true,
|
|
1823
|
+
quotaPollMs: typeof value.quotaPollMs === "number" ? value.quotaPollMs : POLL_DEFAULT_MS
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Stable-reference projection cache.
|
|
1829
|
+
*
|
|
1830
|
+
* React's useSyncExternalStore requires getSnapshot() to return THE SAME
|
|
1831
|
+
* reference between renders unless the store actually changed. project()
|
|
1832
|
+
* builds a fresh object every call, which re-renders forever and crashes the
|
|
1833
|
+
* card with React error #185 ("maximum update depth exceeded") — exactly the
|
|
1834
|
+
* crash the slot ledger reported. The cache below returns the last built
|
|
1835
|
+
* projection until the underlying scope snapshot (or scope identity) changes,
|
|
1836
|
+
* which is the only thing the projection actually derives from.
|
|
1837
|
+
*/
|
|
1838
|
+
let cachedScope;
|
|
1839
|
+
let cachedSource;
|
|
1840
|
+
let cachedProjection;
|
|
1841
|
+
const UNAVAILABLE = {
|
|
1842
|
+
status: "unavailable",
|
|
1843
|
+
writable: false,
|
|
1844
|
+
values: {
|
|
1845
|
+
sidebarQuotaCN: false,
|
|
1846
|
+
sidebarQuotaGlobal: false,
|
|
1847
|
+
quotaPollMs: POLL_DEFAULT_MS
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
function stableProject(scope) {
|
|
1851
|
+
if (scope === void 0) return UNAVAILABLE;
|
|
1852
|
+
const source = scope.getSnapshot();
|
|
1853
|
+
if (scope !== cachedScope || source !== cachedSource || cachedProjection === void 0) {
|
|
1854
|
+
cachedScope = scope;
|
|
1855
|
+
cachedSource = source;
|
|
1856
|
+
cachedProjection = project(scope);
|
|
1857
|
+
}
|
|
1858
|
+
return cachedProjection;
|
|
1859
|
+
}
|
|
1860
|
+
/** One toggle row: label, hint, and a switch drawn to the shell's proportions. */
|
|
1861
|
+
function ToggleRow({ label, hint, checked, disabled, disabledHint, onToggle }) {
|
|
1862
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1863
|
+
style: rowStyle,
|
|
1864
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1865
|
+
style: rowTextStyle,
|
|
1866
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1867
|
+
style: labelStyle,
|
|
1868
|
+
children: label
|
|
1869
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1870
|
+
style: hintStyle,
|
|
1871
|
+
children: disabled === true && disabledHint !== void 0 ? disabledHint : hint
|
|
1872
|
+
})]
|
|
1873
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1874
|
+
type: "button",
|
|
1875
|
+
role: "switch",
|
|
1876
|
+
"aria-checked": checked,
|
|
1877
|
+
disabled,
|
|
1878
|
+
"aria-label": label,
|
|
1879
|
+
onClick: () => onToggle(!checked),
|
|
1880
|
+
style: {
|
|
1881
|
+
...switchStyle,
|
|
1882
|
+
background: checked ? "var(--dsw-alias-brand-primary)" : "var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.2))",
|
|
1883
|
+
justifyContent: checked ? "flex-end" : "flex-start",
|
|
1884
|
+
opacity: disabled === true ? .45 : 1,
|
|
1885
|
+
cursor: disabled === true ? "not-allowed" : "pointer"
|
|
1886
|
+
},
|
|
1887
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: knobStyle })
|
|
1888
|
+
})]
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
/** The shared quota-settings card. */
|
|
1892
|
+
function QuotaSettingsCard(props) {
|
|
1893
|
+
const { t = (key) => key, scope, signedIn } = props;
|
|
1894
|
+
const projection = (0, react.useSyncExternalStore)(scope?.subscribe.bind(scope) ?? ((listener) => listener), () => stableProject(scope));
|
|
1895
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
1896
|
+
const [hovered, setHovered] = (0, react.useState)(false);
|
|
1897
|
+
const [headerFocused, setHeaderFocused] = (0, react.useState)(false);
|
|
1898
|
+
const [probe, setProbe] = (0, react.useState)();
|
|
1899
|
+
(0, react.useEffect)(() => {
|
|
1900
|
+
let disposed = false;
|
|
1901
|
+
const probeOne = async (path) => {
|
|
1902
|
+
try {
|
|
1903
|
+
const body = await (await fetch(path, { headers: { accept: "application/json" } })).json();
|
|
1904
|
+
if (disposed || !isQoderWebStatus(body)) return void 0;
|
|
1905
|
+
noteQuotaSignIn(variantOfStatusPath(path), body.status === "signed-in");
|
|
1906
|
+
return body.status === "signed-in";
|
|
1907
|
+
} catch {
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
(async () => {
|
|
1912
|
+
const [cn, global] = await Promise.all([probeOne(QODER_STATUS_PATH), probeOne(QODER_GLOBAL_STATUS_PATH)]);
|
|
1913
|
+
if (!disposed) setProbe({
|
|
1914
|
+
cn: cn === true,
|
|
1915
|
+
global: global === true
|
|
1916
|
+
});
|
|
1917
|
+
})();
|
|
1918
|
+
return () => {
|
|
1919
|
+
disposed = true;
|
|
1920
|
+
};
|
|
1921
|
+
}, [open]);
|
|
1922
|
+
if (projection.status === "unavailable") return null;
|
|
1923
|
+
const reported = signedIn?.() ?? {
|
|
1924
|
+
cn: false,
|
|
1925
|
+
global: false
|
|
1926
|
+
};
|
|
1927
|
+
const signed = {
|
|
1928
|
+
cn: probe?.cn ?? reported.cn,
|
|
1929
|
+
global: probe?.global ?? reported.global
|
|
1930
|
+
};
|
|
1931
|
+
const write = (field, value) => {
|
|
1932
|
+
scope?.set(field, value);
|
|
1933
|
+
};
|
|
1934
|
+
const minutes = Math.max(POLL_MIN_MS / 6e4, Math.round(projection.values.quotaPollMs / 6e4));
|
|
1935
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
1936
|
+
style: {
|
|
1937
|
+
...cardStyle,
|
|
1938
|
+
...hovered ? cardHoverStyle : {},
|
|
1939
|
+
...open ? cardOpenStyle : {}
|
|
1940
|
+
},
|
|
1941
|
+
onMouseEnter: () => {
|
|
1942
|
+
setHovered(true);
|
|
1943
|
+
},
|
|
1944
|
+
onMouseLeave: () => {
|
|
1945
|
+
setHovered(false);
|
|
1946
|
+
},
|
|
1947
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1948
|
+
type: "button",
|
|
1949
|
+
"aria-expanded": open,
|
|
1950
|
+
"aria-label": `${t(open ? "collapse" : "expand")}: ${t("quotaSettingsTitle")}`,
|
|
1951
|
+
onClick: () => setOpen((o) => !o),
|
|
1952
|
+
onFocus: (event) => {
|
|
1953
|
+
let keyboard = true;
|
|
1954
|
+
try {
|
|
1955
|
+
keyboard = event.currentTarget.matches(":focus-visible");
|
|
1956
|
+
} catch {
|
|
1957
|
+
keyboard = true;
|
|
1958
|
+
}
|
|
1959
|
+
if (keyboard) setHeaderFocused(true);
|
|
1960
|
+
},
|
|
1961
|
+
onBlur: () => {
|
|
1962
|
+
setHeaderFocused(false);
|
|
1963
|
+
},
|
|
1964
|
+
style: {
|
|
1965
|
+
...headerButtonStyle,
|
|
1966
|
+
...headerFocused ? headerFocusStyle : {}
|
|
1967
|
+
},
|
|
1968
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1969
|
+
style: headTextStyle,
|
|
1970
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1971
|
+
style: titleStyle,
|
|
1972
|
+
children: t("quotaSettingsTitle")
|
|
1973
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1974
|
+
style: introStyle,
|
|
1975
|
+
children: t("quotaSettingsIntro")
|
|
1976
|
+
})]
|
|
1977
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1978
|
+
style: {
|
|
1979
|
+
...chevronStyle,
|
|
1980
|
+
transform: open ? "rotate(180deg)" : "none"
|
|
1981
|
+
},
|
|
1982
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDownIcon, {})
|
|
1983
|
+
})]
|
|
1984
|
+
}), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1985
|
+
style: cardBodyStyle,
|
|
1986
|
+
children: [
|
|
1987
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
|
|
1988
|
+
label: t("quotaToggleCN"),
|
|
1989
|
+
hint: t("quotaToggleHint"),
|
|
1990
|
+
checked: projection.values.sidebarQuotaCN,
|
|
1991
|
+
disabled: !signed.cn,
|
|
1992
|
+
disabledHint: t("quotaSignInRequired"),
|
|
1993
|
+
onToggle: (next) => write("sidebarQuotaCN", next)
|
|
1994
|
+
}),
|
|
1995
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
|
|
1996
|
+
label: t("quotaToggleGlobal"),
|
|
1997
|
+
hint: t("quotaToggleHint"),
|
|
1998
|
+
checked: projection.values.sidebarQuotaGlobal,
|
|
1999
|
+
disabled: !signed.global,
|
|
2000
|
+
disabledHint: t("quotaSignInRequired"),
|
|
2001
|
+
onToggle: (next) => write("sidebarQuotaGlobal", next)
|
|
2002
|
+
}),
|
|
2003
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2004
|
+
style: {
|
|
2005
|
+
...rowStyle,
|
|
2006
|
+
borderBottom: "none",
|
|
2007
|
+
paddingBottom: 0
|
|
2008
|
+
},
|
|
2009
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2010
|
+
style: rowTextStyle,
|
|
2011
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2012
|
+
style: labelStyle,
|
|
2013
|
+
children: t("quotaPollLabel")
|
|
2014
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2015
|
+
style: hintStyle,
|
|
2016
|
+
children: t("quotaPollHint")
|
|
2017
|
+
})]
|
|
2018
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2019
|
+
style: pollFieldStyle,
|
|
2020
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2021
|
+
type: "number",
|
|
2022
|
+
min: POLL_MIN_MS / 6e4,
|
|
2023
|
+
step: 1,
|
|
2024
|
+
value: minutes,
|
|
2025
|
+
"aria-label": t("quotaPollLabel"),
|
|
2026
|
+
onChange: (event) => {
|
|
2027
|
+
const mins = Number.parseInt(event.target.value, 10);
|
|
2028
|
+
if (Number.isFinite(mins) && mins > 0) write("quotaPollMs", Math.max(POLL_MIN_MS, mins * 6e4));
|
|
2029
|
+
},
|
|
2030
|
+
style: inputStyle
|
|
2031
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2032
|
+
style: hintStyle,
|
|
2033
|
+
children: t("quotaPollUnit")
|
|
2034
|
+
})]
|
|
2035
|
+
})]
|
|
2036
|
+
}),
|
|
2037
|
+
projection.writable === false ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2038
|
+
style: hintStyle,
|
|
2039
|
+
children: t("quotaSettingsSaveFailed")
|
|
2040
|
+
}) : null
|
|
2041
|
+
]
|
|
2042
|
+
}) : null]
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
/** The disclosure chevron, drawn from the same path data as the variant cards'. */
|
|
2046
|
+
function ChevronDownIcon() {
|
|
2047
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
2048
|
+
width: 14,
|
|
2049
|
+
height: 14,
|
|
2050
|
+
viewBox: "0 0 14 14",
|
|
2051
|
+
fill: "none",
|
|
2052
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
2053
|
+
"aria-hidden": "true",
|
|
2054
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
2055
|
+
d: "M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z",
|
|
2056
|
+
fill: "currentColor"
|
|
2057
|
+
})
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
const cardStyle = {
|
|
2061
|
+
listStyle: "none",
|
|
2062
|
+
borderWidth: "0.5px",
|
|
2063
|
+
borderStyle: "solid",
|
|
2064
|
+
borderColor: "var(--dsw-alias-border-l4)",
|
|
2065
|
+
borderRadius: 16,
|
|
2066
|
+
background: "var(--dsw-alias-bg-layer-3)",
|
|
2067
|
+
transition: "border-color .16s, background .16s"
|
|
2068
|
+
};
|
|
2069
|
+
/** Hover, matching the built-in card's `:hover` (inline styles cannot express pseudo-classes). */
|
|
2070
|
+
const cardHoverStyle = { borderColor: "var(--dsw-alias-label-dimmed)" };
|
|
2071
|
+
/** Expanded, matching the built-in card's open state. */
|
|
2072
|
+
const cardOpenStyle = {
|
|
2073
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
2074
|
+
borderColor: "var(--dsw-alias-label-dimmed)"
|
|
2075
|
+
};
|
|
2076
|
+
const headerButtonStyle = {
|
|
2077
|
+
boxSizing: "border-box",
|
|
2078
|
+
width: "100%",
|
|
2079
|
+
display: "flex",
|
|
2080
|
+
alignItems: "center",
|
|
2081
|
+
gap: 12,
|
|
2082
|
+
border: 0,
|
|
2083
|
+
borderRadius: 12,
|
|
2084
|
+
padding: "14px 16px",
|
|
2085
|
+
background: "transparent",
|
|
2086
|
+
color: "inherit",
|
|
2087
|
+
font: "inherit",
|
|
2088
|
+
textAlign: "left",
|
|
2089
|
+
cursor: "pointer",
|
|
2090
|
+
appearance: "none"
|
|
2091
|
+
};
|
|
2092
|
+
const headerFocusStyle = {
|
|
2093
|
+
outline: "2px solid var(--dsw-alias-brand-primary)",
|
|
2094
|
+
outlineOffset: -2
|
|
2095
|
+
};
|
|
2096
|
+
const headTextStyle = {
|
|
2097
|
+
display: "flex",
|
|
2098
|
+
flex: 1,
|
|
2099
|
+
minWidth: 0,
|
|
2100
|
+
flexDirection: "column",
|
|
2101
|
+
gap: 4
|
|
2102
|
+
};
|
|
2103
|
+
const titleStyle = {
|
|
2104
|
+
fontSize: 15,
|
|
2105
|
+
lineHeight: 1.4,
|
|
2106
|
+
fontWeight: 600,
|
|
2107
|
+
color: "var(--dsw-alias-label-primary)"
|
|
2108
|
+
};
|
|
2109
|
+
const introStyle = {
|
|
2110
|
+
fontSize: 13,
|
|
2111
|
+
lineHeight: 1.5,
|
|
2112
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
2113
|
+
};
|
|
2114
|
+
/** The variant cards' chevron rule. */
|
|
2115
|
+
const chevronStyle = {
|
|
2116
|
+
flex: "none",
|
|
2117
|
+
display: "flex",
|
|
2118
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
2119
|
+
transition: "transform .16s"
|
|
2120
|
+
};
|
|
2121
|
+
/** The variant cards' body: hairline top border, inset margins, no extra box. */
|
|
2122
|
+
const cardBodyStyle = {
|
|
2123
|
+
borderTop: ".5px solid var(--dsw-alias-border-l2)",
|
|
2124
|
+
margin: "0 16px",
|
|
2125
|
+
padding: "12px 0 8px"
|
|
2126
|
+
};
|
|
2127
|
+
/**
|
|
2128
|
+
* One settings row: NO box of its own (the bordered rows read as nested
|
|
2129
|
+
* cards, which the user ruled against) — rows are separated by a hairline
|
|
2130
|
+
* bottom rule like the settings shell's own preference lists.
|
|
2131
|
+
*/
|
|
2132
|
+
const rowStyle = {
|
|
2133
|
+
display: "flex",
|
|
2134
|
+
alignItems: "center",
|
|
2135
|
+
gap: 12,
|
|
2136
|
+
borderBottom: ".5px solid var(--dsw-alias-border-l2)",
|
|
2137
|
+
paddingBottom: 10
|
|
2138
|
+
};
|
|
2139
|
+
const rowTextStyle = {
|
|
2140
|
+
display: "flex",
|
|
2141
|
+
flex: 1,
|
|
2142
|
+
minWidth: 0,
|
|
2143
|
+
flexDirection: "column",
|
|
2144
|
+
gap: 2
|
|
2145
|
+
};
|
|
2146
|
+
const labelStyle = {
|
|
2147
|
+
fontSize: 13,
|
|
2148
|
+
fontWeight: 500,
|
|
2149
|
+
lineHeight: 1.5,
|
|
2150
|
+
color: "var(--dsw-alias-label-primary)"
|
|
2151
|
+
};
|
|
2152
|
+
const hintStyle = {
|
|
2153
|
+
fontSize: 12,
|
|
2154
|
+
lineHeight: 1.5,
|
|
2155
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
2156
|
+
};
|
|
2157
|
+
const switchStyle = {
|
|
2158
|
+
flex: "none",
|
|
2159
|
+
display: "flex",
|
|
2160
|
+
width: 36,
|
|
2161
|
+
height: 20,
|
|
2162
|
+
borderRadius: 10,
|
|
2163
|
+
borderWidth: "1px",
|
|
2164
|
+
borderStyle: "solid",
|
|
2165
|
+
borderColor: "var(--dsw-alias-border-l2)",
|
|
2166
|
+
padding: 1,
|
|
2167
|
+
cursor: "pointer",
|
|
2168
|
+
alignItems: "center",
|
|
2169
|
+
transition: "background .16s"
|
|
2170
|
+
};
|
|
2171
|
+
const knobStyle = {
|
|
2172
|
+
display: "block",
|
|
2173
|
+
width: 16,
|
|
2174
|
+
height: 16,
|
|
2175
|
+
borderRadius: "50%",
|
|
2176
|
+
background: "var(--dsw-alias-bg-layer-1, #fff)",
|
|
2177
|
+
boxShadow: "0 1px 2px rgba(0,0,0,0.2)"
|
|
2178
|
+
};
|
|
2179
|
+
const pollFieldStyle = {
|
|
2180
|
+
display: "flex",
|
|
2181
|
+
alignItems: "center",
|
|
2182
|
+
gap: 6,
|
|
2183
|
+
flex: "none"
|
|
2184
|
+
};
|
|
2185
|
+
const inputStyle = {
|
|
2186
|
+
boxSizing: "border-box",
|
|
2187
|
+
width: 55,
|
|
2188
|
+
padding: "5px 8px",
|
|
2189
|
+
borderWidth: "1px",
|
|
2190
|
+
borderStyle: "solid",
|
|
2191
|
+
borderColor: "var(--dsw-alias-border-l2)",
|
|
2192
|
+
borderRadius: 8,
|
|
2193
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
2194
|
+
color: "var(--dsw-alias-label-primary)",
|
|
2195
|
+
font: "inherit",
|
|
2196
|
+
fontSize: 13,
|
|
2197
|
+
textAlign: "right"
|
|
2198
|
+
};
|
|
2199
|
+
//#endregion
|
|
2200
|
+
//#region src/client/quota-merge.ts
|
|
2201
|
+
/**
|
|
2202
|
+
* Group credit accounts by (packageName, packageEndTime) and sum each group.
|
|
2203
|
+
*
|
|
2204
|
+
* A missing figure counts as 0 (the user's ruling): a package whose total the
|
|
2205
|
+
* upstream did not report contributes nothing to the group's `size` rather
|
|
2206
|
+
* than poisoning the bar into "unknown". A group whose summed `size` is still
|
|
2207
|
+
* 0 keeps an honest "unknown total" rendering in the card — the merge never
|
|
2208
|
+
* invents a denominator. `unlimited` is sticky: one unlimited member makes the
|
|
2209
|
+
* whole group unlimited, and its sums are not displayed as a quota.
|
|
2210
|
+
*
|
|
2211
|
+
* MERGE KEY IS THE NAME ALONE (the user's correction): same-named packages
|
|
2212
|
+
* whose expiries differ by seconds (stacked purchase batches) must still be
|
|
2213
|
+
* one overview row — keying on the expiry exploded 28 same-named packages
|
|
2214
|
+
* back into 28 separate bars the moment the host started reporting real
|
|
2215
|
+
* dates. The expiry travels on the group (earliest of the members) for the
|
|
2216
|
+
* sort/visibility rules; the itemised per-expiry breakdown lives in the
|
|
2217
|
+
* dashboard's table.
|
|
2218
|
+
*
|
|
2219
|
+
* @param accounts - the status document's per-package credit entries.
|
|
2220
|
+
* @returns one group per distinct package NAME, in first-seen order.
|
|
2221
|
+
*/
|
|
2222
|
+
function mergeCreditAccounts(accounts) {
|
|
2223
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2224
|
+
for (const account of accounts) {
|
|
2225
|
+
const key = account.packageName;
|
|
2226
|
+
const existing = groups.get(key);
|
|
2227
|
+
if (existing === void 0) {
|
|
2228
|
+
groups.set(key, {
|
|
2229
|
+
packageName: account.packageName,
|
|
2230
|
+
packageEndTime: account.packageEndTime,
|
|
2231
|
+
remain: account.remain,
|
|
2232
|
+
size: account.size,
|
|
2233
|
+
unlimited: account.unlimited === true
|
|
2234
|
+
});
|
|
2235
|
+
continue;
|
|
2236
|
+
}
|
|
2237
|
+
existing.remain += account.remain;
|
|
2238
|
+
existing.size += account.size;
|
|
2239
|
+
existing.unlimited = existing.unlimited || account.unlimited === true;
|
|
2240
|
+
if (existing.packageEndTime !== void 0 && account.packageEndTime !== void 0) {
|
|
2241
|
+
const a = Date.parse(existing.packageEndTime);
|
|
2242
|
+
const b = Date.parse(account.packageEndTime);
|
|
2243
|
+
if (!Number.isNaN(a) && !Number.isNaN(b) && b < a) existing.packageEndTime = account.packageEndTime;
|
|
2244
|
+
} else existing.packageEndTime = existing.packageEndTime ?? account.packageEndTime;
|
|
2245
|
+
}
|
|
2246
|
+
return [...groups.values()];
|
|
2247
|
+
}
|
|
2248
|
+
/** Clamp helper shared by the card's percent math. */
|
|
2249
|
+
function clampPercent(remain, size) {
|
|
2250
|
+
if (!(size > 0)) return void 0;
|
|
2251
|
+
const percent = remain / size * 100;
|
|
2252
|
+
if (!Number.isFinite(percent)) return void 0;
|
|
2253
|
+
return Math.min(100, Math.max(0, percent));
|
|
2254
|
+
}
|
|
2255
|
+
/** Parse an upstream expiry string ("YYYY-MM-DD HH:mm:ss") into a timestamp; undefined when unparseable. */
|
|
2256
|
+
function parseExpiry(value) {
|
|
2257
|
+
if (value === void 0) return void 0;
|
|
2258
|
+
const parsed = Date.parse(value);
|
|
2259
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
2260
|
+
}
|
|
2261
|
+
/** Whether a group is spent (remain 0) and NOT unlimited. */
|
|
2262
|
+
function isSpent(group) {
|
|
2263
|
+
return !group.unlimited && group.remain <= 0;
|
|
2264
|
+
}
|
|
2265
|
+
/** Whether a spent group's expiry date has already passed (undated → false: no proof of expiry). */
|
|
2266
|
+
function isExpired(group, now) {
|
|
2267
|
+
if (!isSpent(group)) return false;
|
|
2268
|
+
const expiry = parseExpiry(group.packageEndTime);
|
|
2269
|
+
return expiry !== void 0 && expiry < now;
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* SIDEBAR overview rule (the user's spec, restored after it was wrongly
|
|
2273
|
+
* applied to the panel):
|
|
2274
|
+
*
|
|
2275
|
+
* - A group with credit LEFT (remain > 0, or unlimited) renders.
|
|
2276
|
+
* - A SPENT group (remain 0) renders ONLY when EVERY group is spent AND it
|
|
2277
|
+
* is not expired — the account's standing quota whose emptiness is itself
|
|
2278
|
+
* the news. When anything still has credit, spent rows are noise.
|
|
2279
|
+
* - An EXPIRED group (spent and its expiry date has passed) renders
|
|
2280
|
+
* NOWHERE, whatever the rest of the account looks like.
|
|
2281
|
+
*
|
|
2282
|
+
* Applied AFTER the merge so the sums are settled before the test.
|
|
2283
|
+
* "Now" is injectable for tests.
|
|
2284
|
+
*
|
|
2285
|
+
* @param groups - merged groups, in first-seen order.
|
|
2286
|
+
* @param now - current timestamp (defaults to Date.now()).
|
|
2287
|
+
* @returns the groups to display, in first-seen order.
|
|
2288
|
+
*/
|
|
2289
|
+
function visibleQuotaGroups(groups, now = Date.now()) {
|
|
2290
|
+
const hasCredit = groups.some((group) => group.unlimited || group.remain > 0);
|
|
2291
|
+
return groups.filter((group) => {
|
|
2292
|
+
if (group.unlimited || group.remain > 0) return true;
|
|
2293
|
+
return !hasCredit && !isExpired(group, now);
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* PANEL detail-table ordering (the user's spec): every row renders — the
|
|
2298
|
+
* panel is the itemised ledger — but spent-yet-still-active rows sink to the
|
|
2299
|
+
* BOTTOM (lowest priority), and expired rows are dropped entirely. Rows keep
|
|
2300
|
+
* their first-seen order within each band.
|
|
2301
|
+
*
|
|
2302
|
+
* @param rows - per-package rows, unmerged, in first-seen order.
|
|
2303
|
+
* @param now - current timestamp (defaults to Date.now()).
|
|
2304
|
+
*/
|
|
2305
|
+
function sortPackageRows(rows, now = Date.now()) {
|
|
2306
|
+
const live = [];
|
|
2307
|
+
const spent = [];
|
|
2308
|
+
for (const row of rows) {
|
|
2309
|
+
const unlimited = row.unlimited === true;
|
|
2310
|
+
const expiry = parseExpiry(row.packageEndTime);
|
|
2311
|
+
if (!unlimited && row.remain <= 0 && expiry !== void 0 && expiry < now) continue;
|
|
2312
|
+
if (unlimited || row.remain > 0) live.push(row);
|
|
2313
|
+
else spent.push(row);
|
|
2314
|
+
}
|
|
2315
|
+
return [...live, ...spent];
|
|
2316
|
+
}
|
|
2317
|
+
//#endregion
|
|
2318
|
+
//#region src/client/SidebarQuotaCard.tsx
|
|
2319
|
+
/**
|
|
2320
|
+
* The sidebar footer quota card + the center-column dashboard it opens.
|
|
2321
|
+
*
|
|
2322
|
+
* The structure is a direct port of commandcode's plans & quota panel
|
|
2323
|
+
* (src/client/panel-view.tsx + panel.ts), which the user held up as the
|
|
2324
|
+
* reference: the card IS the button (the shell supplies no chrome), it renders
|
|
2325
|
+
* one block per merged package group — the group's remain/total, its
|
|
2326
|
+
* percentage and its bar — carries the last-updated time in the card's top
|
|
2327
|
+
* row, and opens a dashboard in the layout's keyed `main` slot on click. In
|
|
2328
|
+
* the 56px rail it collapses to a 36px icon button carrying the ring.
|
|
2329
|
+
*
|
|
2330
|
+
* Data comes from the variant's status route (poll, paused while hidden);
|
|
2331
|
+
* strings come from the `panel.qoder-quota` locale namespace; classes come
|
|
2332
|
+
* from `./quota-styles.ts` (`qdp-` prefix).
|
|
2333
|
+
*/
|
|
2334
|
+
/** Fallback translator: renders keys bare rather than throwing unbound. */
|
|
2335
|
+
const fallbackT = (key) => key;
|
|
2336
|
+
/**
|
|
2337
|
+
* Identity line under the dashboard card title: the redacted PAT tail when a
|
|
2338
|
+
* token is on file, plain signed-in copy if the summary is missing, and the
|
|
2339
|
+
* save-a-PAT hint otherwise. There is no nickname on a PAT document.
|
|
2340
|
+
*/
|
|
2341
|
+
function ownerText(status, t) {
|
|
2342
|
+
if (status === void 0 || status.status !== "signed-in") return t("quotaNotSignedIn");
|
|
2343
|
+
const tail = status.pat?.patTail;
|
|
2344
|
+
return tail === void 0 ? t("signedIn") : t("patTail", { tail: `****${tail}` });
|
|
2345
|
+
}
|
|
2346
|
+
/** Project the credit accounts into the card's bar list. */
|
|
2347
|
+
function buildBars(accounts) {
|
|
2348
|
+
const groups = visibleQuotaGroups(mergeCreditAccounts(accounts));
|
|
2349
|
+
const bars = [];
|
|
2350
|
+
for (const group of groups) {
|
|
2351
|
+
const percent = group.unlimited ? void 0 : clampPercent(group.remain, group.size);
|
|
2352
|
+
const detail = group.unlimited ? "∞" : percent === void 0 ? `${group.remain.toLocaleString()} · ?` : `${group.remain.toLocaleString()} / ${group.size.toLocaleString()}`;
|
|
2353
|
+
bars.push({
|
|
2354
|
+
label: group.packageName,
|
|
2355
|
+
detail,
|
|
2356
|
+
percent: percent === void 0 ? void 0 : `${Math.round(percent)}%`,
|
|
2357
|
+
barPercent: percent === void 0 ? 0 : Math.max(2, percent),
|
|
2358
|
+
warn: !group.unlimited && percent !== void 0 && percent < 20,
|
|
2359
|
+
packageEndTime: group.packageEndTime
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
return bars;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* The quota ring — commandcode's glyph ported verbatim (`qdp-` classes): a
|
|
2366
|
+
* faint track plus an arc whose sweep is the consumption, drawn from 12
|
|
2367
|
+
* o'clock. Circumference 2πr = 45.55 at r = 7.25.
|
|
2368
|
+
*/
|
|
2369
|
+
function Ring({ percent, warn, size }) {
|
|
2370
|
+
const clamped = Math.min(100, Math.max(0, percent));
|
|
2371
|
+
const circumference = 45.55;
|
|
2372
|
+
const dashoffset = Math.round(circumference * (1 - clamped / 100) * 1e3) / 1e3;
|
|
2373
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2374
|
+
className: "qdp-glyph",
|
|
2375
|
+
"aria-hidden": "true",
|
|
2376
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
2377
|
+
viewBox: "0 0 20 20",
|
|
2378
|
+
width: size,
|
|
2379
|
+
height: size,
|
|
2380
|
+
focusable: "false",
|
|
2381
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
2382
|
+
cx: "10",
|
|
2383
|
+
cy: "10",
|
|
2384
|
+
r: "7.25",
|
|
2385
|
+
fill: "none",
|
|
2386
|
+
stroke: "currentColor",
|
|
2387
|
+
strokeWidth: "1.5",
|
|
2388
|
+
opacity: "0.4"
|
|
2389
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
2390
|
+
cx: "10",
|
|
2391
|
+
cy: "10",
|
|
2392
|
+
r: "7.25",
|
|
2393
|
+
fill: "none",
|
|
2394
|
+
stroke: warn ? "var(--dsw-alias-state-error-primary)" : "currentColor",
|
|
2395
|
+
strokeWidth: "2.5",
|
|
2396
|
+
strokeLinecap: "round",
|
|
2397
|
+
strokeDasharray: String(circumference),
|
|
2398
|
+
strokeDashoffset: String(dashoffset),
|
|
2399
|
+
transform: "rotate(-90 10 10)"
|
|
2400
|
+
})]
|
|
2401
|
+
})
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* The dashboard's detail rows: EVERY package as the upstream reported it —
|
|
2406
|
+
* no merging, exhausted ones included. The sidebar card shows the merged
|
|
2407
|
+
* overview; this panel is the itemised ledger, so collapsing here would
|
|
2408
|
+
* destroy the only place a per-package figure is visible.
|
|
2409
|
+
*/
|
|
2410
|
+
function buildPackageRows(accounts) {
|
|
2411
|
+
return sortPackageRows(accounts).map((account) => {
|
|
2412
|
+
const percent = account.unlimited === true ? void 0 : clampPercent(account.remain, account.size);
|
|
2413
|
+
return {
|
|
2414
|
+
name: account.packageName,
|
|
2415
|
+
remain: account.remain,
|
|
2416
|
+
size: account.size,
|
|
2417
|
+
percent,
|
|
2418
|
+
warn: account.unlimited !== true && percent !== void 0 && percent < 20,
|
|
2419
|
+
packageEndTime: account.packageEndTime
|
|
2420
|
+
};
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
/** Time-of-day formatter for the updated stamp. */
|
|
2424
|
+
function timeText(ms) {
|
|
2425
|
+
return new Date(ms).toLocaleTimeString(void 0, {
|
|
2426
|
+
hour: "2-digit",
|
|
2427
|
+
minute: "2-digit"
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
/** One variant's sidebar quota card. */
|
|
2431
|
+
function SidebarQuotaCard(props) {
|
|
2432
|
+
const { t = fallbackT, statusPath, open } = props;
|
|
2433
|
+
const variantId = statusPath !== void 0 ? variantOfStatusPath(statusPath) : "qoder";
|
|
2434
|
+
const nameKey = variantId === "qoder-global" ? "quotaCardGlobal" : "quotaCardCN";
|
|
2435
|
+
const wide = props.wide !== false;
|
|
2436
|
+
const [failed, setFailed] = (0, react.useState)(false);
|
|
2437
|
+
(0, react.useSyncExternalStore)(onQuotaSettingsChange, quotaSettingsRevision);
|
|
2438
|
+
const enabled = variantId === "qoder" ? quotaToggles().cn : quotaToggles().global;
|
|
2439
|
+
const status = quotaStatus(variantId);
|
|
2440
|
+
(0, react.useEffect)(() => {
|
|
2441
|
+
if (statusPath === void 0 || !enabled) return void 0;
|
|
2442
|
+
let disposed = false;
|
|
2443
|
+
let timer;
|
|
2444
|
+
const controller = new AbortController();
|
|
2445
|
+
const refresh = async () => {
|
|
2446
|
+
try {
|
|
2447
|
+
const response = await fetch(statusPath, {
|
|
2448
|
+
signal: controller.signal,
|
|
2449
|
+
headers: { accept: "application/json" }
|
|
2450
|
+
});
|
|
2451
|
+
const body = await response.json();
|
|
2452
|
+
if (disposed) return;
|
|
2453
|
+
if (!response.ok || !isQoderWebStatus(body)) {
|
|
2454
|
+
setFailed(true);
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
setFailed(false);
|
|
2458
|
+
noteQuotaStatus(variantId, body);
|
|
2459
|
+
} catch {
|
|
2460
|
+
if (!disposed) setFailed(true);
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
const loop = () => {
|
|
2464
|
+
if (document.hidden) return;
|
|
2465
|
+
refresh();
|
|
2466
|
+
};
|
|
2467
|
+
timer = window.setInterval(loop, Math.max(6e4, quotaPollMs()));
|
|
2468
|
+
refresh();
|
|
2469
|
+
const onVisible = () => {
|
|
2470
|
+
if (!document.hidden) loop();
|
|
2471
|
+
};
|
|
2472
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
2473
|
+
return () => {
|
|
2474
|
+
disposed = true;
|
|
2475
|
+
controller.abort();
|
|
2476
|
+
if (timer !== void 0) window.clearInterval(timer);
|
|
2477
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
2478
|
+
};
|
|
2479
|
+
}, [
|
|
2480
|
+
statusPath,
|
|
2481
|
+
enabled,
|
|
2482
|
+
variantId
|
|
2483
|
+
]);
|
|
2484
|
+
if (enabled === false) return null;
|
|
2485
|
+
const credits = status !== void 0 && "credits" in status ? status.credits : void 0;
|
|
2486
|
+
const bars = credits === void 0 ? [] : buildBars(credits.accounts ?? []);
|
|
2487
|
+
const lowest = bars.reduce((acc, bar) => {
|
|
2488
|
+
if (bar.percent === void 0) return acc;
|
|
2489
|
+
const value = Number.parseFloat(bar.percent);
|
|
2490
|
+
if (!Number.isFinite(value)) return acc;
|
|
2491
|
+
return acc === void 0 ? value : Math.min(acc, value);
|
|
2492
|
+
}, void 0);
|
|
2493
|
+
const ringPercent = failed || status === void 0 ? 0 : credits?.unlimited === true ? 100 : lowest ?? 0;
|
|
2494
|
+
const ringWarn = failed || lowest !== void 0 && lowest < 20;
|
|
2495
|
+
const fetchedAt = quotaStatusFetchedAt(variantId);
|
|
2496
|
+
const title = [
|
|
2497
|
+
t(nameKey),
|
|
2498
|
+
...bars.map((bar) => `${bar.label} ${bar.detail}${bar.percent === void 0 ? "" : ` (${bar.percent})`}`),
|
|
2499
|
+
fetchedAt !== void 0 ? `${t("quotaUpdated")} ${timeText(fetchedAt)}` : ""
|
|
2500
|
+
].filter((part) => part !== "").join(" · ");
|
|
2501
|
+
if (!wide) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2502
|
+
type: "button",
|
|
2503
|
+
className: "qdp-railButton",
|
|
2504
|
+
"aria-label": title,
|
|
2505
|
+
title,
|
|
2506
|
+
onClick: () => open?.(),
|
|
2507
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Ring, {
|
|
2508
|
+
percent: ringPercent,
|
|
2509
|
+
warn: ringWarn,
|
|
2510
|
+
size: 18
|
|
2511
|
+
})
|
|
2512
|
+
});
|
|
2513
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2514
|
+
type: "button",
|
|
2515
|
+
className: "qdp-foot",
|
|
2516
|
+
"aria-label": title,
|
|
2517
|
+
title,
|
|
2518
|
+
onClick: () => open?.(),
|
|
2519
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2520
|
+
className: "qdp-footTop",
|
|
2521
|
+
children: [
|
|
2522
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Ring, {
|
|
2523
|
+
percent: ringPercent,
|
|
2524
|
+
warn: ringWarn,
|
|
2525
|
+
size: 16
|
|
2526
|
+
}),
|
|
2527
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2528
|
+
className: "qdp-footName",
|
|
2529
|
+
children: t(nameKey)
|
|
2530
|
+
}),
|
|
2531
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
|
|
2532
|
+
fetchedAt !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2533
|
+
className: "qdp-updated",
|
|
2534
|
+
children: [
|
|
2535
|
+
t("quotaUpdated"),
|
|
2536
|
+
" ",
|
|
2537
|
+
timeText(fetchedAt)
|
|
2538
|
+
]
|
|
2539
|
+
}) : null
|
|
2540
|
+
]
|
|
2541
|
+
}), failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2542
|
+
className: "qdp-footRow",
|
|
2543
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2544
|
+
className: "qdp-footLabel",
|
|
2545
|
+
children: t("quotaError")
|
|
2546
|
+
})
|
|
2547
|
+
}) : bars.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2548
|
+
className: "qdp-footRow",
|
|
2549
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2550
|
+
className: "qdp-footLabel",
|
|
2551
|
+
children: status === void 0 ? "…" : !("credits" in status) ? t("quotaNotSignedIn") : status.creditsError ?? t("quotaError")
|
|
2552
|
+
})
|
|
2553
|
+
}) : bars.map((bar, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2554
|
+
className: "qdp-footRow",
|
|
2555
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2556
|
+
className: "qdp-footHead",
|
|
2557
|
+
children: [
|
|
2558
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2559
|
+
className: "qdp-footLabel",
|
|
2560
|
+
title: bar.packageEndTime !== void 0 ? `${t("quotaExpires")} ${bar.packageEndTime}` : t("quotaNoExpiry"),
|
|
2561
|
+
children: bar.label
|
|
2562
|
+
}),
|
|
2563
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2564
|
+
className: "qdp-footAmount",
|
|
2565
|
+
children: bar.detail
|
|
2566
|
+
}),
|
|
2567
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2568
|
+
className: "qdp-footPct",
|
|
2569
|
+
children: bar.percent ?? ""
|
|
2570
|
+
})
|
|
2571
|
+
]
|
|
2572
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2573
|
+
className: "qdp-footBar",
|
|
2574
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2575
|
+
className: bar.warn ? "qdp-footFill qdp-footFillWarn" : "qdp-footFill",
|
|
2576
|
+
style: { width: `${bar.barPercent}%` }
|
|
2577
|
+
})
|
|
2578
|
+
})]
|
|
2579
|
+
}, `${index}\u0000${bar.label}\u0000${bar.packageEndTime ?? ""}`))]
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* The center-column dashboard, registered into the layout's keyed `main` slot
|
|
2584
|
+
* under the id the footer cards select, so card → panel is one navigation
|
|
2585
|
+
* entry. One variant at a time, switched by tabs (commandcode's account-tab
|
|
2586
|
+
* pattern): CN and international are separate accounts with separate package
|
|
2587
|
+
* lists, so mixing them into one column would misattribute every number.
|
|
2588
|
+
*
|
|
2589
|
+
* The panel fetches BOTH routes itself on mount and on the shared poll
|
|
2590
|
+
* interval — a user opening the panel must never wait for the sidebar cards'
|
|
2591
|
+
* next tick, and must never see a stale "sign in" just because no poll had
|
|
2592
|
+
* run yet. The tab defaults to the variant whose card was clicked.
|
|
2593
|
+
*/
|
|
2594
|
+
function QuotaDashboard(props) {
|
|
2595
|
+
const { t = fallbackT, statusPaths, refresh, close, useQuotaDashboard, onVariantPicked } = props;
|
|
2596
|
+
(0, react.useSyncExternalStore)(onQuotaSettingsChange, quotaSettingsRevision);
|
|
2597
|
+
const state = useQuotaDashboard((s) => s);
|
|
2598
|
+
const followedPath = state.activePath;
|
|
2599
|
+
const [userPicked, setUserPicked] = (0, react.useState)(void 0);
|
|
2600
|
+
const activePathResolved = userPicked ?? followedPath;
|
|
2601
|
+
const activeVariant = variantOfStatusPath(activePathResolved);
|
|
2602
|
+
const status = quotaStatus(activeVariant);
|
|
2603
|
+
const loading = state.loading;
|
|
2604
|
+
const fetchedAt = state.fetchedAt;
|
|
2605
|
+
const credits = status !== void 0 && "credits" in status ? status.credits : void 0;
|
|
2606
|
+
const rows = credits === void 0 ? [] : buildPackageRows(credits.accounts ?? []);
|
|
2607
|
+
const nameKey = activeVariant === "qoder-global" ? "quotaCardGlobal" : "quotaCardCN";
|
|
2608
|
+
const signedIn = status?.status === "signed-in";
|
|
2609
|
+
const totalRemain = credits === void 0 ? 0 : credits.accounts.reduce((sum, account) => sum + account.remain, 0);
|
|
2610
|
+
const totalSize = credits?.totalSize ?? credits?.accounts.reduce((sum, account) => sum + account.size, 0) ?? 0;
|
|
2611
|
+
const totalPercent = clampPercent(totalRemain, totalSize);
|
|
2612
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2613
|
+
className: "qdp-main",
|
|
2614
|
+
role: "region",
|
|
2615
|
+
"aria-label": t("quotaDashboardTitle"),
|
|
2616
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2617
|
+
className: "qdp-mainInner",
|
|
2618
|
+
children: [
|
|
2619
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
2620
|
+
className: "qdp-header",
|
|
2621
|
+
children: [
|
|
2622
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2623
|
+
className: "qdp-headerText",
|
|
2624
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
2625
|
+
className: "qdp-title",
|
|
2626
|
+
children: t("quotaDashboardTitle")
|
|
2627
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2628
|
+
className: "qdp-subtitle",
|
|
2629
|
+
children: t("quotaDashboardSubtitle")
|
|
2630
|
+
})]
|
|
2631
|
+
}),
|
|
2632
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "qdp-spacer" }),
|
|
2633
|
+
fetchedAt !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2634
|
+
className: "qdp-meta",
|
|
2635
|
+
children: [
|
|
2636
|
+
t("quotaUpdated"),
|
|
2637
|
+
" ",
|
|
2638
|
+
timeText(fetchedAt)
|
|
2639
|
+
]
|
|
2640
|
+
}) : null,
|
|
2641
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2642
|
+
type: "button",
|
|
2643
|
+
className: "qdp-refresh",
|
|
2644
|
+
disabled: loading,
|
|
2645
|
+
onClick: () => refresh(),
|
|
2646
|
+
children: loading ? t("quotaRefreshing") : t("quotaRefresh")
|
|
2647
|
+
}),
|
|
2648
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2649
|
+
type: "button",
|
|
2650
|
+
className: "qdp-close",
|
|
2651
|
+
"aria-label": t("quotaClose"),
|
|
2652
|
+
title: t("quotaClose"),
|
|
2653
|
+
onClick: () => close(),
|
|
2654
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2655
|
+
"aria-hidden": "true",
|
|
2656
|
+
children: "×"
|
|
2657
|
+
})
|
|
2658
|
+
})
|
|
2659
|
+
]
|
|
2660
|
+
}),
|
|
2661
|
+
statusPaths.length > 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2662
|
+
className: "qdp-tabs",
|
|
2663
|
+
role: "tablist",
|
|
2664
|
+
"aria-label": t("quotaDashboardTitle"),
|
|
2665
|
+
children: statusPaths.map((path) => {
|
|
2666
|
+
const key = variantOfStatusPath(path) === "qoder-global" ? "quotaCardGlobal" : "quotaCardCN";
|
|
2667
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2668
|
+
type: "button",
|
|
2669
|
+
role: "tab",
|
|
2670
|
+
"aria-selected": path === activePathResolved,
|
|
2671
|
+
className: path === activePathResolved ? "qdp-tab qdp-tabActive" : "qdp-tab",
|
|
2672
|
+
onClick: () => {
|
|
2673
|
+
setUserPicked(path);
|
|
2674
|
+
onVariantPicked(path);
|
|
2675
|
+
},
|
|
2676
|
+
children: t(key)
|
|
2677
|
+
}, path);
|
|
2678
|
+
})
|
|
2679
|
+
}) : null,
|
|
2680
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
2681
|
+
className: "qdp-card",
|
|
2682
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2683
|
+
className: "qdp-cardHead",
|
|
2684
|
+
children: [
|
|
2685
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2686
|
+
className: "qdp-avatar",
|
|
2687
|
+
children: activeVariant === "qoder-global" ? "GL" : "CN"
|
|
2688
|
+
}),
|
|
2689
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2690
|
+
className: "qdp-cardIdentity",
|
|
2691
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2692
|
+
className: "qdp-cardTitle",
|
|
2693
|
+
children: t(nameKey)
|
|
2694
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2695
|
+
className: "qdp-cardOwner",
|
|
2696
|
+
children: ownerText(status, t)
|
|
2697
|
+
})]
|
|
2698
|
+
}),
|
|
2699
|
+
credits?.unlimited === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2700
|
+
className: "qdp-badge",
|
|
2701
|
+
children: t("quotaUnlimited")
|
|
2702
|
+
}) : null
|
|
2703
|
+
]
|
|
2704
|
+
}), !signedIn ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2705
|
+
className: "qdp-notice",
|
|
2706
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2707
|
+
className: "qdp-noticeTitle",
|
|
2708
|
+
children: t("quotaNotSignedIn")
|
|
2709
|
+
})
|
|
2710
|
+
}) : credits === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2711
|
+
className: "qdp-notice qdp-noticeError",
|
|
2712
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2713
|
+
className: "qdp-noticeTitle",
|
|
2714
|
+
children: t("quotaError")
|
|
2715
|
+
})
|
|
2716
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
2717
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
2718
|
+
className: "qdp-totalLine",
|
|
2719
|
+
children: [
|
|
2720
|
+
t("quotaTotalRemain"),
|
|
2721
|
+
" ",
|
|
2722
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
|
|
2723
|
+
className: "qdp-totalValue",
|
|
2724
|
+
children: totalRemain.toLocaleString()
|
|
2725
|
+
}),
|
|
2726
|
+
" / ",
|
|
2727
|
+
totalSize > 0 ? totalSize.toLocaleString() : t("quotaUnknownTotal")
|
|
2728
|
+
]
|
|
2729
|
+
}),
|
|
2730
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2731
|
+
className: "qdp-totalSub",
|
|
2732
|
+
children: t("quotaTotalShare", {
|
|
2733
|
+
percent: totalPercent === void 0 ? t("quotaUnknownTotal") : `${totalPercent.toFixed(2)}%`,
|
|
2734
|
+
remain: totalRemain.toLocaleString(),
|
|
2735
|
+
size: totalSize.toLocaleString()
|
|
2736
|
+
})
|
|
2737
|
+
}),
|
|
2738
|
+
Number.isFinite(credits.total) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2739
|
+
className: "qdp-totalSub",
|
|
2740
|
+
children: t("quotaCycleUsed", { percent: String(credits.total) })
|
|
2741
|
+
}) : null,
|
|
2742
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2743
|
+
className: "qdp-bar",
|
|
2744
|
+
role: "progressbar",
|
|
2745
|
+
"aria-label": t("quotaTotal"),
|
|
2746
|
+
...totalPercent === void 0 ? { "aria-valuetext": t("quotaUnknownTotal") } : {
|
|
2747
|
+
"aria-valuemin": 0,
|
|
2748
|
+
"aria-valuemax": 100,
|
|
2749
|
+
"aria-valuenow": Math.round(totalPercent)
|
|
2750
|
+
},
|
|
2751
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2752
|
+
className: totalPercent !== void 0 && totalPercent < 20 ? "qdp-barFill qdp-barFillWarn" : "qdp-barFill",
|
|
2753
|
+
style: {
|
|
2754
|
+
width: totalPercent === void 0 ? "100%" : `${Math.max(2, totalPercent)}%`,
|
|
2755
|
+
opacity: totalPercent === void 0 ? .25 : 1
|
|
2756
|
+
}
|
|
2757
|
+
})
|
|
2758
|
+
}),
|
|
2759
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
2760
|
+
className: "qdp-blockTitle",
|
|
2761
|
+
children: t("quotaByPackage")
|
|
2762
|
+
}),
|
|
2763
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
|
|
2764
|
+
className: "qdp-table",
|
|
2765
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
|
|
2766
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("quotaColPackage") }),
|
|
2767
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("quotaColRemain") }),
|
|
2768
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("quotaColExpiry") })
|
|
2769
|
+
] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
|
|
2770
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.name }),
|
|
2771
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", {
|
|
2772
|
+
className: "qdp-num",
|
|
2773
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2774
|
+
className: "qdp-numText",
|
|
2775
|
+
children: [
|
|
2776
|
+
row.remain.toLocaleString(),
|
|
2777
|
+
" / ",
|
|
2778
|
+
row.size.toLocaleString()
|
|
2779
|
+
]
|
|
2780
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2781
|
+
className: "qdp-miniBar",
|
|
2782
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2783
|
+
className: row.warn ? "qdp-footFill qdp-footFillWarn" : "qdp-footFill",
|
|
2784
|
+
style: {
|
|
2785
|
+
display: "block",
|
|
2786
|
+
height: "100%",
|
|
2787
|
+
borderRadius: 999,
|
|
2788
|
+
width: row.percent === void 0 ? "100%" : `${Math.max(2, row.percent)}%`,
|
|
2789
|
+
opacity: row.percent === void 0 ? .25 : 1
|
|
2790
|
+
}
|
|
2791
|
+
})
|
|
2792
|
+
})]
|
|
2793
|
+
}),
|
|
2794
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
2795
|
+
className: "qdp-expiry",
|
|
2796
|
+
children: row.packageEndTime ?? t("quotaNoExpiry")
|
|
2797
|
+
})
|
|
2798
|
+
] }, `${index}\u0000${row.name}\u0000${row.packageEndTime ?? ""}`)) })]
|
|
2799
|
+
}),
|
|
2800
|
+
credits.cycleResetTime !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
2801
|
+
className: "qdp-windowReset",
|
|
2802
|
+
children: [
|
|
2803
|
+
t("quotaExpires"),
|
|
2804
|
+
" ",
|
|
2805
|
+
credits.cycleResetTime
|
|
2806
|
+
]
|
|
2807
|
+
}) : null
|
|
2808
|
+
] })]
|
|
2809
|
+
})
|
|
2810
|
+
]
|
|
2811
|
+
})
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
//#endregion
|
|
2815
|
+
//#region src/client/quota-styles.ts
|
|
2816
|
+
/**
|
|
2817
|
+
* Stylesheet for the Qoder quota surfaces (the sidebar footer card and the
|
|
2818
|
+
* dashboard it opens) — a direct translation of commandcode's panel stylesheet
|
|
2819
|
+
* (src/client/panel-styles.ts), which the user held up as the reference look.
|
|
2820
|
+
* Classes are `qdp-` prefixed to stay clear of commandcode's `ccp-` set: both
|
|
2821
|
+
* plugins inject GLOBAL CSS into the same document, so the prefixes must not
|
|
2822
|
+
* collide.
|
|
2823
|
+
*
|
|
2824
|
+
* Same contract as the original: returned as a string (no DOM side effects at
|
|
2825
|
+
* import time), installed once by the client entry keyed by `data-plugin-css`,
|
|
2826
|
+
* and removed when the plugin's fiber unwinds. Every colour comes from a
|
|
2827
|
+
* harness theme alias with a neutral fallback.
|
|
2828
|
+
*/
|
|
2829
|
+
/** Stylesheet id (the `data-plugin-css` value that makes injection idempotent). */
|
|
2830
|
+
const QUOTA_CSS_ID = "dsh-qoder-connect/QuotaPanel.module.css";
|
|
2831
|
+
/** Install the stylesheet once; returns its disposer. */
|
|
2832
|
+
function injectQuotaCss() {
|
|
2833
|
+
if (typeof document === "undefined") return () => {};
|
|
2834
|
+
if (document.querySelector(`style[data-plugin-css="dsh-qoder-connect/QuotaPanel.module.css"]`) !== null) return () => {};
|
|
2835
|
+
const tag = document.createElement("style");
|
|
2836
|
+
tag.dataset.plugin = "dsh-qoder-connect";
|
|
2837
|
+
tag.dataset.pluginCss = QUOTA_CSS_ID;
|
|
2838
|
+
tag.textContent = QUOTA_CSS;
|
|
2839
|
+
document.head.appendChild(tag);
|
|
2840
|
+
return () => {
|
|
2841
|
+
tag.remove();
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
/** The quota panel stylesheet. */
|
|
2845
|
+
const QUOTA_CSS = `
|
|
2846
|
+
/* ------------------------------------------------- sidebar footer card */
|
|
2847
|
+
/* The shell's foot area renders this list ABOVE the Settings seat. The shell
|
|
2848
|
+
supplies no chrome: the entry is the button. Deliberately quiet — a surface
|
|
2849
|
+
beside Settings should read as part of the column — one hover step and a
|
|
2850
|
+
hairline border, exactly like commandcode's card.
|
|
2851
|
+
|
|
2852
|
+
The shell's container is a flex ROW whose occupants each declare a
|
|
2853
|
+
full-width line, so as a row it overflows the column. The fix is the same
|
|
2854
|
+
load-bearing anchored rule commandcode ships (their issue #48): force the
|
|
2855
|
+
sidebar's footer-action container into a column, anchored to "_footArea"
|
|
2856
|
+
because "footerActions" is also used by the ask-user-question dialog — an
|
|
2857
|
+
unanchored rule would stack THAT dialog's buttons too. Anchoring keeps the
|
|
2858
|
+
fix scoped to the sidebar; the descendant combinator survives a wrapper
|
|
2859
|
+
appearing between the two. The rule is idempotent when commandcode is also
|
|
2860
|
+
installed (same selector, same declaration) and makes this plugin
|
|
2861
|
+
self-sufficient when it is not. */
|
|
2862
|
+
[class*="_footArea"] [class*="_footerActions"]{flex-direction:column}
|
|
2863
|
+
.qdp-foot{box-sizing:border-box;flex:0 0 auto;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:0 0;border:1px solid transparent;border-radius:10px;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;display:flex}
|
|
2864
|
+
.qdp-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}
|
|
2865
|
+
.qdp-foot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
2866
|
+
.qdp-footTop{align-items:center;gap:8px;min-width:0;display:flex}
|
|
2867
|
+
.qdp-footName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;font-size:13px;font-weight:500;line-height:20px}
|
|
2868
|
+
.qdp-updated{flex:none;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
2869
|
+
/* One block per merged package group: a head line carrying the group's own
|
|
2870
|
+
remain/total, then the FULL-WIDTH bar under it. Stacking the two lets the
|
|
2871
|
+
card show the figures — the reason this surface exists — without squeezing
|
|
2872
|
+
the bar into what is left beside them. */
|
|
2873
|
+
.qdp-footRow{flex-direction:column;gap:4px;min-width:0;display:flex}
|
|
2874
|
+
.qdp-footHead{align-items:baseline;gap:8px;min-width:0;display:flex}
|
|
2875
|
+
.qdp-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2876
|
+
.qdp-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
2877
|
+
/* The card's markup must stay PHRASING content — it renders inside the shell's
|
|
2878
|
+
own button — so these bars are spans, not divs. display:block is
|
|
2879
|
+
load-bearing on BOTH: an inline box ignores width and height outright, so
|
|
2880
|
+
without it the fill collapses to 0x0 and the bar shows no usage. */
|
|
2881
|
+
.qdp-footBar{display:block;background:var(--dsw-alias-bg-layer-2);border-radius:999px;height:5px;overflow:hidden}
|
|
2882
|
+
.qdp-footFill{display:block;background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease}
|
|
2883
|
+
.qdp-footFillWarn{background:var(--dsw-alias-state-error-primary)}
|
|
2884
|
+
.qdp-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
|
|
2885
|
+
|
|
2886
|
+
/* The 56px rail: one icon button on the shell's own rail geometry (36px cell),
|
|
2887
|
+
so the collapsed column keeps a single glyph like its siblings. */
|
|
2888
|
+
.qdp-railButton{box-sizing:border-box;width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:1px solid transparent;border-radius:8px;flex:none;justify-content:center;align-items:center;margin:0 0 4px;padding:0;display:inline-flex}
|
|
2889
|
+
.qdp-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
2890
|
+
.qdp-railButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
2891
|
+
|
|
2892
|
+
/* The ring glyph. Sized entirely by its own width/height attribute, so the
|
|
2893
|
+
footer row and the rail button can each ask for their own. */
|
|
2894
|
+
.qdp-glyph{flex:none;justify-content:center;align-items:center;display:inline-flex;color:var(--dsw-alias-brand-primary)}
|
|
2895
|
+
.qdp-ringWarn{color:var(--dsw-alias-state-error-primary)}
|
|
2896
|
+
|
|
2897
|
+
/* ------------------------------------------------------------ dashboard */
|
|
2898
|
+
/* The center column in the layout frame: fill it, scroll the content column,
|
|
2899
|
+
and cap the reading width like the harness's own panels. */
|
|
2900
|
+
.qdp-main{background:var(--dsw-alias-bg-layer-1);width:100%;height:100%;overflow:auto;display:block}
|
|
2901
|
+
.qdp-mainInner{max-width:760px;margin:0 auto;padding:24px 20px 40px;flex-direction:column;gap:14px;display:flex;color:var(--dsw-alias-label-primary)}
|
|
2902
|
+
.qdp-header{align-items:center;gap:10px;display:flex;flex-wrap:wrap}
|
|
2903
|
+
.qdp-headerText{flex-direction:column;gap:2px;display:flex;min-width:0}
|
|
2904
|
+
.qdp-title{margin:0;font-size:18px;font-weight:600;line-height:1.4}
|
|
2905
|
+
.qdp-subtitle{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}
|
|
2906
|
+
.qdp-spacer{flex:1}
|
|
2907
|
+
.qdp-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;font-variant-numeric:tabular-nums}
|
|
2908
|
+
/* The dashboard's exit: an icon-sized glyph button. */
|
|
2909
|
+
.qdp-close{min-width:28px;justify-content:center;padding-left:0;padding-right:0;box-sizing:border-box;align-items:center;cursor:pointer;font:inherit;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;height:28px;display:inline-flex}
|
|
2910
|
+
.qdp-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
2911
|
+
.qdp-close:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
2912
|
+
.qdp-close span{font-size:16px;line-height:1}
|
|
2913
|
+
.qdp-refresh{box-sizing:border-box;align-items:center;cursor:pointer;font:inherit;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 12px;display:inline-flex;gap:6px;font-size:12px;line-height:18px}
|
|
2914
|
+
.qdp-refresh:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
2915
|
+
.qdp-refresh:disabled{opacity:.5;cursor:default}
|
|
2916
|
+
.qdp-refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
2917
|
+
|
|
2918
|
+
/* Notices: signed-out and error states. */
|
|
2919
|
+
.qdp-notice{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:12px 14px;flex-direction:column;gap:4px;display:flex}
|
|
2920
|
+
.qdp-noticeError{border-color:var(--dsw-alias-state-error-primary)}
|
|
2921
|
+
.qdp-noticeTitle{margin:0;font-size:13px;font-weight:600;line-height:1.5}
|
|
2922
|
+
.qdp-noticeHint{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}
|
|
2923
|
+
|
|
2924
|
+
/* One card per variant. */
|
|
2925
|
+
.qdp-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:14px;padding:16px 18px;flex-direction:column;gap:16px;display:flex}
|
|
2926
|
+
.qdp-cardHead{align-items:center;gap:10px;display:flex;flex-wrap:wrap}
|
|
2927
|
+
.qdp-avatar{flex:none;width:28px;height:28px;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-module-platform);border-radius:50%;justify-content:center;align-items:center;font-size:12px;font-weight:600;line-height:1;display:inline-flex}
|
|
2928
|
+
.qdp-cardIdentity{flex-direction:column;gap:1px;min-width:0;display:flex}
|
|
2929
|
+
.qdp-cardTitle{font-size:13px;font-weight:600;line-height:1.4}
|
|
2930
|
+
.qdp-cardOwner{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
|
|
2931
|
+
|
|
2932
|
+
/* Quota blocks: each merged group is a label row plus the track. */
|
|
2933
|
+
.qdp-windows{flex-direction:column;gap:14px;display:flex}
|
|
2934
|
+
.qdp-window{flex-direction:column;gap:6px;display:flex}
|
|
2935
|
+
.qdp-windowHead{align-items:baseline;gap:8px;display:flex}
|
|
2936
|
+
.qdp-windowLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:1.5}
|
|
2937
|
+
.qdp-windowValue{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
2938
|
+
.qdp-windowPct{color:var(--dsw-alias-label-primary);min-width:38px;text-align:right;font-size:12px;font-weight:600;line-height:1.5;font-variant-numeric:tabular-nums}
|
|
2939
|
+
.qdp-bar{overflow:hidden;background:var(--dsw-alias-bg-layer-1);border-radius:999px;height:8px}
|
|
2940
|
+
.qdp-barFill{background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease}
|
|
2941
|
+
.qdp-barFillWarn{background:var(--dsw-alias-state-error-primary)}
|
|
2942
|
+
.qdp-windowReset{color:var(--dsw-alias-label-tertiary);margin:0;font-size:11px;line-height:1.5}
|
|
2943
|
+
|
|
2944
|
+
/* Badges. */
|
|
2945
|
+
.qdp-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-brand-primary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;line-height:17px}
|
|
2946
|
+
.qdp-badgeError{background:transparent;color:var(--dsw-alias-state-error-primary)}
|
|
2947
|
+
.qdp-badgeMuted{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;max-width:220px;overflow:hidden;text-overflow:ellipsis}
|
|
2948
|
+
|
|
2949
|
+
/* Overall remaining + share line, ahead of the detail table. */
|
|
2950
|
+
.qdp-totalLine{margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
|
|
2951
|
+
.qdp-totalValue{font-size:22px;font-weight:600;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;margin-left:6px}
|
|
2952
|
+
.qdp-totalSub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums}
|
|
2953
|
+
|
|
2954
|
+
/* Detail table: every package, unmerged. Column heads are the settings
|
|
2955
|
+
shell's tertiary smallcaps; numbers are tabular; the mini bar rides under
|
|
2956
|
+
the figures in the same cell like the reference layout. */
|
|
2957
|
+
.qdp-table{width:100%;border-collapse:collapse;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
|
|
2958
|
+
.qdp-table th{text-align:left;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;line-height:1.5;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--dsw-alias-border-l2);padding:4px 8px}
|
|
2959
|
+
.qdp-table td{padding:7px 8px;border-bottom:1px solid var(--dsw-alias-border-l2);vertical-align:top}
|
|
2960
|
+
.qdp-table tr:last-child td{border-bottom:0}
|
|
2961
|
+
.qdp-num{min-width:150px}
|
|
2962
|
+
.qdp-numText{display:block;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);margin-bottom:3px}
|
|
2963
|
+
.qdp-miniBar{display:block;height:4px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
|
|
2964
|
+
.qdp-expiry{white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
|
|
2965
|
+
|
|
2966
|
+
/* Variant switch: plain buttons, like the settings page's usage carousel. */
|
|
2967
|
+
.qdp-tabs{flex-wrap:wrap;gap:6px;display:flex}
|
|
2968
|
+
.qdp-tab{align-items:center;font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex;gap:6px}
|
|
2969
|
+
.qdp-tab:hover:not(.qdp-tabActive){color:var(--dsw-alias-label-primary)}
|
|
2970
|
+
.qdp-tabActive{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)}
|
|
2971
|
+
|
|
2972
|
+
@media (prefers-reduced-motion:reduce){.qdp-footFill,.qdp-barFill{transition:none}}
|
|
2973
|
+
`;
|
|
2974
|
+
//#endregion
|
|
2975
|
+
//#region src/client/locales.ts
|
|
2976
|
+
/** Plugin-card copy registered under the settings.qoder locale namespace. */
|
|
2977
|
+
const en = {
|
|
2978
|
+
title: "Qoder",
|
|
2979
|
+
intro: "Use the models from your Qoder (China) account directly in DSH — paste a Personal Access Token, no browser sign-in flow.",
|
|
2980
|
+
titleAI: "Qoder Global",
|
|
2981
|
+
introAI: "Use the models from your Qoder Global account directly in DSH — paste a Personal Access Token, no browser sign-in flow.",
|
|
2982
|
+
expand: "Expand",
|
|
2983
|
+
collapse: "Collapse",
|
|
2984
|
+
loading: "Loading account…",
|
|
2985
|
+
signedOut: "Not signed in",
|
|
2986
|
+
signedOutHint: "Save a Personal Access Token to use Qoder (China) models in DSH.",
|
|
2987
|
+
signedOutHintAI: "Save a Personal Access Token to use Qoder Global models in DSH.",
|
|
2988
|
+
patHeading: "Personal Access Token",
|
|
2989
|
+
patGuide: "Generate a PAT in your qoder.com.cn account settings (Account → Personal Access Token), then paste it here.",
|
|
2990
|
+
patGuideAI: "Generate a PAT in your qoder.com account settings (Account → Personal Access Token), then paste it here.",
|
|
2991
|
+
patPlaceholder: "Paste the PAT generated on qoder.com.cn",
|
|
2992
|
+
patPlaceholderAI: "Paste the PAT generated on qoder.com",
|
|
2993
|
+
patSave: "Save",
|
|
2994
|
+
patSaving: "Validating and saving…",
|
|
2995
|
+
patSaved: "PAT saved.",
|
|
2996
|
+
patSaveFailed: "Save failed: {message}",
|
|
2997
|
+
patInvalid: "That PAT was rejected — generate a new one in your account settings and try again.",
|
|
2998
|
+
patReplace: "Replace PAT",
|
|
2999
|
+
patClear: "Clear PAT",
|
|
3000
|
+
patClearing: "Clearing PAT…",
|
|
3001
|
+
signedIn: "Signed in",
|
|
3002
|
+
patSourceCard: "source: saved via this card",
|
|
3003
|
+
patSourceEnv: "source: environment variable",
|
|
3004
|
+
patSourceCli: "source: command line",
|
|
3005
|
+
patSavedAt: "saved {time}",
|
|
3006
|
+
patTail: "ends {tail}",
|
|
3007
|
+
creditsHeading: "Remaining credit",
|
|
3008
|
+
tabStatus: "Status",
|
|
3009
|
+
tabContext: "Context window",
|
|
3010
|
+
tabDetails: "Credit details",
|
|
3011
|
+
creditsDetailHeading: "By package",
|
|
3012
|
+
creditsUsed: "Used this cycle: {percent}%",
|
|
3013
|
+
creditsTotalUnlimited: "Total: Unlimited",
|
|
3014
|
+
unlimitedQuota: "Unlimited",
|
|
3015
|
+
cycleResetAt: "Resets {time}",
|
|
3016
|
+
percentRemaining: "{percent}% remaining",
|
|
3017
|
+
percentUnknown: "Remaining share unknown",
|
|
3018
|
+
exactRemaining: "{remain} / {size} remaining",
|
|
3019
|
+
creditPackageUnknownSize: "{remain} remaining",
|
|
3020
|
+
creditsError: "Credit unavailable: {message}",
|
|
3021
|
+
refresh: "Refresh",
|
|
3022
|
+
refreshing: "Refreshing…",
|
|
3023
|
+
refreshModels: "Refresh model list",
|
|
3024
|
+
refreshingModels: "Refreshing models…",
|
|
3025
|
+
catalogLive: "Model list updated {time}",
|
|
3026
|
+
catalogSaved: "Showing the saved model list from {time}",
|
|
3027
|
+
catalogFallback: "Showing the built-in model list (not yet updated from Qoder)",
|
|
3028
|
+
catalogError: "Last update failed: {message}",
|
|
3029
|
+
requestFailed: "Request failed",
|
|
3030
|
+
statusRefreshFailed: "Refresh failed: {message} — showing the last known state",
|
|
3031
|
+
statusResponseInvalid: "Qoder returned an unreadable status reply",
|
|
3032
|
+
accountHeading: "Account",
|
|
3033
|
+
contextHeading: "Context window",
|
|
3034
|
+
contextUpTo: "up to {size}",
|
|
3035
|
+
contextDefault: "default {size}",
|
|
3036
|
+
useMaximumContextWindow: "Use the largest declared context window",
|
|
3037
|
+
useMaximumContextWindowHint: "When on, models that declare a larger window are requested at it; when off, at their default.",
|
|
3038
|
+
probeLabel: "Reasoning levels",
|
|
3039
|
+
probeTooltipIdle: "Detect the reasoning levels {model} accepts",
|
|
3040
|
+
probeTooltipVerified: "Accepted levels: {levels} · click to detect again",
|
|
3041
|
+
probeTooltipNotValidating: "This model does not check the effort parameter",
|
|
3042
|
+
probeTooltipRetry: "Detection did not complete · click to retry",
|
|
3043
|
+
probeBubbleBody: "Send test requests to confirm the available reasoning levels. May consume a small amount of credit.",
|
|
3044
|
+
probeConfirmAction: "Confirm",
|
|
3045
|
+
probeNoteVerified: "Detected: {levels}",
|
|
3046
|
+
probeNoteNotValidating: "This model does not check the effort parameter",
|
|
3047
|
+
probeNoteUnknown: "Detection did not complete",
|
|
3048
|
+
probeNoteDismiss: "Got it",
|
|
3049
|
+
probeHeading: "Reasoning effort detection",
|
|
3050
|
+
probeResultNoLevels: "No tested levels were accepted.",
|
|
3051
|
+
probeIntro: "Some models reason but declare no selectable effort levels. Detecting which levels a model accepts sends a few real requests that may consume credit.",
|
|
3052
|
+
probeConsentHint: "Each detection sends test requests to one model to confirm its available reasoning levels, and may consume a small amount of credit.",
|
|
3053
|
+
probeStart: "Detect",
|
|
3054
|
+
probeRedetect: "Detect again",
|
|
3055
|
+
probeRunning: "Detecting {model}…",
|
|
3056
|
+
probeRunningGeneric: "Detecting…",
|
|
3057
|
+
probeClear: "Clear detected results",
|
|
3058
|
+
probeCandidates: "Detectable models: {count}",
|
|
3059
|
+
probeConfirmBody: "Send test requests to {model} to confirm its available reasoning levels. May consume a small amount of credit.",
|
|
3060
|
+
cancel: "Cancel",
|
|
3061
|
+
probeResultVerified: "Verified levels: {levels}",
|
|
3062
|
+
probeResultNotValidating: "This model does not check the effort parameter",
|
|
3063
|
+
probeResultUnknown: "Detection did not complete",
|
|
3064
|
+
probeResultAt: "Detected {time}",
|
|
3065
|
+
probeResultEmpty: "No detectable models right now.",
|
|
3066
|
+
probeFailed: "Detection failed: {message}",
|
|
3067
|
+
quotaSettingsTitle: "Qoder sidebar display",
|
|
3068
|
+
quotaSettingsIntro: "Show remaining credit beside the sidebar Settings seat. Each toggle needs its variant to have a saved PAT.",
|
|
3069
|
+
quotaToggleCN: "Show China credit card",
|
|
3070
|
+
quotaToggleGlobal: "Show global credit card",
|
|
3071
|
+
quotaToggleHint: "Show this account’s remaining credit in the sidebar footer.",
|
|
3072
|
+
quotaSignInRequired: "Save a PAT for this variant first to enable this card.",
|
|
3073
|
+
quotaPollLabel: "Refresh interval",
|
|
3074
|
+
quotaPollHint: "Applies to both quota cards. Longer is kinder to the billing endpoint.",
|
|
3075
|
+
quotaPollUnit: "min",
|
|
3076
|
+
quotaSettingsSave: "Save",
|
|
3077
|
+
quotaSettingsSaving: "Saving…",
|
|
3078
|
+
quotaSettingsDiscard: "Discard",
|
|
3079
|
+
quotaSettingsDirty: "Unsaved changes",
|
|
3080
|
+
quotaSettingsInvalid: "A value is invalid — fix it before saving",
|
|
3081
|
+
quotaSettingsSaveFailed: "Save did not land — retry",
|
|
3082
|
+
quotaSettingsSavedHint: "Saved",
|
|
3083
|
+
quotaCardCN: "Qoder credit",
|
|
3084
|
+
quotaCardGlobal: "Qoder Global credit",
|
|
3085
|
+
quotaUnknownTotal: "total unknown",
|
|
3086
|
+
quotaUnlimited: "Unlimited",
|
|
3087
|
+
quotaExpires: "Expires",
|
|
3088
|
+
quotaNoExpiry: "No expiry",
|
|
3089
|
+
quotaError: "Credit unavailable",
|
|
3090
|
+
quotaNotSignedIn: "Save a PAT to see the remaining credit",
|
|
3091
|
+
quotaUpdated: "Updated",
|
|
3092
|
+
quotaDashboardTitle: "Qoder quota",
|
|
3093
|
+
quotaDashboardSubtitle: "Remaining credit by package, per product",
|
|
3094
|
+
quotaRefresh: "Refresh",
|
|
3095
|
+
quotaRefreshing: "Refreshing…",
|
|
3096
|
+
quotaClose: "Close",
|
|
3097
|
+
quotaByPackage: "By package",
|
|
3098
|
+
quotaTotal: "Total",
|
|
3099
|
+
quotaTotalRemain: "Remaining",
|
|
3100
|
+
quotaTotalShare: "{percent} of this cycle’s granted total ({remain} / {size})",
|
|
3101
|
+
quotaCycleUsed: "Used this cycle: {percent}%",
|
|
3102
|
+
quotaColPackage: "Package",
|
|
3103
|
+
quotaColRemain: "Remaining / Total",
|
|
3104
|
+
quotaColExpiry: "Expires"
|
|
3105
|
+
};
|
|
3106
|
+
const zh = {
|
|
3107
|
+
title: "Qoder(国内版)",
|
|
3108
|
+
intro: "保存个人访问令牌(PAT)后,即可在 DSH 中直接使用 Qoder 国内版的模型。",
|
|
3109
|
+
titleAI: "Qoder Global(国际版)",
|
|
3110
|
+
introAI: "保存个人访问令牌(PAT)后,即可在 DSH 中直接使用 Qoder Global 的模型。",
|
|
3111
|
+
expand: "展开",
|
|
3112
|
+
collapse: "收起",
|
|
3113
|
+
loading: "正在读取账号…",
|
|
3114
|
+
signedOut: "未登录",
|
|
3115
|
+
signedOutHint: "填入个人访问令牌(PAT)后即可在 DSH 中使用 Qoder 国内版的模型。",
|
|
3116
|
+
signedOutHintAI: "填入个人访问令牌(PAT)后即可在 DSH 中使用 Qoder Global 的模型。",
|
|
3117
|
+
patHeading: "个人访问令牌(PAT)",
|
|
3118
|
+
patGuide: "请先在 qoder.com.cn 的账号设置(账号 → 个人访问令牌)中生成 PAT,再粘贴到下方输入框。",
|
|
3119
|
+
patGuideAI: "请先在 qoder.com 的账号设置(账号 → 个人访问令牌)中生成 PAT,再粘贴到下方输入框。",
|
|
3120
|
+
patPlaceholder: "粘贴在 qoder.com.cn 生成的 PAT",
|
|
3121
|
+
patPlaceholderAI: "粘贴在 qoder.com 生成的 PAT",
|
|
3122
|
+
patSave: "保存",
|
|
3123
|
+
patSaving: "校验并保存中…",
|
|
3124
|
+
patSaved: "PAT 已保存。",
|
|
3125
|
+
patSaveFailed: "保存失败:{message}",
|
|
3126
|
+
patInvalid: "PAT 无效或已过期 — 请到账号设置重新生成后再试。",
|
|
3127
|
+
patReplace: "更换 PAT",
|
|
3128
|
+
patClear: "清除 PAT",
|
|
3129
|
+
patClearing: "正在清除 PAT…",
|
|
3130
|
+
signedIn: "已登录",
|
|
3131
|
+
patSourceCard: "来源:卡片保存",
|
|
3132
|
+
patSourceEnv: "来源:环境变量",
|
|
3133
|
+
patSourceCli: "来源:命令行",
|
|
3134
|
+
patSavedAt: "保存于 {time}",
|
|
3135
|
+
patTail: "尾号 {tail}",
|
|
3136
|
+
creditsHeading: "剩余额度",
|
|
3137
|
+
tabStatus: "状态",
|
|
3138
|
+
tabContext: "上下文窗口",
|
|
3139
|
+
tabDetails: "额度明细",
|
|
3140
|
+
creditsDetailHeading: "按套餐",
|
|
3141
|
+
creditsUsed: "本轮已用 {percent}%",
|
|
3142
|
+
creditsTotalUnlimited: "合计:不限额",
|
|
3143
|
+
unlimitedQuota: "不限额",
|
|
3144
|
+
cycleResetAt: "重置时间:{time}",
|
|
3145
|
+
percentRemaining: "剩余 {percent}%",
|
|
3146
|
+
percentUnknown: "剩余占比未知",
|
|
3147
|
+
exactRemaining: "剩余 {remain} / {size}",
|
|
3148
|
+
creditPackageUnknownSize: "剩余 {remain}",
|
|
3149
|
+
creditsError: "额度查询失败:{message}",
|
|
3150
|
+
refresh: "刷新",
|
|
3151
|
+
refreshing: "正在刷新…",
|
|
3152
|
+
refreshModels: "刷新模型列表",
|
|
3153
|
+
refreshingModels: "正在刷新模型…",
|
|
3154
|
+
catalogLive: "模型列表更新于 {time}",
|
|
3155
|
+
catalogSaved: "当前显示已保存的模型列表,更新于 {time}",
|
|
3156
|
+
catalogFallback: "当前显示内置模型列表(尚未从 Qoder 更新)",
|
|
3157
|
+
catalogError: "上次更新失败:{message}",
|
|
3158
|
+
requestFailed: "请求失败",
|
|
3159
|
+
statusRefreshFailed: "刷新失败:{message} — 当前显示的是上次成功获取的状态",
|
|
3160
|
+
statusResponseInvalid: "Qoder 返回的状态数据无法识别",
|
|
3161
|
+
accountHeading: "账号",
|
|
3162
|
+
contextHeading: "上下文窗口",
|
|
3163
|
+
contextUpTo: "最高 {size}",
|
|
3164
|
+
contextDefault: "默认 {size}",
|
|
3165
|
+
useMaximumContextWindow: "使用上游声明的最大上下文窗口",
|
|
3166
|
+
useMaximumContextWindowHint: "勾选后,声明了更大窗口的模型按最大窗口请求;不勾选则按默认窗口。",
|
|
3167
|
+
probeLabel: "推理等级",
|
|
3168
|
+
probeTooltipIdle: "检测 {model} 可用的推理档位",
|
|
3169
|
+
probeTooltipVerified: "已接受:{levels} · 点击可重新检测",
|
|
3170
|
+
probeTooltipNotValidating: "该模型不校验该参数",
|
|
3171
|
+
probeTooltipRetry: "检测未完成 · 点击重试",
|
|
3172
|
+
probeBubbleBody: "发送探测请求以确认可用推理档位。可能消耗少量积分。",
|
|
3173
|
+
probeConfirmAction: "确认检测",
|
|
3174
|
+
probeNoteVerified: "已检测:{levels}",
|
|
3175
|
+
probeNoteNotValidating: "该模型不校验该参数",
|
|
3176
|
+
probeNoteUnknown: "检测未完成",
|
|
3177
|
+
probeNoteDismiss: "知道了",
|
|
3178
|
+
probeHeading: "推理档位检测",
|
|
3179
|
+
probeResultNoLevels: "本次测试的档位均未被接受。",
|
|
3180
|
+
probeIntro: "部分模型具备思考能力,但没有声明可选档位。检测会发送少量真实请求,可能消耗积分。",
|
|
3181
|
+
probeConsentHint: "每次检测会向该模型发送探测请求,以确认可用推理档位,可能消耗少量积分。",
|
|
3182
|
+
probeStart: "开始检测",
|
|
3183
|
+
probeRedetect: "重新检测",
|
|
3184
|
+
probeRunning: "正在检测 {model}…",
|
|
3185
|
+
probeRunningGeneric: "正在检测…",
|
|
3186
|
+
probeClear: "清除已探测结果",
|
|
3187
|
+
probeCandidates: "可检测模型:{count} 个",
|
|
3188
|
+
probeConfirmBody: "向 {model} 发送探测请求,以确认可用推理档位。可能消耗少量积分。",
|
|
3189
|
+
cancel: "取消",
|
|
3190
|
+
probeResultVerified: "已验证接受的档位:{levels}",
|
|
3191
|
+
probeResultNotValidating: "该模型不校验该参数",
|
|
3192
|
+
probeResultUnknown: "检测未完成",
|
|
3193
|
+
probeResultAt: "检测于 {time}",
|
|
3194
|
+
probeResultEmpty: "当前没有可检测的模型。",
|
|
3195
|
+
probeFailed: "检测失败:{message}",
|
|
3196
|
+
quotaSettingsTitle: "Qoder 侧栏展示",
|
|
3197
|
+
quotaSettingsIntro: "在侧栏设置项旁展示剩余额度。开关需要对应产品已保存 PAT。",
|
|
3198
|
+
quotaToggleCN: "展示国内版额度",
|
|
3199
|
+
quotaToggleGlobal: "展示国际版额度",
|
|
3200
|
+
quotaToggleHint: "在侧栏底部展示该账号的剩余额度。",
|
|
3201
|
+
quotaSignInRequired: "请先为该变体保存 PAT,再开启此开关。",
|
|
3202
|
+
quotaPollLabel: "刷新间隔",
|
|
3203
|
+
quotaPollHint: "对两张额度卡片同时生效。间隔越长对计费接口越友好。",
|
|
3204
|
+
quotaPollUnit: "分钟",
|
|
3205
|
+
quotaSettingsSave: "保存",
|
|
3206
|
+
quotaSettingsSaving: "保存中…",
|
|
3207
|
+
quotaSettingsDiscard: "放弃更改",
|
|
3208
|
+
quotaSettingsDirty: "有未保存的更改",
|
|
3209
|
+
quotaSettingsInvalid: "有数值不合法,请修正后再保存",
|
|
3210
|
+
quotaSettingsSaveFailed: "保存未生效,请重试",
|
|
3211
|
+
quotaSettingsSavedHint: "已保存",
|
|
3212
|
+
quotaCardCN: "Qoder 额度",
|
|
3213
|
+
quotaCardGlobal: "Qoder Global 额度",
|
|
3214
|
+
quotaUnknownTotal: "总量未知",
|
|
3215
|
+
quotaUnlimited: "不限量",
|
|
3216
|
+
quotaExpires: "到期",
|
|
3217
|
+
quotaNoExpiry: "无到期时间",
|
|
3218
|
+
quotaError: "额度信息不可用",
|
|
3219
|
+
quotaNotSignedIn: "保存 PAT 后显示剩余额度",
|
|
3220
|
+
quotaUpdated: "更新于",
|
|
3221
|
+
quotaDashboardTitle: "Qoder 额度",
|
|
3222
|
+
quotaDashboardSubtitle: "按套餐展示各产品的剩余额度",
|
|
3223
|
+
quotaRefresh: "刷新",
|
|
3224
|
+
quotaRefreshing: "刷新中…",
|
|
3225
|
+
quotaClose: "关闭",
|
|
3226
|
+
quotaByPackage: "按套餐",
|
|
3227
|
+
quotaTotal: "合计",
|
|
3228
|
+
quotaTotalRemain: "剩余额度",
|
|
3229
|
+
quotaTotalShare: "占本轮总额度 {percent}({remain} / {size})",
|
|
3230
|
+
quotaCycleUsed: "本轮已用 {percent}%",
|
|
3231
|
+
quotaColPackage: "资源包",
|
|
3232
|
+
quotaColRemain: "剩余 / 总量",
|
|
3233
|
+
quotaColExpiry: "到期时间"
|
|
3234
|
+
};
|
|
3235
|
+
//#endregion
|
|
3236
|
+
//#region src/client/index.tsx
|
|
3237
|
+
/** Browser half: Qoder account status, quota cards, and plugin settings. */
|
|
3238
|
+
/** Stable browser-plugin name. */
|
|
3239
|
+
const name = "dsh-qoder-connect-client";
|
|
3240
|
+
/**
|
|
3241
|
+
* Client services required by the Plugin configuration contribution.
|
|
3242
|
+
*
|
|
3243
|
+
* DSH 0.1.2 removed `@deepseek-ai/dsh-client-runtime` (the package that used to
|
|
3244
|
+
* hold the browser `ClientContext` alias and the `slots` service). The services
|
|
3245
|
+
* this card relies on now come from narrower packages: the `slots` registry
|
|
3246
|
+
* moved to `@deepseek-ai/dsh-client-ui-renderer`, `locale` stayed in
|
|
3247
|
+
* `@deepseek-ai/dsh-client-locale`, and the `settings.plugin.item` slot is
|
|
3248
|
+
* declared by `@deepseek-ai/dsh-client-ui-settings-plugins`. All three are
|
|
3249
|
+
* named in the package's `dsh.client.inject` list, so cordis has activated
|
|
3250
|
+
* them before this plugin's fiber starts.
|
|
3251
|
+
*/
|
|
3252
|
+
const inject = [
|
|
3253
|
+
"slots",
|
|
3254
|
+
"locale",
|
|
3255
|
+
"remote",
|
|
3256
|
+
"remote.session",
|
|
3257
|
+
"settingsScope"
|
|
3258
|
+
];
|
|
3259
|
+
/** The status routes per variant id (mirrors the host's locked route table). */
|
|
3260
|
+
const VARIANT_STATUS = {
|
|
3261
|
+
qoder: QODER_STATUS_PATH,
|
|
3262
|
+
"qoder-global": QODER_GLOBAL_STATUS_PATH
|
|
3263
|
+
};
|
|
3264
|
+
/**
|
|
3265
|
+
* Register card copy, the shared quota-settings card, the two variant cards,
|
|
3266
|
+
* and the sidebar quota cards.
|
|
3267
|
+
*
|
|
3268
|
+
* The entire body is wrapped so that a DSH slot-API breaking change (for
|
|
3269
|
+
* example the rc.6→rc.7 `id`→`key` / `order`→`priority` rename) degrades
|
|
3270
|
+
* to a `console.error` instead of throwing into the DSH loader and raising
|
|
3271
|
+
* the red "Failed to load plugins" banner. The host provider keeps working:
|
|
3272
|
+
* the `qoder` model channel is unaffected, and `dsh-qoder-connect
|
|
3273
|
+
* status` reports host health via the heartbeat file.
|
|
3274
|
+
*
|
|
3275
|
+
* Card ORDER: the Plugins tab dispatches `settings.plugin.item` in
|
|
3276
|
+
* registration order, so this entry registers the shared quota-settings card
|
|
3277
|
+
* first (top), then CN, then Global — the layout agreed for this feature
|
|
3278
|
+
* (统一设置 → CN → Global, china first).
|
|
3279
|
+
*
|
|
3280
|
+
* NOTE: the try/catch boundary of this function is mirrored (duplicated) in
|
|
3281
|
+
* `tests/client-fallback.spec.ts`, because the real client entry imports
|
|
3282
|
+
* browser-only DSH packages that cannot load in the Node test environment.
|
|
3283
|
+
* That test therefore does not import this function — it replicates its
|
|
3284
|
+
* shape. If you change the guarded body or the `console.error` message here,
|
|
3285
|
+
* update the mirrored `apply()` in that spec too, or the fallback test will
|
|
3286
|
+
* silently diverge from this real implementation.
|
|
3287
|
+
*/
|
|
3288
|
+
function apply(ctx) {
|
|
3289
|
+
try {
|
|
3290
|
+
const namespace = "settings.qoder";
|
|
3291
|
+
ctx.effect(() => ctx.locale.register(namespace, {
|
|
3292
|
+
zh,
|
|
3293
|
+
en
|
|
3294
|
+
}), "dsh-qoder-connect: settings copy");
|
|
3295
|
+
const t = ctx.locale.bind(namespace);
|
|
3296
|
+
const quotaSettingsInjected = {
|
|
3297
|
+
t,
|
|
3298
|
+
signedIn: () => quotaSignInState()
|
|
3299
|
+
};
|
|
3300
|
+
let quotaScope;
|
|
3301
|
+
try {
|
|
3302
|
+
const scope = ctx.settingsScope.bind({ namespace: "qoder-quota" });
|
|
3303
|
+
quotaScope = scope;
|
|
3304
|
+
const applySnapshot = () => {
|
|
3305
|
+
const value = scope.getSnapshot().value;
|
|
3306
|
+
setQuotaToggles(value?.sidebarQuotaCN === true, value?.sidebarQuotaGlobal === true);
|
|
3307
|
+
if (typeof value?.quotaPollMs === "number") setQuotaPollMs(value.quotaPollMs);
|
|
3308
|
+
};
|
|
3309
|
+
applySnapshot();
|
|
3310
|
+
scope.subscribe(applySnapshot);
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
console.error("[dsh-qoder-connect] quota settings scope unavailable (sidebar cards stay hidden):", error);
|
|
3313
|
+
}
|
|
3314
|
+
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
|
3315
|
+
name: "settings.plugin.item",
|
|
3316
|
+
key: "qoder-quota",
|
|
3317
|
+
priority: 50,
|
|
3318
|
+
inject: () => ({
|
|
3319
|
+
...quotaSettingsInjected,
|
|
3320
|
+
scope: quotaScope
|
|
3321
|
+
})
|
|
3322
|
+
}, QuotaSettingsCard));
|
|
3323
|
+
for (const [index, variant] of QODER_CARD_VARIANTS.entries()) ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
|
3324
|
+
name: "settings.plugin.item",
|
|
3325
|
+
key: variant.id,
|
|
3326
|
+
priority: 60 + index * 10,
|
|
3327
|
+
inject: () => ({
|
|
3328
|
+
t,
|
|
3329
|
+
variant
|
|
3330
|
+
})
|
|
3331
|
+
}, QoderPluginCard));
|
|
3332
|
+
const QUOTA_PANEL_ID = "qoder-quota-panel";
|
|
3333
|
+
const CONVERSATION_PANEL_ID = "conversation";
|
|
3334
|
+
const dashboardDocuments = {
|
|
3335
|
+
cn: void 0,
|
|
3336
|
+
global: void 0
|
|
3337
|
+
};
|
|
3338
|
+
let dashboardFetchedAt;
|
|
3339
|
+
let dashboardLoading = false;
|
|
3340
|
+
let dashboardRequestedPath = QODER_STATUS_PATH;
|
|
3341
|
+
/** Whether the dashboard is the CURRENT center panel (its mount owns this). */
|
|
3342
|
+
let quotaPanelOpen = false;
|
|
3343
|
+
const dashboardListeners = /* @__PURE__ */ new Set();
|
|
3344
|
+
/**
|
|
3345
|
+
* The observable source the dashboard reads through the inject face's
|
|
3346
|
+
* `hooks` compartment. The renderer caches an inject face ONCE per entry
|
|
3347
|
+
* and SPREADS it into props — a face getter is read exactly once and
|
|
3348
|
+
* frozen, which is why face-carried documents/activePath went stale. The
|
|
3349
|
+
* hooks channel survives: `bindInjectSources` converts each hooks member
|
|
3350
|
+
* into a `use<Name>` selector hook, and the hook reads the CURRENT
|
|
3351
|
+
* snapshot on every render (the same mechanism commandcode's usage store
|
|
3352
|
+
* rides).
|
|
3353
|
+
*
|
|
3354
|
+
* STABILITY CONTRACT: useSyncExternalStore requires getSnapshot() to
|
|
3355
|
+
* return the SAME reference between changes — a fresh object per call
|
|
3356
|
+
* re-renders forever and React kills the entry (error #185, the same
|
|
3357
|
+
* class of crash the settings card's unstable projection caused). So the
|
|
3358
|
+
* snapshot is a CACHED object, replaced wholesale by publish(); every
|
|
3359
|
+
* mutator builds the next snapshot and publishes exactly once.
|
|
3360
|
+
*/
|
|
3361
|
+
let dashboardSnap = {
|
|
3362
|
+
documents: [void 0, void 0],
|
|
3363
|
+
fetchedAt: void 0,
|
|
3364
|
+
loading: false,
|
|
3365
|
+
activePath: QODER_STATUS_PATH
|
|
3366
|
+
};
|
|
3367
|
+
const rebuildSnapshot = () => {
|
|
3368
|
+
const next = {
|
|
3369
|
+
documents: [dashboardDocuments.cn, dashboardDocuments.global],
|
|
3370
|
+
fetchedAt: dashboardFetchedAt,
|
|
3371
|
+
loading: dashboardLoading,
|
|
3372
|
+
activePath: dashboardRequestedPath
|
|
3373
|
+
};
|
|
3374
|
+
if (JSON.stringify(next) !== JSON.stringify(dashboardSnap)) {
|
|
3375
|
+
dashboardSnap = next;
|
|
3376
|
+
for (const listener of dashboardListeners) listener();
|
|
3377
|
+
}
|
|
3378
|
+
};
|
|
3379
|
+
const dashboardSource = {
|
|
3380
|
+
getSnapshot: () => dashboardSnap,
|
|
3381
|
+
subscribe: (listener) => {
|
|
3382
|
+
dashboardListeners.add(listener);
|
|
3383
|
+
return () => {
|
|
3384
|
+
dashboardListeners.delete(listener);
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
};
|
|
3388
|
+
const notifyDashboard = () => {
|
|
3389
|
+
rebuildSnapshot();
|
|
3390
|
+
};
|
|
3391
|
+
/**
|
|
3392
|
+
* Refresh ONE variant's document (the one the panel is showing) — not
|
|
3393
|
+
* both. The earlier version fetched both routes on every panel mount, so
|
|
3394
|
+
* clicking the CN card also refreshed the Global card's data and timestamp;
|
|
3395
|
+
* the user ruled each click refreshes only what it shows.
|
|
3396
|
+
*
|
|
3397
|
+
* Freshness rule (also the user's): if the shared document for THIS
|
|
3398
|
+
* variant is newer than the configured interval, the fetch is SKIPPED —
|
|
3399
|
+
* a click shows the cached numbers instead of re-billing upstream. A
|
|
3400
|
+
* variant with NO result yet always fetches. A manual Refresh click
|
|
3401
|
+
* (force=true) bypasses the freshness check: an explicit user action
|
|
3402
|
+
* always re-reads.
|
|
3403
|
+
*/
|
|
3404
|
+
const refreshDashboard = async (options = {}) => {
|
|
3405
|
+
if (dashboardLoading) return;
|
|
3406
|
+
const variantId = variantOfStatusPath(dashboardRequestedPath);
|
|
3407
|
+
if (options.force !== true && quotaStatusIsFresh(variantId, quotaPollMs())) return;
|
|
3408
|
+
dashboardLoading = true;
|
|
3409
|
+
rebuildSnapshot();
|
|
3410
|
+
try {
|
|
3411
|
+
const result = await fetchStatusDocument(variantId === "qoder" ? QODER_STATUS_PATH : QODER_GLOBAL_STATUS_PATH);
|
|
3412
|
+
if (result !== void 0) noteQuotaStatus(variantId, result);
|
|
3413
|
+
dashboardFetchedAt = Date.now();
|
|
3414
|
+
} finally {
|
|
3415
|
+
dashboardLoading = false;
|
|
3416
|
+
rebuildSnapshot();
|
|
3417
|
+
}
|
|
3418
|
+
};
|
|
3419
|
+
let dashboardTimer;
|
|
3420
|
+
const startDashboardPoll = () => {
|
|
3421
|
+
if (dashboardTimer !== void 0) return;
|
|
3422
|
+
refreshDashboard();
|
|
3423
|
+
dashboardTimer = window.setInterval(() => {
|
|
3424
|
+
if (document.hidden) return;
|
|
3425
|
+
refreshDashboard();
|
|
3426
|
+
}, Math.max(6e4, quotaPollMs()));
|
|
3427
|
+
};
|
|
3428
|
+
const stopDashboardPoll = () => {
|
|
3429
|
+
if (dashboardTimer === void 0) return;
|
|
3430
|
+
window.clearInterval(dashboardTimer);
|
|
3431
|
+
dashboardTimer = void 0;
|
|
3432
|
+
};
|
|
3433
|
+
async function fetchStatusDocument(path) {
|
|
3434
|
+
try {
|
|
3435
|
+
const response = await fetch(path, { headers: { accept: "application/json" } });
|
|
3436
|
+
const body = await response.json();
|
|
3437
|
+
return response.ok && isQoderWebStatus(body) ? body : void 0;
|
|
3438
|
+
} catch {
|
|
3439
|
+
return;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
function QuotaDashboardWithLifecycle(props) {
|
|
3443
|
+
(0, react.useEffect)(() => {
|
|
3444
|
+
quotaPanelOpen = true;
|
|
3445
|
+
startDashboardPoll();
|
|
3446
|
+
return () => {
|
|
3447
|
+
quotaPanelOpen = false;
|
|
3448
|
+
stopDashboardPoll();
|
|
3449
|
+
};
|
|
3450
|
+
}, []);
|
|
3451
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuotaDashboard, { ...props });
|
|
3452
|
+
}
|
|
3453
|
+
const panelFace = () => ({
|
|
3454
|
+
hooks: { quotaDashboard: dashboardSource },
|
|
3455
|
+
t,
|
|
3456
|
+
statusPaths: [QODER_STATUS_PATH, QODER_GLOBAL_STATUS_PATH],
|
|
3457
|
+
refresh: () => {
|
|
3458
|
+
refreshDashboard({ force: true });
|
|
3459
|
+
},
|
|
3460
|
+
onVariantPicked: (path) => {
|
|
3461
|
+
dashboardRequestedPath = path;
|
|
3462
|
+
notifyDashboard();
|
|
3463
|
+
refreshDashboard();
|
|
3464
|
+
},
|
|
3465
|
+
close: () => {
|
|
3466
|
+
const layout = ctx.get("layout");
|
|
3467
|
+
if (typeof layout?.selectPanel !== "function") return;
|
|
3468
|
+
try {
|
|
3469
|
+
layout.selectPanel(null);
|
|
3470
|
+
} catch {
|
|
3471
|
+
try {
|
|
3472
|
+
layout.selectPanel(CONVERSATION_PANEL_ID);
|
|
3473
|
+
} catch (error) {
|
|
3474
|
+
console.error("[dsh-qoder-connect] could not close the quota panel:", error);
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
});
|
|
3479
|
+
ctx.effect(() => injectQuotaCss(), "dsh-qoder-connect: quota styles");
|
|
3480
|
+
try {
|
|
3481
|
+
ctx.slots.inject("main", () => ctx.slots.register({
|
|
3482
|
+
name: "main",
|
|
3483
|
+
key: QUOTA_PANEL_ID,
|
|
3484
|
+
locale: "panel.qoder-quota",
|
|
3485
|
+
inject: panelFace
|
|
3486
|
+
}, QuotaDashboardWithLifecycle));
|
|
3487
|
+
} catch (error) {
|
|
3488
|
+
console.error("[dsh-qoder-connect] could not register the quota dashboard:", error);
|
|
3489
|
+
}
|
|
3490
|
+
ctx.inject(["layout"], (layoutCtx) => {
|
|
3491
|
+
if (typeof layoutCtx.get("layout")?.selectPanel !== "function") return;
|
|
3492
|
+
try {
|
|
3493
|
+
for (const variant of QODER_CARD_VARIANTS) {
|
|
3494
|
+
const statusPath = VARIANT_STATUS[variant.id];
|
|
3495
|
+
if (statusPath === void 0) continue;
|
|
3496
|
+
const injected = {
|
|
3497
|
+
t,
|
|
3498
|
+
statusPath,
|
|
3499
|
+
open: () => {
|
|
3500
|
+
const current = layoutCtx.get("layout");
|
|
3501
|
+
if (typeof current?.selectPanel !== "function") return;
|
|
3502
|
+
if (quotaPanelOpen && dashboardRequestedPath === statusPath) {
|
|
3503
|
+
current.selectPanel(null);
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
dashboardRequestedPath = statusPath;
|
|
3507
|
+
notifyDashboard();
|
|
3508
|
+
refreshDashboard();
|
|
3509
|
+
current.selectPanel(QUOTA_PANEL_ID);
|
|
3510
|
+
}
|
|
3511
|
+
};
|
|
3512
|
+
layoutCtx.slots.inject("sidebar.footer.action", () => layoutCtx.slots.register({
|
|
3513
|
+
name: "sidebar.footer.action",
|
|
3514
|
+
id: variant.id === "qoder" ? "qoder-quota" : "qoder-global-quota",
|
|
3515
|
+
order: variant.id === "qoder" ? 20 : 21,
|
|
3516
|
+
locale: "panel.qoder-quota",
|
|
3517
|
+
inject: () => injected
|
|
3518
|
+
}, SidebarQuotaCard));
|
|
3519
|
+
}
|
|
3520
|
+
} catch (error) {
|
|
3521
|
+
console.error("[dsh-qoder-connect] could not register the sidebar footer card:", error);
|
|
3522
|
+
}
|
|
3523
|
+
});
|
|
3524
|
+
ctx.inject(["modelDirectories"], (scope) => {
|
|
3525
|
+
scope.slots.inject("conversation.input.right", () => scope.slots.register({
|
|
3526
|
+
name: "conversation.input.right",
|
|
3527
|
+
id: "qoder-probe",
|
|
3528
|
+
order: 10,
|
|
3529
|
+
inject: (sessionId) => ({
|
|
3530
|
+
directory: scope.modelDirectories.directoryFor(sessionId).store,
|
|
3531
|
+
t
|
|
3532
|
+
})
|
|
3533
|
+
}, QoderProbeControl));
|
|
3534
|
+
});
|
|
3535
|
+
} catch (error) {
|
|
3536
|
+
console.error("[dsh-qoder-connect] client card failed to load (host provider unaffected):", error);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
//#endregion
|
|
3540
|
+
exports.apply = apply;
|
|
3541
|
+
exports.inject = inject;
|
|
3542
|
+
exports.name = name;
|
|
3543
|
+
return module.exports;
|
|
3544
|
+
}
|
|
3545
|
+
});
|