dsh-mobile-plugin 0.5.28 → 0.5.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/catalog.json +10 -1
- package/extensions/magpie/agentcompact.js +104 -0
- package/extensions/magpie/app.css +4902 -0
- package/extensions/magpie/app.js +24474 -0
- package/extensions/magpie/autobackup.js +185 -0
- package/extensions/magpie/autoresetown.js +151 -0
- package/extensions/magpie/balfound.js +38 -0
- package/extensions/magpie/boot.js +23 -0
- package/extensions/magpie/cfgblocked.js +39 -0
- package/extensions/magpie/checkinnow.js +111 -0
- package/extensions/magpie/claudemode.js +92 -0
- package/extensions/magpie/compat.js +41 -0
- package/extensions/magpie/context.css +433 -0
- package/extensions/magpie/context.js +1576 -0
- package/extensions/magpie/ctxtext.js +223 -0
- package/extensions/magpie/fonts.js +42 -0
- package/extensions/magpie/i18n.js +18942 -0
- package/extensions/magpie/icons/ai21.svg +1 -0
- package/extensions/magpie/icons/ai302-color.svg +1 -0
- package/extensions/magpie/icons/ai360-color.svg +1 -0
- package/extensions/magpie/icons/aihubmix-color.svg +1 -0
- package/extensions/magpie/icons/aionui.svg +1 -0
- package/extensions/magpie/icons/air.svg +4 -0
- package/extensions/magpie/icons/alibaba-color.svg +1 -0
- package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
- package/extensions/magpie/icons/alma.png +0 -0
- package/extensions/magpie/icons/anthropic.svg +1 -0
- package/extensions/magpie/icons/antigravity-color.svg +1 -0
- package/extensions/magpie/icons/anyscale-color.svg +1 -0
- package/extensions/magpie/icons/app-alma.png +0 -0
- package/extensions/magpie/icons/app-cc-switch.png +0 -0
- package/extensions/magpie/icons/apple-touch-icon.png +0 -0
- package/extensions/magpie/icons/aside.svg +28 -0
- package/extensions/magpie/icons/astrbot.svg +1 -0
- package/extensions/magpie/icons/atomcode.png +0 -0
- package/extensions/magpie/icons/aws.svg +1 -0
- package/extensions/magpie/icons/azure-color.svg +1 -0
- package/extensions/magpie/icons/azureai-color.svg +1 -0
- package/extensions/magpie/icons/baichuan-color.svg +1 -0
- package/extensions/magpie/icons/baidu-color.svg +1 -0
- package/extensions/magpie/icons/baiducloud-color.svg +1 -0
- package/extensions/magpie/icons/bailian-color.svg +1 -0
- package/extensions/magpie/icons/baseten.svg +1 -0
- package/extensions/magpie/icons/bedrock-color.svg +1 -0
- package/extensions/magpie/icons/burncloud-color.svg +1 -0
- package/extensions/magpie/icons/bytedance-color.svg +1 -0
- package/extensions/magpie/icons/cerebras.svg +1 -0
- package/extensions/magpie/icons/chatglm-color.svg +1 -0
- package/extensions/magpie/icons/cherryin-color.svg +1 -0
- package/extensions/magpie/icons/chutes.svg +1 -0
- package/extensions/magpie/icons/cindy.png +0 -0
- package/extensions/magpie/icons/claude-color.svg +1 -0
- package/extensions/magpie/icons/claudecode-color.svg +1 -0
- package/extensions/magpie/icons/cline.svg +1 -0
- package/extensions/magpie/icons/cloudflare-color.svg +1 -0
- package/extensions/magpie/icons/codebuddy-color.svg +1 -0
- package/extensions/magpie/icons/codex-color.svg +1 -0
- package/extensions/magpie/icons/cohere-color.svg +1 -0
- package/extensions/magpie/icons/cometapi-color.svg +1 -0
- package/extensions/magpie/icons/commandcode.svg +1 -0
- package/extensions/magpie/icons/copilot-color.svg +1 -0
- package/extensions/magpie/icons/coze.svg +1 -0
- package/extensions/magpie/icons/crush.png +0 -0
- package/extensions/magpie/icons/curl.svg +1 -0
- package/extensions/magpie/icons/cursor.svg +1 -0
- package/extensions/magpie/icons/deepinfra-color.svg +1 -0
- package/extensions/magpie/icons/deepmind-color.svg +1 -0
- package/extensions/magpie/icons/deepseek-color.svg +1 -0
- package/extensions/magpie/icons/deveco-color.svg +12 -0
- package/extensions/magpie/icons/devin.svg +1 -0
- package/extensions/magpie/icons/doubao-color.svg +1 -0
- package/extensions/magpie/icons/empryo-color.svg +1 -0
- package/extensions/magpie/icons/factory.svg +1 -0
- package/extensions/magpie/icons/favicon.svg +1 -0
- package/extensions/magpie/icons/featherless-color.svg +1 -0
- package/extensions/magpie/icons/fireworks-color.svg +1 -0
- package/extensions/magpie/icons/friendli.svg +1 -0
- package/extensions/magpie/icons/fx.svg +1 -0
- package/extensions/magpie/icons/gemini-color.svg +1 -0
- package/extensions/magpie/icons/geminicli-color.svg +1 -0
- package/extensions/magpie/icons/gemma-color.svg +1 -0
- package/extensions/magpie/icons/giteeai.svg +1 -0
- package/extensions/magpie/icons/github.svg +1 -0
- package/extensions/magpie/icons/githubcopilot.svg +1 -0
- package/extensions/magpie/icons/glama.svg +1 -0
- package/extensions/magpie/icons/gmicloud.svg +1 -0
- package/extensions/magpie/icons/google-color.svg +1 -0
- package/extensions/magpie/icons/googlecloud-color.svg +1 -0
- package/extensions/magpie/icons/goose.svg +1 -0
- package/extensions/magpie/icons/grok.svg +1 -0
- package/extensions/magpie/icons/groq.svg +1 -0
- package/extensions/magpie/icons/hanako.png +0 -0
- package/extensions/magpie/icons/hermes.svg +1 -0
- package/extensions/magpie/icons/huawei-color.svg +1 -0
- package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
- package/extensions/magpie/icons/huggingface-color.svg +1 -0
- package/extensions/magpie/icons/hunyuan-color.svg +1 -0
- package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
- package/extensions/magpie/icons/icon-512.png +0 -0
- package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
- package/extensions/magpie/icons/inference.svg +1 -0
- package/extensions/magpie/icons/infinigence-color.svg +1 -0
- package/extensions/magpie/icons/internlm-color.svg +1 -0
- package/extensions/magpie/icons/jcode.svg +1 -0
- package/extensions/magpie/icons/jina.svg +1 -0
- package/extensions/magpie/icons/kilo.svg +1 -0
- package/extensions/magpie/icons/kilocode.svg +1 -0
- package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
- package/extensions/magpie/icons/kimi.svg +1 -0
- package/extensions/magpie/icons/kiro-color.svg +1 -0
- package/extensions/magpie/icons/kling-color.svg +1 -0
- package/extensions/magpie/icons/lambda.svg +1 -0
- package/extensions/magpie/icons/leptonai-color.svg +1 -0
- package/extensions/magpie/icons/liquid.svg +1 -0
- package/extensions/magpie/icons/liveagent-color.svg +1 -0
- package/extensions/magpie/icons/llmapi.svg +1 -0
- package/extensions/magpie/icons/lmstudio.svg +1 -0
- package/extensions/magpie/icons/lody.png +0 -0
- package/extensions/magpie/icons/longcat.svg +1 -0
- package/extensions/magpie/icons/magpie.svg +1 -0
- package/extensions/magpie/icons/meta-color.svg +1 -0
- package/extensions/magpie/icons/metaai-color.svg +1 -0
- package/extensions/magpie/icons/microsoft-color.svg +1 -0
- package/extensions/magpie/icons/mimocode.svg +1 -0
- package/extensions/magpie/icons/minimax-color.svg +1 -0
- package/extensions/magpie/icons/mistermorph-color.svg +1 -0
- package/extensions/magpie/icons/mistral-color.svg +1 -0
- package/extensions/magpie/icons/mlx-serve.png +0 -0
- package/extensions/magpie/icons/modelscope-color.svg +1 -0
- package/extensions/magpie/icons/monica-color.svg +1 -0
- package/extensions/magpie/icons/moonshot.svg +1 -0
- package/extensions/magpie/icons/morph-color.svg +1 -0
- package/extensions/magpie/icons/mothx.png +0 -0
- package/extensions/magpie/icons/nebius.svg +1 -0
- package/extensions/magpie/icons/newapi-color.svg +1 -0
- package/extensions/magpie/icons/nousresearch.svg +1 -0
- package/extensions/magpie/icons/nova-color.svg +1 -0
- package/extensions/magpie/icons/novita-color.svg +1 -0
- package/extensions/magpie/icons/nvidia-color.svg +1 -0
- package/extensions/magpie/icons/ollama.svg +1 -0
- package/extensions/magpie/icons/omlx.svg +1 -0
- package/extensions/magpie/icons/omo.svg +1 -0
- package/extensions/magpie/icons/omp.svg +1 -0
- package/extensions/magpie/icons/openai.svg +1 -0
- package/extensions/magpie/icons/openchamber.svg +11 -0
- package/extensions/magpie/icons/openclaw.svg +1 -0
- package/extensions/magpie/icons/opencode.svg +1 -0
- package/extensions/magpie/icons/openmausbot.svg +1 -0
- package/extensions/magpie/icons/openrouter.svg +1 -0
- package/extensions/magpie/icons/openwebui.svg +1 -0
- package/extensions/magpie/icons/parasail.svg +1 -0
- package/extensions/magpie/icons/penguin-color.svg +1 -0
- package/extensions/magpie/icons/perplexity-color.svg +1 -0
- package/extensions/magpie/icons/phala-color.svg +1 -0
- package/extensions/magpie/icons/pi.svg +1 -0
- package/extensions/magpie/icons/pipellm-color.svg +5 -0
- package/extensions/magpie/icons/poe.svg +1 -0
- package/extensions/magpie/icons/ppio-color.svg +1 -0
- package/extensions/magpie/icons/prime-agent.svg +1 -0
- package/extensions/magpie/icons/qiniu-color.svg +1 -0
- package/extensions/magpie/icons/qoder.svg +1 -0
- package/extensions/magpie/icons/qwen-color.svg +1 -0
- package/extensions/magpie/icons/qwenpaw.svg +1 -0
- package/extensions/magpie/icons/raycast.svg +1 -0
- package/extensions/magpie/icons/reasonix-color.svg +48 -0
- package/extensions/magpie/icons/reka.svg +1 -0
- package/extensions/magpie/icons/replicate.svg +1 -0
- package/extensions/magpie/icons/roocode.svg +1 -0
- package/extensions/magpie/icons/rtk.png +0 -0
- package/extensions/magpie/icons/sambanova-color.svg +1 -0
- package/extensions/magpie/icons/sensenova-color.svg +1 -0
- package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
- package/extensions/magpie/icons/snow.png +0 -0
- package/extensions/magpie/icons/snowflake-color.svg +1 -0
- package/extensions/magpie/icons/sophnet-color.svg +1 -0
- package/extensions/magpie/icons/spark-color.svg +1 -0
- package/extensions/magpie/icons/stepfun-color.svg +1 -0
- package/extensions/magpie/icons/straico-color.svg +1 -0
- package/extensions/magpie/icons/submodel-color.svg +1 -0
- package/extensions/magpie/icons/t3code.svg +4 -0
- package/extensions/magpie/icons/targon-color.svg +1 -0
- package/extensions/magpie/icons/tencent-color.svg +1 -0
- package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
- package/extensions/magpie/icons/tiangong-color.svg +1 -0
- package/extensions/magpie/icons/together-color.svg +1 -0
- package/extensions/magpie/icons/trae.svg +1 -0
- package/extensions/magpie/icons/typesafe.png +0 -0
- package/extensions/magpie/icons/upstage-color.svg +1 -0
- package/extensions/magpie/icons/venice-color.svg +1 -0
- package/extensions/magpie/icons/vercel.svg +1 -0
- package/extensions/magpie/icons/vertexai-color.svg +1 -0
- package/extensions/magpie/icons/vllm-color.svg +1 -0
- package/extensions/magpie/icons/volcengine-color.svg +1 -0
- package/extensions/magpie/icons/voyage-color.svg +1 -0
- package/extensions/magpie/icons/vscode.svg +1 -0
- package/extensions/magpie/icons/wenxin-color.svg +1 -0
- package/extensions/magpie/icons/windsurf.svg +1 -0
- package/extensions/magpie/icons/workbuddy-color.svg +1 -0
- package/extensions/magpie/icons/workersai-color.svg +1 -0
- package/extensions/magpie/icons/xai.svg +1 -0
- package/extensions/magpie/icons/xiaomimimo.svg +1 -0
- package/extensions/magpie/icons/xinference-color.svg +1 -0
- package/extensions/magpie/icons/yi.svg +1 -0
- package/extensions/magpie/icons/yuanbao-color.svg +1 -0
- package/extensions/magpie/icons/yylx.svg +5 -0
- package/extensions/magpie/icons/zai.svg +1 -0
- package/extensions/magpie/icons/zcode.png +0 -0
- package/extensions/magpie/icons/zed.svg +1 -0
- package/extensions/magpie/icons/zenmux.svg +1 -0
- package/extensions/magpie/icons/zeroone.svg +1 -0
- package/extensions/magpie/icons/zhipu-color.svg +1 -0
- package/extensions/magpie/icons/zoo-code.png +0 -0
- package/extensions/magpie/index.html +572 -0
- package/extensions/magpie/kernel.js +274 -0
- package/extensions/magpie/keyspend.js +63 -0
- package/extensions/magpie/layout.js +26 -0
- package/extensions/magpie/manifest.json +37 -0
- package/extensions/magpie/navlamp.js +47 -0
- package/extensions/magpie/omarchy.css +72 -0
- package/extensions/magpie/omarchy.js +46 -0
- package/extensions/magpie/passerrors.js +21 -0
- package/extensions/magpie/pluginpages.js +405 -0
- package/extensions/magpie/plugins/gateway/client.js +1194 -0
- package/extensions/magpie/plugins/gateway/keys.js +972 -0
- package/extensions/magpie/plugins/gateway/settings.js +459 -0
- package/extensions/magpie/plugins/library/client.js +6788 -0
- package/extensions/magpie/plugins/library/i18n.js +3228 -0
- package/extensions/magpie/plugins/library/library.css +848 -0
- package/extensions/magpie/plugins/otel/i18n.js +48 -0
- package/extensions/magpie/plugins/otel/page.js +189 -0
- package/extensions/magpie/plugins/redact/client.js +46 -0
- package/extensions/magpie/plugins/redact/demo.js +461 -0
- package/extensions/magpie/plugins/redact/i18n.js +432 -0
- package/extensions/magpie/plugins/redact/page.js +985 -0
- package/extensions/magpie/plugins/redact/redact.css +385 -0
- package/extensions/magpie/plugins/redact/settings.js +248 -0
- package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
- package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
- package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
- package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
- package/extensions/magpie/plugins/routing/client.js +8593 -0
- package/extensions/magpie/plugins/routing/i18n.js +2696 -0
- package/extensions/magpie/plugins/routing/routing.css +1111 -0
- package/extensions/magpie/plugins/search/settings.js +460 -0
- package/extensions/magpie/plugins/sessions/client.js +1622 -0
- package/extensions/magpie/plugins/sessions/i18n.js +360 -0
- package/extensions/magpie/plugins/sessions/sessions.css +181 -0
- package/extensions/magpie/plugins/sessions/settings.js +100 -0
- package/extensions/magpie/plugins/usage/client.js +150 -0
- package/extensions/magpie/plugins/usage/i18n.js +984 -0
- package/extensions/magpie/plugins/usage/page.js +4307 -0
- package/extensions/magpie/plugins.css +319 -0
- package/extensions/magpie/plugins.js +1926 -0
- package/extensions/magpie/profilerename.js +27 -0
- package/extensions/magpie/purposes.js +2 -0
- package/extensions/magpie/quitask.js +79 -0
- package/extensions/magpie/quotaall.js +142 -0
- package/extensions/magpie/ratewindows.js +123 -0
- package/extensions/magpie/registry.js +87 -0
- package/extensions/magpie/resetask.js +137 -0
- package/extensions/magpie/ruleedit.js +53 -0
- package/extensions/magpie/scrollends.js +91 -0
- package/extensions/magpie/settingshints.js +75 -0
- package/extensions/magpie/signsheets.js +86 -0
- package/extensions/magpie/start.js +26 -0
- package/extensions/magpie/syncfiles.js +216 -0
- package/extensions/magpie/theme.js +29 -0
- package/extensions/magpie/traywindows.js +41 -0
- package/extensions/magpie/webfonts.js +130 -0
- package/extensions/magpie/whatsnew.js +252 -0
- package/extensions/magpie.zip +0 -0
- package/knowledge/apps/baijiahao.md +30 -0
- package/knowledge/apps/bilibili.md +1 -0
- package/knowledge/apps/dedao.md +33 -0
- package/knowledge/apps/feishu.md +35 -0
- package/knowledge/apps/gmail.md +33 -0
- package/knowledge/apps/jike.md +31 -0
- package/knowledge/apps/kuaishou.md +34 -0
- package/knowledge/apps/linkedin.md +33 -0
- package/knowledge/apps/qqmail.md +30 -0
- package/knowledge/apps/telegram.md +27 -0
- package/knowledge/apps/toutiao.md +32 -0
- package/knowledge/apps/uber-eats.md +26 -0
- package/knowledge/apps/uber.md +27 -0
- package/knowledge/apps/whatsapp.md +27 -0
- package/knowledge/apps/x.md +32 -0
- package/knowledge/apps/xiaohongshu.md +1 -0
- package/knowledge/apps/zhihu.md +1 -0
- package/knowledge/sites/bilibili.com/SITE.md +50 -0
- package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
- package/knowledge/sites/github.com/SITE.md +51 -0
- package/knowledge/sites/github.com/references/repo-fields.md +13 -0
- package/knowledge/sites/x.com/SITE.md +47 -0
- package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
- package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
- package/knowledge/sites/youtube.com/SITE.md +49 -0
- package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
- package/knowledge/sites/zhihu.com/SITE.md +47 -0
- package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
- package/lib/mobile/extension-catalog.js +41 -1
- package/lib/mobile/extension-data.js +224 -0
- package/lib/mobile/extension-dev.js +3 -1
- package/lib/mobile/file-ops.js +252 -0
- package/lib/mobile/history-derive.js +80 -4
- package/lib/mobile/host-bridge/endpoints.js +48 -6
- package/lib/mobile/host-bridge/event-synthesis.js +33 -6
- package/lib/mobile/jobs-control.js +118 -0
- package/lib/mobile/memory.js +635 -0
- package/lib/mobile/model-badges.js +77 -0
- package/lib/mobile/pet-tools.js +50 -4
- package/lib/mobile/pets.js +214 -18
- package/lib/mobile/phone/access.js +85 -0
- package/lib/mobile/phone/evidence.js +228 -0
- package/lib/mobile/phone/gate.js +290 -0
- package/lib/mobile/phone/grounding.js +314 -0
- package/lib/mobile/phone/guard.js +465 -0
- package/lib/mobile/phone/irreversible.js +88 -8
- package/lib/mobile/phone/knowledge-tools.js +665 -0
- package/lib/mobile/phone/learn.js +329 -0
- package/lib/mobile/phone/redact.js +237 -0
- package/lib/mobile/phone/risk.js +95 -0
- package/lib/mobile/phone/routes.js +28 -1
- package/lib/mobile/phone/service.js +23 -0
- package/lib/mobile/phone/site-commands.js +271 -0
- package/lib/mobile/phone/step-summary.js +201 -0
- package/lib/mobile/phone/stop-summary.js +235 -0
- package/lib/mobile/phone/tool-groups.js +125 -0
- package/lib/mobile/phone/tools.js +589 -0
- package/lib/mobile/phone/untrusted.js +65 -0
- package/lib/mobile/phone-knowledge.js +20 -4
- package/lib/mobile/schedule-edit.js +500 -0
- package/lib/mobile/schedule.js +5 -0
- package/lib/mobile/send-continuation.js +52 -0
- package/lib/mobile/session-models.js +27 -31
- package/lib/mobile/session-send.js +12 -12
- package/lib/mobile/skill-import.js +338 -0
- package/lib/mobile/skill-store.js +524 -0
- package/lib/routes/mobile-api.js +42 -0
- package/package.json +7 -6
- package/skills/skill-authoring/SKILL.md +46 -0
- package/skills/skill-authoring/references/examples.md +82 -0
- package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
- package/skills/skill-authoring/scripts/check_skill.py +167 -0
- package/skills/skill-authoring/scripts/manifest.json +3 -0
- package/knowledge/sites/bilibili.com.md +0 -27
- package/knowledge/sites/github.com.md +0 -27
- package/knowledge/sites/x.com.md +0 -28
- package/knowledge/sites/xiaohongshu.com.md +0 -27
- package/knowledge/sites/youtube.com.md +0 -28
- package/knowledge/sites/zhihu.com.md +0 -27
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
// Caller keys use the same account list and edit controls as provider keys.
|
|
2
|
+
let gatewayKeys = null, gatewayKeyDraft = null, gatewayKeysBusy = false;
|
|
3
|
+
// gatewayOwnDraft is the value typed for a new key of the user's own (one
|
|
4
|
+
// its clients already send, love1sbug on X); "" lets magpie make one
|
|
5
|
+
let gatewayOwnDraft = "";
|
|
6
|
+
// gatewayAccountNames maps a key's stored account entry ("<provider>/<id>",
|
|
7
|
+
// #905) to how it is shown; what no provider has now is shown as it is kept
|
|
8
|
+
let gatewayAccountNames = {};
|
|
9
|
+
let gatewayKeysRead = 0;
|
|
10
|
+
// gatewayKeysErr is why the list couldn't be read, said in its place until
|
|
11
|
+
// a read works: a redraw of the page (the providers' poll) keeps it
|
|
12
|
+
let gatewayKeysErr = "";
|
|
13
|
+
// gatewayLimit is the key whose limit editor is open, and its draft
|
|
14
|
+
// (#585): what is picked in it is sent only by its Save.
|
|
15
|
+
let gatewayLimit = null;
|
|
16
|
+
let connectURL = "", connectKeyID = "", connectSecret = null;
|
|
17
|
+
// connectWant and connectKeyWant are the address and gateway key picked in
|
|
18
|
+
// Gateway's Connect, kept across reloads, restarts and updates (coeo91 on
|
|
19
|
+
// Discord: the LAN address went back to 127.0.0.1); "" for a key is This
|
|
20
|
+
// computer
|
|
21
|
+
let connectWant = "", connectKeyWant = "";
|
|
22
|
+
try {
|
|
23
|
+
connectWant = localStorage.getItem("magpie.connectAddress") || "";
|
|
24
|
+
connectKeyWant = localStorage.getItem("magpie.connectKey") || "";
|
|
25
|
+
} catch {}
|
|
26
|
+
function keepPick(name, v) {
|
|
27
|
+
try { localStorage.setItem(name, v); } catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function connectPick(id, label, text, options, value, choose) {
|
|
31
|
+
const b = el("button", "val");
|
|
32
|
+
b.id = id;
|
|
33
|
+
b.type = "button";
|
|
34
|
+
b.setAttribute("aria-label", t(label));
|
|
35
|
+
b.append(el("code", "", text), svg(CHEV, 11, 1.6));
|
|
36
|
+
b.onclick = () => {
|
|
37
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
38
|
+
openProtoMenu(b, options, value, choose, label);
|
|
39
|
+
};
|
|
40
|
+
return b;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function selectConnectKey(id) {
|
|
44
|
+
connectKeyID = id;
|
|
45
|
+
connectSecret = null;
|
|
46
|
+
renderConnect();
|
|
47
|
+
if (!id || !providers?.gateway.lan) return;
|
|
48
|
+
const key = gatewayKeys?.find((k) => k.id === id && !k.off);
|
|
49
|
+
if (!key) return;
|
|
50
|
+
try {
|
|
51
|
+
const out = await api("caller-keys/copy-key", { key: id });
|
|
52
|
+
if (connectKeyID !== id || gatewayKeys?.find((k) => k.id === id) !== key) return;
|
|
53
|
+
connectSecret = { id, secret: out.secret };
|
|
54
|
+
if (view === "gateway") renderConnect();
|
|
55
|
+
} catch (e) { status(t(e.message), "err"); }
|
|
56
|
+
}
|
|
57
|
+
async function loadGatewayKeys() {
|
|
58
|
+
const read = ++gatewayKeysRead;
|
|
59
|
+
try {
|
|
60
|
+
const out = await api("caller-keys");
|
|
61
|
+
if (read !== gatewayKeysRead) return;
|
|
62
|
+
// A fresh list also refreshes a selected credential, which may have rotated.
|
|
63
|
+
connectSecret = null;
|
|
64
|
+
gatewayKeys = out.keys || [];
|
|
65
|
+
gatewayKeysErr = "";
|
|
66
|
+
renderGatewayKeys();
|
|
67
|
+
if (view === "gateway" && providers) selectConnectKey(connectKeyID);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (read !== gatewayKeysRead) return;
|
|
70
|
+
// a list read before stays: only one never read is said unreadable
|
|
71
|
+
if (gatewayKeys !== null) return status(t(e.message), "err");
|
|
72
|
+
gatewayKeysErr = e.message;
|
|
73
|
+
renderGatewayKeys();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// refreshGatewayKeys reads the keys again for what each has used of its
|
|
78
|
+
// limits, which only a visit to the page read before: left open, a reset
|
|
79
|
+
// key's line said $0.00 however much was spent after it (Young on Discord).
|
|
80
|
+
// It runs as the page is drawn again (the window back, a Refresh) and on
|
|
81
|
+
// a timer while the page is in sight; the list is drawn again only when
|
|
82
|
+
// it changed, and not while a key is being added, renamed or its limit
|
|
83
|
+
// edited, nor while an action is on its way.
|
|
84
|
+
async function refreshGatewayKeys() {
|
|
85
|
+
if (gatewayKeys === null || gatewayKeysBusy) return;
|
|
86
|
+
const read = ++gatewayKeysRead;
|
|
87
|
+
let out;
|
|
88
|
+
try { out = await api("caller-keys"); } catch { return; }
|
|
89
|
+
if (read !== gatewayKeysRead || gatewayKeysBusy) return;
|
|
90
|
+
const keys = out.keys || [];
|
|
91
|
+
if (JSON.stringify(keys) === JSON.stringify(gatewayKeys)) return;
|
|
92
|
+
gatewayKeys = keys;
|
|
93
|
+
gatewayAccountNames = out.accountNames || gatewayAccountNames;
|
|
94
|
+
if (gatewayKeyDraft === null && gatewayLimit === null && !$("#gatewayKeys .rename-in")) renderGatewayKeys();
|
|
95
|
+
}
|
|
96
|
+
// the timer: only while the Gateway page is in sight, as a look with
|
|
97
|
+
// nothing to do wakes the web content process all the same (#3185)
|
|
98
|
+
let gatewayKeysTick = 0;
|
|
99
|
+
function gatewayKeysTicks() {
|
|
100
|
+
const page = $("#view-gateway");
|
|
101
|
+
if (!page || page.hidden || document.hidden) { clearInterval(gatewayKeysTick); gatewayKeysTick = 0; return; }
|
|
102
|
+
gatewayKeysTick ||= setInterval(() => {
|
|
103
|
+
if ($("#view-gateway").hidden || document.hidden) return gatewayKeysTicks();
|
|
104
|
+
if (view === "gateway" && gatewayKeys?.some((k) => !k.off && keyUsed(k).length)) refreshGatewayKeys();
|
|
105
|
+
}, 15e3);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function gatewayKeyAction(action, body) {
|
|
109
|
+
if (gatewayKeysBusy) return null;
|
|
110
|
+
gatewayKeysBusy = true;
|
|
111
|
+
gatewayKeysRead++;
|
|
112
|
+
try {
|
|
113
|
+
const out = await api("caller-keys/" + action, body);
|
|
114
|
+
gatewayKeysRead++;
|
|
115
|
+
connectSecret = null;
|
|
116
|
+
gatewayKeys = out.keys || [];
|
|
117
|
+
gatewayAccountNames = out.accountNames || gatewayAccountNames;
|
|
118
|
+
gatewayKeyDraft = null;
|
|
119
|
+
if (action === "limit-key" || !gatewayKeys.some((k) => k.id === gatewayLimit?.id)) gatewayLimit = null;
|
|
120
|
+
renderGatewayKeys();
|
|
121
|
+
if (view === "gateway" && providers) selectConnectKey(connectKeyID);
|
|
122
|
+
return out;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
status(t(e.message), "err");
|
|
125
|
+
return null;
|
|
126
|
+
} finally {
|
|
127
|
+
gatewayKeysBusy = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function gatewayRename(k) {
|
|
132
|
+
const b = el("button", "n rename", k.name);
|
|
133
|
+
b.title = t("Rename");
|
|
134
|
+
b.onclick = () => {
|
|
135
|
+
const i = input(k.name, t("Name"));
|
|
136
|
+
i.className = "rename-in";
|
|
137
|
+
i.setAttribute("aria-label", t("Name"));
|
|
138
|
+
let finished = false;
|
|
139
|
+
const done = async (save) => {
|
|
140
|
+
if (finished) return;
|
|
141
|
+
finished = true;
|
|
142
|
+
// Keep the other controls in place when blur precedes their click.
|
|
143
|
+
i.replaceWith(b);
|
|
144
|
+
if (save && i.value.trim() !== k.name) await gatewayKeyAction("rename-key", { key: k.id, name: i.value });
|
|
145
|
+
};
|
|
146
|
+
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") done(true); else if (e.key === "Escape") done(false); };
|
|
147
|
+
i.onblur = () => done(true);
|
|
148
|
+
b.replaceWith(i);
|
|
149
|
+
i.focus({ preventScroll: true });
|
|
150
|
+
};
|
|
151
|
+
return b;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function gatewayKeyForm() {
|
|
155
|
+
const box = el("div", "acc adding");
|
|
156
|
+
const name = input(gatewayKeyDraft, t("Key name, e.g. Laptop"));
|
|
157
|
+
name.setAttribute("aria-label", t("Gateway key name"));
|
|
158
|
+
name.oninput = () => { gatewayKeyDraft = name.value; };
|
|
159
|
+
const own = input(gatewayOwnDraft, t("Your own key (optional)"), "password");
|
|
160
|
+
own.setAttribute("aria-label", t("Gateway key value"));
|
|
161
|
+
own.title = t("Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.");
|
|
162
|
+
own.autocomplete = "off";
|
|
163
|
+
own.oninput = () => { gatewayOwnDraft = own.value; };
|
|
164
|
+
const add = el("button", "text primary", t("Create"));
|
|
165
|
+
const go = async () => {
|
|
166
|
+
add.disabled = true;
|
|
167
|
+
const mine = own.value.trim() !== "";
|
|
168
|
+
const out = await gatewayKeyAction("add-key", mine ? { name: name.value, secret: own.value } : { name: name.value });
|
|
169
|
+
if (!out) { add.disabled = false; return; }
|
|
170
|
+
gatewayOwnDraft = "";
|
|
171
|
+
status(t(mine ? "Gateway key created. Clients that send this key reach magpie now." : "Gateway key created. Use Copy on its row to connect a client."), "ok");
|
|
172
|
+
};
|
|
173
|
+
add.onclick = go;
|
|
174
|
+
const cancelled = () => { gatewayKeyDraft = null; gatewayOwnDraft = ""; renderGatewayKeys(); };
|
|
175
|
+
for (const i of [name, own]) i.onkeydown = (e) => {
|
|
176
|
+
e.stopPropagation();
|
|
177
|
+
if (e.key === "Enter" && !add.disabled) go();
|
|
178
|
+
else if (e.key === "Escape") cancelled();
|
|
179
|
+
};
|
|
180
|
+
const cancel = el("button", "text", t("Cancel"));
|
|
181
|
+
cancel.onclick = cancelled;
|
|
182
|
+
const fields = el("div", "kf");
|
|
183
|
+
fields.append(name, own);
|
|
184
|
+
const bar = el("div", "kb");
|
|
185
|
+
bar.append(el("span", "grow"), cancel, add);
|
|
186
|
+
box.append(fields, bar);
|
|
187
|
+
queueMicrotask(() => name.focus({ preventScroll: true }));
|
|
188
|
+
return box;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function copyCallerKeyBtn(k) {
|
|
192
|
+
const cp = copyBtn("", t("Gateway key"));
|
|
193
|
+
cp.setAttribute("aria-label", t("Copy gateway key"));
|
|
194
|
+
cp.onclick = async () => {
|
|
195
|
+
try {
|
|
196
|
+
const out = await api("caller-keys/copy-key", { key: k.id });
|
|
197
|
+
await copy(out.secret, t("Gateway key"), cp);
|
|
198
|
+
} catch (e) { status(t(e.message), "err"); }
|
|
199
|
+
};
|
|
200
|
+
return cp;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderGatewayKeys() {
|
|
204
|
+
// a drag in progress keeps its rows: the list is drawn when it ends
|
|
205
|
+
if (gatewayKeysArranging) { gatewayKeysRenderPending = true; return; }
|
|
206
|
+
const box = $("#gatewayKeys");
|
|
207
|
+
$("#gatewayKeysBlock").hidden = !providers?.gateway.lan;
|
|
208
|
+
box.replaceChildren();
|
|
209
|
+
$("#addGatewayKey").onclick = () => { gatewayKeyDraft = ""; renderGatewayKeys(); };
|
|
210
|
+
// keys: a badge left unset folds away until the row is pointed at, as
|
|
211
|
+
// on a provider's keys, so nothing invisible holds a place in the row
|
|
212
|
+
const list = el("div", "accts gw-keys");
|
|
213
|
+
if (gatewayKeys === null) {
|
|
214
|
+
if (gatewayKeysErr) list.append(el("div", "none", t(gatewayKeysErr))), box.append(list);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const rows = [];
|
|
218
|
+
for (const k of gatewayKeys) {
|
|
219
|
+
const row = el("div", "acc" + (k.off ? " off" : " in-use"));
|
|
220
|
+
rows.push(row);
|
|
221
|
+
row.dataset.key = k.id;
|
|
222
|
+
const tick = el("button", "dot tick");
|
|
223
|
+
tick.title = t(k.off ? "Enable key" : "Disable key");
|
|
224
|
+
tick.setAttribute("aria-label", tick.title);
|
|
225
|
+
if (!k.off) tick.append(svg(CHECK, 10, 2.2));
|
|
226
|
+
tick.onclick = () => gatewayKeyAction(k.off ? "on-key" : "off-key", { key: k.id });
|
|
227
|
+
// the copy sits by the key it copies (the owner: stranded among the
|
|
228
|
+
// badges it read as nobody's); Rotate and Remove wait in the row's menu
|
|
229
|
+
row.append(tick, gatewayRename(k), el("span", "plan mono", k.masked), copyCallerKeyBtn(k), el("span", "grow"), ...(k.browser ? [gatewayBrowserTag()] : k.readOnly ? [gatewayReadOnlyTag()] : []), gatewayModelsBadge(k), ...gatewayMCPBadge(k), gatewayQuotasBadge(k), gatewayLimitBadge(k), gatewayKeyMore(k));
|
|
230
|
+
const under = gatewayLimit?.id === k.id ? [gatewayLimitEditor(k)] : keyUsed(k).map((u) => gatewayLimitLine(k, u));
|
|
231
|
+
if (under.length) row.classList.add("with-limit"), row.append(...under);
|
|
232
|
+
list.append(row);
|
|
233
|
+
}
|
|
234
|
+
if (gatewayKeyDraft !== null) list.append(gatewayKeyForm());
|
|
235
|
+
else if (!gatewayKeys.length) list.append(el("div", "none", t("No gateway keys yet")));
|
|
236
|
+
box.append(list);
|
|
237
|
+
gatewayKeysArrange(list, rows);
|
|
238
|
+
const page = gatewayKeyPage();
|
|
239
|
+
if (page) box.append(page);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// gatewayKeysArrange: the keys are listed in the order the user puts them
|
|
243
|
+
// in (alextjy on Discord), dragged by a row anywhere off its buttons, or
|
|
244
|
+
// moved with Alt+↑/↓ on it, as the search APIs are (searchArrange); the
|
|
245
|
+
// grip that says so shows on the row under the pointer. The order is kept
|
|
246
|
+
// with the keys (move-key), so `magpie gateway-key list`, Connect's key
|
|
247
|
+
// picker and the Requests page's key filter list them the same way. The
|
|
248
|
+
// new order shows at once, and the list is read again when it can't be
|
|
249
|
+
// saved.
|
|
250
|
+
let gatewayKeysArranging = false, gatewayKeysRenderPending = false;
|
|
251
|
+
function gatewayKeysArranged() {
|
|
252
|
+
gatewayKeysArranging = false;
|
|
253
|
+
if (gatewayKeysRenderPending) { gatewayKeysRenderPending = false; renderGatewayKeys(); }
|
|
254
|
+
}
|
|
255
|
+
function gatewayKeysArrange(list, rows) {
|
|
256
|
+
if (rows.length < 2) return;
|
|
257
|
+
list.classList.add("reorderable");
|
|
258
|
+
const move = async (row, to) => {
|
|
259
|
+
const from = rows.indexOf(row);
|
|
260
|
+
if (gatewayKeysBusy || to < 0 || to >= rows.length || to === from) return;
|
|
261
|
+
const id = row.dataset.key, focus = document.activeElement === row;
|
|
262
|
+
const next = [...gatewayKeys];
|
|
263
|
+
next.splice(to, 0, ...next.splice(from, 1));
|
|
264
|
+
gatewayKeys = next;
|
|
265
|
+
renderGatewayKeys();
|
|
266
|
+
const refocus = () => { if (focus) $(`#gatewayKeys .acc[data-key="${CSS.escape(id)}"]`)?.focus({ preventScroll: true }); };
|
|
267
|
+
refocus();
|
|
268
|
+
if (await gatewayKeyAction("move-key", { key: id, index: to })) status(t("Gateway key order saved"), "ok");
|
|
269
|
+
else await loadGatewayKeys(); // not saved: the order as it is kept
|
|
270
|
+
refocus();
|
|
271
|
+
};
|
|
272
|
+
for (const row of rows) {
|
|
273
|
+
const name = row.querySelector(".n")?.textContent || "";
|
|
274
|
+
row.tabIndex = 0;
|
|
275
|
+
row.setAttribute("role", "group");
|
|
276
|
+
row.setAttribute("aria-label", name + " · " + t("Drag to reorder · Alt+↑/↓ to move"));
|
|
277
|
+
row.title = t("Drag to reorder · Alt+↑/↓ to move");
|
|
278
|
+
row.prepend(el("span", "grip"));
|
|
279
|
+
row.addEventListener("click", (e) => {
|
|
280
|
+
if (row.dataset.dragged) { e.preventDefault(); e.stopImmediatePropagation(); }
|
|
281
|
+
}, true);
|
|
282
|
+
row.onkeydown = (e) => {
|
|
283
|
+
if (e.target !== row || !e.altKey || !["ArrowUp", "ArrowDown"].includes(e.key) || gatewayKeysArranging) return;
|
|
284
|
+
e.preventDefault(); e.stopPropagation();
|
|
285
|
+
move(row, rows.indexOf(row) + (e.key === "ArrowUp" ? -1 : 1));
|
|
286
|
+
};
|
|
287
|
+
row.onpointerdown = (e) => {
|
|
288
|
+
if (gatewayKeysArranging || gatewayKeysBusy || e.target.closest("input, textarea, select, a, button, .key-limit-ed")) return;
|
|
289
|
+
gatewayKeysArranging = dragRows(e, row, row, list, rows, (to) => move(row, to), () => { closeProtoMenu(); closeAgentMenu(); },
|
|
290
|
+
gatewayKeysArranged);
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// the key page (the gateway's /me): where a key's holder, with their key
|
|
296
|
+
// alone, sees what it has used and may use and the setup for their agents
|
|
297
|
+
// (zytszone on X: a department shares one magpie, whose app its people
|
|
298
|
+
// can't be given). Its address is the one other machines reach.
|
|
299
|
+
function gatewayKeyPage() {
|
|
300
|
+
const g = providers?.gateway, lan = gwFar(g);
|
|
301
|
+
const at = g?.publicURL || (lan.includes(connectURL) ? connectURL : lan[0]);
|
|
302
|
+
if (!at || !gatewayKeys?.length) return null;
|
|
303
|
+
const url = at.replace(/\/+$/, "") + "/me";
|
|
304
|
+
const p = el("div", "usage-note gw-key-page");
|
|
305
|
+
p.append(el("span", "", t("Key holders see their usage and setup at")), " ", el("code", "", url), copyBtn(url, t("Key page")));
|
|
306
|
+
return p;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const ROTATE_GLYPH = "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3.25h-3.25";
|
|
310
|
+
const REMOVE_GLYPH = "M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5M7 7v4M9 7v4";
|
|
311
|
+
const MCP_GLYPH = "M6 2.5v3M10 2.5v3M4.5 5.5h7V8a3.5 3.5 0 0 1-7 0zM8 11.5v2";
|
|
312
|
+
const POWER_GLYPH = "M8 2.5v5M4.8 4.6a4.75 4.75 0 1 0 6.4 0";
|
|
313
|
+
function gatewayKeyMore(k) {
|
|
314
|
+
const more = el("button", "copy gw-key-more");
|
|
315
|
+
more.type = "button";
|
|
316
|
+
more.title = t("Key actions");
|
|
317
|
+
more.setAttribute("aria-label", t("Key actions"));
|
|
318
|
+
more.setAttribute("aria-haspopup", "menu");
|
|
319
|
+
more.append(svg(DOTS, 14, 2));
|
|
320
|
+
more.onclick = (e) => {
|
|
321
|
+
e.stopPropagation();
|
|
322
|
+
if (more.classList.contains("open")) return closeAgentMenu();
|
|
323
|
+
// off and on again here too, by name, beside Remove (wei_VC on X:
|
|
324
|
+
// the tick alone wasn't seen as a switch, so a key was removed and
|
|
325
|
+
// added again)
|
|
326
|
+
openRowMenu(more, [
|
|
327
|
+
{ name: k.off ? "Enable key" : "Disable key", icon: POWER_GLYPH, run: () => gatewayKeyAction(k.off ? "on-key" : "off-key", { key: k.id }) },
|
|
328
|
+
// read-only (Steven on Discord: a desk display's key reads quotas
|
|
329
|
+
// and usage, and uses nothing); the sharing key is given to agents.
|
|
330
|
+
// Magpie Browser's key connects the extension alone, and stays so:
|
|
331
|
+
// it is in a zip in Downloads
|
|
332
|
+
...(k.browser ? [] : [k.readOnly
|
|
333
|
+
? { name: "Give full access", icon: EYE_GLYPH, run: () => gatewayKeyAccess(k, false) }
|
|
334
|
+
: { name: "Make read-only", icon: EYE_GLYPH, run: () => gatewayKeyAccess(k, true), off: k.lan, why: k.lan ? t("The sharing key is given to agents, so it can't be read-only") : "" }]),
|
|
335
|
+
// the MCP servers and tools it may use (wakaka on Discord); the
|
|
336
|
+
// Browser key connects the extension alone
|
|
337
|
+
...(k.browser ? [] : [{ name: "MCP servers", icon: MCP_GLYPH, run: () => gatewayMCPPick(k, more) }]),
|
|
338
|
+
{ name: "Rotate key", icon: ROTATE_GLYPH, run: () => askGatewayKey(k, true) },
|
|
339
|
+
{ name: "Remove", icon: REMOVE_GLYPH, sep: true, run: () => askGatewayKey(k, false) },
|
|
340
|
+
]);
|
|
341
|
+
};
|
|
342
|
+
return more;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const EYE_GLYPH = "M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8zM8 9.9a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8z";
|
|
346
|
+
async function gatewayKeyAccess(k, readOnly) {
|
|
347
|
+
const out = await gatewayKeyAction("access-key", { key: k.id, readOnly });
|
|
348
|
+
if (out) status(t(readOnly ? "{name} is read-only" : "{name} has full access", { name: k.name }), "ok");
|
|
349
|
+
}
|
|
350
|
+
// a read-only key says so in its row, always: it reads quotas and usage
|
|
351
|
+
// and uses no model
|
|
352
|
+
function gatewayReadOnlyTag() {
|
|
353
|
+
const tag = el("span", "amodels key-readonly set", t("Read only"));
|
|
354
|
+
tag.title = t("This key only reads quotas and usage. It can't use models or MCP servers, or change anything.");
|
|
355
|
+
return tag;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Magpie Browser's key (the one the extension's zip was given) says so in
|
|
359
|
+
// its row: it connects the extension and nothing else
|
|
360
|
+
function gatewayBrowserTag() {
|
|
361
|
+
const tag = el("span", "amodels key-readonly key-browser set", t("Browser only"));
|
|
362
|
+
tag.title = t("This key connects the Magpie Browser extension and nothing else. It can't use models, MCP servers or stats.");
|
|
363
|
+
return tag;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function askGatewayKey(k, rotate) {
|
|
367
|
+
const ed = el("div", "editor");
|
|
368
|
+
const head = el("div", "ehead");
|
|
369
|
+
head.append(el("b", "", t(rotate ? "Rotate gateway key?" : "Remove gateway key?")));
|
|
370
|
+
ed.append(head, el("p", "lib-confirm", t(rotate
|
|
371
|
+
? "Clients using {name} will need the new key. Its name and usage history stay the same."
|
|
372
|
+
: "Clients using {name} will lose access from other computers. Its usage history is kept.", { name: k.name })));
|
|
373
|
+
// a value of one's own in place of the one magpie makes (isaac_bai on
|
|
374
|
+
// Discord: one easy to remember and pass on)
|
|
375
|
+
let own = null;
|
|
376
|
+
if (rotate) {
|
|
377
|
+
own = input("", t("Your own key (optional)"), "password");
|
|
378
|
+
own.setAttribute("aria-label", t("Gateway key value"));
|
|
379
|
+
own.title = t("Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.");
|
|
380
|
+
own.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") go.click(); else if (e.key === "Escape") closeConfirmAsk(); };
|
|
381
|
+
ed.append(el("label", "", t("Gateway key")), own);
|
|
382
|
+
}
|
|
383
|
+
const bar = el("div", "bar");
|
|
384
|
+
const cancel = el("button", "text", t("Cancel"));
|
|
385
|
+
cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
|
|
386
|
+
const go = el("button", "text primary", t(rotate ? "Rotate key" : "Remove"));
|
|
387
|
+
go.onclick = async (e) => {
|
|
388
|
+
e.stopPropagation();
|
|
389
|
+
if (go.disabled) return;
|
|
390
|
+
go.disabled = true;
|
|
391
|
+
const mine = !!own?.value.trim();
|
|
392
|
+
const out = await gatewayKeyAction(rotate ? "rotate-key" : "remove-key", mine ? { key: k.id, secret: own.value } : { key: k.id });
|
|
393
|
+
if (!out) { go.disabled = false; return; }
|
|
394
|
+
closeConfirmAsk();
|
|
395
|
+
if (rotate) status(t(mine ? "Gateway key changed. Clients that send this key reach magpie now." : "Gateway key rotated. Update clients on other computers."), "ok");
|
|
396
|
+
};
|
|
397
|
+
bar.append(el("span", "grow"), cancel, go);
|
|
398
|
+
ed.append(bar);
|
|
399
|
+
confirmAsk = ed;
|
|
400
|
+
openModal(ed);
|
|
401
|
+
$("#modal").classList.add("lib");
|
|
402
|
+
cancel.focus({ preventScroll: true });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- the models a gateway key may use (#882), and the accounts (#905) ----
|
|
406
|
+
|
|
407
|
+
// accountShown is a stored account entry ("<provider>/<id>", #905) as the
|
|
408
|
+
// badge says it: by who is signed in, the id itself when no provider has it.
|
|
409
|
+
const accountShown = (a) => gatewayAccountNames[a] || a;
|
|
410
|
+
|
|
411
|
+
// the key whose models menu waits on its list: the badge drawn last opens
|
|
412
|
+
// it, so a redraw of the keys while it loads (a rename's reply, the
|
|
413
|
+
// providers' poll) doesn't drop the click
|
|
414
|
+
let keyModelsLoading = null;
|
|
415
|
+
|
|
416
|
+
// gatewayModelsBadge picks the models and the accounts a key may use, in
|
|
417
|
+
// the app's menu: "All models" on hover when it may use any, else which
|
|
418
|
+
// it may, always shown. A pick is sent when the menu closes.
|
|
419
|
+
function gatewayModelsBadge(k) {
|
|
420
|
+
const ms = k.models || [];
|
|
421
|
+
const as = k.accounts || [];
|
|
422
|
+
const b = el("button", "amodels key-models" + (ms.length || as.length ? " set" : ""));
|
|
423
|
+
b.type = "button";
|
|
424
|
+
b.textContent = !ms.length && !as.length ? t("All models")
|
|
425
|
+
: ms.length ? (ms.length === 1 && !as.length ? ms[0] : t("{n} models", { n: ms.length }))
|
|
426
|
+
: (as.length === 1 ? accountShown(as[0]) : t("{n} accounts", { n: as.length }));
|
|
427
|
+
// an account picked and no model holds the key to the accounts'
|
|
428
|
+
// providers' models (access.HeldModels, Linka on Discord): it is not
|
|
429
|
+
// let off to every other provider's
|
|
430
|
+
b.title = (ms.length ? t("Only these models: {list}", { list: ms.join(", ") }) : t("Pick the models this key may use"))
|
|
431
|
+
+ (as.length ? "\n" + t("Only these accounts: {list}", { list: as.map(accountShown).join(", ") })
|
|
432
|
+
+ "\n" + (ms.length ? t("A provider with no account picked is used on every account; to keep the key off it, pick only other providers' models")
|
|
433
|
+
: t("No model is picked, so this key may use only the picked accounts' providers' models")) : "");
|
|
434
|
+
b.setAttribute("aria-label", t("Models this key may use"));
|
|
435
|
+
b.setAttribute("aria-haspopup", "menu");
|
|
436
|
+
b.setAttribute("aria-expanded", "false");
|
|
437
|
+
const open = (models, accounts) => {
|
|
438
|
+
if (!b.isConnected) return;
|
|
439
|
+
const opts = [{ v: "", name: "All models", note: "Any model and any account, now and later" }];
|
|
440
|
+
const seen = new Set();
|
|
441
|
+
// the accounts and keys the key may be held to (#905), each right
|
|
442
|
+
// after its provider's models, not one block at the end: an account
|
|
443
|
+
// by who is signed in, a key by its fingerprint
|
|
444
|
+
const accountsSet = new Set();
|
|
445
|
+
const ofProvider = new Map();
|
|
446
|
+
// the list holds the key only to the accounts of the providers it
|
|
447
|
+
// names (#905): a provider none of whose accounts is picked is used on
|
|
448
|
+
// every one, and its unticked accounts say so (#2077: unticking all of
|
|
449
|
+
// Codex's left Codex's models served)
|
|
450
|
+
const named = new Set(as.map((v) => v.split("/")[0].toLowerCase()));
|
|
451
|
+
// a provider the key's models leave out is used on none of its
|
|
452
|
+
// accounts: with no model picked, every provider no account is
|
|
453
|
+
// picked of (access.HeldModels)
|
|
454
|
+
const modelsReach = (pid) => ms.some((m) => {
|
|
455
|
+
const head = m.split("/")[0].toLowerCase();
|
|
456
|
+
return head === pid || head.includes("*");
|
|
457
|
+
});
|
|
458
|
+
for (const a of accounts || []) {
|
|
459
|
+
accountsSet.add(a.id);
|
|
460
|
+
const pid = String(a.provider).toLowerCase();
|
|
461
|
+
const note = as.length && !named.has(pid)
|
|
462
|
+
? (ms.length && modelsReach(pid)
|
|
463
|
+
? t("Used: no {provider} account is picked", { provider: a.providerName })
|
|
464
|
+
: t("Not used: no {provider} model or account is picked", { provider: a.providerName }))
|
|
465
|
+
: a.plan ? a.providerName + " · " + a.plan : a.providerName;
|
|
466
|
+
const o = { v: a.id, name: a.name, note, literalName: true };
|
|
467
|
+
ofProvider.set(a.provider, [...(ofProvider.get(a.provider) || []), o]);
|
|
468
|
+
}
|
|
469
|
+
let listed = ""; // the provider whose models the list is in
|
|
470
|
+
const endBlock = () => {
|
|
471
|
+
for (const o of ofProvider.get(listed) || []) opts.push(o);
|
|
472
|
+
ofProvider.delete(listed);
|
|
473
|
+
listed = "";
|
|
474
|
+
};
|
|
475
|
+
for (const m of models || []) {
|
|
476
|
+
// a routing group the key names is its with every member in it
|
|
477
|
+
// (Magic_zero on Discord); the groups come first
|
|
478
|
+
if (m.group) {
|
|
479
|
+
endBlock();
|
|
480
|
+
if (!seen.has("group")) {
|
|
481
|
+
seen.add("group");
|
|
482
|
+
opts.push({ v: "group/*", name: "Every routing group", note: "group/*" });
|
|
483
|
+
}
|
|
484
|
+
opts.push({ v: m.id, name: m.name, note: m.id, literalName: true });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (m.provider !== listed) {
|
|
488
|
+
endBlock();
|
|
489
|
+
listed = m.provider;
|
|
490
|
+
}
|
|
491
|
+
if (!seen.has(m.provider)) {
|
|
492
|
+
seen.add(m.provider);
|
|
493
|
+
opts.push({ v: m.provider + "/*", name: t("Every {provider} model", { provider: m.providerName }), note: m.provider + "/*", literalName: true });
|
|
494
|
+
}
|
|
495
|
+
// a decision model (Jev, Clef…) no agent lists: a routing group
|
|
496
|
+
// asks it whatever the key may use, and the key may ask it itself
|
|
497
|
+
// (/v1/systemone, another magpie's list) only when it is picked
|
|
498
|
+
// (Kautism on Discord)
|
|
499
|
+
opts.push({ v: m.id, name: m.name, note: m.decides ? m.id + " · " + t("Decision model: a routing group asks it whatever this key may use; pick it so the key may ask it itself") : m.id, literalName: true });
|
|
500
|
+
}
|
|
501
|
+
endBlock();
|
|
502
|
+
// a provider with accounts and no model in the list (one kept
|
|
503
|
+
// unlisted, or serving none now) keeps its accounts after the models
|
|
504
|
+
for (const os of ofProvider.values()) opts.push(...os);
|
|
505
|
+
// what the CLI kept that the list hasn't, a pattern, a model gone or
|
|
506
|
+
// an account signed out: behind every live entry, a model gone
|
|
507
|
+
// before an account signed out
|
|
508
|
+
for (const v of ms) if (!opts.some((o) => o.v === v)) opts.push({ v, name: v, note: "Not served now", literalName: true });
|
|
509
|
+
for (const v of as) if (!accountsSet.has(v)) { accountsSet.add(v); opts.push({ v, name: v, note: "Not signed in now", literalName: true }); }
|
|
510
|
+
openProtoMenu(b, opts, [...ms, ...as], (picked) => {
|
|
511
|
+
// one action at a time: gatewayKeyAction is busy while one is sent.
|
|
512
|
+
// The accounts are sent only when they changed: a model picked says
|
|
513
|
+
// nothing of the key's accounts.
|
|
514
|
+
(async () => {
|
|
515
|
+
const pickedModels = picked.filter((v) => !accountsSet.has(v));
|
|
516
|
+
const pickedAccounts = picked.filter((v) => accountsSet.has(v));
|
|
517
|
+
const out = await gatewayKeyAction("models-key", { key: k.id, models: pickedModels });
|
|
518
|
+
if (out) {
|
|
519
|
+
status(t(pickedModels.length ? "{name} may use only the models picked"
|
|
520
|
+
: pickedAccounts.length ? "{name} may use only the picked accounts' models" : "{name} may use every model", { name: k.name }), "ok");
|
|
521
|
+
}
|
|
522
|
+
if (pickedAccounts.length !== as.length || pickedAccounts.some((v) => !as.includes(v))) {
|
|
523
|
+
await gatewayKeyAction("accounts-key", { key: k.id, accounts: pickedAccounts });
|
|
524
|
+
}
|
|
525
|
+
})();
|
|
526
|
+
}, "Models this key may use", "sess-menu", "right");
|
|
527
|
+
};
|
|
528
|
+
b.onclick = async (e) => {
|
|
529
|
+
e.preventDefault();
|
|
530
|
+
e.stopPropagation();
|
|
531
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
532
|
+
const loading = keyModelsLoading = { id: k.id, open };
|
|
533
|
+
let models = [], accounts = [];
|
|
534
|
+
try { ({ models, accounts } = await api("caller-keys/models")); } catch (err) {
|
|
535
|
+
if (keyModelsLoading === loading) keyModelsLoading = null;
|
|
536
|
+
status(t(err.message), "err");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (keyModelsLoading !== loading) return;
|
|
540
|
+
keyModelsLoading = null;
|
|
541
|
+
loading.open(models, accounts);
|
|
542
|
+
};
|
|
543
|
+
if (keyModelsLoading?.id === k.id) keyModelsLoading.open = open;
|
|
544
|
+
return b;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// gatewayMCPPick picks the MCP servers and tools a key may use (wakaka
|
|
548
|
+
// on Discord), as gatewayModelsBadge picks its models: a server whole, or
|
|
549
|
+
// some of its tools, or none. It opens from the row's menu, and from the
|
|
550
|
+
// key's MCP badge once it holds some.
|
|
551
|
+
async function gatewayMCPPick(k, anchor) {
|
|
552
|
+
const ms = k.mcp || [];
|
|
553
|
+
let list = [];
|
|
554
|
+
try { ({ servers: list } = await api("caller-keys/mcp")); } catch (err) { status(t(err.message), "err"); return; }
|
|
555
|
+
if (!anchor.isConnected) return;
|
|
556
|
+
const opts = [{ v: "", name: "All MCP servers", note: "" }, { v: "-", name: "No MCP server", note: "" }];
|
|
557
|
+
for (const sv of list || []) {
|
|
558
|
+
opts.push({ v: sv.name, name: sv.title || sv.name, note: sv.title && sv.title !== sv.name ? sv.name : "", literalName: true });
|
|
559
|
+
for (const tool of sv.tools || []) opts.push({ v: sv.name + "/" + tool, name: tool, note: sv.name + "/" + tool, literalName: true });
|
|
560
|
+
}
|
|
561
|
+
// what the CLI kept that the list hasn't: a server gone, or a tool it
|
|
562
|
+
// no longer lists
|
|
563
|
+
for (const v of ms) if (!opts.some((o) => o.v === v)) opts.push({ v, name: v, note: "Not served now", literalName: true });
|
|
564
|
+
openProtoMenu(anchor, opts, [...ms], (picked) => {
|
|
565
|
+
// "No MCP server" just ticked is none; left ticked beside a server
|
|
566
|
+
// ticked since, the server
|
|
567
|
+
const mcp = picked.includes("-") && !ms.includes("-") ? ["-"] : picked.length > 1 ? picked.filter((v) => v !== "-") : picked;
|
|
568
|
+
(async () => {
|
|
569
|
+
const out = await gatewayKeyAction("mcp-key", { key: k.id, mcp });
|
|
570
|
+
if (out) status(t(!mcp.length ? "{name} may use every MCP server" : mcp[0] === "-" ? "{name} may use no MCP server" : "{name} may use only the MCP servers and tools picked", { name: k.name }), "ok");
|
|
571
|
+
})();
|
|
572
|
+
}, "MCP servers this key may use", "sess-menu", "right");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// gatewayMCPBadge says which MCP servers a key held to some may use; a
|
|
576
|
+
// key that may use any has none (the row's menu sets it)
|
|
577
|
+
function gatewayMCPBadge(k) {
|
|
578
|
+
const ms = k.mcp || [];
|
|
579
|
+
if (!ms.length) return [];
|
|
580
|
+
const b = el("button", "amodels key-mcp set");
|
|
581
|
+
b.type = "button";
|
|
582
|
+
const servers = new Set(ms.map((v) => v.split("/")[0]));
|
|
583
|
+
b.textContent = ms[0] === "-" ? t("No MCP server") : ms.length === 1 ? ms[0] : t("{n} MCP servers", { n: servers.size });
|
|
584
|
+
b.title = ms[0] === "-" ? t("No MCP server") : t("Only these MCP servers and tools: {list}", { list: ms.join(", ") });
|
|
585
|
+
b.setAttribute("aria-label", t("MCP servers this key may use"));
|
|
586
|
+
b.setAttribute("aria-haspopup", "menu");
|
|
587
|
+
b.setAttribute("aria-expanded", "false");
|
|
588
|
+
b.onclick = (e) => {
|
|
589
|
+
e.preventDefault();
|
|
590
|
+
e.stopPropagation();
|
|
591
|
+
if (b.classList.contains("open")) return closeProtoMenu();
|
|
592
|
+
gatewayMCPPick(k, b);
|
|
593
|
+
};
|
|
594
|
+
return [b];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---- the quotas a gateway key is told (#2216) ----
|
|
598
|
+
|
|
599
|
+
// gatewayQuotasBadge turns on or off a key's being told the quotas of what
|
|
600
|
+
// it may use: a key held to a budget, models or accounts is told none of
|
|
601
|
+
// this magpie's quotas (#1981) unless it is on, and then only the cards of
|
|
602
|
+
// the providers whose every model it may use, of the accounts it may use.
|
|
603
|
+
// "Quotas" on hover when off, "Quotas shown" always when on.
|
|
604
|
+
function gatewayQuotasBadge(k) {
|
|
605
|
+
const b = el("button", "amodels key-quotas" + (k.quotas ? " set" : ""));
|
|
606
|
+
b.type = "button";
|
|
607
|
+
b.textContent = t(k.quotas ? "Quotas shown" : "Quotas");
|
|
608
|
+
b.title = t(k.quotas
|
|
609
|
+
? "This key is told the quotas of the providers whose every model it may use, and of the accounts it may use, account names included. Click to stop."
|
|
610
|
+
: "A key held to a budget, models or accounts is told no quotas. Click to tell it the quotas of the providers whose every model it may use, and of the accounts it may use, account names included.");
|
|
611
|
+
b.setAttribute("aria-label", t("Show this key its quotas"));
|
|
612
|
+
b.setAttribute("aria-pressed", k.quotas ? "true" : "false");
|
|
613
|
+
b.onclick = async (e) => {
|
|
614
|
+
e.preventDefault();
|
|
615
|
+
const out = await gatewayKeyAction("quotas-key", { key: k.id, quotas: !k.quotas });
|
|
616
|
+
if (out) status(t(k.quotas ? "{name} is told no quotas" : "{name} is told the quotas of what it may use", { name: k.name }), "ok");
|
|
617
|
+
};
|
|
618
|
+
return b;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---- a gateway key's own limit (#585) ----
|
|
622
|
+
|
|
623
|
+
// "days" is every N days from when the limit is set or reset (#1509),
|
|
624
|
+
// "hours" every N hours (#2102), a subscription's 5-hour window's pace
|
|
625
|
+
const limitPeriods = ["day", "week", "month", "days", "hours"];
|
|
626
|
+
const limitPer = { day: "per day", week: "per week", month: "per month", days: "Every N days", hours: "Every N hours" };
|
|
627
|
+
const cyclic = (period) => period === "days" || period === "hours";
|
|
628
|
+
// perWords is a limit's window as the badge says it: "per week", "every
|
|
629
|
+
// 10 days", "every 5 hours"
|
|
630
|
+
const perWords = (period, days, hours) => period === "days"
|
|
631
|
+
? (days === 1 ? t("every 1 day") : t("every {n} days", { n: days }))
|
|
632
|
+
: period === "hours" ? (hours === 1 ? t("every 1 hour") : t("every {n} hours", { n: hours }))
|
|
633
|
+
: t(limitPer[period] || period);
|
|
634
|
+
// limitDays reads a cycle's days: a whole number from 1 to 3650, else NaN
|
|
635
|
+
function limitDays(v) {
|
|
636
|
+
const s = String(v ?? "").trim();
|
|
637
|
+
return /^\d{1,4}$/.test(s) && +s >= 1 && +s <= 3650 ? +s : NaN;
|
|
638
|
+
}
|
|
639
|
+
// limitHours reads a cycle's hours: a whole number from 1 to 8760, else NaN
|
|
640
|
+
function limitHours(v) {
|
|
641
|
+
const s = String(v ?? "").trim();
|
|
642
|
+
return /^\d{1,4}$/.test(s) && +s >= 1 && +s <= 8760 ? +s : NaN;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// limitTokens reads a count as typed: 2000000, 2,000,000, 500k, 2M, 1.5m,
|
|
646
|
+
// 100万, 1亿; "" for none, NaN for what isn't one.
|
|
647
|
+
function limitTokens(v) {
|
|
648
|
+
const s = String(v ?? "").trim().toLowerCase().replace(/[,\s_]/g, "");
|
|
649
|
+
if (!s) return 0;
|
|
650
|
+
const m = s.match(/^(\d+(?:\.\d+)?)(k|m|b|万|亿)?$/);
|
|
651
|
+
if (!m) return NaN;
|
|
652
|
+
const mult = { k: 1e3, m: 1e6, b: 1e9, "万": 1e4, "亿": 1e8 }[m[2]] || 1;
|
|
653
|
+
return Math.round(parseFloat(m[1]) * mult);
|
|
654
|
+
}
|
|
655
|
+
function limitCost(v) {
|
|
656
|
+
const s = String(v ?? "").trim().replace(/^[$$]/, "");
|
|
657
|
+
if (!s) return 0;
|
|
658
|
+
return /^\d+(\.\d+)?$/.test(s) ? parseFloat(s) : NaN;
|
|
659
|
+
}
|
|
660
|
+
const usd = (n) => "$" + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
|
|
661
|
+
// limitModels names the models a limit counts (zola_xynb on X), "" when
|
|
662
|
+
// it counts every call of the key
|
|
663
|
+
const limitModels = (ms) => !ms?.length ? "" : ms.length === 1 ? ms[0] : t("{n} models", { n: ms.length });
|
|
664
|
+
const sameModels = (a, b) => (a || []).length === (b || []).length && (a || []).every((m) => (b || []).includes(m));
|
|
665
|
+
|
|
666
|
+
// keyLimits are a key's limits, its first and the rest (#2227), and
|
|
667
|
+
// keyUsed what it has used of each, in the same order.
|
|
668
|
+
const keyLimits = (k) => k.limit ? [k.limit, ...(k.moreLimits || [])] : [];
|
|
669
|
+
const keyUsed = (k) => k.usedAll || (k.used ? [k.used] : []);
|
|
670
|
+
// limitDraft is the editor's draft of a key's nth limit, or of a new one
|
|
671
|
+
// at n past the last.
|
|
672
|
+
function limitDraft(k, n) {
|
|
673
|
+
const l = keyLimits(k)[n] || {};
|
|
674
|
+
const taken = keyLimits(k).map((x) => x.period);
|
|
675
|
+
// a new one in a window the key has no limit in yet: a week beside 5 hours
|
|
676
|
+
const period = l.period || (n ? ["week", "hours", "day", "month"].find((p) => !taken.includes(p)) || "days" : "day");
|
|
677
|
+
return { id: k.id, n, period, days: l.days ? String(l.days) : "", hours: l.hours ? String(l.hours) : (n && period === "hours" ? "5" : ""), tokens: l.tokens ? String(l.tokens) : "", cost: l.cost ? String(l.cost) : "", cacheReads: !!l.cacheReads, models: [...(l.models || [])] };
|
|
678
|
+
}
|
|
679
|
+
// bindingUsed is the limit holding a key most: of those spent, the one
|
|
680
|
+
// that resets last, as it is refused until then; else the first
|
|
681
|
+
function bindingUsed(k) {
|
|
682
|
+
const all = keyUsed(k), spent = all.filter((u) => u.spent);
|
|
683
|
+
return spent.length ? spent.reduce((a, b) => (new Date(b.reset) > new Date(a.reset) ? b : a)) : all[0];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// gatewayLimitBadge opens a key's limit editor: "Limit" on hover when it
|
|
687
|
+
// has none, else what it has used, "1.2M / 2M · day", always shown; with
|
|
688
|
+
// several limits, the one holding it most and how many more there are.
|
|
689
|
+
function gatewayLimitBadge(k) {
|
|
690
|
+
const u = bindingUsed(k), more = keyUsed(k).length - 1;
|
|
691
|
+
const b = el("button", "amodels limit" + (u ? " set" : "") + (u?.spent ? " spent" : "") + (gatewayLimit?.id === k.id ? " open" : ""));
|
|
692
|
+
b.type = "button";
|
|
693
|
+
if (!u) b.textContent = t("Limit");
|
|
694
|
+
else {
|
|
695
|
+
const parts = [];
|
|
696
|
+
if (u.tokenLimit) parts.push(fmtN(u.tokens) + " / " + fmtN(u.tokenLimit));
|
|
697
|
+
if (u.costLimit) parts.push(usd(u.cost) + " / " + usd(u.costLimit));
|
|
698
|
+
b.textContent = (u.spent ? t("Spent") + " · " : "") + parts.join(" · ") + " · " + perWords(u.period, u.days, u.hours)
|
|
699
|
+
+ (u.models?.length ? " · " + limitModels(u.models) : "")
|
|
700
|
+
+ (more > 0 ? " · " + t(more === 1 ? "+1 limit" : "+{n} limits", { n: more }) : "");
|
|
701
|
+
}
|
|
702
|
+
b.title = t(u ? "Change this key's limit" : "Set a limit for this key");
|
|
703
|
+
b.setAttribute("aria-label", b.title);
|
|
704
|
+
b.setAttribute("aria-expanded", gatewayLimit?.id === k.id ? "true" : "false");
|
|
705
|
+
b.onclick = (e) => {
|
|
706
|
+
e.preventDefault();
|
|
707
|
+
if (gatewayLimit?.id === k.id) gatewayLimit = null;
|
|
708
|
+
else gatewayLimit = limitDraft(k, Math.max(0, keyUsed(k).indexOf(u)));
|
|
709
|
+
renderGatewayKeys();
|
|
710
|
+
};
|
|
711
|
+
return b;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// gatewayLimitLine says what a limited key has used of a limit u, has
|
|
715
|
+
// left and when it resets, under its row; with several, each says its
|
|
716
|
+
// window first.
|
|
717
|
+
function gatewayLimitLine(k, u) {
|
|
718
|
+
const line = el("div", "key-limit" + (u.spent ? " spent" : ""));
|
|
719
|
+
const parts = [];
|
|
720
|
+
if (keyUsed(k).length > 1) parts.push(perWords(u.period, u.days, u.hours));
|
|
721
|
+
if (u.models?.length) parts.push(limitModels(u.models));
|
|
722
|
+
if (u.tokenLimit) parts.push(t("{used} of {limit} tokens · {left} left", { used: fmtN(u.tokens), limit: fmtN(u.tokenLimit), left: fmtN(u.tokensLeft) }));
|
|
723
|
+
if (u.costLimit) parts.push(t("{used} of {limit} estimated · {left} left", { used: usd(u.cost), limit: usd(u.costLimit), left: usd(u.costLeft) }));
|
|
724
|
+
const at = new Date(u.reset);
|
|
725
|
+
parts.push(t(u.spent ? "refused until {when}" : "resets {when}", { when: resetClock(at) }));
|
|
726
|
+
if (u.inFlight) parts.push(t(u.inFlight === 1 ? "1 in flight" : "{n} in flight", { n: u.inFlight }));
|
|
727
|
+
line.textContent = parts.join(" · ");
|
|
728
|
+
line.title = (u.models?.length ? u.models.join(", ") + "\n" : "")
|
|
729
|
+
+ t("Counted from {start}; resets {when}", { start: new Date(u.start).toLocaleString(intlLang()), when: at.toLocaleString(intlLang()) })
|
|
730
|
+
+ (u.unpriced ? "\n" + t("{n} calls without a known price aren't in the cost", { n: u.unpriced }) : "");
|
|
731
|
+
return line;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// gatewayLimitEditor stages a key's limit: nothing is sent until Save.
|
|
735
|
+
function gatewayLimitEditor(k) {
|
|
736
|
+
const d = gatewayLimit;
|
|
737
|
+
const box = el("div", "acct-models key-limit-ed");
|
|
738
|
+
box.onclick = (e) => e.stopPropagation();
|
|
739
|
+
const limits = keyLimits(k), n = d.n || 0, used = keyUsed(k)[n];
|
|
740
|
+
const kept = limits[n] || {};
|
|
741
|
+
const changed = () => d.period !== (kept.period || "day") || limitTokens(d.tokens) !== (kept.tokens || 0)
|
|
742
|
+
|| limitCost(d.cost) !== (kept.cost || 0) || d.cacheReads !== !!kept.cacheReads
|
|
743
|
+
|| (d.period === "days" && limitDays(d.days) !== kept.days)
|
|
744
|
+
|| (d.period === "hours" && limitHours(d.hours) !== kept.hours)
|
|
745
|
+
|| !sameModels(d.models, kept.models);
|
|
746
|
+
const fields = el("div", "klf");
|
|
747
|
+
// the app's own menu, not a native select
|
|
748
|
+
const period = el("button", "sess-pick klf-period");
|
|
749
|
+
period.type = "button";
|
|
750
|
+
period.setAttribute("aria-label", t("Limit window"));
|
|
751
|
+
period.setAttribute("aria-haspopup", "menu");
|
|
752
|
+
period.setAttribute("aria-expanded", "false");
|
|
753
|
+
const paintPeriod = () => {
|
|
754
|
+
period.dataset.value = d.period;
|
|
755
|
+
period.replaceChildren(el("span", "", t(limitPer[d.period])), svg(CHEV, 11, 1.6));
|
|
756
|
+
fields.classList.toggle("with-days", cyclic(d.period));
|
|
757
|
+
dl.hidden = d.period !== "days";
|
|
758
|
+
hl.hidden = d.period !== "hours";
|
|
759
|
+
};
|
|
760
|
+
// the days of a cycle, shown for "every N days"
|
|
761
|
+
const days = input(d.days, "10");
|
|
762
|
+
days.setAttribute("aria-label", t("Days in a cycle"));
|
|
763
|
+
days.inputMode = "numeric";
|
|
764
|
+
const dl = el("label", "klf-field klf-days");
|
|
765
|
+
dl.append(el("span", "klf-name", t("Days")), days);
|
|
766
|
+
// the hours of a cycle, shown for "every N hours"
|
|
767
|
+
const hours = input(d.hours, "5");
|
|
768
|
+
hours.setAttribute("aria-label", t("Hours in a cycle"));
|
|
769
|
+
hours.inputMode = "numeric";
|
|
770
|
+
const hl = el("label", "klf-field klf-days klf-hours");
|
|
771
|
+
hl.append(el("span", "klf-name", t("Hours")), hours);
|
|
772
|
+
paintPeriod();
|
|
773
|
+
const tokens = input(d.tokens, t("No token cap"));
|
|
774
|
+
tokens.setAttribute("aria-label", t("Token limit"));
|
|
775
|
+
tokens.inputMode = "decimal";
|
|
776
|
+
const cost = input(d.cost, t("No cost cap"));
|
|
777
|
+
cost.setAttribute("aria-label", t("Estimated cost limit, US$"));
|
|
778
|
+
cost.inputMode = "decimal";
|
|
779
|
+
const tl = el("label", "klf-field");
|
|
780
|
+
tl.append(el("span", "klf-name", t("Tokens")), tokens);
|
|
781
|
+
const cl = el("label", "klf-field");
|
|
782
|
+
cl.append(el("span", "klf-name", t("Estimated cost, US$")), cost);
|
|
783
|
+
const pl = el("label", "klf-field");
|
|
784
|
+
pl.append(el("span", "klf-name", t("Window")), period);
|
|
785
|
+
// the models the limit counts (zola_xynb on X): none picked is every
|
|
786
|
+
// call of the key, as a limit set before there was a pick
|
|
787
|
+
const models = el("button", "sess-pick klf-pick klf-models-pick");
|
|
788
|
+
models.type = "button";
|
|
789
|
+
models.setAttribute("aria-label", t("Models this limit counts"));
|
|
790
|
+
models.setAttribute("aria-haspopup", "menu");
|
|
791
|
+
models.setAttribute("aria-expanded", "false");
|
|
792
|
+
const paintModels = () => {
|
|
793
|
+
models.title = d.models.join(", ");
|
|
794
|
+
models.replaceChildren(el("span", "", limitModels(d.models) || t("All models")), svg(CHEV, 11, 1.6));
|
|
795
|
+
};
|
|
796
|
+
paintModels();
|
|
797
|
+
const ml = el("label", "klf-field klf-models");
|
|
798
|
+
ml.append(el("span", "klf-name", t("Models")), models);
|
|
799
|
+
fields.append(pl, dl, hl, tl, cl, ml);
|
|
800
|
+
// a key's limits (#2227), each a tab, when it has more than one or one
|
|
801
|
+
// is being added: the editor is of one at a time
|
|
802
|
+
let which = null;
|
|
803
|
+
if (limits.length > 1 || n >= limits.length && limits.length) {
|
|
804
|
+
which = el("div", "klf-which");
|
|
805
|
+
which.setAttribute("role", "tablist");
|
|
806
|
+
which.setAttribute("aria-label", t("This key's limits"));
|
|
807
|
+
const tabs = limits.map((l, i) => ({ i, name: perWords(l.period, l.days, l.hours) + (l.models?.length ? " · " + limitModels(l.models) : "") }));
|
|
808
|
+
if (n >= limits.length) tabs.push({ i: n, name: t("New limit") });
|
|
809
|
+
for (const x of tabs) {
|
|
810
|
+
const tab = el("button", "klf-tab" + (x.i === n ? " on" : ""), x.name);
|
|
811
|
+
tab.type = "button";
|
|
812
|
+
tab.setAttribute("role", "tab");
|
|
813
|
+
tab.setAttribute("aria-selected", String(x.i === n));
|
|
814
|
+
tab.onclick = (e) => {
|
|
815
|
+
e.stopPropagation();
|
|
816
|
+
if (x.i === n) return;
|
|
817
|
+
gatewayLimit = limitDraft(k, x.i);
|
|
818
|
+
renderGatewayKeys();
|
|
819
|
+
};
|
|
820
|
+
which.append(tab);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const cache = el("label", "klf-check");
|
|
824
|
+
const cb = el("input");
|
|
825
|
+
cb.type = "checkbox";
|
|
826
|
+
cb.checked = d.cacheReads;
|
|
827
|
+
cache.append(cb, el("span", "", t("Count cache reads too")));
|
|
828
|
+
const hint = el("div", "hint", t("Tokens are each call's uncached input, output and cache writes. The cost is an estimate at the Usage page's prices, not a bill. Windows are calendar ones in this computer's time (a week from Monday), or every N days or N hours from when the limit is set or reset. Requests from this computer without a key aren't limited."));
|
|
829
|
+
const err = el("div", "hint klf-err");
|
|
830
|
+
const unsaved = el("span", "hint munsaved", t("unsaved"));
|
|
831
|
+
unsaved.title = t("Made when you Save; Cancel drops it");
|
|
832
|
+
const save = el("button", "text primary", t("Save"));
|
|
833
|
+
const update = () => {
|
|
834
|
+
const bad = d.period === "days" && Number.isNaN(limitDays(d.days)) ? t("Days is a whole number from 1 to 3650")
|
|
835
|
+
: d.period === "hours" && Number.isNaN(limitHours(d.hours)) ? t("Hours is a whole number from 1 to 8760")
|
|
836
|
+
: Number.isNaN(limitTokens(d.tokens)) ? t("Tokens is a count, like 2000000 or 2M")
|
|
837
|
+
: Number.isNaN(limitCost(d.cost)) ? t("The cost is US dollars, like 5 or 2.50") : "";
|
|
838
|
+
err.textContent = bad;
|
|
839
|
+
err.hidden = !bad;
|
|
840
|
+
unsaved.hidden = !changed();
|
|
841
|
+
// a limit being added has a cap to save
|
|
842
|
+
save.disabled = !!bad || !changed() || (n >= limits.length && !limitTokens(d.tokens) && !limitCost(d.cost));
|
|
843
|
+
};
|
|
844
|
+
period.onclick = (e) => {
|
|
845
|
+
e.preventDefault();
|
|
846
|
+
e.stopPropagation();
|
|
847
|
+
if (period.classList.contains("open")) return closeProtoMenu();
|
|
848
|
+
openProtoMenu(period, limitPeriods.map((p) => ({ v: p, name: t(limitPer[p]), note: "", literalName: true })), d.period, (v) => {
|
|
849
|
+
d.period = v;
|
|
850
|
+
paintPeriod();
|
|
851
|
+
update();
|
|
852
|
+
if (v === "days") days.focus({ preventScroll: true });
|
|
853
|
+
if (v === "hours") hours.focus({ preventScroll: true });
|
|
854
|
+
}, "Limit window", "sess-menu");
|
|
855
|
+
};
|
|
856
|
+
models.onclick = async (e) => {
|
|
857
|
+
e.preventDefault();
|
|
858
|
+
e.stopPropagation();
|
|
859
|
+
if (models.classList.contains("open")) return closeProtoMenu();
|
|
860
|
+
let list = [];
|
|
861
|
+
try { ({ models: list } = await api("caller-keys/models")); } catch (err) { status(t(err.message), "err"); return; }
|
|
862
|
+
if (!models.isConnected || gatewayLimit !== d) return;
|
|
863
|
+
const opts = [{ v: "", name: "All models", note: "" }];
|
|
864
|
+
const seen = new Set();
|
|
865
|
+
// a routing group isn't one: what it picks is counted by the model
|
|
866
|
+
// that served the call
|
|
867
|
+
for (const m of list || []) {
|
|
868
|
+
if (m.group) continue;
|
|
869
|
+
if (!seen.has(m.provider)) {
|
|
870
|
+
seen.add(m.provider);
|
|
871
|
+
opts.push({ v: m.provider + "/*", name: t("Every {provider} model", { provider: m.providerName }), note: m.provider + "/*", literalName: true });
|
|
872
|
+
}
|
|
873
|
+
opts.push({ v: m.id, name: m.name, note: m.id, literalName: true });
|
|
874
|
+
}
|
|
875
|
+
for (const v of d.models) if (!opts.some((o) => o.v === v)) opts.push({ v, name: v, note: "Not served now", literalName: true });
|
|
876
|
+
openProtoMenu(models, opts, [...d.models], (picked) => {
|
|
877
|
+
d.models = picked;
|
|
878
|
+
paintModels();
|
|
879
|
+
update();
|
|
880
|
+
}, "Models this limit counts", "sess-menu");
|
|
881
|
+
};
|
|
882
|
+
days.oninput = () => { d.days = days.value; update(); };
|
|
883
|
+
hours.oninput = () => { d.hours = hours.value; update(); };
|
|
884
|
+
tokens.oninput = () => { d.tokens = tokens.value; update(); };
|
|
885
|
+
cost.oninput = () => { d.cost = cost.value; update(); };
|
|
886
|
+
cb.onchange = () => { d.cacheReads = cb.checked; update(); };
|
|
887
|
+
const close = () => { gatewayLimit = null; renderGatewayKeys(); };
|
|
888
|
+
for (const i of [days, hours, tokens, cost]) i.onkeydown = (e) => {
|
|
889
|
+
e.stopPropagation();
|
|
890
|
+
if (e.key === "Escape") close();
|
|
891
|
+
else if (e.key === "Enter" && !save.disabled) save.click();
|
|
892
|
+
};
|
|
893
|
+
const cancel = el("button", "text", t("Cancel"));
|
|
894
|
+
cancel.onclick = close;
|
|
895
|
+
save.onclick = async () => {
|
|
896
|
+
if (save.disabled) return;
|
|
897
|
+
save.disabled = true;
|
|
898
|
+
const tk = limitTokens(d.tokens), c = limitCost(d.cost);
|
|
899
|
+
const limit = tk || c ? { period: d.period, tokens: tk, cost: c, cacheReads: d.cacheReads } : null;
|
|
900
|
+
if (limit && d.models.length) limit.models = d.models;
|
|
901
|
+
if (limit && d.period === "days") limit.days = limitDays(d.days);
|
|
902
|
+
if (limit && d.period === "hours") limit.hours = limitHours(d.hours);
|
|
903
|
+
// which one only when there are several: one is the key's only limit
|
|
904
|
+
const out = await gatewayKeyAction("limit-key", which ? { key: k.id, limit, index: n } : { key: k.id, limit });
|
|
905
|
+
if (!out) { update(); return; }
|
|
906
|
+
status(t(limit ? "Limit saved for {name}" : "Limit removed for {name}", { name: k.name }), "ok");
|
|
907
|
+
};
|
|
908
|
+
const foot = el("div", "acm-foot");
|
|
909
|
+
foot.append(cache, el("span", "grow"), unsaved);
|
|
910
|
+
if (limits.length && n < limits.length && limits.length < 5) {
|
|
911
|
+
// one more limit in a window of its own (#2227): 5 hours and a week,
|
|
912
|
+
// as a subscription's; a request passes only while all have some left
|
|
913
|
+
const more = el("button", "text", t("Add a limit"));
|
|
914
|
+
more.title = t("Another limit in a window of its own; a request passes only while every limit has something left");
|
|
915
|
+
more.onclick = (e) => { e.stopPropagation(); gatewayLimit = limitDraft(k, limits.length); renderGatewayKeys(); };
|
|
916
|
+
foot.append(more);
|
|
917
|
+
}
|
|
918
|
+
if (n < limits.length) {
|
|
919
|
+
const off = el("button", "text", t(limits.length > 1 ? "Remove this limit" : "No limit"));
|
|
920
|
+
off.title = t("Clears the caps; Save makes it");
|
|
921
|
+
off.onclick = () => { d.tokens = ""; d.cost = ""; tokens.value = ""; cost.value = ""; update(); };
|
|
922
|
+
foot.append(off);
|
|
923
|
+
}
|
|
924
|
+
foot.append(cancel, save);
|
|
925
|
+
if (which) box.append(which);
|
|
926
|
+
box.append(fields, hint, err);
|
|
927
|
+
if (used) {
|
|
928
|
+
// count from 0 again, tokens and cost together (#1509)
|
|
929
|
+
const zero = el("button", "text klf-reset", t("Reset usage"));
|
|
930
|
+
zero.title = t("Count this key's use from 0 again, tokens and cost together");
|
|
931
|
+
zero.onclick = (e) => { e.stopPropagation(); askResetLimit(k, n); };
|
|
932
|
+
const line = el("div", "klf-used");
|
|
933
|
+
line.append(gatewayLimitLine(k, used), zero);
|
|
934
|
+
box.append(line);
|
|
935
|
+
}
|
|
936
|
+
box.append(foot);
|
|
937
|
+
update();
|
|
938
|
+
return box;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// askResetLimit confirms a key's Reset (#1509): what it has used goes back
|
|
942
|
+
// to 0, tokens and cost together; an N-day cycle starts again from now, a
|
|
943
|
+
// calendar window keeps its end.
|
|
944
|
+
function askResetLimit(k, n) {
|
|
945
|
+
const u = keyUsed(k)[n] || {};
|
|
946
|
+
const ed = el("div", "editor");
|
|
947
|
+
const head = el("div", "ehead");
|
|
948
|
+
head.append(el("b", "", t("Reset {name}'s usage?", { name: k.name })));
|
|
949
|
+
ed.append(head, el("p", "lib-confirm", u.period === "days"
|
|
950
|
+
? t("The tokens and cost {name} has used go back to 0 together, and a new {n}-day cycle starts now.", { name: k.name, n: u.days })
|
|
951
|
+
: u.period === "hours" ? t("The tokens and cost {name} has used go back to 0 together, and a new {n}-hour cycle starts now.", { name: k.name, n: u.hours })
|
|
952
|
+
: t("The tokens and cost {name} has used go back to 0 together. Calls so far in this window stop counting; it still resets {when}.", { name: k.name, when: resetClock(new Date(u.reset)) })));
|
|
953
|
+
const bar = el("div", "bar");
|
|
954
|
+
const cancel = el("button", "text", t("Cancel"));
|
|
955
|
+
cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
|
|
956
|
+
const go = el("button", "text primary", t("Reset usage"));
|
|
957
|
+
go.onclick = async (e) => {
|
|
958
|
+
e.stopPropagation();
|
|
959
|
+
if (go.disabled) return;
|
|
960
|
+
go.disabled = true;
|
|
961
|
+
const out = await gatewayKeyAction("reset-limit-key", keyUsed(k).length > 1 ? { key: k.id, index: n } : { key: k.id });
|
|
962
|
+
if (!out) { go.disabled = false; return; }
|
|
963
|
+
closeConfirmAsk();
|
|
964
|
+
status(t("Usage reset for {name}", { name: k.name }), "ok");
|
|
965
|
+
};
|
|
966
|
+
bar.append(el("span", "grow"), cancel, go);
|
|
967
|
+
ed.append(bar);
|
|
968
|
+
confirmAsk = ed;
|
|
969
|
+
openModal(ed);
|
|
970
|
+
$("#modal").classList.add("lib");
|
|
971
|
+
cancel.focus({ preventScroll: true });
|
|
972
|
+
}
|