magpie-gemini-web 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +15 -6
- package/README.md +15 -6
- package/docs/studio-release.md +12 -12
- package/lib/studio-native.mjs +192 -29
- package/lib/studio-page.mjs +173 -0
- package/lib/studio-release.json +20 -19
- package/lib/studio.mjs +183 -35
- package/package.json +1 -1
- package/scripts/studio-managed-smoke.mjs +7 -5
- package/scripts/studio-release.mjs +13 -6
- package/patches/studio-managed.patch +0 -625
|
@@ -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
|
@@ -1,38 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.14-magpie.1",
|
|
3
3
|
"repository": "BUKOWSKIREAL/magpie-gemini-web",
|
|
4
|
-
"tag": "studio-v0.2.
|
|
4
|
+
"tag": "studio-v0.2.14-magpie.1",
|
|
5
5
|
"goVersion": "1.27.1",
|
|
6
6
|
"upstream": {
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.14",
|
|
8
8
|
"repository": "https://github.com/Mag1cFall/AIStudio2API.git",
|
|
9
|
-
"commit": "
|
|
9
|
+
"commit": "370a313814099b508881807effa2a9d72f2d7e66",
|
|
10
|
+
"camoufox": "152.0.4-beta.29"
|
|
10
11
|
},
|
|
11
12
|
"binaries": {
|
|
12
13
|
"darwin-arm64": {
|
|
13
|
-
"asset": "aistudio2api-v0.2.
|
|
14
|
-
"sha256": "
|
|
15
|
-
"binarySha256": "
|
|
14
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-darwin-arm64.tar.gz",
|
|
15
|
+
"sha256": "f649c21837741bdb448407f79a3bc998429b446408ef3117a897103b9a47ee72",
|
|
16
|
+
"binarySha256": "999e49572dede3a63f538e3f62ceed5f03349459bdf391fd49fd57784985e13b"
|
|
16
17
|
},
|
|
17
18
|
"darwin-x64": {
|
|
18
|
-
"asset": "aistudio2api-v0.2.
|
|
19
|
-
"sha256": "
|
|
20
|
-
"binarySha256": "
|
|
19
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-darwin-amd64.tar.gz",
|
|
20
|
+
"sha256": "ece680b5dfef57b0b0e96d639b618b74570e6e194f58808d762da85fd41d9b4a",
|
|
21
|
+
"binarySha256": "970df3ac51719721d908162d0233d80711f6c3ee007e193d0a73b6e4e596ca9a"
|
|
21
22
|
},
|
|
22
23
|
"linux-x64": {
|
|
23
|
-
"asset": "aistudio2api-v0.2.
|
|
24
|
-
"sha256": "
|
|
25
|
-
"binarySha256": "
|
|
24
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-linux-amd64.tar.gz",
|
|
25
|
+
"sha256": "f6c7b52a739e4534236f055280fcddd48e64b56fdb954a7844eb453226d5b67f",
|
|
26
|
+
"binarySha256": "316306225bcc343188d6d81635d4bd79e7e82bd5c51b1009cd25361deb3b5e61"
|
|
26
27
|
},
|
|
27
28
|
"linux-arm64": {
|
|
28
|
-
"asset": "aistudio2api-v0.2.
|
|
29
|
-
"sha256": "
|
|
30
|
-
"binarySha256": "
|
|
29
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-linux-arm64.tar.gz",
|
|
30
|
+
"sha256": "a5b838b9b64e78d402372cae6b88e1f3faba406747e598e976728c71da3a75ef",
|
|
31
|
+
"binarySha256": "a5e9573b9edad7fb246178fb4c3d21a0d4905789fd59abf96656d57f26b7526e"
|
|
31
32
|
},
|
|
32
33
|
"win32-x64": {
|
|
33
|
-
"asset": "aistudio2api-v0.2.
|
|
34
|
-
"sha256": "
|
|
35
|
-
"binarySha256": "
|
|
34
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-windows-amd64.zip",
|
|
35
|
+
"sha256": "ccd5b96dabebbc0f1d510959c698beb9d9af09bb65d8b0e021327970b3173384",
|
|
36
|
+
"binarySha256": "ce784c98f1558395a670421ef6cc7d36dde5313f74788f7df5772851c241ae06"
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
}
|
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
|
@@ -25,6 +25,7 @@ try {
|
|
|
25
25
|
for (const query of ['', '?refresh=1']) {
|
|
26
26
|
const update = await studioAdmin(local, 'GET', `/api/update${query}`);
|
|
27
27
|
assert.equal(update.mode, 'managed');
|
|
28
|
+
assert.equal(update.managed_by, 'magpie-gemini-web');
|
|
28
29
|
assert.equal(update.available, false);
|
|
29
30
|
assert.equal(update.updating, false);
|
|
30
31
|
assert.equal(update.current, STUDIO_VERSION);
|
|
@@ -36,8 +37,8 @@ try {
|
|
|
36
37
|
if (String(args[0]).endsWith('/api/update')) updateHTTP = response.status;
|
|
37
38
|
return response;
|
|
38
39
|
}
|
|
39
|
-
}), /
|
|
40
|
-
assert.equal(updateHTTP,
|
|
40
|
+
}), /magpie-gemini-web/);
|
|
41
|
+
assert.equal(updateHTTP, 409);
|
|
41
42
|
assert.equal(computeFileHash(bin), pin.binarySha256, 'update must not replace the binary');
|
|
42
43
|
assert.equal(fs.readFileSync(path.join(dataDir, 'backend.pid'), 'utf8'), pidBefore, 'update must not restart');
|
|
43
44
|
const html = await (await fetch(local.origin)).text();
|
|
@@ -47,11 +48,12 @@ try {
|
|
|
47
48
|
const versionPanel = /(?:\.\/|\/assets\/)?(VersionPanel-[\w-]+\.js)/.exec(js)?.[1];
|
|
48
49
|
assert.ok(versionPanel, 'built admin version panel must exist');
|
|
49
50
|
const panel = await (await fetch(`${local.origin}/assets/${versionPanel}`)).text();
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// Upstream's panel keeps its install button for other builds and shows a managed build's note
|
|
52
|
+
// in its place; the refusal above is what keeps the binary
|
|
53
|
+
assert.match(panel, /version\.managed/);
|
|
52
54
|
assert.deepEqual(await stopStudio({ dataDir }), { stopped: true });
|
|
53
55
|
assert.equal((await studioStatus({ dataDir })).status, 'stopped');
|
|
54
|
-
console.log(`PASS ${key}: ${STUDIO_VERSION}, managed status, update HTTP
|
|
56
|
+
console.log(`PASS ${key}: ${STUDIO_VERSION}, managed status, update HTTP 409, unchanged executable/PID, managed UI, clean stop`);
|
|
55
57
|
} finally {
|
|
56
58
|
let stopped = !local;
|
|
57
59
|
if (local) stopped = (await stopStudio({ dataDir }).catch(() => ({ stopped: false }))).stopped;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Clean upstream commit
|
|
2
|
+
// Clean upstream commit -> managed archives, never a developer's dirty tree. Upstream's own
|
|
3
|
+
// distributor settings make the build managed: it reports this version, and its online updater
|
|
4
|
+
// neither checks GitHub nor installs, saying the plugin manages updates.
|
|
3
5
|
import fs from 'node:fs';
|
|
4
6
|
import os from 'node:os';
|
|
5
7
|
import path from 'node:path';
|
|
@@ -34,9 +36,9 @@ const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).d
|
|
|
34
36
|
const go = (argv, extra = {}) => execFileSync('go', argv, { cwd: source, env: { ...env, ...extra }, stdio: 'inherit' });
|
|
35
37
|
const npm = argv => execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv,
|
|
36
38
|
{ cwd: path.join(source, 'web'), stdio: 'inherit', shell: process.platform === 'win32' });
|
|
37
|
-
const
|
|
39
|
+
const MANAGED_BY = 'magpie-gemini-web';
|
|
38
40
|
const flags = ['-mod=readonly', '-trimpath', '-buildvcs=false',
|
|
39
|
-
`-ldflags=-s -w -X github.com/Mag1cFall/AIStudio2API/internal/app.
|
|
41
|
+
`-ldflags=-s -w -X github.com/Mag1cFall/AIStudio2API/internal/app.version=v${release.version} -X github.com/Mag1cFall/AIStudio2API/internal/app.managedBy=${MANAGED_BY}`];
|
|
40
42
|
|
|
41
43
|
try {
|
|
42
44
|
let upstream = args.find(arg => arg.startsWith('--source='))?.slice(9);
|
|
@@ -50,8 +52,13 @@ try {
|
|
|
50
52
|
}
|
|
51
53
|
execFileSync('git', ['-C', upstream, 'archive', '--format=tar', `--output=${path.join(scratch, 'upstream.tar')}`, release.upstream.commit]);
|
|
52
54
|
execFileSync('tar', ['-xf', 'upstream.tar', '-C', 'source'], { cwd: scratch });
|
|
53
|
-
|
|
54
|
-
|
|
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']);
|
|
@@ -74,7 +81,7 @@ try {
|
|
|
74
81
|
fs.copyFileSync(path.join(source, 'LICENSE'), path.join(output, 'LICENSE-AISTUDIO'));
|
|
75
82
|
fs.writeFileSync(path.join(output, target ? `build-${target}.json` : 'build.json'), JSON.stringify({
|
|
76
83
|
version: release.version, upstream: release.upstream, goVersion: release.goVersion,
|
|
77
|
-
nodeVersion: process.version,
|
|
84
|
+
nodeVersion: process.version, managedBy: MANAGED_BY, flags, binaries
|
|
78
85
|
}, null, 2) + '\n');
|
|
79
86
|
fs.writeFileSync(path.join(output, target ? `SHA256SUMS-${target}.txt` : 'SHA256SUMS.txt'),
|
|
80
87
|
Object.values(binaries).map(pin => `${pin.sha256} ${pin.asset}\n`).join(''));
|