magpie-gemini-web 0.6.0 → 0.7.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 CHANGED
@@ -1,4 +1,6 @@
1
- # magpie-gemini-web
1
+ # <img src="docs/logo.svg" width="28" height="28" align="top" alt="" /> magpie-gemini-web
2
+
3
+ [![npm version](https://img.shields.io/npm/v/magpie-gemini-web?style=flat-square&logo=npm&color=4f46e5)](https://www.npmjs.com/package/magpie-gemini-web) [![npm downloads](https://img.shields.io/npm/dw/magpie-gemini-web?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/magpie-gemini-web) [![Magpie Plugin](https://img.shields.io/badge/Magpie-Plugin-3b82f6?style=flat-square)](https://usemagpie.ai/) [![Node.js >= 22](https://img.shields.io/badge/node-%3E%3D22-8b5cf6?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-6366f1?style=flat-square)](https://github.com/BUKOWSKIREAL/magpie-gemini-web) [![License: MIT](https://img.shields.io/badge/license-MIT-10b981?style=flat-square)](LICENSE)
2
4
 
3
5
  [中文](README.md) | English
4
6
 
@@ -14,10 +16,10 @@ The plugin provides two independent providers that can be used individually or t
14
16
  | :--- | :--- | :--- |
15
17
  | **Best for** | **Coding agents (recommended)** | General chat, web-only models & web quota |
16
18
  | **Tool calling** | **Native Function Calling**, reliable | Prompt-emulated (experimental; Flash often skips tools) |
17
- | **Thinking levels** | `minimal` / `low` / `medium` / `high` (`minimal` is fastest & cheapest) | `low` (base) / `medium` (`-thinking-medium`, `gemini-4-argon` only) / `high` (`-thinking` extended thinking) |
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) |
18
20
  | **Sign-in method** | Sign in directly in an isolated popup browser window | Copy `cookie` from a private/incognito window |
19
21
  | **Usage cards** | Shows models currently on cooldown and recovery times | Syncs **5-hour** & **weekly** usage and reset times from the web |
20
- | **Local backend** | AIStudio2API (`v0.2.16`) | gemini-web2api-go (`4.26.0-magpie.1` optimized build) |
22
+ | **Local backend** | AIStudio2API (`v0.2.17`) | gemini-web2api-go (`4.26.0-magpie.1` optimized build) |
21
23
 
22
24
  ---
23
25
 
@@ -29,7 +31,7 @@ Install **magpie-gemini-web** from the Magpie plugin market, then sign in to eit
29
31
 
30
32
  1. Sign in to **Google AI Studio** in Magpie and choose **「用 Google 账号登录(推荐)」** (Sign in with a Google account).
31
33
  2. Sign in to your Google account in the separate login window that opens (your regular browser is unaffected), then close the window.
32
- > On first run, the local service (~25 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).
34
+ > On first run, the local service (~30 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).
33
35
  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.
34
36
 
35
37
  ### Option 2: Gemini Web (`gemini-web`)
@@ -96,7 +98,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
96
98
  **Using a proxy to reach Google:**
97
99
  - **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.
98
100
  - Alternatively, `studio.proxy` or `AI_STUDIO_PROXY` take precedence over the saved proxy. A change applies after the local service restarts (`npm run studio:stop`; the next request starts it again).
99
- - With none set, the backend goes out directly, or on Windows through the system proxy (Windows proxy settings).
101
+ - With none set, the backend goes out directly, or through the system proxy on macOS and Windows.
100
102
  - **The backend uses neither Magpie's proxies (including the provider proxy set for `ai-studio`) nor `HTTPS_PROXY` and similar variables**: it keeps running after Magpie quits, when a proxy borrowed from Magpie may be gone. Use one of the options above. A backend started by a plugin version from before this fix may still be using a proxy from the environment it was started in (Magpie's, for example); the "代理出口" line marks it and says what a restart changes.
101
103
  - The usage card's "代理出口" (proxy) line, the Google sign-in instructions and failure messages, and `npm run studio:status` show the proxy the backend actually uses, where it comes from, and whether a new setting still needs a restart.
102
104
  - The listen address, API key and admin credentials are managed by the plugin; changing them in the admin page has no effect.
@@ -147,7 +149,7 @@ When a plugin error asks you to run a command, it includes the full ready-to-cop
147
149
  - Concurrency is capped at 1, but Google account rate-limiting or flagging cannot be completely ruled out—use at your own risk.
148
150
 
149
151
  **AI Studio upgrades and model selection:**
150
- - The plugin pins SHA-256-verified `v0.2.16-magpie.1`, based on upstream `v0.2.16`. 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.
152
+ - The plugin pins SHA-256-verified `v0.2.17-magpie.1`, based on upstream `v0.2.17`. 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.
151
153
  - **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.
152
154
  - 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.
153
155
  - 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.
@@ -176,3 +178,17 @@ On macOS, Gemini Web sign-in includes an experimental **macOS Native Task** opti
176
178
  ## License
177
179
 
178
180
  MIT. The upstream `gemini-web2api-go` project is also MIT (see `LICENSE-UPSTREAM`).
181
+
182
+ ---
183
+
184
+ ## Star History
185
+
186
+ <p align="center">
187
+ <a href="https://www.star-history.com/#BUKOWSKIREAL/magpie-gemini-web&amp;Date">
188
+ <picture>
189
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=BUKOWSKIREAL/magpie-gemini-web&amp;type=Date&amp;theme=dark" />
190
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=BUKOWSKIREAL/magpie-gemini-web&amp;type=Date" />
191
+ <img alt="GitHub star history for magpie-gemini-web" src="https://api.star-history.com/svg?repos=BUKOWSKIREAL/magpie-gemini-web&amp;type=Date" width="800" />
192
+ </picture>
193
+ </a>
194
+ </p>
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # magpie-gemini-web
1
+ # <img src="docs/logo.svg" width="28" height="28" align="top" alt="" /> magpie-gemini-web
2
+
3
+ [![npm version](https://img.shields.io/npm/v/magpie-gemini-web?style=flat-square&logo=npm&color=4f46e5)](https://www.npmjs.com/package/magpie-gemini-web) [![npm downloads](https://img.shields.io/npm/dw/magpie-gemini-web?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/magpie-gemini-web) [![Magpie Plugin](https://img.shields.io/badge/Magpie-Plugin-3b82f6?style=flat-square)](https://usemagpie.ai/) [![Node.js >= 22](https://img.shields.io/badge/node-%3E%3D22-8b5cf6?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-6366f1?style=flat-square)](https://github.com/BUKOWSKIREAL/magpie-gemini-web) [![License: MIT](https://img.shields.io/badge/license-MIT-10b981?style=flat-square)](LICENSE)
2
4
 
3
5
  中文 | [English](README.en.md)
4
6
 
@@ -14,10 +16,10 @@
14
16
  | :--- | :--- | :--- |
15
17
  | **适用场景** | **推荐 Coding Agent 使用** | 日常对话、使用网页版专属模型与额度 |
16
18
  | **工具调用** | **原生 Function Calling**,稳定可靠 | 提示词模拟(实验性,Flash 系列常不调工具) |
17
- | **思考档位** | `minimal` / `low` / `medium` / `high` 四档(`minimal` 最快最省) | `low`(普通)/ `medium`(`-thinking-medium`,仅 `gemini-4-argon`)/ `high`(`-thinking` 扩展思考) |
19
+ | **思考档位** | 按模型目录提供 `minimal` / `low` / `medium` / `high` 中支持的档位 | `low`(普通)/ `medium`(`-thinking-medium`,仅 `gemini-4-argon`)/ `high`(`-thinking` 扩展思考) |
18
20
  | **登录方式** | 弹出独立浏览器窗口直接登录 Google | 用无痕窗口登录并复制 `cookie` |
19
21
  | **额度卡片** | 显示冷却中的模型与恢复时间 | 同步网页版 **5 小时** 与 **每周** 用量及重置时间 |
20
- | **本地后端** | AIStudio2API (`v0.2.16`) | gemini-web2api-go (`4.26.0-magpie.1` 优化版) |
22
+ | **本地后端** | AIStudio2API (`v0.2.17`) | gemini-web2api-go (`4.26.0-magpie.1` 优化版) |
21
23
 
22
24
  ---
23
25
 
@@ -29,7 +31,7 @@
29
31
 
30
32
  1. 在 Magpie 中登录 **Google AI Studio**,选择 **「用 Google 账号登录(推荐)」**。
31
33
  2. 在弹出的独立登录窗口中登录 Google 账号(不影响日常浏览器),完成后直接关闭窗口。
32
- > 首次使用要先下载本地服务(约 25 MB)和登录浏览器 Camoufox(约 300 MB),弹窗可能需要等几分钟才出现。需要下载时,Magpie 会在浏览器里打开一个进度页,显示下载进度。Magpie 最多等 10 分钟(下载的时间也算在内),请尽快完成登录;连不上 GitHub 时,先按下面「插件选项与代理配置」设置 `studio.proxy`。
34
+ > 首次使用要先下载本地服务(约 30 MB)和登录浏览器 Camoufox(约 300 MB),弹窗可能需要等几分钟才出现。需要下载时,Magpie 会在浏览器里打开一个进度页,显示下载进度。Magpie 最多等 10 分钟(下载的时间也算在内),请尽快完成登录;连不上 GitHub 时,先按下面「插件选项与代理配置」设置 `studio.proxy`。
33
35
  3. **后续管理**:再次点击「用 Google 账号登录」,可选择**添加新账号**、为本机已有账号建卡,或为过期账号**重新登录**。
34
36
 
35
37
  ### 方式二:Gemini Web (`gemini-web`)
@@ -96,7 +98,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
96
98
  **配置代理访问 Google:**
97
99
  - **AI Studio**:推荐在本地管理页设置。打开额度卡片「管理页」一行里的地址(默认 `http://127.0.0.1:2048`),用户名 `magpie`,密码是数据目录 `config.json` 里的 `adminPassword`;保存代理后在管理页重启服务即可生效,之后插件重启后端也不会覆盖它。
98
100
  - 也可以用上表的 `studio.proxy` 或环境变量 `AI_STUDIO_PROXY`,它们优先于管理页里保存的代理。改了要重启本地服务才生效(`npm run studio:stop`,下一次请求会自动启动)。
99
- - 都没设置时,后端直连;Windows 上会使用系统代理(Windows 的「代理」设置)。
101
+ - 都没设置时,后端直连;macOS 和 Windows 上会使用已配置的系统代理。
100
102
  - **后端不使用 Magpie 的代理(包括给 `ai-studio` 设置的供应商代理),也不读取 `HTTPS_PROXY` 等环境变量**:它在 Magpie 退出后仍会运行,借用 Magpie 的代理到时可能已经失效。请用上面两种方式之一设置。加入这项修复之前的插件版本启动的后端,可能还在用启动时环境变量里的代理(比如 Magpie 的);「代理出口」一行会标出来,并说明重启后会怎样。
101
103
  - 额度卡片的「代理出口」一行会显示后端当前实际使用的代理、它来自哪里,以及新设置是否还要重启才生效;登录 Google 时的说明、登录失败的提示和 `npm run studio:status` 也会显示。
102
104
  - 监听地址、API Key、管理员账号等由插件管理,在管理页修改不会生效。
@@ -147,7 +149,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
147
149
  - 插件将请求并发限制为 1,但仍无法完全排除 Google 账号风控风险,请自行评估使用。
148
150
 
149
151
  **AI Studio 升级与模型范围:**
150
- - 插件固定使用经过 SHA-256 校验的 `v0.2.16-magpie.1`(基于上游 `v0.2.16`),通过升级插件更新后端。同基线或更旧的官方版、以及旧版插件装的托管后端,在下次管理启动时自动升级,保留账号、端口与登录凭据。
152
+ - 插件固定使用经过 SHA-256 校验的 `v0.2.17-magpie.1`(基于上游 `v0.2.17`),通过升级插件更新后端。同基线或更旧的官方版、以及旧版插件装的托管后端,在下次管理启动时自动升级,保留账号、端口与登录凭据。
151
153
  - **托管后端已在 macOS、Linux、Windows 统一禁用在线更新**:管理页提示“更新由 magpie-gemini-web 管理”,更新接口返回 HTTP 409,检查更新也不会访问 GitHub。用户自行连接的外部服务不受影响。托管构建方式和可复现构建流程见 [docs/studio-release.md](docs/studio-release.md)。
152
154
  - Unix 的 `package/` 仍尽力设置为 `0555`,作为额外的文件保护;权限设置失败不阻断服务,在线更新的禁用也不依赖该权限。若要手动删除数据目录来重置,请先停止服务,再运行 `chmod -R u+w "<数据目录>/package"` 恢复可写权限,然后删除;将 `<数据目录>` 替换为实际路径。
153
155
  - 若自行更新到更新版或定制版,插件会提示版本冲突,并提供带正确 `--data-dir` 的 `stop`、`install`、`start` 命令。停止时会找回更新后的监听进程 PID,并核对可执行文件路径;无法核实身份就不会发送终止信号。HTTP 端口关闭但进程尚未退出时,插件不会重装或启动第二个进程。
@@ -180,3 +182,17 @@ macOS 用户登录 Gemini Web 时会额外看到「macOS 原生 Task(实验性
180
182
  ## 许可证
181
183
 
182
184
  MIT。上游 `gemini-web2api-go` 同为 MIT(见 `LICENSE-UPSTREAM`)。
185
+
186
+ ---
187
+
188
+ ## Star 趋势
189
+
190
+ <p align="center">
191
+ <a href="https://www.star-history.com/#BUKOWSKIREAL/magpie-gemini-web&amp;Date">
192
+ <picture>
193
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=BUKOWSKIREAL/magpie-gemini-web&amp;type=Date&amp;theme=dark" />
194
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=BUKOWSKIREAL/magpie-gemini-web&amp;type=Date" />
195
+ <img alt="magpie-gemini-web 的 GitHub Star 增长趋势" src="https://api.star-history.com/svg?repos=BUKOWSKIREAL/magpie-gemini-web&amp;type=Date" width="800" />
196
+ </picture>
197
+ </a>
198
+ </p>
package/docs/logo.svg ADDED
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
2
+ <defs>
3
+ <linearGradient id="a" x1="2" y1="3" x2="20" y2="21" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0%" stop-color="#06B6D4"/>
5
+ <stop offset="50%" stop-color="#3B82F6"/>
6
+ <stop offset="100%" stop-color="#8B5CF6"/>
7
+ </linearGradient>
8
+ <linearGradient id="b" x1="14" y1="2" x2="22" y2="10" gradientUnits="userSpaceOnUse">
9
+ <stop offset="0%" stop-color="#8B5CF6"/>
10
+ <stop offset="100%" stop-color="#EC4899"/>
11
+ </linearGradient>
12
+ </defs>
13
+ <path d="M10 3.2C10.35 3.2 10.62 3.45 10.72 3.8C11.45 6.85 13.15 8.55 16.2 9.28C16.55 9.38 16.8 9.65 16.8 10C16.8 10.35 16.55 10.62 16.2 10.72C13.15 11.45 11.45 13.15 10.72 16.2C10.62 16.55 10.35 16.8 10 16.8C9.65 16.8 9.38 16.55 9.28 16.2C8.55 13.15 6.85 11.45 3.8 10.72C3.45 10.62 3.2 10.35 3.2 10C3.2 9.65 3.45 9.38 3.8 9.28C6.85 8.55 8.55 6.85 9.28 3.8C9.38 3.45 9.65 3.2 10 3.2Z" transform="translate(-0.5 2.5) scale(1.12)" fill="url(#a)"/>
14
+ <path d="M18.2 1.8C18.42 1.8 18.6 1.95 18.66 2.16C19.08 3.82 20.02 4.76 21.68 5.18C21.89 5.24 22.04 5.42 22.04 5.64C22.04 5.86 21.89 6.04 21.68 6.1C20.02 6.52 19.08 7.46 18.66 9.12C18.6 9.33 18.42 9.48 18.2 9.48C17.98 9.48 17.8 9.33 17.74 9.12C17.32 7.46 16.38 6.52 14.72 6.1C14.51 6.04 14.36 5.86 14.36 5.64C14.36 5.42 14.51 5.24 14.72 5.18C16.38 4.76 17.32 3.82 17.74 2.16C17.8 1.95 17.98 1.8 18.2 1.8Z" fill="url(#b)"/>
15
+ <circle cx="17.8" cy="18.2" r="1.35" fill="#6366F1"/>
16
+ </svg>
@@ -1,49 +1,108 @@
1
1
  # AI Studio 托管后端构建
2
2
 
3
- 插件使用 `0.2.16-magpie.1`,基于 AIStudio2API `v0.2.16` 的固定 commit。上游版本、commit、Go 工具链、安装包及二进制 SHA-256 均记录在 `lib/studio-release.json`。`upstream.camoufox` 是这个 commit 要求的 Camoufox 版本(上游 `internal/camoufoxnative/download.go` 里的 `camoufoxRelease`),插件靠它判断登录时要不要重新下载 Camoufox;更新上游 commit 时要一起改,构建脚本发现不一致会报错。MIT 许可见 `LICENSE-AISTUDIO`。
3
+ 插件使用 `0.2.17-magpie.1`,基于 AIStudio2API `v0.2.17` 的固定 commit。上游版本、commit、Go 版本、C 工具链(`toolchains`)、补丁列表、安装包及二进制 SHA-256 均记录在 `lib/studio-release.json`。`upstream.camoufox` 是这个 commit 要求的 Camoufox 版本(上游 `internal/camoufoxnative/download.go` 里的 `camoufoxRelease`),更新上游 commit 时要一起核对,构建脚本发现不一致会报错。MIT 许可见 `LICENSE-AISTUDIO`。
4
+
5
+ ## 升级兼容
6
+
7
+ - 上游 v0.2.17 改用需要 CGO 的原生 SQLite。账号和配置沿用原目录;首次打开旧数据库会整理空闲页。降级前应停止服务并备份完整数据目录,旧版不保证能读取迁移后的数据。
8
+ - 上游会删除旧的请求正文 `bodies` 表。`patches/studio-0.2.17-preserve-request-bodies.patch` 改为在事务内压缩并迁移到 `request_bodies`,已有新版同 ID 记录优先,成功后才删除旧表;失败回滚。补丁包含旧正文读取、重复打开及迁移失败回滚测试,随上游测试一起运行。
9
+ - 新增的 `FIRST_EVENT_HEDGE` 保留管理页保存的值,同时隔离宿主环境同名变量。默认仍由上游设为 `off`。备用请求需要空闲账号和 Worker;插件默认单 Worker,不自动提高并发。
10
+ - 模型档位优先使用 `/v1/models` 返回的 `thinking_levels`;旧外部服务未返回该字段时沿用四档。Playground 通道、纯 Go WAA、托管更新限制及插件默认并发保持原设置。
11
+ - macOS 与 Windows 都可能使用系统代理;显式代理及管理页设置仍优先,宿主的 `HTTP_PROXY` 等变量不传入服务。
4
12
 
5
13
  ## 在线更新策略
6
14
 
7
- 上游从 v0.2.14 起支持由分发方管理更新,不再需要补丁。构建时通过 `-ldflags` 写入两个值:
15
+ 上游从 v0.2.14 起原生支持分发方管理更新。构建时通过 `-ldflags` 写入两个值:
16
+
17
+ - `internal/app.version=v0.2.17-magpie.1`:明确的版本标识。同基线或更旧的官方版、以及更旧的托管版需要升级;更新的官方版、更新的托管版或不认识的定制版仍提示冲突。
18
+ - `internal/app.managedBy=magpie-gemini-web`:`GET /api/update`(包括 `?refresh=1`)只返回本地版本、`mode: managed` 和 `managed_by`,不访问 GitHub;`POST /api/update` 返回 HTTP 409,不下载、不替换文件、不重启。管理页显示“更新由 magpie-gemini-web 管理”。
8
19
 
9
- - `internal/app.version=v0.2.16-magpie.1`:明确的版本标识。插件把同基线或更旧的官方版、以及更旧的托管版(`-magpie.N` 的基线更低,或同基线但 N 更小)视为需要升级;更新的官方版、更新的托管版或不认识的定制版仍提示冲突。
10
- - `internal/app.managedBy=magpie-gemini-web`:更新模式为 `managed`。`GET /api/update`(包括 `?refresh=1`)只返回本地版本、`mode: managed` 和 `managed_by`,不访问 GitHub;`POST /api/update` 返回 HTTP 409,说明更新由 magpie-gemini-web 管理,不下载、不替换文件、不重启。这两个值在链接时写死,没有环境变量或管理页开关,在所有构建平台相同。
11
- - 管理页版本面板显示当前版本和"更新由 magpie-gemini-web 管理",不显示检查更新或安装按钮。上游的在线更新代码仍然编译在内,但托管模式下走不到;`studio:managed:smoke` 用真实二进制验证这一点。
12
- - 构建不带 `desktop` 标签,即上游的命令行版,不包含桌面窗口。
13
- - 普通插件升级、账户登录、模型调用及管理功能继续保留。用户自行连接的外部 AIStudio2API 服务不受影响。
20
+ 构建不带 `desktop` 标签,不包含桌面窗口。普通插件升级、账户登录、模型调用及管理功能继续保留,用户自行连接的外部服务不受影响。
14
21
 
15
22
  ## 复现构建
16
23
 
17
- 维护者需要 Git、tar、Node.js 26.8.2(构建页面用)、Go 1.27.1。Windows 使用 Git Bash 执行以下命令,安装包运行和解压使用 Windows 自带 PowerShell。最终用户不需要这些构建工具。
24
+ 维护者需要 Git、tar、curl、Node.js 26.8.2、Go 1.27.1,以及 `lib/studio-release.json` 的 `toolchains` 固定的 C 工具链。SQLite 需要 CGO,C 工具链因此也是 pin 的一部分:
25
+
26
+ | 目标 | 工具链 | 跨机器字节一致 |
27
+ |---|---|---|
28
+ | Linux x64/arm64 | Zig `toolchains.zig`,musl 静态链接,不依赖构建机器的 glibc | 是:Zig 是自带 libc 的交叉编译器,任何主机产物相同 |
29
+ | Windows x64 | llvm-mingw `toolchains.llvmMingw`(Clang + LLD + UCRT),静态链接,PE 时间戳为 0 | 是:同上;构建脚本自动下载并按 SHA-256 校验 |
30
+ | macOS arm64/x64 | Xcode `toolchains.xcode`,最低目标 macOS 13(与 Go 1.27 一致),只能在 macOS 上构建 | 仅在同一 Xcode 下:Apple 链接器和 SDK 随 Xcode 变化 |
31
+
32
+ - 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`,但 `--verify` / `--write-pins` 要求其版本与 pin 一致。
34
+ - Xcode 用 `DEVELOPER_DIR=/Applications/Xcode_<版本>.app/Contents/Developer` 选择,不需要改系统默认。不要用 beta 版 Xcode 制作发布包。
35
+ - 所有目标都以 `-trimpath -buildvcs=false -ldflags=-buildid=` 编译:Go 默认的 build ID 会哈希 C 编译器路径等构建输入,导致不同机器上内容相同的程序哈希不同。
36
+ - Windows 使用 Git Bash 执行命令,安装包运行和解压使用 Windows 自带 PowerShell。最终用户不需要构建工具。
37
+
38
+ `--verify` 和 `--write-pins` 会在开始构建前检查每个目标的工具链是否与 pin 一致,不一致时几秒内报错并说明需要的版本;不带这两个参数的普通构建只给出警告。
18
39
 
19
40
  ```bash
20
- # 按固定源代码构建所有五个平台,并核对现有 pin。
41
+ # 构建所有五个平台并核对现有 pin(macOS 目标需要固定的 Xcode)。
21
42
  npm run studio:release:build
22
43
 
23
- # 仅构建当前平台。
44
+ # 只构建当前平台,或用逗号指定多个目标(Linux/Windows 目标不要求固定的 Xcode)。
24
45
  node scripts/studio-release.mjs --target=host --verify
46
+ node scripts/studio-release.mjs --target=linux-x64,linux-arm64,win32-x64 --verify
25
47
 
26
- # 制作新版本:审查上游源码后重新生成所有平台 pin。
27
- node scripts/studio-release.mjs --write-pins
28
-
29
- # 可复用本地上游 git 仓库,但始终导出固定 commit,不使用工作区修改。
48
+ # 可复用本地仓库;始终导出固定 commit,再应用清单中的补丁,不使用工作区修改。
30
49
  node scripts/studio-release.mjs --source=/path/to/AIStudio2API --verify
31
50
  ```
32
51
 
33
- 输出在 `studio-build/release/`,不进入 npm 包。包含 macOS x64/arm64、Linux x64/arm64、Windows x64 安装包,以及构建记录、SHA256SUMS 和上游许可证。每个安装包携带许可证和托管升级说明。Go 的标准 tar/zip/gzip 写入器固定文件顺序、权限与时间;编译使用 `-trimpath -buildvcs=false`。上游前端构建用 Node 的 zlib 预压缩网页资源(`.gz`),其输出随 CPU 和系统不同,而这些资源嵌入二进制;构建脚本在前端构建后用 `scripts/studio-regzip.go` 以 Go 的 gzip 重新压缩,使各平台构建结果一致。
52
+ 输出在 `studio-build/release/`,不进入 npm 包。包含安装包、构建记录、SHA256SUMS 和上游许可证。构建记录包含固定工具链、实际编译器版本及补丁 SHA-256;`--verify` 失败时也会写入,便于对比差异。安装包固定文件顺序、权限与时间。前端 `.gz` 资源通过 `scripts/studio-regzip.go` 重新压缩,消除 Node zlib 跨平台差异。
53
+
54
+ ## 生成 pin
55
+
56
+ 正式 pin 由 CI 生成,因为 macOS 产物只能在固定的 Xcode 下复现,而 GitHub runner 上有固定版本的 Xcode。修改上游 commit、补丁或工具链后:
57
+
58
+ 1. 审查源码和补丁,更新 `lib/studio-release.json` 的版本、上游及 `toolchains`,提交并推送。
59
+ 2. 手动运行 **Verify managed AI Studio release** 并勾选 `write_pins`(候选模式):
60
+
61
+ ```bash
62
+ gh workflow run studio.yml --ref <分支> -f write_pins=true
63
+ ```
64
+
65
+ 每个 runner 构建、测试、记录自己平台的哈希;`Collect candidate pins` 作业用 `scripts/studio-pins.mjs` 校验所有构建记录来自同一源码、补丁和工具链,再把五个平台合并成 `candidate-pins` 产物,并在作业摘要中给出 diff。
66
+ 3. 下载产物覆盖清单后提交(`gh run download` 不覆盖已有文件,所以先下载到临时目录):
67
+
68
+ ```bash
69
+ dir="$(mktemp -d)" && gh run download <run-id> --name candidate-pins --dir "$dir" \
70
+ && cp "$dir/studio-release.json" lib/studio-release.json
71
+ ```
72
+
73
+ 4. 推送后,PR 上的验证会在全新 runner 上重新构建并严格比对。这一步证明 pin 可以复现,而不只是记录了一次构建结果。
74
+
75
+ Linux 和 Windows 的 pin 也可以在本机生成(`--target=linux-x64,linux-arm64,win32-x64 --write-pins`),结果应与 CI 相同。不同来源的 pin 不一致说明工具链没有真正固定,应查明原因,不要直接覆盖。候选模式的产物以 `candidate-` 开头,`publish-studio.yml` 只接受 `studio-` 开头的验证产物,因此候选构建不会被上传到 release。
34
76
 
35
77
  ## 验证与发布
36
78
 
37
79
  ```bash
38
- # 在独立临时数据目录中安装当前平台真实安装包,无需 Google 账号。
80
+ # 在独立临时数据目录安装当前平台真实安装包,无需 Google 账号。
39
81
  npm run studio:managed:smoke
40
82
 
41
- # 可选:让插件离线恢复测试使用刚构建的安装包。
83
+ # 让插件离线恢复测试使用刚构建的安装包。
42
84
  AI_STUDIO_TEST_ARCHIVE=/absolute/path/to/archive.tar.gz npm test
43
85
  ```
44
86
 
45
- 真实安装验证检查启动、版本、GET 更新状态、POST 更新返回 409、二进制及 PID 不变、管理页面显示托管提示、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不代替 Windows 真机验证。
87
+ 真实安装验证检查旧版请求正文迁移及重启后可读、管理页保存的备用请求设置、启动、版本、GET 更新状态、POST 更新返回 409、二进制及 PID 不变、管理页面显示托管提示、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构的原生 runner 上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不能代替 Linux/Windows 真机验证。
88
+
89
+ ## 本次升级验证(2026-10-29)
46
90
 
47
- 发布时先审查构建记录和各平台 CI 结果,再将安装包及许可证上传到本仓库 `studio-v0.2.16-magpie.1` release。该 release 可公开下载后,才能发布引用它的插件版本,否则新安装会遇到 404。构建脚本及 CI 不自动发布 release,也不发布 npm 包。
91
+ - 上游前端 lint/build、Go 测试(含迁移补丁测试)通过;插件完整测试 207 项通过,无跳过。
92
+ - Linux 二进制为静态链接,Windows 二进制只导入 UCRT 与 KERNEL32、PE 时间戳为 0;npm 打包检查包含迁移补丁,排除构建产物。
93
+ - 跨机器一致性:Linux 与 Windows 的 pin 在 macOS arm64 本机交叉编译生成,CI 的 Linux x64/arm64 和 Windows x64 runner 原生重建后哈希完全一致;CI 候选构建得到的这三个平台哈希也与本机相同。macOS 的 pin 由 CI 在 Xcode 26.3 下生成,另一次独立的 CI 构建得到相同的 darwin-arm64 哈希。
94
+ - 五个平台都在 CI 原生 runner 上通过真实安装冒烟验证(含旧请求正文迁移及重启)和插件测试。
95
+ - 新版 Google 登录/生成端到端验证尚未完成。
96
+ - 此分支未发布后端 release 或 npm 包。本机日常后端仍使用 `0.2.16-magpie.1`,新版仅在临时数据目录运行。
97
+
98
+ 发布前必须审查各平台 CI 与构建记录,再将安装包及许可证上传到本仓库 `studio-v0.2.17-magpie.1` release。该 release 可公开下载后,才能发布引用它的插件版本,否则新安装会遇到 404。构建脚本及验证 CI 不自动发布 release 或 npm 包。
99
+
100
+ 本地试用建议使用独立数据目录:
101
+
102
+ ```bash
103
+ node scripts/studio.mjs install --data-dir /path/to/studio-017-trial --archive /absolute/path/to/archive
104
+ node scripts/studio.mjs start --data-dir /path/to/studio-017-trial
105
+ node scripts/studio.mjs stop --data-dir /path/to/studio-017-trial
106
+ ```
48
107
 
49
- 本地试用无需发布:先停止当前托管后端,再执行 `node scripts/studio.mjs install --archive /absolute/path/to/archive` 和 `node scripts/studio.mjs start`。可用 `--data-dir` 指定目录。修改前备份账号和配置;该版本与上游 v0.2.16 使用相同的数据格式。
108
+ 使用现有账号数据测试前,先停止旧服务并备份;不要让两个进程同时写同一个数据目录。
package/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHABLE_HEADER } from './lib/backend.mjs';
1
+ import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHABLE_HEADER, PLUGIN_ICON } from './lib/backend.mjs';
2
2
  import { ensureBackend, localBackend } from './lib/native.mjs';
3
3
  import { parseCookieInput, importCookie, ensureProxy, validateProxyURL, requireLoginBackend } from './lib/setup.mjs';
4
4
  import { usage, poolChoices, poolAccounts, accountEmail } from './lib/usage.mjs';
@@ -101,6 +101,7 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
101
101
  },
102
102
  auth: {
103
103
  provider: ID,
104
+ icon: PLUGIN_ICON,
104
105
  maxConcurrency: 1,
105
106
  methods: [{
106
107
  type: 'oauth',
package/lib/backend.mjs CHANGED
@@ -2,6 +2,7 @@ import { citeStream, citeJSON } from './cite.mjs';
2
2
  import { validateProxyURL } from './setup.mjs';
3
3
 
4
4
  export const DEFAULT_BASE = 'http://127.0.0.1:8083/v1';
5
+ export const PLUGIN_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyIiB5MT0iMyIgeDI9IjIwIiB5Mj0iMjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjMDZCNkQ0Ii8+PHN0b3Agb2Zmc2V0PSI1MCUiIHN0b3AtY29sb3I9IiMzQjgyRjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM4QjVDRjYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjE0IiB5MT0iMiIgeDI9IjIyIiB5Mj0iMTAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjOEI1Q0Y2Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRUM0ODk5Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEwIDMuMkMxMC4zNSAzLjIgMTAuNjIgMy40NSAxMC43MiAzLjhDMTEuNDUgNi44NSAxMy4xNSA4LjU1IDE2LjIgOS4yOEMxNi41NSA5LjM4IDE2LjggOS42NSAxNi44IDEwQzE2LjggMTAuMzUgMTYuNTUgMTAuNjIgMTYuMiAxMC43MkMxMy4xNSAxMS40NSAxMS40NSAxMy4xNSAxMC43MiAxNi4yQzEwLjYyIDE2LjU1IDEwLjM1IDE2LjggMTAgMTYuOEM5LjY1IDE2LjggOS4zOCAxNi41NSA5LjI4IDE2LjJDOC41NSAxMy4xNSA2Ljg1IDExLjQ1IDMuOCAxMC43MkMzLjQ1IDEwLjYyIDMuMiAxMC4zNSAzLjIgMTBDMy4yIDkuNjUgMy40NSA5LjM4IDMuOCA5LjI4QzYuODUgOC41NSA4LjU1IDYuODUgOS4yOCAzLjhDOS4zOCAzLjQ1IDkuNjUgMy4yIDEwIDMuMloiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0wLjUgMi41KSBzY2FsZSgxLjEyKSIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik0xOC4yIDEuOEMxOC40MiAxLjggMTguNiAxLjk1IDE4LjY2IDIuMTZDMTkuMDggMy44MiAyMC4wMiA0Ljc2IDIxLjY4IDUuMThDMjEuODkgNS4yNCAyMi4wNCA1LjQyIDIyLjA0IDUuNjRDMjIuMDQgNS44NiAyMS44OSA2LjA0IDIxLjY4IDYuMUMyMC4wMiA2LjUyIDE5LjA4IDcuNDYgMTguNjYgOS4xMkMxOC42IDkuMzMgMTguNDIgOS40OCAxOC4yIDkuNDhDMTcuOTggOS40OCAxNy44IDkuMzMgMTcuNzQgOS4xMkMxNy4zMiA3LjQ2IDE2LjM4IDYuNTIgMTQuNzIgNi4xQzE0LjUxIDYuMDQgMTQuMzYgNS44NiAxNC4zNiA1LjY0QzE0LjM2IDUuNDIgMTQuNTEgNS4yNCAxNC43MiA1LjE4QzE2LjM4IDQuNzYgMTcuMzIgMy44MiAxNy43NCAyLjE2QzE3LjggMS45NSAxNy45OCAxLjggMTguMiAxLjhaIiBmaWxsPSJ1cmwoI2IpIi8+PGNpcmNsZSBjeD0iMTcuOCIgY3k9IjE4LjIiIHI9IjEuMzUiIGZpbGw9IiM2MzY2RjEiLz48L3N2Zz4=';
5
6
 
6
7
  const MEDIA_SPECIAL = /(?:^|[-_])(image|music|canvas|video|research)(?:[-_]|$)/i;
7
8
  const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
@@ -197,7 +197,7 @@ 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', 'FIRST_EVENT_HEDGE'
201
201
  ];
202
202
 
203
203
  // PROXY_ENV are the proxy variables the service would fall back to with no PROXY of its own.
@@ -1,39 +1,67 @@
1
1
  {
2
- "version": "0.2.16-magpie.1",
2
+ "version": "0.2.17-magpie.1",
3
3
  "repository": "BUKOWSKIREAL/magpie-gemini-web",
4
- "tag": "studio-v0.2.16-magpie.1",
4
+ "tag": "studio-v0.2.17-magpie.1",
5
5
  "goVersion": "1.27.1",
6
+ "toolchains": {
7
+ "zig": "0.16.0",
8
+ "xcode": {
9
+ "version": "26.3",
10
+ "build": "17C529"
11
+ },
12
+ "llvmMingw": {
13
+ "version": "20261020",
14
+ "clang": "clang version 23.1.4 (https://github.com/llvm/llvm-project.git 7a941dfb2877a0b8cf30323fefa85ad019e0b0f5)",
15
+ "hosts": {
16
+ "darwin": {
17
+ "asset": "llvm-mingw-20261020-ucrt-macos-universal.tar.xz",
18
+ "sha256": "e9c13e4ede38e715003ffc674d4d00a1088df8235439d1f0c16188ff0704c17e"
19
+ },
20
+ "linux-x64": {
21
+ "asset": "llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz",
22
+ "sha256": "55064b60b991a1e1e8090b8c2f84e82b22cfe5903d12c159d65064cf5e8ae335"
23
+ },
24
+ "win32-x64": {
25
+ "asset": "llvm-mingw-20261020-ucrt-x86_64.zip",
26
+ "sha256": "fd5462b7f5d93b2ff981fcd3f9c7e427e47f3203c64ea91c27bcc444eebd74cd"
27
+ }
28
+ }
29
+ }
30
+ },
6
31
  "upstream": {
7
- "version": "0.2.16",
32
+ "version": "0.2.17",
8
33
  "repository": "https://github.com/Mag1cFall/AIStudio2API.git",
9
- "commit": "fd6aab611b52fbfe179fbfc1bf830c4343066e32",
34
+ "commit": "bd6c40f5f8a3a680a19a1ebff67216298d8bae27",
10
35
  "camoufox": "152.0.4-beta.29"
11
36
  },
37
+ "patches": [
38
+ "studio-0.2.17-preserve-request-bodies.patch"
39
+ ],
12
40
  "binaries": {
13
41
  "darwin-arm64": {
14
- "asset": "aistudio2api-v0.2.16-magpie.1-darwin-arm64.tar.gz",
15
- "sha256": "3dd83c7c7fef6bbf96dc7d8eb6dd7257955af6c274c0fc67ad2ecb24a7df9992",
16
- "binarySha256": "6f07122038587514bebe9f15ba0b00f7d9916d4db130028669380729b2e97c97"
42
+ "asset": "aistudio2api-v0.2.17-magpie.1-darwin-arm64.tar.gz",
43
+ "sha256": "10a6f71fcd8313b4d3afc72797027454ff45ae545dfd2bbd96cc0b4b97004517",
44
+ "binarySha256": "5978c56cb705eb1b449e59b772655aac2b3aa933da82e71ce28e7d6f7029e8ef"
17
45
  },
18
46
  "darwin-x64": {
19
- "asset": "aistudio2api-v0.2.16-magpie.1-darwin-amd64.tar.gz",
20
- "sha256": "4152cbb37d83680c903d1bb3e72e7eaebd80e5e0df617c20e3ba7fbffd160868",
21
- "binarySha256": "a0ad46091dd4ec20f0d82ffa3f14673ac3b5c70dc4a8a0e4cc8aac658525d050"
47
+ "asset": "aistudio2api-v0.2.17-magpie.1-darwin-amd64.tar.gz",
48
+ "sha256": "44527571d9294db1451ebb215552a76f3836be9e3a1a5e5fd79bfc57ad6b9878",
49
+ "binarySha256": "f3b3d48094d1a7849c455b31487db5e4569de00124d092d1c639d63fb0162178"
22
50
  },
23
51
  "linux-x64": {
24
- "asset": "aistudio2api-v0.2.16-magpie.1-linux-amd64.tar.gz",
25
- "sha256": "7f97398ea29c2413d3c3e12aca9525213bc80b97c6667a68429ccd3e8dc27ba1",
26
- "binarySha256": "deef4cc68cafff147ad24fcaed2d42389d2dbd54d081696c3d6ea95b7d0ea8fa"
52
+ "asset": "aistudio2api-v0.2.17-magpie.1-linux-amd64.tar.gz",
53
+ "sha256": "47bc3922c54031435fc35914cf382d20a892ca0a2f4432eb0904677a57b002c0",
54
+ "binarySha256": "385b21e2d5905acb062a355ebf15c2db17492212a2ab93ef14da2f6acece96a1"
27
55
  },
28
56
  "linux-arm64": {
29
- "asset": "aistudio2api-v0.2.16-magpie.1-linux-arm64.tar.gz",
30
- "sha256": "2fccc892f00e17b8171ab2ed5d22b4a413b6e7d8230bb911bc5e846c5dc8f717",
31
- "binarySha256": "567c61bec4f7cf199eabd05ecb3420f409039064fe02a2e38ed22e8b35db3da9"
57
+ "asset": "aistudio2api-v0.2.17-magpie.1-linux-arm64.tar.gz",
58
+ "sha256": "689e7644c766a9da8d8a462752e2495bbc659484e65d280fcf7e6554c6ec17b7",
59
+ "binarySha256": "20971e5abe5eb27e7e91f1cc2b665c41cd07ad9e1e9a2aa2b8aa7f87a8f1ebe0"
32
60
  },
33
61
  "win32-x64": {
34
- "asset": "aistudio2api-v0.2.16-magpie.1-windows-amd64.zip",
35
- "sha256": "e7d115aacd957ffda80cf2ecadc38af8404d780bc45af01b6b945accc50d8bc7",
36
- "binarySha256": "bf96178445dbb653b6876b3abe1768b880465974531d6fb12892857fbc666818"
62
+ "asset": "aistudio2api-v0.2.17-magpie.1-windows-amd64.zip",
63
+ "sha256": "79f8372f81cbe93ad4e663e7a400989e9d55d57f25dd40ca47decfb838071980",
64
+ "binarySha256": "e05b28fee8a5130074e3e0c23514e3b6451ba1c938458ae63576ffc6d81746f5"
37
65
  }
38
66
  }
39
67
  }
package/lib/studio.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER } from './backend.mjs';
1
+ import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER, PLUGIN_ICON } from './backend.mjs';
2
2
  import path from 'node:path';
3
3
  import { localStudio, ensureStudio, studioAdmin, studioAccounts, studioProxy, studioCommand, validateProxy, isConnectRefused, PROXY_SOURCES, downloadProxy, redactProxy, studioDataDir, checkStudioHealth, setupStep, camoufoxState } from './studio-native.mjs';
4
4
  import { openProgressPage } from './studio-page.mjs';
@@ -266,8 +266,11 @@ export async function listStudioModels(c, opts) {
266
266
 
267
267
  const toolcall = Boolean(opts.allowTools && caps.function_declarations === true);
268
268
  const reasoning = Boolean(caps.thinking === true || caps.thinking_level === true);
269
- // Of the levels the backend takes, none is the lowest one again and xhigh/max are high.
270
- const variants = caps.thinking_level === true ? { minimal: {}, low: {}, medium: {}, high: {} } : {};
269
+ // Newer catalogs expose each model's supported levels; older external services do not.
270
+ const levels = Array.isArray(item.thinking_levels)
271
+ ? item.thinking_levels.filter(level => ['minimal', 'low', 'medium', 'high'].includes(level))
272
+ : ['minimal', 'low', 'medium', 'high'];
273
+ const variants = caps.thinking_level === true ? Object.fromEntries(levels.map(level => [level, {}])) : {};
271
274
  const image = Boolean(opts.allowImages && caps.media_resolution === true);
272
275
 
273
276
  result[id] = {
@@ -667,7 +670,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
667
670
  proxy = downloadProxy(studioDataDir(), opts.proxy);
668
671
  } catch {}
669
672
  const via = proxy ? `走代理 ${redactProxy(proxy)}` : '按 Magpie 的代理设置';
670
- return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 25 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
673
+ return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 30 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
671
674
  }
672
675
 
673
676
  // addAccount signs a new Google account in through the service's own login window, setting the
@@ -743,6 +746,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
743
746
  },
744
747
  auth: {
745
748
  provider: STUDIO_ID,
749
+ icon: PLUGIN_ICON,
746
750
  maxConcurrency: 1,
747
751
  methods: [
748
752
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Gemini Web and Google AI Studio provider for Magpie, backed by gemini-web2api-go and AIStudio2API",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -26,6 +26,7 @@
26
26
  "scripts/studio-smoke.mjs",
27
27
  "scripts/studio.mjs",
28
28
  "scripts/studio-release.mjs",
29
+ "scripts/studio-pins.mjs",
29
30
  "scripts/studio-archive.go",
30
31
  "scripts/studio-regzip.go",
31
32
  "scripts/studio-managed-smoke.mjs",
@@ -39,6 +40,7 @@
39
40
  "native-task/LICENSE",
40
41
  "README.md",
41
42
  "README.en.md",
43
+ "docs/logo.svg",
42
44
  "docs/backend-release.md",
43
45
  "docs/studio-release.md",
44
46
  "LICENSE",
@@ -51,7 +53,9 @@
51
53
  "opencode",
52
54
  "provider",
53
55
  "gemini",
54
- "gemini-web"
56
+ "gemini-web",
57
+ "ai-studio",
58
+ "google-ai-studio"
55
59
  ],
56
60
  "engines": {
57
61
  "node": ">=22"
@@ -78,6 +82,7 @@
78
82
  "studio:smoke": "node scripts/studio-smoke.mjs"
79
83
  },
80
84
  "magpie": {
85
+ "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyIiB5MT0iMyIgeDI9IjIwIiB5Mj0iMjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjMDZCNkQ0Ii8+PHN0b3Agb2Zmc2V0PSI1MCUiIHN0b3AtY29sb3I9IiMzQjgyRjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM4QjVDRjYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjE0IiB5MT0iMiIgeDI9IjIyIiB5Mj0iMTAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjOEI1Q0Y2Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRUM0ODk5Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEwIDMuMkMxMC4zNSAzLjIgMTAuNjIgMy40NSAxMC43MiAzLjhDMTEuNDUgNi44NSAxMy4xNSA4LjU1IDE2LjIgOS4yOEMxNi41NSA5LjM4IDE2LjggOS42NSAxNi44IDEwQzE2LjggMTAuMzUgMTYuNTUgMTAuNjIgMTYuMiAxMC43MkMxMy4xNSAxMS40NSAxMS40NSAxMy4xNSAxMC43MiAxNi4yQzEwLjYyIDE2LjU1IDEwLjM1IDE2LjggMTAgMTYuOEM5LjY1IDE2LjggOS4zOCAxNi41NSA5LjI4IDE2LjJDOC41NSAxMy4xNSA2Ljg1IDExLjQ1IDMuOCAxMC43MkMzLjQ1IDEwLjYyIDMuMiAxMC4zNSAzLjIgMTBDMy4yIDkuNjUgMy40NSA5LjM4IDMuOCA5LjI4QzYuODUgOC41NSA4LjU1IDYuODUgOS4yOCAzLjhDOS4zOCAzLjQ1IDkuNjUgMy4yIDEwIDMuMloiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0wLjUgMi41KSBzY2FsZSgxLjEyKSIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik0xOC4yIDEuOEMxOC40MiAxLjggMTguNiAxLjk1IDE4LjY2IDIuMTZDMTkuMDggMy44MiAyMC4wMiA0Ljc2IDIxLjY4IDUuMThDMjEuODkgNS4yNCAyMi4wNCA1LjQyIDIyLjA0IDUuNjRDMjIuMDQgNS44NiAyMS44OSA2LjA0IDIxLjY4IDYuMUMyMC4wMiA2LjUyIDE5LjA4IDcuNDYgMTguNjYgOS4xMkMxOC42IDkuMzMgMTguNDIgOS40OCAxOC4yIDkuNDhDMTcuOTggOS40OCAxNy44IDkuMzMgMTcuNzQgOS4xMkMxNy4zMiA3LjQ2IDE2LjM4IDYuNTIgMTQuNzIgNi4xQzE0LjUxIDYuMDQgMTQuMzYgNS44NiAxNC4zNiA1LjY0QzE0LjM2IDUuNDIgMTQuNTEgNS4yNCAxNC43MiA1LjE4QzE2LjM4IDQuNzYgMTcuMzIgMy44MiAxNy43NCAyLjE2QzE3LjggMS45NSAxNy45OCAxLjggMTguMiAxLjhaIiBmaWxsPSJ1cmwoI2IpIi8+PGNpcmNsZSBjeD0iMTcuOCIgY3k9IjE4LjIiIHI9IjEuMzUiIGZpbGw9IiM2MzY2RjEiLz48L3N2Zz4=",
81
86
  "maxConcurrency": 1
82
87
  }
83
88
  }
@@ -0,0 +1,160 @@
1
+ diff --git a/internal/requestdb/legacy_bodies.go b/internal/requestdb/legacy_bodies.go
2
+ new file mode 100644
3
+ index 0000000..5569c03
4
+ --- /dev/null
5
+ +++ b/internal/requestdb/legacy_bodies.go
6
+ @@ -0,0 +1,49 @@
7
+ +package requestdb
8
+ +
9
+ +import "database/sql"
10
+ +
11
+ +// migrateLegacyBodies preserves pre-0.2.17 plaintext bodies before removing their old table.
12
+ +func migrateLegacyBodies(db *sql.DB) error {
13
+ + var exists bool
14
+ + if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='bodies')`).Scan(&exists); err != nil || !exists {
15
+ + return err
16
+ + }
17
+ + tx, err := db.Begin()
18
+ + if err != nil {
19
+ + return err
20
+ + }
21
+ + defer tx.Rollback()
22
+ + rows, err := tx.Query(`SELECT id,time,request,request_size,response,response_size FROM bodies ORDER BY seq`)
23
+ + if err != nil {
24
+ + return err
25
+ + }
26
+ + defer rows.Close()
27
+ + for rows.Next() {
28
+ + var id, request, response string
29
+ + var at, requestSize, responseSize int64
30
+ + if err := rows.Scan(&id, &at, &request, &requestSize, &response, &responseSize); err != nil {
31
+ + return err
32
+ + }
33
+ + req, err := compress(request)
34
+ + if err != nil {
35
+ + return err
36
+ + }
37
+ + res, err := compress(response)
38
+ + if err != nil {
39
+ + return err
40
+ + }
41
+ + if _, err := tx.Exec(`INSERT OR IGNORE INTO request_bodies (id,time,request,request_size,response,response_size) VALUES (?,?,?,?,?,?)`, id, at, req, requestSize, res, responseSize); err != nil {
42
+ + return err
43
+ + }
44
+ + }
45
+ + if err := rows.Err(); err != nil {
46
+ + return err
47
+ + }
48
+ + if err := rows.Close(); err != nil {
49
+ + return err
50
+ + }
51
+ + if _, err := tx.Exec(`DROP TABLE bodies`); err != nil {
52
+ + return err
53
+ + }
54
+ + return tx.Commit()
55
+ +}
56
+ diff --git a/internal/requestdb/legacy_bodies_test.go b/internal/requestdb/legacy_bodies_test.go
57
+ new file mode 100644
58
+ index 0000000..2761c06
59
+ --- /dev/null
60
+ +++ b/internal/requestdb/legacy_bodies_test.go
61
+ @@ -0,0 +1,77 @@
62
+ +package requestdb
63
+ +
64
+ +import (
65
+ + "context"
66
+ + "path/filepath"
67
+ + "testing"
68
+ +
69
+ + "github.com/Mag1cFall/AIStudio2API/internal/sqlitedb"
70
+ +)
71
+ +
72
+ +func TestLegacyBodiesMigration(t *testing.T) {
73
+ + file := filepath.Join(t.TempDir(), "requests.db")
74
+ + db, err := sqlitedb.Open(file)
75
+ + if err != nil {
76
+ + t.Fatal(err)
77
+ + }
78
+ + _, err = db.Exec(`CREATE TABLE bodies (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, time INTEGER NOT NULL, request TEXT NOT NULL, request_size INTEGER NOT NULL, response TEXT NOT NULL, response_size INTEGER NOT NULL);
79
+ +INSERT INTO bodies (id,time,request,request_size,response,response_size) VALUES ('old',1234,'旧请求',100,'reply',200);`)
80
+ + if err != nil {
81
+ + t.Fatal(err)
82
+ + }
83
+ + if err := db.Close(); err != nil {
84
+ + t.Fatal(err)
85
+ + }
86
+ + for round := range 2 {
87
+ + store, err := Open(file, func(string, string) {})
88
+ + if err != nil {
89
+ + t.Fatal(err)
90
+ + }
91
+ + body, err := store.RequestBody(context.Background(), "old")
92
+ + if err != nil {
93
+ + t.Fatal(err)
94
+ + }
95
+ + if body.Request != "旧请求" || body.Response != "reply" || body.RequestSize != 100 || body.ResponseSize != 200 || body.Time.UnixMilli() != 1234 {
96
+ + t.Fatalf("changed body: %+v", body)
97
+ + }
98
+ + var old int
99
+ + if err := store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name='bodies'`).Scan(&old); err != nil || old != 0 {
100
+ + t.Fatalf("old table: %d, %v", old, err)
101
+ + }
102
+ + if round == 0 {
103
+ + // A retry must keep an already migrated body over a stale legacy copy.
104
+ + if _, err := store.db.Exec(`CREATE TABLE bodies (seq INTEGER, id TEXT, time INTEGER, request TEXT, request_size INTEGER, response TEXT, response_size INTEGER);
105
+ +INSERT INTO bodies VALUES (1,'old',9999,'stale',1,'stale',1);`); err != nil {
106
+ + t.Fatal(err)
107
+ + }
108
+ + }
109
+ + if err := store.Close(); err != nil {
110
+ + t.Fatal(err)
111
+ + }
112
+ + }
113
+ +}
114
+ +
115
+ +func TestLegacyBodiesMigrationRollback(t *testing.T) {
116
+ + db, err := sqlitedb.Open(filepath.Join(t.TempDir(), "requests.db"))
117
+ + if err != nil {
118
+ + t.Fatal(err)
119
+ + }
120
+ + defer db.Close()
121
+ + if _, err := db.Exec(schema + `CREATE TABLE bodies (seq INTEGER, id TEXT, time INTEGER, request TEXT, request_size INTEGER, response TEXT, response_size INTEGER);
122
+ +INSERT INTO bodies VALUES (1,'good',1234,'request',1,'reply',1),(2,'bad',1234,NULL,1,'reply',1);`); err != nil {
123
+ + t.Fatal(err)
124
+ + }
125
+ + if err := migrateLegacyBodies(db); err == nil {
126
+ + t.Fatal("expected invalid legacy row to fail")
127
+ + }
128
+ + var old, migrated int
129
+ + if err := db.QueryRow(`SELECT COUNT(*) FROM bodies`).Scan(&old); err != nil {
130
+ + t.Fatal(err)
131
+ + }
132
+ + if err := db.QueryRow(`SELECT COUNT(*) FROM request_bodies`).Scan(&migrated); err != nil {
133
+ + t.Fatal(err)
134
+ + }
135
+ + if old != 2 || migrated != 0 {
136
+ + t.Fatalf("migration was not atomic: old=%d new=%d", old, migrated)
137
+ + }
138
+ +}
139
+ diff --git a/internal/requestdb/requestdb.go b/internal/requestdb/requestdb.go
140
+ index bff3477..ae92661 100644
141
+ --- a/internal/requestdb/requestdb.go
142
+ +++ b/internal/requestdb/requestdb.go
143
+ @@ -58,7 +58,6 @@ CREATE INDEX IF NOT EXISTS requests_account_time ON requests(account, time);
144
+ CREATE INDEX IF NOT EXISTS requests_state_time ON requests(state, time);
145
+ CREATE TABLE IF NOT EXISTS hours (` + rollupColumns + `) WITHOUT ROWID;
146
+ CREATE TABLE IF NOT EXISTS days (` + rollupColumns + `) WITHOUT ROWID;
147
+ -DROP TABLE IF EXISTS bodies;
148
+ CREATE TABLE IF NOT EXISTS request_bodies (
149
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, time INTEGER NOT NULL,
150
+ request BLOB NOT NULL, request_size INTEGER NOT NULL, response BLOB NOT NULL, response_size INTEGER NOT NULL);`
151
+ @@ -121,6 +120,9 @@ func Open(path string, log Logger) (*Store, error) {
152
+ if _, err := db.Exec(schema); err != nil {
153
+ return nil, errors.Join(fmt.Errorf("初始化请求账本: %w", err), db.Close())
154
+ }
155
+ + if err := migrateLegacyBodies(db); err != nil {
156
+ + return nil, errors.Join(fmt.Errorf("迁移请求正文: %w", err), db.Close())
157
+ + }
158
+ if err := addCachedTokens(db); err != nil {
159
+ return nil, errors.Join(fmt.Errorf("初始化请求账本: %w", err), db.Close())
160
+ }
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { DatabaseSync } from 'node:sqlite';
7
8
 
8
9
  const root = fileURLToPath(new URL('..', import.meta.url));
9
10
  const args = process.argv.slice(2);
@@ -22,7 +23,23 @@ try {
22
23
  local = await installStudio({ dataDir, ...(archivePath ? { archivePath } : {}) });
23
24
  const bin = path.join(dataDir, 'package', process.platform === 'win32' ? 'aistudio2api.exe' : 'aistudio2api');
24
25
  assert.equal(computeFileHash(bin), pin.binarySha256);
26
+ // A pre-0.2.17 ledger must survive the real process's first startup.
27
+ fs.mkdirSync(path.join(dataDir, 'runtime'), { recursive: true });
28
+ const db = new DatabaseSync(path.join(dataDir, 'runtime', 'requests.db'));
29
+ try {
30
+ db.exec(`CREATE TABLE bodies (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, time INTEGER NOT NULL,
31
+ request TEXT NOT NULL, request_size INTEGER NOT NULL, response TEXT NOT NULL, response_size INTEGER NOT NULL)`);
32
+ db.prepare('INSERT INTO bodies (id,time,request,request_size,response,response_size) VALUES (?,?,?,?,?,?)')
33
+ .run('legacy-smoke', 1234, '旧请求', 100, 'old response', 200);
34
+ } finally { db.close(); }
35
+ fs.appendFileSync(path.join(dataDir, '.env'), 'FIRST_EVENT_HEDGE=auto\n');
25
36
  await ensureStudio({ dataDir, managementOnly: true });
37
+ const migratedBody = await studioAdmin(local, 'GET', '/api/requests/legacy-smoke/body');
38
+ assert.equal(migratedBody.request, '旧请求');
39
+ assert.equal(migratedBody.response, 'old response');
40
+ assert.equal(migratedBody.request_size, 100);
41
+ assert.equal(migratedBody.response_size, 200);
42
+ assert.equal((await studioAdmin(local, 'GET', '/api/config')).first_event_hedge, 'auto');
26
43
  const pidBefore = fs.readFileSync(path.join(dataDir, 'backend.pid'), 'utf8');
27
44
  const status = await studioAdmin(local, 'GET', '/api/status');
28
45
  assert.equal(status.version, STUDIO_VERSION);
@@ -57,7 +74,10 @@ try {
57
74
  assert.match(panel, /version\.managed/);
58
75
  assert.deepEqual(await stopStudio({ dataDir }), { stopped: true });
59
76
  assert.equal((await studioStatus({ dataDir })).status, 'stopped');
60
- console.log(`PASS ${key}: ${STUDIO_VERSION} (${source}), managed status, update HTTP 409, unchanged executable/PID, managed UI, clean stop`);
77
+ await ensureStudio({ dataDir, managementOnly: true });
78
+ assert.deepEqual(await studioAdmin(local, 'GET', '/api/requests/legacy-smoke/body'), migratedBody);
79
+ 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`);
61
81
  } finally {
62
82
  let stopped = !local;
63
83
  if (local) stopped = (await stopStudio({ dataDir }).catch(() => ({ stopped: false }))).stopped;
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Candidate build records -> lib/studio-release.json binaries. A candidate run of
3
+ // .github/workflows/studio.yml builds each target on its own runner with --write-pins; this takes
4
+ // the build-<target>.json files those runners upload and pins all five targets at once.
5
+ // Usage: node scripts/studio-pins.mjs candidates/*/build-*.json
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import assert from 'node:assert/strict';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const root = fileURLToPath(new URL('..', import.meta.url));
13
+ const pinPath = path.join(root, 'lib/studio-release.json');
14
+
15
+ // mergePins returns release with the binaries the records built. Each record must come from this
16
+ // release's source, patches and toolchains, and each target from exactly one record.
17
+ export function mergePins(release, records, patchHashes) {
18
+ const patches = release.patches.map(name => ({ name, sha256: patchHashes[name] }));
19
+ const built = {};
20
+ for (const { file, record } of records) {
21
+ assert.equal(record.version, release.version, `${file}: built version ${record.version}`);
22
+ assert.deepEqual(record.upstream, release.upstream, `${file}: built from another upstream`);
23
+ assert.equal(record.goVersion, release.goVersion, `${file}: built with Go ${record.goVersion}`);
24
+ assert.deepEqual(record.toolchains, release.toolchains, `${file}: built with other pinned toolchains`);
25
+ assert.deepEqual(record.patches, patches, `${file}: built with other patches`);
26
+ for (const [key, pin] of Object.entries(record.binaries)) {
27
+ assert.ok(key in release.binaries, `${file}: unknown target ${key}`);
28
+ assert.ok(!(key in built), `${key} is in more than one build record`);
29
+ assert.match(pin.asset, new RegExp(`^aistudio2api-v${release.version.replaceAll('.', '\\.')}-`), `${file}: ${key} asset ${pin.asset}`);
30
+ for (const field of ['sha256', 'binarySha256']) assert.match(pin[field], /^[0-9a-f]{64}$/, `${file}: ${key} ${field}`);
31
+ built[key] = { asset: pin.asset, sha256: pin.sha256, binarySha256: pin.binarySha256 };
32
+ }
33
+ }
34
+ const missing = Object.keys(release.binaries).filter(key => !(key in built));
35
+ assert.deepEqual(missing, [], `No build record for ${missing.join(', ')}`);
36
+ return { ...release, binaries: Object.fromEntries(Object.keys(release.binaries).map(key => [key, built[key]])) };
37
+ }
38
+
39
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
40
+ const files = process.argv.slice(2);
41
+ if (!files.length) throw new Error('Usage: node scripts/studio-pins.mjs <build-record.json>...');
42
+ const release = JSON.parse(fs.readFileSync(pinPath, 'utf8'));
43
+ const patchHashes = Object.fromEntries(release.patches.map(name => [name,
44
+ crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'patches', name))).digest('hex')]));
45
+ const records = files.map(file => ({ file, record: JSON.parse(fs.readFileSync(file, 'utf8')) }));
46
+ const merged = mergePins(release, records, patchHashes);
47
+ fs.writeFileSync(pinPath, JSON.stringify(merged, null, 2) + '\n');
48
+ for (const [key, pin] of Object.entries(merged.binaries)) console.log(`${key}: ${pin.sha256} ${pin.asset}`);
49
+ }
@@ -18,29 +18,130 @@ for (const arg of args) {
18
18
  }
19
19
  const writePins = args.includes('--write-pins');
20
20
  const verify = args.includes('--verify');
21
- const requested = args.find(arg => arg.startsWith('--target='))?.slice(9);
22
- const target = requested === 'host' ? `${process.platform}-${process.arch}` : requested;
21
+ // Pins are only as good as the toolchain that made them: checking or writing them needs the pinned one.
22
+ const strict = writePins || verify;
23
23
  const platforms = { 'darwin-arm64': ['darwin', 'arm64'], 'darwin-x64': ['darwin', 'amd64'],
24
24
  'linux-x64': ['linux', 'amd64'], 'linux-arm64': ['linux', 'arm64'], 'win32-x64': ['windows', 'amd64'] };
25
- if (target && !platforms[target]) throw new Error(`Unsupported target: ${target}`);
26
- if (writePins && (target || verify)) throw new Error('--write-pins requires all targets without --verify');
25
+ // --target=host, one target, or a comma-separated list; all targets without it.
26
+ const requested = args.find(arg => arg.startsWith('--target='))?.slice(9);
27
+ const targets = requested ? requested.split(',').map(key => key === 'host' ? `${process.platform}-${process.arch}` : key)
28
+ : Object.keys(platforms);
29
+ for (const key of targets) if (!platforms[key]) throw new Error(`Unsupported target: ${key}`);
30
+ if (new Set(targets).size !== targets.length) throw new Error(`Duplicate target in --target=${requested}`);
31
+ if (writePins && verify) throw new Error('--write-pins cannot be combined with --verify');
27
32
  const output = path.join(root, 'studio-build/release');
28
33
  fs.mkdirSync(output, { recursive: true });
29
34
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-studio-build-'));
30
35
  const source = path.join(scratch, 'source');
31
36
  fs.mkdirSync(source);
32
- const env = { ...process.env, CGO_ENABLED: '0', GOFLAGS: '', GOEXPERIMENT: '',
37
+ const env = { ...process.env, CGO_ENABLED: '1', GOFLAGS: '', GOEXPERIMENT: '',
33
38
  GOTOOLCHAIN: `go${release.goVersion}`, GOAMD64: 'v1', GOARM64: 'v8.0' };
34
- for (const key of ['GOOS', 'GOARCH']) delete env[key];
39
+ for (const key of ['GOOS', 'GOARCH', 'CC', 'CXX', 'CGO_CFLAGS', 'CGO_CPPFLAGS', 'CGO_CXXFLAGS', 'CGO_LDFLAGS', 'MACOSX_DEPLOYMENT_TARGET']) delete env[key];
35
40
  const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
41
+ // SQLite now needs C, so the C toolchain is part of the pin (lib/studio-release.json "toolchains").
42
+ // Zig (Linux/musl) and llvm-mingw (Windows/UCRT) are cross compilers whose output does not depend on
43
+ // the machine they run on, so any builder reproduces those archives. Apple's linker and SDK are only
44
+ // on macOS and differ between Xcode releases, so macOS archives match only under the pinned Xcode.
45
+ const toolchains = release.toolchains;
46
+ const zig = process.env.STUDIO_ZIG || 'zig';
47
+ function pinned(what, actual, expected, hint) {
48
+ if (actual === expected) return;
49
+ const message = `${what} is ${actual || 'unknown'}, but the pins were made with ${expected}. ${hint}`;
50
+ if (strict) throw new Error(message);
51
+ console.warn(`warning: ${message}`);
52
+ }
53
+ // llvmMingw returns the pinned llvm-mingw's x86_64 compiler, fetched once into studio-build/toolchains
54
+ // and checked against its pinned SHA-256, unless STUDIO_MINGW_CC names one.
55
+ function llvmMingw() {
56
+ if (process.env.STUDIO_MINGW_CC) return process.env.STUDIO_MINGW_CC;
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`);
61
+ const cacheDir = path.join(root, 'studio-build/toolchains');
62
+ const name = archive.asset.replace(/\.(tar\.xz|zip)$/, '');
63
+ const home = path.join(cacheDir, name);
64
+ const cc = path.join(home, 'bin', `x86_64-w64-mingw32-clang${process.platform === 'win32' ? '.exe' : ''}`);
65
+ const stamp = path.join(home, '.sha256');
66
+ if (fs.existsSync(cc) && fs.existsSync(stamp) && fs.readFileSync(stamp, 'utf8').trim() === archive.sha256) return cc;
67
+ fs.rmSync(home, { recursive: true, force: true });
68
+ fs.mkdirSync(cacheDir, { recursive: true });
69
+ const staging = fs.mkdtempSync(path.join(cacheDir, '.fetch-'));
70
+ try {
71
+ const file = path.join(staging, archive.asset);
72
+ const url = `https://github.com/mstorsjo/llvm-mingw/releases/download/${pin.version}/${archive.asset}`;
73
+ console.log(`Fetching pinned llvm-mingw: ${url}`);
74
+ execFileSync('curl', ['--fail', '--location', '--silent', '--show-error', '--retry', '3', '--output', file, url], { stdio: 'inherit' });
75
+ const sha256 = hash(file);
76
+ if (sha256 !== archive.sha256) throw new Error(`${archive.asset} has SHA-256 ${sha256}, not the pinned ${archive.sha256}`);
77
+ // Windows' own bsdtar reads zip; the GNU tar Git Bash puts ahead of it on PATH does not.
78
+ 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
+ fs.writeFileSync(path.join(staging, name, '.sha256'), `${archive.sha256}\n`);
81
+ fs.renameSync(path.join(staging, name), home);
82
+ } finally {
83
+ fs.rmSync(staging, { recursive: true, force: true });
84
+ }
85
+ return cc;
86
+ }
87
+ function xcode() {
88
+ try {
89
+ const [version, build] = execFileSync('xcodebuild', ['-version'], { encoding: 'utf8' }).trim().split('\n');
90
+ return `${version.replace(/^Xcode /, '')} (${build.replace(/^Build version /, '')})`;
91
+ } catch {
92
+ return null; // Command-line tools alone have no xcodebuild.
93
+ }
94
+ }
95
+ // cToolchain returns the C settings for a target and what they were made with. Only the targets
96
+ // being built and pinned must use the pinned toolchain; the host's is also used for upstream tests.
97
+ function cToolchain(key, { pin = false } = {}) {
98
+ const check = pin ? pinned : () => {};
99
+ const [goos, goarch] = platforms[key];
100
+ if (goos === 'darwin') {
101
+ if (process.platform !== 'darwin') throw new Error('Build macOS archives on macOS with Xcode');
102
+ const expected = `${toolchains.xcode.version} (${toolchains.xcode.build})`;
103
+ const actual = xcode();
104
+ check('Xcode', actual, expected,
105
+ `Select it with DEVELOPER_DIR=/Applications/Xcode_${toolchains.xcode.version}.app/Contents/Developer, or let CI make macOS pins (docs/studio-release.md).`);
106
+ const compiler = { xcode: actual, sdk: execFileSync('xcrun', ['--show-sdk-version'], { encoding: 'utf8' }).trim(),
107
+ clang: execFileSync('clang', ['--version'], { encoding: 'utf8' }).split('\n')[0] };
108
+ return { compiler, env: { CC: 'clang', MACOSX_DEPLOYMENT_TARGET: '13.0',
109
+ CGO_CFLAGS: '-O2 -g -mmacosx-version-min=13.0', CGO_LDFLAGS: '-O2 -g -mmacosx-version-min=13.0' } };
110
+ }
111
+ if (goos === 'windows') {
112
+ const cc = llvmMingw();
113
+ const clang = execFileSync(cc, ['--version'], { encoding: 'utf8' }).split('\n')[0];
114
+ const target = execFileSync(cc, ['-dumpmachine'], { encoding: 'utf8' }).trim();
115
+ if (target !== 'x86_64-w64-windows-gnu') throw new Error(`Windows needs llvm-mingw's x86_64 clang, ${cc} targets ${target}`);
116
+ check('Windows C compiler', clang, toolchains.llvmMingw.clang,
117
+ `Unset STUDIO_MINGW_CC to use the pinned llvm-mingw ${toolchains.llvmMingw.version}.`);
118
+ return { compiler: { llvmMingw: toolchains.llvmMingw.version, clang },
119
+ env: { CC: `"${cc}"`, CGO_CFLAGS: '-O2 -g', CGO_LDFLAGS: '-O2 -g' } };
120
+ }
121
+ const version = execFileSync(zig, ['version'], { encoding: 'utf8' }).trim();
122
+ check('Zig', version, toolchains.zig, 'Install it, or point STUDIO_ZIG at it.');
123
+ const triple = `${goarch === 'amd64' ? 'x86_64' : 'aarch64'}-linux-musl`;
124
+ return { compiler: { zig: version, target: triple },
125
+ env: { CC: `"${zig}" cc -target ${triple}`, CGO_CFLAGS: '-O2 -g', CGO_LDFLAGS: '-O2 -g' } };
126
+ }
36
127
  const go = (argv, extra = {}) => execFileSync('go', argv, { cwd: source, env: { ...env, ...extra }, stdio: 'inherit' });
37
128
  const npm = argv => execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv,
38
129
  { cwd: path.join(source, 'web'), stdio: 'inherit', shell: process.platform === 'win32' });
39
130
  const MANAGED_BY = 'magpie-gemini-web';
40
- const flags = ['-mod=readonly', '-trimpath', '-buildvcs=false',
41
- `-ldflags=-s -w -X github.com/Mag1cFall/AIStudio2API/internal/app.version=v${release.version} -X github.com/Mag1cFall/AIStudio2API/internal/app.managedBy=${MANAGED_BY}`];
131
+ // An empty build ID: Go's default one hashes the build's inputs, the C compiler's path among them,
132
+ // so two builders' otherwise identical executables would differ in it.
133
+ const managedFlags = `-s -w -buildid= -X github.com/Mag1cFall/AIStudio2API/internal/app.version=v${release.version} -X github.com/Mag1cFall/AIStudio2API/internal/app.managedBy=${MANAGED_BY}`;
134
+ function linkFlags(goos) {
135
+ return goos === 'linux' ? "-linkmode external -extldflags '-static -Wl,--strip-all'"
136
+ : goos === 'windows' ? "-linkmode external -extldflags '-static -Wl,--no-insert-timestamp'" : '';
137
+ }
138
+ const buildRecords = {};
139
+ const compilers = {};
42
140
 
43
141
  try {
142
+ // Check every target's toolchain before the slow part, so a wrong one fails in seconds.
143
+ for (const key of targets) cToolchain(key, { pin: true });
144
+ const hostToolchain = cToolchain(`${process.platform}-${process.arch}`).env;
44
145
  let upstream = args.find(arg => arg.startsWith('--source='))?.slice(9);
45
146
  if (upstream) {
46
147
  upstream = path.resolve(upstream);
@@ -52,6 +153,12 @@ try {
52
153
  }
53
154
  execFileSync('git', ['-C', upstream, 'archive', '--format=tar', `--output=${path.join(scratch, 'upstream.tar')}`, release.upstream.commit]);
54
155
  execFileSync('tar', ['-xf', 'upstream.tar', '-C', 'source'], { cwd: scratch });
156
+ const patches = (release.patches || []).map(name => {
157
+ const file = path.join(root, 'patches', name);
158
+ execFileSync('git', ['apply', '--check', file], { cwd: source, stdio: 'inherit' });
159
+ execFileSync('git', ['apply', file], { cwd: source, stdio: 'inherit' });
160
+ return { name, sha256: hash(file) };
161
+ });
55
162
  // The plugin tells whether a sign-in will download Camoufox by the version this upstream takes.
56
163
  const camoufox = fs.readFileSync(path.join(source, 'internal/camoufoxnative/download.go'), 'utf8')
57
164
  .match(/const camoufoxRelease = "([^"]+)"/)?.[1];
@@ -64,30 +171,40 @@ try {
64
171
  npm(['run', 'build']);
65
172
  // Node's gzip differs between builders; Go's is the same everywhere (see studio-regzip.go).
66
173
  go(['run', path.join(root, 'scripts/studio-regzip.go'), path.join(source, 'internal/webui/dist')]);
67
- go(['test', '-mod=readonly', './cmd/...', './internal/...', '-count=1']);
174
+ go(['test', '-mod=readonly', `-ldflags=${linkFlags(process.platform === 'win32' ? 'windows' : process.platform)}`, './cmd/...', './internal/...', '-count=1'], hostToolchain);
68
175
  const binaries = {};
69
- for (const key of target ? [target] : Object.keys(platforms)) {
176
+ const mismatches = [];
177
+ for (const key of targets) {
70
178
  const [goos, goarch] = platforms[key];
71
179
  const binary = path.join(scratch, goos === 'windows' ? 'aistudio2api.exe' : 'aistudio2api');
72
180
  const asset = `aistudio2api-v${release.version}-${goos}-${goarch}.${goos === 'windows' ? 'zip' : 'tar.gz'}`;
73
181
  console.log(`Building ${key}: ${asset}`);
74
- go(['build', ...flags, '-o', binary, './cmd/aistudio2api'], { GOOS: goos, GOARCH: goarch });
182
+ const buildFlags = ['-mod=readonly', '-trimpath', '-buildvcs=false',
183
+ `-ldflags=${managedFlags} ${linkFlags(goos)}`, ...(goos === 'linux' ? ['-tags=netgo,osusergo'] : [])];
184
+ const toolchain = cToolchain(key);
185
+ buildRecords[key] = { flags: buildFlags, ...toolchain.env };
186
+ compilers[key] = toolchain.compiler;
187
+ go(['build', ...buildFlags, '-o', binary, './cmd/aistudio2api'], { ...toolchain.env, GOOS: goos, GOARCH: goarch });
75
188
  go(['run', path.join(root, 'scripts/studio-archive.go'), path.join(output, asset), binary, path.join(source, 'LICENSE')]);
76
189
  const sha256 = hash(path.join(output, asset));
77
190
  const binarySha256 = hash(binary);
78
- if (verify && (release.binaries[key]?.sha256 !== sha256 || release.binaries[key]?.binarySha256 !== binarySha256 || release.binaries[key]?.asset !== asset)) {
79
- throw new Error(`Release pin mismatch for ${key}: ${sha256}`);
191
+ const pin = release.binaries[key];
192
+ if (verify && (pin?.sha256 !== sha256 || pin?.binarySha256 !== binarySha256 || pin?.asset !== asset)) {
193
+ mismatches.push(`${key}: built ${asset} ${sha256} (executable ${binarySha256}), pinned ${pin?.asset} ${pin?.sha256} (executable ${pin?.binarySha256})`);
80
194
  }
81
195
  binaries[key] = { asset, sha256, binarySha256 };
82
196
  }
197
+ // Written even when verification fails: the record says what made the archives that differ.
198
+ const single = targets.length === 1 ? `-${targets[0]}` : '';
83
199
  fs.copyFileSync(path.join(source, 'LICENSE'), path.join(output, 'LICENSE-AISTUDIO'));
84
- fs.writeFileSync(path.join(output, target ? `build-${target}.json` : 'build.json'), JSON.stringify({
85
- version: release.version, upstream: release.upstream, goVersion: release.goVersion,
86
- nodeVersion: process.version, managedBy: MANAGED_BY, flags, binaries
200
+ fs.writeFileSync(path.join(output, `build${single}.json`), JSON.stringify({
201
+ version: release.version, upstream: release.upstream, goVersion: release.goVersion, toolchains,
202
+ nodeVersion: process.version, managedBy: MANAGED_BY, builds: buildRecords, compiler: compilers, patches, binaries
87
203
  }, null, 2) + '\n');
88
- fs.writeFileSync(path.join(output, target ? `SHA256SUMS-${target}.txt` : 'SHA256SUMS.txt'),
204
+ fs.writeFileSync(path.join(output, `SHA256SUMS${single}.txt`),
89
205
  Object.values(binaries).map(pin => `${pin.sha256} ${pin.asset}\n`).join(''));
90
- if (writePins) fs.writeFileSync(pinPath, JSON.stringify({ ...release, binaries }, null, 2) + '\n');
206
+ if (mismatches.length) throw new Error(`Release pin mismatch:\n ${mismatches.join('\n ')}`);
207
+ if (writePins) fs.writeFileSync(pinPath, JSON.stringify({ ...release, binaries: { ...release.binaries, ...binaries } }, null, 2) + '\n');
91
208
  console.log(`Managed Studio archives ready: ${output}`);
92
209
  } finally {
93
210
  fs.rmSync(scratch, { recursive: true, force: true });