clauderipple 0.11.0 → 0.12.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 +42 -0
- package/README.es.md +48 -0
- package/README.ja.md +42 -0
- package/README.ko.md +39 -0
- package/README.md +44 -0
- package/README.pt-BR.md +46 -0
- package/README.zh-CN.md +40 -0
- package/dist/app/dist/claude-desktop.js +31 -0
- package/dist/app/dist/main.js +127 -8
- package/dist/app/dist/status-model.js +12 -0
- package/dist/app/dist/update-model.js +32 -0
- package/dist/app/dist/window.js +10 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +5 -1
- package/dist/mod/hooks/i18n.ts +6 -0
- package/dist/mod/hooks/modes.ts +15 -8
- package/dist/mod/hooks/register.tsx +67 -19
- package/dist/mod/hooks/settings.js +37 -2
- package/dist/mod/hooks/settings.ts +31 -3
- package/dist/mod/hooks/ui/agents.tsx +1 -1
- package/dist/mod/hooks/ui/art.ts +8 -3
- package/dist/mod/hooks/ui/band.tsx +6 -2
- package/dist/mod/hooks/ui/flat.ts +11 -8
- package/dist/mod/hooks/ui/look.ts +3 -1
- package/dist/mod/hooks/ui/view.ts +2 -0
- package/dist/mod/hooks/workers.ts +8 -1
- package/dist/mod/tests/delegation.test.tsx +50 -0
- package/dist/mod/tests/register.test.tsx +138 -1
- package/dist/mod/tests/ui.test.tsx +6 -3
- package/dist/mod/types/index.d.ts +14 -0
- package/dist/router/src/admin.js +41 -76
- package/dist/router/src/autoupdate.js +299 -0
- package/dist/router/src/bootstrap.js +3 -2
- package/dist/router/src/cli-control.js +65 -0
- package/dist/router/src/config.js +70 -11
- package/dist/router/src/index.js +11 -1
- package/dist/router/src/picker.js +25 -1
- package/dist/router/src/presets.js +21 -13
- package/dist/router/src/providers/chatgpt/index.js +11 -0
- package/dist/router/src/providers/google/index.js +7 -2
- package/dist/router/src/providers/google/transport.js +16 -1
- package/dist/router/src/proxy.js +46 -6
- package/dist/router/src/requestlog.js +3 -2
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +2 -2
- package/dist/ui/app.js +247 -42
- package/dist/ui/i18n.js +154 -18
- package/dist/ui/style.css +21 -0
- package/docs/ARCHITECTURE.md +61 -11
- package/package.json +1 -1
package/dist/ui/app.js
CHANGED
|
@@ -291,19 +291,48 @@ async function refreshHealth() {
|
|
|
291
291
|
if (clientsLoaded) renderClients();
|
|
292
292
|
if (modState.settings) renderModNotice();
|
|
293
293
|
const details = $("#health-details");
|
|
294
|
-
details
|
|
294
|
+
modRedraw(details, [
|
|
295
295
|
versionRow(),
|
|
296
|
+
autoUpdateRow(),
|
|
296
297
|
// Which files answer, and since when: the only way to see that an update actually replaced the router.
|
|
297
298
|
...(status.runtime ? [cell(t("health.runtime"), null, [el("span", { class: "cell-sub mono", text: status.runtime.router || "?" }), new Date(status.runtime.startedAt).toLocaleString()])] : []),
|
|
298
299
|
el("div", { class: "row" }, [el("span", { class: "k", text: t("health.routes") }), el("span", { class: "v", text: String(status.routes) })]),
|
|
299
300
|
el("div", { class: "row" }, [el("span", { class: "k", text: t("health.cli") }), el("span", { class: "v", text: status.cliVersion })]),
|
|
300
|
-
);
|
|
301
|
+
]);
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
// The update check runs on a click, not with the 5-second refresh: it asks the npm registry. What an
|
|
304
305
|
// update means depends on how this copy was installed, so the router's CLI decides and runs it; the
|
|
305
306
|
// router restarts on the new version and the served-files check above reloads this page.
|
|
306
307
|
let updateState = { busy: false, check: null };
|
|
308
|
+
let autoUpdateSaving = false;
|
|
309
|
+
function autoUpdateRow() {
|
|
310
|
+
const control = modSwitch("auto-update", status.autoUpdate?.enabled !== false, t("update.auto"), toggleAutoUpdate);
|
|
311
|
+
control.querySelector("input").disabled = autoUpdateSaving || !status.autoUpdate;
|
|
312
|
+
return cell(t("update.auto"), control, t("update.autoHelp"));
|
|
313
|
+
}
|
|
314
|
+
async function toggleAutoUpdate(enabled) {
|
|
315
|
+
if (autoUpdateSaving) return;
|
|
316
|
+
autoUpdateSaving = true;
|
|
317
|
+
const input = document.querySelector('[data-key="auto-update"]');
|
|
318
|
+
if (input) input.disabled = true;
|
|
319
|
+
try {
|
|
320
|
+
// Do not use latestConfig's stale fallback for an automatic-install preference.
|
|
321
|
+
const next = await api("/api/config");
|
|
322
|
+
next.autoUpdate = enabled;
|
|
323
|
+
await configRequest(next);
|
|
324
|
+
currentConfig = next;
|
|
325
|
+
if (status.autoUpdate) status.autoUpdate.enabled = enabled;
|
|
326
|
+
toast(t("common.saved"));
|
|
327
|
+
} catch (error) {
|
|
328
|
+
toast(t("common.saveFailed"), true, error.message);
|
|
329
|
+
} finally {
|
|
330
|
+
autoUpdateSaving = false;
|
|
331
|
+
await refreshHealth();
|
|
332
|
+
const current = document.querySelector('[data-key="auto-update"]');
|
|
333
|
+
if (current) { current.disabled = !status.autoUpdate; current.checked = status.autoUpdate?.enabled !== false; }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
307
336
|
function versionRow() {
|
|
308
337
|
const check = updateState.check;
|
|
309
338
|
// The version reads as the row's label; what the update check found sits under it, the action on the right.
|
|
@@ -867,11 +896,25 @@ function providerCannotSearch(name, model) {
|
|
|
867
896
|
return !(Array.isArray(entry.webSearchModels) && entry.webSearchModels.includes(id));
|
|
868
897
|
}
|
|
869
898
|
|
|
899
|
+
// The web search row names a provider as well as a model: one id can be offered by two providers
|
|
900
|
+
// and only one of them may be able to run a search. JSON keeps the two apart whatever they contain.
|
|
901
|
+
function webSearchChoice(provider, model) { return JSON.stringify([provider, model]); }
|
|
902
|
+
function parseWebSearchChoice(value) {
|
|
903
|
+
if (!value) return null;
|
|
904
|
+
try {
|
|
905
|
+
const [provider, model] = JSON.parse(value);
|
|
906
|
+
return typeof provider === "string" && typeof model === "string" ? { provider, model } : null;
|
|
907
|
+
} catch { return null; }
|
|
908
|
+
}
|
|
909
|
+
|
|
870
910
|
function renderModelSlots() {
|
|
871
911
|
const rows = $("#client-model-slots");
|
|
872
912
|
if (!rows) return;
|
|
873
913
|
const chosen = (currentConfig.cli && currentConfig.cli.models) || {};
|
|
874
|
-
|
|
914
|
+
// With a web search backend set, the search is intercepted before routing and never reaches
|
|
915
|
+
// `smallFast`, so the "no web search here" label there would be false.
|
|
916
|
+
const searchIntercepted = Boolean(currentConfig.webSearch);
|
|
917
|
+
const nodes = MODEL_SLOTS.map((slot) => {
|
|
875
918
|
const select = el("select", {});
|
|
876
919
|
select.appendChild(el("option", { value: "", text: t("slots.slotDefault") }));
|
|
877
920
|
for (const group of groupedModels(currentConfig)) for (const model of group.models) {
|
|
@@ -879,7 +922,7 @@ function renderModelSlots() {
|
|
|
879
922
|
if (chosen[slot] === model.id) option.selected = true;
|
|
880
923
|
// `smallFast` is what a WebSearch runs on, so an inability to search is fatal there and
|
|
881
924
|
// merely worth knowing everywhere else.
|
|
882
|
-
if (slot === "smallFast" && providerCannotSearch(group.name, model.id)) {
|
|
925
|
+
if (slot === "smallFast" && !searchIntercepted && providerCannotSearch(group.name, model.id)) {
|
|
883
926
|
option.textContent += ` — ${t("slots.noWebSearch")}`;
|
|
884
927
|
}
|
|
885
928
|
select.appendChild(option);
|
|
@@ -894,7 +937,55 @@ function renderModelSlots() {
|
|
|
894
937
|
select.dataset.slot = slot;
|
|
895
938
|
select.setAttribute("aria-label", t(`slots.slot.${slot}`));
|
|
896
939
|
return cell(t(`slots.slot.${slot}`), select, t(`slots.slotHelp.${slot}`), "slot-row");
|
|
897
|
-
})
|
|
940
|
+
});
|
|
941
|
+
nodes.push(renderWebSearchSlot());
|
|
942
|
+
rows.replaceChildren(...nodes);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// The backend `cfg.webSearch` names answers the search instead of the `smallFast` slot. Only the
|
|
946
|
+
// router knows which models it can actually search with — that comes from `/api/status`, never
|
|
947
|
+
// from a model's name — so an unsearchable model is not offered here at all.
|
|
948
|
+
function renderWebSearchSlot() {
|
|
949
|
+
const saved = currentConfig.webSearch || null;
|
|
950
|
+
const select = el("select", {});
|
|
951
|
+
select.appendChild(el("option", { value: "", text: t("slots.webSearchFollow") }));
|
|
952
|
+
let found = false;
|
|
953
|
+
for (const group of groupedModels(currentConfig)) for (const model of group.models) {
|
|
954
|
+
if (providerCannotSearch(group.name, model.id)) continue;
|
|
955
|
+
// A Claude account searches on its own when routed to, but `serveWebSearch` has no backend for
|
|
956
|
+
// that provider kind: named here, the search would quietly fall back to `smallFast`.
|
|
957
|
+
if (currentConfig.providers[group.name] && currentConfig.providers[group.name].type === "anthropic") continue;
|
|
958
|
+
const option = el("option", { value: webSearchChoice(group.name, model.id), text: `${labelOf(model)} · ${group.name}` });
|
|
959
|
+
if (saved && saved.provider === group.name && saved.model === model.id) { option.selected = true; found = true; }
|
|
960
|
+
select.appendChild(option);
|
|
961
|
+
}
|
|
962
|
+
// A backend the config names but no provider can run any more must not read as "follows the
|
|
963
|
+
// small, fast model" — that would silently change where the search goes.
|
|
964
|
+
if (saved && !found) {
|
|
965
|
+
const orphan = el("option", { value: webSearchChoice(saved.provider, saved.model), text: `${saved.provider} / ${saved.model} (?)` });
|
|
966
|
+
orphan.selected = true;
|
|
967
|
+
select.appendChild(orphan);
|
|
968
|
+
}
|
|
969
|
+
select.onchange = () => void saveWebSearch(select.value);
|
|
970
|
+
select.dataset.webSearch = "true";
|
|
971
|
+
select.setAttribute("aria-label", t("slots.webSearch"));
|
|
972
|
+
return cell(t("slots.webSearch"), select, t("slots.webSearchHelp"), "slot-row");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function saveWebSearch(value) {
|
|
976
|
+
if (!currentConfig) return;
|
|
977
|
+
const next = clone(await latestConfig());
|
|
978
|
+
const picked = parseWebSearchChoice(value);
|
|
979
|
+
// `webSearch` is the only setting this row owns; an existing `maxResults` survives the change.
|
|
980
|
+
const maxResults = (next.webSearch && next.webSearch.maxResults) ?? (currentConfig.webSearch && currentConfig.webSearch.maxResults);
|
|
981
|
+
if (picked) next.webSearch = { provider: picked.provider, model: picked.model, ...(maxResults !== undefined ? { maxResults } : {}) };
|
|
982
|
+
else delete next.webSearch;
|
|
983
|
+
try {
|
|
984
|
+
await configRequest(next);
|
|
985
|
+
currentConfig = next;
|
|
986
|
+
renderModelSlots();
|
|
987
|
+
toast(t("slots.webSearchSaved"));
|
|
988
|
+
} catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
898
989
|
}
|
|
899
990
|
|
|
900
991
|
async function saveModelSlots() {
|
|
@@ -904,15 +995,17 @@ async function saveModelSlots() {
|
|
|
904
995
|
// remove the env key, and the router tells "clear this" from "leave this alone" by whether the
|
|
905
996
|
// slot is present at all — so dropping empty values here would make the choice a silent no-op.
|
|
906
997
|
const models = {};
|
|
907
|
-
|
|
998
|
+
// Only the three slot rows: the web search row below them writes `webSearch`, not a slot.
|
|
999
|
+
for (const select of $all("#client-model-slots select[data-slot]")) models[select.dataset.slot] = select.value;
|
|
908
1000
|
next.cli = { ...(next.cli || {}), models };
|
|
909
1001
|
try {
|
|
910
1002
|
await configRequest(next);
|
|
911
1003
|
currentConfig = next;
|
|
912
1004
|
// Saying "saved" over a choice that will make every search fail is the same silent no-op this
|
|
913
1005
|
// screen was already guilty of once. The label on the option warns before the fact; this catches
|
|
914
|
-
// a session that already had it selected.
|
|
915
|
-
|
|
1006
|
+
// a session that already had it selected. With a web search backend set the search never
|
|
1007
|
+
// reaches `smallFast`, so there is nothing to warn about.
|
|
1008
|
+
const owners = models.smallFast && !next.webSearch ? providersOffering(next, models.smallFast) : [];
|
|
916
1009
|
if (owners.length && providerCannotSearch(owners[0], models.smallFast)) toast(t("slots.noWebSearchWarn"), true);
|
|
917
1010
|
else toast(t("slots.slotSaved"));
|
|
918
1011
|
} catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
@@ -1039,6 +1132,36 @@ async function showPhoneQr(button) {
|
|
|
1039
1132
|
} catch { if (generation === phoneQrGeneration) toast(t("common.loadFailed"), true); }
|
|
1040
1133
|
finally { button.disabled = false; }
|
|
1041
1134
|
}
|
|
1135
|
+
// Ripple Remote in the stores. iPhone links to the storefront of the dashboard's language, as the
|
|
1136
|
+
// README headers do; Android is a closed test on Google Play, joined through Discord, so no Play link.
|
|
1137
|
+
const REMOTE_STOREFRONT = { ko: "kr", en: "us", zh: "us", ja: "jp", es: "es", pt: "br" };
|
|
1138
|
+
/** Read when drawn, not at load: this block is also evaluated on its own by the phone tests. */
|
|
1139
|
+
function remoteAppStoreUrl() { return `https://apps.apple.com/${REMOTE_STOREFRONT[CURRENT_LANG] || "us"}/app/id6823063731`; }
|
|
1140
|
+
const COMMUNITY_URL = "https://discord.gg/R8ZgGSNHjw";
|
|
1141
|
+
function externalLink(href, cls, children) { return el("a", { class: cls, href, target: "_blank", rel: "noopener noreferrer" }, children); }
|
|
1142
|
+
function phonePaired(data) { return Boolean(data && data.hostRunning && data.phones && data.phones.length); }
|
|
1143
|
+
function remoteAndroidNote(cls) { return el("span", { class: `${cls} remote-android` }, [t("phone.promo.android"), " · ", externalLink(COMMUNITY_URL, "login-link", [t("phone.promo.androidLink")])]); }
|
|
1144
|
+
/** No phone paired yet: what the app does that nothing else does, the store, and how the QR below fits in. */
|
|
1145
|
+
function remotePromo(data) {
|
|
1146
|
+
const points = ["models", "hours", "private"].map(key => el("li", {}, [el("strong", { text: t(`phone.promo.${key}`) }), " ", t(`phone.promo.${key}Body`)]));
|
|
1147
|
+
const phoneGlyph = svgIcon("0 0 20 20", ["M7 2h6a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z", "M8.5 14.8h3"]);
|
|
1148
|
+
const download = svgIcon("0 0 20 20", ["M10 3.5v9", "M6.2 9l3.8 3.8L13.8 9", "M4.5 16.5h11"], "remote-store-icon");
|
|
1149
|
+
// Its own lang, so Korean wraps between words in a browser too (the app sets it on <html>).
|
|
1150
|
+
return el("section", { class: "conn-card remote-promo", lang: CURRENT_LANG }, [
|
|
1151
|
+
el("div", { class: "conn-hero" }, [el("div", { class: "conn-icon" }, [phoneGlyph]), el("div", { class: "conn-hero-text" }, [
|
|
1152
|
+
el("p", { class: "remote-eyebrow", text: t("phone.promo.eyebrow") }),
|
|
1153
|
+
el("h2", { class: "conn-title", text: t("phone.promo.title") }),
|
|
1154
|
+
el("p", { class: "conn-lead", text: t("phone.promo.lead") }),
|
|
1155
|
+
])]),
|
|
1156
|
+
el("ul", { class: "remote-points" }, points),
|
|
1157
|
+
el("div", { class: "remote-get" }, [externalLink(remoteAppStoreUrl(), "btn large remote-store", [download, t("phone.promo.appStore")]), remoteAndroidNote("hint")]),
|
|
1158
|
+
el("p", { class: "remote-next", text: t(data.hostRunning ? "phone.promo.nextQr" : "phone.promo.nextHost") }),
|
|
1159
|
+
]);
|
|
1160
|
+
}
|
|
1161
|
+
/** A phone is paired already: one quiet row for the next phone instead of the pitch. */
|
|
1162
|
+
function remoteGetApp() {
|
|
1163
|
+
return section(null, [cell(t("phone.promo.compact"), externalLink(remoteAppStoreUrl(), "btn secondary compact", [t("phone.promo.appStoreShort")]), [remoteAndroidNote("cell-sub")])]);
|
|
1164
|
+
}
|
|
1042
1165
|
function renderPhoneStatus(data) {
|
|
1043
1166
|
const rows = [cell(t("phone.host"), badge(data.hostRunning ? "ok" : "warn", t(data.hostRunning ? "phone.running" : "phone.stopped")))];
|
|
1044
1167
|
if (data.hostRunning) rows.push(cell(t("phone.mode"), el("span", { text: t(data.mode === "relay" ? "phone.relayStatus" : data.mode === "direct" ? "phone.directStatus" : "phone.localStatus") }), `${data.bind}:${data.port} · ${data.version}`),
|
|
@@ -1106,8 +1229,8 @@ function phoneControls(data) {
|
|
|
1106
1229
|
});
|
|
1107
1230
|
const phones = section(t("phone.phones"), []);
|
|
1108
1231
|
$(".group", phones).id = "phone-phones";
|
|
1109
|
-
return [section(t("phone.pairing"), [cell(t("phone.scan"), qr), el("div", { id: "phone-qr", class: "phone-qr" })], [t("phone.qrWarning"), !data.reachableFromPhone ? t("phone.unreachable") : null]),
|
|
1110
|
-
phoneSettings(data), phoneRestart(), phones, section(null, [rotate], t("phone.sharedKey"))];
|
|
1232
|
+
return [section(t("phone.pairing"), [cell(t("phone.scan"), qr, t("phone.scanHelp")), el("div", { id: "phone-qr", class: "phone-qr" })], [t("phone.qrWarning"), !data.reachableFromPhone ? t("phone.unreachable") : null]),
|
|
1233
|
+
phoneSettings(data), phoneRestart(), phones, phonePaired(data) ? remoteGetApp() : null, section(null, [rotate], t("phone.sharedKey"))].filter(Boolean);
|
|
1111
1234
|
}
|
|
1112
1235
|
async function refreshPhone(rebuild = false) {
|
|
1113
1236
|
if (!phoneActive() || offline()) return;
|
|
@@ -1115,11 +1238,12 @@ async function refreshPhone(rebuild = false) {
|
|
|
1115
1238
|
try {
|
|
1116
1239
|
const data = await api("/api/phone/status");
|
|
1117
1240
|
if (generation !== phoneGeneration || !phoneActive()) return;
|
|
1118
|
-
|
|
1241
|
+
// Pairing the first phone (or unpairing the last) swaps the pitch for its one-row form, so it rebuilds too.
|
|
1242
|
+
if (rebuild || !phoneSnapshot || phoneSnapshot.hostRunning !== data.hostRunning || phonePaired(phoneSnapshot) !== phonePaired(data)) {
|
|
1119
1243
|
hidePhoneQr();
|
|
1120
1244
|
const state = section(t("phone.status"), []);
|
|
1121
1245
|
$(".group", state).id = "phone-status";
|
|
1122
|
-
$("#phone-content").replaceChildren(state, ...(data.hostRunning ? phoneControls(data) : []));
|
|
1246
|
+
$("#phone-content").replaceChildren(...(phonePaired(data) ? [] : [remotePromo(data)]), state, ...(data.hostRunning ? phoneControls(data) : []));
|
|
1123
1247
|
}
|
|
1124
1248
|
phoneSnapshot = data;
|
|
1125
1249
|
renderPhoneStatus(data);
|
|
@@ -3263,6 +3387,11 @@ const MOD_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"];
|
|
|
3263
3387
|
const MOD_CLAUDE_IDS = { haiku: "claude-haiku-5-5", sonnet: "claude-sonnet-5-5", opus: "claude-opus-5-5", fable: "claude-fable-5-1" };
|
|
3264
3388
|
// The four workers, low to max (the mod's rung ids: light, standard, hard, plan).
|
|
3265
3389
|
const MOD_RUNGS = ["light", "standard", "hard", "plan"];
|
|
3390
|
+
// A domain ladder has two levels: the light pair (light+standard) and the heavy pair (hard+plan).
|
|
3391
|
+
const MOD_DOMAIN_LEVELS = [
|
|
3392
|
+
{ key: "light", rungs: ["light", "standard"] },
|
|
3393
|
+
{ key: "heavy", rungs: ["hard", "plan"] },
|
|
3394
|
+
];
|
|
3266
3395
|
const modClaude = (model, effort) => ({ target: { agent: "general-purpose", model }, effort });
|
|
3267
3396
|
/** The mod's defaults (packages/mod/hooks/settings.ts), for a router that has stored nothing yet. */
|
|
3268
3397
|
const MOD_DEFAULTS = {
|
|
@@ -3274,7 +3403,7 @@ const MOD_DEFAULTS = {
|
|
|
3274
3403
|
hard: [{ target: { agent: "gpt-6-astra" }, effort: "high" }], plan: [modClaude("opus", "high")],
|
|
3275
3404
|
} },
|
|
3276
3405
|
},
|
|
3277
|
-
allow: [], allowKnown: [], modes: [], providers: {},
|
|
3406
|
+
allow: [], allowKnown: [], modes: [], delegation: "normal", providers: {},
|
|
3278
3407
|
fallback: { agent: "general-purpose", model: "sonnet" },
|
|
3279
3408
|
keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
|
|
3280
3409
|
compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
|
|
@@ -3637,6 +3766,16 @@ function modRungs() {
|
|
|
3637
3766
|
const general = modState.settings.rules.general;
|
|
3638
3767
|
return general && general.mode === "ladder" && general.rungs ? general.rungs : {};
|
|
3639
3768
|
}
|
|
3769
|
+
/** A domain ladder folded into two levels: light+standard hold one list, hard+plan another.
|
|
3770
|
+
* The upper rung of each pair wins; an empty upper rung falls back to the lower. */
|
|
3771
|
+
function modTwoLevelRungs(rungs) {
|
|
3772
|
+
const out = {};
|
|
3773
|
+
const light = rungs.standard && rungs.standard.length ? rungs.standard : rungs.light;
|
|
3774
|
+
const heavy = rungs.plan && rungs.plan.length ? rungs.plan : rungs.hard;
|
|
3775
|
+
if (light && light.length) { out.light = clone(light); out.standard = clone(light); }
|
|
3776
|
+
if (heavy && heavy.length) { out.hard = clone(heavy); out.plan = clone(heavy); }
|
|
3777
|
+
return out;
|
|
3778
|
+
}
|
|
3640
3779
|
/** One menu of `model · effort` for the ticked models, effort limited to what each model takes; the session's model too. */
|
|
3641
3780
|
function modPickSelect(key, step, shown, label, onPick) {
|
|
3642
3781
|
const choices = modChoices().filter((choice) => choice.value === "general-purpose" || modTicked(choice));
|
|
@@ -3657,38 +3796,101 @@ function modPickSelect(key, step, shown, label, onPick) {
|
|
|
3657
3796
|
onPick(modTargetOf(value.slice(0, bar)), value.slice(bar + 1));
|
|
3658
3797
|
}, "rung-model");
|
|
3659
3798
|
}
|
|
3660
|
-
|
|
3799
|
+
/** @typedef {"general" | "ui" | "backend"} WorkerRuleKey */
|
|
3800
|
+
/** Domain selection is local UI state, independently retained for each saved worker set.
|
|
3801
|
+
* @type {Map<string, WorkerRuleKey>}
|
|
3802
|
+
*/
|
|
3803
|
+
const modRuleTabs = new Map();
|
|
3804
|
+
/** Save only this set's optional rule; deleting it makes the set follow its own general ladder.
|
|
3805
|
+
* @param {import("../mod/types").Mode | undefined} mode
|
|
3806
|
+
* @param {"ui" | "backend"} key
|
|
3807
|
+
* @param {import("../mod/types").Rule | undefined} rule
|
|
3808
|
+
*/
|
|
3809
|
+
function modSetDomainRule(mode, key, rule) {
|
|
3810
|
+
const domainRules = { ...(mode || modState.settings).domainRules };
|
|
3811
|
+
if (rule) domainRules[key] = rule;
|
|
3812
|
+
else delete domainRules[key];
|
|
3813
|
+
const change = { domainRules: Object.keys(domainRules).length ? domainRules : undefined };
|
|
3814
|
+
if (mode) modUpdateMode(mode.id, change);
|
|
3815
|
+
else modPatch(change);
|
|
3816
|
+
}
|
|
3817
|
+
/** @param {import("../mod/types").Mode} [mode] */
|
|
3818
|
+
function modWorkerRows(mode) {
|
|
3819
|
+
const id = mode ? mode.id : "base";
|
|
3820
|
+
const key = modRuleTabs.get(id) || "general";
|
|
3821
|
+
const rule = key === "general" ? null : (mode || modState.settings).domainRules?.[key];
|
|
3822
|
+
const rows = [cell(t("mod.workerDomain"), modSegmented(`worker-domain:${id}`, [
|
|
3823
|
+
{ value: "general", label: t("mod.domain.general") },
|
|
3824
|
+
{ value: "ui", label: t("mod.domain.ui") },
|
|
3825
|
+
{ value: "backend", label: t("mod.domain.backend") },
|
|
3826
|
+
], key, t("mod.workerDomain"), (value) => { modRuleTabs.set(id, value); modRefresh(); }), null, "wide")];
|
|
3827
|
+
if (key !== "general") rows.push(cell(t("mod.domainSeparate"), modSwitch(`worker-separate:${id}:${key}`, !!rule, t("mod.domainSeparate"), (on) => {
|
|
3828
|
+
// Switching on copies the set's general rungs, folded the same way a domain ladder is.
|
|
3829
|
+
modSetDomainRule(mode, key, on ? { mode: "ladder", rungs: modTwoLevelRungs(mode ? mode.rungs : modRungs()) } : undefined);
|
|
3830
|
+
}), rule ? null : t("mod.domainFollowsGeneral"), "wide"));
|
|
3831
|
+
if (key === "general" || rule) rows.push(...modRungRows(mode, key));
|
|
3832
|
+
return rows;
|
|
3833
|
+
}
|
|
3834
|
+
/** One rung row: the ordered model·effort list of a rung, with reorder, remove and add.
|
|
3835
|
+
* @param {import("../mod/types").Mode | undefined} mode
|
|
3836
|
+
* @param {WorkerRuleKey} ruleKey
|
|
3837
|
+
* @param {string} rowKey the row's own key (its part in the data-keys)
|
|
3838
|
+
* @param {string} name the row's label
|
|
3839
|
+
* @param {string} hintKey the i18n key of the line under it
|
|
3840
|
+
* @param {import("../mod/types").Step[]} steps the list it shows
|
|
3841
|
+
* @param {(next: import("../mod/types").Step[]) => void} save writes the list back
|
|
3842
|
+
*/
|
|
3843
|
+
function modRungRow(mode, ruleKey, rowKey, name, hintKey, steps, save) {
|
|
3844
|
+
const key = `worker:${mode ? mode.id : "base"}:${ruleKey}:${rowKey}`;
|
|
3845
|
+
const rows = steps.map((step, index) => {
|
|
3846
|
+
const choice = modChoiceOf(step.target);
|
|
3847
|
+
const shown = modNearest(step.effort, modLadderOf(choice));
|
|
3848
|
+
const select = modPickSelect(`${key}:${index}`, step, shown, `${name} ${index + 1}`, (target, effort) => save(steps.map((item, at) => at === index ? { target, effort } : item)));
|
|
3849
|
+
const move = (delta) => { const next = [...steps]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; save(next); };
|
|
3850
|
+
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 });
|
|
3851
|
+
return el("li", { class: "rung-item" }, [
|
|
3852
|
+
select,
|
|
3853
|
+
el("div", { class: "rung-actions" }, [
|
|
3854
|
+
button("up", "↑", t("mod.modeMoveUp"), index === 0, () => move(-1)),
|
|
3855
|
+
button("down", "↓", t("mod.modeMoveDown"), index === steps.length - 1, () => move(1)),
|
|
3856
|
+
button("remove", "✕", t("common.remove"), steps.length <= 1, () => save(steps.filter((_, at) => at !== index))),
|
|
3857
|
+
]),
|
|
3858
|
+
choice && !choice.usable ? el("small", { class: "warn-text", text: t("mod.rungUnusable") }) : null,
|
|
3859
|
+
]);
|
|
3860
|
+
});
|
|
3861
|
+
const add = el("button", { type: "button", class: "btn secondary compact", "data-key": `worker-add:${mode ? mode.id : "base"}:${ruleKey}:${rowKey}`, text: t("mod.modeAddModel"), onclick: () => {
|
|
3862
|
+
const choice = modChoices().find((item) => item.value !== "general-purpose" && modTicked(item)) || modChoices().at(-1);
|
|
3863
|
+
save([...steps, { target: modTargetOf(choice.value), effort: modNearest("medium", modLadderOf(choice)) }]);
|
|
3864
|
+
} });
|
|
3865
|
+
return cell(el("span", { class: "cell-label strong", text: name }), el("div", { class: "rung-list" }, [el("ol", {}, rows), add]), modCap(t(hintKey)), "wide rung-row");
|
|
3866
|
+
}
|
|
3867
|
+
/** The same ordered-rung editor for the selected rule in Base or a saved mode: the general ladder
|
|
3868
|
+
* keeps its four rungs, a domain ladder its two levels.
|
|
3869
|
+
* @param {import("../mod/types").Mode | undefined} mode
|
|
3870
|
+
* @param {WorkerRuleKey} [ruleKey]
|
|
3871
|
+
*/
|
|
3872
|
+
function modRungRows(mode, ruleKey = "general") {
|
|
3873
|
+
if (ruleKey !== "general") {
|
|
3874
|
+
const rungs = (mode || modState.settings).domainRules[ruleKey].rungs;
|
|
3875
|
+
return MOD_DOMAIN_LEVELS.map((level) => {
|
|
3876
|
+
// The pair's upper rung shows the list; an edit writes it to both rungs of the pair.
|
|
3877
|
+
const upper = level.rungs[1];
|
|
3878
|
+
const steps = rungs[upper] && rungs[upper].length ? rungs[upper] : (rungs[level.rungs[0]] || []);
|
|
3879
|
+
const save = (next) => {
|
|
3880
|
+
const updated = { ...rungs, [level.rungs[0]]: clone(next), [upper]: clone(next) };
|
|
3881
|
+
modSetDomainRule(mode, ruleKey, { mode: "ladder", rungs: updated });
|
|
3882
|
+
};
|
|
3883
|
+
return modRungRow(mode, ruleKey, level.key, t(`mod.domainLevel.${level.key}`), `mod.domainHint.${ruleKey}.${level.key}`, steps, save);
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3661
3886
|
const rungs = mode ? mode.rungs : modRungs();
|
|
3662
3887
|
return MOD_RUNGS.map((rung) => {
|
|
3663
|
-
const steps = rungs[rung] || [];
|
|
3664
|
-
const name = t(`mod.rung.${rung}`);
|
|
3665
3888
|
const save = (next) => {
|
|
3666
3889
|
const updated = { ...rungs, [rung]: next };
|
|
3667
3890
|
if (mode) modUpdateMode(mode.id, { rungs: updated });
|
|
3668
3891
|
else modSetRule("general", { mode: "ladder", rungs: updated });
|
|
3669
3892
|
};
|
|
3670
|
-
|
|
3671
|
-
const choice = modChoiceOf(step.target);
|
|
3672
|
-
const shown = modNearest(step.effort, modLadderOf(choice));
|
|
3673
|
-
const key = `worker:${mode ? mode.id : "base"}:${rung}:${index}`;
|
|
3674
|
-
const select = modPickSelect(key, step, shown, `${name} ${index + 1}`, (target, effort) => save(steps.map((item, at) => at === index ? { target, effort } : item)));
|
|
3675
|
-
const move = (delta) => { const next = [...steps]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; save(next); };
|
|
3676
|
-
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 });
|
|
3677
|
-
return el("li", { class: "rung-item" }, [
|
|
3678
|
-
select,
|
|
3679
|
-
el("div", { class: "rung-actions" }, [
|
|
3680
|
-
button("up", "↑", t("mod.modeMoveUp"), index === 0, () => move(-1)),
|
|
3681
|
-
button("down", "↓", t("mod.modeMoveDown"), index === steps.length - 1, () => move(1)),
|
|
3682
|
-
button("remove", "✕", t("common.remove"), steps.length <= 1, () => save(steps.filter((_, at) => at !== index))),
|
|
3683
|
-
]),
|
|
3684
|
-
choice && !choice.usable ? el("small", { class: "warn-text", text: t("mod.rungUnusable") }) : null,
|
|
3685
|
-
]);
|
|
3686
|
-
});
|
|
3687
|
-
const add = el("button", { type: "button", class: "btn secondary compact", "data-key": `worker-add:${mode ? mode.id : "base"}:${rung}`, text: t("mod.modeAddModel"), onclick: () => {
|
|
3688
|
-
const choice = modChoices().find((item) => item.value !== "general-purpose" && modTicked(item)) || modChoices().at(-1);
|
|
3689
|
-
save([...steps, { target: modTargetOf(choice.value), effort: modNearest("medium", modLadderOf(choice)) }]);
|
|
3690
|
-
} });
|
|
3691
|
-
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");
|
|
3893
|
+
return modRungRow(mode, ruleKey, rung, t(`mod.rung.${rung}`), `mod.rungHint.${rung}`, rungs[rung] || [], save);
|
|
3692
3894
|
});
|
|
3693
3895
|
}
|
|
3694
3896
|
/** Said once after settings that still held Auto or field rules are read: they became the four workers. */
|
|
@@ -3769,7 +3971,7 @@ function modNewMode() {
|
|
|
3769
3971
|
const add = () => {
|
|
3770
3972
|
const name = input.value.trim();
|
|
3771
3973
|
if (!name) { toast(t("mod.modeNameRequired"), true); input.focus(); return; }
|
|
3772
|
-
const mode = { id: `mode-${crypto.randomUUID()}`, name, rungs: clone(modRungs()), delegation: "normal", until: "none" };
|
|
3974
|
+
const mode = { id: `mode-${crypto.randomUUID()}`, name, rungs: clone(modRungs()), ...(modState.settings.domainRules ? { domainRules: clone(modState.settings.domainRules) } : {}), delegation: "normal", until: "none" };
|
|
3773
3975
|
modModeTab = mode.id;
|
|
3774
3976
|
modPatch({ modes: [...modState.settings.modes, mode] });
|
|
3775
3977
|
};
|
|
@@ -3821,7 +4023,7 @@ function modModeSection(mode) {
|
|
|
3821
4023
|
const endHelp = kind === "reset" ? t("mod.modeResetHelp", { provider: modResetLabel(mode.until.reset) }) : null;
|
|
3822
4024
|
return modSection(null, [
|
|
3823
4025
|
cell(t("mod.modeName"), name),
|
|
3824
|
-
...
|
|
4026
|
+
...modWorkerRows(mode),
|
|
3825
4027
|
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"),
|
|
3826
4028
|
cell(t("mod.modeUntil"), el("div", { class: "mode-until" }, endControls), endHelp, "wide"),
|
|
3827
4029
|
cell(t("mod.modeActive"), [
|
|
@@ -3842,7 +4044,10 @@ function modWorkersCard() {
|
|
|
3842
4044
|
modModeTabs(),
|
|
3843
4045
|
el("div", { id: "worker-tab-panel", role: "tabpanel" }, modModeTab === "+" ? [modNewMode()] : modModeTab ? [modModeSection(s.modes.find((mode) => mode.id === modModeTab))] : [
|
|
3844
4046
|
modSection(t("mod.models"), modTickRows(), [t("mod.modelsHelp")]),
|
|
3845
|
-
modSection(t("mod.fourWorkers"),
|
|
4047
|
+
modSection(t("mod.fourWorkers"), modWorkerRows(), [t("mod.modeOrderHelp")]),
|
|
4048
|
+
modSection(null, [
|
|
4049
|
+
cell(t("mod.modeDelegation"), modSegmented("base-delegation", [{ value: "normal", label: t("mod.modeNormal") }, { value: "eager", label: t("mod.modeEager") }], s.delegation || "normal", t("mod.modeDelegation"), (delegation) => modPatch({ delegation })), t("mod.baseDelegationHelp"), "wide"),
|
|
4050
|
+
]),
|
|
3846
4051
|
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,
|
|
3847
4052
|
].filter(Boolean)),
|
|
3848
4053
|
]);
|
|
@@ -4213,7 +4418,7 @@ async function refreshRequests() {
|
|
|
4213
4418
|
$("#logs-range-caption").textContent = usageCaption(range, lastSummary && lastSummary.earliest);
|
|
4214
4419
|
const summaryPath = range ? `/api/requests/summary?from=${range.from}&to=${range.to}` : "/api/requests/summary?since=3600";
|
|
4215
4420
|
const suffix = provider ? `&provider=${encodeURIComponent(provider)}` : "";
|
|
4216
|
-
const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages`), api(summaryPath), loadConfiguredProviders()]);
|
|
4421
|
+
const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages,image`), api(summaryPath), loadConfiguredProviders()]);
|
|
4217
4422
|
if (generation !== requestsGeneration) return;
|
|
4218
4423
|
// Each half keeps its last good contents when its own call fails.
|
|
4219
4424
|
if (summary.status === "fulfilled") {
|