@bool01master/gemini-web-mcp 1.0.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/README.md +363 -0
- package/extension/background.js +118 -0
- package/extension/content.js +1475 -0
- package/extension/manifest.json +33 -0
- package/extension/popup.css +43 -0
- package/extension/popup.html +18 -0
- package/extension/popup.js +37 -0
- package/package.json +38 -0
- package/src/extension-bridge.js +749 -0
- package/src/gemini-web-client.js +678 -0
- package/src/index.js +56 -0
- package/src/server.js +272 -0
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const BRIDGE_BASE = "http://127.0.0.1:8765";
|
|
3
|
+
const POLL_BACKOFF_MS = 2500;
|
|
4
|
+
const HEARTBEAT_MS = 10000;
|
|
5
|
+
const COMPOSER_SELECTORS = [
|
|
6
|
+
"textarea",
|
|
7
|
+
"[role='textbox']",
|
|
8
|
+
"[contenteditable='true']",
|
|
9
|
+
"div[contenteditable='true']"
|
|
10
|
+
];
|
|
11
|
+
const NEW_CHAT_PATTERNS = [/new chat/i, /new conversation/i, /新对话/, /新聊天/];
|
|
12
|
+
const SEND_PATTERNS = [/^send$/i, /send message/i, /发送/, /提交/];
|
|
13
|
+
const STOP_PATTERNS = [/stop/i, /停止/, /cancel/i, /取消/];
|
|
14
|
+
const ATTACH_PATTERNS = [/attach/i, /upload/i, /photo/i, /picture/i, /add files?/i, /add photos?/i, /添加/, /上传/, /照片/, /附件/, /^\+$/];
|
|
15
|
+
// More specific patterns for file upload buttons (preferred over ATTACH_PATTERNS)
|
|
16
|
+
const FILE_UPLOAD_PATTERNS = [/文件上传/i, /上传文件/i, /upload.*file/i, /file.*upload/i, /attach.*file/i, /add file/i];
|
|
17
|
+
const MODE_PATTERNS = [/model/i, /mode/i, /flash/i, /pro/i, /image/i, /canvas/i, /deep research/i, /video/i, /gemini/i, /模式/, /模型/, /图像/, /深度研究/, /打开模式选择器/, /快速/, /thinking/i, /思考/, /推理/];
|
|
18
|
+
|
|
19
|
+
const state = {
|
|
20
|
+
clientId: sessionStorage.getItem("__geminiBridgeClientId") || crypto.randomUUID(),
|
|
21
|
+
running: false,
|
|
22
|
+
heartbeatTimer: null,
|
|
23
|
+
backoffTimer: null,
|
|
24
|
+
commandDebug: null
|
|
25
|
+
};
|
|
26
|
+
sessionStorage.setItem("__geminiBridgeClientId", state.clientId);
|
|
27
|
+
|
|
28
|
+
function setCommandDebug(name, stage, extra = {}) {
|
|
29
|
+
state.commandDebug = {
|
|
30
|
+
name,
|
|
31
|
+
stage,
|
|
32
|
+
updatedAt: new Date().toISOString(),
|
|
33
|
+
...extra,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clearCommandDebug(extra = {}) {
|
|
38
|
+
state.commandDebug = {
|
|
39
|
+
name: null,
|
|
40
|
+
stage: "idle",
|
|
41
|
+
updatedAt: new Date().toISOString(),
|
|
42
|
+
...extra,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sleep(ms) {
|
|
47
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeText(value) {
|
|
51
|
+
return (value || "")
|
|
52
|
+
.replace(/\u00a0/g, " ")
|
|
53
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
54
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shortText(value, max = 180) {
|
|
59
|
+
return normalizeText(value).slice(0, max);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function elementText(element) {
|
|
63
|
+
return shortText(
|
|
64
|
+
element.innerText ||
|
|
65
|
+
element.textContent ||
|
|
66
|
+
element.getAttribute("aria-label") ||
|
|
67
|
+
element.getAttribute("title") ||
|
|
68
|
+
"",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isVisible(element, minWidth = 1, minHeight = 1) {
|
|
73
|
+
if (!element) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rect = element.getBoundingClientRect();
|
|
78
|
+
const style = window.getComputedStyle(element);
|
|
79
|
+
return (
|
|
80
|
+
rect.width >= minWidth &&
|
|
81
|
+
rect.height >= minHeight &&
|
|
82
|
+
style.display !== "none" &&
|
|
83
|
+
style.visibility !== "hidden" &&
|
|
84
|
+
style.opacity !== "0"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buttonSnapshot(element) {
|
|
89
|
+
return {
|
|
90
|
+
text: elementText(element),
|
|
91
|
+
ariaLabel: shortText(element.getAttribute("aria-label") || ""),
|
|
92
|
+
title: shortText(element.getAttribute("title") || ""),
|
|
93
|
+
expanded: element.getAttribute("aria-expanded") === "true",
|
|
94
|
+
hasPopup: element.hasAttribute("aria-haspopup")
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function visibleButtons() {
|
|
99
|
+
return Array.from(document.querySelectorAll("button, [role='button']"))
|
|
100
|
+
.filter((element) => isVisible(element, 20, 20))
|
|
101
|
+
.map((element) => ({ element, ...buttonSnapshot(element) }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function visibleImageNodes(minWidth = 120, minHeight = 120) {
|
|
105
|
+
return Array.from(document.querySelectorAll("img, canvas")).filter((element) =>
|
|
106
|
+
isVisible(element, minWidth, minHeight),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function withTimeout(promise, timeoutMs, fallback) {
|
|
111
|
+
let timer = null;
|
|
112
|
+
try {
|
|
113
|
+
return await Promise.race([
|
|
114
|
+
promise,
|
|
115
|
+
new Promise((resolve) => {
|
|
116
|
+
timer = window.setTimeout(() => resolve(fallback()), timeoutMs);
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
} finally {
|
|
120
|
+
if (timer) {
|
|
121
|
+
window.clearTimeout(timer);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function fetchImageViaExtension(imageUrl) {
|
|
127
|
+
if (!imageUrl || !chrome?.runtime?.sendMessage) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
chrome.runtime.sendMessage(
|
|
133
|
+
{
|
|
134
|
+
type: "bridge:fetch_image",
|
|
135
|
+
url: imageUrl,
|
|
136
|
+
},
|
|
137
|
+
(response) => {
|
|
138
|
+
if (chrome.runtime.lastError) {
|
|
139
|
+
resolve({
|
|
140
|
+
error: chrome.runtime.lastError.message,
|
|
141
|
+
url: imageUrl,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (response?.ok && typeof response.dataUrl === "string") {
|
|
147
|
+
resolve(response.dataUrl);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resolve({
|
|
152
|
+
error: response?.error || "Extension image fetch failed.",
|
|
153
|
+
url: imageUrl,
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function captureVisibleTabDataUrl() {
|
|
161
|
+
if (!chrome?.runtime?.sendMessage) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await new Promise((resolve) => {
|
|
166
|
+
chrome.runtime.sendMessage(
|
|
167
|
+
{
|
|
168
|
+
type: "bridge:activate_self",
|
|
169
|
+
},
|
|
170
|
+
() => resolve(),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const visibilityDeadline = Date.now() + 2500;
|
|
175
|
+
while (document.visibilityState !== "visible" && Date.now() < visibilityDeadline) {
|
|
176
|
+
await sleep(100);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await sleep(200);
|
|
180
|
+
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
chrome.runtime.sendMessage(
|
|
183
|
+
{
|
|
184
|
+
type: "bridge:capture_tab",
|
|
185
|
+
},
|
|
186
|
+
(response) => {
|
|
187
|
+
if (chrome.runtime.lastError) {
|
|
188
|
+
resolve({
|
|
189
|
+
error: chrome.runtime.lastError.message,
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (response?.ok && typeof response.dataUrl === "string") {
|
|
195
|
+
resolve(response.dataUrl);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
resolve({
|
|
200
|
+
error: response?.error || "Visible tab capture failed.",
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function cropDataUrl(dataUrl, rect) {
|
|
208
|
+
if (!dataUrl || !rect?.width || !rect?.height) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const source = await new Promise((resolve, reject) => {
|
|
213
|
+
const image = new Image();
|
|
214
|
+
image.onload = () => resolve(image);
|
|
215
|
+
image.onerror = () => reject(new Error("Failed to decode captured tab image."));
|
|
216
|
+
image.src = dataUrl;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const scale = window.devicePixelRatio || 1;
|
|
220
|
+
const sx = Math.max(0, Math.round(rect.left * scale));
|
|
221
|
+
const sy = Math.max(0, Math.round(rect.top * scale));
|
|
222
|
+
const sw = Math.max(1, Math.round(rect.width * scale));
|
|
223
|
+
const sh = Math.max(1, Math.round(rect.height * scale));
|
|
224
|
+
const canvas = document.createElement("canvas");
|
|
225
|
+
canvas.width = sw;
|
|
226
|
+
canvas.height = sh;
|
|
227
|
+
const context = canvas.getContext("2d");
|
|
228
|
+
if (!context) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
context.drawImage(source, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
233
|
+
return canvas.toDataURL("image/png");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function composerRect() {
|
|
237
|
+
const composer = findComposer();
|
|
238
|
+
return composer ? composer.getBoundingClientRect() : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buttonsNearComposer() {
|
|
242
|
+
const rect = composerRect();
|
|
243
|
+
if (!rect) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return visibleButtons().filter((button) => {
|
|
248
|
+
const buttonRect = button.element.getBoundingClientRect();
|
|
249
|
+
const withinVerticalBand =
|
|
250
|
+
buttonRect.bottom >= rect.top - 220 &&
|
|
251
|
+
buttonRect.top <= rect.bottom + 180;
|
|
252
|
+
const withinHorizontalBand =
|
|
253
|
+
buttonRect.right >= rect.left - 220 &&
|
|
254
|
+
buttonRect.left <= rect.right + 220;
|
|
255
|
+
return withinVerticalBand && withinHorizontalBand;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function fileInputsSummary() {
|
|
260
|
+
return Array.from(document.querySelectorAll("input[type='file']")).map((element) => ({
|
|
261
|
+
accept: element.getAttribute("accept") || "",
|
|
262
|
+
multiple: element.hasAttribute("multiple"),
|
|
263
|
+
disabled: element.disabled,
|
|
264
|
+
visible: isVisible(element, 1, 1),
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function matchesPatterns(value, patterns) {
|
|
269
|
+
return patterns.some((pattern) => pattern.test(value || ""));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function getMainRoot() {
|
|
273
|
+
return document.querySelector("main, [role='main']") || document.body;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Find the conversation content container by walking up from the composer.
|
|
278
|
+
* This avoids including sidebar/navigation in text capture.
|
|
279
|
+
*/
|
|
280
|
+
function getConversationContainer() {
|
|
281
|
+
const composer = findComposer();
|
|
282
|
+
if (!composer) return null;
|
|
283
|
+
|
|
284
|
+
// Walk up from the composer to find a scrollable or large container
|
|
285
|
+
// that represents the conversation panel (not the full page).
|
|
286
|
+
let el = composer.parentElement;
|
|
287
|
+
while (el && el !== document.body) {
|
|
288
|
+
const style = window.getComputedStyle(el);
|
|
289
|
+
const isScrollable =
|
|
290
|
+
style.overflowY === "auto" || style.overflowY === "scroll";
|
|
291
|
+
const isLarge = el.offsetHeight > 300 && el.offsetWidth > 400;
|
|
292
|
+
if (isScrollable && isLarge) {
|
|
293
|
+
return el;
|
|
294
|
+
}
|
|
295
|
+
el = el.parentElement;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get the text content of the conversation area only (no sidebar).
|
|
303
|
+
*/
|
|
304
|
+
function getConversationText() {
|
|
305
|
+
const container = getConversationContainer();
|
|
306
|
+
if (container) {
|
|
307
|
+
const text = normalizeText(container.innerText || "");
|
|
308
|
+
// If the container text is meaningful, use it; otherwise fall back.
|
|
309
|
+
if (text.length > 20) {
|
|
310
|
+
return text;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return normalizeText(getMainRoot().innerText || "");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Turn separator patterns for parsing model responses from conversation text.
|
|
317
|
+
const USER_TURN_RE = /\n(你[说曰]|You\s+said)\s*\n/gi;
|
|
318
|
+
const MODEL_TURN_RE = /\n(Gemini\s*(?:[说曰]|said))\s*\n/gi;
|
|
319
|
+
const FOOTER_RE = /\n(?:工具|Tools)\s*\n|Gemini\s*(?:是一款|is\s+a)\s*AI/i;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Extract only the last model response text from the conversation,
|
|
323
|
+
* avoiding sidebar, buttons, and previous turns.
|
|
324
|
+
*
|
|
325
|
+
* Strategy (in order):
|
|
326
|
+
* 1. Try Gemini-specific DOM selectors for model response elements
|
|
327
|
+
* 2. Parse full page text by turn separators ("Gemini 说" / "你说")
|
|
328
|
+
* 3. Return null → caller falls back to diff-based extraction
|
|
329
|
+
*/
|
|
330
|
+
function getLastResponseText() {
|
|
331
|
+
// --- Strategy 1: DOM selectors ---
|
|
332
|
+
const responseSelectors = [
|
|
333
|
+
"model-response .message-content",
|
|
334
|
+
"model-response",
|
|
335
|
+
"[data-message-author-role='model'] .message-content",
|
|
336
|
+
"[data-message-author-role='model']",
|
|
337
|
+
".response-container .message-content",
|
|
338
|
+
".response-container",
|
|
339
|
+
".conversation-container model-response",
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
for (const selector of responseSelectors) {
|
|
343
|
+
const elements = document.querySelectorAll(selector);
|
|
344
|
+
if (elements.length > 0) {
|
|
345
|
+
const last = elements[elements.length - 1];
|
|
346
|
+
const text = normalizeText(last.innerText || "");
|
|
347
|
+
if (text.length > 5) {
|
|
348
|
+
return text;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- Strategy 2: Parse turn separators from full page text ---
|
|
354
|
+
const fullText = normalizeText(getMainRoot().innerText || "");
|
|
355
|
+
if (fullText.length > 20) {
|
|
356
|
+
// Find all "Gemini 说" / "Gemini said" positions
|
|
357
|
+
const modelPositions = [];
|
|
358
|
+
let match;
|
|
359
|
+
const re = /\n(Gemini\s*(?:[说曰]|said))\s*\n/gi;
|
|
360
|
+
while ((match = re.exec(fullText)) !== null) {
|
|
361
|
+
modelPositions.push(match.index + match[0].length);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (modelPositions.length > 0) {
|
|
365
|
+
// Take content after the LAST "Gemini 说"
|
|
366
|
+
let responseText = fullText.slice(modelPositions[modelPositions.length - 1]);
|
|
367
|
+
|
|
368
|
+
// Trim at next user turn ("你说" / "You said")
|
|
369
|
+
const userMatch = responseText.search(USER_TURN_RE);
|
|
370
|
+
if (userMatch > 0) {
|
|
371
|
+
responseText = responseText.slice(0, userMatch);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Trim footer ("工具" / "Gemini 是一款 AI 工具")
|
|
375
|
+
const footerMatch = responseText.search(FOOTER_RE);
|
|
376
|
+
if (footerMatch > 0) {
|
|
377
|
+
responseText = responseText.slice(0, footerMatch);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
responseText = normalizeText(responseText);
|
|
381
|
+
if (responseText.length > 3) {
|
|
382
|
+
return responseText;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function findComposer() {
|
|
391
|
+
const candidates = [];
|
|
392
|
+
|
|
393
|
+
for (const selector of COMPOSER_SELECTORS) {
|
|
394
|
+
for (const element of document.querySelectorAll(selector)) {
|
|
395
|
+
if (!isVisible(element, 160, 24)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const rect = element.getBoundingClientRect();
|
|
400
|
+
candidates.push({ element, rect });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
candidates.sort(
|
|
405
|
+
(left, right) =>
|
|
406
|
+
right.rect.y + right.rect.height - (left.rect.y + left.rect.height),
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
return candidates[0]?.element || null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function collectModeCandidates() {
|
|
413
|
+
const localButtons = buttonsNearComposer();
|
|
414
|
+
const source = localButtons.length > 0 ? localButtons : visibleButtons();
|
|
415
|
+
|
|
416
|
+
return source
|
|
417
|
+
.filter((button) =>
|
|
418
|
+
matchesPatterns(
|
|
419
|
+
`${button.text} ${button.ariaLabel} ${button.title}`,
|
|
420
|
+
MODE_PATTERNS,
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
.slice(0, 12)
|
|
424
|
+
.map((button) => ({
|
|
425
|
+
text: button.text,
|
|
426
|
+
ariaLabel: button.ariaLabel,
|
|
427
|
+
title: button.title
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resolveModeLabels(mode) {
|
|
432
|
+
const labels = new Set([mode.trim()]);
|
|
433
|
+
const normalized = mode.trim().toLowerCase();
|
|
434
|
+
|
|
435
|
+
if (/(thinking|思考|reason|推理)/i.test(normalized)) {
|
|
436
|
+
labels.add("思考");
|
|
437
|
+
labels.add("Thinking");
|
|
438
|
+
labels.add("推理");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (/(image|图片|制图|作图|nanobanana|nano banana)/i.test(normalized)) {
|
|
442
|
+
labels.add("制作图片");
|
|
443
|
+
labels.add("图片");
|
|
444
|
+
labels.add("Nano Banana 2");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return Array.from(labels)
|
|
448
|
+
.map((value) => value.trim())
|
|
449
|
+
.filter(Boolean);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function isStarterMode(mode) {
|
|
453
|
+
return /制作图片|创作音乐|创作视频|image|music|video|nanobanana|nano banana/i.test(
|
|
454
|
+
mode || "",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function pageSummary() {
|
|
459
|
+
const composer = findComposer();
|
|
460
|
+
const fileInputs = fileInputsSummary();
|
|
461
|
+
const localButtons = buttonsNearComposer();
|
|
462
|
+
const rect = composerRect();
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
ready: Boolean(composer),
|
|
466
|
+
url: location.href,
|
|
467
|
+
title: document.title,
|
|
468
|
+
hasFocus: document.hasFocus(),
|
|
469
|
+
visibilityState: document.visibilityState,
|
|
470
|
+
composerFound: Boolean(composer),
|
|
471
|
+
composerPlaceholder: composer
|
|
472
|
+
? shortText(
|
|
473
|
+
composer.getAttribute?.("placeholder") ||
|
|
474
|
+
composer.getAttribute?.("aria-label") ||
|
|
475
|
+
"",
|
|
476
|
+
)
|
|
477
|
+
: "",
|
|
478
|
+
composerRect: rect
|
|
479
|
+
? {
|
|
480
|
+
left: Math.round(rect.left),
|
|
481
|
+
top: Math.round(rect.top),
|
|
482
|
+
width: Math.round(rect.width),
|
|
483
|
+
height: Math.round(rect.height),
|
|
484
|
+
}
|
|
485
|
+
: null,
|
|
486
|
+
fileInputCount: fileInputs.length,
|
|
487
|
+
fileInputs,
|
|
488
|
+
availableButtons: visibleButtons()
|
|
489
|
+
.slice(0, 24)
|
|
490
|
+
.map((button) => button.text || button.ariaLabel || button.title)
|
|
491
|
+
.filter(Boolean),
|
|
492
|
+
composerButtons: localButtons
|
|
493
|
+
.slice(0, 16)
|
|
494
|
+
.map((button) => button.text || button.ariaLabel || button.title)
|
|
495
|
+
.filter(Boolean),
|
|
496
|
+
modeCandidates: collectModeCandidates(),
|
|
497
|
+
commandDebug: state.commandDebug,
|
|
498
|
+
visibleImageCount: visibleImageNodes().length,
|
|
499
|
+
mainText: shortText(getMainRoot().innerText || "", 4000),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function clearComposer(composer) {
|
|
504
|
+
if ("value" in composer) {
|
|
505
|
+
const prototype =
|
|
506
|
+
composer instanceof HTMLTextAreaElement
|
|
507
|
+
? HTMLTextAreaElement.prototype
|
|
508
|
+
: HTMLInputElement.prototype;
|
|
509
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
|
510
|
+
descriptor?.set?.call(composer, "");
|
|
511
|
+
composer.dispatchEvent(new Event("input", { bubbles: true }));
|
|
512
|
+
composer.dispatchEvent(new Event("change", { bubbles: true }));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
composer.textContent = "";
|
|
517
|
+
composer.dispatchEvent(new InputEvent("input", { bubbles: true, data: "" }));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function writeComposer(composer, prompt) {
|
|
521
|
+
composer.focus();
|
|
522
|
+
|
|
523
|
+
if ("value" in composer) {
|
|
524
|
+
const prototype =
|
|
525
|
+
composer instanceof HTMLTextAreaElement
|
|
526
|
+
? HTMLTextAreaElement.prototype
|
|
527
|
+
: HTMLInputElement.prototype;
|
|
528
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
|
529
|
+
descriptor?.set?.call(composer, prompt);
|
|
530
|
+
composer.dispatchEvent(new Event("input", { bubbles: true }));
|
|
531
|
+
composer.dispatchEvent(new Event("change", { bubbles: true }));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
composer.textContent = prompt;
|
|
536
|
+
composer.dispatchEvent(
|
|
537
|
+
new InputEvent("input", {
|
|
538
|
+
bubbles: true,
|
|
539
|
+
data: prompt,
|
|
540
|
+
inputType: "insertText",
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function clickElement(element) {
|
|
546
|
+
element.scrollIntoView({ block: "center", inline: "center" });
|
|
547
|
+
element.dispatchEvent(
|
|
548
|
+
new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
|
|
549
|
+
);
|
|
550
|
+
element.dispatchEvent(
|
|
551
|
+
new MouseEvent("mouseup", { bubbles: true, cancelable: true }),
|
|
552
|
+
);
|
|
553
|
+
element.dispatchEvent(
|
|
554
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function currentComposerText() {
|
|
559
|
+
const composer = findComposer();
|
|
560
|
+
if (!composer) {
|
|
561
|
+
return "";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if ("value" in composer) {
|
|
565
|
+
return normalizeText(composer.value || "");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return normalizeText(composer.textContent || composer.innerText || "");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function findSendButton() {
|
|
572
|
+
const localButtons = buttonsNearComposer();
|
|
573
|
+
const source = localButtons.length > 0 ? localButtons : visibleButtons();
|
|
574
|
+
return source.find((button) =>
|
|
575
|
+
matchesPatterns(
|
|
576
|
+
`${button.text} ${button.ariaLabel} ${button.title}`,
|
|
577
|
+
SEND_PATTERNS,
|
|
578
|
+
),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function tryOpenNewChat() {
|
|
583
|
+
const target = visibleButtons().find((button) =>
|
|
584
|
+
matchesPatterns(
|
|
585
|
+
`${button.text} ${button.ariaLabel} ${button.title}`,
|
|
586
|
+
NEW_CHAT_PATTERNS,
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
if (!target) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
clickElement(target.element);
|
|
595
|
+
await sleep(1200);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function findMenuOption(labels) {
|
|
600
|
+
const normalizedLabels = labels.map((label) => label.trim().toLowerCase());
|
|
601
|
+
const options = Array.from(
|
|
602
|
+
document.querySelectorAll("[role='menuitem'], [role='option'], [role='tab'], button"),
|
|
603
|
+
)
|
|
604
|
+
.filter((element) => isVisible(element, 24, 16))
|
|
605
|
+
.map((element) => ({
|
|
606
|
+
element,
|
|
607
|
+
text: elementText(element).toLowerCase(),
|
|
608
|
+
}));
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
options.find((option) => normalizedLabels.some((label) => option.text === label)) ||
|
|
612
|
+
options.find((option) => normalizedLabels.some((label) => option.text.includes(label)))
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function selectMode(mode) {
|
|
617
|
+
if (!mode) {
|
|
618
|
+
return { attempted: false, selected: null, available: collectModeCandidates() };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const labels = resolveModeLabels(mode);
|
|
622
|
+
const normalizedLabels = labels.map((label) => label.toLowerCase());
|
|
623
|
+
const localButtons = buttonsNearComposer();
|
|
624
|
+
const source = localButtons.length > 0 ? localButtons : visibleButtons();
|
|
625
|
+
const directButton = source.find((button) => {
|
|
626
|
+
const text = `${button.text} ${button.ariaLabel} ${button.title}`.toLowerCase();
|
|
627
|
+
return normalizedLabels.some((label) => text === label || text.includes(label));
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
if (directButton) {
|
|
631
|
+
clickElement(directButton.element);
|
|
632
|
+
await sleep(600);
|
|
633
|
+
return {
|
|
634
|
+
attempted: true,
|
|
635
|
+
selected: mode,
|
|
636
|
+
method: "direct-button",
|
|
637
|
+
available: collectModeCandidates(),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const trigger = source.find((button) =>
|
|
642
|
+
matchesPatterns(
|
|
643
|
+
`${button.text} ${button.ariaLabel} ${button.title}`,
|
|
644
|
+
MODE_PATTERNS,
|
|
645
|
+
),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
if (!trigger) {
|
|
649
|
+
return {
|
|
650
|
+
attempted: true,
|
|
651
|
+
selected: null,
|
|
652
|
+
method: "not-found",
|
|
653
|
+
available: collectModeCandidates(),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
clickElement(trigger.element);
|
|
658
|
+
await sleep(700);
|
|
659
|
+
|
|
660
|
+
const option = findMenuOption(labels);
|
|
661
|
+
if (!option) {
|
|
662
|
+
return {
|
|
663
|
+
attempted: true,
|
|
664
|
+
selected: null,
|
|
665
|
+
method: "menu-opened-no-match",
|
|
666
|
+
available: collectModeCandidates(),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
clickElement(option.element);
|
|
671
|
+
await sleep(900);
|
|
672
|
+
return {
|
|
673
|
+
attempted: true,
|
|
674
|
+
selected: mode,
|
|
675
|
+
method: "menu-option",
|
|
676
|
+
available: collectModeCandidates(),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function ensureFileInput() {
|
|
681
|
+
let input = Array.from(document.querySelectorAll("input[type='file']")).find(
|
|
682
|
+
(element) => !element.disabled,
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
if (input) {
|
|
686
|
+
return input;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const localButtons = buttonsNearComposer();
|
|
690
|
+
const source = localButtons.length > 0 ? localButtons : visibleButtons();
|
|
691
|
+
|
|
692
|
+
// Prefer buttons matching specific file-upload patterns, then fall back to general attach patterns.
|
|
693
|
+
const matchButton = (patterns) =>
|
|
694
|
+
source.find((button) =>
|
|
695
|
+
matchesPatterns(`${button.text} ${button.ariaLabel} ${button.title}`, patterns),
|
|
696
|
+
);
|
|
697
|
+
const attachButton = matchButton(FILE_UPLOAD_PATTERNS) || matchButton(ATTACH_PATTERNS);
|
|
698
|
+
|
|
699
|
+
if (!attachButton) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
clickElement(attachButton.element);
|
|
704
|
+
await sleep(1000);
|
|
705
|
+
|
|
706
|
+
// Check if clicking produced a file input directly.
|
|
707
|
+
input = Array.from(document.querySelectorAll("input[type='file']")).find(
|
|
708
|
+
(element) => !element.disabled,
|
|
709
|
+
);
|
|
710
|
+
if (input) return input;
|
|
711
|
+
|
|
712
|
+
// Gemini may show a sub-menu after clicking the attach button.
|
|
713
|
+
// Look for a menu item that triggers the local file upload.
|
|
714
|
+
const UPLOAD_MENU_PATTERNS = [
|
|
715
|
+
/从.*(?:电脑|设备|本地).*上传/i,
|
|
716
|
+
/upload.*(?:file|computer|device)/i,
|
|
717
|
+
/上传文件/i,
|
|
718
|
+
/我的电脑/i,
|
|
719
|
+
/本地文件/i,
|
|
720
|
+
/my computer/i,
|
|
721
|
+
/from computer/i,
|
|
722
|
+
];
|
|
723
|
+
const allButtons = visibleButtons();
|
|
724
|
+
const menuItems = Array.from(
|
|
725
|
+
document.querySelectorAll("[role='menuitem'], [role='option'], [role='listitem'], [role='button']"),
|
|
726
|
+
)
|
|
727
|
+
.filter((el) => isVisible(el, 10, 10))
|
|
728
|
+
.map((el) => ({ element: el, ...buttonSnapshot(el) }));
|
|
729
|
+
const candidates = [...menuItems, ...allButtons];
|
|
730
|
+
|
|
731
|
+
const uploadItem = candidates.find((item) =>
|
|
732
|
+
matchesPatterns(
|
|
733
|
+
`${item.text} ${item.ariaLabel} ${item.title}`,
|
|
734
|
+
UPLOAD_MENU_PATTERNS,
|
|
735
|
+
),
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
if (uploadItem) {
|
|
739
|
+
clickElement(uploadItem.element);
|
|
740
|
+
await sleep(800);
|
|
741
|
+
input = Array.from(document.querySelectorAll("input[type='file']")).find(
|
|
742
|
+
(element) => !element.disabled,
|
|
743
|
+
);
|
|
744
|
+
if (input) return input;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// If still no file input, try clicking any menu item containing "上传" or "upload"
|
|
748
|
+
const fallbackItem = candidates.find((item) =>
|
|
749
|
+
matchesPatterns(
|
|
750
|
+
`${item.text} ${item.ariaLabel} ${item.title}`,
|
|
751
|
+
[/上传/, /upload/i],
|
|
752
|
+
),
|
|
753
|
+
);
|
|
754
|
+
if (fallbackItem && fallbackItem !== uploadItem) {
|
|
755
|
+
clickElement(fallbackItem.element);
|
|
756
|
+
await sleep(800);
|
|
757
|
+
input = Array.from(document.querySelectorAll("input[type='file']")).find(
|
|
758
|
+
(element) => !element.disabled,
|
|
759
|
+
);
|
|
760
|
+
if (input) return input;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function base64ToBytes(base64) {
|
|
767
|
+
const binary = atob(base64);
|
|
768
|
+
const bytes = new Uint8Array(binary.length);
|
|
769
|
+
|
|
770
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
771
|
+
bytes[index] = binary.charCodeAt(index);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return bytes;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function attachImages(images) {
|
|
778
|
+
if (!Array.isArray(images) || images.length === 0) {
|
|
779
|
+
return { attempted: false, count: 0 };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const input = await ensureFileInput();
|
|
783
|
+
if (!input) {
|
|
784
|
+
throw new Error("Unable to find an image upload input on the page.");
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const transfer = new DataTransfer();
|
|
788
|
+
|
|
789
|
+
for (const image of images) {
|
|
790
|
+
const file = new File([base64ToBytes(image.base64)], image.name, {
|
|
791
|
+
type: image.mimeType || "application/octet-stream",
|
|
792
|
+
lastModified: Date.now(),
|
|
793
|
+
});
|
|
794
|
+
transfer.items.add(file);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
input.files = transfer.files;
|
|
798
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
799
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
800
|
+
await sleep(1200);
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
attempted: true,
|
|
804
|
+
count: transfer.files.length,
|
|
805
|
+
inputAccept: input.getAttribute("accept") || "",
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function captureState() {
|
|
810
|
+
// Use full main root text for reliable change detection.
|
|
811
|
+
const text = normalizeText(getMainRoot().innerText || "");
|
|
812
|
+
const buttons = visibleButtons();
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
text,
|
|
816
|
+
textLength: text.length,
|
|
817
|
+
stopVisible: buttons.some((button) =>
|
|
818
|
+
matchesPatterns(
|
|
819
|
+
`${button.text} ${button.ariaLabel} ${button.title}`,
|
|
820
|
+
STOP_PATTERNS,
|
|
821
|
+
),
|
|
822
|
+
),
|
|
823
|
+
buttonLabels: buttons
|
|
824
|
+
.slice(0, 30)
|
|
825
|
+
.map((button) => button.text || button.ariaLabel || button.title)
|
|
826
|
+
.filter(Boolean),
|
|
827
|
+
imageCount: visibleImageNodes().length,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function waitForResponseStart(beforeState, timeoutMs) {
|
|
832
|
+
const startedAt = Date.now();
|
|
833
|
+
|
|
834
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
835
|
+
const current = captureState();
|
|
836
|
+
if (
|
|
837
|
+
current.textLength > beforeState.textLength + 20 ||
|
|
838
|
+
current.imageCount > beforeState.imageCount ||
|
|
839
|
+
current.stopVisible
|
|
840
|
+
) {
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
await sleep(900);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function waitForResponseCompletion(beforeState, timeoutMs) {
|
|
851
|
+
const startedAt = Date.now();
|
|
852
|
+
let stableSignature = "";
|
|
853
|
+
let stableMs = 0;
|
|
854
|
+
|
|
855
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
856
|
+
const current = captureState();
|
|
857
|
+
const hasMeaningfulChange =
|
|
858
|
+
current.text !== beforeState.text ||
|
|
859
|
+
current.imageCount !== beforeState.imageCount;
|
|
860
|
+
|
|
861
|
+
if (!hasMeaningfulChange && current.stopVisible) {
|
|
862
|
+
await sleep(1200);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const signature = JSON.stringify({
|
|
867
|
+
textTail: current.text.slice(-1500),
|
|
868
|
+
imageCount: current.imageCount,
|
|
869
|
+
stopVisible: current.stopVisible,
|
|
870
|
+
buttons: current.buttonLabels.slice(0, 8),
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
if (signature === stableSignature) {
|
|
874
|
+
stableMs += 1200;
|
|
875
|
+
} else {
|
|
876
|
+
stableSignature = signature;
|
|
877
|
+
stableMs = 0;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (hasMeaningfulChange && stableMs >= 3600 && !current.stopVisible) {
|
|
881
|
+
return current;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
await sleep(1200);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return captureState();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function waitForImageReady(beforeState, timeoutMs) {
|
|
891
|
+
const startedAt = Date.now();
|
|
892
|
+
let stableImageCount = beforeState.imageCount;
|
|
893
|
+
let stableMs = 0;
|
|
894
|
+
let stableSignature = "";
|
|
895
|
+
// After Gemini's text response stabilizes, the image generation tool may
|
|
896
|
+
// still be rendering. Wait at least this long for images to appear.
|
|
897
|
+
const IMAGE_RENDER_GRACE_MS = 45_000;
|
|
898
|
+
|
|
899
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
900
|
+
const current = captureState();
|
|
901
|
+
const hasMeaningfulChange =
|
|
902
|
+
current.text !== beforeState.text ||
|
|
903
|
+
current.imageCount !== beforeState.imageCount ||
|
|
904
|
+
current.buttonLabels.join("\n") !== beforeState.buttonLabels.join("\n");
|
|
905
|
+
|
|
906
|
+
const signature = JSON.stringify({
|
|
907
|
+
textTail: current.text.slice(-1500),
|
|
908
|
+
imageCount: current.imageCount,
|
|
909
|
+
stopVisible: current.stopVisible,
|
|
910
|
+
buttons: current.buttonLabels.slice(0, 8),
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
if (current.imageCount > beforeState.imageCount) {
|
|
914
|
+
if (current.imageCount === stableImageCount) {
|
|
915
|
+
stableMs += 1000;
|
|
916
|
+
} else {
|
|
917
|
+
stableImageCount = current.imageCount;
|
|
918
|
+
stableMs = 0;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (!current.stopVisible && stableMs >= 2000) {
|
|
922
|
+
return current;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (signature === stableSignature) {
|
|
927
|
+
stableMs += 1000;
|
|
928
|
+
} else {
|
|
929
|
+
stableSignature = signature;
|
|
930
|
+
if (current.imageCount <= beforeState.imageCount) {
|
|
931
|
+
stableMs = 0;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Only give up when no new images appeared AND the page has been stable
|
|
936
|
+
// long enough for images to have rendered (Gemini's image tool can take
|
|
937
|
+
// 10-20 s after the text response completes).
|
|
938
|
+
const graceThreshold =
|
|
939
|
+
current.imageCount > beforeState.imageCount ? 3000 : IMAGE_RENDER_GRACE_MS;
|
|
940
|
+
if (hasMeaningfulChange && !current.stopVisible && stableMs >= graceThreshold) {
|
|
941
|
+
return current;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
await sleep(1000);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return captureState();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function newTextFromStates(beforeState, afterState) {
|
|
951
|
+
const before = beforeState.text || "";
|
|
952
|
+
const after = afterState.text || "";
|
|
953
|
+
let index = 0;
|
|
954
|
+
const max = Math.min(before.length, after.length);
|
|
955
|
+
|
|
956
|
+
while (index < max && before[index] === after[index]) {
|
|
957
|
+
index += 1;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return normalizeText(after.slice(index)) || after.slice(-20000);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function imageToDataUrl(image) {
|
|
964
|
+
if (image instanceof HTMLCanvasElement) {
|
|
965
|
+
try {
|
|
966
|
+
return image.toDataURL("image/png");
|
|
967
|
+
} catch (error) {
|
|
968
|
+
return {
|
|
969
|
+
error: error instanceof Error ? error.message : String(error),
|
|
970
|
+
url: null,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (!(image instanceof HTMLImageElement)) {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const imageUrl = image.currentSrc || image.src || "";
|
|
980
|
+
|
|
981
|
+
if (imageUrl.startsWith("data:image/")) {
|
|
982
|
+
return imageUrl;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
try {
|
|
986
|
+
const canvas = document.createElement("canvas");
|
|
987
|
+
canvas.width = Math.max(1, image.naturalWidth || image.width || 1);
|
|
988
|
+
canvas.height = Math.max(1, image.naturalHeight || image.height || 1);
|
|
989
|
+
const context = canvas.getContext("2d");
|
|
990
|
+
if (context) {
|
|
991
|
+
context.drawImage(image, 0, 0);
|
|
992
|
+
return canvas.toDataURL("image/png");
|
|
993
|
+
}
|
|
994
|
+
} catch {
|
|
995
|
+
// Canvas tainted by cross-origin image; tab-capture fallback handles this.
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return {
|
|
999
|
+
error: "Unable to read image bytes directly from page element.",
|
|
1000
|
+
url: imageUrl,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function collectVisibleImages(maxImages) {
|
|
1005
|
+
if (!Number.isFinite(maxImages) || maxImages <= 0) {
|
|
1006
|
+
return [];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const nodes = visibleImageNodes(180, 180)
|
|
1010
|
+
.sort((left, right) => {
|
|
1011
|
+
const leftHasUrl =
|
|
1012
|
+
left instanceof HTMLImageElement && Boolean(left.currentSrc || left.src);
|
|
1013
|
+
const rightHasUrl =
|
|
1014
|
+
right instanceof HTMLImageElement && Boolean(right.currentSrc || right.src);
|
|
1015
|
+
if (leftHasUrl !== rightHasUrl) {
|
|
1016
|
+
return Number(rightHasUrl) - Number(leftHasUrl);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const leftRect = left.getBoundingClientRect();
|
|
1020
|
+
const rightRect = right.getBoundingClientRect();
|
|
1021
|
+
return (
|
|
1022
|
+
rightRect.bottom * rightRect.width * rightRect.height -
|
|
1023
|
+
leftRect.bottom * leftRect.width * leftRect.height
|
|
1024
|
+
);
|
|
1025
|
+
})
|
|
1026
|
+
.slice(0, maxImages);
|
|
1027
|
+
|
|
1028
|
+
const results = [];
|
|
1029
|
+
const failedIndices = [];
|
|
1030
|
+
|
|
1031
|
+
for (const [index, node] of nodes.entries()) {
|
|
1032
|
+
const rect = node.getBoundingClientRect();
|
|
1033
|
+
const url = node instanceof HTMLImageElement ? node.currentSrc || node.src : null;
|
|
1034
|
+
let dataUrl = null;
|
|
1035
|
+
let error = null;
|
|
1036
|
+
try {
|
|
1037
|
+
const extracted = await imageToDataUrl(node);
|
|
1038
|
+
if (typeof extracted === "string") {
|
|
1039
|
+
dataUrl = extracted;
|
|
1040
|
+
} else if (extracted && extracted.error) {
|
|
1041
|
+
error = extracted.error;
|
|
1042
|
+
failedIndices.push(index);
|
|
1043
|
+
}
|
|
1044
|
+
} catch (extractError) {
|
|
1045
|
+
error = extractError instanceof Error ? extractError.message : String(extractError);
|
|
1046
|
+
failedIndices.push(index);
|
|
1047
|
+
}
|
|
1048
|
+
results.push({
|
|
1049
|
+
dataUrl,
|
|
1050
|
+
url,
|
|
1051
|
+
width: Math.round(rect.width),
|
|
1052
|
+
height: Math.round(rect.height),
|
|
1053
|
+
alt: node instanceof HTMLImageElement ? node.alt || "" : "",
|
|
1054
|
+
tagName: node.tagName.toLowerCase(),
|
|
1055
|
+
error,
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Fallback: capture visible tab and crop each failed image region.
|
|
1060
|
+
if (failedIndices.length > 0) {
|
|
1061
|
+
try {
|
|
1062
|
+
for (const index of failedIndices) {
|
|
1063
|
+
// Scroll the image into view first
|
|
1064
|
+
nodes[index].scrollIntoView({ block: "center", inline: "center" });
|
|
1065
|
+
await sleep(300);
|
|
1066
|
+
|
|
1067
|
+
const tabCapture = await captureVisibleTabDataUrl();
|
|
1068
|
+
if (typeof tabCapture !== "string") {
|
|
1069
|
+
// Tab capture failed entirely; stop trying further images.
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const rect = nodes[index].getBoundingClientRect();
|
|
1074
|
+
const cropped = await cropDataUrl(tabCapture, rect);
|
|
1075
|
+
if (typeof cropped === "string") {
|
|
1076
|
+
results[index].dataUrl = cropped;
|
|
1077
|
+
results[index].error = null;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
} catch {
|
|
1081
|
+
// Tab capture unavailable; keep existing errors.
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return results;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function sendPrompt(prompt) {
|
|
1089
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
1090
|
+
const sendButton = findSendButton();
|
|
1091
|
+
if (sendButton) {
|
|
1092
|
+
clickElement(sendButton.element);
|
|
1093
|
+
await sleep(200);
|
|
1094
|
+
if (!findSendButton()) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
await sleep(250);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const composer = findComposer();
|
|
1103
|
+
if (!composer) {
|
|
1104
|
+
throw new Error("Unable to find Gemini composer before submitting.");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
composer.dispatchEvent(
|
|
1108
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
1109
|
+
);
|
|
1110
|
+
composer.dispatchEvent(
|
|
1111
|
+
new KeyboardEvent("keyup", { key: "Enter", bubbles: true, cancelable: true }),
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
await sleep(100);
|
|
1115
|
+
|
|
1116
|
+
if (prompt && findComposer() && currentComposerText()) {
|
|
1117
|
+
document.activeElement?.dispatchEvent(
|
|
1118
|
+
new KeyboardEvent("keydown", {
|
|
1119
|
+
key: "Enter",
|
|
1120
|
+
metaKey: true,
|
|
1121
|
+
bubbles: true,
|
|
1122
|
+
cancelable: true,
|
|
1123
|
+
}),
|
|
1124
|
+
);
|
|
1125
|
+
await sleep(150);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1129
|
+
const sendButton = findSendButton();
|
|
1130
|
+
if (!sendButton) {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
clickElement(sendButton.element);
|
|
1135
|
+
await sleep(250);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function runPrompt(args) {
|
|
1140
|
+
const prompt = normalizeText(args.prompt || "");
|
|
1141
|
+
if (!prompt) {
|
|
1142
|
+
throw new Error("`prompt` cannot be empty.");
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
setCommandDebug("run_prompt", "prepare", {
|
|
1146
|
+
promptPreview: shortText(prompt, 120),
|
|
1147
|
+
mode: args.mode || null,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
let modeResult = {
|
|
1151
|
+
attempted: false,
|
|
1152
|
+
selected: null,
|
|
1153
|
+
available: collectModeCandidates(),
|
|
1154
|
+
};
|
|
1155
|
+
const starterMode = isStarterMode(args.mode || "");
|
|
1156
|
+
|
|
1157
|
+
if (starterMode && args.mode) {
|
|
1158
|
+
setCommandDebug("run_prompt", "starter-mode", {
|
|
1159
|
+
promptPreview: shortText(prompt, 120),
|
|
1160
|
+
mode: args.mode || null,
|
|
1161
|
+
});
|
|
1162
|
+
modeResult = await selectMode(args.mode);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (
|
|
1166
|
+
args.newChat !== false &&
|
|
1167
|
+
!(starterMode && modeResult?.selected && modeResult?.method === "direct-button")
|
|
1168
|
+
) {
|
|
1169
|
+
setCommandDebug("run_prompt", "new-chat", {
|
|
1170
|
+
promptPreview: shortText(prompt, 120),
|
|
1171
|
+
mode: args.mode || null,
|
|
1172
|
+
});
|
|
1173
|
+
await tryOpenNewChat();
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (!modeResult.attempted && args.mode) {
|
|
1177
|
+
setCommandDebug("run_prompt", "select-mode", {
|
|
1178
|
+
promptPreview: shortText(prompt, 120),
|
|
1179
|
+
mode: args.mode || null,
|
|
1180
|
+
});
|
|
1181
|
+
modeResult = await selectMode(args.mode);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
setCommandDebug("run_prompt", "attach-images", {
|
|
1185
|
+
promptPreview: shortText(prompt, 120),
|
|
1186
|
+
mode: args.mode || null,
|
|
1187
|
+
});
|
|
1188
|
+
const attachmentResult = await attachImages(args.images || []);
|
|
1189
|
+
const composer = findComposer();
|
|
1190
|
+
if (!composer) {
|
|
1191
|
+
throw new Error("Unable to find the Gemini prompt input box.");
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
setCommandDebug("run_prompt", "write-composer", {
|
|
1195
|
+
promptPreview: shortText(prompt, 120),
|
|
1196
|
+
mode: args.mode || null,
|
|
1197
|
+
});
|
|
1198
|
+
clearComposer(composer);
|
|
1199
|
+
writeComposer(composer, prompt);
|
|
1200
|
+
|
|
1201
|
+
const beforeState = captureState();
|
|
1202
|
+
setCommandDebug("run_prompt", "send-prompt", {
|
|
1203
|
+
promptPreview: shortText(prompt, 120),
|
|
1204
|
+
mode: args.mode || null,
|
|
1205
|
+
});
|
|
1206
|
+
await sendPrompt(prompt);
|
|
1207
|
+
|
|
1208
|
+
setCommandDebug("run_prompt", "wait-response-start", {
|
|
1209
|
+
promptPreview: shortText(prompt, 120),
|
|
1210
|
+
mode: args.mode || null,
|
|
1211
|
+
});
|
|
1212
|
+
const started = await waitForResponseStart(beforeState, 12_000);
|
|
1213
|
+
if (!started) {
|
|
1214
|
+
throw new Error("Gemini did not appear to start responding.");
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const maxImages = Number(args.maxImages ?? 4);
|
|
1218
|
+
const timeoutMs = Number(args.waitTimeoutMs || 120_000);
|
|
1219
|
+
const prefersImages = maxImages > 0 || /制作图片|image|图片|nanobanana|nano banana/i.test(args.mode || "");
|
|
1220
|
+
|
|
1221
|
+
setCommandDebug("run_prompt", prefersImages ? "wait-image-ready" : "wait-response-complete", {
|
|
1222
|
+
promptPreview: shortText(prompt, 120),
|
|
1223
|
+
mode: args.mode || null,
|
|
1224
|
+
});
|
|
1225
|
+
const afterState = prefersImages
|
|
1226
|
+
? await waitForImageReady(beforeState, timeoutMs)
|
|
1227
|
+
: await waitForResponseCompletion(beforeState, timeoutMs);
|
|
1228
|
+
setCommandDebug("run_prompt", "collect-images", {
|
|
1229
|
+
promptPreview: shortText(prompt, 120),
|
|
1230
|
+
mode: args.mode || null,
|
|
1231
|
+
});
|
|
1232
|
+
const images = await collectVisibleImages(maxImages);
|
|
1233
|
+
setCommandDebug("run_prompt", "build-result", {
|
|
1234
|
+
promptPreview: shortText(prompt, 120),
|
|
1235
|
+
mode: args.mode || null,
|
|
1236
|
+
imageCount: images.length,
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
return {
|
|
1240
|
+
ready: true,
|
|
1241
|
+
title: document.title,
|
|
1242
|
+
url: location.href,
|
|
1243
|
+
mode: args.mode || null,
|
|
1244
|
+
modeResult,
|
|
1245
|
+
attachmentResult,
|
|
1246
|
+
text: getLastResponseText() || newTextFromStates(beforeState, afterState),
|
|
1247
|
+
imageCountOnPage: afterState.imageCount,
|
|
1248
|
+
images,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async function captureImages(args) {
|
|
1253
|
+
setCommandDebug("capture_images", "collect-images");
|
|
1254
|
+
const summary = pageSummary();
|
|
1255
|
+
const images = await collectVisibleImages(Number(args.maxImages ?? 4));
|
|
1256
|
+
setCommandDebug("capture_images", "build-result", {
|
|
1257
|
+
imageCount: images.length,
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
return {
|
|
1261
|
+
ready: summary.ready,
|
|
1262
|
+
title: document.title,
|
|
1263
|
+
url: location.href,
|
|
1264
|
+
mode: null,
|
|
1265
|
+
modeResult: null,
|
|
1266
|
+
attachmentResult: null,
|
|
1267
|
+
text: getLastResponseText() || summary.mainText || "",
|
|
1268
|
+
imageCountOnPage: Number(summary.visibleImageCount || images.length),
|
|
1269
|
+
images,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async function downloadImageUrls(args) {
|
|
1274
|
+
const urls = Array.isArray(args.urls)
|
|
1275
|
+
? Array.from(new Set(args.urls.filter((value) => typeof value === "string" && value)))
|
|
1276
|
+
: [];
|
|
1277
|
+
const downloads = [];
|
|
1278
|
+
|
|
1279
|
+
for (const url of urls) {
|
|
1280
|
+
const result = await withTimeout(
|
|
1281
|
+
fetchImageViaExtension(url),
|
|
1282
|
+
15_000,
|
|
1283
|
+
() => ({
|
|
1284
|
+
error: "Timed out while downloading image URL.",
|
|
1285
|
+
url,
|
|
1286
|
+
}),
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
if (typeof result === "string") {
|
|
1290
|
+
downloads.push({
|
|
1291
|
+
url,
|
|
1292
|
+
dataUrl: result,
|
|
1293
|
+
error: null,
|
|
1294
|
+
});
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
downloads.push({
|
|
1299
|
+
url,
|
|
1300
|
+
dataUrl: null,
|
|
1301
|
+
error: result?.error || "Failed to download image URL.",
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
return { downloads };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function executeCommand(command) {
|
|
1309
|
+
setCommandDebug(command.name, "dispatch");
|
|
1310
|
+
if (command.name === "get_status") {
|
|
1311
|
+
const result = pageSummary();
|
|
1312
|
+
clearCommandDebug({ lastCompletedCommand: command.name });
|
|
1313
|
+
return result;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (command.name === "run_prompt") {
|
|
1317
|
+
const result = await runPrompt(command.args || {});
|
|
1318
|
+
clearCommandDebug({ lastCompletedCommand: command.name });
|
|
1319
|
+
return result;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (command.name === "capture_images") {
|
|
1323
|
+
const result = await captureImages(command.args || {});
|
|
1324
|
+
clearCommandDebug({ lastCompletedCommand: command.name });
|
|
1325
|
+
return result;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (command.name === "download_image_urls") {
|
|
1329
|
+
const result = await downloadImageUrls(command.args || {});
|
|
1330
|
+
clearCommandDebug({ lastCompletedCommand: command.name });
|
|
1331
|
+
return result;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (command.name === "eval_js") {
|
|
1335
|
+
const code = (command.args && command.args.code) || "";
|
|
1336
|
+
try {
|
|
1337
|
+
const fn = new Function(code);
|
|
1338
|
+
const evalResult = fn();
|
|
1339
|
+
clearCommandDebug({ lastCompletedCommand: command.name });
|
|
1340
|
+
return { ok: true, value: evalResult };
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
clearCommandDebug({ lastCompletedCommand: command.name, error: err.message });
|
|
1343
|
+
return { ok: false, error: err.message };
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
clearCommandDebug({
|
|
1348
|
+
lastCompletedCommand: command.name,
|
|
1349
|
+
error: `Unsupported command: ${command.name}`,
|
|
1350
|
+
});
|
|
1351
|
+
throw new Error(`Unsupported command: ${command.name}`);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async function bridgeFetch(pathname, options = {}) {
|
|
1355
|
+
const headers = { ...(options.headers || {}) };
|
|
1356
|
+
if (options.body) {
|
|
1357
|
+
headers["Content-Type"] = "application/json";
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const response = await fetch(`${BRIDGE_BASE}${pathname}`, {
|
|
1361
|
+
headers,
|
|
1362
|
+
...options,
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
if (!response.ok) {
|
|
1366
|
+
throw new Error(`Bridge request failed with status ${response.status}.`);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return response.json();
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function register() {
|
|
1373
|
+
const response = await bridgeFetch("/bridge/register", {
|
|
1374
|
+
method: "POST",
|
|
1375
|
+
body: JSON.stringify({
|
|
1376
|
+
clientId: state.clientId,
|
|
1377
|
+
pageUrl: location.href,
|
|
1378
|
+
title: document.title,
|
|
1379
|
+
capabilities: {
|
|
1380
|
+
modeSelection: true,
|
|
1381
|
+
imageUpload: true,
|
|
1382
|
+
imageExtraction: true,
|
|
1383
|
+
},
|
|
1384
|
+
}),
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
state.clientId = response.clientId;
|
|
1388
|
+
sessionStorage.setItem("__geminiBridgeClientId", state.clientId);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
async function heartbeat() {
|
|
1392
|
+
await bridgeFetch("/bridge/heartbeat", {
|
|
1393
|
+
method: "POST",
|
|
1394
|
+
body: JSON.stringify({
|
|
1395
|
+
clientId: state.clientId,
|
|
1396
|
+
pageUrl: location.href,
|
|
1397
|
+
title: document.title,
|
|
1398
|
+
state: pageSummary(),
|
|
1399
|
+
}),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
async function postResult(requestId, ok, result, error) {
|
|
1404
|
+
await bridgeFetch("/bridge/result", {
|
|
1405
|
+
method: "POST",
|
|
1406
|
+
body: JSON.stringify({
|
|
1407
|
+
clientId: state.clientId,
|
|
1408
|
+
requestId,
|
|
1409
|
+
ok,
|
|
1410
|
+
result,
|
|
1411
|
+
error,
|
|
1412
|
+
}),
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
async function pollLoop() {
|
|
1417
|
+
if (state.running) {
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
state.running = true;
|
|
1422
|
+
|
|
1423
|
+
while (state.running) {
|
|
1424
|
+
try {
|
|
1425
|
+
await register();
|
|
1426
|
+
const payload = await bridgeFetch(
|
|
1427
|
+
`/bridge/next?clientId=${encodeURIComponent(state.clientId)}`,
|
|
1428
|
+
{ method: "GET" },
|
|
1429
|
+
);
|
|
1430
|
+
if (payload.command) {
|
|
1431
|
+
try {
|
|
1432
|
+
const result = await executeCommand(payload.command);
|
|
1433
|
+
await postResult(payload.command.requestId, true, result, null);
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
await postResult(
|
|
1436
|
+
payload.command.requestId,
|
|
1437
|
+
false,
|
|
1438
|
+
null,
|
|
1439
|
+
error instanceof Error ? error.message : String(error),
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
console.debug("Gemini MCP bridge poll failed:", error);
|
|
1445
|
+
await sleep(POLL_BACKOFF_MS);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
1451
|
+
if (message?.type !== "popup:get_status") {
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
Promise.resolve(pageSummary())
|
|
1456
|
+
.then((result) => sendResponse({ ok: true, result }))
|
|
1457
|
+
.catch((error) =>
|
|
1458
|
+
sendResponse({
|
|
1459
|
+
ok: false,
|
|
1460
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1461
|
+
}),
|
|
1462
|
+
);
|
|
1463
|
+
return true;
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
window.addEventListener("focus", () => {
|
|
1467
|
+
heartbeat().catch(() => {});
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
state.heartbeatTimer = window.setInterval(() => {
|
|
1471
|
+
heartbeat().catch(() => {});
|
|
1472
|
+
}, HEARTBEAT_MS);
|
|
1473
|
+
|
|
1474
|
+
pollLoop().catch(() => {});
|
|
1475
|
+
})();
|