clauderipple 0.8.9 → 0.9.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/CHANGELOG.md +62 -0
- package/README.es.md +0 -4
- package/README.ja.md +0 -4
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +0 -4
- package/README.zh-CN.md +0 -4
- package/dist/app/dist/main.js +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/index.js +1 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/cli/src/tray.js +41 -5
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +226 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +370 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +195 -53
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/compat.js +10 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +119 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/anthropic-account-pool.js +10 -4
- package/dist/router/src/providers/anthropic-accounts.js +26 -1
- package/dist/router/src/providers/chatgpt/catalog.js +45 -14
- package/dist/router/src/providers/chatgpt/index.js +9 -3
- package/dist/router/src/providers/chatgpt/translate.js +9 -5
- package/dist/router/src/providers/google/index.js +6 -2
- package/dist/router/src/providers/openai/index.js +4 -1
- package/dist/router/src/providers/openai/translate.js +1 -1
- package/dist/router/src/proxy.js +14 -3
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +932 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +923 -237
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -408
- package/docs/ARCHITECTURE.md +134 -2
- package/package.json +7 -3
package/dist/ui/app.js
CHANGED
|
@@ -155,6 +155,42 @@ let chatgptLoginMessage = "";
|
|
|
155
155
|
function selectOption(value, text) { return el("option", { value }, [text]); }
|
|
156
156
|
function badge(kind, text) { return el("span", { class: `badge ${kind} dot`, text }, []); }
|
|
157
157
|
function hint(text) { return el("p", { class: "hint", text }, []); }
|
|
158
|
+
/** One row of an inset group: a label (and quieter lines under it) on the left, a value or control on the right. */
|
|
159
|
+
function cell(label, value, sub, extraClass) {
|
|
160
|
+
const lines = (Array.isArray(sub) ? sub : [sub]).filter(Boolean).map((line) => typeof line === "string" ? el("span", { class: "cell-sub", text: line }) : line);
|
|
161
|
+
return el("div", { class: `cell${extraClass ? ` ${extraClass}` : ""}` }, [
|
|
162
|
+
el("div", { class: "cell-text" }, [typeof label === "string" ? el("span", { class: "cell-label", text: label }) : label, ...lines]),
|
|
163
|
+
value ? el("div", { class: "cell-value" }, Array.isArray(value) ? value : [value]) : null,
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
/** A titled group of rows with an optional note under it — the System Settings section shape. */
|
|
167
|
+
function section(title, rows, footer, action) {
|
|
168
|
+
return el("section", { class: "section" }, [
|
|
169
|
+
title || action ? el("div", { class: "section-head" }, [title ? el("h3", { class: "section-title", text: title }) : el("span"), action || null]) : null,
|
|
170
|
+
el("div", { class: "group" }, rows.filter(Boolean)),
|
|
171
|
+
...(Array.isArray(footer) ? footer : [footer]).filter(Boolean).map((text) => el("p", { class: "group-footer", text })),
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
175
|
+
/** A small line icon; the paths are drawn in currentColor so the icon follows the text around it. */
|
|
176
|
+
function svgIcon(viewBox, paths, cls) {
|
|
177
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
178
|
+
svg.setAttribute("viewBox", viewBox);
|
|
179
|
+
svg.setAttribute("fill", "none");
|
|
180
|
+
svg.setAttribute("stroke", "currentColor");
|
|
181
|
+
svg.setAttribute("stroke-width", "1.7");
|
|
182
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
183
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
184
|
+
svg.setAttribute("aria-hidden", "true");
|
|
185
|
+
if (cls) svg.setAttribute("class", cls);
|
|
186
|
+
for (const d of paths) {
|
|
187
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
188
|
+
path.setAttribute("d", d);
|
|
189
|
+
svg.appendChild(path);
|
|
190
|
+
}
|
|
191
|
+
return svg;
|
|
192
|
+
}
|
|
193
|
+
function chevronIcon() { return svgIcon("0 0 8 13", ["M1.5 1.5l5 5-5 5"], "chevron"); }
|
|
158
194
|
|
|
159
195
|
function showView(name) {
|
|
160
196
|
for (const button of $all(".nav-btn")) button.classList.toggle("active", button.dataset.view === name);
|
|
@@ -164,25 +200,29 @@ function showView(name) {
|
|
|
164
200
|
if (name === "providers" && !providersLoaded) void loadProviders();
|
|
165
201
|
if (name === "clients" && !clientsLoaded) void loadClients();
|
|
166
202
|
if (name === "logs") queueMicrotask(() => void refreshRequests());
|
|
203
|
+
// Read again on every visit: the bar's ⚙ panel saves the same settings.
|
|
204
|
+
if (name === "mod") queueMicrotask(() => void loadMod());
|
|
167
205
|
}
|
|
168
206
|
for (const button of $all(".nav-btn")) button.addEventListener("click", () => showView(button.dataset.view));
|
|
169
207
|
window.addEventListener("hashchange", () => showView((location.hash || "#health").slice(1)));
|
|
170
208
|
showView((location.hash || "#health").slice(1) || "health");
|
|
171
209
|
|
|
210
|
+
// The sidebar carries one language menu and About another (the sidebar is a tab bar on a phone).
|
|
172
211
|
(function setupLanguage() {
|
|
173
|
-
const select = $("#lang-toggle");
|
|
174
212
|
const lang = (typeof CURRENT_LANG !== "undefined" && CURRENT_LANG) || "en";
|
|
175
|
-
for (const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
213
|
+
for (const select of $all(".lang-select")) {
|
|
214
|
+
for (const [code, name] of LANGUAGES) {
|
|
215
|
+
const option = document.createElement("option");
|
|
216
|
+
option.value = code;
|
|
217
|
+
option.textContent = name;
|
|
218
|
+
option.selected = code === lang;
|
|
219
|
+
select.append(option);
|
|
220
|
+
}
|
|
221
|
+
select.addEventListener("change", () => {
|
|
222
|
+
try { localStorage.setItem("clauderipple_lang", select.value); } catch { /* unavailable */ }
|
|
223
|
+
location.reload();
|
|
224
|
+
});
|
|
181
225
|
}
|
|
182
|
-
select.addEventListener("change", () => {
|
|
183
|
-
try { localStorage.setItem("clauderipple_lang", select.value); } catch { /* unavailable */ }
|
|
184
|
-
location.reload();
|
|
185
|
-
});
|
|
186
226
|
})();
|
|
187
227
|
|
|
188
228
|
async function loadCatalogs() {
|
|
@@ -198,7 +238,7 @@ async function refreshHealth() {
|
|
|
198
238
|
try {
|
|
199
239
|
status = await api("/api/status");
|
|
200
240
|
} catch (error) {
|
|
201
|
-
$("#health-desktop").replaceChildren(
|
|
241
|
+
$("#health-desktop").replaceChildren(cell(t("health.connection"), badge("bad", t("health.disconnected"))));
|
|
202
242
|
return;
|
|
203
243
|
}
|
|
204
244
|
// Served files changed underneath an open window (e.g. after an update): reload, unless a form is open.
|
|
@@ -207,18 +247,20 @@ async function refreshHealth() {
|
|
|
207
247
|
else if (loadedUiRevision !== status.uiRevision && $("#modal-backdrop").hidden) location.reload();
|
|
208
248
|
}
|
|
209
249
|
const desktop = $("#health-desktop");
|
|
210
|
-
desktop.replaceChildren(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
250
|
+
desktop.replaceChildren(cell(
|
|
251
|
+
t("health.connection"),
|
|
252
|
+
status.settings.pointsAtRouter ? badge("ok", t("health.connected")) : badge("bad", t("health.notConnected")),
|
|
253
|
+
status.settings.pointsAtRouter ? t("health.connectedHelp") : t("health.notConnectedHelp"),
|
|
254
|
+
));
|
|
255
|
+
$("#health-requests").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("health.requests") }), el("span", { class: "v", text: t("health.requestsFmt", Object.fromEntries(Object.entries(status.stats || {}).map(([key, value]) => [key, typeof value === "number" ? value.toLocaleString() : value]))) })]));
|
|
215
256
|
renderHealthProviders();
|
|
216
257
|
if (clientsLoaded) renderClients();
|
|
258
|
+
if (modState.settings) renderModNotice();
|
|
217
259
|
const details = $("#health-details");
|
|
218
260
|
details.replaceChildren(
|
|
219
261
|
versionRow(),
|
|
220
262
|
// Which files answer, and since when: the only way to see that an update actually replaced the router.
|
|
221
|
-
...(status.runtime ? [
|
|
263
|
+
...(status.runtime ? [cell(t("health.runtime"), null, [el("span", { class: "cell-sub mono", text: status.runtime.router || "?" }), new Date(status.runtime.startedAt).toLocaleString()])] : []),
|
|
222
264
|
el("div", { class: "row" }, [el("span", { class: "k", text: t("health.routes") }), el("span", { class: "v", text: String(status.routes) })]),
|
|
223
265
|
el("div", { class: "row" }, [el("span", { class: "k", text: t("health.cli") }), el("span", { class: "v", text: status.cliVersion })]),
|
|
224
266
|
);
|
|
@@ -230,22 +272,25 @@ async function refreshHealth() {
|
|
|
230
272
|
let updateState = { busy: false, check: null };
|
|
231
273
|
function versionRow() {
|
|
232
274
|
const check = updateState.check;
|
|
233
|
-
|
|
275
|
+
// The version reads as the row's label; what the update check found sits under it, the action on the right.
|
|
276
|
+
let note = null;
|
|
277
|
+
let button = null;
|
|
234
278
|
if (check && check.newer && check.kind === "checkout") {
|
|
235
|
-
|
|
279
|
+
note = t("update.checkout", { latest: check.latest });
|
|
236
280
|
} else {
|
|
237
281
|
const action = check && check.newer;
|
|
238
282
|
const label = updateState.busy ? t("update.checking")
|
|
239
283
|
: check && check.running ? t("update.running")
|
|
240
284
|
: action ? (check.kind === "packaged" ? t("update.download", { latest: check.latest }) : t("update.install", { latest: check.latest }))
|
|
241
285
|
: t("update.check");
|
|
242
|
-
|
|
286
|
+
button = el("button", { class: action ? "btn" : "btn secondary", type: "button", text: label });
|
|
243
287
|
button.disabled = updateState.busy || Boolean(check && check.running);
|
|
244
288
|
button.addEventListener("click", () => void (action ? startUpdate() : checkForUpdate()));
|
|
245
|
-
|
|
246
|
-
if (check && !check.newer && !updateState.busy) children.push(el("span", { class: "small", text: t("update.latest") }));
|
|
289
|
+
if (check && !check.newer && !updateState.busy) note = t("update.latest");
|
|
247
290
|
}
|
|
248
|
-
|
|
291
|
+
const row = cell(el("span", { class: "cell-label", text: `${t("health.version")} ${status.version}` }), button, note);
|
|
292
|
+
row.id = "health-version-row";
|
|
293
|
+
return row;
|
|
249
294
|
}
|
|
250
295
|
function renderVersionRow() {
|
|
251
296
|
const row = $("#health-version-row");
|
|
@@ -372,6 +417,8 @@ function credentialLine(name) {
|
|
|
372
417
|
*/
|
|
373
418
|
function providerConnection(name) {
|
|
374
419
|
const live = status && status.providers && status.providers[name];
|
|
420
|
+
// A successful TCP probe (or an older model probe) cannot undo a real billing refusal.
|
|
421
|
+
if (live && live.exhaustion) return { level: "warn", text: `${live.exhaustion.reason.replace(/_/g, " ")} · ${new Date(live.exhaustion.until).toLocaleTimeString()}` };
|
|
375
422
|
// Reaching the host is not the same as being able to use it. A ChatGPT provider with no
|
|
376
423
|
// credentials would otherwise read "Connected" and send the user off believing it works.
|
|
377
424
|
if (live && live.needsLogin) return { level: "warn", text: t("providerStatus.loginNeeded") };
|
|
@@ -407,14 +454,9 @@ function renderHealthProviders() {
|
|
|
407
454
|
}
|
|
408
455
|
box.replaceChildren(...providers.map(([name, provider]) => {
|
|
409
456
|
const live = status && status.providers && status.providers[name];
|
|
410
|
-
const
|
|
411
|
-
el("strong", { text: name }),
|
|
412
|
-
providerState(name, provider),
|
|
413
|
-
live && live.needsLogin && provider.type === "chatgpt" ? chatgptLoginButton(() => renderHealthProviders()) : null,
|
|
414
|
-
live && live.needsLogin && provider.type === "chatgpt" && chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
|
|
415
|
-
].filter(Boolean));
|
|
457
|
+
const lines = [];
|
|
416
458
|
const quota = quotaLine(name);
|
|
417
|
-
if (quota)
|
|
459
|
+
if (quota) lines.push(el("span", { class: "cell-sub", text: quota }));
|
|
418
460
|
// Claude subscription accounts, one line each: the current login and every account added here.
|
|
419
461
|
// Before the config has loaded the rows come from the status, which names the sign-in source
|
|
420
462
|
// rather than the auth mode.
|
|
@@ -423,15 +465,26 @@ function renderHealthProviders() {
|
|
|
423
465
|
.map((account) => ({ account, text: chatgptQuotaText(account.quota) }))
|
|
424
466
|
.filter(({ text }) => text)
|
|
425
467
|
.map(({ account, text }) => el("div", { text: `${claudeAccountLabel(account)} · ${text}` }));
|
|
426
|
-
if (usage.length)
|
|
468
|
+
if (usage.length) lines.push(el("div", { class: "cell-sub account-usage" }, usage));
|
|
427
469
|
}
|
|
428
470
|
const pool = credentialLine(name);
|
|
429
|
-
if (pool)
|
|
430
|
-
|
|
471
|
+
if (pool) lines.push(pool);
|
|
472
|
+
if (live && live.needsLogin && provider.type === "chatgpt") {
|
|
473
|
+
lines.push(el("div", { class: "cell-actions" }, [
|
|
474
|
+
chatgptLoginButton(() => renderHealthProviders()),
|
|
475
|
+
chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
|
|
476
|
+
]));
|
|
477
|
+
}
|
|
478
|
+
// The row opens the provider's own page; a button inside it keeps its own click.
|
|
479
|
+
const row = cell(el("span", { class: "cell-label", text: name }), [providerState(name, provider), chevronIcon()], lines, "provider-status link");
|
|
480
|
+
row.tabIndex = 0;
|
|
481
|
+
const open = (event) => { if (!event.target.closest("button, a")) openProvider(name, provider); };
|
|
482
|
+
row.addEventListener("click", open);
|
|
483
|
+
row.addEventListener("keydown", (event) => { if (event.key === "Enter" && event.target === row) open(event); });
|
|
484
|
+
return row;
|
|
431
485
|
}));
|
|
432
486
|
}
|
|
433
487
|
|
|
434
|
-
let agentTitleBusy = false;
|
|
435
488
|
let statusModBusy = false;
|
|
436
489
|
let codexBusy = false;
|
|
437
490
|
function renderClients() {
|
|
@@ -439,41 +492,43 @@ function renderClients() {
|
|
|
439
492
|
const picker = status.picker || { enabled: false, last: null };
|
|
440
493
|
const last = picker.last || null;
|
|
441
494
|
const names = status.pickerModels || [];
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
495
|
+
// The switch rows are static markup, so a five-second refresh never takes keyboard focus off one;
|
|
496
|
+
// only the rows after the switch are rebuilt.
|
|
497
|
+
const pickerGroup = $("#client-picker-rows");
|
|
498
|
+
while (pickerGroup.children.length > 1) pickerGroup.lastElementChild.remove();
|
|
499
|
+
if (picker.enabled) {
|
|
500
|
+
pickerGroup.append(
|
|
501
|
+
el("div", { class: "cell-note", text: t("picker.models", { count: names.length, names: names.join(", ") || "—" }) }),
|
|
502
|
+
el("div", { class: "cell-note", text: last && last.at ? t("picker.lastAt", { at: new Date(last.at).toLocaleString() }) : t("picker.never") }),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const pickerSwitch = $("#client-picker-toggle");
|
|
506
|
+
pickerSwitch.checked = Boolean(picker.enabled);
|
|
507
|
+
pickerSwitch.disabled = pickerBusy;
|
|
508
|
+
// Turning it on asks first (the OS trust prompt follows); a "no" puts the switch back.
|
|
509
|
+
pickerSwitch.onchange = () => {
|
|
510
|
+
if (!picker.enabled && !confirm(t("picker.confirmOn"))) { pickerSwitch.checked = false; return; }
|
|
511
|
+
pickerSwitch.disabled = true;
|
|
512
|
+
void togglePicker(!picker.enabled, true);
|
|
513
|
+
};
|
|
452
514
|
renderClientPickerModels(picker.enabled);
|
|
453
515
|
renderModelSlots();
|
|
454
|
-
const agentEnabled = Boolean(status.agentTitle);
|
|
455
|
-
$("#client-agent-title-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), agentEnabled ? badge("ok", t("agentTitle.on")) : el("span", { class: "small", text: t("agentTitle.off") })]));
|
|
456
|
-
const agentButton = $("#client-agent-title-toggle");
|
|
457
|
-
agentButton.textContent = agentEnabled ? t("agentTitle.turnOff") : t("agentTitle.turnOn");
|
|
458
|
-
agentButton.className = agentEnabled ? "btn secondary" : "btn";
|
|
459
|
-
agentButton.disabled = agentTitleBusy;
|
|
460
|
-
agentButton.onclick = () => toggleAgentTitle(!agentEnabled);
|
|
461
516
|
const modEnabled = Boolean(status.statusMod);
|
|
462
|
-
$("#client-status-mod-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
modButton.disabled = statusModBusy;
|
|
467
|
-
modButton.onclick = () => toggleStatusMod(!modEnabled);
|
|
517
|
+
const modSwitch = $("#client-status-mod-toggle");
|
|
518
|
+
modSwitch.checked = modEnabled;
|
|
519
|
+
modSwitch.disabled = statusModBusy;
|
|
520
|
+
modSwitch.onchange = () => { modSwitch.disabled = true; void toggleStatusMod(!modEnabled); };
|
|
468
521
|
const toolsLimited = Boolean(currentConfig.cli && currentConfig.cli.limitWorkerTools);
|
|
469
|
-
$("#client-worker-tools-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
toolsButton.className = toolsLimited ? "btn secondary" : "btn";
|
|
473
|
-
toolsButton.onclick = () => toggleWorkerTools(!toolsLimited);
|
|
522
|
+
const toolsSwitch = $("#client-worker-tools-toggle");
|
|
523
|
+
toolsSwitch.checked = toolsLimited;
|
|
524
|
+
toolsSwitch.onchange = () => { toolsSwitch.disabled = true; void toggleWorkerTools(!toolsLimited).finally(() => { toolsSwitch.disabled = false; }); };
|
|
474
525
|
void renderCodexClient();
|
|
475
|
-
const
|
|
476
|
-
|
|
526
|
+
const groups = groupedModels(currentConfig);
|
|
527
|
+
const mapped = Object.entries(currentConfig.routes || {}).map(([source, route]) => cell(
|
|
528
|
+
labelOf(claudeModels.find((model) => model.id === source) || { id: source }),
|
|
529
|
+
el("span", { text: labelOf((groups.find((group) => group.name === route.provider) || { models: [] }).models.find((model) => model.id === route.model) || { id: route.model }) }),
|
|
530
|
+
));
|
|
531
|
+
$("#client-claude-code-rows").replaceChildren(...(mapped.length ? mapped : [el("div", { class: "cell-note", text: t("slots.noChanges") })]));
|
|
477
532
|
}
|
|
478
533
|
function renderClientPickerModels(enabled) {
|
|
479
534
|
const card = $("#client-picker-models-card");
|
|
@@ -552,11 +607,8 @@ function renderModelSlots() {
|
|
|
552
607
|
}
|
|
553
608
|
select.onchange = () => void saveModelSlots();
|
|
554
609
|
select.dataset.slot = slot;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
select,
|
|
558
|
-
el("span", { class: "hint", text: t(`slots.slotHelp.${slot}`) }),
|
|
559
|
-
]);
|
|
610
|
+
select.setAttribute("aria-label", t(`slots.slot.${slot}`));
|
|
611
|
+
return cell(t(`slots.slot.${slot}`), select, t(`slots.slotHelp.${slot}`), "slot-row");
|
|
560
612
|
}));
|
|
561
613
|
}
|
|
562
614
|
|
|
@@ -622,19 +674,6 @@ async function togglePicker(enabled, confirmed = false) {
|
|
|
622
674
|
void refreshHealth();
|
|
623
675
|
}
|
|
624
676
|
}
|
|
625
|
-
async function toggleAgentTitle(enabled) {
|
|
626
|
-
if (agentTitleBusy) return;
|
|
627
|
-
agentTitleBusy = true;
|
|
628
|
-
try {
|
|
629
|
-
await api("/api/agent-title", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
|
|
630
|
-
toast(t("common.saved"));
|
|
631
|
-
} catch (error) {
|
|
632
|
-
toast(t("common.actionFailed"), true, error.message);
|
|
633
|
-
} finally {
|
|
634
|
-
agentTitleBusy = false;
|
|
635
|
-
void refreshHealth();
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
677
|
async function toggleStatusMod(enabled) {
|
|
639
678
|
if (statusModBusy) return;
|
|
640
679
|
statusModBusy = true;
|
|
@@ -660,17 +699,19 @@ async function toggleWorkerTools(enabled) {
|
|
|
660
699
|
renderClients();
|
|
661
700
|
}
|
|
662
701
|
async function renderCodexClient() {
|
|
663
|
-
const
|
|
664
|
-
const
|
|
702
|
+
const path = $("#client-codex-rows");
|
|
703
|
+
const toggle = $("#client-codex-toggle");
|
|
665
704
|
try {
|
|
666
705
|
const codex = await api("/api/codex");
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
706
|
+
path.textContent = codex.configPath || "";
|
|
707
|
+
path.classList.remove("bad-text");
|
|
708
|
+
toggle.checked = Boolean(codex.enabled);
|
|
709
|
+
toggle.disabled = codexBusy;
|
|
710
|
+
toggle.onchange = () => { toggle.disabled = true; void toggleCodex(!codex.enabled); };
|
|
672
711
|
} catch (error) {
|
|
673
|
-
|
|
712
|
+
path.textContent = error.message;
|
|
713
|
+
path.classList.add("bad-text");
|
|
714
|
+
toggle.disabled = true;
|
|
674
715
|
}
|
|
675
716
|
}
|
|
676
717
|
async function toggleCodex(enabled) {
|
|
@@ -716,14 +757,19 @@ function routeFromTarget(value) {
|
|
|
716
757
|
return provider && model ? { provider, model } : null;
|
|
717
758
|
} catch { return null; }
|
|
718
759
|
}
|
|
760
|
+
// One mapping per row: the Claude model, what answers it, and its effort. On a narrow window the
|
|
761
|
+
// three stack, so each carries its own small label (hidden where the column heads show).
|
|
719
762
|
function slotRow(id, route) {
|
|
720
|
-
const row = el("
|
|
763
|
+
const row = el("div", { class: "map-row" });
|
|
721
764
|
const source = claudeSelect(id);
|
|
722
765
|
const target = targetSelect(route);
|
|
766
|
+
source.setAttribute("aria-label", t("slots.th.claude"));
|
|
767
|
+
target.setAttribute("aria-label", t("slots.th.target"));
|
|
723
768
|
const initialTarget = routeFromTarget(target.value);
|
|
724
769
|
const effort = effortSelect(route && route.effort, initialTarget ? effortLevelsFor(initialTarget.provider, initialTarget.model) : ["high"]);
|
|
725
|
-
|
|
726
|
-
const
|
|
770
|
+
effort.setAttribute("aria-label", t("slots.th.effort"));
|
|
771
|
+
const effortCell = el("div", { class: "map-col map-effort" }, [el("span", { class: "map-label", text: t("slots.th.effort") }), effort]);
|
|
772
|
+
const remove = el("button", { class: "icon-btn remove", type: "button", title: t("common.remove"), "aria-label": t("common.remove"), onclick: () => { row.remove(); updateSlotSummary(); scheduleSlotsSave(); } }, [svgIcon("0 0 20 20", ["M10 2.8a7.2 7.2 0 1 0 0 14.4 7.2 7.2 0 0 0 0-14.4z", "M6.6 10h6.8"])]);
|
|
727
773
|
function sync() {
|
|
728
774
|
const selected = routeFromTarget(target.value);
|
|
729
775
|
const levels = selected ? effortLevelsFor(selected.provider, selected.model) : [];
|
|
@@ -745,7 +791,13 @@ function slotRow(id, route) {
|
|
|
745
791
|
target.addEventListener("change", () => { sync(); scheduleSlotsSave(); });
|
|
746
792
|
source.addEventListener("change", () => { updateSlotSummary(); scheduleSlotsSave(); });
|
|
747
793
|
effort.addEventListener("change", scheduleSlotsSave);
|
|
748
|
-
row.append(
|
|
794
|
+
row.append(
|
|
795
|
+
el("div", { class: "map-col map-source" }, [el("span", { class: "map-label", text: t("slots.th.claude") }), source]),
|
|
796
|
+
el("span", { class: "map-arrow", "aria-hidden": "true", text: "→" }),
|
|
797
|
+
el("div", { class: "map-col map-target" }, [el("span", { class: "map-label", text: t("slots.th.target") }), target]),
|
|
798
|
+
effortCell,
|
|
799
|
+
el("div", { class: "map-remove" }, [remove]),
|
|
800
|
+
);
|
|
749
801
|
row._get = () => ({ id: source.value, route: routeFromTarget(target.value), effort: effort.value });
|
|
750
802
|
sync();
|
|
751
803
|
return row;
|
|
@@ -755,7 +807,7 @@ async function loadSlots() {
|
|
|
755
807
|
try {
|
|
756
808
|
await loadCatalogs();
|
|
757
809
|
currentConfig = await api("/api/config");
|
|
758
|
-
const rows = $("#slots-
|
|
810
|
+
const rows = $("#slots-list");
|
|
759
811
|
rows.replaceChildren();
|
|
760
812
|
const routes = currentConfig.routes || {};
|
|
761
813
|
const ids = [...claudeModels.map((model) => model.id), ...Object.keys(routes).filter((id) => !claudeModels.some((model) => model.id === id))];
|
|
@@ -767,7 +819,7 @@ async function loadSlots() {
|
|
|
767
819
|
}
|
|
768
820
|
$("#slots-add").addEventListener("click", () => {
|
|
769
821
|
if (!currentConfig) return;
|
|
770
|
-
$("#slots-
|
|
822
|
+
$("#slots-list").appendChild(slotRow(claudeModels[0].id, null));
|
|
771
823
|
updateSlotSummary();
|
|
772
824
|
});
|
|
773
825
|
function allKnownModelIds(config) { return new Set(groupedModels(config).flatMap((group) => group.models.map((model) => model.id))); }
|
|
@@ -836,7 +888,7 @@ function applyPickerSelections(next, selections) {
|
|
|
836
888
|
return next;
|
|
837
889
|
}
|
|
838
890
|
function updateSlotSummary() {
|
|
839
|
-
const summaries = $all("#slots-
|
|
891
|
+
const summaries = $all("#slots-list .map-row").map((row) => row._get()).filter((item) => item.route).slice(0, 3).map((item) => {
|
|
840
892
|
const source = claudeModels.find((model) => model.id === item.id);
|
|
841
893
|
const group = groupedModels(currentConfig).find((itemGroup) => itemGroup.name === item.route.provider);
|
|
842
894
|
const target = group && group.models.find((model) => model.id === item.route.model);
|
|
@@ -864,7 +916,7 @@ async function saveSlots() {
|
|
|
864
916
|
const next = clone(await latestConfig());
|
|
865
917
|
const routes = {};
|
|
866
918
|
const seen = new Set();
|
|
867
|
-
for (const row of $all("#slots-
|
|
919
|
+
for (const row of $all("#slots-list .map-row")) {
|
|
868
920
|
const item = row._get();
|
|
869
921
|
if (seen.has(item.id)) { slotsStatus(t("slots.duplicate"), true); slotsSaving = false; return; }
|
|
870
922
|
seen.add(item.id);
|
|
@@ -1026,14 +1078,27 @@ function providerRailItem(name, provider) {
|
|
|
1026
1078
|
]),
|
|
1027
1079
|
providerState(name, provider),
|
|
1028
1080
|
]);
|
|
1029
|
-
button.addEventListener("click", () =>
|
|
1030
|
-
selectedProviderName = name;
|
|
1031
|
-
providerDetailTab = provider.type === "anthropic" && provider.auth === "claude-code" ? "accounts" : "overview";
|
|
1032
|
-
renderProviderWorkspace();
|
|
1033
|
-
});
|
|
1081
|
+
button.addEventListener("click", () => openProvider(name, provider));
|
|
1034
1082
|
return button;
|
|
1035
1083
|
}
|
|
1036
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* Show one provider's page, from its list or from the Status screen. A narrow window shows the list
|
|
1087
|
+
* and the page one at a time (`show-detail`), so this is also the "push" to the page.
|
|
1088
|
+
*/
|
|
1089
|
+
function openProvider(name, provider) {
|
|
1090
|
+
selectedProviderName = name;
|
|
1091
|
+
providerDetailTab = provider.type === "anthropic" && provider.auth === "claude-code" ? "accounts" : "overview";
|
|
1092
|
+
$("#view-providers").classList.add("show-detail");
|
|
1093
|
+
if (!$("#view-providers").classList.contains("active")) showView("providers");
|
|
1094
|
+
if (currentConfig && currentConfig.providers && currentConfig.providers[name]) renderProviderWorkspace();
|
|
1095
|
+
window.scrollTo(0, 0);
|
|
1096
|
+
}
|
|
1097
|
+
function closeProviderDetail() {
|
|
1098
|
+
$("#view-providers").classList.remove("show-detail");
|
|
1099
|
+
window.scrollTo(0, 0);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1037
1102
|
function renderProviderRail() {
|
|
1038
1103
|
const list = $("#providers-list");
|
|
1039
1104
|
const entries = Object.entries((currentConfig && currentConfig.providers) || {});
|
|
@@ -1058,6 +1123,7 @@ async function removeProvider(name, provider) {
|
|
|
1058
1123
|
providerDetailTab = "overview";
|
|
1059
1124
|
slotsLoaded = false;
|
|
1060
1125
|
clientsLoaded = false;
|
|
1126
|
+
closeProviderDetail();
|
|
1061
1127
|
renderProviderWorkspace();
|
|
1062
1128
|
toast(t("common.saved"));
|
|
1063
1129
|
} catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
@@ -1077,36 +1143,36 @@ function providerTabs(name, provider) {
|
|
|
1077
1143
|
function providerOverview(name, provider) {
|
|
1078
1144
|
const state = stateFor(name);
|
|
1079
1145
|
const live = status && status.providers && status.providers[name];
|
|
1080
|
-
const connection =
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
el("
|
|
1084
|
-
|
|
1146
|
+
const connection = section(t("providers.connection"), [
|
|
1147
|
+
// The kind is already under the page title; this row is only the verdict and what to do about it.
|
|
1148
|
+
cell(t("health.connection"), providerState(name, provider), [
|
|
1149
|
+
state && !state.ok && state.error ? el("span", { class: "cell-sub bad-text", text: shortError(state.error), title: state.error }) : null,
|
|
1150
|
+
live && live.needsLogin && provider.type === "chatgpt" ? el("div", { class: "cell-actions" }, [chatgptLoginButton(renderProviderDetail)]) : null,
|
|
1085
1151
|
]),
|
|
1086
|
-
state && !state.ok && state.error ? el("p", { class: "bad-text small provider-detail-error", text: shortError(state.error), title: state.error }) : null,
|
|
1087
|
-
live && live.needsLogin && provider.type === "chatgpt" ? chatgptLoginButton(renderProviderDetail) : null,
|
|
1088
|
-
].filter(Boolean));
|
|
1089
|
-
const models = modelsOf(provider);
|
|
1090
|
-
const modelSummary = el("section", { class: "detail-section" }, [
|
|
1091
|
-
el("h3", { text: t("providers.selectedModels") }),
|
|
1092
|
-
models.length ? el("div", { class: "model-chip-list" }, models.map((model) => el("span", { class: "model-chip", text: labelOf(model) }))) : hint(t("providers.noModels")),
|
|
1093
1152
|
]);
|
|
1153
|
+
const models = modelsOf(provider);
|
|
1094
1154
|
const measuring = measureStates.get(name);
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1155
|
+
const running = measuring && measuring.state === "running";
|
|
1156
|
+
// Names only, as one quiet line: the Models tab is where each one is listed and edited.
|
|
1157
|
+
const modelSummary = section(t("providers.selectedModels"), [
|
|
1158
|
+
models.length ? el("div", { class: "cell-note" }, [el("span", { class: "cell-label", text: models.map(labelOf).join(", ") })]) : el("div", { class: "cell-note", text: t("providers.noModels") }),
|
|
1159
|
+
running ? el("div", { class: "cell-note", text: t("measure.running", { done: measuring.done, total: measuring.total }) }) : null,
|
|
1160
|
+
], running ? t("measure.help") : null);
|
|
1099
1161
|
const quota = quotaLine(name);
|
|
1100
|
-
return el("div", { class: "provider-panel" }, [
|
|
1162
|
+
return el("div", { class: "provider-panel" }, [
|
|
1163
|
+
connection,
|
|
1164
|
+
quota ? section(null, [cell(t("providers.usage"), el("span", { text: quota }))]) : null,
|
|
1165
|
+
modelSummary,
|
|
1166
|
+
]);
|
|
1101
1167
|
}
|
|
1102
1168
|
|
|
1103
1169
|
function providerModelsPanel(name, provider) {
|
|
1104
1170
|
const models = modelsOf(provider);
|
|
1171
|
+
const edit = el("button", { class: "btn secondary compact", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) });
|
|
1105
1172
|
return el("div", { class: "provider-panel" }, [
|
|
1106
|
-
|
|
1107
|
-
el("
|
|
1108
|
-
|
|
1109
|
-
]),
|
|
1173
|
+
section(t("providers.selectedModels"), models.length
|
|
1174
|
+
? models.map((model) => cell(labelOf(model), modelEffortTag(model) ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: modelEffortTag(model) }) : null, el("span", { class: "cell-sub mono", text: model.id }), "provider-model-row"))
|
|
1175
|
+
: [el("div", { class: "cell-note", text: t("providers.noModels") })], t("providers.modelsHelp"), edit),
|
|
1110
1176
|
]);
|
|
1111
1177
|
}
|
|
1112
1178
|
|
|
@@ -1141,14 +1207,37 @@ function claudeQuotaNode(account) {
|
|
|
1141
1207
|
return text ? el("p", { class: "small account-quota", text }) : null;
|
|
1142
1208
|
}
|
|
1143
1209
|
|
|
1210
|
+
/** "Use first" for an account the pool does not lead with; `null` hands first place back to the current login. */
|
|
1211
|
+
function claudePreferButton(name, id, disabled) {
|
|
1212
|
+
const button = el("button", { class: "btn secondary compact", type: "button", text: t("providers.preferAction") });
|
|
1213
|
+
button.disabled = disabled;
|
|
1214
|
+
button.addEventListener("click", async () => {
|
|
1215
|
+
button.disabled = true;
|
|
1216
|
+
try { await api("/api/claude-accounts", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ preferred: id }) }); await refreshClaudeAccountPanel(name); }
|
|
1217
|
+
catch (error) { button.disabled = false; toast(t("common.actionFailed"), true, error.message); }
|
|
1218
|
+
});
|
|
1219
|
+
return button;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1144
1222
|
function renderClaudeAccountRows(target, data, name, generation) {
|
|
1145
1223
|
if (generation !== providerDetailGeneration) return;
|
|
1224
|
+
// Rows follow the order the router tries them in: the preferred account, else the current login, first.
|
|
1225
|
+
const stored = Array.isArray(data.accounts) ? [...data.accounts] : [];
|
|
1226
|
+
const preferredIndex = stored.findIndex((account) => account.preferred);
|
|
1227
|
+
if (preferredIndex > 0) stored.unshift(...stored.splice(preferredIndex, 1));
|
|
1228
|
+
const leader = preferredIndex >= 0 ? stored[0].id : data.current ? "current" : stored[0] && stored[0].id;
|
|
1229
|
+
const choosable = (data.current ? 1 : 0) + stored.length > 1;
|
|
1230
|
+
const firstBadge = (id) => (choosable && id === leader ? el("span", { class: "badge first", text: t("providers.preferBadge") }) : null);
|
|
1146
1231
|
const rows = [];
|
|
1147
|
-
|
|
1148
|
-
el("div", { class: "account-card-copy" }, [
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1232
|
+
const currentRow = data.current ? el("article", { class: "cell account-card current" }, [
|
|
1233
|
+
el("div", { class: "cell-text account-card-copy" }, [
|
|
1234
|
+
el("strong", { text: data.current.label }), el("span", { class: "small", text: anthropicSourceText(data.current.source) }), claudeQuotaNode(data.current), hint(t("providers.currentAccountHelp")),
|
|
1235
|
+
choosable && leader !== "current" ? el("div", { class: "account-card-actions" }, [claudePreferButton(name, null, false)]) : null,
|
|
1236
|
+
]),
|
|
1237
|
+
el("div", { class: "account-card-badges" }, [firstBadge("current"), el("span", { class: "badge ok", text: t("providers.anthropicCurrent") })]),
|
|
1238
|
+
]) : null;
|
|
1239
|
+
if (currentRow && preferredIndex < 0) rows.push(currentRow);
|
|
1240
|
+
for (const account of stored) {
|
|
1152
1241
|
const unavailable = account.needsReauth || account.expiresAt <= Date.now();
|
|
1153
1242
|
const rename = el("button", { class: "btn secondary compact", type: "button", text: t("common.edit") });
|
|
1154
1243
|
rename.addEventListener("click", async () => {
|
|
@@ -1164,19 +1253,25 @@ function renderClaudeAccountRows(target, data, name, generation) {
|
|
|
1164
1253
|
catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
1165
1254
|
});
|
|
1166
1255
|
const actions = [rename, remove];
|
|
1256
|
+
if (choosable && account.id !== leader) actions.unshift(claudePreferButton(name, account.id, unavailable));
|
|
1167
1257
|
if (unavailable) {
|
|
1168
1258
|
const reauth = el("button", { class: "btn secondary compact", type: "button", text: t("providers.reauthAction") });
|
|
1169
1259
|
reauth.addEventListener("click", () => openClaudeAccountConsent(name));
|
|
1170
1260
|
actions.unshift(reauth);
|
|
1171
1261
|
}
|
|
1172
|
-
rows.push(el("article", { class: "account-card" }, [
|
|
1173
|
-
el("div", { class: "account-card-copy" }, [
|
|
1174
|
-
|
|
1175
|
-
|
|
1262
|
+
rows.push(el("article", { class: "cell account-card" }, [
|
|
1263
|
+
el("div", { class: "cell-text account-card-copy" }, [
|
|
1264
|
+
el("strong", { text: account.label }), account.email && account.email !== account.label ? el("span", { class: "small", text: account.email }) : null, claudeQuotaNode(account), hint(t("providers.addedAccountHelp")),
|
|
1265
|
+
el("div", { class: "account-card-actions" }, actions),
|
|
1266
|
+
]),
|
|
1267
|
+
el("div", { class: "account-card-badges" }, [firstBadge(account.id), el("span", { class: `badge ${unavailable ? "bad" : "ok"}`, text: unavailable ? t("providers.anthropicReauth") : t("pool.ready") })]),
|
|
1176
1268
|
]));
|
|
1269
|
+
if (currentRow && rows.length === 1 && preferredIndex >= 0) rows.push(currentRow);
|
|
1177
1270
|
}
|
|
1178
|
-
target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty
|
|
1179
|
-
const
|
|
1271
|
+
target.replaceChildren(...(rows.length ? rows : [el("div", { class: "account-empty", text: t("providers.anthropicNoAccounts") })]));
|
|
1272
|
+
const preferNote = $("#claude-account-prefer");
|
|
1273
|
+
if (preferNote) preferNote.hidden = !choosable;
|
|
1274
|
+
const count = (data.current ? 1 : 0) + stored.length;
|
|
1180
1275
|
const countNode = $("#claude-account-count");
|
|
1181
1276
|
if (countNode) countNode.textContent = t("providers.accountCount", { count });
|
|
1182
1277
|
}
|
|
@@ -1193,24 +1288,32 @@ function anthropicAccountsPanel(name, provider) {
|
|
|
1193
1288
|
const rotation = el("input", { type: "checkbox", checked: Boolean(provider.accountPool) });
|
|
1194
1289
|
const rotationMessage = el("span", { class: "small" });
|
|
1195
1290
|
rotation.addEventListener("change", () => void saveAnthropicRotation(name, provider, rotation.checked, rotation, rotationMessage));
|
|
1196
|
-
const add = el("button", { class: "
|
|
1291
|
+
const add = el("button", { class: "cell-button", type: "button", text: t("providers.addClaudeAccount") });
|
|
1197
1292
|
add.addEventListener("click", () => openClaudeAccountConsent(name));
|
|
1198
|
-
const removeAll = el("button", { class: "
|
|
1293
|
+
const removeAll = el("button", { class: "cell-button destructive", type: "button", text: t("providers.anthropicLogoutAll") });
|
|
1199
1294
|
removeAll.addEventListener("click", async () => {
|
|
1200
1295
|
if (!confirm(t("providers.anthropicLogoutAllConfirm"))) return;
|
|
1201
1296
|
try { await api("/api/claude-logout", { method: "POST" }); await refreshClaudeAccountPanel(name); }
|
|
1202
1297
|
catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
1203
1298
|
});
|
|
1204
|
-
|
|
1299
|
+
rotation.setAttribute("role", "switch");
|
|
1300
|
+
rotation.setAttribute("aria-label", t("providers.anthropicPool"));
|
|
1301
|
+
const rows = el("div", { id: "claude-account-rows", class: "group account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
|
|
1302
|
+
// The rotation switch first (it decides whether added accounts are used at all), then the
|
|
1303
|
+
// accounts with "Add" at the end of their list, then the one destructive act on its own.
|
|
1205
1304
|
const panel = el("div", { class: "provider-panel" }, [
|
|
1206
|
-
el("section", { class: "
|
|
1207
|
-
el("div", { class: "
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1305
|
+
el("section", { class: "section" }, [
|
|
1306
|
+
el("div", { class: "group" }, [cell(t("providers.anthropicPool"), el("label", { class: "switch" }, [rotation, el("span")]), [t("providers.accountPoolSubtitle"), rotationMessage])]),
|
|
1307
|
+
!provider.accountPool ? el("div", { class: "group-footer notice" }, [el("strong", { text: t("providers.rotationOff") }), el("span", { text: t("providers.rotationRequired") })]) : null,
|
|
1308
|
+
]),
|
|
1309
|
+
el("section", { class: "section" }, [
|
|
1310
|
+
el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: t("providers.accountPoolTitle") }), el("span", { id: "claude-account-count", class: "small", text: t("providers.accountCount", { count: 0 }) })]),
|
|
1311
|
+
rows,
|
|
1312
|
+
el("div", { class: "group stacked-add" }, [add]),
|
|
1313
|
+
el("p", { class: "group-footer", text: t("providers.accountCountHelp") }),
|
|
1314
|
+
el("p", { id: "claude-account-prefer", class: "group-footer", text: t("providers.preferHelp"), hidden: "" }),
|
|
1211
1315
|
]),
|
|
1212
|
-
|
|
1213
|
-
el("section", { class: "detail-section danger-section" }, [el("h3", { text: t("providers.dangerZone") }), hint(t("providers.anthropicLogoutAllConfirm")), removeAll]),
|
|
1316
|
+
section(t("providers.dangerZone"), [removeAll], t("providers.anthropicLogoutAllConfirm")),
|
|
1214
1317
|
]);
|
|
1215
1318
|
queueMicrotask(() => void refreshClaudeAccountPanel(name));
|
|
1216
1319
|
return panel;
|
|
@@ -1294,18 +1397,18 @@ function renderChatgptAccountRows(target, data, name, generation) {
|
|
|
1294
1397
|
const quota = chatgptQuotaText(account.quota);
|
|
1295
1398
|
const detail = [account.email && account.email !== account.label ? account.email : null, account.planType || null].filter(Boolean).join(" · ");
|
|
1296
1399
|
const help = account.state === "needs-login" || account.state === "quarantined" ? t("providers.chatgptReauthHelp") : own ? t("providers.chatgptOwnHelp") : t("providers.chatgptCodexHelp");
|
|
1297
|
-
return el("article", { class: `account-card
|
|
1298
|
-
el("div", { class: "account-card-copy" }, [
|
|
1400
|
+
return el("article", { class: `cell account-card${account.active ? " current" : ""}` }, [
|
|
1401
|
+
el("div", { class: "cell-text account-card-copy" }, [
|
|
1299
1402
|
el("strong", { text: account.label }),
|
|
1300
1403
|
detail ? el("span", { class: "small", text: detail }) : null,
|
|
1301
1404
|
quota ? el("p", { class: "small account-quota", text: quota }) : null,
|
|
1302
1405
|
hint(help),
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
]
|
|
1406
|
+
actions.length ? el("div", { class: "account-card-actions" }, actions) : null,
|
|
1407
|
+
]),
|
|
1408
|
+
el("div", { class: "account-card-badges" }, [chatgptStateBadge(account)]),
|
|
1409
|
+
]);
|
|
1307
1410
|
});
|
|
1308
|
-
target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty
|
|
1411
|
+
target.replaceChildren(...(rows.length ? rows : [el("div", { class: "account-empty", text: t("providers.chatgptNoAccounts") })]));
|
|
1309
1412
|
const countNode = $("#chatgpt-account-count");
|
|
1310
1413
|
if (countNode) countNode.textContent = t("providers.chatgptAccountCount", { count: accounts.length });
|
|
1311
1414
|
}
|
|
@@ -1397,17 +1500,17 @@ function renderGoogleAccountRows(target, data, name, generation) {
|
|
|
1397
1500
|
});
|
|
1398
1501
|
actions.push(pause, rename, remove);
|
|
1399
1502
|
const detail = [account.email && account.email !== account.label ? account.email : null, account.projectId ? t("providers.googleProject", { id: account.projectId }) : null].filter(Boolean).join(" · ");
|
|
1400
|
-
return el("article", { class: `account-card
|
|
1401
|
-
el("div", { class: "account-card-copy" }, [
|
|
1503
|
+
return el("article", { class: `cell account-card${account.active ? " current" : ""}` }, [
|
|
1504
|
+
el("div", { class: "cell-text account-card-copy" }, [
|
|
1402
1505
|
el("strong", { text: account.label }),
|
|
1403
1506
|
detail ? el("span", { class: "small", text: detail }) : null,
|
|
1404
1507
|
hint(account.state === "needs-login" || account.state === "quarantined" ? t("providers.chatgptReauthHelp") : account.state === "needs-verification" ? t("providers.googleVerifyHelp") : t("providers.googleOwnHelp")),
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
el("div", { class: "account-card-
|
|
1408
|
-
]
|
|
1508
|
+
el("div", { class: "account-card-actions" }, actions),
|
|
1509
|
+
]),
|
|
1510
|
+
el("div", { class: "account-card-badges" }, [chatgptStateBadge(account)]),
|
|
1511
|
+
]);
|
|
1409
1512
|
});
|
|
1410
|
-
target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty
|
|
1513
|
+
target.replaceChildren(...(rows.length ? rows : [el("div", { class: "account-empty", text: t("providers.googleNoAccounts") })]));
|
|
1411
1514
|
const countNode = $("#google-account-count");
|
|
1412
1515
|
if (countNode) countNode.textContent = t("providers.chatgptAccountCount", { count: accounts.length });
|
|
1413
1516
|
}
|
|
@@ -1421,17 +1524,19 @@ async function refreshGoogleAccountPanel(name) {
|
|
|
1421
1524
|
}
|
|
1422
1525
|
|
|
1423
1526
|
function googleAccountsPanel(name) {
|
|
1424
|
-
const add = el("button", { class: "
|
|
1527
|
+
const add = el("button", { class: "cell-button", type: "button", text: t("providers.addGoogleAccount") });
|
|
1425
1528
|
add.disabled = googleLoginBusy;
|
|
1426
1529
|
add.addEventListener("click", () => void startGoogleLogin(() => { renderProviderDetail(); void refreshGoogleAccountPanel(name); }));
|
|
1427
|
-
const rows = el("div", { id: "google-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
|
|
1530
|
+
const rows = el("div", { id: "google-account-rows", class: "group account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
|
|
1428
1531
|
const panel = el("div", { class: "provider-panel" }, [
|
|
1429
|
-
el("section", { class: "
|
|
1430
|
-
el("div", { class: "section-
|
|
1431
|
-
|
|
1432
|
-
el("
|
|
1433
|
-
|
|
1434
|
-
|
|
1532
|
+
el("section", { class: "section" }, [
|
|
1533
|
+
el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: t("providers.googleAccountsTitle") }), el("span", { id: "google-account-count", class: "small", text: t("providers.chatgptAccountCount", { count: 0 }) })]),
|
|
1534
|
+
rows,
|
|
1535
|
+
el("div", { class: "group stacked-add" }, [add]),
|
|
1536
|
+
googleLoginBusy ? el("p", { class: "group-footer", text: t("providers.googleLoginWaiting") }) : null,
|
|
1537
|
+
el("p", { class: "group-footer", text: t("providers.googleAccountsSubtitle") }),
|
|
1538
|
+
el("div", { class: "group-footer notice" }, [el("strong", { text: t("providers.googleWarningShort") })]),
|
|
1539
|
+
]),
|
|
1435
1540
|
]);
|
|
1436
1541
|
queueMicrotask(() => void refreshGoogleAccountPanel(name));
|
|
1437
1542
|
return panel;
|
|
@@ -1446,17 +1551,20 @@ async function refreshChatgptAccountPanel(name) {
|
|
|
1446
1551
|
}
|
|
1447
1552
|
|
|
1448
1553
|
function chatgptAccountsPanel(name) {
|
|
1449
|
-
const add = el("button", { class: "
|
|
1554
|
+
const add = el("button", { class: "cell-button", type: "button", text: t("providers.addChatgptAccount") });
|
|
1450
1555
|
add.disabled = chatgptLoginBusy;
|
|
1451
|
-
const waiting = el("p", { class: "
|
|
1556
|
+
const waiting = el("p", { class: "group-footer", text: chatgptLoginBusy ? t("providers.chatgptAddWaiting") : "" });
|
|
1557
|
+
waiting.hidden = !chatgptLoginBusy;
|
|
1452
1558
|
add.addEventListener("click", () => void startChatgptLogin(() => { renderProviderDetail(); void refreshChatgptAccountPanel(name); }, true));
|
|
1453
|
-
const rows = el("div", { id: "chatgpt-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
|
|
1559
|
+
const rows = el("div", { id: "chatgpt-account-rows", class: "group account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
|
|
1454
1560
|
const panel = el("div", { class: "provider-panel" }, [
|
|
1455
|
-
el("section", { class: "
|
|
1456
|
-
el("div", { class: "section-
|
|
1561
|
+
el("section", { class: "section" }, [
|
|
1562
|
+
el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: t("providers.chatgptAccountsTitle") }), el("span", { id: "chatgpt-account-count", class: "small", text: t("providers.chatgptAccountCount", { count: 0 }) })]),
|
|
1563
|
+
rows,
|
|
1564
|
+
el("div", { class: "group stacked-add" }, [add]),
|
|
1457
1565
|
waiting,
|
|
1566
|
+
el("p", { class: "group-footer", text: t("providers.chatgptAccountsSubtitle") }),
|
|
1458
1567
|
]),
|
|
1459
|
-
el("section", { class: "detail-section" }, [rows]),
|
|
1460
1568
|
]);
|
|
1461
1569
|
queueMicrotask(() => void refreshChatgptAccountPanel(name));
|
|
1462
1570
|
return panel;
|
|
@@ -1474,10 +1582,12 @@ function renderProviderDetail() {
|
|
|
1474
1582
|
const check = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
|
|
1475
1583
|
check.addEventListener("click", async () => { check.disabled = true; await probeProvider(name, provider); check.disabled = false; renderProviderWorkspace(); });
|
|
1476
1584
|
const edit = el("button", { class: "btn secondary", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) });
|
|
1477
|
-
|
|
1585
|
+
// Removing is the one destructive act on this page, so it sits apart at the bottom, not beside Edit.
|
|
1586
|
+
const remove = el("button", { class: "cell-button destructive", type: "button", text: t("providers.remove"), onclick: () => void removeProvider(name, provider) });
|
|
1587
|
+
const back = el("button", { class: "provider-back", type: "button", onclick: closeProviderDetail }, [svgIcon("0 0 9 15", ["M7.5 1.5l-6 6 6 6"]), el("span", { text: t("nav.providers") })]);
|
|
1478
1588
|
const header = el("header", { class: "provider-detail-header" }, [
|
|
1479
1589
|
el("div", {}, [el("div", { class: "provider-title-line" }, [el("h2", { text: name }), providerState(name, provider)]), el("p", { class: "small", text: providerKind(name, provider) })]),
|
|
1480
|
-
el("div", { class: "provider-detail-actions" }, [check, edit
|
|
1590
|
+
el("div", { class: "provider-detail-actions" }, [check, edit]),
|
|
1481
1591
|
]);
|
|
1482
1592
|
let content;
|
|
1483
1593
|
if (providerDetailTab === "accounts" && provider.type === "anthropic" && provider.auth === "claude-code") content = anthropicAccountsPanel(name, provider);
|
|
@@ -1485,7 +1595,7 @@ function renderProviderDetail() {
|
|
|
1485
1595
|
else if (providerDetailTab === "accounts" && provider.type === "google" && provider.auth === "antigravity") content = googleAccountsPanel(name);
|
|
1486
1596
|
else if (providerDetailTab === "models") content = providerModelsPanel(name, provider);
|
|
1487
1597
|
else { providerDetailTab = "overview"; content = providerOverview(name, provider); }
|
|
1488
|
-
detail.replaceChildren(header, providerTabs(name, provider), content);
|
|
1598
|
+
detail.replaceChildren(back, header, providerTabs(name, provider), content, el("div", { class: "group provider-remove" }, [remove]));
|
|
1489
1599
|
}
|
|
1490
1600
|
|
|
1491
1601
|
function renderProviderWorkspace() {
|
|
@@ -1631,35 +1741,34 @@ async function openClaudeAccountSignIn(providerName, manual) {
|
|
|
1631
1741
|
} catch (error) { showError({ ok: false, error: error.message }); }
|
|
1632
1742
|
}
|
|
1633
1743
|
|
|
1744
|
+
// The services one can connect, as grouped lists: sign-in subscriptions first, then the API-key
|
|
1745
|
+
// presets by the format they speak (the "paste a key" note is said once per group, not per row),
|
|
1746
|
+
// then the manual entry on its own.
|
|
1634
1747
|
function openProviderChooser() {
|
|
1635
1748
|
const grid = el("div", { class: "chooser-grid" });
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
chatgpt.addEventListener("click", () => openProviderForm({ kind: "chatgpt" }));
|
|
1641
|
-
grid.appendChild(chatgpt);
|
|
1642
|
-
const google = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.google") }), el("span", { text: t("providers.googleHelp") })]);
|
|
1643
|
-
google.addEventListener("click", () => openProviderForm({ kind: "google" }));
|
|
1644
|
-
grid.appendChild(google);
|
|
1645
|
-
const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
|
|
1646
|
-
for (const preset of native) {
|
|
1647
|
-
const tile = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: preset.name }), el("span", { text: t("providers.presetHelp") })]);
|
|
1648
|
-
tile.addEventListener("click", () => openProviderForm({ preset }));
|
|
1649
|
-
grid.appendChild(tile);
|
|
1749
|
+
function tile(title, help, options) {
|
|
1750
|
+
const button = el("button", { class: "chooser-tile", type: "button" }, [el("span", { class: "cell-text" }, [el("strong", { text: title }), help ? el("span", { text: help }) : null]), chevronIcon()]);
|
|
1751
|
+
button.addEventListener("click", () => openProviderForm(options));
|
|
1752
|
+
return button;
|
|
1650
1753
|
}
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
grid.appendChild(el("
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
}
|
|
1754
|
+
function group(title, tiles, footer, single) {
|
|
1755
|
+
if (!tiles.length) return;
|
|
1756
|
+
grid.appendChild(el("section", { class: "chooser-section" }, [
|
|
1757
|
+
title ? el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: title })]) : null,
|
|
1758
|
+
el("div", { class: `group chooser-group chooser-tiles${single ? " single" : ""}` }, tiles),
|
|
1759
|
+
footer ? el("p", { class: "group-footer", text: footer }) : null,
|
|
1760
|
+
]));
|
|
1659
1761
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1762
|
+
group(t("providers.subscriptionGroup"), [
|
|
1763
|
+
tile(t("providers.anthropic"), t("providers.anthropicHelp"), { kind: "anthropic" }),
|
|
1764
|
+
tile(t("providers.chatgpt"), t("providers.chatgptHelp"), { kind: "chatgpt" }),
|
|
1765
|
+
tile(t("providers.google"), t("providers.googleHelp"), { kind: "google" }),
|
|
1766
|
+
], null, true);
|
|
1767
|
+
const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
|
|
1768
|
+
group(t("providers.anthropicGroup"), native.map((preset) => tile(preset.name, null, { preset })), t("providers.presetHelp"));
|
|
1769
|
+
const openai = presets.filter((preset) => preset.kind === "openai-compatible");
|
|
1770
|
+
group(t("providers.openaiGroup"), openai.map((preset) => tile(preset.name, null, { preset })), `${t("providers.presetHelp")} ${t("providers.openaiGroupHelp")}`);
|
|
1771
|
+
group(null, [tile(t("providers.custom"), t("providers.customHelp"), { kind: "custom" })], null, true);
|
|
1663
1772
|
showModal(el("div", {}, [el("h1", { id: "modal-title", text: t("providers.choose") }), hint(t("providers.chooseHelp")), grid]));
|
|
1664
1773
|
}
|
|
1665
1774
|
function inputRow(label, control, helpText) {
|
|
@@ -2241,6 +2350,619 @@ function openProviderForm(options) {
|
|
|
2241
2350
|
showModal(form, () => { if (isChatgpt) formActive = false; });
|
|
2242
2351
|
}
|
|
2243
2352
|
|
|
2353
|
+
// ---- ClaudeRipple mod ---------------------------------------------------------------
|
|
2354
|
+
//
|
|
2355
|
+
// The mod's settings live in the router (GET/PUT /api/mod-settings); the ⚙ panel in the bar edits
|
|
2356
|
+
// the same object. As in System Settings a change applies itself: it is saved after a short pause
|
|
2357
|
+
// with the revision it was made on. A 409 means another surface saved first — its settings are
|
|
2358
|
+
// shown and the change made here is dropped, rather than quietly overwriting theirs.
|
|
2359
|
+
|
|
2360
|
+
const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
2361
|
+
// The band's parts the person can turn off (`hide`), then the ones it shows only when turned on
|
|
2362
|
+
// (`extras`, as the mod's settings.ts EXTRAS): the judge's reason is in the effort's tip alone.
|
|
2363
|
+
const MOD_PARTS = ["timer", "route"];
|
|
2364
|
+
const MOD_EXTRAS = ["context", "limits"];
|
|
2365
|
+
// Named in themselves, as the mod's own Look card lists them.
|
|
2366
|
+
const MOD_LANGUAGES = [["ko", "한국어"], ["en", "English"], ["zh", "简体中文"], ["ja", "日本語"], ["es", "Español"], ["pt", "Português"]];
|
|
2367
|
+
// Claude Code's own agent with a model: what the mod offers besides the generated workers, smallest
|
|
2368
|
+
// first, and their current model ids (packages/mod/hooks/settings.ts CLAUDE_WORKERS).
|
|
2369
|
+
const MOD_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"];
|
|
2370
|
+
const MOD_CLAUDE_IDS = { haiku: "claude-haiku-5-5", sonnet: "claude-sonnet-5-5", opus: "claude-opus-5-5", fable: "claude-fable-5-1" };
|
|
2371
|
+
// The four workers, low to max (the mod's rung ids: light, standard, hard, plan).
|
|
2372
|
+
const MOD_RUNGS = ["light", "standard", "hard", "plan"];
|
|
2373
|
+
const modClaude = (model, effort) => ({ target: { agent: "general-purpose", model }, effort });
|
|
2374
|
+
/** The mod's defaults (packages/mod/hooks/settings.ts), for a router that has stored nothing yet. */
|
|
2375
|
+
const MOD_DEFAULTS = {
|
|
2376
|
+
effortAuto: true, judge: "haiku", judgeModel: "deepseek-v4.1-flash", jevSource: "typesafe",
|
|
2377
|
+
bias: 0, floor: "low", ceiling: "max", agentAuto: "auto",
|
|
2378
|
+
rules: {
|
|
2379
|
+
general: { mode: "ladder", rungs: {
|
|
2380
|
+
light: [{ target: { agent: "deepseek-v4-1-flash" }, effort: "max" }], standard: [{ target: { agent: "gpt-6-1-sol" }, effort: "medium" }],
|
|
2381
|
+
hard: [{ target: { agent: "gpt-6-astra" }, effort: "high" }], plan: [modClaude("opus", "high")],
|
|
2382
|
+
} },
|
|
2383
|
+
},
|
|
2384
|
+
allow: [], modes: [], providers: {},
|
|
2385
|
+
fallback: { agent: "general-purpose", model: "sonnet" },
|
|
2386
|
+
keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
|
|
2387
|
+
compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
|
|
2388
|
+
swampAt: 50, layout: "dashboard", appearance: "auto", language: "auto", hide: ["reason"], extras: [], setupDone: false,
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
let modState = { settings: null, revision: 0, options: null, retiredNote: false };
|
|
2392
|
+
let modSaveTimer = null;
|
|
2393
|
+
let modSaving = false;
|
|
2394
|
+
let modSaveAgain = false;
|
|
2395
|
+
|
|
2396
|
+
/**
|
|
2397
|
+
* The router hands back the mod's full normalized settings; this only fills what an older router
|
|
2398
|
+
* (or an empty store) leaves out, so the page never draws a control over `undefined`. Fields this
|
|
2399
|
+
* page does not know are kept and sent back as they came.
|
|
2400
|
+
*/
|
|
2401
|
+
function modNormalize(raw) {
|
|
2402
|
+
const value = raw && typeof raw === "object" && !Array.isArray(raw) ? clone(raw) : {};
|
|
2403
|
+
return {
|
|
2404
|
+
...clone(MOD_DEFAULTS),
|
|
2405
|
+
...value,
|
|
2406
|
+
rules: { ...clone(MOD_DEFAULTS.rules), ...(value.rules || {}) },
|
|
2407
|
+
allow: Array.isArray(value.allow) ? value.allow : [],
|
|
2408
|
+
modes: Array.isArray(value.modes) ? value.modes : [],
|
|
2409
|
+
providers: { ...(value.providers || {}) },
|
|
2410
|
+
hide: Array.isArray(value.hide) ? value.hide : clone(MOD_DEFAULTS.hide),
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function modCap(text) { return text ? text.charAt(0).toLocaleUpperCase() + text.slice(1) : text; }
|
|
2415
|
+
function modEffortName(effort) { return t(`mod.effort.${effort}`); }
|
|
2416
|
+
function modSigned(n) { return n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0"; }
|
|
2417
|
+
function modTargetValue(target) { return target && target.agent ? (target.model ? `${target.agent}+${target.model}` : target.agent) : ""; }
|
|
2418
|
+
function modTargetOf(value) {
|
|
2419
|
+
const plus = value.indexOf("+");
|
|
2420
|
+
return plus < 0 ? { agent: value } : { agent: value.slice(0, plus), model: value.slice(plus + 1) };
|
|
2421
|
+
}
|
|
2422
|
+
function modAgents() { return (modState.options && modState.options.agents) || []; }
|
|
2423
|
+
function modProviderInfo(name) { return ((modState.options && modState.options.providers) || []).find((provider) => provider.name === name); }
|
|
2424
|
+
function modModels() {
|
|
2425
|
+
const seen = new Set();
|
|
2426
|
+
return ((modState.options && modState.options.models) || []).filter((model) => !model.judgeOnly && !seen.has(model.model) && seen.add(model.model));
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
/** Rebuild a region in place, keeping keyboard focus on the control that had it. */
|
|
2430
|
+
function modRedraw(root, nodes) {
|
|
2431
|
+
const active = document.activeElement;
|
|
2432
|
+
const key = active && root.contains(active) ? active.dataset.key : null;
|
|
2433
|
+
root.replaceChildren(...nodes.filter(Boolean));
|
|
2434
|
+
if (key) {
|
|
2435
|
+
const again = root.querySelector(`[data-key="${CSS.escape(key)}"]`);
|
|
2436
|
+
if (again) again.focus();
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
function modSwitch(key, checked, label, onChange) {
|
|
2440
|
+
const input = el("input", { type: "checkbox", role: "switch", "data-key": key, "aria-label": label, checked });
|
|
2441
|
+
input.addEventListener("change", () => onChange(input.checked));
|
|
2442
|
+
return el("span", { class: "switch" }, [input, el("span")]);
|
|
2443
|
+
}
|
|
2444
|
+
function modSegmented(key, choices, value, label, onPick) {
|
|
2445
|
+
return el("div", { class: "segmented", role: "radiogroup", "aria-label": label }, choices.map((choice) => {
|
|
2446
|
+
const on = String(choice.value) === String(value);
|
|
2447
|
+
const button = el("button", { type: "button", class: on ? "active" : "", role: "radio", "aria-checked": String(on), "data-key": `${key}:${choice.value}`, text: choice.label, ...(choice.disabled ? { disabled: "" } : {}) });
|
|
2448
|
+
button.addEventListener("click", () => { if (!on) onPick(choice.value); });
|
|
2449
|
+
return button;
|
|
2450
|
+
}));
|
|
2451
|
+
}
|
|
2452
|
+
/** A pop-up menu. Items are `{value, label}` or `{group, items}`; a saved value no longer offered stays visible. */
|
|
2453
|
+
function modSelect(key, items, value, label, onChange, cls) {
|
|
2454
|
+
const select = el("select", { "data-key": key, "aria-label": label, class: cls });
|
|
2455
|
+
const known = new Set();
|
|
2456
|
+
const add = (parent, item) => { known.add(String(item.value)); parent.appendChild(el("option", { value: item.value, text: item.label })); };
|
|
2457
|
+
for (const item of items) {
|
|
2458
|
+
if (!item.group) { add(select, item); continue; }
|
|
2459
|
+
if (!item.items.length) continue;
|
|
2460
|
+
const group = el("optgroup", { label: item.group });
|
|
2461
|
+
for (const entry of item.items) add(group, entry);
|
|
2462
|
+
select.appendChild(group);
|
|
2463
|
+
}
|
|
2464
|
+
if (!known.has(String(value))) select.appendChild(el("option", { value, text: `${value}${t("mod.unavailable")}` }));
|
|
2465
|
+
select.value = String(value);
|
|
2466
|
+
select.addEventListener("change", () => onChange(select.value));
|
|
2467
|
+
return select;
|
|
2468
|
+
}
|
|
2469
|
+
function modSection(title, rows, footers, action) {
|
|
2470
|
+
return el("section", { class: "section" }, [
|
|
2471
|
+
title || action ? el("div", { class: "section-head" }, [title ? el("h3", { class: "section-title", text: title }) : el("span"), action || null]) : null,
|
|
2472
|
+
el("div", { class: "group" }, rows.filter(Boolean)),
|
|
2473
|
+
...(footers || []).filter(Boolean).map((footer) => typeof footer === "string" ? el("p", { class: "group-footer", text: footer }) : footer),
|
|
2474
|
+
]);
|
|
2475
|
+
}
|
|
2476
|
+
function modCard(id, title, blurb, sections) {
|
|
2477
|
+
return el("section", { class: "mod-card", "aria-labelledby": `mod-card-${id}` }, [
|
|
2478
|
+
el("div", { class: "mod-card-head" }, [el("h2", { id: `mod-card-${id}`, text: title }), blurb ? el("p", { class: "hint", text: blurb }) : null]),
|
|
2479
|
+
...sections.filter(Boolean),
|
|
2480
|
+
]);
|
|
2481
|
+
}
|
|
2482
|
+
// ---- Saving
|
|
2483
|
+
|
|
2484
|
+
function modStatus(text, isError) {
|
|
2485
|
+
const box = $("#mod-status");
|
|
2486
|
+
box.textContent = text;
|
|
2487
|
+
box.classList.toggle("bad-text", Boolean(isError));
|
|
2488
|
+
}
|
|
2489
|
+
function modPatch(change) {
|
|
2490
|
+
modState.settings = { ...modState.settings, ...change };
|
|
2491
|
+
modRefresh();
|
|
2492
|
+
clearTimeout(modSaveTimer);
|
|
2493
|
+
modStatus(t("slots.saving"));
|
|
2494
|
+
modSaveTimer = setTimeout(() => { modSaveTimer = null; void saveMod(); }, 450);
|
|
2495
|
+
}
|
|
2496
|
+
function modSetRule(domain, rule) { modPatch({ rules: { ...modState.settings.rules, [domain]: rule } }); }
|
|
2497
|
+
async function saveMod() {
|
|
2498
|
+
if (modSaving) { modSaveAgain = true; return; }
|
|
2499
|
+
modSaving = true;
|
|
2500
|
+
try {
|
|
2501
|
+
const out = await api("/api/mod-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ schemaVersion: 1, settings: modState.settings, revision: modState.revision }) });
|
|
2502
|
+
modState.revision = Number(out.revision) || modState.revision + 1;
|
|
2503
|
+
// The router's copy is the normalized one; take it unless a newer change is already waiting.
|
|
2504
|
+
if (out.settings && !modSaveTimer && !modSaveAgain) { modState.settings = modNormalize(out.settings); modRefresh(); }
|
|
2505
|
+
modStatus(t("mod.saved"));
|
|
2506
|
+
} catch (error) {
|
|
2507
|
+
if (error.status === 409 && error.body && "settings" in error.body) {
|
|
2508
|
+
clearTimeout(modSaveTimer);
|
|
2509
|
+
modSaveTimer = null;
|
|
2510
|
+
modSaveAgain = false;
|
|
2511
|
+
modState.settings = modNormalize(error.body.settings);
|
|
2512
|
+
modState.revision = Number(error.body.revision) || 0;
|
|
2513
|
+
modRefresh();
|
|
2514
|
+
modStatus(t("mod.conflict"), true);
|
|
2515
|
+
toast(t("mod.conflict"), true);
|
|
2516
|
+
} else {
|
|
2517
|
+
modStatus(`${t("common.saveFailed")} ${error.message}`, true);
|
|
2518
|
+
}
|
|
2519
|
+
} finally {
|
|
2520
|
+
modSaving = false;
|
|
2521
|
+
if (modSaveAgain) { modSaveAgain = false; void saveMod(); }
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
async function loadMod() {
|
|
2525
|
+
// A change still on its way would be overwritten by what the router holds now.
|
|
2526
|
+
if (modSaveTimer || modSaving) return;
|
|
2527
|
+
let retired = false;
|
|
2528
|
+
const [settingsResult, optionsResult] = await Promise.allSettled([api("/api/mod-settings"), api("/api/mod-options")]);
|
|
2529
|
+
if (settingsResult.status === "rejected") {
|
|
2530
|
+
if (!modState.settings) {
|
|
2531
|
+
$("#mod-content").replaceChildren(modSection(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${settingsResult.reason.message}` })]));
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
} else {
|
|
2535
|
+
modState.settings = modNormalize(settingsResult.value.settings);
|
|
2536
|
+
modState.revision = Number(settingsResult.value.revision) || 0;
|
|
2537
|
+
retired = settingsResult.value.legacy === true;
|
|
2538
|
+
}
|
|
2539
|
+
modState.options = optionsResult.status === "fulfilled" ? optionsResult.value : null;
|
|
2540
|
+
await modLoadResets();
|
|
2541
|
+
if (retired) modState.retiredNote = true;
|
|
2542
|
+
modRefresh();
|
|
2543
|
+
// The settings came back without Auto and the field rules; saving them as they are keeps the note to this once.
|
|
2544
|
+
if (retired) modPatch({});
|
|
2545
|
+
}
|
|
2546
|
+
function modRefresh() {
|
|
2547
|
+
if (!modState.settings) return;
|
|
2548
|
+
renderModNotice();
|
|
2549
|
+
modRedraw($("#mod-content"), [modEffortCard(), modWorkersCard(), modJudgeCard(), modCacheCard(), modHandoffCard(), modLookCard()]);
|
|
2550
|
+
}
|
|
2551
|
+
/** Settings for a mod that is switched off do nothing until it is on; the switch lives in Clients. */
|
|
2552
|
+
function renderModNotice() {
|
|
2553
|
+
const box = $("#mod-notice");
|
|
2554
|
+
const off = Boolean(status) && !status.statusMod;
|
|
2555
|
+
if (box.dataset.state === String(off)) return;
|
|
2556
|
+
box.dataset.state = String(off);
|
|
2557
|
+
box.hidden = !off;
|
|
2558
|
+
box.replaceChildren(...(off ? [el("div", { class: "group" }, [cell(
|
|
2559
|
+
el("span", { class: "cell-label", text: t("mod.offNotice", { clients: t("nav.clients") }) }),
|
|
2560
|
+
el("button", { class: "btn secondary compact", type: "button", text: t("nav.clients"), onclick: () => showView("clients") }),
|
|
2561
|
+
)])] : []));
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// ---- Cards
|
|
2565
|
+
|
|
2566
|
+
function modEffortCard() {
|
|
2567
|
+
const s = modState.settings;
|
|
2568
|
+
const efforts = MOD_EFFORTS.map((effort) => ({ value: effort, label: modEffortName(effort) }));
|
|
2569
|
+
const range = el("input", { type: "range", class: "lean-range", min: "-2", max: "2", step: "1", value: String(s.bias), "data-key": "bias", "aria-label": t("mod.lean"), "aria-valuetext": modSigned(s.bias) });
|
|
2570
|
+
range.addEventListener("change", () => modPatch({ bias: Number(range.value) }));
|
|
2571
|
+
const lean = el("div", { class: "lean" }, [
|
|
2572
|
+
el("span", { class: "lean-end", text: t("mod.cheaper") }),
|
|
2573
|
+
el("div", { class: "lean-track" }, [range, el("div", { class: "lean-ticks", "aria-hidden": "true" }, [-2, -1, 0, 1, 2].map(() => el("span")))]),
|
|
2574
|
+
el("span", { class: "lean-end", text: t("mod.smarter") }),
|
|
2575
|
+
]);
|
|
2576
|
+
const inverted = MOD_EFFORTS.indexOf(s.ceiling) < MOD_EFFORTS.indexOf(s.floor);
|
|
2577
|
+
return modCard("effort", t("mod.effort"), t("mod.effortBlurb"), [modSection(null, [
|
|
2578
|
+
cell(t("mod.auto"), modSwitch("effort-auto", s.effortAuto, t("mod.auto"), (on) => modPatch({ effortAuto: on })), modCap(t("mod.autoHint"))),
|
|
2579
|
+
cell(t("mod.lean"), lean, modCap(t("mod.leanHint")), "wide"),
|
|
2580
|
+
cell(t("mod.floor"), modSelect("floor", efforts, s.floor, t("mod.floor"), (floor) => modPatch({ floor }), "narrow")),
|
|
2581
|
+
cell(t("mod.ceiling"), modSelect("ceiling", efforts, s.ceiling, t("mod.ceiling"), (ceiling) => modPatch({ ceiling }), "narrow"), inverted ? el("span", { class: "cell-sub warn-text", text: modCap(t("mod.floorWins")) }) : null),
|
|
2582
|
+
])]);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// ---- The four workers (docs/specs/2026-10-12-worker-tiers.md)
|
|
2586
|
+
|
|
2587
|
+
/**
|
|
2588
|
+
* Everything a worker can run: the router's workers (each with its model's effort ladder and how it
|
|
2589
|
+
* is paid for), Claude Code's own agent on each Claude model, and the session's own model.
|
|
2590
|
+
*/
|
|
2591
|
+
function modChoices() {
|
|
2592
|
+
const options = modState.options || {};
|
|
2593
|
+
const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "max"];
|
|
2594
|
+
const workers = modAgents().map((agent) => {
|
|
2595
|
+
const info = modProviderInfo(agent.provider);
|
|
2596
|
+
return {
|
|
2597
|
+
value: agent.name, label: agent.model, model: agent.model, provider: agent.provider,
|
|
2598
|
+
billing: /(?::|-)free$/i.test(agent.model) ? "free" : agent.billing || (info && info.billing) || null,
|
|
2599
|
+
usable: !info || info.usable !== false,
|
|
2600
|
+
efforts: Array.isArray(agent.efforts) ? agent.efforts : effortLevelsFor(agent.provider, agent.model),
|
|
2601
|
+
};
|
|
2602
|
+
});
|
|
2603
|
+
const claude = MOD_CLAUDE_MODELS.map((alias) => ({
|
|
2604
|
+
value: `general-purpose+${alias}`, label: `Claude ${modCap(alias)}`, model: MOD_CLAUDE_IDS[alias], provider: "anthropic",
|
|
2605
|
+
billing: "subscription", usable: true, efforts: claudeEfforts, claude: true,
|
|
2606
|
+
}));
|
|
2607
|
+
const session = { value: "general-purpose", label: t("mod.sessionModel"), model: null, provider: null, billing: null, usable: true, efforts: claudeEfforts, claude: true };
|
|
2608
|
+
return [...workers, ...claude, session];
|
|
2609
|
+
}
|
|
2610
|
+
function modChoiceOf(target) { const value = modTargetValue(target); return modChoices().find((choice) => choice.value === value); }
|
|
2611
|
+
/** The efforts a model takes, in the mod's terms; an unknown ladder offers them all. */
|
|
2612
|
+
function modLadderOf(choice) {
|
|
2613
|
+
const levels = choice && Array.isArray(choice.efforts) ? choice.efforts.filter((effort) => MOD_EFFORTS.includes(effort)) : [];
|
|
2614
|
+
return levels.length ? levels : [...MOD_EFFORTS];
|
|
2615
|
+
}
|
|
2616
|
+
/** The nearest effort a model takes, ties going up: how the router clamps one (compat.ts). */
|
|
2617
|
+
function modNearest(effort, ladder) {
|
|
2618
|
+
if (ladder.includes(effort)) return effort;
|
|
2619
|
+
const at = MOD_EFFORTS.indexOf(effort);
|
|
2620
|
+
return ladder.reduce((best, next) => {
|
|
2621
|
+
const d = Math.abs(MOD_EFFORTS.indexOf(next) - at);
|
|
2622
|
+
const bd = Math.abs(MOD_EFFORTS.indexOf(best) - at);
|
|
2623
|
+
return d < bd || (d === bd && MOD_EFFORTS.indexOf(next) > MOD_EFFORTS.indexOf(best)) ? next : best;
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
/** The group a model sits in: its provider, Claude's own together. */
|
|
2627
|
+
function modGroupName(choice) { return choice.claude ? "Claude" : choice.provider; }
|
|
2628
|
+
/** Choices in provider groups, each group tagged free / pay-per-token / subscription. */
|
|
2629
|
+
function modGroups(choices) {
|
|
2630
|
+
const groups = [];
|
|
2631
|
+
for (const choice of choices) {
|
|
2632
|
+
const name = modGroupName(choice);
|
|
2633
|
+
let group = groups.find((entry) => entry.name === name);
|
|
2634
|
+
if (!group) { group = { name, billing: choice.billing, items: [] }; groups.push(group); }
|
|
2635
|
+
group.items.push(choice);
|
|
2636
|
+
}
|
|
2637
|
+
return groups;
|
|
2638
|
+
}
|
|
2639
|
+
function modGroupLabel(group) { return group.billing ? `${group.name} · ${t(`mod.billing.${group.billing}`)}` : group.name; }
|
|
2640
|
+
|
|
2641
|
+
// ---- The models to use: what the four workers are filled from and may run on
|
|
2642
|
+
|
|
2643
|
+
/** An allow-list key: a worker's agent name, or `general-purpose:<model>` for Claude's own. */
|
|
2644
|
+
function modTickKey(choice) { return choice.claude ? choice.value.replace("+", ":") : choice.value; }
|
|
2645
|
+
/** The models that can be ticked: every worker and Claude's own; the session's model is always there. */
|
|
2646
|
+
function modTickable() { return modChoices().filter((choice) => choice.value !== "general-purpose"); }
|
|
2647
|
+
/** Whether a model is ticked: an empty list means every model, and an old provider name ticks that provider's. */
|
|
2648
|
+
function modTicked(choice) {
|
|
2649
|
+
const allow = modState.settings.allow || [];
|
|
2650
|
+
return !allow.length || allow.includes(modTickKey(choice)) || allow.includes(choice.provider);
|
|
2651
|
+
}
|
|
2652
|
+
/** Ticks one model; every model on again is stored as [], and the last one on cannot go off. */
|
|
2653
|
+
function modTick(choice, on) {
|
|
2654
|
+
const all = modTickable();
|
|
2655
|
+
const kept = new Set(all.filter(modTicked).map(modTickKey));
|
|
2656
|
+
if (on) kept.add(modTickKey(choice)); else kept.delete(modTickKey(choice));
|
|
2657
|
+
modPatch({ allow: kept.size === all.length ? [] : all.map(modTickKey).filter((key) => kept.has(key)) });
|
|
2658
|
+
}
|
|
2659
|
+
function modTickRows() {
|
|
2660
|
+
const choices = modTickable();
|
|
2661
|
+
if (!choices.length) return [el("div", { class: "cell-note", text: t("mod.noModels") })];
|
|
2662
|
+
const onCount = choices.filter(modTicked).length;
|
|
2663
|
+
return modGroups(choices).map((group) => {
|
|
2664
|
+
const boxes = group.items.map((choice) => {
|
|
2665
|
+
const on = modTicked(choice);
|
|
2666
|
+
const input = el("input", { type: "checkbox", "data-key": `tick:${modTickKey(choice)}`, "aria-label": `${choice.label} · ${group.name}`, checked: on });
|
|
2667
|
+
if (on && onCount === 1) input.disabled = true;
|
|
2668
|
+
input.addEventListener("change", () => modTick(choice, input.checked));
|
|
2669
|
+
// A model paid differently from its group (a :free model on a paid provider) says so beside its name.
|
|
2670
|
+
const other = choice.billing && choice.billing !== group.billing ? el("span", { class: "model-effort-tag", text: t(`mod.billing.${choice.billing}`) }) : null;
|
|
2671
|
+
const off = choice.usable ? null : el("span", { class: "model-effort-tag warn-text", text: t("mod.notUsable") });
|
|
2672
|
+
return el("label", { class: "model-check", title: choice.model || choice.label }, [input, el("span", { text: choice.label }), other, off]);
|
|
2673
|
+
});
|
|
2674
|
+
return el("div", { class: "tick-group" }, [el("div", { class: "tick-head", text: modGroupLabel(group) }), el("div", { class: "model-checklist" }, boxes)]);
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// ---- The four workers: a meaning, the pick, and one menu to change it
|
|
2679
|
+
|
|
2680
|
+
/** The general ladder's rungs; the router fills any that are empty, so a missing one is only a failed load. */
|
|
2681
|
+
function modRungs() {
|
|
2682
|
+
const general = modState.settings.rules.general;
|
|
2683
|
+
return general && general.mode === "ladder" && general.rungs ? general.rungs : {};
|
|
2684
|
+
}
|
|
2685
|
+
/** One menu of `model · effort` for the ticked models, effort limited to what each model takes; the session's model too. */
|
|
2686
|
+
function modPickSelect(key, step, shown, label, onPick) {
|
|
2687
|
+
const choices = modChoices().filter((choice) => choice.value === "general-purpose" || modTicked(choice));
|
|
2688
|
+
const groups = modGroups(choices).map((group) => ({
|
|
2689
|
+
group: modGroupLabel(group),
|
|
2690
|
+
items: group.items.flatMap((choice) => modLadderOf(choice).map((effort) => ({ value: `${choice.value}|${effort}`, label: `${choice.label} · ${modEffortName(effort)}${choice.usable ? "" : t("mod.unavailableNow")}` }))),
|
|
2691
|
+
}));
|
|
2692
|
+
const current = step ? `${modTargetValue(step.target)}|${shown}` : "";
|
|
2693
|
+
const items = [...(step ? [] : [{ value: "", label: "—" }]), ...groups];
|
|
2694
|
+
// A pick that is not on offer now (its model unticked, or gone) keeps showing rather than a wrong neighbour.
|
|
2695
|
+
if (step && !groups.some((group) => group.items.some((item) => item.value === current))) {
|
|
2696
|
+
const choice = modChoiceOf(step.target);
|
|
2697
|
+
items.unshift({ value: current, label: `${choice ? choice.label : modTargetValue(step.target)} · ${modEffortName(shown)}${t("mod.unavailable")}` });
|
|
2698
|
+
}
|
|
2699
|
+
return modSelect(key, items, current, label, (value) => {
|
|
2700
|
+
if (!value) return;
|
|
2701
|
+
const bar = value.lastIndexOf("|");
|
|
2702
|
+
onPick(modTargetOf(value.slice(0, bar)), value.slice(bar + 1));
|
|
2703
|
+
}, "rung-model");
|
|
2704
|
+
}
|
|
2705
|
+
function modRungRows(mode) {
|
|
2706
|
+
const rungs = mode ? mode.rungs : modRungs();
|
|
2707
|
+
return MOD_RUNGS.map((rung) => {
|
|
2708
|
+
const steps = rungs[rung] || [];
|
|
2709
|
+
const name = t(`mod.rung.${rung}`);
|
|
2710
|
+
const save = (next) => {
|
|
2711
|
+
const updated = { ...rungs, [rung]: next };
|
|
2712
|
+
if (mode) modUpdateMode(mode.id, { rungs: updated });
|
|
2713
|
+
else modSetRule("general", { mode: "ladder", rungs: updated });
|
|
2714
|
+
};
|
|
2715
|
+
const rows = steps.map((step, index) => {
|
|
2716
|
+
const choice = modChoiceOf(step.target);
|
|
2717
|
+
const shown = modNearest(step.effort, modLadderOf(choice));
|
|
2718
|
+
const key = `worker:${mode ? mode.id : "base"}:${rung}:${index}`;
|
|
2719
|
+
const select = modPickSelect(key, step, shown, `${name} ${index + 1}`, (target, effort) => save(steps.map((item, at) => at === index ? { target, effort } : item)));
|
|
2720
|
+
const move = (delta) => { const next = [...steps]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; save(next); };
|
|
2721
|
+
const button = (suffix, text, label, disabled, onclick) => el("button", { type: "button", class: "btn secondary compact", "data-key": `${key}:${suffix}`, text, "aria-label": label, ...(disabled ? { disabled: "" } : {}), onclick });
|
|
2722
|
+
return el("li", { class: "rung-item" }, [
|
|
2723
|
+
select,
|
|
2724
|
+
el("div", { class: "rung-actions" }, [
|
|
2725
|
+
button("up", "↑", t("mod.modeMoveUp"), index === 0, () => move(-1)),
|
|
2726
|
+
button("down", "↓", t("mod.modeMoveDown"), index === steps.length - 1, () => move(1)),
|
|
2727
|
+
button("remove", "✕", t("common.remove"), steps.length <= 1, () => save(steps.filter((_, at) => at !== index))),
|
|
2728
|
+
]),
|
|
2729
|
+
choice && !choice.usable ? el("small", { class: "warn-text", text: t("mod.rungUnusable") }) : null,
|
|
2730
|
+
]);
|
|
2731
|
+
});
|
|
2732
|
+
const add = el("button", { type: "button", class: "btn secondary compact", "data-key": `worker-add:${mode ? mode.id : "base"}:${rung}`, text: t("mod.modeAddModel"), onclick: () => {
|
|
2733
|
+
const choice = modChoices().find((item) => item.value !== "general-purpose" && modTicked(item)) || modChoices().at(-1);
|
|
2734
|
+
save([...steps, { target: modTargetOf(choice.value), effort: modNearest("medium", modLadderOf(choice)) }]);
|
|
2735
|
+
} });
|
|
2736
|
+
return cell(el("span", { class: "cell-label strong", text: name }), el("div", { class: "rung-list" }, [el("ol", {}, rows), add]), modCap(t(`mod.rungHint.${rung}`)), "wide rung-row");
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
/** Said once after settings that still held Auto or field rules are read: they became the four workers. */
|
|
2740
|
+
function modRetiredNote() {
|
|
2741
|
+
const close = el("button", { class: "btn secondary compact", type: "button", "data-key": "retired-ok", text: t("common.close"), onclick: () => { modState.retiredNote = false; modRefresh(); } });
|
|
2742
|
+
return modSection(null, [cell(el("span", { class: "cell-label", text: t("mod.retired") }), close, null, "offer-row")]);
|
|
2743
|
+
}
|
|
2744
|
+
// ---- Operating modes (docs/specs/2026-10-12-worker-tiers.md "Operating modes")
|
|
2745
|
+
//
|
|
2746
|
+
// A tab edits a saved worker set; activation is always an explicit, separate action.
|
|
2747
|
+
let modModeTab = "";
|
|
2748
|
+
/** Providers a mode can end at the reset of, known now: ChatGPT ones, then Claude ('anthropic'). */
|
|
2749
|
+
let modResetProviders = [];
|
|
2750
|
+
|
|
2751
|
+
/** Only actual future reset timestamps enable the option, never a provider type alone. */
|
|
2752
|
+
function modQuotaKnown(accounts, binding = false) {
|
|
2753
|
+
return (accounts || []).some((account) => {
|
|
2754
|
+
if (account.state === "paused" || account.state === "needs-login") return false;
|
|
2755
|
+
const quota = account.quota || {};
|
|
2756
|
+
const windows = Object.values(quota.rate_limits || {}).filter(Boolean);
|
|
2757
|
+
const selected = binding ? windows.sort((a, b) => (b.used_percent || 0) - (a.used_percent || 0)).slice(0, 1) : windows;
|
|
2758
|
+
return selected.some((window) => {
|
|
2759
|
+
if (!window) return false;
|
|
2760
|
+
const at = typeof window.reset_at === "number" ? window.reset_at * (window.reset_at > 1e12 ? 1 : 1000)
|
|
2761
|
+
: typeof window.reset_after_seconds === "number" && typeof quota.at === "number" ? quota.at + window.reset_after_seconds * 1000 : NaN;
|
|
2762
|
+
return Number.isFinite(at) && at > Date.now();
|
|
2763
|
+
});
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
async function modLoadResets() {
|
|
2767
|
+
const providers = (modState.options && modState.options.providers) || [];
|
|
2768
|
+
const chatgpt = await Promise.all(providers.filter((provider) => provider.type === "chatgpt").map(async (provider) => {
|
|
2769
|
+
try {
|
|
2770
|
+
const body = await api(`/api/chatgpt-accounts?provider=${encodeURIComponent(provider.name)}`);
|
|
2771
|
+
return body.provider === provider.name && modQuotaKnown(body.accounts) ? [provider.name] : [];
|
|
2772
|
+
} catch (error) { console.warn("Mode quota unavailable", provider.name, error.message); return []; }
|
|
2773
|
+
}));
|
|
2774
|
+
// The mod reads Claude's reset from the band's binding limit, under the name 'anthropic'.
|
|
2775
|
+
const claude = providers.some((provider) => provider.type === "anthropic") && modQuotaKnown(status && status.claude && status.claude.accounts, true);
|
|
2776
|
+
modResetProviders = [...chatgpt.flat(), ...(claude ? ["anthropic"] : [])];
|
|
2777
|
+
}
|
|
2778
|
+
/** The name a reset provider goes by: Claude, ChatGPT, or another ChatGPT provider's own name. */
|
|
2779
|
+
function modResetLabel(provider) {
|
|
2780
|
+
return provider === "anthropic" ? "Claude" : provider === "chatgpt" ? "ChatGPT" : provider;
|
|
2781
|
+
}
|
|
2782
|
+
/** Choosing reset starts on a known provider the mode's workers use, else the first known one. */
|
|
2783
|
+
function modResetDefault(rungs) {
|
|
2784
|
+
const used = new Set(Object.values(rungs).flatMap((steps) => steps.map((step) => { const choice = modChoiceOf(step.target); return choice && choice.provider; })));
|
|
2785
|
+
return modResetProviders.find((provider) => used.has(provider)) || modResetProviders[0];
|
|
2786
|
+
}
|
|
2787
|
+
function modUpdateMode(id, change) {
|
|
2788
|
+
modPatch({ modes: modState.settings.modes.map((mode) => mode.id === id ? { ...mode, ...change } : mode) });
|
|
2789
|
+
}
|
|
2790
|
+
function modSetActive(id) {
|
|
2791
|
+
// The start time is when a mode that runs until the quota resets counts its reset from.
|
|
2792
|
+
modPatch(id ? { activeMode: id, activeSince: Date.now() } : { activeMode: undefined, activeSince: undefined });
|
|
2793
|
+
}
|
|
2794
|
+
function modRemoveMode(mode) {
|
|
2795
|
+
if (!confirm(t("mod.modeRemoveConfirm", { name: mode.name }))) return;
|
|
2796
|
+
const s = modState.settings;
|
|
2797
|
+
modPatch({ modes: s.modes.filter((entry) => entry.id !== mode.id), ...(s.activeMode === mode.id ? { activeMode: undefined, activeSince: undefined } : {}) });
|
|
2798
|
+
}
|
|
2799
|
+
function modModeTabs() {
|
|
2800
|
+
const s = modState.settings;
|
|
2801
|
+
const tabs = [{ id: "", name: t("mod.modeNone") }, ...s.modes];
|
|
2802
|
+
if (!tabs.some((tab) => tab.id === modModeTab) && modModeTab !== "+") modModeTab = "";
|
|
2803
|
+
const buttons = tabs.map((tab) => {
|
|
2804
|
+
const on = (s.activeMode || "") === tab.id;
|
|
2805
|
+
return el("button", { type: "button", role: "tab", class: modModeTab === tab.id ? "active" : "", "aria-selected": String(modModeTab === tab.id), "aria-controls": "worker-tab-panel", "data-key": `mode-tab:${tab.id || "base"}`, onclick: () => { modModeTab = tab.id; modRefresh(); } }, [
|
|
2806
|
+
el("span", { text: tab.name }), on ? el("span", { class: "mode-on", text: t("mod.modeOn") }) : null,
|
|
2807
|
+
]);
|
|
2808
|
+
});
|
|
2809
|
+
buttons.push(el("button", { type: "button", role: "tab", "aria-selected": String(modModeTab === "+"), "data-key": "mode-add", text: t("mod.modeAdd"), ...(s.modes.length >= 20 ? { disabled: "" } : {}), onclick: () => { modModeTab = "+"; modRefresh(); } }));
|
|
2810
|
+
return el("div", { class: "mode-tabs", role: "tablist", "aria-label": t("mod.modes") }, buttons);
|
|
2811
|
+
}
|
|
2812
|
+
function modNewMode() {
|
|
2813
|
+
const input = el("input", { type: "text", class: "text-field", maxlength: "40", "data-key": "mode-new-name", "aria-label": t("mod.modeName"), placeholder: t("mod.modeName") });
|
|
2814
|
+
const add = () => {
|
|
2815
|
+
const name = input.value.trim();
|
|
2816
|
+
if (!name) { toast(t("mod.modeNameRequired"), true); input.focus(); return; }
|
|
2817
|
+
const mode = { id: `mode-${crypto.randomUUID()}`, name, rungs: clone(modRungs()), delegation: "normal", until: "none" };
|
|
2818
|
+
modModeTab = mode.id;
|
|
2819
|
+
modPatch({ modes: [...modState.settings.modes, mode] });
|
|
2820
|
+
};
|
|
2821
|
+
input.addEventListener("keydown", (event) => { if (event.key === "Enter") add(); });
|
|
2822
|
+
return modSection(t("mod.modeAddTitle"), [cell(t("mod.modeName"), [input, el("button", { type: "button", class: "btn compact", text: t("common.save"), "data-key": "mode-create", onclick: add })], t("mod.modeNameHelp"), "wide")]);
|
|
2823
|
+
}
|
|
2824
|
+
/** A datetime-local value for an epoch-ms time, in this browser's zone. */
|
|
2825
|
+
function modLocalTime(ms) {
|
|
2826
|
+
const at = new Date(ms);
|
|
2827
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
2828
|
+
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())}T${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
2829
|
+
}
|
|
2830
|
+
function modModeSection(mode) {
|
|
2831
|
+
const on = modState.settings.activeMode === mode.id;
|
|
2832
|
+
const name = el("input", { type: "text", class: "text-field", value: mode.name, maxlength: "40", "data-key": `mode-name:${mode.id}`, "aria-label": t("mod.modeName") });
|
|
2833
|
+
name.addEventListener("change", () => {
|
|
2834
|
+
if (!name.value.trim()) { name.value = mode.name; toast(t("mod.modeNameRequired"), true); return; }
|
|
2835
|
+
modUpdateMode(mode.id, { name: name.value.trim() });
|
|
2836
|
+
});
|
|
2837
|
+
const known = modResetProviders.length > 0;
|
|
2838
|
+
const kind = typeof mode.until === "number" ? "time" : mode.until && typeof mode.until === "object" ? "reset" : "none";
|
|
2839
|
+
const untilOf = (value) => value === "time" ? Date.now() + 3 * 3_600_000 : value === "reset" ? { reset: modResetDefault(mode.rungs) } : value;
|
|
2840
|
+
const until = modSegmented("mode-until", [
|
|
2841
|
+
{ value: "none", label: t("mod.modeUntilNone") }, { value: "time", label: t("mod.modeUntilTime") },
|
|
2842
|
+
{ value: "reset", label: t("mod.modeUntilResetChoice"), disabled: !known },
|
|
2843
|
+
], kind, t("mod.modeUntil"), (value) => modUpdateMode(mode.id, { until: untilOf(value) }));
|
|
2844
|
+
const reset = until.querySelector('[data-key="mode-until:reset"]');
|
|
2845
|
+
if (!known) {
|
|
2846
|
+
reset.setAttribute("aria-describedby", "mode-reset-reason");
|
|
2847
|
+
until.appendChild(el("span", { id: "mode-reset-reason", class: "mode-reset-reason", text: t("mod.modeResetUnknown") }));
|
|
2848
|
+
}
|
|
2849
|
+
const endControls = [until];
|
|
2850
|
+
if (kind === "time") {
|
|
2851
|
+
const at = el("input", { type: "datetime-local", class: "text-field", "data-key": "mode-until-time", value: modLocalTime(mode.until), "aria-label": t("mod.modeUntilTime") });
|
|
2852
|
+
at.addEventListener("change", () => {
|
|
2853
|
+
const ms = new Date(at.value).getTime();
|
|
2854
|
+
if (!Number.isFinite(ms) || ms <= Date.now()) { at.value = modLocalTime(mode.until); toast(t("mod.modeTimePast"), true); return; }
|
|
2855
|
+
modUpdateMode(mode.id, { until: ms });
|
|
2856
|
+
});
|
|
2857
|
+
endControls.push(at);
|
|
2858
|
+
}
|
|
2859
|
+
if (kind === "reset") {
|
|
2860
|
+
// Only providers whose reset is known now; a saved one that is not stays shown as unavailable.
|
|
2861
|
+
const label = (provider) => t("mod.modeUntilResetOf", { provider: modResetLabel(provider) });
|
|
2862
|
+
const items = modResetProviders.map((provider) => ({ value: provider, label: label(provider) }));
|
|
2863
|
+
if (!modResetProviders.includes(mode.until.reset)) items.unshift({ value: mode.until.reset, label: `${label(mode.until.reset)}${t("mod.unavailable")}` });
|
|
2864
|
+
endControls.push(modSelect("mode-until-reset", items, mode.until.reset, t("mod.modeResetProvider"), (reset) => modUpdateMode(mode.id, { until: { reset } })));
|
|
2865
|
+
}
|
|
2866
|
+
const endHelp = kind === "reset" ? t("mod.modeResetHelp", { provider: modResetLabel(mode.until.reset) }) : null;
|
|
2867
|
+
return modSection(null, [
|
|
2868
|
+
cell(t("mod.modeName"), name),
|
|
2869
|
+
...modRungRows(mode),
|
|
2870
|
+
cell(t("mod.modeDelegation"), modSegmented("mode-delegation", [{ value: "normal", label: t("mod.modeNormal") }, { value: "eager", label: t("mod.modeEager") }], mode.delegation, t("mod.modeDelegation"), (delegation) => modUpdateMode(mode.id, { delegation })), null, "wide"),
|
|
2871
|
+
cell(t("mod.modeUntil"), el("div", { class: "mode-until" }, endControls), endHelp, "wide"),
|
|
2872
|
+
cell(t("mod.modeActive"), [
|
|
2873
|
+
el("button", { type: "button", class: "btn compact", "data-key": "mode-toggle", text: t(on ? "mod.modeTurnOff" : "mod.modeTurnOn"), onclick: () => modSetActive(on ? "" : mode.id) }),
|
|
2874
|
+
el("button", { type: "button", class: "btn secondary compact", "data-key": `mode-remove:${mode.id}`, text: t("common.remove"), onclick: () => modRemoveMode(mode) }),
|
|
2875
|
+
], on ? t("mod.modeOn") : null, "wide"),
|
|
2876
|
+
]);
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
function modWorkersCard() {
|
|
2880
|
+
const s = modState.settings;
|
|
2881
|
+
return modCard("workers", t("mod.workers"), t("mod.workersBlurb"), [
|
|
2882
|
+
modState.options ? null : modSection(null, [el("div", { class: "cell-note warn-text", text: t("mod.optionsFailed") })]),
|
|
2883
|
+
modState.retiredNote ? modRetiredNote() : null,
|
|
2884
|
+
modSection(null, [
|
|
2885
|
+
cell(t("mod.takesOver"), modSegmented("agent-auto", [{ value: "off", label: t("mod.off") }, { value: "auto", label: "ripple:auto" }, { value: "all", label: t("mod.allSpawns") }], s.agentAuto, t("mod.takesOver"), (agentAuto) => modPatch({ agentAuto })), null, "wide"),
|
|
2886
|
+
], [t("mod.takesOverHelp"), t("mod.takesOverHelp2")]),
|
|
2887
|
+
modModeTabs(),
|
|
2888
|
+
el("div", { id: "worker-tab-panel", role: "tabpanel" }, modModeTab === "+" ? [modNewMode()] : modModeTab ? [modModeSection(s.modes.find((mode) => mode.id === modModeTab))] : [
|
|
2889
|
+
modSection(t("mod.models"), modTickRows(), [t("mod.modelsHelp")]),
|
|
2890
|
+
modSection(t("mod.fourWorkers"), modRungRows(), [t("mod.modeOrderHelp")]),
|
|
2891
|
+
s.activeMode ? modSection(null, [cell(t("mod.modeActive"), el("button", { type: "button", class: "btn secondary compact", "data-key": "mode-base-on", text: t("mod.modeUseBase"), onclick: () => modSetActive("") }))]) : null,
|
|
2892
|
+
].filter(Boolean)),
|
|
2893
|
+
]);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
function modJudgeCard() {
|
|
2897
|
+
const s = modState.settings;
|
|
2898
|
+
const zen = modState.options && modState.options.zen;
|
|
2899
|
+
const rows = [cell(t("mod.judge"), modSegmented("judge", [{ value: "haiku", label: t("mod.haiku") }, { value: "jev", label: t("mod.jev") }, { value: "model", label: t("mod.routerModel") }], s.judge, t("mod.judge"), (judge) => modPatch({ judge })), null, "wide")];
|
|
2900
|
+
const footers = [];
|
|
2901
|
+
if (s.judge === "haiku") rows.push(el("div", { class: "cell-note", text: t("mod.haikuHint") }));
|
|
2902
|
+
if (s.judge === "jev") {
|
|
2903
|
+
rows.push(cell(t("mod.jevSource"), modSegmented("jev-source", [{ value: "typesafe", label: t("mod.typesafe") }, { value: "zen", label: t("mod.zen") }], s.jevSource, t("mod.jevSource"), (jevSource) => modPatch({ jevSource })), null, "wide"));
|
|
2904
|
+
if (s.jevSource === "zen") {
|
|
2905
|
+
rows.push(zen && zen.configured
|
|
2906
|
+
? cell(t("mod.model"), el("span", { class: "mono", text: zen.model }), modCap(t("mod.zenKey")))
|
|
2907
|
+
: cell(el("span", { class: "cell-label", text: modCap(t("mod.zenMissing")) }), el("button", { class: "btn secondary compact", type: "button", text: t("nav.providers"), onclick: () => showView("providers") }), t("mod.zenAdd")));
|
|
2908
|
+
} else {
|
|
2909
|
+
rows.push(el("div", { class: "cell-note", text: t("mod.jevKeyNote") }));
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
if (s.judge === "model") {
|
|
2913
|
+
const models = modModels().map((model) => ({ value: model.model, label: `${model.name || model.model} · ${model.provider}` }));
|
|
2914
|
+
rows.push(cell(t("mod.model"), models.length || s.judgeModel
|
|
2915
|
+
? modSelect("judge-model", models, s.judgeModel, t("mod.model"), (judgeModel) => modPatch({ judgeModel }))
|
|
2916
|
+
: el("span", { text: modCap(t("mod.noModels")) }), modCap(t("mod.haikuFallback"))));
|
|
2917
|
+
}
|
|
2918
|
+
return modCard("judge", t("mod.judge"), t("mod.judgeBlurb"), [modSection(null, rows, footers)]);
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
function modCacheCard() {
|
|
2922
|
+
const s = modState.settings;
|
|
2923
|
+
return modCard("cache", t("mod.cache"), t("mod.cacheBlurb"), [modSection(null, [
|
|
2924
|
+
cell(t("mod.keepWarm"), modSwitch("keep-warm", s.keepWarm, t("mod.keepWarm"), (keepWarm) => modPatch({ keepWarm })), modCap(t("mod.keepWarmHint"))),
|
|
2925
|
+
cell(t("mod.for"), modSegmented("warm-hours", [1, 2, 4, 8].map((n) => ({ value: n, label: t("mod.hours", { n }) })), s.keepWarmHours, t("mod.for"), (n) => modPatch({ keepWarmHours: Number(n) })), modCap(t("mod.afterReply")), "wide"),
|
|
2926
|
+
cell(t("mod.chatsFrom"), modSegmented("warm-min", [10000, 30000, 60000, 100000].map((n) => ({ value: n, label: `${n / 1000}k` })), s.keepWarmMinTokens, t("mod.chatsFrom"), (n) => modPatch({ keepWarmMinTokens: Number(n) })), modCap(t("mod.tokens")), "wide"),
|
|
2927
|
+
], [t("mod.warmCost")])]);
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
function modHandoffCard() {
|
|
2931
|
+
const s = modState.settings;
|
|
2932
|
+
const skill = el("input", { type: "text", class: "text-field", value: s.handoffSkill, placeholder: t("mod.noSkill"), spellcheck: "false", autocomplete: "off", "data-key": "handoff-skill", "aria-label": t("mod.fullHandoff") });
|
|
2933
|
+
skill.addEventListener("change", () => modPatch({ handoffSkill: skill.value.trim() }));
|
|
2934
|
+
return modCard("handoff", t("mod.handoff"), t("mod.handoffBlurb"), [modSection(null, [
|
|
2935
|
+
cell(t("mod.compactWith"), modSegmented("compact-with", [{ value: "haiku", label: t("mod.haiku") }, { value: "session", label: t("mod.sessionCompactor") }], s.compactWith, t("mod.compactWith"), (compactWith) => modPatch({ compactWith })), null, "wide"),
|
|
2936
|
+
cell(t("mod.afterHandoff"), modSegmented("handoff-after", [{ value: "continue", label: t("mod.continue") }, { value: "confirm", label: t("mod.confirm") }, { value: "copy", label: t("mod.copy") }], s.handoffAfter, t("mod.afterHandoff"), (handoffAfter) => modPatch({ handoffAfter })), null, "wide"),
|
|
2937
|
+
cell(t("mod.handoffButton"), modSegmented("handoff-button", [{ value: "advised", label: t("mod.whenAdvised") }, { value: "always", label: t("mod.always") }], s.handoffButton, t("mod.handoffButton"), (handoffButton) => modPatch({ handoffButton })), null, "wide"),
|
|
2938
|
+
cell(t("mod.fullHandoff"), skill, null, "wide"),
|
|
2939
|
+
cell(t("mod.heavyAt"), modSegmented("swamp-at", [40, 50, 60, 70].map((n) => ({ value: n, label: `${n}%` })), s.swampAt, t("mod.heavyAt"), (n) => modPatch({ swampAt: Number(n) })), modCap(t("mod.context")), "wide"),
|
|
2940
|
+
])]);
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
function modLookCard() {
|
|
2944
|
+
const s = modState.settings;
|
|
2945
|
+
const languages = [{ value: "auto", label: t("mod.auto") }, ...MOD_LANGUAGES.map(([value, label]) => ({ value, label }))];
|
|
2946
|
+
return modCard("look", t("mod.look"), t("mod.lookBlurb"), [
|
|
2947
|
+
modSection(null, [
|
|
2948
|
+
cell(t("mod.layout"), modSegmented("layout", [{ value: "dashboard", label: t("mod.dashboard") }, { value: "minimal", label: t("mod.minimal") }], s.layout, t("mod.layout"), (layout) => modPatch({ layout })), s.layout === "minimal" ? modCap(t("mod.footerHint")) : null, "wide"),
|
|
2949
|
+
cell(t("mod.appearance"), modSegmented("appearance", [{ value: "auto", label: t("mod.auto") }, { value: "dark", label: t("mod.dark") }, { value: "light", label: t("mod.lightAppearance") }], s.appearance, t("mod.appearance"), (appearance) => modPatch({ appearance })), null, "wide"),
|
|
2950
|
+
cell(t("mod.language"), modSelect("language", languages, s.language, t("mod.language"), (language) => modPatch({ language }), "narrow")),
|
|
2951
|
+
]),
|
|
2952
|
+
modSection(t("mod.show"), [
|
|
2953
|
+
...MOD_PARTS.map((part) => {
|
|
2954
|
+
const shown = !s.hide.includes(part);
|
|
2955
|
+
return cell(t(`mod.part.${part}`), modSwitch(`part:${part}`, shown, t(`mod.part.${part}`), (on) => modPatch({ hide: on ? s.hide.filter((entry) => entry !== part) : [...s.hide, part] })));
|
|
2956
|
+
}),
|
|
2957
|
+
...MOD_EXTRAS.map((part) => {
|
|
2958
|
+
const extras = s.extras || [];
|
|
2959
|
+
const shown = extras.includes(part);
|
|
2960
|
+
return cell(t(`mod.part.${part}`), modSwitch(`part:${part}`, shown, t(`mod.part.${part}`), (on) => modPatch({ extras: on ? [...extras, part] : extras.filter((entry) => entry !== part) })));
|
|
2961
|
+
}),
|
|
2962
|
+
]),
|
|
2963
|
+
]);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2244
2966
|
// ---- Logs ---------------------------------------------------------------------------
|
|
2245
2967
|
|
|
2246
2968
|
let logsPanel = "requests";
|
|
@@ -2340,14 +3062,15 @@ function requestRow(record) {
|
|
|
2340
3062
|
const status = record.resent
|
|
2341
3063
|
? el("span", { class: "status-text resent", text: `${t("logs.status.resent")} ${record.status}`, title: t("logs.status.resentHelp") })
|
|
2342
3064
|
: el("span", { class: `status-text ${record.ok ? "ok" : "bad"}`, text: `${record.ok ? t("logs.status.ok") : t("logs.status.error")} ${record.status}` });
|
|
3065
|
+
// Column classes let a narrow window lay the same row out as a two-line list item.
|
|
2343
3066
|
row.append(
|
|
2344
|
-
el("td", { text: timeOf(record.at) }),
|
|
2345
|
-
el("td", {}, [model]),
|
|
2346
|
-
el("td", { text: record.effort || t("common.notAvailable") }),
|
|
2347
|
-
el("td", {}, [input]),
|
|
2348
|
-
el("td", { class: record.usage ? "" : "no-usage", text: record.usage ? formatNumber(record.usage.output) : t("common.notAvailable") }),
|
|
2349
|
-
el("td", { text: formatSeconds(record.ms) }),
|
|
2350
|
-
el("td", {}, [status]),
|
|
3067
|
+
el("td", { class: "c-time", text: timeOf(record.at) }),
|
|
3068
|
+
el("td", { class: "c-model" }, [model]),
|
|
3069
|
+
el("td", { class: "c-effort", text: record.effort || t("common.notAvailable") }),
|
|
3070
|
+
el("td", { class: "c-in" }, [input]),
|
|
3071
|
+
el("td", { class: record.usage ? "c-out" : "c-out no-usage", text: record.usage ? formatNumber(record.usage.output) : t("common.notAvailable") }),
|
|
3072
|
+
el("td", { class: "c-dur", text: formatSeconds(record.ms) }),
|
|
3073
|
+
el("td", { class: "c-status" }, [status]),
|
|
2351
3074
|
);
|
|
2352
3075
|
return row;
|
|
2353
3076
|
}
|