clauderipple 0.9.4 → 0.10.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 +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
package/dist/ui/app.js
CHANGED
|
@@ -71,9 +71,13 @@ function modelsOf(provider) {
|
|
|
71
71
|
}
|
|
72
72
|
function labelOf(model) { return model.name || model.id; }
|
|
73
73
|
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
|
74
|
-
function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "")
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); }
|
|
75
|
+
// A name with no Latin letters ("하이브") slugs to nothing: fall back to the URL's domain ("thehive").
|
|
76
|
+
function domainWord(url) {
|
|
77
|
+
try { const parts = new URL(url).hostname.split("."); return safeName(parts.length > 1 ? parts[parts.length - 2] : parts[0]); } catch { return ""; }
|
|
78
|
+
}
|
|
79
|
+
function uniqueName(name, providers, url) {
|
|
80
|
+
const base = safeName(name) || (url ? domainWord(url) : "") || "provider";
|
|
77
81
|
let value = base;
|
|
78
82
|
let number = 2;
|
|
79
83
|
while (providers[value]) value = `${base}-${number++}`;
|
|
@@ -153,6 +157,10 @@ let pickerBusy = false;
|
|
|
153
157
|
let probeStates = new Map();
|
|
154
158
|
let chatgptLoginBusy = false;
|
|
155
159
|
let chatgptLoginMessage = "";
|
|
160
|
+
let phoneGeneration = 0;
|
|
161
|
+
let phoneSnapshot = null;
|
|
162
|
+
let phoneQrGeneration = 0;
|
|
163
|
+
let phoneBusy = false;
|
|
156
164
|
|
|
157
165
|
function selectOption(value, text) { return el("option", { value }, [text]); }
|
|
158
166
|
function badge(kind, text) { return el("span", { class: `badge ${kind} dot`, text }, []); }
|
|
@@ -195,6 +203,8 @@ function svgIcon(viewBox, paths, cls) {
|
|
|
195
203
|
function chevronIcon() { return svgIcon("0 0 8 13", ["M1.5 1.5l5 5-5 5"], "chevron"); }
|
|
196
204
|
|
|
197
205
|
function showView(name) {
|
|
206
|
+
if (name !== "phone") { phoneGeneration++; hidePhoneQr(); }
|
|
207
|
+
if (name === "phone") queueMicrotask(() => void loadPhone());
|
|
198
208
|
for (const button of $all(".nav-btn")) button.classList.toggle("active", button.dataset.view === name);
|
|
199
209
|
for (const view of $all(".view")) view.classList.toggle("active", view.id === `view-${name}`);
|
|
200
210
|
if (location.hash !== `#${name}`) history.replaceState(null, "", `#${name}`);
|
|
@@ -727,6 +737,125 @@ async function toggleCodex(enabled) {
|
|
|
727
737
|
void Promise.all([loadCatalogs(), refreshHealth()]);
|
|
728
738
|
setInterval(refreshHealth, 5000);
|
|
729
739
|
|
|
740
|
+
// ---- Phone --------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
function hidePhoneQr() {
|
|
743
|
+
phoneQrGeneration++;
|
|
744
|
+
const box = $("#phone-qr");
|
|
745
|
+
if (box) box.replaceChildren();
|
|
746
|
+
}
|
|
747
|
+
function phoneActive() { return $("#view-phone").classList.contains("active"); }
|
|
748
|
+
function phonePost(route, body) {
|
|
749
|
+
return api(`/api/phone/${route}`, { method: "POST", headers: { "content-type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
750
|
+
}
|
|
751
|
+
async function showPhoneQr(button) {
|
|
752
|
+
if (phoneBusy) return;
|
|
753
|
+
const generation = ++phoneQrGeneration;
|
|
754
|
+
button.disabled = true;
|
|
755
|
+
try {
|
|
756
|
+
const pairing = await api("/api/phone/pairing");
|
|
757
|
+
if (generation !== phoneQrGeneration || !phoneActive()) return;
|
|
758
|
+
const copy = el("button", { class: "btn secondary", type: "button", text: t("phone.copyLink") });
|
|
759
|
+
copy.addEventListener("click", async () => {
|
|
760
|
+
try { await navigator.clipboard.writeText(pairing.url); toast(t("phone.copied")); }
|
|
761
|
+
catch { toast(t("phone.copyFailed"), true); }
|
|
762
|
+
});
|
|
763
|
+
// An image, never trusted SVG markup in the dashboard's DOM.
|
|
764
|
+
$("#phone-qr").replaceChildren(el("img", { class: "phone-qr-image", alt: t("phone.qrAlt"), src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(pairing.svg)}`, width: "240", height: "240" }), copy,
|
|
765
|
+
el("button", { class: "btn secondary", type: "button", text: t("common.hide"), onclick: hidePhoneQr }));
|
|
766
|
+
} catch { if (generation === phoneQrGeneration) toast(t("common.loadFailed"), true); }
|
|
767
|
+
finally { button.disabled = false; }
|
|
768
|
+
}
|
|
769
|
+
function renderPhoneStatus(data) {
|
|
770
|
+
const rows = [cell(t("phone.host"), badge(data.hostRunning ? "ok" : "warn", t(data.hostRunning ? "phone.running" : "phone.stopped")))];
|
|
771
|
+
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}`),
|
|
772
|
+
cell(t("phone.notifications"), badge(data.pushReady ? "ok" : "warn", t(data.pushReady ? "phone.ready" : "phone.needsRelay"))));
|
|
773
|
+
else rows.push(el("div", { class: "cell-note" }, [hint(t("phone.install")), el("code", { text: data.hint })]));
|
|
774
|
+
$("#phone-status").replaceChildren(...rows);
|
|
775
|
+
if (!data.hostRunning) return;
|
|
776
|
+
$("#phone-phones").replaceChildren(...(data.phones.length ? data.phones.map(phone => cell(
|
|
777
|
+
el("span", { class: "cell-label mono", text: phone.clientId.length > 12 ? `${phone.clientId.slice(0, 12)}…` : phone.clientId, title: phone.clientId }),
|
|
778
|
+
badge(phone.connected ? "ok" : "", t(phone.connected ? "phone.connected" : "phone.offline")),
|
|
779
|
+
[phone.lastSeen ? t("phone.lastSeen", { time: new Date(phone.lastSeen).toLocaleString(CURRENT_LANG) }) : t("phone.unknownSeen"), t(phone.push ? "phone.pushOn" : "phone.pushOff")],
|
|
780
|
+
)) : [el("div", { class: "cell-note", text: t("phone.empty") })]));
|
|
781
|
+
$("#phone-restart").hidden = !data.restartRequired;
|
|
782
|
+
}
|
|
783
|
+
function phoneSettings(data) {
|
|
784
|
+
// The phone app pairs through the Relay only, so phone access is on (Relay) or off. A custom
|
|
785
|
+
// relay address stays in the host's config file; the dashboard keeps whichever one is saved.
|
|
786
|
+
const saved = data.settings;
|
|
787
|
+
const selected = saved.relay ? "relay" : "local";
|
|
788
|
+
const choices = ["relay", "local"].map(value => {
|
|
789
|
+
const radio = el("input", { type: "radio", name: "phone-mode", value, checked: value === selected });
|
|
790
|
+
return el("label", { class: "cell phone-choice" }, [radio, el("span", { class: "cell-text" }, [el("span", { class: "cell-label", text: t(`phone.choice.${value}`) }), el("span", { class: "cell-sub", text: t(`phone.help.${value}`) })])]);
|
|
791
|
+
});
|
|
792
|
+
const save = el("button", { class: "btn", type: "button", text: t("common.save") });
|
|
793
|
+
save.addEventListener("click", async () => {
|
|
794
|
+
if (phoneBusy) return;
|
|
795
|
+
const mode = $('#phone-content input[name="phone-mode"]:checked').value;
|
|
796
|
+
phoneBusy = true; save.disabled = true;
|
|
797
|
+
hidePhoneQr();
|
|
798
|
+
try {
|
|
799
|
+
const result = await phonePost("settings", mode === "relay" ? { relay: saved.relay || data.defaultRelay, bind: "local" } : { relay: "off", bind: "local" });
|
|
800
|
+
toast(t(result.restartRequired ? "phone.restartRequired" : "common.saved"));
|
|
801
|
+
await refreshPhone();
|
|
802
|
+
} catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
803
|
+
finally { phoneBusy = false; save.disabled = false; }
|
|
804
|
+
});
|
|
805
|
+
return section(t("phone.settings"), [...choices, cell(t("phone.apply"), save)]);
|
|
806
|
+
}
|
|
807
|
+
function phoneRestart() {
|
|
808
|
+
const button = el("button", { class: "btn secondary", type: "button", text: t("phone.restart") });
|
|
809
|
+
const message = hint("");
|
|
810
|
+
button.addEventListener("click", async () => {
|
|
811
|
+
if (!confirm(t("phone.restartConfirm"))) return;
|
|
812
|
+
button.disabled = true;
|
|
813
|
+
hidePhoneQr();
|
|
814
|
+
try {
|
|
815
|
+
const result = await phonePost("restart");
|
|
816
|
+
if (result.manual) message.textContent = t("phone.manualRestart", { command: result.command });
|
|
817
|
+
else { toast(t("phone.restarting")); setTimeout(() => void loadPhone(), 2000); }
|
|
818
|
+
} catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
819
|
+
finally { button.disabled = false; }
|
|
820
|
+
});
|
|
821
|
+
return el("div", { id: "phone-restart", class: "group stacked-group", hidden: "" }, [cell(t("phone.restartRequired"), button, message, "wide")]);
|
|
822
|
+
}
|
|
823
|
+
function phoneControls(data) {
|
|
824
|
+
const qr = el("button", { class: "btn secondary", type: "button", text: t("phone.showQr") });
|
|
825
|
+
qr.addEventListener("click", () => void showPhoneQr(qr));
|
|
826
|
+
const rotate = el("button", { class: "cell-button destructive", type: "button", text: t("phone.rotate") });
|
|
827
|
+
rotate.addEventListener("click", async () => {
|
|
828
|
+
if (phoneBusy || !confirm(t("phone.rotateConfirm"))) return;
|
|
829
|
+
phoneBusy = true; rotate.disabled = true; hidePhoneQr();
|
|
830
|
+
try { await phonePost("rotate"); toast(t("phone.rotated")); await refreshPhone(); }
|
|
831
|
+
catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
832
|
+
finally { phoneBusy = false; rotate.disabled = false; }
|
|
833
|
+
});
|
|
834
|
+
const phones = section(t("phone.phones"), []);
|
|
835
|
+
$(".group", phones).id = "phone-phones";
|
|
836
|
+
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]),
|
|
837
|
+
phoneSettings(data), phoneRestart(), phones, section(null, [rotate], t("phone.sharedKey"))];
|
|
838
|
+
}
|
|
839
|
+
async function refreshPhone(rebuild = false) {
|
|
840
|
+
if (!phoneActive()) return;
|
|
841
|
+
const generation = phoneGeneration;
|
|
842
|
+
try {
|
|
843
|
+
const data = await api("/api/phone/status");
|
|
844
|
+
if (generation !== phoneGeneration || !phoneActive()) return;
|
|
845
|
+
if (rebuild || !phoneSnapshot || phoneSnapshot.hostRunning !== data.hostRunning) {
|
|
846
|
+
hidePhoneQr();
|
|
847
|
+
const state = section(t("phone.status"), []);
|
|
848
|
+
$(".group", state).id = "phone-status";
|
|
849
|
+
$("#phone-content").replaceChildren(state, ...(data.hostRunning ? phoneControls(data) : []));
|
|
850
|
+
}
|
|
851
|
+
phoneSnapshot = data;
|
|
852
|
+
renderPhoneStatus(data);
|
|
853
|
+
} catch (error) { toast(t("common.loadFailed"), true, error.message); }
|
|
854
|
+
}
|
|
855
|
+
async function loadPhone() { phoneGeneration++; hidePhoneQr(); await refreshPhone(true); }
|
|
856
|
+
$("#phone-refresh").addEventListener("click", () => void loadPhone());
|
|
857
|
+
setInterval(() => { if (!phoneBusy) void refreshPhone(); }, 5000);
|
|
858
|
+
|
|
730
859
|
// ---- Model links --------------------------------------------------------------------
|
|
731
860
|
|
|
732
861
|
function claudeSelect(selected) {
|
|
@@ -1928,10 +2057,12 @@ function openAnthropicProviderForm(options) {
|
|
|
1928
2057
|
function syncModeHint() { modeHint.textContent = auth.value === "claude-code" && accountPool ? t("providers.anthropicPoolRouting") : t("providers.anthropicIngressOnly"); }
|
|
1929
2058
|
auth.addEventListener("change", syncModeHint);
|
|
1930
2059
|
syncModeHint();
|
|
2060
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2061
|
+
const limitsField = el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]);
|
|
1931
2062
|
const form = el("div", { class: "provider-form" }, [
|
|
1932
2063
|
el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
|
|
1933
2064
|
inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, result, modelArea,
|
|
1934
|
-
modeHint,
|
|
2065
|
+
modeHint, limitsField,
|
|
1935
2066
|
]);
|
|
1936
2067
|
const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
|
|
1937
2068
|
saveButton.addEventListener("click", async () => {
|
|
@@ -1940,7 +2071,7 @@ function openAnthropicProviderForm(options) {
|
|
|
1940
2071
|
const next = clone(await latestConfig());
|
|
1941
2072
|
const providerName = existing ? options.name : uniqueName(typedName, next.providers);
|
|
1942
2073
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), existing && next.providers[options.name]);
|
|
1943
|
-
const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "claude-code" && accountPool ? { accountPool: true } : {}), ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), models: checkedModels };
|
|
2074
|
+
const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "claude-code" && accountPool ? { accountPool: true } : {}), ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), limits: limitsInput.checked, models: checkedModels };
|
|
1944
2075
|
next.providers[providerName] = provider;
|
|
1945
2076
|
// accountPool makes these models native Claude routing targets. Without it this remains the
|
|
1946
2077
|
// historical OpenAI ingress provider, and routing deliberately ignores it.
|
|
@@ -2007,9 +2138,11 @@ function openGrokProviderForm(options) {
|
|
|
2007
2138
|
probeButton.addEventListener("click", () => void runProbe());
|
|
2008
2139
|
const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
|
|
2009
2140
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
2141
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2010
2142
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2011
2143
|
advanced.appendChild(el("div", { class: "advanced-content" }, [
|
|
2012
2144
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
2145
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2013
2146
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2014
2147
|
]));
|
|
2015
2148
|
const pickerInput = el("input", { type: "checkbox", checked: existing ? modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id)) : true });
|
|
@@ -2030,12 +2163,13 @@ function openGrokProviderForm(options) {
|
|
|
2030
2163
|
const providerName = existing ? options.name : uniqueName(typedName, next.providers);
|
|
2031
2164
|
const saved = existing && (next.providers[options.name] || existing);
|
|
2032
2165
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
|
|
2033
|
-
const formKeys = new Set(["type", "identity", "instructionsAppend", "models"]);
|
|
2166
|
+
const formKeys = new Set(["type", "identity", "limits", "instructionsAppend", "models"]);
|
|
2034
2167
|
const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
|
|
2035
2168
|
next.providers[providerName] = {
|
|
2036
2169
|
...kept,
|
|
2037
2170
|
type: "grok",
|
|
2038
2171
|
identity: identity.checked,
|
|
2172
|
+
limits: limitsInput.checked,
|
|
2039
2173
|
...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
|
|
2040
2174
|
models: checkedModels,
|
|
2041
2175
|
};
|
|
@@ -2110,9 +2244,11 @@ function openGoogleProviderForm(options) {
|
|
|
2110
2244
|
probeButton.addEventListener("click", () => void runProbe());
|
|
2111
2245
|
const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
|
|
2112
2246
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
2247
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2113
2248
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2114
2249
|
advanced.appendChild(el("div", { class: "advanced-content" }, [
|
|
2115
2250
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
2251
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2116
2252
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2117
2253
|
]));
|
|
2118
2254
|
const pickerInput = el("input", { type: "checkbox", checked: Boolean(existing && modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id))) });
|
|
@@ -2133,7 +2269,7 @@ function openGoogleProviderForm(options) {
|
|
|
2133
2269
|
const saved = existing && (next.providers[options.name] || existing);
|
|
2134
2270
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
|
|
2135
2271
|
// Fields this form has no control for (defaultEffort, a hand-written setting) survive the save.
|
|
2136
|
-
const formKeys = new Set(["type", "auth", "apiKey", "identity", "instructionsAppend", "models"]);
|
|
2272
|
+
const formKeys = new Set(["type", "auth", "apiKey", "identity", "limits", "instructionsAppend", "models"]);
|
|
2137
2273
|
const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
|
|
2138
2274
|
next.providers[providerName] = {
|
|
2139
2275
|
...kept,
|
|
@@ -2142,6 +2278,7 @@ function openGoogleProviderForm(options) {
|
|
|
2142
2278
|
...(apiKey ? { apiKey } : {}),
|
|
2143
2279
|
...(existing && existing.url ? { url: existing.url } : {}),
|
|
2144
2280
|
identity: identity.checked,
|
|
2281
|
+
limits: limitsInput.checked,
|
|
2145
2282
|
...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
|
|
2146
2283
|
models: checkedModels,
|
|
2147
2284
|
};
|
|
@@ -2242,12 +2379,14 @@ function openProviderForm(options) {
|
|
|
2242
2379
|
// Without a line of its own the model reads Claude Code's system prompt and answers that it is
|
|
2243
2380
|
// Claude, which is what DeepSeek did before this was offered here too.
|
|
2244
2381
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
2382
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2245
2383
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2246
2384
|
advancedContent.append(
|
|
2247
2385
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
2386
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2248
2387
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2249
2388
|
);
|
|
2250
|
-
advancedContent._prompt = { identity, append };
|
|
2389
|
+
advancedContent._prompt = { identity, limits: limitsInput, append };
|
|
2251
2390
|
} else {
|
|
2252
2391
|
const auth = el("select", {}, [selectOption("auto", t("providers.authAuto")), selectOption("own", t("providers.authOwn")), selectOption("borrow-codex", t("providers.authBorrow"))]);
|
|
2253
2392
|
auth.value = (existing && existing.auth) || "auto";
|
|
@@ -2259,6 +2398,7 @@ function openProviderForm(options) {
|
|
|
2259
2398
|
const loginHelp = el("small", { text: t("providers.loginHelp") });
|
|
2260
2399
|
const effort = effortSelect((existing && existing.defaultEffort) || "high", ["low", "medium", "high", "xhigh", "max"]);
|
|
2261
2400
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
2401
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2262
2402
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2263
2403
|
chatgptFields = [
|
|
2264
2404
|
inputRow(t("providers.credentials"), auth, t("providers.credentialsHelp")),
|
|
@@ -2267,9 +2407,10 @@ function openProviderForm(options) {
|
|
|
2267
2407
|
];
|
|
2268
2408
|
advancedContent.append(
|
|
2269
2409
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
2410
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2270
2411
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2271
2412
|
);
|
|
2272
|
-
advancedContent._chatgpt = { auth, effort, identity, append };
|
|
2413
|
+
advancedContent._chatgpt = { auth, effort, identity, limits: limitsInput, append };
|
|
2273
2414
|
}
|
|
2274
2415
|
advanced.appendChild(advancedContent);
|
|
2275
2416
|
const customUrlRow = isCustom ? inputRow(t("providers.url"), urlInput, t("providers.urlHelp")) : null;
|
|
@@ -2311,13 +2452,13 @@ function openProviderForm(options) {
|
|
|
2311
2452
|
function draftProvider() {
|
|
2312
2453
|
if (isChatgpt) {
|
|
2313
2454
|
const chat = advancedContent._chatgpt;
|
|
2314
|
-
return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
|
|
2455
|
+
return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, limits: chat.limits.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
|
|
2315
2456
|
}
|
|
2316
2457
|
const prompt = advancedContent._prompt;
|
|
2317
2458
|
return {
|
|
2318
2459
|
type: openAiNow() ? "openai-compatible" : "anthropic-compatible",
|
|
2319
2460
|
url: urlInput.value.trim(),
|
|
2320
|
-
...(prompt ? { identity: prompt.identity.checked } : {}),
|
|
2461
|
+
...(prompt ? { identity: prompt.identity.checked, limits: prompt.limits.checked } : {}),
|
|
2321
2462
|
...(prompt && prompt.append.value.trim() ? { instructionsAppend: prompt.append.value.trim() } : {}),
|
|
2322
2463
|
...(preset ? { preset: preset.id } : {}),
|
|
2323
2464
|
// A vendor that asks for a session header keys its prompt cache on it, so carry it from the
|
|
@@ -2410,7 +2551,7 @@ function openProviderForm(options) {
|
|
|
2410
2551
|
const provider = draftProvider();
|
|
2411
2552
|
if (!isChatgpt && (!provider.url || !/^https?:\/\//.test(provider.url))) { toast(t("providers.urlRequired"), true); return; }
|
|
2412
2553
|
const next = clone(await latestConfig());
|
|
2413
|
-
const providerName = existing ? options.name : uniqueName(typedName, next.providers);
|
|
2554
|
+
const providerName = existing ? options.name : uniqueName(typedName, next.providers, provider.url);
|
|
2414
2555
|
const saved = existing && (next.providers[options.name] || existing);
|
|
2415
2556
|
if (existing && providerName !== options.name) delete next.providers[options.name];
|
|
2416
2557
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
|
|
@@ -2419,8 +2560,8 @@ function openProviderForm(options) {
|
|
|
2419
2560
|
// (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
|
|
2420
2561
|
// A field the form owns is left to the draft, so clearing it in the form still clears it.
|
|
2421
2562
|
const formKeys = new Set(isChatgpt
|
|
2422
|
-
? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
|
|
2423
|
-
: ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
|
|
2563
|
+
? ["type", "auth", "defaultEffort", "identity", "limits", "instructionsAppend", "models"]
|
|
2564
|
+
: ["type", "url", "identity", "limits", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
|
|
2424
2565
|
const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
|
|
2425
2566
|
next.providers[providerName] = { ...kept, ...provider };
|
|
2426
2567
|
// Read after `next.providers` has been updated, so an unticked model is already undeclared here
|
|
@@ -2459,8 +2600,8 @@ function openProviderForm(options) {
|
|
|
2459
2600
|
//
|
|
2460
2601
|
// The mod's settings live in the router (GET/PUT /api/mod-settings); the ⚙ panel in the bar edits
|
|
2461
2602
|
// the same object. As in System Settings a change applies itself: it is saved after a short pause
|
|
2462
|
-
// with the revision it was made on. A 409 means another surface saved first
|
|
2463
|
-
//
|
|
2603
|
+
// with the revision it was made on. A 409 means another surface saved first: the fields changed
|
|
2604
|
+
// here are laid over its settings and sent again, so neither side's change is lost.
|
|
2464
2605
|
|
|
2465
2606
|
const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
2466
2607
|
// The band's parts the person can turn off (`hide`), then the ones it shows only when turned on
|
|
@@ -2490,13 +2631,16 @@ const MOD_DEFAULTS = {
|
|
|
2490
2631
|
fallback: { agent: "general-purpose", model: "sonnet" },
|
|
2491
2632
|
keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
|
|
2492
2633
|
compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
|
|
2493
|
-
swampAt: 50, layout: "dashboard", appearance: "auto", language: "auto", hide: ["reason"], extras: [], setupDone: false,
|
|
2634
|
+
swampAt: 50, layout: "dashboard", appearance: "auto", skin: "deep", language: "auto", hide: ["reason"], extras: [], setupDone: false,
|
|
2494
2635
|
};
|
|
2495
2636
|
|
|
2496
|
-
|
|
2637
|
+
// `base` is the router's copy `settings` was edited from: what differs between them is this page's own change.
|
|
2638
|
+
let modState = { settings: null, base: null, revision: 0, options: null, retiredNote: false };
|
|
2497
2639
|
let modSaveTimer = null;
|
|
2498
2640
|
let modSaving = false;
|
|
2499
2641
|
let modSaveAgain = false;
|
|
2642
|
+
// Counts the router's answers to saves, so a read that went out before one does not undo it.
|
|
2643
|
+
let modWrites = 0;
|
|
2500
2644
|
|
|
2501
2645
|
/**
|
|
2502
2646
|
* The router hands back the mod's full normalized settings; this only fills what an older router
|
|
@@ -2517,6 +2661,42 @@ function modNormalize(raw) {
|
|
|
2517
2661
|
};
|
|
2518
2662
|
}
|
|
2519
2663
|
|
|
2664
|
+
/**
|
|
2665
|
+
* The fields changed from `before` to `after`, found as the mod's settingsChanges finds them: values
|
|
2666
|
+
* and arrays whole, objects field by field, and a rule whose mode changed replaced whole.
|
|
2667
|
+
*/
|
|
2668
|
+
function modChanges(before, after, path = []) {
|
|
2669
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return [];
|
|
2670
|
+
if (after === undefined) return [{ path, remove: true }];
|
|
2671
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2672
|
+
if (isObject(before) && isObject(after) && before.mode === after.mode) {
|
|
2673
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => modChanges(before[key], after[key], [...path, key]));
|
|
2674
|
+
}
|
|
2675
|
+
return [{ path, value: after }];
|
|
2676
|
+
}
|
|
2677
|
+
function modApply(base, changes) {
|
|
2678
|
+
const result = clone(base);
|
|
2679
|
+
for (const change of changes) {
|
|
2680
|
+
const key = change.path.at(-1);
|
|
2681
|
+
if (key === undefined) continue;
|
|
2682
|
+
let parent = result;
|
|
2683
|
+
for (const step of change.path.slice(0, -1)) {
|
|
2684
|
+
if (!parent[step] || typeof parent[step] !== "object" || Array.isArray(parent[step])) parent[step] = {};
|
|
2685
|
+
parent = parent[step];
|
|
2686
|
+
}
|
|
2687
|
+
if (change.remove) delete parent[key];
|
|
2688
|
+
else parent[key] = clone(change.value);
|
|
2689
|
+
}
|
|
2690
|
+
return result;
|
|
2691
|
+
}
|
|
2692
|
+
/** Take the router's copy, keeping on it what was changed here and is not saved yet (unless `drop`). */
|
|
2693
|
+
function modAdopt(snapshot, drop) {
|
|
2694
|
+
const changes = !drop && modState.base && modState.settings ? modChanges(modState.base, modState.settings) : [];
|
|
2695
|
+
modState.base = modNormalize(snapshot.settings);
|
|
2696
|
+
modState.settings = modApply(modState.base, changes);
|
|
2697
|
+
modState.revision = Number(snapshot.revision) || 0;
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2520
2700
|
function modCap(text) { return text ? text.charAt(0).toLocaleUpperCase() + text.slice(1) : text; }
|
|
2521
2701
|
function modEffortName(effort) { return t(`mod.effort.${effort}`); }
|
|
2522
2702
|
function modSigned(n) { return n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0"; }
|
|
@@ -2604,42 +2784,57 @@ async function saveMod() {
|
|
|
2604
2784
|
if (modSaving) { modSaveAgain = true; return; }
|
|
2605
2785
|
modSaving = true;
|
|
2606
2786
|
try {
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
//
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2787
|
+
// Another surface saving first (the bar's ⚙ panel, another tab of this page) costs this change
|
|
2788
|
+
// nothing: it is laid over their settings and sent again. Only a router that keeps moving under
|
|
2789
|
+
// it, three times running, gets the change dropped.
|
|
2790
|
+
for (let attempt = 1; ; attempt++) {
|
|
2791
|
+
const sent = clone(modState.settings);
|
|
2792
|
+
let out;
|
|
2793
|
+
try {
|
|
2794
|
+
out = await api("/api/mod-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ schemaVersion: 1, settings: sent, revision: modState.revision }) });
|
|
2795
|
+
} catch (error) {
|
|
2796
|
+
if (error.status !== 409 || !error.body || !("settings" in error.body)) throw error;
|
|
2797
|
+
modWrites++;
|
|
2798
|
+
if (attempt < 3) { modAdopt(error.body); modRefresh(); continue; }
|
|
2799
|
+
clearTimeout(modSaveTimer);
|
|
2800
|
+
modSaveTimer = null;
|
|
2801
|
+
modSaveAgain = false;
|
|
2802
|
+
modAdopt(error.body, true);
|
|
2803
|
+
modRefresh();
|
|
2804
|
+
modStatus(t("mod.conflict"), true);
|
|
2805
|
+
toast(t("mod.conflict"), true);
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
modWrites++;
|
|
2809
|
+
modState.revision = Number(out.revision) || modState.revision + 1;
|
|
2810
|
+
// The router's copy is the normalized one; take it unless a newer change is already waiting.
|
|
2811
|
+
if (out.settings && !modSaveTimer && !modSaveAgain) { modAdopt(out, true); modRefresh(); }
|
|
2812
|
+
else modState.base = sent;
|
|
2813
|
+
modStatus(t("mod.saved"));
|
|
2814
|
+
return;
|
|
2624
2815
|
}
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
modStatus(`${t("common.saveFailed")} ${error.message}`, true);
|
|
2625
2818
|
} finally {
|
|
2626
2819
|
modSaving = false;
|
|
2627
2820
|
if (modSaveAgain) { modSaveAgain = false; void saveMod(); }
|
|
2628
2821
|
}
|
|
2629
2822
|
}
|
|
2630
2823
|
async function loadMod() {
|
|
2631
|
-
// A
|
|
2824
|
+
// A save on its way brings back the router's copy itself.
|
|
2632
2825
|
if (modSaveTimer || modSaving) return;
|
|
2633
2826
|
let retired = false;
|
|
2827
|
+
const writes = modWrites;
|
|
2634
2828
|
const [settingsResult, optionsResult] = await Promise.allSettled([api("/api/mod-settings"), api("/api/mod-options")]);
|
|
2635
2829
|
if (settingsResult.status === "rejected") {
|
|
2636
2830
|
if (!modState.settings) {
|
|
2637
2831
|
$("#mod-content").replaceChildren(modSection(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${settingsResult.reason.message}` })]));
|
|
2638
2832
|
return;
|
|
2639
2833
|
}
|
|
2640
|
-
} else {
|
|
2641
|
-
|
|
2642
|
-
|
|
2834
|
+
} else if (!modSaving && modWrites === writes) {
|
|
2835
|
+
// A change made while this read was out stays on top of what it brought. One already saved, or
|
|
2836
|
+
// being saved, has the router's newer answer from that save instead.
|
|
2837
|
+
modAdopt(settingsResult.value);
|
|
2643
2838
|
retired = settingsResult.value.legacy === true;
|
|
2644
2839
|
}
|
|
2645
2840
|
modState.options = optionsResult.status === "fulfilled" ? optionsResult.value : null;
|
|
@@ -2649,6 +2844,10 @@ async function loadMod() {
|
|
|
2649
2844
|
// The settings came back without Auto and the field rules; saving them as they are keeps the note to this once.
|
|
2650
2845
|
if (retired) modPatch({});
|
|
2651
2846
|
}
|
|
2847
|
+
// Back on this page after a while: the bar's ⚙ panel or another tab may have saved meanwhile.
|
|
2848
|
+
document.addEventListener("visibilitychange", () => {
|
|
2849
|
+
if (document.visibilityState === "visible" && $("#view-mod").classList.contains("active")) void loadMod();
|
|
2850
|
+
});
|
|
2652
2851
|
function modRefresh() {
|
|
2653
2852
|
if (!modState.settings) return;
|
|
2654
2853
|
renderModNotice();
|
|
@@ -3056,6 +3255,7 @@ function modLookCard() {
|
|
|
3056
3255
|
modSection(null, [
|
|
3057
3256
|
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"),
|
|
3058
3257
|
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"),
|
|
3258
|
+
cell(t("mod.skin"), modSegmented("skin", [{ value: "deep", label: t("mod.skinDeep") }, { value: "flat", label: t("mod.skinFlat") }], s.skin || "deep", t("mod.skin"), (skin) => modPatch({ skin })), null, "wide"),
|
|
3059
3259
|
cell(t("mod.language"), modSelect("language", languages, s.language, t("mod.language"), (language) => modPatch({ language }), "narrow")),
|
|
3060
3260
|
]),
|
|
3061
3261
|
modSection(t("mod.show"), [
|
|
@@ -3080,6 +3280,29 @@ let knownRequestProviders = new Set();
|
|
|
3080
3280
|
let requestProvider = "";
|
|
3081
3281
|
let showCountTokens = false;
|
|
3082
3282
|
let expandedRequestId = null;
|
|
3283
|
+
// The usage period. "hour" is the exact rolling hour; every other period reads the hourly history.
|
|
3284
|
+
const USAGE_PERIODS = ["hour", "today", "7d", "30d", "month", "all", "custom"];
|
|
3285
|
+
const USAGE_STORE = "clauderipple_logs_usage";
|
|
3286
|
+
let usagePeriod = "hour";
|
|
3287
|
+
let usageFrom = ""; // custom start, a local "YYYY-MM-DD"
|
|
3288
|
+
let usageTo = ""; // custom end (inclusive), a local "YYYY-MM-DD"
|
|
3289
|
+
let lastSummary = null;
|
|
3290
|
+
let configuredProviders = null; // provider names from /api/config, fetched once for this view
|
|
3291
|
+
let renderedProviderNames = "";
|
|
3292
|
+
let requestsGeneration = 0;
|
|
3293
|
+
(function restoreUsage() {
|
|
3294
|
+
try {
|
|
3295
|
+
const saved = JSON.parse(localStorage.getItem(USAGE_STORE) || "null");
|
|
3296
|
+
if (!saved || typeof saved !== "object") return;
|
|
3297
|
+
if (USAGE_PERIODS.includes(saved.period)) usagePeriod = saved.period;
|
|
3298
|
+
if (isDay(saved.from)) usageFrom = saved.from;
|
|
3299
|
+
if (isDay(saved.to)) usageTo = saved.to;
|
|
3300
|
+
if (typeof saved.provider === "string") requestProvider = saved.provider;
|
|
3301
|
+
} catch { /* storage unavailable or unreadable: start from the defaults */ }
|
|
3302
|
+
})();
|
|
3303
|
+
function saveUsage() {
|
|
3304
|
+
try { localStorage.setItem(USAGE_STORE, JSON.stringify({ period: usagePeriod, from: usageFrom, to: usageTo, provider: requestProvider })); } catch { /* unavailable */ }
|
|
3305
|
+
}
|
|
3083
3306
|
|
|
3084
3307
|
function colorizeLogLine(line) {
|
|
3085
3308
|
const cls = /\bERROR\b|\berror\b/.test(line) ? "tag-ERR" : /\bWARN\b/.test(line) ? "tag-WARN" : "tag-PASS";
|
|
@@ -3110,10 +3333,97 @@ function showLogsPanel(name) {
|
|
|
3110
3333
|
}
|
|
3111
3334
|
$("#logs-requests-tab").addEventListener("click", () => showLogsPanel("requests"));
|
|
3112
3335
|
$("#logs-raw-tab").addEventListener("click", () => showLogsPanel("raw"));
|
|
3113
|
-
$("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; void refreshRequests(); });
|
|
3336
|
+
$("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; saveUsage(); void refreshRequests(); });
|
|
3337
|
+
|
|
3338
|
+
// ---- Usage period: local calendar days, so "today" starts at this machine's midnight.
|
|
3339
|
+
function isDay(value) { return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(dayStart(value).valueOf()); }
|
|
3340
|
+
function dayStart(value) { const [y, m, d] = value.split("-").map(Number); return new Date(y, m - 1, d); }
|
|
3341
|
+
function dayKey(date) { const two = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`; }
|
|
3342
|
+
function daysAgo(n) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate() - n); }
|
|
3343
|
+
/** The period as `{from, to}` in epoch ms (`to` exclusive), or null for the rolling hour. */
|
|
3344
|
+
function usageRange() {
|
|
3345
|
+
const now = Date.now();
|
|
3346
|
+
if (usagePeriod === "today") return { from: daysAgo(0).valueOf(), to: now };
|
|
3347
|
+
if (usagePeriod === "7d") return { from: daysAgo(6).valueOf(), to: now };
|
|
3348
|
+
if (usagePeriod === "30d") return { from: daysAgo(29).valueOf(), to: now };
|
|
3349
|
+
if (usagePeriod === "month") { const today = new Date(); return { from: new Date(today.getFullYear(), today.getMonth(), 1).valueOf(), to: now }; }
|
|
3350
|
+
if (usagePeriod === "all") return { from: 0, to: now };
|
|
3351
|
+
if (usagePeriod === "custom") {
|
|
3352
|
+
if (!isDay(usageFrom) || !isDay(usageTo)) return null;
|
|
3353
|
+
const [first, last] = usageFrom <= usageTo ? [usageFrom, usageTo] : [usageTo, usageFrom];
|
|
3354
|
+
const end = dayStart(last);
|
|
3355
|
+
return { from: dayStart(first).valueOf(), to: new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1).valueOf() };
|
|
3356
|
+
}
|
|
3357
|
+
return null;
|
|
3358
|
+
}
|
|
3359
|
+
function formatDay(ms, withYear) {
|
|
3360
|
+
const options = withYear ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" };
|
|
3361
|
+
try { return new Intl.DateTimeFormat(CURRENT_LANG, options).format(new Date(ms)); } catch { return dayKey(new Date(ms)); }
|
|
3362
|
+
}
|
|
3363
|
+
/** What the usage numbers cover, in words: "Last hour · …" or "Oct 14 – Oct 20 · Records start Oct 12". */
|
|
3364
|
+
function usageCaption(range, earliest) {
|
|
3365
|
+
if (!range) return `${t("logs.lastHour")} · ${t("logs.polling")}`;
|
|
3366
|
+
const firstRecord = earliest ? new Date(earliest).valueOf() : NaN;
|
|
3367
|
+
// "All" starts where the records do; any other period that starts before them says so.
|
|
3368
|
+
const start = usagePeriod === "all" && Number.isFinite(firstRecord) ? firstRecord : range.from;
|
|
3369
|
+
const end = Math.max(start, Math.min(range.to, Date.now()) - 1);
|
|
3370
|
+
const year = new Date().getFullYear();
|
|
3371
|
+
const withYear = new Date(start).getFullYear() !== year || new Date(end).getFullYear() !== year;
|
|
3372
|
+
const first = formatDay(start, withYear);
|
|
3373
|
+
const last = formatDay(end, withYear);
|
|
3374
|
+
const parts = [first === last ? first : `${first} – ${last}`];
|
|
3375
|
+
if (usagePeriod !== "all" && Number.isFinite(firstRecord) && firstRecord > range.from) parts.push(t("logs.since", { date: formatDay(firstRecord, withYear) }));
|
|
3376
|
+
return parts.join(" · ");
|
|
3377
|
+
}
|
|
3378
|
+
function renderPeriodControl() {
|
|
3379
|
+
const control = $("#logs-period");
|
|
3380
|
+
control.replaceChildren(...USAGE_PERIODS.map((period) => {
|
|
3381
|
+
const on = period === usagePeriod;
|
|
3382
|
+
return el("button", { type: "button", class: on ? "active" : "", role: "radio", "aria-checked": String(on), text: t(`logs.period.${period}`), onclick: () => pickPeriod(period) });
|
|
3383
|
+
}));
|
|
3384
|
+
$("#logs-custom").hidden = usagePeriod !== "custom";
|
|
3385
|
+
}
|
|
3386
|
+
function pickPeriod(period) {
|
|
3387
|
+
if (period === usagePeriod) return;
|
|
3388
|
+
usagePeriod = period;
|
|
3389
|
+
if (period === "custom") {
|
|
3390
|
+
// A first custom range starts as the last seven days, so both fields hold something to adjust.
|
|
3391
|
+
if (!isDay(usageFrom)) usageFrom = dayKey(daysAgo(6));
|
|
3392
|
+
if (!isDay(usageTo)) usageTo = dayKey(daysAgo(0));
|
|
3393
|
+
fillCustomDates();
|
|
3394
|
+
}
|
|
3395
|
+
saveUsage();
|
|
3396
|
+
renderPeriodControl();
|
|
3397
|
+
void refreshRequests();
|
|
3398
|
+
}
|
|
3399
|
+
// Written only on load and when the period becomes custom: the 3-second refresh never touches these
|
|
3400
|
+
// fields, so a date being typed is not reset under the cursor.
|
|
3401
|
+
function fillCustomDates() {
|
|
3402
|
+
const today = dayKey(daysAgo(0));
|
|
3403
|
+
for (const [selector, value] of [["#logs-from", usageFrom], ["#logs-to", usageTo]]) {
|
|
3404
|
+
const input = $(selector);
|
|
3405
|
+
input.value = value;
|
|
3406
|
+
input.max = today;
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
for (const selector of ["#logs-from", "#logs-to"]) {
|
|
3410
|
+
$(selector).addEventListener("change", () => {
|
|
3411
|
+
const from = $("#logs-from").value;
|
|
3412
|
+
const to = $("#logs-to").value;
|
|
3413
|
+
if (!isDay(from) || !isDay(to)) return;
|
|
3414
|
+
usageFrom = from;
|
|
3415
|
+
usageTo = to;
|
|
3416
|
+
saveUsage();
|
|
3417
|
+
void refreshRequests();
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
renderPeriodControl();
|
|
3421
|
+
fillCustomDates();
|
|
3114
3422
|
|
|
3423
|
+
const EMPTY_BUCKET = { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, avgMs: 0, cacheHitPercent: 0 };
|
|
3115
3424
|
function renderSummary(summary) {
|
|
3116
|
-
const
|
|
3425
|
+
const providers = (summary && summary.providers) || {};
|
|
3426
|
+
const total = (requestProvider ? providers[requestProvider] : summary && summary.total) || EMPTY_BUCKET;
|
|
3117
3427
|
$("#logs-summary").replaceChildren(
|
|
3118
3428
|
summaryChip(t("logs.summary.requests"), formatNumber(total.count)),
|
|
3119
3429
|
summaryChip(t("logs.summary.success"), `${formatNumber(total.ok)} / ${formatNumber(total.failed)}`),
|
|
@@ -3123,17 +3433,70 @@ function renderSummary(summary) {
|
|
|
3123
3433
|
summaryChip(t("logs.summary.output"), formatNumber(total.output)),
|
|
3124
3434
|
summaryChip(t("logs.summary.duration"), formatSeconds(total.avgMs)),
|
|
3125
3435
|
);
|
|
3436
|
+
fitSummaryValues();
|
|
3437
|
+
}
|
|
3438
|
+
// A long period runs the input to ten digits and the requests to "52,939 / 310", wider than a chip
|
|
3439
|
+
// in a 1,024px window (2026-10-20): a value that overflows steps its type down before it ellipsizes.
|
|
3440
|
+
function fitSummaryValues() {
|
|
3441
|
+
for (const value of document.querySelectorAll("#logs-summary .logs-chip .value")) {
|
|
3442
|
+
value.style.fontSize = "";
|
|
3443
|
+
for (let size = parseFloat(getComputedStyle(value).fontSize); value.scrollWidth > value.clientWidth && size > 14; size--) value.style.fontSize = `${size - 1}px`;
|
|
3444
|
+
// A pane about 300px wide fits no ten digits even then: a plain number goes compact ("78.9억"),
|
|
3445
|
+
// the whole of it on hover. The next refresh draws it whole again.
|
|
3446
|
+
const whole = value.dataset.whole || value.textContent;
|
|
3447
|
+
const number = Number(whole.replace(/,/g, ""));
|
|
3448
|
+
if (value.scrollWidth > value.clientWidth && whole.includes(",") && Number.isFinite(number)) {
|
|
3449
|
+
value.dataset.whole = whole;
|
|
3450
|
+
value.title = whole;
|
|
3451
|
+
value.textContent = new Intl.NumberFormat(CURRENT_LANG, { notation: "compact", maximumFractionDigits: 1 }).format(number);
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3126
3454
|
}
|
|
3127
|
-
|
|
3128
|
-
|
|
3455
|
+
window.addEventListener("resize", fitSummaryValues);
|
|
3456
|
+
// Every provider that is configured or shows up in the usage or the requests, not only the ones in
|
|
3457
|
+
// the last 200 requests: those were all one provider for a heavy user, so the menu offered nothing
|
|
3458
|
+
// else (2026-10-20). The chosen provider stays listed even before anything names it.
|
|
3459
|
+
function renderProviderFilter(summary, records) {
|
|
3460
|
+
const configured = (currentConfig && currentConfig.providers) || configuredProviders || (status && status.providers) || {};
|
|
3461
|
+
for (const name of Object.keys(configured)) knownRequestProviders.add(name);
|
|
3462
|
+
for (const name of Object.keys((summary && summary.providers) || {})) knownRequestProviders.add(name);
|
|
3463
|
+
for (const model of (summary && Array.isArray(summary.models) ? summary.models : [])) if (model.provider) knownRequestProviders.add(model.provider);
|
|
3129
3464
|
for (const record of records) if (record.provider) knownRequestProviders.add(record.provider);
|
|
3465
|
+
if (requestProvider) knownRequestProviders.add(requestProvider);
|
|
3130
3466
|
const names = [...knownRequestProviders].sort();
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3467
|
+
// Rebuilt only when the list changes, so the 3-second refresh does not close an open menu.
|
|
3468
|
+
const key = names.join("\n");
|
|
3469
|
+
const select = $("#logs-provider");
|
|
3470
|
+
if (key !== renderedProviderNames) {
|
|
3471
|
+
renderedProviderNames = key;
|
|
3472
|
+
select.replaceChildren(el("option", { value: "", text: t("logs.all") }), ...names.map((name) => el("option", { value: name, text: name })));
|
|
3473
|
+
}
|
|
3474
|
+
if (select.value !== requestProvider) select.value = requestProvider;
|
|
3475
|
+
}
|
|
3476
|
+
async function loadConfiguredProviders() {
|
|
3477
|
+
if (configuredProviders || currentConfig) return;
|
|
3478
|
+
try { configuredProviders = (await api("/api/config")).providers || {}; } catch { /* the usage and requests still name their providers */ }
|
|
3479
|
+
}
|
|
3480
|
+
function modelUsageRow(entry) {
|
|
3481
|
+
const total = totalInputOf(entry);
|
|
3482
|
+
const model = el("div", { class: "request-models" }, [
|
|
3483
|
+
el("span", { class: "model", text: entry.model, title: entry.model }),
|
|
3484
|
+
el("span", { class: `provider-badge ${providerClass(entry.provider)}`, text: entry.provider }),
|
|
3485
|
+
]);
|
|
3486
|
+
// The labels show only where a narrow window drops the header row.
|
|
3487
|
+
return el("tr", {}, [
|
|
3488
|
+
el("td", { class: "c-model" }, [model]),
|
|
3489
|
+
el("td", { class: "c-count", "data-label": t("logs.th.count") }, [formatNumber(entry.count)]),
|
|
3490
|
+
el("td", { class: "c-in", "data-label": t("logs.th.input") }, [el("span", { class: "token-cell", text: formatNumber(total) }, [el("span", { class: "cache-pill", text: t("logs.cacheHit", { percent: entry.cacheHitPercent || 0 }) })])]),
|
|
3491
|
+
el("td", { class: "c-out", "data-label": t("logs.th.output") }, [formatNumber(entry.output)]),
|
|
3492
|
+
el("td", { class: "c-dur", "data-label": t("logs.th.avg") }, [formatSeconds(entry.avgMs)]),
|
|
3493
|
+
]);
|
|
3494
|
+
}
|
|
3495
|
+
function renderModelUsage(summary) {
|
|
3496
|
+
const models = (summary && Array.isArray(summary.models) ? summary.models : []).filter((entry) => !requestProvider || entry.provider === requestProvider);
|
|
3497
|
+
$("#models-table tbody").replaceChildren(...models.map(modelUsageRow));
|
|
3498
|
+
$("#models-table").closest(".logs-table-card").hidden = models.length === 0;
|
|
3499
|
+
$("#models-empty").hidden = models.length !== 0;
|
|
3137
3500
|
}
|
|
3138
3501
|
/** Everything the model read: uncached, read from the cache, and written to it. */
|
|
3139
3502
|
function totalInputOf(usage) {
|
|
@@ -3184,25 +3547,39 @@ function requestRow(record) {
|
|
|
3184
3547
|
return row;
|
|
3185
3548
|
}
|
|
3186
3549
|
function renderRequests(records) {
|
|
3187
|
-
const scroll = $(".logs-table-card");
|
|
3550
|
+
const scroll = $("#requests-table").closest(".logs-table-card");
|
|
3188
3551
|
const left = scroll.scrollLeft;
|
|
3189
3552
|
const tbody = $("#requests-table tbody");
|
|
3190
3553
|
tbody.replaceChildren(...records.flatMap((record) => expandedRequestId === record.id ? [requestRow(record), requestDetail(record)] : [requestRow(record)]));
|
|
3191
|
-
|
|
3554
|
+
scroll.hidden = records.length === 0;
|
|
3192
3555
|
$("#requests-empty").hidden = records.length !== 0;
|
|
3193
3556
|
scroll.scrollLeft = left;
|
|
3194
3557
|
}
|
|
3195
3558
|
async function refreshRequests() {
|
|
3196
3559
|
if (logsPanel !== "requests") return;
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3560
|
+
// A period or provider picked while an older refresh is in flight must not be painted over by it.
|
|
3561
|
+
const generation = ++requestsGeneration;
|
|
3562
|
+
const provider = requestProvider;
|
|
3563
|
+
const range = usageRange();
|
|
3564
|
+
// A custom range with an unusable date waits for the fields instead of falling back to the hour.
|
|
3565
|
+
if (usagePeriod === "custom" && !range) return;
|
|
3566
|
+
$("#logs-range-caption").textContent = usageCaption(range, lastSummary && lastSummary.earliest);
|
|
3567
|
+
const summaryPath = range ? `/api/requests/summary?from=${range.from}&to=${range.to}` : "/api/requests/summary?since=3600";
|
|
3568
|
+
const suffix = provider ? `&provider=${encodeURIComponent(provider)}` : "";
|
|
3569
|
+
const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages`), api(summaryPath), loadConfiguredProviders()]);
|
|
3570
|
+
if (generation !== requestsGeneration) return;
|
|
3571
|
+
// Each half keeps its last good contents when its own call fails.
|
|
3572
|
+
if (summary.status === "fulfilled") {
|
|
3573
|
+
lastSummary = summary.value;
|
|
3574
|
+
$("#logs-range-caption").textContent = usageCaption(range, lastSummary.earliest);
|
|
3575
|
+
renderSummary(lastSummary);
|
|
3576
|
+
renderModelUsage(lastSummary);
|
|
3577
|
+
}
|
|
3578
|
+
if (records.status === "fulfilled") {
|
|
3579
|
+
requestRows = Array.isArray(records.value.requests) ? records.value.requests : [];
|
|
3204
3580
|
renderRequests(requestRows);
|
|
3205
|
-
}
|
|
3581
|
+
}
|
|
3582
|
+
renderProviderFilter(lastSummary, requestRows);
|
|
3206
3583
|
}
|
|
3207
3584
|
async function refreshLogs() {
|
|
3208
3585
|
if (logsPanel !== "raw") return;
|