chrome-ai-bridge 1.0.4 → 1.0.6

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.
@@ -0,0 +1,163 @@
1
+ // Copyright 2025 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+ import * as Common from '../../core/common/common.js';
5
+ import * as GreenDev from '../greendev/greendev.js';
6
+ import { AnnotationType } from './AnnotationType.js';
7
+ export class AnnotationRepository {
8
+ static #instance = null;
9
+ static #hasRepliedGreenDevDisabled = false;
10
+ static #hasShownFlagWarning = false;
11
+ #events = new Common.ObjectWrapper.ObjectWrapper();
12
+ #annotationData = [];
13
+ #nextId = 0;
14
+ static instance() {
15
+ if (!AnnotationRepository.#instance) {
16
+ AnnotationRepository.#instance = new AnnotationRepository();
17
+ }
18
+ return AnnotationRepository.#instance;
19
+ }
20
+ static annotationsEnabled() {
21
+ const enabled = GreenDev.Prototypes.instance().isEnabled('aiAnnotations');
22
+ // TODO(finnur): Fix race when Repository is created before feature flags have been set properly.
23
+ if (!enabled) {
24
+ this.#hasRepliedGreenDevDisabled = true;
25
+ }
26
+ else if (this.#hasRepliedGreenDevDisabled && !this.#hasShownFlagWarning) {
27
+ console.warn('Flag controlling GreenDev has flipped from false to true. ' +
28
+ 'Only some callers will expect GreenDev to be enabled, which can lead to unexpected results.');
29
+ this.#hasShownFlagWarning = true;
30
+ }
31
+ return Boolean(enabled);
32
+ }
33
+ addEventListener(eventType, listener, thisObject) {
34
+ if (!AnnotationRepository.annotationsEnabled()) {
35
+ console.warn('Received request to add event listener with annotations disabled');
36
+ }
37
+ return this.#events.addEventListener(eventType, listener, thisObject);
38
+ }
39
+ getAnnotationDataByType(type) {
40
+ if (!AnnotationRepository.annotationsEnabled()) {
41
+ console.warn('Received query for annotation types with annotations disabled');
42
+ return [];
43
+ }
44
+ const annotations = this.#annotationData.filter(annotation => annotation.type === type);
45
+ return annotations;
46
+ }
47
+ getAnnotationDataById(id) {
48
+ if (!AnnotationRepository.annotationsEnabled()) {
49
+ console.warn('Received query for annotation type with annotations disabled');
50
+ return undefined;
51
+ }
52
+ return this.#annotationData.find(annotation => annotation.id === id);
53
+ }
54
+ #getExistingAnnotation(type, anchor) {
55
+ const annotations = this.getAnnotationDataByType(type);
56
+ const annotation = annotations.find(annotation => {
57
+ if (typeof anchor === 'string') {
58
+ return annotation.lookupId === anchor;
59
+ }
60
+ switch (type) {
61
+ case AnnotationType.ELEMENT_NODE: {
62
+ const elementAnnotation = annotation;
63
+ return elementAnnotation.anchor === anchor;
64
+ }
65
+ case AnnotationType.NETWORK_REQUEST_SUBPANEL_HEADERS: {
66
+ const networkRequestDetailsAnnotation = annotation;
67
+ return networkRequestDetailsAnnotation.anchor === anchor;
68
+ }
69
+ default:
70
+ console.warn('[AnnotationRepository] Unknown AnnotationType', type);
71
+ return false;
72
+ }
73
+ });
74
+ return annotation;
75
+ }
76
+ #updateExistingAnnotationLabel(label, type, anchor) {
77
+ const annotation = this.#getExistingAnnotation(type, anchor);
78
+ if (annotation) {
79
+ // TODO(finnur): This should work for annotations that have not been displayed yet,
80
+ // but we need to also notify the AnnotationManager for those that have been shown.
81
+ annotation.message = label;
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+ addElementsAnnotation(label, anchor, anchorToString) {
87
+ if (!AnnotationRepository.annotationsEnabled()) {
88
+ console.warn('Received annotation registration with annotations disabled');
89
+ return;
90
+ }
91
+ if (this.#updateExistingAnnotationLabel(label, AnnotationType.ELEMENT_NODE, anchor)) {
92
+ return;
93
+ }
94
+ const annotationData = {
95
+ id: this.#nextId++,
96
+ type: AnnotationType.ELEMENT_NODE,
97
+ message: label,
98
+ lookupId: typeof anchor === 'string' ? anchor : '',
99
+ anchor: typeof anchor !== 'string' ? anchor : undefined,
100
+ anchorToString,
101
+ };
102
+ this.#annotationData.push(annotationData);
103
+ // eslint-disable-next-line no-console
104
+ console.log('[AnnotationRepository] Added element annotation:', label, {
105
+ annotationData,
106
+ annotations: this.#annotationData.length,
107
+ });
108
+ this.#events.dispatchEventToListeners("AnnotationAdded" /* Events.ANNOTATION_ADDED */, annotationData);
109
+ }
110
+ addNetworkRequestAnnotation(label, anchor, anchorToString) {
111
+ if (!AnnotationRepository.annotationsEnabled()) {
112
+ console.warn('Received annotation registration with annotations disabled');
113
+ return;
114
+ }
115
+ // We only need to update the NETWORK_REQUEST_SUBPANEL_HEADERS because the
116
+ // NETWORK_REQUEST Annotation has no meaningful label.
117
+ if (this.#updateExistingAnnotationLabel(label, AnnotationType.NETWORK_REQUEST_SUBPANEL_HEADERS, anchor)) {
118
+ return;
119
+ }
120
+ const annotationData = {
121
+ id: this.#nextId++,
122
+ type: AnnotationType.NETWORK_REQUEST,
123
+ message: '',
124
+ lookupId: typeof anchor === 'string' ? anchor : '',
125
+ anchor: typeof anchor !== 'string' ? anchor : undefined,
126
+ anchorToString,
127
+ };
128
+ this.#annotationData.push(annotationData);
129
+ // eslint-disable-next-line no-console
130
+ console.log('[AnnotationRepository] Added annotation:', label, {
131
+ annotationData,
132
+ annotations: this.#annotationData.length,
133
+ });
134
+ this.#events.dispatchEventToListeners("AnnotationAdded" /* Events.ANNOTATION_ADDED */, annotationData);
135
+ const annotationDetailsData = {
136
+ id: this.#nextId++,
137
+ type: AnnotationType.NETWORK_REQUEST_SUBPANEL_HEADERS,
138
+ message: label,
139
+ lookupId: typeof anchor === 'string' ? anchor : '',
140
+ anchor: typeof anchor !== 'string' ? anchor : undefined,
141
+ anchorToString,
142
+ };
143
+ this.#annotationData.push(annotationDetailsData);
144
+ this.#events.dispatchEventToListeners("AnnotationAdded" /* Events.ANNOTATION_ADDED */, annotationDetailsData);
145
+ }
146
+ deleteAllAnnotations() {
147
+ this.#annotationData = [];
148
+ this.#events.dispatchEventToListeners("AllAnnotationsDeleted" /* Events.ALL_ANNOTATIONS_DELETED */);
149
+ // eslint-disable-next-line no-console
150
+ console.log('[AnnotationRepository] Deleting all annotations');
151
+ }
152
+ deleteAnnotation(id) {
153
+ const index = this.#annotationData.findIndex(annotation => annotation.id === id);
154
+ if (index === -1) {
155
+ console.warn(`[AnnotationRepository] Could not find annotation with id ${id}`);
156
+ return;
157
+ }
158
+ this.#annotationData.splice(index, 1);
159
+ this.#events.dispatchEventToListeners("AnnotationDeleted" /* Events.ANNOTATION_DELETED */, { id });
160
+ // eslint-disable-next-line no-console
161
+ console.log(`[AnnotationRepository] Deleted annotation with id ${id}`);
162
+ }
163
+ }
@@ -0,0 +1,10 @@
1
+ // Copyright 2025 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+ export var AnnotationType;
5
+ (function (AnnotationType) {
6
+ AnnotationType[AnnotationType["ELEMENT_NODE"] = 0] = "ELEMENT_NODE";
7
+ AnnotationType[AnnotationType["STYLE_RULE"] = 1] = "STYLE_RULE";
8
+ AnnotationType[AnnotationType["NETWORK_REQUEST"] = 2] = "NETWORK_REQUEST";
9
+ AnnotationType[AnnotationType["NETWORK_REQUEST_SUBPANEL_HEADERS"] = 3] = "NETWORK_REQUEST_SUBPANEL_HEADERS";
10
+ })(AnnotationType || (AnnotationType = {}));
@@ -0,0 +1,5 @@
1
+ // Copyright 2025 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+ export * from './AnnotationRepository.js';
5
+ export * from './AnnotationType.js';
@@ -0,0 +1,33 @@
1
+ // Copyright 2025 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+ import * as Common from '../../core/common/common.js';
5
+ import * as Root from '../../core/root/root.js';
6
+ let instance = null;
7
+ export class Prototypes {
8
+ constructor() {
9
+ }
10
+ static instance() {
11
+ if (instance) {
12
+ return instance;
13
+ }
14
+ instance = new Prototypes();
15
+ return instance;
16
+ }
17
+ /**
18
+ * Returns true if the specific setting is turned on AND the GreenDev flag is enabled
19
+ */
20
+ isEnabled(setting) {
21
+ const greendevFlagEnabled = Boolean(Root.Runtime.hostConfig.devToolsGreenDevUi?.enabled);
22
+ return greendevFlagEnabled && this.settings()[setting].get();
23
+ }
24
+ settings() {
25
+ const settings = Common.Settings.Settings.instance();
26
+ const inDevToolsFloaty = settings.createSetting('greendev-in-devtools-floaty-enabled', false, "Local" /* Common.Settings.SettingStorageType.LOCAL */);
27
+ const inlineWidgets = settings.createSetting('greendev-inline-widgets-enabled', false, "Local" /* Common.Settings.SettingStorageType.LOCAL */);
28
+ const aiAnnotations = settings.createSetting('greendev-ai-annotations-enabled', false, "Local" /* Common.Settings.SettingStorageType.LOCAL */);
29
+ const artifactViewer = settings.createSetting('greendev-artifact-viewer-enabled', false, "Local" /* Common.Settings.SettingStorageType.LOCAL */);
30
+ const copyToGemini = settings.createSetting('greendev-copy-to-gemini-enabled', false, "Local" /* Common.Settings.SettingStorageType.LOCAL */);
31
+ return { inDevToolsFloaty, inlineWidgets, aiAnnotations, artifactViewer, copyToGemini };
32
+ }
33
+ }
@@ -0,0 +1,4 @@
1
+ // Copyright 2025 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+ export * from './Prototypes.js';
@@ -256,7 +256,7 @@ export const askChatGPTWeb = defineTool({
256
256
  response.appendResponseLine(`既存のプロジェクトチャットを使用: ${latestSession.url}`);
257
257
  }
258
258
  else {
259
- response.appendResponseLine('既存チャットが見つかりませんでした。新規作成します。');
259
+ response.appendResponseLine('📝 新規チャットを作成します');
260
260
  isNewChat = true;
261
261
  }
262
262
  }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { Jimp } from 'jimp';
10
+ import z from 'zod';
11
+ import { GEMINI_CONFIG } from '../config.js';
12
+ import { getLoginStatus, waitForLoginStatus, LoginStatus, } from '../login-helper.js';
13
+ import { ToolCategories } from './categories.js';
14
+ import { defineTool } from './ToolDefinition.js';
15
+ /**
16
+ * Default crop margin in pixels (will be adjusted based on actual watermark size)
17
+ */
18
+ const DEFAULT_CROP_MARGIN = 80;
19
+ /**
20
+ * Navigate with retry logic
21
+ */
22
+ async function navigateWithRetry(page, url, options = { waitUntil: 'networkidle2', maxRetries: 3 }) {
23
+ const { waitUntil, maxRetries = 3 } = options;
24
+ let lastError = null;
25
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
26
+ try {
27
+ await page.goto(url, { waitUntil, timeout: 30000 });
28
+ return;
29
+ }
30
+ catch (error) {
31
+ lastError = error instanceof Error ? error : new Error(String(error));
32
+ const isRetryable = lastError.message.includes('ERR_ABORTED') ||
33
+ lastError.message.includes('net::ERR_');
34
+ if (!isRetryable || attempt === maxRetries) {
35
+ throw lastError;
36
+ }
37
+ await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
38
+ }
39
+ }
40
+ throw lastError;
41
+ }
42
+ /**
43
+ * Find or create a dedicated Gemini tab
44
+ */
45
+ async function getOrCreateGeminiPage(context) {
46
+ await context.createPagesSnapshot();
47
+ const pages = context.getPages();
48
+ for (const page of pages) {
49
+ const url = page.url();
50
+ if (url.includes('gemini.google.com')) {
51
+ await page.bringToFront();
52
+ return page;
53
+ }
54
+ }
55
+ const newPage = await context.newPage();
56
+ return newPage;
57
+ }
58
+ /**
59
+ * Enhance prompt for better watermark cropping
60
+ * Adds composition requirements to center the subject and use solid background
61
+ */
62
+ function enhancePromptForCropping(prompt) {
63
+ const compositionRequirements = `
64
+
65
+ Composition requirements:
66
+ - Center the main subject with generous padding on all sides (at least 15% margin from edges)
67
+ - Use a clean, solid background color
68
+ - Ensure no important elements touch the image edges, especially the bottom-right corner`;
69
+ return prompt + compositionRequirements;
70
+ }
71
+ /**
72
+ * Crop image to remove watermark (uniform crop from all sides)
73
+ */
74
+ async function cropWatermark(inputPath, outputPath, margin = DEFAULT_CROP_MARGIN) {
75
+ const image = await Jimp.read(inputPath);
76
+ const { width, height } = image;
77
+ // Crop from all sides
78
+ const newWidth = width - margin * 2;
79
+ const newHeight = height - margin * 2;
80
+ if (newWidth <= 0 || newHeight <= 0) {
81
+ throw new Error(`Image too small to crop: ${width}x${height} with margin ${margin}`);
82
+ }
83
+ image.crop({ x: margin, y: margin, w: newWidth, h: newHeight });
84
+ await image.write(outputPath);
85
+ return { width: newWidth, height: newHeight };
86
+ }
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
+ export const askGeminiImage = defineTool({
112
+ name: 'ask_gemini_image',
113
+ description: 'Generate image using Gemini (Nano Banana / 3 Preview) via browser. ' +
114
+ 'Automatically crops watermark from edges. ' +
115
+ 'Rate limit: ~2 images/day for free users.',
116
+ annotations: {
117
+ category: ToolCategories.NAVIGATION_AUTOMATION,
118
+ readOnlyHint: false,
119
+ },
120
+ schema: {
121
+ prompt: z
122
+ .string()
123
+ .describe('Image generation prompt. Use natural language descriptions. ' +
124
+ 'Structure: [Subject + Adjectives] doing [Action] in [Location/Context]. ' +
125
+ '[Composition/Camera Angle]. [Lighting/Atmosphere]. [Style/Media]. ' +
126
+ 'HEX color codes like "#9F2B68" are supported.'),
127
+ outputPath: z
128
+ .string()
129
+ .describe('Output file path for the generated image. ' +
130
+ 'Will be cropped to remove watermark. Example: /tmp/generated-image.png'),
131
+ cropMargin: z
132
+ .number()
133
+ .optional()
134
+ .describe(`Pixels to crop from each edge to remove watermark. Default: ${DEFAULT_CROP_MARGIN}`),
135
+ skipCrop: z
136
+ .boolean()
137
+ .optional()
138
+ .describe('Skip watermark cropping (keep original image). Default: false'),
139
+ },
140
+ handler: async (request, response, context) => {
141
+ const { prompt, outputPath, cropMargin = DEFAULT_CROP_MARGIN, skipCrop = false, } = request.params;
142
+ 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
+ try {
153
+ response.appendResponseLine('Geminiに接続中...');
154
+ // Navigate to Gemini
155
+ await navigateWithRetry(page, GEMINI_CONFIG.BASE_URL + 'app', {
156
+ waitUntil: 'networkidle2',
157
+ });
158
+ // Wait for UI to stabilize
159
+ try {
160
+ await Promise.race([
161
+ page.waitForSelector('button[aria-label*="Account"], button[aria-label*="アカウント"]', { timeout: 10000 }),
162
+ page.waitForSelector('[role="textbox"]', { timeout: 10000 }),
163
+ ]);
164
+ }
165
+ catch {
166
+ response.appendResponseLine('⚠️ UI安定化待機タイムアウト(続行)');
167
+ }
168
+ // Check login
169
+ const loginStatus = await getLoginStatus(page, 'gemini');
170
+ if (loginStatus === LoginStatus.NEEDS_LOGIN) {
171
+ response.appendResponseLine('\n❌ Geminiへのログインが必要です');
172
+ response.appendResponseLine('📱 ブラウザでGoogleアカウントにログインしてください');
173
+ const finalStatus = await waitForLoginStatus(page, 'gemini', 120000, msg => response.appendResponseLine(msg));
174
+ if (finalStatus !== LoginStatus.LOGGED_IN) {
175
+ response.appendResponseLine('❌ ログインタイムアウト');
176
+ return;
177
+ }
178
+ }
179
+ response.appendResponseLine('✅ ログイン確認完了');
180
+ // Enhance prompt for better cropping
181
+ const enhancedPrompt = enhancePromptForCropping(prompt);
182
+ response.appendResponseLine('プロンプトを送信中...');
183
+ // Input enhanced prompt
184
+ const questionSent = await page.evaluate(promptText => {
185
+ const clearElement = (el) => {
186
+ while (el.firstChild) {
187
+ el.removeChild(el.firstChild);
188
+ }
189
+ };
190
+ const textbox = document.querySelector('[role="textbox"]');
191
+ if (textbox) {
192
+ textbox.focus();
193
+ clearElement(textbox);
194
+ textbox.textContent = promptText;
195
+ textbox.dispatchEvent(new Event('input', { bubbles: true }));
196
+ return true;
197
+ }
198
+ return false;
199
+ }, enhancedPrompt);
200
+ if (!questionSent) {
201
+ response.appendResponseLine('❌ 入力欄が見つかりません');
202
+ return;
203
+ }
204
+ await new Promise(resolve => setTimeout(resolve, 500));
205
+ // Click send button
206
+ const sent = await page.evaluate(() => {
207
+ const buttons = Array.from(document.querySelectorAll('button'));
208
+ const sendButton = buttons.find(b => b.textContent?.includes('プロンプトを送信') ||
209
+ b.textContent?.includes('送信') ||
210
+ b.getAttribute('aria-label')?.includes('送信') ||
211
+ b.getAttribute('aria-label')?.includes('Send'));
212
+ if (sendButton && !sendButton.disabled) {
213
+ sendButton.click();
214
+ return true;
215
+ }
216
+ return false;
217
+ });
218
+ if (!sent) {
219
+ await page.keyboard.press('Enter');
220
+ response.appendResponseLine('⚠️ 送信ボタンが見つかりません (Enterキーを試行)');
221
+ }
222
+ response.appendResponseLine('🎨 画像生成中... (1-2分かかることがあります)');
223
+ // Wait for image generation to complete
224
+ // Look for generated image or download button
225
+ const startTime = Date.now();
226
+ const maxWaitTime = 180000; // 3 minutes
227
+ let imageFound = false;
228
+ while (Date.now() - startTime < maxWaitTime) {
229
+ await new Promise(resolve => setTimeout(resolve, 2000));
230
+ const status = await page.evaluate(() => {
231
+ // Check for generated image
232
+ const images = document.querySelectorAll('img[src*="blob:"], img[src*="generated"]');
233
+ // Check for download button or menu
234
+ const downloadButtons = Array.from(document.querySelectorAll('button, [role="menuitem"]'));
235
+ const hasDownload = downloadButtons.some(b => b.textContent?.includes('ダウンロード') ||
236
+ b.textContent?.includes('Download') ||
237
+ b.getAttribute('aria-label')?.includes('download') ||
238
+ b.getAttribute('aria-label')?.includes('ダウンロード'));
239
+ // Check if still generating
240
+ const isGenerating = document.body.innerText.includes('生成中') ||
241
+ document.body.innerText.includes('Generating') ||
242
+ document.querySelector('[role="progressbar"]') !== null;
243
+ return {
244
+ imageCount: images.length,
245
+ hasDownload,
246
+ isGenerating,
247
+ };
248
+ });
249
+ if (status.imageCount > 0 || status.hasDownload) {
250
+ imageFound = true;
251
+ response.appendResponseLine(`✅ 画像生成完了 (${Math.floor((Date.now() - startTime) / 1000)}秒)`);
252
+ break;
253
+ }
254
+ if (!status.isGenerating && Date.now() - startTime > 30000) {
255
+ // Not generating and no image after 30s - might have failed
256
+ response.appendResponseLine('⚠️ 生成中インジケータが消えました...');
257
+ }
258
+ }
259
+ if (!imageFound) {
260
+ response.appendResponseLine('❌ 画像生成タイムアウト (3分)');
261
+ return;
262
+ }
263
+ // Try to download the image
264
+ response.appendResponseLine('📥 画像をダウンロード中...');
265
+ // Method 1: Click download button
266
+ const downloadClicked = await page.evaluate(() => {
267
+ // Look for three-dot menu or download button on the image
268
+ const buttons = Array.from(document.querySelectorAll('button, [role="menuitem"]'));
269
+ // First try direct download button
270
+ let downloadBtn = buttons.find(b => b.textContent?.includes('ダウンロード') ||
271
+ b.textContent?.includes('Download'));
272
+ if (downloadBtn) {
273
+ downloadBtn.click();
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();
296
+ }
297
+ });
298
+ }
299
+ // Wait for download to complete
300
+ let downloadedPath;
301
+ try {
302
+ downloadedPath = await waitForDownload(downloadDir, 30000);
303
+ response.appendResponseLine(`✅ ダウンロード完了: ${path.basename(downloadedPath)}`);
304
+ }
305
+ catch (error) {
306
+ response.appendResponseLine('❌ ダウンロード待機タイムアウト');
307
+ response.appendResponseLine('ヒント: ブラウザで手動でダウンロードしてください');
308
+ return;
309
+ }
310
+ // Ensure output directory exists
311
+ const outputDir = path.dirname(outputPath);
312
+ await fs.promises.mkdir(outputDir, { recursive: true });
313
+ // Crop watermark or copy directly
314
+ if (skipCrop) {
315
+ await fs.promises.copyFile(downloadedPath, outputPath);
316
+ response.appendResponseLine(`📄 画像保存(クロップなし): ${outputPath}`);
317
+ }
318
+ else {
319
+ response.appendResponseLine(`✂️ ウォーターマークをクロップ中 (margin: ${cropMargin}px)...`);
320
+ try {
321
+ const { width, height } = await cropWatermark(downloadedPath, outputPath, cropMargin);
322
+ response.appendResponseLine(`✅ クロップ完了: ${width}x${height}px → ${outputPath}`);
323
+ }
324
+ catch (error) {
325
+ const msg = error instanceof Error ? error.message : String(error);
326
+ response.appendResponseLine(`⚠️ クロップ失敗: ${msg}`);
327
+ response.appendResponseLine('元の画像をそのまま保存します...');
328
+ await fs.promises.copyFile(downloadedPath, outputPath);
329
+ }
330
+ }
331
+ // Cleanup temp file
332
+ try {
333
+ await fs.promises.unlink(downloadedPath);
334
+ }
335
+ catch {
336
+ // Ignore cleanup errors
337
+ }
338
+ response.appendResponseLine('\n🎉 画像生成完了!');
339
+ response.appendResponseLine(`📁 出力: ${outputPath}`);
340
+ }
341
+ catch (error) {
342
+ const msg = error instanceof Error ? error.message : String(error);
343
+ if (msg.includes('Target closed') || msg.includes('Session closed')) {
344
+ response.appendResponseLine('❌ ブラウザ接続が切れました');
345
+ response.appendResponseLine('→ MCPサーバーを再起動してください');
346
+ }
347
+ else {
348
+ response.appendResponseLine(`❌ エラー: ${msg}`);
349
+ }
350
+ }
351
+ },
352
+ });
@@ -304,6 +304,10 @@ export const askGeminiWeb = defineTool({
304
304
  return;
305
305
  }
306
306
  await new Promise(resolve => setTimeout(resolve, 500));
307
+ // 質問送信前に model-response 要素数を記録(ChatGPTと同じカウント方式)
308
+ const initialModelResponseCount = await page.evaluate(() => {
309
+ return document.querySelectorAll('model-response').length;
310
+ });
307
311
  // Click send button - look for "プロンプトを送信" or similar
308
312
  const sent = await page.evaluate(() => {
309
313
  const buttons = Array.from(document.querySelectorAll('button'));
@@ -405,9 +409,17 @@ export const askGeminiWeb = defineTool({
405
409
  });
406
410
  return !!stopButton;
407
411
  });
408
- // Stop button/icon disappeared = generation complete
412
+ // Stop button/icon disappeared = check if new message appeared
409
413
  if (!hasStopIndicator) {
410
- break;
414
+ // 追加: model-response 要素数が増えたか確認(ChatGPTと同じ方式)
415
+ const currentModelResponseCount = await page.evaluate(() => {
416
+ return document.querySelectorAll('model-response').length;
417
+ });
418
+ if (currentModelResponseCount > initialModelResponseCount) {
419
+ // ストップボタン消滅 AND 新規メッセージ出現で完了
420
+ break;
421
+ }
422
+ // メッセージ数が増えていなければ、まだ待機続行
411
423
  }
412
424
  if (Date.now() - startTime > 180000) {
413
425
  // 3 mins timeout
@@ -415,19 +427,24 @@ export const askGeminiWeb = defineTool({
415
427
  break;
416
428
  }
417
429
  }
418
- // Get the final response content
419
- const responseText = await page.evaluate(() => {
430
+ // Get the final response content (新規に追加された model-response のみを取得)
431
+ const responseText = await page.evaluate(initialCount => {
420
432
  // Get content from model-response elements
421
433
  const modelResponses = Array.from(document.querySelectorAll('model-response'));
434
+ if (modelResponses.length > initialCount) {
435
+ // 新規に追加された model-response を取得(ChatGPTと同じ方式)
436
+ const newResponse = modelResponses[initialCount];
437
+ return newResponse.textContent?.trim() || '';
438
+ }
439
+ // Fallback: get the last model response if any
422
440
  if (modelResponses.length > 0) {
423
- // Get the last model response
424
441
  const lastResponse = modelResponses[modelResponses.length - 1];
425
442
  return lastResponse.textContent?.trim() || '';
426
443
  }
427
444
  // Fallback: get text from main area
428
445
  const main = document.querySelector('main');
429
446
  return main?.innerText.slice(-5000) || '';
430
- });
447
+ }, initialModelResponseCount);
431
448
  response.appendResponseLine('✅ 回答完了');
432
449
  // Always save/update session (not just for new chats)
433
450
  const chatUrl = page.url();
@@ -4,12 +4,14 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import * as chatgptWebTools from './chatgpt-web.js';
7
+ import * as geminiImageTools from './gemini-image.js';
7
8
  import * as geminiWebTools from './gemini-web.js';
8
9
  /**
9
10
  * All optional (web-llm) tools as an array.
10
11
  */
11
12
  export const optionalTools = [
12
13
  ...Object.values(chatgptWebTools),
14
+ ...Object.values(geminiImageTools),
13
15
  ...Object.values(geminiWebTools),
14
16
  ];
15
17
  /**
@@ -61,9 +63,9 @@ export function getOptionalToolCount() {
61
63
  * Metadata about optional tools for documentation.
62
64
  */
63
65
  export const WEB_LLM_TOOLS_INFO = {
64
- disclaimer: 'Web-LLM tools (ask_chatgpt_web, ask_gemini_web) are experimental and best-effort. ' +
66
+ disclaimer: 'Web-LLM tools (ask_chatgpt_web, ask_gemini_web, ask_gemini_image) are experimental and best-effort. ' +
65
67
  'They depend on specific website UIs and may break when those UIs change. ' +
66
68
  'For production use, consider using official APIs instead.',
67
69
  disableEnvVar: 'MCP_DISABLE_WEB_LLM',
68
- tools: ['ask_chatgpt_web', 'ask_gemini_web'],
70
+ tools: ['ask_chatgpt_web', 'ask_gemini_image', 'ask_gemini_web'],
69
71
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "MCP server bridging Chrome browser and AI assistants (ChatGPT, Gemini). Browser automation + AI consultation.",
5
5
  "type": "module",
6
6
  "bin": "./scripts/cli.mjs",
@@ -59,6 +59,7 @@
59
59
  "@modelcontextprotocol/sdk": "1.18.1",
60
60
  "archiver": "^7.0.1",
61
61
  "debug": "4.4.3",
62
+ "jimp": "^1.6.0",
62
63
  "puppeteer": "^24.31.0",
63
64
  "yargs": "18.0.0"
64
65
  },