magpie-gemini-web 0.5.4 → 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
@@ -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 |
package/README.md CHANGED
@@ -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
  | 命令 | 作用 |
@@ -1,6 +1,6 @@
1
1
  # AI Studio 托管后端构建
2
2
 
3
- 插件使用 `0.2.14-magpie.1`,基于 AIStudio2API `v0.2.14` 的固定 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
 
@@ -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;
@@ -181,6 +196,9 @@ const ENV_KEYS = [
181
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),
@@ -738,6 +764,102 @@ export async function checkStudioHealth(local, fetchFn) {
738
764
  return { state: 'up', status };
739
765
  }
740
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
+
741
863
  export async function studioStatus(options = {}) {
742
864
  const dataDir = options.dataDir || studioDataDir();
743
865
  const platform = options.platform || process.platform;
@@ -974,6 +1096,20 @@ async function withLock(dataDir, fn, timeoutMs = 600000) {
974
1096
  }
975
1097
  }
976
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
+
977
1113
  export async function installStudio(options = {}) {
978
1114
  const platform = options.platform || process.platform;
979
1115
  const arch = options.arch || process.arch;
@@ -1037,6 +1173,7 @@ export async function installStudio(options = {}) {
1037
1173
  const downloadUrl = `https://github.com/${release.repository}/releases/download/${release.tag}/${pin.asset}`;
1038
1174
  archivePath = path.join(stageDir, pin.asset);
1039
1175
  const proxy = downloadProxy(dataDir, options.proxy, savedConfig);
1176
+ setupSteps.set(dataDir, { step: 'download', received: 0, total: 0 });
1040
1177
 
1041
1178
  if (options.fetchFn && options.fetchFn !== globalThis.fetch) {
1042
1179
  const res = await options.fetchFn(downloadUrl, { signal: AbortSignal.timeout(300000) });
@@ -1055,18 +1192,26 @@ export async function installStudio(options = {}) {
1055
1192
  if (proxy) {
1056
1193
  curlArgs.push('--proxy', proxy);
1057
1194
  }
1058
- curlArgs.push('-o', archivePath, downloadUrl);
1059
- await new Promise((resolve, reject) => {
1060
- execFile('curl', curlArgs, (err, _stdout, stderr) => {
1061
- if (err) {
1062
- const status = err.code || err.status || 'failed';
1063
- const reason = String(stderr || '').trim().slice(0, 500);
1064
- reject(new Error(`Failed to download AIStudio2API archive via curl (status: ${status})${reason ? `: ${reason}` : ''}`));
1065
- } else {
1066
- resolve();
1067
- }
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
+ });
1068
1211
  });
1069
- });
1212
+ } finally {
1213
+ clearInterval(watch);
1214
+ }
1070
1215
  const downloadedHash = computeFileHash(archivePath);
1071
1216
  if (downloadedHash !== expectedHash) {
1072
1217
  throw new Error(`SHA256 checksum mismatch for downloaded archive: expected ${expectedHash}, got ${downloadedHash}`);
@@ -1079,11 +1224,12 @@ export async function installStudio(options = {}) {
1079
1224
  fs.chmodSync(retainedArchive, 0o600);
1080
1225
  }
1081
1226
 
1227
+ setupSteps.set(dataDir, { step: 'install' });
1082
1228
  const extractDir = path.join(stageDir, 'extracted');
1083
1229
  fs.mkdirSync(extractDir, { recursive: true, mode: 0o700 });
1084
1230
 
1085
1231
  if (platform === 'win32') {
1086
- execFileSync('powershell', [
1232
+ await execFileAsync('powershell', [
1087
1233
  '-NoProfile',
1088
1234
  '-NonInteractive',
1089
1235
  '-Command',
@@ -1093,7 +1239,7 @@ export async function installStudio(options = {}) {
1093
1239
  windowsHide: true
1094
1240
  });
1095
1241
  } else {
1096
- 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);
1097
1243
  for (const line of listOutput) {
1098
1244
  const parts = line.split(/\s+/);
1099
1245
  const mode = parts[0];
@@ -1101,14 +1247,14 @@ export async function installStudio(options = {}) {
1101
1247
  throw new Error(`Symlinks or special files in archive rejected: ${line}`);
1102
1248
  }
1103
1249
  }
1104
- 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);
1105
1251
  for (const entry of listing) {
1106
1252
  const entryParts = entry.replace(/\\/g, '/').split('/');
1107
1253
  if (entryParts.includes('..') || entry.startsWith('/') || entry.startsWith('\\') || /^[a-zA-Z]:/.test(entry)) {
1108
1254
  throw new Error(`Dangerous member in archive rejected: ${entry}`);
1109
1255
  }
1110
1256
  }
1111
- execFileSync('tar', ['-xzf', archivePath, '-C', extractDir]);
1257
+ await execFileAsync('tar', ['-xzf', archivePath, '-C', extractDir]);
1112
1258
  }
1113
1259
 
1114
1260
  let topDir = extractDir;
@@ -1279,7 +1425,8 @@ function shared(map, key, fn) {
1279
1425
  // it to take requests; a managementOnly caller doesn't wait for the latter.
1280
1426
  export async function ensureStudio(options = {}) {
1281
1427
  const dataDir = options.dataDir || studioDataDir();
1282
- 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)));
1283
1430
  if (options.managementOnly) return local;
1284
1431
  return shared(inFlightReady, dataDir, () => readyStudio(local, status, options));
1285
1432
  }
@@ -1372,12 +1519,17 @@ async function launchStudio(options) {
1372
1519
  // would hide the ones saved in .env
1373
1520
  const cleanEnv = {};
1374
1521
  for (const [k, v] of Object.entries(process.env)) {
1375
- 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())) {
1376
1523
  cleanEnv[k] = v;
1377
1524
  }
1378
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] = '';
1379
1530
  Object.assign(cleanEnv, serviceEnv(dataDir, config, { proxy: options.proxy }));
1380
1531
 
1532
+ setupSteps.set(dataDir, { step: 'start' });
1381
1533
  const logPath = path.join(dataDir, 'backend.log');
1382
1534
  rotateLog(logPath);
1383
1535
  const logFd = fs.openSync(logPath, 'a', 0o600);
@@ -0,0 +1,173 @@
1
+ import http from 'node:http';
2
+ import crypto from 'node:crypto';
3
+
4
+ // A page closes this long after it opened even when nothing closes it: longer than a sign-in can
5
+ // take (a download, the service's start, the login's 15 minutes).
6
+ const PAGE_LIFETIME_MS = 40 * 60 * 1000;
7
+
8
+ // openProgressPage serves a sign-in's progress as a page of its own on 127.0.0.1, at a path no
9
+ // other page can guess. read() is what it shows, which it asks for every second:
10
+ // { title, note, steps: [{ label, state, detail, received, total }], done, error }
11
+ // with state 'pending', 'active', 'done' or 'failed', and received and total in bytes (total 0
12
+ // while not known) for a step that downloads. It stops at close(), or at PAGE_LIFETIME_MS.
13
+ export async function openProgressPage(read) {
14
+ const token = crypto.randomBytes(16).toString('hex');
15
+ const nonce = crypto.randomBytes(16).toString('base64');
16
+ let host = '';
17
+ const server = http.createServer((req, res) => {
18
+ const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
19
+ // the Host a rebound DNS name sends can't reach it either
20
+ if (req.method !== 'GET' || req.headers.host !== host) {
21
+ res.writeHead(404, headers).end();
22
+ return;
23
+ }
24
+ if (req.url === `/${token}`) {
25
+ res.writeHead(200, {
26
+ ...headers,
27
+ 'Content-Type': 'text/html; charset=utf-8',
28
+ 'Content-Security-Policy': `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`
29
+ }).end(PAGE.replace('NONCE', nonce));
30
+ return;
31
+ }
32
+ if (req.url === `/${token}/status`) {
33
+ let body;
34
+ try {
35
+ body = JSON.stringify(read());
36
+ } catch (err) {
37
+ res.writeHead(500, { ...headers, 'Content-Type': 'application/json' }).end(JSON.stringify({ error: err.message }));
38
+ return;
39
+ }
40
+ res.writeHead(200, { ...headers, 'Content-Type': 'application/json' }).end(body);
41
+ return;
42
+ }
43
+ res.writeHead(404, headers).end();
44
+ });
45
+ await new Promise((resolve, reject) => {
46
+ server.once('error', reject);
47
+ server.listen(0, '127.0.0.1', resolve);
48
+ });
49
+ server.unref();
50
+ host = `127.0.0.1:${server.address().port}`;
51
+ const timer = setTimeout(close, PAGE_LIFETIME_MS);
52
+ timer.unref();
53
+ function close() {
54
+ clearTimeout(timer);
55
+ server.close();
56
+ server.closeAllConnections?.();
57
+ }
58
+ return { url: `http://${host}/${token}`, close };
59
+ }
60
+
61
+ // PAGE draws what the status says, as text only (nothing in it is read as HTML).
62
+ const PAGE = `<!doctype html>
63
+ <html lang="zh-CN">
64
+ <head>
65
+ <meta charset="utf-8">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1">
67
+ <title>Google AI Studio 登录进度</title>
68
+ <style>
69
+ :root { color-scheme: light dark; --fg: #1f2328; --muted: #656d76; --line: #d0d7de; --bar: #2f6feb; --ok: #1a7f37; --bad: #cf222e; --bg: #fff; }
70
+ @media (prefers-color-scheme: dark) { :root { --fg: #e6edf3; --muted: #8d96a0; --line: #30363d; --bar: #4c8dff; --ok: #3fb950; --bad: #f85149; --bg: #0d1117; } }
71
+ body { font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; color: var(--fg); background: var(--bg); max-width: 640px; margin: 48px auto; padding: 0 20px; }
72
+ h1 { font-size: 20px; margin: 0 0 4px; }
73
+ .note { color: var(--muted); margin: 0 0 24px; }
74
+ ol { list-style: none; padding: 0; margin: 0; }
75
+ li { border: 1px solid var(--line); border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; }
76
+ li.pending { opacity: .55; }
77
+ li.failed { border-color: var(--bad); }
78
+ .head { display: flex; gap: 10px; align-items: center; font-weight: 600; }
79
+ .mark { width: 20px; flex: none; text-align: center; }
80
+ li.done .mark { color: var(--ok); }
81
+ li.failed .mark { color: var(--bad); }
82
+ .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--bar); border-right-color: transparent; border-radius: 50%; animation: turn .8s linear infinite; }
83
+ @keyframes turn { to { transform: rotate(360deg); } }
84
+ .detail { color: var(--muted); font-size: 13px; margin: 4px 0 0 30px; white-space: pre-wrap; }
85
+ .track { height: 8px; border-radius: 4px; background: var(--line); margin: 8px 0 0 30px; overflow: hidden; }
86
+ .fill { height: 100%; background: var(--bar); transition: width .4s; }
87
+ .track.unknown .fill { width: 30%; animation: slide 1.2s linear infinite; }
88
+ @keyframes slide { from { margin-left: -30%; } to { margin-left: 100%; } }
89
+ .end { margin-top: 20px; font-weight: 600; white-space: pre-wrap; }
90
+ .end.ok { color: var(--ok); }
91
+ .end.bad { color: var(--bad); }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <h1 id="title">Google AI Studio 登录进度</h1>
96
+ <p class="note" id="note"></p>
97
+ <ol id="steps"></ol>
98
+ <p class="end" id="end"></p>
99
+ <script nonce="NONCE">
100
+ const STATUS = location.pathname + '/status';
101
+ const MARKS = { pending: '○', done: '✓', failed: '✕' };
102
+ const mb = n => (n / 1048576).toFixed(1);
103
+
104
+ function el(tag, cls, text) {
105
+ const e = document.createElement(tag);
106
+ if (cls) e.className = cls;
107
+ if (text) e.textContent = text;
108
+ return e;
109
+ }
110
+
111
+ function render(s) {
112
+ if (s.title) document.getElementById('title').textContent = s.title;
113
+ document.getElementById('note').textContent = s.note || '';
114
+ const list = document.getElementById('steps');
115
+ list.replaceChildren(...(s.steps || []).map(step => {
116
+ const li = el('li', step.state);
117
+ const mark = el('span', 'mark');
118
+ if (step.state === 'active') mark.append(el('span', 'spin'));
119
+ else mark.textContent = MARKS[step.state] || '';
120
+ const head = el('div', 'head');
121
+ head.append(mark, el('span', '', step.label));
122
+ li.append(head);
123
+ let detail = step.detail || '';
124
+ const downloading = step.state === 'active' && typeof step.received === 'number';
125
+ if (downloading) {
126
+ const pct = step.total ? Math.min(100, Math.floor(step.received * 100 / step.total)) : 0;
127
+ const got = step.total ? '已下载 ' + mb(step.received) + ' / ' + mb(step.total) + ' MB(' + pct + '%)'
128
+ : step.received ? '已下载 ' + mb(step.received) + ' MB' : '';
129
+ detail = [detail, got].filter(Boolean).join(':');
130
+ }
131
+ if (detail) li.append(el('div', 'detail', detail));
132
+ if (downloading) {
133
+ const track = el('div', step.total ? 'track' : 'track unknown');
134
+ const fill = el('div', 'fill');
135
+ if (step.total) fill.style.width = Math.min(100, step.received * 100 / step.total) + '%';
136
+ track.append(fill);
137
+ li.append(track);
138
+ }
139
+ return li;
140
+ }));
141
+ const end = document.getElementById('end');
142
+ if (s.error) {
143
+ end.className = 'end bad';
144
+ end.textContent = s.error;
145
+ } else if (s.done) {
146
+ end.className = 'end ok';
147
+ end.textContent = s.doneText || '完成了,可以关掉这个页面。';
148
+ }
149
+ }
150
+
151
+ let misses = 0;
152
+ async function poll() {
153
+ try {
154
+ const res = await fetch(STATUS, { cache: 'no-store' });
155
+ if (!res.ok) throw new Error(String(res.status));
156
+ const s = await res.json();
157
+ misses = 0;
158
+ render(s);
159
+ if (s.done || s.error) return;
160
+ } catch {
161
+ if (++misses >= 3) {
162
+ const end = document.getElementById('end');
163
+ if (!end.textContent) end.textContent = '这个页面已经停止更新,请回到 Magpie 查看结果。';
164
+ return;
165
+ }
166
+ }
167
+ setTimeout(poll, 1000);
168
+ }
169
+ poll();
170
+ </script>
171
+ </body>
172
+ </html>
173
+ `;
@@ -6,7 +6,8 @@
6
6
  "upstream": {
7
7
  "version": "0.2.14",
8
8
  "repository": "https://github.com/Mag1cFall/AIStudio2API.git",
9
- "commit": "370a313814099b508881807effa2a9d72f2d7e66"
9
+ "commit": "370a313814099b508881807effa2a9d72f2d7e66",
10
+ "camoufox": "152.0.4-beta.29"
10
11
  },
11
12
  "binaries": {
12
13
  "darwin-arm64": {
package/lib/studio.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER } from './backend.mjs';
2
2
  import path from 'node:path';
3
- import { localStudio, ensureStudio, studioAdmin, studioAccounts, studioProxy, studioCommand, validateProxy, isConnectRefused, PROXY_SOURCES } from './studio-native.mjs';
3
+ import { localStudio, ensureStudio, studioAdmin, studioAccounts, studioProxy, studioCommand, validateProxy, isConnectRefused, PROXY_SOURCES, downloadProxy, redactProxy, studioDataDir, checkStudioHealth, setupStep, camoufoxState } from './studio-native.mjs';
4
+ import { openProgressPage } from './studio-page.mjs';
4
5
 
5
6
  export const STUDIO_ID = 'ai-studio';
6
7
  export const STUDIO_BASE = 'http://127.0.0.1:2048/v1';
@@ -11,19 +12,32 @@ const STUDIO_EXCLUDE = /(?:image|nano-banana|tts|audio|live|transcribe|robotics|
11
12
  const NON_TEXT_CAPS = ['image_route', 'speech_route', 'live_route', 'aspect_ratio', 'output_resolution', 'interactions_api', 'transcription_output'];
12
13
  const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
13
14
 
14
- // The service opens its own login window for a new account, and waits this long for it.
15
- const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
15
+ // Magpie waits 10 minutes for a sign-in once authorize returns (signInTimeout), downloads and the
16
+ // login window included, and doesn't stop the callback when it gives up: the callback ends a
17
+ // little before, saying so, and the service's login window closes with its request.
18
+ const SIGN_IN_BUDGET_MS = 10 * 60 * 1000 - 20 * 1000;
19
+ const LATE = '超过了 Magpie 等登录的 10 分钟。已经下载的部分会保留,再登录一次会接着来。';
20
+ // How long the question which account to sign in waits for the service to start: Magpie gives
21
+ // the question a minute, with nothing on screen. A start that takes longer (an update to
22
+ // download) goes on behind a new account's sign-in, whose progress page shows it.
23
+ const ASK_WAIT_MS = 15 * 1000;
16
24
  // Upstream logs in with Camoufox even when generation runs in Go, and downloads it on the first
17
25
  // login, so that one can take minutes before a window shows.
18
26
  const LOGIN_WINDOW = [
19
27
  '第一次登录要先下载登录用的浏览器 Camoufox(约 300 MB),可能要等几分钟窗口才出现,期间这里没有变化,请耐心等待。',
20
- '登录窗口是独立的,不影响你平时用的浏览器;请在 15 分钟内完成登录,完成后可以关掉窗口,日常生成不需要浏览器。'
28
+ '登录窗口是独立的,不影响你平时用的浏览器。Magpie 最多等 10 分钟(下载的时间也算在内),请尽快在窗口里登录;完成后可以关掉窗口,日常生成不需要浏览器。'
21
29
  ];
22
30
  const LOGIN_STEPS = [
23
31
  ...LOGIN_WINDOW,
24
32
  '想加更多 Google 账号,再来一次即可,每个账号一张卡片。'
25
33
  ].join('\n');
26
34
  const RELOGIN_STEPS = LOGIN_WINDOW.join('\n');
35
+ // Where to set a proxy when the service can't be downloaded from GitHub
36
+ const SETUP_PROXY_HINT = '连不上 GitHub 时,请先在插件选项 studio.proxy 或环境变量 AI_STUDIO_PROXY 里设置代理,再重新登录。';
37
+ // A sign-in's progress page (see signInProgress), as its instructions point to it
38
+ const PAGE_LINE = '下载和登录的进度显示在刚打开的浏览器页面里;没有自动打开的话,把下面的链接复制到浏览器里打开。';
39
+ // A progress page stays up this long after its sign-in ends, to say how it ended.
40
+ const PAGE_LINGER_MS = 2 * 60 * 1000;
27
41
  // AdminAccount.state as the account list shows it
28
42
  const ACCOUNT_STATES = {
29
43
  ready: '可用',
@@ -465,6 +479,9 @@ export function proxyLine(proxy, local) {
465
479
  line += `;管理页保存的是 ${named(proxy.saved)},但服务启动时的环境变量盖住了它(旧版插件启动的服务会这样)。${restart}后应按管理页设置生效;如果由 Magpie 再次启动后仍被盖住,检查插件选项 studio.proxy 和环境变量 AI_STUDIO_PROXY`;
466
480
  return line;
467
481
  }
482
+ if (proxy.source === 'legacy') {
483
+ return `${line}。如果它来自启动时的环境变量(例如 Magpie 或终端里的 HTTPS_PROXY),${restart}后就不再使用它,改按管理页设置;如果来自系统代理设置,重启后仍会使用`;
484
+ }
468
485
  if (proxy.pending === 'restart') {
469
486
  line += `;${PROXY_SOURCES[proxy.wantedSource]}设置的是 ${named(proxy.wanted)},${restart}后生效`;
470
487
  } else if (proxy.pending === 'generation') {
@@ -492,6 +509,126 @@ function serviceWindows(local, proxy) {
492
509
  return windows;
493
510
  }
494
511
 
512
+ // TooLate is a deadline passing before what was waited for (byDeadline).
513
+ class TooLate extends Error {}
514
+
515
+ // byDeadline is work, or a TooLate when deadline (ms since the epoch) passes first; the work goes
516
+ // on behind it.
517
+ function byDeadline(work, deadline) {
518
+ let timer;
519
+ const late = new Promise((_, reject) => {
520
+ timer = setTimeout(() => reject(new TooLate()), Math.max(0, deadline - Date.now()));
521
+ });
522
+ return Promise.race([work, late]).finally(() => clearTimeout(timer));
523
+ }
524
+
525
+ // failedWhy is why a sign-in failed, as its error says it: LATE once its deadline has passed (a
526
+ // request cut off by it fails with an abort of its own).
527
+ function failedWhy(err, deadline) {
528
+ return err instanceof TooLate || Date.now() >= deadline ? LATE : err.message;
529
+ }
530
+
531
+ // accountQuestion is the sign-in's question which Google account to sign in (opts the plugin's):
532
+ // a new one, or one the local service has. null when there is nothing to ask: no service set up,
533
+ // no account in it, or a service not up within waitMs.
534
+ export async function accountQuestion(opts, waitMs = ASK_WAIT_MS) {
535
+ if (!localStudio(opts)) return null;
536
+ try {
537
+ await byDeadline(ensureStudio({ proxy: opts.proxy, managementOnly: true }), Date.now() + waitMs);
538
+ } catch (err) {
539
+ if (err instanceof TooLate) return null;
540
+ throw err;
541
+ }
542
+ const accounts = await studioAccounts(opts);
543
+ if (!accounts.length) return null;
544
+ return {
545
+ type: 'select',
546
+ key: 'account',
547
+ message: '选择 Google 账号',
548
+ placeholder: '',
549
+ options: [
550
+ { label: '添加新的 Google 账号', value: NEW_ACCOUNT, hint: '打开登录窗口' },
551
+ ...accounts.map(a => ({
552
+ label: labelOf(a),
553
+ value: a.id,
554
+ hint: a.state === 'auth_required' ? '需要重新登录:会打开登录窗口' : ACCOUNT_STATES[a.state] || a.state || ''
555
+ }))
556
+ ]
557
+ };
558
+ }
559
+
560
+ // signInProgress opens a progress page for a sign-in with something to wait for that Magpie can't
561
+ // show as it goes (its card shows the instructions authorize gives, and nothing after): the
562
+ // service to set up (serviceUp false), or Camoufox to download. Magpie opens the page as the
563
+ // sign-in's url. null when there is nothing to wait for, or the page can't open; login() says the
564
+ // service is up and the login has begun, end(error) that the sign-in ended.
565
+ export async function signInProgress(serviceUp) {
566
+ const dataDir = studioDataDir();
567
+ if (serviceUp && camoufoxState(dataDir).ready) return null;
568
+ const sign = { at: 'service', error: '' };
569
+ let page;
570
+ try {
571
+ page = await openProgressPage(() => progressSteps(sign, dataDir));
572
+ } catch (err) {
573
+ console.error(`[ai-studio] could not open the sign-in progress page: ${err.message}`);
574
+ return null;
575
+ }
576
+ // The first end stands: a step that ends after the sign-in did changes nothing.
577
+ let ended = false;
578
+ return {
579
+ url: page.url,
580
+ login() {
581
+ if (!ended) sign.at = 'login';
582
+ },
583
+ end(error) {
584
+ if (ended) return;
585
+ ended = true;
586
+ if (error) sign.error = error;
587
+ else sign.at = 'done';
588
+ setTimeout(page.close, PAGE_LINGER_MS).unref();
589
+ }
590
+ };
591
+ }
592
+
593
+ // progressSteps is what a sign-in's progress page shows: the service set up, Camoufox downloaded,
594
+ // the login itself, and how far the one under way has got.
595
+ function progressSteps(sign, dataDir) {
596
+ const steps = [
597
+ { label: '准备本地 AI Studio 服务', state: 'done' },
598
+ { label: '准备登录用的浏览器 Camoufox', state: 'pending' },
599
+ { label: '在弹出的登录窗口里登录 Google 账号', state: 'pending' }
600
+ ];
601
+ let current;
602
+ if (sign.at === 'service') {
603
+ current = steps[0];
604
+ const setup = setupStep(dataDir);
605
+ if (setup?.step === 'download') Object.assign(current, { detail: '正在从 GitHub 下载', received: setup.received, total: setup.total });
606
+ else current.detail = { install: '正在安装', start: '正在启动' }[setup?.step] ?? '正在检查';
607
+ } else if (sign.at === 'login') {
608
+ const camoufox = camoufoxState(dataDir);
609
+ if (camoufox.ready) {
610
+ steps[1].state = 'done';
611
+ current = steps[2];
612
+ current.detail = '登录窗口是单独弹出的,在里面登录即可,登录完可以关掉它。';
613
+ } else {
614
+ current = steps[1];
615
+ if (camoufox.unpacking) current.detail = '正在解压';
616
+ else Object.assign(current, { detail: '正在从 GitHub 下载(约 300 MB)', received: camoufox.received, total: camoufox.total });
617
+ }
618
+ } else {
619
+ for (const step of steps) step.state = 'done';
620
+ }
621
+ if (current && sign.error) Object.assign(current, { state: 'failed', detail: '没有完成' });
622
+ else if (current) current.state = 'active';
623
+ return {
624
+ note: '这个页面只显示进度,由 Magpie 的 magpie-gemini-web 插件打开;Google 登录在另外弹出的窗口里完成。',
625
+ steps,
626
+ done: sign.at === 'done',
627
+ doneText: '登录完成了,可以关掉这个页面,回到 Magpie。',
628
+ error: sign.error
629
+ };
630
+ }
631
+
495
632
  export const AIStudioPlugin = async (_input, options = {}) => {
496
633
  const opts = studioSettings(options);
497
634
 
@@ -523,26 +660,48 @@ export const AIStudioPlugin = async (_input, options = {}) => {
523
660
  }
524
661
  }
525
662
 
663
+ // setupNote is a line for a sign-in that must set the service up before its login window can
664
+ // open: the download (from GitHub, and on Windows not through the system proxy) shows nothing
665
+ // until it ends, which can take minutes.
666
+ function setupNote() {
667
+ let proxy = '';
668
+ try {
669
+ proxy = downloadProxy(studioDataDir(), opts.proxy);
670
+ } catch {}
671
+ const via = proxy ? `走代理 ${redactProxy(proxy)}` : '直连 GitHub,不走系统代理';
672
+ return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 25 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
673
+ }
674
+
526
675
  // addAccount signs a new Google account in through the service's own login window, setting the
527
- // service up first if this is the first.
676
+ // service up first if this is the first. Setting it up waits in the callback, after the
677
+ // instructions show, so the sign-in doesn't sit without a word while it downloads.
528
678
  async function addAccount() {
529
- const ready = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
679
+ const installed = localStudio(opts);
680
+ const serviceUp = !!installed && (await checkStudioHealth(installed, globalThis.fetch)).state === 'up';
681
+ const note = serviceUp ? await proxyNote(installed) : '';
682
+ const progress = await signInProgress(serviceUp);
683
+ const deadline = Date.now() + SIGN_IN_BUDGET_MS;
530
684
  return {
531
- url: '',
685
+ url: progress?.url ?? '',
532
686
  method: 'auto',
533
- instructions: LOGIN_STEPS + await proxyNote(ready),
687
+ instructions: (serviceUp ? '' : setupNote()) + (progress ? `${PAGE_LINE}\n` : '') + LOGIN_STEPS + note,
534
688
  async callback() {
535
689
  let local, account;
536
690
  try {
537
- local = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
538
- const res = await studioAdmin(local, 'POST', '/api/accounts', {}, { timeoutMs: LOGIN_TIMEOUT_MS });
691
+ local = await byDeadline(ensureStudio({ proxy: opts.proxy, managementOnly: true }), deadline);
692
+ progress?.login();
693
+ const res = await studioAdmin(local, 'POST', '/api/accounts', {}, { timeoutMs: Math.max(1, deadline - Date.now()) });
539
694
  account = res?.account;
540
695
  if (!account || typeof account.id !== 'string' || !account.id || account.enabled === false) {
541
696
  throw new Error('AIStudio2API did not return an enabled account');
542
697
  }
543
698
  } catch (err) {
544
- return { type: 'failed', error: `Google 登录没有完成:${err.message}${await proxyNote(local)}` };
699
+ const hint = local ? await proxyNote(local) : `\n${SETUP_PROXY_HINT}`;
700
+ const error = `Google 登录没有完成:${failedWhy(err, deadline)}${hint}`;
701
+ progress?.end(error);
702
+ return { type: 'failed', error };
545
703
  }
704
+ progress?.end();
546
705
  return afterLogin(local, account);
547
706
  }
548
707
  };
@@ -550,19 +709,25 @@ export const AIStudioPlugin = async (_input, options = {}) => {
550
709
 
551
710
  // signInAgain signs an account whose Google sign-in expired in again, in a login window.
552
711
  async function signInAgain(ready, chosen) {
712
+ const progress = await signInProgress(true);
713
+ const deadline = Date.now() + SIGN_IN_BUDGET_MS;
553
714
  return {
554
- url: '',
715
+ url: progress?.url ?? '',
555
716
  method: 'auto',
556
- instructions: RELOGIN_STEPS + await proxyNote(ready),
717
+ instructions: (progress ? `${PAGE_LINE}\n` : '') + RELOGIN_STEPS + await proxyNote(ready),
557
718
  async callback() {
558
719
  let local;
559
720
  try {
560
- local = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
561
- const res = await studioAdmin(local, 'POST', `/api/accounts/${encodeURIComponent(chosen.id)}/login`, {}, { timeoutMs: LOGIN_TIMEOUT_MS });
721
+ local = await byDeadline(ensureStudio({ proxy: opts.proxy, managementOnly: true }), deadline);
722
+ progress?.login();
723
+ const res = await studioAdmin(local, 'POST', `/api/accounts/${encodeURIComponent(chosen.id)}/login`, {}, { timeoutMs: Math.max(1, deadline - Date.now()) });
562
724
  if (res?.account?.state === 'auth_required') throw new Error(res.account.message || '账号仍然需要重新登录');
563
725
  } catch (err) {
564
- return { type: 'failed', error: `${labelOf(chosen)} 没有重新登录:${err.message}${await proxyNote(local)}` };
726
+ const error = `${labelOf(chosen)} 没有重新登录:${failedWhy(err, deadline)}${await proxyNote(local)}`;
727
+ progress?.end(error);
728
+ return { type: 'failed', error };
565
729
  }
730
+ progress?.end();
566
731
  return afterLogin(local, chosen);
567
732
  }
568
733
  };
@@ -588,25 +753,8 @@ export const AIStudioPlugin = async (_input, options = {}) => {
588
753
  type: 'oauth',
589
754
  label: SIGN_IN_METHOD,
590
755
  prompts: [{ type: 'select', key: 'account', message: '选择 Google 账号', options: [] }],
591
- async ask(inputs) {
592
- if (inputs?.account || !localStudio(opts)) return null;
593
- await ensureStudio({ proxy: opts.proxy, managementOnly: true });
594
- const accounts = await studioAccounts(opts);
595
- if (!accounts.length) return null;
596
- return {
597
- type: 'select',
598
- key: 'account',
599
- message: '选择 Google 账号',
600
- placeholder: '',
601
- options: [
602
- { label: '添加新的 Google 账号', value: NEW_ACCOUNT, hint: '打开登录窗口' },
603
- ...accounts.map(a => ({
604
- label: labelOf(a),
605
- value: a.id,
606
- hint: a.state === 'auth_required' ? '需要重新登录:会打开登录窗口' : ACCOUNT_STATES[a.state] || a.state || ''
607
- }))
608
- ]
609
- };
756
+ ask(inputs) {
757
+ return inputs?.account ? null : accountQuestion(opts);
610
758
  },
611
759
  async authorize(inputs) {
612
760
  const id = inputs?.account;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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",
@@ -52,6 +52,13 @@ try {
52
52
  }
53
53
  execFileSync('git', ['-C', upstream, 'archive', '--format=tar', `--output=${path.join(scratch, 'upstream.tar')}`, release.upstream.commit]);
54
54
  execFileSync('tar', ['-xf', 'upstream.tar', '-C', 'source'], { cwd: scratch });
55
+ // The plugin tells whether a sign-in will download Camoufox by the version this upstream takes.
56
+ const camoufox = fs.readFileSync(path.join(source, 'internal/camoufoxnative/download.go'), 'utf8')
57
+ .match(/const camoufoxRelease = "([^"]+)"/)?.[1];
58
+ if (!camoufox) throw new Error('Could not find camoufoxRelease in upstream internal/camoufoxnative/download.go');
59
+ if (camoufox !== release.upstream.camoufox) {
60
+ throw new Error(`Upstream takes Camoufox ${camoufox}: set upstream.camoufox in lib/studio-release.json to it (now ${release.upstream.camoufox})`);
61
+ }
55
62
  npm(['ci', '--no-audit', '--no-fund']);
56
63
  npm(['run', 'lint']);
57
64
  npm(['run', 'build']);