dsh-agy 0.2.12 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -11
- package/cordis.patch.yml +4 -2
- package/lib/{accounts-DB1tT9Gy.mjs → accounts-CkaHhb5H.mjs} +1155 -646
- package/lib/accounts-CkaHhb5H.mjs.map +1 -0
- package/lib/{catalog-bfGIyoba.mjs → catalog-BgdDVJEB.mjs} +17 -2
- package/lib/{catalog-bfGIyoba.mjs.map → catalog-BgdDVJEB.mjs.map} +1 -1
- package/lib/cli/index.d.mts +10 -1
- package/lib/cli/index.d.mts.map +1 -1
- package/lib/cli/index.mjs +21 -8
- package/lib/cli/index.mjs.map +1 -1
- package/lib/client.js +2168 -14
- package/lib/{constants-BFKYjOmh.mjs → constants-Db4tmyfb.mjs} +79 -14
- package/lib/constants-Db4tmyfb.mjs.map +1 -0
- package/lib/{exchange-dQm2mjJ5.mjs → exchange-yZPIZumk.mjs} +27 -12
- package/lib/exchange-yZPIZumk.mjs.map +1 -0
- package/lib/identity-Ci8EGc1F.mjs +96 -0
- package/lib/identity-Ci8EGc1F.mjs.map +1 -0
- package/lib/{import-D7R0GpRr.mjs → import-BaFULQnH.mjs} +11 -7
- package/lib/import-BaFULQnH.mjs.map +1 -0
- package/lib/index.mjs +2 -2
- package/lib/{models-ClGTQBLY.mjs → models-BGuwl50d.mjs} +35 -22
- package/lib/models-BGuwl50d.mjs.map +1 -0
- package/lib/plugin-common-B1MG00JT.mjs +1313 -0
- package/lib/plugin-common-B1MG00JT.mjs.map +1 -0
- package/lib/{proxy-DLvX8yxv.mjs → proxy-DfaL73mL.mjs} +52 -3
- package/lib/proxy-DfaL73mL.mjs.map +1 -0
- package/lib/quota-summary-5CypVffd.mjs +127 -0
- package/lib/quota-summary-5CypVffd.mjs.map +1 -0
- package/lib/{translate-D3GFUpw4.mjs → translate-BQu8HLvB.mjs} +25 -6
- package/lib/translate-BQu8HLvB.mjs.map +1 -0
- package/lib/web/plugin.d.mts +1 -0
- package/lib/web/plugin.d.mts.map +1 -1
- package/lib/web/plugin.mjs +651 -1701
- package/lib/web/plugin.mjs.map +1 -1
- package/package.json +16 -4
- package/lib/accounts-DB1tT9Gy.mjs.map +0 -1
- package/lib/constants-BFKYjOmh.mjs.map +0 -1
- package/lib/exchange-dQm2mjJ5.mjs.map +0 -1
- package/lib/identity-DsftHTJC.mjs +0 -35
- package/lib/identity-DsftHTJC.mjs.map +0 -1
- package/lib/import-D7R0GpRr.mjs.map +0 -1
- package/lib/models-ClGTQBLY.mjs.map +0 -1
- package/lib/plugin-common-B63uavj7.mjs +0 -254
- package/lib/plugin-common-B63uavj7.mjs.map +0 -1
- package/lib/proxy-DLvX8yxv.mjs.map +0 -1
- package/lib/translate-D3GFUpw4.mjs.map +0 -1
package/lib/client.js
CHANGED
|
@@ -5,32 +5,2186 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
let react = require("react");
|
|
8
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
9
|
+
//#region src/client/styles.ts
|
|
10
|
+
/**
|
|
11
|
+
* Injected stylesheet for the agy Settings section.
|
|
12
|
+
*
|
|
13
|
+
* Plain CSS in a single <style> element rather than CSS modules: this plugin
|
|
14
|
+
* ships one bundle and cannot rely on the host's bundler to process a
|
|
15
|
+
* `*.module.css` import.
|
|
16
|
+
*
|
|
17
|
+
* Follows DSH's own styling contract (deepseek-harness `docs/web-styling.md`):
|
|
18
|
+
*
|
|
19
|
+
* - Colours come from `--dsw-alias-*` semantic tokens, never literal values, so
|
|
20
|
+
* the section follows the host's light/dark theme instead of shipping a
|
|
21
|
+
* second palette that drifts.
|
|
22
|
+
* - Text uses the theme's typography ROLE variables (`--dsw-font-*`, which carry
|
|
23
|
+
* family + size + line-height + weight together) rather than hand-picked
|
|
24
|
+
* `font-size`/`font-weight` pairs. A hand-picked 550 is not in the host's
|
|
25
|
+
* scale and is what made this page read heavier than a native section.
|
|
26
|
+
* - Neutral solid borders draw at `0.5px`, the shared hairline weight.
|
|
27
|
+
* - Controls are NOT styled here: they come from the
|
|
28
|
+
* `@deepseek-ai/dsh-client-ui-primitives` catalog (Button/Switch/Input/Tag/
|
|
29
|
+
* StateDot), so focus rings, disabled states and size tiers are the
|
|
30
|
+
* platform's own. This file covers layout and the data visualisations the
|
|
31
|
+
* host has no primitive for.
|
|
32
|
+
*/
|
|
33
|
+
const STYLE_ID = "dsh-agy-styles";
|
|
34
|
+
const CSS = `
|
|
35
|
+
.agy-root { display: flex; flex-direction: column; gap: 12px; font: var(--dsw-font-xs-13); }
|
|
36
|
+
.agy-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
37
|
+
.agy-title { font: var(--dsw-font-base-strong-16); color: var(--dsw-alias-label-primary, #1f2329); }
|
|
38
|
+
.agy-sub { margin-top: 3px; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
39
|
+
|
|
40
|
+
.agy-tabs {
|
|
41
|
+
display: flex; align-items: flex-end; gap: 22px; margin-top: 2px;
|
|
42
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l2, #eef0f3);
|
|
43
|
+
}
|
|
44
|
+
.agy-tab {
|
|
45
|
+
position: relative; border: 0; padding: 7px 1px 9px; background: transparent;
|
|
46
|
+
color: var(--dsw-alias-label-tertiary, #8f959e);
|
|
47
|
+
font: var(--dsw-font-xs-13); cursor: pointer;
|
|
48
|
+
}
|
|
49
|
+
.agy-tab:hover, .agy-tab[data-active="true"] { color: var(--dsw-alias-label-primary, #1f2329); }
|
|
50
|
+
/* Active tab is an underline rule, matching the Plugins settings section's own
|
|
51
|
+
tab bar rather than introducing a second, boxed tab idiom. */
|
|
52
|
+
.agy-tab[data-active="true"]::after {
|
|
53
|
+
position: absolute; right: 0; bottom: -1px; left: 0; height: 2px;
|
|
54
|
+
border-radius: 2px 2px 0 0; background: var(--dsw-alias-label-primary, #1f2329);
|
|
55
|
+
content: '';
|
|
56
|
+
}
|
|
57
|
+
.agy-tab:focus-visible {
|
|
58
|
+
outline: 2px solid var(--dsw-alias-state-business-primary, #4176e6);
|
|
59
|
+
outline-offset: 2px;
|
|
60
|
+
border-radius: 4px;
|
|
61
|
+
}
|
|
62
|
+
.agy-tab .agy-count { margin-left: 5px; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
63
|
+
|
|
64
|
+
/* A failed action states WHAT failed and then WHY, on two lines. The default
|
|
65
|
+
white-space (normal) folds that newline into a space and runs the verdict
|
|
66
|
+
into the upstream error, so the separator has to be preserved here exactly as
|
|
67
|
+
.agy-notice already does for its own multi-line form. */
|
|
68
|
+
.agy-error { padding: 9px 12px; border-radius: 8px; font: var(--dsw-font-xxs-12);
|
|
69
|
+
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
70
|
+
color: var(--dsw-alias-state-error-primary, #ec1313);
|
|
71
|
+
background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ec1313) 10%, transparent); }
|
|
72
|
+
/* A non-fatal outcome (a partial import). Neutral, not alarming, and it keeps
|
|
73
|
+
newlines so a list of per-source failures stays readable. */
|
|
74
|
+
.agy-notice { padding: 9px 12px; border-radius: 8px; font: var(--dsw-font-xxs-12);
|
|
75
|
+
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
76
|
+
color: var(--dsw-alias-label-secondary, #61666b);
|
|
77
|
+
background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
|
|
78
|
+
.agy-empty { padding: 24px 12px; text-align: center; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
79
|
+
.agy-hint { margin: 0; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
80
|
+
.agy-aside { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
81
|
+
.agy-grow { flex: 1; }
|
|
82
|
+
|
|
83
|
+
/* ── Grouping surface ───────────────────────────────────────────────────────
|
|
84
|
+
DSH groups with spacing first and a light container second. A settings page
|
|
85
|
+
built only from tables reads as a spreadsheet, so each block gets a card. */
|
|
86
|
+
.agy-card {
|
|
87
|
+
border: 0.5px solid var(--dsw-alias-border-l2, #eef0f3);
|
|
88
|
+
border-radius: 12px;
|
|
89
|
+
background: var(--dsw-alias-bg-layer-3, #fff);
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
}
|
|
92
|
+
.agy-card-head {
|
|
93
|
+
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
94
|
+
padding: 10px 12px;
|
|
95
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04));
|
|
96
|
+
background: var(--dsw-alias-bg-layer-2, #f9fafb);
|
|
97
|
+
}
|
|
98
|
+
.agy-card-title { font: var(--dsw-font-xs-strong-13); color: var(--dsw-alias-label-primary, #1f2329); }
|
|
99
|
+
.agy-card-body { padding: 6px 12px 8px; }
|
|
100
|
+
|
|
101
|
+
/* ── Rows inside a card ────────────────────────────────────────────────────
|
|
102
|
+
Follows DSH's own list-row convention (ui-sidebar .panelRow): a 12px-radius
|
|
103
|
+
rounded rect inset 2px from the card edge and transparent at rest. A
|
|
104
|
+
full-bleed rectangle reads as a slab and fights the card's own radius. */
|
|
105
|
+
.agy-rows { display: flex; flex-direction: column; gap: 2px; }
|
|
106
|
+
/* Master/detail: the account list beside the selected account's detail, so a
|
|
107
|
+
* row and the panel it opens stay in view together.
|
|
108
|
+
*
|
|
109
|
+
* The collapse MUST be a CONTAINER query, not a viewport one. This section
|
|
110
|
+
* renders inside the Settings panel, which is ~600px wide even on a large
|
|
111
|
+
* display, so the former @media (max-width: 720px) never fired: the split
|
|
112
|
+
* stayed two-column everywhere and the 300px master column squeezed the detail
|
|
113
|
+
* to ~280px. That is the reported symptom (the panel "feels too narrow"): the
|
|
114
|
+
* account email truncated to "a1…" and the latency value wrapped onto three
|
|
115
|
+
* lines. The measured constraint is the PANEL's width, so the query must follow
|
|
116
|
+
* it.
|
|
117
|
+
*
|
|
118
|
+
* The containment lives on a dedicated wrapper, NOT on .agy-root:
|
|
119
|
+
* container-type: inline-size applies layout containment, which makes the
|
|
120
|
+
* element a containing block for fixed-position descendants — and the host's
|
|
121
|
+
* Tooltip (used by the thinking-budget fields) positions its bubble with
|
|
122
|
+
* position: fixed. Scoping it here keeps that behaviour intact.
|
|
123
|
+
*/
|
|
124
|
+
.agy-split-wrap { container-type: inline-size; }
|
|
125
|
+
.agy-split { display: grid; grid-template-columns: 1fr; gap: 12px; align-items: start; }
|
|
126
|
+
@container (min-width: 700px) {
|
|
127
|
+
.agy-split { grid-template-columns: minmax(0, 300px) minmax(0, 1fr); }
|
|
128
|
+
}
|
|
129
|
+
/* Cap the master list so a large pool cannot push the detail it opens below the
|
|
130
|
+
fold — the reason the split exists at all. Scoped to the split: the Models tab
|
|
131
|
+
shares .agy-rows for its own long list and must keep growing freely. */
|
|
132
|
+
.agy-split .agy-rows { max-height: 300px; overflow-y: auto; }
|
|
133
|
+
.agy-rowitem {
|
|
134
|
+
/* Flex-wrap, NOT the former grid-template-columns: minmax(0,1fr) auto.
|
|
135
|
+
A grid's 1fr may shrink to zero, so the identity column yielded all its
|
|
136
|
+
width to the action cluster: at the 300px master column the row's ~167px of
|
|
137
|
+
state badge + Verify/Delete left ~45px for the email (which needs ~177px),
|
|
138
|
+
truncating every address to "a1…" even though the row had room to grow
|
|
139
|
+
downward. With a flex BASIS the actions wrap to a second line instead of
|
|
140
|
+
squeezing the name, and margin-left: auto keeps them right-aligned on the
|
|
141
|
+
same line whenever they do fit. */
|
|
142
|
+
display: flex; flex-wrap: wrap;
|
|
143
|
+
align-items: center; gap: 4px 12px;
|
|
144
|
+
margin: 0 2px; padding: 10px 8px; box-sizing: border-box;
|
|
145
|
+
min-height: 36px; border-radius: 12px; background: transparent;
|
|
146
|
+
}
|
|
147
|
+
.agy-rowitem[data-clickable="true"] { cursor: pointer; }
|
|
148
|
+
.agy-rowitem[data-clickable="true"]:hover {
|
|
149
|
+
background: var(--dsw-alias-interactive-bg-hover, #f4f5f7);
|
|
150
|
+
}
|
|
151
|
+
.agy-rowitem[data-selected="true"] {
|
|
152
|
+
background: var(--dsw-alias-interactive-bg-hover, #f4f5f7);
|
|
153
|
+
}
|
|
154
|
+
.agy-rowitem:focus-visible {
|
|
155
|
+
outline: 2px solid var(--dsw-alias-label-primary, #1f2329);
|
|
156
|
+
outline-offset: -2px;
|
|
157
|
+
}
|
|
158
|
+
.agy-rowmain { min-width: 0; flex: 1 1 160px; display: flex; flex-direction: column; gap: 3px; }
|
|
159
|
+
.agy-rowtitle { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
|
160
|
+
.agy-rowname {
|
|
161
|
+
font: var(--dsw-font-xs-strong-13); color: var(--dsw-alias-label-primary, #1f2329);
|
|
162
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
163
|
+
}
|
|
164
|
+
.agy-rowmeta {
|
|
165
|
+
font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e);
|
|
166
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
167
|
+
}
|
|
168
|
+
/* margin-left: auto right-aligns the cluster while it shares a line with the
|
|
169
|
+
identity, and becomes inert once flex-wrap moves it to its own line. */
|
|
170
|
+
.agy-rowactions { display: flex; align-items: center; gap: 6px; flex: none; margin-left: auto; }
|
|
171
|
+
.agy-state { display: inline-flex; align-items: center; gap: 6px; flex: none; }
|
|
172
|
+
|
|
173
|
+
.agy-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
174
|
+
.agy-actions > :first-child:not(button) { flex: 1; min-width: 150px; }
|
|
175
|
+
.agy-detail { display: flex; flex-direction: column; gap: 12px; }
|
|
176
|
+
|
|
177
|
+
/* ── Metric strip ────────────────────────────────────────────────────────── */
|
|
178
|
+
.agy-metrics { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); }
|
|
179
|
+
/* No vertical rules between cells: the columns read as separated already by
|
|
180
|
+
the gap and their own left alignment, and the dividers turned a metric strip
|
|
181
|
+
into a spreadsheet grid. */
|
|
182
|
+
.agy-metric { padding: 12px 0; }
|
|
183
|
+
.agy-metric-k { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
184
|
+
/* The theme's own role carries family + size + line-height + weight; the
|
|
185
|
+
metric only tightens the tracking. */
|
|
186
|
+
.agy-metric-v { margin-top: 4px; font: var(--dsw-font-base-strong-16); letter-spacing: -.015em;
|
|
187
|
+
font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #1f2329); }
|
|
188
|
+
.agy-metric-v small { margin-left: 2px; font: var(--dsw-font-xxs-strong-12);
|
|
189
|
+
color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
190
|
+
.agy-metric-d { margin-top: 3px; font: var(--dsw-font-xxxs-11);
|
|
191
|
+
color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
192
|
+
|
|
193
|
+
/* ── Token composition ─────────────────────────────────────────────────────
|
|
194
|
+
A share bar per bucket, under the headline metrics. This exists to answer
|
|
195
|
+
"why is cache read larger than the input?" with a proportion instead of
|
|
196
|
+
prose: the cache re-reads the whole prefix each turn, so its share dominates.
|
|
197
|
+
Rows are laid out as label / track / value / percent so the numbers stay in
|
|
198
|
+
columns and remain scannable. */
|
|
199
|
+
.agy-compose { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
|
200
|
+
.agy-compose-row {
|
|
201
|
+
display: grid; grid-template-columns: 64px minmax(0,1fr) 56px 44px;
|
|
202
|
+
align-items: center; gap: 10px;
|
|
203
|
+
}
|
|
204
|
+
.agy-compose-k { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
205
|
+
.agy-compose-track {
|
|
206
|
+
height: 6px; border-radius: 3px; overflow: hidden;
|
|
207
|
+
background: var(--dsw-alias-border-l2, rgba(0,0,0,.12));
|
|
208
|
+
}
|
|
209
|
+
.agy-compose-track i { display: block; height: 100%; border-radius: 3px; }
|
|
210
|
+
.agy-compose-v { text-align: right; font: var(--dsw-font-xxs-12);
|
|
211
|
+
font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary, #61666b); }
|
|
212
|
+
.agy-compose-p { text-align: right; font: var(--dsw-font-xxxs-11);
|
|
213
|
+
font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
214
|
+
/* A table's own footnote: states a column's scope that its header cannot. */
|
|
215
|
+
.agy-table-note { padding: 6px 8px 2px; }
|
|
216
|
+
/* The 65535 row is a CONFIGURATION of High, not a sibling tier: indenting it
|
|
217
|
+
makes the dependency visible without adding a column or a badge. */
|
|
218
|
+
.agy-table td.agy-nested { padding-left: 20px; font-weight: 400; }
|
|
219
|
+
|
|
220
|
+
/* ── Definition rows (label / value pairs) ───────────────────────────────── */
|
|
221
|
+
.agy-defs { display: grid; grid-template-columns: 92px minmax(0,1fr); margin: 0; }
|
|
222
|
+
.agy-defs dt {
|
|
223
|
+
padding: 7px 0; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary, #8f959e);
|
|
224
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04));
|
|
225
|
+
}
|
|
226
|
+
.agy-defs dd {
|
|
227
|
+
margin: 0; padding: 7px 0; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-secondary, #61666b);
|
|
228
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04));
|
|
229
|
+
overflow-wrap: anywhere;
|
|
230
|
+
}
|
|
231
|
+
.agy-defs dt:last-of-type, .agy-defs dd:last-of-type { border-bottom: 0; }
|
|
232
|
+
|
|
233
|
+
/* ── Disclosure (the collapsible model-quota block) ──────────────────────── */
|
|
234
|
+
.agy-disclosure { border-top: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04)); }
|
|
235
|
+
.agy-disclosure-toggle {
|
|
236
|
+
display: flex; align-items: center; gap: 7px; width: 100%;
|
|
237
|
+
padding: 9px 0; border: 0; cursor: pointer;
|
|
238
|
+
font: var(--dsw-font-xxs-strong-12); text-align: left;
|
|
239
|
+
color: var(--dsw-alias-label-secondary, #61666b); background: none;
|
|
240
|
+
}
|
|
241
|
+
.agy-disclosure-toggle:hover { color: var(--dsw-alias-label-primary, #1f2329); }
|
|
242
|
+
.agy-caret {
|
|
243
|
+
flex: none; width: 0; height: 0; border-left: 4px solid currentColor;
|
|
244
|
+
border-top: 3.5px solid transparent; border-bottom: 3.5px solid transparent;
|
|
245
|
+
transition: transform .15s ease;
|
|
246
|
+
}
|
|
247
|
+
.agy-disclosure[data-open="true"] .agy-caret { transform: rotate(90deg); }
|
|
248
|
+
.agy-disclosure-body { padding-bottom: 8px; }
|
|
249
|
+
/* One row per reasoning level: label, then the budget input. The input is
|
|
250
|
+
width-capped so the empty state reads as "no value set" rather than as a wide
|
|
251
|
+
field waiting to be filled. */
|
|
252
|
+
/* Label | input | chips. Two content columns plus shortcuts, with real vertical
|
|
253
|
+
breathing room: the earlier three-column version (label | input | a sentence
|
|
254
|
+
restating the row's own label) packed four rows into 5px padding and 10px gaps,
|
|
255
|
+
which read as one solid block. */
|
|
256
|
+
.agy-thinking-group { display: flex; flex-direction: column; }
|
|
257
|
+
.agy-thinking-group + .agy-thinking-group { margin-top: 16px; }
|
|
258
|
+
.agy-thinking-group-name {
|
|
259
|
+
font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-secondary, #61666b);
|
|
260
|
+
margin-bottom: 2px;
|
|
261
|
+
}
|
|
262
|
+
.agy-thinking-group .agy-hint { margin: 0 0 8px; }
|
|
263
|
+
/* The four things a reader can actually DO with this setting. A list, because
|
|
264
|
+
each is an independent action, and prose buried them. */
|
|
265
|
+
.agy-thinking-effects { margin: 2px 0 10px; }
|
|
266
|
+
/* Claude's three differences, as a list: each is an independent axis, and the
|
|
267
|
+
last one is why that group has no reference table. */
|
|
268
|
+
.agy-thinking-notes { margin: 0 0 8px; padding-left: 18px; }
|
|
269
|
+
.agy-thinking-notes li {
|
|
270
|
+
font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e);
|
|
271
|
+
line-height: 1.6;
|
|
272
|
+
}
|
|
273
|
+
.agy-thinking-row {
|
|
274
|
+
display: grid; grid-template-columns: 72px minmax(0, 180px) auto;
|
|
275
|
+
align-items: center; gap: 14px; padding: 8px 0;
|
|
276
|
+
}
|
|
277
|
+
.agy-thinking-k { font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-secondary, #61666b); }
|
|
278
|
+
/* Shortcut chips, not a second control: they fill the field beside them. */
|
|
279
|
+
.agy-thinking-chips { display: flex; gap: 6px; }
|
|
280
|
+
.agy-thinking-chip {
|
|
281
|
+
border: 0.5px solid var(--dsw-alias-border-l3, rgba(0,0,0,.15));
|
|
282
|
+
background: transparent; cursor: pointer; border-radius: 10px;
|
|
283
|
+
padding: 2px 8px; font: var(--dsw-font-xxxs-11);
|
|
284
|
+
color: var(--dsw-alias-label-secondary, #61666b);
|
|
285
|
+
}
|
|
286
|
+
.agy-thinking-chip:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
287
|
+
.agy-thinking-chip:focus-visible {
|
|
288
|
+
outline: 2px solid var(--dsw-alias-brand-primary, #4176e6); outline-offset: 1px;
|
|
289
|
+
}
|
|
290
|
+
.agy-disclosure-meta { margin-left: auto; font: var(--dsw-font-xxxs-11);
|
|
291
|
+
color: var(--dsw-alias-label-tertiary, #8f959e); font-variant-numeric: tabular-nums; }
|
|
292
|
+
|
|
293
|
+
/* ── 5h / weekly limits ────────────────────────────────────────────────────
|
|
294
|
+
One group per upstream group (Gemini, Claude+GPT), each with its windows.
|
|
295
|
+
The rows are a fixed 4-column grid so the bars and the percentages line up
|
|
296
|
+
across groups: label / bar / percentage / reset countdown. */
|
|
297
|
+
.agy-limits { display: flex; flex-direction: column; gap: 10px; padding: 4px 0; }
|
|
298
|
+
.agy-limit-age { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
299
|
+
.agy-limit-group { display: flex; flex-direction: column; gap: 2px; }
|
|
300
|
+
.agy-limit-group-name {
|
|
301
|
+
font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-secondary, #61666b);
|
|
302
|
+
padding-bottom: 2px;
|
|
303
|
+
}
|
|
304
|
+
.agy-limit-row {
|
|
305
|
+
display: grid; grid-template-columns: 58px minmax(0,1fr) 40px minmax(0,auto);
|
|
306
|
+
align-items: center; gap: 10px; padding: 4px 0;
|
|
307
|
+
font: var(--dsw-font-xxs-12);
|
|
308
|
+
}
|
|
309
|
+
.agy-limit-k { color: var(--dsw-alias-label-secondary, #61666b); }
|
|
310
|
+
.agy-limit-track { height: 6px; border-radius: 3px; overflow: hidden;
|
|
311
|
+
background: var(--dsw-alias-border-l2, rgba(0,0,0,.12)); }
|
|
312
|
+
.agy-limit-track i { display: block; height: 100%; border-radius: 3px; }
|
|
313
|
+
.agy-limit-p { text-align: right; font-variant-numeric: tabular-nums;
|
|
314
|
+
color: var(--dsw-alias-label-primary, #1f2329); }
|
|
315
|
+
.agy-limit-reset { text-align: right; font: var(--dsw-font-xxxs-11);
|
|
316
|
+
color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
317
|
+
|
|
318
|
+
/* ── Dense breakdown tables (Usage tab only) ─────────────────────────────── */
|
|
319
|
+
.agy-table-wrap { padding: 6px 0 2px; }
|
|
320
|
+
.agy-table { width: 100%; border-collapse: collapse; font: var(--dsw-font-xs-13); table-layout: fixed; }
|
|
321
|
+
.agy-table th {
|
|
322
|
+
text-align: left; padding: 0 8px 7px; font: var(--dsw-font-xxxs-strong-11);
|
|
323
|
+
color: var(--dsw-alias-label-tertiary, #8f959e);
|
|
324
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l2, #eef0f3); white-space: nowrap;
|
|
325
|
+
}
|
|
326
|
+
.agy-table td { padding: 8px; vertical-align: middle; color: var(--dsw-alias-label-secondary, #61666b);
|
|
327
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04)); }
|
|
328
|
+
.agy-table tr:last-child td { border-bottom: 0; }
|
|
329
|
+
.agy-table tbody tr:hover td { background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
|
|
330
|
+
.agy-num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
331
|
+
/* Numeric HEADERS must right-align too, and .agy-num alone cannot do it: the
|
|
332
|
+
.agy-table th rule above is specificity 0-1-1 and outranks the bare .agy-num
|
|
333
|
+
class (0-1-0), so every numeric th stayed left while its td cells
|
|
334
|
+
right-aligned — the header label sat at the column's left edge with its
|
|
335
|
+
figure out at the right, which is what read as "the data is skewed right".
|
|
336
|
+
Matching the cell class on the header element (0-2-1) wins instead of
|
|
337
|
+
escalating with !important. */
|
|
338
|
+
.agy-table th.agy-num { text-align: right; }
|
|
339
|
+
/* Inline emphasis on a table cell: a strong role, not a heavier size. */
|
|
340
|
+
.agy-strong { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 500; }
|
|
341
|
+
.agy-mail { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
342
|
+
.agy-mono { font-family: var(--ds-font-family-code); }
|
|
343
|
+
/* The one external link in the section (a verification appeal URL). Colored and
|
|
344
|
+
underlined with theme tokens rather than left to the browser default, which
|
|
345
|
+
ignores both the light/dark theme and the host's brand color. */
|
|
346
|
+
.agy-link { color: var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6); text-decoration: underline; }
|
|
347
|
+
.agy-link:hover { opacity: 0.8; }
|
|
348
|
+
|
|
349
|
+
.agy-bar { display: inline-flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
|
350
|
+
.agy-bar .agy-track { width: 56px; height: 4px; border-radius: 2px; overflow: hidden;
|
|
351
|
+
background: var(--dsw-alias-border-l2, rgba(0,0,0,.12)); }
|
|
352
|
+
.agy-bar .agy-track i { display: block; height: 100%; border-radius: 2px;
|
|
353
|
+
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6); }
|
|
354
|
+
|
|
355
|
+
/* ── Range picker container ────────────────────────────────────────────────
|
|
356
|
+
The pills themselves are the host Pill primitive (its own fill pair and
|
|
357
|
+
active state); this only lays them out in a row. */
|
|
358
|
+
.agy-chips { display: flex; gap: 6px; }
|
|
359
|
+
|
|
360
|
+
/* Danger has no primitive variant; keep the ghost skin and tint the label. */
|
|
361
|
+
.agy-btn-danger { color: var(--dsw-alias-state-error-primary, #ec1313) !important; }
|
|
362
|
+
|
|
363
|
+
.agy-toolbar { display: flex; align-items: center; gap: 8px; }
|
|
364
|
+
.agy-textarea { width: 100%; min-height: 88px; resize: vertical; outline: none;
|
|
365
|
+
padding: 9px 10px; font: var(--dsw-font-xxs-12); font-family: var(--ds-font-family-code);
|
|
366
|
+
color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff);
|
|
367
|
+
border: 0.5px solid var(--dsw-alias-border-l2, #eef0f3); border-radius: 8px; }
|
|
368
|
+
.agy-textarea:focus { border-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6); }
|
|
369
|
+
`;
|
|
370
|
+
/** Install the stylesheet once (idempotent across plugin reloads). */
|
|
371
|
+
function installAgyStyles() {
|
|
372
|
+
if (typeof document === "undefined") return () => {};
|
|
373
|
+
if (document.getElementById(STYLE_ID) !== null) return () => {
|
|
374
|
+
document.getElementById(STYLE_ID)?.remove();
|
|
375
|
+
};
|
|
376
|
+
const style = document.createElement("style");
|
|
377
|
+
style.id = STYLE_ID;
|
|
378
|
+
style.textContent = CSS;
|
|
379
|
+
document.head.appendChild(style);
|
|
380
|
+
return () => {
|
|
381
|
+
document.getElementById(STYLE_ID)?.remove();
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/client/locales.ts
|
|
386
|
+
/**
|
|
387
|
+
* Bilingual copy for the agy Settings section.
|
|
388
|
+
*
|
|
389
|
+
* Registered through the host's locale service (`ctx.locale.register`), so the
|
|
390
|
+
* section follows DSH's own language setting instead of carrying a private
|
|
391
|
+
* language switch. Keys are typed, and the `zh` and `en` dictionaries must stay
|
|
392
|
+
* key-for-key identical (a test enforces it).
|
|
393
|
+
*/
|
|
394
|
+
const zh = {
|
|
395
|
+
title: "Antigravity",
|
|
396
|
+
subtitle: "账号轮换、模型可见性、用量与凭据",
|
|
397
|
+
refresh: "刷新",
|
|
398
|
+
login: "登录",
|
|
399
|
+
loading: "加载中…",
|
|
400
|
+
tabAccounts: "账号",
|
|
401
|
+
tabModels: "模型",
|
|
402
|
+
tabUsage: "用量",
|
|
403
|
+
tabCredentials: "凭据",
|
|
404
|
+
stateActive: "使用中",
|
|
405
|
+
stateCooling: "冷却中",
|
|
406
|
+
stateVerificationRequired: "待验证",
|
|
407
|
+
stateDisabled: "已停用",
|
|
408
|
+
coolingUntil: "冷却至",
|
|
409
|
+
currentAccount: "当前账号",
|
|
410
|
+
noProject: "—",
|
|
411
|
+
valueUnknown: "—",
|
|
412
|
+
colAccount: "账号",
|
|
413
|
+
colRequests: "请求",
|
|
414
|
+
colActions: "操作",
|
|
415
|
+
emptyAccounts: "还没有账号。切到「凭据」标签导入,或点击上方「登录」。",
|
|
416
|
+
detailTitle: "已选账号",
|
|
417
|
+
limitsTitle: "限额",
|
|
418
|
+
limitsUnavailable: "尚未测量。配额按账号定期刷新后显示。",
|
|
419
|
+
limitsMeasured: "测量于 {ago}",
|
|
420
|
+
limitsRefreshOk: "已刷新 {measured} 个账号的限额",
|
|
421
|
+
limitsRefreshFresh: "限额仍是新鲜的,无需刷新",
|
|
422
|
+
limitsRefreshFailed: "{failed} 个账号的限额刷新失败",
|
|
423
|
+
quotaWindow5h: "5 小时",
|
|
424
|
+
quotaWindowWeekly: "每周",
|
|
425
|
+
fieldProject: "项目",
|
|
426
|
+
fieldProxy: "代理",
|
|
427
|
+
fieldFingerprint: "指纹",
|
|
428
|
+
fieldCooldownReason: "冷却原因",
|
|
429
|
+
cooldownReasonNetworkError: "网络错误",
|
|
430
|
+
cooldownReasonQuotaExhausted: "额度耗尽",
|
|
431
|
+
cooldownReasonValidationRequired: "需要验证",
|
|
432
|
+
cooldownReasonProjectError: "项目错误",
|
|
433
|
+
fieldSources: "调用来源",
|
|
434
|
+
fieldLatency: "延迟",
|
|
435
|
+
fieldVerification: "验证",
|
|
436
|
+
verificationOpen: "前往验证",
|
|
437
|
+
verificationNoUrl: "上游未提供链接,请在 Antigravity 中完成验证",
|
|
438
|
+
labelLatencyAverage: "平均延迟",
|
|
439
|
+
labelTtft: "首 token",
|
|
440
|
+
proxyPlaceholder: "socks5://host:port",
|
|
441
|
+
proxyDirect: "未配置(直连)",
|
|
442
|
+
fingerprintNone: "未生成",
|
|
443
|
+
fingerprintRegenerated: "已重建 {count} 次 · {date}",
|
|
444
|
+
latencyAverage: "平均 {value}",
|
|
445
|
+
latencyTtft: "首 token {value}",
|
|
446
|
+
sourcesSummary: "对话 {chat} · CLI {cli} · 验证 {verify} · 测试 {test}",
|
|
447
|
+
quotaResetIn: "重置 {value}",
|
|
448
|
+
relNow: "即将",
|
|
449
|
+
relJustNow: "刚刚",
|
|
450
|
+
relAgo: "{value}前",
|
|
451
|
+
relMinutes: "{n} 分钟",
|
|
452
|
+
relHours: "{n} 小时",
|
|
453
|
+
relDays: "{n} 天",
|
|
454
|
+
relMonths: "{n} 个月",
|
|
455
|
+
relYears: "{n} 年",
|
|
456
|
+
actionActivate: "激活",
|
|
457
|
+
actionVerify: "验证",
|
|
458
|
+
actionDelete: "删除",
|
|
459
|
+
actionTest: "测试调用",
|
|
460
|
+
actionExport: "导出 Blob",
|
|
461
|
+
actionRegenerateFingerprint: "重置指纹",
|
|
462
|
+
actionSave: "保存",
|
|
463
|
+
actionClear: "清除",
|
|
464
|
+
actionTestProxy: "测试",
|
|
465
|
+
actionTestModel: "测试",
|
|
466
|
+
modelTesting: "测试中…",
|
|
467
|
+
modelTestOk: "{model} 可用。",
|
|
468
|
+
modelTestFail: "{model} 测试失败:",
|
|
469
|
+
proxyTestOk: "代理可达:{proxy}",
|
|
470
|
+
proxyTestFail: "代理不可达:{proxy}",
|
|
471
|
+
proxyTestNoTarget: "先填写代理地址,或保存一个代理",
|
|
472
|
+
verifyOk: "凭证有效:{email}",
|
|
473
|
+
verifyFail: "验证失败:",
|
|
474
|
+
confirmDelete: "删除这个账号?此操作不可撤销。",
|
|
475
|
+
noModelToTest: "没有可用于测试的模型",
|
|
476
|
+
exportFailed: "导出失败",
|
|
477
|
+
importResult: "导入 {imported} 个,覆盖 {replaced} 个。",
|
|
478
|
+
importPartial: "导入 {imported} 个,覆盖 {replaced} 个,失败 {failed} 个:",
|
|
479
|
+
usageCumulative: "用量 · 累计",
|
|
480
|
+
usageTitle: "用量",
|
|
481
|
+
kpiTotalTokens: "总 Token",
|
|
482
|
+
kpiTotalDetail: "{requests} 次请求 · 失败 {failed}",
|
|
483
|
+
kpiInput: "输入",
|
|
484
|
+
kpiOutput: "输出",
|
|
485
|
+
kpiCacheRead: "缓存命中",
|
|
486
|
+
kpiRequests: "请求",
|
|
487
|
+
kpiInputMissed: "未命中 {tokens}",
|
|
488
|
+
kpiInputMissedLabel: "未命中",
|
|
489
|
+
kpiOutputDetail: "含推理",
|
|
490
|
+
kpiCacheHit: "占输入 {percent}%",
|
|
491
|
+
kpiNoBilledInput: "无计费输入",
|
|
492
|
+
kpiRequestsDetail: "成功 {succeeded} · 失败 {failed}",
|
|
493
|
+
fieldCacheWrite: "缓存写",
|
|
494
|
+
fieldRateLimitRotation: "限流 / 轮换",
|
|
495
|
+
byModel: "按模型",
|
|
496
|
+
byAccount: "按账号",
|
|
497
|
+
colModel: "模型",
|
|
498
|
+
colTokenShare: "Token 占比",
|
|
499
|
+
colOutput: "输出",
|
|
500
|
+
colToken: "Token",
|
|
501
|
+
colFailed: "失败",
|
|
502
|
+
colRateLimited: "限流",
|
|
503
|
+
colRotations: "轮换",
|
|
504
|
+
rangeToday: "今日",
|
|
505
|
+
rangeWeek: "7 天",
|
|
506
|
+
rangeMonth: "30 天",
|
|
507
|
+
rangeAll: "全部",
|
|
508
|
+
since: "自 {date} 起",
|
|
509
|
+
emptyUsage: "还没有用量记录。",
|
|
510
|
+
thinkingTitle: "思考预算",
|
|
511
|
+
thinkingDefaultAll: "默认",
|
|
512
|
+
thinkingConfigured: "{count} 项已设置",
|
|
513
|
+
thinkingLevelLow: "低",
|
|
514
|
+
thinkingLevelMedium: "中",
|
|
515
|
+
thinkingLevelHigh: "高",
|
|
516
|
+
thinkingAuto: "默认",
|
|
517
|
+
thinkingChipClear: "清空",
|
|
518
|
+
thinkingGeminiGroup: "Gemini",
|
|
519
|
+
thinkingGeminiHint: "思考预算是 API 里的一个隐藏参数,用来控制模型思考的努力程度。上游给其中几个取值起了名字,这就是你在模型选择器里看到的 reasoning effort(high / medium / low)。填入预算会替换掉原本的 high / medium / low 传给模型,而不是叠加。",
|
|
520
|
+
thinkingEffectLowUp: "让低档想得更多:在 Low 行填入较大数值",
|
|
521
|
+
thinkingEffectHighDown: "让高档想得更少(更快、更省):在 High 行填入较小数值",
|
|
522
|
+
thinkingEffectMax: "获得最大思考:直接选 High,不必填值",
|
|
523
|
+
thinkingEffectReset: "恢复该档默认:清空该行",
|
|
524
|
+
thinkingTieredLabel: "Default",
|
|
525
|
+
thinkingSamplesTitle: "实测思考量参考",
|
|
526
|
+
thinkingSamplesIntro: "模型会基于预算,根据问题难度自适应调整思考长短。",
|
|
527
|
+
thinkingSamplesCaption: "这张表格展示了测试中,Gemini 3.8 Flash 在不同预算下,对不同难度题目的实际思考消耗(单位:token)。",
|
|
528
|
+
thinkingSamplesComparison: "填入最大值(65535)与选 High 在困难题上完全相同,但可以提高简单题和中等题的思考程度(约 10–15%)。数值决定效果——同一数值填在任意一行,发出的请求完全相同。",
|
|
529
|
+
thinkingSamplesSources: "注:困难题使用一道组合计数推导题(推导铺砖递推式并求第 40 项),中等题使用一道数论证明题(证明 n⁴+4 恒为合数),简单题使用一道两位数乘法。",
|
|
530
|
+
thinkingSamplesZero: "Low 档在中等题上确实不产生思考(多次实测为 0),并非数据缺失——同一道题上 Medium 与 High 均正常思考。",
|
|
531
|
+
thinkingSamplesLevel: "设置",
|
|
532
|
+
thinkingSamplesEasy: "简单题",
|
|
533
|
+
thinkingSamplesMedium: "中等题",
|
|
534
|
+
thinkingSamplesHard: "困难题",
|
|
535
|
+
thinkingSamplesMaxRow: "填入 65535",
|
|
536
|
+
thinkingClaudeGroup: "Claude",
|
|
537
|
+
thinkingClaudeLabel: "预算",
|
|
538
|
+
thinkingClaudeNoLevels: "无思考等级,只有一个预算值({min}–{max})。",
|
|
539
|
+
thinkingClaudeMaxTokens: "必须小于单次回复上限,否则不发送。",
|
|
540
|
+
thinkingClaudeNoReport: "上游不回报实际思考量,故无参考表。",
|
|
541
|
+
thinkingInvalid: "请填整数。",
|
|
542
|
+
modelsTitle: "模型可见性",
|
|
543
|
+
modelsHelp: "关闭开关后,该模型不再出现在对话框的模型选择里。黑名单制:只有被关闭的才隐藏,服务端新增的模型一律默认显示。",
|
|
544
|
+
modelsHiddenSuffix: "当前已隐藏 {count} 个。",
|
|
545
|
+
modelToggleAria: "{name} 是否在模型选择中显示",
|
|
546
|
+
emptyModels: "没有可用模型。先登录一个账号。",
|
|
547
|
+
importTitle: "导入凭据",
|
|
548
|
+
importHelp: "粘贴 agy auth.json,或 dsh-agy login --blob 生成的凭据 blob;多行可批量导入。",
|
|
549
|
+
importPlaceholder: "{\"token\":{\"access_token\":\"...\",\"refresh_token\":\"...\"}} 或 dsh-agy-cred-v1....(每行一个)",
|
|
550
|
+
importJson: "导入 JSON",
|
|
551
|
+
importBlob: "导入 Blob",
|
|
552
|
+
exportAll: "导出全部"
|
|
553
|
+
};
|
|
554
|
+
const en = {
|
|
555
|
+
title: "Antigravity",
|
|
556
|
+
subtitle: "Account rotation, model visibility, usage, and credentials",
|
|
557
|
+
refresh: "Refresh",
|
|
558
|
+
login: "Sign in",
|
|
559
|
+
loading: "Loading…",
|
|
560
|
+
tabAccounts: "Accounts",
|
|
561
|
+
tabModels: "Models",
|
|
562
|
+
tabUsage: "Usage",
|
|
563
|
+
tabCredentials: "Credentials",
|
|
564
|
+
stateActive: "Active",
|
|
565
|
+
stateCooling: "Cooling down",
|
|
566
|
+
stateVerificationRequired: "Needs verification",
|
|
567
|
+
stateDisabled: "Disabled",
|
|
568
|
+
coolingUntil: "Cooling until",
|
|
569
|
+
currentAccount: "current",
|
|
570
|
+
noProject: "—",
|
|
571
|
+
valueUnknown: "—",
|
|
572
|
+
colAccount: "Account",
|
|
573
|
+
colRequests: "Requests",
|
|
574
|
+
colActions: "Actions",
|
|
575
|
+
emptyAccounts: "No accounts yet. Import one from the Credentials tab, or use Sign in above.",
|
|
576
|
+
detailTitle: "Selected account",
|
|
577
|
+
limitsTitle: "Limits",
|
|
578
|
+
limitsUnavailable: "Not measured yet. Windows appear after the quota refresh runs for this account.",
|
|
579
|
+
limitsMeasured: "measured {ago}",
|
|
580
|
+
limitsRefreshOk: "Refreshed limits for {measured} account(s)",
|
|
581
|
+
limitsRefreshFresh: "Limits are already fresh — nothing to refresh",
|
|
582
|
+
limitsRefreshFailed: "Limit refresh failed for {failed} account(s)",
|
|
583
|
+
quotaWindow5h: "5 hours",
|
|
584
|
+
quotaWindowWeekly: "Weekly",
|
|
585
|
+
fieldProject: "Project",
|
|
586
|
+
fieldProxy: "Proxy",
|
|
587
|
+
fieldFingerprint: "Fingerprint",
|
|
588
|
+
fieldCooldownReason: "Cooldown reason",
|
|
589
|
+
cooldownReasonNetworkError: "network error",
|
|
590
|
+
cooldownReasonQuotaExhausted: "quota exhausted",
|
|
591
|
+
cooldownReasonValidationRequired: "verification required",
|
|
592
|
+
cooldownReasonProjectError: "project error",
|
|
593
|
+
fieldSources: "Call sources",
|
|
594
|
+
fieldLatency: "Latency",
|
|
595
|
+
fieldVerification: "Verification",
|
|
596
|
+
verificationOpen: "Verify now",
|
|
597
|
+
verificationNoUrl: "No link from upstream — complete verification in Antigravity",
|
|
598
|
+
labelLatencyAverage: "Average latency",
|
|
599
|
+
labelTtft: "First token",
|
|
600
|
+
proxyPlaceholder: "socks5://host:port",
|
|
601
|
+
proxyDirect: "Not configured (direct)",
|
|
602
|
+
fingerprintNone: "Not generated",
|
|
603
|
+
fingerprintRegenerated: "Regenerated {count}× · {date}",
|
|
604
|
+
latencyAverage: "avg {value}",
|
|
605
|
+
latencyTtft: "first token {value}",
|
|
606
|
+
sourcesSummary: "chat {chat} · CLI {cli} · verify {verify} · test {test}",
|
|
607
|
+
quotaResetIn: "resets {value}",
|
|
608
|
+
relNow: "shortly",
|
|
609
|
+
relJustNow: "just now",
|
|
610
|
+
relAgo: "{value} ago",
|
|
611
|
+
relMinutes: "{n} min",
|
|
612
|
+
relHours: "{n} h",
|
|
613
|
+
relDays: "{n} d",
|
|
614
|
+
relMonths: "{n} mo",
|
|
615
|
+
relYears: "{n} y",
|
|
616
|
+
actionActivate: "Activate",
|
|
617
|
+
actionVerify: "Verify",
|
|
618
|
+
actionDelete: "Delete",
|
|
619
|
+
actionTest: "Test call",
|
|
620
|
+
actionExport: "Export blob",
|
|
621
|
+
actionRegenerateFingerprint: "Reset fingerprint",
|
|
622
|
+
actionSave: "Save",
|
|
623
|
+
actionClear: "Clear",
|
|
624
|
+
actionTestProxy: "Test",
|
|
625
|
+
actionTestModel: "Test",
|
|
626
|
+
modelTesting: "Testing…",
|
|
627
|
+
modelTestOk: "{model} is working.",
|
|
628
|
+
modelTestFail: "{model} test failed:",
|
|
629
|
+
proxyTestOk: "Proxy reachable: {proxy}",
|
|
630
|
+
proxyTestFail: "Proxy unreachable: {proxy}",
|
|
631
|
+
proxyTestNoTarget: "Enter a proxy address, or save one first",
|
|
632
|
+
verifyOk: "Credentials valid: {email}",
|
|
633
|
+
verifyFail: "Verification failed:",
|
|
634
|
+
confirmDelete: "Delete this account? This cannot be undone.",
|
|
635
|
+
noModelToTest: "No model available to test",
|
|
636
|
+
exportFailed: "Export failed",
|
|
637
|
+
importResult: "Imported {imported}, replaced {replaced}.",
|
|
638
|
+
importPartial: "Imported {imported}, replaced {replaced}, {failed} failed:",
|
|
639
|
+
usageCumulative: "Usage · cumulative",
|
|
640
|
+
usageTitle: "Usage",
|
|
641
|
+
kpiTotalTokens: "Total tokens",
|
|
642
|
+
kpiTotalDetail: "{requests} requests · {failed} failed",
|
|
643
|
+
kpiInput: "Input",
|
|
644
|
+
kpiOutput: "Output",
|
|
645
|
+
kpiCacheRead: "Cache hit",
|
|
646
|
+
kpiRequests: "Requests",
|
|
647
|
+
kpiInputMissed: "{tokens} missed",
|
|
648
|
+
kpiInputMissedLabel: "Missed",
|
|
649
|
+
kpiOutputDetail: "incl. reasoning",
|
|
650
|
+
kpiCacheHit: "{percent}% of input",
|
|
651
|
+
kpiNoBilledInput: "no billed input",
|
|
652
|
+
kpiRequestsDetail: "{succeeded} ok · {failed} failed",
|
|
653
|
+
fieldCacheWrite: "Cache write",
|
|
654
|
+
fieldRateLimitRotation: "Rate limits / rotations",
|
|
655
|
+
byModel: "By model",
|
|
656
|
+
byAccount: "By account",
|
|
657
|
+
colModel: "Model",
|
|
658
|
+
colTokenShare: "Token share",
|
|
659
|
+
colOutput: "Output",
|
|
660
|
+
colToken: "Tokens",
|
|
661
|
+
colFailed: "Failed",
|
|
662
|
+
colRateLimited: "Rate ltd",
|
|
663
|
+
colRotations: "Rotations",
|
|
664
|
+
rangeToday: "Today",
|
|
665
|
+
rangeWeek: "7 days",
|
|
666
|
+
rangeMonth: "30 days",
|
|
667
|
+
rangeAll: "All",
|
|
668
|
+
since: "since {date}",
|
|
669
|
+
emptyUsage: "No usage recorded yet.",
|
|
670
|
+
thinkingTitle: "Thinking budget",
|
|
671
|
+
thinkingDefaultAll: "default",
|
|
672
|
+
thinkingConfigured: "{count} set",
|
|
673
|
+
thinkingLevelLow: "Low",
|
|
674
|
+
thinkingLevelMedium: "Medium",
|
|
675
|
+
thinkingLevelHigh: "High",
|
|
676
|
+
thinkingAuto: "default",
|
|
677
|
+
thinkingChipClear: "Clear",
|
|
678
|
+
thinkingGeminiGroup: "Gemini",
|
|
679
|
+
thinkingGeminiHint: "A thinking budget is a hidden parameter in the API that controls how hard the model thinks. Upstream gives a few of its values names, and those names are what you see as reasoning effort (high / medium / low) in the model picker. A budget replaces the high / medium / low value that would otherwise be sent, rather than stacking with it.",
|
|
680
|
+
thinkingEffectLowUp: "Make a low tier think more: put a larger value on the Low row",
|
|
681
|
+
thinkingEffectHighDown: "Make a high tier think less (faster, cheaper): put a smaller value on the High row",
|
|
682
|
+
thinkingEffectMax: "Maximum thinking: just select High, no value needed",
|
|
683
|
+
thinkingEffectReset: "Back to that tier's default: clear the row",
|
|
684
|
+
thinkingTieredLabel: "Default",
|
|
685
|
+
thinkingSamplesTitle: "Measured thinking tokens",
|
|
686
|
+
thinkingSamplesIntro: "The model adapts how long it thinks to the difficulty of the question, within the budget.",
|
|
687
|
+
thinkingSamplesCaption: "This table shows what Gemini 3.8 Flash actually spent (in tokens) under different budgets, on questions of different difficulty.",
|
|
688
|
+
thinkingSamplesComparison: "Entering the maximum (65535) is identical to selecting High on the hard question, but raises thinking on the easy and medium ones by about 10–15%. The value alone decides the effect — the same number entered on any row sends the same request.",
|
|
689
|
+
thinkingSamplesSources: "Note: the hard question is a combinatorial derivation (derive a tiling recurrence and compute term 40), the medium one a number-theory proof (prove n⁴+4 is always composite), and the easy one a two-digit multiplication.",
|
|
690
|
+
thinkingSamplesZero: "Low genuinely produces no thinking on the medium question (0 in repeated runs) — this is not missing data; Medium and High both think normally on the same question.",
|
|
691
|
+
thinkingSamplesLevel: "Setting",
|
|
692
|
+
thinkingSamplesEasy: "Easy",
|
|
693
|
+
thinkingSamplesMedium: "Medium",
|
|
694
|
+
thinkingSamplesHard: "Hard",
|
|
695
|
+
thinkingSamplesMaxRow: "entered 65535",
|
|
696
|
+
thinkingClaudeGroup: "Claude",
|
|
697
|
+
thinkingClaudeLabel: "Budget",
|
|
698
|
+
thinkingClaudeNoLevels: "No thinking levels — a single budget value ({min}–{max}).",
|
|
699
|
+
thinkingClaudeMaxTokens: "Must be below the reply limit, or it is not sent.",
|
|
700
|
+
thinkingClaudeNoReport: "Upstream does not report thinking tokens, so there is no reference table.",
|
|
701
|
+
thinkingInvalid: "Enter a whole number.",
|
|
702
|
+
modelsTitle: "Model visibility",
|
|
703
|
+
modelsHelp: "Switching a model off removes it from the model picker. Blacklist semantics: only models you switch off are hidden, so models the server adds later stay visible.",
|
|
704
|
+
modelsHiddenSuffix: "{count} hidden right now.",
|
|
705
|
+
modelToggleAria: "Show {name} in the model picker",
|
|
706
|
+
emptyModels: "No models available. Sign in to an account first.",
|
|
707
|
+
importTitle: "Import credentials",
|
|
708
|
+
importHelp: "Paste an agy auth.json, or a credential blob from dsh-agy login --blob. One per line for a batch import.",
|
|
709
|
+
importPlaceholder: "{\"token\":{\"access_token\":\"...\",\"refresh_token\":\"...\"}} or dsh-agy-cred-v1.... (one per line)",
|
|
710
|
+
importJson: "Import JSON",
|
|
711
|
+
importBlob: "Import blob",
|
|
712
|
+
exportAll: "Export all"
|
|
713
|
+
};
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region src/thinking-types.ts
|
|
716
|
+
/**
|
|
717
|
+
* Thinking-budget vocabulary shared by the host store, the RPC wire contract,
|
|
718
|
+
* and the browser UI.
|
|
719
|
+
*
|
|
720
|
+
* Split out for the same reason `usage-types.ts` exists: these types live on
|
|
721
|
+
* their own, free of any `node:*` import, so the browser bundle can describe the
|
|
722
|
+
* settings surface without pulling in the host's storage module. Putting them in
|
|
723
|
+
* `thinking-budget.ts` instead made `rpc-contract.ts` (which the client
|
|
724
|
+
* typechecks) reach `node:fs` through the transitive import — caught by
|
|
725
|
+
* `tsconfig.client.json`, which includes the contract and excludes `src/store`.
|
|
726
|
+
*/
|
|
727
|
+
/** The three levels a tiered model exposes, in display order. */
|
|
728
|
+
const THINKING_LEVELS = [
|
|
729
|
+
"low",
|
|
730
|
+
"medium",
|
|
731
|
+
"high"
|
|
732
|
+
];
|
|
733
|
+
/**
|
|
734
|
+
* The Claude family's `thinkingBudget` bounds, measured separately from Gemini's.
|
|
735
|
+
*
|
|
736
|
+
* The two families do NOT share a contract, which is why one interval cannot
|
|
737
|
+
* serve both:
|
|
738
|
+
* - Claude's floor is **1024**, not `-1`. A budget of 1 or 512 is rejected with
|
|
739
|
+
* `thinking.enabled.budget_tokens: Input should be greater than or equal to
|
|
740
|
+
* 1024`; `-1` and `0` are accepted as special values.
|
|
741
|
+
* - Claude additionally requires **`max_tokens` strictly greater than the
|
|
742
|
+
* budget**: `budget=1024, max_tokens=1024` is a 400, and a budget sent with
|
|
743
|
+
* no `maxOutputTokens` at all also fails. So the margin is at least one token.
|
|
744
|
+
*
|
|
745
|
+
* `CLAUDE_BUDGET_MAX` is therefore one below `AGY_CLAUDE_MAX_OUTPUT_TOKENS` — the
|
|
746
|
+
* largest budget that can still leave room for a strictly greater `max_tokens`.
|
|
747
|
+
*/
|
|
748
|
+
const CLAUDE_BUDGET_MIN = 1024;
|
|
749
|
+
const CLAUDE_BUDGET_MAX = 63999;
|
|
750
|
+
//#endregion
|
|
8
751
|
//#region src/client/index.ts
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
752
|
+
/**
|
|
753
|
+
* agy Settings section — the browser half.
|
|
754
|
+
*
|
|
755
|
+
* One `settings.section` page with four tabs (accounts, models, usage,
|
|
756
|
+
* credentials), replacing the standalone `/agy` dashboard. Every figure comes
|
|
757
|
+
* from the `/api/agy` RPC over `ctx.connection`, so this section needs no
|
|
758
|
+
* host-rendered page and there is no second UI to keep in step.
|
|
759
|
+
*
|
|
760
|
+
* Elements are built through the local `h` helper rather than JSX or nested
|
|
761
|
+
* `createElement` calls: this package configures no JSX transform for the client
|
|
762
|
+
* bundle, and `h(tag, props, ...children)` keeps the element tree flat and
|
|
763
|
+
* readable where nested `createElement` calls become a parenthesis maze.
|
|
764
|
+
*/
|
|
765
|
+
/** Required browser services: the slot registry, dictionaries, and the RPC carrier. */
|
|
766
|
+
const inject = [
|
|
767
|
+
"slots",
|
|
768
|
+
"locale",
|
|
769
|
+
"connection"
|
|
770
|
+
];
|
|
771
|
+
/** Dictionary namespace this plugin owns. */
|
|
772
|
+
const NS = "agy";
|
|
773
|
+
/** RPC channel and endpoint the host registers as `/api/agy`. */
|
|
774
|
+
const RPC_CHANNEL = "/api";
|
|
775
|
+
const RPC_ENDPOINT = "agy";
|
|
776
|
+
/**
|
|
777
|
+
* How long a one-shot action's verdict stays on screen.
|
|
778
|
+
*
|
|
779
|
+
* Both the success and the failure channel use this: an action's outcome is a
|
|
780
|
+
* transient acknowledgement, and the screen is in a valid state either way. A
|
|
781
|
+
* STANDING failure (the account list failing to load) deliberately does not use
|
|
782
|
+
* it — see `actionError` in `AgySettings`.
|
|
783
|
+
*/
|
|
784
|
+
const ACTION_MESSAGE_TTL_MS = 3500;
|
|
785
|
+
function h(tag, props, ...children) {
|
|
786
|
+
return (0, react.createElement)(tag, props ?? null, ...children);
|
|
787
|
+
}
|
|
788
|
+
/** Call one management method and unwrap the Connection RPC envelope. */
|
|
789
|
+
function createRpc(connection) {
|
|
790
|
+
return { async call(method, payload, signal) {
|
|
791
|
+
const raw = await connection.rpc.call(RPC_CHANNEL, RPC_ENDPOINT, {
|
|
792
|
+
method,
|
|
793
|
+
payload
|
|
794
|
+
}, signal);
|
|
795
|
+
if (raw?.ok === true) return raw.value;
|
|
796
|
+
if (raw?.ok === false) throw new Error(raw.error?.message ?? `${method} failed`);
|
|
797
|
+
throw new Error(`${method}: malformed RPC response`);
|
|
798
|
+
} };
|
|
799
|
+
}
|
|
800
|
+
/** Compact token text: 1.2M / 284K / 512. */
|
|
801
|
+
/** Token-count units, largest first. */
|
|
802
|
+
const TOKEN_UNITS = [
|
|
803
|
+
[0xe8d4a51000, "T"],
|
|
804
|
+
[1e9, "B"],
|
|
805
|
+
[1e6, "M"],
|
|
806
|
+
[1e3, "K"]
|
|
807
|
+
];
|
|
808
|
+
/**
|
|
809
|
+
* Compact token count: `1.2M` / `284K` / `512`. Counts below 1000 render in
|
|
810
|
+
* full — a suffix starts at 1K.
|
|
811
|
+
*
|
|
812
|
+
* The unit is chosen from the value ROUNDED TO ITS DISPLAYED PRECISION, and
|
|
813
|
+
* promoted when that rounding would reach 1000 (which belongs to the next unit).
|
|
814
|
+
* Deciding from the raw magnitude let rounding contradict the suffix: 999_999
|
|
815
|
+
* printed as `1000K`, 99999 as `100.0K` while 100000 was `100K`, and 9999999 as
|
|
816
|
+
* `10.0M` while 10000000 was `10M`.
|
|
817
|
+
*
|
|
818
|
+
* Exported for a direct unit test: the boundaries are exactly where this broke.
|
|
819
|
+
* @param value - token count.
|
|
820
|
+
* @returns the display string.
|
|
821
|
+
*/
|
|
822
|
+
function tokenText(value) {
|
|
823
|
+
if (value < 1e3) return String(value);
|
|
824
|
+
/** One decimal below 100 (`1.2M`), none at or above (`284K`). */
|
|
825
|
+
const render = (scaled, suffix) => `${scaled < 100 ? scaled.toFixed(1) : String(Math.round(scaled))}${suffix}`;
|
|
826
|
+
const start = TOKEN_UNITS.findIndex(([divisor]) => value >= divisor);
|
|
827
|
+
for (let index = start; index >= 0; index--) {
|
|
828
|
+
const [divisor, suffix] = TOKEN_UNITS[index];
|
|
829
|
+
const scaled = Math.round(value / divisor * 10) / 10;
|
|
830
|
+
if (scaled < 1e3) return render(scaled, suffix);
|
|
831
|
+
}
|
|
832
|
+
const [divisor, suffix] = TOKEN_UNITS[0];
|
|
833
|
+
return render(Math.round(value / divisor * 10) / 10, suffix);
|
|
834
|
+
}
|
|
835
|
+
function formatDuration(ms) {
|
|
836
|
+
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
|
837
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
838
|
+
const seconds = ms / 1e3;
|
|
839
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
840
|
+
const whole = Math.round(seconds);
|
|
841
|
+
return `${Math.floor(whole / 60)}m${String(whole % 60).padStart(2, "0")}s`;
|
|
842
|
+
}
|
|
843
|
+
/** Cache-hit share of prompt-side input; null when nothing was billed. */
|
|
844
|
+
function cacheHitPercent(counters) {
|
|
845
|
+
const billed = counters.input + counters.cacheRead + counters.cacheWrite;
|
|
846
|
+
if (billed <= 0) return null;
|
|
847
|
+
return Math.round(counters.cacheRead / billed * 100);
|
|
848
|
+
}
|
|
849
|
+
function average(total, count) {
|
|
850
|
+
return count > 0 ? total / count : 0;
|
|
851
|
+
}
|
|
852
|
+
/** Total tokens across the four disjoint buckets. */
|
|
853
|
+
function totalTokens(counters) {
|
|
854
|
+
return counters.input + counters.output + counters.cacheRead + counters.cacheWrite;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* The whole prompt side: everything the model read, cached or not.
|
|
858
|
+
*
|
|
859
|
+
* The LEDGER keeps `input` as the uncached portion alone, because that is DSH's
|
|
860
|
+
* own disjoint-bucket vocabulary (`usage-types.ts`, and the adapter's SSE parse
|
|
861
|
+
* subtracts the cached count for exactly this reason) — that split must not be
|
|
862
|
+
* redefined at the storage layer.
|
|
863
|
+
*
|
|
864
|
+
* The DISPLAY folds it, though: an input figure of 18.6M beside a cache figure
|
|
865
|
+
* of 62.2M reads as though 62.2M went unaccounted for, and a cache read LARGER
|
|
866
|
+
* than the input looks like a bug rather than the expected shape of a prefix
|
|
867
|
+
* cache. So the headline shows the whole prompt side and the cache line becomes
|
|
868
|
+
* a HIT count — a subset, which is why the two are never added together and the
|
|
869
|
+
* old explanatory footnote is gone.
|
|
870
|
+
* @param counters - one scope's counters.
|
|
871
|
+
* @returns prompt tokens including the cached portion.
|
|
872
|
+
*/
|
|
873
|
+
function promptTokens(counters) {
|
|
874
|
+
return counters.input + counters.cacheRead + counters.cacheWrite;
|
|
875
|
+
}
|
|
876
|
+
/** Quota tint by remaining fraction: healthy / low / critical. */
|
|
877
|
+
function quotaColor(fraction) {
|
|
878
|
+
if (fraction > .7) return "var(--dsw-alias-state-success-primary, #22c55e)";
|
|
879
|
+
if (fraction >= .3) return "var(--dsw-alias-state-warn-primary, #f59e0b)";
|
|
880
|
+
return "var(--dsw-alias-state-error-primary, #ec1313)";
|
|
881
|
+
}
|
|
882
|
+
/** Localized account-state label. */
|
|
883
|
+
function stateLabel(state, t) {
|
|
884
|
+
switch (state) {
|
|
885
|
+
case "active": return t("stateActive");
|
|
886
|
+
case "cooling": return t("stateCooling");
|
|
887
|
+
case "verification-required": return t("stateVerificationRequired");
|
|
888
|
+
case "disabled": return t("stateDisabled");
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Localized label for a reasoning level.
|
|
893
|
+
*
|
|
894
|
+
* Falls back to the raw id so a level added upstream is still usable rather than
|
|
895
|
+
* rendering blank.
|
|
896
|
+
*/
|
|
897
|
+
function levelLabel(level, t) {
|
|
898
|
+
switch (level) {
|
|
899
|
+
case "low": return t("thinkingLevelLow");
|
|
900
|
+
case "medium": return t("thinkingLevelMedium");
|
|
901
|
+
case "high": return t("thinkingLevelHigh");
|
|
902
|
+
default: return level;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Localized label for an upstream quota window token.
|
|
907
|
+
*
|
|
908
|
+
* Upstream sends `5h` / `weekly` today. The tokens are mapped rather than
|
|
909
|
+
* printed so the panel follows the UI language, and an UNRECOGNIZED token falls
|
|
910
|
+
* back to its raw value: upstream may add a window, and showing `30d` is better
|
|
911
|
+
* than a blank or a wrong localized label.
|
|
912
|
+
*/
|
|
913
|
+
function windowLabel(window, t) {
|
|
914
|
+
switch (window) {
|
|
915
|
+
case "5h": return t("quotaWindow5h");
|
|
916
|
+
case "weekly": return t("quotaWindowWeekly");
|
|
917
|
+
default: return window;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* A wall-clock moment for a state label (a cooldown end).
|
|
922
|
+
*
|
|
923
|
+
* Time-of-day alone is enough while the wall is today; past midnight it must
|
|
924
|
+
* carry the date, or a 24h quota cooldown reads as though it ends in a few
|
|
925
|
+
* minutes. (`untilText` is the relative form, used where "how long from now" is
|
|
926
|
+
* the question rather than "when".)
|
|
927
|
+
*/
|
|
928
|
+
function clockTime(iso) {
|
|
929
|
+
if (iso === null) return "—";
|
|
930
|
+
const date = new Date(iso);
|
|
931
|
+
if (Number.isNaN(date.getTime())) return "—";
|
|
932
|
+
const now = /* @__PURE__ */ new Date();
|
|
933
|
+
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() ? date.toLocaleTimeString([], {
|
|
934
|
+
hour: "2-digit",
|
|
935
|
+
minute: "2-digit"
|
|
936
|
+
}) : date.toLocaleString([], {
|
|
937
|
+
month: "short",
|
|
938
|
+
day: "numeric",
|
|
939
|
+
hour: "2-digit",
|
|
940
|
+
minute: "2-digit"
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
const MINUTE_MS = 6e4;
|
|
944
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
945
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
946
|
+
/**
|
|
947
|
+
* Time until a future moment, as localized copy.
|
|
948
|
+
*
|
|
949
|
+
* A quota reset wall is often more than 24h out, so a bare `HH:mm` cannot say
|
|
950
|
+
* whether it means today or tomorrow — the failure this replaces. Bucket
|
|
951
|
+
* boundaries mirror the host's `relativeTime` (which is defined for past-dated
|
|
952
|
+
* rows and would need its arguments reversed to serve a future one, so the
|
|
953
|
+
* comparison is written out here instead); the words stay in this plugin's own
|
|
954
|
+
* dictionary, which is exactly the split that API intends.
|
|
955
|
+
*/
|
|
956
|
+
function untilText(iso, t, now) {
|
|
957
|
+
if (iso === null) return "—";
|
|
958
|
+
const at = new Date(iso).getTime();
|
|
959
|
+
if (Number.isNaN(at)) return "—";
|
|
960
|
+
const diff = at - now;
|
|
961
|
+
if (diff <= 0) return t("relNow");
|
|
962
|
+
return t("quotaResetIn", { value: diff < MINUTE_MS ? t("relNow") : diff < HOUR_MS ? t("relMinutes", { n: Math.floor(diff / MINUTE_MS) }) : diff < DAY_MS ? t("relHours", { n: Math.floor(diff / HOUR_MS) }) : diff < 30 * DAY_MS ? t("relDays", { n: Math.floor(diff / DAY_MS) }) : diff < 365 * DAY_MS ? t("relMonths", { n: Math.floor(diff / (30 * DAY_MS)) }) : t("relYears", { n: Math.floor(diff / (365 * DAY_MS)) }) });
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Localized label for a cooldown reason.
|
|
966
|
+
*
|
|
967
|
+
* Falls back to the raw token so a reason added on the host side still renders
|
|
968
|
+
* something legible rather than blank.
|
|
969
|
+
*/
|
|
970
|
+
function cooldownReasonLabel(reason, t) {
|
|
971
|
+
switch (reason) {
|
|
972
|
+
case "network-error": return t("cooldownReasonNetworkError");
|
|
973
|
+
case "quota-exhausted": return t("cooldownReasonQuotaExhausted");
|
|
974
|
+
case "validation-required": return t("cooldownReasonValidationRequired");
|
|
975
|
+
case "project-error": return t("cooldownReasonProjectError");
|
|
976
|
+
default: return reason;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* How long ago a past moment was (the mirror of `untilText`).
|
|
981
|
+
*
|
|
982
|
+
* Reuses the same `rel*` magnitudes so the two read consistently, but adds a
|
|
983
|
+
* direction suffix: a bare magnitude beside a cooldown could equally mean when
|
|
984
|
+
* it started or when it ends. The suffix and every magnitude come from the
|
|
985
|
+
* dictionary, so nothing here is language-specific.
|
|
986
|
+
*/
|
|
987
|
+
function agoText(iso, t, now) {
|
|
988
|
+
if (iso === null) return "—";
|
|
989
|
+
const at = new Date(iso).getTime();
|
|
990
|
+
if (Number.isNaN(at)) return "—";
|
|
991
|
+
const diff = now - at;
|
|
992
|
+
if (diff < MINUTE_MS) return t("relJustNow");
|
|
993
|
+
return t("relAgo", { value: diff < HOUR_MS ? t("relMinutes", { n: Math.floor(diff / MINUTE_MS) }) : diff < DAY_MS ? t("relHours", { n: Math.floor(diff / HOUR_MS) }) : diff < 30 * DAY_MS ? t("relDays", { n: Math.floor(diff / DAY_MS) }) : diff < 365 * DAY_MS ? t("relMonths", { n: Math.floor(diff / (30 * DAY_MS)) }) : t("relYears", { n: Math.floor(diff / (365 * DAY_MS)) }) });
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* A host-styled button.
|
|
997
|
+
*
|
|
998
|
+
* Delegates to the host's `Button` so focus rings, disabled states, size tiers
|
|
999
|
+
* and theming are the platform's rather than an imitation. `danger` has no
|
|
1000
|
+
* primitive equivalent, so it keeps the ghost family with a local class.
|
|
1001
|
+
*
|
|
1002
|
+
* Every click stops propagating. Account rows are themselves click targets
|
|
1003
|
+
* (selecting the row), so without this a row's "Delete"/"Verify"/"Activate"
|
|
1004
|
+
* button also selected that row — and for Delete the row indices then shifted
|
|
1005
|
+
* underneath a selection that was about to be acted on. Stopping unconditionally
|
|
1006
|
+
* is safe for buttons outside rows, where there is no ancestor handler.
|
|
1007
|
+
*/
|
|
1008
|
+
function button(label, onClick, options = {}) {
|
|
1009
|
+
return h(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1010
|
+
variant: options.variant === "danger" ? "outline" : options.variant ?? "outline",
|
|
1011
|
+
size: options.size === "sm" ? "sm" : "md",
|
|
1012
|
+
...options.disabled === true ? { disabled: true } : {},
|
|
1013
|
+
...options.title === void 0 ? {} : { title: options.title },
|
|
1014
|
+
...options.variant === "danger" ? { className: "agy-btn-danger" } : {},
|
|
1015
|
+
onClick: (event) => {
|
|
1016
|
+
event?.stopPropagation?.();
|
|
1017
|
+
onClick();
|
|
1018
|
+
}
|
|
1019
|
+
}, label);
|
|
1020
|
+
}
|
|
1021
|
+
/** Full-width quota row used by the (collapsible) model quota list. */
|
|
1022
|
+
/** Account state rendered with the host's state dot plus a tinted tag. */
|
|
1023
|
+
function stateBadge(state, label) {
|
|
1024
|
+
const dot = state === "active" ? "done" : state === "cooling" ? "warning" : "error";
|
|
1025
|
+
const tone = state === "active" ? "success" : state === "cooling" ? "warning" : "danger";
|
|
1026
|
+
return h("span", { className: "agy-state" }, h(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
1027
|
+
state: dot,
|
|
1028
|
+
size: 8
|
|
1029
|
+
}), h(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone }, label));
|
|
1030
|
+
}
|
|
1031
|
+
function subhead(title, aside) {
|
|
1032
|
+
return h("div", { className: "agy-subhead" }, h("span", null, title), aside === void 0 || aside === "" ? null : h("span", { className: "agy-aside" }, aside));
|
|
1033
|
+
}
|
|
1034
|
+
function hint(text) {
|
|
1035
|
+
return h("p", { className: "agy-hint" }, text);
|
|
1036
|
+
}
|
|
1037
|
+
function table(headers, rows) {
|
|
1038
|
+
return h("table", { className: "agy-table" }, headers === null ? null : h("thead", null, headers), h("tbody", null, ...rows));
|
|
1039
|
+
}
|
|
1040
|
+
/** A titled grouping surface. Grouping is what keeps a dense page readable. */
|
|
1041
|
+
function card(title, body, aside) {
|
|
1042
|
+
return h("section", { className: "agy-card" }, h("div", { className: "agy-card-head" }, h("span", { className: "agy-card-title" }, title), aside === void 0 ? null : h("span", { className: "agy-aside" }, aside)), h("div", { className: "agy-card-body" }, body));
|
|
1043
|
+
}
|
|
1044
|
+
/** A metric strip inside a card. */
|
|
1045
|
+
function metrics(cells) {
|
|
1046
|
+
return h("div", { className: "agy-metrics" }, ...cells);
|
|
1047
|
+
}
|
|
1048
|
+
/** One metric cell: a label, a large value, and an optional detail line. */
|
|
1049
|
+
function metric(label, value, detail) {
|
|
1050
|
+
const text = typeof value === "number" ? tokenText(value) : value;
|
|
1051
|
+
const match = /^([\d.]+)([MK]?)$/.exec(text);
|
|
1052
|
+
return h("div", { className: "agy-metric" }, h("div", { className: "agy-metric-k" }, label), h("div", { className: "agy-metric-v" }, match === null ? text : match[1], match !== null && match[2] !== "" ? h("small", null, match[2]) : null), h("div", { className: "agy-metric-d" }, detail));
|
|
1053
|
+
}
|
|
1054
|
+
/** Definition rows: label/value pairs with hairline separators. */
|
|
1055
|
+
function defs(rows) {
|
|
1056
|
+
return h("dl", { className: "agy-defs" }, ...rows.flatMap(([label, value], index) => [h("dt", { key: `k${index}` }, label), h("dd", { key: `v${index}` }, value)]));
|
|
1057
|
+
}
|
|
1058
|
+
/** One account's detail: identity, cumulative usage, proxy, and its actions. */
|
|
1059
|
+
function AccountDetail(props) {
|
|
1060
|
+
const { account, busy, handlers, t } = props;
|
|
1061
|
+
const [proxyDraft, setProxyDraft] = (0, react.useState)("");
|
|
1062
|
+
const usage = account.usage;
|
|
1063
|
+
const now = Date.now();
|
|
1064
|
+
const identityRows = [
|
|
1065
|
+
[t("fieldProject"), account.projectId ?? t("noProject")],
|
|
1066
|
+
[t("fieldProxy"), h("span", { className: "agy-mono" }, account.proxy ?? t("proxyDirect"))],
|
|
1067
|
+
[t("fieldFingerprint"), account.fingerprint === null ? t("fingerprintNone") : t("fingerprintRegenerated", {
|
|
1068
|
+
count: account.fingerprintHistory,
|
|
1069
|
+
date: new Date(account.fingerprint.createdAt).toLocaleDateString()
|
|
1070
|
+
})],
|
|
1071
|
+
[t("fieldCooldownReason"), account.cooldownReason === null ? t("noProject") : `${cooldownReasonLabel(account.cooldownReason, t)} · ${agoText(account.cooldownSetAt, t, now)}`],
|
|
1072
|
+
[t("fieldSources"), usage === null ? t("noProject") : t("sourcesSummary", {
|
|
1073
|
+
chat: usage.sources.chat,
|
|
1074
|
+
cli: usage.sources.cli,
|
|
1075
|
+
verify: usage.sources.verify,
|
|
1076
|
+
test: usage.sources.test
|
|
1077
|
+
})],
|
|
1078
|
+
[t("fieldLatency"), usage === null ? t("noProject") : `${t("latencyAverage", { value: formatDuration(average(usage.totals.latencyMs, usage.totals.latencyN)) })} · ${t("latencyTtft", { value: formatDuration(average(usage.totals.ttftMs, usage.totals.ttftN)) })}`]
|
|
1079
|
+
];
|
|
1080
|
+
if (account.verificationRequired) identityRows.push([t("fieldVerification"), account.verificationUrl === null ? t("verificationNoUrl") : h("a", {
|
|
1081
|
+
className: "agy-link",
|
|
1082
|
+
href: account.verificationUrl,
|
|
15
1083
|
target: "_blank",
|
|
16
|
-
rel: "noreferrer"
|
|
17
|
-
}, "
|
|
1084
|
+
rel: "noreferrer noopener"
|
|
1085
|
+
}, t("verificationOpen"))]);
|
|
1086
|
+
const identity = card(t("detailTitle"), defs(identityRows), account.email ?? `#${account.index}`);
|
|
1087
|
+
const actions = card(t("colActions"), h("div", { className: "agy-actions" }, button(t("actionTest"), () => {
|
|
1088
|
+
handlers.onTest(account.index);
|
|
1089
|
+
}, { disabled: busy }), button(t("actionExport"), () => {
|
|
1090
|
+
handlers.onExport(account.index);
|
|
1091
|
+
}, { disabled: busy }), button(t("actionRegenerateFingerprint"), () => {
|
|
1092
|
+
handlers.onRegenerateFingerprint(account.index);
|
|
1093
|
+
}, { disabled: busy })));
|
|
1094
|
+
/**
|
|
1095
|
+
* The 5-hour / weekly windows, placed ABOVE the cumulative usage card.
|
|
1096
|
+
*
|
|
1097
|
+
* Ordering is deliberate: these are the figures a user actually acts on
|
|
1098
|
+
* (the rolling budget still available), while cumulative usage is a
|
|
1099
|
+
* retrospective total that only grows. Putting the actionable number first is
|
|
1100
|
+
* the whole point of the panel.
|
|
1101
|
+
*
|
|
1102
|
+
* A window with no reported fraction renders its bar empty and its percentage
|
|
1103
|
+
* as an em dash — "unknown" must not look like "0% left". A null `limits` means
|
|
1104
|
+
* the account has not been measured YET, and says so rather than showing an
|
|
1105
|
+
* empty card. That is reachable at any pool size: `refreshLimits` runs for a
|
|
1106
|
+
* solo account too (unlike the scheduling quota refresh, which a pool of one
|
|
1107
|
+
* skips because measuring it could block the only account).
|
|
1108
|
+
*/
|
|
1109
|
+
const limitsBlock = card(t("limitsTitle"), account.limits === null || account.limits.length === 0 ? h("div", { className: "agy-empty" }, t("limitsUnavailable")) : h("div", { className: "agy-limits" }, account.limitsUpdatedAt === null ? null : h("div", { className: "agy-limit-age" }, t("limitsMeasured", { ago: agoText(new Date(account.limitsUpdatedAt).toISOString(), t, now) })), ...account.limits.map((group) => h("div", {
|
|
1110
|
+
className: "agy-limit-group",
|
|
1111
|
+
key: group.name
|
|
1112
|
+
}, h("div", { className: "agy-limit-group-name" }, group.name), ...group.windows.map((window) => {
|
|
1113
|
+
const fraction = window.remainingFraction;
|
|
1114
|
+
return h("div", {
|
|
1115
|
+
className: "agy-limit-row",
|
|
1116
|
+
key: window.bucketId
|
|
1117
|
+
}, h("span", { className: "agy-limit-k" }, windowLabel(window.window, t)), h("span", { className: "agy-limit-track" }, fraction === null ? null : h("i", { style: {
|
|
1118
|
+
width: `${Math.round(fraction * 100)}%`,
|
|
1119
|
+
background: quotaColor(fraction)
|
|
1120
|
+
} })), h("span", { className: "agy-limit-p" }, fraction === null ? t("valueUnknown") : `${Math.round(fraction * 100)}%`), h("span", { className: "agy-limit-reset" }, window.resetTime === null ? null : untilText(window.resetTime, t, now)));
|
|
1121
|
+
})))));
|
|
1122
|
+
const usageBlock = usage === null ? null : card(t("usageCumulative"), metrics([
|
|
1123
|
+
metric(t("kpiInput"), promptTokens(usage.totals), t("kpiInputMissed", { tokens: tokenText(usage.totals.input) })),
|
|
1124
|
+
metric(t("kpiOutput"), usage.totals.output, t("kpiOutputDetail")),
|
|
1125
|
+
metric(t("kpiCacheRead"), usage.totals.cacheRead, t("kpiCacheHit", { percent: cacheHitPercent(usage.totals) ?? 0 })),
|
|
1126
|
+
metric(t("kpiRequests"), String(usage.totals.requests), t("kpiRequestsDetail", {
|
|
1127
|
+
succeeded: usage.totals.succeeded,
|
|
1128
|
+
failed: usage.totals.failed
|
|
1129
|
+
}))
|
|
1130
|
+
]));
|
|
1131
|
+
const saveProxy = () => {
|
|
1132
|
+
const value = proxyDraft.trim();
|
|
1133
|
+
if (value === "") return;
|
|
1134
|
+
handlers.onSetProxy(account.index, value);
|
|
1135
|
+
setProxyDraft("");
|
|
1136
|
+
};
|
|
1137
|
+
return h("div", { className: "agy-detail" }, identity, actions, limitsBlock, usageBlock, card(t("fieldProxy"), h("div", { className: "agy-actions" }, h(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
1138
|
+
value: proxyDraft,
|
|
1139
|
+
placeholder: t("proxyPlaceholder"),
|
|
1140
|
+
onChange: (event) => {
|
|
1141
|
+
setProxyDraft(event.target.value);
|
|
1142
|
+
}
|
|
1143
|
+
}), button(t("actionSave"), saveProxy, { disabled: busy || proxyDraft.trim() === "" }), button(t("actionClear"), () => {
|
|
1144
|
+
handlers.onSetProxy(account.index, "");
|
|
1145
|
+
setProxyDraft("");
|
|
1146
|
+
}, { disabled: busy || account.proxy === null }), (() => {
|
|
1147
|
+
const noTarget = proxyDraft.trim() === "" && account.proxy === null;
|
|
1148
|
+
return button(t("actionTestProxy"), () => {
|
|
1149
|
+
handlers.onTestProxy(account.index, proxyDraft.trim());
|
|
1150
|
+
}, {
|
|
1151
|
+
disabled: busy || noTarget,
|
|
1152
|
+
...noTarget ? { title: t("proxyTestNoTarget") } : {}
|
|
1153
|
+
});
|
|
1154
|
+
})())));
|
|
1155
|
+
}
|
|
1156
|
+
function AccountsTab(props) {
|
|
1157
|
+
const { accounts, busy, handlers, t } = props;
|
|
1158
|
+
const [selected, setSelected] = (0, react.useState)(0);
|
|
1159
|
+
if (accounts.length === 0) return card(t("colAccount"), h("div", { className: "agy-empty" }, t("emptyAccounts")));
|
|
1160
|
+
const index = Math.min(selected, accounts.length - 1);
|
|
1161
|
+
const current = accounts[index];
|
|
1162
|
+
const rows = accounts.map((account, at) => h("div", {
|
|
1163
|
+
key: String(account.index),
|
|
1164
|
+
className: "agy-rowitem",
|
|
1165
|
+
"data-clickable": "true",
|
|
1166
|
+
"data-selected": at === index,
|
|
1167
|
+
role: "button",
|
|
1168
|
+
tabIndex: 0,
|
|
1169
|
+
"aria-pressed": at === index,
|
|
1170
|
+
onClick: () => {
|
|
1171
|
+
setSelected(at);
|
|
1172
|
+
},
|
|
1173
|
+
onKeyDown: (event) => {
|
|
1174
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1175
|
+
event.preventDefault();
|
|
1176
|
+
setSelected(at);
|
|
1177
|
+
}
|
|
1178
|
+
}, h("div", { className: "agy-rowmain" }, h("div", { className: "agy-rowtitle" }, h("span", { className: "agy-rowname" }, account.email ?? `#${account.index}`), account.active ? h(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "info" }, t("currentAccount")) : null), h("div", { className: "agy-rowmeta" }, account.projectId ?? t("noProject"), account.usage === null || account.usage.totals.requests === 0 ? null : ` · ${t("colRequests")} ${account.usage.totals.requests}`)), h("div", { className: "agy-rowactions" }, stateBadge(account.state, account.state === "cooling" ? `${t("coolingUntil")} ${clockTime(account.cooldownUntil)}` : stateLabel(account.state, t)), account.active ? null : button(t("actionActivate"), () => {
|
|
1179
|
+
handlers.onActivate(account.index);
|
|
1180
|
+
}, {
|
|
1181
|
+
size: "sm",
|
|
1182
|
+
disabled: busy
|
|
1183
|
+
}), button(t("actionVerify"), () => {
|
|
1184
|
+
handlers.onVerify(account.index);
|
|
1185
|
+
}, {
|
|
1186
|
+
size: "sm",
|
|
1187
|
+
disabled: busy
|
|
1188
|
+
}), button(t("actionDelete"), () => {
|
|
1189
|
+
handlers.onDelete(account.index);
|
|
1190
|
+
}, {
|
|
1191
|
+
size: "sm",
|
|
1192
|
+
variant: "danger",
|
|
1193
|
+
disabled: busy
|
|
1194
|
+
}))));
|
|
1195
|
+
return h("div", { className: "agy-root" }, h("div", { className: "agy-split-wrap" }, h("div", { className: "agy-split" }, card(t("colAccount"), h("div", { className: "agy-rows" }, ...rows), `${accounts.length}`), current === void 0 ? null : h(AccountDetail, {
|
|
1196
|
+
key: String(current.index),
|
|
1197
|
+
account: current,
|
|
1198
|
+
busy,
|
|
1199
|
+
handlers,
|
|
1200
|
+
t
|
|
1201
|
+
}))));
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Models tab: per-model visibility switches, plus the account's model quota in a
|
|
1205
|
+
* collapsible block.
|
|
1206
|
+
*
|
|
1207
|
+
* Quota lives here rather than in the account list because both answer the same
|
|
1208
|
+
* question — "which models can I use, and how much is left" — and the quota list
|
|
1209
|
+
* is long (20+ rows), so it must not push the account list off screen. The
|
|
1210
|
+
* disclosure keeps it one click away without spending the space by default.
|
|
1211
|
+
*/
|
|
1212
|
+
/**
|
|
1213
|
+
* Order models for display: enabled first, disabled sunk to the bottom.
|
|
1214
|
+
*
|
|
1215
|
+
* Exported for a direct unit test — the rule is pure and worth pinning, and the
|
|
1216
|
+
* client test harness does not render. Stable by construction: `Array.sort` is
|
|
1217
|
+
* stable per spec, so the host's own order survives inside each group. That is
|
|
1218
|
+
* what makes a toggle move exactly one row instead of reshuffling the list.
|
|
1219
|
+
* @param models - the host's list, in host order.
|
|
1220
|
+
* @returns a new array, enabled models first.
|
|
1221
|
+
*/
|
|
1222
|
+
function orderModels(models) {
|
|
1223
|
+
return [...models].sort((a, b) => Number(a.disabled) - Number(b.disabled));
|
|
1224
|
+
}
|
|
1225
|
+
function ModelsTab(props) {
|
|
1226
|
+
const { models, account, pending, testing, onToggle, onTestModel, rpc, t } = props;
|
|
1227
|
+
const ordered = (0, react.useMemo)(() => orderModels(models), [models]);
|
|
1228
|
+
if (models.length === 0) return card(t("modelsTitle"), h("div", { className: "agy-empty" }, t("emptyModels")));
|
|
1229
|
+
const hidden = models.filter((model) => model.disabled).length;
|
|
1230
|
+
const rows = ordered.map((model) => h("div", {
|
|
1231
|
+
className: "agy-rowitem",
|
|
1232
|
+
key: model.id
|
|
1233
|
+
}, h("div", { className: "agy-rowmain" }, h("div", { className: "agy-rowtitle" }, h("span", { className: "agy-rowname" }, model.name)), model.name === model.id ? null : h("div", { className: "agy-rowmeta agy-mono" }, model.id)), h("div", { className: "agy-rowactions" }, button(testing.has(model.id) ? t("modelTesting") : t("actionTestModel"), () => {
|
|
1234
|
+
onTestModel(model.id);
|
|
1235
|
+
}, {
|
|
1236
|
+
size: "sm",
|
|
1237
|
+
variant: "ghost",
|
|
1238
|
+
disabled: testing.has(model.id)
|
|
1239
|
+
}), h(_deepseek_ai_dsh_client_ui_primitives.Switch, {
|
|
1240
|
+
checked: !model.disabled,
|
|
1241
|
+
disabled: pending.has(model.id),
|
|
1242
|
+
label: t("modelToggleAria", { name: model.name }),
|
|
1243
|
+
onChange: () => {
|
|
1244
|
+
onToggle(model.id, !model.disabled);
|
|
1245
|
+
}
|
|
1246
|
+
}))));
|
|
1247
|
+
return h("div", { className: "agy-root" }, card(t("modelsTitle"), h("div", { className: "agy-rows" }, ...rows), hidden > 0 ? t("modelsHiddenSuffix", { count: hidden }) : account ?? void 0), hint(t("modelsHelp")), h(ThinkingBudgetCard, {
|
|
1248
|
+
rpc,
|
|
1249
|
+
t
|
|
1250
|
+
}));
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* The global reasoning-level token budgets.
|
|
1254
|
+
*
|
|
1255
|
+
* One row per level rather than per model: only `*-tiered` models send a
|
|
1256
|
+
* `thinkingConfig` at all, and the level itself is already chosen in DSH's model
|
|
1257
|
+
* selector. So this supplies the missing VALUE behind each level — the same three
|
|
1258
|
+
* numbers for every such model.
|
|
1259
|
+
*
|
|
1260
|
+
* An EMPTY input is the meaningful default: the request then sends
|
|
1261
|
+
* `thinkingLevel` and lets upstream pick, which is exactly the behaviour before
|
|
1262
|
+
* this setting existed. That is why the field is not a `number` input with a
|
|
1263
|
+
* zero fallback, and why clearing it is a real action rather than "set to 0"
|
|
1264
|
+
* (measured: `0` reduces thinking but does not reliably disable it).
|
|
1265
|
+
*/
|
|
1266
|
+
/**
|
|
1267
|
+
* One budget row: label, input, and optional shortcut chips.
|
|
1268
|
+
*
|
|
1269
|
+
* There is deliberately NO trailing text column. An earlier version put the wire
|
|
1270
|
+
* form there ("sends thinkingLevel: high"), which was wrong twice over: on the
|
|
1271
|
+
* "High" row it restated that row's own label, and `thinkingLevel` /
|
|
1272
|
+
* `thinkingBudget` are identifiers for us rather than words a user needs. The
|
|
1273
|
+
* input plus its label carry all the information the row has.
|
|
1274
|
+
*
|
|
1275
|
+
* `chips` are shortcuts, not a second control: they only fill the input, and the
|
|
1276
|
+
* empty one is the meaningful default (upstream allocates).
|
|
1277
|
+
*/
|
|
1278
|
+
function thinkingRow(id, label, value, t, handlers) {
|
|
1279
|
+
return h("div", {
|
|
1280
|
+
className: "agy-thinking-row",
|
|
1281
|
+
key: id
|
|
1282
|
+
}, h("span", { className: "agy-thinking-k" }, label), h(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
1283
|
+
value,
|
|
1284
|
+
placeholder: t("thinkingAuto"),
|
|
1285
|
+
inputMode: "numeric",
|
|
1286
|
+
onChange: (event) => {
|
|
1287
|
+
handlers.onInput(event.target.value);
|
|
1288
|
+
},
|
|
1289
|
+
onBlur: (event) => {
|
|
1290
|
+
handlers.onCommit(event.target.value);
|
|
1291
|
+
}
|
|
1292
|
+
}), handlers.chips === void 0 ? null : h("span", { className: "agy-thinking-chips" }, ...handlers.chips.map((chip) => h("button", {
|
|
1293
|
+
key: chip.label,
|
|
1294
|
+
type: "button",
|
|
1295
|
+
className: "agy-thinking-chip",
|
|
1296
|
+
onClick: () => {
|
|
1297
|
+
handlers.onInput(chip.value);
|
|
1298
|
+
handlers.onCommit(chip.value);
|
|
1299
|
+
}
|
|
1300
|
+
}, chip.label))));
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Measured thinking-token samples, one row per effort THE PICKER ACTUALLY OFFERS.
|
|
1304
|
+
*
|
|
1305
|
+
* The rows are exactly the picker's entries — Default, Low, Medium, High — and
|
|
1306
|
+
* nothing else. `Max` is deliberately absent: it was a chip that filled the input
|
|
1307
|
+
* with the maximum, and measurement showed it buys nothing. At the model's real
|
|
1308
|
+
* ceiling (65536) `high` reaches ~63k, i.e. 96% of it, so there is no higher
|
|
1309
|
+
* ceiling for a bigger number to unlock.
|
|
1310
|
+
*
|
|
1311
|
+
* WHY A TABLE AND NOT A SENTENCE. These numbers only mean anything compared
|
|
1312
|
+
* DOWN a column: `High` spends ~165 tokens on `17*23` and ~63,000 on a hard
|
|
1313
|
+
* derivation. A sentence listing both is unreadable, and a single number per tier
|
|
1314
|
+
* reads as a fixed value when the real behaviour is a range.
|
|
1315
|
+
*
|
|
1316
|
+
* MEASUREMENT TRAP, recorded because it wasted a full investigation: thinking and
|
|
1317
|
+
* output SHARE `maxOutputTokens` (measured exactly — cap 2048 gave 1962 thoughts
|
|
1318
|
+
* + 82 output = 2044). A harness that pins the cap below the thinking demand
|
|
1319
|
+
* measures its own pin. Six runs at cap 60000 all reported ~24k regardless of
|
|
1320
|
+
* level and looked like "the tiers are equivalent"; at cap 65536 they separate.
|
|
1321
|
+
* The hard column below was re-measured at the ceiling. `pnpm run
|
|
1322
|
+
* verify:thinking-levels` re-derives all of it.
|
|
1323
|
+
*
|
|
1324
|
+
* A `0` is a MEASURED ZERO, not a placeholder: on the medium prompt `Low`
|
|
1325
|
+
* returned no `thoughtsTokenCount` field at all, and `total - output` equalled
|
|
1326
|
+
* the prompt size in all 5 runs — upstream really spent no thinking tokens. It is
|
|
1327
|
+
* printed as `0` rather than `-` so the row does not look like missing data.
|
|
1328
|
+
*
|
|
1329
|
+
* Every value is the mean of 2-11 live samples, rounded. `Default`'s hard cell
|
|
1330
|
+
* carried only 2 samples (34k and 57k, 51% apart) until four more were taken:
|
|
1331
|
+
* 6 samples now give ~48,700 at sd ~8,800. The spread is why these are labelled
|
|
1332
|
+
* samples and not a specification.
|
|
1333
|
+
*/
|
|
1334
|
+
const THINKING_SAMPLES = [
|
|
1335
|
+
{
|
|
1336
|
+
level: "Default",
|
|
1337
|
+
easy: 135,
|
|
1338
|
+
medium: 1100,
|
|
1339
|
+
hard: 48700
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
level: "Low",
|
|
1343
|
+
easy: 50,
|
|
1344
|
+
medium: 0,
|
|
1345
|
+
hard: 9e3
|
|
1346
|
+
},
|
|
1347
|
+
{
|
|
1348
|
+
level: "Medium",
|
|
1349
|
+
easy: 150,
|
|
1350
|
+
medium: 870,
|
|
1351
|
+
hard: 60400
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
level: "High",
|
|
1355
|
+
easy: 165,
|
|
1356
|
+
medium: 1855,
|
|
1357
|
+
hard: 63400
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
level: "max",
|
|
1361
|
+
easy: 185,
|
|
1362
|
+
medium: 2130,
|
|
1363
|
+
hard: 62900
|
|
1364
|
+
}
|
|
1365
|
+
];
|
|
1366
|
+
function thinkingSamples(t) {
|
|
1367
|
+
const [show, setShow] = (0, react.useState)(false);
|
|
1368
|
+
const cell = (value) => h("td", { className: "agy-num" }, value === null ? "-" : value === 0 ? "0" : `~${value.toLocaleString()}`);
|
|
1369
|
+
return h("div", {
|
|
1370
|
+
className: "agy-disclosure",
|
|
1371
|
+
"data-open": show
|
|
1372
|
+
}, h("button", {
|
|
1373
|
+
type: "button",
|
|
1374
|
+
className: "agy-disclosure-toggle",
|
|
1375
|
+
"aria-expanded": show,
|
|
1376
|
+
onClick: () => {
|
|
1377
|
+
setShow(!show);
|
|
1378
|
+
}
|
|
1379
|
+
}, h("span", { className: "agy-caret" }), h("span", null, t("thinkingSamplesTitle"))), show ? h("div", { className: "agy-disclosure-body" }, h("p", { className: "agy-hint" }, t("thinkingSamplesIntro")), h("p", { className: "agy-hint" }, t("thinkingSamplesCaption")), h("div", { className: "agy-table-wrap" }, table(h("tr", null, h("th", null, t("thinkingSamplesLevel")), h("th", { className: "agy-num" }, t("thinkingSamplesEasy")), h("th", { className: "agy-num" }, t("thinkingSamplesMedium")), h("th", { className: "agy-num" }, t("thinkingSamplesHard"))), THINKING_SAMPLES.map((row) => h("tr", { key: row.level }, h("td", { className: "agy-strong" }, row.level === "max" ? t("thinkingSamplesMaxRow") : row.level), cell(row.easy), cell(row.medium), cell(row.hard))))), h("p", { className: "agy-hint agy-table-note" }, t("thinkingSamplesComparison")), h("p", { className: "agy-hint agy-table-note" }, t("thinkingSamplesSources")), h("p", { className: "agy-hint agy-table-note" }, t("thinkingSamplesZero"))) : null);
|
|
1380
|
+
}
|
|
1381
|
+
function ThinkingBudgetCard(props) {
|
|
1382
|
+
const { rpc, t } = props;
|
|
1383
|
+
const [budgets, setBudgets] = (0, react.useState)({});
|
|
1384
|
+
const [tieredBudget, setTieredBudget] = (0, react.useState)(null);
|
|
1385
|
+
const [claudeBudget, setClaudeBudget] = (0, react.useState)(null);
|
|
1386
|
+
const [claudeDraft, setClaudeDraft] = (0, react.useState)("");
|
|
1387
|
+
const [drafts, setDrafts] = (0, react.useState)({});
|
|
1388
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
1389
|
+
const [error, setError] = (0, react.useState)(void 0);
|
|
1390
|
+
const [loaded, setLoaded] = (0, react.useState)(false);
|
|
1391
|
+
const alive = (0, react.useRef)(true);
|
|
1392
|
+
(0, react.useEffect)(() => {
|
|
1393
|
+
alive.current = true;
|
|
1394
|
+
return () => {
|
|
1395
|
+
alive.current = false;
|
|
1396
|
+
};
|
|
1397
|
+
}, []);
|
|
1398
|
+
const load = (0, react.useCallback)(() => {
|
|
1399
|
+
(async () => {
|
|
1400
|
+
try {
|
|
1401
|
+
const result = await rpc.call("thinking.get", {});
|
|
1402
|
+
if (!alive.current) return;
|
|
1403
|
+
setBudgets(result.budgets);
|
|
1404
|
+
setTieredBudget(result.tieredBudget);
|
|
1405
|
+
setDrafts((c) => ({
|
|
1406
|
+
...c,
|
|
1407
|
+
tiered: result.tieredBudget === null ? "" : String(result.tieredBudget)
|
|
1408
|
+
}));
|
|
1409
|
+
setClaudeBudget(result.claudeBudget);
|
|
1410
|
+
setClaudeDraft(result.claudeBudget === null ? "" : String(result.claudeBudget));
|
|
1411
|
+
setDrafts(Object.fromEntries(THINKING_LEVELS.map((level) => [level, result.budgets[level] === void 0 ? "" : String(result.budgets[level])])));
|
|
1412
|
+
setError(void 0);
|
|
1413
|
+
} catch (caught) {
|
|
1414
|
+
if (!alive.current) return;
|
|
1415
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1416
|
+
} finally {
|
|
1417
|
+
if (alive.current) setLoaded(true);
|
|
1418
|
+
}
|
|
1419
|
+
})();
|
|
1420
|
+
}, [rpc]);
|
|
1421
|
+
(0, react.useEffect)(() => {
|
|
1422
|
+
if (open && !loaded) load();
|
|
1423
|
+
}, [
|
|
1424
|
+
open,
|
|
1425
|
+
loaded,
|
|
1426
|
+
load
|
|
1427
|
+
]);
|
|
1428
|
+
const save = (level, raw) => {
|
|
1429
|
+
const trimmed = raw.trim();
|
|
1430
|
+
const value = trimmed === "" ? null : Number(trimmed);
|
|
1431
|
+
if (value !== null && !Number.isInteger(value)) {
|
|
1432
|
+
setError(t("thinkingInvalid"));
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
(async () => {
|
|
1436
|
+
try {
|
|
1437
|
+
const result = await rpc.call("thinking.set", {
|
|
1438
|
+
level,
|
|
1439
|
+
budget: value
|
|
1440
|
+
});
|
|
1441
|
+
if (!alive.current) return;
|
|
1442
|
+
setBudgets(result.budgets);
|
|
1443
|
+
setError(void 0);
|
|
1444
|
+
} catch (caught) {
|
|
1445
|
+
if (!alive.current) return;
|
|
1446
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1447
|
+
}
|
|
1448
|
+
})();
|
|
1449
|
+
};
|
|
1450
|
+
const saveTiered = (raw) => {
|
|
1451
|
+
const trimmed = raw.trim();
|
|
1452
|
+
const value = trimmed === "" ? null : Number(trimmed);
|
|
1453
|
+
if (value !== null && !Number.isInteger(value)) {
|
|
1454
|
+
setError(t("thinkingInvalid"));
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
(async () => {
|
|
1458
|
+
try {
|
|
1459
|
+
const result = await rpc.call("thinking.setTiered", { budget: value });
|
|
1460
|
+
if (!alive.current) return;
|
|
1461
|
+
setTieredBudget(result.tieredBudget);
|
|
1462
|
+
setError(void 0);
|
|
1463
|
+
} catch (caught) {
|
|
1464
|
+
if (!alive.current) return;
|
|
1465
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1466
|
+
}
|
|
1467
|
+
})();
|
|
1468
|
+
};
|
|
1469
|
+
const saveClaude = (raw) => {
|
|
1470
|
+
const trimmed = raw.trim();
|
|
1471
|
+
const value = trimmed === "" ? null : Number(trimmed);
|
|
1472
|
+
if (value !== null && !Number.isInteger(value)) {
|
|
1473
|
+
setError(t("thinkingInvalid"));
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
(async () => {
|
|
1477
|
+
try {
|
|
1478
|
+
const result = await rpc.call("thinking.setClaude", { budget: value });
|
|
1479
|
+
if (!alive.current) return;
|
|
1480
|
+
setClaudeBudget(result.claudeBudget);
|
|
1481
|
+
setError(void 0);
|
|
1482
|
+
} catch (caught) {
|
|
1483
|
+
if (!alive.current) return;
|
|
1484
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1485
|
+
}
|
|
1486
|
+
})();
|
|
1487
|
+
};
|
|
1488
|
+
const configured = THINKING_LEVELS.filter((level) => budgets[level] !== void 0).length + (tieredBudget === null ? 0 : 1);
|
|
1489
|
+
const block = h("div", {
|
|
1490
|
+
className: "agy-disclosure",
|
|
1491
|
+
"data-open": open
|
|
1492
|
+
}, h("button", {
|
|
1493
|
+
type: "button",
|
|
1494
|
+
className: "agy-disclosure-toggle",
|
|
1495
|
+
"aria-expanded": open,
|
|
1496
|
+
onClick: () => {
|
|
1497
|
+
setOpen(!open);
|
|
1498
|
+
}
|
|
1499
|
+
}, h("span", { className: "agy-caret" }), h("span", null, t("thinkingTitle")), h("span", { className: "agy-disclosure-meta" }, configured === 0 ? t("thinkingDefaultAll") : t("thinkingConfigured", { count: configured }))), open ? h("div", { className: "agy-disclosure-body" }, error === void 0 ? null : h("div", { className: "agy-error" }, error), h("div", { className: "agy-thinking-group" }, h("div", { className: "agy-thinking-group-name" }, t("thinkingGeminiGroup")), h("p", { className: "agy-hint" }, t("thinkingGeminiHint")), h("ul", { className: "agy-thinking-notes agy-thinking-effects" }, h("li", null, t("thinkingEffectLowUp")), h("li", null, t("thinkingEffectHighDown")), h("li", null, t("thinkingEffectMax")), h("li", null, t("thinkingEffectReset"))), thinkingRow("tiered", t("thinkingTieredLabel"), drafts.tiered ?? "", t, {
|
|
1500
|
+
onInput: (value) => {
|
|
1501
|
+
setDrafts((c) => ({
|
|
1502
|
+
...c,
|
|
1503
|
+
tiered: value
|
|
1504
|
+
}));
|
|
1505
|
+
},
|
|
1506
|
+
onCommit: (value) => {
|
|
1507
|
+
const stored = tieredBudget === null ? "" : String(tieredBudget);
|
|
1508
|
+
if (value.trim() !== stored) saveTiered(value);
|
|
1509
|
+
},
|
|
1510
|
+
chips: [{
|
|
1511
|
+
label: t("thinkingChipClear"),
|
|
1512
|
+
value: ""
|
|
1513
|
+
}]
|
|
1514
|
+
}), ...THINKING_LEVELS.map((level) => thinkingRow(level, levelLabel(level, t), drafts[level] ?? "", t, {
|
|
1515
|
+
onInput: (value) => {
|
|
1516
|
+
setDrafts((c) => ({
|
|
1517
|
+
...c,
|
|
1518
|
+
[level]: value
|
|
1519
|
+
}));
|
|
1520
|
+
},
|
|
1521
|
+
onCommit: (value) => {
|
|
1522
|
+
const stored = budgets[level] === void 0 ? "" : String(budgets[level]);
|
|
1523
|
+
if (value.trim() !== stored) save(level, value);
|
|
1524
|
+
}
|
|
1525
|
+
})), thinkingSamples(t)), h("div", { className: "agy-thinking-group" }, h("div", { className: "agy-thinking-group-name" }, t("thinkingClaudeGroup")), h("ul", { className: "agy-thinking-notes" }, h("li", null, t("thinkingClaudeNoLevels", {
|
|
1526
|
+
min: CLAUDE_BUDGET_MIN,
|
|
1527
|
+
max: CLAUDE_BUDGET_MAX
|
|
1528
|
+
})), h("li", null, t("thinkingClaudeMaxTokens")), h("li", null, t("thinkingClaudeNoReport"))), thinkingRow("claude", t("thinkingClaudeLabel"), claudeDraft, t, {
|
|
1529
|
+
onInput: (value) => {
|
|
1530
|
+
setClaudeDraft(value);
|
|
1531
|
+
},
|
|
1532
|
+
onCommit: (value) => {
|
|
1533
|
+
const stored = claudeBudget === null ? "" : String(claudeBudget);
|
|
1534
|
+
if (value.trim() !== stored) saveClaude(value);
|
|
1535
|
+
},
|
|
1536
|
+
chips: [{
|
|
1537
|
+
label: t("thinkingChipClear"),
|
|
1538
|
+
value: ""
|
|
1539
|
+
}]
|
|
1540
|
+
}))) : null);
|
|
1541
|
+
return card(t("thinkingTitle"), block);
|
|
1542
|
+
}
|
|
1543
|
+
const RANGE_IDS = [
|
|
1544
|
+
"today",
|
|
1545
|
+
"week",
|
|
1546
|
+
"month",
|
|
1547
|
+
"all"
|
|
1548
|
+
];
|
|
1549
|
+
/** Localized label for one range chip. */
|
|
1550
|
+
function rangeLabel(id, t) {
|
|
1551
|
+
switch (id) {
|
|
1552
|
+
case "today": return t("rangeToday");
|
|
1553
|
+
case "week": return t("rangeWeek");
|
|
1554
|
+
case "month": return t("rangeMonth");
|
|
1555
|
+
case "all": return t("rangeAll");
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Fixed widths for the numeric columns, shared by BOTH breakdown tables.
|
|
1560
|
+
*
|
|
1561
|
+
* They must be identical across the two tables. `table-layout: fixed` gives the
|
|
1562
|
+
* auto-width first column whatever is left, so two different width sums put the
|
|
1563
|
+
* numeric columns of the two tables at different x positions — the by-account
|
|
1564
|
+
* table's numbers sat 48px right of the by-model table's, which is what made a
|
|
1565
|
+
* long account row look like it was shoving the figures sideways. Sharing one
|
|
1566
|
+
* vector keeps every numeric column vertically aligned down the page.
|
|
1567
|
+
*
|
|
1568
|
+
* Last entry is wider because it carries the share bar (by model) as well as a
|
|
1569
|
+
* plain count (by account, "rotations").
|
|
1570
|
+
*/
|
|
1571
|
+
const NUM_COL_WIDTHS = [
|
|
1572
|
+
"48px",
|
|
1573
|
+
"56px",
|
|
1574
|
+
"56px",
|
|
1575
|
+
"56px",
|
|
1576
|
+
"64px"
|
|
1577
|
+
];
|
|
1578
|
+
/** One right-aligned numeric header cell at column position `index`. */
|
|
1579
|
+
function numHeader(index, label) {
|
|
1580
|
+
return h("th", {
|
|
1581
|
+
className: "agy-num",
|
|
1582
|
+
style: { width: NUM_COL_WIDTHS[index] }
|
|
1583
|
+
}, label);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* The Token composition bar rows: cache hits, input misses, output.
|
|
1587
|
+
*
|
|
1588
|
+
* A plain-prefix cache makes the cached share dominate (it re-reads the whole
|
|
1589
|
+
* prefix every turn), which is correct but reads as impossible without a
|
|
1590
|
+
* breakdown — hence this bar, which shows the proportion rather than restating
|
|
1591
|
+
* the numbers.
|
|
1592
|
+
*
|
|
1593
|
+
* The three rows must stay a PARTITION of `totalTokens` (they sum to 100%), so
|
|
1594
|
+
* the prompt side is split rather than labelled: `kpiCacheRead` (the hit count)
|
|
1595
|
+
* and `kpiInputMissed` (the rest). Using `kpiInput` for a row here would
|
|
1596
|
+
* double-count every cached token, because the headline input figure is now the
|
|
1597
|
+
* WHOLE prompt side — which already contains the hits. The split is what keeps
|
|
1598
|
+
* the bar honest and still adds up.
|
|
1599
|
+
*
|
|
1600
|
+
* `labelKey` and `id` are SEPARATE fields on purpose. A single `key` field used
|
|
1601
|
+
* for both the i18n lookup and React's `key` prop is how the label position ended
|
|
1602
|
+
* up rendering the raw dictionary key (`kpiCacheRead`) to every user: the value
|
|
1603
|
+
* served React correctly, so nothing failed, and the same variable was then
|
|
1604
|
+
* handed to the label span. Two names make that mix-up impossible.
|
|
1605
|
+
*/
|
|
1606
|
+
function tokenComposition(counters, t) {
|
|
1607
|
+
const total = totalTokens(counters);
|
|
1608
|
+
return h("div", { className: "agy-compose" }, ...[
|
|
1609
|
+
{
|
|
1610
|
+
id: "cacheRead",
|
|
1611
|
+
labelKey: "kpiCacheRead",
|
|
1612
|
+
value: counters.cacheRead,
|
|
1613
|
+
tone: "var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6)"
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
id: "missed",
|
|
1617
|
+
labelKey: "kpiInputMissedLabel",
|
|
1618
|
+
value: counters.input,
|
|
1619
|
+
tone: "var(--dsw-static-neutral-bluish-700, #8b8f96)"
|
|
1620
|
+
},
|
|
1621
|
+
{
|
|
1622
|
+
id: "output",
|
|
1623
|
+
labelKey: "kpiOutput",
|
|
1624
|
+
value: counters.output,
|
|
1625
|
+
tone: "var(--dsw-alias-state-success-primary, #22c55e)"
|
|
1626
|
+
}
|
|
1627
|
+
].map((row) => {
|
|
1628
|
+
const share = total > 0 ? row.value / total * 100 : 0;
|
|
1629
|
+
return h("div", {
|
|
1630
|
+
className: "agy-compose-row",
|
|
1631
|
+
key: row.id
|
|
1632
|
+
}, h("span", { className: "agy-compose-k" }, t(row.labelKey)), h("span", { className: "agy-compose-track" }, h("i", { style: {
|
|
1633
|
+
width: `${Math.max(share, row.value > 0 ? 1 : 0)}%`,
|
|
1634
|
+
background: row.tone
|
|
1635
|
+
} })), h("span", { className: "agy-compose-v" }, tokenText(row.value)), h("span", { className: "agy-compose-p" }, `${share.toFixed(1)}%`));
|
|
1636
|
+
}));
|
|
1637
|
+
}
|
|
1638
|
+
function UsageTab(props) {
|
|
1639
|
+
const { t } = props;
|
|
1640
|
+
const [range, setRange] = (0, react.useState)("today");
|
|
1641
|
+
const stats = props.stats;
|
|
1642
|
+
if (stats === null) return h("div", { className: "agy-empty" }, t("loading"));
|
|
1643
|
+
if (stats.all.counters.requests === 0) return card(t("usageTitle"), h("div", { className: "agy-empty" }, t("emptyUsage")));
|
|
1644
|
+
const view = range === "today" ? stats.today : range === "week" ? stats.week : range === "month" ? stats.month : stats.all;
|
|
1645
|
+
const counters = view.counters;
|
|
1646
|
+
const hit = cacheHitPercent(counters);
|
|
1647
|
+
const total = totalTokens(counters);
|
|
1648
|
+
return h("div", { className: "agy-root" }, h("div", { className: "agy-toolbar" }, h("div", { className: "agy-chips" }, ...RANGE_IDS.map((id) => h(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
1649
|
+
key: id,
|
|
1650
|
+
active: range === id,
|
|
1651
|
+
onClick: () => {
|
|
1652
|
+
setRange(id);
|
|
1653
|
+
}
|
|
1654
|
+
}, rangeLabel(id, t)))), h("span", { className: "agy-grow" }), stats.since === null ? null : h("span", { className: "agy-aside" }, t("since", { date: new Date(stats.since).toLocaleDateString() }))), card(t("usageTitle"), h("div", null, metrics([
|
|
1655
|
+
metric(t("kpiTotalTokens"), total, t("kpiTotalDetail", {
|
|
1656
|
+
requests: counters.requests,
|
|
1657
|
+
failed: counters.failed
|
|
1658
|
+
})),
|
|
1659
|
+
metric(t("kpiInput"), promptTokens(counters), t("kpiInputMissed", { tokens: tokenText(counters.input) })),
|
|
1660
|
+
metric(t("kpiCacheRead"), counters.cacheRead, hit === null ? t("kpiNoBilledInput") : t("kpiCacheHit", { percent: hit })),
|
|
1661
|
+
metric(t("kpiOutput"), counters.output, t("kpiOutputDetail"))
|
|
1662
|
+
]), tokenComposition(counters, t))), card(t("fieldLatency"), defs([
|
|
1663
|
+
[t("fieldCacheWrite"), tokenText(counters.cacheWrite)],
|
|
1664
|
+
[t("fieldRateLimitRotation"), `${counters.rateLimited} / ${counters.rotations}`],
|
|
1665
|
+
[t("labelLatencyAverage"), formatDuration(average(counters.latencyMs, counters.latencyN))],
|
|
1666
|
+
[t("labelTtft"), formatDuration(average(counters.ttftMs, counters.ttftN))]
|
|
1667
|
+
])), view.models.length === 0 ? null : card(t("byModel"), h("div", { className: "agy-table-wrap" }, table(h("tr", null, h("th", null, t("colModel")), numHeader(0, t("colRequests")), numHeader(1, t("kpiInput")), numHeader(2, t("kpiCacheRead")), numHeader(3, t("colOutput")), numHeader(4, t("colTokenShare"))), view.models.map((row) => h("tr", { key: row.model }, h("td", { className: "agy-strong" }, h("span", { className: "agy-mail" }, row.model)), h("td", { className: "agy-num" }, String(row.counters.requests)), h("td", { className: "agy-num" }, tokenText(promptTokens(row.counters))), h("td", { className: "agy-num" }, tokenText(row.counters.cacheRead)), h("td", { className: "agy-num" }, tokenText(row.counters.output)), h("td", { className: "agy-num" }, h("span", { className: "agy-bar" }, h("span", { className: "agy-track" }, h("i", { style: { width: `${Math.round(totalTokens(row.counters) / Math.max(1, total) * 100)}%` } }))))))))), view.accounts.length === 0 ? null : card(t("byAccount"), h("div", { className: "agy-table-wrap" }, table(h("tr", null, h("th", null, t("colAccount")), numHeader(0, t("colRequests")), numHeader(1, t("colToken")), numHeader(2, t("colFailed")), numHeader(3, t("colRateLimited")), numHeader(4, t("colRotations"))), view.accounts.map((row) => h("tr", { key: row.account }, h("td", { className: "agy-strong" }, h("span", { className: "agy-mail" }, row.account)), h("td", { className: "agy-num" }, String(row.counters.requests)), h("td", { className: "agy-num" }, tokenText(totalTokens(row.counters))), h("td", { className: "agy-num" }, String(row.counters.failed)), h("td", { className: "agy-num" }, String(row.counters.rateLimited)), h("td", { className: "agy-num" }, String(row.counters.rotations))))))));
|
|
1668
|
+
}
|
|
1669
|
+
function CredentialsTab(props) {
|
|
1670
|
+
const { t } = props;
|
|
1671
|
+
const [text, setText] = (0, react.useState)("");
|
|
1672
|
+
const sources = (0, react.useMemo)(() => text.split("\n").map((line) => line.trim()).filter((line) => line !== ""), [text]);
|
|
1673
|
+
const suffix = sources.length > 1 ? ` (${sources.length})` : "";
|
|
1674
|
+
return h("div", { className: "agy-root" }, subhead(t("importTitle")), hint(t("importHelp")), h("textarea", {
|
|
1675
|
+
className: "agy-textarea",
|
|
1676
|
+
style: { marginTop: "8px" },
|
|
1677
|
+
value: text,
|
|
1678
|
+
placeholder: t("importPlaceholder"),
|
|
1679
|
+
onChange: (event) => {
|
|
1680
|
+
setText(event.target.value);
|
|
1681
|
+
}
|
|
1682
|
+
}), h("div", {
|
|
1683
|
+
className: "agy-toolbar",
|
|
1684
|
+
style: { marginTop: "8px" }
|
|
1685
|
+
}, button(`${t("importJson")}${suffix}`, () => {
|
|
1686
|
+
props.onImport("json", sources);
|
|
1687
|
+
}, { disabled: props.busy || sources.length === 0 }), button(`${t("importBlob")}${suffix}`, () => {
|
|
1688
|
+
props.onImport("blob", sources);
|
|
1689
|
+
}, { disabled: props.busy || sources.length === 0 }), h("span", { className: "agy-grow" }), button(t("exportAll"), () => {
|
|
1690
|
+
props.onExportAll();
|
|
1691
|
+
}, { disabled: props.busy })));
|
|
1692
|
+
}
|
|
1693
|
+
/** The Settings section body. */
|
|
1694
|
+
function AgySettings(props) {
|
|
1695
|
+
const { rpc, t } = props;
|
|
1696
|
+
const [tab, setTab] = (0, react.useState)("accounts");
|
|
1697
|
+
const [accounts, setAccounts] = (0, react.useState)([]);
|
|
1698
|
+
const [models, setModels] = (0, react.useState)([]);
|
|
1699
|
+
const [modelAccount, setModelAccount] = (0, react.useState)(null);
|
|
1700
|
+
/** Model-discovery failure, shown on the Models tab only (see loadModels). */
|
|
1701
|
+
const [modelError, setModelError] = (0, react.useState)(void 0);
|
|
1702
|
+
/** Model ids whose own visibility write is in flight (see the toggle handler). */
|
|
1703
|
+
const [toggling, setToggling] = (0, react.useState)(() => /* @__PURE__ */ new Set());
|
|
1704
|
+
/** Model ids whose own test call is in flight (see the per-row test button). */
|
|
1705
|
+
const [modelTesting, setModelTesting] = (0, react.useState)(() => /* @__PURE__ */ new Set());
|
|
1706
|
+
const [stats, setStats] = (0, react.useState)(null);
|
|
1707
|
+
const [error, setError] = (0, react.useState)(void 0);
|
|
1708
|
+
/** A non-fatal outcome worth reporting (e.g. a partial credential import). */
|
|
1709
|
+
const [notice, setNoticeState] = (0, react.useState)(void 0);
|
|
1710
|
+
/**
|
|
1711
|
+
* The outcome of a one-shot action, timed like `notice`.
|
|
1712
|
+
*
|
|
1713
|
+
* A SEPARATE channel from `error` on purpose. `error` is also where a failed
|
|
1714
|
+
* `refresh()` lands, and "the account list could not be loaded" is a standing
|
|
1715
|
+
* condition: auto-dismissing it would leave a broken page looking fine 3.5s
|
|
1716
|
+
* later. Only an action's own verdict is transient, because the user already
|
|
1717
|
+
* knows what they clicked and the screen returns to a valid state either way.
|
|
1718
|
+
*/
|
|
1719
|
+
const [actionError, setActionErrorState] = (0, react.useState)(void 0);
|
|
1720
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
1721
|
+
const [loaded, setLoaded] = (0, react.useState)(false);
|
|
1722
|
+
/** Guards state updates after the section unmounts mid-request. */
|
|
1723
|
+
const alive = (0, react.useRef)(true);
|
|
1724
|
+
/**
|
|
1725
|
+
* Show a transient message, then clear it.
|
|
1726
|
+
*
|
|
1727
|
+
* A message that never clears is indistinguishable from a stuck UI: the model
|
|
1728
|
+
* test's "X is working." stayed on screen forever, through every later action.
|
|
1729
|
+
* The deleted dashboard's toasts auto-dismissed after 3.5s for the same
|
|
1730
|
+
* reason; this keeps that behaviour, and a newer message simply replaces the
|
|
1731
|
+
* pending timer.
|
|
1732
|
+
*
|
|
1733
|
+
* One factory rather than two near-identical setters: the timer bookkeeping is
|
|
1734
|
+
* the part that must not drift between the success and failure channels. It
|
|
1735
|
+
* takes the timer ref as an argument rather than creating one, because a
|
|
1736
|
+
* `useCallback(timedChannel(...))` argument is evaluated on EVERY render — the
|
|
1737
|
+
* discarded function would still have appended its timer to the cancel list.
|
|
1738
|
+
* @param timer - the ref owning this channel's pending dismissal.
|
|
1739
|
+
* @param set - the state setter this channel writes to.
|
|
1740
|
+
* @returns a setter that arms the dismissal timer.
|
|
1741
|
+
*/
|
|
1742
|
+
const timedChannel = (timer, set) => (text) => {
|
|
1743
|
+
if (timer.current !== void 0) clearTimeout(timer.current);
|
|
1744
|
+
timer.current = void 0;
|
|
1745
|
+
set(text);
|
|
1746
|
+
if (text === void 0) return;
|
|
1747
|
+
timer.current = setTimeout(() => {
|
|
1748
|
+
timer.current = void 0;
|
|
1749
|
+
if (alive.current) set(void 0);
|
|
1750
|
+
}, ACTION_MESSAGE_TTL_MS);
|
|
1751
|
+
};
|
|
1752
|
+
/** Pending dismissal timers, one per channel, cancelled together on unmount. */
|
|
1753
|
+
const noticeTimer = (0, react.useRef)(void 0);
|
|
1754
|
+
const actionErrorTimer = (0, react.useRef)(void 0);
|
|
1755
|
+
const setNotice = (0, react.useCallback)(timedChannel(noticeTimer, setNoticeState), []);
|
|
1756
|
+
const setActionError = (0, react.useCallback)(timedChannel(actionErrorTimer, setActionErrorState), []);
|
|
1757
|
+
(0, react.useEffect)(() => () => {
|
|
1758
|
+
if (noticeTimer.current !== void 0) clearTimeout(noticeTimer.current);
|
|
1759
|
+
if (actionErrorTimer.current !== void 0) clearTimeout(actionErrorTimer.current);
|
|
1760
|
+
}, []);
|
|
1761
|
+
(0, react.useEffect)(() => {
|
|
1762
|
+
alive.current = true;
|
|
1763
|
+
return () => {
|
|
1764
|
+
alive.current = false;
|
|
1765
|
+
};
|
|
1766
|
+
}, []);
|
|
1767
|
+
const refresh = (0, react.useCallback)(async () => {
|
|
1768
|
+
const [accountOutcome, statsOutcome] = await Promise.allSettled([rpc.call("account.list", {}), rpc.call("stats.get", {})]);
|
|
1769
|
+
if (!alive.current) return;
|
|
1770
|
+
if (accountOutcome.status === "fulfilled") setAccounts(accountOutcome.value.accounts);
|
|
1771
|
+
if (statsOutcome.status === "fulfilled") setStats(statsOutcome.value);
|
|
1772
|
+
const failed = [accountOutcome, statsOutcome].find((outcome) => outcome.status === "rejected");
|
|
1773
|
+
if (failed?.status === "rejected") {
|
|
1774
|
+
const caught = failed.reason;
|
|
1775
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1776
|
+
} else setError(void 0);
|
|
1777
|
+
if (alive.current) setLoaded(true);
|
|
1778
|
+
}, [rpc]);
|
|
1779
|
+
/**
|
|
1780
|
+
* Refresh the 5h/weekly windows and merge them into the account rows.
|
|
1781
|
+
*
|
|
1782
|
+
* A separate call from `account.list` because that reply is deliberately
|
|
1783
|
+
* probe-free — folding an upstream call into it once made the whole view wait
|
|
1784
|
+
* on the network. Here the rows render immediately from `account.list` and the
|
|
1785
|
+
* windows fill in when they arrive, so a slow probe costs a placeholder.
|
|
1786
|
+
*
|
|
1787
|
+
* It also works for a pool of ANY size: the scheduling quota refresh is skipped
|
|
1788
|
+
* for a single enabled account (measuring that one could block the only
|
|
1789
|
+
* account), so this endpoint is the only thing that fills `cachedLimits` there.
|
|
1790
|
+
*
|
|
1791
|
+
* @param force - re-probe inside the TTL. Only an EXPLICIT refresh does this;
|
|
1792
|
+
* an automatic one must respect the TTL, or every action that reloads the
|
|
1793
|
+
* page would spend an upstream quota call.
|
|
1794
|
+
* @param report - whether to surface the outcome. Set only when the user asked
|
|
1795
|
+
* for the refresh, since an automatic one must stay silent.
|
|
1796
|
+
*/
|
|
1797
|
+
const loadLimits = (0, react.useCallback)(async (force = false, report = false) => {
|
|
1798
|
+
try {
|
|
1799
|
+
const result = await rpc.call("account.limits", force ? { force: true } : {});
|
|
1800
|
+
if (!alive.current) return;
|
|
1801
|
+
const byIndex = new Map(result.limits.map((entry) => [entry.index, entry]));
|
|
1802
|
+
setAccounts((current) => current.map((account) => {
|
|
1803
|
+
const entry = byIndex.get(account.index);
|
|
1804
|
+
if (entry === void 0 || entry.groups === null) return account;
|
|
1805
|
+
return {
|
|
1806
|
+
...account,
|
|
1807
|
+
limits: entry.groups,
|
|
1808
|
+
limitsUpdatedAt: entry.updatedAt
|
|
1809
|
+
};
|
|
1810
|
+
}));
|
|
1811
|
+
if (report) {
|
|
1812
|
+
if (result.failed > 0) setActionError(t("limitsRefreshFailed", { failed: result.failed }));
|
|
1813
|
+
else if (result.measured > 0) setNotice(t("limitsRefreshOk", { measured: result.measured }));
|
|
1814
|
+
else setNotice(t("limitsRefreshFresh"));
|
|
1815
|
+
}
|
|
1816
|
+
} catch (caught) {
|
|
1817
|
+
if (report) setActionError(caught instanceof Error ? caught.message : String(caught));
|
|
1818
|
+
}
|
|
1819
|
+
}, [
|
|
1820
|
+
rpc,
|
|
1821
|
+
setActionError,
|
|
1822
|
+
setNotice,
|
|
1823
|
+
t
|
|
1824
|
+
]);
|
|
1825
|
+
(0, react.useEffect)(() => {
|
|
1826
|
+
refresh();
|
|
1827
|
+
}, [refresh]);
|
|
1828
|
+
(0, react.useEffect)(() => {
|
|
1829
|
+
if (tab === "accounts" && accounts.length > 0) loadLimits();
|
|
1830
|
+
}, [
|
|
1831
|
+
tab,
|
|
1832
|
+
accounts.length,
|
|
1833
|
+
loadLimits
|
|
1834
|
+
]);
|
|
1835
|
+
/**
|
|
1836
|
+
* The toolbar's Refresh: reload the page AND force the quota windows.
|
|
1837
|
+
*
|
|
1838
|
+
* The force is the whole point of a manual refresh — without it the click
|
|
1839
|
+
* could not deliver anything newer than what the 10-minute TTL already holds,
|
|
1840
|
+
* so "I want the latest numbers now" was unanswerable. The two halves are
|
|
1841
|
+
* deliberately not awaited together: `refresh()` is the fast local reload,
|
|
1842
|
+
* while the forced probe can take seconds, and the rows should not wait on it.
|
|
1843
|
+
*/
|
|
1844
|
+
const refreshAll = (0, react.useCallback)(() => {
|
|
1845
|
+
refresh();
|
|
1846
|
+
if (tab === "accounts") loadLimits(true, true);
|
|
1847
|
+
}, [
|
|
1848
|
+
loadLimits,
|
|
1849
|
+
refresh,
|
|
1850
|
+
tab
|
|
1851
|
+
]);
|
|
1852
|
+
/**
|
|
1853
|
+
* The model list loads separately: it is the one call that may reach upstream
|
|
1854
|
+
* (model discovery), so the page must render even when it is slow or fails.
|
|
1855
|
+
*
|
|
1856
|
+
* Its failure is kept out of the shared error banner. The list is loaded
|
|
1857
|
+
* eagerly (the tab badge needs it), and "no account configured yet" is a
|
|
1858
|
+
* perfectly ordinary startup state for an accounts-first page — putting that
|
|
1859
|
+
* in the banner would greet every new user with an error.
|
|
1860
|
+
*/
|
|
1861
|
+
const loadModels = (0, react.useCallback)(async () => {
|
|
1862
|
+
try {
|
|
1863
|
+
const result = await rpc.call("model.list", {});
|
|
1864
|
+
if (!alive.current) return;
|
|
1865
|
+
setModels(result.models);
|
|
1866
|
+
setModelAccount(result.account);
|
|
1867
|
+
setModelError(void 0);
|
|
1868
|
+
} catch (caught) {
|
|
1869
|
+
if (!alive.current) return;
|
|
1870
|
+
setModelError(caught instanceof Error ? caught.message : String(caught));
|
|
1871
|
+
}
|
|
1872
|
+
}, [rpc]);
|
|
1873
|
+
(0, react.useEffect)(() => {
|
|
1874
|
+
loadModels();
|
|
1875
|
+
}, [loadModels]);
|
|
1876
|
+
(0, react.useEffect)(() => {
|
|
1877
|
+
if (tab === "models" && models.length === 0) loadModels();
|
|
1878
|
+
}, [
|
|
1879
|
+
tab,
|
|
1880
|
+
models.length,
|
|
1881
|
+
loadModels
|
|
1882
|
+
]);
|
|
1883
|
+
/** Run one mutating call, then reload; failures land in the banner. */
|
|
1884
|
+
const act = (0, react.useCallback)(async (run) => {
|
|
1885
|
+
setBusy(true);
|
|
1886
|
+
setNotice(void 0);
|
|
1887
|
+
try {
|
|
1888
|
+
await run();
|
|
1889
|
+
await refresh();
|
|
1890
|
+
setError(void 0);
|
|
1891
|
+
} catch (caught) {
|
|
1892
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1893
|
+
} finally {
|
|
1894
|
+
if (alive.current) setBusy(false);
|
|
1895
|
+
}
|
|
1896
|
+
}, [refresh]);
|
|
1897
|
+
const startLogin = (0, react.useCallback)(() => {
|
|
1898
|
+
setBusy(true);
|
|
1899
|
+
rpc.call("auth.url", {}).then((result) => {
|
|
1900
|
+
window.open(result.url, "agy-oauth", "width=520,height=680");
|
|
1901
|
+
setError(void 0);
|
|
1902
|
+
}).catch((caught) => {
|
|
1903
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1904
|
+
}).finally(() => {
|
|
1905
|
+
if (alive.current) setBusy(false);
|
|
1906
|
+
});
|
|
1907
|
+
}, [rpc]);
|
|
1908
|
+
(0, react.useEffect)(() => {
|
|
1909
|
+
const onMessage = (event) => {
|
|
1910
|
+
if (event.origin !== window.location.origin) return;
|
|
1911
|
+
if (event.data?.type === "agy_login_success") refresh();
|
|
1912
|
+
};
|
|
1913
|
+
window.addEventListener("message", onMessage);
|
|
1914
|
+
return () => {
|
|
1915
|
+
window.removeEventListener("message", onMessage);
|
|
1916
|
+
};
|
|
1917
|
+
}, [refresh]);
|
|
1918
|
+
const copyText = (0, react.useCallback)(async (text) => {
|
|
1919
|
+
await navigator.clipboard?.writeText(text);
|
|
1920
|
+
}, []);
|
|
1921
|
+
/**
|
|
1922
|
+
* Run one one-shot action under the shared busy / clear / report envelope.
|
|
1923
|
+
*
|
|
1924
|
+
* Deliberately NOT `act()`. `act()` reads only a REJECTION and then calls
|
|
1925
|
+
* `refresh()`, which clears the banner — so a handler whose failure arrives
|
|
1926
|
+
* IN BAND (`{ ok: false }`, which is how `account.verify`, `account.test` and
|
|
1927
|
+
* `account.proxyTest` all report) had its verdict both discarded and erased:
|
|
1928
|
+
* the click fired, the host answered, and the panel showed nothing. The
|
|
1929
|
+
* reload is also not always wanted; each caller decides.
|
|
1930
|
+
*
|
|
1931
|
+
* A thrown error still lands here, so a request-level failure (a rejected
|
|
1932
|
+
* promise, e.g. "no proxy configured") reports through the same channel.
|
|
1933
|
+
* @param run - the action, which reports its own in-band verdict.
|
|
1934
|
+
*/
|
|
1935
|
+
const runAction = (0, react.useCallback)(async (run) => {
|
|
1936
|
+
setBusy(true);
|
|
1937
|
+
setNotice(void 0);
|
|
1938
|
+
setActionError(void 0);
|
|
1939
|
+
try {
|
|
1940
|
+
await run();
|
|
1941
|
+
} catch (caught) {
|
|
1942
|
+
if (alive.current) setActionError(caught instanceof Error ? caught.message : String(caught));
|
|
1943
|
+
} finally {
|
|
1944
|
+
if (alive.current) setBusy(false);
|
|
1945
|
+
}
|
|
1946
|
+
}, [setActionError, setNotice]);
|
|
1947
|
+
const handlers = (0, react.useMemo)(() => ({
|
|
1948
|
+
t,
|
|
1949
|
+
onActivate: (index) => {
|
|
1950
|
+
act(() => rpc.call("account.activate", { index }));
|
|
1951
|
+
},
|
|
1952
|
+
onVerify: (index) => {
|
|
1953
|
+
runAction(async () => {
|
|
1954
|
+
const result = await rpc.call("account.verify", { index });
|
|
1955
|
+
if (alive.current) await refresh();
|
|
1956
|
+
if (!alive.current) return;
|
|
1957
|
+
if (result.ok) setNotice(t("verifyOk", { email: result.email ?? `#${index}` }));
|
|
1958
|
+
else setActionError(t("verifyFail") + (result.error === void 0 ? "" : `\n${result.error}`));
|
|
1959
|
+
});
|
|
1960
|
+
},
|
|
1961
|
+
onDelete: (index) => {
|
|
1962
|
+
if (!window.confirm(t("confirmDelete"))) return;
|
|
1963
|
+
act(() => rpc.call("account.delete", { index }));
|
|
1964
|
+
},
|
|
1965
|
+
onTest: (index) => {
|
|
1966
|
+
runAction(async () => {
|
|
1967
|
+
const target = (models.length > 0 ? models : (await rpc.call("model.list", {})).models).find((entry) => !entry.disabled)?.id;
|
|
1968
|
+
if (target === void 0) throw new Error(t("noModelToTest"));
|
|
1969
|
+
const result = await rpc.call("account.test", {
|
|
1970
|
+
model: target,
|
|
1971
|
+
index
|
|
1972
|
+
});
|
|
1973
|
+
if (alive.current) await refresh();
|
|
1974
|
+
if (!alive.current) return;
|
|
1975
|
+
if (result.ok) setNotice(t("modelTestOk", { model: target }));
|
|
1976
|
+
else setActionError(t("modelTestFail", { model: target }) + (result.error === void 0 ? "" : `\n${result.error}`));
|
|
1977
|
+
});
|
|
1978
|
+
},
|
|
1979
|
+
onExport: (index) => {
|
|
1980
|
+
act(async () => {
|
|
1981
|
+
const result = await rpc.call("account.export", { index });
|
|
1982
|
+
if (result.blob === void 0) throw new Error(result.error ?? t("exportFailed"));
|
|
1983
|
+
await copyText(result.blob);
|
|
1984
|
+
});
|
|
1985
|
+
},
|
|
1986
|
+
onRegenerateFingerprint: (index) => {
|
|
1987
|
+
act(() => rpc.call("account.fingerprint", {
|
|
1988
|
+
index,
|
|
1989
|
+
action: "regenerate"
|
|
1990
|
+
}));
|
|
1991
|
+
},
|
|
1992
|
+
onSetProxy: (index, proxy) => {
|
|
1993
|
+
act(() => rpc.call("account.proxy", {
|
|
1994
|
+
index,
|
|
1995
|
+
proxy
|
|
1996
|
+
}));
|
|
1997
|
+
},
|
|
1998
|
+
/**
|
|
1999
|
+
* Probe the proxy and REPORT the verdict.
|
|
2000
|
+
*
|
|
2001
|
+
* `account.proxyTest` answers in band (`{ ok, masked, error? }`) and only
|
|
2002
|
+
* throws for a request-level failure, so the in-band verdict is read here
|
|
2003
|
+
* rather than left to `act()`. No `refresh()`: the probe mutates no account
|
|
2004
|
+
* state, so reloading would be a second `account.list` + `stats.get` round
|
|
2005
|
+
* trip bought for nothing.
|
|
2006
|
+
*/
|
|
2007
|
+
onTestProxy: (index, proxy) => {
|
|
2008
|
+
runAction(async () => {
|
|
2009
|
+
const result = await rpc.call("account.proxyTest", {
|
|
2010
|
+
index,
|
|
2011
|
+
...proxy === "" ? {} : { proxy }
|
|
2012
|
+
});
|
|
2013
|
+
if (!alive.current) return;
|
|
2014
|
+
if (result.ok) setNotice(t("proxyTestOk", { proxy: result.masked }));
|
|
2015
|
+
else setActionError(t("proxyTestFail", { proxy: result.masked }) + (result.error === void 0 ? "" : `\n${result.error}`));
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}), [
|
|
2019
|
+
act,
|
|
2020
|
+
copyText,
|
|
2021
|
+
models,
|
|
2022
|
+
refresh,
|
|
2023
|
+
rpc,
|
|
2024
|
+
runAction,
|
|
2025
|
+
setActionError,
|
|
2026
|
+
setNotice,
|
|
2027
|
+
t
|
|
2028
|
+
]);
|
|
2029
|
+
const tabButton = (id, label, count) => h("button", {
|
|
2030
|
+
key: id,
|
|
2031
|
+
type: "button",
|
|
2032
|
+
className: "agy-tab",
|
|
2033
|
+
"data-active": tab === id,
|
|
2034
|
+
onClick: () => {
|
|
2035
|
+
setTab(id);
|
|
2036
|
+
}
|
|
2037
|
+
}, label, count === void 0 ? null : h("span", { className: "agy-count" }, String(count)));
|
|
2038
|
+
const body = tab === "accounts" ? h(AccountsTab, {
|
|
2039
|
+
accounts,
|
|
2040
|
+
busy,
|
|
2041
|
+
handlers,
|
|
2042
|
+
t
|
|
2043
|
+
}) : tab === "models" ? modelError === void 0 ? h(ModelsTab, {
|
|
2044
|
+
models,
|
|
2045
|
+
account: modelAccount,
|
|
2046
|
+
pending: toggling,
|
|
2047
|
+
testing: modelTesting,
|
|
2048
|
+
rpc,
|
|
2049
|
+
t,
|
|
2050
|
+
/**
|
|
2051
|
+
* Optimistic, per-model toggle.
|
|
2052
|
+
*
|
|
2053
|
+
* The old handler ran the write AND a full `model.list` reload through
|
|
2054
|
+
* `act()`, so a click cost two network round trips (the reload reaches
|
|
2055
|
+
* upstream model discovery) while a single global `busy` flag disabled
|
|
2056
|
+
* every control on the page — hence "it hangs, everything is disabled,
|
|
2057
|
+
* then it switches". The write is authoritative and its result is
|
|
2058
|
+
* already known, so the list is updated from the response and no
|
|
2059
|
+
* reload is needed: the switch reflects exactly what the host stored.
|
|
2060
|
+
*/
|
|
2061
|
+
onToggle: (modelId, disabled) => {
|
|
2062
|
+
setToggling((current) => new Set(current).add(modelId));
|
|
2063
|
+
(async () => {
|
|
2064
|
+
try {
|
|
2065
|
+
const result = await rpc.call("model.setDisabled", {
|
|
2066
|
+
modelId,
|
|
2067
|
+
disabled
|
|
2068
|
+
});
|
|
2069
|
+
if (!alive.current) return;
|
|
2070
|
+
setModels((current) => current.map((model) => model.id === result.modelId ? {
|
|
2071
|
+
...model,
|
|
2072
|
+
disabled: result.disabled
|
|
2073
|
+
} : model));
|
|
2074
|
+
setError(void 0);
|
|
2075
|
+
} catch (caught) {
|
|
2076
|
+
if (!alive.current) return;
|
|
2077
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
2078
|
+
} finally {
|
|
2079
|
+
if (alive.current) setToggling((current) => {
|
|
2080
|
+
const next = new Set(current);
|
|
2081
|
+
next.delete(modelId);
|
|
2082
|
+
return next;
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
})();
|
|
2086
|
+
},
|
|
2087
|
+
/**
|
|
2088
|
+
* One billed test call against exactly this model, reported in the
|
|
2089
|
+
* shared notice line (OK) or error banner (failure). The result must
|
|
2090
|
+
* not be silent: a test that shows nothing looks like nothing ran.
|
|
2091
|
+
*/
|
|
2092
|
+
onTestModel: (modelId) => {
|
|
2093
|
+
setError(void 0);
|
|
2094
|
+
setNotice(void 0);
|
|
2095
|
+
setModelTesting((current) => new Set(current).add(modelId));
|
|
2096
|
+
(async () => {
|
|
2097
|
+
try {
|
|
2098
|
+
const result = await rpc.call("account.test", { model: modelId });
|
|
2099
|
+
if (!alive.current) return;
|
|
2100
|
+
if (result.ok) setNotice(t("modelTestOk", { model: modelId }));
|
|
2101
|
+
else setError(t("modelTestFail", { model: modelId }) + (result.error ? `\n${result.error}` : ""));
|
|
2102
|
+
} catch (caught) {
|
|
2103
|
+
if (!alive.current) return;
|
|
2104
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
2105
|
+
} finally {
|
|
2106
|
+
if (alive.current) setModelTesting((current) => {
|
|
2107
|
+
const next = new Set(current);
|
|
2108
|
+
next.delete(modelId);
|
|
2109
|
+
return next;
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
})();
|
|
2113
|
+
}
|
|
2114
|
+
}) : h("div", { className: "agy-root" }, h("div", { className: "agy-error" }, modelError), button(t("refresh"), () => {
|
|
2115
|
+
loadModels();
|
|
2116
|
+
}, { size: "sm" })) : tab === "usage" ? h(UsageTab, {
|
|
2117
|
+
stats,
|
|
2118
|
+
t
|
|
2119
|
+
}) : h(CredentialsTab, {
|
|
2120
|
+
busy,
|
|
2121
|
+
t,
|
|
2122
|
+
onImport: (kind, sources) => {
|
|
2123
|
+
act(async () => {
|
|
2124
|
+
const result = await rpc.call("account.import", {
|
|
2125
|
+
kind,
|
|
2126
|
+
sources
|
|
2127
|
+
});
|
|
2128
|
+
if (result.errors.length > 0) setNotice(`${t("importPartial", {
|
|
2129
|
+
imported: result.imported,
|
|
2130
|
+
replaced: result.replaced,
|
|
2131
|
+
failed: result.errors.length
|
|
2132
|
+
})}\n${result.errors.join("\n")}`);
|
|
2133
|
+
else setNotice(t("importResult", {
|
|
2134
|
+
imported: result.imported,
|
|
2135
|
+
replaced: result.replaced
|
|
2136
|
+
}));
|
|
2137
|
+
});
|
|
2138
|
+
},
|
|
2139
|
+
onExportAll: () => {
|
|
2140
|
+
act(async () => {
|
|
2141
|
+
const { blobs } = await rpc.call("account.exportAll", {});
|
|
2142
|
+
await copyText(blobs.map((entry) => entry.blob).join("\n"));
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
return h("div", { className: "agy-root" }, h("div", { className: "agy-head" }, h("div", null, h("div", { className: "agy-title" }, "Antigravity"), h("div", { className: "agy-sub" }, t("subtitle"))), h("div", { className: "agy-toolbar" }, button(t("refresh"), refreshAll, {
|
|
2147
|
+
size: "sm",
|
|
2148
|
+
disabled: busy
|
|
2149
|
+
}), button(t("login"), startLogin, {
|
|
2150
|
+
size: "sm",
|
|
2151
|
+
disabled: busy
|
|
2152
|
+
}))), h("div", { className: "agy-tabs" }, tabButton("accounts", t("tabAccounts"), accounts.length), tabButton("models", t("tabModels"), models.length > 0 ? models.length : void 0), tabButton("usage", t("tabUsage")), tabButton("credentials", t("tabCredentials"))), error === void 0 ? null : h("div", { className: "agy-error" }, error), actionError === void 0 ? null : h("div", { className: "agy-error" }, actionError), notice === void 0 ? null : h("div", { className: "agy-notice" }, notice), body, loaded || error !== void 0 ? null : h("div", { className: "agy-empty" }, t("loading")));
|
|
18
2153
|
}
|
|
19
2154
|
/**
|
|
20
|
-
*
|
|
2155
|
+
* Register the Antigravity Settings section while this client plugin is active.
|
|
21
2156
|
* @param ctx - Client Cordis context.
|
|
22
2157
|
*/
|
|
23
2158
|
function apply(ctx) {
|
|
24
|
-
ctx.effect(() =>
|
|
25
|
-
|
|
2159
|
+
ctx.effect(() => installAgyStyles(), "dsh-agy: styles");
|
|
2160
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
2161
|
+
zh,
|
|
2162
|
+
en
|
|
2163
|
+
}), "dsh-agy: dictionaries");
|
|
2164
|
+
const connection = ctx.get("connection");
|
|
2165
|
+
if (connection === void 0) {
|
|
2166
|
+
ctx.logger.warn("[dsh-agy] connection service unavailable — Antigravity settings not registered");
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
const rpc = createRpc(connection);
|
|
2170
|
+
const t = ctx.locale.bind(NS);
|
|
2171
|
+
ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
2172
|
+
name: "settings.section",
|
|
26
2173
|
id: "agy",
|
|
27
|
-
order:
|
|
28
|
-
|
|
29
|
-
|
|
2174
|
+
order: 30,
|
|
2175
|
+
locale: NS,
|
|
2176
|
+
label: () => t("title")
|
|
2177
|
+
}, () => h(AgySettings, {
|
|
2178
|
+
rpc,
|
|
2179
|
+
t
|
|
2180
|
+
}))), "dsh-agy: Settings section");
|
|
30
2181
|
}
|
|
31
2182
|
//#endregion
|
|
2183
|
+
exports.AgySettings = AgySettings;
|
|
32
2184
|
exports.apply = apply;
|
|
33
2185
|
exports.inject = inject;
|
|
2186
|
+
exports.orderModels = orderModels;
|
|
2187
|
+
exports.tokenText = tokenText;
|
|
34
2188
|
return module.exports;
|
|
35
2189
|
}
|
|
36
2190
|
});
|