dsh-mobile-plugin 0.5.27 → 0.5.29
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/extensions/catalog.json +10 -1
- package/extensions/magpie/agentcompact.js +104 -0
- package/extensions/magpie/app.css +4902 -0
- package/extensions/magpie/app.js +24474 -0
- package/extensions/magpie/autobackup.js +185 -0
- package/extensions/magpie/autoresetown.js +151 -0
- package/extensions/magpie/balfound.js +38 -0
- package/extensions/magpie/boot.js +23 -0
- package/extensions/magpie/cfgblocked.js +39 -0
- package/extensions/magpie/checkinnow.js +111 -0
- package/extensions/magpie/claudemode.js +92 -0
- package/extensions/magpie/compat.js +41 -0
- package/extensions/magpie/context.css +433 -0
- package/extensions/magpie/context.js +1576 -0
- package/extensions/magpie/ctxtext.js +223 -0
- package/extensions/magpie/fonts.js +42 -0
- package/extensions/magpie/i18n.js +18942 -0
- package/extensions/magpie/icons/ai21.svg +1 -0
- package/extensions/magpie/icons/ai302-color.svg +1 -0
- package/extensions/magpie/icons/ai360-color.svg +1 -0
- package/extensions/magpie/icons/aihubmix-color.svg +1 -0
- package/extensions/magpie/icons/aionui.svg +1 -0
- package/extensions/magpie/icons/air.svg +4 -0
- package/extensions/magpie/icons/alibaba-color.svg +1 -0
- package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
- package/extensions/magpie/icons/alma.png +0 -0
- package/extensions/magpie/icons/anthropic.svg +1 -0
- package/extensions/magpie/icons/antigravity-color.svg +1 -0
- package/extensions/magpie/icons/anyscale-color.svg +1 -0
- package/extensions/magpie/icons/app-alma.png +0 -0
- package/extensions/magpie/icons/app-cc-switch.png +0 -0
- package/extensions/magpie/icons/apple-touch-icon.png +0 -0
- package/extensions/magpie/icons/aside.svg +28 -0
- package/extensions/magpie/icons/astrbot.svg +1 -0
- package/extensions/magpie/icons/atomcode.png +0 -0
- package/extensions/magpie/icons/aws.svg +1 -0
- package/extensions/magpie/icons/azure-color.svg +1 -0
- package/extensions/magpie/icons/azureai-color.svg +1 -0
- package/extensions/magpie/icons/baichuan-color.svg +1 -0
- package/extensions/magpie/icons/baidu-color.svg +1 -0
- package/extensions/magpie/icons/baiducloud-color.svg +1 -0
- package/extensions/magpie/icons/bailian-color.svg +1 -0
- package/extensions/magpie/icons/baseten.svg +1 -0
- package/extensions/magpie/icons/bedrock-color.svg +1 -0
- package/extensions/magpie/icons/burncloud-color.svg +1 -0
- package/extensions/magpie/icons/bytedance-color.svg +1 -0
- package/extensions/magpie/icons/cerebras.svg +1 -0
- package/extensions/magpie/icons/chatglm-color.svg +1 -0
- package/extensions/magpie/icons/cherryin-color.svg +1 -0
- package/extensions/magpie/icons/chutes.svg +1 -0
- package/extensions/magpie/icons/cindy.png +0 -0
- package/extensions/magpie/icons/claude-color.svg +1 -0
- package/extensions/magpie/icons/claudecode-color.svg +1 -0
- package/extensions/magpie/icons/cline.svg +1 -0
- package/extensions/magpie/icons/cloudflare-color.svg +1 -0
- package/extensions/magpie/icons/codebuddy-color.svg +1 -0
- package/extensions/magpie/icons/codex-color.svg +1 -0
- package/extensions/magpie/icons/cohere-color.svg +1 -0
- package/extensions/magpie/icons/cometapi-color.svg +1 -0
- package/extensions/magpie/icons/commandcode.svg +1 -0
- package/extensions/magpie/icons/copilot-color.svg +1 -0
- package/extensions/magpie/icons/coze.svg +1 -0
- package/extensions/magpie/icons/crush.png +0 -0
- package/extensions/magpie/icons/curl.svg +1 -0
- package/extensions/magpie/icons/cursor.svg +1 -0
- package/extensions/magpie/icons/deepinfra-color.svg +1 -0
- package/extensions/magpie/icons/deepmind-color.svg +1 -0
- package/extensions/magpie/icons/deepseek-color.svg +1 -0
- package/extensions/magpie/icons/deveco-color.svg +12 -0
- package/extensions/magpie/icons/devin.svg +1 -0
- package/extensions/magpie/icons/doubao-color.svg +1 -0
- package/extensions/magpie/icons/empryo-color.svg +1 -0
- package/extensions/magpie/icons/factory.svg +1 -0
- package/extensions/magpie/icons/favicon.svg +1 -0
- package/extensions/magpie/icons/featherless-color.svg +1 -0
- package/extensions/magpie/icons/fireworks-color.svg +1 -0
- package/extensions/magpie/icons/friendli.svg +1 -0
- package/extensions/magpie/icons/fx.svg +1 -0
- package/extensions/magpie/icons/gemini-color.svg +1 -0
- package/extensions/magpie/icons/geminicli-color.svg +1 -0
- package/extensions/magpie/icons/gemma-color.svg +1 -0
- package/extensions/magpie/icons/giteeai.svg +1 -0
- package/extensions/magpie/icons/github.svg +1 -0
- package/extensions/magpie/icons/githubcopilot.svg +1 -0
- package/extensions/magpie/icons/glama.svg +1 -0
- package/extensions/magpie/icons/gmicloud.svg +1 -0
- package/extensions/magpie/icons/google-color.svg +1 -0
- package/extensions/magpie/icons/googlecloud-color.svg +1 -0
- package/extensions/magpie/icons/goose.svg +1 -0
- package/extensions/magpie/icons/grok.svg +1 -0
- package/extensions/magpie/icons/groq.svg +1 -0
- package/extensions/magpie/icons/hanako.png +0 -0
- package/extensions/magpie/icons/hermes.svg +1 -0
- package/extensions/magpie/icons/huawei-color.svg +1 -0
- package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
- package/extensions/magpie/icons/huggingface-color.svg +1 -0
- package/extensions/magpie/icons/hunyuan-color.svg +1 -0
- package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
- package/extensions/magpie/icons/icon-512.png +0 -0
- package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
- package/extensions/magpie/icons/inference.svg +1 -0
- package/extensions/magpie/icons/infinigence-color.svg +1 -0
- package/extensions/magpie/icons/internlm-color.svg +1 -0
- package/extensions/magpie/icons/jcode.svg +1 -0
- package/extensions/magpie/icons/jina.svg +1 -0
- package/extensions/magpie/icons/kilo.svg +1 -0
- package/extensions/magpie/icons/kilocode.svg +1 -0
- package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
- package/extensions/magpie/icons/kimi.svg +1 -0
- package/extensions/magpie/icons/kiro-color.svg +1 -0
- package/extensions/magpie/icons/kling-color.svg +1 -0
- package/extensions/magpie/icons/lambda.svg +1 -0
- package/extensions/magpie/icons/leptonai-color.svg +1 -0
- package/extensions/magpie/icons/liquid.svg +1 -0
- package/extensions/magpie/icons/liveagent-color.svg +1 -0
- package/extensions/magpie/icons/llmapi.svg +1 -0
- package/extensions/magpie/icons/lmstudio.svg +1 -0
- package/extensions/magpie/icons/lody.png +0 -0
- package/extensions/magpie/icons/longcat.svg +1 -0
- package/extensions/magpie/icons/magpie.svg +1 -0
- package/extensions/magpie/icons/meta-color.svg +1 -0
- package/extensions/magpie/icons/metaai-color.svg +1 -0
- package/extensions/magpie/icons/microsoft-color.svg +1 -0
- package/extensions/magpie/icons/mimocode.svg +1 -0
- package/extensions/magpie/icons/minimax-color.svg +1 -0
- package/extensions/magpie/icons/mistermorph-color.svg +1 -0
- package/extensions/magpie/icons/mistral-color.svg +1 -0
- package/extensions/magpie/icons/mlx-serve.png +0 -0
- package/extensions/magpie/icons/modelscope-color.svg +1 -0
- package/extensions/magpie/icons/monica-color.svg +1 -0
- package/extensions/magpie/icons/moonshot.svg +1 -0
- package/extensions/magpie/icons/morph-color.svg +1 -0
- package/extensions/magpie/icons/mothx.png +0 -0
- package/extensions/magpie/icons/nebius.svg +1 -0
- package/extensions/magpie/icons/newapi-color.svg +1 -0
- package/extensions/magpie/icons/nousresearch.svg +1 -0
- package/extensions/magpie/icons/nova-color.svg +1 -0
- package/extensions/magpie/icons/novita-color.svg +1 -0
- package/extensions/magpie/icons/nvidia-color.svg +1 -0
- package/extensions/magpie/icons/ollama.svg +1 -0
- package/extensions/magpie/icons/omlx.svg +1 -0
- package/extensions/magpie/icons/omo.svg +1 -0
- package/extensions/magpie/icons/omp.svg +1 -0
- package/extensions/magpie/icons/openai.svg +1 -0
- package/extensions/magpie/icons/openchamber.svg +11 -0
- package/extensions/magpie/icons/openclaw.svg +1 -0
- package/extensions/magpie/icons/opencode.svg +1 -0
- package/extensions/magpie/icons/openmausbot.svg +1 -0
- package/extensions/magpie/icons/openrouter.svg +1 -0
- package/extensions/magpie/icons/openwebui.svg +1 -0
- package/extensions/magpie/icons/parasail.svg +1 -0
- package/extensions/magpie/icons/penguin-color.svg +1 -0
- package/extensions/magpie/icons/perplexity-color.svg +1 -0
- package/extensions/magpie/icons/phala-color.svg +1 -0
- package/extensions/magpie/icons/pi.svg +1 -0
- package/extensions/magpie/icons/pipellm-color.svg +5 -0
- package/extensions/magpie/icons/poe.svg +1 -0
- package/extensions/magpie/icons/ppio-color.svg +1 -0
- package/extensions/magpie/icons/prime-agent.svg +1 -0
- package/extensions/magpie/icons/qiniu-color.svg +1 -0
- package/extensions/magpie/icons/qoder.svg +1 -0
- package/extensions/magpie/icons/qwen-color.svg +1 -0
- package/extensions/magpie/icons/qwenpaw.svg +1 -0
- package/extensions/magpie/icons/raycast.svg +1 -0
- package/extensions/magpie/icons/reasonix-color.svg +48 -0
- package/extensions/magpie/icons/reka.svg +1 -0
- package/extensions/magpie/icons/replicate.svg +1 -0
- package/extensions/magpie/icons/roocode.svg +1 -0
- package/extensions/magpie/icons/rtk.png +0 -0
- package/extensions/magpie/icons/sambanova-color.svg +1 -0
- package/extensions/magpie/icons/sensenova-color.svg +1 -0
- package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
- package/extensions/magpie/icons/snow.png +0 -0
- package/extensions/magpie/icons/snowflake-color.svg +1 -0
- package/extensions/magpie/icons/sophnet-color.svg +1 -0
- package/extensions/magpie/icons/spark-color.svg +1 -0
- package/extensions/magpie/icons/stepfun-color.svg +1 -0
- package/extensions/magpie/icons/straico-color.svg +1 -0
- package/extensions/magpie/icons/submodel-color.svg +1 -0
- package/extensions/magpie/icons/t3code.svg +4 -0
- package/extensions/magpie/icons/targon-color.svg +1 -0
- package/extensions/magpie/icons/tencent-color.svg +1 -0
- package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
- package/extensions/magpie/icons/tiangong-color.svg +1 -0
- package/extensions/magpie/icons/together-color.svg +1 -0
- package/extensions/magpie/icons/trae.svg +1 -0
- package/extensions/magpie/icons/typesafe.png +0 -0
- package/extensions/magpie/icons/upstage-color.svg +1 -0
- package/extensions/magpie/icons/venice-color.svg +1 -0
- package/extensions/magpie/icons/vercel.svg +1 -0
- package/extensions/magpie/icons/vertexai-color.svg +1 -0
- package/extensions/magpie/icons/vllm-color.svg +1 -0
- package/extensions/magpie/icons/volcengine-color.svg +1 -0
- package/extensions/magpie/icons/voyage-color.svg +1 -0
- package/extensions/magpie/icons/vscode.svg +1 -0
- package/extensions/magpie/icons/wenxin-color.svg +1 -0
- package/extensions/magpie/icons/windsurf.svg +1 -0
- package/extensions/magpie/icons/workbuddy-color.svg +1 -0
- package/extensions/magpie/icons/workersai-color.svg +1 -0
- package/extensions/magpie/icons/xai.svg +1 -0
- package/extensions/magpie/icons/xiaomimimo.svg +1 -0
- package/extensions/magpie/icons/xinference-color.svg +1 -0
- package/extensions/magpie/icons/yi.svg +1 -0
- package/extensions/magpie/icons/yuanbao-color.svg +1 -0
- package/extensions/magpie/icons/yylx.svg +5 -0
- package/extensions/magpie/icons/zai.svg +1 -0
- package/extensions/magpie/icons/zcode.png +0 -0
- package/extensions/magpie/icons/zed.svg +1 -0
- package/extensions/magpie/icons/zenmux.svg +1 -0
- package/extensions/magpie/icons/zeroone.svg +1 -0
- package/extensions/magpie/icons/zhipu-color.svg +1 -0
- package/extensions/magpie/icons/zoo-code.png +0 -0
- package/extensions/magpie/index.html +572 -0
- package/extensions/magpie/kernel.js +274 -0
- package/extensions/magpie/keyspend.js +63 -0
- package/extensions/magpie/layout.js +26 -0
- package/extensions/magpie/manifest.json +37 -0
- package/extensions/magpie/navlamp.js +47 -0
- package/extensions/magpie/omarchy.css +72 -0
- package/extensions/magpie/omarchy.js +46 -0
- package/extensions/magpie/passerrors.js +21 -0
- package/extensions/magpie/pluginpages.js +405 -0
- package/extensions/magpie/plugins/gateway/client.js +1194 -0
- package/extensions/magpie/plugins/gateway/keys.js +972 -0
- package/extensions/magpie/plugins/gateway/settings.js +459 -0
- package/extensions/magpie/plugins/library/client.js +6788 -0
- package/extensions/magpie/plugins/library/i18n.js +3228 -0
- package/extensions/magpie/plugins/library/library.css +848 -0
- package/extensions/magpie/plugins/otel/i18n.js +48 -0
- package/extensions/magpie/plugins/otel/page.js +189 -0
- package/extensions/magpie/plugins/redact/client.js +46 -0
- package/extensions/magpie/plugins/redact/demo.js +461 -0
- package/extensions/magpie/plugins/redact/i18n.js +432 -0
- package/extensions/magpie/plugins/redact/page.js +985 -0
- package/extensions/magpie/plugins/redact/redact.css +385 -0
- package/extensions/magpie/plugins/redact/settings.js +248 -0
- package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
- package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
- package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
- package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
- package/extensions/magpie/plugins/routing/client.js +8593 -0
- package/extensions/magpie/plugins/routing/i18n.js +2696 -0
- package/extensions/magpie/plugins/routing/routing.css +1111 -0
- package/extensions/magpie/plugins/search/settings.js +460 -0
- package/extensions/magpie/plugins/sessions/client.js +1622 -0
- package/extensions/magpie/plugins/sessions/i18n.js +360 -0
- package/extensions/magpie/plugins/sessions/sessions.css +181 -0
- package/extensions/magpie/plugins/sessions/settings.js +100 -0
- package/extensions/magpie/plugins/usage/client.js +150 -0
- package/extensions/magpie/plugins/usage/i18n.js +984 -0
- package/extensions/magpie/plugins/usage/page.js +4307 -0
- package/extensions/magpie/plugins.css +319 -0
- package/extensions/magpie/plugins.js +1926 -0
- package/extensions/magpie/profilerename.js +27 -0
- package/extensions/magpie/purposes.js +2 -0
- package/extensions/magpie/quitask.js +79 -0
- package/extensions/magpie/quotaall.js +142 -0
- package/extensions/magpie/ratewindows.js +123 -0
- package/extensions/magpie/registry.js +87 -0
- package/extensions/magpie/resetask.js +137 -0
- package/extensions/magpie/ruleedit.js +53 -0
- package/extensions/magpie/scrollends.js +91 -0
- package/extensions/magpie/settingshints.js +75 -0
- package/extensions/magpie/signsheets.js +86 -0
- package/extensions/magpie/start.js +26 -0
- package/extensions/magpie/syncfiles.js +216 -0
- package/extensions/magpie/theme.js +29 -0
- package/extensions/magpie/traywindows.js +41 -0
- package/extensions/magpie/webfonts.js +130 -0
- package/extensions/magpie/whatsnew.js +252 -0
- package/extensions/magpie.zip +0 -0
- package/knowledge/apps/baijiahao.md +30 -0
- package/knowledge/apps/bilibili.md +1 -0
- package/knowledge/apps/dedao.md +33 -0
- package/knowledge/apps/feishu.md +35 -0
- package/knowledge/apps/gmail.md +33 -0
- package/knowledge/apps/jike.md +31 -0
- package/knowledge/apps/kuaishou.md +34 -0
- package/knowledge/apps/linkedin.md +33 -0
- package/knowledge/apps/qqmail.md +30 -0
- package/knowledge/apps/telegram.md +27 -0
- package/knowledge/apps/toutiao.md +32 -0
- package/knowledge/apps/uber-eats.md +26 -0
- package/knowledge/apps/uber.md +27 -0
- package/knowledge/apps/whatsapp.md +27 -0
- package/knowledge/apps/x.md +32 -0
- package/knowledge/apps/xiaohongshu.md +1 -0
- package/knowledge/apps/zhihu.md +1 -0
- package/knowledge/sites/bilibili.com/SITE.md +50 -0
- package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
- package/knowledge/sites/github.com/SITE.md +51 -0
- package/knowledge/sites/github.com/references/repo-fields.md +13 -0
- package/knowledge/sites/x.com/SITE.md +47 -0
- package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
- package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
- package/knowledge/sites/youtube.com/SITE.md +49 -0
- package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
- package/knowledge/sites/zhihu.com/SITE.md +47 -0
- package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
- package/lib/mobile/extension-catalog.js +41 -1
- package/lib/mobile/extension-data.js +224 -0
- package/lib/mobile/extension-dev.js +3 -1
- package/lib/mobile/file-ops.js +252 -0
- package/lib/mobile/history-derive.js +80 -4
- package/lib/mobile/host-bridge/endpoints.js +48 -6
- package/lib/mobile/host-bridge/event-synthesis.js +33 -6
- package/lib/mobile/jobs-control.js +118 -0
- package/lib/mobile/memory.js +635 -0
- package/lib/mobile/model-badges.js +77 -0
- package/lib/mobile/pet-tools.js +50 -4
- package/lib/mobile/pets.js +214 -18
- package/lib/mobile/phone/access.js +85 -0
- package/lib/mobile/phone/evidence.js +228 -0
- package/lib/mobile/phone/gate.js +290 -0
- package/lib/mobile/phone/grounding.js +314 -0
- package/lib/mobile/phone/guard.js +465 -0
- package/lib/mobile/phone/irreversible.js +88 -8
- package/lib/mobile/phone/knowledge-tools.js +665 -0
- package/lib/mobile/phone/learn.js +329 -0
- package/lib/mobile/phone/redact.js +237 -0
- package/lib/mobile/phone/risk.js +95 -0
- package/lib/mobile/phone/routes.js +28 -1
- package/lib/mobile/phone/service.js +23 -0
- package/lib/mobile/phone/site-commands.js +271 -0
- package/lib/mobile/phone/step-summary.js +201 -0
- package/lib/mobile/phone/stop-summary.js +235 -0
- package/lib/mobile/phone/tool-groups.js +125 -0
- package/lib/mobile/phone/tools.js +589 -0
- package/lib/mobile/phone/untrusted.js +65 -0
- package/lib/mobile/phone-knowledge.js +20 -4
- package/lib/mobile/schedule-edit.js +500 -0
- package/lib/mobile/schedule.js +5 -0
- package/lib/mobile/send-continuation.js +52 -0
- package/lib/mobile/session-models.js +27 -31
- package/lib/mobile/session-send.js +12 -12
- package/lib/mobile/skill-import.js +338 -0
- package/lib/mobile/skill-store.js +524 -0
- package/lib/routes/mobile-api.js +42 -0
- package/package.json +7 -6
- package/skills/skill-authoring/SKILL.md +46 -0
- package/skills/skill-authoring/references/examples.md +82 -0
- package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
- package/skills/skill-authoring/scripts/check_skill.py +167 -0
- package/skills/skill-authoring/scripts/manifest.json +3 -0
- package/knowledge/sites/bilibili.com.md +0 -27
- package/knowledge/sites/github.com.md +0 -27
- package/knowledge/sites/x.com.md +0 -28
- package/knowledge/sites/xiaohongshu.com.md +0 -27
- package/knowledge/sites/youtube.com.md +0 -28
- package/knowledge/sites/zhihu.com.md +0 -27
|
@@ -0,0 +1,4307 @@
|
|
|
1
|
+
// The Usage page's code (client.js has its markup and hooks): run after
|
|
2
|
+
// app.js, whose helpers and state it shares, and before start.js shows the
|
|
3
|
+
// first page.
|
|
4
|
+
let usageHidOpen = false; // the hidden agents shown under their line
|
|
5
|
+
|
|
6
|
+
// What was read back from the archive, by "<date>/<id>": the archive's
|
|
7
|
+
// copy, or {busy} while it is read, or {error}.
|
|
8
|
+
const archivedCalls = new Map();
|
|
9
|
+
|
|
10
|
+
function headersPanel(label, first, headers) {
|
|
11
|
+
const panel = el("section", "call-body");
|
|
12
|
+
const head = el("div", "call-body-head");
|
|
13
|
+
const lines = [first, ...Object.keys(headers || {}).sort().flatMap((k) => headers[k].map((v) => `${k}: ${v}`))].filter(Boolean);
|
|
14
|
+
head.append(el("span", "call-body-label", label), el("span", "grow"), copyBtn(lines.join("\n"), label));
|
|
15
|
+
const pre = el("pre");
|
|
16
|
+
pre.append(el("code", "", lines.join("\n")));
|
|
17
|
+
panel.append(head, pre);
|
|
18
|
+
return panel;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A call's HTTP headers both ways (usrname1833 on Discord: 查看原始请求和
|
|
22
|
+
// 原始响应的http头, to debug what a capture would show), as the gateway
|
|
23
|
+
// read them out, secrets taken out: the client's, then each request magpie
|
|
24
|
+
// sent upstream for it and the head of its answer. Folded under the bodies
|
|
25
|
+
// on the Gateway page's recent calls and on Routing; open when open is.
|
|
26
|
+
// h is /api/gateway/call's headers, or the archive's upstream ones.
|
|
27
|
+
function wireHeadersView(h, open, toggle) {
|
|
28
|
+
const box = el("section", "call-wire" + (open ? " open" : ""));
|
|
29
|
+
const b = el("button", "text call-wire-toggle");
|
|
30
|
+
b.setAttribute("aria-expanded", String(open));
|
|
31
|
+
const chev = el("span", "call-chev");
|
|
32
|
+
chev.append(svg(CHEV_R, 11, 1.6));
|
|
33
|
+
b.append(chev, el("span", "", t("HTTP headers")));
|
|
34
|
+
b.onclick = (ev) => { ev.stopPropagation(); toggle(); };
|
|
35
|
+
box.append(b);
|
|
36
|
+
if (!open) return box;
|
|
37
|
+
box.append(wireHeadersGrid(h, true));
|
|
38
|
+
return box;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// the panels: the client's request (when withClient), and each upstream
|
|
42
|
+
// request with its answer
|
|
43
|
+
function wireHeadersGrid(h, withClient) {
|
|
44
|
+
const grid = el("div", "call-details call-wire-grid");
|
|
45
|
+
if (withClient) {
|
|
46
|
+
const client = headersPanel(t("From the client"), "", h.request);
|
|
47
|
+
client.classList.add("call-wire-wide");
|
|
48
|
+
grid.append(client);
|
|
49
|
+
}
|
|
50
|
+
const ups = h.upstream || [];
|
|
51
|
+
if (!ups.length) grid.append(el("p", "call-wire-note", t("No request went upstream.")));
|
|
52
|
+
let plugin = false;
|
|
53
|
+
for (const u of ups) {
|
|
54
|
+
plugin ||= !!u.plugin;
|
|
55
|
+
grid.append(
|
|
56
|
+
headersPanel(u.plugin ? t("Handed to plugin {name}", { name: u.plugin }) : t("Sent upstream"), `${u.method} ${u.url}`, u.request),
|
|
57
|
+
headersPanel(t("Upstream response"), u.error ? u.error : `${u.proto || "HTTP"} ${u.status} · ${ledTook(u.ms)}`, u.response),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (h.more) grid.append(el("p", "call-wire-note", t("{n} more requests upstream, not kept", { n: h.more })));
|
|
61
|
+
if (plugin) grid.append(el("p", "call-wire-note", t("A plugin sends a request on to its vendor with headers of its own, which magpie doesn't see.")));
|
|
62
|
+
return grid;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The archived file, whole, however large: to the browser in magpie web,
|
|
66
|
+
// to Downloads in the app (#447).
|
|
67
|
+
async function downloadArchive(name, b) {
|
|
68
|
+
const [date, aid] = name.split("/");
|
|
69
|
+
const q = `date=${encodeURIComponent(date)}&id=${encodeURIComponent(aid)}`;
|
|
70
|
+
if (web) {
|
|
71
|
+
const a = el("a");
|
|
72
|
+
a.href = "/api/archive/file?" + q;
|
|
73
|
+
a.download = "";
|
|
74
|
+
a.click();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
b.classList.add("busy");
|
|
78
|
+
b.disabled = true;
|
|
79
|
+
try {
|
|
80
|
+
const r = await api("archive/export?" + q, {});
|
|
81
|
+
status(t("Saved to {path}", { path: r.path }), "ok");
|
|
82
|
+
} catch (e) {
|
|
83
|
+
status(t(e.message), "err");
|
|
84
|
+
} finally {
|
|
85
|
+
b.classList.remove("busy");
|
|
86
|
+
b.disabled = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// one body read back from the archive: shown, or, past 256 KB, only its
|
|
91
|
+
// size — the server leaves it out — for the file to be downloaded whole
|
|
92
|
+
function archiveBodyPanel(label, part, id, key = id) {
|
|
93
|
+
if (part.omitted) {
|
|
94
|
+
const panel = el("section", "call-body");
|
|
95
|
+
const head = el("div", "call-body-head");
|
|
96
|
+
head.append(el("span", "call-body-label", t(label)), el("span", "call-body-count", fmtBytes(part.size)));
|
|
97
|
+
panel.append(head, el("p", "call-archive-big", t("Too long to show here. Download the archive to read it whole.")));
|
|
98
|
+
return panel;
|
|
99
|
+
}
|
|
100
|
+
// cut where the archive stops: its limit, or 256 KB in one from before #447
|
|
101
|
+
const cut = part.truncated && (part.size ? t("first {n} of {size}", { n: fmtBytes(new Blob([part.body]).size), size: fmtBytes(part.size) }) : true);
|
|
102
|
+
return callBodyPanel(label, part.body, cut, id, key);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A call the archive kept, by "<date>/<id>": read back when asked, and its
|
|
106
|
+
// file downloaded, drawn again by redraw as it is read — under a request's
|
|
107
|
+
// row on the Usage page, and under a Routing trace's (plugins/routing draws
|
|
108
|
+
// it once this file has loaded; the Gateway page's recent calls show their
|
|
109
|
+
// own bodies, and no longer the archive's copy as well).
|
|
110
|
+
function archivePanel(name, id, redraw) {
|
|
111
|
+
const box = el("section", "call-archive");
|
|
112
|
+
const [date, aid] = name.split("/");
|
|
113
|
+
const got = archivedCalls.get(name);
|
|
114
|
+
const head = el("div", "call-body-head");
|
|
115
|
+
head.append(el("span", "call-body-label", t("Request archive")), el("code", "call-archive-id", name), el("span", "grow"));
|
|
116
|
+
box.append(head);
|
|
117
|
+
const dl = el("button", "text call-archive-dl", t("Download"));
|
|
118
|
+
dl.onclick = (ev) => { ev.stopPropagation(); downloadArchive(name, dl); };
|
|
119
|
+
if (!got || got.busy || got.error) {
|
|
120
|
+
const b = el("button", "text", t(got?.busy ? "Fetching…" : "Fetch from archive"));
|
|
121
|
+
b.disabled = !!got?.busy;
|
|
122
|
+
b.onclick = (ev) => {
|
|
123
|
+
ev.stopPropagation();
|
|
124
|
+
archivedCalls.set(name, { busy: true });
|
|
125
|
+
redraw();
|
|
126
|
+
api(`archive?date=${encodeURIComponent(date)}&id=${encodeURIComponent(aid)}`)
|
|
127
|
+
.then((a) => archivedCalls.set(name, a))
|
|
128
|
+
.catch((e) => archivedCalls.set(name, { error: t(e.message) }))
|
|
129
|
+
.finally(redraw);
|
|
130
|
+
};
|
|
131
|
+
head.append(b);
|
|
132
|
+
if (got?.error) box.append(el("div", "call-archive-err", got.error));
|
|
133
|
+
else head.append(dl); // one not in the bucket has nothing to download
|
|
134
|
+
return box;
|
|
135
|
+
}
|
|
136
|
+
head.append(el("span", "call-body-truncated call-archive-note", t("Secrets taken out")), dl);
|
|
137
|
+
if (got.large) {
|
|
138
|
+
box.append(el("p", "call-archive-big", t("This archive is {size}, too large to show here. Download it to read it.", { size: got.bytes > 0 ? fmtBytes(got.bytes) : t("very large") })));
|
|
139
|
+
return box;
|
|
140
|
+
}
|
|
141
|
+
const grid = el("div", "call-details");
|
|
142
|
+
grid.append(
|
|
143
|
+
headersPanel(t("Request Headers"), `${got.request.method || ""} ${got.request.path || ""}`.trim(), got.request.headers),
|
|
144
|
+
headersPanel(t("Response Headers"), got.response.status ? `HTTP ${got.response.status}` : "", got.response.headers),
|
|
145
|
+
archiveBodyPanel("Request Body", got.request, undefined, id + "|archive|req"),
|
|
146
|
+
archiveBodyPanel("Response Body", got.response, id + "|archive"),
|
|
147
|
+
);
|
|
148
|
+
box.append(grid);
|
|
149
|
+
// what magpie sent upstream for it, and the heads that came back
|
|
150
|
+
if (got.upstream?.length) box.append(wireHeadersGrid({ upstream: got.upstream, more: got.upstreamMore }, false));
|
|
151
|
+
return box;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const PERIODS = [["today", "Today"], ["7d", "7 days"], ["30d", "30 days"], ["all", "All"]];
|
|
155
|
+
|
|
156
|
+
// The reader's time zone, which magpie reads the period's days, the chart's
|
|
157
|
+
// hours and a picked day in: magpie web may be read from another zone than
|
|
158
|
+
// the computer's, whose days put a call at 9:00 in UTC+8 under 1:00 (Outvi V
|
|
159
|
+
// on Discord). None, and magpie reads its own.
|
|
160
|
+
function usageZone() {
|
|
161
|
+
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch { return ""; }
|
|
162
|
+
}
|
|
163
|
+
const usageZoneQ = () => (usageZone() ? "&tz=" + encodeURIComponent(usageZone()) : "");
|
|
164
|
+
|
|
165
|
+
// asked: the reader opened the page, so a Claude account's usage is read
|
|
166
|
+
// at once, by running Claude Code's own /usage, rather than when its last
|
|
167
|
+
// reading is due (every 5 to 15 minutes, at random, once Claude Code was used).
|
|
168
|
+
// The Overview's reads share one count: only the newest draws, and only for the
|
|
169
|
+
// period and tab the page is on, whether a pick or the timer asked for it.
|
|
170
|
+
let usageRead = 0;
|
|
171
|
+
async function loadUsage(asked) {
|
|
172
|
+
renderUsageTab();
|
|
173
|
+
renderUsageEvery(); // the refresh's tooltip says what it reads on this tab
|
|
174
|
+
// only the Overview shows the allowances: the page opened on another
|
|
175
|
+
// tab asks no vendor, nor reads their history, beside its own read and
|
|
176
|
+
// the page's way in (#4064)
|
|
177
|
+
if (asked && usageTab === "usage") quotasOpened();
|
|
178
|
+
if (usageTab === "sessions") return loadSessions();
|
|
179
|
+
if (usageTab === "requests") return loadLedger(asked && ledger && ledgerWant === ledParams({ offset: ledOffset, limit: LED_PAGE }) ? "open" : false);
|
|
180
|
+
if (usageTab === "context") return window.loadContext?.();
|
|
181
|
+
renderUsageLoading();
|
|
182
|
+
if (!asked) loadQuotas();
|
|
183
|
+
const p = period, read = ++usageRead;
|
|
184
|
+
const next = await api("usage?period=" + encodeURIComponent(p) + usageZoneQ());
|
|
185
|
+
if (read !== usageRead || view !== "usage" || usageTab !== "usage" || period !== p) return;
|
|
186
|
+
usage = next;
|
|
187
|
+
renderUsage();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// the period picker, in the page's head, for the Overview and Requests
|
|
191
|
+
function renderPeriod(loading) {
|
|
192
|
+
const seg = $("#period");
|
|
193
|
+
seg.replaceChildren();
|
|
194
|
+
for (const [id, name] of PERIODS) {
|
|
195
|
+
const b = el("button", "opt" + (id === period ? " on" : ""), t(name));
|
|
196
|
+
b.disabled = !!loading;
|
|
197
|
+
b.onclick = () => { for (const x of seg.querySelectorAll(".opt")) x.classList.toggle("on", x === b); slide(seg, "period"); pickPeriod(id); };
|
|
198
|
+
seg.append(b);
|
|
199
|
+
}
|
|
200
|
+
// days of the reader's own (#1492): the last opt names them once picked,
|
|
201
|
+
// and opens the range menu to pick them, or others
|
|
202
|
+
const range = periodDays(period);
|
|
203
|
+
const b = el("button", "opt range-opt" + (range ? " on" : ""));
|
|
204
|
+
b.type = "button";
|
|
205
|
+
b.disabled = !!loading;
|
|
206
|
+
b.setAttribute("aria-haspopup", "dialog");
|
|
207
|
+
b.setAttribute("aria-expanded", "false");
|
|
208
|
+
b.setAttribute("aria-label", range ? t("Days picked: {days}", { days: rangeName(range) }) : t("Pick days"));
|
|
209
|
+
b.append(svg("M3 4.5h10v8.5H3zM3 7h10M5.5 3v3M10.5 3v3", 12, 1.4), el("span", "", range ? rangeName(range) : t("Custom")));
|
|
210
|
+
if (range) b.title = t("Days picked: {days}", { days: rangeName(range) });
|
|
211
|
+
b.onclick = () => {
|
|
212
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
213
|
+
// on a phone the strip is hidden behind the head's menu, which opens it
|
|
214
|
+
openRangeMenu(b.offsetParent ? b : $("#usageRangeMenu"), range, (from, to) => {
|
|
215
|
+
for (const x of seg.querySelectorAll(".opt")) x.classList.toggle("on", x === b);
|
|
216
|
+
b.lastChild.textContent = rangeName({ from, to });
|
|
217
|
+
b.title = t("Days picked: {days}", { days: rangeName({ from, to }) });
|
|
218
|
+
b.setAttribute("aria-label", b.title);
|
|
219
|
+
slide(seg, "period");
|
|
220
|
+
pickPeriod(from + ".." + to);
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
seg.append(b);
|
|
224
|
+
slide(seg, "period");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// On a phone the head's period, Sessions' range and Context's days are
|
|
228
|
+
// one menu, "7 days ⌄", over whichever of them the tab shows, so the tabs
|
|
229
|
+
// are the head's one strip there (app.css hides the strips). It follows
|
|
230
|
+
// that strip as each tab redraws its own, and a pick clicks the strip's
|
|
231
|
+
// option, so every choice keeps the one handler it has.
|
|
232
|
+
{
|
|
233
|
+
const b = $("#usageRangeMenu");
|
|
234
|
+
const strips = ["#period", "#sessRange", "#ctxRange"].map((q) => $(q));
|
|
235
|
+
const shown = () => {
|
|
236
|
+
const box = strips.find((x) => !x.hidden);
|
|
237
|
+
const seg = box && (box.matches(".segs") ? box : box.querySelector(".segs"));
|
|
238
|
+
return seg ? [...seg.querySelectorAll(":scope > .opt")] : [];
|
|
239
|
+
};
|
|
240
|
+
const draw = () => {
|
|
241
|
+
const opts = shown();
|
|
242
|
+
b.hidden = !opts.length;
|
|
243
|
+
if (!opts.length) return;
|
|
244
|
+
const on = opts.find((o) => o.classList.contains("on")) || opts[0];
|
|
245
|
+
b.disabled = opts.every((o) => o.disabled);
|
|
246
|
+
b.replaceChildren(el("span", "", on.textContent.trim()), svg(CHEV, 11, 1.8));
|
|
247
|
+
};
|
|
248
|
+
b.onclick = (e) => {
|
|
249
|
+
e.stopPropagation();
|
|
250
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
251
|
+
const opts = shown();
|
|
252
|
+
const on = opts.findIndex((o) => o.classList.contains("on"));
|
|
253
|
+
openProtoMenu(b, opts.map((o, i) => ({ v: String(i), name: o.textContent.trim(), literalName: true, note: "" })), String(on), (v) => opts[+v]?.click(), "Time range", "sess-menu");
|
|
254
|
+
};
|
|
255
|
+
const watch = new MutationObserver(draw);
|
|
256
|
+
for (const x of strips) watch.observe(x, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["class", "hidden", "disabled"] });
|
|
257
|
+
draw();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pickPeriod(id) {
|
|
261
|
+
period = id; ledDay = ""; ledOffset = 0;
|
|
262
|
+
loadUsage().catch((e) => status(e.message, "err"));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const dayKeyOf = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
266
|
+
// rangeName says picked days short: "Oct 1 – Oct 7", the year only when it
|
|
267
|
+
// isn't this one, one day alone as itself
|
|
268
|
+
function rangeName({ from, to }) {
|
|
269
|
+
const year = new Date().getFullYear();
|
|
270
|
+
const name = (k) => dayOfKey(k).toLocaleDateString(intlLang(), { month: "short", day: "numeric", ...(dayOfKey(k).getFullYear() !== year ? { year: "numeric" } : {}) });
|
|
271
|
+
return from === to ? name(from) : name(from) + " – " + name(to);
|
|
272
|
+
}
|
|
273
|
+
// noCallsIn is what an empty page says of the period it is on
|
|
274
|
+
function noCallsIn(p) {
|
|
275
|
+
if (periodDays(p)) return "No calls on the days picked.";
|
|
276
|
+
return { today: "No calls today.", "7d": "No calls in the last 7 days.", "30d": "No calls in the last 30 days.", all: "No calls yet." }[p] || "No calls yet.";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// openRangeMenu is the app's own menu for picking days (#1492): a few
|
|
280
|
+
// spans a click away, and a month to pick the first day on, then the last.
|
|
281
|
+
// Days after today can't be picked. choose gets the two days, in order.
|
|
282
|
+
function openRangeMenu(anchor, current, choose) {
|
|
283
|
+
closeProtoMenu();
|
|
284
|
+
const today = dayKeyOf(new Date());
|
|
285
|
+
let start = null; // the first day clicked, waiting for the last
|
|
286
|
+
let shown = dayOfKey(current?.to || today);
|
|
287
|
+
shown = new Date(shown.getFullYear(), shown.getMonth(), 1, 12);
|
|
288
|
+
const box = el("div", "pop proto-menu range-menu");
|
|
289
|
+
box.setAttribute("role", "dialog");
|
|
290
|
+
box.setAttribute("aria-label", t("Pick days"));
|
|
291
|
+
const head = el("div", "pm-head");
|
|
292
|
+
const quick = el("div", "rm-quick");
|
|
293
|
+
const span = (from, to) => ({ from: dayKeyOf(from), to: dayKeyOf(to) });
|
|
294
|
+
const now = new Date();
|
|
295
|
+
now.setHours(12, 0, 0, 0);
|
|
296
|
+
const back = (n) => { const d = new Date(now); d.setDate(d.getDate() - n); return d; };
|
|
297
|
+
const quicks = [
|
|
298
|
+
["Yesterday", span(back(1), back(1))],
|
|
299
|
+
["This month", span(new Date(now.getFullYear(), now.getMonth(), 1, 12), now)],
|
|
300
|
+
["Last month", span(new Date(now.getFullYear(), now.getMonth() - 1, 1, 12), new Date(now.getFullYear(), now.getMonth(), 0, 12))],
|
|
301
|
+
["90 days", span(back(89), now)],
|
|
302
|
+
];
|
|
303
|
+
for (const [name, r] of quicks) {
|
|
304
|
+
const b = el("button", "rm-span" + (current && current.from === r.from && current.to === r.to ? " on" : ""), t(name));
|
|
305
|
+
b.type = "button";
|
|
306
|
+
b.title = rangeName(r);
|
|
307
|
+
b.onclick = (e) => { e.stopPropagation(); closeProtoMenu(); choose(r.from, r.to); };
|
|
308
|
+
quick.append(b);
|
|
309
|
+
}
|
|
310
|
+
const nav = el("div", "rm-nav");
|
|
311
|
+
const prev = el("button", "rm-step"), next = el("button", "rm-step"), month = el("span", "rm-month");
|
|
312
|
+
prev.type = next.type = "button";
|
|
313
|
+
prev.setAttribute("aria-label", t("Previous month"));
|
|
314
|
+
next.setAttribute("aria-label", t("Next month"));
|
|
315
|
+
prev.append(svg("M10 4 6 8l4 4", 12, 1.6));
|
|
316
|
+
next.append(svg("m6 4 4 4-4 4", 12, 1.6));
|
|
317
|
+
nav.append(prev, month, next);
|
|
318
|
+
const grid = el("div", "rm-grid");
|
|
319
|
+
grid.setAttribute("role", "grid");
|
|
320
|
+
box.append(head, quick, nav, grid);
|
|
321
|
+
const draw = (focusDay) => {
|
|
322
|
+
head.textContent = start ? t("Now the last day") : t("Pick the first day, then the last");
|
|
323
|
+
month.textContent = shown.toLocaleDateString(intlLang(), { year: "numeric", month: "long" });
|
|
324
|
+
next.disabled = dayKeyOf(new Date(shown.getFullYear(), shown.getMonth() + 1, 1, 12)) > today;
|
|
325
|
+
const cells = [];
|
|
326
|
+
// the weekdays, Monday first, as the app's weeks are
|
|
327
|
+
for (let i = 0; i < 7; i++) cells.push(el("span", "rm-wd", new Date(2024, 0, 1 + i, 12).toLocaleDateString(intlLang(), { weekday: "narrow" })));
|
|
328
|
+
const first = new Date(shown);
|
|
329
|
+
const lead = (first.getDay() + 6) % 7;
|
|
330
|
+
for (let i = 0; i < lead; i++) cells.push(el("span", "rm-pad"));
|
|
331
|
+
const lo = start || current?.from, hi = start ? null : current?.to;
|
|
332
|
+
for (let d = new Date(first); d.getMonth() === shown.getMonth(); d.setDate(d.getDate() + 1)) {
|
|
333
|
+
const k = dayKeyOf(d);
|
|
334
|
+
const b = el("button", "rm-day", String(d.getDate()));
|
|
335
|
+
b.type = "button";
|
|
336
|
+
b.dataset.day = k;
|
|
337
|
+
b.setAttribute("aria-label", d.toLocaleDateString(intlLang(), { year: "numeric", month: "long", day: "numeric" }));
|
|
338
|
+
if (k === today) b.classList.add("today");
|
|
339
|
+
if (lo && (k === lo || k === hi)) b.classList.add("end");
|
|
340
|
+
if (lo && hi && k > lo && k < hi) b.classList.add("in");
|
|
341
|
+
b.disabled = k > today;
|
|
342
|
+
b.onclick = (e) => {
|
|
343
|
+
e.stopPropagation();
|
|
344
|
+
if (!start) { start = k; draw(k); return; }
|
|
345
|
+
const [from, to] = start <= k ? [start, k] : [k, start];
|
|
346
|
+
closeProtoMenu();
|
|
347
|
+
choose(from, to);
|
|
348
|
+
};
|
|
349
|
+
cells.push(b);
|
|
350
|
+
}
|
|
351
|
+
grid.replaceChildren(...cells);
|
|
352
|
+
(focusDay && grid.querySelector(`[data-day="${focusDay}"]`))?.focus({ preventScroll: true });
|
|
353
|
+
};
|
|
354
|
+
const turn = (by) => (e) => { e.stopPropagation(); shown = new Date(shown.getFullYear(), shown.getMonth() + by, 1, 12); draw(); };
|
|
355
|
+
prev.onclick = turn(-1);
|
|
356
|
+
next.onclick = turn(1);
|
|
357
|
+
draw();
|
|
358
|
+
document.body.append(box);
|
|
359
|
+
placeMenu(box, anchor);
|
|
360
|
+
anchor.classList.add("open");
|
|
361
|
+
anchor.setAttribute("aria-expanded", "true");
|
|
362
|
+
const outside = (e) => { if (!box.contains(e.target) && !anchor.contains(e.target)) closeProtoMenu(); };
|
|
363
|
+
const scroll = (e) => { if (scrollCloses(box, e)) closeProtoMenu(); };
|
|
364
|
+
// Escape closes; arrows walk the days, a week up or down
|
|
365
|
+
const keys = (e) => {
|
|
366
|
+
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeProtoMenu(); anchor.focus({ preventScroll: true }); return; }
|
|
367
|
+
const at = document.activeElement?.dataset?.day;
|
|
368
|
+
const by = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
|
|
369
|
+
if (!at || !by || !box.contains(document.activeElement)) return;
|
|
370
|
+
e.preventDefault(); e.stopPropagation();
|
|
371
|
+
const d = dayOfKey(at);
|
|
372
|
+
d.setDate(d.getDate() + by);
|
|
373
|
+
const k = dayKeyOf(d);
|
|
374
|
+
if (k > today) return;
|
|
375
|
+
if (d.getMonth() !== shown.getMonth() || d.getFullYear() !== shown.getFullYear()) shown = new Date(d.getFullYear(), d.getMonth(), 1, 12);
|
|
376
|
+
draw(k);
|
|
377
|
+
};
|
|
378
|
+
document.addEventListener("mousedown", outside, true);
|
|
379
|
+
document.addEventListener("keydown", keys, true);
|
|
380
|
+
document.addEventListener("scroll", scroll, true);
|
|
381
|
+
addEventListener("resize", closeProtoMenu);
|
|
382
|
+
protoMenu = { box, anchor, outside, keys, scroll, done: null };
|
|
383
|
+
(grid.querySelector(".rm-day.end") || grid.querySelector(".rm-day.today") || quick.firstChild).focus({ preventScroll: true });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderUsageLoading() {
|
|
387
|
+
const view = $("#view-usage");
|
|
388
|
+
view.classList.add("loading");
|
|
389
|
+
view.setAttribute("aria-busy", "true");
|
|
390
|
+
renderPeriod(true);
|
|
391
|
+
$("#usageCost").replaceChildren(el("span", "skeleton sk-cost"));
|
|
392
|
+
// cards drawn already stay as they are: the read of the allowances that
|
|
393
|
+
// goes with every load (loadQuotas) draws them again once it answers.
|
|
394
|
+
// Drawn here as well, every card and curve was built twice, the first
|
|
395
|
+
// time inside the click that opened the page, so its motion stuttered
|
|
396
|
+
// (_hii11 on Discord)
|
|
397
|
+
if (!quotas || !$("#subscriptionUsage").hasChildNodes()) renderQuotas();
|
|
398
|
+
const stats = $("#stats");
|
|
399
|
+
stats.classList.remove("empty");
|
|
400
|
+
stats.replaceChildren();
|
|
401
|
+
for (let i = 0; i < 4; i++) {
|
|
402
|
+
const tile = el("div", "kpi loading-kpi");
|
|
403
|
+
tile.append(el("span", "skeleton sk-number"), el("span", "skeleton sk-label"));
|
|
404
|
+
stats.append(tile);
|
|
405
|
+
}
|
|
406
|
+
$("#chart").hidden = true;
|
|
407
|
+
for (const id of ["usageAgents", "usageAgentsHid", "usageModels", "usageKeys"]) $("#" + id).hidden = true;
|
|
408
|
+
// the allowances' head is renderQuotas' alone: the quotas it heads are
|
|
409
|
+
// still there while the period loads (#809, Hu9956: 用量界面红框这行会延迟几秒才会出现)
|
|
410
|
+
for (const h of $$("#view-usage .row-head:not(#quotaHead, .plugin-panel > .row-head)")) h.hidden = true;
|
|
411
|
+
$("#usageNote").textContent = "";
|
|
412
|
+
$("#usageClear").hidden = true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// costHead is the Usage head's sum: the cost at effective prices, or, when
|
|
416
|
+
// some of what it sums has no known price, how many beside it (#1856) — a
|
|
417
|
+
// total never reads as if those cost nothing — and with none priced, the
|
|
418
|
+
// count alone. note is that count in words ("" when everything was
|
|
419
|
+
// priced), title the tooltip.
|
|
420
|
+
function costHead(c, note, title) {
|
|
421
|
+
const cost = $("#usageCost");
|
|
422
|
+
cost.replaceChildren();
|
|
423
|
+
cost.title = "";
|
|
424
|
+
if (!c && !note) return;
|
|
425
|
+
if (c) cost.append(el("b", "", "≈" + c));
|
|
426
|
+
cost.append(el("span", note ? "unpriced" : "", note || t("effective prices")));
|
|
427
|
+
cost.title = title;
|
|
428
|
+
}
|
|
429
|
+
// a request magpie sent a provider by itself to test it (SourceProbe)
|
|
430
|
+
const probeWhy = () => t("magpie sent this itself to test the provider (Test, Detect APIs or a model list), not for an agent. It is not counted in any total.");
|
|
431
|
+
const unpricedCalls = (n) => t(n === 1 ? "{n} call unpriced" : "{n} calls unpriced", { n: ledNum(n) });
|
|
432
|
+
const unpricedCallsWhy = (n) => n ? t(n === 1 ? "{n} call had no known price and is not counted" : "{n} calls had no known price and are not counted", { n }) : t("Estimated at effective model prices. A model's price at one provider is set in that provider's Names, prices & levels and counts over its list price");
|
|
433
|
+
// renderCosts redraws whatever on the Usage page shows a cost, once the
|
|
434
|
+
// currency changes — the numbers alone, not the page around them, so a
|
|
435
|
+
// click on the setting never moves anything it isn't showing (#212)
|
|
436
|
+
function renderCosts() {
|
|
437
|
+
if (usage) renderUsage();
|
|
438
|
+
if (ledger && usageTab === "requests") renderLedger();
|
|
439
|
+
if (sessions) renderSessions();
|
|
440
|
+
document.dispatchEvent(new Event("magpie-costs-changed"));
|
|
441
|
+
}
|
|
442
|
+
// sharedBalances (keyCards): the keys magpie found to read one account's
|
|
443
|
+
// balance (sharedBalance, the first one's name) as one card, the first's,
|
|
444
|
+
// with the keys it stands for (sharedKeys): mimi on Discord, a site's three
|
|
445
|
+
// keys each showing the account's $8.39 and its curve. Keys that read
|
|
446
|
+
// their own, or another account's, stay each its own.
|
|
447
|
+
function sharedBalances(list) {
|
|
448
|
+
if (!list.some((q) => q.sharedBalance)) return list;
|
|
449
|
+
const same = (a, b) => a.provider === b.provider && (a.from || "") === (b.from || "") && a.sharedBalance === b.sharedBalance;
|
|
450
|
+
const out = [];
|
|
451
|
+
for (const q of list) {
|
|
452
|
+
if (!q.sharedBalance) { out.push(q); continue; }
|
|
453
|
+
const of = list.filter((x) => x.sharedBalance && same(x, q));
|
|
454
|
+
if (of[0] !== q) continue;
|
|
455
|
+
out.push(of.length > 1 ? { ...q, sharedKeys: of.map((x) => x.user) } : q);
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
// beside such a card's key, how many more keys its balance is for, and on
|
|
460
|
+
// hover which
|
|
461
|
+
function sharedKeysNote(q) {
|
|
462
|
+
const n = q.sharedKeys.length - 1;
|
|
463
|
+
const s = el("span", "quota-of quota-shared", t(n === 1 ? "+1 key" : "+{n} keys", { n }));
|
|
464
|
+
s.title = t("One balance for the account these keys share: {keys}", { keys: q.sharedKeys.join(", ") });
|
|
465
|
+
return s;
|
|
466
|
+
}
|
|
467
|
+
// the keys' sum's tooltip: an account several keys share counted once
|
|
468
|
+
function keysSumTip(subs) {
|
|
469
|
+
return subs.some((q) => q.sharedKeys) ? t("The balances added up, each account's once") : t("The {n} keys' balances added up", { n: subs.length });
|
|
470
|
+
}
|
|
471
|
+
// quotaWho is how an account's card names it (#1515): by the name the user
|
|
472
|
+
// gave it, the account beside it, else the account; and the key plan it
|
|
473
|
+
// stands in for (seat) when that isn't already its name. A team's GLM
|
|
474
|
+
// seats read as the user tells them apart, not as 31****36@qq.com.
|
|
475
|
+
function quotaWho(q) {
|
|
476
|
+
const out = [];
|
|
477
|
+
if (q.alias) {
|
|
478
|
+
const a = el("span", "user quota-alias" + (q.named ? " own-name" : ""), q.alias);
|
|
479
|
+
a.title = q.user ? q.alias + " · " + q.user : q.alias;
|
|
480
|
+
out.push(a);
|
|
481
|
+
if (q.user) {
|
|
482
|
+
const u = el("span", "quota-of", q.user);
|
|
483
|
+
u.title = q.user;
|
|
484
|
+
out.push(u);
|
|
485
|
+
}
|
|
486
|
+
} else if (q.user) {
|
|
487
|
+
const u = el("span", "user", q.user);
|
|
488
|
+
u.title = q.user;
|
|
489
|
+
out.push(u);
|
|
490
|
+
}
|
|
491
|
+
if (q.seat && q.seat !== q.alias) {
|
|
492
|
+
const s = el("span", "quota-seat", "≡ " + q.seat);
|
|
493
|
+
s.title = t("The same seat as the key plan {name}, shown once, on this account's card", { name: q.seat });
|
|
494
|
+
out.push(s);
|
|
495
|
+
} else if (q.seat) out[0].title += "\n" + t("The same seat as the key plan {name}, shown once, on this account's card", { name: q.seat });
|
|
496
|
+
if (q.sharedKeys) out.push(sharedKeysNote(q));
|
|
497
|
+
if (q.user || q.alias) out.push(hideAccounts.eye(q.user || q.alias, q.provider));
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// quotaWarm is Warm up on the card of a Codex or Claude account whose
|
|
502
|
+
// 5-hour window isn't running (01huadalang on Discord, who looked here, not
|
|
503
|
+
// in Settings): the request Settings › Usage's Warm up now sends (POST
|
|
504
|
+
// /api/warm-now {agent, user}), for this account. Idle is as the
|
|
505
|
+
// warm-up tells it (idle in codex_warmup.go): nothing used and its reset a
|
|
506
|
+
// whole window away, or gone by; or not read at all, which a Claude account
|
|
507
|
+
// never used is, while a Codex account with no 5-hour window has none to
|
|
508
|
+
// start. One just warmed reads the same for warmSlack, so it waits that out.
|
|
509
|
+
const quotaWarmed = new Map();
|
|
510
|
+
function quotaWarm(q) {
|
|
511
|
+
const agent = q.provider, user = q.user, key = agent + "\n" + user?.toLowerCase();
|
|
512
|
+
if (!user || q.error || !(state.settings?.[agent + "Users"] || []).some((u) => u.toLowerCase() === user.toLowerCase())) return null;
|
|
513
|
+
const w = q.windows?.find((x) => x.name === "5 hours"), now = Date.now(), at = w?.resetsAt ? Date.parse(w.resetsAt) : 0;
|
|
514
|
+
if (w ? !(at && at <= now) && (w.used > 0 || at && at - now < 5 * 3600e3 - 600e3) : agent !== "claude") return null;
|
|
515
|
+
const busy = quotaWarmed.get(key);
|
|
516
|
+
if (busy && busy !== true && now - busy < 600e3) return null;
|
|
517
|
+
const b = el("button", "text acct-warm" + (busy === true ? " busy" : ""), t("Warm up"));
|
|
518
|
+
b.type = "button";
|
|
519
|
+
b.disabled = busy === true;
|
|
520
|
+
b.title = t("The 5-hour window hasn't started: one tiny request starts it now");
|
|
521
|
+
b.onclick = async (e) => {
|
|
522
|
+
e.stopPropagation();
|
|
523
|
+
quotaWarmed.set(key, true);
|
|
524
|
+
renderQuotas();
|
|
525
|
+
try {
|
|
526
|
+
await api("warm-now", { agent, user });
|
|
527
|
+
quotaWarmed.set(key, Date.now());
|
|
528
|
+
status(t("Warmed up {who}", { who: user }), "ok");
|
|
529
|
+
} catch (err) {
|
|
530
|
+
quotaWarmed.delete(key);
|
|
531
|
+
status(t("Couldn't warm up {who}: {why}", { who: user, why: err.message }), "err");
|
|
532
|
+
}
|
|
533
|
+
renderQuotas();
|
|
534
|
+
loadQuotas(); // the window it started
|
|
535
|
+
};
|
|
536
|
+
return b;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function ownCalls(n) {
|
|
540
|
+
return t("{n} by magpie itself", { n: fmtN(n) });
|
|
541
|
+
}
|
|
542
|
+
// quotaReadings: a line's last eight readings, newest first, each what was
|
|
543
|
+
// left and when it was read (#802), a line each, ↻ where the window
|
|
544
|
+
// started again; for its legend key's title
|
|
545
|
+
function quotaReadings(l, now) {
|
|
546
|
+
const pts = l.points.slice(-8);
|
|
547
|
+
return pts.map((p, i) => (i > 0 && quotaCycleBreak(pts[i - 1], p) ? "↻ " : "") + Math.round(p.left) + "% · " + quotaTimeText(p.at, now)).reverse();
|
|
548
|
+
}
|
|
549
|
+
// Keep the last date and as many earlier dates as fit, without clipping their
|
|
550
|
+
// text. Visibility keeps each label aligned to its bar; resizing restores them.
|
|
551
|
+
function fitChartLabels() {
|
|
552
|
+
const narrow = web && matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 1024px)").matches;
|
|
553
|
+
for (const labels of document.querySelectorAll(".chart .labels")) {
|
|
554
|
+
const spans = [...labels.children];
|
|
555
|
+
for (const span of spans) span.style.visibility = "";
|
|
556
|
+
if (!narrow || !labels.offsetWidth) continue;
|
|
557
|
+
const box = labels.getBoundingClientRect();
|
|
558
|
+
let right = box.right + 8;
|
|
559
|
+
for (const span of spans.reverse()) {
|
|
560
|
+
if (!span.textContent) continue;
|
|
561
|
+
const range = document.createRange();
|
|
562
|
+
range.selectNodeContents(span);
|
|
563
|
+
const r = range.getBoundingClientRect();
|
|
564
|
+
if (r.right + 8 > right + 1 || r.left < box.left - 1) span.style.visibility = "hidden";
|
|
565
|
+
else right = r.left;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
new ResizeObserver(fitChartLabels).observe($("#view-usage"));
|
|
570
|
+
|
|
571
|
+
// The Overview's lists — Agents, Models, Accounts, Gateway keys — each fold
|
|
572
|
+
// away under their heading (#2378: 日常看账号维度较多,都要拉到最底下), and
|
|
573
|
+
// stay folded across a reload (magpie.usageFolded, the folded ones' names).
|
|
574
|
+
let usageFolded = new Set();
|
|
575
|
+
try { usageFolded = new Set(JSON.parse(localStorage.getItem("magpie.usageFolded") || "[]")); } catch {}
|
|
576
|
+
const USAGE_FOLDS = { agents: ["usageAgents", "usageAgentsHid"], models: ["usageModels"], accounts: ["usageAccounts"], keys: ["usageKeys"] };
|
|
577
|
+
const USAGE_FOLD_TITLES = {
|
|
578
|
+
agents: ["Show the agents", "Fold the agents away"],
|
|
579
|
+
models: ["Show the models", "Fold the models away"],
|
|
580
|
+
accounts: ["Show the accounts", "Fold the accounts away"],
|
|
581
|
+
keys: ["Show the gateway keys", "Fold the gateway keys away"],
|
|
582
|
+
};
|
|
583
|
+
function mountUsageFolds() {
|
|
584
|
+
for (const b of $$("#usagePane .row-head .fold[data-fold]")) {
|
|
585
|
+
b.prepend(svg(CHEV_R, 11, 1.6));
|
|
586
|
+
b.onclick = () => {
|
|
587
|
+
const id = b.dataset.fold;
|
|
588
|
+
if (usageFolded.has(id)) usageFolded.delete(id); else usageFolded.add(id);
|
|
589
|
+
try { localStorage.setItem("magpie.usageFolded", JSON.stringify([...usageFolded])); } catch {}
|
|
590
|
+
showUsageFolds();
|
|
591
|
+
backToReader($("#view-usage"));
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
showUsageFolds();
|
|
595
|
+
}
|
|
596
|
+
// a folded list is out of sight by its class, so the page's own hidden
|
|
597
|
+
// (no calls, no accounts) is left as it is
|
|
598
|
+
function showUsageFolds() {
|
|
599
|
+
for (const b of $$("#usagePane .row-head .fold[data-fold]")) {
|
|
600
|
+
const id = b.dataset.fold, folded = usageFolded.has(id);
|
|
601
|
+
b.setAttribute("aria-expanded", String(!folded));
|
|
602
|
+
b.title = t(USAGE_FOLD_TITLES[id][folded ? 0 : 1]);
|
|
603
|
+
for (const box of USAGE_FOLDS[id]) $("#" + box)?.classList.toggle("folded", folded);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// usageParts: a line of counts, each kept whole in a span of its own and
|
|
608
|
+
// " · " between them, where the line may wrap ("{a} in · {b} out" is two)
|
|
609
|
+
function usageParts(texts) {
|
|
610
|
+
const out = [];
|
|
611
|
+
for (const x of texts.flatMap((s) => s.split(" · "))) out.push(...(out.length ? [" · "] : []), el("span", "", x));
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// The Usage pages' token totals count what went in and out; the cache's
|
|
616
|
+
// tokens, read and written, can be counted in them too (#2378), as the
|
|
617
|
+
// Requests tab's total always has them — the Overview's tiles, chart and
|
|
618
|
+
// lists and, since #2591, the Sessions tab's alike. Kept as the reader
|
|
619
|
+
// left it (magpie.usageWithCache).
|
|
620
|
+
let usageWithCache = false;
|
|
621
|
+
try { usageWithCache = localStorage.getItem("magpie.usageWithCache") === "1"; } catch {}
|
|
622
|
+
const cacheOf = (x) => (x.cache_read || 0) + (x.cache_write || 0);
|
|
623
|
+
const usageTokens = (x) => tokensOf(x) + (usageWithCache ? cacheOf(x) : 0);
|
|
624
|
+
// the tokens tile's label: a pop-up of the two ways to count
|
|
625
|
+
function usageTokensPick() {
|
|
626
|
+
const b = el("button", "kpi-pick");
|
|
627
|
+
b.type = "button";
|
|
628
|
+
b.setAttribute("aria-haspopup", "menu");
|
|
629
|
+
b.append(el("span", "", t(usageWithCache ? "tokens with cache" : "tokens")), svg(CHEV, 10, 1.6));
|
|
630
|
+
b.title = t("Count the cache's tokens in the totals, or leave them out");
|
|
631
|
+
b.onclick = (e) => {
|
|
632
|
+
e.stopPropagation();
|
|
633
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
634
|
+
openProtoMenu(b, [
|
|
635
|
+
{ v: "", name: "Without cache", note: "Input and output" },
|
|
636
|
+
{ v: "1", name: "With cache", note: "Cache read and written counted too" },
|
|
637
|
+
], usageWithCache ? "1" : "", (v) => {
|
|
638
|
+
usageWithCache = v === "1";
|
|
639
|
+
try { localStorage.setItem("magpie.usageWithCache", usageWithCache ? "1" : "0"); } catch {}
|
|
640
|
+
// the tab it was picked on follows; the pick lives on the Overview's
|
|
641
|
+
// and the Sessions tab's tokens tiles (#2591)
|
|
642
|
+
(usageTab === "sessions" ? renderSessions : renderUsage)();
|
|
643
|
+
}, "Token totals", "sess-menu");
|
|
644
|
+
};
|
|
645
|
+
return b;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function renderUsage() {
|
|
649
|
+
const u = usage;
|
|
650
|
+
const view = $("#view-usage");
|
|
651
|
+
showUsageFolds();
|
|
652
|
+
view.classList.remove("loading");
|
|
653
|
+
view.removeAttribute("aria-busy");
|
|
654
|
+
renderPeriod();
|
|
655
|
+
costHead(fmtCost(u), u.unpriced ? unpricedCalls(u.unpriced) : "", unpricedCallsWhy(u.unpriced));
|
|
656
|
+
|
|
657
|
+
const stats = $("#stats");
|
|
658
|
+
stats.replaceChildren();
|
|
659
|
+
const empty = !u.calls;
|
|
660
|
+
$("#chart").hidden = empty;
|
|
661
|
+
for (const id of ["usageAgents", "usageModels"]) $("#" + id).hidden = empty;
|
|
662
|
+
if (empty) $("#usageAgentsHid").hidden = true;
|
|
663
|
+
for (const h of $$("#view-usage .row-head:not(#quotaHead, .plugin-panel > .row-head)")) h.hidden = empty;
|
|
664
|
+
$("#usageKeysHead").hidden = $("#usageKeys").hidden = empty || !u.callerKeys?.length;
|
|
665
|
+
// each subscription account's share, by the account that answered (#557)
|
|
666
|
+
$("#usageAccountsHead").hidden = $("#usageAccounts").hidden = empty || !u.accounts?.length;
|
|
667
|
+
if (empty) {
|
|
668
|
+
stats.classList.add("empty");
|
|
669
|
+
const none = noCallsIn(period);
|
|
670
|
+
stats.append(el("div", "none", t(none) + " " + t("Point an agent at a catalog model and use it; every call through the gateway is counted here.")));
|
|
671
|
+
$("#usageNote").textContent = "";
|
|
672
|
+
$("#usageClear").hidden = true;
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
stats.classList.remove("empty");
|
|
676
|
+
const tile = (n, label, sub, title) => {
|
|
677
|
+
const t = el("div", "kpi");
|
|
678
|
+
if (title) t.title = title;
|
|
679
|
+
t.append(el("b", "", n), typeof label === "string" ? el("span", "", label) : label);
|
|
680
|
+
// a line in parts keeps each whole, wrapping between them rather than
|
|
681
|
+
// cutting the last off
|
|
682
|
+
if (Array.isArray(sub)) {
|
|
683
|
+
const s = el("small", "parts");
|
|
684
|
+
s.append(...usageParts(sub));
|
|
685
|
+
t.append(s);
|
|
686
|
+
} else if (sub) t.append(el("small", "", sub));
|
|
687
|
+
stats.append(t);
|
|
688
|
+
};
|
|
689
|
+
tile(fmtN(usageTokens(u)), usageTokensPick(), [t("{a} in · {b} out", { a: fmtN(u.input), b: fmtN(u.output) }), ...(usageWithCache && cacheOf(u) ? [t("{a} cache", { a: fmtN(cacheOf(u)) })] : [])]);
|
|
690
|
+
// cache reads are billed at a fraction of input, so how much of the prompt
|
|
691
|
+
// came from cache is the number that explains the bill; input here already
|
|
692
|
+
// excludes the cached tokens, read and written (the gateway subtracts
|
|
693
|
+
// them), so what was written counts in the prompt too: left out, a prompt
|
|
694
|
+
// written to the cache again at every turn showed a rate near 100%. The
|
|
695
|
+
// written count is secondary and only fits in the tooltip.
|
|
696
|
+
// a vendor that counts no cache (cache_unknown) is left out (#3464)
|
|
697
|
+
const hitRate = cacheHitOf(u);
|
|
698
|
+
const hit = u.cache_read && hitRate !== null ? t("hit rate {p}", { p: Math.round(100 * hitRate) + "%" }) : "";
|
|
699
|
+
tile(fmtN(u.cache_read), t("cache read"), hit, u.cache_write ? t("{n} written", { n: fmtN(u.cache_write) }) + cacheTTLs(u, fmtN) : "");
|
|
700
|
+
tile(fmtN(u.reasoning), t("reasoning"), t("inside output"));
|
|
701
|
+
tile(String(u.calls), t(u.calls === 1 ? "call" : "calls"), [u.credited ? ledCredits(u).textContent : "", u.errors ? t("{n} failed", { n: u.errors }) : "", u.retried ? t("{n} answered on a retry", { n: u.retried }) : "", u.canceled ? t("{n} canceled", { n: u.canceled }) : "", u.unpriced ? t("{n} unpriced", { n: u.unpriced }) : ""].filter(Boolean).join(" · "), u.unpriced ? unpricedCallsWhy(u.unpriced) : "");
|
|
702
|
+
|
|
703
|
+
// the timeline: one bar per hour, day or week; output sits on top of input
|
|
704
|
+
const chart = $("#chart");
|
|
705
|
+
chart.replaceChildren();
|
|
706
|
+
const bars = el("div", "bars");
|
|
707
|
+
const peak = Math.max(1, ...u.series.map(usageTokens));
|
|
708
|
+
const labels = el("div", "labels");
|
|
709
|
+
const n = u.series.length;
|
|
710
|
+
const every = n <= 8 ? 1 : n <= 31 ? Math.ceil(n / 6) : Math.ceil(n / 5);
|
|
711
|
+
// the day in the reader's language (the label comes as "Sep 5" in every one)
|
|
712
|
+
const dayOf = (p) => u.bucket === "hour" || !p.time ? p.label : new Date(p.time).toLocaleDateString(intlLang(), { month: "short", day: "numeric" });
|
|
713
|
+
u.series.forEach((p, i) => {
|
|
714
|
+
const b = el("div", "bar");
|
|
715
|
+
const inp = el("i", "in"), out = el("i", "out");
|
|
716
|
+
// counted with the cache, its tokens are in the prompt's part
|
|
717
|
+
inp.style.height = (100 * (usageTokens(p) - p.output) / peak).toFixed(1) + "%";
|
|
718
|
+
out.style.height = (100 * p.output / peak).toFixed(1) + "%";
|
|
719
|
+
b.append(out, inp);
|
|
720
|
+
const when = u.bucket === "hour" ? `${p.label}:00` : u.bucket === "week" ? t("week of {label}", { label: dayOf(p) }) : dayOf(p);
|
|
721
|
+
b.title = p.calls ? t(p.calls === 1 ? "{when} · {tokens} tokens · {n} call" : "{when} · {tokens} tokens · {n} calls", { when, tokens: fmtN(usageTokens(p)), n: p.calls }) + (fmtCost(p) ? " · ≈" + fmtCost(p) : "") : t("{when} · nothing", { when });
|
|
722
|
+
bars.append(b);
|
|
723
|
+
const last = i === n - 1 && (n - 1) % every >= every / 2;
|
|
724
|
+
labels.append(el("span", "", i % every === 0 || last ? dayOf(p) : ""));
|
|
725
|
+
});
|
|
726
|
+
chart.append(el("div", "peak", fmtN(peak)), bars, labels);
|
|
727
|
+
fitChartLabels();
|
|
728
|
+
|
|
729
|
+
const total = Math.max(1, usageTokens(u));
|
|
730
|
+
const list = (id, groups, act) => {
|
|
731
|
+
const box = typeof id === "string" ? $("#" + id) : id;
|
|
732
|
+
box.replaceChildren();
|
|
733
|
+
for (const g of groups) {
|
|
734
|
+
const r = el("div", "row stat");
|
|
735
|
+
r.dataset.id = g.id || "";
|
|
736
|
+
r.append(icon(g.icon || "generic"));
|
|
737
|
+
const who = el("div", "who");
|
|
738
|
+
who.append(el("div", "name" + (g.name ? "" : " faint"), g.name || t("account not recorded")));
|
|
739
|
+
const sub = [];
|
|
740
|
+
if (g.sub) sub.push(g.sub);
|
|
741
|
+
sub.push(t(g.calls === 1 ? "{n} call" : "{n} calls", { n: g.calls }));
|
|
742
|
+
// what the calls cost in their plan's credits, as the replies said
|
|
743
|
+
// (WorkBuddy's 积分): an account's, a model's, an agent's (lcyksp
|
|
744
|
+
// on Discord), not only their tokens
|
|
745
|
+
if (g.credited) sub.push(ledCredits(g).textContent);
|
|
746
|
+
// magpie's own calls apart from the agents' (#1839): magpie's row
|
|
747
|
+
// says it is, and a model's, a key's or an account's how many of
|
|
748
|
+
// its calls were
|
|
749
|
+
if (id === "usageAgents" && g.id === "magpie") who.firstChild.append(ownBadge());
|
|
750
|
+
else if (g.own?.calls) sub.push(ownCalls(g.own.calls));
|
|
751
|
+
if (g.errors) sub.push(t("{n} failed", { n: g.errors }));
|
|
752
|
+
// of those, the replies magpie ended stuck in a loop (#1858)
|
|
753
|
+
if (g.loops) sub.push(t("{n} looped", { n: g.loops }));
|
|
754
|
+
if (g.retried) sub.push(t("{n} answered on a retry", { n: g.retried }));
|
|
755
|
+
// how long the streamed replies took to begin, and how fast they
|
|
756
|
+
// wrote after (#196)
|
|
757
|
+
if (g.timed) {
|
|
758
|
+
sub.push(t("TTFT {ms}", { ms: ledTook(Math.round(g.ttft_ms / g.timed)) }));
|
|
759
|
+
}
|
|
760
|
+
const gs = ledGroupSpeed(g);
|
|
761
|
+
if (gs) sub.push(gs.text);
|
|
762
|
+
const subLine = el("div", "sub", sub.join(" · "));
|
|
763
|
+
if (g.own?.calls) subLine.title = ownWhy();
|
|
764
|
+
who.append(subLine);
|
|
765
|
+
r.append(who);
|
|
766
|
+
const share = el("div", "share");
|
|
767
|
+
const fill = el("i");
|
|
768
|
+
fill.style.width = Math.max(1.5, 100 * usageTokens(g) / total).toFixed(1) + "%";
|
|
769
|
+
share.append(fill);
|
|
770
|
+
share.title = t("{n}% of tokens", { n: Math.round(100 * usageTokens(g) / total) });
|
|
771
|
+
r.append(share);
|
|
772
|
+
const num = el("div", "num");
|
|
773
|
+
// how much of its prompt came from the cache, counted as the cache
|
|
774
|
+
// read tile counts it (#925)
|
|
775
|
+
const hitRate = cacheHitOf(g);
|
|
776
|
+
const hit = g.cache_read && hitRate !== null ? " · " + t("hit rate {p}", { p: Math.round(100 * hitRate) + "%" }) : "";
|
|
777
|
+
num.append(el("b", "", fmtN(usageTokens(g))), el("small", "", t("{a} in · {b} out", { a: fmtN(g.input), b: fmtN(g.output) }) + (g.cache_read ? " · " + t("{n} cached", { n: fmtN(g.cache_read) }) : "") + hit));
|
|
778
|
+
r.append(num);
|
|
779
|
+
r.append(el("div", "cost", fmtCost(g) ? "≈" + fmtCost(g) : ""));
|
|
780
|
+
if (act) act(r, g);
|
|
781
|
+
box.append(r);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
// a client hidden from the Agents list (ikunbots on X: curl,
|
|
785
|
+
// Go-http-client, python-httpx beside the agents in use) is left out of
|
|
786
|
+
// it, and only of it: the totals, the chart and every share above still
|
|
787
|
+
// count its calls. One line under the list says how many, and opens them.
|
|
788
|
+
const hid = new Set(state.settings?.usageAgentsHidden || []);
|
|
789
|
+
const hidden = u.agents.filter((g) => hid.has(g.id));
|
|
790
|
+
list("usageAgents", u.agents.filter((g) => !hid.has(g.id)), (r, g) => {
|
|
791
|
+
r.classList.add("acts");
|
|
792
|
+
const b = el("button", "icon us-more");
|
|
793
|
+
b.type = "button";
|
|
794
|
+
b.setAttribute("aria-haspopup", "menu");
|
|
795
|
+
b.setAttribute("aria-label", t("Actions for {name}", { name: g.name || g.id }));
|
|
796
|
+
b.append(svg(DOTS, 14, 2.2));
|
|
797
|
+
// a client magpie doesn't know can be counted as one of the agents
|
|
798
|
+
// it does (#1958, NorethSea): a bridge that starts Codex under a name
|
|
799
|
+
// of its own; the agent counting one has it put back on its own
|
|
800
|
+
const counted = Object.entries(state.settings?.clientAgents || {}).filter(([, as]) => as === g.id).map(([c]) => c);
|
|
801
|
+
const menu = () => {
|
|
802
|
+
const again = agentMenu?.anchor === b;
|
|
803
|
+
closeAgentMenu();
|
|
804
|
+
if (again) return;
|
|
805
|
+
openRowMenu(b, [
|
|
806
|
+
...(countable(g.id) ? [{ name: "Count as an agent…", icon: COUNT_AS, run: () => pickCountAs(b, g) }] : []),
|
|
807
|
+
...counted.map((c) => ({ name: t("Count {client} on its own", { client: c }), icon: OUT, run: () => countClientAs(c, "") })),
|
|
808
|
+
{ name: "Hide from this list", icon: EYE_OFF, sep: countable(g.id) || counted.length > 0, run: () => hideUsageAgent(g.id, true) },
|
|
809
|
+
...(hidden.length ? [{ name: t("Show {n} Hidden", { n: hidden.length }), literal: true, icon: EYE, sep: true, run: (e) => openHidden(e, "usageAgentsHid", () => { usageHidOpen = true; renderUsage(); }) }] : []),
|
|
810
|
+
]);
|
|
811
|
+
};
|
|
812
|
+
if (counted.length) {
|
|
813
|
+
const sub = r.querySelector(".who .sub");
|
|
814
|
+
sub.textContent = t("with {clients}", { clients: counted.join(", ") }) + " · " + sub.textContent;
|
|
815
|
+
}
|
|
816
|
+
b.onclick = (e) => { e.stopPropagation(); menu(); };
|
|
817
|
+
r.oncontextmenu = (e) => { e.preventDefault(); menu(); };
|
|
818
|
+
r.querySelector(".share").after(b);
|
|
819
|
+
});
|
|
820
|
+
const hb = $("#usageAgentsHid");
|
|
821
|
+
hb.hidden = !hidden.length;
|
|
822
|
+
if (!hidden.length) usageHidOpen = false;
|
|
823
|
+
const line = hb.querySelector(".us-hidline"), hl = hb.querySelector(".list");
|
|
824
|
+
line.replaceChildren(el("span", "", t("{n} hidden", { n: hidden.length })), svg(CHEV, 11, 1.6));
|
|
825
|
+
line.title = t("Hidden from this list only: their calls still count in the totals and the chart. Click to see them.");
|
|
826
|
+
line.setAttribute("aria-expanded", String(usageHidOpen));
|
|
827
|
+
line.onclick = () => { usageHidOpen = !usageHidOpen; renderUsage(); };
|
|
828
|
+
hl.hidden = !usageHidOpen;
|
|
829
|
+
list(hl, usageHidOpen ? hidden : [], (r, g) => {
|
|
830
|
+
r.classList.add("hid", "acts");
|
|
831
|
+
const b = el("button", "text us-unhide", t("Unhide"));
|
|
832
|
+
b.type = "button";
|
|
833
|
+
b.onclick = (e) => { e.stopPropagation(); hideUsageAgent(g.id, false); };
|
|
834
|
+
r.append(b);
|
|
835
|
+
});
|
|
836
|
+
// a model's usage cleared (CrazyMouse_TT on X: a local model deployed
|
|
837
|
+
// again, its requests, speed and cache wanted from nothing)
|
|
838
|
+
list("usageModels", u.models, (r, g) => {
|
|
839
|
+
if (!g.model) return;
|
|
840
|
+
r.classList.add("acts");
|
|
841
|
+
const b = el("button", "icon us-more");
|
|
842
|
+
b.type = "button";
|
|
843
|
+
b.setAttribute("aria-haspopup", "menu");
|
|
844
|
+
b.setAttribute("aria-label", t("Actions for {name}", { name: g.name || g.model }));
|
|
845
|
+
b.append(svg(DOTS, 14, 2.2));
|
|
846
|
+
const menu = () => {
|
|
847
|
+
const again = agentMenu?.anchor === b;
|
|
848
|
+
closeAgentMenu();
|
|
849
|
+
if (again) return;
|
|
850
|
+
openRowMenu(b, [{ name: "Clear usage…", icon: USAGE_CLEAR, run: () => clearUsage(g) }]);
|
|
851
|
+
};
|
|
852
|
+
b.onclick = (e) => { e.stopPropagation(); menu(); };
|
|
853
|
+
r.oncontextmenu = (e) => { e.preventDefault(); menu(); };
|
|
854
|
+
r.querySelector(".share").after(b);
|
|
855
|
+
});
|
|
856
|
+
list("usageAccounts", u.accounts || []);
|
|
857
|
+
list("usageKeys", u.callerKeys || []);
|
|
858
|
+
$("#usageNote").textContent = t("Counted from the providers' own usage reports on every call through the gateway · {path}", { path: u.path });
|
|
859
|
+
const clear = $("#usageClear");
|
|
860
|
+
clear.hidden = false;
|
|
861
|
+
clear.onclick = () => clearUsage();
|
|
862
|
+
// the account cards say how many of this period's requests were
|
|
863
|
+
// magpie's own (ownLine), which they could only once the summary came
|
|
864
|
+
if (quotas && (u.accounts?.some((g) => g.own?.calls) || $("#subscriptionUsage .quota-own"))) renderQuotas();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// clearUsage clears the Usage page's figures, after the reader confirms:
|
|
868
|
+
// one model's (g, from the Models list) or everything's (no g). The calls
|
|
869
|
+
// up to now stop being counted on the usage pages, here and on the
|
|
870
|
+
// computers this one syncs with; the request log keeps them, so a key's
|
|
871
|
+
// spending limit and the allowances still count them
|
|
872
|
+
// (settings.UsageCleared).
|
|
873
|
+
const USAGE_CLEAR = "M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5M7 7v3.5M9 7v3.5";
|
|
874
|
+
async function clearUsage(g) {
|
|
875
|
+
const ok = await confirmAction(
|
|
876
|
+
g ? t("Clear the usage of {model}?", { model: g.name || g.model }) : t("Clear all usage?"),
|
|
877
|
+
(g
|
|
878
|
+
? t("Every call to {model} at {provider} so far stops being counted on the Usage page: its requests, tokens, cost, speed and cache, in every period, on this computer and those it syncs with.", { model: g.name || g.model, provider: (g.sub || g.provider || "").split(" · ")[0] })
|
|
879
|
+
: t("Every call so far stops being counted on the Usage page: the requests, tokens, cost, speed and cache of every agent and model, in every period, on this computer and those it syncs with."))
|
|
880
|
+
+ " " + t("Gateway keys' spending limits and the allowances still count these calls. This can't be undone."),
|
|
881
|
+
t("Clear"),
|
|
882
|
+
);
|
|
883
|
+
if (!ok) return;
|
|
884
|
+
try {
|
|
885
|
+
await api("usage/clear", g ? { provider: g.provider || "", model: g.model } : {});
|
|
886
|
+
status(g ? t("The usage of {model} is cleared", { model: g.name || g.model }) : t("All usage is cleared"), "ok");
|
|
887
|
+
ledger = null;
|
|
888
|
+
await loadUsage();
|
|
889
|
+
} catch (e) {
|
|
890
|
+
status(e.message, "err");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// countable is whether the Usage page's Agents list offers to count a
|
|
895
|
+
// client as an agent: one magpie doesn't know, not magpie's own (#1839)
|
|
896
|
+
// nor the calls that named no client.
|
|
897
|
+
const COUNT_AS = "M3 3v3.5A3.5 3.5 0 0 0 6.5 10H13M10 7l3 3-3 3";
|
|
898
|
+
function countable(id) {
|
|
899
|
+
return !!id && id !== "other" && !/^magpie/i.test(id) && !(state.clients || []).some((a) => a.id === id);
|
|
900
|
+
}
|
|
901
|
+
// pickCountAs opens the agents a client can be counted as, under its menu
|
|
902
|
+
// button.
|
|
903
|
+
function pickCountAs(anchor, g) {
|
|
904
|
+
const opts = (state.clients || []).filter((a) => a.id !== "magpie").map((a) => ({ v: a.id, name: a.name, literalName: true, note: "" }));
|
|
905
|
+
openProtoMenu(anchor, opts, "", (v) => countClientAs(g.id, v), "Count this client's calls as", "", "left", false, opts.length > 8 ? "Search agents…" : "");
|
|
906
|
+
}
|
|
907
|
+
// countClientAs counts a client's calls as an agent's on the Usage and
|
|
908
|
+
// Sessions pages, or on its own again (as ""), and draws the page again
|
|
909
|
+
// from the ledger read anew: the history is counted the new way too.
|
|
910
|
+
async function countClientAs(client, as) {
|
|
911
|
+
try {
|
|
912
|
+
const s = await api("usage/arrange", { count: { client, as } });
|
|
913
|
+
state.settings = { ...state.settings, clientAgents: s.clientAgents || {} };
|
|
914
|
+
status(as ? t("{client} is counted as {agent}", { client, agent: (state.clients || []).find((a) => a.id === as)?.name || as }) : t("{client} is counted on its own", { client }), "ok");
|
|
915
|
+
await loadUsage();
|
|
916
|
+
} catch (e) {
|
|
917
|
+
status(e.message, "err");
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// openHidden opens a list's hidden ones from a row's menu (yuu003348 on
|
|
922
|
+
// Discord: 从概览中隐藏 后 无法取消隐藏): the line under the list, opened, with
|
|
923
|
+
// the keyboard on it, brought into sight when the menu's click asks
|
|
924
|
+
function openHidden(e, id, open) {
|
|
925
|
+
open();
|
|
926
|
+
const line = $("#" + id + " .us-hidline");
|
|
927
|
+
if (!line || line.closest("[hidden]")) return;
|
|
928
|
+
line.focus({ preventScroll: true });
|
|
929
|
+
const box = $("#" + id), v = $("#view-usage"), r = box.getBoundingClientRect(), b = v.getBoundingClientRect();
|
|
930
|
+
if ((r.top < b.top || r.bottom > b.bottom) && scrollOnPurpose(e, 400)) box.scrollIntoView({ block: "nearest" });
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// hideUsageAgent takes a client out of the Usage page's Agents list, or
|
|
934
|
+
// puts it back: drawn at once, put back if the save fails. Its calls stay
|
|
935
|
+
// in the ledger and in every total.
|
|
936
|
+
async function hideUsageAgent(id, hide) {
|
|
937
|
+
const prev = state.settings || {};
|
|
938
|
+
const cur = (prev.usageAgentsHidden || []).filter((k) => k !== id);
|
|
939
|
+
const agentsHidden = hide ? [...cur, id] : cur;
|
|
940
|
+
state.settings = { ...prev, usageAgentsHidden: agentsHidden };
|
|
941
|
+
const again = () => {
|
|
942
|
+
if (!usage) return;
|
|
943
|
+
renderUsage();
|
|
944
|
+
// the focus stays on the list: the row put back, else the hidden line
|
|
945
|
+
const to = hide ? $("#usageAgentsHid .us-hidline") : $(`#usageAgents .row.stat[data-id="${CSS.escape(id)}"] .us-more`);
|
|
946
|
+
(to && !to.closest("[hidden]") ? to : null)?.focus({ preventScroll: true });
|
|
947
|
+
};
|
|
948
|
+
again();
|
|
949
|
+
try {
|
|
950
|
+
const s = await api("usage/arrange", { agentsHidden });
|
|
951
|
+
state.settings = { ...state.settings, usageAgentsHidden: s.usageAgentsHidden || [] };
|
|
952
|
+
} catch (e) {
|
|
953
|
+
state.settings = prev;
|
|
954
|
+
again();
|
|
955
|
+
status(e.message, "err");
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ---------- allowance cards hidden from the Overview ----------
|
|
960
|
+
//
|
|
961
|
+
// An allowance card can be hidden from the Overview (whqtian_07012 on
|
|
962
|
+
// Discord: StepFun's plan card wanted, its balance card not), from the
|
|
963
|
+
// card's own menu (quotaCardMenus). Only from this page: routing, caps and
|
|
964
|
+
// the counting never hear of it, the tray panel's Allowances tab has its
|
|
965
|
+
// own (Arrange), and a menu bar cell asking for a hidden card shows it
|
|
966
|
+
// until the page is left. Each is kept by its tray card id and its kind
|
|
967
|
+
// (cardHideKey), in magpie's settings (usageCardsHidden), so every window
|
|
968
|
+
// and magpie web agree; one line at the section's end says how many, and
|
|
969
|
+
// has them back.
|
|
970
|
+
let quotaHidOpen = false; // the hidden cards listed under their line
|
|
971
|
+
let usagePeek = ""; // a card a menu bar cell asked for, shown though hidden
|
|
972
|
+
new MutationObserver(() => { if ($("#view-usage").hidden) usagePeek = ""; }).observe($("#view-usage"), { attributes: true, attributeFilter: ["hidden"] });
|
|
973
|
+
const cardHideKind = (q) => q.balance && !q.windows?.length && !q.plan ? "balance" : "plan";
|
|
974
|
+
const cardHideKey = (q) => trayCardID(q) + "|" + cardHideKind(q);
|
|
975
|
+
// the keys a card as drawn stands for: a plan's card carrying its key's
|
|
976
|
+
// balance (keyCards) hides both
|
|
977
|
+
const cardHideKeys = (q) => [cardHideKey(q), ...(q.balanceCard ? [q.balanceCard + "|balance"] : [])];
|
|
978
|
+
const hiddenCards = () => new Set(state.settings?.usageCardsHidden || []);
|
|
979
|
+
const cardPeeked = (q) => !!usagePeek && (trayCardID(q) === usagePeek || q.provider === usagePeek);
|
|
980
|
+
// overviewCards: the cards the Overview draws, a provider's keys sharing
|
|
981
|
+
// one balance already one card (sharedBalances), the hidden left out
|
|
982
|
+
function overviewCards(list) {
|
|
983
|
+
const hid = hiddenCards();
|
|
984
|
+
list = sharedBalances(list);
|
|
985
|
+
return hid.size ? list.filter((q) => !hid.has(cardHideKey(q)) || cardPeeked(q)) : list;
|
|
986
|
+
}
|
|
987
|
+
// quotaCardMenus gives a card its menu: a right-click, the keyboard's menu
|
|
988
|
+
// key on its handle, or on a touch screen a long press (iOS Safari sends
|
|
989
|
+
// no contextmenu for one), for the account whose section it was on, or
|
|
990
|
+
// the whole card from its head
|
|
991
|
+
// when a long press last opened a card's menu; never, at first (from 0, a
|
|
992
|
+
// menu opened in the page's first second ate every click outside it)
|
|
993
|
+
let quotaPressed = -Infinity;
|
|
994
|
+
// the mouse events a phone makes up once the finger lifts don't close it,
|
|
995
|
+
// nor press what the finger was on
|
|
996
|
+
const quotaPressing = () => !!agentMenu && performance.now() - quotaPressed < 1000;
|
|
997
|
+
for (const k of ["mousedown", "click"]) addEventListener(k, (e) => { if (quotaPressing() && !e.target.closest?.(".row-menu")) { e.stopImmediatePropagation(); e.preventDefault(); } }, true);
|
|
998
|
+
function quotaCardMenus(card, subs) {
|
|
999
|
+
let press = null;
|
|
1000
|
+
card.oncontextmenu = (e) => {
|
|
1001
|
+
if (e.target.closest("input, textarea")) return;
|
|
1002
|
+
e.preventDefault();
|
|
1003
|
+
// Android sends one after the long press that opened it already
|
|
1004
|
+
if (quotaPressing()) return;
|
|
1005
|
+
quotaCardMenu(e.target, card, subs);
|
|
1006
|
+
};
|
|
1007
|
+
const stop = () => { clearTimeout(press?.timer); press = null; };
|
|
1008
|
+
card.addEventListener("pointerdown", (e) => {
|
|
1009
|
+
stop();
|
|
1010
|
+
if (e.pointerType !== "touch" || usageArranging || e.target.closest(".us-handle, input, textarea")) return;
|
|
1011
|
+
const at = e.target;
|
|
1012
|
+
press = { x: e.clientX, y: e.clientY, timer: setTimeout(() => { press = null; quotaPressed = performance.now(); quotaCardMenu(at, card, subs); }, 550) };
|
|
1013
|
+
});
|
|
1014
|
+
card.addEventListener("pointermove", (e) => { if (press && Math.hypot(e.clientX - press.x, e.clientY - press.y) > 8) stop(); });
|
|
1015
|
+
for (const k of ["pointerup", "pointercancel", "pointerleave"]) card.addEventListener(k, stop);
|
|
1016
|
+
}
|
|
1017
|
+
function quotaCardMenu(target, card, subs) {
|
|
1018
|
+
let part = [...card.children].find((c) => c.contains(target));
|
|
1019
|
+
while (part && !part.classList.contains("subscription-account") && !part.classList.contains("subscription-head")) part = part.previousElementSibling;
|
|
1020
|
+
const who = part?.classList.contains("subscription-account") && subs.find((q) => trayCardID(q) === part.dataset.card);
|
|
1021
|
+
const of = who ? [who] : subs;
|
|
1022
|
+
const anchor = card.querySelector(".us-handle") || card.querySelector(".subscription-head");
|
|
1023
|
+
const again = agentMenu?.anchor === anchor;
|
|
1024
|
+
closeAgentMenu();
|
|
1025
|
+
if (again) return;
|
|
1026
|
+
const one = of.length === 1 && of[0];
|
|
1027
|
+
// a card shown though hidden, a menu bar cell having asked for it
|
|
1028
|
+
// (usagePeek): its way back, not Hide again
|
|
1029
|
+
const hid = hiddenCards(), peeked = of.flatMap(cardHideKeys).filter((k) => hid.has(k));
|
|
1030
|
+
const others = hiddenQuotas().length;
|
|
1031
|
+
openRowMenu(anchor, [
|
|
1032
|
+
...(peeked.length ? [{ name: "Show in Overview", icon: EYE, run: () => hideQuotaCards(peeked, false) }]
|
|
1033
|
+
: [{ name: who && subs.length > 1 ? "Hide this account from Overview" : "Hide from Overview", icon: EYE_OFF, run: () => hideQuotaCards(of.flatMap(cardHideKeys), true) }]),
|
|
1034
|
+
// a plan's card showing its key's balance: the balance alone
|
|
1035
|
+
...(one?.balanceCard && !peeked.length ? [{ name: "Hide its balance from Overview", icon: EYE_OFF, run: () => hideQuotaCards([one.balanceCard + "|balance"], true) }] : []),
|
|
1036
|
+
// the ones hidden already, from the menu that hid them
|
|
1037
|
+
...(others ? [{ name: t("Show {n} Hidden", { n: others }), literal: true, icon: EYE, sep: true, run: (e) => openHidden(e, "quotaHid", () => { quotaHidOpen = true; renderQuotaHid(); }) }] : []),
|
|
1038
|
+
]);
|
|
1039
|
+
}
|
|
1040
|
+
// hiddenQuotas: the cards the Overview leaves out, those the line lists
|
|
1041
|
+
function hiddenQuotas() {
|
|
1042
|
+
const hid = hiddenCards();
|
|
1043
|
+
return quotas && hid.size ? sharedBalances(quotas).filter((q) => hid.has(cardHideKey(q)) && !cardPeeked(q)) : [];
|
|
1044
|
+
}
|
|
1045
|
+
// renderQuotaHid: the line under the cards saying how many are hidden,
|
|
1046
|
+
// opening them, each with Unhide, and Show all
|
|
1047
|
+
function renderQuotaHid() {
|
|
1048
|
+
const hb = $("#quotaHid");
|
|
1049
|
+
const hidden = hiddenQuotas();
|
|
1050
|
+
hb.hidden = !hidden.length;
|
|
1051
|
+
if (!hidden.length) { quotaHidOpen = false; return; }
|
|
1052
|
+
const line = hb.querySelector(".us-hidline"), hl = hb.querySelector(".list");
|
|
1053
|
+
let all = hb.querySelector(".us-showall");
|
|
1054
|
+
if (!all) {
|
|
1055
|
+
all = el("button", "text us-showall", t("Show all"));
|
|
1056
|
+
all.type = "button";
|
|
1057
|
+
hb.append(all);
|
|
1058
|
+
}
|
|
1059
|
+
all.textContent = t("Show all");
|
|
1060
|
+
all.hidden = !quotaHidOpen || hidden.length < 2;
|
|
1061
|
+
all.onclick = () => hideQuotaCards(hidden.map(cardHideKey), false);
|
|
1062
|
+
line.replaceChildren(el("span", "", t("{n} hidden", { n: hidden.length })), svg(CHEV, 11, 1.6));
|
|
1063
|
+
line.title = t("Hidden from the Overview only: they are still used and counted. Click to see them.");
|
|
1064
|
+
line.setAttribute("aria-expanded", String(quotaHidOpen));
|
|
1065
|
+
line.onclick = () => { quotaHidOpen = !quotaHidOpen; renderQuotaHid(); };
|
|
1066
|
+
hl.hidden = !quotaHidOpen;
|
|
1067
|
+
hl.replaceChildren();
|
|
1068
|
+
if (!quotaHidOpen) return;
|
|
1069
|
+
for (const q of hidden) {
|
|
1070
|
+
const r = el("div", "row stat hid acts");
|
|
1071
|
+
r.dataset.id = cardHideKey(q);
|
|
1072
|
+
r.append(icon(q.icon));
|
|
1073
|
+
const who = el("div", "who");
|
|
1074
|
+
who.append(el("div", "name", quotaName(q)), el("div", "sub", [q.alias || q.user, t(cardHideKind(q) === "balance" ? "Balance" : "Plan")].filter(Boolean).join(" · ")));
|
|
1075
|
+
const b = el("button", "text us-unhide", t("Unhide"));
|
|
1076
|
+
b.type = "button";
|
|
1077
|
+
b.onclick = (e) => { e.stopPropagation(); hideQuotaCards([cardHideKey(q)], false); };
|
|
1078
|
+
r.append(who, b);
|
|
1079
|
+
hl.append(r);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// hideQuotaCards hides cards from the Overview, or has them back: drawn at
|
|
1083
|
+
// once, put back if the save fails
|
|
1084
|
+
async function hideQuotaCards(keys, hide) {
|
|
1085
|
+
const prev = state.settings || {};
|
|
1086
|
+
const cur = (prev.usageCardsHidden || []).filter((k) => !keys.includes(k));
|
|
1087
|
+
const cardsHidden = hide ? [...cur, ...keys] : cur;
|
|
1088
|
+
usagePeek = "";
|
|
1089
|
+
state.settings = { ...prev, usageCardsHidden: cardsHidden };
|
|
1090
|
+
const again = () => {
|
|
1091
|
+
renderQuotas();
|
|
1092
|
+
// the keyboard stays near: on the hidden line, or the card put back
|
|
1093
|
+
const to = $("#quotaHid .us-hidline");
|
|
1094
|
+
(hide && to && !to.closest("[hidden]") ? to : null)?.focus({ preventScroll: true });
|
|
1095
|
+
};
|
|
1096
|
+
again();
|
|
1097
|
+
try {
|
|
1098
|
+
const s = await api("usage/arrange", { cardsHidden });
|
|
1099
|
+
state.settings = { ...state.settings, usageCardsHidden: s.usageCardsHidden || [] };
|
|
1100
|
+
} catch (e) {
|
|
1101
|
+
state.settings = prev;
|
|
1102
|
+
again();
|
|
1103
|
+
status(e.message, "err");
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ---------- requests ----------
|
|
1108
|
+
//
|
|
1109
|
+
// The ledger: every request of the period, one row each, newest first —
|
|
1110
|
+
// what the agent asked for, where it went, the model sent and the one the
|
|
1111
|
+
// reply says answered, the tokens, what they cost at effective prices, how long
|
|
1112
|
+
// it took and how it ended — to set beside a vendor's own bill. The
|
|
1113
|
+
// server pages it (/api/usage/requests) and saves it whole as CSV.
|
|
1114
|
+
|
|
1115
|
+
let ledger = null; // the page shown: { rows, offset, total, agents, …totals }
|
|
1116
|
+
let ledgerWant = ""; // the ledParams it was read with
|
|
1117
|
+
let ledOffset = 0, ledPurpose = "", ledAgent = "", ledProvider = "", ledAccount = "", ledCallerKey = "", ledFailed = false, ledRetried = false, ledQuery = "", ledModel = "", ledRoute = 0, ledComputer = "", ledDay = "", ledVia = "";
|
|
1118
|
+
|
|
1119
|
+
const LED_PAGE = 100;
|
|
1120
|
+
// Remember the chart metric; start each app load split by model.
|
|
1121
|
+
let ledMetric = "tokens", ledSplit = "model";
|
|
1122
|
+
try {
|
|
1123
|
+
const m = localStorage.getItem("magpie.ledMetric");
|
|
1124
|
+
if (["tokens", "cost", "calls", "speed"].includes(m)) ledMetric = m;
|
|
1125
|
+
} catch {}
|
|
1126
|
+
// Columns or lines (molvqingtai on Discord), kept per viewer as the metric
|
|
1127
|
+
// is; anything but "line", or a read that fails, is columns.
|
|
1128
|
+
let ledLine = false;
|
|
1129
|
+
try { ledLine = localStorage.getItem("magpie.ledChart") === "line"; } catch {}
|
|
1130
|
+
|
|
1131
|
+
// The filters a click set for the reader rather than one they picked in a
|
|
1132
|
+
// filter's menu: someone in the chart's ranking or a day of its columns
|
|
1133
|
+
// ("a click lists only theirs"), or a way in from elsewhere (the tray
|
|
1134
|
+
// panel's Open Usage, a route Routing sent here). They last while the
|
|
1135
|
+
// Requests are looked at, and go when the reader leaves them, for another
|
|
1136
|
+
// tab or page (TJHHHH on Discord: a click on Codex in the ranking had
|
|
1137
|
+
// every later visit list only Codex's requests). A filter picked in its
|
|
1138
|
+
// menu stays, as one picked in a Mac app's does.
|
|
1139
|
+
const ledLent = new Set();
|
|
1140
|
+
const LED_UNLEND = {
|
|
1141
|
+
agent: () => { ledAgent = ""; },
|
|
1142
|
+
provider: () => { ledProvider = ""; },
|
|
1143
|
+
model: () => { ledModel = ""; ledQuery = ""; $("#ledQ").value = ""; },
|
|
1144
|
+
callerKey: () => { ledCallerKey = ""; },
|
|
1145
|
+
day: () => { ledDay = ""; },
|
|
1146
|
+
purpose: () => { ledPurpose = ""; },
|
|
1147
|
+
computer: () => { ledComputer = ""; },
|
|
1148
|
+
via: () => { ledVia = ""; },
|
|
1149
|
+
};
|
|
1150
|
+
// the reader picked this filter themselves: it stays
|
|
1151
|
+
const ledOwn = (k) => ledLent.delete(k);
|
|
1152
|
+
|
|
1153
|
+
let ledRouteInfo = null, ledBeforeRoute = null;
|
|
1154
|
+
window.openUsageRoute = (route) => {
|
|
1155
|
+
if (!ledBeforeRoute) ledBeforeRoute = { period, ledOffset, ledPurpose, ledAgent, ledProvider, ledAccount, ledCallerKey, ledFailed, ledRetried, ledQuery, ledModel, ledComputer, ledDay, ledVia, lent: [...ledLent] };
|
|
1156
|
+
ledRoute = route.id;
|
|
1157
|
+
ledRouteInfo = route;
|
|
1158
|
+
ledOffset = 0; ledPurpose = ""; ledAgent = ""; ledProvider = ""; ledAccount = ""; ledCallerKey = ""; ledFailed = false; ledRetried = false; ledQuery = ""; ledModel = ""; ledComputer = ""; ledVia = "";
|
|
1159
|
+
ledDay = "";
|
|
1160
|
+
ledLent.clear();
|
|
1161
|
+
$("#ledQ").value = "";
|
|
1162
|
+
period = "all";
|
|
1163
|
+
usageTab = "requests";
|
|
1164
|
+
ledger = null;
|
|
1165
|
+
show("usage");
|
|
1166
|
+
};
|
|
1167
|
+
// Routing's account list sends its reader to each account's
|
|
1168
|
+
// allowances in full: the Overview, its tab not kept as the reader's own
|
|
1169
|
+
window.openUsageOverview = () => {
|
|
1170
|
+
if (usageTab === "requests") ledLeave();
|
|
1171
|
+
usageTab = "usage";
|
|
1172
|
+
show("usage");
|
|
1173
|
+
};
|
|
1174
|
+
// the one request a route from Routing listed gives way to the list as it
|
|
1175
|
+
// was before it
|
|
1176
|
+
function ledRouteOff() {
|
|
1177
|
+
ledRoute = 0; ledRouteInfo = null;
|
|
1178
|
+
if (ledBeforeRoute) {
|
|
1179
|
+
({ period, ledOffset, ledPurpose = "", ledAgent, ledProvider, ledAccount = "", ledCallerKey, ledFailed, ledRetried = false, ledQuery, ledModel, ledComputer = "", ledDay = "", ledVia = "" } = ledBeforeRoute);
|
|
1180
|
+
ledLent.clear();
|
|
1181
|
+
for (const k of ledBeforeRoute.lent || []) ledLent.add(k);
|
|
1182
|
+
}
|
|
1183
|
+
ledBeforeRoute = null;
|
|
1184
|
+
$("#ledQ").value = ledQuery;
|
|
1185
|
+
}
|
|
1186
|
+
// ledLeave: the reader leaves the Requests; what a click lent them goes
|
|
1187
|
+
// (the rows drawn for them aren't shown again on the way back)
|
|
1188
|
+
function ledLeave() {
|
|
1189
|
+
const routed = !!ledRoute;
|
|
1190
|
+
if (routed) ledRouteOff();
|
|
1191
|
+
if (!ledLent.size && !routed) return;
|
|
1192
|
+
for (const k of ledLent) LED_UNLEND[k]();
|
|
1193
|
+
ledLent.clear();
|
|
1194
|
+
ledOffset = 0;
|
|
1195
|
+
ledger = null;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function ledParams(extra) {
|
|
1199
|
+
const q = new URLSearchParams({ period });
|
|
1200
|
+
if (usageZone()) q.set("tz", usageZone());
|
|
1201
|
+
if (ledDay) q.set("day", ledDay);
|
|
1202
|
+
if (ledAgent) q.set("agent", ledAgent);
|
|
1203
|
+
if (ledPurpose) q.set("purpose", ledPurpose);
|
|
1204
|
+
if (ledProvider) q.set("provider", ledProvider);
|
|
1205
|
+
if (ledAccount) q.set("account", ledAccount);
|
|
1206
|
+
if (ledComputer) q.set("computer", ledComputer);
|
|
1207
|
+
if (ledCallerKey) q.set("callerKey", ledCallerKey);
|
|
1208
|
+
if (ledVia) q.set("via", ledVia);
|
|
1209
|
+
if (ledRoute) q.set("route", ledRoute);
|
|
1210
|
+
if (ledFailed) q.set("failed", "1");
|
|
1211
|
+
if (ledRetried) q.set("retried", "1");
|
|
1212
|
+
if (ledModel) q.set("model", ledModel);
|
|
1213
|
+
else if (ledQuery.trim()) q.set("q", ledQuery.trim());
|
|
1214
|
+
if (extra) for (const k in extra) q.set(k, extra[k]);
|
|
1215
|
+
return q.toString();
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// One read of the ledger at a time (Vantiboo1ean, ~750k requests): the page
|
|
1219
|
+
// asked again while it is read (the refresh button, the timer) waits for
|
|
1220
|
+
// that read, and another page or filter cancels it. Each pick and press
|
|
1221
|
+
// used to send one more, and a long history's reads, all still running,
|
|
1222
|
+
// took the browser's few connections to magpie, so the page never came.
|
|
1223
|
+
// flight: { want, ctl, p }
|
|
1224
|
+
const ledFlights = { page: null, heat: null };
|
|
1225
|
+
function ledRead(kind, path, want) {
|
|
1226
|
+
const was = ledFlights[kind];
|
|
1227
|
+
if (was?.want === want) return was.p;
|
|
1228
|
+
was?.ctl.abort();
|
|
1229
|
+
const ctl = new AbortController();
|
|
1230
|
+
const f = { want, ctl, p: api(path + want, undefined, undefined, ctl.signal) };
|
|
1231
|
+
ledFlights[kind] = f;
|
|
1232
|
+
const done = () => { if (ledFlights[kind] === f) ledFlights[kind] = null; };
|
|
1233
|
+
f.p.then(done, done);
|
|
1234
|
+
return f.p;
|
|
1235
|
+
}
|
|
1236
|
+
const ledAborted = (e) => e?.name === "AbortError";
|
|
1237
|
+
|
|
1238
|
+
// quiet: a refresh while it's looked at, redrawn only on a change.
|
|
1239
|
+
// "open": the page opened again on the rows it showed (#4064, xisdumpling):
|
|
1240
|
+
// they stay as they are while read, are drawn again only on a change, and
|
|
1241
|
+
// that redraw (a hundred rows, their chart and heatmap) waits for the
|
|
1242
|
+
// sidebar to settle on the page, not stopping it halfway
|
|
1243
|
+
async function loadLedger(quiet) {
|
|
1244
|
+
if (!ledger && !quiet) renderLedgerLoading();
|
|
1245
|
+
// a pick over rows shown: they stay, dimmed, till its answer comes, so
|
|
1246
|
+
// the pick is seen taken (#1990, overstarry: over a long history the
|
|
1247
|
+
// read takes seconds, and the table sat as it was, as if the pick
|
|
1248
|
+
// hadn't been)
|
|
1249
|
+
else if (!quiet) ledPending(true);
|
|
1250
|
+
loadLedHeat().catch(() => {});
|
|
1251
|
+
const want = ledParams({ offset: ledOffset, limit: LED_PAGE });
|
|
1252
|
+
let l;
|
|
1253
|
+
try { l = await ledRead("page", "usage/requests?", want); } catch (e) {
|
|
1254
|
+
if (ledAborted(e)) return; // another page or filter was picked, and is read instead
|
|
1255
|
+
ledPending(false);
|
|
1256
|
+
throw e;
|
|
1257
|
+
}
|
|
1258
|
+
if (want !== ledParams({ offset: ledOffset, limit: LED_PAGE })) return; // another page or filter was picked meanwhile
|
|
1259
|
+
ledPending(false);
|
|
1260
|
+
if (quiet && JSON.stringify(l) === JSON.stringify(ledger)) return;
|
|
1261
|
+
if (quiet === "open") {
|
|
1262
|
+
const was = ledger;
|
|
1263
|
+
await navSettled();
|
|
1264
|
+
if (ledger !== was || want !== ledParams({ offset: ledOffset, limit: LED_PAGE })) return; // read again or drawn meanwhile
|
|
1265
|
+
}
|
|
1266
|
+
ledger = l;
|
|
1267
|
+
ledgerWant = want;
|
|
1268
|
+
if (view === "usage" && usageTab === "requests") renderLedger();
|
|
1269
|
+
}
|
|
1270
|
+
// navSettled: the sidebar's move to a page opened (navTopStep's spring,
|
|
1271
|
+
// navSubEnter's tabs coming in) has run its course, or 700ms have passed
|
|
1272
|
+
function navSettled() {
|
|
1273
|
+
const until = performance.now() + 700;
|
|
1274
|
+
return new Promise((done) => {
|
|
1275
|
+
const look = () => performance.now() < until && (window.navTop?.raf || document.querySelector("#nav .nav-sub.moving")) ? requestAnimationFrame(look) : done();
|
|
1276
|
+
look();
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function ledPending(on) {
|
|
1281
|
+
const wrap = $("#ledWrap");
|
|
1282
|
+
wrap.classList.toggle("led-pending", on);
|
|
1283
|
+
if (on) wrap.setAttribute("aria-busy", "true");
|
|
1284
|
+
else wrap.removeAttribute("aria-busy");
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function renderLedgerLoading() {
|
|
1288
|
+
const view = $("#view-usage");
|
|
1289
|
+
view.classList.add("loading");
|
|
1290
|
+
view.setAttribute("aria-busy", "true");
|
|
1291
|
+
renderPeriod(true);
|
|
1292
|
+
$("#usageCost").replaceChildren(el("span", "skeleton sk-cost"));
|
|
1293
|
+
$("#ledSum").textContent = "";
|
|
1294
|
+
$("#ledPager").hidden = true;
|
|
1295
|
+
const wrap = $("#ledWrap");
|
|
1296
|
+
wrap.classList.remove("none");
|
|
1297
|
+
wrap.replaceChildren();
|
|
1298
|
+
$("#ledHScroll").hidden = true;
|
|
1299
|
+
for (let i = 0; i < 5; i++) {
|
|
1300
|
+
const r = el("div", "led-sk");
|
|
1301
|
+
r.append(el("span", "skeleton sk-line"));
|
|
1302
|
+
wrap.append(r);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function ledTime(when) {
|
|
1307
|
+
const d = new Date(when), now = new Date();
|
|
1308
|
+
const opts = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
|
|
1309
|
+
if (d.toDateString() !== now.toDateString()) Object.assign(opts, { month: "short", day: "numeric" });
|
|
1310
|
+
return d.toLocaleString(intlLang(), opts);
|
|
1311
|
+
}
|
|
1312
|
+
// the model the reply named: amber, as the Routing page's tag, when it is
|
|
1313
|
+
// another than the one sent; plain when it is that one under a dated name,
|
|
1314
|
+
// or the member a remote magpie's routing group sent it to, said in its title
|
|
1315
|
+
function ledServed(r) {
|
|
1316
|
+
if (!r.served) return el("span", "faint", "—");
|
|
1317
|
+
if (r.routed && !r.swapped) {
|
|
1318
|
+
const k = el("span", "muted model-value routed", r.served);
|
|
1319
|
+
k.title = window.routedWhy ? window.routedWhy({ model: r.model, served: r.served }) : "";
|
|
1320
|
+
return k;
|
|
1321
|
+
}
|
|
1322
|
+
if (!r.swapped) return el("span", "model-value", r.served);
|
|
1323
|
+
const k = el("span", "swap", r.served);
|
|
1324
|
+
k.title = window.swapWhy ? window.swapWhy({ model: r.model, served: r.served, upstream: r.upstream }, { pinUnknown: true }) : "";
|
|
1325
|
+
return k;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// the protocol a request was sent upstream in, told by the path it went
|
|
1329
|
+
// out on, and the one its agent spoke when that was another: the
|
|
1330
|
+
// endpoint reads "/v1/chat/completions → /v1/messages" for a request
|
|
1331
|
+
// translated, the agent's own path alone for one sent as it came (蓝猫 on
|
|
1332
|
+
// Discord). null for a request with no path kept, a session file's. Gemini's
|
|
1333
|
+
// is generateContent, or streamGenerateContent streamed (Gemini CLI's,
|
|
1334
|
+
// Vertex AI's).
|
|
1335
|
+
const ledProtoOf = (path) => /\/messages\b/.test(path) ? "Anthropic" : /\/responses\b/.test(path) ? "Responses"
|
|
1336
|
+
: /\/chat\/completions\b/.test(path) ? "Chat" : /[gG]enerateContent|\/generate\b/.test(path) ? "Gemini" : "";
|
|
1337
|
+
function ledProtos(r) {
|
|
1338
|
+
if (!r.ep || r.source === "log") return null;
|
|
1339
|
+
const [a, b] = String(r.ep).split(" → ");
|
|
1340
|
+
const from = ledProtoOf(a), to = b ? ledProtoOf(b) : from;
|
|
1341
|
+
if (!to) return null;
|
|
1342
|
+
return { from: from || to, to };
|
|
1343
|
+
}
|
|
1344
|
+
function ledProtoBadge(r) {
|
|
1345
|
+
const p = ledProtos(r);
|
|
1346
|
+
if (!p) return null;
|
|
1347
|
+
const k = el("span", "src proto proto-" + p.to.toLowerCase(), p.from !== p.to ? p.from + " → " + p.to : p.to);
|
|
1348
|
+
k.title = p.from !== p.to ? t("The agent spoke {from}; sent upstream as {to}", p) : t("Sent upstream as {to}, as the agent spoke it", p);
|
|
1349
|
+
return k;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// a request that failed: told by its status, or, for one read from a
|
|
1353
|
+
// session file, which records none, by the error that ended it. One its
|
|
1354
|
+
// agent canceled (r.canceled: a reply stopped, or the next message sent
|
|
1355
|
+
// while it came) saw no error, and is no failure
|
|
1356
|
+
const ledFailed_ = (r) => !r.canceled && (r.status >= 400 || !!r.err);
|
|
1357
|
+
const ledKey = (r) => [r.t, r.agent, r.model, r.rid, r.session, r.computer || ""].join("|");
|
|
1358
|
+
const ledOpen = new Set(); // the rows opened, kept across a refresh
|
|
1359
|
+
|
|
1360
|
+
// what is known of one request beyond its row: the id its vendor gave it,
|
|
1361
|
+
// the path it went to, and, if it failed, what the vendor said
|
|
1362
|
+
function ledDetail(r, cols) {
|
|
1363
|
+
const dl = el("dl", "led-dl");
|
|
1364
|
+
const add = (name, value, cls) => {
|
|
1365
|
+
if (value === "" || value == null) return;
|
|
1366
|
+
dl.append(el("dt", "", t(name)), el("dd", cls || "", value));
|
|
1367
|
+
};
|
|
1368
|
+
if (ledFailed_(r)) {
|
|
1369
|
+
add("Status", r.status ? String(r.status) : "—", "bad");
|
|
1370
|
+
add("Error type", r.err_type, "bad");
|
|
1371
|
+
add(r.source === "log" ? "Error" : "Upstream said", r.err, "said");
|
|
1372
|
+
} else {
|
|
1373
|
+
// how it went, here as well as in the row's last column, which a narrow
|
|
1374
|
+
// window leaves out of sight (#799)
|
|
1375
|
+
add("Status", r.canceled ? `${r.status} · ${t("canceled")}` : r.status ? String(r.status) : r.source === "log" ? t("Succeeded") : "");
|
|
1376
|
+
}
|
|
1377
|
+
if (Number.isFinite(r.ms) && r.ms > 0) add("Duration", (r.source === "log" ? "≈" : "") + ledTook(r.ms));
|
|
1378
|
+
// the way it was routed: the row's time links there too, which reads as
|
|
1379
|
+
// a time rather than a link (#799)
|
|
1380
|
+
// (with the Routing page switched off there is nowhere to see it)
|
|
1381
|
+
if (r.route_id && !featuresOff.has("routing")) {
|
|
1382
|
+
const go = el("button", "text led-route-link", t("View routing"));
|
|
1383
|
+
go.onclick = (e) => { e.stopPropagation(); window.openRoute(r.route_id, r.t).catch((err) => status(err.message, "err")); };
|
|
1384
|
+
const dd = el("dd");
|
|
1385
|
+
dd.append(go);
|
|
1386
|
+
dl.append(el("dt", "", t("Routing")), dd);
|
|
1387
|
+
}
|
|
1388
|
+
if (r.computerName) add("Computer", r.computerName);
|
|
1389
|
+
// the client's own name, for one the user counts as an agent (#1958)
|
|
1390
|
+
add("Client", r.client);
|
|
1391
|
+
// the columns a narrow window leaves out, shown only while it does (#860)
|
|
1392
|
+
const opt = (name, value, cls = "lc-opt") => {
|
|
1393
|
+
if (!value) return;
|
|
1394
|
+
add(name, value);
|
|
1395
|
+
dl.lastChild.classList.add(cls);
|
|
1396
|
+
dl.lastChild.previousSibling.classList.add(cls);
|
|
1397
|
+
};
|
|
1398
|
+
opt("Sent", r.model);
|
|
1399
|
+
opt("Effort", r.effort);
|
|
1400
|
+
// the cache hit rate's column stays while the raw cache columns go, and
|
|
1401
|
+
// goes only with the first token's
|
|
1402
|
+
opt("Cache hit rate", ledHitText(ledCacheHit(r)), "lc-opt2");
|
|
1403
|
+
// its tokens at every width, the cache's beside in and out: a prompt read
|
|
1404
|
+
// from the cache is told from one that missed it, whose input is the
|
|
1405
|
+
// whole of it, where the row's cache columns are out of sight (#1839).
|
|
1406
|
+
// In leaves the cache out, as Anthropic counts it.
|
|
1407
|
+
if (r.in || r.out || r.cache_read || r.cache_write || !ledFailed_(r)) {
|
|
1408
|
+
add("Tokens", t("{i} in · {o} out · {r} cache read · {w} cache creation", { i: ledNum(r.in || 0), o: ledNum(r.out || 0), r: ledNum(r.cache_read || 0), w: ledNum(r.cache_write || 0) }) + cacheTTLs(r, ledNum) + (r.cache_unknown ? " · " + t("The vendor doesn't count the cache") : ""), "led-tokens");
|
|
1409
|
+
}
|
|
1410
|
+
if (r.credits != null) add("Credits", ledCredits({ credits: r.credits, credited: 1 }).textContent);
|
|
1411
|
+
if (ledRowSpeed(r)) add("Speed", t("{n} tok/s", { n: ledNum(Math.round(ledRowSpeed(r))) }));
|
|
1412
|
+
else if (ledFloorSpeed(r)) add("Speed", "≥" + t("{n} tok/s", { n: ledNum(Math.round(ledFloorSpeed(r))) }));
|
|
1413
|
+
// what went up to the vendor and came back, its tries summed, and what
|
|
1414
|
+
// DeepSeek's image files kept out of it (#5407); a row from before has
|
|
1415
|
+
// none of them, and says nothing
|
|
1416
|
+
if (r.up_bytes > 0) add("Sent upstream", fmtBytes(r.up_bytes));
|
|
1417
|
+
if (r.down_bytes > 0) add("Received", fmtBytes(r.down_bytes));
|
|
1418
|
+
// and how many images they were, for a row that kept it (matrix0487)
|
|
1419
|
+
if (r.image_files_saved > 0) add("Saved by image files", r.image_files > 0 ? t(r.image_files === 1 ? "{size} ({n} image)" : "{size} ({n} images)", { size: fmtBytes(r.image_files_saved), n: ledNum(r.image_files) }) : fmtBytes(r.image_files_saved));
|
|
1420
|
+
add("Request ID", r.rid);
|
|
1421
|
+
add("Endpoint", r.ep);
|
|
1422
|
+
const protos = ledProtos(r);
|
|
1423
|
+
if (protos) add("Protocol", protos.from !== protos.to ? protos.from + " → " + protos.to : protos.to);
|
|
1424
|
+
// why the upstream said its reply ended, in its own words: a reply that
|
|
1425
|
+
// ended too soon is told apart by it (蓝猫 on Discord)
|
|
1426
|
+
add("Upstream stop reason", r.stop);
|
|
1427
|
+
// the provider an aggregator (OpenRouter …) said answered behind it
|
|
1428
|
+
add("Upstream provider", r.upstream);
|
|
1429
|
+
add("Session ID", r.session);
|
|
1430
|
+
// a call that isn't a turn of the conversation says what it was for, as
|
|
1431
|
+
// the agent named it: a subagent's, a title, an approval check… — a
|
|
1432
|
+
// model the conversation never picked reads as what it is (#714)
|
|
1433
|
+
if (r.kind) add("Called for", window.kindName ? window.kindName(r.kind) : r.kind);
|
|
1434
|
+
if (r.ttft_ms) add("First token", ledTook(r.ttft_ms));
|
|
1435
|
+
// of it, what came before the request went to the vendor, and the
|
|
1436
|
+
// vendor's wait from then to its first token. Before it, magpie's own
|
|
1437
|
+
// time is told apart from the tries that failed first, a wait for a
|
|
1438
|
+
// slot and connecting and sending the body (#1532: a vendor that held a
|
|
1439
|
+
// try ~100 s before its 503 read as 104 s in magpie); a record from
|
|
1440
|
+
// before they were kept has only the whole
|
|
1441
|
+
if (r.ttft_ms && r.sent_ms) {
|
|
1442
|
+
const split = r.own_ms != null;
|
|
1443
|
+
// of magpie's own, what went on masking it (Settings → Privacy)
|
|
1444
|
+
const masking = split && r.masking_ms > 0 ? Math.min(r.masking_ms, r.own_ms) : 0;
|
|
1445
|
+
if (masking) add("Masking", ledTook(masking));
|
|
1446
|
+
add("In magpie", ledTook((split ? r.own_ms : r.sent_ms) - masking));
|
|
1447
|
+
if (split) {
|
|
1448
|
+
if (r.retried_ms > 0) add("Tries that failed first", ledTook(r.retried_ms));
|
|
1449
|
+
if (r.queued_ms > 0) add("Waiting for a slot", ledTook(r.queued_ms));
|
|
1450
|
+
const sending = r.sent_ms - r.own_ms - (r.retried_ms || 0) - (r.queued_ms || 0);
|
|
1451
|
+
if (sending > 0) add("Connecting and sending", ledTook(sending));
|
|
1452
|
+
}
|
|
1453
|
+
add("Vendor's first token", ledTook(Math.max(0, r.ttft_ms - r.sent_ms)));
|
|
1454
|
+
}
|
|
1455
|
+
if (r.reasoning) add("Reasoning tokens", ledNum(r.reasoning));
|
|
1456
|
+
if (r.session_provider) add("Recorded provider ID", r.session_provider);
|
|
1457
|
+
if (r.session_account) add("Session account", r.session_account);
|
|
1458
|
+
if (r.source === "log" && r.session_account && r.session_official_login) add("Login method", t("Official login"));
|
|
1459
|
+
if (r.pricing_model) add("API price reference", r.pricing_model);
|
|
1460
|
+
// with the archive on, a request it has no copy of says so: from before
|
|
1461
|
+
// it was on, or not through the gateway
|
|
1462
|
+
if (!r.archive && !r.computer && (providers?.gateway?.archive?.on ?? state.settings?.requestArchive)) add("Request archive", t("Not archived"), "muted");
|
|
1463
|
+
if (r.source === "log") add("Data source", t("Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred."), "muted");
|
|
1464
|
+
const tr = el("tr", "led-detail");
|
|
1465
|
+
const td = el("td");
|
|
1466
|
+
td.colSpan = cols;
|
|
1467
|
+
const box = el("div", "led-box");
|
|
1468
|
+
box.append(dl.childElementCount ? dl : el("span", "faint", t("Nothing more was kept of this request")));
|
|
1469
|
+
// the call as the request archive kept it, when it was on (#447)
|
|
1470
|
+
if (r.archive) {
|
|
1471
|
+
const ab = el("div", "led-archive");
|
|
1472
|
+
const draw = () => ab.replaceChildren(archivePanel(r.archive, "led|" + ledKey(r), draw));
|
|
1473
|
+
draw();
|
|
1474
|
+
box.append(ab);
|
|
1475
|
+
}
|
|
1476
|
+
// what was said: read from the agent's session file, when the row is
|
|
1477
|
+
// opened; one made on another computer has its files there, and sync
|
|
1478
|
+
// brings none of what was said
|
|
1479
|
+
const cx = el("div", "led-cx");
|
|
1480
|
+
if (r.computer) {
|
|
1481
|
+
cx.append(el("p", "cx-none", t("What was said stays on {computer}: sync shares the usage alone.", { computer: r.computerName || r.computer })));
|
|
1482
|
+
} else {
|
|
1483
|
+
cx.append(el("p", "cx-none", t("Loading…")));
|
|
1484
|
+
ledLoadContent(r).then((c) => { if (cx.isConnected) cx.replaceChildren(ledContentBox(c)); });
|
|
1485
|
+
}
|
|
1486
|
+
box.append(cx);
|
|
1487
|
+
td.append(box);
|
|
1488
|
+
tr.append(td);
|
|
1489
|
+
return tr;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// What was said in a request — what its agent was given, and what came back —
|
|
1493
|
+
// is in the agent's own session file, and read from it when a row is opened
|
|
1494
|
+
// (the server finds the call by its session and time); magpie keeps no copy.
|
|
1495
|
+
// What came is kept here while the list is looked at, so a row opened again, or
|
|
1496
|
+
// drawn again as the list is read anew, doesn't ask again.
|
|
1497
|
+
const ledContent = new Map();
|
|
1498
|
+
function ledLoadContent(r) {
|
|
1499
|
+
const key = ledKey(r);
|
|
1500
|
+
if (!ledContent.has(key)) {
|
|
1501
|
+
if (ledContent.size > 60) ledContent.delete(ledContent.keys().next().value);
|
|
1502
|
+
const t0 = new Date(r.t).getTime();
|
|
1503
|
+
// a call of a session file is at its own time; a request the gateway logged began
|
|
1504
|
+
// a little before the file wrote it, and the call is in the span it took
|
|
1505
|
+
const [a, b] = r.source === "log" ? [t0 - 1, t0 + 1] : [t0 - 5e3, t0 + (r.ms || 0) + 30e3];
|
|
1506
|
+
// the window can hold the next calls of a quick tool loop too: the one that
|
|
1507
|
+
// ended nearest the request's own end is its call
|
|
1508
|
+
const at = r.source === "log" ? t0 : t0 + (r.ms || 0);
|
|
1509
|
+
const q = new URLSearchParams({ agent: r.agent, session: r.native_session || r.session || "", from: new Date(a).toISOString(), to: new Date(b).toISOString(), at: new Date(at).toISOString() });
|
|
1510
|
+
const pending = api("usage/requests/content?" + q).catch(() => ({ found: false, why: "read" })).then((c) => {
|
|
1511
|
+
if (!c.found && ledContent.get(key) === pending) ledContent.delete(key);
|
|
1512
|
+
return c;
|
|
1513
|
+
});
|
|
1514
|
+
ledContent.set(key, pending);
|
|
1515
|
+
}
|
|
1516
|
+
return ledContent.get(key);
|
|
1517
|
+
}
|
|
1518
|
+
const LED_WHY = {
|
|
1519
|
+
session: "No session was named with this request, so its session file can't be found",
|
|
1520
|
+
agent: "magpie reads the session files of Claude Code, Claude Desktop and Codex only",
|
|
1521
|
+
missing: "This request isn't in the agent's session files: they may be deleted, moved, or not written yet",
|
|
1522
|
+
read: "The session file couldn't be read",
|
|
1523
|
+
};
|
|
1524
|
+
function ledContentBox(c) {
|
|
1525
|
+
const box = el("div", "cx");
|
|
1526
|
+
if (!c.found) {
|
|
1527
|
+
box.append(el("p", "cx-none", t(LED_WHY[c.why] || LED_WHY.read)));
|
|
1528
|
+
return box;
|
|
1529
|
+
}
|
|
1530
|
+
for (const [name, parts] of [["Input", c.input || []], ["Output", c.output || []]]) {
|
|
1531
|
+
const sec = el("section", "cx-sec");
|
|
1532
|
+
sec.append(el("h4", "", t(name)));
|
|
1533
|
+
if (!parts.length) sec.append(el("p", "cx-none", t("Nothing was said here")));
|
|
1534
|
+
for (const p of parts) sec.append(ledSaid(p));
|
|
1535
|
+
box.append(sec);
|
|
1536
|
+
}
|
|
1537
|
+
if (c.cut) box.append(el("p", "cx-none", t("There was more than is shown here")));
|
|
1538
|
+
box.append(el("p", "cx-src", t("Read from the agent's session file; magpie keeps no copy")));
|
|
1539
|
+
return box;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// ---- the totals and the trend over the requests listed
|
|
1543
|
+
//
|
|
1544
|
+
// A strip of four totals, then the trend of one metric — tokens, cost or
|
|
1545
|
+
// requests — as columns by the hour, day or week, each told apart by provider,
|
|
1546
|
+
// agent or model, beside a ranking of the same: which is the chart's legend
|
|
1547
|
+
// and a way in (a click on a provider or agent lists only its requests). The
|
|
1548
|
+
// server sums it all (the ledger's "series" and "by"); the tray panel's Usage
|
|
1549
|
+
// tab draws the same from the same answer.
|
|
1550
|
+
|
|
1551
|
+
// the window's chart can also say how fast the replies came (#860)
|
|
1552
|
+
const LED_TREND = [...LED_METRICS, ["speed", "Speed"]];
|
|
1553
|
+
// "modelAt" is a model at the provider it went to, so one model's speed at
|
|
1554
|
+
// each provider is ranked side by side (inaction on Discord); "callerKey"
|
|
1555
|
+
// the gateway key that made it, "none" for calls with none, offered while
|
|
1556
|
+
// the period has a key's calls (#2521)
|
|
1557
|
+
const LED_SPLITS = [["model", "Model"], ["modelAt", "Model · provider"], ["provider", "Provider"], ["agent", "Agent"], ["callerKey", "Gateway key"]];
|
|
1558
|
+
// a count said short (fmtN: 1.2M, 35K, 12.3 万) with its exact value for a
|
|
1559
|
+
// tooltip, nothing when the two read the same (skyhuang1994 on Discord:
|
|
1560
|
+
// "看这么长串的数字有点懵逼")
|
|
1561
|
+
const exactN = (n) => fmtN(n ||= 0) == String(n) ? "" : ledNum(n);
|
|
1562
|
+
const tipOf = (...lines) => lines.filter(Boolean).join("\n");
|
|
1563
|
+
// cacheTTLs: a call's cache writes split by how long they are kept, where
|
|
1564
|
+
// some were for an hour (Anthropic bills those at 2× input, 5 minutes'
|
|
1565
|
+
// at 1.25×); nothing when none were
|
|
1566
|
+
function cacheTTLs(x, fmt) {
|
|
1567
|
+
const h = Math.min(x.cache_write_1h || 0, x.cache_write || 0);
|
|
1568
|
+
return h ? " · " + t("{m} for 5 minutes, {h} for an hour", { m: fmt(x.cache_write - h), h: fmt(h) }) : "";
|
|
1569
|
+
}
|
|
1570
|
+
// the rail's thumb drags the ranking; a click on the rail takes it there
|
|
1571
|
+
function ledRail(rail, box) {
|
|
1572
|
+
box.rail = rail;
|
|
1573
|
+
rail.onpointerdown = (e) => {
|
|
1574
|
+
if (e.button !== 0) return;
|
|
1575
|
+
e.preventDefault();
|
|
1576
|
+
const end = box.scrollHeight - box.clientHeight, thumb = rail.firstChild.offsetHeight, room = rail.clientHeight - thumb;
|
|
1577
|
+
if (end <= 0 || room <= 0) return;
|
|
1578
|
+
if (e.target !== rail.firstChild) {
|
|
1579
|
+
// the thumb's middle to where the rail was clicked
|
|
1580
|
+
const y = e.clientY - rail.getBoundingClientRect().top - thumb / 2;
|
|
1581
|
+
box.scrollTop = (Math.max(0, Math.min(room, y)) / room) * end;
|
|
1582
|
+
}
|
|
1583
|
+
const y0 = e.clientY, top0 = box.scrollTop;
|
|
1584
|
+
rail.setPointerCapture(e.pointerId);
|
|
1585
|
+
rail.classList.add("drag");
|
|
1586
|
+
rail.onpointermove = (m) => { box.scrollTop = top0 + ((m.clientY - y0) / room) * end; };
|
|
1587
|
+
rail.onpointerup = rail.onpointercancel = () => {
|
|
1588
|
+
rail.classList.remove("drag");
|
|
1589
|
+
rail.onpointermove = rail.onpointerup = rail.onpointercancel = null;
|
|
1590
|
+
};
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// the request list's status filter: "" for all, "failed" or "retried"
|
|
1595
|
+
const ledStatusOf = () => ledFailed ? "failed" : ledRetried ? "retried" : "";
|
|
1596
|
+
function ledShowStatus(to) {
|
|
1597
|
+
if (to === ledStatusOf()) return;
|
|
1598
|
+
ledFailed = to === "failed";
|
|
1599
|
+
ledRetried = to === "retried";
|
|
1600
|
+
ledOffset = 0;
|
|
1601
|
+
loadLedger().catch((e) => status(e.message, "err"));
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function renderLedgerDash(l) {
|
|
1605
|
+
const dash = $("#ledDash");
|
|
1606
|
+
dash.hidden = !l.total && !l.day;
|
|
1607
|
+
if (dash.hidden) return;
|
|
1608
|
+
const total = allTokens(l);
|
|
1609
|
+
// of the calls whose vendor counted the cache: none when no call's was (#3464)
|
|
1610
|
+
const rate = cacheHitOf(l);
|
|
1611
|
+
const strip = $("#ledKpi");
|
|
1612
|
+
strip.replaceChildren();
|
|
1613
|
+
const block = (label, value, sub, cls, title) => {
|
|
1614
|
+
const b = el("div", "blk");
|
|
1615
|
+
if (title) b.title = title;
|
|
1616
|
+
// a value in parts (an array) keeps each whole, wrapping between them
|
|
1617
|
+
const v = el("span", "v" + (cls ? " " + cls : "") + (Array.isArray(value) ? " parts" : ""), Array.isArray(value) ? undefined : value);
|
|
1618
|
+
// (a space between two stays out of them: one leading a part is dropped)
|
|
1619
|
+
if (Array.isArray(value)) value.forEach((x, i) => v.append(...(i && /^\s/.test(x) ? [" "] : []), el("span", "", x.trim())));
|
|
1620
|
+
b.append(el("span", "k", t(label)), v);
|
|
1621
|
+
if (sub) b.append(sub);
|
|
1622
|
+
strip.append(b);
|
|
1623
|
+
return b;
|
|
1624
|
+
};
|
|
1625
|
+
const line = (...parts) => { const s = el("span", "sub"); s.append(...parts); return s; };
|
|
1626
|
+
// a line holding a count's button wraps rather than cut the button off
|
|
1627
|
+
// (German's "nach erneutem Versuch beantwortet" runs past a narrow card)
|
|
1628
|
+
const jumps = (...parts) => { const s = line(...parts); s.classList.add("jumps"); return s; };
|
|
1629
|
+
// its parts kept whole, the cache's going under in a card too narrow for
|
|
1630
|
+
// all three rather than cut (#2378: 缓存 879… in a wide window's five cards)
|
|
1631
|
+
const tokParts = line(...usageParts([t("{a} in · {b} out", { a: fmtN(l.input), b: fmtN(l.output) }), t("{a} cache", { a: fmtN(l.cache_read + l.cache_write) })]));
|
|
1632
|
+
tokParts.classList.add("parts");
|
|
1633
|
+
block("Tokens", fmtN(total), tokParts, "", ledNum(total));
|
|
1634
|
+
// a request is counted once, by how it ended: one a later try answered
|
|
1635
|
+
// is not a failure, and is counted beside them as answered on a retry
|
|
1636
|
+
// (小一 on Discord). Each count is a button for its requests in the list.
|
|
1637
|
+
const jump = (text, to, title) => {
|
|
1638
|
+
const b = el("button", "led-jump" + (to === "failed" ? " bad" : "") + (ledStatusOf() === to ? " on" : ""), text);
|
|
1639
|
+
b.type = "button";
|
|
1640
|
+
b.title = title;
|
|
1641
|
+
b.setAttribute("aria-pressed", ledStatusOf() === to ? "true" : "false");
|
|
1642
|
+
b.onclick = () => ledShowStatus(ledStatusOf() === to ? "" : to);
|
|
1643
|
+
return b;
|
|
1644
|
+
};
|
|
1645
|
+
// the share that succeeded is of the requests that ended in an answer or
|
|
1646
|
+
// an error: not those their agent canceled, which are counted apart
|
|
1647
|
+
const settled = l.calls - (l.canceled || 0);
|
|
1648
|
+
block("Requests", fmtN(l.calls), l.errors
|
|
1649
|
+
? jumps(jump(t("{n} failed", { n: fmtN(l.errors) }), "failed", t("Show only the failed requests")), " · " + t("{p} succeeded", { p: (100 * (1 - l.errors / settled)).toFixed(1) + "%" }))
|
|
1650
|
+
: line(t("none failed")), "", exactN(l.calls));
|
|
1651
|
+
if (l.retried) strip.lastChild.append(jumps(jump(t("{n} answered on a retry", { n: fmtN(l.retried) }), "retried", t("Show only the requests answered on a retry"))));
|
|
1652
|
+
if (l.canceled) {
|
|
1653
|
+
const c = line(t("{n} canceled", { n: fmtN(l.canceled) }));
|
|
1654
|
+
c.title = t("Canceled by the agent before an answer came: a reply stopped, or a message sent while it was coming. Not counted as failed.");
|
|
1655
|
+
strip.lastChild.append(c);
|
|
1656
|
+
}
|
|
1657
|
+
// the period's traffic, of the requests that kept it: up and down, and
|
|
1658
|
+
// what DeepSeek's image files saved on the way up. Its parts kept whole,
|
|
1659
|
+
// the saving going under in a card too narrow for all three rather than
|
|
1660
|
+
// cut (matrix0487 on Discord: "图片文件省下 44…")
|
|
1661
|
+
if (l.up_bytes > 0 || l.down_bytes > 0) {
|
|
1662
|
+
const io = line(...usageParts(["↑ " + fmtBytes(l.up_bytes || 0), "↓ " + fmtBytes(l.down_bytes || 0), ...(l.image_files_saved > 0 ? [t("{n} saved by image files", { n: fmtBytes(l.image_files_saved) })] : [])]));
|
|
1663
|
+
io.classList.add("parts", "led-io");
|
|
1664
|
+
io.title = t("Sent upstream") + ": " + ledNum(l.up_bytes || 0) + " B\n" + t("Received") + ": " + ledNum(l.down_bytes || 0) + " B";
|
|
1665
|
+
strip.lastChild.append(io);
|
|
1666
|
+
}
|
|
1667
|
+
const c = fmtCost(l);
|
|
1668
|
+
block("Cost", c ? "≈" + c : "—", line(l.unpriced ? t(l.unpriced === 1 ? "{n} call had no known price and is not counted" : "{n} calls had no known price and are not counted", { n: l.unpriced }) : t("effective prices")), c ? "cost" : "");
|
|
1669
|
+
const hit = block("Cache hit rate", rate === null ? "—" : (100 * rate).toFixed(1) + "%", cacheLeftOut(l) ? line(cacheLeftOut(l)) : null, "", t("The share of the prompt read from the cache"));
|
|
1670
|
+
const meter = el("div", "meter"), fill = el("i");
|
|
1671
|
+
fill.style.width = (100 * (rate || 0)).toFixed(1) + "%";
|
|
1672
|
+
meter.append(fill);
|
|
1673
|
+
hit.append(meter);
|
|
1674
|
+
// how fast the streamed replies wrote, and how long they took to begin (#860):
|
|
1675
|
+
// the figure of more replies, its count beside it; the other under it; the
|
|
1676
|
+
// wait for the first token on a line of its own, with how many it is of
|
|
1677
|
+
// (Fallmonkey on Discord)
|
|
1678
|
+
const gs = ledGroupSpeed(l);
|
|
1679
|
+
const ttft = ledTTFT(l) ? t("first token in {ms} on average", { ms: ledTook(ledTTFT(l)) }) + " · " + t(l.timed === 1 ? "{k} timed reply" : "{k} timed replies", { k: ledNum(l.timed) }) : t("no streamed replies timed");
|
|
1680
|
+
const speedCard = block("Output speed", gs ? gs.mainParts : "—", gs?.other ? line(gs.other) : null,
|
|
1681
|
+
"", [gs?.title, t("Output tokens a second after the first, over the streamed replies") + ". " + t("A reply that reasoned counts only its answer, from its first text: the reasoning was written before the stream showed it") + ". " + t("No answer text: speed includes reasoning only when visible reasoning streamed; hidden reasoning has no speed")].filter(Boolean).join("\n"));
|
|
1682
|
+
speedCard.append(Object.assign(line(ttft), { className: "sub ttft" }));
|
|
1683
|
+
drawLedTrend();
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// the chart's two looks, as a pair of icons beside the metrics: columns
|
|
1687
|
+
// (the default) or lines
|
|
1688
|
+
const LED_STYLES = [["bars", "Bar chart", "M3.5 13V8.5M8 13V3.5M12.5 13V6.5"], ["line", "Line chart", "M2.5 11.5 6 7.5l3 2.5 4.5-6"]];
|
|
1689
|
+
function ledStyle(host) {
|
|
1690
|
+
const cur = ledLine ? "line" : "bars";
|
|
1691
|
+
host.setAttribute("role", "group");
|
|
1692
|
+
host.setAttribute("aria-label", t("Chart style"));
|
|
1693
|
+
host.replaceChildren();
|
|
1694
|
+
for (const [id, name, d] of LED_STYLES) {
|
|
1695
|
+
const b = el("button", "opt" + (id === cur ? " on" : ""));
|
|
1696
|
+
b.type = "button";
|
|
1697
|
+
b.dataset.style = id;
|
|
1698
|
+
b.title = t(name);
|
|
1699
|
+
b.setAttribute("aria-label", t(name));
|
|
1700
|
+
b.setAttribute("aria-pressed", id === cur ? "true" : "false");
|
|
1701
|
+
b.append(svg(d, 14, 1.7));
|
|
1702
|
+
b.onclick = () => {
|
|
1703
|
+
if (id === cur) return;
|
|
1704
|
+
ledLine = id === "line";
|
|
1705
|
+
try { localStorage.setItem("magpie.ledChart", id); } catch {}
|
|
1706
|
+
drawLedTrend();
|
|
1707
|
+
};
|
|
1708
|
+
host.append(b);
|
|
1709
|
+
}
|
|
1710
|
+
slide(host, "ledStyle");
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// The chart as lines (drawLedColumns' box.drawn): the columns drawn on its
|
|
1714
|
+
// axis are taken away and each one told apart becomes a line through its
|
|
1715
|
+
// points. Tokens, cost and requests stay stacked, each line where its part
|
|
1716
|
+
// of the column ended, with its band softly filled under it, so the top
|
|
1717
|
+
// line is the total as the column's top was; speeds don't add up, so each
|
|
1718
|
+
// model's line is at its own speed, broken where it had none, over the
|
|
1719
|
+
// point's own speed dashed. The tooltip, the hover, a day's pick and the
|
|
1720
|
+
// ranking's emphasis are drawLedColumns' own, over the same slots.
|
|
1721
|
+
// A smooth line through points [x, y], as d3's curveMonotoneX draws it
|
|
1722
|
+
// (Fritsch–Carlson): it bends between points but never past one, so a
|
|
1723
|
+
// quiet hour isn't drawn dipping under zero or a peak over its value.
|
|
1724
|
+
// "from" starts it ("M", or "L" to go on from where a path is).
|
|
1725
|
+
function ledCurve(ps, from = "M") {
|
|
1726
|
+
const n = ps.length, f = (v) => v.toFixed(1);
|
|
1727
|
+
if (!n) return "";
|
|
1728
|
+
let d = from + f(ps[0][0]) + " " + f(ps[0][1]);
|
|
1729
|
+
if (n === 1) return d;
|
|
1730
|
+
const s = [], m = [];
|
|
1731
|
+
for (let i = 0; i < n - 1; i++) s.push((ps[i + 1][1] - ps[i][1]) / (ps[i + 1][0] - ps[i][0]));
|
|
1732
|
+
for (let i = 1; i < n - 1; i++) {
|
|
1733
|
+
const h0 = ps[i][0] - ps[i - 1][0], h1 = ps[i + 1][0] - ps[i][0], p = (s[i - 1] * h1 + s[i] * h0) / (h0 + h1);
|
|
1734
|
+
m[i] = s[i - 1] * s[i] <= 0 ? 0 : (Math.sign(s[i - 1]) + Math.sign(s[i])) * Math.min(Math.abs(s[i - 1]), Math.abs(s[i]), Math.abs(p) / 2);
|
|
1735
|
+
}
|
|
1736
|
+
m[0] = n > 2 ? (3 * s[0] - m[1]) / 2 : s[0];
|
|
1737
|
+
m[n - 1] = n > 2 ? (3 * s[n - 2] - m[n - 2]) / 2 : s[0];
|
|
1738
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1739
|
+
const [xa, ya] = ps[i], [xb, yb] = ps[i + 1], dx = (xb - xa) / 3;
|
|
1740
|
+
d += "C" + f(xa + dx) + " " + f(ya + dx * m[i]) + " " + f(xb - dx) + " " + f(yb - dx * m[i + 1]) + " " + f(xb) + " " + f(yb);
|
|
1741
|
+
}
|
|
1742
|
+
return d;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function ledLines(g, Y, x0, slot, segs) {
|
|
1746
|
+
const l = ledger, pts = l?.series || [], metric = ledMetric, split = ledSplit;
|
|
1747
|
+
for (const r of segs) r.remove();
|
|
1748
|
+
segs.length = 0;
|
|
1749
|
+
if (!l) return;
|
|
1750
|
+
const speed = metric === "speed";
|
|
1751
|
+
const { top, list } = ledCategories(l.chartBy ? { by: l.chartBy } : l, split, metric, true);
|
|
1752
|
+
const X = (i) => x0 + slot * i, dot = slot >= 12 ? 4.5 : 3;
|
|
1753
|
+
const at = (i, v) => [X(i), Y(v)];
|
|
1754
|
+
// stacked, each one goes under those before it, so a band never covers
|
|
1755
|
+
// the line under it
|
|
1756
|
+
let head = null;
|
|
1757
|
+
const totals = pts.map((p) => ledValue(metric, p));
|
|
1758
|
+
// one series: its line, the dots at its points and the band under it
|
|
1759
|
+
const series = (key, color, vals, base, dashed) => {
|
|
1760
|
+
const s = sv("g", { class: "col ln", "data-k": key, "data-day": l.day || "", "data-color": color }, { stroke: color });
|
|
1761
|
+
// each unbroken run is one smooth curve; a point alone in its run has
|
|
1762
|
+
// no line to sit on, so it is a dot
|
|
1763
|
+
let line = "", dots = "", run = [];
|
|
1764
|
+
const end = () => {
|
|
1765
|
+
if (run.length === 1 && !dashed) dots += "M" + run[0][0].toFixed(1) + " " + run[0][1].toFixed(1) + "h0";
|
|
1766
|
+
line += ledCurve(run);
|
|
1767
|
+
run = [];
|
|
1768
|
+
};
|
|
1769
|
+
vals.forEach((v, i) => { if (v == null) end(); else run.push(at(i, v)); });
|
|
1770
|
+
end();
|
|
1771
|
+
if (!line) return;
|
|
1772
|
+
if (base) {
|
|
1773
|
+
const up = ledCurve(vals.map((v, i) => at(i, v)));
|
|
1774
|
+
const down = ledCurve(base.map((v, i) => at(i, v)).reverse(), "L");
|
|
1775
|
+
s.append(sv("path", { d: up + down + "Z", class: "band" }, { fill: color }));
|
|
1776
|
+
}
|
|
1777
|
+
s.append(sv("path", { d: line, class: "line" + (dashed ? " all" : "") }));
|
|
1778
|
+
if (dots) s.append(sv("path", { d: dots, class: "dots" }, { strokeWidth: dot }));
|
|
1779
|
+
head = g.insertBefore(s, speed ? null : head);
|
|
1780
|
+
segs.push(s);
|
|
1781
|
+
};
|
|
1782
|
+
if (speed) {
|
|
1783
|
+
series("\0all", "var(--muted)", totals.map((v) => (v > 0 ? v : null)), null, true);
|
|
1784
|
+
const rest = new Map(pts.flatMap((p) => ledOthers(p.by?.[split] || {}, top, list)).map((c) => [c.id, c]));
|
|
1785
|
+
for (const c of [...top, ...rest.values()]) series(c.id, c.color, pts.map((p) => ledSpeed(p.by?.[split]?.[c.id]) || null));
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
// from the bottom up: each one's part on what is under it, "Other" last
|
|
1789
|
+
let under = pts.map(() => 0);
|
|
1790
|
+
const parts = top.map((c) => [c.id, c.color, pts.map((p) => ledPart(metric, p.by?.[split]?.[c.id] || {}))]);
|
|
1791
|
+
parts.push(["\0other", "var(--faint)", pts.map((p, i) => Math.max(0, totals[i] - parts.reduce((a, x) => a + x[2][i], 0)))]);
|
|
1792
|
+
for (const [key, color, vs] of parts) {
|
|
1793
|
+
const over = under.map((u, i) => u + vs[i]);
|
|
1794
|
+
if (vs.some((v) => v > 0)) series(key, color, over, under);
|
|
1795
|
+
under = over;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function drawLedTrend() {
|
|
1800
|
+
const l = ledger;
|
|
1801
|
+
if (!l || $("#ledDash").hidden) return;
|
|
1802
|
+
// the two switches
|
|
1803
|
+
const pill = (host, key, opts, cur, pick) => {
|
|
1804
|
+
host.replaceChildren();
|
|
1805
|
+
for (const [id, name] of opts) {
|
|
1806
|
+
const b = el("button", "opt" + (id === cur ? " on" : ""), t(name));
|
|
1807
|
+
b.onclick = () => { if (id !== cur) pick(id); };
|
|
1808
|
+
host.append(b);
|
|
1809
|
+
}
|
|
1810
|
+
slide(host, key);
|
|
1811
|
+
};
|
|
1812
|
+
pill($("#ledMetric"), "ledMetric", LED_TREND, ledMetric, (id) => { ledMetric = id; try { localStorage.setItem("magpie.ledMetric", id); } catch {} drawLedTrend(); });
|
|
1813
|
+
ledStyle($("#ledStyle"));
|
|
1814
|
+
const keyed = (l.callerKeys || []).length > 0;
|
|
1815
|
+
if (ledSplit === "callerKey" && !keyed) ledSplit = "model";
|
|
1816
|
+
pill($("#ledSplit"), "ledSplit", LED_SPLITS.filter(([id]) => id !== "callerKey" || keyed), ledSplit, (id) => { ledSplit = id; drawLedTrend(); });
|
|
1817
|
+
const chart = $("#ledChart"), rank = $("#ledRank");
|
|
1818
|
+
chart.drawn = ledLine ? ledLines : null;
|
|
1819
|
+
drawLedColumns(chart, l, ledSplit, ledMetric, false, (day) => {
|
|
1820
|
+
ledDay = ledDay === day ? "" : day;
|
|
1821
|
+
ledLent.add("day");
|
|
1822
|
+
ledOffset = 0;
|
|
1823
|
+
loadLedger().catch((e) => status(e.message, "err"));
|
|
1824
|
+
});
|
|
1825
|
+
rank.chart = chart;
|
|
1826
|
+
if (!rank.rail) ledRail($("#ledRail"), rank);
|
|
1827
|
+
const picked = ledSplit === "provider" ? ledProvider : ledSplit === "agent" ? ledAgent : ledSplit === "callerKey" ? ledCallerKey
|
|
1828
|
+
: ledSplit === "modelAt" ? (ledProvider && ledModel ? ledProvider + "/" + ledModel : "") : ledModel;
|
|
1829
|
+
drawLedRank(rank, l, ledSplit, ledMetric, picked, (x) => {
|
|
1830
|
+
// a click lists only that one's requests; on the one listed, all again
|
|
1831
|
+
if (ledSplit === "modelAt") {
|
|
1832
|
+
// provider/model: both filters, the provider's id has no "/"
|
|
1833
|
+
const q = $("#ledQ"), cut = x.id.indexOf("/"), off = picked === x.id;
|
|
1834
|
+
ledProvider = off ? "" : x.id.slice(0, cut);
|
|
1835
|
+
ledModel = off ? "" : x.id.slice(cut + 1);
|
|
1836
|
+
ledQuery = ledModel; q.value = ledQuery;
|
|
1837
|
+
ledLent.add("provider").add("model");
|
|
1838
|
+
} else if (ledSplit === "provider") { ledProvider = ledProvider === x.id ? "" : x.id; ledLent.add("provider"); }
|
|
1839
|
+
else if (ledSplit === "agent") { ledAgent = ledAgent === x.id ? "" : x.id; ledLent.add("agent"); }
|
|
1840
|
+
else if (ledSplit === "callerKey") { ledCallerKey = ledCallerKey === x.id ? "" : x.id; ledLent.add("callerKey"); }
|
|
1841
|
+
else { const q = $("#ledQ"); ledModel = ledModel === x.id ? "" : x.id; ledQuery = ledModel; q.value = ledQuery; ledLent.add("model"); }
|
|
1842
|
+
ledOffset = 0;
|
|
1843
|
+
loadLedger().catch((e) => status(e.message, "err"));
|
|
1844
|
+
}, false);
|
|
1845
|
+
}
|
|
1846
|
+
// ---------- the heatmap (#1369) ----------
|
|
1847
|
+
//
|
|
1848
|
+
// The last 53 weeks a day each, Monday on top, as a calendar of
|
|
1849
|
+
// contributions lays them out: each day as dark as it is busy among the
|
|
1850
|
+
// others, by tokens, requests or cost. It is of the requests the filters
|
|
1851
|
+
// above keep, whatever the period (the server's /api/usage/heatmap, read
|
|
1852
|
+
// from the same index as the page), its days in the gateway's time zone. A
|
|
1853
|
+
// narrow window scrolls it sideways inside its card, the newest week in
|
|
1854
|
+
// sight; the pointer on a day says its date and what it had.
|
|
1855
|
+
let ledHeat = null, ledHeatWant = "", ledHeatMetric = "tokens";
|
|
1856
|
+
try {
|
|
1857
|
+
const m = localStorage.getItem("magpie.ledHeatMetric");
|
|
1858
|
+
if (["tokens", "calls", "cost"].includes(m)) ledHeatMetric = m;
|
|
1859
|
+
} catch {}
|
|
1860
|
+
const LED_HEAT = [["tokens", "Tokens"], ["calls", "Requests"], ["cost", "Cost"]];
|
|
1861
|
+
function ledHeatParams() {
|
|
1862
|
+
const q = new URLSearchParams(ledParams());
|
|
1863
|
+
q.delete("period");
|
|
1864
|
+
q.delete("day");
|
|
1865
|
+
return q.toString();
|
|
1866
|
+
}
|
|
1867
|
+
async function loadLedHeat() {
|
|
1868
|
+
const want = ledHeatParams();
|
|
1869
|
+
ledHeatWant = want;
|
|
1870
|
+
const h = await ledRead("heat", "usage/heatmap?", want).catch((e) => { if (ledAborted(e)) return ledAborted; throw e; });
|
|
1871
|
+
if (h === ledAborted) return;
|
|
1872
|
+
if (want !== ledHeatWant) return; // another filter was picked meanwhile
|
|
1873
|
+
if (ledHeat && ledHeat.want === want && JSON.stringify(ledHeat.data) === JSON.stringify(h)) return;
|
|
1874
|
+
ledHeat = { want, data: h };
|
|
1875
|
+
if (view === "usage" && usageTab === "requests") drawLedHeat();
|
|
1876
|
+
}
|
|
1877
|
+
const ledHeatDate = (s) => { const [y, m, d] = String(s).split("-").map(Number); return new Date(y, m - 1, d); };
|
|
1878
|
+
function ledHeatTip(x) {
|
|
1879
|
+
const loc = intlLang();
|
|
1880
|
+
const tip = el("div");
|
|
1881
|
+
tip.append(el("b", "", x.day.toLocaleDateString(loc, { year: "numeric", month: "short", day: "numeric", weekday: "short" })));
|
|
1882
|
+
if (!x.calls) {
|
|
1883
|
+
tip.append(el("span", "", t("No requests")));
|
|
1884
|
+
return tip;
|
|
1885
|
+
}
|
|
1886
|
+
const parts = [t("{n} tokens", { n: fmtN(x.tokens) }), t(x.calls === 1 ? "{n} request" : "{n} requests", { n: ledNum(x.calls) })];
|
|
1887
|
+
const c = fmtCost(x);
|
|
1888
|
+
if (c) parts.push("≈" + c);
|
|
1889
|
+
const order = { tokens: 0, calls: 1, cost: 2 }[ledHeatMetric];
|
|
1890
|
+
if (order && parts[order]) parts.unshift(...parts.splice(order, 1)); // the metric shown first
|
|
1891
|
+
tip.append(el("span", "", parts.join(" · ")));
|
|
1892
|
+
return tip;
|
|
1893
|
+
}
|
|
1894
|
+
function drawLedHeat() {
|
|
1895
|
+
const box = $("#ledHeat"), h = ledHeat?.data;
|
|
1896
|
+
const at = new Map((h?.days || []).map((d) => [d.date, d]));
|
|
1897
|
+
box.hidden = !h?.from || !h?.to || ![...at.values()].some((d) => d.calls > 0);
|
|
1898
|
+
if (box.hidden) return;
|
|
1899
|
+
const first = ledHeatDate(h.from), last = ledHeatDate(h.to);
|
|
1900
|
+
const days = [];
|
|
1901
|
+
for (const d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) {
|
|
1902
|
+
const x = at.get(sessISO(d)) || { calls: 0, tokens: 0, cost: 0 };
|
|
1903
|
+
days.push({ ...x, day: new Date(d) });
|
|
1904
|
+
}
|
|
1905
|
+
const value = (x) => ledHeatMetric === "cost" ? x.cost : ledHeatMetric === "calls" ? x.calls : x.tokens;
|
|
1906
|
+
const lv = sessLevels(days.map(value));
|
|
1907
|
+
const seg = $("#ledHeatMetric");
|
|
1908
|
+
seg.replaceChildren();
|
|
1909
|
+
for (const [id, name] of LED_HEAT) {
|
|
1910
|
+
const b = el("button", "opt" + (id === ledHeatMetric ? " on" : ""), t(name));
|
|
1911
|
+
b.type = "button";
|
|
1912
|
+
b.onclick = () => {
|
|
1913
|
+
if (id === ledHeatMetric) return;
|
|
1914
|
+
ledHeatMetric = id;
|
|
1915
|
+
try { localStorage.setItem("magpie.ledHeatMetric", id); } catch {}
|
|
1916
|
+
drawLedHeat();
|
|
1917
|
+
};
|
|
1918
|
+
seg.append(b);
|
|
1919
|
+
}
|
|
1920
|
+
slide(seg, "ledHeatMetric");
|
|
1921
|
+
const scroll = $("#ledHeatScroll");
|
|
1922
|
+
// the same days in the same language keep their cells, coloured again, so
|
|
1923
|
+
// a refresh moves nothing and the scroll stays where the reader left it
|
|
1924
|
+
const shape = [h.from, h.to, locale].join("|");
|
|
1925
|
+
let grid = scroll.querySelector(".heat-grid");
|
|
1926
|
+
if (!grid || grid.dataset.shape !== shape) {
|
|
1927
|
+
grid = el("div", "heat-grid");
|
|
1928
|
+
grid.dataset.shape = shape;
|
|
1929
|
+
grid.setAttribute("role", "img");
|
|
1930
|
+
const names = sessWeekdays();
|
|
1931
|
+
// every row has its place at the edge, named every other day
|
|
1932
|
+
for (let i = 0; i < 7; i++) {
|
|
1933
|
+
const l = el("span", "wd", i % 2 ? "" : names[i]);
|
|
1934
|
+
l.style.gridArea = `${i + 2} / 1`;
|
|
1935
|
+
grid.append(l);
|
|
1936
|
+
}
|
|
1937
|
+
const loc = intlLang();
|
|
1938
|
+
const weeks = Math.ceil(days.length / 7), labels = [];
|
|
1939
|
+
let month = -1;
|
|
1940
|
+
days.forEach((x, i) => {
|
|
1941
|
+
const w = Math.floor(i / 7), wd = i % 7;
|
|
1942
|
+
if (wd === 0 && x.day.getMonth() !== month) {
|
|
1943
|
+
month = x.day.getMonth();
|
|
1944
|
+
// a month mostly gone in the first week is left unnamed, so as not
|
|
1945
|
+
// to crowd out the next one's name, and so is one begun in the last
|
|
1946
|
+
// week, with no room for its name
|
|
1947
|
+
if ((!labels.length || w - labels.at(-1).w >= 3) && !(w === 0 && x.day.getDate() > 14) && w < weeks - 1) {
|
|
1948
|
+
const m = el("span", "mo", x.day.toLocaleDateString(loc, { month: "short" }));
|
|
1949
|
+
labels.push({ m, w });
|
|
1950
|
+
grid.append(m);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
const c = el("i");
|
|
1954
|
+
c.dataset.date = sessISO(x.day);
|
|
1955
|
+
c.style.gridArea = `${wd + 2} / ${w + 2}`;
|
|
1956
|
+
grid.append(c);
|
|
1957
|
+
});
|
|
1958
|
+
// a month's name spans its weeks up to the next name, cut there, so a
|
|
1959
|
+
// long one never widens the grid past its last week
|
|
1960
|
+
labels.forEach(({ m, w }, i) => { m.style.gridArea = `1 / ${w + 2} / 2 / ${(labels[i + 1]?.w ?? weeks) + 2}`; });
|
|
1961
|
+
grid.onpointerover = (e) => { if (e.target.dataset?.date) ledHeatShow(e.target); };
|
|
1962
|
+
grid.onpointerleave = () => { $("#ledHeatTip").hidden = true; };
|
|
1963
|
+
scroll.replaceChildren(grid);
|
|
1964
|
+
if (!scroll.onscroll) {
|
|
1965
|
+
// the newest week in sight, by the card's own scroll, never the page's:
|
|
1966
|
+
// kept there as the card is laid out or resized, until the reader
|
|
1967
|
+
// scrolls it back, and again once they scroll to the end
|
|
1968
|
+
scroll.onscroll = () => {
|
|
1969
|
+
$("#ledHeatTip").hidden = true;
|
|
1970
|
+
// its own scroll to the end, seen after the card narrowed, is not the reader's
|
|
1971
|
+
if (scroll.clientWidth && scroll.scrollLeft !== ledHeatSetAt) ledHeatAtEnd = scroll.scrollLeft >= scroll.scrollWidth - scroll.clientWidth - 2;
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
ledHeatSized.disconnect();
|
|
1975
|
+
ledHeatSized.observe(scroll);
|
|
1976
|
+
ledHeatSized.observe(grid);
|
|
1977
|
+
ledHeatKeepEnd();
|
|
1978
|
+
}
|
|
1979
|
+
grid.setAttribute("aria-label", t("Usage heatmap"));
|
|
1980
|
+
const cells = grid.querySelectorAll("i");
|
|
1981
|
+
days.forEach((x, i) => {
|
|
1982
|
+
const c = cells[i];
|
|
1983
|
+
c.className = "l" + lv(value(x));
|
|
1984
|
+
c.day = x;
|
|
1985
|
+
});
|
|
1986
|
+
const legend = box.querySelector(".heat-foot");
|
|
1987
|
+
legend.replaceChildren(sessLegend());
|
|
1988
|
+
const tip = $("#ledHeatTip");
|
|
1989
|
+
if (!tip.hidden && tip.cell?.isConnected) ledHeatShow(tip.cell);
|
|
1990
|
+
else tip.hidden = true;
|
|
1991
|
+
}
|
|
1992
|
+
let ledHeatAtEnd = true, ledHeatSetAt = -1;
|
|
1993
|
+
const ledHeatSized = new ResizeObserver(() => ledHeatKeepEnd());
|
|
1994
|
+
function ledHeatKeepEnd() {
|
|
1995
|
+
const scroll = $("#ledHeatScroll");
|
|
1996
|
+
if (!ledHeatAtEnd || !scroll.clientWidth || scroll.scrollWidth <= scroll.clientWidth) return;
|
|
1997
|
+
scroll.scrollLeft = scroll.scrollWidth;
|
|
1998
|
+
ledHeatSetAt = scroll.scrollLeft;
|
|
1999
|
+
}
|
|
2000
|
+
// the tip over a day, above it, kept inside the card
|
|
2001
|
+
function ledHeatShow(cell) {
|
|
2002
|
+
const box = $("#ledHeat"), tip = $("#ledHeatTip");
|
|
2003
|
+
if (!cell.day) return;
|
|
2004
|
+
tip.replaceChildren(...ledHeatTip(cell.day).children); // the date over the figures
|
|
2005
|
+
tip.cell = cell;
|
|
2006
|
+
tip.hidden = false;
|
|
2007
|
+
const b = box.getBoundingClientRect(), c = cell.getBoundingClientRect();
|
|
2008
|
+
const w = tip.offsetWidth, hgt = tip.offsetHeight;
|
|
2009
|
+
const left = Math.max(6, Math.min(b.width - w - 6, c.left - b.left + c.width / 2 - w / 2));
|
|
2010
|
+
let top = c.top - b.top - hgt - 6;
|
|
2011
|
+
if (top < 4) top = c.bottom - b.top + 6;
|
|
2012
|
+
tip.style.left = left + "px";
|
|
2013
|
+
tip.style.top = top + "px";
|
|
2014
|
+
}
|
|
2015
|
+
// Each follows the other, and the scroll a follow sets off is its own echo,
|
|
2016
|
+
// never a move to pass back: WebKit tells it a frame later, and on a phone
|
|
2017
|
+
// the finger has moved the table on by then, so the bar's echo wrote the
|
|
2018
|
+
// table back a frame, every other frame of a swipe (Pluviophile on
|
|
2019
|
+
// Discord: 左右滑动震颤, 粘滞卡顿)
|
|
2020
|
+
const ledEcho = new WeakMap();
|
|
2021
|
+
function ledFollow(to, from) {
|
|
2022
|
+
const was = to.scrollLeft;
|
|
2023
|
+
if (was === from.scrollLeft) return;
|
|
2024
|
+
to.scrollLeft = from.scrollLeft;
|
|
2025
|
+
// one held where it was (no room yet) tells no scroll
|
|
2026
|
+
if (to.scrollLeft !== was) ledEcho.set(to, to.scrollLeft);
|
|
2027
|
+
}
|
|
2028
|
+
function ledFollowed(e, other) {
|
|
2029
|
+
const box = e.currentTarget, echo = ledEcho.get(box);
|
|
2030
|
+
ledEcho.delete(box);
|
|
2031
|
+
if (box.scrollLeft !== echo) ledFollow(other(), box);
|
|
2032
|
+
}
|
|
2033
|
+
// the table's scrollbar, kept in view at the window's foot (#799): as wide
|
|
2034
|
+
// as the table can go sideways, and moving it as the table moves it
|
|
2035
|
+
function ledHScroll() {
|
|
2036
|
+
const wrap = $("#ledWrap"), bar = $("#ledHScroll");
|
|
2037
|
+
const over = !!wrap.querySelector("table") && wrap.scrollWidth > wrap.clientWidth + 1;
|
|
2038
|
+
bar.hidden = !over;
|
|
2039
|
+
if (!over) return;
|
|
2040
|
+
bar.firstElementChild.style.width = wrap.scrollWidth - wrap.clientWidth + bar.clientWidth + "px";
|
|
2041
|
+
ledFollow(bar, wrap);
|
|
2042
|
+
// a sticky box keeps clear of its scroller's padding, so at bottom 0 it
|
|
2043
|
+
// stood the view's bottom padding above the window's foot, the table
|
|
2044
|
+
// showing through the strip under it: it goes down over that padding
|
|
2045
|
+
const view = bar.closest(".view");
|
|
2046
|
+
const pad = view ? parseFloat(getComputedStyle(view).paddingBottom) || 0 : 0;
|
|
2047
|
+
bar.style.bottom = -pad + "px";
|
|
2048
|
+
}
|
|
2049
|
+
$("#ledWrap").addEventListener("scroll", (e) => ledFollowed(e, () => $("#ledHScroll")), { passive: true });
|
|
2050
|
+
$("#ledHScroll").addEventListener("scroll", (e) => ledFollowed(e, () => $("#ledWrap")), { passive: true });
|
|
2051
|
+
// a window that changes size redraws the chart at its new width
|
|
2052
|
+
// a new width fits the table again, on the next frame, and so do the
|
|
2053
|
+
// details' width (--ledw) and the scrollbar: each changes the table's or the
|
|
2054
|
+
// wrap's height, which this would be told of in its own call, and WebKit
|
|
2055
|
+
// reports that as a ResizeObserver loop (number-units' page error)
|
|
2056
|
+
let ledFitW = 0, ledFitFrame = 0;
|
|
2057
|
+
new ResizeObserver(() => {
|
|
2058
|
+
if (ledFitFrame) return;
|
|
2059
|
+
ledFitFrame = requestAnimationFrame(() => {
|
|
2060
|
+
ledFitFrame = 0;
|
|
2061
|
+
const wrap = $("#ledWrap");
|
|
2062
|
+
// out of sight it has no width: what was fit stays, so the page shown
|
|
2063
|
+
// again at the width it had fits nothing again (#4064: each fit of a
|
|
2064
|
+
// hundred rows restyles them twice and lays them out)
|
|
2065
|
+
if (!wrap.clientWidth) return;
|
|
2066
|
+
wrap.style.setProperty("--ledw", wrap.clientWidth + "px");
|
|
2067
|
+
if (wrap.clientWidth !== ledFitW) { ledFitW = wrap.clientWidth; ledFit(); }
|
|
2068
|
+
ledHScroll();
|
|
2069
|
+
});
|
|
2070
|
+
}).observe($("#ledWrap"));
|
|
2071
|
+
// a table that grows past the window when its width didn't change (a font
|
|
2072
|
+
// arriving, a redraw while out of sight) leaves its columns out then too;
|
|
2073
|
+
// and its scrollbar goes once a table left without them has come to fit,
|
|
2074
|
+
// rather than staying under a table with nothing to scroll
|
|
2075
|
+
let ledGrowFrame = 0;
|
|
2076
|
+
const ledGrow = new ResizeObserver(() => {
|
|
2077
|
+
if (ledGrowFrame) return;
|
|
2078
|
+
ledGrowFrame = requestAnimationFrame(() => {
|
|
2079
|
+
ledGrowFrame = 0;
|
|
2080
|
+
const wrap = $("#ledWrap"), table = wrap.querySelector("table.led");
|
|
2081
|
+
if (table && !table.classList.contains("tight2") && wrap.clientWidth && wrap.scrollWidth > wrap.clientWidth + 1) ledFit();
|
|
2082
|
+
ledHScroll();
|
|
2083
|
+
});
|
|
2084
|
+
});
|
|
2085
|
+
new ResizeObserver(() => {
|
|
2086
|
+
if (!ledger || usageTab !== "requests" || view !== "usage" || $("#ledDash").hidden) return;
|
|
2087
|
+
drawLedColumns($("#ledChart"), ledger, ledSplit, ledMetric, false);
|
|
2088
|
+
ledRankEdges($("#ledRank"));
|
|
2089
|
+
}).observe($("#ledChart"));
|
|
2090
|
+
|
|
2091
|
+
// the third: a column a narrow window leaves out, its value in the row's
|
|
2092
|
+
// details (#860), 2 for one left out only when that isn't enough; the
|
|
2093
|
+
// fourth, the header's tooltip
|
|
2094
|
+
// a request's cache hit rate, as the Routing page's request list says it
|
|
2095
|
+
// (teenux on Discord): what was read from the cache over its whole
|
|
2096
|
+
// prompt, writes included; null for a vendor that counts no cache
|
|
2097
|
+
// (#3464) or a request with no prompt counted
|
|
2098
|
+
const ledCacheHit = (r) => r.cache_unknown ? null : cacheHitOf({ input: r.in, cache_read: r.cache_read, cache_write: r.cache_write });
|
|
2099
|
+
const ledHitText = (h) => h === null ? "" : Math.round(100 * h) + "%";
|
|
2100
|
+
const LED_COLS = [
|
|
2101
|
+
["Time"], ["Agent"], ["Requested"], ["Provider · account"], ["Sent", "", true], ["Served"], ["Effort", "", true],
|
|
2102
|
+
["In", "n"], ["Out", "n"], ["Cache write", "n", true], ["Cache read", "n", true], ["Cache hit rate", "n", 2, "The share of the prompt read from the cache"], ["Cost", "n"], ["TTFT", "n", 2, "Time to first token"], ["Duration", "n"], ["Speed", "n"], ["Status"],
|
|
2103
|
+
];
|
|
2104
|
+
|
|
2105
|
+
// a window under a second after a wait over four times longer: the reply came
|
|
2106
|
+
// whole in a moment after a long silence, and what it was written over isn't
|
|
2107
|
+
// seen (pluviophile714 on Discord: gpt-6.1-sol, 392 tokens 0.25 s after an
|
|
2108
|
+
// 8.2 s first token, read 1,539 tok/s; usage.HeldWindowMs)
|
|
2109
|
+
const ledHeldShort_ = (w, from) => w < 1000 && from > 4 * w;
|
|
2110
|
+
// the tokens a reply was seen to write and the ms it took, as
|
|
2111
|
+
// usage.DecodeOf and routing/client.js's decodeOf tell them: one that reasoned
|
|
2112
|
+
// counts its answer from its first text, its reasoning written before
|
|
2113
|
+
// the stream showed any (tony on Discord); null when it tells no speed.
|
|
2114
|
+
// A reply held back and let go in a burst at its end tells none (John on
|
|
2115
|
+
// Discord): its content came in under a quarter of its window (flow_ms),
|
|
2116
|
+
// or its answer at over 20 times the pace its reasoning came before it,
|
|
2117
|
+
// or it came in under a second after a wait four times longer (ledHeldShort_)
|
|
2118
|
+
const ledDecode = (r) => {
|
|
2119
|
+
if (ledFailed_(r) || r.canceled || r.reasoning_hidden && !(r.first_text_ms > 0) || !(r.ttft_ms > 0)) return null;
|
|
2120
|
+
const think = r.reasoning > 0, visible = think && !(r.first_text_ms > 0) && r.first_reasoning_ms >= r.ttft_ms && r.flow_ms > 0 && r.reasoning <= r.out;
|
|
2121
|
+
const n = think && !visible ? r.out - r.reasoning : r.out, from = visible ? r.first_reasoning_ms : think ? r.first_text_ms : r.ttft_ms;
|
|
2122
|
+
const w = r.ms - from;
|
|
2123
|
+
if (!(n > 0 && from > 0 && w >= 100 && n * 1000 <= 10000 * w)) return null;
|
|
2124
|
+
if (r.flow_ms > 0 && r.flow_ms * 4 < w) return null;
|
|
2125
|
+
if (ledHeldShort_(w, from)) return null;
|
|
2126
|
+
if (think && r.first_text_ms > 0 && n * (r.first_text_ms - r.ttft_ms) > 20 * r.reasoning * w) return null;
|
|
2127
|
+
if (ledCodexTurn(r) && (w < LED_CODEX_WINDOW_MS || n < LED_CODEX_BODY)) return null;
|
|
2128
|
+
return { n, w };
|
|
2129
|
+
};
|
|
2130
|
+
// a Codex turn of one call read from its session file, its first token
|
|
2131
|
+
// known: its window after the first token can be one network push, not the
|
|
2132
|
+
// writing (Fallmonkey on Discord: a first token at 6.8 s, the rest 0.2 s
|
|
2133
|
+
// later), so it tells a speed only over a second or more and 100 tokens or
|
|
2134
|
+
// more (usage.CodexWindowMs, CodexBodyTokens); else ledFloor_ tells the
|
|
2135
|
+
// least it wrote at over the whole turn
|
|
2136
|
+
const LED_CODEX_WINDOW_MS = 1000, LED_CODEX_BODY = 100;
|
|
2137
|
+
const ledCodexTurn = (r) => r.source === "log" && r.agent === "codex" && r.ttft_ms > 0;
|
|
2138
|
+
// how fast a reply wrote, in tokens a second: 0 when it can't tell
|
|
2139
|
+
const ledRowSpeed = (r) => { const d = ledDecode(r); return d ? d.n / (d.w / 1000) : 0; };
|
|
2140
|
+
// the least a reply read from a session file wrote at, in tokens a second:
|
|
2141
|
+
// the file has no first token, only when the line that asked for it and its
|
|
2142
|
+
// last line were written (Claude Code's are each block's end), so its tokens
|
|
2143
|
+
// over that whole time, the wait for the first one in it, are a floor the
|
|
2144
|
+
// speed after the first token can't be under. Against Claude Code's own
|
|
2145
|
+
// stream it read 2–5% under for a reply of 1,400 tokens or more, 11–33% for
|
|
2146
|
+
// a shorter one or one asked with a tool's result, so only a reply of 1,000
|
|
2147
|
+
// or more is told. 0 when it tells none, and a call magpie timed tells its
|
|
2148
|
+
// own (ledDecode). Only the row's: the period's speed counts timed calls
|
|
2149
|
+
// and session files' floors (usage.Row.Floor), not a gateway row's below.
|
|
2150
|
+
// Claude Code's, Claude Desktop's and Codex's files only (usage.FloorAgents):
|
|
2151
|
+
// a call's time is from what asked for it, never from inside it. OpenCode's
|
|
2152
|
+
// isn't checked against real sessions, and tells none.
|
|
2153
|
+
// A Codex turn of one call that reasoned has its first token but not its
|
|
2154
|
+
// first text (Fallmonkey on Discord): its answer, its output less the
|
|
2155
|
+
// reasoning, all came after the first token, so the answer over the time
|
|
2156
|
+
// after it is a floor of the answer's speed, when that time is a second or
|
|
2157
|
+
// more and the answer 100 tokens or more, as a measured one is. A Codex turn
|
|
2158
|
+
// of one call that tells neither tells its output over the whole turn, the
|
|
2159
|
+
// wait in it, by the same 1,000-token rule as any whole-time floor (Fallmonkey
|
|
2160
|
+
// on Discord: 11 tokens in 2.75 s read ≥4 tok/s).
|
|
2161
|
+
// A gateway call that tells no speed of its own (夜松云 on Discord: the
|
|
2162
|
+
// Requests tab's speed was blank for some rows) has the same two floors, as
|
|
2163
|
+
// its time is from when it came to its last token: one that reasoned with no
|
|
2164
|
+
// first text recorded (a tool call after hidden reasoning), its answer after
|
|
2165
|
+
// the first token, unless its content came in a burst; else one not streamed,
|
|
2166
|
+
// or not timed, its output over its whole time, by the 1,000-token rule.
|
|
2167
|
+
// ledFloor_ says the tokens, the ms, and which it is (answer, turn, gateway).
|
|
2168
|
+
const LED_FLOOR_AGENTS = new Set(["claude", "claude-desktop", "codex"]);
|
|
2169
|
+
const ledWhole_ = (r) => r.out >= 1000 && r.ms >= 1000 && r.out * 1000 <= 10000 * r.ms;
|
|
2170
|
+
const ledFloor_ = (r) => {
|
|
2171
|
+
if (ledFailed_(r) || r.canceled || ledRowSpeed(r)) return null;
|
|
2172
|
+
if (r.source !== "log") {
|
|
2173
|
+
const n = r.out - (r.reasoning || 0), w = r.ms - r.ttft_ms;
|
|
2174
|
+
if (r.ttft_ms > 0 && r.reasoning > 0 && !(r.first_text_ms > 0) && n >= LED_CODEX_BODY && w >= LED_CODEX_WINDOW_MS && n * 1000 <= 10000 * w && !(r.flow_ms > 0 && r.flow_ms * 4 < w)) return { n, w, answer: true, gateway: true };
|
|
2175
|
+
return ledWhole_(r) ? { n: r.out, w: r.ms, gateway: true } : null;
|
|
2176
|
+
}
|
|
2177
|
+
if (!LED_FLOOR_AGENTS.has(r.agent)) return null;
|
|
2178
|
+
if (ledCodexTurn(r)) {
|
|
2179
|
+
const n = r.out - (r.reasoning || 0), w = r.ms - r.ttft_ms;
|
|
2180
|
+
if (r.reasoning > 0 && n >= LED_CODEX_BODY && w >= LED_CODEX_WINDOW_MS && n * 1000 <= 10000 * w) return { n, w, answer: true };
|
|
2181
|
+
return ledWhole_(r) ? { n: r.out, w: r.ms, turn: true } : null;
|
|
2182
|
+
}
|
|
2183
|
+
return ledWhole_(r) ? { n: r.out, w: r.ms } : null;
|
|
2184
|
+
};
|
|
2185
|
+
// why a row tells no speed of its own, in words for its tooltip, so a dash
|
|
2186
|
+
// is never left unexplained: ledDecode's reasons, in its order
|
|
2187
|
+
const ledNoSpeed_ = (r) => {
|
|
2188
|
+
if (ledFailed_(r)) return t("A failed request tells no speed");
|
|
2189
|
+
if (r.canceled) return t("A canceled request tells no speed");
|
|
2190
|
+
if (!(r.ms > 0)) return t("No duration was recorded to time a speed from");
|
|
2191
|
+
if (!(r.out > 0)) return t("No output tokens to time a speed from");
|
|
2192
|
+
if (!(r.ttft_ms > 0)) return r.source === "log" ? t("The session file has no first token to time a speed from") : t("Not streamed: no first token to time a speed from");
|
|
2193
|
+
const think = r.reasoning > 0, visible = think && !(r.first_text_ms > 0) && r.first_reasoning_ms >= r.ttft_ms && r.flow_ms > 0 && r.reasoning <= r.out;
|
|
2194
|
+
const n = think && !visible ? r.out - r.reasoning : r.out;
|
|
2195
|
+
if (!(n > 0)) return t("All its output was reasoning: no answer after it to time a speed from");
|
|
2196
|
+
const from = visible ? r.first_reasoning_ms : think && r.first_text_ms > 0 ? r.first_text_ms : r.ttft_ms, w = r.ms - from;
|
|
2197
|
+
const little = t("Too little came after the first token to time a speed by: {n} tokens in {ms}", { n: ledNum(n), ms: ledTook(Math.max(0, w)) });
|
|
2198
|
+
if (w < 100) return little;
|
|
2199
|
+
if (ledHeldShort_(w, from) && !(n * 1000 > 10000 * w || r.flow_ms > 0 && r.flow_ms * 4 < w)) return t("It came in under a second after a wait of {wait}: it may have been written during the wait and let go at once, so how fast it was written isn't seen", { wait: ledTook(from) });
|
|
2200
|
+
if (n * 1000 > 10000 * w || r.flow_ms > 0 && r.flow_ms * 4 < w || think && r.first_text_ms > 0 && n * (r.first_text_ms - r.ttft_ms) > 20 * r.reasoning * w) return t("It came in one burst at its end, not as it was written, so how fast it was written isn't seen");
|
|
2201
|
+
return little;
|
|
2202
|
+
};
|
|
2203
|
+
const ledFloorSpeed = (r) => { const f = ledFloor_(r); return f ? f.n / (f.w / 1000) : 0; };
|
|
2204
|
+
|
|
2205
|
+
// the table as wide as the window, when leaving out what the row's details
|
|
2206
|
+
// say anyway makes it so: else it scrolls sideways (#799)
|
|
2207
|
+
function ledFit() {
|
|
2208
|
+
const wrap = $("#ledWrap"), table = wrap.querySelector("table.led");
|
|
2209
|
+
let hint = $("#ledTight");
|
|
2210
|
+
if (!table) { if (hint) hint.hidden = true; return; }
|
|
2211
|
+
table.classList.remove("tight", "tight2", "tighter");
|
|
2212
|
+
const over = () => wrap.scrollWidth > wrap.clientWidth + 1;
|
|
2213
|
+
const tight = over();
|
|
2214
|
+
table.classList.toggle("tight", tight);
|
|
2215
|
+
// still too wide (#860: a Windows window at 175%, its wider fonts): the
|
|
2216
|
+
// price reference line goes too, in the cost's tooltip and the details
|
|
2217
|
+
// as well, names are cut shorter, whole in their tooltips, and headers
|
|
2218
|
+
// wrap. Only when that is still too wide does the first token's column
|
|
2219
|
+
// go (#2522), its value under the duration: a column is the larger loss,
|
|
2220
|
+
// and two Windows windows a little apart in width showed it on one and
|
|
2221
|
+
// not the other (unscientific9 on Discord)
|
|
2222
|
+
if (tight && over()) table.classList.add("tighter");
|
|
2223
|
+
if (tight && over()) table.classList.add("tight2");
|
|
2224
|
+
if (!hint) {
|
|
2225
|
+
hint = el("span", "led-tight");
|
|
2226
|
+
hint.id = "ledTight";
|
|
2227
|
+
$("#ledSum").after(hint);
|
|
2228
|
+
}
|
|
2229
|
+
// the first token's column, left out, reads under each duration
|
|
2230
|
+
hint.textContent = hint.title = t("Sent, Effort and the cache are in each row's details");
|
|
2231
|
+
hint.hidden = !tight;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// ledProviderMore is the menu beside the Provider filter while a deleted
|
|
2235
|
+
// provider is picked in it (CyberCaius on Discord): its requests merged
|
|
2236
|
+
// into a provider there is now, or shown by a name of the reader's. With
|
|
2237
|
+
// a provider picked that has deleted ones merged in, it parts each again.
|
|
2238
|
+
// Nothing in the log is rewritten (POST /api/usage/deleted), and a merged
|
|
2239
|
+
// request costs what it did.
|
|
2240
|
+
function ledProviderMore(p) {
|
|
2241
|
+
const b = $("#ledProviderMore");
|
|
2242
|
+
const merged = p?.merged || [];
|
|
2243
|
+
b.hidden = !p || (!p.deleted && !merged.length);
|
|
2244
|
+
if (b.hidden) return;
|
|
2245
|
+
b.replaceChildren(svg(DOTS, 14, 2));
|
|
2246
|
+
const tip = t(p.deleted ? "Merge or rename this deleted provider" : "Providers merged into this one");
|
|
2247
|
+
b.title = tip;
|
|
2248
|
+
b.setAttribute("aria-label", tip);
|
|
2249
|
+
const reload = () => { ledOffset = 0; return loadLedger(); };
|
|
2250
|
+
const send = (body) => api("usage/deleted", Object.assign({ id: p.id }, body));
|
|
2251
|
+
b.onclick = (e) => {
|
|
2252
|
+
e.stopPropagation();
|
|
2253
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
2254
|
+
if (!p.deleted) {
|
|
2255
|
+
openProtoMenu(b, merged.map((m) => ({ v: m.id, name: t("Separate {name}", { name: m.name }), literalName: true, note: "List its requests on their own again" })), "", async (id) => {
|
|
2256
|
+
const m = merged.find((x) => x.id === id);
|
|
2257
|
+
try {
|
|
2258
|
+
await api("usage/deleted", { id, into: "" });
|
|
2259
|
+
ledProvider = id;
|
|
2260
|
+
ledOwn("provider");
|
|
2261
|
+
await reload();
|
|
2262
|
+
status(t("{name}'s requests are listed on their own again", { name: m.name }));
|
|
2263
|
+
} catch (err) { status(err.message, "err"); }
|
|
2264
|
+
}, "Merged providers", "sess-menu", "right");
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
openProtoMenu(b, [
|
|
2268
|
+
{ v: "merge", name: "Merge into…", note: "List its requests under a provider you have" },
|
|
2269
|
+
{ v: "rename", name: "Rename…", note: "The name this page shows it by" },
|
|
2270
|
+
], "", (v) => (v === "merge" ? mergeInto() : renameDeleted()), "Deleted provider", "sess-menu", "right");
|
|
2271
|
+
};
|
|
2272
|
+
// a provider there is now, picked from a menu of them
|
|
2273
|
+
const mergeInto = async () => {
|
|
2274
|
+
let into;
|
|
2275
|
+
try { into = await api("usage/deleted/into"); } catch (err) { return status(err.message, "err"); }
|
|
2276
|
+
const opts = into.map((x) => ({ v: x.id, name: x.name, literalName: true, note: x.name === x.id ? "" : x.id }));
|
|
2277
|
+
if (!opts.length) return status(t("No provider to merge into"), "err");
|
|
2278
|
+
openProtoMenu(b, opts, "", async (id) => {
|
|
2279
|
+
const to = opts.find((o) => o.v === id);
|
|
2280
|
+
try {
|
|
2281
|
+
await send({ into: id });
|
|
2282
|
+
ledProvider = id;
|
|
2283
|
+
ledOwn("provider");
|
|
2284
|
+
await reload();
|
|
2285
|
+
status(t("{from}'s requests are listed under {to} now", { from: p.name, to: to.name }));
|
|
2286
|
+
} catch (err) { status(err.message, "err"); }
|
|
2287
|
+
}, "Merge into", "sess-menu", "right", false, opts.length > 8 ? "Search providers…" : "");
|
|
2288
|
+
};
|
|
2289
|
+
// the filter's own button takes the name, as a key's row does
|
|
2290
|
+
const renameDeleted = () => {
|
|
2291
|
+
const pick = $("#ledProvider");
|
|
2292
|
+
const i = input(p.name, t("Name"));
|
|
2293
|
+
i.className = "rename-in led-rename";
|
|
2294
|
+
i.setAttribute("aria-label", t("Rename"));
|
|
2295
|
+
let over = false;
|
|
2296
|
+
const done = async (save) => {
|
|
2297
|
+
if (over) return;
|
|
2298
|
+
over = true;
|
|
2299
|
+
i.remove();
|
|
2300
|
+
pick.hidden = false;
|
|
2301
|
+
b.hidden = false;
|
|
2302
|
+
if (!save || i.value.trim() === p.name) return;
|
|
2303
|
+
try {
|
|
2304
|
+
await send({ name: i.value });
|
|
2305
|
+
await reload();
|
|
2306
|
+
} catch (err) { status(err.message, "err"); }
|
|
2307
|
+
};
|
|
2308
|
+
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") done(true); else if (e.key === "Escape") done(false); };
|
|
2309
|
+
i.onblur = () => done(true);
|
|
2310
|
+
pick.hidden = true;
|
|
2311
|
+
b.hidden = true;
|
|
2312
|
+
pick.after(i);
|
|
2313
|
+
i.focus();
|
|
2314
|
+
i.select();
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function renderLedger() {
|
|
2319
|
+
const l = ledger;
|
|
2320
|
+
const callers = l.callerKeys || [];
|
|
2321
|
+
const providers = l.providers || [];
|
|
2322
|
+
const missingAgent = ledAgent && !l.agents.some((a) => a.id === ledAgent);
|
|
2323
|
+
const missingProvider = ledProvider && !providers.some((p) => p.id === ledProvider);
|
|
2324
|
+
const accounts = l.accounts || [];
|
|
2325
|
+
const missingAccount = ledAccount && !accounts.some((a) => a.id === ledAccount);
|
|
2326
|
+
// "none": the calls no gateway key made, a choice beside the keys when
|
|
2327
|
+
// there are such calls, so one key's are told from the rest (#2521)
|
|
2328
|
+
const noKey = callers.length > 0 && (ledCallerKey === "none" || (l.by?.callerKey || []).some((s) => s.id === "none"));
|
|
2329
|
+
const missingCaller = ledCallerKey && ledCallerKey !== "none" && !callers.some((k) => k.id === ledCallerKey);
|
|
2330
|
+
const computers = computerOpts(l.computers);
|
|
2331
|
+
const missingComputer = ledComputer && !computers.some((c) => c.v === ledComputer);
|
|
2332
|
+
if (missingAgent || missingProvider || missingAccount || missingCaller || missingComputer) {
|
|
2333
|
+
if (missingComputer) ledComputer = "";
|
|
2334
|
+
if (missingAccount) ledAccount = "";
|
|
2335
|
+
if (missingAgent) ledAgent = "";
|
|
2336
|
+
if (missingProvider) ledProvider = "";
|
|
2337
|
+
if (missingCaller) ledCallerKey = "";
|
|
2338
|
+
ledOffset = 0;
|
|
2339
|
+
// Reload the rows too: this response still belongs to the missing filters.
|
|
2340
|
+
loadLedger().catch((e) => status(e.message, "err"));
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
const view = $("#view-usage");
|
|
2344
|
+
view.classList.remove("loading");
|
|
2345
|
+
view.removeAttribute("aria-busy");
|
|
2346
|
+
renderPeriod();
|
|
2347
|
+
renderArchive();
|
|
2348
|
+
|
|
2349
|
+
costHead(fmtCost(l), l.unpriced ? unpricedCalls(l.unpriced) : "", unpricedCallsWhy(l.unpriced));
|
|
2350
|
+
|
|
2351
|
+
// the filters: choices seen in the period, and failures alone
|
|
2352
|
+
sessPick($("#ledPurpose"), "All purposes", ledPurpose, purposeOptions(l.purposes || [], ledPurpose), "Purpose", (v) => { ledPurpose = v; ledOwn("purpose"); ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); });
|
|
2353
|
+
$("#ledPurpose").title = t("Purpose");
|
|
2354
|
+
sessPick($("#ledComputer"), "All computers", ledComputer, computers, "Computer", (v) => { ledComputer = v; ledOwn("computer"); ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); });
|
|
2355
|
+
sessPick($("#ledAgent"), "All agents", ledAgent, l.agents.map((a) => ({ v: a.id, name: a.name, note: "" })), "Agent", (v) => { ledAgent = v; ledOwn("agent"); ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); });
|
|
2356
|
+
sessPick($("#ledProvider"), "All providers", ledProvider, providers.map((p) => ({ v: p.id, name: t(p.name), note: p.deleted ? t("Deleted {date}", { date: new Date(p.deleted).toLocaleDateString(intlLang(), { year: "numeric", month: "short", day: "numeric" }) }) : "" })), "Provider", (v) => { ledProvider = v; ledOwn("provider"); ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); });
|
|
2357
|
+
ledProviderMore(providers.find((p) => p.id === ledProvider));
|
|
2358
|
+
// a model beside the provider (OHHHHHH05 on X): the models the other filters keep, by tokens;
|
|
2359
|
+
// the page's totals, cells and chart are then that model's, gateway and session-log calls alike
|
|
2360
|
+
sessPick($("#ledModel"), "All models", ledModel, (l.by?.model || []).filter((s) => s.id).map((s) => ({ v: s.id, name: s.id, note: t("{n} tokens", { n: fmtN(allTokens(s)) }) })), "Model", (v) => { ledModel = v; ledQuery = ""; $("#ledQ").value = ""; ledOwn("model"); ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); });
|
|
2361
|
+
sessPick($("#ledAccount"), "All accounts", ledAccount, accounts.map((a) => ({ v: a.id, name: a.name || a.id, note: [a.name && a.id, ...(a.providers || []).map((p) => t(p))].filter(Boolean).join(" · ") })), "Account", (v) => { ledAccount = v; ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); });
|
|
2362
|
+
sessPick($("#ledKey"), "All gateway keys", ledCallerKey, callers.map((k) => ({
|
|
2363
|
+
v: k.id, name: k.name, note: "",
|
|
2364
|
+
})).concat(noKey ? [{ v: "none", name: t("No gateway key"), note: "" }] : []), "Gateway keys", (v) => { ledCallerKey = v; ledOwn("callerKey"); ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); });
|
|
2365
|
+
const seg = $("#ledStatus");
|
|
2366
|
+
seg.replaceChildren();
|
|
2367
|
+
// every request, those the agent got an error for, and those answered
|
|
2368
|
+
// after a try that failed first
|
|
2369
|
+
const shown = ledStatusOf();
|
|
2370
|
+
for (const [on, name] of [["", "All"], ["failed", "Failed"], ["retried", "Retried"]]) {
|
|
2371
|
+
const b = el("button", "opt" + (on === shown ? " on" : ""), t(name));
|
|
2372
|
+
if (on === "retried") b.title = t("Show only the requests answered on a retry");
|
|
2373
|
+
b.onclick = () => {
|
|
2374
|
+
if (on === ledStatusOf()) return;
|
|
2375
|
+
for (const x of seg.querySelectorAll(".opt")) x.classList.toggle("on", x === b);
|
|
2376
|
+
slide(seg, "ledStatus");
|
|
2377
|
+
ledShowStatus(on);
|
|
2378
|
+
};
|
|
2379
|
+
seg.append(b);
|
|
2380
|
+
}
|
|
2381
|
+
slide(seg, "ledStatus");
|
|
2382
|
+
$("#ledExport").disabled = !l.total;
|
|
2383
|
+
|
|
2384
|
+
// each count short, and exact in the line's tooltip
|
|
2385
|
+
const sum = [], exact = [];
|
|
2386
|
+
const say = (k, n) => { sum.push(t(k, { n: fmtN(n || 0) })); exact.push(t(k, { n: ledNum(n) })); };
|
|
2387
|
+
say(l.total === 1 ? "{n} request" : "{n} requests", l.total);
|
|
2388
|
+
if (l.total) {
|
|
2389
|
+
say("{n} in", l.input); say("{n} out", l.output); say("{n} cache write", l.cache_write); say("{n} cache read", l.cache_read);
|
|
2390
|
+
if (l.errors) say("{n} failed", l.errors);
|
|
2391
|
+
if (l.retried) say("{n} answered on a retry", l.retried);
|
|
2392
|
+
if (l.canceled) say("{n} canceled", l.canceled);
|
|
2393
|
+
}
|
|
2394
|
+
$("#ledSum").textContent = sum.join(" · ");
|
|
2395
|
+
$("#ledSum").title = sum.join() == exact.join() ? "" : exact.join(" · ");
|
|
2396
|
+
// the two sources and their total, in the same block of cells as the totals
|
|
2397
|
+
// above them: what magpie carried, what it only read about in the agents'
|
|
2398
|
+
// own session files, and the two together. Each is a filter: picking one
|
|
2399
|
+
// lists only its requests, and the total is the way back. Its own numbers
|
|
2400
|
+
// are counted with the pick cleared (usage.RequestPage), so all three stay
|
|
2401
|
+
// switchable whichever is on.
|
|
2402
|
+
const via = $("#ledVia");
|
|
2403
|
+
const thru = l.through || { calls: 0 }, dir = l.direct || { calls: 0 }, rep = l.reported || { calls: 0 };
|
|
2404
|
+
if (thru.calls + dir.calls + rep.calls > 0) {
|
|
2405
|
+
// the total is the two sources added up, not the page's own totals: those
|
|
2406
|
+
// are the rows the filter keeps, so with one source picked the total cell
|
|
2407
|
+
// would read that one's figures under the whole period's count
|
|
2408
|
+
const all = {};
|
|
2409
|
+
for (const k of ["calls", "errors", "input", "output", "cache_read", "cache_write", "cache_unknown", "cache_unknown_calls", "cost", "timed", "ttft_ms", "decode_ms", "decode_out"]) {
|
|
2410
|
+
all[k] = (thru[k] || 0) + (dir[k] || 0) + (rep[k] || 0);
|
|
2411
|
+
}
|
|
2412
|
+
const tile = (b, label, calls, tot, pick) => {
|
|
2413
|
+
const on = ledVia === pick;
|
|
2414
|
+
b.className = "blk" + (on ? " on" : "");
|
|
2415
|
+
b.type = "button";
|
|
2416
|
+
b.setAttribute("aria-pressed", on ? "true" : "false");
|
|
2417
|
+
b.title = pick ? t("Show only these requests") : t("Show every request again");
|
|
2418
|
+
const v = el("span", "v", fmtN(calls));
|
|
2419
|
+
if (exactN(calls)) v.title = exactN(calls);
|
|
2420
|
+
// the unit, so a bare number isn't read as the tokens below it
|
|
2421
|
+
v.append(el("em", "", t(calls === 1 ? "request" : "requests")));
|
|
2422
|
+
b.replaceChildren(el("span", "k", t(label)), v,
|
|
2423
|
+
el("span", "sub", t("{n} tokens", { n: fmtN(allTokens(tot)) })));
|
|
2424
|
+
b.onclick = () => {
|
|
2425
|
+
const next = ledVia === pick ? "" : pick;
|
|
2426
|
+
if (next === ledVia) return;
|
|
2427
|
+
ledVia = next;
|
|
2428
|
+
ledOwn("via");
|
|
2429
|
+
ledOffset = 0;
|
|
2430
|
+
loadLedger().catch((e) => status(e.message, "err"));
|
|
2431
|
+
};
|
|
2432
|
+
return b;
|
|
2433
|
+
};
|
|
2434
|
+
// the calls apps reported themselves (#1874) are a fourth cell, once
|
|
2435
|
+
// there are some, or while they are the ones picked
|
|
2436
|
+
const n = rep.calls > 0 || ledVia === "reported" ? 4 : 3;
|
|
2437
|
+
// the cells are the same elements from one redraw to the next, so
|
|
2438
|
+
// one being pressed or focused is never swapped out from under a reader
|
|
2439
|
+
let cells = [...via.children];
|
|
2440
|
+
if (cells.length !== n) {
|
|
2441
|
+
cells = cells.slice(0, n);
|
|
2442
|
+
while (cells.length < n) cells.push(el("button"));
|
|
2443
|
+
via.replaceChildren(...cells);
|
|
2444
|
+
}
|
|
2445
|
+
via.style.setProperty("--vias", String(n));
|
|
2446
|
+
tile(cells[0], "all requests", thru.calls + dir.calls + rep.calls, all, "");
|
|
2447
|
+
tile(cells[1], "through magpie", thru.calls, thru, "through");
|
|
2448
|
+
tile(cells[2], "not through magpie", dir.calls, dir, "direct");
|
|
2449
|
+
if (n === 4) tile(cells[3], "reported by apps", rep.calls, rep, "reported");
|
|
2450
|
+
via.hidden = false;
|
|
2451
|
+
} else {
|
|
2452
|
+
via.replaceChildren();
|
|
2453
|
+
via.hidden = true;
|
|
2454
|
+
}
|
|
2455
|
+
const routeFilter = $("#ledRoute");
|
|
2456
|
+
routeFilter.hidden = !ledRoute;
|
|
2457
|
+
$("#ledRouteLabel").textContent = ledRouteInfo ? t("Request: {what}", { what: new Date(ledRouteInfo.time).toLocaleString(intlLang()) + " · " + ledRouteInfo.model }) : "";
|
|
2458
|
+
$("#ledRouteClear").title = t("Clear filter");
|
|
2459
|
+
$("#ledRouteClear").setAttribute("aria-label", t("Clear filter"));
|
|
2460
|
+
$("#ledRouteClear").onclick = () => {
|
|
2461
|
+
ledRouteOff();
|
|
2462
|
+
loadLedger().catch((e) => status(e.message, "err"));
|
|
2463
|
+
};
|
|
2464
|
+
renderLedgerDash(l);
|
|
2465
|
+
drawLedHeat();
|
|
2466
|
+
|
|
2467
|
+
const wrap = $("#ledWrap");
|
|
2468
|
+
const pager = $("#ledPager");
|
|
2469
|
+
if (!l.total) {
|
|
2470
|
+
wrap.classList.add("none");
|
|
2471
|
+
const filtered = ledPurpose || ledDay || ledRoute || ledAgent || ledProvider || ledAccount || ledComputer || ledCallerKey || ledModel || ledVia || ledFailed || ledRetried || ledQuery.trim();
|
|
2472
|
+
const none = noCallsIn(period);
|
|
2473
|
+
wrap.replaceChildren(el("div", "led-none", filtered ? t("No requests match these filters.") : t(none)));
|
|
2474
|
+
ledFit();
|
|
2475
|
+
ledHScroll();
|
|
2476
|
+
pager.hidden = true;
|
|
2477
|
+
$("#ledNote").textContent = "";
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
wrap.classList.remove("none");
|
|
2481
|
+
const table = el("table", "led");
|
|
2482
|
+
const head = el("tr");
|
|
2483
|
+
const optCls = (opt) => opt === 2 ? " lc-opt2" : opt ? " lc-opt" : "";
|
|
2484
|
+
for (const [name, cls, opt, tip] of LED_COLS) {
|
|
2485
|
+
const th = el("th", (cls || "") + optCls(opt), t(name));
|
|
2486
|
+
if (tip) th.title = t(tip);
|
|
2487
|
+
head.append(th);
|
|
2488
|
+
}
|
|
2489
|
+
table.append(el("thead"), el("tbody"));
|
|
2490
|
+
table.tHead.append(head);
|
|
2491
|
+
for (const r of l.rows) {
|
|
2492
|
+
const bad = ledFailed_(r);
|
|
2493
|
+
const key = ledKey(r);
|
|
2494
|
+
// one its agent canceled is neither: muted, not the answered green
|
|
2495
|
+
const tr = el("tr", "led-row" + (bad ? " bad" : r.canceled ? " gone" : "") + (ledOpen.has(key) ? " open" : ""));
|
|
2496
|
+
tr.tabIndex = 0;
|
|
2497
|
+
tr.setAttribute("aria-expanded", ledOpen.has(key) ? "true" : "false");
|
|
2498
|
+
const td = (child, cls, title) => {
|
|
2499
|
+
const c = el("td", (cls || "") + optCls(LED_COLS[tr.childElementCount]?.[2]));
|
|
2500
|
+
if (typeof child === "string") c.textContent = child; else c.append(child);
|
|
2501
|
+
if (title) c.title = title;
|
|
2502
|
+
tr.append(c);
|
|
2503
|
+
return c;
|
|
2504
|
+
};
|
|
2505
|
+
const routed = r.route_id && !featuresOff.has("routing"); // the Routing page there to open it on
|
|
2506
|
+
const when = routed ? el("button", "text led-route-link", ledTime(r.t)) : ledTime(r.t);
|
|
2507
|
+
if (routed) {
|
|
2508
|
+
when.title = t("View routing");
|
|
2509
|
+
// the link opens the route, not the row's details as well
|
|
2510
|
+
when.onclick = (e) => { e.stopPropagation(); window.openRoute(r.route_id, r.t).catch((err) => status(err.message, "err")); };
|
|
2511
|
+
}
|
|
2512
|
+
td(when, "when", new Date(r.t).toLocaleString(intlLang()));
|
|
2513
|
+
const who = el("span", "who");
|
|
2514
|
+
// an agent on another computer, whose magpie passed the request on
|
|
2515
|
+
const name = r.agentName || r.agent;
|
|
2516
|
+
// one made on another computer, whose usage sync brought (#542)
|
|
2517
|
+
const at = r.computerName ? t("{agent} · on {computer}", { agent: name, computer: r.computerName }) : name;
|
|
2518
|
+
who.append(icon(r.icon || "generic"), el("span", "", [r.via ? t("{agent} · via {host}", { agent: at, host: r.via }) : at, r.callerKeyLabel || r.callerKeyName].filter(Boolean).join(" · ")));
|
|
2519
|
+
// a request magpie made by itself, apart from the agents' own (#1839);
|
|
2520
|
+
// one that tested the provider (the editor's Test, Detect APIs, a model
|
|
2521
|
+
// list) is counted nowhere, and says so (neiko on Discord)
|
|
2522
|
+
const probe = r.source === "probe";
|
|
2523
|
+
// an agent's call to a tool of magpie's MCP servers (ferock on Discord)
|
|
2524
|
+
const mcp = r.source === "mcp";
|
|
2525
|
+
// magpie's ask of a search API, for a search an agent's model made (cybercaius on Discord)
|
|
2526
|
+
const search = r.source === "search";
|
|
2527
|
+
if (r.agent === "magpie") {
|
|
2528
|
+
const own = ownBadge();
|
|
2529
|
+
if (probe) own.title = probeWhy();
|
|
2530
|
+
who.append(own);
|
|
2531
|
+
}
|
|
2532
|
+
td(who, "", [r.kind, r.session && t("session {id}", { id: r.session })].filter(Boolean).join(" · "));
|
|
2533
|
+
td(r.req || "—", "model" + (r.req ? "" : " faint"), r.req || (probe ? "" : t("Not kept for requests before this version")));
|
|
2534
|
+
const local = r.source === "log";
|
|
2535
|
+
// A session file names the provider it was set up to call, which is
|
|
2536
|
+
// where a call that went straight to a vendor went: nothing stood
|
|
2537
|
+
// between the agent and it. It is the file's own record and not a route
|
|
2538
|
+
// magpie saw, so the id the file carries is shown as it is — never
|
|
2539
|
+
// resolved to a provider's name, which would promote an unknown request
|
|
2540
|
+
// to an official provider — and the row keeps saying it is a local
|
|
2541
|
+
// session. A row whose file names none says only that.
|
|
2542
|
+
const where = local ? (r.session_account || r.session_provider || t("Local session"))
|
|
2543
|
+
: mcp ? [r.providerName && t(r.providerName), r.host].filter(Boolean).join(" · ") || "—"
|
|
2544
|
+
: search ? [r.providerName && t(r.providerName), r.host, r.providerKeyName].filter(Boolean).join(" · ") || "—"
|
|
2545
|
+
: t(r.providerName) + (r.host ? " · " + r.host : "");
|
|
2546
|
+
// the provider's logo before its name, as on the Routing page (Jiabubu
|
|
2547
|
+
// on Discord); a session file's record names none for sure
|
|
2548
|
+
const face = !local && providers.find((p) => p.id === r.provider);
|
|
2549
|
+
const line = el("div", "where-line");
|
|
2550
|
+
line.append(...(face ? [icon(face.icon || "generic")] : []), el("div", "where-name", where));
|
|
2551
|
+
const wc = td(line, "where", where);
|
|
2552
|
+
const badges = el("div", "source-badges");
|
|
2553
|
+
if (local && r.session_account && r.session_official_login) {
|
|
2554
|
+
const badge = el("span", "src official", t("OFFICIAL"));
|
|
2555
|
+
badge.title = t("Official login confirmed for this account by local login metadata. This does not establish the route or authentication used for this request.");
|
|
2556
|
+
badges.append(badge);
|
|
2557
|
+
}
|
|
2558
|
+
let access = "";
|
|
2559
|
+
if (!local && r.access === "subscription") access = "Subscription";
|
|
2560
|
+
else if (!local && r.access === "api") access = "API";
|
|
2561
|
+
if (access) {
|
|
2562
|
+
const badge = el("span", "src access access-" + r.access, t(access));
|
|
2563
|
+
badge.title = t(access);
|
|
2564
|
+
badges.append(badge);
|
|
2565
|
+
}
|
|
2566
|
+
const proto = ledProtoBadge(r);
|
|
2567
|
+
if (proto) badges.append(proto);
|
|
2568
|
+
// the badge says where the row came from, beside a name that is not
|
|
2569
|
+
// that: a row whose name already says it has nothing to add
|
|
2570
|
+
if (local && (r.session_account || r.session_provider)) {
|
|
2571
|
+
const k = el("span", "src local", t("Local session"));
|
|
2572
|
+
k.title = r.session_provider
|
|
2573
|
+
? t("Read from the agent's session file, which names the provider it was set up to call. The name shown is that record, not a route magpie saw.")
|
|
2574
|
+
: t("Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.");
|
|
2575
|
+
badges.append(k);
|
|
2576
|
+
}
|
|
2577
|
+
// a call an app reported itself (#1874): it went straight to its vendor
|
|
2578
|
+
if (r.source === "reported") {
|
|
2579
|
+
const k = el("span", "src reported", t("Reported"));
|
|
2580
|
+
k.title = t("Reported by the app that made it, through POST /v1/magpie/usage/requests: the call went straight to its vendor, not through magpie.");
|
|
2581
|
+
badges.append(k);
|
|
2582
|
+
}
|
|
2583
|
+
if (probe) {
|
|
2584
|
+
const k = el("span", "src probe", window.kindName ? window.kindName(r.kind) : r.kind);
|
|
2585
|
+
k.title = probeWhy();
|
|
2586
|
+
badges.append(k);
|
|
2587
|
+
}
|
|
2588
|
+
if (mcp) {
|
|
2589
|
+
const k = el("span", "src mcp", "MCP");
|
|
2590
|
+
k.title = t("{agent} called the tool {tool} of magpie's MCP server {server}. Its arguments are not kept, and it is not counted in any total: the model requests it made are rows of their own.", { agent: name, tool: r.req || "—", server: r.host || "magpie" });
|
|
2591
|
+
badges.append(k);
|
|
2592
|
+
}
|
|
2593
|
+
if (search) {
|
|
2594
|
+
// a search API's, or a plan's own search service's (r.provider)
|
|
2595
|
+
const k = el("span", "src search", t(r.provider ? "Web search" : "Search API"));
|
|
2596
|
+
k.title = t("magpie asked {api} to search the web for {agent}. The query is not kept, and it is not counted in any total.", { api: r.req || "—", agent: name });
|
|
2597
|
+
badges.append(k);
|
|
2598
|
+
}
|
|
2599
|
+
if (badges.childElementCount) wc.append(badges);
|
|
2600
|
+
// the model sent is what the gateway sent the vendor: a session file has none
|
|
2601
|
+
// The log names a model, but does not capture the outbound HTTP request.
|
|
2602
|
+
td(r.model || "—", "model " + (r.model ? "model-value" : "faint"), r.source === "log" && r.model ? t("Model recorded in the local session log; the outbound HTTP request was not captured.") : r.model);
|
|
2603
|
+
td(ledServed(r), "model");
|
|
2604
|
+
td(r.effort || "—", r.effort ? "" : "faint");
|
|
2605
|
+
td(fmtN(r.in || 0), "n", exactN(r.in));
|
|
2606
|
+
td(fmtN(r.out || 0), "n", tipOf(exactN(r.out), r.reasoning ? t("{n} reasoning, inside output", { n: ledNum(r.reasoning) }) : ""));
|
|
2607
|
+
// a vendor that counts no cache wrote and read none we know of, not 0 (#3464)
|
|
2608
|
+
if (r.cache_unknown) for (let i = 0; i < 2; i++) td("—", "n faint", t("The vendor doesn't count the cache"));
|
|
2609
|
+
else {
|
|
2610
|
+
td(fmtN(r.cache_write || 0), "n" + (r.cache_write ? "" : " faint"), tipOf(exactN(r.cache_write), cacheTTLs(r, ledNum).replace(/^ · /, "")));
|
|
2611
|
+
td(fmtN(r.cache_read || 0), "n" + (r.cache_read ? "" : " faint"), exactN(r.cache_read));
|
|
2612
|
+
}
|
|
2613
|
+
const hit = ledHitText(ledCacheHit(r));
|
|
2614
|
+
td(hit || "—", "n cache-hit" + (hit ? "" : " faint"), r.cache_unknown ? t("The vendor doesn't count the cache") : t("Cache hit rate") + ": " + t("The share of the prompt read from the cache"));
|
|
2615
|
+
// what the reply said it cost in its plan's credits (WorkBuddy's
|
|
2616
|
+
// usage.credit, #2512), on its own row as on the ranking's line
|
|
2617
|
+
// (jiabubu on Discord): in place of the dash a plan's model has for a
|
|
2618
|
+
// price, or under the price; a reply that said none says nothing
|
|
2619
|
+
const credits = r.credits != null ? ledCredits({ credits: r.credits, credited: 1 }).textContent : "";
|
|
2620
|
+
const cost = td(r.priced ? "≈" + fmtCost({ cost: r.cost, unpriced: 0 }) : credits || "—", "n cost" + (r.priced || credits ? "" : " faint"), r.priced ? (r.pricing_model ? t("API price reference") + ": " + r.pricing_model : "") : credits ? t("What it cost in its plan's credits, as the reply said") : t("No known price for this model"));
|
|
2621
|
+
if (r.priced && r.pricing_model) {
|
|
2622
|
+
const reference = el("div", "price-reference", t("Price reference: {model}", { model: r.pricing_model }));
|
|
2623
|
+
reference.title = t("Model used for the API price estimate; this is not an observed model forwarding event.");
|
|
2624
|
+
cost.append(reference);
|
|
2625
|
+
}
|
|
2626
|
+
if (r.priced && credits) {
|
|
2627
|
+
const said = el("div", "row-credits", credits);
|
|
2628
|
+
said.title = t("What it cost in its plan's credits, as the reply said");
|
|
2629
|
+
cost.append(said);
|
|
2630
|
+
}
|
|
2631
|
+
// a session file has the times of its lines: a call took about from the line that
|
|
2632
|
+
// asked for it to its last; some have none, and are dashes, not 0 ms
|
|
2633
|
+
const timed = r.source === "log";
|
|
2634
|
+
const untimed = !Number.isFinite(r.ms) || r.ms <= 0;
|
|
2635
|
+
// the wait for its first token, in a column of its own to compare down
|
|
2636
|
+
// the list: the duration holds it and the writing after it (#2522).
|
|
2637
|
+
// One not streamed, or that failed before any, has none
|
|
2638
|
+
td(r.ttft_ms > 0 ? (timed ? "≈" : "") + ledTook(r.ttft_ms) : "—", "n ttft" + (r.ttft_ms > 0 ? "" : " faint"),
|
|
2639
|
+
r.ttft_ms > 0 ? [t("{n} ms", { n: ledNum(r.ttft_ms) }), timed ? t("About: told from the session file's times, a little more or less than it took") : ""].filter(Boolean).join(" · ") : "");
|
|
2640
|
+
const durationBand = untimed ? "" : r.ms <= 10000 ? "fast" : r.ms <= 30000 ? "slow" : "long";
|
|
2641
|
+
const dur = td(untimed ? "—" : (timed ? "≈" : "") + ledTook(r.ms), "n duration " + (untimed ? "faint" : "duration-" + durationBand),
|
|
2642
|
+
[!untimed ? t("{n} ms", { n: ledNum(r.ms) }) : "", !untimed ? t("Duration colors: ≤10 s green · 10–30 s amber · >30 s red") : "", timed && !untimed ? t("About: told from the session file's times, a little more or less than it took") : "", r.ttft_ms ? t("TTFT {ms}", { ms: ledTook(r.ttft_ms) }) : ""].filter(Boolean).join(" · "));
|
|
2643
|
+
// where a narrow window leaves the first token's column out, it reads
|
|
2644
|
+
// under the duration it is part of, small, rather than only in the
|
|
2645
|
+
// details (unscientific9 on Discord); a row with none has no line.
|
|
2646
|
+
// Drawn by app.css from the attribute, out of the cell's text
|
|
2647
|
+
if (r.ttft_ms > 0) dur.dataset.ttft = t("TTFT {ms}", { ms: (timed ? "≈" : "") + ledTook(r.ttft_ms) });
|
|
2648
|
+
// how fast it wrote once it began, as CC Switch's log has it (#860)
|
|
2649
|
+
const v = ledRowSpeed(r), floor = ledFloorSpeed(r), f = ledFloor_(r);
|
|
2650
|
+
const ttft = r.ttft_ms > 0 ? " · " + t("TTFT {ms}", { ms: ledTook(r.ttft_ms) }) : "";
|
|
2651
|
+
td(v ? t("{n} tok/s", { n: ledNum(Math.round(v)) }) : floor ? "≥" + t("{n} tok/s", { n: ledNum(Math.round(floor)) }) : "—", "n speed" + (v || floor ? "" : " faint"),
|
|
2652
|
+
v ? (r.reasoning > 0 && r.first_text_ms > 0 ? t("{n} answer tokens in {ms} after the first text, the {r} reasoning tokens before it left out", { n: ledNum(ledDecode(r).n), ms: ledTook(ledDecode(r).w), r: ledNum(r.reasoning) })
|
|
2653
|
+
: t("{n} output tokens in {ms} after the first", { n: ledNum(ledDecode(r).n), ms: ledTook(ledDecode(r).w) })) + ttft
|
|
2654
|
+
: floor && f.gateway && f.answer ? t("At least: {n} answer tokens in {ms} after the first token, the {r} reasoning tokens left out. No first text was recorded, so reasoning after the first token is counted too", { n: ledNum(f.n), ms: ledTook(f.w), r: ledNum(r.reasoning) }) + ttft
|
|
2655
|
+
: floor && f.gateway ? t("At least: {n} output tokens in {ms}, from the request to its last token, the wait for the first one counted too", { n: ledNum(r.out), ms: ledTook(r.ms) }) + ". " + ledNoSpeed_(r) + ttft
|
|
2656
|
+
: floor && f.answer ? t("At least: {n} answer tokens in {ms} after the first token, the {r} reasoning tokens left out. The session file has no first text, so reasoning after the first token is counted too", { n: ledNum(f.n), ms: ledTook(f.w), r: ledNum(r.reasoning) }) + ttft
|
|
2657
|
+
: floor && f.turn ? t("At least: {n} output tokens in {ms}, the whole turn. What came after the first token ({after}) is too little to time a speed by: under a second or under 100 tokens, so the wait for it is counted too", { n: ledNum(r.out), ms: ledTook(r.ms), after: ledTook(r.ms - r.ttft_ms) }) + ttft
|
|
2658
|
+
: floor ? t("At least: {n} output tokens in {ms}, from the line that asked for it to its last. The session file has no first token, so the wait for it is counted too", { n: ledNum(r.out), ms: ledTook(r.ms) })
|
|
2659
|
+
: ledNoSpeed_(r));
|
|
2660
|
+
const st = el("span", "st");
|
|
2661
|
+
// a status when the gateway logged the call; a session file has none,
|
|
2662
|
+
// and says only whether it went well
|
|
2663
|
+
let word = r.status ? String(r.status) : r.source === "log" || mcp || search ? (bad ? r.err_type || t("Failed") : t("Succeeded")) : "—";
|
|
2664
|
+
if (r.status && bad && r.err_type) word += " · " + r.err_type;
|
|
2665
|
+
if (r.canceled) word += " · " + t("canceled");
|
|
2666
|
+
if (r.handed_on) word += " · " + t("handed on");
|
|
2667
|
+
else if (r.tries > 1) word += " · " + t("{n} tries", { n: r.tries });
|
|
2668
|
+
st.append(el("i", "dot"), document.createTextNode(word));
|
|
2669
|
+
td(st, "", [r.handed_on && t("This try failed and the request was handed on to the next: the request is counted by how it ended, not by this try"),
|
|
2670
|
+
r.canceled && t("Canceled by the agent before an answer came: a reply stopped, or a message sent while it was coming. Not counted as failed."),
|
|
2671
|
+
bad ? (r.err || t("Failed: the agent was answered {status}", { status: r.status })) : ""].filter(Boolean).join("\n"));
|
|
2672
|
+
const detail = ledOpen.has(key) ? ledDetail(r, LED_COLS.length) : null;
|
|
2673
|
+
const toggle = () => {
|
|
2674
|
+
if (ledOpen.delete(key)) { tr.classList.remove("open"); tr.setAttribute("aria-expanded", "false"); tr.nextElementSibling?.classList.contains("led-detail") && tr.nextElementSibling.remove(); return; }
|
|
2675
|
+
ledOpen.add(key);
|
|
2676
|
+
tr.classList.add("open");
|
|
2677
|
+
tr.setAttribute("aria-expanded", "true");
|
|
2678
|
+
tr.after(ledDetail(r, LED_COLS.length));
|
|
2679
|
+
};
|
|
2680
|
+
// a click that ends a text selection is the reader copying, not asking
|
|
2681
|
+
tr.onclick = () => { if (!String(getSelection() || "")) toggle(); };
|
|
2682
|
+
tr.onkeydown = (e) => { if (e.target === tr && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); toggle(); } };
|
|
2683
|
+
table.tBodies[0].append(tr);
|
|
2684
|
+
if (detail) table.tBodies[0].append(detail);
|
|
2685
|
+
}
|
|
2686
|
+
wrap.replaceChildren(table);
|
|
2687
|
+
wrap.style.setProperty("--ledw", wrap.clientWidth + "px");
|
|
2688
|
+
ledFit();
|
|
2689
|
+
ledHScroll();
|
|
2690
|
+
ledGrow.disconnect();
|
|
2691
|
+
ledGrow.observe(table);
|
|
2692
|
+
|
|
2693
|
+
// a page at a time: the newest first
|
|
2694
|
+
pager.hidden = l.total <= LED_PAGE;
|
|
2695
|
+
pager.replaceChildren();
|
|
2696
|
+
if (!pager.hidden) {
|
|
2697
|
+
const prev = el("button", "text", t("Newer"));
|
|
2698
|
+
const next = el("button", "text", t("Older"));
|
|
2699
|
+
prev.disabled = l.offset <= 0;
|
|
2700
|
+
next.disabled = l.offset + l.rows.length >= l.total;
|
|
2701
|
+
prev.onclick = () => { ledOffset = Math.max(0, l.offset - LED_PAGE); loadLedger().catch((e) => status(e.message, "err")); };
|
|
2702
|
+
next.onclick = () => { ledOffset = l.offset + LED_PAGE; loadLedger().catch((e) => status(e.message, "err")); };
|
|
2703
|
+
pager.append(prev, el("span", "", t("{a}–{b} of {n}", { a: ledNum(l.offset + 1), b: ledNum(l.offset + l.rows.length), n: ledNum(l.total) })), next);
|
|
2704
|
+
}
|
|
2705
|
+
$("#ledNote").textContent = t("Each request's tokens as its provider reported them; the cost estimated using effective model prices, including custom prices. Export CSV saves every page.");
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
{
|
|
2709
|
+
let typing = 0;
|
|
2710
|
+
$("#ledQ").oninput = (e) => {
|
|
2711
|
+
ledQuery = e.target.value;
|
|
2712
|
+
ledModel = "";
|
|
2713
|
+
ledOwn("model");
|
|
2714
|
+
clearTimeout(typing);
|
|
2715
|
+
typing = setTimeout(() => { ledOffset = 0; loadLedger().catch((err) => status(err.message, "err")); }, 250);
|
|
2716
|
+
};
|
|
2717
|
+
$("#ledQ").onkeydown = (e) => {
|
|
2718
|
+
if (e.key === "Escape" && e.target.value) { e.stopPropagation(); e.target.value = ""; ledQuery = ""; ledModel = ""; ledOwn("model"); ledOffset = 0; loadLedger().catch((err) => status(err.message, "err")); }
|
|
2719
|
+
};
|
|
2720
|
+
$("#ledExport").onclick = async () => {
|
|
2721
|
+
const b = $("#ledExport");
|
|
2722
|
+
// in a browser the file comes to it; in the app it goes to Downloads
|
|
2723
|
+
if (web) {
|
|
2724
|
+
const a = el("a");
|
|
2725
|
+
a.href = "/api/usage/requests.csv?" + ledParams();
|
|
2726
|
+
a.download = "";
|
|
2727
|
+
a.click();
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
b.classList.add("busy");
|
|
2731
|
+
try {
|
|
2732
|
+
const r = await api("usage/requests/export?" + ledParams(), {});
|
|
2733
|
+
status(t(r.rows === 1 ? "Saved {n} request to {path}" : "Saved {n} requests to {path}", { n: ledNum(r.rows), path: r.path }), "ok");
|
|
2734
|
+
} catch (e) {
|
|
2735
|
+
status(e.message, "err");
|
|
2736
|
+
} finally {
|
|
2737
|
+
b.classList.remove("busy");
|
|
2738
|
+
}
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// ---------- sessions ----------
|
|
2743
|
+
//
|
|
2744
|
+
// The agents' own sessions, read from their session files: what each cost,
|
|
2745
|
+
// and the command that picks it up again. A segment of the Usage page.
|
|
2746
|
+
|
|
2747
|
+
// A session's row and details, as this list and the Sessions page's
|
|
2748
|
+
// (plugins/sessions/client.js) both draw them.
|
|
2749
|
+
|
|
2750
|
+
// sessModelNames lists models for a cost note, one an agent recorded no
|
|
2751
|
+
// name for told as such: joined bare, the note read "Not counted: , with
|
|
2752
|
+
// no known price" (tony, Discord)
|
|
2753
|
+
const sessModelNames = (models) => [...new Set(models.map((m) => m || t("a model with no name")))].join(", ");
|
|
2754
|
+
|
|
2755
|
+
// wslBadge marks a session an agent ran in a WSL distro, read through
|
|
2756
|
+
// \\wsl.localhost; its resume command runs it there through wsl.exe
|
|
2757
|
+
function wslBadge(s) {
|
|
2758
|
+
const b = el("span", "badge wsl", "WSL " + s.wsl);
|
|
2759
|
+
b.title = t("In WSL {distro}: resumed there through wsl.exe", { distro: s.wsl });
|
|
2760
|
+
return b;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// sessModelsText: a session's models, the first two and how many more, and
|
|
2764
|
+
// where magpie's gateway sent its calls, the most first: a routing group's
|
|
2765
|
+
// member and the reasoning it was asked for
|
|
2766
|
+
function sessModelsText(s) {
|
|
2767
|
+
const via = s.via?.length ? "→ " + [s.via[0].model, s.via[0].effort].filter(Boolean).join(" · ") + (s.via.length > 1 ? " +" + (s.via.length - 1) : "") : "";
|
|
2768
|
+
return sessModelNames(s.models.slice(0, 2).map((m) => m.model)) + (s.models.length > 2 ? " +" + (s.models.length - 2) : "") + (via ? " " + via : "");
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
// sessUsageDetail adds to a session's details what it spent by model, and
|
|
2772
|
+
// what the gateway sent its calls to, at what reasoning; line(label, value)
|
|
2773
|
+
// adds a line of the details
|
|
2774
|
+
function sessUsageDetail(d, s, line) {
|
|
2775
|
+
if (s.models?.length) {
|
|
2776
|
+
const m = el("div", "sess-models");
|
|
2777
|
+
for (const x of s.models) {
|
|
2778
|
+
m.append(el("span", "model", x.model || t("a model with no name")),
|
|
2779
|
+
el("span", "n", t("{a} in · {b} out", { a: fmtN(x.input), b: fmtN(x.output) }) + (x.cache_read ? " · " + t("{n} cached", { n: fmtN(x.cache_read) }) : "") + (x.cache_write ? " · " + t("{n} written", { n: fmtN(x.cache_write) }) : "")),
|
|
2780
|
+
el("span", "c" + (x.priced ? "" : " none"), x.priced ? "≈" + fmtCost({ cost: x.cost }) : "—"));
|
|
2781
|
+
}
|
|
2782
|
+
d.append(m);
|
|
2783
|
+
}
|
|
2784
|
+
(s.via || []).forEach((v, i) => line(i ? "" : t("Routed"), viaText(v) + " · " + t("{n} tokens", { n: fmtN(v.tokens) }) + (v.credited ? " · " + ledCredits(v).textContent : "")));
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// one place a session's calls went through magpie, in words
|
|
2788
|
+
function viaText(v) {
|
|
2789
|
+
return `${v.provider}/${v.model}` + (v.effort ? " · " + v.effort : "") + " · " + t("{n} calls", { n: v.calls });
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
const ledExpanded = new WeakSet();
|
|
2793
|
+
const LED_KINDS = { tool_use: "Tool call", tool_result: "Tool result", thinking: "Thinking", context: "Context", image: "Image" };
|
|
2794
|
+
const LED_ROLES = { user: "You", assistant: "Assistant", tool: "Tool" };
|
|
2795
|
+
|
|
2796
|
+
// one thing said: who said it, and its words in a box; long ones show a part and
|
|
2797
|
+
// unroll, and reasoning and what the agent put in itself are folded; model,
|
|
2798
|
+
// when given, is said muted after it (a session's reader names the model
|
|
2799
|
+
// of each reply, #2253)
|
|
2800
|
+
function ledSaid(p, model) {
|
|
2801
|
+
const head = el("div", "cx-h");
|
|
2802
|
+
head.append(el("span", "cx-role " + p.role, t(LED_KINDS[p.kind] || LED_ROLES[p.role] || p.role)));
|
|
2803
|
+
if (p.name) head.append(el("code", "cx-name", p.name));
|
|
2804
|
+
if (model) {
|
|
2805
|
+
const m = el("span", "cx-model", model);
|
|
2806
|
+
m.title = t("Model") + ": " + model;
|
|
2807
|
+
head.append(m);
|
|
2808
|
+
}
|
|
2809
|
+
const words = el("pre", "cx-t", p.text);
|
|
2810
|
+
if (p.cut) words.append(el("em", "cx-cut", "\n" + t("… {n} more characters not shown", { n: ledNum(p.cut) })));
|
|
2811
|
+
if (p.kind === "thinking" || p.kind === "context") {
|
|
2812
|
+
const d = el("details", "cx-part fold");
|
|
2813
|
+
const sum = el("summary");
|
|
2814
|
+
sum.append(head);
|
|
2815
|
+
d.append(sum, words);
|
|
2816
|
+
return d;
|
|
2817
|
+
}
|
|
2818
|
+
const part = el("div", "cx-part");
|
|
2819
|
+
part.append(head, words);
|
|
2820
|
+
if (p.text.length > 700 || p.text.split("\n").length > 9) {
|
|
2821
|
+
part.classList.toggle("clamp", !ledExpanded.has(p));
|
|
2822
|
+
const more = el("button", "text cx-more", t(ledExpanded.has(p) ? "Collapse content" : "Show full content"));
|
|
2823
|
+
more.type = "button";
|
|
2824
|
+
more.onclick = () => {
|
|
2825
|
+
const clamped = part.classList.toggle("clamp");
|
|
2826
|
+
if (clamped) ledExpanded.delete(p); else ledExpanded.add(p);
|
|
2827
|
+
more.textContent = t(clamped ? "Show full content" : "Collapse content");
|
|
2828
|
+
};
|
|
2829
|
+
part.append(more);
|
|
2830
|
+
}
|
|
2831
|
+
return part;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
const USAGE_TABS = [["usage", "Overview"], ["requests", "Requests"], ["sessions", "Sessions"], ["context", "Context"]];
|
|
2835
|
+
let usageTab = "usage";
|
|
2836
|
+
try { const k = localStorage.getItem("magpie.usageTab"); if (USAGE_TABS.some(([id]) => id === k)) usageTab = k; } catch {}
|
|
2837
|
+
let sessAgent = "all";
|
|
2838
|
+
let sessQuery = "";
|
|
2839
|
+
// The list is drawn in pages: every session is read (so the count beside it
|
|
2840
|
+
// and the list agree), but a filter click or a keystroke in Search draws
|
|
2841
|
+
// SESS_PAGE rows and no more, and "Show N more" appends the next page. A
|
|
2842
|
+
// history of thousands rebuilt whole on every keystroke took ~300ms a
|
|
2843
|
+
// keystroke on a fast Mac (yetone, #1019). sessShown resets to one page
|
|
2844
|
+
// whenever the filters change, so what is drawn never grows with the history.
|
|
2845
|
+
const SESS_PAGE = 200;
|
|
2846
|
+
let sessShown = SESS_PAGE;
|
|
2847
|
+
// What the last draw put in the list, for the note under it to say: drawn is
|
|
2848
|
+
// the rows drawn and total the rows the filters kept — the same list, so a
|
|
2849
|
+
// search that matches one session says "1 session", never "1 of 2600" (the
|
|
2850
|
+
// note is about the list, not about every session on the computer).
|
|
2851
|
+
let sessDrawn = null, sessTotal = null;
|
|
2852
|
+
// what the drawn list was filtered by, so a redraw from the same filters
|
|
2853
|
+
// keeps the pages opened and a changed one starts again at the first
|
|
2854
|
+
let sessListKey = null;
|
|
2855
|
+
// The totals and the chart are every session's, by day, over a range; the
|
|
2856
|
+
// list is the latest sessions within it. [id, name, days (0: all)]
|
|
2857
|
+
const SESS_RANGES = [["today", "Today", 1], ["7d", "7 days", 7], ["30d", "30 days", 30], ["90d", "90 days", 90], ["all", "All", 0]];
|
|
2858
|
+
let sessRange = "30d";
|
|
2859
|
+
// what the activity chart counts each day
|
|
2860
|
+
const SESS_METRICS = [["tokens", "Tokens"], ["output", "Output tokens"], ["messages", "Messages"], ["sessions", "Sessions"], ["cost", "Cost"], ["active", "Active"]];
|
|
2861
|
+
let sessMetric = "tokens";
|
|
2862
|
+
// what the top sessions are the top by
|
|
2863
|
+
const SESS_TOPS = [["tokens", "Tokens"], ["cost", "Cost"], ["active", "Active"]];
|
|
2864
|
+
let sessTopBy = "tokens";
|
|
2865
|
+
try {
|
|
2866
|
+
const r = localStorage.getItem("magpie.sessRange");
|
|
2867
|
+
if (SESS_RANGES.some(([id]) => id === r)) sessRange = r;
|
|
2868
|
+
const m = localStorage.getItem("magpie.sessMetric");
|
|
2869
|
+
if (SESS_METRICS.some(([id]) => id === m)) sessMetric = m;
|
|
2870
|
+
const b = localStorage.getItem("magpie.sessTop");
|
|
2871
|
+
if (SESS_TOPS.some(([id]) => id === b)) sessTopBy = b;
|
|
2872
|
+
} catch {}
|
|
2873
|
+
let sessStats = null; // { from, to, days: [{ date, usage, active }], agents } for sessRange
|
|
2874
|
+
let sessModel = ""; // "" for every model
|
|
2875
|
+
let sessFolder = ""; // "" for every folder
|
|
2876
|
+
// the sessions of the range summed up under the filters, by the server:
|
|
2877
|
+
// { count, median, p90, days, top: { tokens, cost, active } }, and the
|
|
2878
|
+
// query it answers
|
|
2879
|
+
let sessOver = null;
|
|
2880
|
+
let sessOverAt = "";
|
|
2881
|
+
let sessOverLoading = "";
|
|
2882
|
+
let sessTopOpen = ""; // the top session opened to its details
|
|
2883
|
+
let sessListOpen = ""; // with the Sessions page switched off, the listed session opened here to its details (#2311)
|
|
2884
|
+
const sessFull = new Map(); // a top session's key → the session read whole, or "…" while it is read
|
|
2885
|
+
|
|
2886
|
+
function renderUsageTab() {
|
|
2887
|
+
const seg = $("#usageTab");
|
|
2888
|
+
seg.replaceChildren();
|
|
2889
|
+
for (const [id, name] of USAGE_TABS) {
|
|
2890
|
+
const b = el("button", "opt" + (id === usageTab ? " on" : ""), t(name));
|
|
2891
|
+
b.onclick = () => {
|
|
2892
|
+
if (id === usageTab) return;
|
|
2893
|
+
if (usageTab === "requests") ledLeave();
|
|
2894
|
+
usageTab = id;
|
|
2895
|
+
try { localStorage.setItem("magpie.usageTab", id); } catch {}
|
|
2896
|
+
$("#usageCost").replaceChildren();
|
|
2897
|
+
// to the Overview is to the allowances (#1588)
|
|
2898
|
+
loadUsage(id === "usage").catch((e) => status(e.message, "err"));
|
|
2899
|
+
};
|
|
2900
|
+
seg.append(b);
|
|
2901
|
+
}
|
|
2902
|
+
slide(seg, "usageTab");
|
|
2903
|
+
const on = usageTab === "sessions";
|
|
2904
|
+
// the Context tab has its own range, in its pane
|
|
2905
|
+
$("#period").hidden = on || usageTab === "context";
|
|
2906
|
+
$("#sessRange").hidden = !on;
|
|
2907
|
+
$("#ctxRange").hidden = usageTab !== "context";
|
|
2908
|
+
$("#usagePane").hidden = usageTab !== "usage";
|
|
2909
|
+
$("#ledgerPane").hidden = usageTab !== "requests";
|
|
2910
|
+
$("#sessionsPane").hidden = !on;
|
|
2911
|
+
$("#contextPane").hidden = usageTab !== "context";
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
const sessDays = () => SESS_RANGES.find(([id]) => id === sessRange)[2];
|
|
2915
|
+
// two answers are the same: with the tags api read them under (readTags in
|
|
2916
|
+
// app.js), only when they are one object
|
|
2917
|
+
const sameRead = (a, b) => a === b || !(readTags.has(a) && readTags.has(b)) && JSON.stringify(a) === JSON.stringify(b);
|
|
2918
|
+
async function loadSessions() {
|
|
2919
|
+
const first = !sessions || !sessStats;
|
|
2920
|
+
if (first) renderSessionsLoading();
|
|
2921
|
+
const range = sessRange, q = sessOverQ();
|
|
2922
|
+
const stop = first ? sessWatchIndex() : () => {};
|
|
2923
|
+
let s, st, o;
|
|
2924
|
+
try {
|
|
2925
|
+
[s, st, o] = await Promise.all([api("sessions"), api("sessions/stats?days=" + sessDays()), sessOverview(q)]);
|
|
2926
|
+
} finally { stop(); }
|
|
2927
|
+
if (range !== sessRange) return; // another range was picked meanwhile; its load draws
|
|
2928
|
+
const fresh = q === sessOverQ(); // no filter changed meanwhile
|
|
2929
|
+
// an answer the same as the last is the object read then (api's sameReads)
|
|
2930
|
+
if (sessions && sessStats && sameRead(s, sessions) && sameRead(st, sessStats) &&
|
|
2931
|
+
(!fresh || JSON.stringify(o) === JSON.stringify(sessOver))) return;
|
|
2932
|
+
sessions = s;
|
|
2933
|
+
sessStats = st;
|
|
2934
|
+
if (fresh) { sessOver = o; sessOverAt = q; }
|
|
2935
|
+
if (view === "usage" && usageTab === "sessions") renderSessions();
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// sessOverview reads a query's overview, once while a read of it is under
|
|
2939
|
+
// way: the page's reload, a filter's and a range's each asked for their
|
|
2940
|
+
// own, and over a long history each read is a long one (#2278)
|
|
2941
|
+
const sessOverReads = new Map();
|
|
2942
|
+
function sessOverview(q) {
|
|
2943
|
+
let read = sessOverReads.get(q);
|
|
2944
|
+
if (!read) {
|
|
2945
|
+
read = api("sessions/overview?" + q).finally(() => sessOverReads.delete(q));
|
|
2946
|
+
sessOverReads.set(q, read);
|
|
2947
|
+
}
|
|
2948
|
+
return read;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
// the overview's query: the range and the filters
|
|
2952
|
+
const sessOverQ = () => new URLSearchParams({ days: sessDays(), agent: sessAgent === "all" ? "" : sessAgent, model: sessModel, cwd: sessFolder }).toString();
|
|
2953
|
+
// loadSessOverview reads the overview again when a filter changes, and draws
|
|
2954
|
+
// the page once it is in; one read at a time for a query
|
|
2955
|
+
function loadSessOverview() {
|
|
2956
|
+
const q = sessOverQ();
|
|
2957
|
+
if (sessOverLoading === q) return;
|
|
2958
|
+
sessOverLoading = q;
|
|
2959
|
+
const done = () => { if (sessOverLoading === q) sessOverLoading = ""; };
|
|
2960
|
+
sessOverview(q).then((o) => {
|
|
2961
|
+
done();
|
|
2962
|
+
if (q !== sessOverQ()) return;
|
|
2963
|
+
sessOver = o;
|
|
2964
|
+
sessOverAt = q;
|
|
2965
|
+
if (view === "usage" && usageTab === "sessions" && sessions && sessStats) renderSessions();
|
|
2966
|
+
}, (e) => { done(); status(e.message, "err"); });
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function renderSessionsLoading() {
|
|
2970
|
+
const view = $("#view-usage");
|
|
2971
|
+
view.classList.add("loading");
|
|
2972
|
+
view.setAttribute("aria-busy", "true");
|
|
2973
|
+
$("#usageCost").replaceChildren(el("span", "skeleton sk-cost"));
|
|
2974
|
+
renderSessRange(true);
|
|
2975
|
+
$("#sessAgent").hidden = $("#sessModel").hidden = $("#sessFolder").hidden = true;
|
|
2976
|
+
$("#sessChart").hidden = true;
|
|
2977
|
+
$("#sessGrid").hidden = true;
|
|
2978
|
+
$("#sessListHead").hidden = true;
|
|
2979
|
+
const stats = $("#sessStats");
|
|
2980
|
+
stats.classList.remove("empty");
|
|
2981
|
+
stats.classList.add("six");
|
|
2982
|
+
stats.replaceChildren();
|
|
2983
|
+
for (let i = 0; i < 6; i++) {
|
|
2984
|
+
const tile = el("div", "kpi loading-kpi");
|
|
2985
|
+
tile.append(el("span", "skeleton sk-number"), el("span", "skeleton sk-label"));
|
|
2986
|
+
stats.append(tile);
|
|
2987
|
+
}
|
|
2988
|
+
const list = $("#sessList");
|
|
2989
|
+
list.hidden = false;
|
|
2990
|
+
list.replaceChildren();
|
|
2991
|
+
for (let i = 0; i < 4; i++) {
|
|
2992
|
+
const r = el("div", "row sess-sk");
|
|
2993
|
+
r.append(el("span", "skeleton sk-title"), el("span", "skeleton sk-line short"));
|
|
2994
|
+
list.append(r);
|
|
2995
|
+
}
|
|
2996
|
+
$("#sessNote").textContent = t("Reading the agents' session files…");
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
const SESS_INDEX_SHOW = 700;
|
|
3000
|
+
|
|
3001
|
+
// sessWatchIndex asks how far the reading of the session files has got
|
|
3002
|
+
// while the page waits on it; a read of the kept index, over in a moment,
|
|
3003
|
+
// shows the skeleton alone, and one that takes a while the indexing show.
|
|
3004
|
+
// It returns the stop.
|
|
3005
|
+
function sessWatchIndex() {
|
|
3006
|
+
let on = true, timer = 0, shown = false, since = 0;
|
|
3007
|
+
const tick = async () => {
|
|
3008
|
+
if (!on) return;
|
|
3009
|
+
let p = null;
|
|
3010
|
+
try { p = await api("sessions/progress"); } catch {}
|
|
3011
|
+
if (!on) return;
|
|
3012
|
+
// only a real indexing run is shown: a read of the kept index, however
|
|
3013
|
+
// long, keeps the skeleton, and so does the catch-up read of the few
|
|
3014
|
+
// files the agents wrote to since (every reload has some, while an agent
|
|
3015
|
+
// is at work), over well before SESS_INDEX_SHOW; the show would play
|
|
3016
|
+
// again from nought for it
|
|
3017
|
+
since = p?.indexing ? since || Date.now() : 0;
|
|
3018
|
+
const long = since && Date.now() - since >= SESS_INDEX_SHOW;
|
|
3019
|
+
if (p && (long || shown) && view === "usage" && usageTab === "sessions") {
|
|
3020
|
+
const stats = $("#sessStats");
|
|
3021
|
+
let hero = stats.querySelector(".sess-indexing");
|
|
3022
|
+
if (!hero) {
|
|
3023
|
+
hero = sessIndexHero();
|
|
3024
|
+
stats.replaceChildren(hero);
|
|
3025
|
+
}
|
|
3026
|
+
// an earlier watch's stop may have taken the class off the hero kept
|
|
3027
|
+
stats.classList.add("indexing");
|
|
3028
|
+
shown = true;
|
|
3029
|
+
hero.update(p);
|
|
3030
|
+
}
|
|
3031
|
+
timer = setTimeout(tick, 350);
|
|
3032
|
+
};
|
|
3033
|
+
timer = setTimeout(tick, 150);
|
|
3034
|
+
return () => {
|
|
3035
|
+
on = false;
|
|
3036
|
+
clearTimeout(timer);
|
|
3037
|
+
if (shown) $("#sessStats").classList.remove("indexing");
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// sessIndexHero is the show put on while the session files are read: the
|
|
3042
|
+
// words and how far it has got on the left, on the right a field of dots, the
|
|
3043
|
+
// heatmap's cells, that light up as the files land, a wave running through
|
|
3044
|
+
// them; a hairline along the foot. Still with reduced motion.
|
|
3045
|
+
const SESS_TIPS = [
|
|
3046
|
+
"Read once, kept: after this the page opens from the index in a blink",
|
|
3047
|
+
"Only what changed is read again, from where it was left",
|
|
3048
|
+
"Every session is read on this computer; nothing leaves it",
|
|
3049
|
+
"The biggest files go first, so none is left running on alone",
|
|
3050
|
+
];
|
|
3051
|
+
function sessIndexHero() {
|
|
3052
|
+
const hero = el("div", "sess-indexing");
|
|
3053
|
+
hero.setAttribute("role", "status");
|
|
3054
|
+
const field = el("div", "si-field");
|
|
3055
|
+
const cv = el("canvas", "si-canvas");
|
|
3056
|
+
cv.setAttribute("aria-hidden", "true");
|
|
3057
|
+
field.append(cv);
|
|
3058
|
+
const words = el("div", "si-words");
|
|
3059
|
+
const head = el("div", "si-head");
|
|
3060
|
+
head.append(el("i", "si-spin"), el("span", "si-title", t("Indexing your sessions")));
|
|
3061
|
+
const pct = el("div", "si-pct");
|
|
3062
|
+
const num = el("b", "", "");
|
|
3063
|
+
pct.append(num, el("span", "", "%"));
|
|
3064
|
+
const line = el("div", "si-line", t("Looking for session files…"));
|
|
3065
|
+
const tip = el("div", "si-tip", t(SESS_TIPS[0]));
|
|
3066
|
+
words.append(head, pct, line, tip);
|
|
3067
|
+
const bar = el("div", "si-bar");
|
|
3068
|
+
const fill = el("i", "");
|
|
3069
|
+
bar.append(fill);
|
|
3070
|
+
hero.append(words, field, bar);
|
|
3071
|
+
|
|
3072
|
+
const reduced = calm();
|
|
3073
|
+
let frac = 0, shown = 0, known = false, tipAt = Date.now(), tipI = 0, rate = 0, last = null;
|
|
3074
|
+
let done = false;
|
|
3075
|
+
hero.update = (p) => {
|
|
3076
|
+
if (!(p.indexing && p.bytes > 0) && known) {
|
|
3077
|
+
// the run is over while the page is put together: held full, never
|
|
3078
|
+
// back to the unknown state or a second run from nought
|
|
3079
|
+
if (!done) {
|
|
3080
|
+
done = true;
|
|
3081
|
+
frac = 1;
|
|
3082
|
+
fill.style.width = "100%";
|
|
3083
|
+
hero.classList.add("done");
|
|
3084
|
+
line.textContent = t("Indexed · putting the page together…");
|
|
3085
|
+
}
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
if (done) return;
|
|
3089
|
+
known = !!p.indexing && p.bytes > 0;
|
|
3090
|
+
const f = known ? Math.min(1, p.read / p.bytes) : 0;
|
|
3091
|
+
// a steady speed, for the time left
|
|
3092
|
+
if (known && last && p.read > last.read) {
|
|
3093
|
+
const r = (p.read - last.read) / ((Date.now() - last.at) / 1000);
|
|
3094
|
+
rate = rate ? rate * 0.8 + r * 0.2 : r;
|
|
3095
|
+
}
|
|
3096
|
+
if (known) last = { read: p.read, at: Date.now() };
|
|
3097
|
+
frac = Math.max(frac, f);
|
|
3098
|
+
hero.classList.toggle("known", known);
|
|
3099
|
+
fill.style.width = known ? (frac * 100).toFixed(1) + "%" : "";
|
|
3100
|
+
if (known) {
|
|
3101
|
+
line.textContent = t("{done} of {files} files · {read} of {bytes}", { done: fmtN(p.done), files: fmtN(p.files), read: fmtBytes(p.read), bytes: fmtBytes(p.bytes) });
|
|
3102
|
+
const left = rate > 0 ? (p.bytes - p.read) / rate : 0;
|
|
3103
|
+
if (left > 3) line.textContent += " · " + t("about {t} left", { t: left < 60 ? t("{n}s", { n: Math.ceil(left) }) : t("{n} min", { n: Math.ceil(left / 60) }) });
|
|
3104
|
+
} else line.textContent = t("Reading the kept index…");
|
|
3105
|
+
if (Date.now() - tipAt > 4500) {
|
|
3106
|
+
tipAt = Date.now();
|
|
3107
|
+
tipI = (tipI + 1) % SESS_TIPS.length;
|
|
3108
|
+
tip.classList.remove("in");
|
|
3109
|
+
void tip.offsetWidth;
|
|
3110
|
+
tip.textContent = t(SESS_TIPS[tipI]);
|
|
3111
|
+
tip.classList.add("in");
|
|
3112
|
+
}
|
|
3113
|
+
if (reduced) paint(0);
|
|
3114
|
+
};
|
|
3115
|
+
|
|
3116
|
+
// the drawing: each dot has its turn, mostly left to right, a little
|
|
3117
|
+
// scattered, so the edge of what is read is ragged like rain landing
|
|
3118
|
+
const ctx = cv.getContext("2d");
|
|
3119
|
+
const GAP = 14;
|
|
3120
|
+
let W = 0, H = 0, dpr = 1, dots = [], ink = "#1c1c21", accent = "#4f46e5", frame = 0;
|
|
3121
|
+
const colours = () => {
|
|
3122
|
+
const c = getComputedStyle(hero);
|
|
3123
|
+
ink = c.getPropertyValue("--fg").trim() || ink;
|
|
3124
|
+
accent = c.getPropertyValue("--accent").trim() || accent;
|
|
3125
|
+
};
|
|
3126
|
+
const size = () => {
|
|
3127
|
+
dpr = devicePixelRatio || 1;
|
|
3128
|
+
const r = cv.getBoundingClientRect();
|
|
3129
|
+
if (!r.width || !r.height) return false;
|
|
3130
|
+
if (r.width !== W || r.height !== H) {
|
|
3131
|
+
W = r.width; H = r.height;
|
|
3132
|
+
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
|
|
3133
|
+
const cols = Math.max(1, Math.floor((W - 4) / GAP)), rows = Math.max(1, Math.floor((H - 4) / GAP));
|
|
3134
|
+
const ox = (W - (cols - 1) * GAP) / 2, oy = (H - (rows - 1) * GAP) / 2;
|
|
3135
|
+
dots = [];
|
|
3136
|
+
for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
|
|
3137
|
+
const seed = Math.random();
|
|
3138
|
+
dots.push({ x: ox + i * GAP, y: oy + j * GAP, nx: cols > 1 ? i / (cols - 1) : 0, ny: rows > 1 ? j / (rows - 1) : 0, seed, turn: (cols > 1 ? i / (cols - 1) : 0) * 0.86 + seed * 0.14 });
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
return true;
|
|
3142
|
+
};
|
|
3143
|
+
function paint(now) {
|
|
3144
|
+
if (!size()) return;
|
|
3145
|
+
if (frame++ % 30 === 0) colours();
|
|
3146
|
+
const T = now / 1000;
|
|
3147
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
3148
|
+
ctx.clearRect(0, 0, W, H);
|
|
3149
|
+
shown += (frac - shown) * (reduced ? 1 : 0.06);
|
|
3150
|
+
// unknown: a band of light sweeps across and on again
|
|
3151
|
+
const scan = ((T * 0.32) % 1.4) - 0.2;
|
|
3152
|
+
// a faint glow where the reading is
|
|
3153
|
+
const gx = (known ? shown * 0.86 + 0.07 : scan) * W;
|
|
3154
|
+
const glow = ctx.createRadialGradient(gx, H / 2, 0, gx, H / 2, H * 0.9);
|
|
3155
|
+
glow.addColorStop(0, hexA(accent, reduced ? 0.06 : 0.1));
|
|
3156
|
+
glow.addColorStop(1, hexA(accent, 0));
|
|
3157
|
+
ctx.fillStyle = glow;
|
|
3158
|
+
ctx.fillRect(0, 0, W, H);
|
|
3159
|
+
for (const d of dots) {
|
|
3160
|
+
const wave = reduced ? 0.5 : 0.5 + 0.5 * Math.sin(d.nx * 7 + d.ny * 2.4 - T * 1.5 + d.seed * 0.8);
|
|
3161
|
+
let a = 0.08 + 0.05 * wave, r = 1.05 + 0.2 * wave, c = ink;
|
|
3162
|
+
if (known) {
|
|
3163
|
+
const since = shown - d.turn;
|
|
3164
|
+
if (since >= 0) {
|
|
3165
|
+
// read: resting in the accent, just landed brighter and bigger
|
|
3166
|
+
const fresh = Math.max(0, 1 - since / 0.07);
|
|
3167
|
+
c = accent;
|
|
3168
|
+
a = 0.4 + 0.32 * wave * (0.3 + d.seed) + 0.5 * fresh;
|
|
3169
|
+
r = 1.25 + 0.25 * wave + 1.1 * fresh * fresh;
|
|
3170
|
+
} else if (since > -0.035 && !reduced) {
|
|
3171
|
+
// about to land: a flicker
|
|
3172
|
+
const near = 1 + since / 0.035;
|
|
3173
|
+
if (Math.sin(T * 9 + d.seed * 40) > 0.3) { c = accent; a = 0.1 + 0.35 * near; }
|
|
3174
|
+
}
|
|
3175
|
+
} else if (!reduced) {
|
|
3176
|
+
const g = Math.exp(-(((d.nx - scan) / 0.07) ** 2));
|
|
3177
|
+
if (g > 0.02) { c = accent; a = Math.max(a, 0.9 * g * (0.55 + 0.45 * d.seed)); r += 1 * g; }
|
|
3178
|
+
}
|
|
3179
|
+
ctx.globalAlpha = Math.min(1, a);
|
|
3180
|
+
ctx.fillStyle = c;
|
|
3181
|
+
ctx.beginPath();
|
|
3182
|
+
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
|
|
3183
|
+
ctx.fill();
|
|
3184
|
+
}
|
|
3185
|
+
ctx.globalAlpha = 1;
|
|
3186
|
+
num.textContent = known ? String(Math.floor(shown * 100)) : "";
|
|
3187
|
+
}
|
|
3188
|
+
// it runs until the page takes it away
|
|
3189
|
+
const loop = (now) => {
|
|
3190
|
+
if (!hero.isConnected) return;
|
|
3191
|
+
paint(now);
|
|
3192
|
+
requestAnimationFrame(loop);
|
|
3193
|
+
};
|
|
3194
|
+
if (!reduced) requestAnimationFrame(loop);
|
|
3195
|
+
else requestAnimationFrame(() => paint(0));
|
|
3196
|
+
return hero;
|
|
3197
|
+
}
|
|
3198
|
+
// hexA is a #rrggbb colour at an alpha
|
|
3199
|
+
function hexA(hex, a) {
|
|
3200
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
3201
|
+
if (!m) return `rgba(79,70,229,${a})`;
|
|
3202
|
+
const n = parseInt(m[1], 16);
|
|
3203
|
+
return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`;
|
|
3204
|
+
}
|
|
3205
|
+
const sessKey = (s) => s.agent + ":" + s.id;
|
|
3206
|
+
// counted the way the tokens tiles count, cache and all when the reader
|
|
3207
|
+
// asked for it (#2591); both lists' rows spend it (sessSpent)
|
|
3208
|
+
const sessTokens = (s) => usageTokens(s);
|
|
3209
|
+
// a session's cost: "—" when none of its models has a known price
|
|
3210
|
+
function sessCost(s) {
|
|
3211
|
+
if (!s.models.some((m) => m.priced && (m.input || m.output || m.cache_read || m.cache_write))) return "—";
|
|
3212
|
+
return "≈" + fmtCost({ cost: s.cost, unpriced: s.unpriced });
|
|
3213
|
+
}
|
|
3214
|
+
// the range picker, in the page's head where the Overview's period is
|
|
3215
|
+
function renderSessRange(loading) {
|
|
3216
|
+
const seg = $("#sessRange");
|
|
3217
|
+
seg.replaceChildren();
|
|
3218
|
+
for (const [id, name] of SESS_RANGES) {
|
|
3219
|
+
const b = el("button", "opt" + (id === sessRange ? " on" : ""), t(name));
|
|
3220
|
+
b.disabled = !!loading;
|
|
3221
|
+
b.onclick = () => {
|
|
3222
|
+
if (id === sessRange) return;
|
|
3223
|
+
sessRange = id;
|
|
3224
|
+
try { localStorage.setItem("magpie.sessRange", id); } catch {}
|
|
3225
|
+
sessStats = null;
|
|
3226
|
+
loadSessions().catch((e) => status(e.message, "err"));
|
|
3227
|
+
};
|
|
3228
|
+
seg.append(b);
|
|
3229
|
+
}
|
|
3230
|
+
slide(seg, "sessRange");
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
// a "YYYY-MM-DD" as a local date, and back
|
|
3234
|
+
const sessDate = (d) => { const [y, m, day] = d.split("-").map(Number); return new Date(y, m - 1, day); };
|
|
3235
|
+
const sessISO = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
3236
|
+
const sessDay = (d) => d.toLocaleDateString(intlLang(), { month: "short", day: "numeric" });
|
|
3237
|
+
// a length of time, as hours and minutes
|
|
3238
|
+
function fmtDur(sec) {
|
|
3239
|
+
const m = Math.round(sec / 60);
|
|
3240
|
+
if (!sec) return "0";
|
|
3241
|
+
if (m < 1) return t("<1m");
|
|
3242
|
+
if (m < 60) return t("{m}m", { m });
|
|
3243
|
+
return t("{h}h {m}m", { h: Math.floor(m / 60), m: m % 60 });
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
function renderSessions() {
|
|
3247
|
+
const view = $("#view-usage");
|
|
3248
|
+
view.classList.remove("loading");
|
|
3249
|
+
view.removeAttribute("aria-busy");
|
|
3250
|
+
renderSessRange();
|
|
3251
|
+
const all = sessions?.sessions || [];
|
|
3252
|
+
const st = sessStats || { from: "", to: "", days: [], agents: {} };
|
|
3253
|
+
const rows = st.days.flatMap((d) => d.usage.map((u) => ({ ...u, date: d.date })));
|
|
3254
|
+
const acts = st.days.flatMap((d) => d.active.map((a) => ({ ...a, date: d.date })));
|
|
3255
|
+
|
|
3256
|
+
// the agent filter is a menu, as the model and folder ones beside it: a
|
|
3257
|
+
// strip of a segment per agent ran past the window with a dozen of them
|
|
3258
|
+
// in range, and scrolled sideways to reach the last
|
|
3259
|
+
// a session is under the agent it ran in: s.app, when its files are
|
|
3260
|
+
// another's (Claude Desktop's Code tab's, in Claude Code's folder)
|
|
3261
|
+
const agents = [...new Map([...all.map((s) => [s.app || s.agent, s.name]), ...Object.entries(st.agents || {})]).entries()];
|
|
3262
|
+
if (sessAgent !== "all" && !agents.some(([id]) => id === sessAgent)) sessAgent = "all";
|
|
3263
|
+
|
|
3264
|
+
// the model and folder filters offer what the range has, under the other filters
|
|
3265
|
+
const byAgent = (x) => sessAgent === "all" || (x.app || x.agent) === sessAgent;
|
|
3266
|
+
const byModel = (x) => !sessModel || x.model === sessModel;
|
|
3267
|
+
const byFolder = (x) => !sessFolder || x.cwd === sessFolder;
|
|
3268
|
+
const spent = (list, key) => {
|
|
3269
|
+
const m = new Map();
|
|
3270
|
+
for (const r of list) if (r[key]) m.set(r[key], (m.get(r[key]) || 0) + usageTokens(r));
|
|
3271
|
+
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
|
3272
|
+
};
|
|
3273
|
+
const models = spent(rows.filter((r) => byAgent(r) && byFolder(r)), "model").map(([v, n]) => ({ v, name: v, note: t("{n} tokens", { n: fmtN(n) }) }));
|
|
3274
|
+
const folders = spent(rows.filter((r) => byAgent(r) && byModel(r)), "cwd").map(([v, n]) => ({ v, name: baseName(v), note: v + " · " + t("{n} tokens", { n: fmtN(n) }) }));
|
|
3275
|
+
const agentSpent = new Map(spent(rows.filter((r) => byModel(r) && byFolder(r)), "agent"));
|
|
3276
|
+
const agentOpts = agents.map(([v, name]) => ({ v, name: name || v, note: agentSpent.has(v) ? t("{n} tokens", { n: fmtN(agentSpent.get(v)) }) : "" }))
|
|
3277
|
+
.sort((a, b) => (agentSpent.get(b.v) || 0) - (agentSpent.get(a.v) || 0));
|
|
3278
|
+
sessPick($("#sessAgent"), "All agents", sessAgent === "all" ? "" : sessAgent, agentOpts, "Agent", (v) => { sessAgent = v || "all"; renderSessions(); });
|
|
3279
|
+
sessPick($("#sessModel"), "All models", sessModel, models, "Model", (v) => { sessModel = v; renderSessions(); });
|
|
3280
|
+
sessPick($("#sessFolder"), "All folders", sessFolder, folders, "Folder", (v) => { sessFolder = v; renderSessions(); });
|
|
3281
|
+
|
|
3282
|
+
// the list: the sessions at work in the range, under every filter. Which
|
|
3283
|
+
// ones were at work is the range's own answer (st.keys, the sessions
|
|
3284
|
+
// stats counted), not a guess from s.last: a session's file goes on being
|
|
3285
|
+
// written after its last call — a fork's seed, a setting — so one whose
|
|
3286
|
+
// last line falls in the range spent nothing in it, and it was listed but
|
|
3287
|
+
// not counted, the note beside the list saying more than the KPI above it.
|
|
3288
|
+
const inRange = st.keys ? new Set(st.keys) : null;
|
|
3289
|
+
const from = !inRange && sessDays() && st.from ? sessDate(st.from) : null;
|
|
3290
|
+
const q = sessQuery.trim().toLowerCase();
|
|
3291
|
+
const list = all.filter((s) => byAgent(s) && (!sessFolder || s.cwd === sessFolder) &&
|
|
3292
|
+
(!sessModel || s.models.some((m) => m.model === sessModel)) &&
|
|
3293
|
+
(inRange ? inRange.has(s.agent + ":" + s.id) : !from || new Date(s.last) >= from) &&
|
|
3294
|
+
(!q || [s.title, s.cwd, s.id, s.name, ...s.models.map((m) => m.model)].some((x) => (x || "").toLowerCase().includes(q))));
|
|
3295
|
+
|
|
3296
|
+
// the range's totals, of every session under the filters
|
|
3297
|
+
const used = rows.filter((r) => byAgent(r) && byModel(r) && byFolder(r));
|
|
3298
|
+
const tot = { input: 0, output: 0, cache_read: 0, cache_write: 0, cost: 0, unpriced: 0 };
|
|
3299
|
+
const unpriced = new Set();
|
|
3300
|
+
for (const r of used) {
|
|
3301
|
+
for (const k of ["input", "output", "cache_read", "cache_write", "cost"]) tot[k] += r[k];
|
|
3302
|
+
if (!r.priced) unpriced.add(r.model);
|
|
3303
|
+
}
|
|
3304
|
+
tot.unpriced = unpriced.size;
|
|
3305
|
+
// active time isn't told apart by model
|
|
3306
|
+
const active = sessModel ? null : acts.filter((a) => byAgent(a) && byFolder(a)).reduce((n, a) => n + a.seconds, 0);
|
|
3307
|
+
|
|
3308
|
+
const c = tot.cost ? fmtCost(tot) : "";
|
|
3309
|
+
const unpricedNote = tot.unpriced ? t("Not counted: {models}, with no known price", { models: sessModelNames([...unpriced]) }) : t("Estimated at effective model prices. A model's price at one provider is set in that provider's Names, prices & levels and counts over its list price");
|
|
3310
|
+
// a session's models are counted, not its calls: the agents' files price by model
|
|
3311
|
+
const unpricedModels = tot.unpriced ? t(tot.unpriced === 1 ? "{n} model unpriced" : "{n} models unpriced", { n: tot.unpriced }) : "";
|
|
3312
|
+
costHead(c, unpricedModels, unpricedNote);
|
|
3313
|
+
|
|
3314
|
+
// the overview is read again for the filters as they are now; until it is
|
|
3315
|
+
// in, the last one stands, dimmed
|
|
3316
|
+
if (sessOverAt !== sessOverQ()) loadSessOverview();
|
|
3317
|
+
const ov = sessOver || {};
|
|
3318
|
+
const stale = sessOverAt !== sessOverQ();
|
|
3319
|
+
|
|
3320
|
+
const stats = $("#sessStats");
|
|
3321
|
+
stats.replaceChildren();
|
|
3322
|
+
const box = $("#sessList");
|
|
3323
|
+
box.replaceChildren();
|
|
3324
|
+
const chart = $("#sessChart");
|
|
3325
|
+
const grid = $("#sessGrid");
|
|
3326
|
+
const head = $("#sessListHead");
|
|
3327
|
+
if (!used.length && !list.length) {
|
|
3328
|
+
stats.classList.remove("six");
|
|
3329
|
+
stats.classList.add("empty");
|
|
3330
|
+
const filtered = sessAgent !== "all" || sessModel || sessFolder || q;
|
|
3331
|
+
stats.append(el("div", "none", !all.length && !rows.length ? t("No sessions yet. Claude Code's, Codex's, Hermes, OpenCode's and Pi's sessions on this computer show up here, with what each cost and the command that resumes it.") : filtered ? t("No session matches.") : t("Nothing in this range.")));
|
|
3332
|
+
if (from && all.some((s) => byAgent(s) && byFolder(s) &&
|
|
3333
|
+
(!sessModel || s.models.some((m) => m.model === sessModel)) &&
|
|
3334
|
+
(!q || [s.title, s.cwd, s.id, s.name, ...s.models.map((m) => m.model)].some((x) => (x || "").toLowerCase().includes(q))))) {
|
|
3335
|
+
stats.firstChild.textContent = t("No sessions in this range");
|
|
3336
|
+
const history = el("button", "text sess-show-history", t("All sessions"));
|
|
3337
|
+
history.onclick = () => {
|
|
3338
|
+
sessRange = "all";
|
|
3339
|
+
try { localStorage.setItem("magpie.sessRange", sessRange); } catch {}
|
|
3340
|
+
sessStats = null;
|
|
3341
|
+
loadSessions().catch((e) => status(e.message, "err"));
|
|
3342
|
+
};
|
|
3343
|
+
stats.append(history);
|
|
3344
|
+
}
|
|
3345
|
+
box.hidden = true;
|
|
3346
|
+
chart.hidden = true;
|
|
3347
|
+
grid.hidden = true;
|
|
3348
|
+
sessDrawn = 0;
|
|
3349
|
+
sessTotal = 0;
|
|
3350
|
+
// the search stays where it was typed, to be cleared
|
|
3351
|
+
head.hidden = !q;
|
|
3352
|
+
} else {
|
|
3353
|
+
stats.classList.remove("empty");
|
|
3354
|
+
stats.classList.add("six");
|
|
3355
|
+
const tile = (n, label, sub, title, cls) => {
|
|
3356
|
+
const e = el("div", "kpi" + (cls ? " " + cls : ""));
|
|
3357
|
+
if (title) e.title = title;
|
|
3358
|
+
// a figure never wraps: a tile a line taller moves the page when a
|
|
3359
|
+
// filter shortens it; a time's units step down, as the Health app's do
|
|
3360
|
+
const b = el("b", "");
|
|
3361
|
+
if (cls?.includes("dur")) for (const part of n.split(/(\d+)/)) part && b.append(/^\d+$/.test(part) ? part : el("i", "u", part.trim()));
|
|
3362
|
+
else b.textContent = n;
|
|
3363
|
+
b.title = n;
|
|
3364
|
+
e.append(b, typeof label === "string" ? el("span", "", label) : label);
|
|
3365
|
+
// a line in parts keeps each whole, wrapping between them rather
|
|
3366
|
+
// than cutting the last off, as the Overview's tiles do
|
|
3367
|
+
if (Array.isArray(sub)) {
|
|
3368
|
+
const s = el("small", "parts");
|
|
3369
|
+
s.append(...usageParts(sub));
|
|
3370
|
+
e.append(s);
|
|
3371
|
+
} else if (sub) e.append(el("small", "", sub));
|
|
3372
|
+
stats.append(e);
|
|
3373
|
+
};
|
|
3374
|
+
const days = new Set(used.map((r) => r.date)).size;
|
|
3375
|
+
// how many sessions, and what the middle one and the one in ten spent
|
|
3376
|
+
tile(ov.count == null ? "—" : fmtN(ov.count), t("sessions"), ov.count ? t("median {m} · p90 {p}", { m: fmtN(ov.median), p: fmtN(ov.p90) }) : "",
|
|
3377
|
+
t("Tokens in and out per session: the middle session's, and what nine in ten stay under"), stale ? "stale" : "");
|
|
3378
|
+
// counted as the Overview's tokens tile is, by the same pick (#2591)
|
|
3379
|
+
tile(fmtN(usageTokens(tot)), usageTokensPick(), [t("{a} in · {b} out", { a: fmtN(tot.input), b: fmtN(tot.output) }), ...(usageWithCache && cacheOf(tot) ? [t("{a} cache", { a: fmtN(cacheOf(tot)) })] : [])]);
|
|
3380
|
+
tile(c ? "≈" + c : "—", t("cost"), unpricedModels || t("at effective prices"), unpricedNote);
|
|
3381
|
+
// input leaves out what was read from the cache and written to it
|
|
3382
|
+
const prompt = tot.input + tot.cache_read + tot.cache_write;
|
|
3383
|
+
tile(fmtN(tot.cache_read), t("cache read"), tot.cache_read && prompt ? t("hit rate {p}", { p: Math.round(100 * tot.cache_read / prompt) + "%" }) : "", tot.cache_write ? t("{n} written", { n: fmtN(tot.cache_write) }) : "");
|
|
3384
|
+
tile(active == null ? "—" : fmtDur(active), t("active"), active == null ? t("not kept by model") : t(days === 1 ? "on {n} day" : "on {n} days", { n: days }),
|
|
3385
|
+
t("The time the sessions were at work: the pauses between one message and the next, each under five minutes"), "dur");
|
|
3386
|
+
// how many folders, and how much of it the first took
|
|
3387
|
+
const byCwd = sessSums(used, "cwd");
|
|
3388
|
+
const lead = byCwd[0], spentAll = byCwd.reduce((n, f) => n + f.n, 0);
|
|
3389
|
+
tile(String(byCwd.length), t("projects"), !lead ? "" : byCwd.length > 1 && spentAll ? t("{p} in {name}", { p: Math.round(100 * lead.n / spentAll) + "%", name: baseName(lead.v) }) : baseName(lead.v), lead?.v || "");
|
|
3390
|
+
|
|
3391
|
+
const actsIn = sessModel ? null : acts.filter((a) => byAgent(a) && byFolder(a));
|
|
3392
|
+
renderSessChart(chart, st, used, ov, actsIn);
|
|
3393
|
+
grid.hidden = false;
|
|
3394
|
+
// the folders and models to pick from, as the pickers offer them
|
|
3395
|
+
sessBars($("#sessProjects"), "Projects", sessSums(rows.filter((r) => byAgent(r) && byModel(r)), "cwd"), sessFolder, (v) => { sessFolder = v; renderSessions(); }, baseName);
|
|
3396
|
+
sessBars($("#sessModels"), "Models", sessSums(rows.filter((r) => byAgent(r) && byFolder(r)), "model"), sessModel, (v) => { sessModel = v; renderSessions(); }, (v) => v);
|
|
3397
|
+
renderSessHours($("#sessHours"), actsIn);
|
|
3398
|
+
renderSessTop();
|
|
3399
|
+
renderSessShape();
|
|
3400
|
+
renderSessTools();
|
|
3401
|
+
renderSessSkills();
|
|
3402
|
+
head.hidden = !list.length && !q;
|
|
3403
|
+
box.hidden = !list.length && !q;
|
|
3404
|
+
// a page of the list, not all of it: the same filters keep the pages
|
|
3405
|
+
// opened, a changed one starts at the first (see sessListKey)
|
|
3406
|
+
const key = [sessAgent, sessModel, sessFolder, sessRange, q].join("\0");
|
|
3407
|
+
if (key !== sessListKey) { sessListKey = key; sessShown = SESS_PAGE; }
|
|
3408
|
+
const page = list.slice(0, sessShown);
|
|
3409
|
+
sessDrawn = page.length;
|
|
3410
|
+
sessTotal = list.length;
|
|
3411
|
+
for (const s of page) box.append(sessionItem(s));
|
|
3412
|
+
if (list.length > page.length) {
|
|
3413
|
+
const more = el("button", "text sess-page-more");
|
|
3414
|
+
more.textContent = t("Show {n} more", { n: Math.min(SESS_PAGE, list.length - page.length) });
|
|
3415
|
+
more.onclick = () => { sessShown += SESS_PAGE; renderSessions(); };
|
|
3416
|
+
box.append(more);
|
|
3417
|
+
}
|
|
3418
|
+
if (!list.length && q) box.append(el("div", "empty-state", t("No session matches.")));
|
|
3419
|
+
}
|
|
3420
|
+
const dirs = (sessions?.dirs || []).join(" · ");
|
|
3421
|
+
// The list is every session the filters keep, drawn in pages, so the note
|
|
3422
|
+
// counts that list and not every session on the computer: a search that
|
|
3423
|
+
// matches one session says "1 session", not "1 of 2600", which read as if
|
|
3424
|
+
// the rest were behind a "Show more" that isn't there (yetone, #1019).
|
|
3425
|
+
// Nothing left to draw, the note is just the list's length, in words a
|
|
3426
|
+
// reader uses: one session is a session, not "1 sessions".
|
|
3427
|
+
const parts = [t("Totals count native sessions and gateway requests with a session ID")];
|
|
3428
|
+
if (sessDrawn !== null) {
|
|
3429
|
+
parts.push(sessDrawn < sessTotal
|
|
3430
|
+
? t("showing {drawn} of {n}", { drawn: sessDrawn, n: sessTotal })
|
|
3431
|
+
: t(sessTotal === 1 ? "{n} session" : "{n} sessions", { n: sessTotal }));
|
|
3432
|
+
}
|
|
3433
|
+
if (dirs) parts.push(dirs);
|
|
3434
|
+
$("#sessNote").textContent = parts.join(" · ");
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
// sessSums adds up usage rows by one of their fields, the most tokens first
|
|
3438
|
+
function sessSums(list, key) {
|
|
3439
|
+
const m = new Map();
|
|
3440
|
+
for (const r of list) {
|
|
3441
|
+
if (!r[key]) continue;
|
|
3442
|
+
const s = m.get(r[key]) || { v: r[key], n: 0, cost: 0, unpriced: 0 };
|
|
3443
|
+
s.n += usageTokens(r);
|
|
3444
|
+
s.cost += r.cost;
|
|
3445
|
+
if (!r.priced) s.unpriced++;
|
|
3446
|
+
m.set(r[key], s);
|
|
3447
|
+
}
|
|
3448
|
+
return [...m.values()].sort((a, b) => b.n - a.n);
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
// sessLevels grades values 0 (none) to 4 by the quartiles of those above 0,
|
|
3452
|
+
// as a calendar of contributions does. Under eight such values a quartile
|
|
3453
|
+
// is one value or none, so two days 2% apart could be two shades apart and
|
|
3454
|
+
// one twice another the same (#1921): a few are graded by their share of the
|
|
3455
|
+
// busiest instead.
|
|
3456
|
+
function sessLevels(values) {
|
|
3457
|
+
const nz = values.filter((v) => v > 0).sort((a, b) => a - b);
|
|
3458
|
+
if (!nz.length) return () => 0;
|
|
3459
|
+
if (nz.length < 8) {
|
|
3460
|
+
const most = nz[nz.length - 1];
|
|
3461
|
+
return (v) => v <= 0 ? 0 : Math.min(4, Math.max(1, Math.ceil(4 * v / most - 1e-9)));
|
|
3462
|
+
}
|
|
3463
|
+
const q = (p) => nz[Math.min(nz.length - 1, Math.floor(p * nz.length))];
|
|
3464
|
+
const [a, b, c, top] = [q(0.25), q(0.5), q(0.75), nz[nz.length - 1]];
|
|
3465
|
+
return (v) => v <= 0 ? 0 : v >= top ? 4 : v <= a ? 1 : v <= b ? 2 : v <= c ? 3 : 4;
|
|
3466
|
+
}
|
|
3467
|
+
function sessLegend() {
|
|
3468
|
+
const l = el("span", "sess-legend");
|
|
3469
|
+
l.append(el("span", "", t("Less")));
|
|
3470
|
+
for (let i = 0; i <= 4; i++) l.append(el("i", "l" + i));
|
|
3471
|
+
l.append(el("span", "", t("More")));
|
|
3472
|
+
return l;
|
|
3473
|
+
}
|
|
3474
|
+
// the short names of the days of the week, Monday first
|
|
3475
|
+
function sessWeekdays() {
|
|
3476
|
+
const loc = intlLang();
|
|
3477
|
+
return Array.from({ length: 7 }, (_, i) => new Date(2024, 0, 1 + i).toLocaleDateString(loc, { weekday: "short" }));
|
|
3478
|
+
}
|
|
3479
|
+
function sessCardHead(title) {
|
|
3480
|
+
const h = el("div", "sess-card-head");
|
|
3481
|
+
h.append(el("span", "label", t(title)));
|
|
3482
|
+
return h;
|
|
3483
|
+
}
|
|
3484
|
+
function sessSegs(opts, cur, choose) {
|
|
3485
|
+
const seg = el("div", "segs");
|
|
3486
|
+
for (const [id, name, off] of opts) {
|
|
3487
|
+
const b = el("button", "opt" + (id === cur ? " on" : ""), t(name));
|
|
3488
|
+
if (off) { b.disabled = true; b.title = off; }
|
|
3489
|
+
b.onclick = () => { if (id !== cur) choose(id); };
|
|
3490
|
+
seg.append(b);
|
|
3491
|
+
}
|
|
3492
|
+
return seg;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
// renderSessChart draws the range's activity: day by day as bars up to a
|
|
3496
|
+
// hundred days, as a calendar up to a year, week by week past that. It counts
|
|
3497
|
+
// tokens (output on top of input, as on the Overview), output tokens alone,
|
|
3498
|
+
// messages, sessions at work, cost or active time.
|
|
3499
|
+
function renderSessChart(chart, st, used, ov, acts) {
|
|
3500
|
+
const perDay = ov.days, msgs = ov.messages;
|
|
3501
|
+
const first = sessDate(st.from), last = sessDate(st.to);
|
|
3502
|
+
const n = Math.round((last - first) / 864e5) + 1;
|
|
3503
|
+
chart.hidden = !(n >= 2);
|
|
3504
|
+
if (chart.hidden) { chart.dataset.drawn = ""; return; }
|
|
3505
|
+
const days = [], at = new Map();
|
|
3506
|
+
for (let i = 0, d = new Date(first); i < n; i++, d.setDate(d.getDate() + 1)) {
|
|
3507
|
+
const x = { day: new Date(d), input: 0, output: 0, cache_read: 0, cache_write: 0, cost: 0, unpriced: 0, sessions: perDay?.length === n ? perDay[i] : 0,
|
|
3508
|
+
messages: msgs?.length === n ? msgs[i] : 0, active: 0 };
|
|
3509
|
+
days.push(x);
|
|
3510
|
+
at.set(sessISO(d), x);
|
|
3511
|
+
}
|
|
3512
|
+
for (const r of used) {
|
|
3513
|
+
const x = at.get(r.date);
|
|
3514
|
+
if (!x) continue;
|
|
3515
|
+
x.input += r.input; x.output += r.output; x.cost += r.cost;
|
|
3516
|
+
x.cache_read += r.cache_read || 0; x.cache_write += r.cache_write || 0;
|
|
3517
|
+
if (!r.priced) x.unpriced++;
|
|
3518
|
+
}
|
|
3519
|
+
for (const a of acts || []) { const x = at.get(a.date); if (x) x.active += a.seconds; }
|
|
3520
|
+
const off = { sessions: perDay?.length === n ? "" : t("Still counting"), messages: msgs?.length === n ? "" : t("Still counting"), active: acts ? "" : t("not kept by model") };
|
|
3521
|
+
const metric = off[sessMetric] ? "tokens" : sessMetric;
|
|
3522
|
+
const value = (x) => metric === "cost" ? x.cost : metric === "sessions" ? x.sessions : metric === "messages" ? x.messages :
|
|
3523
|
+
metric === "active" ? x.active : metric === "output" ? x.output : usageTokens(x);
|
|
3524
|
+
const show = (v) => metric === "cost" ? "≈" + fmtCost({ cost: v }) : metric === "active" ? fmtDur(v) : fmtN(v);
|
|
3525
|
+
const tip = (x, when) => {
|
|
3526
|
+
const parts = [usageTokens(x) ? t("{n} tokens", { n: fmtN(usageTokens(x)) }) : "", x.cost && fmtCost(x) ? "≈" + fmtCost(x) : "",
|
|
3527
|
+
x.messages ? t(x.messages === 1 ? "{n} message" : "{n} messages", { n: fmtN(x.messages) }) : "",
|
|
3528
|
+
x.sessions ? t(x.sessions === 1 ? "{n} session" : "{n} sessions", { n: x.sessions }) : "", x.active ? t("{d} active", { d: fmtDur(x.active) }) : ""].filter(Boolean);
|
|
3529
|
+
return parts.length ? when + " · " + parts.join(" · ") : t("{when} · nothing", { when });
|
|
3530
|
+
};
|
|
3531
|
+
const shape = n <= 100 ? "day" : n <= 371 ? "calendar" : "week";
|
|
3532
|
+
// the page reads the sessions again every few seconds, and while an agent
|
|
3533
|
+
// is at work today's numbers change each time: the same days by the same
|
|
3534
|
+
// metric keep their bars, filled in again where they are, so the one under
|
|
3535
|
+
// the pointer — today's, as often as not — keeps its tooltip (#309)
|
|
3536
|
+
const drawn = [shape, metric, st.from, n, locale, ...Object.values(off).map(Boolean)].join("|");
|
|
3537
|
+
const same = chart.dataset.drawn === drawn;
|
|
3538
|
+
chart.dataset.drawn = drawn;
|
|
3539
|
+
chart.redraw = () => renderSessChart(chart, st, used, ov, acts); // the metrics kept draw what is in now
|
|
3540
|
+
|
|
3541
|
+
if (same && shape === "calendar") {
|
|
3542
|
+
const lv = sessLevels(days.map(value));
|
|
3543
|
+
chart.querySelectorAll(".sess-cal > i").forEach((c, i) => {
|
|
3544
|
+
c.className = "l" + lv(value(days[i]));
|
|
3545
|
+
c.title = tip(days[i], sessDay(days[i].day));
|
|
3546
|
+
});
|
|
3547
|
+
chart.querySelector(".sess-cal-side").replaceWith(sessCalSide(days, value, show));
|
|
3548
|
+
chart.fitCal = () => sessCalFit(chart, days, value, tip);
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
chart.fitCal = null;
|
|
3552
|
+
if (shape === "calendar") {
|
|
3553
|
+
chart.replaceChildren();
|
|
3554
|
+
chart.classList.add("cal");
|
|
3555
|
+
drawHead();
|
|
3556
|
+
const wrap = el("div", "sess-cal-wrap");
|
|
3557
|
+
wrap.append(sessCalendar(days, value, tip), sessCalSide(days, value, show));
|
|
3558
|
+
chart.append(wrap);
|
|
3559
|
+
chart.querySelector(".sess-chart-head").append(sessLegend());
|
|
3560
|
+
chart.fitCal = () => sessCalFit(chart, days, value, tip);
|
|
3561
|
+
chart.fitCal();
|
|
3562
|
+
sessCalWidth.observe(chart);
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
// bars: a day each, or a week
|
|
3567
|
+
const step = shape === "week" ? 7 : 1;
|
|
3568
|
+
const buckets = [];
|
|
3569
|
+
for (let i = 0; i < n; i += step) {
|
|
3570
|
+
const b = { day: days[i].day, input: 0, output: 0, cache_read: 0, cache_write: 0, cost: 0, unpriced: 0, sessions: 0, messages: 0, active: 0 };
|
|
3571
|
+
for (const x of days.slice(i, i + step)) for (const k of ["input", "output", "cache_read", "cache_write", "cost", "unpriced", "sessions", "messages", "active"]) b[k] += x[k];
|
|
3572
|
+
buckets.push(b);
|
|
3573
|
+
}
|
|
3574
|
+
const peak = Math.max(metric === "cost" ? 0.001 : 1, ...buckets.map(value));
|
|
3575
|
+
const fill = (bar, b) => {
|
|
3576
|
+
const [top, low] = bar.children;
|
|
3577
|
+
if (metric === "tokens") {
|
|
3578
|
+
top.style.height = (100 * b.output / peak).toFixed(1) + "%";
|
|
3579
|
+
// the input's bar carries the cache's tokens too, as the Overview's
|
|
3580
|
+
// chart does (#2591)
|
|
3581
|
+
low.style.height = (100 * (usageTokens(b) - b.output) / peak).toFixed(1) + "%";
|
|
3582
|
+
} else top.style.height = (100 * value(b) / peak).toFixed(1) + "%";
|
|
3583
|
+
const label = sessDay(b.day);
|
|
3584
|
+
bar.title = tip(b, step === 7 ? t("week of {label}", { label }) : label);
|
|
3585
|
+
};
|
|
3586
|
+
if (same) {
|
|
3587
|
+
chart.querySelector(".sess-chart-head .peak").textContent = show(peak);
|
|
3588
|
+
const bars = chart.querySelectorAll(".bars > .bar");
|
|
3589
|
+
buckets.forEach((b, i) => fill(bars[i], b));
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3592
|
+
chart.replaceChildren();
|
|
3593
|
+
chart.classList.remove("cal");
|
|
3594
|
+
drawHead().append(el("span", "peak", show(peak)));
|
|
3595
|
+
const bars = el("div", "bars");
|
|
3596
|
+
const labels = el("div", "labels");
|
|
3597
|
+
const k = buckets.length;
|
|
3598
|
+
const every = k <= 8 ? 1 : k <= 31 ? Math.ceil(k / 6) : Math.ceil(k / 5);
|
|
3599
|
+
buckets.forEach((b, i) => {
|
|
3600
|
+
const bar = el("div", "bar");
|
|
3601
|
+
if (metric === "tokens") bar.append(el("i", "out"), el("i", "in"));
|
|
3602
|
+
else bar.append(el("i", "out"));
|
|
3603
|
+
fill(bar, b);
|
|
3604
|
+
bars.append(bar);
|
|
3605
|
+
const label = sessDay(b.day);
|
|
3606
|
+
const end = i === k - 1 && (k - 1) % every >= every / 2;
|
|
3607
|
+
labels.append(el("span", "", i % every === 0 || end ? label : ""));
|
|
3608
|
+
});
|
|
3609
|
+
chart.append(bars, labels);
|
|
3610
|
+
fitChartLabels();
|
|
3611
|
+
|
|
3612
|
+
function drawHead() {
|
|
3613
|
+
const head = el("div", "sess-chart-head");
|
|
3614
|
+
const seg = sessSegs(SESS_METRICS.map(([id, name]) => [id, name, off[id]]), metric, (id) => {
|
|
3615
|
+
sessMetric = id;
|
|
3616
|
+
try { localStorage.setItem("magpie.sessMetric", id); } catch {}
|
|
3617
|
+
chart.redraw();
|
|
3618
|
+
});
|
|
3619
|
+
head.append(el("span", "label", t(shape === "week" ? "By week" : shape === "day" ? "By day" : "Activity")), seg, el("span", "grow"));
|
|
3620
|
+
chart.append(head);
|
|
3621
|
+
slide(seg, "sessMetric");
|
|
3622
|
+
return head;
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
// a week's column in the calendar, cell and gap: as wide as weeks are counted
|
|
3627
|
+
// by, and as wide as one may grow to take what is left over
|
|
3628
|
+
const SESS_WEEK = 20, SESS_WEEK_MAX = 24;
|
|
3629
|
+
|
|
3630
|
+
// sessCalFit lays the calendar across its card (John on Discord: 这个活跃度怎么
|
|
3631
|
+
// 没有铺满全部宽度呢?): a range of a few months took a few hundred pixels of a
|
|
3632
|
+
// wide card and left the rest empty. The weeks before the range come in as
|
|
3633
|
+
// empty cells, GitHub's way, up to a year in all, and the cells grow a little
|
|
3634
|
+
// to take what is left; a narrow card gets none and its cells shrink.
|
|
3635
|
+
// It fits on the next frame: a calendar put again changes the card's height,
|
|
3636
|
+
// which this would be told of in the same call, a ResizeObserver loop.
|
|
3637
|
+
const sessCalWidth = new ResizeObserver((es) => {
|
|
3638
|
+
for (const { target: chart } of es) {
|
|
3639
|
+
if (!chart.fitFrame) chart.fitFrame = requestAnimationFrame(() => { chart.fitFrame = 0; chart.fitCal?.(); });
|
|
3640
|
+
}
|
|
3641
|
+
});
|
|
3642
|
+
function sessCalFit(chart, days, value, tip) {
|
|
3643
|
+
const wrap = chart.querySelector(".sess-cal-wrap"), cal = wrap?.querySelector(".sess-cal");
|
|
3644
|
+
if (!cal || !wrap.clientWidth) return;
|
|
3645
|
+
const ws = getComputedStyle(wrap), side = wrap.querySelector(".sess-cal-side");
|
|
3646
|
+
const wd = Math.max(0, ...[...cal.querySelectorAll(".wd")].map((l) => l.getBoundingClientRect().width));
|
|
3647
|
+
const room = wrap.clientWidth - wd - (ws.flexDirection === "row" ? side.getBoundingClientRect().width + (parseFloat(ws.columnGap) || 0) : 0);
|
|
3648
|
+
const weeks = Math.ceil(((days[0].day.getDay() + 6) % 7 + days.length) / 7);
|
|
3649
|
+
const pad = Math.max(0, Math.min(53, Math.floor(room / SESS_WEEK)) - weeks);
|
|
3650
|
+
if (String(pad) !== cal.dataset.pad) cal.replaceWith(sessCalendar(days, value, tip, pad));
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
// sessCalendar is the range as weeks of days, Monday on top, each day as
|
|
3654
|
+
// dark as it is busy among the others, after pad empty weeks before it
|
|
3655
|
+
function sessCalendar(days, value, tip, pad = 0) {
|
|
3656
|
+
const lv = sessLevels(days.map(value));
|
|
3657
|
+
const lead = (days[0].day.getDay() + 6) % 7 + 7 * pad;
|
|
3658
|
+
const weeks = Math.ceil((lead + days.length) / 7);
|
|
3659
|
+
const cal = el("div", "sess-cal");
|
|
3660
|
+
cal.dataset.pad = pad;
|
|
3661
|
+
cal.style.gridTemplateColumns = `auto repeat(${weeks}, minmax(0, 1fr))`;
|
|
3662
|
+
cal.style.maxWidth = `calc(2.6em + ${weeks * (pad ? SESS_WEEK_MAX : SESS_WEEK)}px)`;
|
|
3663
|
+
const names = sessWeekdays();
|
|
3664
|
+
for (const i of [0, 2, 4]) {
|
|
3665
|
+
const l = el("span", "wd", names[i]);
|
|
3666
|
+
l.style.gridArea = `${i + 2} / 1`;
|
|
3667
|
+
cal.append(l);
|
|
3668
|
+
}
|
|
3669
|
+
const loc = intlLang();
|
|
3670
|
+
let month = -1, labelAt = -9;
|
|
3671
|
+
// the days before the range, from the Monday it is laid out from: their
|
|
3672
|
+
// months named as the range's are, the days themselves left blank
|
|
3673
|
+
const before = Array.from({ length: pad ? lead : 0 }, (_, i) => {
|
|
3674
|
+
const d = new Date(days[0].day);
|
|
3675
|
+
d.setDate(d.getDate() - lead + i);
|
|
3676
|
+
return { day: d, before: true };
|
|
3677
|
+
});
|
|
3678
|
+
[...before, ...days].forEach((x, i) => {
|
|
3679
|
+
const k = (pad ? 0 : lead) + i, w = Math.floor(k / 7), wd = k % 7;
|
|
3680
|
+
if ((wd === 0 || i === 0) && x.day.getMonth() !== month) {
|
|
3681
|
+
month = x.day.getMonth();
|
|
3682
|
+
// a month mostly gone when the weeks before begin is left unnamed, so
|
|
3683
|
+
// as not to crowd out the next one's name
|
|
3684
|
+
if (w - labelAt >= 3 && !(x.before && x.day.getDate() > 14)) {
|
|
3685
|
+
const m = el("span", "mo", x.day.toLocaleDateString(loc, month === 0 && weeks > 20 ? { month: "short", year: "numeric" } : { month: "short" }));
|
|
3686
|
+
m.style.gridArea = `1 / ${w + 2}`;
|
|
3687
|
+
cal.append(m);
|
|
3688
|
+
labelAt = w;
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
const c = x.before ? el("s") : el("i", "l" + lv(value(x)));
|
|
3692
|
+
c.style.gridArea = `${wd + 2} / ${w + 2}`;
|
|
3693
|
+
if (!x.before) c.title = tip(x, sessDay(x.day));
|
|
3694
|
+
cal.append(c);
|
|
3695
|
+
});
|
|
3696
|
+
return cal;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
// beside the calendar: the days at work, the longest run of them, the busiest
|
|
3700
|
+
function sessCalSide(days, value, show) {
|
|
3701
|
+
const side = el("div", "sess-cal-side");
|
|
3702
|
+
const fact = (label, v, sub) => {
|
|
3703
|
+
const f = el("div", "fact");
|
|
3704
|
+
f.append(el("b", "", v), el("span", "", label));
|
|
3705
|
+
if (sub) f.append(el("small", "", sub));
|
|
3706
|
+
side.append(f);
|
|
3707
|
+
};
|
|
3708
|
+
const busy = (x) => usageTokens(x) || x.active || x.sessions;
|
|
3709
|
+
let run = 0, streak = 0, best = null;
|
|
3710
|
+
for (const x of days) {
|
|
3711
|
+
run = busy(x) ? run + 1 : 0;
|
|
3712
|
+
streak = Math.max(streak, run);
|
|
3713
|
+
if (value(x) > 0 && (!best || value(x) > value(best))) best = x;
|
|
3714
|
+
}
|
|
3715
|
+
fact(t("days at work"), `${days.filter(busy).length} / ${days.length}`);
|
|
3716
|
+
fact(t("longest streak"), t(streak === 1 ? "{n} day" : "{n} days", { n: streak }));
|
|
3717
|
+
if (best) fact(t("busiest day"), sessDay(best.day), show(value(best)));
|
|
3718
|
+
return side;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
// renderSessHours lays the range's active time out by day of the week and
|
|
3722
|
+
// hour of the day, in this computer's time zone
|
|
3723
|
+
function renderSessHours(box, acts) {
|
|
3724
|
+
box.replaceChildren();
|
|
3725
|
+
const head = sessCardHead("By hour");
|
|
3726
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
|
3727
|
+
head.append(el("span", "grow"), el("span", "tz", tz));
|
|
3728
|
+
box.append(head);
|
|
3729
|
+
if (!acts) return box.append(el("div", "sess-none", t("Active time isn't kept by model.")));
|
|
3730
|
+
const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
|
|
3731
|
+
let total = 0;
|
|
3732
|
+
for (const a of acts) {
|
|
3733
|
+
if (!a.hours) continue;
|
|
3734
|
+
const wd = (sessDate(a.date).getDay() + 6) % 7;
|
|
3735
|
+
a.hours.forEach((s, h) => { grid[wd][h] += s; total += s; });
|
|
3736
|
+
}
|
|
3737
|
+
if (!total) return box.append(el("div", "sess-none", t("No active time in this range.")));
|
|
3738
|
+
const lv = sessLevels(grid.flat());
|
|
3739
|
+
const names = sessWeekdays();
|
|
3740
|
+
const hh = (h) => String(h).padStart(2, "0") + ":00";
|
|
3741
|
+
const map = el("div", "sess-hours");
|
|
3742
|
+
let best = [0, 0];
|
|
3743
|
+
grid.forEach((row, wd) => {
|
|
3744
|
+
map.append(el("span", "wd", names[wd]));
|
|
3745
|
+
row.forEach((s, h) => {
|
|
3746
|
+
const c = el("i", "l" + lv(s));
|
|
3747
|
+
c.title = `${names[wd]} ${hh(h)} · ` + (s ? fmtDur(s) : t("nothing"));
|
|
3748
|
+
if (s > grid[best[0]][best[1]]) best = [wd, h];
|
|
3749
|
+
map.append(c);
|
|
3750
|
+
});
|
|
3751
|
+
});
|
|
3752
|
+
map.append(el("span", ""));
|
|
3753
|
+
for (let h = 0; h < 24; h++) map.append(el("span", "hr", h % 6 === 0 ? String(h) : ""));
|
|
3754
|
+
box.append(map);
|
|
3755
|
+
const foot = el("div", "sess-card-foot");
|
|
3756
|
+
foot.append(el("span", "", t("Busiest at {when}", { when: `${names[best[0]]} ${hh(best[1])}` })), el("span", "grow"), sessLegend());
|
|
3757
|
+
box.append(foot);
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
// renderSessTop lists the sessions that spent the most in the range, under
|
|
3761
|
+
// the filters; a click opens one to its details, read whole from its files
|
|
3762
|
+
function renderSessTop() {
|
|
3763
|
+
const box = $("#sessTop");
|
|
3764
|
+
const ov = sessOver || {};
|
|
3765
|
+
box.replaceChildren();
|
|
3766
|
+
box.classList.toggle("stale", sessOverAt !== sessOverQ());
|
|
3767
|
+
const head = sessCardHead("Top sessions");
|
|
3768
|
+
const seg = sessSegs(SESS_TOPS, sessTopBy, (id) => {
|
|
3769
|
+
sessTopBy = id;
|
|
3770
|
+
try { localStorage.setItem("magpie.sessTop", id); } catch {}
|
|
3771
|
+
renderSessTop();
|
|
3772
|
+
});
|
|
3773
|
+
head.append(el("span", "grow"), seg);
|
|
3774
|
+
box.append(head);
|
|
3775
|
+
slide(seg, "sessTop");
|
|
3776
|
+
let list = ov.top?.[sessTopBy] || [];
|
|
3777
|
+
// ranked without the cache, shown with it: ranked again by the count the
|
|
3778
|
+
// reader sees, so the numbers still run down (#2591)
|
|
3779
|
+
if (sessTopBy === "tokens" && usageWithCache) list = [...list].sort((a, b) => usageTokens(b) - usageTokens(a));
|
|
3780
|
+
const body = el("div", "sess-tops");
|
|
3781
|
+
if (!list.length) body.append(el("div", "sess-none", ov.top ? t("Nothing in this range.") : "…"));
|
|
3782
|
+
const icons = new Map((sessions?.sessions || []).map((s) => [s.app || s.agent, s.icon]));
|
|
3783
|
+
const names = sessStats?.agents || {};
|
|
3784
|
+
for (const s of list) {
|
|
3785
|
+
const open = sessTopOpen === s.key;
|
|
3786
|
+
const item = el("div", "sess-item" + (open ? " open" : ""));
|
|
3787
|
+
const r = el("div", "row sess sess-top");
|
|
3788
|
+
r.append(icon(icons.get(s.agent) || "generic"));
|
|
3789
|
+
const who = el("div", "who");
|
|
3790
|
+
// a session magpie saw only through the gateway (#2285: a Pi
|
|
3791
|
+
// subagent's, its file in a temp folder) has no folder and no prompt
|
|
3792
|
+
// unless gateway conversations are recorded: say so, not "(no prompt)"
|
|
3793
|
+
who.append(el("div", "name", s.title || t(s.gateway ? "(prompt not recorded)" : "(no prompt)")));
|
|
3794
|
+
const sub = el("div", "sub", [s.cwd ? baseName(s.cwd) : s.gateway ? t("Gateway session") : "", names[s.agent] || s.agent, s.models?.[0], ago(s.last)].filter(Boolean).join(" · "));
|
|
3795
|
+
sub.title = s.cwd || (s.gateway ? t("Seen only through the gateway: the agent kept no session file magpie can read. Turn on Record gateway conversations in Sessions to keep its prompts.") : "");
|
|
3796
|
+
who.append(sub);
|
|
3797
|
+
r.append(who);
|
|
3798
|
+
const fc = fmtCost({ cost: s.cost, unpriced: !s.priced });
|
|
3799
|
+
const v = sessTopBy === "cost" ? (fc ? "≈" + fc : "—") : sessTopBy === "active" ? fmtDur(s.active) : fmtN(usageTokens(s));
|
|
3800
|
+
r.append(el("div", "v", v));
|
|
3801
|
+
r.onclick = () => {
|
|
3802
|
+
if (window.getSelection()?.toString()) return;
|
|
3803
|
+
sessTopOpen = open ? "" : s.key;
|
|
3804
|
+
const had = sessFull.get(s.key);
|
|
3805
|
+
if (!open && (!had || had.error)) {
|
|
3806
|
+
sessFull.set(s.key, "…");
|
|
3807
|
+
api("sessions/one?key=" + encodeURIComponent(s.key)).then((full) => sessFull.set(s.key, full), (e) => sessFull.set(s.key, { error: e.message }))
|
|
3808
|
+
.then(() => { if (sessTopOpen === s.key && view === "usage" && usageTab === "sessions") renderSessTop(); });
|
|
3809
|
+
}
|
|
3810
|
+
renderSessTop();
|
|
3811
|
+
};
|
|
3812
|
+
item.append(r);
|
|
3813
|
+
if (open) {
|
|
3814
|
+
const full = sessFull.get(s.key);
|
|
3815
|
+
item.append(!full || full === "…" ? el("div", "sess-detail sess-wait", t("Reading the session…")) : full.error ? el("div", "sess-detail sess-wait", full.error) : sessionDetail(full));
|
|
3816
|
+
}
|
|
3817
|
+
body.append(item);
|
|
3818
|
+
}
|
|
3819
|
+
box.append(body);
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
// the ways a session's shape is told, and the one shown
|
|
3823
|
+
const SESS_SHAPES = [["messages", "Messages"], ["minutes", "Length"], ["autonomy", "Tool calls"]];
|
|
3824
|
+
let sessShapeBy = "messages";
|
|
3825
|
+
try { const v = localStorage.getItem("magpie.sessShape"); if (SESS_SHAPES.some(([id]) => id === v)) sessShapeBy = v; } catch {}
|
|
3826
|
+
|
|
3827
|
+
// renderSessShape counts the range's sessions by how long they ran: by
|
|
3828
|
+
// messages, by minutes at work, or by tool calls a prompt
|
|
3829
|
+
function renderSessShape() {
|
|
3830
|
+
const box = $("#sessShape");
|
|
3831
|
+
const ov = sessOver || {};
|
|
3832
|
+
box.replaceChildren();
|
|
3833
|
+
box.classList.toggle("stale", sessOverAt !== sessOverQ());
|
|
3834
|
+
const sh = ov.shape?.[sessShapeBy];
|
|
3835
|
+
const head = sessCardHead("Session shape");
|
|
3836
|
+
const seg = sessSegs(SESS_SHAPES, sessShapeBy, (id) => {
|
|
3837
|
+
sessShapeBy = id;
|
|
3838
|
+
try { localStorage.setItem("magpie.sessShape", id); } catch {}
|
|
3839
|
+
renderSessShape();
|
|
3840
|
+
});
|
|
3841
|
+
head.append(seg, el("span", "grow"), el("span", "tz", sh ? t(sh.total === 1 ? "{n} session" : "{n} sessions", { n: fmtN(sh.total) }) : ""));
|
|
3842
|
+
box.append(head);
|
|
3843
|
+
slide(seg, "sessShape");
|
|
3844
|
+
if (!sh?.total) return box.append(el("div", "sess-none", ov.shape ? t("Nothing in this range.") : "…"));
|
|
3845
|
+
const label = (i) => {
|
|
3846
|
+
const lo = sh.edges[i], hi = sh.edges[i + 1];
|
|
3847
|
+
if (sessShapeBy === "autonomy" && lo === 0) return "<1";
|
|
3848
|
+
const r = hi == null ? lo + "+" : hi - 1 === lo ? String(lo) : `${lo}–${hi - 1}`;
|
|
3849
|
+
return sessShapeBy === "minutes" ? t("{r}m", { r }) : r;
|
|
3850
|
+
};
|
|
3851
|
+
const what = { messages: "{r} messages", minutes: "{r} minutes at work", autonomy: "{r} tool calls a prompt" }[sessShapeBy];
|
|
3852
|
+
const peak = Math.max(1, ...sh.counts);
|
|
3853
|
+
const bars = el("div", "sess-shape");
|
|
3854
|
+
sh.counts.forEach((c, i) => {
|
|
3855
|
+
const col = el("div", "col");
|
|
3856
|
+
const pct = Math.round(100 * c / sh.total);
|
|
3857
|
+
col.title = t(what, { r: label(i) }) + " · " + t(c === 1 ? "{n} session" : "{n} sessions", { n: fmtN(c) }) + ` (${pct}%)`;
|
|
3858
|
+
// a bar as tall as its share of the busiest bin, on a baseline; an
|
|
3859
|
+
// empty bin a stub, so no grey column reads as a bar of its own
|
|
3860
|
+
const plot = el("div", "plot");
|
|
3861
|
+
const fill = el("i", c ? "" : "none");
|
|
3862
|
+
fill.style.height = c ? `max(4px, calc((100% - 20px) * ${(c / peak).toFixed(3)}))` : "";
|
|
3863
|
+
plot.append(el("b", c ? "" : "zero", fmtN(c)), fill);
|
|
3864
|
+
// the busiest bin in full, when one is busiest
|
|
3865
|
+
col.classList.toggle("peak", c === peak && c > 0 && sh.counts.filter((n) => n === peak).length === 1);
|
|
3866
|
+
col.append(plot, el("span", "", label(i)));
|
|
3867
|
+
bars.append(col);
|
|
3868
|
+
});
|
|
3869
|
+
box.append(bars);
|
|
3870
|
+
// what the two axes are, said plainly
|
|
3871
|
+
const axis = { messages: "messages in a session, prompts and replies", minutes: "minutes a session was at work", autonomy: "tool calls the agent made on its own for each prompt" }[sessShapeBy];
|
|
3872
|
+
const foot = el("div", "sess-card-foot");
|
|
3873
|
+
foot.append(el("span", "", t("Across: {axis} · Height: sessions", { axis: t(axis) })));
|
|
3874
|
+
box.append(foot);
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
// the colours of the kinds of tool, as the calls are told apart
|
|
3878
|
+
const TOOL_CATS = { Bash: "#e0823d", Edit: "#4f8cf0", Read: "#2fb087", Write: "#9b6cf0", Grep: "#e0608f", Glob: "#d4a72c", Task: "#35a9c7", Tool: "#7c83f2", Other: "#9aa0a8" };
|
|
3879
|
+
const toolColor = (c) => TOOL_CATS[c] || TOOL_CATS.Other;
|
|
3880
|
+
// a kind's name, as its tools are named, "Other" aside
|
|
3881
|
+
const toolKind = (c) => c === "Other" ? t("Other") : c;
|
|
3882
|
+
function toolDot(c) {
|
|
3883
|
+
const d = el("i", "dot");
|
|
3884
|
+
d.style.background = toolColor(c);
|
|
3885
|
+
return d;
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
// renderSessTools is what the sessions called their tools for: the most
|
|
3889
|
+
// called, by kind, and week by week
|
|
3890
|
+
function renderSessTools() {
|
|
3891
|
+
const box = $("#sessTools");
|
|
3892
|
+
const tu = sessOver?.tools;
|
|
3893
|
+
box.replaceChildren();
|
|
3894
|
+
box.classList.toggle("stale", sessOverAt !== sessOverQ());
|
|
3895
|
+
const head = sessCardHead("Tool use");
|
|
3896
|
+
head.append(el("span", "grow"));
|
|
3897
|
+
if (tu?.calls) head.append(el("span", "tz", t("{n} calls", { n: fmtN(tu.calls) }) + " · " + t(tu.sessions === 1 ? "{n} session" : "{n} sessions", { n: fmtN(tu.sessions) })));
|
|
3898
|
+
box.append(head);
|
|
3899
|
+
if (!tu?.calls) return box.append(el("div", "sess-none", tu ? t("No tool calls in this range.") : "…"));
|
|
3900
|
+
const cols = el("div", "sess-tools-cols");
|
|
3901
|
+
|
|
3902
|
+
// the most called, each with its kind's dot, calls, sessions and share
|
|
3903
|
+
const top = el("div", "sess-bars tools");
|
|
3904
|
+
const peak = Math.max(1, ...tu.top.map((x) => x.calls));
|
|
3905
|
+
for (const x of tu.top) {
|
|
3906
|
+
const r = el("div", "sess-bar tool");
|
|
3907
|
+
r.title = `${x.name} · ${toolKind(x.category)}`;
|
|
3908
|
+
const n = el("span", "n");
|
|
3909
|
+
n.append(toolDot(x.category), el("span", "", x.name));
|
|
3910
|
+
const track = el("span", "track");
|
|
3911
|
+
const fill = el("i");
|
|
3912
|
+
fill.style.width = Math.max(1.5, 100 * x.calls / peak).toFixed(1) + "%";
|
|
3913
|
+
fill.style.background = toolColor(x.category);
|
|
3914
|
+
track.append(fill);
|
|
3915
|
+
r.append(n, track, el("span", "v", fmtN(x.calls)), el("span", "s", t(x.sessions === 1 ? "{n} session" : "{n} sessions", { n: fmtN(x.sessions) })),
|
|
3916
|
+
el("span", "p", Math.round(100 * x.calls / tu.calls) + "%"));
|
|
3917
|
+
top.append(r);
|
|
3918
|
+
}
|
|
3919
|
+
cols.append(top);
|
|
3920
|
+
queueMicrotask(() => sessLineUp(top));
|
|
3921
|
+
|
|
3922
|
+
const side = el("div", "sess-tools-side");
|
|
3923
|
+
// by kind: one bar of every call, and each kind's count
|
|
3924
|
+
const mix = el("div", "sess-mix");
|
|
3925
|
+
const kinds = el("div", "sess-kinds");
|
|
3926
|
+
for (const c of tu.categories) {
|
|
3927
|
+
const seg = el("i");
|
|
3928
|
+
seg.style.flexGrow = c.calls;
|
|
3929
|
+
seg.style.background = toolColor(c.name);
|
|
3930
|
+
seg.title = `${toolKind(c.name)} · ${t("{n} calls", { n: fmtN(c.calls) })} (${Math.round(100 * c.calls / tu.calls)}%)`;
|
|
3931
|
+
mix.append(seg);
|
|
3932
|
+
const k = el("span", "kind");
|
|
3933
|
+
k.title = seg.title;
|
|
3934
|
+
k.append(toolDot(c.name), el("span", "", toolKind(c.name)), el("b", "", fmtN(c.calls)));
|
|
3935
|
+
kinds.append(k);
|
|
3936
|
+
}
|
|
3937
|
+
side.append(mix, kinds);
|
|
3938
|
+
|
|
3939
|
+
// week by week, the last sixteen, each kind stacked
|
|
3940
|
+
const weeks = tu.weeks.slice(-16);
|
|
3941
|
+
if (weeks.length > 1) {
|
|
3942
|
+
side.append(el("div", "sess-sub", t("By week")));
|
|
3943
|
+
const order = tu.categories.map((c) => c.name);
|
|
3944
|
+
const total = (w) => Object.values(w.calls).reduce((a, b) => a + b, 0);
|
|
3945
|
+
const wpeak = Math.max(1, ...weeks.map(total));
|
|
3946
|
+
const bars = el("div", "sess-weeks");
|
|
3947
|
+
for (const w of weeks) {
|
|
3948
|
+
const bar = el("div", "bar");
|
|
3949
|
+
const n = total(w);
|
|
3950
|
+
bar.title = t("week of {label}", { label: sessDay(sessDate(w.start)) }) + " · " + t("{n} calls", { n: fmtN(n) });
|
|
3951
|
+
const stack = el("div", "stack");
|
|
3952
|
+
stack.style.height = (n ? Math.max(2, 100 * n / wpeak) : 0).toFixed(1) + "%";
|
|
3953
|
+
for (const c of order) {
|
|
3954
|
+
if (!w.calls[c]) continue;
|
|
3955
|
+
const part = el("i");
|
|
3956
|
+
part.style.flexGrow = w.calls[c];
|
|
3957
|
+
part.style.background = toolColor(c);
|
|
3958
|
+
stack.append(part);
|
|
3959
|
+
}
|
|
3960
|
+
bar.append(stack);
|
|
3961
|
+
bars.append(bar);
|
|
3962
|
+
}
|
|
3963
|
+
const labels = el("div", "sess-weeks-labels");
|
|
3964
|
+
labels.append(el("span", "", sessDay(sessDate(weeks[0].start))), el("span", "", sessDay(sessDate(weeks[weeks.length - 1].start))));
|
|
3965
|
+
side.append(bars, labels);
|
|
3966
|
+
}
|
|
3967
|
+
cols.append(side);
|
|
3968
|
+
box.append(cols);
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
// renderSessSkills lists the skills the sessions called up, the most first
|
|
3972
|
+
function renderSessSkills() {
|
|
3973
|
+
const box = $("#sessSkills");
|
|
3974
|
+
const su = sessOver?.skills;
|
|
3975
|
+
box.replaceChildren();
|
|
3976
|
+
box.classList.toggle("stale", sessOverAt !== sessOverQ());
|
|
3977
|
+
const head = sessCardHead("Top skills");
|
|
3978
|
+
head.append(el("span", "grow"));
|
|
3979
|
+
if (su?.calls) head.append(el("span", "tz", t("{n} calls", { n: fmtN(su.calls) }) + " · " + t(su.count === 1 ? "{n} skill" : "{n} skills", { n: fmtN(su.count) })));
|
|
3980
|
+
box.append(head);
|
|
3981
|
+
if (!su?.calls) return box.append(el("div", "sess-none", su ? t("No skill was called up in this range.") : "…"));
|
|
3982
|
+
const names = sessStats?.agents || {};
|
|
3983
|
+
const peak = Math.max(1, ...su.top.map((x) => x.calls));
|
|
3984
|
+
const body = el("div", "sess-skills");
|
|
3985
|
+
for (const x of su.top) {
|
|
3986
|
+
const r = el("div", "sess-skill");
|
|
3987
|
+
const line = el("div", "line");
|
|
3988
|
+
const track = el("span", "track");
|
|
3989
|
+
const fill = el("i");
|
|
3990
|
+
fill.style.width = Math.max(1.5, 100 * x.calls / peak).toFixed(1) + "%";
|
|
3991
|
+
track.append(fill);
|
|
3992
|
+
line.append(el("span", "n", x.name), track, el("span", "v", fmtN(x.calls)));
|
|
3993
|
+
const agents = Object.entries(x.agents || {}).sort((a, b) => b[1] - a[1]);
|
|
3994
|
+
const share = agents.map(([a, c]) => `${names[a] || a} ${Math.round(100 * c / x.calls)}%`).join(" · ");
|
|
3995
|
+
const where = (x.projects || []).map((p) => baseName(p.name)).join(", ");
|
|
3996
|
+
const sub = el("div", "sub", [t(x.sessions === 1 ? "{n} session" : "{n} sessions", { n: fmtN(x.sessions) }),
|
|
3997
|
+
x.last ? t("last {when}", { when: sessDay(sessDate(x.last)) }) : "", share, where].filter(Boolean).join(" · "));
|
|
3998
|
+
sub.title = (x.projects || []).map((p) => `${p.name} · ${t("{n} calls", { n: fmtN(p.calls) })}`).join("\n");
|
|
3999
|
+
r.append(line, sub);
|
|
4000
|
+
body.append(r);
|
|
4001
|
+
}
|
|
4002
|
+
if (su.count > su.top.length) body.append(el("div", "sess-more", t("+{n} more", { n: su.count - su.top.length })));
|
|
4003
|
+
box.append(body);
|
|
4004
|
+
sessLineUp(body);
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
// sessLineUp gives every row of a card of bars the columns of the widest:
|
|
4008
|
+
// each row is a grid of its own (#3955), so each column's width is read
|
|
4009
|
+
// with the rows at their natural width and set on the card as --sb<i>. The
|
|
4010
|
+
// widths don't change with the window's, so a card is measured once, when
|
|
4011
|
+
// it is first shown
|
|
4012
|
+
const sessSeen = new ResizeObserver((es) => es.forEach((e) => { if (e.contentRect.width) { sessSeen.unobserve(e.target); sessLineUp(e.target); } }));
|
|
4013
|
+
function sessLineUp(box) {
|
|
4014
|
+
if (!box.isConnected) return;
|
|
4015
|
+
if (!box.offsetWidth) return sessSeen.observe(box);
|
|
4016
|
+
const rows = [...box.querySelectorAll(box.classList.contains("sess-skills") ? ":scope > .sess-skill > .line" : ":scope > .sess-bar")];
|
|
4017
|
+
for (const p of [...box.style]) if (p.startsWith("--sb")) box.style.removeProperty(p);
|
|
4018
|
+
box.classList.add("fit");
|
|
4019
|
+
const w = [];
|
|
4020
|
+
for (const r of rows) [...r.children].forEach((c, i) => { w[i] = Math.max(w[i] || 0, c.getBoundingClientRect().width); });
|
|
4021
|
+
box.classList.remove("fit");
|
|
4022
|
+
w.forEach((x, i) => box.style.setProperty("--sb" + i, Math.ceil(x) + "px"));
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
// sessBars is a card of folders or models as bars of the tokens each took;
|
|
4026
|
+
// a click shows that one alone, and a click on it again every one
|
|
4027
|
+
const SESS_BARS = 6;
|
|
4028
|
+
function sessBars(box, title, items, cur, choose, name) {
|
|
4029
|
+
box.replaceChildren();
|
|
4030
|
+
box.append(sessCardHead(title));
|
|
4031
|
+
const body = el("div", "sess-bars");
|
|
4032
|
+
let shown = items.slice(0, SESS_BARS);
|
|
4033
|
+
const picked = cur && items.find((x) => x.v === cur);
|
|
4034
|
+
if (picked && !shown.includes(picked)) shown = [...shown.slice(0, SESS_BARS - 1), picked];
|
|
4035
|
+
const peak = Math.max(1, ...items.map((x) => x.n));
|
|
4036
|
+
for (const x of shown) {
|
|
4037
|
+
const r = el("button", "sess-bar" + (x.v === cur ? " on" : ""));
|
|
4038
|
+
r.type = "button";
|
|
4039
|
+
r.title = [name(x.v) !== x.v ? x.v : "", x.v === cur ? t("Click again to show every one") : t("Click to show only this")].filter(Boolean).join("\n");
|
|
4040
|
+
const track = el("span", "track");
|
|
4041
|
+
const fill = el("i");
|
|
4042
|
+
fill.style.width = Math.max(1.5, 100 * x.n / peak).toFixed(1) + "%";
|
|
4043
|
+
track.append(fill);
|
|
4044
|
+
const fc = x.cost ? fmtCost(x) : "";
|
|
4045
|
+
r.append(el("span", "n", name(x.v)), track, el("span", "v", fmtN(x.n)), el("span", "c", fc ? "≈" + fc : ""));
|
|
4046
|
+
r.onclick = () => choose(x.v === cur ? "" : x.v);
|
|
4047
|
+
body.append(r);
|
|
4048
|
+
}
|
|
4049
|
+
if (!items.length) body.append(el("div", "sess-none", t("Nothing in this range.")));
|
|
4050
|
+
if (items.length > shown.length) body.append(el("div", "sess-more", t("+{n} more", { n: items.length - shown.length })));
|
|
4051
|
+
box.append(body);
|
|
4052
|
+
sessLineUp(body);
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
// sessSpent is a session's tokens and cost, as the two right-hand columns
|
|
4056
|
+
// of its row: the Usage page's list and the Sessions page's (#752)
|
|
4057
|
+
function sessSpent(s) {
|
|
4058
|
+
const tokens = sessTokens(s);
|
|
4059
|
+
const num = el("div", "num" + (tokens ? "" : " none"));
|
|
4060
|
+
num.append(el("b", "", tokens ? fmtN(tokens) : "—"));
|
|
4061
|
+
if (tokens) num.append(el("small", "", t("{a} in · {b} out", { a: fmtN(s.input), b: fmtN(s.output) }) + (s.cache_read ? " · " + t("{n} cached", { n: fmtN(s.cache_read) }) : "")));
|
|
4062
|
+
const sc = sessCost(s);
|
|
4063
|
+
const cost = el("div", "cost" + (sc === "—" ? " none" : ""), sc);
|
|
4064
|
+
if (sc === "—") cost.title = t("No known price for {models}", { models: sessModelNames(s.models.map((m) => m.model)) || "—" });
|
|
4065
|
+
else if (s.unpriced) cost.title = t("Not counted: {models}, with no known price", { models: sessModelNames(s.models.filter((m) => !m.priced).map((m) => m.model)) });
|
|
4066
|
+
return [num, cost];
|
|
4067
|
+
}
|
|
4068
|
+
|
|
4069
|
+
// A row of the Usage page's list: what the session spent. It opens on the
|
|
4070
|
+
// Sessions page, where it is resumed, read in full and deleted (#752: the
|
|
4071
|
+
// two lists were one done twice, each lacking half).
|
|
4072
|
+
// A row opens its session on the Sessions page. With that page switched off
|
|
4073
|
+
// (Plugins › Built-in) there is nowhere to open it, so the row unfolds the
|
|
4074
|
+
// session's details in place, as a top session's do, instead of looking like
|
|
4075
|
+
// a link and doing nothing when clicked (#2311, betacatZ).
|
|
4076
|
+
function sessionItem(s) {
|
|
4077
|
+
const item = el("div", "sess-item");
|
|
4078
|
+
item.sess = s;
|
|
4079
|
+
const r = el("div", "row sess sess-link");
|
|
4080
|
+
const key = s.agent + ":" + s.id;
|
|
4081
|
+
const here = featuresOff.has("sessions");
|
|
4082
|
+
const unfolded = here && sessListOpen === key;
|
|
4083
|
+
r.tabIndex = 0;
|
|
4084
|
+
if (here) {
|
|
4085
|
+
r.setAttribute("role", "button");
|
|
4086
|
+
r.setAttribute("aria-expanded", String(unfolded));
|
|
4087
|
+
} else {
|
|
4088
|
+
r.setAttribute("role", "link");
|
|
4089
|
+
r.title = t("Open in Sessions");
|
|
4090
|
+
}
|
|
4091
|
+
r.append(icon(s.icon || "generic"));
|
|
4092
|
+
const who = el("div", "who");
|
|
4093
|
+
who.append(el("div", "name", s.title || t("(no prompt)")));
|
|
4094
|
+
const sub = el("div", "sub", [s.cwd ? baseName(s.cwd) : "", sessModelsText(s), ago(s.last)].filter(Boolean).join(" · "));
|
|
4095
|
+
sub.title = [s.cwd, ...(s.via || []).map(viaText)].filter(Boolean).join("\n");
|
|
4096
|
+
if (s.wsl) sub.prepend(wslBadge(s), " ");
|
|
4097
|
+
who.append(sub);
|
|
4098
|
+
r.append(who, ...sessSpent(s));
|
|
4099
|
+
const go = el("span", "sess-go");
|
|
4100
|
+
go.append(svg(here ? CHEV : CHEV_R, 12, 1.6));
|
|
4101
|
+
if (unfolded) go.classList.add("up");
|
|
4102
|
+
r.append(go);
|
|
4103
|
+
const open = (e) => {
|
|
4104
|
+
if (window.getSelection()?.toString()) return;
|
|
4105
|
+
if (!featuresOff.has("sessions")) return magpie.call("sessions", "reveal", s, e);
|
|
4106
|
+
// Sessions is switched off: there is no page to open it on
|
|
4107
|
+
sessListOpen = sessListOpen === key ? "" : key;
|
|
4108
|
+
// one open at a time: the one that was folds
|
|
4109
|
+
for (const o of item.parentElement?.querySelectorAll(":scope > .sess-item.open") || []) if (o !== item) o.replaceWith(sessionItem(o.sess));
|
|
4110
|
+
const next = sessionItem(s);
|
|
4111
|
+
item.replaceWith(next);
|
|
4112
|
+
next.querySelector(".row.sess").focus({ preventScroll: true });
|
|
4113
|
+
};
|
|
4114
|
+
r.onclick = open;
|
|
4115
|
+
r.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(e); } };
|
|
4116
|
+
item.append(r);
|
|
4117
|
+
if (unfolded) {
|
|
4118
|
+
item.classList.add("open");
|
|
4119
|
+
item.append(sessionDetail(s));
|
|
4120
|
+
}
|
|
4121
|
+
return item;
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
function sessionDetail(s) {
|
|
4125
|
+
const d = el("div", "sess-detail");
|
|
4126
|
+
const line = (label, value, extra) => {
|
|
4127
|
+
const l = el("div", "sess-line");
|
|
4128
|
+
l.append(el("span", "k", label));
|
|
4129
|
+
const v = el("span", "v", value);
|
|
4130
|
+
l.append(v);
|
|
4131
|
+
if (extra) l.append(extra);
|
|
4132
|
+
d.append(l);
|
|
4133
|
+
};
|
|
4134
|
+
line(t("Time"), stamp(s.start) + " – " + stamp(s.last));
|
|
4135
|
+
if (s.wsl) line("WSL", s.wsl);
|
|
4136
|
+
if (s.cwd) line(t("Folder"), s.cwd);
|
|
4137
|
+
line(t("Session ID"), s.id, copyBtn(s.id, t("Session id")));
|
|
4138
|
+
if (s.resume) {
|
|
4139
|
+
const code = el("code", "", s.resume);
|
|
4140
|
+
const l = el("div", "sess-line");
|
|
4141
|
+
l.append(el("span", "k", t("Resume")), code, copyBtn(s.resume, t("Resume command")));
|
|
4142
|
+
d.append(l);
|
|
4143
|
+
}
|
|
4144
|
+
sessUsageDetail(d, s, line);
|
|
4145
|
+
if (s.path) line(t("File"), s.path);
|
|
4146
|
+
return d;
|
|
4147
|
+
}
|
|
4148
|
+
|
|
4149
|
+
// All sessions: the Sessions page, on the agent picked here if one is
|
|
4150
|
+
$("#sessAll").onclick = (e) => magpie.call("sessions", "reveal", sessAgent === "all" ? {} : { agent: sessAgent }, e);
|
|
4151
|
+
$("#sessQ").oninput = (e) => { sessQuery = e.target.value; if (sessions) renderSessions(); };
|
|
4152
|
+
$("#sessQ").onkeydown = (e) => { if (e.key === "Escape" && e.target.value) { e.stopPropagation(); e.target.value = ""; sessQuery = ""; if (sessions) renderSessions(); } };
|
|
4153
|
+
|
|
4154
|
+
// ---------- settings ----------
|
|
4155
|
+
//
|
|
4156
|
+
// Three choices (palette, language, what the tray icon opens) and the facts
|
|
4157
|
+
// people come looking for:
|
|
4158
|
+
// the version, where magpie keeps its files, the gateway's address.
|
|
4159
|
+
|
|
4160
|
+
// The Usage page reads its numbers again while it is looked at, as often as
|
|
4161
|
+
// the reader says — off, or every 5 s to half an hour (a request through the
|
|
4162
|
+
// gateway shows within that; the subscriptions' windows each minute at most, the
|
|
4163
|
+
// vendors' answers being cached behind them, and the sessions each 15 s) —
|
|
4164
|
+
// redrawn only on a change. The button beside it reads them now. The longer
|
|
4165
|
+
// ones are for a page left open all day (114514 on Discord).
|
|
4166
|
+
const USAGE_EVERY = [[0, "Off"], [5, "5 s"], [10, "10 s"], [30, "30 s"], [60, "1 min"], [300, "5 min"], [900, "15 min"], [1800, "30 min"]];
|
|
4167
|
+
let usageEvery = 5; // seconds; 0 is off
|
|
4168
|
+
try {
|
|
4169
|
+
const v = localStorage.getItem("magpie.usageEvery");
|
|
4170
|
+
if (USAGE_EVERY.some(([n]) => String(n) === v)) usageEvery = Number(v);
|
|
4171
|
+
} catch {}
|
|
4172
|
+
let usageLast = 0, usageReadAt = 0, sessionsAt = 0, quotasAsked = 0;
|
|
4173
|
+
|
|
4174
|
+
function renderUsageEvery() {
|
|
4175
|
+
const cur = USAGE_EVERY.find(([n]) => n === usageEvery);
|
|
4176
|
+
const b = $("#usageEvery");
|
|
4177
|
+
b.replaceChildren(el("span", "", t(cur[1])), svg(CHEV, 11, 1.6));
|
|
4178
|
+
b.classList.toggle("live", usageEvery > 0); // a phone's dot: it reads again by itself
|
|
4179
|
+
b.title = t("Refresh every");
|
|
4180
|
+
b.onclick = (e) => {
|
|
4181
|
+
e.stopPropagation();
|
|
4182
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
4183
|
+
openProtoMenu(b, USAGE_EVERY.map(([n, name]) => ({ v: String(n), name: t(name), note: "" })), String(usageEvery), (v) => {
|
|
4184
|
+
usageEvery = Number(v);
|
|
4185
|
+
try { localStorage.setItem("magpie.usageEvery", v); } catch {}
|
|
4186
|
+
usageLast = performance.now();
|
|
4187
|
+
renderUsageEvery();
|
|
4188
|
+
}, "Refresh every", "sess-menu");
|
|
4189
|
+
};
|
|
4190
|
+
const r = $("#usageReload");
|
|
4191
|
+
// on the Overview it reads the allowances again too (#486: they had a
|
|
4192
|
+
// Refresh of their own beside it)
|
|
4193
|
+
const what = usageTab === "usage" ? t("Refresh now, the allowances too; a Claude account's is read by running Claude Code's own /usage") : t("Refresh now");
|
|
4194
|
+
r.title = usageReadAt ? what + " · " + t("Updated {time}", { time: new Date(usageReadAt).toLocaleTimeString(intlLang(), { hour12: false }) }) : what;
|
|
4195
|
+
r.setAttribute("aria-label", t("Refresh now"));
|
|
4196
|
+
r.onclick = async () => {
|
|
4197
|
+
r.classList.add("busy");
|
|
4198
|
+
try { await refreshUsage(true); } finally { r.classList.remove("busy"); r.blur(); }
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
// refreshUsage reads what the tab shown draws; now is the reader asking, which
|
|
4203
|
+
// reads the page it is on too, and the sessions and the allowances.
|
|
4204
|
+
// A read that outlives its interval must not have another started behind it:
|
|
4205
|
+
// one is in flight at a time, and a read asked for while one is (the timer, or
|
|
4206
|
+
// the reader's button, which may not be swallowed) is served when it is done,
|
|
4207
|
+
// the reader's own if either asked for it. The promise covers that second read
|
|
4208
|
+
// too, so the button turns while the reader's own is still to come.
|
|
4209
|
+
let usageFlight = null, usageQueued = false, usageQueuedNow = false;
|
|
4210
|
+
// usageDrawn: the tab shown has its figures on the page already, which a
|
|
4211
|
+
// quiet read (refreshUsage) only replaces when they changed
|
|
4212
|
+
function usageDrawn() {
|
|
4213
|
+
if (usageTab === "sessions") return !!(sessions && sessStats);
|
|
4214
|
+
if (usageTab === "requests") return !!ledger;
|
|
4215
|
+
if (usageTab === "context") return true; // loadContext keeps what it drew when nothing changed
|
|
4216
|
+
return !!usage && !$("#view-usage").classList.contains("loading");
|
|
4217
|
+
}
|
|
4218
|
+
function refreshUsage(now = false) {
|
|
4219
|
+
usageLast = performance.now();
|
|
4220
|
+
if (usageFlight) {
|
|
4221
|
+
usageQueued = true;
|
|
4222
|
+
usageQueuedNow = usageQueuedNow || now;
|
|
4223
|
+
return usageFlight; // this promise includes the queued read
|
|
4224
|
+
}
|
|
4225
|
+
const run = async (want) => {
|
|
4226
|
+
try {
|
|
4227
|
+
if (usageTab === "sessions") {
|
|
4228
|
+
if (sessions && (want || performance.now() - sessionsAt > 15e3)) { sessionsAt = performance.now(); await loadSessions(); }
|
|
4229
|
+
} else if (usageTab === "requests") {
|
|
4230
|
+
// the newest page takes the requests as they come; an older one stays put
|
|
4231
|
+
if (ledger && (want || !ledOffset)) await loadLedger(true);
|
|
4232
|
+
} else if (usageTab === "context") {
|
|
4233
|
+
await window.loadContext?.();
|
|
4234
|
+
} else {
|
|
4235
|
+
// the reader asking reads the allowances afresh, a Claude account's by
|
|
4236
|
+
// running Claude Code's own /usage (the backend runs it at most once in 30s)
|
|
4237
|
+
let q = null;
|
|
4238
|
+
if (want) { quotasAsked = performance.now(); q = loadQuotas("all"); }
|
|
4239
|
+
else if (usage && performance.now() - quotasAsked > 60e3) { quotasAsked = performance.now(); loadQuotas(); }
|
|
4240
|
+
if (usage) {
|
|
4241
|
+
const p = period, read = ++usageRead;
|
|
4242
|
+
const u = await api("usage?period=" + encodeURIComponent(p) + usageZoneQ());
|
|
4243
|
+
if (read === usageRead && view === "usage" && usageTab === "usage" && p === period) {
|
|
4244
|
+
if (JSON.stringify(u) !== JSON.stringify(usage)) { usage = u; renderUsage(); }
|
|
4245
|
+
// this read claimed the newest one: a pick it superseded had put the
|
|
4246
|
+
// skeleton up, and the figures on hand are what ends it
|
|
4247
|
+
else if ($("#view-usage").classList.contains("loading")) renderUsage();
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
await q;
|
|
4251
|
+
}
|
|
4252
|
+
usageReadAt = Date.now();
|
|
4253
|
+
renderUsageEvery();
|
|
4254
|
+
} catch {}
|
|
4255
|
+
};
|
|
4256
|
+
const flight = Promise.resolve().then(() => run(now)).then(() => {
|
|
4257
|
+
if (usageFlight !== flight) return;
|
|
4258
|
+
usageFlight = null;
|
|
4259
|
+
if (!usageQueued) return;
|
|
4260
|
+
const want = usageQueuedNow;
|
|
4261
|
+
usageQueued = usageQueuedNow = false;
|
|
4262
|
+
// one more read, the reader's own if either asked for it
|
|
4263
|
+
return refreshUsage(want);
|
|
4264
|
+
});
|
|
4265
|
+
usageFlight = flight;
|
|
4266
|
+
return flight;
|
|
4267
|
+
}
|
|
4268
|
+
renderUsageEvery();
|
|
4269
|
+
// the refresh is looked at each second only while the page is in sight: a
|
|
4270
|
+
// look with nothing to do wakes the web content process all the same
|
|
4271
|
+
// (#3185), so out of sight there is none until it is shown again
|
|
4272
|
+
let usageSecond = 0;
|
|
4273
|
+
function usageTicks() {
|
|
4274
|
+
if ($("#view-usage").hidden || document.hidden) { clearInterval(usageSecond); usageSecond = 0; return; }
|
|
4275
|
+
usageSecond ||= setInterval(() => {
|
|
4276
|
+
if ($("#view-usage").hidden || document.hidden) return usageTicks();
|
|
4277
|
+
if (!usageEvery || view !== "usage" || document.querySelector(".pop:not([hidden])")) return;
|
|
4278
|
+
if (performance.now() - usageLast >= usageEvery * 1000) refreshUsage();
|
|
4279
|
+
}, 1000);
|
|
4280
|
+
}
|
|
4281
|
+
new MutationObserver(usageTicks).observe($("#view-usage"), { attributes: true, attributeFilter: ["hidden"] });
|
|
4282
|
+
document.addEventListener("visibilitychange", usageTicks);
|
|
4283
|
+
window.addEventListener("focus", usageTicks);
|
|
4284
|
+
usageTicks();
|
|
4285
|
+
// opened from the tray panel's Usage tab: on the Requests of one provider or agent
|
|
4286
|
+
if (mode === "window" && params.get("view") === "usage") {
|
|
4287
|
+
// An explicit destination takes precedence over the remembered tab.
|
|
4288
|
+
const tab = params.get("tab");
|
|
4289
|
+
if (tab === "requests" || tab === "usage") usageTab = tab;
|
|
4290
|
+
// a total in the menu bar opens the Overview at its period
|
|
4291
|
+
const per = params.get("period");
|
|
4292
|
+
if (PERIODS.some(([id]) => id === per)) period = per;
|
|
4293
|
+
quotaFocus = usageTab === "usage" ? (params.get("card") || params.get("provider") || "") : "";
|
|
4294
|
+
usagePeek = quotaFocus;
|
|
4295
|
+
quotaFocusUntil = performance.now() + 5000;
|
|
4296
|
+
ledProvider = params.get("provider") || "";
|
|
4297
|
+
ledAgent = params.get("agent") || "";
|
|
4298
|
+
ledPurpose = params.get("purpose") || "";
|
|
4299
|
+
ledComputer = params.get("computer") || "";
|
|
4300
|
+
const v = params.get("via") || "";
|
|
4301
|
+
ledVia = v === "through" || v === "direct" || v === "reported" ? v : "";
|
|
4302
|
+
// a way in, not the reader's pick: they go once the Requests are left
|
|
4303
|
+
for (const [k, x] of [["provider", ledProvider], ["agent", ledAgent], ["purpose", ledPurpose], ["computer", ledComputer], ["via", ledVia]]) if (x) ledLent.add(k);
|
|
4304
|
+
const u = new URL(location.href);
|
|
4305
|
+
for (const k of ["tab", "provider", "agent", "computer", "card", "purpose", "via", "period"]) u.searchParams.delete(k);
|
|
4306
|
+
history.replaceState(null, "", u);
|
|
4307
|
+
}
|