magpie-gemini-web 0.5.2 → 0.5.4
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 +10 -5
- package/README.md +10 -5
- package/docs/studio-release.md +12 -12
- package/lib/studio-native.mjs +285 -43
- package/lib/studio-release.json +19 -19
- package/lib/studio.mjs +72 -18
- package/package.json +1 -1
- package/scripts/studio-managed-smoke.mjs +7 -5
- package/scripts/studio-release.mjs +6 -6
- package/scripts/studio.mjs +17 -26
- package/patches/studio-managed.patch +0 -625
package/lib/studio.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER } from './backend.mjs';
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { localStudio, ensureStudio, studioAdmin, studioAccounts, studioProxy, studioCommand, validateProxy, isConnectRefused, PROXY_SOURCES } from './studio-native.mjs';
|
|
3
4
|
|
|
4
5
|
export const STUDIO_ID = 'ai-studio';
|
|
5
6
|
export const STUDIO_BASE = 'http://127.0.0.1:2048/v1';
|
|
@@ -454,6 +455,43 @@ export function poolUsage(accounts, cooldowns, now = Date.now()) {
|
|
|
454
455
|
return { user, windows: cooldownWindows(cooldowns, accounts, now) };
|
|
455
456
|
}
|
|
456
457
|
|
|
458
|
+
// proxyLine says which proxy the local service goes out through, where that comes from, a saved
|
|
459
|
+
// one set aside for another, and what it takes for the one set to apply (see studioProxy).
|
|
460
|
+
export function proxyLine(proxy, local) {
|
|
461
|
+
const named = p => p || '不走代理';
|
|
462
|
+
const restart = `重启本地服务(${studioCommand('stop', { dataDir: local.dataDir })},下一次请求会自动启动)`;
|
|
463
|
+
let line = `${named(proxy.active)}(${PROXY_SOURCES[proxy.source]})`;
|
|
464
|
+
if (proxy.overridden === 'unknown') {
|
|
465
|
+
line += `;管理页保存的是 ${named(proxy.saved)},但服务启动时的环境变量盖住了它(旧版插件启动的服务会这样)。${restart}后应按管理页设置生效;如果由 Magpie 再次启动后仍被盖住,检查插件选项 studio.proxy 和环境变量 AI_STUDIO_PROXY`;
|
|
466
|
+
return line;
|
|
467
|
+
}
|
|
468
|
+
if (proxy.pending === 'restart') {
|
|
469
|
+
line += `;${PROXY_SOURCES[proxy.wantedSource]}设置的是 ${named(proxy.wanted)},${restart}后生效`;
|
|
470
|
+
} else if (proxy.pending === 'generation') {
|
|
471
|
+
line += proxy.source === 'unknown'
|
|
472
|
+
? ';管理页有改动还没生效,在管理页重启服务后生效'
|
|
473
|
+
: `;管理页保存的 ${named(proxy.wanted)} 要在管理页重启服务后生效`;
|
|
474
|
+
}
|
|
475
|
+
if (proxy.overridden) {
|
|
476
|
+
const by = PROXY_SOURCES[proxy.overridden];
|
|
477
|
+
line += `;管理页保存的 ${proxy.saved} 被 ${by} 覆盖,不会生效。要改用它,先去掉 ${by},再${restart}`;
|
|
478
|
+
}
|
|
479
|
+
return line;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// serviceWindows are the usage card's lines about the local service itself: the proxy it goes out
|
|
483
|
+
// through (when it could be read), and where its admin page is.
|
|
484
|
+
function serviceWindows(local, proxy) {
|
|
485
|
+
const windows = [];
|
|
486
|
+
if (proxy) windows.push({ name: '代理出口', used: 0, display: proxyLine(proxy, local) });
|
|
487
|
+
windows.push({
|
|
488
|
+
name: '管理页',
|
|
489
|
+
used: 0,
|
|
490
|
+
display: `${local.origin}(用户名 ${local.adminUsername},密码是 ${path.join(local.dataDir, 'config.json')} 里的 adminPassword)`
|
|
491
|
+
});
|
|
492
|
+
return windows;
|
|
493
|
+
}
|
|
494
|
+
|
|
457
495
|
export const AIStudioPlugin = async (_input, options = {}) => {
|
|
458
496
|
const opts = studioSettings(options);
|
|
459
497
|
|
|
@@ -473,14 +511,26 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
473
511
|
return accountSignIn(local, account);
|
|
474
512
|
}
|
|
475
513
|
|
|
514
|
+
// proxyNote is a line on the proxy the local service goes out through, for a sign-in to show
|
|
515
|
+
// before its login window opens and when it fails: a wrong proxy is the usual reason the window
|
|
516
|
+
// can't reach Google, and nothing else shows it before an account is signed in.
|
|
517
|
+
async function proxyNote(local) {
|
|
518
|
+
if (!local) return '';
|
|
519
|
+
try {
|
|
520
|
+
return `\n本地服务的代理出口:${proxyLine(await studioProxy(local, opts), local)}`;
|
|
521
|
+
} catch {
|
|
522
|
+
return '';
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
476
526
|
// addAccount signs a new Google account in through the service's own login window, setting the
|
|
477
527
|
// service up first if this is the first.
|
|
478
528
|
async function addAccount() {
|
|
479
|
-
await ensureStudio({ proxy: opts.proxy, managementOnly: true });
|
|
529
|
+
const ready = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
|
|
480
530
|
return {
|
|
481
531
|
url: '',
|
|
482
532
|
method: 'auto',
|
|
483
|
-
instructions: LOGIN_STEPS,
|
|
533
|
+
instructions: LOGIN_STEPS + await proxyNote(ready),
|
|
484
534
|
async callback() {
|
|
485
535
|
let local, account;
|
|
486
536
|
try {
|
|
@@ -491,7 +541,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
491
541
|
throw new Error('AIStudio2API did not return an enabled account');
|
|
492
542
|
}
|
|
493
543
|
} catch (err) {
|
|
494
|
-
return { type: 'failed', error: `Google 登录没有完成:${err.message}` };
|
|
544
|
+
return { type: 'failed', error: `Google 登录没有完成:${err.message}${await proxyNote(local)}` };
|
|
495
545
|
}
|
|
496
546
|
return afterLogin(local, account);
|
|
497
547
|
}
|
|
@@ -499,11 +549,11 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
499
549
|
}
|
|
500
550
|
|
|
501
551
|
// signInAgain signs an account whose Google sign-in expired in again, in a login window.
|
|
502
|
-
function signInAgain(chosen) {
|
|
552
|
+
async function signInAgain(ready, chosen) {
|
|
503
553
|
return {
|
|
504
554
|
url: '',
|
|
505
555
|
method: 'auto',
|
|
506
|
-
instructions: RELOGIN_STEPS,
|
|
556
|
+
instructions: RELOGIN_STEPS + await proxyNote(ready),
|
|
507
557
|
async callback() {
|
|
508
558
|
let local;
|
|
509
559
|
try {
|
|
@@ -511,7 +561,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
511
561
|
const res = await studioAdmin(local, 'POST', `/api/accounts/${encodeURIComponent(chosen.id)}/login`, {}, { timeoutMs: LOGIN_TIMEOUT_MS });
|
|
512
562
|
if (res?.account?.state === 'auth_required') throw new Error(res.account.message || '账号仍然需要重新登录');
|
|
513
563
|
} catch (err) {
|
|
514
|
-
return { type: 'failed', error: `${labelOf(chosen)} 没有重新登录:${err.message}` };
|
|
564
|
+
return { type: 'failed', error: `${labelOf(chosen)} 没有重新登录:${err.message}${await proxyNote(local)}` };
|
|
515
565
|
}
|
|
516
566
|
return afterLogin(local, chosen);
|
|
517
567
|
}
|
|
@@ -564,7 +614,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
564
614
|
const local = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
|
|
565
615
|
const chosen = (await studioAccounts(opts)).find(a => a.id === id);
|
|
566
616
|
if (!chosen) throw new Error(`本地 Google AI Studio 里没有启用的账号 ${id}`);
|
|
567
|
-
if (chosen.state === 'auth_required') return signInAgain(chosen);
|
|
617
|
+
if (chosen.state === 'auth_required') return signInAgain(local, chosen);
|
|
568
618
|
return { url: '', method: 'auto', instructions: '', callback: async () => accountSignIn(local, chosen) };
|
|
569
619
|
}
|
|
570
620
|
},
|
|
@@ -631,12 +681,13 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
631
681
|
return { error: '本地 AI Studio 配置不匹配,请重新登录', windows: [], signIn: 'expired' };
|
|
632
682
|
}
|
|
633
683
|
|
|
634
|
-
let accounts, cooldowns;
|
|
684
|
+
let accounts, cooldowns, proxy;
|
|
635
685
|
try {
|
|
636
|
-
[accounts, cooldowns] = await Promise.all([
|
|
686
|
+
[accounts, cooldowns, proxy] = await Promise.all([
|
|
637
687
|
studioAdmin(local, 'GET', '/api/accounts', null, { timeoutMs: 3000 }),
|
|
638
|
-
// Without its cooldowns the card still says whose accounts these are.
|
|
639
|
-
studioAdmin(local, 'GET', '/api/cooldowns', null, { timeoutMs: 3000 }).catch(() => null)
|
|
688
|
+
// Without its cooldowns or proxy the card still says whose accounts these are.
|
|
689
|
+
studioAdmin(local, 'GET', '/api/cooldowns', null, { timeoutMs: 3000 }).catch(() => null),
|
|
690
|
+
studioProxy(local, opts).catch(() => null)
|
|
640
691
|
]);
|
|
641
692
|
} catch (err) {
|
|
642
693
|
if (isConnectRefused(err)) {
|
|
@@ -647,15 +698,18 @@ export const AIStudioPlugin = async (_input, options = {}) => {
|
|
|
647
698
|
const enabled = (Array.isArray(accounts?.accounts) ? accounts.accounts : []).filter(a => a && a.enabled !== false);
|
|
648
699
|
const cooling = Array.isArray(cooldowns?.cooldowns) ? cooldowns.cooldowns : [];
|
|
649
700
|
const id = auth.metadata.account;
|
|
701
|
+
const service = serviceWindows(local, proxy);
|
|
702
|
+
let card;
|
|
650
703
|
if (typeof id !== 'string' || !id) {
|
|
651
704
|
// A sign-in for the whole pool, as 0.5.0 made them
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
705
|
+
card = poolUsage(enabled, cooling);
|
|
706
|
+
} else {
|
|
707
|
+
const account = enabled.find(a => a.id === id);
|
|
708
|
+
card = account
|
|
709
|
+
? accountUsage(account, cooling)
|
|
710
|
+
: { error: `本地 AI Studio 里已经没有这个账号(${auth.metadata.email || id}),可能被停用或删除了`, windows: [] };
|
|
657
711
|
}
|
|
658
|
-
return { ...
|
|
712
|
+
return { ...card, windows: [...card.windows, ...service], signIn: 'kept' };
|
|
659
713
|
}
|
|
660
714
|
},
|
|
661
715
|
provider: {
|
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,6 @@ 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
|
-
execFileSync('git', ['apply', '--check', patch], { cwd: source, stdio: 'inherit' });
|
|
54
|
-
execFileSync('git', ['apply', patch], { cwd: source, stdio: 'inherit' });
|
|
55
55
|
npm(['ci', '--no-audit', '--no-fund']);
|
|
56
56
|
npm(['run', 'lint']);
|
|
57
57
|
npm(['run', 'build']);
|
|
@@ -74,7 +74,7 @@ try {
|
|
|
74
74
|
fs.copyFileSync(path.join(source, 'LICENSE'), path.join(output, 'LICENSE-AISTUDIO'));
|
|
75
75
|
fs.writeFileSync(path.join(output, target ? `build-${target}.json` : 'build.json'), JSON.stringify({
|
|
76
76
|
version: release.version, upstream: release.upstream, goVersion: release.goVersion,
|
|
77
|
-
nodeVersion: process.version,
|
|
77
|
+
nodeVersion: process.version, managedBy: MANAGED_BY, flags, binaries
|
|
78
78
|
}, null, 2) + '\n');
|
|
79
79
|
fs.writeFileSync(path.join(output, target ? `SHA256SUMS-${target}.txt` : 'SHA256SUMS.txt'),
|
|
80
80
|
Object.values(binaries).map(pin => `${pin.sha256} ${pin.asset}\n`).join(''));
|
package/scripts/studio.mjs
CHANGED
|
@@ -11,11 +11,11 @@ import {
|
|
|
11
11
|
studioAccounts,
|
|
12
12
|
stopStudio,
|
|
13
13
|
studioStatus,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
generateEnv,
|
|
14
|
+
studioProxy,
|
|
15
|
+
saveStudioProxy,
|
|
17
16
|
studioCommand
|
|
18
17
|
} from '../lib/studio-native.mjs';
|
|
18
|
+
import { proxyLine } from '../lib/studio.mjs';
|
|
19
19
|
|
|
20
20
|
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
21
21
|
|
|
@@ -63,22 +63,10 @@ function parseArgs(args) {
|
|
|
63
63
|
return flags;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
config.proxy = validateProxy(proxy);
|
|
71
|
-
const tmp = `${configPath}.tmp.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
|
|
72
|
-
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
73
|
-
fs.renameSync(tmp, configPath);
|
|
74
|
-
fs.chmodSync(configPath, 0o600);
|
|
75
|
-
|
|
76
|
-
const envPath = path.join(dataDir, '.env');
|
|
77
|
-
const envContent = generateEnv(dataDir, config, { proxy: config.proxy });
|
|
78
|
-
const tmpEnv = `${envPath}.tmp.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
|
|
79
|
-
fs.writeFileSync(tmpEnv, envContent, { mode: 0o600 });
|
|
80
|
-
fs.renameSync(tmpEnv, envPath);
|
|
81
|
-
fs.chmodSync(envPath, 0o600);
|
|
66
|
+
// saveProxy saves the proxy given with --proxy as the service's own, as its admin page does,
|
|
67
|
+
// once the data directory is set up.
|
|
68
|
+
function saveProxy(dataDir, proxy) {
|
|
69
|
+
if (proxy !== undefined && fs.existsSync(path.join(dataDir, 'config.json'))) saveStudioProxy(dataDir, proxy);
|
|
82
70
|
}
|
|
83
71
|
|
|
84
72
|
function validateStorageState(filePath) {
|
|
@@ -128,15 +116,13 @@ async function main() {
|
|
|
128
116
|
if (status.status === 'running' || status.status === 'outdated' || status.status === 'unresponsive') {
|
|
129
117
|
throw new Error('Cannot install or update AI Studio while service is running. Stop the service first.');
|
|
130
118
|
}
|
|
131
|
-
if (flags.proxy !== undefined) {
|
|
132
|
-
await updateProxyConfig(dataDir, flags.proxy);
|
|
133
|
-
}
|
|
134
119
|
console.log(`Installing AIStudio2API into ${dataDir}...`);
|
|
135
120
|
const local = await installStudio({
|
|
136
121
|
dataDir,
|
|
137
122
|
archivePath: flags.archive,
|
|
138
123
|
proxy: flags.proxy
|
|
139
124
|
});
|
|
125
|
+
saveProxy(dataDir, flags.proxy);
|
|
140
126
|
console.log(`AIStudio2API installed successfully.`);
|
|
141
127
|
console.log(`Listen origin: ${local.origin}`);
|
|
142
128
|
console.log(`Data directory: ${local.dataDir}`);
|
|
@@ -155,14 +141,12 @@ async function main() {
|
|
|
155
141
|
console.log(`Management URL: ${local.origin}`);
|
|
156
142
|
return;
|
|
157
143
|
}
|
|
158
|
-
if (flags.proxy !== undefined) {
|
|
159
|
-
await updateProxyConfig(dataDir, flags.proxy);
|
|
160
|
-
}
|
|
161
144
|
if (status.status === 'outdated') {
|
|
162
145
|
console.log(`Replacing AIStudio2API ${status.version} from an earlier install with ${status.expected}...`);
|
|
163
146
|
}
|
|
164
147
|
console.log(`Starting AIStudio2API service in ${dataDir}...`);
|
|
165
|
-
|
|
148
|
+
// --proxy is saved as the service's own, as its admin page saves one, not set over it
|
|
149
|
+
const local = await ensureStudio({ dataDir, savedProxy: flags.proxy });
|
|
166
150
|
console.log(`AIStudio2API service is running.`);
|
|
167
151
|
console.log(`API Base URL: ${local.baseURL}`);
|
|
168
152
|
console.log(`Management URL: ${local.origin}`);
|
|
@@ -175,6 +159,13 @@ async function main() {
|
|
|
175
159
|
console.log(`State: ${status.state || 'RUNNING'}`);
|
|
176
160
|
console.log(`Listen port: ${status.port}`);
|
|
177
161
|
console.log(`Accounts: ready=${status.accounts?.ready ?? 0}, total=${status.accounts?.total ?? 0}`);
|
|
162
|
+
const local = localStudio({ dataDir });
|
|
163
|
+
console.log(`Management URL: ${local.origin} (user ${local.adminUsername}, password: adminPassword in ${path.join(dataDir, 'config.json')})`);
|
|
164
|
+
try {
|
|
165
|
+
console.log(`Proxy: ${proxyLine(await studioProxy(local, { standalone: true }), local)}`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.log(`Proxy: could not be read (${err.message})`);
|
|
168
|
+
}
|
|
178
169
|
const accounts = await studioAccounts({ dataDir });
|
|
179
170
|
if (accounts.length > 0) {
|
|
180
171
|
console.log(`Enabled accounts:`);
|