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 +12 -3
- package/README.md +12 -3
- package/docs/studio-release.md +1 -1
- package/lib/studio-native.mjs +174 -22
- package/lib/studio-page.mjs +173 -0
- package/lib/studio-release.json +2 -1
- package/lib/studio.mjs +183 -35
- package/package.json +1 -1
- package/scripts/studio-release.mjs +7 -0
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)
|
|
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
|
|
93
|
-
- **The provider proxy set for `ai-studio`
|
|
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
|
-
>
|
|
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
|
-
-
|
|
93
|
-
-
|
|
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
|
| 命令 | 作用 |
|
package/docs/studio-release.md
CHANGED
|
@@ -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
|
|
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
|
|
package/lib/studio-native.mjs
CHANGED
|
@@ -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: '
|
|
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)
|
|
641
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
+
`;
|
package/lib/studio-release.json
CHANGED
|
@@ -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
|
-
//
|
|
15
|
-
|
|
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
|
-
'
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
@@ -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']);
|