@isen/chatgpt-image-web-mcp 0.3.0
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/LICENSE +21 -0
- package/README.md +273 -0
- package/dist/browser-manager.d.ts +100 -0
- package/dist/browser-manager.js +710 -0
- package/dist/browser-manager.js.map +1 -0
- package/dist/chatgpt-automation.d.ts +146 -0
- package/dist/chatgpt-automation.js +901 -0
- package/dist/chatgpt-automation.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +159 -0
- package/dist/index.js.map +1 -0
- package/dist/utils.d.ts +12 -0
- package/dist/utils.js +117 -0
- package/dist/utils.js.map +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { chromium } from "playwright-core";
|
|
7
|
+
import { atomicWriteJson, ensureDirectory, processIsAlive, readJsonFile, resolvePath, sleep, } from "./utils.js";
|
|
8
|
+
const CHATGPT_URL = process.env.IMAGE_BROWSER_START_URL || "https://chatgpt.com/";
|
|
9
|
+
const AGENT_TAB_PREFIX = "chatgpt-image-web-mcp-agent:";
|
|
10
|
+
function defaultStateDirectory() {
|
|
11
|
+
if (process.env.IMAGE_BROWSER_HOME)
|
|
12
|
+
return resolvePath(process.env.IMAGE_BROWSER_HOME);
|
|
13
|
+
if (process.platform === "darwin") {
|
|
14
|
+
return path.join(os.homedir(), "Library", "Application Support", "ChatGPTImageWebMCP");
|
|
15
|
+
}
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
return path.join(process.env.LOCALAPPDATA || os.homedir(), "ChatGPTImageWebMCP");
|
|
18
|
+
}
|
|
19
|
+
return path.join(os.homedir(), ".local", "share", "chatgpt-image-web-mcp");
|
|
20
|
+
}
|
|
21
|
+
function boundedParallelism() {
|
|
22
|
+
const parsed = Number(process.env.IMAGE_BROWSER_MAX_PARALLEL_TABS || "3");
|
|
23
|
+
if (!Number.isFinite(parsed))
|
|
24
|
+
return 3;
|
|
25
|
+
return Math.max(1, Math.min(Math.trunc(parsed), 8));
|
|
26
|
+
}
|
|
27
|
+
function useHeadlessAutomation() {
|
|
28
|
+
return !/^(0|false|no|off)$/i.test(process.env.IMAGE_BROWSER_HEADLESS || "true");
|
|
29
|
+
}
|
|
30
|
+
function environmentFlag(name, defaultValue = false) {
|
|
31
|
+
const fallback = defaultValue ? "true" : "false";
|
|
32
|
+
return /^(1|true|yes|on)$/i.test(process.env[name] || fallback);
|
|
33
|
+
}
|
|
34
|
+
export class BrowserManager {
|
|
35
|
+
connectedBrowser;
|
|
36
|
+
connectedContext;
|
|
37
|
+
connectedMetadata;
|
|
38
|
+
stateDir;
|
|
39
|
+
profileDir;
|
|
40
|
+
metadataPath;
|
|
41
|
+
loginMetadataPath;
|
|
42
|
+
activePortPath;
|
|
43
|
+
launchLockPath;
|
|
44
|
+
tabLockPath;
|
|
45
|
+
jobAdmissionLockPath;
|
|
46
|
+
maintenanceLockPath;
|
|
47
|
+
legacyJobLockPath;
|
|
48
|
+
jobLocksDir;
|
|
49
|
+
slotLocksDir;
|
|
50
|
+
maxParallelTabs;
|
|
51
|
+
autoCloseAfterSuccess;
|
|
52
|
+
stopToolEnabled;
|
|
53
|
+
stopOnTimeout;
|
|
54
|
+
constructor(stateDir = defaultStateDirectory()) {
|
|
55
|
+
this.stateDir = ensureDirectory(stateDir);
|
|
56
|
+
this.profileDir = ensureDirectory(path.join(this.stateDir, "chrome-profile"));
|
|
57
|
+
this.metadataPath = path.join(this.stateDir, "browser.json");
|
|
58
|
+
this.loginMetadataPath = path.join(this.stateDir, "login-browser.json");
|
|
59
|
+
this.activePortPath = path.join(this.profileDir, "DevToolsActivePort");
|
|
60
|
+
this.launchLockPath = path.join(this.stateDir, "launch.lock");
|
|
61
|
+
this.tabLockPath = path.join(this.stateDir, "tab-assignment.lock");
|
|
62
|
+
this.jobAdmissionLockPath = path.join(this.stateDir, "job-admission.lock");
|
|
63
|
+
this.maintenanceLockPath = path.join(this.stateDir, "maintenance.lock");
|
|
64
|
+
this.legacyJobLockPath = path.join(this.stateDir, "job.lock");
|
|
65
|
+
this.jobLocksDir = ensureDirectory(path.join(this.stateDir, "agent-job-locks"));
|
|
66
|
+
this.slotLocksDir = ensureDirectory(path.join(this.stateDir, "parallel-slots"));
|
|
67
|
+
this.maxParallelTabs = boundedParallelism();
|
|
68
|
+
this.autoCloseAfterSuccess = environmentFlag("IMAGE_BROWSER_AUTO_CLOSE");
|
|
69
|
+
this.stopToolEnabled = environmentFlag("IMAGE_BROWSER_ENABLE_STOP_TOOL");
|
|
70
|
+
this.stopOnTimeout = environmentFlag("IMAGE_BROWSER_STOP_ON_TIMEOUT");
|
|
71
|
+
// Remove the legacy global lock only when no live owner remains.
|
|
72
|
+
const legacyOwner = readJsonFile(path.join(this.legacyJobLockPath, "owner.json"));
|
|
73
|
+
if (fs.existsSync(this.legacyJobLockPath) && !processIsAlive(legacyOwner?.pid)) {
|
|
74
|
+
fs.rmSync(this.legacyJobLockPath, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
agentKey(agentId) {
|
|
78
|
+
return crypto.createHash("sha256").update(agentId).digest("hex").slice(0, 20);
|
|
79
|
+
}
|
|
80
|
+
tabName(agentId) {
|
|
81
|
+
return `${AGENT_TAB_PREFIX}${this.agentKey(agentId)}`;
|
|
82
|
+
}
|
|
83
|
+
findExecutable() {
|
|
84
|
+
const configured = process.env.IMAGE_BROWSER_EXECUTABLE;
|
|
85
|
+
const candidates = [
|
|
86
|
+
configured,
|
|
87
|
+
process.platform === "darwin"
|
|
88
|
+
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
89
|
+
: undefined,
|
|
90
|
+
process.platform === "darwin"
|
|
91
|
+
? path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome")
|
|
92
|
+
: undefined,
|
|
93
|
+
process.platform === "darwin"
|
|
94
|
+
? "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
|
95
|
+
: undefined,
|
|
96
|
+
process.platform === "win32"
|
|
97
|
+
? path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe")
|
|
98
|
+
: undefined,
|
|
99
|
+
process.platform === "win32"
|
|
100
|
+
? path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe")
|
|
101
|
+
: undefined,
|
|
102
|
+
process.platform === "linux" ? "/usr/bin/google-chrome" : undefined,
|
|
103
|
+
process.platform === "linux" ? "/usr/bin/google-chrome-stable" : undefined,
|
|
104
|
+
process.platform === "linux" ? "/usr/bin/chromium" : undefined,
|
|
105
|
+
process.platform === "linux" ? "/usr/bin/chromium-browser" : undefined,
|
|
106
|
+
].filter((item) => Boolean(item));
|
|
107
|
+
const executable = candidates.find((item) => fs.existsSync(item));
|
|
108
|
+
if (!executable) {
|
|
109
|
+
throw new Error("找不到 Chrome/Brave。请安装 Google Chrome,或设置 IMAGE_BROWSER_EXECUTABLE 为浏览器可执行文件路径。");
|
|
110
|
+
}
|
|
111
|
+
return executable;
|
|
112
|
+
}
|
|
113
|
+
readMetadata() {
|
|
114
|
+
return readJsonFile(this.metadataPath);
|
|
115
|
+
}
|
|
116
|
+
readLoginMetadata() {
|
|
117
|
+
return readJsonFile(this.loginMetadataPath);
|
|
118
|
+
}
|
|
119
|
+
readActivePort() {
|
|
120
|
+
try {
|
|
121
|
+
const firstLine = fs.readFileSync(this.activePortPath, "utf8").split(/\r?\n/, 1)[0];
|
|
122
|
+
const port = Number(firstLine);
|
|
123
|
+
return Number.isInteger(port) && port > 0 ? port : undefined;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async endpointIsHealthy(port) {
|
|
130
|
+
try {
|
|
131
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
132
|
+
signal: AbortSignal.timeout(1_500),
|
|
133
|
+
});
|
|
134
|
+
if (!response.ok)
|
|
135
|
+
return false;
|
|
136
|
+
const payload = (await response.json());
|
|
137
|
+
return Boolean(payload.webSocketDebuggerUrl);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lockOwner(lockPath) {
|
|
144
|
+
return readJsonFile(path.join(lockPath, "owner.json"));
|
|
145
|
+
}
|
|
146
|
+
removeStaleLock(lockPath, minimumAgeMs = 5_000) {
|
|
147
|
+
if (!fs.existsSync(lockPath))
|
|
148
|
+
return false;
|
|
149
|
+
const owner = this.lockOwner(lockPath);
|
|
150
|
+
const stat = fs.statSync(lockPath);
|
|
151
|
+
if (!processIsAlive(owner?.pid) && Date.now() - stat.mtimeMs >= minimumAgeMs) {
|
|
152
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
activeJobOwners() {
|
|
158
|
+
const owners = [];
|
|
159
|
+
if (fs.existsSync(this.legacyJobLockPath)) {
|
|
160
|
+
const legacy = readJsonFile(path.join(this.legacyJobLockPath, "owner.json"));
|
|
161
|
+
if (!processIsAlive(legacy?.pid)) {
|
|
162
|
+
fs.rmSync(this.legacyJobLockPath, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
else if (legacy?.pid) {
|
|
165
|
+
owners.push({
|
|
166
|
+
pid: legacy.pid,
|
|
167
|
+
agentId: "legacy-v0.2-global-lock",
|
|
168
|
+
startedAt: legacy.startedAt || "unknown",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const entry of fs.readdirSync(this.jobLocksDir, { withFileTypes: true })) {
|
|
173
|
+
if (!entry.isDirectory())
|
|
174
|
+
continue;
|
|
175
|
+
const lockPath = path.join(this.jobLocksDir, entry.name);
|
|
176
|
+
if (this.removeStaleLock(lockPath))
|
|
177
|
+
continue;
|
|
178
|
+
const owner = this.lockOwner(lockPath);
|
|
179
|
+
if (owner && processIsAlive(owner.pid))
|
|
180
|
+
owners.push(owner);
|
|
181
|
+
}
|
|
182
|
+
return owners;
|
|
183
|
+
}
|
|
184
|
+
assertNoActiveJobs(action) {
|
|
185
|
+
const active = this.activeJobOwners();
|
|
186
|
+
if (active.length > 0) {
|
|
187
|
+
throw new Error(`${action}被拒绝:仍有${active.length}个Agent任务运行中(${active.map((item) => item.agentId).join(", ")})。`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async status() {
|
|
191
|
+
const metadata = this.readMetadata();
|
|
192
|
+
const loginBrowser = this.readLoginMetadata();
|
|
193
|
+
const loginModeActive = processIsAlive(loginBrowser?.pid);
|
|
194
|
+
const activeJobs = this.activeJobOwners().map(({ agentId, pid, startedAt }) => ({ agentId, pid, startedAt }));
|
|
195
|
+
const port = metadata?.port || this.readActivePort();
|
|
196
|
+
if (!port) {
|
|
197
|
+
return {
|
|
198
|
+
running: false,
|
|
199
|
+
loginModeActive,
|
|
200
|
+
loginBrowser: loginModeActive ? loginBrowser : undefined,
|
|
201
|
+
maxParallelTabs: this.maxParallelTabs,
|
|
202
|
+
autoCloseAfterSuccess: this.autoCloseAfterSuccess,
|
|
203
|
+
stopToolEnabled: this.stopToolEnabled,
|
|
204
|
+
stopOnTimeout: this.stopOnTimeout,
|
|
205
|
+
activeJobs,
|
|
206
|
+
profileDir: this.profileDir,
|
|
207
|
+
stateDir: this.stateDir,
|
|
208
|
+
reason: loginModeActive
|
|
209
|
+
? "专用浏览器正处于人工登录模式(无自动化调试端口)"
|
|
210
|
+
: "未找到专用浏览器调试端口",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const healthy = await this.endpointIsHealthy(port);
|
|
214
|
+
return {
|
|
215
|
+
running: healthy,
|
|
216
|
+
endpoint: healthy ? `http://127.0.0.1:${port}` : undefined,
|
|
217
|
+
metadata,
|
|
218
|
+
loginModeActive,
|
|
219
|
+
loginBrowser: loginModeActive ? loginBrowser : undefined,
|
|
220
|
+
maxParallelTabs: this.maxParallelTabs,
|
|
221
|
+
autoCloseAfterSuccess: this.autoCloseAfterSuccess,
|
|
222
|
+
stopToolEnabled: this.stopToolEnabled,
|
|
223
|
+
stopOnTimeout: this.stopOnTimeout,
|
|
224
|
+
activeJobs,
|
|
225
|
+
profileDir: this.profileDir,
|
|
226
|
+
stateDir: this.stateDir,
|
|
227
|
+
reason: healthy ? undefined : "专用浏览器进程不存在或调试端口不可访问",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
async withDirectoryLock(lockPath, timeoutMs, callback) {
|
|
231
|
+
const deadline = Date.now() + timeoutMs;
|
|
232
|
+
while (true) {
|
|
233
|
+
try {
|
|
234
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
235
|
+
fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify({ pid: process.pid, agentId: "system", startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error.code !== "EEXIST")
|
|
240
|
+
throw error;
|
|
241
|
+
if (this.removeStaleLock(lockPath))
|
|
242
|
+
continue;
|
|
243
|
+
if (Date.now() >= deadline)
|
|
244
|
+
throw new Error(`等待锁超时:${path.basename(lockPath)}`);
|
|
245
|
+
await sleep(150);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
return await callback();
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async withLaunchLock(callback) {
|
|
256
|
+
return this.withDirectoryLock(this.launchLockPath, 15_000, callback);
|
|
257
|
+
}
|
|
258
|
+
async withJobAdmissionLock(callback) {
|
|
259
|
+
return this.withDirectoryLock(this.jobAdmissionLockPath, 15_000, callback);
|
|
260
|
+
}
|
|
261
|
+
async beginExclusiveMaintenance(action) {
|
|
262
|
+
return this.withJobAdmissionLock(async () => {
|
|
263
|
+
if (fs.existsSync(this.maintenanceLockPath)) {
|
|
264
|
+
if (!this.removeStaleLock(this.maintenanceLockPath)) {
|
|
265
|
+
const owner = this.lockOwner(this.maintenanceLockPath);
|
|
266
|
+
throw new Error(`浏览器正在进行维护操作(${owner?.action || "unknown"},owner pid: ${owner?.pid ?? "unknown"})。`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
fs.mkdirSync(this.maintenanceLockPath, { mode: 0o700 });
|
|
270
|
+
try {
|
|
271
|
+
atomicWriteJson(path.join(this.maintenanceLockPath, "owner.json"), {
|
|
272
|
+
pid: process.pid,
|
|
273
|
+
agentId: "system",
|
|
274
|
+
action,
|
|
275
|
+
startedAt: new Date().toISOString(),
|
|
276
|
+
});
|
|
277
|
+
this.assertNoActiveJobs(action);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
fs.rmSync(this.maintenanceLockPath, { recursive: true, force: true });
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
let finished = false;
|
|
284
|
+
return () => {
|
|
285
|
+
if (finished)
|
|
286
|
+
return;
|
|
287
|
+
finished = true;
|
|
288
|
+
fs.rmSync(this.maintenanceLockPath, { recursive: true, force: true });
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async withExclusiveMaintenance(action, callback) {
|
|
293
|
+
return this.withLaunchLock(async () => {
|
|
294
|
+
const finishMaintenance = await this.beginExclusiveMaintenance(action);
|
|
295
|
+
try {
|
|
296
|
+
return await callback();
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
finishMaintenance();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async ensureRunning() {
|
|
304
|
+
return this.withLaunchLock(async () => {
|
|
305
|
+
const current = await this.status();
|
|
306
|
+
if (current.loginModeActive) {
|
|
307
|
+
throw new Error("专用浏览器正在人工登录模式。请完成登录后调用 switch_to_automation_browser;系统不会同时打开第二个浏览器。");
|
|
308
|
+
}
|
|
309
|
+
fs.rmSync(this.loginMetadataPath, { force: true });
|
|
310
|
+
const desiredHeadless = useHeadlessAutomation();
|
|
311
|
+
if (current.running && current.metadata?.headless === desiredHeadless)
|
|
312
|
+
return current.metadata;
|
|
313
|
+
if (current.running && !current.metadata) {
|
|
314
|
+
throw new Error("检测到正在运行的专用浏览器,但其PID元数据缺失。为避免打开第二个浏览器,请先手动关闭该专用窗口。");
|
|
315
|
+
}
|
|
316
|
+
const previous = this.readMetadata();
|
|
317
|
+
const previousIsAlive = Boolean(previous && processIsAlive(previous.pid));
|
|
318
|
+
const maintenanceAction = current.running
|
|
319
|
+
? desiredHeadless
|
|
320
|
+
? "切换无窗口后台模式"
|
|
321
|
+
: "切换可见浏览器模式"
|
|
322
|
+
: previousIsAlive
|
|
323
|
+
? "重启无法连接的专用浏览器"
|
|
324
|
+
: undefined;
|
|
325
|
+
const finishMaintenance = maintenanceAction
|
|
326
|
+
? await this.beginExclusiveMaintenance(maintenanceAction)
|
|
327
|
+
: undefined;
|
|
328
|
+
try {
|
|
329
|
+
if (previous && previousIsAlive) {
|
|
330
|
+
try {
|
|
331
|
+
process.kill(previous.pid, "SIGTERM");
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// Process may have exited between checks.
|
|
335
|
+
}
|
|
336
|
+
const stopDeadline = Date.now() + 8_000;
|
|
337
|
+
while (processIsAlive(previous.pid) && Date.now() < stopDeadline)
|
|
338
|
+
await sleep(200);
|
|
339
|
+
if (processIsAlive(previous.pid)) {
|
|
340
|
+
throw new Error("旧的专用浏览器仍在运行但无法连接;请手动关闭后重试。不会再启动第二个浏览器。");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
fs.rmSync(this.activePortPath, { force: true });
|
|
344
|
+
const executable = this.findExecutable();
|
|
345
|
+
const headless = useHeadlessAutomation();
|
|
346
|
+
const browserArgs = [
|
|
347
|
+
"--remote-debugging-address=127.0.0.1",
|
|
348
|
+
"--remote-debugging-port=0",
|
|
349
|
+
`--user-data-dir=${this.profileDir}`,
|
|
350
|
+
"--profile-directory=Default",
|
|
351
|
+
"--no-first-run",
|
|
352
|
+
"--no-default-browser-check",
|
|
353
|
+
"--disable-background-mode",
|
|
354
|
+
"--disable-session-crashed-bubble",
|
|
355
|
+
"--disable-background-timer-throttling",
|
|
356
|
+
"--disable-backgrounding-occluded-windows",
|
|
357
|
+
"--disable-renderer-backgrounding",
|
|
358
|
+
"--window-size=1280,960",
|
|
359
|
+
];
|
|
360
|
+
if (headless)
|
|
361
|
+
browserArgs.push("--headless=new");
|
|
362
|
+
else
|
|
363
|
+
browserArgs.push("--start-minimized", "--new-window");
|
|
364
|
+
browserArgs.push(CHATGPT_URL);
|
|
365
|
+
const child = spawn(executable, browserArgs, { detached: true, stdio: "ignore" });
|
|
366
|
+
child.unref();
|
|
367
|
+
if (!child.pid)
|
|
368
|
+
throw new Error("无法启动专用浏览器进程");
|
|
369
|
+
const deadline = Date.now() + 30_000;
|
|
370
|
+
let port;
|
|
371
|
+
while (Date.now() < deadline) {
|
|
372
|
+
port = this.readActivePort();
|
|
373
|
+
if (port && (await this.endpointIsHealthy(port)))
|
|
374
|
+
break;
|
|
375
|
+
if (!processIsAlive(child.pid))
|
|
376
|
+
break;
|
|
377
|
+
await sleep(250);
|
|
378
|
+
}
|
|
379
|
+
if (!port || !(await this.endpointIsHealthy(port))) {
|
|
380
|
+
throw new Error("专用浏览器启动失败:30秒内没有可用的调试端口");
|
|
381
|
+
}
|
|
382
|
+
const metadata = {
|
|
383
|
+
pid: child.pid,
|
|
384
|
+
port,
|
|
385
|
+
headless,
|
|
386
|
+
executable,
|
|
387
|
+
profileDir: this.profileDir,
|
|
388
|
+
launchedAt: new Date().toISOString(),
|
|
389
|
+
};
|
|
390
|
+
atomicWriteJson(this.metadataPath, metadata);
|
|
391
|
+
return metadata;
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
finishMaintenance?.();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
async openLoginBrowser() {
|
|
399
|
+
return this.withExclusiveMaintenance("切换人工登录模式", async () => {
|
|
400
|
+
const existingLogin = this.readLoginMetadata();
|
|
401
|
+
if (existingLogin && processIsAlive(existingLogin.pid))
|
|
402
|
+
return existingLogin;
|
|
403
|
+
fs.rmSync(this.loginMetadataPath, { force: true });
|
|
404
|
+
const automated = this.readMetadata();
|
|
405
|
+
if (automated && processIsAlive(automated.pid)) {
|
|
406
|
+
try {
|
|
407
|
+
process.kill(automated.pid, "SIGTERM");
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// Process may have exited between checks.
|
|
411
|
+
}
|
|
412
|
+
const deadline = Date.now() + 10_000;
|
|
413
|
+
while (processIsAlive(automated.pid) && Date.now() < deadline)
|
|
414
|
+
await sleep(200);
|
|
415
|
+
if (processIsAlive(automated.pid)) {
|
|
416
|
+
throw new Error("无法关闭自动化浏览器,因此不会再打开人工登录浏览器。请手动关闭专用窗口后重试。");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
this.connectedBrowser = undefined;
|
|
420
|
+
this.connectedContext = undefined;
|
|
421
|
+
this.connectedMetadata = undefined;
|
|
422
|
+
fs.rmSync(this.metadataPath, { force: true });
|
|
423
|
+
fs.rmSync(this.activePortPath, { force: true });
|
|
424
|
+
await sleep(1_000);
|
|
425
|
+
const executable = this.findExecutable();
|
|
426
|
+
const child = spawn(executable, [
|
|
427
|
+
`--user-data-dir=${this.profileDir}`,
|
|
428
|
+
"--profile-directory=Default",
|
|
429
|
+
"--no-first-run",
|
|
430
|
+
"--no-default-browser-check",
|
|
431
|
+
"--new-window",
|
|
432
|
+
CHATGPT_URL,
|
|
433
|
+
], { detached: true, stdio: "ignore" });
|
|
434
|
+
child.unref();
|
|
435
|
+
if (!child.pid)
|
|
436
|
+
throw new Error("无法启动人工登录浏览器");
|
|
437
|
+
await sleep(2_000);
|
|
438
|
+
if (!processIsAlive(child.pid)) {
|
|
439
|
+
throw new Error("人工登录浏览器启动后立即退出。请确认没有其他进程占用专用Profile。");
|
|
440
|
+
}
|
|
441
|
+
const loginMetadata = {
|
|
442
|
+
pid: child.pid,
|
|
443
|
+
executable,
|
|
444
|
+
profileDir: this.profileDir,
|
|
445
|
+
launchedAt: new Date().toISOString(),
|
|
446
|
+
};
|
|
447
|
+
atomicWriteJson(this.loginMetadataPath, loginMetadata);
|
|
448
|
+
return loginMetadata;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async switchToAutomationBrowser(agentId = "default") {
|
|
452
|
+
await this.withExclusiveMaintenance("切换自动化模式", async () => {
|
|
453
|
+
const login = this.readLoginMetadata();
|
|
454
|
+
if (login && processIsAlive(login.pid)) {
|
|
455
|
+
try {
|
|
456
|
+
process.kill(login.pid, "SIGTERM");
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// Process may have exited between checks.
|
|
460
|
+
}
|
|
461
|
+
const deadline = Date.now() + 12_000;
|
|
462
|
+
while (processIsAlive(login.pid) && Date.now() < deadline)
|
|
463
|
+
await sleep(200);
|
|
464
|
+
if (processIsAlive(login.pid)) {
|
|
465
|
+
throw new Error("人工登录浏览器未能关闭。为避免第二个浏览器,请手动关闭该窗口后重试。");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
fs.rmSync(this.loginMetadataPath, { force: true });
|
|
469
|
+
await sleep(1_000);
|
|
470
|
+
});
|
|
471
|
+
const release = await this.acquireJobLock(agentId);
|
|
472
|
+
try {
|
|
473
|
+
return await this.connect({ agentId, bringToFront: false });
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
release();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async browserConnection(metadata) {
|
|
480
|
+
let browser = this.connectedBrowser;
|
|
481
|
+
let context = this.connectedContext;
|
|
482
|
+
if (!browser ||
|
|
483
|
+
!browser.isConnected() ||
|
|
484
|
+
!context ||
|
|
485
|
+
this.connectedMetadata?.port !== metadata.port) {
|
|
486
|
+
browser = await chromium.connectOverCDP(`http://127.0.0.1:${metadata.port}`, { timeout: 15_000 });
|
|
487
|
+
context = browser.contexts()[0];
|
|
488
|
+
if (!context)
|
|
489
|
+
throw new Error("专用浏览器没有可连接的默认上下文");
|
|
490
|
+
this.connectedBrowser = browser;
|
|
491
|
+
this.connectedContext = context;
|
|
492
|
+
this.connectedMetadata = metadata;
|
|
493
|
+
browser.on("disconnected", () => {
|
|
494
|
+
if (this.connectedBrowser === browser) {
|
|
495
|
+
this.connectedBrowser = undefined;
|
|
496
|
+
this.connectedContext = undefined;
|
|
497
|
+
this.connectedMetadata = undefined;
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return { browser, context };
|
|
502
|
+
}
|
|
503
|
+
async readPageName(page) {
|
|
504
|
+
return page.evaluate(() => window.name).catch(() => "");
|
|
505
|
+
}
|
|
506
|
+
isChatGptPage(page) {
|
|
507
|
+
try {
|
|
508
|
+
return new URL(page.url()).hostname.endsWith("chatgpt.com");
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async createBackgroundPage(browser, context) {
|
|
515
|
+
const existing = new Set(context.pages());
|
|
516
|
+
try {
|
|
517
|
+
const session = await browser.newBrowserCDPSession();
|
|
518
|
+
try {
|
|
519
|
+
await session.send("Target.createTarget", { url: "about:blank", background: true });
|
|
520
|
+
const deadline = Date.now() + 10_000;
|
|
521
|
+
while (Date.now() < deadline) {
|
|
522
|
+
const created = context.pages().find((page) => !existing.has(page));
|
|
523
|
+
if (created)
|
|
524
|
+
return created;
|
|
525
|
+
await sleep(100);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
await session.detach().catch(() => undefined);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// Older Chromium variants may not support browser-level CDP sessions.
|
|
534
|
+
}
|
|
535
|
+
return context.newPage();
|
|
536
|
+
}
|
|
537
|
+
async pageForAgent(browser, context, agentId, navigateToChatGpt) {
|
|
538
|
+
return this.withDirectoryLock(this.tabLockPath, 30_000, async () => {
|
|
539
|
+
const tabName = this.tabName(agentId);
|
|
540
|
+
const pages = context.pages();
|
|
541
|
+
for (const candidate of pages) {
|
|
542
|
+
if ((await this.readPageName(candidate)) === tabName)
|
|
543
|
+
return { page: candidate, tabName };
|
|
544
|
+
}
|
|
545
|
+
let page;
|
|
546
|
+
for (const candidate of pages) {
|
|
547
|
+
const name = await this.readPageName(candidate);
|
|
548
|
+
if (this.isChatGptPage(candidate) && !name.startsWith(AGENT_TAB_PREFIX)) {
|
|
549
|
+
page = candidate;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
page ||= await this.createBackgroundPage(browser, context);
|
|
554
|
+
await page.evaluate((name) => { window.name = name; }, tabName);
|
|
555
|
+
if (navigateToChatGpt && !this.isChatGptPage(page)) {
|
|
556
|
+
await page.goto(CHATGPT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
557
|
+
await page.evaluate((name) => { window.name = name; }, tabName);
|
|
558
|
+
}
|
|
559
|
+
return { page, tabName };
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
async connect(options = {}) {
|
|
563
|
+
const agentId = options.agentId || "default";
|
|
564
|
+
const metadata = await this.ensureRunning();
|
|
565
|
+
const { browser, context } = await this.browserConnection(metadata);
|
|
566
|
+
const { page, tabName } = await this.pageForAgent(browser, context, agentId, options.navigateToChatGpt !== false);
|
|
567
|
+
if (options.bringToFront)
|
|
568
|
+
await page.bringToFront();
|
|
569
|
+
return { browser, context, page, metadata, agentId, tabName };
|
|
570
|
+
}
|
|
571
|
+
async acquireJobLock(agentId = "default") {
|
|
572
|
+
return this.withJobAdmissionLock(async () => {
|
|
573
|
+
if (fs.existsSync(this.maintenanceLockPath)) {
|
|
574
|
+
if (!this.removeStaleLock(this.maintenanceLockPath)) {
|
|
575
|
+
const owner = this.lockOwner(this.maintenanceLockPath);
|
|
576
|
+
throw new Error(`浏览器正在进行维护操作(${owner?.action || "unknown"}),暂时不能开始Agent任务。`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const legacy = this.activeJobOwners().find((owner) => owner.agentId === "legacy-v0.2-global-lock");
|
|
580
|
+
if (legacy) {
|
|
581
|
+
throw new Error(`检测到旧版本图片任务锁(pid: ${legacy.pid})。请让所有已打开的Agent执行/reload,或等待旧任务结束后再使用多标签并行。`);
|
|
582
|
+
}
|
|
583
|
+
const agentKey = this.agentKey(agentId);
|
|
584
|
+
const agentLockPath = path.join(this.jobLocksDir, agentKey);
|
|
585
|
+
try {
|
|
586
|
+
fs.mkdirSync(agentLockPath, { mode: 0o700 });
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
if (error.code !== "EEXIST")
|
|
590
|
+
throw error;
|
|
591
|
+
if (this.removeStaleLock(agentLockPath)) {
|
|
592
|
+
fs.mkdirSync(agentLockPath, { mode: 0o700 });
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
const owner = this.lockOwner(agentLockPath);
|
|
596
|
+
throw new Error(`Agent ${agentId} 已有任务运行中(owner pid: ${owner?.pid ?? "unknown"})。`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
let slotPath;
|
|
600
|
+
let slotNumber;
|
|
601
|
+
for (let slot = 1; slot <= this.maxParallelTabs; slot += 1) {
|
|
602
|
+
const candidate = path.join(this.slotLocksDir, `slot-${slot}`);
|
|
603
|
+
this.removeStaleLock(candidate);
|
|
604
|
+
try {
|
|
605
|
+
fs.mkdirSync(candidate, { mode: 0o700 });
|
|
606
|
+
slotPath = candidate;
|
|
607
|
+
slotNumber = slot;
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (error.code !== "EEXIST") {
|
|
612
|
+
fs.rmSync(agentLockPath, { recursive: true, force: true });
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (!slotPath || !slotNumber) {
|
|
618
|
+
fs.rmSync(agentLockPath, { recursive: true, force: true });
|
|
619
|
+
throw new Error(`已有${this.maxParallelTabs}个Agent并行生成,已达到安全上限。请稍后重试。`);
|
|
620
|
+
}
|
|
621
|
+
const owner = {
|
|
622
|
+
pid: process.pid,
|
|
623
|
+
agentId,
|
|
624
|
+
startedAt: new Date().toISOString(),
|
|
625
|
+
slot: slotNumber,
|
|
626
|
+
};
|
|
627
|
+
atomicWriteJson(path.join(agentLockPath, "owner.json"), owner);
|
|
628
|
+
atomicWriteJson(path.join(slotPath, "owner.json"), owner);
|
|
629
|
+
let released = false;
|
|
630
|
+
return () => {
|
|
631
|
+
if (released)
|
|
632
|
+
return;
|
|
633
|
+
released = true;
|
|
634
|
+
fs.rmSync(slotPath, { recursive: true, force: true });
|
|
635
|
+
fs.rmSync(agentLockPath, { recursive: true, force: true });
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
async closeAgentTab(agentId) {
|
|
640
|
+
const release = await this.acquireJobLock(agentId);
|
|
641
|
+
try {
|
|
642
|
+
const status = await this.status();
|
|
643
|
+
if (!status.running)
|
|
644
|
+
return false;
|
|
645
|
+
if (!status.metadata) {
|
|
646
|
+
throw new Error("专用浏览器正在运行,但缺少连接元数据;为避免启动第二个浏览器,本次不会关闭标签页。");
|
|
647
|
+
}
|
|
648
|
+
const { context } = await this.browserConnection(status.metadata);
|
|
649
|
+
const expected = this.tabName(agentId);
|
|
650
|
+
for (const page of context.pages()) {
|
|
651
|
+
if ((await this.readPageName(page)) === expected) {
|
|
652
|
+
await page.close();
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
release();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
hasPendingPreparedJobs() {
|
|
663
|
+
const directory = path.join(this.stateDir, "prepared-jobs");
|
|
664
|
+
if (!fs.existsSync(directory))
|
|
665
|
+
return false;
|
|
666
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
667
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
668
|
+
continue;
|
|
669
|
+
const prepared = readJsonFile(path.join(directory, entry.name));
|
|
670
|
+
if (!prepared || prepared.state !== "completed")
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
async closeDedicatedBrowser(options = {}) {
|
|
676
|
+
return this.withExclusiveMaintenance("关闭专用浏览器", async () => {
|
|
677
|
+
if (options.preservePreparedJobs && this.hasPendingPreparedJobs())
|
|
678
|
+
return false;
|
|
679
|
+
const metadata = this.readMetadata();
|
|
680
|
+
const loginMetadata = this.readLoginMetadata();
|
|
681
|
+
const livePids = [metadata?.pid, loginMetadata?.pid]
|
|
682
|
+
.filter((pid) => processIsAlive(pid));
|
|
683
|
+
const wasRunning = livePids.length > 0;
|
|
684
|
+
for (const pid of livePids) {
|
|
685
|
+
try {
|
|
686
|
+
process.kill(pid, "SIGTERM");
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
// Process may have exited between checks.
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const deadline = Date.now() + 8_000;
|
|
693
|
+
while (livePids.some((pid) => processIsAlive(pid)) && Date.now() < deadline) {
|
|
694
|
+
await sleep(200);
|
|
695
|
+
}
|
|
696
|
+
const remainingPids = livePids.filter((pid) => processIsAlive(pid));
|
|
697
|
+
if (remainingPids.length > 0) {
|
|
698
|
+
throw new Error(`专用浏览器未能在8秒内关闭(pid: ${remainingPids.join(", ")});状态文件已保留,请人工检查。`);
|
|
699
|
+
}
|
|
700
|
+
this.connectedBrowser = undefined;
|
|
701
|
+
this.connectedContext = undefined;
|
|
702
|
+
this.connectedMetadata = undefined;
|
|
703
|
+
fs.rmSync(this.metadataPath, { force: true });
|
|
704
|
+
fs.rmSync(this.loginMetadataPath, { force: true });
|
|
705
|
+
fs.rmSync(this.activePortPath, { force: true });
|
|
706
|
+
return wasRunning;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
//# sourceMappingURL=browser-manager.js.map
|