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.
- package/build/src/tools/chatgpt-web.js +209 -159
- package/build/src/tools/dom-utils.js +182 -0
- package/build/src/tools/gemini-web.js +138 -109
- package/package.json +1 -1
|
@@ -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:
|
|
511
|
+
// Step 5: Wait for response using MutationObserver-based detection
|
|
385
512
|
if (useDeepResearch) {
|
|
386
|
-
response.appendResponseLine('DeepResearchを実行中... (
|
|
513
|
+
response.appendResponseLine('DeepResearchを実行中... (MutationObserverで完了を検出)');
|
|
387
514
|
}
|
|
388
515
|
else {
|
|
389
|
-
response.appendResponseLine('ChatGPTの回答を待機中... (
|
|
516
|
+
response.appendResponseLine('ChatGPTの回答を待機中... (MutationObserverで完了を検出)');
|
|
390
517
|
}
|
|
391
518
|
const startTime = Date.now();
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|