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/lib/studio.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER } from './backend.mjs';
2
- import { localStudio, ensureStudio, studioAdmin, studioAccounts, validateProxy, isConnectRefused } from './studio-native.mjs';
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
- return { ...poolUsage(enabled, cooling), signIn: 'kept' };
653
- }
654
- const account = enabled.find(a => a.id === id);
655
- if (!account) {
656
- return { error: `本地 AI Studio 里已经没有这个账号(${auth.metadata.email || id}),可能被停用或删除了`, windows: [], signIn: 'kept' };
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 { ...accountUsage(account, cooling), signIn: 'kept' };
712
+ return { ...card, windows: [...card.windows, ...service], signIn: 'kept' };
659
713
  }
660
714
  },
661
715
  provider: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Gemini Web and Google AI Studio provider for Magpie, backed by gemini-web2api-go and AIStudio2API",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -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
- }), /Magpie/);
40
- assert.equal(updateHTTP, 403);
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
- assert.match(panel, /Online updates are disabled/);
51
- assert.ok(!panel.includes('applyUpdate'), 'version panel must not offer online installation');
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 403, unchanged executable/PID, managed UI, clean stop`);
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 + reviewed patch -> managed archives, never a developer's dirty tree.
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 patch = path.join(root, 'patches/studio-managed.patch');
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.managedVersion=v${release.version}`];
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, patchSha256: hash(patch), flags, binaries
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(''));
@@ -11,11 +11,11 @@ import {
11
11
  studioAccounts,
12
12
  stopStudio,
13
13
  studioStatus,
14
- validateConfig,
15
- validateProxy,
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
- async function updateProxyConfig(dataDir, proxy) {
67
- const configPath = path.join(dataDir, 'config.json');
68
- if (!fs.existsSync(configPath)) return;
69
- const config = validateConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
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
- const local = await ensureStudio({ dataDir, proxy: flags.proxy });
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:`);