magpie-gemini-web 0.5.3 → 0.5.5

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
@@ -17,7 +17,7 @@ The plugin provides two independent providers that can be used individually or t
17
17
  | **Thinking levels** | `minimal` / `low` / `medium` / `high` (`minimal` is fastest & cheapest) | `low` (base) / `high` (`-thinking` extended thinking) |
18
18
  | **Sign-in method** | Sign in directly in an isolated popup browser window | Copy `cookie` from a private/incognito window |
19
19
  | **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.13`) | gemini-web2api-go (`4.22.0-magpie.1` optimized build) |
20
+ | **Local backend** | AIStudio2API (`v0.2.14`) | gemini-web2api-go (`4.22.0-magpie.1` optimized build) |
21
21
 
22
22
  ---
23
23
 
@@ -29,7 +29,7 @@ Install **magpie-gemini-web** from the Magpie plugin market, then sign in to eit
29
29
 
30
30
  1. Sign in to **Google AI Studio** in Magpie and choose **「用 Google 账号登录(推荐)」** (Sign in with a Google account).
31
31
  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 login browser Camoufox (~300 MB) is downloaded first, so the window may take a few minutes to appear. Complete sign-in within 15 minutes.
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).
33
33
  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
34
 
35
35
  ### Option 2: Gemini Web (`gemini-web`)
@@ -74,6 +74,10 @@ magpie plugin options magpie-gemini-web '{"allowImages":true}'
74
74
  magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:7890"}}'
75
75
  ```
76
76
 
77
+ > **Windows PowerShell**: `magpie` runs the `magpie.cmd` wrapper, which loses the double quotes inside the JSON when it passes the arguments on. Call the program behind it directly instead: it is in the same folder as `magpie.cmd` (which `where.exe magpie` finds), named something like `magpie-windows-amd64.exe`, and Magpie's Settings > Command line shows it too. How to write it depends on the PowerShell version (`$PSVersionTable.PSVersion` shows it):
78
+ > - PowerShell 7.3 or later, default settings: `& "<full path to the program>" plugin options magpie-gemini-web '{"allowImages":true}'`
79
+ > - Windows PowerShell 5.1 (built into Windows): it doesn't escape the double quotes in an argument for you, so write them as `\"`: `& "<full path to the program>" plugin options magpie-gemini-web '{\"allowImages\":true}'`
80
+
77
81
  | Option | Default | Scope | Description |
78
82
  | :--- | :--- | :--- | :--- |
79
83
  | `allowTools` | `true` | Shared (override via `studio.allowTools`) | Allow tool calls. When `false`, requests with tools return 400 |
@@ -89,8 +93,8 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
89
93
  **Using a proxy to reach Google:**
90
94
  - **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.
91
95
  - 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).
92
- - With neither set, the backend falls back to the `HTTPS_PROXY` environment variable or the system proxy (Windows proxy settings).
93
- - **The provider proxy set for `ai-studio` in Magpie is not passed to the local backend**; use one of the options above.
96
+ - With none set, the backend goes out directly, or on Windows through the system proxy (Windows proxy settings).
97
+ - **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.
94
98
  - 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.
95
99
  - The listen address, API key and admin credentials are managed by the plugin; changing them in the admin page has no effect.
96
100
  - **Gemini Web:** The backend ignores `HTTP_PROXY`. Add your proxy to the proxy pool in the local admin panel at `http://127.0.0.1:8083/admin` (password is `adminToken` in `secrets.json` inside the data directory).
@@ -109,6 +113,11 @@ After upgrading the plugin, run `magpie provider models gemini-web` to refresh a
109
113
 
110
114
  <br>
111
115
 
116
+ **When the local service starts and stops:**
117
+ - It starts in the background when the plugin loads (when Magpie opens, for example); after that, a request or a usage card refresh starts it again if it isn't running.
118
+ - It is a process of its own: **quitting Magpie doesn't stop it**, and Magpie picks it up again the next time it opens.
119
+ - To stop it, use `studio:stop` below (or the full command the card or an error gives). A stop lasts until the next request, which starts it again.
120
+
112
121
  When a plugin error asks you to run a command, it includes the full ready-to-copy path. From the plugin directory, you can also run:
113
122
 
114
123
  | Command | Description |
@@ -135,8 +144,8 @@ When a plugin error asks you to run a command, it includes the full ready-to-cop
135
144
  - Concurrency is capped at 1, but Google account rate-limiting or flagging cannot be completely ruled out—use at your own risk.
136
145
 
137
146
  **AI Studio upgrades and model selection:**
138
- - The plugin pins SHA-256-verified `v0.2.13-magpie.1`, based on upstream `v0.2.13`. Upgrade the plugin to update the backend. Official managed releases at or below that upstream version are upgraded on the next managed startup, preserving accounts, port and sign-in credentials.
139
- - **Online updates are disabled in the managed backend on macOS, Linux, and Windows.** The admin page directs users to upgrade through the Magpie plugin; the update endpoint returns HTTP 403 and update checks do not contact GitHub. Independently managed external servers are unaffected. See [docs/studio-release.md](docs/studio-release.md) for the patch and reproducible build process.
147
+ - The plugin pins SHA-256-verified `v0.2.14-magpie.1`, based on upstream `v0.2.14`. 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.
148
+ - **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.
140
149
  - 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.
141
150
  - 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.
142
151
  - When stopped, a replaced executable is restored from the verified pinned archive; failed verification never launches the replacement. Existing account and runtime data files are kept, but **an older backend may not read data migrated by a newer version**. Before manually downgrading, stop the service and back up the entire data directory, including `auth/` and `runtime/requests.db`. If data migration is known to have occurred, update the plugin to match the newer backend.
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  | **思考档位** | `minimal` / `low` / `medium` / `high` 四档(`minimal` 最快最省) | `low`(普通)/ `high`(`-thinking` 扩展思考)两档 |
18
18
  | **登录方式** | 弹出独立浏览器窗口直接登录 Google | 用无痕窗口登录并复制 `cookie` |
19
19
  | **额度卡片** | 显示冷却中的模型与恢复时间 | 同步网页版 **5 小时** 与 **每周** 用量及重置时间 |
20
- | **本地后端** | AIStudio2API (`v0.2.13`) | gemini-web2api-go (`4.22.0-magpie.1` 优化版) |
20
+ | **本地后端** | AIStudio2API (`v0.2.14`) | gemini-web2api-go (`4.22.0-magpie.1` 优化版) |
21
21
 
22
22
  ---
23
23
 
@@ -29,7 +29,7 @@
29
29
 
30
30
  1. 在 Magpie 中登录 **Google AI Studio**,选择 **「用 Google 账号登录(推荐)」**。
31
31
  2. 在弹出的独立登录窗口中登录 Google 账号(不影响日常浏览器),完成后直接关闭窗口。
32
- > 首次使用会自动下载登录浏览器 Camoufox(约 300 MB),弹窗可能需要等几分钟才出现;请在 15 分钟内完成登录。
32
+ > 首次使用要先下载本地服务(约 25 MB)和登录浏览器 Camoufox(约 300 MB),弹窗可能需要等几分钟才出现。需要下载时,Magpie 会在浏览器里打开一个进度页,显示下载进度。Magpie 最多等 10 分钟(下载的时间也算在内),请尽快完成登录;连不上 GitHub 时,先按下面「插件选项与代理配置」设置 `studio.proxy`。
33
33
  3. **后续管理**:再次点击「用 Google 账号登录」,可选择**添加新账号**、为本机已有账号建卡,或为过期账号**重新登录**。
34
34
 
35
35
  ### 方式二:Gemini Web (`gemini-web`)
@@ -74,6 +74,10 @@ magpie plugin options magpie-gemini-web '{"allowImages":true}'
74
74
  magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:7890"}}'
75
75
  ```
76
76
 
77
+ > **Windows PowerShell**:`magpie` 实际运行的是 `magpie.cmd` 包装器,它转发参数时会弄丢 JSON 里的双引号。请直接调用它背后的可执行文件:这个文件和 `magpie.cmd` 在同一个文件夹(`where.exe magpie` 可以找到),文件名可能是 `magpie-windows-amd64.exe` 之类,Magpie 的「设置 → 命令行」里也会显示。写法取决于 PowerShell 版本(用 `$PSVersionTable.PSVersion` 查看):
78
+ > - PowerShell 7.3 及以上,默认设置:`& "<可执行文件的完整路径>" plugin options magpie-gemini-web '{"allowImages":true}'`
79
+ > - Windows PowerShell 5.1(系统自带):它不会替你转义参数里的双引号,要写成 `\"`:`& "<可执行文件的完整路径>" plugin options magpie-gemini-web '{\"allowImages\":true}'`
80
+
77
81
  | 选项 | 默认值 | 适用范围 | 说明 |
78
82
  | :--- | :--- | :--- | :--- |
79
83
  | `allowTools` | `true` | 共用(可用 `studio.allowTools` 单独覆盖) | 允许工具调用。设为 `false` 时带工具的请求返回 400 |
@@ -89,8 +93,8 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
89
93
  **配置代理访问 Google:**
90
94
  - **AI Studio**:推荐在本地管理页设置。打开额度卡片「管理页」一行里的地址(默认 `http://127.0.0.1:2048`),用户名 `magpie`,密码是数据目录 `config.json` 里的 `adminPassword`;保存代理后在管理页重启服务即可生效,之后插件重启后端也不会覆盖它。
91
95
  - 也可以用上表的 `studio.proxy` 或环境变量 `AI_STUDIO_PROXY`,它们优先于管理页里保存的代理。改了要重启本地服务才生效(`npm run studio:stop`,下一次请求会自动启动)。
92
- - 都没设置时,后端自动使用 `HTTPS_PROXY` 环境变量或系统代理(Windows 的「代理」设置)。
93
- - **Magpie 里给 `ai-studio` 设置的供应商代理不会传给本地后端**,请用上面两种方式之一设置。
96
+ - 都没设置时,后端直连;Windows 上会使用系统代理(Windows 的「代理」设置)。
97
+ - **后端不使用 Magpie 的代理(包括给 `ai-studio` 设置的供应商代理),也不读取 `HTTPS_PROXY` 等环境变量**:它在 Magpie 退出后仍会运行,借用 Magpie 的代理到时可能已经失效。请用上面两种方式之一设置。加入这项修复之前的插件版本启动的后端,可能还在用启动时环境变量里的代理(比如 Magpie 的);「代理出口」一行会标出来,并说明重启后会怎样。
94
98
  - 额度卡片的「代理出口」一行会显示后端当前实际使用的代理、它来自哪里,以及新设置是否还要重启才生效;登录 Google 时的说明、登录失败的提示和 `npm run studio:status` 也会显示。
95
99
  - 监听地址、API Key、管理员账号等由插件管理,在管理页修改不会生效。
96
100
  - **Gemini Web**:后端不读取 `HTTP_PROXY`,请打开本地管理面板 `http://127.0.0.1:8083/admin` 在代理池中添加(登录口令为数据目录 `secrets.json` 中的 `adminToken`)。
@@ -109,6 +113,11 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
109
113
 
110
114
  <br>
111
115
 
116
+ **本地服务什么时候启动、停止:**
117
+ - 插件加载时(例如打开 Magpie)会在后台启动本地服务;之后发请求、刷新额度卡片时,服务没在运行也会自动启动。
118
+ - 本地服务是独立进程,**退出 Magpie 不会停止它**,下次打开 Magpie 直接接着用。
119
+ - 停止服务用下表的 `studio:stop`(或卡片、报错里给出的完整命令)。停止只是一次性的:下一次请求会再次启动它。
120
+
112
121
  当插件报错提示运行命令时,直接复制报错中的完整命令执行即可。在插件目录下也可使用:
113
122
 
114
123
  | 命令 | 作用 |
@@ -135,8 +144,8 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
135
144
  - 插件将请求并发限制为 1,但仍无法完全排除 Google 账号风控风险,请自行评估使用。
136
145
 
137
146
  **AI Studio 升级与模型范围:**
138
- - 插件固定使用经过 SHA-256 校验的 `v0.2.13-magpie.1`(基于上游 `v0.2.13`),通过升级插件更新后端。同基线或更旧的官方托管后端在下次管理启动时自动升级,保留账号、端口与登录凭据。
139
- - **托管后端已在 macOS、Linux、Windows 统一禁用在线更新**:管理页提示“请通过 Magpie 插件升级”,更新接口返回 HTTP 403,检查更新也不会访问 GitHub。用户自行连接的外部服务不受影响。补丁和可复现构建流程见 [docs/studio-release.md](docs/studio-release.md)。
147
+ - 插件固定使用经过 SHA-256 校验的 `v0.2.14-magpie.1`(基于上游 `v0.2.14`),通过升级插件更新后端。同基线或更旧的官方版、以及旧版插件装的托管后端,在下次管理启动时自动升级,保留账号、端口与登录凭据。
148
+ - **托管后端已在 macOS、Linux、Windows 统一禁用在线更新**:管理页提示“更新由 magpie-gemini-web 管理”,更新接口返回 HTTP 409,检查更新也不会访问 GitHub。用户自行连接的外部服务不受影响。托管构建方式和可复现构建流程见 [docs/studio-release.md](docs/studio-release.md)。
140
149
  - Unix 的 `package/` 仍尽力设置为 `0555`,作为额外的文件保护;权限设置失败不阻断服务,在线更新的禁用也不依赖该权限。若要手动删除数据目录来重置,请先停止服务,再运行 `chmod -R u+w "<数据目录>/package"` 恢复可写权限,然后删除;将 `<数据目录>` 替换为实际路径。
141
150
  - 若自行更新到更新版或定制版,插件会提示版本冲突,并提供带正确 `--data-dir` 的 `stop`、`install`、`start` 命令。停止时会找回更新后的监听进程 PID,并核对可执行文件路径;无法核实身份就不会发送终止信号。HTTP 端口关闭但进程尚未退出时,插件不会重装或启动第二个进程。
142
151
  - 已停止但文件被替换的后端会从校验通过的固定版本安装包恢复;校验失败不会启动替换文件。安装会保留账号和运行数据文件,但**不保证旧版本能读取新版迁移过的数据**。手动降级前先停止服务并备份整个数据目录(包括 `auth/`、`runtime/requests.db` 等);已知发生数据迁移时应升级插件以匹配新版后端。
@@ -1,29 +1,29 @@
1
1
  # AI Studio 托管后端构建
2
2
 
3
- 插件使用 `0.2.13-magpie.1`,基于 AIStudio2API `v0.2.13` 的固定 commit。上游版本、commit、Go 工具链、安装包及二进制 SHA-256 均记录在 `lib/studio-release.json`。MIT 许可见 `LICENSE-AISTUDIO`。
3
+ 插件使用 `0.2.14-magpie.1`,基于 AIStudio2API `v0.2.14` 的固定 commit。上游版本、commit、Go 工具链、安装包及二进制 SHA-256 均记录在 `lib/studio-release.json`。`upstream.camoufox` 是这个 commit 要求的 Camoufox 版本(上游 `internal/camoufoxnative/download.go` 里的 `camoufoxRelease`),插件靠它判断登录时要不要重新下载 Camoufox;更新上游 commit 时要一起改,构建脚本发现不一致会报错。MIT 许可见 `LICENSE-AISTUDIO`。
4
4
 
5
5
  ## 在线更新策略
6
6
 
7
- `patches/studio-managed.patch` 包含后端与管理页面修改:
7
+ 上游从 v0.2.14 起支持由分发方管理更新,不再需要补丁。构建时通过 `-ldflags` 写入两个值:
8
8
 
9
- - 后端移除在线更新的下载、文件替换逻辑;`GET /api/update`(包括 `?refresh=1`)仅返回本地版本及 `mode: managed`,不访问 GitHub。
10
- - `POST /api/update` 返回 HTTP 403,提示通过 Magpie 插件升级;不替换文件、不重启。此策略无环境变量或管理页开关,在所有构建平台相同。
11
- - 管理页版本面板显示当前版本及中英文升级提示,不提供检查更新或安装更新按钮。
12
- - 设置明确的 `v0.2.13-magpie.1` 版本标识。插件将同基线或更旧的官方版视为需要升级;更新的官方版或不认识的定制版仍提示冲突。
13
- - 普通插件升级、账户登录、模型调用及管理功能继续保留。用户自行连接的外部 AIStudio2API 服务不受此补丁控制。
9
+ - `internal/app.version=v0.2.14-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 服务不受影响。
14
14
 
15
15
  ## 复现构建
16
16
 
17
17
  维护者需要 Git、tar、Node.js 26.8.2(构建页面用)、Go 1.27.1。Windows 使用 Git Bash 执行以下命令,安装包运行和解压使用 Windows 自带 PowerShell。最终用户不需要这些构建工具。
18
18
 
19
19
  ```bash
20
- # 按固定源代码和补丁构建所有五个平台,并核对现有 pin。
20
+ # 按固定源代码构建所有五个平台,并核对现有 pin。
21
21
  npm run studio:release:build
22
22
 
23
23
  # 仅构建当前平台。
24
24
  node scripts/studio-release.mjs --target=host --verify
25
25
 
26
- # 制作新版本:审查源码/补丁后重新生成所有平台 pin。
26
+ # 制作新版本:审查上游源码后重新生成所有平台 pin。
27
27
  node scripts/studio-release.mjs --write-pins
28
28
 
29
29
  # 可复用本地上游 git 仓库,但始终导出固定 commit,不使用工作区修改。
@@ -42,8 +42,8 @@ npm run studio:managed:smoke
42
42
  AI_STUDIO_TEST_ARCHIVE=/absolute/path/to/archive.tar.gz npm test
43
43
  ```
44
44
 
45
- 真实安装验证检查启动、版本、GET 更新状态、POST 更新返回 403、二进制及 PID 不变、管理页面无更新入口、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不代替 Windows 真机验证。
45
+ 真实安装验证检查启动、版本、GET 更新状态、POST 更新返回 409、二进制及 PID 不变、管理页面显示托管提示、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不代替 Windows 真机验证。
46
46
 
47
- 发布时先审查构建记录和各平台 CI 结果,再将安装包及许可证上传到本仓库 `studio-v0.2.13-magpie.1` release。该 release 可公开下载后,才能发布引用它的插件版本,否则新安装会遇到 404。构建脚本及 CI 不自动发布 release,也不发布 npm 包。
47
+ 发布时先审查构建记录和各平台 CI 结果,再将安装包及许可证上传到本仓库 `studio-v0.2.14-magpie.1` release。该 release 可公开下载后,才能发布引用它的插件版本,否则新安装会遇到 404。构建脚本及 CI 不自动发布 release,也不发布 npm 包。
48
48
 
49
- 本地试用无需发布:先停止当前托管后端,再执行 `node scripts/studio.mjs install --archive /absolute/path/to/archive` 和 `node scripts/studio.mjs start`。可用 `--data-dir` 指定目录。修改前备份账号和配置;该定制版本与上游 v0.2.13 使用相同的数据格式。
49
+ 本地试用无需发布:先停止当前托管后端,再执行 `node scripts/studio.mjs install --archive /absolute/path/to/archive` 和 `node scripts/studio.mjs start`。可用 `--data-dir` 指定目录。修改前备份账号和配置;该版本与上游 v0.2.14 使用相同的数据格式。v0.2.14 对数据的改动都是新增的(账号记录的可选字段、新的 `runtime/prompts.db`、`.env` 的新设置),用真实账号验证过:`v0.2.13-magpie.1` 的数据可以直接被它读取,用它运行过之后退回 `v0.2.13-magpie.1` 也能正常使用。
@@ -5,8 +5,13 @@ import crypto from 'node:crypto';
5
5
  import net from 'node:net';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { spawn, execFile, execFileSync } from 'node:child_process';
8
+ import { promisify } from 'node:util';
8
9
  import release from './studio-release.json' with { type: 'json' };
9
10
 
11
+ // execFileAsync is execFile awaited: unpacking a download this way leaves the plugin host free to answer
12
+ // meanwhile, a sign-in's progress page among others.
13
+ const execFileAsync = promisify(execFile);
14
+
10
15
  export const STUDIO_VERSION = `v${release.version}`;
11
16
  export const PINNED_ARCHIVES = release.binaries;
12
17
  // The port a new setup tries first: AIStudio2API's own default.
@@ -17,6 +22,16 @@ const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
17
22
  const adminSessions = new Map();
18
23
  const inFlightStarts = new Map();
19
24
  const inFlightReady = new Map();
25
+ // setupSteps is where a start of the service stands, by data dir, while one is in flight:
26
+ // { step: 'download', received, total } (bytes; total 0 while unknown), { step: 'install' } or
27
+ // { step: 'start' }. A sign-in's progress page reads it (setupStep).
28
+ const setupSteps = new Map();
29
+
30
+ // setupStep is where the start of the service in dataDir stands, null when none is in flight or
31
+ // it hasn't got to a step yet (as while it waits for another's lock).
32
+ export function setupStep(dataDir) {
33
+ return setupSteps.get(dataDir) ?? null;
34
+ }
20
35
 
21
36
  export function studioDataDir(env = process.env) {
22
37
  return env.AI_STUDIO_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-ai-studio');
@@ -122,7 +137,7 @@ export function pluginProxy(explicit) {
122
137
 
123
138
  // downloadProxy is the proxy an install downloads through: the one given (an empty one turns it
124
139
  // off), else AI_STUDIO_PROXY, else the service's saved one, else the one an older config.json kept.
125
- function downloadProxy(dataDir, explicit, savedConfig) {
140
+ export function downloadProxy(dataDir, explicit, savedConfig) {
126
141
  if (explicit !== undefined) return validateProxy(explicit);
127
142
  if (process.env.AI_STUDIO_PROXY !== undefined) return validateProxy(process.env.AI_STUDIO_PROXY);
128
143
  let saved;
@@ -178,9 +193,12 @@ const ENV_KEYS = [
178
193
  'WARM_WORKER_LIMIT', 'MAX_ACTIVE_WORKERS', 'WARM_STARTUP_CONCURRENCY', 'PER_ACCOUNT_CONCURRENCY',
179
194
  'ROUTING_STRATEGY', 'UPSTREAM_CHANNELS', 'MODEL_MAPPING', 'TEMPORARY_CHAT', 'WAA_BACKEND', 'AUTO_START',
180
195
  'REQUEST_BODY_LOG', 'LOG_FILE', 'LOG_MAX_TOTAL_MB', 'ADMIN_AUTH_ENABLED', 'ADMIN_USERNAME', 'ADMIN_PASSWORD',
181
- 'BUILD_NATIVE_NONSTREAM'
196
+ 'BUILD_NATIVE_NONSTREAM', 'MODEL_CHANNELS', 'MEDIA_OUTPUT', 'MEDIA_BASE_URL'
182
197
  ];
183
198
 
199
+ // PROXY_ENV are the proxy variables the service would fall back to with no PROXY of its own.
200
+ const PROXY_ENV = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY'];
201
+
184
202
  // SETTING_DEFAULTS are the settings the admin page owns, as a new setup has them. They are written
185
203
  // to .env when it lacks them and left as the admin page saves them after that.
186
204
  const SETTING_DEFAULTS = {
@@ -550,7 +568,8 @@ export const PROXY_SOURCES = {
550
568
  saved: '管理页',
551
569
  startup: '服务启动时的设置',
552
570
  unknown: '来源待确认',
553
- system: '自动检测:HTTPS_PROXY 环境变量或系统代理',
571
+ system: '自动检测:系统代理设置',
572
+ legacy: '旧版插件启动的服务自动检测到的,可能来自启动时的环境变量或系统代理设置',
554
573
  direct: '直连'
555
574
  };
556
575
 
@@ -570,10 +589,13 @@ export function redactProxy(value) {
570
589
  // LAUNCH_RECORD says, for the service process this plugin last started, whether it was started
571
590
  // with a PROXY of the plugin's over the saved one, and whose: 'option' or 'env' (see pluginProxy),
572
591
  // null for none. A caller that doesn't know the plugin's options (the CLI) reads it from there.
592
+ // proxyEnv 'none' says it was started with no *_PROXY in its environment (see launchStudio); a
593
+ // record without it is an older plugin's, whose service may have had one: Magpie's, a shell's,
594
+ // or none, which nothing kept tells apart.
573
595
  const LAUNCH_RECORD = 'backend.launch.json';
574
596
 
575
597
  function writeLaunchRecord(dataDir, pid, proxy) {
576
- const record = { pid, proxy: pluginProxy(proxy)?.source ?? null };
598
+ const record = { pid, proxy: pluginProxy(proxy)?.source ?? null, proxyEnv: 'none' };
577
599
  try {
578
600
  fs.writeFileSync(path.join(dataDir, LAUNCH_RECORD), JSON.stringify(record) + '\n', { mode: 0o600 });
579
601
  } catch {}
@@ -637,8 +659,12 @@ export async function studioProxy(local, options = {}) {
637
659
  if (!active) source = 'direct';
638
660
  else if (unsure) source = 'unknown';
639
661
  else if (pending) source = 'startup';
640
- else if (!loaded) source = 'system';
641
- else source = wantedSource;
662
+ else if (!loaded) {
663
+ // An older plugin started it, maybe with a proxy in its environment, which the system's can't
664
+ // be told from
665
+ const record = launchRecord(local.dataDir);
666
+ source = record && record.proxyEnv !== 'none' ? 'legacy' : 'system';
667
+ } else source = wantedSource;
642
668
 
643
669
  return {
644
670
  active: redactProxy(active),
@@ -668,6 +694,20 @@ export function isConnectRefused(err) {
668
694
  return false;
669
695
  }
670
696
 
697
+ // olderStudioVersion says whether a version (without its v) is one this plugin may replace: an
698
+ // official X.Y.Z at or below the upstream base, or a managed X.Y.Z-magpie.N on an older base, or
699
+ // on the same base with a lower N.
700
+ function olderStudioVersion(version) {
701
+ const running = /^(\d+)\.(\d+)\.(\d+)(?:-magpie\.(\d+))?$/.exec(version);
702
+ if (!running) return false;
703
+ const ours = /-magpie\.(\d+)$/.exec(release.version);
704
+ const base = running.slice(1, 4).map(Number);
705
+ const expected = release.upstream.version.split('.').map(Number);
706
+ const different = base.findIndex((n, i) => n !== expected[i]);
707
+ if (different !== -1) return base[different] < expected[different];
708
+ return running[4] === undefined || Number(running[4]) < Number(ours[1]);
709
+ }
710
+
671
711
  // checkStudioHealth says what answers on the setup's port: 'down' (nothing), 'up' (this setup's
672
712
  // service, at the pinned version), 'outdated' (an older official version),
673
713
  // 'version_conflict' (a newer or custom version installed outside this plugin),
@@ -713,20 +753,113 @@ export async function checkStudioHealth(local, fetchFn) {
713
753
  }
714
754
  }
715
755
 
716
- // Official releases at or below our upstream base can move to the managed
717
- // build. Newer/custom installations require an explicit recovery decision.
756
+ // Official releases at or below our upstream base, and managed builds older than this one, can
757
+ // move to this managed build. Newer/custom installations require an explicit recovery decision.
718
758
  const runningVersion = String(status.version).replace(/^v/, '');
719
759
  if (runningVersion !== release.version) {
720
- const parts = /^\d+\.\d+\.\d+$/.test(runningVersion) ? runningVersion.split('.').map(Number) : null;
721
- const expected = release.upstream.version.split('.').map(Number);
722
- const different = parts?.findIndex((n, i) => n !== expected[i]);
723
- const older = parts && (different === -1 || parts[different] < expected[different]);
760
+ const older = olderStudioVersion(runningVersion);
724
761
  return { state: older ? 'outdated' : 'version_conflict', status, reason: `Running version ${status.version}, expected ${STUDIO_VERSION}` };
725
762
  }
726
763
 
727
764
  return { state: 'up', status };
728
765
  }
729
766
 
767
+ // CAMOUFOX_EXECUTABLES are where upstream's Camoufox, the browser it signs Google accounts in
768
+ // with, has its executable in its folder, by platform (camoufoxnative.camoufoxExecutablePath).
769
+ const CAMOUFOX_EXECUTABLES = {
770
+ win32: 'camoufox.exe',
771
+ linux: 'camoufox-bin',
772
+ darwin: path.join('Camoufox.app', 'Contents', 'MacOS', 'camoufox')
773
+ };
774
+ // How much of the end of backend.log camoufoxState reads for the download's size
775
+ const LOG_TAIL_BYTES = 256 * 1024;
776
+ // CAMOUFOX_VERSION is the Camoufox the pinned upstream takes (camoufoxnative.camoufoxRelease): it
777
+ // downloads that one over any other it finds.
778
+ export const CAMOUFOX_VERSION = release.upstream.camoufox;
779
+
780
+ // camoufoxState says whether the service in dataDir has Camoufox, which it downloads (from GitHub,
781
+ // some 300 MB) when an account first signs in and whenever the one it has isn't CAMOUFOX_VERSION:
782
+ // { ready: true }, or how far that download has got, as upstream's camoufoxnative leaves it in
783
+ // runtime/ under the service's working directory (dataDir): the bytes received of the archive's
784
+ // total (0 while not known), and whether it is unpacking. One named by CAMOUFOX_PATH is the only
785
+ // one upstream takes, and never downloads over.
786
+ export function camoufoxState(dataDir, platform = process.platform, env = process.env) {
787
+ const exe = CAMOUFOX_EXECUTABLES[platform];
788
+ if (!exe || env.CAMOUFOX_PATH?.trim()) return { ready: true };
789
+ const runtime = path.join(dataDir, 'runtime');
790
+ const candidates = [path.join(runtime, 'camoufox', exe), path.join(dataDir, 'package', 'runtime', 'camoufox', exe)];
791
+ if (platform === 'win32' && env.LOCALAPPDATA) candidates.push(path.join(env.LOCALAPPDATA, 'camoufox', 'camoufox', 'Cache', exe));
792
+ if (candidates.some(camoufoxPinned)) return { ready: true };
793
+
794
+ let names = [];
795
+ try { names = fs.readdirSync(runtime); } catch {}
796
+ const part = names.find(n => /^camoufox-.+\.zip\.part$/.test(n));
797
+ let received = 0;
798
+ try { if (part) received = fs.statSync(path.join(runtime, part)).size; } catch {}
799
+ const total = camoufoxSize(path.join(dataDir, 'backend.log'));
800
+ return {
801
+ ready: false,
802
+ received,
803
+ // the log rounds the size down to a MiB
804
+ total: total && Math.max(total, received),
805
+ unpacking: names.some(n => n.startsWith('.camoufox-stage-'))
806
+ };
807
+ }
808
+
809
+ // camoufoxPinned says whether exe is a Camoufox upstream takes as it is: the version its
810
+ // application.ini gives, beside it or in the app's Resources (macOS), is CAMOUFOX_VERSION
811
+ // (camoufoxnative.validateCamoufoxExecutable).
812
+ function camoufoxPinned(exe) {
813
+ try {
814
+ if (fs.statSync(exe).isDirectory()) return false;
815
+ } catch {
816
+ return false;
817
+ }
818
+ const dir = path.dirname(exe);
819
+ for (const ini of [path.join(dir, 'application.ini'), path.join(path.dirname(dir), 'Resources', 'application.ini')]) {
820
+ let text;
821
+ try {
822
+ text = fs.readFileSync(ini, 'utf8');
823
+ } catch (err) {
824
+ if (err.code === 'ENOENT') continue;
825
+ return false;
826
+ }
827
+ for (const line of text.split('\n')) {
828
+ const version = line.trim().match(/^Version=(.*)$/);
829
+ if (version) return version[1].trim() === CAMOUFOX_VERSION;
830
+ }
831
+ }
832
+ return false;
833
+ }
834
+
835
+ // camoufoxSize is the size of the Camoufox archive the service last began downloading, as its log
836
+ // says it ("Camoufox 下载已开始", in whole MiB), 0 when it doesn't.
837
+ function camoufoxSize(logPath) {
838
+ let tail;
839
+ try {
840
+ const fd = fs.openSync(logPath, 'r');
841
+ try {
842
+ const size = fs.fstatSync(fd).size;
843
+ const length = Math.min(size, LOG_TAIL_BYTES);
844
+ tail = Buffer.alloc(length);
845
+ fs.readSync(fd, tail, 0, length, size - length);
846
+ } finally {
847
+ fs.closeSync(fd);
848
+ }
849
+ } catch {
850
+ return 0;
851
+ }
852
+ const lines = tail.toString('utf8').split('\n');
853
+ for (let i = lines.length - 1; i >= 0; i--) {
854
+ if (!lines[i].includes('Camoufox 下载已开始')) continue;
855
+ try {
856
+ const mib = JSON.parse(lines[i]).size_mib;
857
+ if (Number.isFinite(mib) && mib > 0) return mib * 1024 * 1024;
858
+ } catch {}
859
+ }
860
+ return 0;
861
+ }
862
+
730
863
  export async function studioStatus(options = {}) {
731
864
  const dataDir = options.dataDir || studioDataDir();
732
865
  const platform = options.platform || process.platform;
@@ -963,6 +1096,20 @@ async function withLock(dataDir, fn, timeoutMs = 600000) {
963
1096
  }
964
1097
  }
965
1098
 
1099
+ // downloadProgress is a download's step as curl writes it: the bytes in file so far, of the
1100
+ // Content-Length the last response in headersFile gave (the archive's, after GitHub's redirects),
1101
+ // 0 while that isn't known.
1102
+ export function downloadProgress(file, headersFile) {
1103
+ let received = 0;
1104
+ let total = 0;
1105
+ try { received = fs.statSync(file).size; } catch {}
1106
+ try {
1107
+ const lengths = [...fs.readFileSync(headersFile, 'latin1').matchAll(/^content-length:\s*(\d+)/gim)];
1108
+ if (lengths.length) total = Number(lengths.at(-1)[1]);
1109
+ } catch {}
1110
+ return { step: 'download', received, total: total >= received ? total : 0 };
1111
+ }
1112
+
966
1113
  export async function installStudio(options = {}) {
967
1114
  const platform = options.platform || process.platform;
968
1115
  const arch = options.arch || process.arch;
@@ -1026,6 +1173,7 @@ export async function installStudio(options = {}) {
1026
1173
  const downloadUrl = `https://github.com/${release.repository}/releases/download/${release.tag}/${pin.asset}`;
1027
1174
  archivePath = path.join(stageDir, pin.asset);
1028
1175
  const proxy = downloadProxy(dataDir, options.proxy, savedConfig);
1176
+ setupSteps.set(dataDir, { step: 'download', received: 0, total: 0 });
1029
1177
 
1030
1178
  if (options.fetchFn && options.fetchFn !== globalThis.fetch) {
1031
1179
  const res = await options.fetchFn(downloadUrl, { signal: AbortSignal.timeout(300000) });
@@ -1044,18 +1192,26 @@ export async function installStudio(options = {}) {
1044
1192
  if (proxy) {
1045
1193
  curlArgs.push('--proxy', proxy);
1046
1194
  }
1047
- curlArgs.push('-o', archivePath, downloadUrl);
1048
- await new Promise((resolve, reject) => {
1049
- execFile('curl', curlArgs, (err, _stdout, stderr) => {
1050
- if (err) {
1051
- const status = err.code || err.status || 'failed';
1052
- const reason = String(stderr || '').trim().slice(0, 500);
1053
- reject(new Error(`Failed to download AIStudio2API archive via curl (status: ${status})${reason ? `: ${reason}` : ''}`));
1054
- } else {
1055
- resolve();
1056
- }
1195
+ // The headers curl dumps as they come say the archive's size, and the file it writes says
1196
+ // how much has come: together they show the download's progress.
1197
+ const headersPath = path.join(stageDir, 'headers.txt');
1198
+ curlArgs.push('-D', headersPath, '-o', archivePath, downloadUrl);
1199
+ const watch = setInterval(() => setupSteps.set(dataDir, downloadProgress(archivePath, headersPath)), 500);
1200
+ try {
1201
+ await new Promise((resolve, reject) => {
1202
+ execFile('curl', curlArgs, (err, _stdout, stderr) => {
1203
+ if (err) {
1204
+ const status = err.code || err.status || 'failed';
1205
+ const reason = String(stderr || '').trim().slice(0, 500);
1206
+ reject(new Error(`Failed to download AIStudio2API archive via curl (status: ${status})${reason ? `: ${reason}` : ''}`));
1207
+ } else {
1208
+ resolve();
1209
+ }
1210
+ });
1057
1211
  });
1058
- });
1212
+ } finally {
1213
+ clearInterval(watch);
1214
+ }
1059
1215
  const downloadedHash = computeFileHash(archivePath);
1060
1216
  if (downloadedHash !== expectedHash) {
1061
1217
  throw new Error(`SHA256 checksum mismatch for downloaded archive: expected ${expectedHash}, got ${downloadedHash}`);
@@ -1068,11 +1224,12 @@ export async function installStudio(options = {}) {
1068
1224
  fs.chmodSync(retainedArchive, 0o600);
1069
1225
  }
1070
1226
 
1227
+ setupSteps.set(dataDir, { step: 'install' });
1071
1228
  const extractDir = path.join(stageDir, 'extracted');
1072
1229
  fs.mkdirSync(extractDir, { recursive: true, mode: 0o700 });
1073
1230
 
1074
1231
  if (platform === 'win32') {
1075
- execFileSync('powershell', [
1232
+ await execFileAsync('powershell', [
1076
1233
  '-NoProfile',
1077
1234
  '-NonInteractive',
1078
1235
  '-Command',
@@ -1082,7 +1239,7 @@ export async function installStudio(options = {}) {
1082
1239
  windowsHide: true
1083
1240
  });
1084
1241
  } else {
1085
- const listOutput = execFileSync('tar', ['-tvf', archivePath], { encoding: 'utf8' }).split('\n').filter(Boolean);
1242
+ const listOutput = (await execFileAsync('tar', ['-tvf', archivePath], { encoding: 'utf8' })).stdout.split('\n').filter(Boolean);
1086
1243
  for (const line of listOutput) {
1087
1244
  const parts = line.split(/\s+/);
1088
1245
  const mode = parts[0];
@@ -1090,14 +1247,14 @@ export async function installStudio(options = {}) {
1090
1247
  throw new Error(`Symlinks or special files in archive rejected: ${line}`);
1091
1248
  }
1092
1249
  }
1093
- const listing = execFileSync('tar', ['-tf', archivePath], { encoding: 'utf8' }).split('\n').filter(Boolean);
1250
+ const listing = (await execFileAsync('tar', ['-tf', archivePath], { encoding: 'utf8' })).stdout.split('\n').filter(Boolean);
1094
1251
  for (const entry of listing) {
1095
1252
  const entryParts = entry.replace(/\\/g, '/').split('/');
1096
1253
  if (entryParts.includes('..') || entry.startsWith('/') || entry.startsWith('\\') || /^[a-zA-Z]:/.test(entry)) {
1097
1254
  throw new Error(`Dangerous member in archive rejected: ${entry}`);
1098
1255
  }
1099
1256
  }
1100
- execFileSync('tar', ['-xzf', archivePath, '-C', extractDir]);
1257
+ await execFileAsync('tar', ['-xzf', archivePath, '-C', extractDir]);
1101
1258
  }
1102
1259
 
1103
1260
  let topDir = extractDir;
@@ -1268,7 +1425,8 @@ function shared(map, key, fn) {
1268
1425
  // it to take requests; a managementOnly caller doesn't wait for the latter.
1269
1426
  export async function ensureStudio(options = {}) {
1270
1427
  const dataDir = options.dataDir || studioDataDir();
1271
- const { local, status } = await shared(inFlightStarts, dataDir, () => launchStudio({ ...options, dataDir }));
1428
+ const { local, status } = await shared(inFlightStarts, dataDir,
1429
+ () => launchStudio({ ...options, dataDir }).finally(() => setupSteps.delete(dataDir)));
1272
1430
  if (options.managementOnly) return local;
1273
1431
  return shared(inFlightReady, dataDir, () => readyStudio(local, status, options));
1274
1432
  }
@@ -1361,12 +1519,17 @@ async function launchStudio(options) {
1361
1519
  // would hide the ones saved in .env
1362
1520
  const cleanEnv = {};
1363
1521
  for (const [k, v] of Object.entries(process.env)) {
1364
- if (!k.startsWith('AISTUDIO_') && !k.startsWith('WAA_') && !ENV_KEYS.includes(k.toUpperCase())) {
1522
+ if (!k.startsWith('AISTUDIO_') && !k.startsWith('WAA_') && !ENV_KEYS.includes(k.toUpperCase()) && !PROXY_ENV.includes(k.toUpperCase())) {
1365
1523
  cleanEnv[k] = v;
1366
1524
  }
1367
1525
  }
1526
+ // Nor a proxy of the starter's: the service outlives Magpie, whose plugin host gives a program it
1527
+ // starts with none of these the proxy of the request it starts in, or Magpie's own, which may be
1528
+ // gone by the time the service uses it. Empty ones keep the host from adding its own.
1529
+ for (const k of PROXY_ENV) cleanEnv[k] = '';
1368
1530
  Object.assign(cleanEnv, serviceEnv(dataDir, config, { proxy: options.proxy }));
1369
1531
 
1532
+ setupSteps.set(dataDir, { step: 'start' });
1370
1533
  const logPath = path.join(dataDir, 'backend.log');
1371
1534
  rotateLog(logPath);
1372
1535
  const logFd = fs.openSync(logPath, 'a', 0o600);