@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,901 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { atomicWriteJson, detectImageContentType, ensureDirectory, imageExtension, makeJobId, processIsAlive, readJsonFile, resolvePath, safeFileStem, sleep, } from "./utils.js";
|
|
5
|
+
const DEFAULT_OUTPUT_DIR = process.env.IMAGE_BROWSER_DEFAULT_OUTPUT_DIR || "generated/web-images";
|
|
6
|
+
const PREPARED_JOB_PATH_NAME = "prepared-job.json";
|
|
7
|
+
const STOP_SELECTORS = [
|
|
8
|
+
"button[data-testid='stop-button']",
|
|
9
|
+
"button[aria-label*='Stop']",
|
|
10
|
+
"button[aria-label*='停止']",
|
|
11
|
+
];
|
|
12
|
+
const GENERATION_ERROR_PATTERN = /(Something went wrong|There was an error|出错了|发生错误|请重试)/i;
|
|
13
|
+
export function newAssistantMessagesContainError(assistantMessages, baselineCount) {
|
|
14
|
+
return assistantMessages.slice(Math.max(0, baselineCount)).some((message) => GENERATION_ERROR_PATTERN.test(message));
|
|
15
|
+
}
|
|
16
|
+
const ATTACHMENT_REMOVE_SELECTOR = [
|
|
17
|
+
"button[aria-label^='Remove file']",
|
|
18
|
+
"button[aria-label^='Remove image']",
|
|
19
|
+
"button[aria-label^='移除文件']",
|
|
20
|
+
"button[aria-label^='删除文件']",
|
|
21
|
+
"button[aria-label^='移除图片']",
|
|
22
|
+
].join(", ");
|
|
23
|
+
export class ChatGptAutomation {
|
|
24
|
+
browserManager;
|
|
25
|
+
agentId;
|
|
26
|
+
constructor(browserManager, agentId) {
|
|
27
|
+
this.browserManager = browserManager;
|
|
28
|
+
this.agentId = agentId;
|
|
29
|
+
}
|
|
30
|
+
async autoCloseBrowserAfterSuccess() {
|
|
31
|
+
if (!this.browserManager.autoCloseAfterSuccess)
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
await this.browserManager.closeDedicatedBrowser({ preservePreparedJobs: true });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error("自动关闭专用浏览器已跳过:", error instanceof Error ? error.message : String(error));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
agentKey(length = 20) {
|
|
41
|
+
return crypto.createHash("sha256").update(this.agentId).digest("hex").slice(0, length);
|
|
42
|
+
}
|
|
43
|
+
preparedJobPath() {
|
|
44
|
+
const directory = ensureDirectory(path.join(this.browserManager.stateDir, "prepared-jobs"));
|
|
45
|
+
return path.join(directory, `${this.agentKey()}-${PREPARED_JOB_PATH_NAME}`);
|
|
46
|
+
}
|
|
47
|
+
readPreparedJob() {
|
|
48
|
+
const prepared = JSON.parse(fs.readFileSync(this.preparedJobPath(), "utf8"));
|
|
49
|
+
if (!prepared || prepared.agentId !== this.agentId || !prepared.jobId) {
|
|
50
|
+
throw new Error("待发送任务状态无效或不属于当前Agent。请清除后重新准备任务。");
|
|
51
|
+
}
|
|
52
|
+
// v0.3.0 initially had no explicit state; preserve those already-prepared jobs.
|
|
53
|
+
prepared.state ||= "prepared";
|
|
54
|
+
const validStates = [
|
|
55
|
+
"prepared",
|
|
56
|
+
"submitting",
|
|
57
|
+
"sent",
|
|
58
|
+
"needs_human",
|
|
59
|
+
"completed",
|
|
60
|
+
];
|
|
61
|
+
if (!validStates.includes(prepared.state)) {
|
|
62
|
+
throw new Error("待发送任务状态无法识别。请检查页面,确认后清除并重新准备任务。");
|
|
63
|
+
}
|
|
64
|
+
return prepared;
|
|
65
|
+
}
|
|
66
|
+
writePreparedJob(prepared) {
|
|
67
|
+
atomicWriteJson(this.preparedJobPath(), prepared);
|
|
68
|
+
}
|
|
69
|
+
assertJobCanBeSubmitted(prepared) {
|
|
70
|
+
if (prepared.state === "prepared")
|
|
71
|
+
return;
|
|
72
|
+
const stateMessages = {
|
|
73
|
+
submitting: "发送动作已经开始,但是否到达ChatGPT尚不确定",
|
|
74
|
+
sent: "任务已经发送到ChatGPT",
|
|
75
|
+
needs_human: "任务已经发送且需要人工检查结果",
|
|
76
|
+
completed: "任务已经完成",
|
|
77
|
+
};
|
|
78
|
+
throw new Error(`${stateMessages[prepared.state]}。为避免重复消耗额度,本次拒绝再次发送;请检查页面并使用 download_latest_image,或明确清除后重新准备任务。`);
|
|
79
|
+
}
|
|
80
|
+
outputStemAlreadyExists(outputDir, outputStem, ignoredPaths = []) {
|
|
81
|
+
const ignored = new Set(ignoredPaths.map((item) => path.resolve(item)));
|
|
82
|
+
const knownPaths = [
|
|
83
|
+
path.join(outputDir, `${outputStem}.json`),
|
|
84
|
+
path.join(outputDir, `${outputStem}-prepared.png`),
|
|
85
|
+
path.join(outputDir, `${outputStem}-result.png`),
|
|
86
|
+
path.join(outputDir, `${outputStem}-batch.json`),
|
|
87
|
+
...[".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"].flatMap((extension) => [
|
|
88
|
+
path.join(outputDir, `${outputStem}${extension}`),
|
|
89
|
+
path.join(outputDir, `${outputStem}-image${extension}`),
|
|
90
|
+
]),
|
|
91
|
+
];
|
|
92
|
+
return knownPaths.some((item) => !ignored.has(path.resolve(item)) && fs.existsSync(item));
|
|
93
|
+
}
|
|
94
|
+
reserveOutputStem(outputDir, requestedStem, jobId, ignoredPaths = []) {
|
|
95
|
+
const reservations = ensureDirectory(path.join(this.browserManager.stateDir, "output-reservations"));
|
|
96
|
+
const base = safeFileStem(requestedStem);
|
|
97
|
+
for (let attempt = 0; attempt < 1_000; attempt += 1) {
|
|
98
|
+
const suffix = attempt === 0
|
|
99
|
+
? ""
|
|
100
|
+
: attempt === 1
|
|
101
|
+
? `-${this.agentKey(8)}`
|
|
102
|
+
: `-${this.agentKey(8)}-${attempt}`;
|
|
103
|
+
const truncatedBase = base.slice(0, Math.max(1, 80 - suffix.length));
|
|
104
|
+
const candidate = safeFileStem(`${truncatedBase}${suffix}`);
|
|
105
|
+
if (this.outputStemAlreadyExists(outputDir, candidate, ignoredPaths))
|
|
106
|
+
continue;
|
|
107
|
+
const reservationKey = crypto
|
|
108
|
+
.createHash("sha256")
|
|
109
|
+
.update(`${outputDir}\u0000${candidate}`)
|
|
110
|
+
.digest("hex");
|
|
111
|
+
const reservationPath = path.join(reservations, `${reservationKey}.json`);
|
|
112
|
+
const reservation = {
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
agentId: this.agentId,
|
|
115
|
+
jobId,
|
|
116
|
+
outputDir,
|
|
117
|
+
outputStem: candidate,
|
|
118
|
+
createdAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(reservationPath, `${JSON.stringify(reservation)}\n`, { flag: "wx", mode: 0o600 });
|
|
122
|
+
return { outputStem: candidate, reservationPath };
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code !== "EEXIST")
|
|
126
|
+
throw error;
|
|
127
|
+
const existing = readJsonFile(reservationPath);
|
|
128
|
+
if (existing?.agentId === this.agentId && existing.jobId === jobId) {
|
|
129
|
+
return { outputStem: candidate, reservationPath };
|
|
130
|
+
}
|
|
131
|
+
const stat = fs.statSync(reservationPath, { throwIfNoEntry: false });
|
|
132
|
+
if (!stat) {
|
|
133
|
+
attempt -= 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
137
|
+
if (ageMs >= 5_000 && !processIsAlive(existing?.pid)) {
|
|
138
|
+
fs.rmSync(reservationPath, { force: true });
|
|
139
|
+
attempt -= 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`无法为输出文件分配唯一名称:${base}`);
|
|
144
|
+
}
|
|
145
|
+
releaseOutputReservation(prepared) {
|
|
146
|
+
if (!prepared.reservationPath)
|
|
147
|
+
return;
|
|
148
|
+
const reservation = readJsonFile(prepared.reservationPath);
|
|
149
|
+
if (reservation?.agentId === prepared.agentId && reservation.jobId === prepared.jobId) {
|
|
150
|
+
fs.rmSync(prepared.reservationPath, { force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async findComposer(page, timeoutMs = 5_000) {
|
|
154
|
+
const selectors = [
|
|
155
|
+
"#prompt-textarea",
|
|
156
|
+
"textarea[data-testid='prompt-textarea']",
|
|
157
|
+
"textarea[placeholder*='Message']",
|
|
158
|
+
"textarea[placeholder*='消息']",
|
|
159
|
+
"main [contenteditable='true']",
|
|
160
|
+
];
|
|
161
|
+
const deadline = Date.now() + timeoutMs;
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
for (const selector of selectors) {
|
|
164
|
+
const candidate = page.locator(selector).first();
|
|
165
|
+
try {
|
|
166
|
+
if (await candidate.isVisible({ timeout: 250 }))
|
|
167
|
+
return candidate;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Try the next selector while ChatGPT is rendering.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await sleep(250);
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
async inspectPage(page) {
|
|
178
|
+
const composerReady = Boolean(await this.findComposer(page, 1_500));
|
|
179
|
+
const bodyText = (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 20_000);
|
|
180
|
+
const loginLikelyRequired = !composerReady &&
|
|
181
|
+
/(Log in|Sign up|登录|注册|欢迎使用 ChatGPT|Welcome to ChatGPT)/i.test(bodyText);
|
|
182
|
+
const challengeLikelyPresent = /(verify you are human|security check|Cloudflare|验证码|验证您是真人|安全检查)/i.test(bodyText);
|
|
183
|
+
return {
|
|
184
|
+
url: page.url(),
|
|
185
|
+
title: await page.title().catch(() => ""),
|
|
186
|
+
composerReady,
|
|
187
|
+
loginLikelyRequired,
|
|
188
|
+
challengeLikelyPresent,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
validateReferences(referenceImages) {
|
|
192
|
+
return referenceImages.map((input) => {
|
|
193
|
+
const absolute = resolvePath(input);
|
|
194
|
+
const stat = fs.statSync(absolute, { throwIfNoEntry: false });
|
|
195
|
+
if (!stat?.isFile())
|
|
196
|
+
throw new Error(`参考图不存在或不是文件:${absolute}`);
|
|
197
|
+
if (stat.size > 50 * 1024 * 1024)
|
|
198
|
+
throw new Error(`参考图超过50MB:${absolute}`);
|
|
199
|
+
return absolute;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async uploadReferences(page, references) {
|
|
203
|
+
if (references.length === 0)
|
|
204
|
+
return;
|
|
205
|
+
let fileInput = page.locator("input[type='file']").last();
|
|
206
|
+
if ((await fileInput.count()) === 0) {
|
|
207
|
+
const attachSelectors = [
|
|
208
|
+
"button[data-testid='composer-plus-btn']",
|
|
209
|
+
"button[aria-label*='Attach']",
|
|
210
|
+
"button[aria-label*='Upload']",
|
|
211
|
+
"button[aria-label*='添加']",
|
|
212
|
+
"button[aria-label*='上传']",
|
|
213
|
+
];
|
|
214
|
+
for (const selector of attachSelectors) {
|
|
215
|
+
const button = page.locator(selector).first();
|
|
216
|
+
if (await button.isVisible().catch(() => false)) {
|
|
217
|
+
await button.click();
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await sleep(500);
|
|
222
|
+
fileInput = page.locator("input[type='file']").last();
|
|
223
|
+
}
|
|
224
|
+
if ((await fileInput.count()) === 0) {
|
|
225
|
+
throw new Error("没有找到ChatGPT附件上传控件。浏览器保持打开,请人工检查页面或登录状态。");
|
|
226
|
+
}
|
|
227
|
+
await fileInput.setInputFiles(references);
|
|
228
|
+
await sleep(Math.min(2_000 + references.length * 500, 6_000));
|
|
229
|
+
}
|
|
230
|
+
async composerText(composer) {
|
|
231
|
+
return composer.evaluate((element) => {
|
|
232
|
+
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
|
|
233
|
+
return element.value;
|
|
234
|
+
}
|
|
235
|
+
return element.innerText || element.textContent || "";
|
|
236
|
+
}).catch(() => "");
|
|
237
|
+
}
|
|
238
|
+
async fillPrompt(composer, prompt) {
|
|
239
|
+
await composer.click();
|
|
240
|
+
await composer.fill(prompt);
|
|
241
|
+
const actual = await this.composerText(composer);
|
|
242
|
+
if (actual.trim() !== prompt.trim()) {
|
|
243
|
+
throw new Error("提示词未能完整写入ChatGPT输入框");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async attachmentCount(page) {
|
|
247
|
+
const buttons = page.locator(ATTACHMENT_REMOVE_SELECTOR);
|
|
248
|
+
let visible = 0;
|
|
249
|
+
for (let index = 0; index < await buttons.count(); index += 1) {
|
|
250
|
+
if (await buttons.nth(index).isVisible().catch(() => false))
|
|
251
|
+
visible += 1;
|
|
252
|
+
}
|
|
253
|
+
return visible;
|
|
254
|
+
}
|
|
255
|
+
async firstVisibleAttachmentRemoveButton(page) {
|
|
256
|
+
const buttons = page.locator(ATTACHMENT_REMOVE_SELECTOR);
|
|
257
|
+
for (let index = 0; index < await buttons.count(); index += 1) {
|
|
258
|
+
const button = buttons.nth(index);
|
|
259
|
+
if (await button.isVisible().catch(() => false))
|
|
260
|
+
return button;
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
async clearComposerContents(page) {
|
|
265
|
+
const composer = await this.findComposer(page, 3_000);
|
|
266
|
+
let clearedPrompt = false;
|
|
267
|
+
if (composer) {
|
|
268
|
+
await composer.fill("");
|
|
269
|
+
clearedPrompt = true;
|
|
270
|
+
}
|
|
271
|
+
let removedAttachments = 0;
|
|
272
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
273
|
+
const removeButton = await this.firstVisibleAttachmentRemoveButton(page);
|
|
274
|
+
if (!removeButton)
|
|
275
|
+
break;
|
|
276
|
+
await removeButton.click({ force: true, timeout: 3_000 });
|
|
277
|
+
removedAttachments += 1;
|
|
278
|
+
await sleep(150);
|
|
279
|
+
}
|
|
280
|
+
if (await this.attachmentCount(page)) {
|
|
281
|
+
throw new Error("未能清空ChatGPT输入框中的旧附件。为避免引用错误图片,本次任务已停止。");
|
|
282
|
+
}
|
|
283
|
+
return { clearedPrompt, removedAttachments };
|
|
284
|
+
}
|
|
285
|
+
async savePageScreenshot(page, outputPath) {
|
|
286
|
+
const bytes = await page.screenshot({ fullPage: false });
|
|
287
|
+
fs.writeFileSync(outputPath, bytes, { flag: "wx", mode: 0o600 });
|
|
288
|
+
}
|
|
289
|
+
async collectImageCandidates(page) {
|
|
290
|
+
return page.locator("main img").evaluateAll((images) => images
|
|
291
|
+
.map((element, index) => {
|
|
292
|
+
const image = element;
|
|
293
|
+
const roleElement = image.closest("[data-message-author-role]");
|
|
294
|
+
return {
|
|
295
|
+
index,
|
|
296
|
+
src: image.currentSrc || image.src || "",
|
|
297
|
+
alt: image.alt || "",
|
|
298
|
+
width: image.naturalWidth || image.width || 0,
|
|
299
|
+
height: image.naturalHeight || image.height || 0,
|
|
300
|
+
role: roleElement?.getAttribute("data-message-author-role") || "",
|
|
301
|
+
};
|
|
302
|
+
})
|
|
303
|
+
.filter((image) => {
|
|
304
|
+
const largeEnough = image.width >= 512 && image.height >= 512;
|
|
305
|
+
const generatedAlt = /(generated|生成|image)/i.test(image.alt);
|
|
306
|
+
const assistantImage = image.role === "assistant";
|
|
307
|
+
return Boolean(image.src) && largeEnough && (assistantImage || generatedAlt);
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
async prepareJobUnlocked(args) {
|
|
311
|
+
const { page } = await this.browserManager.connect({ agentId: this.agentId });
|
|
312
|
+
const pageState = await this.inspectPage(page);
|
|
313
|
+
if (!pageState.composerReady) {
|
|
314
|
+
throw new Error(pageState.challengeLikelyPresent
|
|
315
|
+
? "浏览器出现安全验证,请在专用浏览器中人工完成后重试。"
|
|
316
|
+
: "ChatGPT输入框不可用。请在专用浏览器中登录Pro账号并进入可发送消息的页面。");
|
|
317
|
+
}
|
|
318
|
+
const references = this.validateReferences(args.referenceImages || []);
|
|
319
|
+
await this.clearComposerContents(page);
|
|
320
|
+
const previousPrepared = readJsonFile(this.preparedJobPath());
|
|
321
|
+
if (previousPrepared)
|
|
322
|
+
this.releaseOutputReservation(previousPrepared);
|
|
323
|
+
fs.rmSync(this.preparedJobPath(), { force: true });
|
|
324
|
+
const outputDir = ensureDirectory(resolvePath(args.outputDir || DEFAULT_OUTPUT_DIR));
|
|
325
|
+
const jobId = makeJobId(`${this.agentId}\n${args.prompt}`);
|
|
326
|
+
const reserved = this.reserveOutputStem(outputDir, args.outputStem || jobId, jobId);
|
|
327
|
+
const screenshotPath = path.join(outputDir, `${reserved.outputStem}-prepared.png`);
|
|
328
|
+
try {
|
|
329
|
+
await this.uploadReferences(page, references);
|
|
330
|
+
const composer = await this.findComposer(page, 5_000);
|
|
331
|
+
if (!composer)
|
|
332
|
+
throw new Error("上传参考图后找不到提示词输入框");
|
|
333
|
+
await this.fillPrompt(composer, args.prompt);
|
|
334
|
+
await this.savePageScreenshot(page, screenshotPath);
|
|
335
|
+
const baseline = await this.collectImageCandidates(page);
|
|
336
|
+
const prepared = {
|
|
337
|
+
agentId: this.agentId,
|
|
338
|
+
jobId,
|
|
339
|
+
state: "prepared",
|
|
340
|
+
outputStem: reserved.outputStem,
|
|
341
|
+
prompt: args.prompt,
|
|
342
|
+
referenceImages: references,
|
|
343
|
+
preparedAt: new Date().toISOString(),
|
|
344
|
+
reservationPath: reserved.reservationPath,
|
|
345
|
+
preparedAttachmentCount: await this.attachmentCount(page),
|
|
346
|
+
baselineImageSources: baseline.map((item) => item.src),
|
|
347
|
+
outputDir,
|
|
348
|
+
screenshotPath,
|
|
349
|
+
};
|
|
350
|
+
this.writePreparedJob(prepared);
|
|
351
|
+
return { prepared, pageState: await this.inspectPage(page) };
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
this.releaseOutputReservation({
|
|
355
|
+
agentId: this.agentId,
|
|
356
|
+
jobId,
|
|
357
|
+
reservationPath: reserved.reservationPath,
|
|
358
|
+
});
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async prepareJob(args) {
|
|
363
|
+
const release = await this.browserManager.acquireJobLock(this.agentId);
|
|
364
|
+
try {
|
|
365
|
+
return await this.prepareJobUnlocked(args);
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
release();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async clickSend(page) {
|
|
372
|
+
const sendSelectors = [
|
|
373
|
+
"button[data-testid='send-button']",
|
|
374
|
+
"button[aria-label*='Send']",
|
|
375
|
+
"button[aria-label*='发送']",
|
|
376
|
+
];
|
|
377
|
+
for (const selector of sendSelectors) {
|
|
378
|
+
const button = page.locator(selector).first();
|
|
379
|
+
if (await button.isVisible().catch(() => false)) {
|
|
380
|
+
if (await button.isEnabled().catch(() => false)) {
|
|
381
|
+
await button.click();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const composer = await this.findComposer(page, 2_000);
|
|
387
|
+
if (!composer)
|
|
388
|
+
throw new Error("找不到发送按钮或提示词输入框");
|
|
389
|
+
await composer.press("Enter");
|
|
390
|
+
}
|
|
391
|
+
async generationStillRunning(page) {
|
|
392
|
+
for (const selector of STOP_SELECTORS) {
|
|
393
|
+
if (await page.locator(selector).first().isVisible().catch(() => false))
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
const bodyTail = (await page.locator("main").innerText().catch(() => "")).slice(-3_000);
|
|
397
|
+
return /(Creating image|Generating image|正在创建|正在生成)/i.test(bodyTail);
|
|
398
|
+
}
|
|
399
|
+
async clickStopGeneration(page) {
|
|
400
|
+
for (const selector of STOP_SELECTORS) {
|
|
401
|
+
const button = page.locator(selector).first();
|
|
402
|
+
if (await button.isVisible().catch(() => false) &&
|
|
403
|
+
await button.isEnabled().catch(() => false)) {
|
|
404
|
+
await button.click();
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
async waitUntilGenerationStops(page, timeoutMs = 10_000) {
|
|
411
|
+
const deadline = Date.now() + timeoutMs;
|
|
412
|
+
while (Date.now() < deadline) {
|
|
413
|
+
if (!(await this.generationStillRunning(page)))
|
|
414
|
+
return true;
|
|
415
|
+
await sleep(250);
|
|
416
|
+
}
|
|
417
|
+
return !(await this.generationStillRunning(page));
|
|
418
|
+
}
|
|
419
|
+
async stopCurrentGeneration() {
|
|
420
|
+
if (!this.browserManager.stopToolEnabled) {
|
|
421
|
+
throw new Error("stop_current_generation 默认禁用。请设置 IMAGE_BROWSER_ENABLE_STOP_TOOL=true 并重新加载MCP。");
|
|
422
|
+
}
|
|
423
|
+
const release = await this.browserManager.acquireJobLock(this.agentId);
|
|
424
|
+
try {
|
|
425
|
+
const browserStatus = await this.browserManager.status();
|
|
426
|
+
if (!browserStatus.running) {
|
|
427
|
+
return {
|
|
428
|
+
stopped: false,
|
|
429
|
+
stillRunning: false,
|
|
430
|
+
message: "专用浏览器当前未运行,没有需要停止的生成任务。",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const { page } = await this.browserManager.connect({ agentId: this.agentId });
|
|
434
|
+
const wasRunning = await this.generationStillRunning(page);
|
|
435
|
+
if (!wasRunning) {
|
|
436
|
+
return {
|
|
437
|
+
stopped: false,
|
|
438
|
+
stillRunning: false,
|
|
439
|
+
message: "当前页面没有检测到正在运行的图片生成任务。",
|
|
440
|
+
pageState: await this.inspectPage(page),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const stopped = await this.clickStopGeneration(page);
|
|
444
|
+
const stoppedCompletely = stopped
|
|
445
|
+
? await this.waitUntilGenerationStops(page)
|
|
446
|
+
: false;
|
|
447
|
+
return {
|
|
448
|
+
stopped,
|
|
449
|
+
stillRunning: !stoppedCompletely,
|
|
450
|
+
message: stopped
|
|
451
|
+
? stoppedCompletely
|
|
452
|
+
? "已点击停止,当前生成任务已结束。不会自动重新发送。"
|
|
453
|
+
: "已点击停止,但页面仍显示生成中,请人工检查。"
|
|
454
|
+
: "检测到生成状态,但没有找到可点击的停止按钮,请人工检查页面。",
|
|
455
|
+
pageState: await this.inspectPage(page),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
release();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async waitForNewImage(page, baselineSources, baselineAssistantMessageCount, timeoutMs) {
|
|
463
|
+
const deadline = Date.now() + timeoutMs;
|
|
464
|
+
let stableSource = "";
|
|
465
|
+
let stableSince = 0;
|
|
466
|
+
while (Date.now() < deadline) {
|
|
467
|
+
const candidates = await this.collectImageCandidates(page).catch(() => []);
|
|
468
|
+
const newest = [...candidates].reverse().find((candidate) => !baselineSources.has(candidate.src));
|
|
469
|
+
if (newest) {
|
|
470
|
+
if (newest.src !== stableSource) {
|
|
471
|
+
stableSource = newest.src;
|
|
472
|
+
stableSince = Date.now();
|
|
473
|
+
}
|
|
474
|
+
const running = await this.generationStillRunning(page);
|
|
475
|
+
if (!running && Date.now() - stableSince >= 4_000) {
|
|
476
|
+
return { candidate: newest, outcome: "completed", autoStopped: false };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const assistantMessages = await page
|
|
480
|
+
.locator("[data-message-author-role='assistant']")
|
|
481
|
+
.allInnerTexts()
|
|
482
|
+
.catch(() => []);
|
|
483
|
+
if (newAssistantMessagesContainError(assistantMessages, baselineAssistantMessageCount)) {
|
|
484
|
+
return { outcome: "generation_error", autoStopped: false };
|
|
485
|
+
}
|
|
486
|
+
await sleep(2_000);
|
|
487
|
+
}
|
|
488
|
+
let autoStopped = false;
|
|
489
|
+
if (this.browserManager.stopOnTimeout && await this.generationStillRunning(page)) {
|
|
490
|
+
autoStopped = await this.clickStopGeneration(page);
|
|
491
|
+
if (autoStopped)
|
|
492
|
+
await this.waitUntilGenerationStops(page);
|
|
493
|
+
const candidates = await this.collectImageCandidates(page).catch(() => []);
|
|
494
|
+
const newest = [...candidates].reverse().find((candidate) => !baselineSources.has(candidate.src));
|
|
495
|
+
if (newest) {
|
|
496
|
+
return { candidate: newest, outcome: "stopped_with_image", autoStopped: true };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { outcome: "timeout", autoStopped };
|
|
500
|
+
}
|
|
501
|
+
validatedImageBytes(bytes, method) {
|
|
502
|
+
const contentType = detectImageContentType(bytes);
|
|
503
|
+
return contentType ? { bytes, contentType, method } : undefined;
|
|
504
|
+
}
|
|
505
|
+
async fetchImageBytes(context, page, candidate) {
|
|
506
|
+
if (candidate.src.startsWith("data:")) {
|
|
507
|
+
const commaIndex = candidate.src.indexOf(",");
|
|
508
|
+
if (commaIndex > 0) {
|
|
509
|
+
const metadata = candidate.src.slice(5, commaIndex);
|
|
510
|
+
const payload = candidate.src.slice(commaIndex + 1);
|
|
511
|
+
try {
|
|
512
|
+
const bytes = /(?:^|;)base64(?:;|$)/i.test(metadata)
|
|
513
|
+
? Buffer.from(payload, "base64")
|
|
514
|
+
: Buffer.from(decodeURIComponent(payload), "utf8");
|
|
515
|
+
const validated = this.validatedImageBytes(bytes, "page-fetch");
|
|
516
|
+
if (validated)
|
|
517
|
+
return validated;
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
// Fall through to browser fetch or element screenshot.
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (/^https?:/i.test(candidate.src)) {
|
|
525
|
+
const response = await context.request
|
|
526
|
+
.get(candidate.src, { headers: { referer: page.url() }, timeout: 60_000 })
|
|
527
|
+
.catch(() => undefined);
|
|
528
|
+
if (response?.ok()) {
|
|
529
|
+
const validated = this.validatedImageBytes(await response.body(), "http");
|
|
530
|
+
if (validated)
|
|
531
|
+
return validated;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const fetched = await page
|
|
535
|
+
.evaluate(async (sourceUrl) => {
|
|
536
|
+
const response = await fetch(sourceUrl, { credentials: "include" });
|
|
537
|
+
if (!response.ok)
|
|
538
|
+
throw new Error(`HTTP ${response.status}`);
|
|
539
|
+
const blob = await response.blob();
|
|
540
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
541
|
+
const reader = new FileReader();
|
|
542
|
+
reader.onload = () => resolve(String(reader.result));
|
|
543
|
+
reader.onerror = () => reject(reader.error);
|
|
544
|
+
reader.readAsDataURL(blob);
|
|
545
|
+
});
|
|
546
|
+
return dataUrl;
|
|
547
|
+
}, candidate.src)
|
|
548
|
+
.catch(() => undefined);
|
|
549
|
+
if (fetched) {
|
|
550
|
+
const bytes = Buffer.from(fetched.split(",", 2)[1] || "", "base64");
|
|
551
|
+
const validated = this.validatedImageBytes(bytes, "page-fetch");
|
|
552
|
+
if (validated)
|
|
553
|
+
return validated;
|
|
554
|
+
}
|
|
555
|
+
const currentIndex = await page.locator("main img").evaluateAll((images, sourceUrl) => images.findIndex((element) => {
|
|
556
|
+
const image = element;
|
|
557
|
+
return (image.currentSrc || image.src) === sourceUrl;
|
|
558
|
+
}), candidate.src).catch(() => -1);
|
|
559
|
+
const image = page.locator("main img").nth(currentIndex >= 0 ? currentIndex : candidate.index);
|
|
560
|
+
const screenshot = await image.screenshot({ type: "png" });
|
|
561
|
+
const validated = this.validatedImageBytes(screenshot, "element-screenshot");
|
|
562
|
+
if (!validated)
|
|
563
|
+
throw new Error("下载结果不是可识别的图片格式");
|
|
564
|
+
return validated;
|
|
565
|
+
}
|
|
566
|
+
async saveCandidate(context, page, candidate, outputDir, fileStem) {
|
|
567
|
+
const downloaded = await this.fetchImageBytes(context, page, candidate);
|
|
568
|
+
const extension = imageExtension(downloaded.contentType, candidate.src);
|
|
569
|
+
const outputPath = path.join(outputDir, `${safeFileStem(fileStem)}${extension}`);
|
|
570
|
+
fs.writeFileSync(outputPath, downloaded.bytes, { flag: "wx", mode: 0o600 });
|
|
571
|
+
return {
|
|
572
|
+
path: outputPath,
|
|
573
|
+
method: downloaded.method,
|
|
574
|
+
sourceUrl: candidate.src,
|
|
575
|
+
contentType: downloaded.contentType,
|
|
576
|
+
width: candidate.width,
|
|
577
|
+
height: candidate.height,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
async submitPreparedJobUnlocked(args) {
|
|
581
|
+
try {
|
|
582
|
+
let prepared = this.readPreparedJob();
|
|
583
|
+
if (args.expectedJobId && prepared.jobId !== args.expectedJobId) {
|
|
584
|
+
throw new Error("准备任务在发送前被另一个会话替换。为避免发送错误提示词,本次任务已停止。");
|
|
585
|
+
}
|
|
586
|
+
this.assertJobCanBeSubmitted(prepared);
|
|
587
|
+
const outputDir = ensureDirectory(resolvePath(args.outputDir || prepared.outputDir));
|
|
588
|
+
if (outputDir !== prepared.outputDir || !prepared.reservationPath || !fs.existsSync(prepared.reservationPath)) {
|
|
589
|
+
const previousReservation = prepared;
|
|
590
|
+
const reserved = this.reserveOutputStem(outputDir, prepared.outputStem, prepared.jobId, outputDir === prepared.outputDir ? [prepared.screenshotPath] : []);
|
|
591
|
+
prepared = {
|
|
592
|
+
...prepared,
|
|
593
|
+
outputDir,
|
|
594
|
+
outputStem: reserved.outputStem,
|
|
595
|
+
reservationPath: reserved.reservationPath,
|
|
596
|
+
};
|
|
597
|
+
this.writePreparedJob(prepared);
|
|
598
|
+
if (previousReservation.reservationPath !== reserved.reservationPath) {
|
|
599
|
+
this.releaseOutputReservation(previousReservation);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const timeoutSeconds = Math.max(30, Math.min(args.timeoutSeconds ?? 480, 900));
|
|
603
|
+
const { context, page } = await this.browserManager.connect({ agentId: this.agentId });
|
|
604
|
+
const pageState = await this.inspectPage(page);
|
|
605
|
+
if (!pageState.composerReady) {
|
|
606
|
+
throw new Error("ChatGPT输入框不可用;请检查登录、安全验证或当前页面。不会启动第二个浏览器。");
|
|
607
|
+
}
|
|
608
|
+
const composer = await this.findComposer(page, 2_000);
|
|
609
|
+
if (!composer || (await this.composerText(composer)).trim() !== prepared.prompt.trim()) {
|
|
610
|
+
throw new Error("ChatGPT输入框内容与准备任务不一致。为避免发送错误提示词,本次任务已停止;请清除后重新准备。");
|
|
611
|
+
}
|
|
612
|
+
if (prepared.preparedAttachmentCount !== undefined) {
|
|
613
|
+
const currentAttachmentCount = await this.attachmentCount(page);
|
|
614
|
+
if (currentAttachmentCount !== prepared.preparedAttachmentCount) {
|
|
615
|
+
throw new Error(`ChatGPT附件数量已变化(准备时${prepared.preparedAttachmentCount},当前${currentAttachmentCount})。为避免引用错误图片,本次任务已停止。`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Capture again immediately before sending, so uploaded reference previews are excluded.
|
|
619
|
+
const immediateBaseline = await this.collectImageCandidates(page);
|
|
620
|
+
const baselineAssistantMessageCount = await page
|
|
621
|
+
.locator("[data-message-author-role='assistant']")
|
|
622
|
+
.count();
|
|
623
|
+
const baseline = new Set([
|
|
624
|
+
...prepared.baselineImageSources,
|
|
625
|
+
...immediateBaseline.map((candidate) => candidate.src),
|
|
626
|
+
]);
|
|
627
|
+
const submitting = {
|
|
628
|
+
...prepared,
|
|
629
|
+
state: "submitting",
|
|
630
|
+
submittingAt: new Date().toISOString(),
|
|
631
|
+
};
|
|
632
|
+
this.writePreparedJob(submitting);
|
|
633
|
+
await this.clickSend(page);
|
|
634
|
+
const sent = {
|
|
635
|
+
...submitting,
|
|
636
|
+
state: "sent",
|
|
637
|
+
sentAt: new Date().toISOString(),
|
|
638
|
+
};
|
|
639
|
+
this.writePreparedJob(sent);
|
|
640
|
+
const waitResult = await this.waitForNewImage(page, baseline, baselineAssistantMessageCount, timeoutSeconds * 1_000);
|
|
641
|
+
const candidate = waitResult.candidate;
|
|
642
|
+
const screenshotPath = path.join(outputDir, `${prepared.outputStem}-result.png`);
|
|
643
|
+
await this.savePageScreenshot(page, screenshotPath);
|
|
644
|
+
if (!candidate) {
|
|
645
|
+
const result = {
|
|
646
|
+
agentId: this.agentId,
|
|
647
|
+
status: "needs_human",
|
|
648
|
+
jobId: prepared.jobId,
|
|
649
|
+
outputStem: prepared.outputStem,
|
|
650
|
+
outputDir,
|
|
651
|
+
screenshotPath,
|
|
652
|
+
waitOutcome: waitResult.outcome,
|
|
653
|
+
autoStopped: waitResult.autoStopped,
|
|
654
|
+
message: waitResult.autoStopped
|
|
655
|
+
? "等待超时后已自动点击停止,但没有检测到可保存的新图片。浏览器保持打开,请人工检查;系统不会自动重复发送。"
|
|
656
|
+
: "没有可靠检测到新的生成图片。专用浏览器保持打开,请人工检查;系统不会自动重复发送以避免消耗额度。",
|
|
657
|
+
};
|
|
658
|
+
const needsHuman = {
|
|
659
|
+
...sent,
|
|
660
|
+
state: "needs_human",
|
|
661
|
+
needsHumanAt: new Date().toISOString(),
|
|
662
|
+
};
|
|
663
|
+
this.writePreparedJob(needsHuman);
|
|
664
|
+
const finalizedNeedsHuman = { ...needsHuman, reservationPath: undefined };
|
|
665
|
+
atomicWriteJson(path.join(outputDir, `${prepared.outputStem}.json`), {
|
|
666
|
+
...finalizedNeedsHuman,
|
|
667
|
+
...result,
|
|
668
|
+
});
|
|
669
|
+
this.releaseOutputReservation(needsHuman);
|
|
670
|
+
this.writePreparedJob(finalizedNeedsHuman);
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
const image = await this.saveCandidate(context, page, candidate, outputDir, `${prepared.outputStem}-image`);
|
|
674
|
+
const result = {
|
|
675
|
+
agentId: this.agentId,
|
|
676
|
+
status: "completed",
|
|
677
|
+
jobId: prepared.jobId,
|
|
678
|
+
outputStem: prepared.outputStem,
|
|
679
|
+
outputDir,
|
|
680
|
+
image,
|
|
681
|
+
screenshotPath,
|
|
682
|
+
waitOutcome: waitResult.outcome,
|
|
683
|
+
autoStopped: waitResult.autoStopped,
|
|
684
|
+
message: waitResult.outcome === "stopped_with_image"
|
|
685
|
+
? "等待超时后已自动停止生成,并保存停止前已出现的图片。请检查图片是否完整。"
|
|
686
|
+
: "图片已生成并保存。请根据当前项目要求继续处理或质检。",
|
|
687
|
+
};
|
|
688
|
+
const completed = {
|
|
689
|
+
...sent,
|
|
690
|
+
state: "completed",
|
|
691
|
+
completedAt: new Date().toISOString(),
|
|
692
|
+
};
|
|
693
|
+
this.writePreparedJob(completed);
|
|
694
|
+
atomicWriteJson(path.join(outputDir, `${prepared.outputStem}.json`), {
|
|
695
|
+
...completed,
|
|
696
|
+
reservationPath: undefined,
|
|
697
|
+
...result,
|
|
698
|
+
});
|
|
699
|
+
this.releaseOutputReservation(completed);
|
|
700
|
+
fs.rmSync(this.preparedJobPath(), { force: true });
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (error.code === "ENOENT") {
|
|
705
|
+
throw new Error("没有待发送任务。请先调用 prepare_image_job。");
|
|
706
|
+
}
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async submitPreparedJob(args) {
|
|
711
|
+
const release = await this.browserManager.acquireJobLock(this.agentId);
|
|
712
|
+
let completed = false;
|
|
713
|
+
try {
|
|
714
|
+
const result = await this.submitPreparedJobUnlocked(args);
|
|
715
|
+
completed = result.status === "completed";
|
|
716
|
+
return result;
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
release();
|
|
720
|
+
if (completed)
|
|
721
|
+
await this.autoCloseBrowserAfterSuccess();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async clearPreparedJobUnlocked() {
|
|
725
|
+
const { page } = await this.browserManager.connect({ agentId: this.agentId });
|
|
726
|
+
const cleared = await this.clearComposerContents(page);
|
|
727
|
+
const prepared = readJsonFile(this.preparedJobPath());
|
|
728
|
+
if (prepared)
|
|
729
|
+
this.releaseOutputReservation(prepared);
|
|
730
|
+
fs.rmSync(this.preparedJobPath(), { force: true });
|
|
731
|
+
return cleared;
|
|
732
|
+
}
|
|
733
|
+
async clearPreparedJob() {
|
|
734
|
+
const release = await this.browserManager.acquireJobLock(this.agentId);
|
|
735
|
+
let cleared = false;
|
|
736
|
+
try {
|
|
737
|
+
const result = await this.clearPreparedJobUnlocked();
|
|
738
|
+
cleared = true;
|
|
739
|
+
return result;
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
release();
|
|
743
|
+
if (cleared)
|
|
744
|
+
await this.autoCloseBrowserAfterSuccess();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async runJob(args) {
|
|
748
|
+
const release = await this.browserManager.acquireJobLock(this.agentId);
|
|
749
|
+
let completed = false;
|
|
750
|
+
try {
|
|
751
|
+
const { prepared } = await this.prepareJobUnlocked(args);
|
|
752
|
+
const result = await this.submitPreparedJobUnlocked({
|
|
753
|
+
timeoutSeconds: args.timeoutSeconds,
|
|
754
|
+
outputDir: args.outputDir,
|
|
755
|
+
expectedJobId: prepared.jobId,
|
|
756
|
+
});
|
|
757
|
+
completed = result.status === "completed";
|
|
758
|
+
return result;
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
release();
|
|
762
|
+
if (completed)
|
|
763
|
+
await this.autoCloseBrowserAfterSuccess();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async runBatchJobs(args) {
|
|
767
|
+
const release = await this.browserManager.acquireJobLock(this.agentId);
|
|
768
|
+
let batchReservation;
|
|
769
|
+
let batchReservationJobId = "";
|
|
770
|
+
let completedSuccessfully = false;
|
|
771
|
+
try {
|
|
772
|
+
if (args.jobs.length === 0)
|
|
773
|
+
throw new Error("批量任务不能为空");
|
|
774
|
+
if (args.jobs.length > 12)
|
|
775
|
+
throw new Error("单批最多12张图片,请拆成多个批次");
|
|
776
|
+
const outputDir = ensureDirectory(resolvePath(args.outputDir || DEFAULT_OUTPUT_DIR));
|
|
777
|
+
const timeoutSeconds = Math.max(30, Math.min(args.timeoutSeconds ?? 480, 900));
|
|
778
|
+
const delaySeconds = Math.max(0, Math.min(args.delaySeconds ?? 3, 30));
|
|
779
|
+
const stopOnFailure = args.stopOnFailure ?? true;
|
|
780
|
+
const requestedBatchId = safeFileStem(makeJobId(`batch:${this.agentId}:${args.jobs.map((job) => job.prompt).join("\n")}`));
|
|
781
|
+
batchReservationJobId = `batch-manifest:${requestedBatchId}`;
|
|
782
|
+
batchReservation = this.reserveOutputStem(outputDir, requestedBatchId, batchReservationJobId);
|
|
783
|
+
const batchId = batchReservation.outputStem;
|
|
784
|
+
const manifestPath = path.join(outputDir, `${batchId}-batch.json`);
|
|
785
|
+
const results = [];
|
|
786
|
+
const writeManifest = (status) => {
|
|
787
|
+
atomicWriteJson(manifestPath, {
|
|
788
|
+
agentId: this.agentId,
|
|
789
|
+
batchId,
|
|
790
|
+
status,
|
|
791
|
+
total: args.jobs.length,
|
|
792
|
+
completed: results.filter((item) => item.status === "completed").length,
|
|
793
|
+
outputDir,
|
|
794
|
+
manifestPath,
|
|
795
|
+
results,
|
|
796
|
+
updatedAt: new Date().toISOString(),
|
|
797
|
+
});
|
|
798
|
+
};
|
|
799
|
+
writeManifest("running");
|
|
800
|
+
for (const [index, job] of args.jobs.entries()) {
|
|
801
|
+
const ordinal = String(index + 1).padStart(2, "0");
|
|
802
|
+
const outputStem = safeFileStem(job.outputStem || `${batchId}-${ordinal}`);
|
|
803
|
+
try {
|
|
804
|
+
const { prepared } = await this.prepareJobUnlocked({
|
|
805
|
+
prompt: job.prompt,
|
|
806
|
+
referenceImages: job.referenceImages,
|
|
807
|
+
outputDir,
|
|
808
|
+
outputStem,
|
|
809
|
+
});
|
|
810
|
+
const submitted = await this.submitPreparedJobUnlocked({
|
|
811
|
+
timeoutSeconds,
|
|
812
|
+
outputDir,
|
|
813
|
+
expectedJobId: prepared.jobId,
|
|
814
|
+
});
|
|
815
|
+
const itemStatus = submitted.status === "completed" ? "completed" : "needs_human";
|
|
816
|
+
results.push({ index, status: itemStatus, job: submitted });
|
|
817
|
+
writeManifest("running");
|
|
818
|
+
if (itemStatus !== "completed" && stopOnFailure)
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
catch (error) {
|
|
822
|
+
results.push({
|
|
823
|
+
index,
|
|
824
|
+
status: "failed",
|
|
825
|
+
error: error instanceof Error ? error.message : String(error),
|
|
826
|
+
});
|
|
827
|
+
writeManifest("running");
|
|
828
|
+
if (stopOnFailure)
|
|
829
|
+
break;
|
|
830
|
+
await this.clearPreparedJobUnlocked().catch(() => undefined);
|
|
831
|
+
}
|
|
832
|
+
if (index < args.jobs.length - 1 && delaySeconds > 0) {
|
|
833
|
+
await sleep(delaySeconds * 1_000);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const completed = results.filter((item) => item.status === "completed").length;
|
|
837
|
+
const status = completed === args.jobs.length
|
|
838
|
+
? "completed"
|
|
839
|
+
: completed > 0
|
|
840
|
+
? "partial"
|
|
841
|
+
: "failed";
|
|
842
|
+
const result = {
|
|
843
|
+
agentId: this.agentId,
|
|
844
|
+
batchId,
|
|
845
|
+
status,
|
|
846
|
+
total: args.jobs.length,
|
|
847
|
+
completed,
|
|
848
|
+
outputDir,
|
|
849
|
+
manifestPath,
|
|
850
|
+
results,
|
|
851
|
+
};
|
|
852
|
+
atomicWriteJson(manifestPath, { ...result, updatedAt: new Date().toISOString() });
|
|
853
|
+
completedSuccessfully = result.status === "completed";
|
|
854
|
+
return result;
|
|
855
|
+
}
|
|
856
|
+
finally {
|
|
857
|
+
if (batchReservation) {
|
|
858
|
+
this.releaseOutputReservation({
|
|
859
|
+
agentId: this.agentId,
|
|
860
|
+
jobId: batchReservationJobId,
|
|
861
|
+
reservationPath: batchReservation.reservationPath,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
release();
|
|
865
|
+
if (completedSuccessfully)
|
|
866
|
+
await this.autoCloseBrowserAfterSuccess();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
async downloadLatest(args) {
|
|
870
|
+
const release = await this.browserManager.acquireJobLock(this.agentId);
|
|
871
|
+
let downloaded = false;
|
|
872
|
+
try {
|
|
873
|
+
const outputDir = ensureDirectory(resolvePath(args.outputDir || DEFAULT_OUTPUT_DIR));
|
|
874
|
+
const { context, page } = await this.browserManager.connect({ agentId: this.agentId });
|
|
875
|
+
const candidates = await this.collectImageCandidates(page);
|
|
876
|
+
const candidate = candidates.at(-1);
|
|
877
|
+
if (!candidate)
|
|
878
|
+
throw new Error("当前ChatGPT会话中没有检测到可下载的生成图片");
|
|
879
|
+
const jobId = makeJobId(`manual-download:${this.agentId}`);
|
|
880
|
+
const reserved = this.reserveOutputStem(outputDir, args.fileStem || `${jobId}-image`, jobId);
|
|
881
|
+
try {
|
|
882
|
+
const result = await this.saveCandidate(context, page, candidate, outputDir, reserved.outputStem);
|
|
883
|
+
downloaded = true;
|
|
884
|
+
return result;
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
this.releaseOutputReservation({
|
|
888
|
+
agentId: this.agentId,
|
|
889
|
+
jobId,
|
|
890
|
+
reservationPath: reserved.reservationPath,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
finally {
|
|
895
|
+
release();
|
|
896
|
+
if (downloaded)
|
|
897
|
+
await this.autoCloseBrowserAfterSuccess();
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
//# sourceMappingURL=chatgpt-automation.js.map
|