magpie-gemini-web 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +24 -6
- package/README.md +24 -6
- package/docs/studio-release.md +33 -13
- package/lib/backend.mjs +120 -3
- package/lib/studio-native.mjs +7 -3
- package/lib/studio-release.json +45 -19
- package/lib/studio.mjs +75 -9
- package/package.json +1 -1
- package/scripts/studio-managed-smoke.mjs +6 -2
- package/scripts/studio-release.mjs +89 -31
package/README.en.md
CHANGED
|
@@ -17,9 +17,11 @@ The plugin provides two independent providers that can be used individually or t
|
|
|
17
17
|
| **Best for** | **Coding agents (recommended)** | General chat, web-only models & web quota |
|
|
18
18
|
| **Tool calling** | **Native Function Calling**, reliable | Prompt-emulated (experimental; Flash often skips tools) |
|
|
19
19
|
| **Thinking levels** | Supported levels from the model catalog: `minimal` / `low` / `medium` / `high` | `low` (base) / `medium` (`-thinking-medium`, `gemini-4-argon` only) / `high` (`-thinking` extended thinking) |
|
|
20
|
+
| **Image input** | On by default; chat models read images | Off by default; needs a signed-in cookie once on (see below) |
|
|
21
|
+
| **Image generation** | Nano Banana models, for Magpie's image generation (Magpie v0.1.2075+, see below) | Not supported |
|
|
20
22
|
| **Sign-in method** | Sign in directly in an isolated popup browser window | Copy `cookie` from a private/incognito window |
|
|
21
23
|
| **Usage cards** | Shows models currently on cooldown and recovery times | Syncs **5-hour** & **weekly** usage and reset times from the web |
|
|
22
|
-
| **Local backend** | AIStudio2API (`v0.2.
|
|
24
|
+
| **Local backend** | AIStudio2API (`v0.2.18`) | gemini-web2api-go (`4.26.0-magpie.1` optimized build) |
|
|
23
25
|
|
|
24
26
|
---
|
|
25
27
|
|
|
@@ -31,7 +33,7 @@ Install **magpie-gemini-web** from the Magpie plugin market, then sign in to eit
|
|
|
31
33
|
|
|
32
34
|
1. Sign in to **Google AI Studio** in Magpie and choose **「用 Google 账号登录(推荐)」** (Sign in with a Google account).
|
|
33
35
|
2. Sign in to your Google account in the separate login window that opens (your regular browser is unaffected), then close the window.
|
|
34
|
-
> On first run, the local service (~
|
|
36
|
+
> On first run, the local service (~40 MB) and the login browser Camoufox (~300 MB) are downloaded first, so the window may take a few minutes to appear. While they download, Magpie opens a progress page in your browser. Magpie waits at most 10 minutes, downloads included, so finish signing in promptly; if GitHub is unreachable, set `studio.proxy` first (see plugin options and proxy settings below).
|
|
35
37
|
3. **Managing accounts:** Choose "Sign in with a Google account" again at any time to **add a new account**, create a card for an existing local account, or **re-authenticate** an expired account.
|
|
36
38
|
|
|
37
39
|
### Option 2: Gemini Web (`gemini-web`)
|
|
@@ -61,6 +63,18 @@ Install **magpie-gemini-web** from the Magpie plugin market, then sign in to eit
|
|
|
61
63
|
|
|
62
64
|
---
|
|
63
65
|
|
|
66
|
+
## Image Generation (AI Studio)
|
|
67
|
+
|
|
68
|
+
`ai-studio` lists the Nano Banana image models your account can use (such as `gemini-3.1-flash-image` and `gemini-3-pro-image`), for Magpie's image generation. **This needs Magpie v0.1.2075 or later**, the first to take a plugin's Gemini image models as models that draw.
|
|
69
|
+
|
|
70
|
+
- **No need to tick them, or to pick them for a chat:** Magpie's Settings list them under **Images → Image generation**. Pick one there, or leave it on Automatic.
|
|
71
|
+
- **How agents draw:** an agent calls Magpie's `generate_image` tool (the Magpie Image MCP from the Library), which draws with the model picked there; reference images make it an edit. To try it from the command line: `magpie tool generate_image "a magpie on a branch, watercolour" --size 16:9 -o magpie.png`.
|
|
72
|
+
- **Size:** `size` gives the aspect ratio. A ratio (such as `16:9` or `1:4`) is used as it is when the model takes it, else the nearest one it does; a size such as `1536x1024` is made the nearest usual ratio by Magpie first. `quality` makes no difference for these models.
|
|
73
|
+
- **Don't tick them as chat models:** Magpie still keeps a plugin's models that draw in its chat model lists ([yetone/magpie#2792](https://github.com/yetone/magpie/issues/2792)). They take no tool calls and have 32K–128K context windows, so they make poor chat models for agents.
|
|
74
|
+
- If you don't want the image models, turn them off with `studio.imageModels` (see the options below).
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
64
78
|
## Configuration & Advanced Usage
|
|
65
79
|
|
|
66
80
|
<details>
|
|
@@ -68,12 +82,15 @@ Install **magpie-gemini-web** from the Magpie plugin market, then sign in to eit
|
|
|
68
82
|
|
|
69
83
|
<br>
|
|
70
84
|
|
|
71
|
-
Tool calling is enabled by default
|
|
85
|
+
Tool calling is enabled by default. Image input is on by default for AI Studio and off for Gemini Web. Configure plugin options via the CLI:
|
|
72
86
|
|
|
73
87
|
```bash
|
|
74
|
-
#
|
|
88
|
+
# Turn image input on for both providers
|
|
75
89
|
magpie plugin options magpie-gemini-web '{"allowImages":true}'
|
|
76
90
|
|
|
91
|
+
# Turn image input off for AI Studio
|
|
92
|
+
magpie plugin options magpie-gemini-web '{"studio":{"allowImages":false}}'
|
|
93
|
+
|
|
77
94
|
# Configure a proxy for AI Studio
|
|
78
95
|
magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:7890"}}'
|
|
79
96
|
```
|
|
@@ -85,7 +102,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
|
|
|
85
102
|
| Option | Default | Scope | Description |
|
|
86
103
|
| :--- | :--- | :--- | :--- |
|
|
87
104
|
| `allowTools` | `true` | Shared (override via `studio.allowTools`) | Allow tool calls. When `false`, requests with tools return 400 |
|
|
88
|
-
| `allowImages` | `false` | Shared (override via `studio.allowImages`) | Allow image input |
|
|
105
|
+
| `allowImages` | Web: `false`; Studio: `true` | Shared (override via `studio.allowImages`) | Allow image input in a chat (an image model's reference images don't need it). Gemini Web reads images only when signed in and returns 400 otherwise; in testing, `gemini-3.6-flash` waited until the timeout on a chat with an image, while `gemini-3.5-flash-lite` and `gemini-3.8-flash` answered |
|
|
89
106
|
| `requestTimeoutMs` / `modelTimeoutMs` | Web: `240000` / `10000`; Studio: `1800000` / `10000` | Shared (override via `studio.*`) | Timeout (ms) per backend wait or model list fetch; resets on stream data, explicit settings take precedence |
|
|
90
107
|
| `stripCitations` | `true` | `gemini-web` only | Strip `[cite: 1]` and `[cite_start]` markers from replies and tool arguments (not supported on AI Studio) |
|
|
91
108
|
| `proxy` | none | `gemini-web` only | Proxy for the managed Gemini Web backend to reach Google (`http` / `https` / `socks5` / `socks5h`), or set `GEMINI_WEB_PROXY` env var / enter during sign-in |
|
|
@@ -94,6 +111,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
|
|
|
94
111
|
| `studio.proxy` | none | `ai-studio` only | Proxy for reaching Google (`http` / `https` / `socks5`), or set `AI_STUDIO_PROXY` env var; when set, it takes precedence over the proxy saved in the admin page. An empty value counts as unset |
|
|
95
112
|
| `studio.baseURL` | `http://127.0.0.1:2048/v1` | `ai-studio` only | Default URL when connecting an existing AIStudio2API instance |
|
|
96
113
|
| `studio.contextLimit` / `studio.outputLimit` | Model catalog defaults | `ai-studio` only | Limits reported to clients (can only lower the catalog defaults) |
|
|
114
|
+
| `studio.imageModels` | `true` | `ai-studio` only | List the Nano Banana image models, for Magpie's image generation; `false` lists text models only |
|
|
97
115
|
|
|
98
116
|
**Using a proxy to reach Google:**
|
|
99
117
|
- **AI Studio:** Preferably set it in the local admin page. Open the address on the usage card's "管理页" (admin page) line (default `http://127.0.0.1:2048`) and sign in as `magpie` with `adminPassword` from `config.json` in the data directory. Save the proxy, then restart the service from the admin page. The plugin keeps the saved value when it restarts the backend.
|
|
@@ -149,7 +167,7 @@ When a plugin error asks you to run a command, it includes the full ready-to-cop
|
|
|
149
167
|
- Concurrency is capped at 1, but Google account rate-limiting or flagging cannot be completely ruled out—use at your own risk.
|
|
150
168
|
|
|
151
169
|
**AI Studio upgrades and model selection:**
|
|
152
|
-
- The plugin pins SHA-256-verified `v0.2.
|
|
170
|
+
- The plugin pins SHA-256-verified `v0.2.18-magpie.1`, based on upstream `v0.2.18`. Upgrade the plugin to update the backend. Official releases at or below that upstream version, and managed backends an older plugin installed, are upgraded on the next managed startup, preserving accounts, port and sign-in credentials.
|
|
153
171
|
- **Online updates are disabled in the managed backend on macOS, Linux, and Windows.** The admin page says updates are managed by magpie-gemini-web; the update endpoint returns HTTP 409 and update checks do not contact GitHub. Independently managed external servers are unaffected. See [docs/studio-release.md](docs/studio-release.md) for how the managed build is made and reproduced.
|
|
154
172
|
- Managed Unix installs still attempt to set `package/` to `0555` as additional file protection. Permission failures do not block service, and disabling online updates does not depend on permissions. To reset by manually deleting the data directory, stop the service first, then run `chmod -R u+w "<data-dir>/package"` before deleting it; replace `<data-dir>` with the actual path.
|
|
155
173
|
- If a newer or custom version is installed, the plugin reports a conflict with `stop`, `install`, and `start` commands carrying the correct `--data-dir`. Stop can recover a changed listener PID and verifies its executable path before signaling it. If HTTP is down but the process is still alive, the plugin refuses to reinstall or launch a second process.
|
package/README.md
CHANGED
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
| **适用场景** | **推荐 Coding Agent 使用** | 日常对话、使用网页版专属模型与额度 |
|
|
18
18
|
| **工具调用** | **原生 Function Calling**,稳定可靠 | 提示词模拟(实验性,Flash 系列常不调工具) |
|
|
19
19
|
| **思考档位** | 按模型目录提供 `minimal` / `low` / `medium` / `high` 中支持的档位 | `low`(普通)/ `medium`(`-thinking-medium`,仅 `gemini-4-argon`)/ `high`(`-thinking` 扩展思考) |
|
|
20
|
+
| **图片输入** | 默认开启,对话模型可以读图 | 默认关闭;开启后需要登录(挂 Cookie),见下文 |
|
|
21
|
+
| **图片生成** | Nano Banana 系列模型,供 Magpie 的图片生成使用(需要 Magpie v0.1.2075+,见下文) | 不支持 |
|
|
20
22
|
| **登录方式** | 弹出独立浏览器窗口直接登录 Google | 用无痕窗口登录并复制 `cookie` |
|
|
21
23
|
| **额度卡片** | 显示冷却中的模型与恢复时间 | 同步网页版 **5 小时** 与 **每周** 用量及重置时间 |
|
|
22
|
-
| **本地后端** | AIStudio2API (`v0.2.
|
|
24
|
+
| **本地后端** | AIStudio2API (`v0.2.18`) | gemini-web2api-go (`4.26.0-magpie.1` 优化版) |
|
|
23
25
|
|
|
24
26
|
---
|
|
25
27
|
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
|
|
32
34
|
1. 在 Magpie 中登录 **Google AI Studio**,选择 **「用 Google 账号登录(推荐)」**。
|
|
33
35
|
2. 在弹出的独立登录窗口中登录 Google 账号(不影响日常浏览器),完成后直接关闭窗口。
|
|
34
|
-
> 首次使用要先下载本地服务(约
|
|
36
|
+
> 首次使用要先下载本地服务(约 40 MB)和登录浏览器 Camoufox(约 300 MB),弹窗可能需要等几分钟才出现。需要下载时,Magpie 会在浏览器里打开一个进度页,显示下载进度。Magpie 最多等 10 分钟(下载的时间也算在内),请尽快完成登录;连不上 GitHub 时,先按下面「插件选项与代理配置」设置 `studio.proxy`。
|
|
35
37
|
3. **后续管理**:再次点击「用 Google 账号登录」,可选择**添加新账号**、为本机已有账号建卡,或为过期账号**重新登录**。
|
|
36
38
|
|
|
37
39
|
### 方式二:Gemini Web (`gemini-web`)
|
|
@@ -61,6 +63,18 @@
|
|
|
61
63
|
|
|
62
64
|
---
|
|
63
65
|
|
|
66
|
+
## 图片生成(AI Studio)
|
|
67
|
+
|
|
68
|
+
`ai-studio` 会列出账号可用的 Nano Banana 系列图像模型(如 `gemini-3.1-flash-image`、`gemini-3-pro-image`),供 Magpie 的图片生成使用。**需要 Magpie v0.1.2075 及以上**:从这个版本起,Magpie 才会把插件的 Gemini 图像模型列为出图模型。
|
|
69
|
+
|
|
70
|
+
- **不用勾选,也不用在对话里选它们**:Magpie 设置的 **Images → Image generation** 会直接列出这些模型,选中一个,或保持 Automatic(自动)即可。
|
|
71
|
+
- **Agent 怎么出图**:Agent 调用 Magpie 的 `generate_image` 工具(Library 里的 Magpie Image MCP),Magpie 用上面选的模型出图;带参考图就是改图。也可以在命令行试:`magpie tool generate_image "a magpie on a branch, watercolour" --size 16:9 -o magpie.png`。
|
|
72
|
+
- **尺寸**:`size` 决定宽高比:写比例(如 `16:9`、`1:4`)时,模型支持就照用,不支持就取它支持的最接近的比例;写 `1536x1024` 这样的尺寸时,Magpie 先换成最接近的常用比例。`quality` 对这些模型不起作用。
|
|
73
|
+
- **别把它们勾进对话模型**:Magpie 目前会把能出图的插件模型也留在对话模型列表里([yetone/magpie#2792](https://github.com/yetone/magpie/issues/2792))。它们不支持工具调用,上下文也只有 32K–128K,不适合当 Agent 的对话模型。
|
|
74
|
+
- 不需要图像模型时,可以用 `studio.imageModels` 关掉(见下方选项)。
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
64
78
|
## 配置与进阶
|
|
65
79
|
|
|
66
80
|
<details>
|
|
@@ -68,12 +82,15 @@
|
|
|
68
82
|
|
|
69
83
|
<br>
|
|
70
84
|
|
|
71
|
-
|
|
85
|
+
工具调用默认开启。图片输入在 AI Studio 默认开启,在 Gemini Web 默认关闭。可通过命令行修改插件配置:
|
|
72
86
|
|
|
73
87
|
```bash
|
|
74
|
-
#
|
|
88
|
+
# 两个 Provider 都开启图片输入
|
|
75
89
|
magpie plugin options magpie-gemini-web '{"allowImages":true}'
|
|
76
90
|
|
|
91
|
+
# 关闭 AI Studio 的图片输入
|
|
92
|
+
magpie plugin options magpie-gemini-web '{"studio":{"allowImages":false}}'
|
|
93
|
+
|
|
77
94
|
# 为 AI Studio 配置代理
|
|
78
95
|
magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:7890"}}'
|
|
79
96
|
```
|
|
@@ -85,7 +102,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
|
|
|
85
102
|
| 选项 | 默认值 | 适用范围 | 说明 |
|
|
86
103
|
| :--- | :--- | :--- | :--- |
|
|
87
104
|
| `allowTools` | `true` | 共用(可用 `studio.allowTools` 单独覆盖) | 允许工具调用。设为 `false` 时带工具的请求返回 400 |
|
|
88
|
-
| `allowImages` |
|
|
105
|
+
| `allowImages` | Web:`false`;Studio:`true` | 共用(可用 `studio.allowImages` 单独覆盖) | 允许对话中的图片输入(图像模型的参考图不受它限制)。Gemini Web 读图需要登录,未登录时返回 400;实测 `gemini-3.6-flash` 带图请求会一直等到超时,`gemini-3.5-flash-lite`、`gemini-3.8-flash` 正常 |
|
|
89
106
|
| `requestTimeoutMs` / `modelTimeoutMs` | Web:`240000` / `10000`;Studio:`1800000` / `10000` | 共用(`studio.*` 可覆盖) | 单次等待后端 / 拉取模型列表的超时(毫秒);收到流数据后重新计时,显式配置优先 |
|
|
90
107
|
| `stripCitations` | `true` | 仅 `gemini-web` | 剥离回复与工具参数中的 `[cite: 1]`、`[cite_start]` 标记(AI Studio 不支持剥离) |
|
|
91
108
|
| `proxy` | 无 | 仅 `gemini-web` | 一键配置后端访问 Google 的代理(`http` / `https` / `socks5` / `socks5h`),也可用环境变量 `GEMINI_WEB_PROXY` 或在登录第一步填写 |
|
|
@@ -94,6 +111,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
|
|
|
94
111
|
| `studio.proxy` | 无 | 仅 `ai-studio` | 访问 Google 的代理(`http` / `https` / `socks5`),也可用环境变量 `AI_STUDIO_PROXY`;设置后优先于管理页里保存的代理,留空等于不设置 |
|
|
95
112
|
| `studio.baseURL` | `http://127.0.0.1:2048/v1` | 仅 `ai-studio` | 连接已有 AIStudio2API 时的默认地址 |
|
|
96
113
|
| `studio.contextLimit` / `studio.outputLimit` | 模型目录默认值 | 仅 `ai-studio` | 向客户端声明的上限(仅支持在目录值基础上调低) |
|
|
114
|
+
| `studio.imageModels` | `true` | 仅 `ai-studio` | 列出 Nano Banana 等图像模型,供 Magpie 的图片生成使用;设为 `false` 时只列文本模型 |
|
|
97
115
|
|
|
98
116
|
**配置代理访问 Google:**
|
|
99
117
|
- **AI Studio**:推荐在本地管理页设置。打开额度卡片「管理页」一行里的地址(默认 `http://127.0.0.1:2048`),用户名 `magpie`,密码是数据目录 `config.json` 里的 `adminPassword`;保存代理后在管理页重启服务即可生效,之后插件重启后端也不会覆盖它。
|
|
@@ -149,7 +167,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
|
|
|
149
167
|
- 插件将请求并发限制为 1,但仍无法完全排除 Google 账号风控风险,请自行评估使用。
|
|
150
168
|
|
|
151
169
|
**AI Studio 升级与模型范围:**
|
|
152
|
-
- 插件固定使用经过 SHA-256 校验的 `v0.2.
|
|
170
|
+
- 插件固定使用经过 SHA-256 校验的 `v0.2.18-magpie.1`(基于上游 `v0.2.18`),通过升级插件更新后端。同基线或更旧的官方版、以及旧版插件装的托管后端,在下次管理启动时自动升级,保留账号、端口与登录凭据。
|
|
153
171
|
- **托管后端已在 macOS、Linux、Windows 统一禁用在线更新**:管理页提示“更新由 magpie-gemini-web 管理”,更新接口返回 HTTP 409,检查更新也不会访问 GitHub。用户自行连接的外部服务不受影响。托管构建方式和可复现构建流程见 [docs/studio-release.md](docs/studio-release.md)。
|
|
154
172
|
- Unix 的 `package/` 仍尽力设置为 `0555`,作为额外的文件保护;权限设置失败不阻断服务,在线更新的禁用也不依赖该权限。若要手动删除数据目录来重置,请先停止服务,再运行 `chmod -R u+w "<数据目录>/package"` 恢复可写权限,然后删除;将 `<数据目录>` 替换为实际路径。
|
|
155
173
|
- 若自行更新到更新版或定制版,插件会提示版本冲突,并提供带正确 `--data-dir` 的 `stop`、`install`、`start` 命令。停止时会找回更新后的监听进程 PID,并核对可执行文件路径;无法核实身份就不会发送终止信号。HTTP 端口关闭但进程尚未退出时,插件不会重装或启动第二个进程。
|
package/docs/studio-release.md
CHANGED
|
@@ -1,36 +1,43 @@
|
|
|
1
1
|
# AI Studio 托管后端构建
|
|
2
2
|
|
|
3
|
-
插件使用 `0.2.
|
|
3
|
+
插件使用 `0.2.18-magpie.1`,基于 AIStudio2API `v0.2.18` 的固定 commit。上游版本、commit、Go 版本、C/C++ 工具链与 SpiderMonkey 静态库(`toolchains`)、补丁列表、安装包及二进制 SHA-256 均记录在 `lib/studio-release.json`。`upstream.camoufox` 是这个 commit 要求的 Camoufox 版本(上游 `internal/camoufoxnative/download.go` 里的 `camoufoxRelease`),更新上游 commit 时要一起核对,构建脚本发现不一致会报错。MIT 许可见 `LICENSE-AISTUDIO`。
|
|
4
4
|
|
|
5
5
|
## 升级兼容
|
|
6
6
|
|
|
7
|
+
- 上游 v0.2.18 删除了纯 Go(goja)的 WAA 后端 `go`,改为经 CGO 内嵌 Firefox 的 JS 引擎 SpiderMonkey(`WAA_BACKEND=spidermonkey`)。插件固定使用 `spidermonkey`:生成仍在服务进程内完成,不需要浏览器;Camoufox 仍只用于 Google 登录。上游默认的 `camoufox` 后端未采用。
|
|
8
|
+
- SpiderMonkey 使用上游自己发布时用的预编译静态库(`Mag1cFall/firefox` 的 `v152.0.4-aistudio2api.1` release),每个目标的归档名与 SHA-256 记录在 `toolchains.spidermonkey`,构建时下载到 `studio-build/toolchains/` 并校验。头文件与库经 `CGO_CXXFLAGS` / `CGO_LDFLAGS` 传入,库名由上游 `internal/waa/mozjs` 的 `#cgo` 指令给出,与上游 `.github/actions/spidermonkey` 一致。
|
|
9
|
+
- 新增设置 `SEARCH_MODEL_ALIASES`、`GOOGLE_SEARCH`、`CONTINUATION_REWRITE`、`CONTINUATION_PROMPT` 保留管理页保存的值,并隔离宿主环境同名变量。上游默认开启 `SEARCH_MODEL_ALIASES`,把每个可搜索的对话模型再列一份 `<id>-search`;插件在 `.env` 缺少该项时写入 `false`,避免 Magpie 的模型列表大多重复一遍,用户可在管理页开启。其余三项沿用上游默认。
|
|
10
|
+
- Gemini 原生接口(`/v1beta`,Magpie 画图用)在 v0.2.18 按 Gemini 的方式拒绝密钥:错误密钥返回 400 `API_KEY_INVALID`,缺少密钥返回 403。插件把这两种情况当作 401 处理,提示重新登录;Google 自身返回的 403(如 code 7 “The caller does not have permission”)属于账号问题,原样传回。
|
|
7
11
|
- 上游 v0.2.17 改用需要 CGO 的原生 SQLite。账号和配置沿用原目录;首次打开旧数据库会整理空闲页。降级前应停止服务并备份完整数据目录,旧版不保证能读取迁移后的数据。
|
|
8
|
-
-
|
|
12
|
+
- 上游(v0.2.17 起,v0.2.18 仍然)会删除旧的请求正文 `bodies` 表。`patches/studio-0.2.17-preserve-request-bodies.patch` 改为在事务内压缩并迁移到 `request_bodies`,已有新版同 ID 记录优先,成功后才删除旧表;失败回滚。补丁原样适用于 v0.2.18,供从 0.2.16 及更早版本直接升级的用户使用;它包含旧正文读取、重复打开及迁移失败回滚测试,随上游测试一起运行。
|
|
9
13
|
- 新增的 `FIRST_EVENT_HEDGE` 保留管理页保存的值,同时隔离宿主环境同名变量。默认仍由上游设为 `off`。备用请求需要空闲账号和 Worker;插件默认单 Worker,不自动提高并发。
|
|
10
|
-
- 模型档位优先使用 `/v1/models` 返回的 `thinking_levels`;旧外部服务未返回该字段时沿用四档。Playground
|
|
14
|
+
- 模型档位优先使用 `/v1/models` 返回的 `thinking_levels`;旧外部服务未返回该字段时沿用四档。Playground 通道、托管更新限制及插件默认并发保持原设置。
|
|
11
15
|
- macOS 与 Windows 都可能使用系统代理;显式代理及管理页设置仍优先,宿主的 `HTTP_PROXY` 等变量不传入服务。
|
|
12
16
|
|
|
13
17
|
## 在线更新策略
|
|
14
18
|
|
|
15
19
|
上游从 v0.2.14 起原生支持分发方管理更新。构建时通过 `-ldflags` 写入两个值:
|
|
16
20
|
|
|
17
|
-
- `internal/app.version=v0.2.
|
|
21
|
+
- `internal/app.version=v0.2.18-magpie.1`:明确的版本标识。同基线或更旧的官方版、以及更旧的托管版需要升级;更新的官方版、更新的托管版或不认识的定制版仍提示冲突。
|
|
18
22
|
- `internal/app.managedBy=magpie-gemini-web`:`GET /api/update`(包括 `?refresh=1`)只返回本地版本、`mode: managed` 和 `managed_by`,不访问 GitHub;`POST /api/update` 返回 HTTP 409,不下载、不替换文件、不重启。管理页显示“更新由 magpie-gemini-web 管理”。
|
|
19
23
|
|
|
20
24
|
构建不带 `desktop` 标签,不包含桌面窗口。普通插件升级、账户登录、模型调用及管理功能继续保留,用户自行连接的外部服务不受影响。
|
|
21
25
|
|
|
22
26
|
## 复现构建
|
|
23
27
|
|
|
24
|
-
维护者需要 Git、tar、curl、Node.js 26.8.2、Go 1.27.1,以及 `lib/studio-release.json` 的 `toolchains` 固定的 C 工具链。SQLite 需要
|
|
28
|
+
维护者需要 Git、tar、curl、Node.js 26.8.2、Go 1.27.1,以及 `lib/studio-release.json` 的 `toolchains` 固定的 C/C++ 工具链。SQLite 需要 C、SpiderMonkey 需要 C++,工具链因此也是 pin 的一部分:
|
|
25
29
|
|
|
26
30
|
| 目标 | 工具链 | 跨机器字节一致 |
|
|
27
31
|
|---|---|---|
|
|
28
|
-
| Linux x64/arm64 | Zig `toolchains.zig
|
|
29
|
-
| Windows x64 | llvm-mingw `toolchains.llvmMingw`(Clang + LLD + UCRT),静态链接,PE 时间戳为 0 | 是:同上;构建脚本自动下载并按 SHA-256
|
|
32
|
+
| Linux x64/arm64 | Zig `toolchains.zig`(`zig cc` / `zig c++`),musl 静态链接,不依赖构建机器的 glibc | 是:Zig 是自带 libc 的交叉编译器,任何主机产物相同 |
|
|
33
|
+
| Windows x64 | llvm-mingw `toolchains.llvmMingw`(Clang + LLD + UCRT),静态链接,PE 时间戳为 0 | 是:同上;构建脚本自动下载并按 SHA-256 校验。Windows 的库(含 C++ 运行库)取自 Linux x64 归档,见下 |
|
|
30
34
|
| macOS arm64/x64 | Xcode `toolchains.xcode`,最低目标 macOS 13(与 Go 1.27 一致),只能在 macOS 上构建 | 仅在同一 Xcode 下:Apple 链接器和 SDK 随 Xcode 变化 |
|
|
31
35
|
|
|
32
36
|
- Zig 默认从 PATH 查找,也可用 `STUDIO_ZIG=/absolute/path/to/zig` 指定。
|
|
33
|
-
- llvm-mingw 首次使用时下载到 `studio-build/toolchains/`(macOS、Linux x64、Windows x64 主机各有固定的归档及 SHA-256),之后复用。`STUDIO_MINGW_CC` 可指定其他 `x86_64-w64-mingw32-clang
|
|
37
|
+
- llvm-mingw 首次使用时下载到 `studio-build/toolchains/`(macOS、Linux x64、Windows x64 主机各有固定的归档及 SHA-256),之后复用。`STUDIO_MINGW_CC` 可指定其他 `x86_64-w64-mingw32-clang`(C++ 编译器取同目录的 `clang++`),但 `--verify` / `--write-pins` 要求其版本与 pin 一致。
|
|
38
|
+
- llvm-mingw 的 Linux 与 Windows 归档里 Windows 目标库逐字节相同,macOS 归档里的是另行构建的,其中 C++ 运行库(libc++abi)会把各自的构建路径写进可执行文件。因此在 macOS 上构建 Windows 时,链接 Linux x64 归档(同样固定 SHA-256)里的目标库;各主机都以一个 `-L` 指明目标库目录,因为 cgo 会把每个链接参数写成生成代码的一行,参数个数不同会改变可执行文件里的行号表。
|
|
39
|
+
- SpiderMonkey 静态库同样在首次使用时按目标下载到 `studio-build/toolchains/` 并校验 SHA-256,之后复用。
|
|
40
|
+
- Linux(musl)的 SpiderMonkey 静态库带有 mozglue 的环境变量拦截函数(`libjs_static.a` 的 `Unified_cpp_mozglue_interposers0.o`):除 `getenv` 外,`setenv` 等都经 `dlsym(RTLD_NEXT)` 找 C 库原函数,静态链接的程序找不到,便有意崩溃(`MOZ_CRASH`);Go 的 `os.Setenv` 在 cgo 程序里正走这条路。构建脚本校验下载后用 `zig ar` 删除这个目标文件,改用 musl 自己的函数;找不到该文件时报错,提示核对新版 SpiderMonkey。上游测试只在动态链接的 glibc 库(`linux-amd64-gnu`)上运行,不会遇到这个问题。
|
|
34
41
|
- Xcode 用 `DEVELOPER_DIR=/Applications/Xcode_<版本>.app/Contents/Developer` 选择,不需要改系统默认。不要用 beta 版 Xcode 制作发布包。
|
|
35
42
|
- 所有目标都以 `-trimpath -buildvcs=false -ldflags=-buildid=` 编译:Go 默认的 build ID 会哈希 C 编译器路径等构建输入,导致不同机器上内容相同的程序哈希不同。
|
|
36
43
|
- Windows 使用 Git Bash 执行命令,安装包运行和解压使用 Windows 自带 PowerShell。最终用户不需要构建工具。
|
|
@@ -86,7 +93,20 @@ AI_STUDIO_TEST_ARCHIVE=/absolute/path/to/archive.tar.gz npm test
|
|
|
86
93
|
|
|
87
94
|
真实安装验证检查旧版请求正文迁移及重启后可读、管理页保存的备用请求设置、启动、版本、GET 更新状态、POST 更新返回 409、二进制及 PID 不变、管理页面显示托管提示、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构的原生 runner 上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不能代替 Linux/Windows 真机验证。
|
|
88
95
|
|
|
89
|
-
##
|
|
96
|
+
## 0.2.18 升级验证(2026-10-30)
|
|
97
|
+
|
|
98
|
+
以下为本分支的实际状态,发布前以最新 CI 结果为准。
|
|
99
|
+
|
|
100
|
+
- 首次候选构建(run 41025535280)中,Linux x64/arm64 在上游测试 `TestUserCacheRootWithoutEnvironment` 的 `t.Setenv` 处崩溃(见上文环境变量拦截函数),Windows 的 macOS 本机交叉编译结果与 CI 不同(见上文 C++ 运行库)。两处修复后的候选构建(run 41033635405)五个平台都通过上游测试、真实安装冒烟验证(含 `waa_backend=spidermonkey`、`search_model_aliases=false`)与插件测试。
|
|
101
|
+
- 跨机器一致性:Linux x64/arm64 与 Windows x64 的 CI 候选哈希与 macOS arm64 本机交叉编译完全一致;darwin-arm64 两次 CI 构建得到相同哈希。pin 来自第二次候选构建,随后的严格验证构建(run 41036094948)在五个平台的全新 runner 上重新构建,哈希与 pin 完全一致,冒烟验证与插件测试通过。
|
|
102
|
+
- Linux 二进制为静态链接,不含环境变量拦截函数;Windows 二进制只导入系统 DLL(UCRT、KERNEL32 等),C++ 运行库静态链接,PE 时间戳为 0。
|
|
103
|
+
- 真实账号端到端验证(CI 构建的 darwin-arm64 安装包,一个已登录账号复制到临时数据目录,原服务在验证期间停止、结束后恢复):服务 8 秒就绪,WAA 后端为 spidermonkey,全程未启动 Camoufox;45 个模型中无 `-search` 副本;`gemini-3.8-flash` 文本(4 秒)、流式(3 秒)和图片输入(17 秒,答对左右两半颜色)正常;经插件用 `gemini-3.1-flash-image` 按 16:9 画图得到 1376×768 图片(9 秒);错误密钥在 Gemini 接口上返回 400 `API_KEY_INVALID`,与插件的判断一致。
|
|
104
|
+
- Google 登录(Camoufox 登录窗口)本次未验证:使用的是已登录账号。
|
|
105
|
+
- 此分支未发布后端 release 或 npm 包。
|
|
106
|
+
|
|
107
|
+
## 0.2.17 升级验证(2026-10-29,历史记录)
|
|
108
|
+
|
|
109
|
+
以下为上一版 `0.2.17-magpie.1` 的验证结果,不代表 0.2.18。
|
|
90
110
|
|
|
91
111
|
- 上游前端 lint/build、Go 测试(含迁移补丁测试)通过;插件完整测试 207 项通过,无跳过。
|
|
92
112
|
- Linux 二进制为静态链接,Windows 二进制只导入 UCRT 与 KERNEL32、PE 时间戳为 0;npm 打包检查包含迁移补丁,排除构建产物。
|
|
@@ -95,14 +115,14 @@ AI_STUDIO_TEST_ARCHIVE=/absolute/path/to/archive.tar.gz npm test
|
|
|
95
115
|
- 新版 Google 登录/生成端到端验证尚未完成。
|
|
96
116
|
- 此分支未发布后端 release 或 npm 包。本机日常后端仍使用 `0.2.16-magpie.1`,新版仅在临时数据目录运行。
|
|
97
117
|
|
|
98
|
-
发布前必须审查各平台 CI 与构建记录,再将安装包及许可证上传到本仓库 `studio-v0.2.
|
|
118
|
+
发布前必须审查各平台 CI 与构建记录,再将安装包及许可证上传到本仓库 `studio-v0.2.18-magpie.1` release。该 release 可公开下载后,才能发布引用它的插件版本,否则新安装会遇到 404。构建脚本及验证 CI 不自动发布 release 或 npm 包。
|
|
99
119
|
|
|
100
120
|
本地试用建议使用独立数据目录:
|
|
101
121
|
|
|
102
122
|
```bash
|
|
103
|
-
node scripts/studio.mjs install --data-dir /path/to/studio-
|
|
104
|
-
node scripts/studio.mjs start --data-dir /path/to/studio-
|
|
105
|
-
node scripts/studio.mjs stop --data-dir /path/to/studio-
|
|
123
|
+
node scripts/studio.mjs install --data-dir /path/to/studio-018-trial --archive /absolute/path/to/archive
|
|
124
|
+
node scripts/studio.mjs start --data-dir /path/to/studio-018-trial
|
|
125
|
+
node scripts/studio.mjs stop --data-dir /path/to/studio-018-trial
|
|
106
126
|
```
|
|
107
127
|
|
|
108
128
|
使用现有账号数据测试前,先停止旧服务并备份;不要让两个进程同时写同一个数据目录。
|
package/lib/backend.mjs
CHANGED
|
@@ -426,6 +426,68 @@ function timedBody(body, timer) {
|
|
|
426
426
|
});
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
// keyRefused says whether an error on Gemini's own API is the AI Studio service refusing the key.
|
|
430
|
+
// From 0.2.18 it says so as Gemini does: 400 API_KEY_INVALID for a wrong key, 403 for none. A 403
|
|
431
|
+
// from Google itself ("The caller does not have permission") is about the account, not the key.
|
|
432
|
+
function keyRefused(status, text) {
|
|
433
|
+
let error;
|
|
434
|
+
try {
|
|
435
|
+
const body = JSON.parse(text);
|
|
436
|
+
error = (Array.isArray(body) ? body[0] : body)?.error;
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
if (status === 400) return Array.isArray(error?.details) && error.details.some(detail => detail?.reason === 'API_KEY_INVALID');
|
|
441
|
+
return error?.status === 'PERMISSION_DENIED' && /unregistered callers/.test(error.message);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const GEMINI_CALL = /^\/models\/([^/:]+):(generateContent|streamGenerateContent)$/;
|
|
445
|
+
|
|
446
|
+
// geminiTarget is where a call on Gemini's own API goes at the AI Studio service, with the model
|
|
447
|
+
// it names: the service's /v1beta, beside the /v1 that baseURL always ends in. Magpie draws with
|
|
448
|
+
// an image model there (the model's npm is @ai-sdk/google, see listStudioModels), asking
|
|
449
|
+
// <baseURL>/models/<id>:streamGenerateContent?alt=sse. null for any other request, and for one
|
|
450
|
+
// with any other query.
|
|
451
|
+
function geminiTarget(url, baseURL) {
|
|
452
|
+
const base = new URL(baseURL);
|
|
453
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname + '/')) return null;
|
|
454
|
+
const m = GEMINI_CALL.exec(url.pathname.slice(base.pathname.length));
|
|
455
|
+
if (!m || url.hash || (url.search && url.search !== '?alt=sse')) return null;
|
|
456
|
+
let model;
|
|
457
|
+
try {
|
|
458
|
+
model = decodeURIComponent(m[1]);
|
|
459
|
+
} catch {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
return { url: `${baseURL}beta/models/${m[1]}:${m[2]}${url.search}`, model };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The line Magpie ends a drawing's prompt with when a size is asked for (drawPrompt): a ratio
|
|
466
|
+
// given as one, or the nearest of its usual ones to a WIDTHxHEIGHT size.
|
|
467
|
+
const ASPECT_LINE = /\n\nAspect ratio: (\d+(?:\.\d+)?):(\d+(?:\.\d+)?)\./;
|
|
468
|
+
|
|
469
|
+
// aspectRatio moves the aspect ratio a generateContent request's prompt names to
|
|
470
|
+
// imageConfig.aspectRatio, as one of aspects, the model's: the one named when the model takes it,
|
|
471
|
+
// else the nearest. With the line in its prompt, gemini-3.1-flash-lite-image (seen on
|
|
472
|
+
// AIStudio2API 0.2.17) answered with no image at all, each of three times; with imageConfig it
|
|
473
|
+
// drew. A request that names a ratio in imageConfig already is left as it is.
|
|
474
|
+
function aspectRatio(body, aspects) {
|
|
475
|
+
const config = body.generationConfig && typeof body.generationConfig === 'object' ? body.generationConfig : {};
|
|
476
|
+
if (config.imageConfig?.aspectRatio) return;
|
|
477
|
+
const contents = Array.isArray(body.contents) ? body.contents : [];
|
|
478
|
+
const content = contents.findLast(c => c?.role === 'user' && Array.isArray(c.parts));
|
|
479
|
+
const part = content?.parts.find(p => typeof p?.text === 'string' && ASPECT_LINE.test(p.text));
|
|
480
|
+
if (!part) return;
|
|
481
|
+
const [, w, h] = ASPECT_LINE.exec(part.text);
|
|
482
|
+
const asked = `${w}:${h}`;
|
|
483
|
+
const log = r => Math.log(Number(r.split(':')[0]) / Number(r.split(':')[1]));
|
|
484
|
+
const nearest = aspects.includes(asked)
|
|
485
|
+
? asked
|
|
486
|
+
: aspects.reduce((best, r) => Math.abs(log(r) - log(asked)) < Math.abs(log(best) - log(asked)) ? r : best);
|
|
487
|
+
part.text = part.text.replace(ASPECT_LINE, '');
|
|
488
|
+
body.generationConfig = { ...config, imageConfig: { ...config.imageConfig, aspectRatio: nearest } };
|
|
489
|
+
}
|
|
490
|
+
|
|
429
491
|
export async function forward(input, init, getAuth, opts) {
|
|
430
492
|
const req = new Request(input, init);
|
|
431
493
|
|
|
@@ -454,7 +516,11 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
454
516
|
return makeOpenAIError(400, 'Invalid request URL');
|
|
455
517
|
}
|
|
456
518
|
|
|
457
|
-
|
|
519
|
+
// AI Studio's image models are asked on Gemini's own API (opts.imageModel, see imageModelOf)
|
|
520
|
+
const gemini = opts.imageModel ? geminiTarget(reqUrl, c.baseURL) : null;
|
|
521
|
+
const geminiUrl = gemini?.url;
|
|
522
|
+
|
|
523
|
+
if ((reqUrl.search || reqUrl.hash) && !geminiUrl) {
|
|
458
524
|
return makeOpenAIError(400, 'Query parameters and fragments are not allowed');
|
|
459
525
|
}
|
|
460
526
|
|
|
@@ -462,13 +528,16 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
462
528
|
const modelsUrl = `${c.baseURL}/models`;
|
|
463
529
|
const incomingUrl = `${reqUrl.origin}${reqUrl.pathname}`;
|
|
464
530
|
|
|
465
|
-
if (incomingUrl !== chatUrl && incomingUrl !== modelsUrl) {
|
|
531
|
+
if (incomingUrl !== chatUrl && incomingUrl !== modelsUrl && !geminiUrl) {
|
|
466
532
|
return makeOpenAIError(404, 'Endpoint not found');
|
|
467
533
|
}
|
|
468
534
|
|
|
469
535
|
if (incomingUrl === chatUrl && req.method !== 'POST') {
|
|
470
536
|
return makeOpenAIError(400, 'Method not allowed: /chat/completions requires POST');
|
|
471
537
|
}
|
|
538
|
+
if (geminiUrl && req.method !== 'POST') {
|
|
539
|
+
return makeOpenAIError(400, 'Method not allowed: generateContent requires POST');
|
|
540
|
+
}
|
|
472
541
|
if (incomingUrl === modelsUrl && req.method !== 'GET') {
|
|
473
542
|
return makeOpenAIError(400, 'Method not allowed: /models requires GET');
|
|
474
543
|
}
|
|
@@ -550,6 +619,47 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
550
619
|
body.model = twin;
|
|
551
620
|
rawBody = JSON.stringify(body);
|
|
552
621
|
}
|
|
622
|
+
} else if (geminiUrl) {
|
|
623
|
+
try {
|
|
624
|
+
rawBody = await req.text();
|
|
625
|
+
} catch {
|
|
626
|
+
return makeOpenAIError(400, 'Failed to read request body');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
let body;
|
|
630
|
+
try {
|
|
631
|
+
body = JSON.parse(rawBody);
|
|
632
|
+
} catch {
|
|
633
|
+
return makeOpenAIError(400, 'Invalid JSON body');
|
|
634
|
+
}
|
|
635
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
636
|
+
return makeOpenAIError(400, 'Request body must be a JSON object');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (!opts.allowTools && Array.isArray(body.tools) && body.tools.length > 0) {
|
|
640
|
+
return makeOpenAIError(400, `Tool calls are turned off for magpie-gemini-web. Turn them on with: magpie plugin options magpie-gemini-web '{"allowTools":true}'`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// A text model is asked on chat completions, where allowImages holds. One the service's list
|
|
644
|
+
// can't tell of goes no further: an unreachable service is answered as one, for a managed one
|
|
645
|
+
// to be started again (forwardManagedStudio).
|
|
646
|
+
let aspects;
|
|
647
|
+
try {
|
|
648
|
+
aspects = await opts.imageModel(c, gemini.model);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
if (err?.unreachable) {
|
|
651
|
+
return makeOpenAIError(502, 'Bad gateway: failed to reach backend service', { [UNREACHABLE_HEADER]: 'unreachable' });
|
|
652
|
+
}
|
|
653
|
+
if (err?.signIn === 'expired') {
|
|
654
|
+
return makeOpenAIError(401, 'Unauthorized: AI Studio refused the key');
|
|
655
|
+
}
|
|
656
|
+
return makeOpenAIError(503, `Can't tell whether ${gemini.model} is one of AI Studio's image models: ${err?.message || err}`);
|
|
657
|
+
}
|
|
658
|
+
if (!aspects) {
|
|
659
|
+
return makeOpenAIError(400, `${gemini.model} isn't one of AI Studio's image models: generateContent here is for those alone, and a text model is asked on /chat/completions`);
|
|
660
|
+
}
|
|
661
|
+
aspectRatio(body, aspects);
|
|
662
|
+
rawBody = JSON.stringify(body);
|
|
553
663
|
}
|
|
554
664
|
|
|
555
665
|
// requestTimeoutMs bounds each wait on the backend, for the response headers and then for each
|
|
@@ -561,7 +671,7 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
561
671
|
timer.start();
|
|
562
672
|
try {
|
|
563
673
|
// Bun's fetch drops a connection idle for 300 s; the timer sets the deadline instead.
|
|
564
|
-
upstreamRes = await fetch(incomingUrl, {
|
|
674
|
+
upstreamRes = await fetch(geminiUrl || incomingUrl, {
|
|
565
675
|
method: req.method,
|
|
566
676
|
headers,
|
|
567
677
|
body: rawBody,
|
|
@@ -593,6 +703,13 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
593
703
|
|
|
594
704
|
// Gemini's citation markers come out of a chat reply, streamed or whole (see cite.mjs).
|
|
595
705
|
let respBody = upstreamRes.body && timedBody(upstreamRes.body, timer);
|
|
706
|
+
if (geminiUrl && (upstreamRes.status === 400 || upstreamRes.status === 403) && respBody) {
|
|
707
|
+
const text = await new Response(respBody).text();
|
|
708
|
+
if (keyRefused(upstreamRes.status, text)) {
|
|
709
|
+
return makeOpenAIError(401, 'Unauthorized: AI Studio refused the key');
|
|
710
|
+
}
|
|
711
|
+
respBody = text;
|
|
712
|
+
}
|
|
596
713
|
if (opts.stripCitations && incomingUrl === chatUrl && upstreamRes.ok && respBody) {
|
|
597
714
|
const type = upstreamRes.headers.get('content-type') || '';
|
|
598
715
|
if (type.includes('text/event-stream')) respBody = citeStream(respBody);
|
package/lib/studio-native.mjs
CHANGED
|
@@ -197,7 +197,8 @@ const ENV_KEYS = [
|
|
|
197
197
|
'ROUTING_STRATEGY', 'UPSTREAM_CHANNELS', 'MODEL_MAPPING', 'MODEL_CHANNELS', 'TEMPORARY_CHAT', 'WAA_BACKEND',
|
|
198
198
|
'MEDIA_OUTPUT', 'MEDIA_BASE_URL', 'AUTO_START', 'REQUEST_BODY_LOG', 'LOG_FILE', 'LOG_MAX_TOTAL_MB',
|
|
199
199
|
'ADMIN_AUTH_ENABLED', 'ADMIN_USERNAME', 'ADMIN_PASSWORD', 'BUILD_NATIVE_NONSTREAM',
|
|
200
|
-
'NONSTREAM_KEEPALIVE', 'FAKE_STREAM', 'YOUTUBE_LINKS', '
|
|
200
|
+
'NONSTREAM_KEEPALIVE', 'FAKE_STREAM', 'YOUTUBE_LINKS', 'SEARCH_MODEL_ALIASES', 'GOOGLE_SEARCH',
|
|
201
|
+
'FIRST_EVENT_HEDGE', 'CONTINUATION_REWRITE', 'CONTINUATION_PROMPT'
|
|
201
202
|
];
|
|
202
203
|
|
|
203
204
|
// PROXY_ENV are the proxy variables the service would fall back to with no PROXY of its own.
|
|
@@ -216,7 +217,10 @@ const SETTING_DEFAULTS = {
|
|
|
216
217
|
ROUTING_STRATEGY: 'fill-first',
|
|
217
218
|
REQUEST_TIMEOUT: '30m',
|
|
218
219
|
INIT_TIMEOUT: '2m',
|
|
219
|
-
NONSTREAM_KEEPALIVE: 'false'
|
|
220
|
+
NONSTREAM_KEEPALIVE: 'false',
|
|
221
|
+
// Upstream lists each chat model that can search Google again as <id>-search (0.2.18), which
|
|
222
|
+
// would list most of Magpie's AI Studio models twice.
|
|
223
|
+
SEARCH_MODEL_ALIASES: 'false'
|
|
220
224
|
};
|
|
221
225
|
|
|
222
226
|
// pluginEnv is the settings this plugin depends on to reach, trust and drive the service. The
|
|
@@ -235,7 +239,7 @@ function pluginEnv(dataDir, config) {
|
|
|
235
239
|
ADMIN_USERNAME: config.adminUsername,
|
|
236
240
|
ADMIN_PASSWORD: config.adminPassword,
|
|
237
241
|
AUTO_START: 'true',
|
|
238
|
-
WAA_BACKEND: '
|
|
242
|
+
WAA_BACKEND: 'spidermonkey',
|
|
239
243
|
UPSTREAM_CHANNELS: 'playground',
|
|
240
244
|
BUILD_NATIVE_NONSTREAM: 'false'
|
|
241
245
|
};
|
package/lib/studio-release.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.18-magpie.1",
|
|
3
3
|
"repository": "BUKOWSKIREAL/magpie-gemini-web",
|
|
4
|
-
"tag": "studio-v0.2.
|
|
4
|
+
"tag": "studio-v0.2.18-magpie.1",
|
|
5
5
|
"goVersion": "1.27.1",
|
|
6
6
|
"toolchains": {
|
|
7
7
|
"zig": "0.16.0",
|
|
@@ -26,12 +26,38 @@
|
|
|
26
26
|
"sha256": "fd5462b7f5d93b2ff981fcd3f9c7e427e47f3203c64ea91c27bcc444eebd74cd"
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
},
|
|
30
|
+
"spidermonkey": {
|
|
31
|
+
"release": "v152.0.4-aistudio2api.1",
|
|
32
|
+
"repository": "Mag1cFall/firefox",
|
|
33
|
+
"targets": {
|
|
34
|
+
"darwin-arm64": {
|
|
35
|
+
"asset": "spidermonkey-v152.0.4-aistudio2api.1-darwin-arm64.tar.gz",
|
|
36
|
+
"sha256": "19f690d52ce2cfe7dbaedcc9771976145fff0dbd81f24e89a2dd094a1424b673"
|
|
37
|
+
},
|
|
38
|
+
"darwin-x64": {
|
|
39
|
+
"asset": "spidermonkey-v152.0.4-aistudio2api.1-darwin-amd64.tar.gz",
|
|
40
|
+
"sha256": "8063d69f56711c72b1244c8415b6c5096aa64ae8b022f555722667531acfe659"
|
|
41
|
+
},
|
|
42
|
+
"linux-x64": {
|
|
43
|
+
"asset": "spidermonkey-v152.0.4-aistudio2api.1-linux-amd64.tar.gz",
|
|
44
|
+
"sha256": "d3ecebf1890355426ad261ab8723038849a524b348af53e1bb440ca53cc2cacb"
|
|
45
|
+
},
|
|
46
|
+
"linux-arm64": {
|
|
47
|
+
"asset": "spidermonkey-v152.0.4-aistudio2api.1-linux-arm64.tar.gz",
|
|
48
|
+
"sha256": "b3c9539b8d8378d99f86b267e0e3d007dd01ff80acebf4f52f3e1d6ac04d9d00"
|
|
49
|
+
},
|
|
50
|
+
"win32-x64": {
|
|
51
|
+
"asset": "spidermonkey-v152.0.4-aistudio2api.1-windows-amd64.tar.gz",
|
|
52
|
+
"sha256": "942a3b57b202f899d799cac678b7471d01d0b34714070f0810c18fca37ffd721"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
29
55
|
}
|
|
30
56
|
},
|
|
31
57
|
"upstream": {
|
|
32
|
-
"version": "0.2.
|
|
58
|
+
"version": "0.2.18",
|
|
33
59
|
"repository": "https://github.com/Mag1cFall/AIStudio2API.git",
|
|
34
|
-
"commit": "
|
|
60
|
+
"commit": "9b694a6b7c6439db1767a577994dfcc87a3cf588",
|
|
35
61
|
"camoufox": "152.0.4-beta.29"
|
|
36
62
|
},
|
|
37
63
|
"patches": [
|
|
@@ -39,29 +65,29 @@
|
|
|
39
65
|
],
|
|
40
66
|
"binaries": {
|
|
41
67
|
"darwin-arm64": {
|
|
42
|
-
"asset": "aistudio2api-v0.2.
|
|
43
|
-
"sha256": "
|
|
44
|
-
"binarySha256": "
|
|
68
|
+
"asset": "aistudio2api-v0.2.18-magpie.1-darwin-arm64.tar.gz",
|
|
69
|
+
"sha256": "9f70596a5b95f4bcf5347a52b88ad41e26106cd6e2a79304183a73d58d8a7a29",
|
|
70
|
+
"binarySha256": "d41e9d4c950efb479ad19727bdf496f8de05c01451110fd9eb7425a9054f5c66"
|
|
45
71
|
},
|
|
46
72
|
"darwin-x64": {
|
|
47
|
-
"asset": "aistudio2api-v0.2.
|
|
48
|
-
"sha256": "
|
|
49
|
-
"binarySha256": "
|
|
73
|
+
"asset": "aistudio2api-v0.2.18-magpie.1-darwin-amd64.tar.gz",
|
|
74
|
+
"sha256": "b118733750a3f882fe13754b2079f9cc89b2645d3cd3bce4bab26aaee24b54e5",
|
|
75
|
+
"binarySha256": "6a1017195809a881614032be647faf25754ebfe060e3301424dcdacefba32469"
|
|
50
76
|
},
|
|
51
77
|
"linux-x64": {
|
|
52
|
-
"asset": "aistudio2api-v0.2.
|
|
53
|
-
"sha256": "
|
|
54
|
-
"binarySha256": "
|
|
78
|
+
"asset": "aistudio2api-v0.2.18-magpie.1-linux-amd64.tar.gz",
|
|
79
|
+
"sha256": "e6310fb89c34295d1a4c60e40b30d7a3073e9f5fc16343650f4021f396225922",
|
|
80
|
+
"binarySha256": "1803037342daada925486f5acd40bfc0bc06bda54b0f1f4459da79d1e917d283"
|
|
55
81
|
},
|
|
56
82
|
"linux-arm64": {
|
|
57
|
-
"asset": "aistudio2api-v0.2.
|
|
58
|
-
"sha256": "
|
|
59
|
-
"binarySha256": "
|
|
83
|
+
"asset": "aistudio2api-v0.2.18-magpie.1-linux-arm64.tar.gz",
|
|
84
|
+
"sha256": "965336192889bcd2d9f6db1e5f9c035251ff64db86ff0e590d9ed89662867629",
|
|
85
|
+
"binarySha256": "81553da83a7322d608b2498e6c6a01b08fe963341d8655262ed2dd17ca7a83df"
|
|
60
86
|
},
|
|
61
87
|
"win32-x64": {
|
|
62
|
-
"asset": "aistudio2api-v0.2.
|
|
63
|
-
"sha256": "
|
|
64
|
-
"binarySha256": "
|
|
88
|
+
"asset": "aistudio2api-v0.2.18-magpie.1-windows-amd64.zip",
|
|
89
|
+
"sha256": "537884412868849665b22d5d9066101a94d389ad0f048555da8c8ea55c7240ba",
|
|
90
|
+
"binarySha256": "0024d8709eb97950ddb3ad6016612521a76c7aca553235db2dddb9183188806e"
|
|
65
91
|
}
|
|
66
92
|
}
|
|
67
93
|
}
|
package/lib/studio.mjs
CHANGED
|
@@ -10,6 +10,19 @@ const STUDIO_EXCLUDE = /(?:image|nano-banana|tts|audio|live|transcribe|robotics|
|
|
|
10
10
|
// Catalog capabilities of models made for images, speech or live sessions. Such a model can be
|
|
11
11
|
// marked chat_model and named like a text model too (gemini-omni-*), so the name can't tell.
|
|
12
12
|
const NON_TEXT_CAPS = ['image_route', 'speech_route', 'live_route', 'aspect_ratio', 'output_resolution', 'interactions_api', 'transcription_output'];
|
|
13
|
+
// Of those, the ones an image model (image_route) has. Such a model is listed for Magpie's image
|
|
14
|
+
// generation (one with any other is left out still): on Gemini's own API (@ai-sdk/google), as
|
|
15
|
+
// Magpie draws only with a plugin's Gemini image model there, asking generateContent for images
|
|
16
|
+
// (see forward). Magpie keeps it in its chat model lists too, if it is ticked there.
|
|
17
|
+
const IMAGE_CAPS = new Set(['image_route', 'aspect_ratio', 'output_resolution']);
|
|
18
|
+
// The aspect ratios of an image model whose catalog entry lists none (capability_options
|
|
19
|
+
// .image_aspect_ratios), as the service takes them then (openAIImageAspectRatios).
|
|
20
|
+
const DEFAULT_ASPECTS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'];
|
|
21
|
+
const ASPECT = /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/;
|
|
22
|
+
// The image models each backend's last list named (listStudioModels), with their aspect ratios,
|
|
23
|
+
// for the requests on Gemini's API (imageModelOf); asked again once this old.
|
|
24
|
+
const IMAGE_CATALOG_MS = 10 * 60 * 1000;
|
|
25
|
+
const imageCatalogs = new Map();
|
|
13
26
|
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
14
27
|
|
|
15
28
|
// Magpie waits 10 minutes for a sign-in once authorize returns (signInTimeout), downloads and the
|
|
@@ -57,6 +70,7 @@ const KNOWN_STUDIO_NESTED = new Set([
|
|
|
57
70
|
'baseURL',
|
|
58
71
|
'allowTools',
|
|
59
72
|
'allowImages',
|
|
73
|
+
'imageModels',
|
|
60
74
|
'contextLimit',
|
|
61
75
|
'outputLimit',
|
|
62
76
|
'requestTimeoutMs',
|
|
@@ -78,7 +92,10 @@ export function studioSettings(options = {}) {
|
|
|
78
92
|
allowTools = options.allowTools;
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
// On unless turned off: AI Studio's chat models read a chat's images (seen on AIStudio2API
|
|
96
|
+
// 0.2.17). Gemini Web keeps it off by default (settings), as gemini-3.6-flash there never
|
|
97
|
+
// answered a chat with an image, each of two times.
|
|
98
|
+
let allowImages = true;
|
|
82
99
|
if (options.allowImages !== undefined) {
|
|
83
100
|
if (typeof options.allowImages !== 'boolean') {
|
|
84
101
|
throw new Error('Invalid option allowImages: must be a boolean');
|
|
@@ -102,6 +119,7 @@ export function studioSettings(options = {}) {
|
|
|
102
119
|
modelTimeoutMs = options.modelTimeoutMs;
|
|
103
120
|
}
|
|
104
121
|
|
|
122
|
+
let imageModels = true;
|
|
105
123
|
let baseURL = STUDIO_BASE;
|
|
106
124
|
let proxy;
|
|
107
125
|
let explicitContextLimit = null;
|
|
@@ -140,6 +158,12 @@ export function studioSettings(options = {}) {
|
|
|
140
158
|
}
|
|
141
159
|
allowImages = s.allowImages;
|
|
142
160
|
}
|
|
161
|
+
if (s.imageModels !== undefined) {
|
|
162
|
+
if (typeof s.imageModels !== 'boolean') {
|
|
163
|
+
throw new Error('Invalid option studio.imageModels: must be a boolean');
|
|
164
|
+
}
|
|
165
|
+
imageModels = s.imageModels;
|
|
166
|
+
}
|
|
143
167
|
if (s.requestTimeoutMs !== undefined) {
|
|
144
168
|
if (!Number.isInteger(s.requestTimeoutMs) || s.requestTimeoutMs <= 0) {
|
|
145
169
|
throw new Error('Invalid option studio.requestTimeoutMs: must be a positive integer');
|
|
@@ -173,6 +197,7 @@ export function studioSettings(options = {}) {
|
|
|
173
197
|
baseURL,
|
|
174
198
|
allowTools,
|
|
175
199
|
allowImages,
|
|
200
|
+
imageModels,
|
|
176
201
|
requestTimeoutMs,
|
|
177
202
|
modelTimeoutMs,
|
|
178
203
|
proxy,
|
|
@@ -225,6 +250,7 @@ export async function listStudioModels(c, opts) {
|
|
|
225
250
|
|
|
226
251
|
const result = Object.create(null);
|
|
227
252
|
const seen = new Set();
|
|
253
|
+
const images = new Map();
|
|
228
254
|
// A model no enabled account's subscription covers is listed without channels, and calls to it
|
|
229
255
|
// get a 403. A backend that lists no channels at all is older than that rule: keep its models.
|
|
230
256
|
const listsChannels = body.data.some(item => Array.isArray(item?.channels));
|
|
@@ -240,14 +266,17 @@ export async function listStudioModels(c, opts) {
|
|
|
240
266
|
if (!id.startsWith('gemini-')) continue;
|
|
241
267
|
// Build assistant models inject Google's coding-agent prompt and do not expose
|
|
242
268
|
// ordinary function calling. Keep them out of this general-purpose provider.
|
|
243
|
-
if (
|
|
269
|
+
if (/-build$/i.test(id)) continue;
|
|
244
270
|
|
|
245
271
|
const methods = Array.isArray(item.supported_generation_methods) ? item.supported_generation_methods : [];
|
|
246
272
|
if (!methods.includes('generateContent')) continue;
|
|
247
273
|
|
|
248
274
|
const caps = item.capabilities && typeof item.capabilities === 'object' ? item.capabilities : {};
|
|
249
275
|
if (caps.chat_model !== true || caps.assistant_route === true) continue;
|
|
250
|
-
|
|
276
|
+
// An image model is named for images, so the name filter is for the others only.
|
|
277
|
+
const draws = caps.image_route === true;
|
|
278
|
+
if (draws ? !opts.imageModels : STUDIO_EXCLUDE.test(id)) continue;
|
|
279
|
+
if (NON_TEXT_CAPS.some(cap => caps[cap] === true && !(draws && IMAGE_CAPS.has(cap)))) continue;
|
|
251
280
|
if (listsChannels && !(Array.isArray(item.channels) && item.channels.length > 0)) continue;
|
|
252
281
|
|
|
253
282
|
if (seen.has(id)) continue;
|
|
@@ -271,7 +300,14 @@ export async function listStudioModels(c, opts) {
|
|
|
271
300
|
? item.thinking_levels.filter(level => ['minimal', 'low', 'medium', 'high'].includes(level))
|
|
272
301
|
: ['minimal', 'low', 'medium', 'high'];
|
|
273
302
|
const variants = caps.thinking_level === true ? Object.fromEntries(levels.map(level => [level, {}])) : {};
|
|
274
|
-
|
|
303
|
+
// An image model takes images to edit or draw from, which allowImages (images in a chat on
|
|
304
|
+
// chat completions) doesn't cover.
|
|
305
|
+
const image = draws || Boolean(opts.allowImages && caps.media_resolution === true);
|
|
306
|
+
if (draws) {
|
|
307
|
+
const listed = item.capability_options?.image_aspect_ratios;
|
|
308
|
+
const aspects = Array.isArray(listed) ? listed.filter(r => typeof r === 'string' && ASPECT.test(r)) : [];
|
|
309
|
+
images.set(id, aspects.length > 0 ? aspects : DEFAULT_ASPECTS);
|
|
310
|
+
}
|
|
275
311
|
|
|
276
312
|
result[id] = {
|
|
277
313
|
id,
|
|
@@ -280,7 +316,7 @@ export async function listStudioModels(c, opts) {
|
|
|
280
316
|
api: {
|
|
281
317
|
id,
|
|
282
318
|
url: c.baseURL,
|
|
283
|
-
npm: '@ai-sdk/openai-compatible'
|
|
319
|
+
npm: draws ? '@ai-sdk/google' : '@ai-sdk/openai-compatible'
|
|
284
320
|
},
|
|
285
321
|
limit: {
|
|
286
322
|
context,
|
|
@@ -296,7 +332,8 @@ export async function listStudioModels(c, opts) {
|
|
|
296
332
|
image
|
|
297
333
|
},
|
|
298
334
|
output: {
|
|
299
|
-
text: true
|
|
335
|
+
text: true,
|
|
336
|
+
image: draws
|
|
300
337
|
}
|
|
301
338
|
},
|
|
302
339
|
cost: {
|
|
@@ -318,9 +355,31 @@ export async function listStudioModels(c, opts) {
|
|
|
318
355
|
throw new Error('No usable Gemini models found in AI Studio response');
|
|
319
356
|
}
|
|
320
357
|
|
|
358
|
+
imageCatalogs.set(catalogKey(c), { images, at: Date.now() });
|
|
321
359
|
return result;
|
|
322
360
|
}
|
|
323
361
|
|
|
362
|
+
function catalogKey(c) {
|
|
363
|
+
return `${c.baseURL}\n${c.key}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// imageModelOf is the aspect ratios of the image model id, which a request on Gemini's API names
|
|
367
|
+
// (see forward), as the backend's list says, asking it again once its last is old: null for a
|
|
368
|
+
// model that isn't one. When the list can't be had again, its last still holds; with none, what
|
|
369
|
+
// the model is isn't known, and the list's error is thrown for forward to answer with.
|
|
370
|
+
async function imageModelOf(c, opts, id) {
|
|
371
|
+
const key = catalogKey(c);
|
|
372
|
+
const cached = imageCatalogs.get(key);
|
|
373
|
+
if (!cached || Date.now() - cached.at > IMAGE_CATALOG_MS) {
|
|
374
|
+
try {
|
|
375
|
+
await listStudioModels(c, opts);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (!cached) throw err;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return imageCatalogs.get(key).images.get(id) ?? null;
|
|
381
|
+
}
|
|
382
|
+
|
|
324
383
|
function sameBackend(c, local) {
|
|
325
384
|
return c.key === local.apiKey && c.baseURL === local.baseURL;
|
|
326
385
|
}
|
|
@@ -349,6 +408,13 @@ function staleManagedSignIn(auth, opts) {
|
|
|
349
408
|
|
|
350
409
|
async function forwardManagedStudio(input, init, getAuth, opts) {
|
|
351
410
|
const retryInput = input instanceof Request ? input.clone() : input;
|
|
411
|
+
// Gemini's API, for the image models alone (see forward)
|
|
412
|
+
const forwardOpts = {
|
|
413
|
+
...opts,
|
|
414
|
+
nativeThinking: true,
|
|
415
|
+
stripCitations: false,
|
|
416
|
+
imageModel: opts.imageModels ? (c, id) => imageModelOf(c, opts, id) : undefined
|
|
417
|
+
};
|
|
352
418
|
|
|
353
419
|
const auth = await getAuth();
|
|
354
420
|
if (auth?.metadata?.managed === true && staleManagedSignIn(auth, opts)) {
|
|
@@ -356,7 +422,7 @@ async function forwardManagedStudio(input, init, getAuth, opts) {
|
|
|
356
422
|
}
|
|
357
423
|
|
|
358
424
|
const callerSignal = input instanceof Request ? input.signal : init?.signal;
|
|
359
|
-
const res = await forward(input, init, getAuth,
|
|
425
|
+
const res = await forward(input, init, getAuth, forwardOpts);
|
|
360
426
|
if (res.headers.get(UNREACHABLE_HEADER) !== 'unreachable') return res;
|
|
361
427
|
|
|
362
428
|
if (callerSignal?.aborted) return res;
|
|
@@ -369,7 +435,7 @@ async function forwardManagedStudio(input, init, getAuth, opts) {
|
|
|
369
435
|
}
|
|
370
436
|
|
|
371
437
|
if (callerSignal?.aborted) return res;
|
|
372
|
-
return forward(retryInput, init, getAuth,
|
|
438
|
+
return forward(retryInput, init, getAuth, forwardOpts);
|
|
373
439
|
}
|
|
374
440
|
|
|
375
441
|
// accountSignIn is the Magpie sign-in for one Google account of the local service: the service's
|
|
@@ -670,7 +736,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
670
736
|
proxy = downloadProxy(studioDataDir(), opts.proxy);
|
|
671
737
|
} catch {}
|
|
672
738
|
const via = proxy ? `走代理 ${redactProxy(proxy)}` : '按 Magpie 的代理设置';
|
|
673
|
-
return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约
|
|
739
|
+
return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 40 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
|
|
674
740
|
}
|
|
675
741
|
|
|
676
742
|
// addAccount signs a new Google account in through the service's own login window, setting the
|
package/package.json
CHANGED
|
@@ -39,7 +39,11 @@ try {
|
|
|
39
39
|
assert.equal(migratedBody.response, 'old response');
|
|
40
40
|
assert.equal(migratedBody.request_size, 100);
|
|
41
41
|
assert.equal(migratedBody.response_size, 200);
|
|
42
|
-
|
|
42
|
+
const config = await studioAdmin(local, 'GET', '/api/config');
|
|
43
|
+
assert.equal(config.first_event_hedge, 'auto');
|
|
44
|
+
// Generation's WAA runs in the service itself, on SpiderMonkey, with no copies of the models
|
|
45
|
+
assert.equal(config.waa_backend, 'spidermonkey');
|
|
46
|
+
assert.equal(config.search_model_aliases, false);
|
|
43
47
|
const pidBefore = fs.readFileSync(path.join(dataDir, 'backend.pid'), 'utf8');
|
|
44
48
|
const status = await studioAdmin(local, 'GET', '/api/status');
|
|
45
49
|
assert.equal(status.version, STUDIO_VERSION);
|
|
@@ -77,7 +81,7 @@ try {
|
|
|
77
81
|
await ensureStudio({ dataDir, managementOnly: true });
|
|
78
82
|
assert.deepEqual(await studioAdmin(local, 'GET', '/api/requests/legacy-smoke/body'), migratedBody);
|
|
79
83
|
assert.deepEqual(await stopStudio({ dataDir }), { stopped: true });
|
|
80
|
-
console.log(`PASS ${key}: ${STUDIO_VERSION} (${source}), legacy body migration/restart, saved hedge setting, managed status, update HTTP 409, unchanged executable/PID, managed UI, clean stop`);
|
|
84
|
+
console.log(`PASS ${key}: ${STUDIO_VERSION} (${source}), legacy body migration/restart, saved hedge setting, SpiderMonkey WAA, managed status, update HTTP 409, unchanged executable/PID, managed UI, clean stop`);
|
|
81
85
|
} finally {
|
|
82
86
|
let stopped = !local;
|
|
83
87
|
if (local) stopped = (await stopStudio({ dataDir }).catch(() => ({ stopped: false }))).stopped;
|
|
@@ -38,10 +38,11 @@ const env = { ...process.env, CGO_ENABLED: '1', GOFLAGS: '', GOEXPERIMENT: '',
|
|
|
38
38
|
GOTOOLCHAIN: `go${release.goVersion}`, GOAMD64: 'v1', GOARM64: 'v8.0' };
|
|
39
39
|
for (const key of ['GOOS', 'GOARCH', 'CC', 'CXX', 'CGO_CFLAGS', 'CGO_CPPFLAGS', 'CGO_CXXFLAGS', 'CGO_LDFLAGS', 'MACOSX_DEPLOYMENT_TARGET']) delete env[key];
|
|
40
40
|
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
41
|
-
// SQLite
|
|
42
|
-
// Zig (Linux/musl) and llvm-mingw (Windows/UCRT) are cross compilers whose output does
|
|
43
|
-
// the machine they run on, so any builder reproduces those archives. Apple's linker and
|
|
44
|
-
// on macOS and differ between Xcode releases, so macOS archives match only under the
|
|
41
|
+
// SQLite needs C and SpiderMonkey C++, so the C/C++ toolchain is part of the pin (lib/studio-release.json
|
|
42
|
+
// "toolchains"). Zig (Linux/musl) and llvm-mingw (Windows/UCRT) are cross compilers whose output does
|
|
43
|
+
// not depend on the machine they run on, so any builder reproduces those archives. Apple's linker and
|
|
44
|
+
// SDK are only on macOS and differ between Xcode releases, so macOS archives match only under the
|
|
45
|
+
// pinned Xcode. SpiderMonkey's static libraries are upstream's prebuilt ones, pinned per target.
|
|
45
46
|
const toolchains = release.toolchains;
|
|
46
47
|
const zig = process.env.STUDIO_ZIG || 'zig';
|
|
47
48
|
function pinned(what, actual, expected, hint) {
|
|
@@ -50,39 +51,87 @@ function pinned(what, actual, expected, hint) {
|
|
|
50
51
|
if (strict) throw new Error(message);
|
|
51
52
|
console.warn(`warning: ${message}`);
|
|
52
53
|
}
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const pin = toolchains.llvmMingw;
|
|
58
|
-
const host = process.platform === 'darwin' ? 'darwin' : `${process.platform}-${process.arch}`;
|
|
59
|
-
const archive = pin.hosts[host];
|
|
60
|
-
if (!archive) throw new Error(`No pinned llvm-mingw for ${host}: set STUDIO_MINGW_CC to llvm-mingw ${pin.version}'s x86_64-w64-mingw32-clang`);
|
|
54
|
+
// fetchPinned returns studio-build/toolchains/<name>, where the archive at url is unpacked once after
|
|
55
|
+
// its SHA-256 is checked against the pinned one. top is the folder the archive holds everything in,
|
|
56
|
+
// or null for an archive that unpacks in place; prepare, if given, changes what was unpacked first.
|
|
57
|
+
function fetchPinned(what, url, { asset, sha256 }, name, top, prepare) {
|
|
61
58
|
const cacheDir = path.join(root, 'studio-build/toolchains');
|
|
62
|
-
const name = archive.asset.replace(/\.(tar\.xz|zip)$/, '');
|
|
63
59
|
const home = path.join(cacheDir, name);
|
|
64
|
-
const cc = path.join(home, 'bin', `x86_64-w64-mingw32-clang${process.platform === 'win32' ? '.exe' : ''}`);
|
|
65
60
|
const stamp = path.join(home, '.sha256');
|
|
66
|
-
if (fs.existsSync(
|
|
61
|
+
if (fs.existsSync(stamp) && fs.readFileSync(stamp, 'utf8').trim() === sha256) return home;
|
|
67
62
|
fs.rmSync(home, { recursive: true, force: true });
|
|
68
63
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
69
64
|
const staging = fs.mkdtempSync(path.join(cacheDir, '.fetch-'));
|
|
70
65
|
try {
|
|
71
|
-
const file = path.join(staging,
|
|
72
|
-
|
|
73
|
-
console.log(`Fetching pinned llvm-mingw: ${url}`);
|
|
66
|
+
const file = path.join(staging, asset);
|
|
67
|
+
console.log(`Fetching pinned ${what}: ${url}`);
|
|
74
68
|
execFileSync('curl', ['--fail', '--location', '--silent', '--show-error', '--retry', '3', '--output', file, url], { stdio: 'inherit' });
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
69
|
+
const actual = hash(file);
|
|
70
|
+
if (actual !== sha256) throw new Error(`${asset} has SHA-256 ${actual}, not the pinned ${sha256}`);
|
|
71
|
+
const unpacked = path.join(staging, top ?? 'unpacked');
|
|
72
|
+
if (!top) fs.mkdirSync(unpacked);
|
|
77
73
|
// Windows' own bsdtar reads zip; the GNU tar Git Bash puts ahead of it on PATH does not.
|
|
78
74
|
const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
|
79
|
-
execFileSync(tar, ['-xf', file, '-C', staging], { stdio: 'inherit' });
|
|
80
|
-
|
|
81
|
-
fs.
|
|
75
|
+
execFileSync(tar, ['-xf', file, '-C', top ? staging : unpacked], { stdio: 'inherit' });
|
|
76
|
+
prepare?.(unpacked);
|
|
77
|
+
fs.writeFileSync(path.join(unpacked, '.sha256'), `${sha256}\n`);
|
|
78
|
+
fs.renameSync(unpacked, home);
|
|
82
79
|
} finally {
|
|
83
80
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
84
81
|
}
|
|
85
|
-
return
|
|
82
|
+
return home;
|
|
83
|
+
}
|
|
84
|
+
// mingwHome returns the pinned llvm-mingw for a builder host, fetched into studio-build/toolchains.
|
|
85
|
+
function mingwHome(host) {
|
|
86
|
+
const pin = toolchains.llvmMingw;
|
|
87
|
+
const archive = pin.hosts[host];
|
|
88
|
+
if (!archive) throw new Error(`No pinned llvm-mingw for ${host}: set STUDIO_MINGW_CC to llvm-mingw ${pin.version}'s x86_64-w64-mingw32-clang`);
|
|
89
|
+
const name = archive.asset.replace(/\.(tar\.xz|zip)$/, '');
|
|
90
|
+
return fetchPinned('llvm-mingw', `https://github.com/mstorsjo/llvm-mingw/releases/download/${pin.version}/${archive.asset}`,
|
|
91
|
+
archive, name, name);
|
|
92
|
+
}
|
|
93
|
+
// llvmMingw returns the pinned llvm-mingw's x86_64 C and C++ compilers, unless STUDIO_MINGW_CC names
|
|
94
|
+
// a C compiler, whose C++ one is next to it. libs is where Windows' libraries are linked from first:
|
|
95
|
+
// llvm-mingw's Linux and Windows archives have the same ones, byte for byte, but its macOS archive's
|
|
96
|
+
// are built apart, and the C++ runtime among them puts its own build paths in the executable. So a
|
|
97
|
+
// macOS builder links the Linux archive's, and makes the same executable as the others. Every host
|
|
98
|
+
// names its folder all the same: cgo writes a line for each linker flag into the Go it generates,
|
|
99
|
+
// so one flag more moves the line numbers the executable keeps.
|
|
100
|
+
function llvmMingw() {
|
|
101
|
+
const exe = process.platform === 'win32' ? '.exe' : '';
|
|
102
|
+
const cxxOf = cc => cc.replace(/clang(\.exe)?$/i, 'clang++$1');
|
|
103
|
+
const libsOf = home => path.join(home, 'x86_64-w64-mingw32', 'lib');
|
|
104
|
+
const macOS = process.platform === 'darwin';
|
|
105
|
+
if (process.env.STUDIO_MINGW_CC) {
|
|
106
|
+
const cc = process.env.STUDIO_MINGW_CC;
|
|
107
|
+
return { cc, cxx: cxxOf(cc), libs: libsOf(macOS ? mingwHome('linux-x64') : path.resolve(cc, '..', '..')) };
|
|
108
|
+
}
|
|
109
|
+
const home = mingwHome(macOS ? 'darwin' : `${process.platform}-${process.arch}`);
|
|
110
|
+
const cc = path.join(home, 'bin', `x86_64-w64-mingw32-clang${exe}`);
|
|
111
|
+
return { cc, cxx: cxxOf(cc), libs: libsOf(macOS ? mingwHome('linux-x64') : home) };
|
|
112
|
+
}
|
|
113
|
+
// The Linux (musl) SpiderMonkey libraries keep mozglue's interposers for the environment's functions.
|
|
114
|
+
// But for getenv, each calls the C library's own, found with dlsym(RTLD_NEXT), and a static executable
|
|
115
|
+
// has none to find: setenv and the rest crash on purpose (MOZ_CRASH), as Go's os.Setenv does through
|
|
116
|
+
// cgo. Without them, musl's own are linked; dynamic builds, which upstream tests, find those anyway.
|
|
117
|
+
const ENV_INTERPOSERS = 'Unified_cpp_mozglue_interposers0.o';
|
|
118
|
+
function dropEnvInterposers(dir) {
|
|
119
|
+
const archive = path.join(dir, 'lib', 'libjs_static.a');
|
|
120
|
+
const members = execFileSync(zig, ['ar', 't', archive], { encoding: 'utf8' }).split('\n');
|
|
121
|
+
if (!members.includes(ENV_INTERPOSERS)) {
|
|
122
|
+
throw new Error(`${archive} has no ${ENV_INTERPOSERS}: check how this SpiderMonkey reaches the environment`);
|
|
123
|
+
}
|
|
124
|
+
execFileSync(zig, ['ar', '--format=gnu', 'd', archive, ENV_INTERPOSERS], { stdio: 'inherit' });
|
|
125
|
+
}
|
|
126
|
+
// spidermonkey returns the folder with a target's pinned SpiderMonkey headers (include) and static
|
|
127
|
+
// libraries (lib), the ones upstream builds its own releases with.
|
|
128
|
+
function spidermonkey(key) {
|
|
129
|
+
const pin = toolchains.spidermonkey;
|
|
130
|
+
const archive = pin.targets[key];
|
|
131
|
+
if (!archive) throw new Error(`No pinned SpiderMonkey for ${key}`);
|
|
132
|
+
const linux = platforms[key][0] === 'linux';
|
|
133
|
+
return fetchPinned('SpiderMonkey', `https://github.com/${pin.repository}/releases/download/${pin.release}/${archive.asset}`,
|
|
134
|
+
archive, archive.asset.replace(/\.tar\.gz$/, linux ? '-without-env-interposers' : ''), null, linux ? dropEnvInterposers : null);
|
|
86
135
|
}
|
|
87
136
|
function xcode() {
|
|
88
137
|
try {
|
|
@@ -92,11 +141,17 @@ function xcode() {
|
|
|
92
141
|
return null; // Command-line tools alone have no xcodebuild.
|
|
93
142
|
}
|
|
94
143
|
}
|
|
95
|
-
// cToolchain returns the C settings for a target and what they were made with. Only the
|
|
96
|
-
// being built and pinned must use the pinned toolchain; the host's is also used for upstream
|
|
144
|
+
// cToolchain returns the C and C++ settings for a target and what they were made with. Only the
|
|
145
|
+
// targets being built and pinned must use the pinned toolchain; the host's is also used for upstream
|
|
146
|
+
// tests. SpiderMonkey's headers and libraries are found through CGO_CXXFLAGS and CGO_LDFLAGS, as
|
|
147
|
+
// upstream's own build finds them (.github/actions/spidermonkey); its mozjs package names the libraries.
|
|
97
148
|
function cToolchain(key, { pin = false } = {}) {
|
|
98
149
|
const check = pin ? pinned : () => {};
|
|
99
150
|
const [goos, goarch] = platforms[key];
|
|
151
|
+
const mozjs = spidermonkey(key);
|
|
152
|
+
// Quoted whole, as cgo splits these flags, so a folder with spaces stays one argument.
|
|
153
|
+
const include = `"-I${path.join(mozjs, 'include')}"`;
|
|
154
|
+
const lib = `"-L${path.join(mozjs, 'lib')}"`;
|
|
100
155
|
if (goos === 'darwin') {
|
|
101
156
|
if (process.platform !== 'darwin') throw new Error('Build macOS archives on macOS with Xcode');
|
|
102
157
|
const expected = `${toolchains.xcode.version} (${toolchains.xcode.build})`;
|
|
@@ -105,24 +160,27 @@ function cToolchain(key, { pin = false } = {}) {
|
|
|
105
160
|
`Select it with DEVELOPER_DIR=/Applications/Xcode_${toolchains.xcode.version}.app/Contents/Developer, or let CI make macOS pins (docs/studio-release.md).`);
|
|
106
161
|
const compiler = { xcode: actual, sdk: execFileSync('xcrun', ['--show-sdk-version'], { encoding: 'utf8' }).trim(),
|
|
107
162
|
clang: execFileSync('clang', ['--version'], { encoding: 'utf8' }).split('\n')[0] };
|
|
108
|
-
|
|
109
|
-
|
|
163
|
+
const flags = '-O2 -g -mmacosx-version-min=13.0';
|
|
164
|
+
return { compiler, env: { CC: 'clang', CXX: 'clang++', MACOSX_DEPLOYMENT_TARGET: '13.0',
|
|
165
|
+
CGO_CFLAGS: flags, CGO_CXXFLAGS: `${flags} ${include}`, CGO_LDFLAGS: `${flags} ${lib}` } };
|
|
110
166
|
}
|
|
111
167
|
if (goos === 'windows') {
|
|
112
|
-
const cc = llvmMingw();
|
|
168
|
+
const { cc, cxx, libs } = llvmMingw();
|
|
113
169
|
const clang = execFileSync(cc, ['--version'], { encoding: 'utf8' }).split('\n')[0];
|
|
114
170
|
const target = execFileSync(cc, ['-dumpmachine'], { encoding: 'utf8' }).trim();
|
|
115
171
|
if (target !== 'x86_64-w64-windows-gnu') throw new Error(`Windows needs llvm-mingw's x86_64 clang, ${cc} targets ${target}`);
|
|
116
172
|
check('Windows C compiler', clang, toolchains.llvmMingw.clang,
|
|
117
173
|
`Unset STUDIO_MINGW_CC to use the pinned llvm-mingw ${toolchains.llvmMingw.version}.`);
|
|
118
174
|
return { compiler: { llvmMingw: toolchains.llvmMingw.version, clang },
|
|
119
|
-
env: { CC: `"${cc}"`, CGO_CFLAGS: '-O2 -g',
|
|
175
|
+
env: { CC: `"${cc}"`, CXX: `"${cxx}"`, CGO_CFLAGS: '-O2 -g', CGO_CXXFLAGS: `-O2 -g ${include}`,
|
|
176
|
+
CGO_LDFLAGS: `-O2 -g "-L${libs}" ${lib}` } };
|
|
120
177
|
}
|
|
121
178
|
const version = execFileSync(zig, ['version'], { encoding: 'utf8' }).trim();
|
|
122
179
|
check('Zig', version, toolchains.zig, 'Install it, or point STUDIO_ZIG at it.');
|
|
123
180
|
const triple = `${goarch === 'amd64' ? 'x86_64' : 'aarch64'}-linux-musl`;
|
|
124
181
|
return { compiler: { zig: version, target: triple },
|
|
125
|
-
env: { CC: `"${zig}" cc -target ${triple}`,
|
|
182
|
+
env: { CC: `"${zig}" cc -target ${triple}`, CXX: `"${zig}" c++ -target ${triple}`,
|
|
183
|
+
CGO_CFLAGS: '-O2 -g', CGO_CXXFLAGS: `-O2 -g ${include}`, CGO_LDFLAGS: `-O2 -g ${lib}` } };
|
|
126
184
|
}
|
|
127
185
|
const go = (argv, extra = {}) => execFileSync('go', argv, { cwd: source, env: { ...env, ...extra }, stdio: 'inherit' });
|
|
128
186
|
const npm = argv => execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv,
|