chrome-devtools-mcp-for-extension 0.20.0 → 0.21.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.
@@ -11,6 +11,133 @@ import { defineTool } from './ToolDefinition.js';
11
11
  import { loadSelectors, getSelector } from '../selectors/loader.js';
12
12
  import { CHATGPT_CONFIG } from '../config.js';
13
13
  import { isLoginRequired } from '../login-helper.js';
14
+ /**
15
+ * Wait for ChatGPT response completion using MutationObserver.
16
+ * More reliable than polling for detecting when streaming ends.
17
+ */
18
+ async function waitForChatGPTComplete(page, options = {}) {
19
+ const { isDeepResearch = false, silenceDuration = 2000, timeout = 300000, } = options;
20
+ const startTime = Date.now();
21
+ // Use MutationObserver for completion detection
22
+ const result = await page.evaluate(({ isDeepResearch, silenceDuration, timeout }) => {
23
+ return new Promise((resolve) => {
24
+ const startTime = Date.now();
25
+ let silenceTimeout;
26
+ let overallTimeout;
27
+ let lastCheckTime = 0;
28
+ const checkCompletion = () => {
29
+ const buttons = Array.from(document.querySelectorAll('button'));
30
+ if (isDeepResearch) {
31
+ // DeepResearch: check for stop button
32
+ const isRunning = buttons.some((btn) => {
33
+ const text = btn.textContent || '';
34
+ const aria = btn.getAttribute('aria-label') || '';
35
+ return (text.includes('停止') ||
36
+ text.includes('リサーチを停止') ||
37
+ aria.includes('停止'));
38
+ });
39
+ if (!isRunning) {
40
+ const assistantMessages = document.querySelectorAll('[data-message-author-role="assistant"]');
41
+ if (assistantMessages.length > 0) {
42
+ const latestMessage = assistantMessages[assistantMessages.length - 1];
43
+ return {
44
+ isStreaming: false,
45
+ text: latestMessage.textContent || '',
46
+ };
47
+ }
48
+ }
49
+ return { isStreaming: true };
50
+ }
51
+ // Normal streaming: check for stop button
52
+ const isStreaming = buttons.some((btn) => {
53
+ const text = btn.textContent || '';
54
+ const aria = btn.getAttribute('aria-label') || '';
55
+ return (text.includes('ストリーミングの停止') ||
56
+ text.includes('停止') ||
57
+ aria.includes('ストリーミングの停止') ||
58
+ aria.includes('停止'));
59
+ });
60
+ if (!isStreaming) {
61
+ const assistantMessages = document.querySelectorAll('[data-message-author-role="assistant"]');
62
+ if (assistantMessages.length > 0) {
63
+ const latestMessage = assistantMessages[assistantMessages.length - 1];
64
+ const thinkingButton = latestMessage.querySelector('button[aria-label*="思考時間"]');
65
+ const thinkingTime = thinkingButton
66
+ ? parseInt((thinkingButton.textContent || '').match(/\d+/)?.[0] || '0')
67
+ : undefined;
68
+ return {
69
+ isStreaming: false,
70
+ text: latestMessage.textContent || '',
71
+ thinkingTime,
72
+ };
73
+ }
74
+ }
75
+ return { isStreaming: true };
76
+ };
77
+ const handleSilence = () => {
78
+ const status = checkCompletion();
79
+ if (!status.isStreaming) {
80
+ cleanup();
81
+ resolve({
82
+ completed: true,
83
+ text: status.text,
84
+ thinkingTime: status.thinkingTime,
85
+ isDeepResearch,
86
+ });
87
+ }
88
+ // Still streaming, wait for more changes
89
+ };
90
+ const cleanup = () => {
91
+ clearTimeout(silenceTimeout);
92
+ clearTimeout(overallTimeout);
93
+ observer.disconnect();
94
+ };
95
+ // Observe the response container
96
+ const responseContainer = document.querySelector('[role="main"]') || document.body;
97
+ const observer = new MutationObserver(() => {
98
+ // Reset silence timer on DOM change
99
+ clearTimeout(silenceTimeout);
100
+ // Throttle status checks to every 500ms
101
+ const now = Date.now();
102
+ if (now - lastCheckTime > 500) {
103
+ lastCheckTime = now;
104
+ const status = checkCompletion();
105
+ if (!status.isStreaming) {
106
+ // Give a small delay to ensure streaming is truly done
107
+ silenceTimeout = setTimeout(handleSilence, silenceDuration);
108
+ }
109
+ }
110
+ else {
111
+ silenceTimeout = setTimeout(handleSilence, silenceDuration);
112
+ }
113
+ });
114
+ // Overall timeout
115
+ overallTimeout = setTimeout(() => {
116
+ cleanup();
117
+ const status = checkCompletion();
118
+ resolve({
119
+ completed: !status.isStreaming,
120
+ timedOut: true,
121
+ text: status.text,
122
+ thinkingTime: status.thinkingTime,
123
+ isDeepResearch,
124
+ });
125
+ }, timeout);
126
+ // Start observing
127
+ observer.observe(responseContainer, {
128
+ childList: true,
129
+ subtree: true,
130
+ characterData: true,
131
+ });
132
+ // Initial check - maybe already complete
133
+ const initialStatus = checkCompletion();
134
+ if (!initialStatus.isStreaming) {
135
+ silenceTimeout = setTimeout(handleSilence, silenceDuration);
136
+ }
137
+ });
138
+ }, { isDeepResearch, silenceDuration, timeout });
139
+ return result;
140
+ }
14
141
  /**
15
142
  * Path to store chat session data
16
143
  */
@@ -381,177 +508,100 @@ export const askChatGPTWeb = defineTool({
381
508
  return messages.length > 0;
382
509
  }, { timeout: 10000 });
383
510
  response.appendResponseLine('✅ 質問送信完了');
384
- // Step 5: Monitor streaming/research with progress updates
511
+ // Step 5: Wait for response using MutationObserver-based detection
385
512
  if (useDeepResearch) {
386
- response.appendResponseLine('DeepResearchを実行中... (10秒ごとに進捗を表示)');
513
+ response.appendResponseLine('DeepResearchを実行中... (MutationObserverで完了を検出)');
387
514
  }
388
515
  else {
389
- response.appendResponseLine('ChatGPTの回答を待機中... (10秒ごとに進捗を表示)');
516
+ response.appendResponseLine('ChatGPTの回答を待機中... (MutationObserverで完了を検出)');
390
517
  }
391
518
  const startTime = Date.now();
392
- let lastText = '';
393
- let lastProgress = '';
394
- while (true) {
395
- await new Promise((resolve) => setTimeout(resolve, 2000));
396
- const status = await page.evaluate((isDeepResearch) => {
397
- // DeepResearch progress detection
398
- if (isDeepResearch) {
399
- // Look for research progress indicators
400
- const progressElements = Array.from(document.querySelectorAll('[role="status"], [aria-live="polite"]'));
401
- const progressText = progressElements
402
- .map((el) => el.textContent)
403
- .join(' ');
404
- // Check if DeepResearch is still running
405
- const buttons = Array.from(document.querySelectorAll('button'));
406
- const isRunning = buttons.some((btn) => {
407
- const text = btn.textContent || '';
408
- const aria = btn.getAttribute('aria-label') || '';
409
- return (text.includes('停止') ||
410
- text.includes('リサーチを停止') ||
411
- aria.includes('停止'));
519
+ // Use MutationObserver-based completion detection
520
+ const status = await waitForChatGPTComplete(page, {
521
+ isDeepResearch: useDeepResearch,
522
+ silenceDuration: 2000, // 2 seconds of DOM silence = complete
523
+ timeout: 300000, // 5 minutes max
524
+ });
525
+ if (status.completed) {
526
+ const completionMessage = useDeepResearch
527
+ ? `\n✅ DeepResearch完了 (所要時間: ${Math.floor((Date.now() - startTime) / 1000)}秒)`
528
+ : `\n✅ 回答完了 (所要時間: ${Math.floor((Date.now() - startTime) / 1000)}秒)`;
529
+ response.appendResponseLine(completionMessage);
530
+ if (status.timedOut) {
531
+ response.appendResponseLine('⚠️ タイムアウトしましたが、回答を取得しました');
532
+ }
533
+ if (status.thinkingTime) {
534
+ response.appendResponseLine(`🤔 思考時間: ${status.thinkingTime}秒`);
535
+ }
536
+ // Save chat session if it's a new chat
537
+ if (isNewChat) {
538
+ response.appendResponseLine('チャットセッションを保存中...');
539
+ // Extract chat ID from URL
540
+ const chatUrl = page.url();
541
+ const chatIdMatch = chatUrl.match(/\/c\/([a-f0-9-]+)/);
542
+ if (chatIdMatch) {
543
+ const chatId = chatIdMatch[1];
544
+ const now = new Date().toISOString();
545
+ await saveChatSession(project, {
546
+ chatId,
547
+ url: chatUrl,
548
+ lastUsed: now,
549
+ createdAt: now,
550
+ title: `[Project: ${project}]`,
551
+ conversationCount: 1,
412
552
  });
413
- if (!isRunning) {
414
- // Research completed - get the report
415
- const assistantMessages = document.querySelectorAll('[data-message-author-role="assistant"]');
416
- if (assistantMessages.length === 0)
417
- return { completed: false, progress: progressText };
418
- const latestMessage = assistantMessages[assistantMessages.length - 1];
419
- return {
420
- completed: true,
421
- text: latestMessage.textContent || '',
422
- isDeepResearch: true,
423
- };
424
- }
425
- return {
426
- completed: false,
427
- streaming: true,
428
- progress: progressText,
429
- currentText: progressText.substring(0, 200),
430
- };
431
- }
432
- // Normal streaming detection
433
- const buttons = Array.from(document.querySelectorAll('button'));
434
- const isStreaming = buttons.some((btn) => {
435
- const text = btn.textContent || '';
436
- const aria = btn.getAttribute('aria-label') || '';
437
- return (text.includes('ストリーミングの停止') ||
438
- text.includes('停止') ||
439
- aria.includes('ストリーミングの停止') ||
440
- aria.includes('停止'));
441
- });
442
- if (!isStreaming) {
443
- // Get final response
444
- const assistantMessages = document.querySelectorAll('[data-message-author-role="assistant"]');
445
- if (assistantMessages.length === 0)
446
- return { completed: false };
447
- const latestMessage = assistantMessages[assistantMessages.length - 1];
448
- const thinkingButton = latestMessage.querySelector('button[aria-label*="思考時間"]');
449
- const thinkingTime = thinkingButton
450
- ? parseInt((thinkingButton.textContent || '').match(/\d+/)?.[0] || '0')
451
- : undefined;
452
- return {
453
- completed: true,
454
- text: latestMessage.textContent || '',
455
- thinkingTime,
456
- };
457
- }
458
- // Get current text
459
- const assistantMessages = document.querySelectorAll('[data-message-author-role="assistant"]');
460
- const latestMessage = assistantMessages[assistantMessages.length - 1];
461
- const currentText = latestMessage
462
- ? latestMessage.textContent?.substring(0, 200)
463
- : '';
464
- return {
465
- completed: false,
466
- streaming: true,
467
- currentText,
468
- };
469
- }, useDeepResearch);
470
- if (status.completed) {
471
- const completionMessage = useDeepResearch
472
- ? `\n✅ DeepResearch完了 (所要時間: ${Math.floor((Date.now() - startTime) / 1000)}秒)`
473
- : `\n✅ 回答完了 (所要時間: ${Math.floor((Date.now() - startTime) / 1000)}秒)`;
474
- response.appendResponseLine(completionMessage);
475
- if (status.thinkingTime) {
476
- response.appendResponseLine(`🤔 思考時間: ${status.thinkingTime}秒`);
477
- }
478
- // Save chat session if it's a new chat
479
- if (isNewChat) {
480
- response.appendResponseLine('チャットセッションを保存中...');
481
- // Extract chat ID from URL
482
- const chatUrl = page.url();
483
- const chatIdMatch = chatUrl.match(/\/c\/([a-f0-9-]+)/);
484
- if (chatIdMatch) {
485
- const chatId = chatIdMatch[1];
486
- const now = new Date().toISOString();
487
- await saveChatSession(project, {
488
- chatId,
489
- url: chatUrl,
490
- lastUsed: now,
491
- createdAt: now,
492
- title: `[Project: ${project}]`,
493
- conversationCount: 1,
494
- });
495
- sessionChatId = chatId;
496
- response.appendResponseLine(`✅ チャットセッション保存: ${chatId}`);
497
- }
498
- else {
499
- response.appendResponseLine('⚠️ チャットIDが取得できませんでした');
500
- }
553
+ sessionChatId = chatId;
554
+ response.appendResponseLine(`✅ チャットセッション保存: ${chatId}`);
501
555
  }
502
556
  else {
503
- // Update last used timestamp and conversation count for existing session
504
- if (sessionChatId) {
505
- const chatUrl = page.url();
506
- const sessions = await loadChatSessions();
507
- const projectSessions = sessions[project] || [];
508
- const existingSession = projectSessions.find(s => s.chatId === sessionChatId);
509
- await saveChatSession(project, {
510
- chatId: sessionChatId,
511
- url: chatUrl,
512
- lastUsed: new Date().toISOString(),
513
- createdAt: existingSession?.createdAt || new Date().toISOString(),
514
- title: existingSession?.title || `[Project: ${project}]`,
515
- conversationCount: (existingSession?.conversationCount || 0) + 1,
516
- });
517
- }
557
+ response.appendResponseLine('⚠️ チャットIDが取得できませんでした');
518
558
  }
519
- // Save conversation log
520
- const chatUrl = page.url();
521
- const modelName = useDeepResearch
522
- ? 'ChatGPT DeepResearch'
523
- : 'ChatGPT 5 Thinking';
524
- // Get current conversation count
525
- const sessions = await loadChatSessions();
526
- const projectSessions = sessions[project] || [];
527
- const currentSession = projectSessions.find(s => s.chatId === sessionChatId);
528
- const conversationNum = currentSession?.conversationCount || 1;
529
- const logPath = await saveConversationLog(project, sanitizedQuestion, status.text || '', {
530
- thinkingTime: status.thinkingTime,
531
- chatUrl,
532
- model: modelName,
533
- chatId: sessionChatId,
534
- conversationNumber: conversationNum,
535
- });
536
- response.appendResponseLine(`📝 会話ログ保存: ${logPath}`);
537
- response.appendResponseLine(`🔗 チャットURL: ${chatUrl}`);
538
- response.appendResponseLine('\n' + '='.repeat(60));
539
- response.appendResponseLine('ChatGPTの回答:\n');
540
- response.appendResponseLine(status.text || '');
541
- break;
542
559
  }
543
- // Show progress every 10 seconds
544
- const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
545
- if (elapsedSeconds % 10 === 0) {
546
- if (useDeepResearch && status.progress !== lastProgress) {
547
- lastProgress = status.progress || '';
548
- response.appendResponseLine(`⏱️ ${elapsedSeconds}秒経過 - 進捗: ${lastProgress.substring(0, 100)}...`);
549
- }
550
- else if (status.currentText !== lastText) {
551
- lastText = status.currentText || '';
552
- response.appendResponseLine(`⏱️ ${elapsedSeconds}秒経過 - 現在のテキスト: ${lastText.substring(0, 100)}...`);
560
+ else {
561
+ // Update last used timestamp and conversation count for existing session
562
+ if (sessionChatId) {
563
+ const chatUrl = page.url();
564
+ const sessions = await loadChatSessions();
565
+ const projectSessions = sessions[project] || [];
566
+ const existingSession = projectSessions.find(s => s.chatId === sessionChatId);
567
+ await saveChatSession(project, {
568
+ chatId: sessionChatId,
569
+ url: chatUrl,
570
+ lastUsed: new Date().toISOString(),
571
+ createdAt: existingSession?.createdAt || new Date().toISOString(),
572
+ title: existingSession?.title || `[Project: ${project}]`,
573
+ conversationCount: (existingSession?.conversationCount || 0) + 1,
574
+ });
553
575
  }
554
576
  }
577
+ // Save conversation log
578
+ const chatUrl = page.url();
579
+ const modelName = useDeepResearch
580
+ ? 'ChatGPT DeepResearch'
581
+ : 'ChatGPT 5 Thinking';
582
+ // Get current conversation count
583
+ const sessions = await loadChatSessions();
584
+ const projectSessions = sessions[project] || [];
585
+ const currentSession = projectSessions.find(s => s.chatId === sessionChatId);
586
+ const conversationNum = currentSession?.conversationCount || 1;
587
+ const logPath = await saveConversationLog(project, sanitizedQuestion, status.text || '', {
588
+ thinkingTime: status.thinkingTime,
589
+ chatUrl,
590
+ model: modelName,
591
+ chatId: sessionChatId,
592
+ conversationNumber: conversationNum,
593
+ });
594
+ response.appendResponseLine(`📝 会話ログ保存: ${logPath}`);
595
+ response.appendResponseLine(`🔗 チャットURL: ${chatUrl}`);
596
+ response.appendResponseLine('\n' + '='.repeat(60));
597
+ response.appendResponseLine('ChatGPTの回答:\n');
598
+ response.appendResponseLine(status.text || '');
599
+ }
600
+ else {
601
+ response.appendResponseLine('❌ 回答の取得に失敗しました');
602
+ if (status.timedOut) {
603
+ response.appendResponseLine('⚠️ タイムアウトしました');
604
+ }
555
605
  }
556
606
  }
557
607
  catch (error) {
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ /**
7
+ * Wait for DOM changes to settle using MutationObserver.
8
+ * This is more reliable than polling for streaming AI responses.
9
+ *
10
+ * Based on Gemini's recommendation:
11
+ * - Use MutationObserver to detect when DOM stops changing
12
+ * - Consider generation complete when no changes for `silenceDuration` ms
13
+ * - Combine with additional checks (e.g., stop button disappearance)
14
+ */
15
+ export async function waitForDomSilence(page, options = {}) {
16
+ const { silenceDuration = 2000, timeout = 300000, observeSelector = 'body', additionalCheck, } = options;
17
+ return page.evaluate(({ silenceDuration, timeout, observeSelector, additionalCheck }) => {
18
+ return new Promise((resolve) => {
19
+ const target = document.querySelector(observeSelector);
20
+ if (!target) {
21
+ resolve({ completed: false });
22
+ return;
23
+ }
24
+ let silenceTimeout;
25
+ let overallTimeout;
26
+ const observer = new MutationObserver(() => {
27
+ // Reset silence timer on any DOM change
28
+ clearTimeout(silenceTimeout);
29
+ silenceTimeout = setTimeout(() => {
30
+ // DOM has been silent for silenceDuration
31
+ // Run additional check if provided
32
+ if (additionalCheck) {
33
+ try {
34
+ const checkFn = new Function('return (' + additionalCheck + ')()');
35
+ if (!checkFn()) {
36
+ // Additional check failed, keep waiting
37
+ return;
38
+ }
39
+ }
40
+ catch {
41
+ // Check failed, consider complete anyway
42
+ }
43
+ }
44
+ cleanup();
45
+ resolve({ completed: true });
46
+ }, silenceDuration);
47
+ });
48
+ const cleanup = () => {
49
+ clearTimeout(silenceTimeout);
50
+ clearTimeout(overallTimeout);
51
+ observer.disconnect();
52
+ };
53
+ // Start overall timeout
54
+ overallTimeout = setTimeout(() => {
55
+ cleanup();
56
+ resolve({ completed: false, timedOut: true });
57
+ }, timeout);
58
+ // Start observing
59
+ observer.observe(target, {
60
+ childList: true,
61
+ subtree: true,
62
+ characterData: true,
63
+ attributes: true,
64
+ });
65
+ // Initial silence timer
66
+ silenceTimeout = setTimeout(() => {
67
+ if (additionalCheck) {
68
+ try {
69
+ const checkFn = new Function('return (' + additionalCheck + ')()');
70
+ if (!checkFn()) {
71
+ return;
72
+ }
73
+ }
74
+ catch {
75
+ // Check failed
76
+ }
77
+ }
78
+ cleanup();
79
+ resolve({ completed: true });
80
+ }, silenceDuration);
81
+ });
82
+ }, { silenceDuration, timeout, observeSelector, additionalCheck });
83
+ }
84
+ /**
85
+ * Multi-fallback selector strategy.
86
+ * Try multiple selectors in order of preference until one matches.
87
+ */
88
+ export async function findElementWithFallback(page, selectors, options = {}) {
89
+ const { timeout = 5000 } = options;
90
+ for (const selector of selectors) {
91
+ try {
92
+ const element = await page.waitForSelector(selector, {
93
+ timeout: Math.min(1000, timeout / selectors.length),
94
+ visible: options.visible,
95
+ });
96
+ if (element) {
97
+ return { found: true, selector };
98
+ }
99
+ }
100
+ catch {
101
+ // Continue to next selector
102
+ }
103
+ }
104
+ return { found: false };
105
+ }
106
+ /**
107
+ * Combined completion detection for ChatGPT/Gemini.
108
+ * Uses MutationObserver + stop button check for robust detection.
109
+ */
110
+ export async function waitForAIResponseComplete(page, options = {}) {
111
+ const { stopSelectors = [], completeSelectors = [], responseSelector = 'body', silenceDuration = 2000, timeout = 300000, } = options;
112
+ const startTime = Date.now();
113
+ // Build additional check function as string (to be evaluated in browser)
114
+ const additionalCheckFn = `
115
+ function() {
116
+ // Check if still generating (stop button visible)
117
+ const stopSelectors = ${JSON.stringify(stopSelectors)};
118
+ for (const sel of stopSelectors) {
119
+ if (document.querySelector(sel)) {
120
+ return false; // Still generating
121
+ }
122
+ }
123
+
124
+ // Check for completion indicators
125
+ const completeSelectors = ${JSON.stringify(completeSelectors)};
126
+ if (completeSelectors.length > 0) {
127
+ for (const sel of completeSelectors) {
128
+ if (document.querySelector(sel)) {
129
+ return true; // Explicitly complete
130
+ }
131
+ }
132
+ }
133
+
134
+ return true; // No stop button, consider complete
135
+ }
136
+ `;
137
+ const result = await waitForDomSilence(page, {
138
+ silenceDuration,
139
+ timeout,
140
+ observeSelector: responseSelector,
141
+ additionalCheck: additionalCheckFn,
142
+ });
143
+ // Get final response text
144
+ let responseText = '';
145
+ if (result.completed) {
146
+ responseText = await page.evaluate((selector) => {
147
+ const el = document.querySelector(selector);
148
+ return el?.textContent || '';
149
+ }, responseSelector);
150
+ }
151
+ return {
152
+ completed: result.completed,
153
+ timedOut: result.timedOut,
154
+ responseText,
155
+ };
156
+ }
157
+ /**
158
+ * Type text using clipboard injection for faster input.
159
+ * Much faster than page.type() for long text.
160
+ */
161
+ export async function typeViaClipboard(page, text, targetSelector) {
162
+ try {
163
+ // Focus the target element
164
+ await page.click(targetSelector);
165
+ // Use clipboard to paste text (faster than typing)
166
+ await page.evaluate(async (text) => {
167
+ // Write to clipboard
168
+ await navigator.clipboard.writeText(text);
169
+ }, text);
170
+ // Paste using keyboard shortcut
171
+ const isMac = (await page.evaluate(() => navigator.platform.includes('Mac'))) || false;
172
+ const modifier = isMac ? 'Meta' : 'Control';
173
+ await page.keyboard.down(modifier);
174
+ await page.keyboard.press('KeyV');
175
+ await page.keyboard.up(modifier);
176
+ return true;
177
+ }
178
+ catch {
179
+ // Fallback to regular typing if clipboard fails
180
+ return false;
181
+ }
182
+ }
@@ -10,6 +10,130 @@ import { ToolCategories } from './categories.js';
10
10
  import { defineTool } from './ToolDefinition.js';
11
11
  import { GEMINI_CONFIG } from '../config.js';
12
12
  import { isLoginRequired } from '../login-helper.js';
13
+ /**
14
+ * Wait for Gemini response completion using MutationObserver.
15
+ * More reliable than polling for detecting when streaming ends.
16
+ */
17
+ async function waitForGeminiComplete(page, options = {}) {
18
+ const { silenceDuration = 2000, timeout = 180000, // 3 minutes
19
+ } = options;
20
+ // Use MutationObserver for completion detection
21
+ const result = await page.evaluate(({ silenceDuration, timeout }) => {
22
+ return new Promise((resolve) => {
23
+ let silenceTimeout;
24
+ let overallTimeout;
25
+ let lastCheckTime = 0;
26
+ const checkCompletion = () => {
27
+ // Check for stop icon (Gemini's thinking/generating indicator)
28
+ const stopIcon = document.querySelector('.stop-icon mat-icon[fonticon="stop"]') ||
29
+ document.querySelector('mat-icon[data-mat-icon-name="stop"]') ||
30
+ document.querySelector('.blue-circle.stop-icon') ||
31
+ document.querySelector('div.stop-icon');
32
+ const hasStopIcon = !!stopIcon;
33
+ // Check for stop button
34
+ const buttons = Array.from(document.querySelectorAll('button'));
35
+ const stopButton = buttons.find(b => {
36
+ const text = b.textContent || '';
37
+ const ariaLabel = b.getAttribute('aria-label') || '';
38
+ return text.includes('回答を停止') || text.includes('Stop') ||
39
+ ariaLabel.includes('Stop') || ariaLabel.includes('停止');
40
+ });
41
+ // Check for send button enabled (indicates completion)
42
+ const sendButton = buttons.find(b => {
43
+ const hasLabel = b.textContent?.includes('プロンプトを送信') ||
44
+ b.getAttribute('aria-label')?.includes('プロンプトを送信') ||
45
+ b.getAttribute('aria-label')?.includes('Send message');
46
+ return hasLabel && !b.disabled;
47
+ });
48
+ // Check for thinking indicators
49
+ const bodyText = document.body.innerText;
50
+ const isTyping = bodyText.includes('Gemini が入力中です') ||
51
+ bodyText.includes('Gemini is typing');
52
+ const isThinking = bodyText.includes('Analyzing') ||
53
+ bodyText.includes('分析中') ||
54
+ bodyText.includes('Crafting') ||
55
+ bodyText.includes('作成中') ||
56
+ bodyText.includes('Thinking') ||
57
+ bodyText.includes('思考中');
58
+ // Check for loading indicators
59
+ const hasSpinner = document.querySelector('[role="progressbar"]') !== null ||
60
+ document.querySelector('[aria-busy="true"]') !== null;
61
+ const isGenerating = hasStopIcon || !!stopButton || isTyping || isThinking || hasSpinner;
62
+ const isComplete = !!sendButton && !isGenerating;
63
+ // Get response text
64
+ const modelResponses = Array.from(document.querySelectorAll('model-response'));
65
+ let responseText = '';
66
+ if (modelResponses.length > 0) {
67
+ const lastResponse = modelResponses[modelResponses.length - 1];
68
+ responseText = lastResponse.textContent?.trim() || '';
69
+ }
70
+ if (!responseText) {
71
+ const main = document.querySelector('main');
72
+ responseText = main?.innerText.slice(-5000) || '';
73
+ }
74
+ return { isGenerating, responseText };
75
+ };
76
+ const handleSilence = () => {
77
+ const status = checkCompletion();
78
+ if (!status.isGenerating) {
79
+ cleanup();
80
+ resolve({
81
+ completed: true,
82
+ responseText: status.responseText,
83
+ });
84
+ }
85
+ // Still generating, wait for more changes
86
+ };
87
+ const cleanup = () => {
88
+ clearTimeout(silenceTimeout);
89
+ clearTimeout(overallTimeout);
90
+ observer.disconnect();
91
+ };
92
+ // Observe the response container
93
+ const responseContainer = document.querySelector('[role="main"]') ||
94
+ document.querySelector('main') ||
95
+ document.body;
96
+ const observer = new MutationObserver(() => {
97
+ // Reset silence timer on DOM change
98
+ clearTimeout(silenceTimeout);
99
+ // Throttle status checks
100
+ const now = Date.now();
101
+ if (now - lastCheckTime > 500) {
102
+ lastCheckTime = now;
103
+ const status = checkCompletion();
104
+ if (!status.isGenerating) {
105
+ silenceTimeout = setTimeout(handleSilence, silenceDuration);
106
+ }
107
+ }
108
+ else {
109
+ silenceTimeout = setTimeout(handleSilence, silenceDuration);
110
+ }
111
+ });
112
+ // Overall timeout
113
+ overallTimeout = setTimeout(() => {
114
+ cleanup();
115
+ const status = checkCompletion();
116
+ resolve({
117
+ completed: !status.isGenerating,
118
+ timedOut: true,
119
+ responseText: status.responseText,
120
+ });
121
+ }, timeout);
122
+ // Start observing
123
+ observer.observe(responseContainer, {
124
+ childList: true,
125
+ subtree: true,
126
+ characterData: true,
127
+ });
128
+ // Initial check
129
+ const initialStatus = checkCompletion();
130
+ if (!initialStatus.isGenerating && initialStatus.responseText) {
131
+ silenceTimeout = setTimeout(handleSilence, silenceDuration);
132
+ }
133
+ });
134
+ }, { silenceDuration, timeout });
135
+ return result;
136
+ }
13
137
  /**
14
138
  * Navigate with retry logic for handling ERR_ABORTED and other network errors
15
139
  */
@@ -311,101 +435,18 @@ export const askGeminiWeb = defineTool({
311
435
  if (!generationStarted) {
312
436
  response.appendResponseLine('⚠️ 生成開始を検出できませんでした(続行します)');
313
437
  }
438
+ // Use MutationObserver-based completion detection
439
+ response.appendResponseLine('回答を待機中... (MutationObserverで完了を検出)');
314
440
  const startTime = Date.now();
315
- let lastResponseText = '';
316
- while (true) {
317
- await new Promise((resolve) => setTimeout(resolve, 1000));
318
- const status = await page.evaluate(() => {
319
- // Check for stop icon (Gemini's thinking/generating indicator)
320
- // The stop icon is in a div.stop-icon with mat-icon[fonticon="stop"]
321
- const stopIcon = document.querySelector('.stop-icon mat-icon[fonticon="stop"]') ||
322
- document.querySelector('mat-icon[data-mat-icon-name="stop"]') ||
323
- document.querySelector('.blue-circle.stop-icon');
324
- const hasStopIcon = !!stopIcon;
325
- // Also check for stop button (fallback)
326
- const buttons = Array.from(document.querySelectorAll('button'));
327
- const stopButton = buttons.find(b => {
328
- const text = b.textContent || '';
329
- const ariaLabel = b.getAttribute('aria-label') || '';
330
- return text.includes('回答を停止') || text.includes('Stop') ||
331
- ariaLabel.includes('Stop') || ariaLabel.includes('停止');
332
- });
333
- // Check for "プロンプトを送信" button - this indicates response is complete
334
- // Must be enabled (not disabled) to indicate completion
335
- const sendButton = buttons.find(b => {
336
- const hasLabel = b.textContent?.includes('プロンプトを送信') ||
337
- b.getAttribute('aria-label')?.includes('プロンプトを送信') ||
338
- b.getAttribute('aria-label')?.includes('Send message');
339
- return hasLabel && !b.disabled;
340
- });
341
- // Check for status text and thinking indicators
342
- const bodyText = document.body.innerText;
343
- const isTyping = bodyText.includes('Gemini が入力中です') ||
344
- bodyText.includes('Gemini is typing');
345
- // Check for thinking/analyzing indicators (Gemini shows these during processing)
346
- const isThinking = bodyText.includes('Analyzing') ||
347
- bodyText.includes('分析中') ||
348
- bodyText.includes('Crafting') ||
349
- bodyText.includes('作成中') ||
350
- bodyText.includes('Thinking') ||
351
- bodyText.includes('思考中') ||
352
- bodyText.includes('Researching') ||
353
- bodyText.includes('調査中');
354
- // Check for loading spinners or progress indicators
355
- const hasSpinner = document.querySelector('[role="progressbar"]') !== null ||
356
- document.querySelector('.loading') !== null ||
357
- document.querySelector('[aria-busy="true"]') !== null;
358
- const isComplete = (bodyText.includes('Gemini が回答しました') ||
359
- bodyText.includes('Gemini has responded') ||
360
- !!sendButton) && !isThinking && !hasSpinner && !hasStopIcon;
361
- const isGenerating = hasStopIcon || !!stopButton || isTyping || isThinking || hasSpinner;
362
- // Get the response content from model-response elements
363
- const modelResponses = Array.from(document.querySelectorAll('model-response'));
364
- let responseContent = '';
365
- if (modelResponses.length > 0) {
366
- // Get the last model response
367
- const lastResponse = modelResponses[modelResponses.length - 1];
368
- responseContent = lastResponse.textContent || '';
369
- }
370
- // Fallback: get text from main area
371
- if (!responseContent) {
372
- const main = document.querySelector('main');
373
- responseContent = main?.innerText || '';
374
- }
375
- return {
376
- isGenerating,
377
- isComplete,
378
- responseContent
379
- };
380
- });
381
- // If explicitly marked as complete, we're done
382
- if (status.isComplete && !status.isGenerating) {
383
- break;
384
- }
385
- // If not generating and response text is stable, we're done
386
- if (!status.isGenerating && status.responseContent === lastResponseText && status.responseContent.length > 0) {
387
- break; // No need to wait for multiple stable iterations
388
- }
389
- lastResponseText = status.responseContent;
390
- if (Date.now() - startTime > 180000) { // 3 mins timeout
391
- response.appendResponseLine('⚠️ タイムアウト(3分)');
392
- break;
393
- }
394
- }
395
- // Get the final response content
396
- const responseText = await page.evaluate(() => {
397
- // Get content from model-response elements
398
- const modelResponses = Array.from(document.querySelectorAll('model-response'));
399
- if (modelResponses.length > 0) {
400
- // Get the last model response
401
- const lastResponse = modelResponses[modelResponses.length - 1];
402
- return lastResponse.textContent?.trim() || '';
403
- }
404
- // Fallback: get text from main area
405
- const main = document.querySelector('main');
406
- return main?.innerText.slice(-5000) || '';
441
+ const completionResult = await waitForGeminiComplete(page, {
442
+ silenceDuration: 2000,
443
+ timeout: 180000,
407
444
  });
408
- response.appendResponseLine('✅ 回答完了');
445
+ if (completionResult.timedOut) {
446
+ response.appendResponseLine('⚠️ タイムアウト(3分)');
447
+ }
448
+ const responseText = completionResult.responseText || '';
449
+ response.appendResponseLine(`✅ 回答完了 (所要時間: ${Math.floor((Date.now() - startTime) / 1000)}秒)`);
409
450
  // Save session
410
451
  if (isNewChat) {
411
452
  const chatUrl = page.url();
@@ -427,26 +468,14 @@ export const askGeminiWeb = defineTool({
427
468
  chatId: sessionChatId,
428
469
  });
429
470
  response.appendResponseLine(`📝 会話ログ保存: ${logPath}`);
471
+ response.appendResponseLine(`🔗 チャットURL: ${page.url()}`);
472
+ response.appendResponseLine('\n' + '='.repeat(60));
473
+ response.appendResponseLine('Geminiの回答:\n');
474
+ response.appendResponseLine(responseText);
430
475
  }
431
476
  catch (error) {
432
477
  const errorMessage = error instanceof Error ? error.message : String(error);
433
478
  response.appendResponseLine(`❌ エラー: ${errorMessage}`);
434
- // Error snapshot
435
- try {
436
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
437
- const debugDir = path.join(process.cwd(), 'docs/ask/gemini/debug');
438
- await fs.promises.mkdir(debugDir, { recursive: true });
439
- const screenshotPath = path.join(debugDir, `error-${timestamp}.png`);
440
- await page.screenshot({ path: screenshotPath });
441
- response.appendResponseLine(`📸 エラー時のスクリーンショット: ${screenshotPath}`);
442
- const htmlPath = path.join(debugDir, `error-${timestamp}.html`);
443
- const html = await page.content();
444
- await fs.promises.writeFile(htmlPath, html, 'utf-8');
445
- response.appendResponseLine(`📄 エラー時のHTML: ${htmlPath}`);
446
- }
447
- catch (snapshotError) {
448
- console.error('Failed to capture error snapshot:', snapshotError);
449
- }
450
479
  }
451
480
  },
452
481
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-devtools-mcp-for-extension",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "MCP server for Chrome extension development with Web Store automation. Fork of chrome-devtools-mcp with extension-specific tools.",
5
5
  "type": "module",
6
6
  "bin": "./scripts/cli.mjs",