dsh-mobile-plugin 0.5.27 → 0.5.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/catalog.json +10 -1
- package/extensions/magpie/agentcompact.js +104 -0
- package/extensions/magpie/app.css +4902 -0
- package/extensions/magpie/app.js +24474 -0
- package/extensions/magpie/autobackup.js +185 -0
- package/extensions/magpie/autoresetown.js +151 -0
- package/extensions/magpie/balfound.js +38 -0
- package/extensions/magpie/boot.js +23 -0
- package/extensions/magpie/cfgblocked.js +39 -0
- package/extensions/magpie/checkinnow.js +111 -0
- package/extensions/magpie/claudemode.js +92 -0
- package/extensions/magpie/compat.js +41 -0
- package/extensions/magpie/context.css +433 -0
- package/extensions/magpie/context.js +1576 -0
- package/extensions/magpie/ctxtext.js +223 -0
- package/extensions/magpie/fonts.js +42 -0
- package/extensions/magpie/i18n.js +18942 -0
- package/extensions/magpie/icons/ai21.svg +1 -0
- package/extensions/magpie/icons/ai302-color.svg +1 -0
- package/extensions/magpie/icons/ai360-color.svg +1 -0
- package/extensions/magpie/icons/aihubmix-color.svg +1 -0
- package/extensions/magpie/icons/aionui.svg +1 -0
- package/extensions/magpie/icons/air.svg +4 -0
- package/extensions/magpie/icons/alibaba-color.svg +1 -0
- package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
- package/extensions/magpie/icons/alma.png +0 -0
- package/extensions/magpie/icons/anthropic.svg +1 -0
- package/extensions/magpie/icons/antigravity-color.svg +1 -0
- package/extensions/magpie/icons/anyscale-color.svg +1 -0
- package/extensions/magpie/icons/app-alma.png +0 -0
- package/extensions/magpie/icons/app-cc-switch.png +0 -0
- package/extensions/magpie/icons/apple-touch-icon.png +0 -0
- package/extensions/magpie/icons/aside.svg +28 -0
- package/extensions/magpie/icons/astrbot.svg +1 -0
- package/extensions/magpie/icons/atomcode.png +0 -0
- package/extensions/magpie/icons/aws.svg +1 -0
- package/extensions/magpie/icons/azure-color.svg +1 -0
- package/extensions/magpie/icons/azureai-color.svg +1 -0
- package/extensions/magpie/icons/baichuan-color.svg +1 -0
- package/extensions/magpie/icons/baidu-color.svg +1 -0
- package/extensions/magpie/icons/baiducloud-color.svg +1 -0
- package/extensions/magpie/icons/bailian-color.svg +1 -0
- package/extensions/magpie/icons/baseten.svg +1 -0
- package/extensions/magpie/icons/bedrock-color.svg +1 -0
- package/extensions/magpie/icons/burncloud-color.svg +1 -0
- package/extensions/magpie/icons/bytedance-color.svg +1 -0
- package/extensions/magpie/icons/cerebras.svg +1 -0
- package/extensions/magpie/icons/chatglm-color.svg +1 -0
- package/extensions/magpie/icons/cherryin-color.svg +1 -0
- package/extensions/magpie/icons/chutes.svg +1 -0
- package/extensions/magpie/icons/cindy.png +0 -0
- package/extensions/magpie/icons/claude-color.svg +1 -0
- package/extensions/magpie/icons/claudecode-color.svg +1 -0
- package/extensions/magpie/icons/cline.svg +1 -0
- package/extensions/magpie/icons/cloudflare-color.svg +1 -0
- package/extensions/magpie/icons/codebuddy-color.svg +1 -0
- package/extensions/magpie/icons/codex-color.svg +1 -0
- package/extensions/magpie/icons/cohere-color.svg +1 -0
- package/extensions/magpie/icons/cometapi-color.svg +1 -0
- package/extensions/magpie/icons/commandcode.svg +1 -0
- package/extensions/magpie/icons/copilot-color.svg +1 -0
- package/extensions/magpie/icons/coze.svg +1 -0
- package/extensions/magpie/icons/crush.png +0 -0
- package/extensions/magpie/icons/curl.svg +1 -0
- package/extensions/magpie/icons/cursor.svg +1 -0
- package/extensions/magpie/icons/deepinfra-color.svg +1 -0
- package/extensions/magpie/icons/deepmind-color.svg +1 -0
- package/extensions/magpie/icons/deepseek-color.svg +1 -0
- package/extensions/magpie/icons/deveco-color.svg +12 -0
- package/extensions/magpie/icons/devin.svg +1 -0
- package/extensions/magpie/icons/doubao-color.svg +1 -0
- package/extensions/magpie/icons/empryo-color.svg +1 -0
- package/extensions/magpie/icons/factory.svg +1 -0
- package/extensions/magpie/icons/favicon.svg +1 -0
- package/extensions/magpie/icons/featherless-color.svg +1 -0
- package/extensions/magpie/icons/fireworks-color.svg +1 -0
- package/extensions/magpie/icons/friendli.svg +1 -0
- package/extensions/magpie/icons/fx.svg +1 -0
- package/extensions/magpie/icons/gemini-color.svg +1 -0
- package/extensions/magpie/icons/geminicli-color.svg +1 -0
- package/extensions/magpie/icons/gemma-color.svg +1 -0
- package/extensions/magpie/icons/giteeai.svg +1 -0
- package/extensions/magpie/icons/github.svg +1 -0
- package/extensions/magpie/icons/githubcopilot.svg +1 -0
- package/extensions/magpie/icons/glama.svg +1 -0
- package/extensions/magpie/icons/gmicloud.svg +1 -0
- package/extensions/magpie/icons/google-color.svg +1 -0
- package/extensions/magpie/icons/googlecloud-color.svg +1 -0
- package/extensions/magpie/icons/goose.svg +1 -0
- package/extensions/magpie/icons/grok.svg +1 -0
- package/extensions/magpie/icons/groq.svg +1 -0
- package/extensions/magpie/icons/hanako.png +0 -0
- package/extensions/magpie/icons/hermes.svg +1 -0
- package/extensions/magpie/icons/huawei-color.svg +1 -0
- package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
- package/extensions/magpie/icons/huggingface-color.svg +1 -0
- package/extensions/magpie/icons/hunyuan-color.svg +1 -0
- package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
- package/extensions/magpie/icons/icon-512.png +0 -0
- package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
- package/extensions/magpie/icons/inference.svg +1 -0
- package/extensions/magpie/icons/infinigence-color.svg +1 -0
- package/extensions/magpie/icons/internlm-color.svg +1 -0
- package/extensions/magpie/icons/jcode.svg +1 -0
- package/extensions/magpie/icons/jina.svg +1 -0
- package/extensions/magpie/icons/kilo.svg +1 -0
- package/extensions/magpie/icons/kilocode.svg +1 -0
- package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
- package/extensions/magpie/icons/kimi.svg +1 -0
- package/extensions/magpie/icons/kiro-color.svg +1 -0
- package/extensions/magpie/icons/kling-color.svg +1 -0
- package/extensions/magpie/icons/lambda.svg +1 -0
- package/extensions/magpie/icons/leptonai-color.svg +1 -0
- package/extensions/magpie/icons/liquid.svg +1 -0
- package/extensions/magpie/icons/liveagent-color.svg +1 -0
- package/extensions/magpie/icons/llmapi.svg +1 -0
- package/extensions/magpie/icons/lmstudio.svg +1 -0
- package/extensions/magpie/icons/lody.png +0 -0
- package/extensions/magpie/icons/longcat.svg +1 -0
- package/extensions/magpie/icons/magpie.svg +1 -0
- package/extensions/magpie/icons/meta-color.svg +1 -0
- package/extensions/magpie/icons/metaai-color.svg +1 -0
- package/extensions/magpie/icons/microsoft-color.svg +1 -0
- package/extensions/magpie/icons/mimocode.svg +1 -0
- package/extensions/magpie/icons/minimax-color.svg +1 -0
- package/extensions/magpie/icons/mistermorph-color.svg +1 -0
- package/extensions/magpie/icons/mistral-color.svg +1 -0
- package/extensions/magpie/icons/mlx-serve.png +0 -0
- package/extensions/magpie/icons/modelscope-color.svg +1 -0
- package/extensions/magpie/icons/monica-color.svg +1 -0
- package/extensions/magpie/icons/moonshot.svg +1 -0
- package/extensions/magpie/icons/morph-color.svg +1 -0
- package/extensions/magpie/icons/mothx.png +0 -0
- package/extensions/magpie/icons/nebius.svg +1 -0
- package/extensions/magpie/icons/newapi-color.svg +1 -0
- package/extensions/magpie/icons/nousresearch.svg +1 -0
- package/extensions/magpie/icons/nova-color.svg +1 -0
- package/extensions/magpie/icons/novita-color.svg +1 -0
- package/extensions/magpie/icons/nvidia-color.svg +1 -0
- package/extensions/magpie/icons/ollama.svg +1 -0
- package/extensions/magpie/icons/omlx.svg +1 -0
- package/extensions/magpie/icons/omo.svg +1 -0
- package/extensions/magpie/icons/omp.svg +1 -0
- package/extensions/magpie/icons/openai.svg +1 -0
- package/extensions/magpie/icons/openchamber.svg +11 -0
- package/extensions/magpie/icons/openclaw.svg +1 -0
- package/extensions/magpie/icons/opencode.svg +1 -0
- package/extensions/magpie/icons/openmausbot.svg +1 -0
- package/extensions/magpie/icons/openrouter.svg +1 -0
- package/extensions/magpie/icons/openwebui.svg +1 -0
- package/extensions/magpie/icons/parasail.svg +1 -0
- package/extensions/magpie/icons/penguin-color.svg +1 -0
- package/extensions/magpie/icons/perplexity-color.svg +1 -0
- package/extensions/magpie/icons/phala-color.svg +1 -0
- package/extensions/magpie/icons/pi.svg +1 -0
- package/extensions/magpie/icons/pipellm-color.svg +5 -0
- package/extensions/magpie/icons/poe.svg +1 -0
- package/extensions/magpie/icons/ppio-color.svg +1 -0
- package/extensions/magpie/icons/prime-agent.svg +1 -0
- package/extensions/magpie/icons/qiniu-color.svg +1 -0
- package/extensions/magpie/icons/qoder.svg +1 -0
- package/extensions/magpie/icons/qwen-color.svg +1 -0
- package/extensions/magpie/icons/qwenpaw.svg +1 -0
- package/extensions/magpie/icons/raycast.svg +1 -0
- package/extensions/magpie/icons/reasonix-color.svg +48 -0
- package/extensions/magpie/icons/reka.svg +1 -0
- package/extensions/magpie/icons/replicate.svg +1 -0
- package/extensions/magpie/icons/roocode.svg +1 -0
- package/extensions/magpie/icons/rtk.png +0 -0
- package/extensions/magpie/icons/sambanova-color.svg +1 -0
- package/extensions/magpie/icons/sensenova-color.svg +1 -0
- package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
- package/extensions/magpie/icons/snow.png +0 -0
- package/extensions/magpie/icons/snowflake-color.svg +1 -0
- package/extensions/magpie/icons/sophnet-color.svg +1 -0
- package/extensions/magpie/icons/spark-color.svg +1 -0
- package/extensions/magpie/icons/stepfun-color.svg +1 -0
- package/extensions/magpie/icons/straico-color.svg +1 -0
- package/extensions/magpie/icons/submodel-color.svg +1 -0
- package/extensions/magpie/icons/t3code.svg +4 -0
- package/extensions/magpie/icons/targon-color.svg +1 -0
- package/extensions/magpie/icons/tencent-color.svg +1 -0
- package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
- package/extensions/magpie/icons/tiangong-color.svg +1 -0
- package/extensions/magpie/icons/together-color.svg +1 -0
- package/extensions/magpie/icons/trae.svg +1 -0
- package/extensions/magpie/icons/typesafe.png +0 -0
- package/extensions/magpie/icons/upstage-color.svg +1 -0
- package/extensions/magpie/icons/venice-color.svg +1 -0
- package/extensions/magpie/icons/vercel.svg +1 -0
- package/extensions/magpie/icons/vertexai-color.svg +1 -0
- package/extensions/magpie/icons/vllm-color.svg +1 -0
- package/extensions/magpie/icons/volcengine-color.svg +1 -0
- package/extensions/magpie/icons/voyage-color.svg +1 -0
- package/extensions/magpie/icons/vscode.svg +1 -0
- package/extensions/magpie/icons/wenxin-color.svg +1 -0
- package/extensions/magpie/icons/windsurf.svg +1 -0
- package/extensions/magpie/icons/workbuddy-color.svg +1 -0
- package/extensions/magpie/icons/workersai-color.svg +1 -0
- package/extensions/magpie/icons/xai.svg +1 -0
- package/extensions/magpie/icons/xiaomimimo.svg +1 -0
- package/extensions/magpie/icons/xinference-color.svg +1 -0
- package/extensions/magpie/icons/yi.svg +1 -0
- package/extensions/magpie/icons/yuanbao-color.svg +1 -0
- package/extensions/magpie/icons/yylx.svg +5 -0
- package/extensions/magpie/icons/zai.svg +1 -0
- package/extensions/magpie/icons/zcode.png +0 -0
- package/extensions/magpie/icons/zed.svg +1 -0
- package/extensions/magpie/icons/zenmux.svg +1 -0
- package/extensions/magpie/icons/zeroone.svg +1 -0
- package/extensions/magpie/icons/zhipu-color.svg +1 -0
- package/extensions/magpie/icons/zoo-code.png +0 -0
- package/extensions/magpie/index.html +572 -0
- package/extensions/magpie/kernel.js +274 -0
- package/extensions/magpie/keyspend.js +63 -0
- package/extensions/magpie/layout.js +26 -0
- package/extensions/magpie/manifest.json +37 -0
- package/extensions/magpie/navlamp.js +47 -0
- package/extensions/magpie/omarchy.css +72 -0
- package/extensions/magpie/omarchy.js +46 -0
- package/extensions/magpie/passerrors.js +21 -0
- package/extensions/magpie/pluginpages.js +405 -0
- package/extensions/magpie/plugins/gateway/client.js +1194 -0
- package/extensions/magpie/plugins/gateway/keys.js +972 -0
- package/extensions/magpie/plugins/gateway/settings.js +459 -0
- package/extensions/magpie/plugins/library/client.js +6788 -0
- package/extensions/magpie/plugins/library/i18n.js +3228 -0
- package/extensions/magpie/plugins/library/library.css +848 -0
- package/extensions/magpie/plugins/otel/i18n.js +48 -0
- package/extensions/magpie/plugins/otel/page.js +189 -0
- package/extensions/magpie/plugins/redact/client.js +46 -0
- package/extensions/magpie/plugins/redact/demo.js +461 -0
- package/extensions/magpie/plugins/redact/i18n.js +432 -0
- package/extensions/magpie/plugins/redact/page.js +985 -0
- package/extensions/magpie/plugins/redact/redact.css +385 -0
- package/extensions/magpie/plugins/redact/settings.js +248 -0
- package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
- package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
- package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
- package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
- package/extensions/magpie/plugins/routing/client.js +8593 -0
- package/extensions/magpie/plugins/routing/i18n.js +2696 -0
- package/extensions/magpie/plugins/routing/routing.css +1111 -0
- package/extensions/magpie/plugins/search/settings.js +460 -0
- package/extensions/magpie/plugins/sessions/client.js +1622 -0
- package/extensions/magpie/plugins/sessions/i18n.js +360 -0
- package/extensions/magpie/plugins/sessions/sessions.css +181 -0
- package/extensions/magpie/plugins/sessions/settings.js +100 -0
- package/extensions/magpie/plugins/usage/client.js +150 -0
- package/extensions/magpie/plugins/usage/i18n.js +984 -0
- package/extensions/magpie/plugins/usage/page.js +4307 -0
- package/extensions/magpie/plugins.css +319 -0
- package/extensions/magpie/plugins.js +1926 -0
- package/extensions/magpie/profilerename.js +27 -0
- package/extensions/magpie/purposes.js +2 -0
- package/extensions/magpie/quitask.js +79 -0
- package/extensions/magpie/quotaall.js +142 -0
- package/extensions/magpie/ratewindows.js +123 -0
- package/extensions/magpie/registry.js +87 -0
- package/extensions/magpie/resetask.js +137 -0
- package/extensions/magpie/ruleedit.js +53 -0
- package/extensions/magpie/scrollends.js +91 -0
- package/extensions/magpie/settingshints.js +75 -0
- package/extensions/magpie/signsheets.js +86 -0
- package/extensions/magpie/start.js +26 -0
- package/extensions/magpie/syncfiles.js +216 -0
- package/extensions/magpie/theme.js +29 -0
- package/extensions/magpie/traywindows.js +41 -0
- package/extensions/magpie/webfonts.js +130 -0
- package/extensions/magpie/whatsnew.js +252 -0
- package/extensions/magpie.zip +0 -0
- package/knowledge/apps/baijiahao.md +30 -0
- package/knowledge/apps/bilibili.md +1 -0
- package/knowledge/apps/dedao.md +33 -0
- package/knowledge/apps/feishu.md +35 -0
- package/knowledge/apps/gmail.md +33 -0
- package/knowledge/apps/jike.md +31 -0
- package/knowledge/apps/kuaishou.md +34 -0
- package/knowledge/apps/linkedin.md +33 -0
- package/knowledge/apps/qqmail.md +30 -0
- package/knowledge/apps/telegram.md +27 -0
- package/knowledge/apps/toutiao.md +32 -0
- package/knowledge/apps/uber-eats.md +26 -0
- package/knowledge/apps/uber.md +27 -0
- package/knowledge/apps/whatsapp.md +27 -0
- package/knowledge/apps/x.md +32 -0
- package/knowledge/apps/xiaohongshu.md +1 -0
- package/knowledge/apps/zhihu.md +1 -0
- package/knowledge/sites/bilibili.com/SITE.md +50 -0
- package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
- package/knowledge/sites/github.com/SITE.md +51 -0
- package/knowledge/sites/github.com/references/repo-fields.md +13 -0
- package/knowledge/sites/x.com/SITE.md +47 -0
- package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
- package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
- package/knowledge/sites/youtube.com/SITE.md +49 -0
- package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
- package/knowledge/sites/zhihu.com/SITE.md +47 -0
- package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
- package/lib/mobile/extension-catalog.js +41 -1
- package/lib/mobile/extension-data.js +224 -0
- package/lib/mobile/extension-dev.js +3 -1
- package/lib/mobile/file-ops.js +252 -0
- package/lib/mobile/history-derive.js +80 -4
- package/lib/mobile/host-bridge/endpoints.js +48 -6
- package/lib/mobile/host-bridge/event-synthesis.js +33 -6
- package/lib/mobile/jobs-control.js +118 -0
- package/lib/mobile/memory.js +635 -0
- package/lib/mobile/model-badges.js +77 -0
- package/lib/mobile/pet-tools.js +50 -4
- package/lib/mobile/pets.js +214 -18
- package/lib/mobile/phone/access.js +85 -0
- package/lib/mobile/phone/evidence.js +228 -0
- package/lib/mobile/phone/gate.js +290 -0
- package/lib/mobile/phone/grounding.js +314 -0
- package/lib/mobile/phone/guard.js +465 -0
- package/lib/mobile/phone/irreversible.js +88 -8
- package/lib/mobile/phone/knowledge-tools.js +665 -0
- package/lib/mobile/phone/learn.js +329 -0
- package/lib/mobile/phone/redact.js +237 -0
- package/lib/mobile/phone/risk.js +95 -0
- package/lib/mobile/phone/routes.js +28 -1
- package/lib/mobile/phone/service.js +23 -0
- package/lib/mobile/phone/site-commands.js +271 -0
- package/lib/mobile/phone/step-summary.js +201 -0
- package/lib/mobile/phone/stop-summary.js +235 -0
- package/lib/mobile/phone/tool-groups.js +125 -0
- package/lib/mobile/phone/tools.js +589 -0
- package/lib/mobile/phone/untrusted.js +65 -0
- package/lib/mobile/phone-knowledge.js +20 -4
- package/lib/mobile/schedule-edit.js +500 -0
- package/lib/mobile/schedule.js +5 -0
- package/lib/mobile/send-continuation.js +52 -0
- package/lib/mobile/session-models.js +27 -31
- package/lib/mobile/session-send.js +12 -12
- package/lib/mobile/skill-import.js +338 -0
- package/lib/mobile/skill-store.js +524 -0
- package/lib/routes/mobile-api.js +42 -0
- package/package.json +7 -6
- package/skills/skill-authoring/SKILL.md +46 -0
- package/skills/skill-authoring/references/examples.md +82 -0
- package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
- package/skills/skill-authoring/scripts/check_skill.py +167 -0
- package/skills/skill-authoring/scripts/manifest.json +3 -0
- package/knowledge/sites/bilibili.com.md +0 -27
- package/knowledge/sites/github.com.md +0 -27
- package/knowledge/sites/x.com.md +0 -28
- package/knowledge/sites/xiaohongshu.com.md +0 -27
- package/knowledge/sites/youtube.com.md +0 -28
- package/knowledge/sites/zhihu.com.md +0 -27
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
// The Gateway page (plugins/gateway): the gateway's state and its keys
|
|
2
|
+
// (keys.js), how to connect a client, its models and its recent calls. It
|
|
3
|
+
// is a page the plugin adds to the window (kernel.js): its markup is the
|
|
4
|
+
// html below, and the shell draws it through its hooks — loading before
|
|
5
|
+
// the state is in, draft from the state kept, render with the new one —
|
|
6
|
+
// behind the kernel's error boundary.
|
|
7
|
+
//
|
|
8
|
+
// Classic script, loaded before app.js: what runs as it loads touches
|
|
9
|
+
// neither the page nor app.js; mount, at magpie.start, wires the page.
|
|
10
|
+
|
|
11
|
+
// the gateway tab's choices, kept per machine
|
|
12
|
+
const gwParams = new URLSearchParams(location.search);
|
|
13
|
+
let flavor = gwParams.get("flavor") || localStorage.getItem("magpie.flavor") || "openai"; // which API the snippets speak
|
|
14
|
+
let lang = gwParams.get("lang") || localStorage.getItem("magpie.lang") || "shell"; // which snippet
|
|
15
|
+
let snipOS = localStorage.getItem("magpie.snipOS") || ""; // the shell the Shell and curl snippets are for (#1514)
|
|
16
|
+
let exampleModel = localStorage.getItem("magpie.model") || ""; // the model in the snippets
|
|
17
|
+
let decideModel = localStorage.getItem("magpie.decideModel") || ""; // the System One snippets' decision model
|
|
18
|
+
// Connect folded away under its heading: folded until it is opened, as it
|
|
19
|
+
// is set up once and seldom read again (the owner)
|
|
20
|
+
let connectFolded = true;
|
|
21
|
+
try { connectFolded = localStorage.getItem("magpie.gwConnectFolded") !== "0"; } catch {}
|
|
22
|
+
const expandedCalls = new Set(); // recent-call ids whose wire bodies are open
|
|
23
|
+
const callBodies = new Map(); // callRow → its bodies, read when it is opened (#1521)
|
|
24
|
+
const wireOpen = new Set(); // recent-call ids whose HTTP headers are open
|
|
25
|
+
// recent calls drawn a page at a time, as many as the plugin's setting
|
|
26
|
+
// says (callsPage)
|
|
27
|
+
let callsPage = 20;
|
|
28
|
+
let callsShown = callsPage;
|
|
29
|
+
|
|
30
|
+
function renderGatewayLoading() {
|
|
31
|
+
const page = $("#view-gateway");
|
|
32
|
+
page.classList.add("loading");
|
|
33
|
+
page.setAttribute("aria-busy", "true");
|
|
34
|
+
$("#connectNote").textContent = "";
|
|
35
|
+
$("#callsNote").textContent = "";
|
|
36
|
+
$("#copyModels").hidden = true;
|
|
37
|
+
|
|
38
|
+
const gateway = $("#gateway");
|
|
39
|
+
gateway.replaceChildren();
|
|
40
|
+
const mark = el("span", "skeleton gw-sk-dot");
|
|
41
|
+
const who = el("div", "who gw-sk-who");
|
|
42
|
+
who.append(el("span", "skeleton gw-sk-title"), el("span", "skeleton gw-sk-sub"));
|
|
43
|
+
gateway.append(mark, who, el("span", "skeleton gw-sk-url"));
|
|
44
|
+
|
|
45
|
+
const connect = $("#connect");
|
|
46
|
+
connect.replaceChildren();
|
|
47
|
+
connect.hidden = connectFolded;
|
|
48
|
+
for (let i = 0; i < 4; i++) {
|
|
49
|
+
connect.append(el("span", "skeleton gw-sk-label"));
|
|
50
|
+
const value = el("div", "gw-sk-field");
|
|
51
|
+
value.append(el("span", "skeleton"), el("span", "skeleton short"));
|
|
52
|
+
connect.append(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const models = $("#gwModels");
|
|
56
|
+
models.replaceChildren();
|
|
57
|
+
for (let i = 0; i < 4; i++) {
|
|
58
|
+
const row = el("div", "row model gw-sk-row");
|
|
59
|
+
row.append(el("span", "skeleton gw-sk-model"), el("span", "grow"), el("span", "skeleton gw-sk-provider"));
|
|
60
|
+
models.append(row);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const activity = $("#activity");
|
|
64
|
+
activity.replaceChildren();
|
|
65
|
+
for (let i = 0; i < 4; i++) {
|
|
66
|
+
const row = el("div", "call gw-sk-call");
|
|
67
|
+
row.append(el("span", "skeleton time"), el("span", "skeleton agent"), el("span", "skeleton model"), el("span", "grow"), el("span", "skeleton result"));
|
|
68
|
+
activity.append(row);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderGatewayView() {
|
|
73
|
+
const page = $("#view-gateway");
|
|
74
|
+
page.classList.remove("loading");
|
|
75
|
+
page.removeAttribute("aria-busy");
|
|
76
|
+
renderGateway();
|
|
77
|
+
renderGatewayLan();
|
|
78
|
+
$("#gatewayKeysBlock").hidden = !providers.gateway.lan;
|
|
79
|
+
// not shared, the keys are left out; this line says where they come from,
|
|
80
|
+
// as a tunnel to loopback is refused until sharing is on (DD on Discord)
|
|
81
|
+
$("#gatewayKeysOff").hidden = !!providers.gateway.lan;
|
|
82
|
+
if (!providers.gateway.lan) gatewayKeyDraft = null;
|
|
83
|
+
if (gatewayKeyDraft === null && gatewayLimit === null && !$("#gatewayKeys .rename-in")) renderGatewayKeys();
|
|
84
|
+
renderConnect();
|
|
85
|
+
renderGatewayModels();
|
|
86
|
+
renderActivity();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The status card's button (leslie_luo on Discord: told to quit the older
|
|
90
|
+
// magpie serving the gateway, with no way to tell which one it was): with
|
|
91
|
+
// an older magpie on the port it quits that one, checked to be magpie by
|
|
92
|
+
// its process, and this one serves the gateway at once; otherwise it
|
|
93
|
+
// restarts the gateway this one serves, or starts it. A magpie of this
|
|
94
|
+
// version or newer serving it gets none: that one is not to be quit here.
|
|
95
|
+
// What stood in the way stays under it until the gateway's state changes.
|
|
96
|
+
let gatewayFix = { busy: false, err: "", when: "" };
|
|
97
|
+
const gatewayFixWhen = (g) => [g.running, g.mine, g.version || ""].join("|");
|
|
98
|
+
function gatewayFixButton(g, older) {
|
|
99
|
+
if (g.running && !g.mine && !older) return null;
|
|
100
|
+
// bordered: a plain text button read as words on the card in dark mode
|
|
101
|
+
const b = el("button", "text action gw-fix");
|
|
102
|
+
b.textContent = gatewayFix.busy
|
|
103
|
+
? (older ? t("Quitting magpie {v}…", { v: g.version }) : t(g.running ? "Restarting the gateway…" : "Starting the gateway…"))
|
|
104
|
+
: (older ? t("Quit magpie {v} and take over", { v: g.version }) : t(g.running ? "Restart gateway" : "Start gateway"));
|
|
105
|
+
b.disabled = gatewayFix.busy;
|
|
106
|
+
b.onclick = async () => {
|
|
107
|
+
const when = gatewayFixWhen(g);
|
|
108
|
+
gatewayFix = { busy: true, err: "", when };
|
|
109
|
+
renderGateway();
|
|
110
|
+
let out;
|
|
111
|
+
try { out = await api(older ? "gateway/take-over" : "gateway/restart", {}); } catch (e) { out = { reason: "error", error: e.message }; }
|
|
112
|
+
gatewayFix = { busy: false, err: out?.ok ? "" : gatewayFixSays(out || {}), when };
|
|
113
|
+
try { await loadProviders(); } catch {}
|
|
114
|
+
renderGateway();
|
|
115
|
+
};
|
|
116
|
+
return b;
|
|
117
|
+
}
|
|
118
|
+
// gatewayFixSays is why a take-over or a restart didn't go, in the page's
|
|
119
|
+
// language, naming the process it was
|
|
120
|
+
function gatewayFixSays(o) {
|
|
121
|
+
const p = { port: o.port || "", pid: o.pid || "", path: o.path || "", v: o.version || "", error: o.error || "" };
|
|
122
|
+
switch (o.reason) {
|
|
123
|
+
case "not-magpie": return t("Port {port} is held by {path} (pid {pid}), which isn't magpie, so magpie leaves it alone. Quit it yourself, or move the gateway to another port in Settings.", p);
|
|
124
|
+
// leslie_luo on Discord: an old magpie in OrbStack, its helper named
|
|
125
|
+
// as the program to quit
|
|
126
|
+
case "container": return t("Port {port} is forwarded by {path} (pid {pid}) to a container or a VM: the magpie {v} answering there runs inside it, out of this magpie's reach. Stop that container (docker ps lists it, docker stop <name> stops it, or stop it in OrbStack or Docker Desktop), or move this magpie's gateway to another port in Settings.", p);
|
|
127
|
+
case "unseen": return t("magpie can't see which process holds port {port}: it runs as another user or as administrator. Quit it there.", p);
|
|
128
|
+
case "denied": return t("magpie {v} (pid {pid}, {path}) runs as another user or as administrator, so this magpie can't quit it. Quit it there.", p);
|
|
129
|
+
case "stuck": return t("magpie {v} (pid {pid}) didn't quit. End it in your system's process list (Activity Monitor, Task Manager).", p);
|
|
130
|
+
case "respawned": return t("Something started magpie on port {port} again at once ({path}, pid {pid}): a login item or a service keeps it running (a LaunchAgent, a systemd unit, a scheduled task). Remove that, then try again.", p);
|
|
131
|
+
case "taken": return p.pid
|
|
132
|
+
? t("Port {port} is in use by {path} (pid {pid}): quit it, or move the gateway to another port in Settings.", p)
|
|
133
|
+
: t("Port {port} is in use by another program: quit it, or move the gateway to another port in Settings.", p);
|
|
134
|
+
case "other": return t("magpie {v} serves the gateway, and it isn't older than this one: quit it there to serve the gateway here.", p);
|
|
135
|
+
}
|
|
136
|
+
return t("The gateway couldn't be served here: {error}", p);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// gatewaySkewLine: a magpie of another version serves the gateway in this
|
|
140
|
+
// one's place (gui/gateway_older.go). matrix0487 on Discord: the app wrote pi
|
|
141
|
+
// a model its older gateway didn't know, pi said 404 "add a provider in
|
|
142
|
+
// magpie first", and only the Gateway page told of the older magpie. Said
|
|
143
|
+
// over the Agents list, in the panel and the window, until it is gone: an
|
|
144
|
+
// older copy of the app is taken over by itself once idle; any other is
|
|
145
|
+
// the user's to update or quit, here with the Gateway page's own button.
|
|
146
|
+
let gwSkewFix = { busy: false, err: "", pid: 0 };
|
|
147
|
+
function gatewaySkewLine(list) {
|
|
148
|
+
let box = $("#gwSkew");
|
|
149
|
+
const o = state.gatewayOther;
|
|
150
|
+
if (!o || !o.version) {
|
|
151
|
+
if (box) box.hidden = true;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!box) {
|
|
155
|
+
box = el("div", "gw-skew");
|
|
156
|
+
box.id = "gwSkew";
|
|
157
|
+
($("#agentsLead") || list).before(box);
|
|
158
|
+
}
|
|
159
|
+
box.hidden = false;
|
|
160
|
+
if (gwSkewFix.pid !== (o.pid || 0)) gwSkewFix = { busy: false, err: "", pid: o.pid || 0 };
|
|
161
|
+
const p = { v: o.version, mine: o.mine, n: o.busy || 0 };
|
|
162
|
+
let head, why, quit = false;
|
|
163
|
+
if (o.replacing) {
|
|
164
|
+
head = t("magpie {v}, an older copy of this app, still serves the gateway", p);
|
|
165
|
+
why = o.busy > 0
|
|
166
|
+
? t(o.busy === 1 ? "This magpie ({mine}) takes it over once its {n} request in flight finishes; until then agents' requests go through magpie {v}." : "This magpie ({mine}) takes it over once its {n} requests in flight finish; until then agents' requests go through magpie {v}.", p)
|
|
167
|
+
: o.busy < 0
|
|
168
|
+
? t("This magpie ({mine}) takes it over once it has no request in flight; until then agents' requests go through magpie {v}.", p)
|
|
169
|
+
: t("This magpie ({mine}) takes it over in a moment.", p);
|
|
170
|
+
} else if (o.older) {
|
|
171
|
+
head = t("The gateway is served by magpie {v}, older than this app ({mine})", p);
|
|
172
|
+
why = o.serves === "serve"
|
|
173
|
+
? t("It runs as magpie serve, in a terminal or as the service. Agents' requests go through it without this version's fixes, and models set up here may not reach them. Update that magpie and start it again (magpie daemon restart, for the service), or quit it here and this app serves the gateway.", p)
|
|
174
|
+
: t("Agents' requests go through it without this version's fixes, and models set up here may not reach them. Quit it here and this app serves the gateway at once.", p);
|
|
175
|
+
quit = true;
|
|
176
|
+
} else if (o.newer) {
|
|
177
|
+
head = t("The gateway is served by magpie {v}, newer than this app ({mine})", p);
|
|
178
|
+
why = t("Agents' requests go through it, and what this app writes for them may not match it. Update this magpie, or quit the newer one there.", p);
|
|
179
|
+
} else {
|
|
180
|
+
head = t("The gateway is served by magpie {v}, a different build from this app ({mine})", p);
|
|
181
|
+
why = t("Agents' requests go through it, and what this app writes for them may not match it. Quit it there for this app to serve the gateway.", p);
|
|
182
|
+
}
|
|
183
|
+
const card = el("div", "deprecation");
|
|
184
|
+
card.setAttribute("role", "note");
|
|
185
|
+
const body = el("div", "dep-body");
|
|
186
|
+
body.append(el("div", "dep-head", head), el("p", "dep-why", why));
|
|
187
|
+
if (gwSkewFix.err) body.append(el("p", "dep-why gw-skew-err", gwSkewFix.err));
|
|
188
|
+
card.append(el("span", "dep-dot"), body);
|
|
189
|
+
if (quit) {
|
|
190
|
+
const acts = el("div", "dep-acts");
|
|
191
|
+
const b = el("button", "text action", gwSkewFix.busy ? t("Quitting magpie {v}…", p) : t("Quit magpie {v} and take over", p));
|
|
192
|
+
b.disabled = b.classList.toggle("busy", gwSkewFix.busy);
|
|
193
|
+
b.onclick = async () => {
|
|
194
|
+
gwSkewFix = { busy: true, err: "", pid: o.pid || 0 };
|
|
195
|
+
gatewaySkewLine(list);
|
|
196
|
+
let out;
|
|
197
|
+
try { out = await api("gateway/take-over", {}); } catch (e) { out = { reason: "error", error: e.message }; }
|
|
198
|
+
gwSkewFix = { busy: false, err: out?.ok ? "" : gatewayFixSays(out || {}), pid: o.pid || 0 };
|
|
199
|
+
try { state = await api("state"); } catch {}
|
|
200
|
+
renderAgents();
|
|
201
|
+
};
|
|
202
|
+
acts.append(b);
|
|
203
|
+
card.append(acts);
|
|
204
|
+
}
|
|
205
|
+
box.replaceChildren(card);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The status card: the gateway's mark, its state and what it does, and the
|
|
209
|
+
// button that fixes what stands in its way.
|
|
210
|
+
const GW_GLYPH = '<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="5" rx="1.5"/><rect x="3" y="11" width="14" height="5" rx="1.5"/></g><circle class="led" cx="6.2" cy="6.5" r="1.1"/><circle class="led" cx="6.2" cy="13.5" r="1.1"/></svg>';
|
|
211
|
+
function renderGateway() {
|
|
212
|
+
const g = providers.gateway;
|
|
213
|
+
const box = $("#gateway");
|
|
214
|
+
box.replaceChildren();
|
|
215
|
+
// an older magpie keeping the port sends every agent's request its own
|
|
216
|
+
// way, without this version's fixes (#506): said in place of the counts
|
|
217
|
+
const older = g.running && !g.mine && g.older;
|
|
218
|
+
// the sidebar's mark, its lights the state: green running, amber not
|
|
219
|
+
const mark = el("span", "gw-mark " + (older ? "old" : g.running ? "on" : ""));
|
|
220
|
+
mark.innerHTML = GW_GLYPH;
|
|
221
|
+
const who = el("div", "who");
|
|
222
|
+
const name = el("div", "name", t("Gateway"));
|
|
223
|
+
name.append(el("span", "state", older
|
|
224
|
+
? t("running · served by magpie {v}, older than this one", { v: g.version })
|
|
225
|
+
: t(g.running ? (g.mine ? "running" : "running · served by another magpie") : "not running")));
|
|
226
|
+
const routed = new Set();
|
|
227
|
+
for (const p of providers.providers) for (const a of p.agents) if (a.current) routed.add(a.id);
|
|
228
|
+
const n = routed.size;
|
|
229
|
+
if (older) {
|
|
230
|
+
who.append(name, el("div", "sub old", t("Agents' requests go through magpie {v} and are sent as it sends them, without this version's fixes. Quit it here and this one serves the gateway at once.", { v: g.version })));
|
|
231
|
+
} else who.append(name, el("div", "sub", g.running
|
|
232
|
+
? [t(g.models === 1 ? "{n} model" : "{n} models", { n: g.models }), n ? t(n === 1 ? "{n} agent routed through it" : "{n} agents routed through it", { n }) : t("no agent routed through it yet"), t("five APIs, one URL")].join(" · ")
|
|
233
|
+
: t("start it with magpie serve, or open magpie at login")));
|
|
234
|
+
if (gatewayFix.err && gatewayFix.when === gatewayFixWhen(g)) who.append(el("div", "sub old err", gatewayFix.err));
|
|
235
|
+
box.append(mark, who);
|
|
236
|
+
const fix = gatewayFixButton(g, older);
|
|
237
|
+
if (fix) box.append(fix);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Share on local network, set on the page that hands out its keys (the
|
|
241
|
+
// owner): the same setting as Settings › Network and sharing, and every
|
|
242
|
+
// address the gateway answers on, each to copy.
|
|
243
|
+
let gatewayLanBusy = false;
|
|
244
|
+
function renderGatewayLan() {
|
|
245
|
+
const g = providers.gateway;
|
|
246
|
+
const box = $("#gatewayLan");
|
|
247
|
+
box.replaceChildren();
|
|
248
|
+
const head = el("div", "row gw-share");
|
|
249
|
+
const choice = segs([["off", t("This computer")], ["on", t("Local network")]], g.lan ? "on" : "off", (v) => {
|
|
250
|
+
if (gatewayLanBusy || (v === "on") === !!g.lan) return;
|
|
251
|
+
gatewayLanBusy = true;
|
|
252
|
+
writingPrefs(api("settings/lan", { on: v === "on" }))
|
|
253
|
+
.then((ns) => { prefs = ns; if (ns.rekeyed) status(ns.rekeyed, "err"); })
|
|
254
|
+
.catch((e) => status(t(e.message), "err"))
|
|
255
|
+
.then(() => loadProviders().catch(() => {}))
|
|
256
|
+
.finally(() => {
|
|
257
|
+
gatewayLanBusy = false;
|
|
258
|
+
if (view !== "gateway" || !providers) return;
|
|
259
|
+
renderGatewayView();
|
|
260
|
+
if (providers.gateway.lan) loadGatewayKeys();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
choice.setAttribute("role", "group");
|
|
264
|
+
choice.setAttribute("aria-label", t("Share on local network"));
|
|
265
|
+
head.append(el("div", "name grow", t("Share on local network")), choice);
|
|
266
|
+
box.append(head);
|
|
267
|
+
const list = el("div", "gw-urls");
|
|
268
|
+
const line = (label, url) => {
|
|
269
|
+
const r = el("div", "gw-url");
|
|
270
|
+
const val = el("span", "gw-url-val");
|
|
271
|
+
val.append(el("code", "", url), copyBtn(url, t("Gateway URL")));
|
|
272
|
+
r.append(el("span", "gw-url-label", label), val);
|
|
273
|
+
list.append(r);
|
|
274
|
+
};
|
|
275
|
+
// served to other machines' browsers (magpie web), the address shown is
|
|
276
|
+
// the one they reach, MAGPIE_PUBLIC_URL's, not this computer's loopback
|
|
277
|
+
// (#1551)
|
|
278
|
+
const shown = web && g.publicURL || g.url;
|
|
279
|
+
line(t(shown === g.url ? "This computer" : "Public address"), shown);
|
|
280
|
+
if (g.lan) {
|
|
281
|
+
const { urls, others, sub } = gwLAN(g);
|
|
282
|
+
// an internet address on an interface (an ISP's static one) is said
|
|
283
|
+
// to be one, not listed as this network's, and the one other
|
|
284
|
+
// containers on this host use (the host's Docker bridge address) is
|
|
285
|
+
// said to be theirs
|
|
286
|
+
const far = new Set(g.lanPublic || []), rest = urls.filter((u) => u !== shown);
|
|
287
|
+
rest.filter((u) => !far.has(u) && !others.includes(u)).forEach((u, i) => line(i ? "" : t("Local network"), u));
|
|
288
|
+
rest.filter((u) => others.includes(u)).forEach((u, i) => line(i ? "" : t("Other containers on this host"), u));
|
|
289
|
+
rest.filter((u) => far.has(u) && !others.includes(u)).forEach((u, i) => line(i ? "" : t("On the internet"), u));
|
|
290
|
+
if (!urls.length) list.append(el("div", "gw-url-none", t("This computer has no local network address right now")));
|
|
291
|
+
if (sub) list.append(el("div", "gw-url-none", sub));
|
|
292
|
+
}
|
|
293
|
+
box.append(list);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// One entry per API the gateway serves: where each SDK's base URL points,
|
|
297
|
+
// the env vars the usual tools read, and a request in four dialects.
|
|
298
|
+
const FLAVORS = {
|
|
299
|
+
openai: {
|
|
300
|
+
name: "OpenAI", base: (u) => u + "/v1", baseEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY",
|
|
301
|
+
note: "Chat Completions, the API most tools speak. Anything with an OpenAI base-URL setting works.",
|
|
302
|
+
curl: (b, m, k = "magpie") => ({ url: `${b}/chat/completions`, headers: [`Authorization: Bearer ${k}`],
|
|
303
|
+
body: `{"model": "${m}",\n "messages": [{"role": "user", "content": "hi"}]}` }),
|
|
304
|
+
python: (b, m, k = "magpie") => `from openai import OpenAI
|
|
305
|
+
|
|
306
|
+
client = OpenAI(base_url="${b}", api_key="${k}")
|
|
307
|
+
r = client.chat.completions.create(
|
|
308
|
+
model="${m}",
|
|
309
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
310
|
+
)
|
|
311
|
+
print(r.choices[0].message.content)`,
|
|
312
|
+
node: (b, m, k = "magpie") => `import OpenAI from "openai";
|
|
313
|
+
|
|
314
|
+
const client = new OpenAI({ baseURL: "${b}", apiKey: "${k}" });
|
|
315
|
+
const r = await client.chat.completions.create({
|
|
316
|
+
model: "${m}",
|
|
317
|
+
messages: [{ role: "user", content: "hi" }],
|
|
318
|
+
});
|
|
319
|
+
console.log(r.choices[0].message.content);`,
|
|
320
|
+
},
|
|
321
|
+
responses: {
|
|
322
|
+
name: "Responses", base: (u) => u + "/v1", baseEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY",
|
|
323
|
+
note: "OpenAI's newer API: reasoning, built-in tool items, encrypted reasoning. Codex speaks this.",
|
|
324
|
+
curl: (b, m, k = "magpie") => ({ url: `${b}/responses`, headers: [`Authorization: Bearer ${k}`],
|
|
325
|
+
body: `{"model": "${m}", "input": "hi"}` }),
|
|
326
|
+
python: (b, m, k = "magpie") => `from openai import OpenAI
|
|
327
|
+
|
|
328
|
+
client = OpenAI(base_url="${b}", api_key="${k}")
|
|
329
|
+
r = client.responses.create(model="${m}", input="hi")
|
|
330
|
+
print(r.output_text)`,
|
|
331
|
+
node: (b, m, k = "magpie") => `import OpenAI from "openai";
|
|
332
|
+
|
|
333
|
+
const client = new OpenAI({ baseURL: "${b}", apiKey: "${k}" });
|
|
334
|
+
const r = await client.responses.create({ model: "${m}", input: "hi" });
|
|
335
|
+
console.log(r.output_text);`,
|
|
336
|
+
},
|
|
337
|
+
anthropic: {
|
|
338
|
+
name: "Anthropic", base: (u) => u, baseEnv: "ANTHROPIC_BASE_URL", keyEnv: "ANTHROPIC_API_KEY",
|
|
339
|
+
note: "Messages API. Claude Code reads ANTHROPIC_AUTH_TOKEN instead of the key; the Agents tab sets that for you.",
|
|
340
|
+
curl: (b, m, k = "magpie") => ({ url: `${b}/v1/messages`, headers: [`x-api-key: ${k}`, "anthropic-version: 2023-06-01"],
|
|
341
|
+
body: `{"model": "${m}", "max_tokens": 1024,\n "messages": [{"role": "user", "content": "hi"}]}` }),
|
|
342
|
+
python: (b, m, k = "magpie") => `import anthropic
|
|
343
|
+
|
|
344
|
+
client = anthropic.Anthropic(
|
|
345
|
+
base_url="${b}", api_key="${k}",
|
|
346
|
+
)
|
|
347
|
+
m = client.messages.create(
|
|
348
|
+
model="${m}",
|
|
349
|
+
max_tokens=1024,
|
|
350
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
351
|
+
)
|
|
352
|
+
print(m.content[0].text)`,
|
|
353
|
+
node: (b, m, k = "magpie") => `import Anthropic from "@anthropic-ai/sdk";
|
|
354
|
+
|
|
355
|
+
const client = new Anthropic({ baseURL: "${b}", apiKey: "${k}" });
|
|
356
|
+
const m = await client.messages.create({
|
|
357
|
+
model: "${m}",
|
|
358
|
+
max_tokens: 1024,
|
|
359
|
+
messages: [{ role: "user", content: "hi" }],
|
|
360
|
+
});
|
|
361
|
+
console.log(m.content[0].text);`,
|
|
362
|
+
},
|
|
363
|
+
gemini: {
|
|
364
|
+
name: "Gemini", base: (u) => u, baseEnv: "GOOGLE_GEMINI_BASE_URL", keyEnv: "GEMINI_API_KEY",
|
|
365
|
+
note: "Google's generateContent API, v1beta. Gemini CLI and the google-genai SDKs speak this.",
|
|
366
|
+
curl: (b, m, k = "magpie") => ({ url: `${b}/v1beta/models/${m}:generateContent`, headers: [`x-goog-api-key: ${k}`],
|
|
367
|
+
body: `{"contents": [{"parts": [{"text": "hi"}]}]}` }),
|
|
368
|
+
python: (b, m, k = "magpie") => `from google import genai
|
|
369
|
+
|
|
370
|
+
client = genai.Client(api_key="${k}", http_options={"base_url": "${b}"})
|
|
371
|
+
r = client.models.generate_content(model="${m}", contents="hi")
|
|
372
|
+
print(r.text)`,
|
|
373
|
+
node: (b, m, k = "magpie") => `import { GoogleGenAI } from "@google/genai";
|
|
374
|
+
|
|
375
|
+
const ai = new GoogleGenAI({
|
|
376
|
+
apiKey: "${k}",
|
|
377
|
+
httpOptions: { baseUrl: "${b}" },
|
|
378
|
+
});
|
|
379
|
+
const r = await ai.models.generateContent({ model: "${m}", contents: "hi" });
|
|
380
|
+
console.log(r.text);`,
|
|
381
|
+
},
|
|
382
|
+
// magpie's own System One API (ARNO on Discord: gateway添加system one
|
|
383
|
+
// api 有想法吗): a decision model — Jev, Clef, Bailian's — answers typed
|
|
384
|
+
// questions about a state; no SDK speaks it, so no shell variables
|
|
385
|
+
systemone: {
|
|
386
|
+
name: "System One", base: (u) => u + "/v1", decide: true,
|
|
387
|
+
note: "TypeSafe's decision API: a Jev or Clef model answers typed questions (choice, noul, score) about a state, with how sure it is. Clef also reads images: add \"images\": [\"data:image/png;base64,…\"].",
|
|
388
|
+
curl: (b, m, k = "magpie") => ({ url: `${b}/systemone`, headers: [`Authorization: Bearer ${k}`],
|
|
389
|
+
body: `{"model": "${m}",\n "state": {"message": "My order never arrived, refund it"},\n "questions": {"team": {"type": "choice",\n "instructions": "Which team should answer the message?",\n "criteria": {"billing": "Payments and refunds", "shipping": "Deliveries", "other": "Anything else"}}}}` }),
|
|
390
|
+
python: (b, m, k = "magpie") => `import requests
|
|
391
|
+
|
|
392
|
+
r = requests.post("${b}/systemone",
|
|
393
|
+
headers={"Authorization": "Bearer ${k}"},
|
|
394
|
+
json={
|
|
395
|
+
"model": "${m}",
|
|
396
|
+
"state": {"message": "My order never arrived, refund it"},
|
|
397
|
+
"questions": {"team": {
|
|
398
|
+
"type": "choice",
|
|
399
|
+
"instructions": "Which team should answer the message?",
|
|
400
|
+
"criteria": {"billing": "Payments and refunds", "shipping": "Deliveries", "other": "Anything else"},
|
|
401
|
+
}},
|
|
402
|
+
})
|
|
403
|
+
a = r.json()["answers"]["team"]
|
|
404
|
+
print(a["choice"], a["confidence"])`,
|
|
405
|
+
node: (b, m, k = "magpie") => `const r = await fetch("${b}/systemone", {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: { Authorization: "Bearer ${k}", "Content-Type": "application/json" },
|
|
408
|
+
body: JSON.stringify({
|
|
409
|
+
model: "${m}",
|
|
410
|
+
state: { message: "My order never arrived, refund it" },
|
|
411
|
+
questions: { team: {
|
|
412
|
+
type: "choice",
|
|
413
|
+
instructions: "Which team should answer the message?",
|
|
414
|
+
criteria: { billing: "Payments and refunds", shipping: "Deliveries", other: "Anything else" },
|
|
415
|
+
} },
|
|
416
|
+
}),
|
|
417
|
+
});
|
|
418
|
+
const a = (await r.json()).answers.team;
|
|
419
|
+
console.log(a.choice, a.confidence);`,
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
// The Shell and curl snippets are written for one shell: PowerShell on
|
|
423
|
+
// Windows ($env: in place of export, and the curl.exe that ships with it,
|
|
424
|
+
// as plain curl there is Invoke-WebRequest; the body goes in on stdin,
|
|
425
|
+
// since Windows PowerShell drops the quotes inside an argument), or a POSIX
|
|
426
|
+
// shell (bash, zsh) on macOS and Linux. They start as the shell of the
|
|
427
|
+
// machine magpie runs on, and can be switched to the other: a Windows
|
|
428
|
+
// desktop's user often runs them on a Linux server over ssh (FourLeafTec,
|
|
429
|
+
// #1514). The choice is kept with the snippet language.
|
|
430
|
+
const WIN = /^Win/.test(navigator.platform);
|
|
431
|
+
const SNIP_OSES = [["windows", "Windows"], ["unix", "macOS · Linux"]];
|
|
432
|
+
const snipWin = () => (SNIP_OSES.some(([id]) => id === snipOS) ? snipOS : WIN ? "windows" : "unix") === "windows";
|
|
433
|
+
const langs4 = () => [["shell", snipWin() ? "PowerShell" : "Shell"], ["curl", "curl"], ["python", "Python"], ["node", "Node"]];
|
|
434
|
+
|
|
435
|
+
// A word each shell takes as it is, quoted only where it has to be: a key
|
|
436
|
+
// of the user's own can hold any ASCII symbol (", $, `, ', !), and a model
|
|
437
|
+
// id brackets ([1m]) that zsh would glob; a comma would make PowerShell
|
|
438
|
+
// pass a list.
|
|
439
|
+
const plainWord = /^[\w%+=:./-]+$/;
|
|
440
|
+
const shQuote = (s) => (plainWord.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`);
|
|
441
|
+
// in "…" for a header's spaces, unless it holds what bash expands there
|
|
442
|
+
const shHeader = (s) => (/[$`"\\!]/.test(s) ? `'${s.replaceAll("'", `'\\''`)}'` : `"${s}"`);
|
|
443
|
+
const psQuote = (s) => (plainWord.test(s) ? s : `'${s.replace(/['‘’‚‛]/g, "$&$&")}'`);
|
|
444
|
+
// "…" as the C runtime splits a command line: \" for a quote, and the
|
|
445
|
+
// backslashes before one doubled
|
|
446
|
+
const winArg = (s) => (plainWord.test(s) ? s : `"${s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`);
|
|
447
|
+
const psHeader = (s) => (/[$`"“”„]/.test(s) ? `'${s.replace(/['‘’‚‛]/g, "$&$&")}'` : `"${s}"`);
|
|
448
|
+
|
|
449
|
+
function curlSnippet({ url, headers, body }) {
|
|
450
|
+
headers = [...headers, "Content-Type: application/json"];
|
|
451
|
+
// curl reads [ ] { } in a URL as a range unless told not to
|
|
452
|
+
const g = /[[\]{}]/.test(url) ? " -g" : "";
|
|
453
|
+
// A " inside an argument reaches curl.exe differently from Windows
|
|
454
|
+
// PowerShell 5.1 and from PowerShell 7, and whole from neither as
|
|
455
|
+
// written; after --% the line goes to curl.exe as it stands, on one line,
|
|
456
|
+
// quoted as the C runtime reads a command line. (A %NAME% of a variable
|
|
457
|
+
// the shell has is still put in there; a key would have to hold both.)
|
|
458
|
+
if (snipWin() && headers.some((h) => h.includes('"'))) return `@'\n${body}\n'@ | curl.exe --%${g} ${winArg(url)} ${headers.map((h) => `-H ${winArg(h)}`).join(" ")} --data-binary "@-"`;
|
|
459
|
+
if (snipWin()) return `@'\n${body}\n'@ | curl.exe${g} ${psQuote(url)} \`\n${headers.map((h) => ` -H ${psHeader(h)} \``).join("\n")}\n --data-binary "@-"`;
|
|
460
|
+
return `curl${g} ${shQuote(url)} \\\n${headers.map((h) => ` -H ${shHeader(h)} \\`).join("\n")}\n -d '${body.replaceAll("'", `'\\''`).replaceAll("\n", "\n ")}'`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function envSnippet(vars) {
|
|
464
|
+
return vars.map(([k, v]) => (snipWin() ? `$env:${k}=${psHeader(v)}` : `export ${k}=${shQuote(v)}`)).join("\n");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// every exposed model, as the ids agents use
|
|
468
|
+
function gatewayModels() {
|
|
469
|
+
// the routing groups first, as the agents' pickers list them
|
|
470
|
+
// a group's lists may come as null (#1585): one such group mustn't take
|
|
471
|
+
// every model off the list
|
|
472
|
+
const out = (providers.gateway.groups || []).filter((g) => g && typeof g.id === "string").map((g) => ({ id: g.id, name: g.name || g.id, icons: g.icons || [], group: true,
|
|
473
|
+
provider: { name: [t("routing group"), (g.providers || []).join(", ")].filter(Boolean).join(" · ") },
|
|
474
|
+
efforts: g.efforts || [], images: !!g.images, context: g.context, output: g.output, members: g.members || [] }));
|
|
475
|
+
// a provider switched off is skipped: the gateway refuses its models, so
|
|
476
|
+
// they are not ids an agent can use, and listing them here would show a
|
|
477
|
+
// model the picker's own list, the Gateway count and the Connect example
|
|
478
|
+
// all say works but a request to it turns away (the same off gate the
|
|
479
|
+
// backend's providerEntries and the fallback picker already honour).
|
|
480
|
+
// the levels agents are offered: those kept, else all it has; one whose
|
|
481
|
+
// levels aren't known (given) offers only those it was given; one sent
|
|
482
|
+
// no reasoning field (reasonless) offers none
|
|
483
|
+
const offered = (m) => m.reasonless ? [] : m.kept?.length || m.given ? m.kept || [] : m.efforts || [];
|
|
484
|
+
for (const p of providers.providers) { if (p.off) continue; for (const m of p.models) if (m.on) out.push({ id: `${p.id}/${m.id}`, name: m.name, provider: p, context: m.context, output: m.output, efforts: offered(m), images: !!m.images }); }
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Connect is set up once and seldom looked at again, so it folds away under
|
|
489
|
+
// its heading, the base URL left beside it; the fold is remembered
|
|
490
|
+
// (connectFolded, with the tab's other choices at the top).
|
|
491
|
+
|
|
492
|
+
function renderConnect() {
|
|
493
|
+
const g = providers.gateway;
|
|
494
|
+
const box = $("#connect");
|
|
495
|
+
box.replaceChildren();
|
|
496
|
+
const f = FLAVORS[flavor] || FLAVORS.openai;
|
|
497
|
+
// System One's snippets take a decision model, the others any other
|
|
498
|
+
const models = gatewayModels().filter((m) => decideEntry(m) === !!f.decide);
|
|
499
|
+
let model;
|
|
500
|
+
if (f.decide) {
|
|
501
|
+
if (!models.some((m) => m.id === decideModel)) decideModel = models[0]?.id || "";
|
|
502
|
+
model = decideModel || "provider/jev-latest";
|
|
503
|
+
} else {
|
|
504
|
+
if (!models.some((m) => m.id === exampleModel)) exampleModel = models[0]?.id || "";
|
|
505
|
+
model = exampleModel || "provider/model";
|
|
506
|
+
}
|
|
507
|
+
// no SDK speaks System One, so it has no shell variables to set
|
|
508
|
+
const langs = f.baseEnv ? langs4() : langs4().filter(([id]) => id !== "shell");
|
|
509
|
+
const snipLang = langs.some(([id]) => id === lang) ? lang : "curl";
|
|
510
|
+
const urls = [g.url, ...gwLAN(g).urls];
|
|
511
|
+
// the address picked is kept (coeo91): an address not offered right now
|
|
512
|
+
// (the network not up yet after a start) shows this computer's, and the
|
|
513
|
+
// pick comes back with it. With none picked, MAGPIE_PUBLIC_URL is where
|
|
514
|
+
// it starts: it says the address other machines use (#1551)
|
|
515
|
+
const first = g.publicURL && urls.includes(g.publicURL) ? g.publicURL : g.url;
|
|
516
|
+
connectURL = urls.includes(connectWant) ? connectWant : first;
|
|
517
|
+
// a gateway MAGPIE_ADDR puts on the network (a Docker image's, whose
|
|
518
|
+
// host is another machine to it) is shown with a gateway key everywhere
|
|
519
|
+
const remote = connectURL !== g.url || !!g.onNetwork;
|
|
520
|
+
const keys = g.lan ? (gatewayKeys || []).filter((k) => !k.off) : [];
|
|
521
|
+
if (!keys.some((k) => k.id === connectKeyID)) {
|
|
522
|
+
connectKeyID = "";
|
|
523
|
+
connectSecret = null;
|
|
524
|
+
}
|
|
525
|
+
if (!connectKeyID && connectKeyWant && keys.some((k) => k.id === connectKeyWant)) {
|
|
526
|
+
queueMicrotask(() => selectConnectKey(connectKeyWant));
|
|
527
|
+
} else if (remote && !connectKeyID && keys.length) {
|
|
528
|
+
queueMicrotask(() => selectConnectKey(keys[0].id));
|
|
529
|
+
}
|
|
530
|
+
const key = keys.find((k) => k.id === connectKeyID);
|
|
531
|
+
const secret = key ? (connectSecret?.id === key.id ? connectSecret.secret : "") : remote ? "" : "magpie";
|
|
532
|
+
const base = f.base(connectURL);
|
|
533
|
+
const fold = $("#foldConnect");
|
|
534
|
+
fold.setAttribute("aria-expanded", String(!connectFolded));
|
|
535
|
+
fold.title = t(connectFolded ? "Show how to connect" : "Fold Connect away");
|
|
536
|
+
box.hidden = connectFolded;
|
|
537
|
+
// folded, the head keeps the one thing reached for: the address, to copy
|
|
538
|
+
const note = $("#connectNote");
|
|
539
|
+
note.replaceChildren();
|
|
540
|
+
note.classList.toggle("brief", connectFolded);
|
|
541
|
+
if (connectFolded) note.append(el("code", "", base), copyBtn(base, "Base URL"));
|
|
542
|
+
else note.textContent = t(remote ? "Local network · an enabled gateway key is required" : g.lan && gatewayKeys?.length ? "Use a gateway key to track usage" : "Loopback only · the key can be anything");
|
|
543
|
+
|
|
544
|
+
box.append(...field("API", segs(Object.entries(FLAVORS).map(([k, v]) => [k, v.name]), flavor, (id) => { flavor = id; localStorage.setItem("magpie.flavor", id); renderConnect(); }), t(f.note)));
|
|
545
|
+
|
|
546
|
+
const b = el("div", "val");
|
|
547
|
+
if (urls.length > 1) b.append(connectPick("connectAddress", "Address", base,
|
|
548
|
+
urls.map((u) => ({ v: u, name: f.base(u), note: t(u === g.url ? "This computer" : u === g.publicURL ? "Public address" : "Local network") })), connectURL,
|
|
549
|
+
(u) => { connectWant = u; keepPick("magpie.connectAddress", u); renderConnect(); }));
|
|
550
|
+
else b.append(el("code", "", base));
|
|
551
|
+
b.append(copyBtn(base, "Base URL"));
|
|
552
|
+
box.append(...field("Base URL", b, f.baseEnv ? t("What {env} takes.", { env: f.baseEnv }) : t("Requests go to POST {url}.", { url: base + "/systemone" })));
|
|
553
|
+
|
|
554
|
+
const k = el("div", "val");
|
|
555
|
+
const options = keys.map((k) => ({ v: k.id, name: k.name, literalName: true, note: k.masked }));
|
|
556
|
+
if (!remote) options.unshift({ v: "", name: "This computer", note: t("Any key · no key attribution") });
|
|
557
|
+
const keyLabel = remote || key ? "Gateway key" : "API key";
|
|
558
|
+
if (!g.lan) k.append(el("code", "", "magpie"));
|
|
559
|
+
else if (options.length) k.append(connectPick("connectKey", keyLabel, key ? key.name : remote ? t("Choose a gateway key") : t("This computer"),
|
|
560
|
+
options, connectKeyID, (id) => { connectKeyWant = id; keepPick("magpie.connectKey", id); selectConnectKey(id); }));
|
|
561
|
+
else k.append(el("code", "", t("Create a gateway key above to connect")));
|
|
562
|
+
if (key) k.append(copyCallerKeyBtn(key));
|
|
563
|
+
else if (!remote) k.append(copyBtn("magpie", t("Key")));
|
|
564
|
+
box.append(...field(t(keyLabel), k, !f.keyEnv ? t(g.lan && (remote || gatewayKeys?.length)
|
|
565
|
+
? "Choose a gateway key to send as Authorization: Bearer; usage is tracked by key."
|
|
566
|
+
: "Sent as Authorization: Bearer. The gateway trusts everything on loopback, so any value works.") : t(g.lan && (remote || gatewayKeys?.length)
|
|
567
|
+
? "Choose a gateway key to use as {env}; usage is tracked by key."
|
|
568
|
+
: "{env}=magpie. The gateway trusts everything on loopback, so any value works.", { env: f.keyEnv })));
|
|
569
|
+
|
|
570
|
+
const m = el("div", "val");
|
|
571
|
+
m.append(el("code", "", model), copyBtn(model, t("Model id")));
|
|
572
|
+
box.append(...field(t("Model"), m, f.decide
|
|
573
|
+
? t(models.length ? "A decision model (Jev, Clef), as listed below. Click one there to put it in the snippets." : "No decision models yet. Add a System One, Jev or Workers AI provider.")
|
|
574
|
+
: t(models.length ? "provider/model, as listed below. Click a model there to put it in the snippets." : "No models yet. Add a provider, or sign in to Codex or Copilot.")));
|
|
575
|
+
|
|
576
|
+
const ex = el("div", "stack");
|
|
577
|
+
const tabs = el("div", "snip-tabs");
|
|
578
|
+
tabs.append(segs(langs, snipLang, (id) => { lang = id; localStorage.setItem("magpie.lang", id); renderConnect(); }));
|
|
579
|
+
// the shell the Shell and curl snippets are for; Python and Node read the same everywhere
|
|
580
|
+
if (snipLang === "shell" || snipLang === "curl") {
|
|
581
|
+
const os = segs(SNIP_OSES.map(([id, name]) => [id, t(name)]), snipWin() ? "windows" : "unix", (id) => {
|
|
582
|
+
snipOS = id;
|
|
583
|
+
try { localStorage.setItem("magpie.snipOS", id); } catch {}
|
|
584
|
+
renderConnect();
|
|
585
|
+
});
|
|
586
|
+
os.classList.add("snip-os");
|
|
587
|
+
os.setAttribute("role", "group");
|
|
588
|
+
os.setAttribute("aria-label", t("Where the snippet runs"));
|
|
589
|
+
os.title = t("Where the snippet runs");
|
|
590
|
+
tabs.append(os);
|
|
591
|
+
}
|
|
592
|
+
ex.append(tabs);
|
|
593
|
+
const code = !secret ? t(key ? "Loading gateway key…" : "Create a gateway key above to connect")
|
|
594
|
+
: snipLang === "shell" ? envSnippet([[f.baseEnv, base], [f.keyEnv, secret]])
|
|
595
|
+
: snipLang === "curl" ? curlSnippet(f.curl(base, model, secret))
|
|
596
|
+
: f[snipLang](base, model, secret);
|
|
597
|
+
const pre = el("pre", "snip");
|
|
598
|
+
const c = el("code");
|
|
599
|
+
c.append(highlight(code, snipLang));
|
|
600
|
+
pre.append(c);
|
|
601
|
+
// the button sits outside the scrolling box, so a long line doesn't carry it off
|
|
602
|
+
const wrap = el("div", "snip-wrap");
|
|
603
|
+
wrap.append(pre);
|
|
604
|
+
if (secret) wrap.append(copyBtn(code, t("Snippet")));
|
|
605
|
+
ex.append(wrap);
|
|
606
|
+
box.append(...field(t("Example"), ex, snipLang === "shell" ? t("Put these in the shell (or the tool's settings) and the tool talks to magpie instead of the vendor.") : ""));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let modelQuery = "";
|
|
610
|
+
// The model list can be folded away, so Recent calls sits under Connect;
|
|
611
|
+
// the fold is remembered.
|
|
612
|
+
let modelsFolded = false;
|
|
613
|
+
try { modelsFolded = localStorage.getItem("magpie.gwModelsFolded") === "1"; } catch {}
|
|
614
|
+
|
|
615
|
+
function renderGatewayModels() {
|
|
616
|
+
const list = $("#gwModels");
|
|
617
|
+
list.replaceChildren();
|
|
618
|
+
const all = gatewayModels();
|
|
619
|
+
const fold = $("#foldModels");
|
|
620
|
+
fold.setAttribute("aria-expanded", String(!modelsFolded));
|
|
621
|
+
fold.title = t(modelsFolded ? "Show the models" : "Fold the models away");
|
|
622
|
+
$("#modelsCount").textContent = all.length ? String(all.length) : "";
|
|
623
|
+
list.hidden = modelsFolded && all.length > 0;
|
|
624
|
+
// the search sits in the section head, beside Copy all ids; typing
|
|
625
|
+
// redraws only the list, so it keeps its focus
|
|
626
|
+
let q = $("#findModel");
|
|
627
|
+
if (!q) {
|
|
628
|
+
q = input(modelQuery, t("Find a model…"));
|
|
629
|
+
q.id = "findModel";
|
|
630
|
+
q.className = "find";
|
|
631
|
+
q.oninput = () => { modelQuery = q.value; renderGatewayModels(); };
|
|
632
|
+
q.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape" && q.value) { q.value = modelQuery = ""; renderGatewayModels(); } };
|
|
633
|
+
$("#copyModels").before(q);
|
|
634
|
+
}
|
|
635
|
+
q.hidden = all.length < 5 || list.hidden;
|
|
636
|
+
q.title = t("Words match a model's id, name, provider or a group's models");
|
|
637
|
+
const words = modelQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
638
|
+
const models = q.hidden ? all : all.filter((m) => {
|
|
639
|
+
const hay = `${m.id} ${m.name || ""} ${m.provider.name} ${(m.members || []).join(" ")}`.toLowerCase();
|
|
640
|
+
return words.every((w) => hay.includes(w));
|
|
641
|
+
});
|
|
642
|
+
$("#copyModels").hidden = !models.length || list.hidden;
|
|
643
|
+
$("#copyModels").onclick = () => copy(models.map((m) => m.id).join("\n"), t("Model ids"));
|
|
644
|
+
if (!all.length) {
|
|
645
|
+
const empty = el("div", "empty-state", "");
|
|
646
|
+
empty.append(el("b", "", t("No models exposed yet")), t("Add a provider, or sign in to Codex or Copilot; their models show up here for every agent."));
|
|
647
|
+
list.append(empty);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (!models.length) {
|
|
651
|
+
list.append(el("div", "none", t("No models match “{q}”", { q: modelQuery.trim() })));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
for (const m of models) {
|
|
655
|
+
const row = el("div", "row model" + (m.id === (FLAVORS[flavor]?.decide ? decideModel : exampleModel) ? " selected" : ""));
|
|
656
|
+
const who = el("div", "who");
|
|
657
|
+
const name = el("div", "name", m.id);
|
|
658
|
+
if (namedFree(m.id, m.name)) name.append(freeBadge(false));
|
|
659
|
+
who.append(name, el("div", "sub", m.name && m.name !== m.id.split("/")[1] ? `${m.name} · ${m.provider.name}` : m.provider.name));
|
|
660
|
+
row.append(m.group ? stackIcon(m.icons) : icon(m.provider.icon || "generic"), who, modelInfo(m), copyBtn(m.id, t("Model id")));
|
|
661
|
+
row.title = t("Use this model in the snippets");
|
|
662
|
+
// a decision model is put in System One's snippets, any other in the
|
|
663
|
+
// other APIs'; the API follows the model clicked
|
|
664
|
+
row.onclick = () => {
|
|
665
|
+
const selection = window.getSelection();
|
|
666
|
+
if (selection && !selection.isCollapsed && row.contains(selection.anchorNode)) return;
|
|
667
|
+
const so = decideEntry(m);
|
|
668
|
+
if (so) { decideModel = m.id; localStorage.setItem("magpie.decideModel", m.id); }
|
|
669
|
+
else { exampleModel = m.id; localStorage.setItem("magpie.model", m.id); }
|
|
670
|
+
if (so !== !!FLAVORS[flavor]?.decide) { flavor = so ? "systemone" : "openai"; localStorage.setItem("magpie.flavor", flavor); }
|
|
671
|
+
renderConnect(); renderGatewayModels();
|
|
672
|
+
};
|
|
673
|
+
list.append(row);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// A recent call's bodies, which /api/providers leaves out: forty calls'
|
|
678
|
+
// bodies made every refresh of it 20 MB (#1521).
|
|
679
|
+
function loadCallBodies(c, id) {
|
|
680
|
+
const pending = { busy: true };
|
|
681
|
+
callBodies.set(id, pending);
|
|
682
|
+
api(`gateway/call?id=${encodeURIComponent(c.id)}`)
|
|
683
|
+
.then((body) => { if (callBodies.get(id) === pending) callBodies.set(id, body); })
|
|
684
|
+
.catch((e) => { if (callBodies.get(id) === pending) callBodies.set(id, { error: t(e.message) }); })
|
|
685
|
+
.finally(() => { if (view === "gateway" && providers) renderActivity(); });
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// a recent call's row: by the gateway's number for it, as two calls of
|
|
689
|
+
// one agent to one model can come in the same instant
|
|
690
|
+
const callRow = (c) => (c.id ? `#${c.id}` : `${c.time}|${c.agent}|${c.model}`);
|
|
691
|
+
|
|
692
|
+
function renderActivity() {
|
|
693
|
+
const g = providers.gateway;
|
|
694
|
+
// what calls left the ring keep, they let go of
|
|
695
|
+
const ids = new Set(g.calls.map(callRow));
|
|
696
|
+
for (const id of callBodies.keys()) if (!ids.has(id)) callBodies.delete(id);
|
|
697
|
+
for (const id of expandedCalls) if (!ids.has(id)) expandedCalls.delete(id);
|
|
698
|
+
for (const id of wireOpen) if (!ids.has(id)) wireOpen.delete(id);
|
|
699
|
+
const box = $("#activity");
|
|
700
|
+
// a body being read keeps its place as new calls come in
|
|
701
|
+
const kept = new Map();
|
|
702
|
+
for (const item of box.querySelectorAll(".call-item[data-id]")) kept.set(item.dataset.id, [...item.querySelectorAll("pre")].map((p) => p.scrollTop));
|
|
703
|
+
box.replaceChildren();
|
|
704
|
+
$("#callsNote").textContent = g.running && !g.mine ? t("shown by the magpie that serves the gateway") : "";
|
|
705
|
+
const calls = g.calls.slice(0, callsShown);
|
|
706
|
+
if (!calls.length) { box.append(el("div", "none", t("No requests yet. Point an agent at a model, or run the example above; every call shows up here as it happens."))); return; }
|
|
707
|
+
for (const c of calls) {
|
|
708
|
+
const id = callRow(c);
|
|
709
|
+
const open = expandedCalls.has(id);
|
|
710
|
+
// a call its agent canceled (c.canceled) saw no error: told in the
|
|
711
|
+
// muted colour, neither failed nor answered
|
|
712
|
+
const item = el("div", "call-item" + (open ? " open" : "") + (c.status >= 400 ? (c.canceled ? " gone" : " bad") : ""));
|
|
713
|
+
const r = el("div", "call");
|
|
714
|
+
r.setAttribute("role", "button");
|
|
715
|
+
r.setAttribute("tabindex", "0");
|
|
716
|
+
r.setAttribute("aria-expanded", String(open));
|
|
717
|
+
const chev = el("span", "call-chev");
|
|
718
|
+
chev.append(svg(CHEV_R, 11, 1.6));
|
|
719
|
+
r.append(chev);
|
|
720
|
+
r.append(el("span", "when", new Date(c.time).toLocaleTimeString(intlLang(), { hour: "2-digit", minute: "2-digit", second: "2-digit" })));
|
|
721
|
+
r.append(el("span", "a", c.agent || "—"));
|
|
722
|
+
r.append(el("span", "m", c.model));
|
|
723
|
+
r.append(el("span", "p", c.from === c.to ? c.from : `${c.from} → ${c.to}`));
|
|
724
|
+
// what masking kept from the vendor, its record a click away
|
|
725
|
+
if (c.masked?.n || c.masked?.warned) r.append(maskedBadge(c.masked));
|
|
726
|
+
r.append(el("span", "grow"));
|
|
727
|
+
r.append(el("span", "st", c.error ? `${c.status} ${c.error}` : `${c.status} · ${ledTook(c.ms)}` + (c.ttft ? " · " + t("TTFT {ms}", { ms: ledTook(c.ttft) }) : "")));
|
|
728
|
+
r.title = open ? t("Hide request and response bodies") : t("Show request and response bodies");
|
|
729
|
+
const toggle = () => {
|
|
730
|
+
if (expandedCalls.has(id)) expandedCalls.delete(id); else expandedCalls.add(id);
|
|
731
|
+
renderActivity();
|
|
732
|
+
};
|
|
733
|
+
r.onclick = toggle;
|
|
734
|
+
r.onkeydown = (ev) => {
|
|
735
|
+
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); toggle(); }
|
|
736
|
+
};
|
|
737
|
+
item.append(r);
|
|
738
|
+
if (open) {
|
|
739
|
+
if (!callBodies.has(id)) loadCallBodies(c, id);
|
|
740
|
+
const body = callBodies.get(id);
|
|
741
|
+
const details = el("div", body.busy || body.error ? "" : "call-details");
|
|
742
|
+
if (body.busy) details.append(el("div", "none", t("Fetching…")));
|
|
743
|
+
else if (body.error) {
|
|
744
|
+
details.append(el("div", "call-archive-err", body.error));
|
|
745
|
+
const retry = el("button", "text", t("Try again"));
|
|
746
|
+
retry.onclick = () => { loadCallBodies(c, id); renderActivity(); };
|
|
747
|
+
details.append(retry);
|
|
748
|
+
} else details.append(
|
|
749
|
+
callBodyPanel("Request Body", body.requestBody, body.requestTruncated, undefined, id + "|req"),
|
|
750
|
+
callBodyPanel("Response Body", body.responseBody, body.responseTruncated, id),
|
|
751
|
+
);
|
|
752
|
+
item.dataset.id = id;
|
|
753
|
+
item.append(details);
|
|
754
|
+
// the headers both ways, folded (wireHeadersView, usage/page.js)
|
|
755
|
+
if (body.headers) item.append(wireHeadersView(body.headers, wireOpen.has(id), () => {
|
|
756
|
+
if (wireOpen.has(id)) wireOpen.delete(id); else wireOpen.add(id);
|
|
757
|
+
renderActivity();
|
|
758
|
+
}));
|
|
759
|
+
}
|
|
760
|
+
box.append(item);
|
|
761
|
+
}
|
|
762
|
+
if (g.calls.length > calls.length) {
|
|
763
|
+
const more = el("button", "text activity-more");
|
|
764
|
+
more.textContent = t("Show {n} more", { n: Math.min(callsPage, g.calls.length - calls.length) });
|
|
765
|
+
more.onclick = () => {
|
|
766
|
+
callsShown = Math.min(g.calls.length, callsShown + callsPage);
|
|
767
|
+
renderActivity();
|
|
768
|
+
};
|
|
769
|
+
box.append(more);
|
|
770
|
+
}
|
|
771
|
+
for (const item of box.querySelectorAll(".call-item[data-id]")) {
|
|
772
|
+
const tops = kept.get(item.dataset.id) || [];
|
|
773
|
+
item.querySelectorAll("pre").forEach((p, i) => { if (tops[i]) p.scrollTop = tops[i]; });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
magpie.plugin("gateway", (ctx) => {
|
|
779
|
+
const configured = () => {
|
|
780
|
+
callsPage = ctx.config.callsPage || 20;
|
|
781
|
+
callsShown = callsPage;
|
|
782
|
+
};
|
|
783
|
+
configured();
|
|
784
|
+
ctx.onConfig(() => {
|
|
785
|
+
configured();
|
|
786
|
+
magpie.call("gateway", "calls");
|
|
787
|
+
});
|
|
788
|
+
ctx.slots.add("page", {
|
|
789
|
+
key: "gateway",
|
|
790
|
+
title: "Gateway",
|
|
791
|
+
// what the page draws from: the shell reads /api/providers for it
|
|
792
|
+
loads: "providers",
|
|
793
|
+
html: `
|
|
794
|
+
<div class="list gwcard">
|
|
795
|
+
<div class="row gw" id="gateway"></div>
|
|
796
|
+
<div id="gatewayLan"></div>
|
|
797
|
+
</div>
|
|
798
|
+
<section id="gatewayKeysBlock" hidden>
|
|
799
|
+
<div class="row-head"><span class="label" data-t>Gateway keys</span><span class="grow"></span><button class="text" id="addGatewayKey" data-t>Add gateway key</button></div>
|
|
800
|
+
<p class="usage-note" data-t>Create a gateway key for each client. Usage is counted separately for each key.</p>
|
|
801
|
+
<section id="gatewayKeys"></section>
|
|
802
|
+
</section>
|
|
803
|
+
<p class="usage-note gw-keys-off" id="gatewayKeysOff" hidden data-t>Gateway keys are for other computers, and for a tunnel to this one such as Cloudflare Tunnel: they are added here once Share on local network is on. Agents on this computer need none.</p>
|
|
804
|
+
<div class="row-head"><button class="fold" id="foldConnect" aria-expanded="true" aria-controls="connect"><span class="label" data-t>Connect</span></button><span class="grow"></span><span class="note" id="connectNote"></span></div>
|
|
805
|
+
<section class="list connect" id="connect"></section>
|
|
806
|
+
<div class="row-head"><button class="fold" id="foldModels" aria-expanded="true" aria-controls="gwModels"><span class="label" data-t>Models</span><span class="note" id="modelsCount"></span></button><span class="grow"></span><button class="text" id="copyModels" data-t>Copy all ids</button></div>
|
|
807
|
+
<div class="list" id="gwModels"></div>
|
|
808
|
+
<div class="row-head"><span class="label" data-t>Recent calls</span><span class="grow"></span><span class="note" id="callsNote"></span></div>
|
|
809
|
+
<div class="list activity" id="activity"></div>
|
|
810
|
+
`,
|
|
811
|
+
mount() {
|
|
812
|
+
$("#foldConnect").prepend(svg(CHEV_R, 11, 1.6));
|
|
813
|
+
$("#foldConnect").onclick = () => {
|
|
814
|
+
connectFolded = !connectFolded;
|
|
815
|
+
try { localStorage.setItem("magpie.gwConnectFolded", connectFolded ? "1" : "0"); } catch {}
|
|
816
|
+
renderConnect();
|
|
817
|
+
backToReader($("#view-gateway"));
|
|
818
|
+
};
|
|
819
|
+
$("#foldModels").prepend(svg(CHEV_R, 11, 1.6));
|
|
820
|
+
$("#foldModels").onclick = () => {
|
|
821
|
+
modelsFolded = !modelsFolded;
|
|
822
|
+
try { localStorage.setItem("magpie.gwModelsFolded", modelsFolded ? "1" : "0"); } catch {}
|
|
823
|
+
renderGatewayModels();
|
|
824
|
+
backToReader($("#view-gateway"));
|
|
825
|
+
};
|
|
826
|
+
new MutationObserver(gatewayKeysTicks).observe($("#view-gateway"), { attributes: true, attributeFilter: ["hidden"] });
|
|
827
|
+
document.addEventListener("visibilitychange", gatewayKeysTicks);
|
|
828
|
+
gatewayKeysTicks();
|
|
829
|
+
},
|
|
830
|
+
// nothing read yet: the page's skeleton (#123)
|
|
831
|
+
loading: renderGatewayLoading,
|
|
832
|
+
// opened from another page that had the list: drawn from it at once,
|
|
833
|
+
// not left as empty cards until the new one is in (#123)
|
|
834
|
+
draft() {
|
|
835
|
+
if (!$("#gateway").childElementCount) renderGatewayView();
|
|
836
|
+
},
|
|
837
|
+
// The page is looked at and left open: coming back to the window
|
|
838
|
+
// redraws it only when something on it changed, and when only the calls
|
|
839
|
+
// did, only them (an account's last sign-in check isn't on it).
|
|
840
|
+
render(was) {
|
|
841
|
+
const json = (a) => JSON.stringify(a, (k, v) => (k === "seen" ? undefined : v));
|
|
842
|
+
const same = (a, b) => json(a) === json(b);
|
|
843
|
+
if (was && !$("#view-gateway").classList.contains("loading") && same({ ...was, gateway: { ...was.gateway, calls: 0 } }, { ...providers, gateway: { ...providers.gateway, calls: 0 } })) {
|
|
844
|
+
if (!same(was.gateway.calls, providers.gateway.calls)) renderActivity();
|
|
845
|
+
} else renderGatewayView();
|
|
846
|
+
// and what each key has used of its limits, read on open only before
|
|
847
|
+
refreshGatewayKeys();
|
|
848
|
+
},
|
|
849
|
+
// the recent calls again, a page of them (the setting changed)
|
|
850
|
+
calls() {
|
|
851
|
+
if (view === "gateway" && providers && !$("#view-gateway").classList.contains("loading")) renderActivity();
|
|
852
|
+
},
|
|
853
|
+
open: loadGatewayKeys,
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// A recorded call's request and response bodies, drawn here for the
|
|
858
|
+
// recent calls and shared with Usage's ledger and Routing's trace, which
|
|
859
|
+
// read the same calls (callBodyPanel); moved out of app.js, the core.
|
|
860
|
+
// a streamed reply's body is read as its reply, its events or as it came;
|
|
861
|
+
// the pick is one for every call and kept, the events drawn per call
|
|
862
|
+
let sseView = "events";
|
|
863
|
+
try { sseView = localStorage.getItem("magpie.sseView") || "events"; } catch {}
|
|
864
|
+
const sseShown = new Map(); // call id → how many events are drawn
|
|
865
|
+
|
|
866
|
+
function formatWireBody(raw) {
|
|
867
|
+
if (!raw) return "";
|
|
868
|
+
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// A JSON body as a tree: each object and array folds at its bracket, its
|
|
872
|
+
// parts in the snippets' colours; the copy button a line shows on hover
|
|
873
|
+
// copies its value, and a click on a key copies the key. The text is the
|
|
874
|
+
// JSON pretty-printed, as before, so what is selected and copied by hand
|
|
875
|
+
// still reads as JSON. What is folded is kept by the body's key and the
|
|
876
|
+
// node's path, across the redraws a new call brings.
|
|
877
|
+
const jsonFolds = new Set();
|
|
878
|
+
function jsonTree(v, key) {
|
|
879
|
+
const code = el("code", "jt");
|
|
880
|
+
code.append(jtNode(v, undefined, "", key + "|", ""));
|
|
881
|
+
return code;
|
|
882
|
+
}
|
|
883
|
+
function jtNode(v, k, indent, path, comma) {
|
|
884
|
+
const f = document.createDocumentFragment();
|
|
885
|
+
const head = el("span", "jt-head");
|
|
886
|
+
f.append(indent, head);
|
|
887
|
+
if (k !== undefined) {
|
|
888
|
+
const ks = el("span", "jt-key", JSON.stringify(k));
|
|
889
|
+
ks.title = t("Click to copy the key");
|
|
890
|
+
// a click copies; a drag selects, as in any text
|
|
891
|
+
ks.onclick = (ev) => {
|
|
892
|
+
ev.stopPropagation();
|
|
893
|
+
if (!String(getSelection())) copy(String(k), String(k), null);
|
|
894
|
+
};
|
|
895
|
+
head.append(ks, ": ");
|
|
896
|
+
}
|
|
897
|
+
const copyV = el("button", "jt-copy");
|
|
898
|
+
copyV.type = "button";
|
|
899
|
+
copyV.title = t("Copy value");
|
|
900
|
+
copyV.append(svg(COPY_ICON, 11, 1.5));
|
|
901
|
+
// a string's value is its text, not the JSON that quotes it
|
|
902
|
+
copyV.onclick = (ev) => {
|
|
903
|
+
ev.stopPropagation();
|
|
904
|
+
copy(typeof v === "string" ? v : JSON.stringify(v, null, 2), k !== undefined ? String(k) : t("Value"), copyV);
|
|
905
|
+
};
|
|
906
|
+
if (v === null || typeof v !== "object") {
|
|
907
|
+
head.append(el("span", typeof v === "string" ? "tk-s" : typeof v === "number" ? "tk-n" : "tk-k", JSON.stringify(v)), copyV);
|
|
908
|
+
f.append(comma);
|
|
909
|
+
return f;
|
|
910
|
+
}
|
|
911
|
+
const arr = Array.isArray(v);
|
|
912
|
+
const keys = arr ? v.map((_, i) => i) : Object.keys(v);
|
|
913
|
+
const [o, c] = arr ? ["[", "]"] : ["{", "}"];
|
|
914
|
+
if (!keys.length) {
|
|
915
|
+
head.append(o + c, copyV);
|
|
916
|
+
f.append(comma);
|
|
917
|
+
return f;
|
|
918
|
+
}
|
|
919
|
+
const tog = el("button", "jt-fold");
|
|
920
|
+
tog.type = "button";
|
|
921
|
+
tog.append(svg(CHEV_R, 10, 1.6));
|
|
922
|
+
const kids = el("span", "jt-kids");
|
|
923
|
+
const sum = el("button", "jt-sum", "…");
|
|
924
|
+
sum.type = "button";
|
|
925
|
+
sum.title = t("Unfold");
|
|
926
|
+
sum.dataset.n = t(keys.length === 1 ? "1 item" : "{n} items", { n: keys.length });
|
|
927
|
+
// the summary is on the head's line: what a click on it holds in place is
|
|
928
|
+
// then its neighbours there, not the line after, which the unfolding pushes down
|
|
929
|
+
head.prepend(tog);
|
|
930
|
+
head.append(o, sum, copyV);
|
|
931
|
+
f.append(kids, c, comma);
|
|
932
|
+
let built = false;
|
|
933
|
+
const set = (folded) => {
|
|
934
|
+
if (!folded && !built) { // its insides are drawn the first time it opens
|
|
935
|
+
built = true;
|
|
936
|
+
keys.forEach((kk, i) => kids.append("\n", jtNode(v[kk], arr ? undefined : kk, indent + " ", path + "/" + kk, i < keys.length - 1 ? "," : "")));
|
|
937
|
+
kids.append("\n" + indent);
|
|
938
|
+
}
|
|
939
|
+
kids.hidden = folded;
|
|
940
|
+
sum.hidden = !folded;
|
|
941
|
+
tog.classList.toggle("open", !folded);
|
|
942
|
+
tog.setAttribute("aria-expanded", String(!folded));
|
|
943
|
+
tog.title = t(folded ? "Unfold" : "Fold");
|
|
944
|
+
if (folded) jsonFolds.add(path); else jsonFolds.delete(path);
|
|
945
|
+
};
|
|
946
|
+
// the box keeps its height across a fold, so what is below it, and the
|
|
947
|
+
// page, stay where they are
|
|
948
|
+
const keep = () => { const pre = tog.closest("pre"); if (pre) pre.style.minHeight = pre.offsetHeight + "px"; };
|
|
949
|
+
tog.onclick = (ev) => { ev.stopPropagation(); keep(); set(!kids.hidden); };
|
|
950
|
+
sum.onclick = (ev) => { ev.stopPropagation(); keep(); set(false); };
|
|
951
|
+
set(jsonFolds.has(path));
|
|
952
|
+
return f;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// A streamed body (server-sent events) as its events: each one's name and
|
|
956
|
+
// its data, the data lines joined. Anything else is not one: null.
|
|
957
|
+
function parseSSE(raw) {
|
|
958
|
+
if (!raw || !/^\s*(?::[^\n]*\n\s*)*(event|data|id|retry):/.test(raw)) return null;
|
|
959
|
+
const events = [];
|
|
960
|
+
let ev = null;
|
|
961
|
+
const done = () => { if (ev && ev.data.length) events.push({ event: ev.event, data: ev.data.join("\n") }); ev = null; };
|
|
962
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
963
|
+
if (line === "") { done(); continue; }
|
|
964
|
+
if (line[0] === ":") continue; // a comment, a keep-alive
|
|
965
|
+
const i = line.indexOf(":");
|
|
966
|
+
const field = i < 0 ? line : line.slice(0, i);
|
|
967
|
+
let value = i < 0 ? "" : line.slice(i + 1);
|
|
968
|
+
if (value[0] === " ") value = value.slice(1);
|
|
969
|
+
ev = ev || { event: "", data: [] };
|
|
970
|
+
if (field === "event") ev.event = value;
|
|
971
|
+
else if (field === "data") ev.data.push(value);
|
|
972
|
+
}
|
|
973
|
+
done();
|
|
974
|
+
return events.length ? events : null;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const jsonOr = (s) => { try { return JSON.parse(s); } catch { return undefined; } };
|
|
978
|
+
|
|
979
|
+
// What a stream adds up to — its text, reasoning and tool calls, how it
|
|
980
|
+
// stopped and what it used — for the Anthropic Messages, OpenAI Responses,
|
|
981
|
+
// Chat Completions and Gemini streams; null for one it can't read.
|
|
982
|
+
function sseReply(events) {
|
|
983
|
+
const out = {};
|
|
984
|
+
const blocks = []; // Anthropic content blocks, Chat tool calls, Responses items, in order
|
|
985
|
+
const byKey = new Map();
|
|
986
|
+
const block = (key, init) => { let b = byKey.get(key); if (!b) { b = { ...init }; byKey.set(key, b); blocks.push(b); } return b; };
|
|
987
|
+
let kind = "";
|
|
988
|
+
for (const e of events) {
|
|
989
|
+
const d = jsonOr(e.data);
|
|
990
|
+
if (!d || typeof d !== "object") continue;
|
|
991
|
+
const type = d.type || e.event || "";
|
|
992
|
+
if (d.error) { out.error = d.error; continue; }
|
|
993
|
+
if (type === "message_start" && d.message) {
|
|
994
|
+
kind = "anthropic";
|
|
995
|
+
out.id = d.message.id; out.model = d.message.model;
|
|
996
|
+
if (d.message.usage) out.usage = { ...d.message.usage };
|
|
997
|
+
} else if (type === "content_block_start" && d.content_block) {
|
|
998
|
+
kind = "anthropic";
|
|
999
|
+
const cb = d.content_block;
|
|
1000
|
+
const b = block("a" + d.index, { type: cb.type });
|
|
1001
|
+
if (cb.type === "tool_use" || cb.type === "server_tool_use") { b.id = cb.id; b.name = cb.name; b.input = ""; }
|
|
1002
|
+
else if (cb.type === "text") b.text = cb.text || "";
|
|
1003
|
+
else if (cb.type === "thinking") b.thinking = cb.thinking || "";
|
|
1004
|
+
else Object.assign(b, cb);
|
|
1005
|
+
} else if (type === "content_block_delta" && d.delta) {
|
|
1006
|
+
const b = block("a" + d.index, { type: "text" });
|
|
1007
|
+
const x = d.delta;
|
|
1008
|
+
if (x.type === "text_delta") b.text = (b.text || "") + x.text;
|
|
1009
|
+
else if (x.type === "thinking_delta") b.thinking = (b.thinking || "") + x.thinking;
|
|
1010
|
+
else if (x.type === "input_json_delta") b.input = (b.input || "") + x.partial_json;
|
|
1011
|
+
} else if (type === "message_delta") {
|
|
1012
|
+
if (d.delta?.stop_reason) out.stop_reason = d.delta.stop_reason;
|
|
1013
|
+
if (d.usage) out.usage = { ...out.usage, ...d.usage };
|
|
1014
|
+
} else if (type.startsWith("response.")) {
|
|
1015
|
+
kind = "responses";
|
|
1016
|
+
if (d.response) {
|
|
1017
|
+
out.id = d.response.id; out.model = d.response.model; out.status = d.response.status;
|
|
1018
|
+
if (d.response.usage) out.usage = d.response.usage;
|
|
1019
|
+
if (d.response.error) out.error = d.response.error;
|
|
1020
|
+
if (d.response.incomplete_details) out.incomplete = d.response.incomplete_details;
|
|
1021
|
+
if (type === "response.completed" && d.response.output?.length) out.output = d.response.output;
|
|
1022
|
+
}
|
|
1023
|
+
const key = "r" + (d.item_id || d.item?.id || d.output_index);
|
|
1024
|
+
if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
1025
|
+
const it = d.item || {};
|
|
1026
|
+
const b = block(key, { type: it.type });
|
|
1027
|
+
if (it.type === "function_call" || it.type === "custom_tool_call") { b.call_id = it.call_id; b.name = it.name; }
|
|
1028
|
+
if (type === "response.output_item.done") {
|
|
1029
|
+
if (it.arguments !== undefined) b.arguments = it.arguments;
|
|
1030
|
+
if (it.input !== undefined) b.input = it.input;
|
|
1031
|
+
const text = (it.content || []).map((c) => c.text || c.refusal || "").join("");
|
|
1032
|
+
if (text) b.text = text;
|
|
1033
|
+
const sum = (it.summary || []).map((c) => c.text || "").join("\n\n");
|
|
1034
|
+
if (sum) b.summary = sum;
|
|
1035
|
+
}
|
|
1036
|
+
} else if (type === "response.output_text.delta" || type === "response.refusal.delta") {
|
|
1037
|
+
const b = block(key, { type: "message" }); b.text = (b.text || "") + d.delta;
|
|
1038
|
+
} else if (type === "response.reasoning_summary_text.delta") {
|
|
1039
|
+
const b = block(key, { type: "reasoning" }); b.summary = (b.summary || "") + d.delta;
|
|
1040
|
+
} else if (type === "response.reasoning_text.delta") {
|
|
1041
|
+
const b = block(key, { type: "reasoning" }); b.text = (b.text || "") + d.delta;
|
|
1042
|
+
} else if (type === "response.function_call_arguments.delta") {
|
|
1043
|
+
const b = block(key, { type: "function_call" }); b.arguments = (b.arguments || "") + d.delta;
|
|
1044
|
+
} else if (type === "response.custom_tool_call_input.delta") {
|
|
1045
|
+
const b = block(key, { type: "custom_tool_call" }); b.input = (b.input || "") + d.delta;
|
|
1046
|
+
}
|
|
1047
|
+
} else if (Array.isArray(d.choices)) {
|
|
1048
|
+
kind = "chat";
|
|
1049
|
+
if (d.id) out.id = d.id;
|
|
1050
|
+
if (d.model) out.model = d.model;
|
|
1051
|
+
if (d.usage) out.usage = d.usage;
|
|
1052
|
+
for (const c of d.choices) {
|
|
1053
|
+
const x = c.delta || c.message || {};
|
|
1054
|
+
const msg = block("c" + (c.index || 0), { type: "message" });
|
|
1055
|
+
if (typeof x.content === "string") msg.content = (msg.content || "") + x.content;
|
|
1056
|
+
const r = x.reasoning_content ?? x.reasoning;
|
|
1057
|
+
if (typeof r === "string") msg.reasoning = (msg.reasoning || "") + r;
|
|
1058
|
+
for (const tc of x.tool_calls || []) {
|
|
1059
|
+
const b = block("c" + (c.index || 0) + ":" + (tc.index ?? tc.id), { type: "tool_call" });
|
|
1060
|
+
if (tc.id) b.id = tc.id;
|
|
1061
|
+
if (tc.function?.name) b.name = (b.name || "") + tc.function.name;
|
|
1062
|
+
if (tc.function?.arguments) b.arguments = (b.arguments || "") + tc.function.arguments;
|
|
1063
|
+
}
|
|
1064
|
+
if (c.finish_reason) out.finish_reason = c.finish_reason;
|
|
1065
|
+
}
|
|
1066
|
+
} else if (Array.isArray(d.candidates)) {
|
|
1067
|
+
kind = "gemini";
|
|
1068
|
+
if (d.modelVersion) out.model = d.modelVersion;
|
|
1069
|
+
if (d.usageMetadata) out.usage = d.usageMetadata;
|
|
1070
|
+
for (const c of d.candidates) {
|
|
1071
|
+
for (const p of c.content?.parts || []) {
|
|
1072
|
+
if (typeof p.text === "string") {
|
|
1073
|
+
const b = block("g" + (c.index || 0) + (p.thought ? "t" : ""), { type: p.thought ? "thought" : "text" });
|
|
1074
|
+
b.text = (b.text || "") + p.text;
|
|
1075
|
+
} else if (p.functionCall) blocks.push({ type: "functionCall", ...p.functionCall });
|
|
1076
|
+
}
|
|
1077
|
+
if (c.finishReason) out.finishReason = c.finishReason;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (!kind && !out.error) return null;
|
|
1082
|
+
// the Responses API's own finished output is the reply, when it came
|
|
1083
|
+
const content = (out.output || blocks).filter((b) => !(kind === "chat" && b.type === "message" && !b.content && !b.reasoning));
|
|
1084
|
+
// a tool's arguments read as the object they spell, when they are whole
|
|
1085
|
+
for (const b of content) {
|
|
1086
|
+
for (const k of ["input", "arguments"]) {
|
|
1087
|
+
if (typeof b[k] !== "string") continue;
|
|
1088
|
+
const v = b[k] === "" && k === "input" ? {} : jsonOr(b[k]);
|
|
1089
|
+
if (v !== undefined) b[k] = v;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const reply = {};
|
|
1093
|
+
for (const k of ["id", "model", "status"]) if (out[k] !== undefined) reply[k] = out[k];
|
|
1094
|
+
if (content.length) reply[{ anthropic: "content", responses: "output", gemini: "parts" }[kind] || "choices"] = content;
|
|
1095
|
+
for (const k of ["stop_reason", "finish_reason", "finishReason", "incomplete", "usage", "error"]) if (out[k] !== undefined) reply[k] = out[k];
|
|
1096
|
+
return reply;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// The data of each event, as JSON where it is JSON, under its name.
|
|
1100
|
+
function sseEventNode(e, key) {
|
|
1101
|
+
const box = el("span", "sse-ev");
|
|
1102
|
+
if (e.event) box.append(el("span", "sse-name", "event: " + e.event + "\n"));
|
|
1103
|
+
const d = jsonOr(e.data);
|
|
1104
|
+
box.append(d !== null && typeof d === "object" ? jtNode(d, undefined, "", key + "|", "") : e.data); // [DONE] as it is
|
|
1105
|
+
return box;
|
|
1106
|
+
}
|
|
1107
|
+
function sseData(data) {
|
|
1108
|
+
const d = jsonOr(data);
|
|
1109
|
+
return d !== null && typeof d === "object" ? JSON.stringify(d, null, 2) : data; // [DONE] as it is
|
|
1110
|
+
}
|
|
1111
|
+
const SSE_PAGE = 200; // events drawn at a time: a long stream stays quick to open
|
|
1112
|
+
|
|
1113
|
+
function sseBodyPanel(label, raw, truncated, id) {
|
|
1114
|
+
const events = parseSSE(raw);
|
|
1115
|
+
if (!events) return null;
|
|
1116
|
+
const reply = sseReply(events);
|
|
1117
|
+
const panel = el("section", "call-body sse");
|
|
1118
|
+
const head = el("div", "call-body-head");
|
|
1119
|
+
head.append(el("span", "call-body-label", t(label)));
|
|
1120
|
+
if (truncated) head.append(el("span", "call-body-truncated", typeof truncated === "string" ? truncated : t("first 256 KB")));
|
|
1121
|
+
const views = [["reply", t("Reply")], ["events", t("Events")], ["raw", t("Raw")]].filter(([v]) => v !== "reply" || reply);
|
|
1122
|
+
let view = views.some(([v]) => v === sseView) ? sseView : "events";
|
|
1123
|
+
const count = el("span", "call-body-count", t(events.length === 1 ? "1 event" : "{n} events", { n: events.length }));
|
|
1124
|
+
const pick = segs(views, view, (v) => { view = sseView = v; try { localStorage.setItem("magpie.sseView", v); } catch {} draw(); });
|
|
1125
|
+
pick.classList.add("sse-views");
|
|
1126
|
+
const copyB = copyBtn("", t(label)); // what it copies is set below
|
|
1127
|
+
head.append(count, el("span", "grow"), pick, copyB);
|
|
1128
|
+
panel.append(head);
|
|
1129
|
+
const pre = el("pre");
|
|
1130
|
+
// a long stream's events come a page at a time; what draws more sits
|
|
1131
|
+
// under the box, where it stays as they come in rather than at the end
|
|
1132
|
+
// of what is drawn
|
|
1133
|
+
const foot = el("div", "call-body-foot");
|
|
1134
|
+
const shownNote = el("span");
|
|
1135
|
+
const moreB = el("button", "link", "");
|
|
1136
|
+
foot.append(shownNote, el("span", "grow"), moreB);
|
|
1137
|
+
panel.append(pre, foot);
|
|
1138
|
+
let code = null, shown = 0;
|
|
1139
|
+
const more = () => {
|
|
1140
|
+
const upto = Math.min(events.length, Math.max(sseShown.get(id) || 0, shown + SSE_PAGE));
|
|
1141
|
+
for (; shown < upto; shown++) code.append(sseEventNode(events[shown], id + "|ev" + shown));
|
|
1142
|
+
if (shown > SSE_PAGE) sseShown.set(id, shown);
|
|
1143
|
+
foot.hidden = view !== "events" || shown >= events.length;
|
|
1144
|
+
shownNote.textContent = t("{n} of {total} events shown", { n: shown, total: events.length });
|
|
1145
|
+
moreB.textContent = t("Show {n} more events", { n: Math.min(SSE_PAGE, events.length - shown) });
|
|
1146
|
+
};
|
|
1147
|
+
moreB.onclick = (ev) => { ev.stopPropagation(); more(); };
|
|
1148
|
+
const draw = () => {
|
|
1149
|
+
// the box keeps its height across a switch, so what is below it, and
|
|
1150
|
+
// the page, stay where they are
|
|
1151
|
+
if (pre.isConnected) pre.style.minHeight = pre.offsetHeight + "px";
|
|
1152
|
+
code = view === "reply" ? jsonTree(reply, id + "|reply") : el("code", view === "events" ? "jt" : "");
|
|
1153
|
+
pre.replaceChildren(code);
|
|
1154
|
+
pre.scrollTop = 0;
|
|
1155
|
+
shown = 0;
|
|
1156
|
+
if (view === "raw") code.textContent = raw;
|
|
1157
|
+
if (view === "events") more(); else foot.hidden = true;
|
|
1158
|
+
};
|
|
1159
|
+
// what is copied is what is shown: the reply, every event, or the body
|
|
1160
|
+
copyB.onclick = (ev) => {
|
|
1161
|
+
ev.stopPropagation();
|
|
1162
|
+
const text = view === "reply" ? JSON.stringify(reply, null, 2) : view === "raw" ? raw
|
|
1163
|
+
: events.map((e) => (e.event ? "event: " + e.event + "\n" : "") + sseData(e.data)).join("\n\n");
|
|
1164
|
+
copy(text, t(label), copyB);
|
|
1165
|
+
};
|
|
1166
|
+
draw();
|
|
1167
|
+
return panel;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// id, given, is a response's, whose stream reads as its events; key names
|
|
1171
|
+
// the body for what is folded in it
|
|
1172
|
+
function callBodyPanel(label, raw, truncated, id, key = id) {
|
|
1173
|
+
if (id) {
|
|
1174
|
+
const sse = sseBodyPanel(label, raw, truncated, id);
|
|
1175
|
+
if (sse) return sse;
|
|
1176
|
+
}
|
|
1177
|
+
const panel = el("section", "call-body");
|
|
1178
|
+
const head = el("div", "call-body-head");
|
|
1179
|
+
head.append(el("span", "call-body-label", t(label)));
|
|
1180
|
+
if (truncated) head.append(el("span", "call-body-truncated", typeof truncated === "string" ? truncated : t("first 256 KB")));
|
|
1181
|
+
const formatted = formatWireBody(raw);
|
|
1182
|
+
if (formatted) head.append(el("span", "grow"), copyBtn(raw, t(label)));
|
|
1183
|
+
panel.append(head);
|
|
1184
|
+
const pre = el("pre");
|
|
1185
|
+
const v = formatted ? jsonOr(raw) : undefined;
|
|
1186
|
+
if (v !== null && typeof v === "object") pre.append(jsonTree(v, key));
|
|
1187
|
+
else {
|
|
1188
|
+
const code = el("code", "", formatted || t("No body captured"));
|
|
1189
|
+
if (!formatted) code.classList.add("empty");
|
|
1190
|
+
pre.append(code);
|
|
1191
|
+
}
|
|
1192
|
+
panel.append(pre);
|
|
1193
|
+
return panel;
|
|
1194
|
+
}
|