chrome-ai-bridge 1.0.6 → 1.0.8
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/build/src/download-manager.js +181 -0
- package/build/src/tools/gemini-image.js +107 -101
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
export class DownloadManager extends EventEmitter {
|
|
8
|
+
cdpSession = null;
|
|
9
|
+
downloadDir;
|
|
10
|
+
page;
|
|
11
|
+
pendingDownloads = new Map();
|
|
12
|
+
downloadPromiseResolvers = new Map();
|
|
13
|
+
isMonitoring = false;
|
|
14
|
+
constructor(page, downloadDir) {
|
|
15
|
+
super();
|
|
16
|
+
this.page = page;
|
|
17
|
+
this.downloadDir = downloadDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Start monitoring downloads using CDP events
|
|
21
|
+
*/
|
|
22
|
+
async startMonitoring() {
|
|
23
|
+
if (this.isMonitoring) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Create CDP session
|
|
27
|
+
this.cdpSession = await this.page.createCDPSession();
|
|
28
|
+
// Enable download events
|
|
29
|
+
await this.cdpSession.send('Browser.setDownloadBehavior', {
|
|
30
|
+
behavior: 'allowAndName',
|
|
31
|
+
downloadPath: this.downloadDir,
|
|
32
|
+
eventsEnabled: true,
|
|
33
|
+
});
|
|
34
|
+
// Listen for download events
|
|
35
|
+
this.cdpSession.on('Browser.downloadWillBegin', (event) => {
|
|
36
|
+
const info = {
|
|
37
|
+
guid: event.guid,
|
|
38
|
+
url: event.url,
|
|
39
|
+
suggestedFilename: event.suggestedFilename,
|
|
40
|
+
state: 'inProgress',
|
|
41
|
+
receivedBytes: 0,
|
|
42
|
+
totalBytes: 0,
|
|
43
|
+
};
|
|
44
|
+
this.pendingDownloads.set(event.guid, info);
|
|
45
|
+
this.emit('started', event.suggestedFilename);
|
|
46
|
+
});
|
|
47
|
+
this.cdpSession.on('Browser.downloadProgress', (event) => {
|
|
48
|
+
const info = this.pendingDownloads.get(event.guid);
|
|
49
|
+
if (!info)
|
|
50
|
+
return;
|
|
51
|
+
info.receivedBytes = event.receivedBytes;
|
|
52
|
+
info.totalBytes = event.totalBytes;
|
|
53
|
+
info.state = event.state;
|
|
54
|
+
// Emit progress
|
|
55
|
+
if (event.totalBytes > 0) {
|
|
56
|
+
const percent = Math.round((event.receivedBytes / event.totalBytes) * 100);
|
|
57
|
+
this.emit('progress', percent, info.suggestedFilename);
|
|
58
|
+
}
|
|
59
|
+
// Handle completion
|
|
60
|
+
if (event.state === 'completed') {
|
|
61
|
+
// Construct the final path
|
|
62
|
+
const finalPath = `${this.downloadDir}/${info.suggestedFilename}`;
|
|
63
|
+
info.resolvedPath = finalPath;
|
|
64
|
+
this.emit('completed', finalPath);
|
|
65
|
+
// Resolve any waiting promises
|
|
66
|
+
const resolver = this.downloadPromiseResolvers.get(event.guid);
|
|
67
|
+
if (resolver) {
|
|
68
|
+
resolver.resolve(finalPath);
|
|
69
|
+
this.downloadPromiseResolvers.delete(event.guid);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (event.state === 'canceled') {
|
|
73
|
+
this.emit('canceled', info.suggestedFilename);
|
|
74
|
+
const resolver = this.downloadPromiseResolvers.get(event.guid);
|
|
75
|
+
if (resolver) {
|
|
76
|
+
resolver.reject(new Error('Download canceled'));
|
|
77
|
+
this.downloadPromiseResolvers.delete(event.guid);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
this.isMonitoring = true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Wait for any download to complete
|
|
85
|
+
* @param timeoutMs Timeout in milliseconds
|
|
86
|
+
* @returns Path to the downloaded file
|
|
87
|
+
*/
|
|
88
|
+
async waitForDownload(timeoutMs = 60000) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const timeoutId = setTimeout(() => {
|
|
91
|
+
reject(new Error(`Download timeout after ${timeoutMs}ms`));
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
// Check if there's already a pending download
|
|
94
|
+
const existingDownload = Array.from(this.pendingDownloads.values()).find(d => d.state === 'inProgress');
|
|
95
|
+
const handleCompletion = (filepath) => {
|
|
96
|
+
clearTimeout(timeoutId);
|
|
97
|
+
this.off('completed', handleCompletion);
|
|
98
|
+
this.off('canceled', handleCancellation);
|
|
99
|
+
resolve(filepath);
|
|
100
|
+
};
|
|
101
|
+
const handleCancellation = (filename) => {
|
|
102
|
+
clearTimeout(timeoutId);
|
|
103
|
+
this.off('completed', handleCompletion);
|
|
104
|
+
this.off('canceled', handleCancellation);
|
|
105
|
+
reject(new Error(`Download canceled: ${filename}`));
|
|
106
|
+
};
|
|
107
|
+
this.on('completed', handleCompletion);
|
|
108
|
+
this.on('canceled', handleCancellation);
|
|
109
|
+
// If a download is already completed, resolve immediately
|
|
110
|
+
const completedDownload = Array.from(this.pendingDownloads.values()).find(d => d.state === 'completed' && d.resolvedPath);
|
|
111
|
+
if (completedDownload?.resolvedPath) {
|
|
112
|
+
clearTimeout(timeoutId);
|
|
113
|
+
this.off('completed', handleCompletion);
|
|
114
|
+
this.off('canceled', handleCancellation);
|
|
115
|
+
resolve(completedDownload.resolvedPath);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Wait for a specific download by GUID
|
|
121
|
+
*/
|
|
122
|
+
async waitForSpecificDownload(guid, timeoutMs = 60000) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const timeoutId = setTimeout(() => {
|
|
125
|
+
this.downloadPromiseResolvers.delete(guid);
|
|
126
|
+
reject(new Error(`Download timeout after ${timeoutMs}ms`));
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
// Check if already completed
|
|
129
|
+
const info = this.pendingDownloads.get(guid);
|
|
130
|
+
if (info?.state === 'completed' && info.resolvedPath) {
|
|
131
|
+
clearTimeout(timeoutId);
|
|
132
|
+
resolve(info.resolvedPath);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.downloadPromiseResolvers.set(guid, {
|
|
136
|
+
resolve: (path) => {
|
|
137
|
+
clearTimeout(timeoutId);
|
|
138
|
+
resolve(path);
|
|
139
|
+
},
|
|
140
|
+
reject: (err) => {
|
|
141
|
+
clearTimeout(timeoutId);
|
|
142
|
+
reject(err);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Get current pending downloads
|
|
149
|
+
*/
|
|
150
|
+
getPendingDownloads() {
|
|
151
|
+
return Array.from(this.pendingDownloads.values()).filter(d => d.state === 'inProgress');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Clear download history
|
|
155
|
+
*/
|
|
156
|
+
clearHistory() {
|
|
157
|
+
this.pendingDownloads.clear();
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Release resources
|
|
161
|
+
*/
|
|
162
|
+
async dispose() {
|
|
163
|
+
if (this.cdpSession) {
|
|
164
|
+
try {
|
|
165
|
+
// Reset download behavior to default
|
|
166
|
+
await this.cdpSession.send('Browser.setDownloadBehavior', {
|
|
167
|
+
behavior: 'default',
|
|
168
|
+
});
|
|
169
|
+
await this.cdpSession.detach();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Ignore errors during cleanup
|
|
173
|
+
}
|
|
174
|
+
this.cdpSession = null;
|
|
175
|
+
}
|
|
176
|
+
this.isMonitoring = false;
|
|
177
|
+
this.pendingDownloads.clear();
|
|
178
|
+
this.downloadPromiseResolvers.clear();
|
|
179
|
+
this.removeAllListeners();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -9,6 +9,7 @@ import os from 'node:os';
|
|
|
9
9
|
import { Jimp } from 'jimp';
|
|
10
10
|
import z from 'zod';
|
|
11
11
|
import { GEMINI_CONFIG } from '../config.js';
|
|
12
|
+
import { DownloadManager } from '../download-manager.js';
|
|
12
13
|
import { getLoginStatus, waitForLoginStatus, LoginStatus, } from '../login-helper.js';
|
|
13
14
|
import { ToolCategories } from './categories.js';
|
|
14
15
|
import { defineTool } from './ToolDefinition.js';
|
|
@@ -84,30 +85,6 @@ async function cropWatermark(inputPath, outputPath, margin = DEFAULT_CROP_MARGIN
|
|
|
84
85
|
await image.write(outputPath);
|
|
85
86
|
return { width: newWidth, height: newHeight };
|
|
86
87
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Wait for download to complete and return the file path
|
|
89
|
-
*/
|
|
90
|
-
async function waitForDownload(downloadDir, timeoutMs = 60000) {
|
|
91
|
-
const startTime = Date.now();
|
|
92
|
-
const checkInterval = 500;
|
|
93
|
-
// Get initial files
|
|
94
|
-
const initialFiles = new Set(await fs.promises.readdir(downloadDir));
|
|
95
|
-
while (Date.now() - startTime < timeoutMs) {
|
|
96
|
-
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
97
|
-
const currentFiles = await fs.promises.readdir(downloadDir);
|
|
98
|
-
const newFiles = currentFiles.filter(f => !initialFiles.has(f) &&
|
|
99
|
-
!f.endsWith('.crdownload') &&
|
|
100
|
-
!f.endsWith('.tmp'));
|
|
101
|
-
if (newFiles.length > 0) {
|
|
102
|
-
// Return the newest file (by modification time)
|
|
103
|
-
const filePaths = newFiles.map(f => path.join(downloadDir, f));
|
|
104
|
-
const stats = await Promise.all(filePaths.map(async (p) => ({ path: p, mtime: (await fs.promises.stat(p)).mtime })));
|
|
105
|
-
stats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
106
|
-
return stats[0].path;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
throw new Error(`Download timeout after ${timeoutMs}ms`);
|
|
110
|
-
}
|
|
111
88
|
export const askGeminiImage = defineTool({
|
|
112
89
|
name: 'ask_gemini_image',
|
|
113
90
|
description: 'Generate image using Gemini (Nano Banana / 3 Preview) via browser. ' +
|
|
@@ -140,15 +117,6 @@ export const askGeminiImage = defineTool({
|
|
|
140
117
|
handler: async (request, response, context) => {
|
|
141
118
|
const { prompt, outputPath, cropMargin = DEFAULT_CROP_MARGIN, skipCrop = false, } = request.params;
|
|
142
119
|
const page = await getOrCreateGeminiPage(context);
|
|
143
|
-
// Set up download directory
|
|
144
|
-
const downloadDir = path.join(os.tmpdir(), 'gemini-image-downloads');
|
|
145
|
-
await fs.promises.mkdir(downloadDir, { recursive: true });
|
|
146
|
-
// Configure download behavior
|
|
147
|
-
const client = await page.createCDPSession();
|
|
148
|
-
await client.send('Page.setDownloadBehavior', {
|
|
149
|
-
behavior: 'allow',
|
|
150
|
-
downloadPath: downloadDir,
|
|
151
|
-
});
|
|
152
120
|
try {
|
|
153
121
|
response.appendResponseLine('Geminiに接続中...');
|
|
154
122
|
// Navigate to Gemini
|
|
@@ -260,83 +228,121 @@ export const askGeminiImage = defineTool({
|
|
|
260
228
|
response.appendResponseLine('❌ 画像生成タイムアウト (3分)');
|
|
261
229
|
return;
|
|
262
230
|
}
|
|
263
|
-
// Try to download the image
|
|
231
|
+
// Try to download the image using CDP-based download manager
|
|
264
232
|
response.appendResponseLine('📥 画像をダウンロード中...');
|
|
265
|
-
//
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return 'direct';
|
|
275
|
-
}
|
|
276
|
-
// Try three-dot menu on generated images
|
|
277
|
-
const moreButtons = buttons.filter(b => b.getAttribute('aria-label')?.includes('more') ||
|
|
278
|
-
b.getAttribute('aria-label')?.includes('その他') ||
|
|
279
|
-
b.querySelector('mat-icon[data-mat-icon-name="more_vert"]'));
|
|
280
|
-
if (moreButtons.length > 0) {
|
|
281
|
-
// Click the last one (likely the image menu)
|
|
282
|
-
moreButtons[moreButtons.length - 1].click();
|
|
283
|
-
return 'menu-opened';
|
|
284
|
-
}
|
|
285
|
-
return null;
|
|
286
|
-
});
|
|
287
|
-
if (downloadClicked === 'menu-opened') {
|
|
288
|
-
// Wait for menu to open and click download
|
|
289
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
290
|
-
await page.evaluate(() => {
|
|
291
|
-
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"], button'));
|
|
292
|
-
const downloadItem = menuItems.find(item => item.textContent?.includes('ダウンロード') ||
|
|
293
|
-
item.textContent?.includes('Download'));
|
|
294
|
-
if (downloadItem) {
|
|
295
|
-
downloadItem.click();
|
|
233
|
+
// Set up download manager with CDP events
|
|
234
|
+
const userDownloadsDir = path.join(os.homedir(), 'Downloads');
|
|
235
|
+
const downloadManager = new DownloadManager(page, userDownloadsDir);
|
|
236
|
+
try {
|
|
237
|
+
await downloadManager.startMonitoring();
|
|
238
|
+
// Listen for progress updates
|
|
239
|
+
downloadManager.on('progress', (percent, filename) => {
|
|
240
|
+
if (percent % 25 === 0) {
|
|
241
|
+
response.appendResponseLine(`📥 ダウンロード中... ${percent}% (${filename})`);
|
|
296
242
|
}
|
|
297
243
|
});
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
244
|
+
downloadManager.on('started', (filename) => {
|
|
245
|
+
response.appendResponseLine(`📥 ダウンロード開始: ${filename}`);
|
|
246
|
+
});
|
|
247
|
+
// Click download button - Gemini uses "フルサイズの画像をダウンロード" button
|
|
248
|
+
// Improved selector: prioritize aria-describedby for more reliable detection
|
|
249
|
+
const downloadClicked = await page.evaluate(() => {
|
|
250
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
251
|
+
// First, try to find button with aria-describedby pointing to "フルサイズ"
|
|
252
|
+
let downloadBtn = buttons.find(b => {
|
|
253
|
+
const describedBy = b.getAttribute('aria-describedby');
|
|
254
|
+
if (describedBy) {
|
|
255
|
+
const descEl = document.getElementById(describedBy);
|
|
256
|
+
const desc = descEl?.textContent || '';
|
|
257
|
+
return desc.includes('フルサイズ') || desc.includes('ダウンロード');
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
});
|
|
261
|
+
// Fallback: look for text/aria-label containing download keywords
|
|
262
|
+
if (!downloadBtn) {
|
|
263
|
+
downloadBtn = buttons.find(b => {
|
|
264
|
+
const text = b.textContent || '';
|
|
265
|
+
const ariaLabel = b.getAttribute('aria-label') || '';
|
|
266
|
+
// Avoid "Cancel download" buttons
|
|
267
|
+
const lowerText = text.toLowerCase();
|
|
268
|
+
const lowerLabel = ariaLabel.toLowerCase();
|
|
269
|
+
if (lowerText.includes('cancel') || lowerLabel.includes('cancel') ||
|
|
270
|
+
lowerText.includes('キャンセル') || lowerLabel.includes('キャンセル')) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return (text.includes('フルサイズ') ||
|
|
274
|
+
text.includes('ダウンロード') ||
|
|
275
|
+
ariaLabel.includes('ダウンロード') ||
|
|
276
|
+
ariaLabel.includes('download'));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (downloadBtn) {
|
|
280
|
+
downloadBtn.click();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
});
|
|
285
|
+
if (!downloadClicked) {
|
|
286
|
+
response.appendResponseLine('⚠️ ダウンロードボタンが見つかりません');
|
|
287
|
+
response.appendResponseLine('ヒント: ブラウザで画像を右クリックして保存してください');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// Wait for download to complete using CDP events (more reliable than file polling)
|
|
291
|
+
response.appendResponseLine('⏳ CDP経由でダウンロード完了を待機中...');
|
|
292
|
+
let downloadedPath;
|
|
320
293
|
try {
|
|
321
|
-
|
|
322
|
-
response.appendResponseLine(`✅
|
|
294
|
+
downloadedPath = await downloadManager.waitForDownload(60000); // 60 seconds
|
|
295
|
+
response.appendResponseLine(`✅ ダウンロード完了: ${path.basename(downloadedPath)}`);
|
|
296
|
+
}
|
|
297
|
+
catch (downloadError) {
|
|
298
|
+
const errMsg = downloadError instanceof Error ? downloadError.message : String(downloadError);
|
|
299
|
+
if (errMsg.includes('timeout')) {
|
|
300
|
+
response.appendResponseLine('❌ ダウンロードタイムアウト (60秒)');
|
|
301
|
+
response.appendResponseLine('💡 ヒント: ブラウザで画像を右クリックして「画像を保存」してください');
|
|
302
|
+
}
|
|
303
|
+
else if (errMsg.includes('canceled')) {
|
|
304
|
+
response.appendResponseLine('❌ ダウンロードがキャンセルされました');
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
response.appendResponseLine(`❌ ダウンロードエラー: ${errMsg}`);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
323
310
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
311
|
+
// Ensure output directory exists
|
|
312
|
+
const outputDir = path.dirname(outputPath);
|
|
313
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
314
|
+
// Crop watermark or copy directly
|
|
315
|
+
if (skipCrop) {
|
|
328
316
|
await fs.promises.copyFile(downloadedPath, outputPath);
|
|
317
|
+
response.appendResponseLine(`📄 画像保存(クロップなし): ${outputPath}`);
|
|
329
318
|
}
|
|
319
|
+
else {
|
|
320
|
+
response.appendResponseLine(`✂️ ウォーターマークをクロップ中 (margin: ${cropMargin}px)...`);
|
|
321
|
+
try {
|
|
322
|
+
const { width, height } = await cropWatermark(downloadedPath, outputPath, cropMargin);
|
|
323
|
+
response.appendResponseLine(`✅ クロップ完了: ${width}x${height}px → ${outputPath}`);
|
|
324
|
+
}
|
|
325
|
+
catch (cropError) {
|
|
326
|
+
const msg = cropError instanceof Error ? cropError.message : String(cropError);
|
|
327
|
+
response.appendResponseLine(`⚠️ クロップ失敗: ${msg}`);
|
|
328
|
+
response.appendResponseLine('元の画像をそのまま保存します...');
|
|
329
|
+
await fs.promises.copyFile(downloadedPath, outputPath);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Cleanup temp file
|
|
333
|
+
try {
|
|
334
|
+
await fs.promises.unlink(downloadedPath);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Ignore cleanup errors
|
|
338
|
+
}
|
|
339
|
+
response.appendResponseLine('\n🎉 画像生成完了!');
|
|
340
|
+
response.appendResponseLine(`📁 出力: ${outputPath}`);
|
|
330
341
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
await
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
// Ignore cleanup errors
|
|
342
|
+
finally {
|
|
343
|
+
// Ensure download manager is always disposed
|
|
344
|
+
await downloadManager.dispose();
|
|
337
345
|
}
|
|
338
|
-
response.appendResponseLine('\n🎉 画像生成完了!');
|
|
339
|
-
response.appendResponseLine(`📁 出力: ${outputPath}`);
|
|
340
346
|
}
|
|
341
347
|
catch (error) {
|
|
342
348
|
const msg = error instanceof Error ? error.message : String(error);
|
package/package.json
CHANGED