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.
Files changed (350) hide show
  1. package/extensions/catalog.json +10 -1
  2. package/extensions/magpie/agentcompact.js +104 -0
  3. package/extensions/magpie/app.css +4902 -0
  4. package/extensions/magpie/app.js +24474 -0
  5. package/extensions/magpie/autobackup.js +185 -0
  6. package/extensions/magpie/autoresetown.js +151 -0
  7. package/extensions/magpie/balfound.js +38 -0
  8. package/extensions/magpie/boot.js +23 -0
  9. package/extensions/magpie/cfgblocked.js +39 -0
  10. package/extensions/magpie/checkinnow.js +111 -0
  11. package/extensions/magpie/claudemode.js +92 -0
  12. package/extensions/magpie/compat.js +41 -0
  13. package/extensions/magpie/context.css +433 -0
  14. package/extensions/magpie/context.js +1576 -0
  15. package/extensions/magpie/ctxtext.js +223 -0
  16. package/extensions/magpie/fonts.js +42 -0
  17. package/extensions/magpie/i18n.js +18942 -0
  18. package/extensions/magpie/icons/ai21.svg +1 -0
  19. package/extensions/magpie/icons/ai302-color.svg +1 -0
  20. package/extensions/magpie/icons/ai360-color.svg +1 -0
  21. package/extensions/magpie/icons/aihubmix-color.svg +1 -0
  22. package/extensions/magpie/icons/aionui.svg +1 -0
  23. package/extensions/magpie/icons/air.svg +4 -0
  24. package/extensions/magpie/icons/alibaba-color.svg +1 -0
  25. package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
  26. package/extensions/magpie/icons/alma.png +0 -0
  27. package/extensions/magpie/icons/anthropic.svg +1 -0
  28. package/extensions/magpie/icons/antigravity-color.svg +1 -0
  29. package/extensions/magpie/icons/anyscale-color.svg +1 -0
  30. package/extensions/magpie/icons/app-alma.png +0 -0
  31. package/extensions/magpie/icons/app-cc-switch.png +0 -0
  32. package/extensions/magpie/icons/apple-touch-icon.png +0 -0
  33. package/extensions/magpie/icons/aside.svg +28 -0
  34. package/extensions/magpie/icons/astrbot.svg +1 -0
  35. package/extensions/magpie/icons/atomcode.png +0 -0
  36. package/extensions/magpie/icons/aws.svg +1 -0
  37. package/extensions/magpie/icons/azure-color.svg +1 -0
  38. package/extensions/magpie/icons/azureai-color.svg +1 -0
  39. package/extensions/magpie/icons/baichuan-color.svg +1 -0
  40. package/extensions/magpie/icons/baidu-color.svg +1 -0
  41. package/extensions/magpie/icons/baiducloud-color.svg +1 -0
  42. package/extensions/magpie/icons/bailian-color.svg +1 -0
  43. package/extensions/magpie/icons/baseten.svg +1 -0
  44. package/extensions/magpie/icons/bedrock-color.svg +1 -0
  45. package/extensions/magpie/icons/burncloud-color.svg +1 -0
  46. package/extensions/magpie/icons/bytedance-color.svg +1 -0
  47. package/extensions/magpie/icons/cerebras.svg +1 -0
  48. package/extensions/magpie/icons/chatglm-color.svg +1 -0
  49. package/extensions/magpie/icons/cherryin-color.svg +1 -0
  50. package/extensions/magpie/icons/chutes.svg +1 -0
  51. package/extensions/magpie/icons/cindy.png +0 -0
  52. package/extensions/magpie/icons/claude-color.svg +1 -0
  53. package/extensions/magpie/icons/claudecode-color.svg +1 -0
  54. package/extensions/magpie/icons/cline.svg +1 -0
  55. package/extensions/magpie/icons/cloudflare-color.svg +1 -0
  56. package/extensions/magpie/icons/codebuddy-color.svg +1 -0
  57. package/extensions/magpie/icons/codex-color.svg +1 -0
  58. package/extensions/magpie/icons/cohere-color.svg +1 -0
  59. package/extensions/magpie/icons/cometapi-color.svg +1 -0
  60. package/extensions/magpie/icons/commandcode.svg +1 -0
  61. package/extensions/magpie/icons/copilot-color.svg +1 -0
  62. package/extensions/magpie/icons/coze.svg +1 -0
  63. package/extensions/magpie/icons/crush.png +0 -0
  64. package/extensions/magpie/icons/curl.svg +1 -0
  65. package/extensions/magpie/icons/cursor.svg +1 -0
  66. package/extensions/magpie/icons/deepinfra-color.svg +1 -0
  67. package/extensions/magpie/icons/deepmind-color.svg +1 -0
  68. package/extensions/magpie/icons/deepseek-color.svg +1 -0
  69. package/extensions/magpie/icons/deveco-color.svg +12 -0
  70. package/extensions/magpie/icons/devin.svg +1 -0
  71. package/extensions/magpie/icons/doubao-color.svg +1 -0
  72. package/extensions/magpie/icons/empryo-color.svg +1 -0
  73. package/extensions/magpie/icons/factory.svg +1 -0
  74. package/extensions/magpie/icons/favicon.svg +1 -0
  75. package/extensions/magpie/icons/featherless-color.svg +1 -0
  76. package/extensions/magpie/icons/fireworks-color.svg +1 -0
  77. package/extensions/magpie/icons/friendli.svg +1 -0
  78. package/extensions/magpie/icons/fx.svg +1 -0
  79. package/extensions/magpie/icons/gemini-color.svg +1 -0
  80. package/extensions/magpie/icons/geminicli-color.svg +1 -0
  81. package/extensions/magpie/icons/gemma-color.svg +1 -0
  82. package/extensions/magpie/icons/giteeai.svg +1 -0
  83. package/extensions/magpie/icons/github.svg +1 -0
  84. package/extensions/magpie/icons/githubcopilot.svg +1 -0
  85. package/extensions/magpie/icons/glama.svg +1 -0
  86. package/extensions/magpie/icons/gmicloud.svg +1 -0
  87. package/extensions/magpie/icons/google-color.svg +1 -0
  88. package/extensions/magpie/icons/googlecloud-color.svg +1 -0
  89. package/extensions/magpie/icons/goose.svg +1 -0
  90. package/extensions/magpie/icons/grok.svg +1 -0
  91. package/extensions/magpie/icons/groq.svg +1 -0
  92. package/extensions/magpie/icons/hanako.png +0 -0
  93. package/extensions/magpie/icons/hermes.svg +1 -0
  94. package/extensions/magpie/icons/huawei-color.svg +1 -0
  95. package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
  96. package/extensions/magpie/icons/huggingface-color.svg +1 -0
  97. package/extensions/magpie/icons/hunyuan-color.svg +1 -0
  98. package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
  99. package/extensions/magpie/icons/icon-512.png +0 -0
  100. package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
  101. package/extensions/magpie/icons/inference.svg +1 -0
  102. package/extensions/magpie/icons/infinigence-color.svg +1 -0
  103. package/extensions/magpie/icons/internlm-color.svg +1 -0
  104. package/extensions/magpie/icons/jcode.svg +1 -0
  105. package/extensions/magpie/icons/jina.svg +1 -0
  106. package/extensions/magpie/icons/kilo.svg +1 -0
  107. package/extensions/magpie/icons/kilocode.svg +1 -0
  108. package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
  109. package/extensions/magpie/icons/kimi.svg +1 -0
  110. package/extensions/magpie/icons/kiro-color.svg +1 -0
  111. package/extensions/magpie/icons/kling-color.svg +1 -0
  112. package/extensions/magpie/icons/lambda.svg +1 -0
  113. package/extensions/magpie/icons/leptonai-color.svg +1 -0
  114. package/extensions/magpie/icons/liquid.svg +1 -0
  115. package/extensions/magpie/icons/liveagent-color.svg +1 -0
  116. package/extensions/magpie/icons/llmapi.svg +1 -0
  117. package/extensions/magpie/icons/lmstudio.svg +1 -0
  118. package/extensions/magpie/icons/lody.png +0 -0
  119. package/extensions/magpie/icons/longcat.svg +1 -0
  120. package/extensions/magpie/icons/magpie.svg +1 -0
  121. package/extensions/magpie/icons/meta-color.svg +1 -0
  122. package/extensions/magpie/icons/metaai-color.svg +1 -0
  123. package/extensions/magpie/icons/microsoft-color.svg +1 -0
  124. package/extensions/magpie/icons/mimocode.svg +1 -0
  125. package/extensions/magpie/icons/minimax-color.svg +1 -0
  126. package/extensions/magpie/icons/mistermorph-color.svg +1 -0
  127. package/extensions/magpie/icons/mistral-color.svg +1 -0
  128. package/extensions/magpie/icons/mlx-serve.png +0 -0
  129. package/extensions/magpie/icons/modelscope-color.svg +1 -0
  130. package/extensions/magpie/icons/monica-color.svg +1 -0
  131. package/extensions/magpie/icons/moonshot.svg +1 -0
  132. package/extensions/magpie/icons/morph-color.svg +1 -0
  133. package/extensions/magpie/icons/mothx.png +0 -0
  134. package/extensions/magpie/icons/nebius.svg +1 -0
  135. package/extensions/magpie/icons/newapi-color.svg +1 -0
  136. package/extensions/magpie/icons/nousresearch.svg +1 -0
  137. package/extensions/magpie/icons/nova-color.svg +1 -0
  138. package/extensions/magpie/icons/novita-color.svg +1 -0
  139. package/extensions/magpie/icons/nvidia-color.svg +1 -0
  140. package/extensions/magpie/icons/ollama.svg +1 -0
  141. package/extensions/magpie/icons/omlx.svg +1 -0
  142. package/extensions/magpie/icons/omo.svg +1 -0
  143. package/extensions/magpie/icons/omp.svg +1 -0
  144. package/extensions/magpie/icons/openai.svg +1 -0
  145. package/extensions/magpie/icons/openchamber.svg +11 -0
  146. package/extensions/magpie/icons/openclaw.svg +1 -0
  147. package/extensions/magpie/icons/opencode.svg +1 -0
  148. package/extensions/magpie/icons/openmausbot.svg +1 -0
  149. package/extensions/magpie/icons/openrouter.svg +1 -0
  150. package/extensions/magpie/icons/openwebui.svg +1 -0
  151. package/extensions/magpie/icons/parasail.svg +1 -0
  152. package/extensions/magpie/icons/penguin-color.svg +1 -0
  153. package/extensions/magpie/icons/perplexity-color.svg +1 -0
  154. package/extensions/magpie/icons/phala-color.svg +1 -0
  155. package/extensions/magpie/icons/pi.svg +1 -0
  156. package/extensions/magpie/icons/pipellm-color.svg +5 -0
  157. package/extensions/magpie/icons/poe.svg +1 -0
  158. package/extensions/magpie/icons/ppio-color.svg +1 -0
  159. package/extensions/magpie/icons/prime-agent.svg +1 -0
  160. package/extensions/magpie/icons/qiniu-color.svg +1 -0
  161. package/extensions/magpie/icons/qoder.svg +1 -0
  162. package/extensions/magpie/icons/qwen-color.svg +1 -0
  163. package/extensions/magpie/icons/qwenpaw.svg +1 -0
  164. package/extensions/magpie/icons/raycast.svg +1 -0
  165. package/extensions/magpie/icons/reasonix-color.svg +48 -0
  166. package/extensions/magpie/icons/reka.svg +1 -0
  167. package/extensions/magpie/icons/replicate.svg +1 -0
  168. package/extensions/magpie/icons/roocode.svg +1 -0
  169. package/extensions/magpie/icons/rtk.png +0 -0
  170. package/extensions/magpie/icons/sambanova-color.svg +1 -0
  171. package/extensions/magpie/icons/sensenova-color.svg +1 -0
  172. package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
  173. package/extensions/magpie/icons/snow.png +0 -0
  174. package/extensions/magpie/icons/snowflake-color.svg +1 -0
  175. package/extensions/magpie/icons/sophnet-color.svg +1 -0
  176. package/extensions/magpie/icons/spark-color.svg +1 -0
  177. package/extensions/magpie/icons/stepfun-color.svg +1 -0
  178. package/extensions/magpie/icons/straico-color.svg +1 -0
  179. package/extensions/magpie/icons/submodel-color.svg +1 -0
  180. package/extensions/magpie/icons/t3code.svg +4 -0
  181. package/extensions/magpie/icons/targon-color.svg +1 -0
  182. package/extensions/magpie/icons/tencent-color.svg +1 -0
  183. package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
  184. package/extensions/magpie/icons/tiangong-color.svg +1 -0
  185. package/extensions/magpie/icons/together-color.svg +1 -0
  186. package/extensions/magpie/icons/trae.svg +1 -0
  187. package/extensions/magpie/icons/typesafe.png +0 -0
  188. package/extensions/magpie/icons/upstage-color.svg +1 -0
  189. package/extensions/magpie/icons/venice-color.svg +1 -0
  190. package/extensions/magpie/icons/vercel.svg +1 -0
  191. package/extensions/magpie/icons/vertexai-color.svg +1 -0
  192. package/extensions/magpie/icons/vllm-color.svg +1 -0
  193. package/extensions/magpie/icons/volcengine-color.svg +1 -0
  194. package/extensions/magpie/icons/voyage-color.svg +1 -0
  195. package/extensions/magpie/icons/vscode.svg +1 -0
  196. package/extensions/magpie/icons/wenxin-color.svg +1 -0
  197. package/extensions/magpie/icons/windsurf.svg +1 -0
  198. package/extensions/magpie/icons/workbuddy-color.svg +1 -0
  199. package/extensions/magpie/icons/workersai-color.svg +1 -0
  200. package/extensions/magpie/icons/xai.svg +1 -0
  201. package/extensions/magpie/icons/xiaomimimo.svg +1 -0
  202. package/extensions/magpie/icons/xinference-color.svg +1 -0
  203. package/extensions/magpie/icons/yi.svg +1 -0
  204. package/extensions/magpie/icons/yuanbao-color.svg +1 -0
  205. package/extensions/magpie/icons/yylx.svg +5 -0
  206. package/extensions/magpie/icons/zai.svg +1 -0
  207. package/extensions/magpie/icons/zcode.png +0 -0
  208. package/extensions/magpie/icons/zed.svg +1 -0
  209. package/extensions/magpie/icons/zenmux.svg +1 -0
  210. package/extensions/magpie/icons/zeroone.svg +1 -0
  211. package/extensions/magpie/icons/zhipu-color.svg +1 -0
  212. package/extensions/magpie/icons/zoo-code.png +0 -0
  213. package/extensions/magpie/index.html +572 -0
  214. package/extensions/magpie/kernel.js +274 -0
  215. package/extensions/magpie/keyspend.js +63 -0
  216. package/extensions/magpie/layout.js +26 -0
  217. package/extensions/magpie/manifest.json +37 -0
  218. package/extensions/magpie/navlamp.js +47 -0
  219. package/extensions/magpie/omarchy.css +72 -0
  220. package/extensions/magpie/omarchy.js +46 -0
  221. package/extensions/magpie/passerrors.js +21 -0
  222. package/extensions/magpie/pluginpages.js +405 -0
  223. package/extensions/magpie/plugins/gateway/client.js +1194 -0
  224. package/extensions/magpie/plugins/gateway/keys.js +972 -0
  225. package/extensions/magpie/plugins/gateway/settings.js +459 -0
  226. package/extensions/magpie/plugins/library/client.js +6788 -0
  227. package/extensions/magpie/plugins/library/i18n.js +3228 -0
  228. package/extensions/magpie/plugins/library/library.css +848 -0
  229. package/extensions/magpie/plugins/otel/i18n.js +48 -0
  230. package/extensions/magpie/plugins/otel/page.js +189 -0
  231. package/extensions/magpie/plugins/redact/client.js +46 -0
  232. package/extensions/magpie/plugins/redact/demo.js +461 -0
  233. package/extensions/magpie/plugins/redact/i18n.js +432 -0
  234. package/extensions/magpie/plugins/redact/page.js +985 -0
  235. package/extensions/magpie/plugins/redact/redact.css +385 -0
  236. package/extensions/magpie/plugins/redact/settings.js +248 -0
  237. package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
  238. package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
  239. package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
  240. package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
  241. package/extensions/magpie/plugins/routing/client.js +8593 -0
  242. package/extensions/magpie/plugins/routing/i18n.js +2696 -0
  243. package/extensions/magpie/plugins/routing/routing.css +1111 -0
  244. package/extensions/magpie/plugins/search/settings.js +460 -0
  245. package/extensions/magpie/plugins/sessions/client.js +1622 -0
  246. package/extensions/magpie/plugins/sessions/i18n.js +360 -0
  247. package/extensions/magpie/plugins/sessions/sessions.css +181 -0
  248. package/extensions/magpie/plugins/sessions/settings.js +100 -0
  249. package/extensions/magpie/plugins/usage/client.js +150 -0
  250. package/extensions/magpie/plugins/usage/i18n.js +984 -0
  251. package/extensions/magpie/plugins/usage/page.js +4307 -0
  252. package/extensions/magpie/plugins.css +319 -0
  253. package/extensions/magpie/plugins.js +1926 -0
  254. package/extensions/magpie/profilerename.js +27 -0
  255. package/extensions/magpie/purposes.js +2 -0
  256. package/extensions/magpie/quitask.js +79 -0
  257. package/extensions/magpie/quotaall.js +142 -0
  258. package/extensions/magpie/ratewindows.js +123 -0
  259. package/extensions/magpie/registry.js +87 -0
  260. package/extensions/magpie/resetask.js +137 -0
  261. package/extensions/magpie/ruleedit.js +53 -0
  262. package/extensions/magpie/scrollends.js +91 -0
  263. package/extensions/magpie/settingshints.js +75 -0
  264. package/extensions/magpie/signsheets.js +86 -0
  265. package/extensions/magpie/start.js +26 -0
  266. package/extensions/magpie/syncfiles.js +216 -0
  267. package/extensions/magpie/theme.js +29 -0
  268. package/extensions/magpie/traywindows.js +41 -0
  269. package/extensions/magpie/webfonts.js +130 -0
  270. package/extensions/magpie/whatsnew.js +252 -0
  271. package/extensions/magpie.zip +0 -0
  272. package/knowledge/apps/baijiahao.md +30 -0
  273. package/knowledge/apps/bilibili.md +1 -0
  274. package/knowledge/apps/dedao.md +33 -0
  275. package/knowledge/apps/feishu.md +35 -0
  276. package/knowledge/apps/gmail.md +33 -0
  277. package/knowledge/apps/jike.md +31 -0
  278. package/knowledge/apps/kuaishou.md +34 -0
  279. package/knowledge/apps/linkedin.md +33 -0
  280. package/knowledge/apps/qqmail.md +30 -0
  281. package/knowledge/apps/telegram.md +27 -0
  282. package/knowledge/apps/toutiao.md +32 -0
  283. package/knowledge/apps/uber-eats.md +26 -0
  284. package/knowledge/apps/uber.md +27 -0
  285. package/knowledge/apps/whatsapp.md +27 -0
  286. package/knowledge/apps/x.md +32 -0
  287. package/knowledge/apps/xiaohongshu.md +1 -0
  288. package/knowledge/apps/zhihu.md +1 -0
  289. package/knowledge/sites/bilibili.com/SITE.md +50 -0
  290. package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
  291. package/knowledge/sites/github.com/SITE.md +51 -0
  292. package/knowledge/sites/github.com/references/repo-fields.md +13 -0
  293. package/knowledge/sites/x.com/SITE.md +47 -0
  294. package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
  295. package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
  296. package/knowledge/sites/youtube.com/SITE.md +49 -0
  297. package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
  298. package/knowledge/sites/zhihu.com/SITE.md +47 -0
  299. package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
  300. package/lib/mobile/extension-catalog.js +41 -1
  301. package/lib/mobile/extension-data.js +224 -0
  302. package/lib/mobile/extension-dev.js +3 -1
  303. package/lib/mobile/file-ops.js +252 -0
  304. package/lib/mobile/history-derive.js +80 -4
  305. package/lib/mobile/host-bridge/endpoints.js +48 -6
  306. package/lib/mobile/host-bridge/event-synthesis.js +33 -6
  307. package/lib/mobile/jobs-control.js +118 -0
  308. package/lib/mobile/memory.js +635 -0
  309. package/lib/mobile/model-badges.js +77 -0
  310. package/lib/mobile/pet-tools.js +50 -4
  311. package/lib/mobile/pets.js +214 -18
  312. package/lib/mobile/phone/access.js +85 -0
  313. package/lib/mobile/phone/evidence.js +228 -0
  314. package/lib/mobile/phone/gate.js +290 -0
  315. package/lib/mobile/phone/grounding.js +314 -0
  316. package/lib/mobile/phone/guard.js +465 -0
  317. package/lib/mobile/phone/irreversible.js +88 -8
  318. package/lib/mobile/phone/knowledge-tools.js +665 -0
  319. package/lib/mobile/phone/learn.js +329 -0
  320. package/lib/mobile/phone/redact.js +237 -0
  321. package/lib/mobile/phone/risk.js +95 -0
  322. package/lib/mobile/phone/routes.js +28 -1
  323. package/lib/mobile/phone/service.js +23 -0
  324. package/lib/mobile/phone/site-commands.js +271 -0
  325. package/lib/mobile/phone/step-summary.js +201 -0
  326. package/lib/mobile/phone/stop-summary.js +235 -0
  327. package/lib/mobile/phone/tool-groups.js +125 -0
  328. package/lib/mobile/phone/tools.js +589 -0
  329. package/lib/mobile/phone/untrusted.js +65 -0
  330. package/lib/mobile/phone-knowledge.js +20 -4
  331. package/lib/mobile/schedule-edit.js +500 -0
  332. package/lib/mobile/schedule.js +5 -0
  333. package/lib/mobile/send-continuation.js +52 -0
  334. package/lib/mobile/session-models.js +27 -31
  335. package/lib/mobile/session-send.js +12 -12
  336. package/lib/mobile/skill-import.js +338 -0
  337. package/lib/mobile/skill-store.js +524 -0
  338. package/lib/routes/mobile-api.js +42 -0
  339. package/package.json +7 -6
  340. package/skills/skill-authoring/SKILL.md +46 -0
  341. package/skills/skill-authoring/references/examples.md +82 -0
  342. package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
  343. package/skills/skill-authoring/scripts/check_skill.py +167 -0
  344. package/skills/skill-authoring/scripts/manifest.json +3 -0
  345. package/knowledge/sites/bilibili.com.md +0 -27
  346. package/knowledge/sites/github.com.md +0 -27
  347. package/knowledge/sites/x.com.md +0 -28
  348. package/knowledge/sites/xiaohongshu.com.md +0 -27
  349. package/knowledge/sites/youtube.com.md +0 -28
  350. 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
+ }