chrome-ai-bridge 2.0.4 → 2.0.9

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.
@@ -39,6 +39,37 @@ async function isConnectionHealthy(client, kind) {
39
39
  return false;
40
40
  }
41
41
  }
42
+ /**
43
+ * メッセージカウントが安定するまで待機
44
+ * ページ読み込み完了を確認するため、カウントが2回連続で同じ値になるまで待機
45
+ * @param client CDPクライアント
46
+ * @param countExpr カウントを取得するJavaScript式
47
+ * @param maxWaitMs 最大待機時間(デフォルト3000ms)
48
+ * @param pollIntervalMs ポーリング間隔(デフォルト300ms)
49
+ * @returns 安定したカウント値
50
+ */
51
+ async function waitForStableCount(client, countExpr, maxWaitMs = 3000, pollIntervalMs = 300) {
52
+ const startTime = Date.now();
53
+ let lastCount = -1;
54
+ let stableCount = 0;
55
+ while (Date.now() - startTime < maxWaitMs) {
56
+ const currentCount = await client.evaluate(countExpr);
57
+ if (currentCount === lastCount) {
58
+ stableCount++;
59
+ if (stableCount >= 2) {
60
+ // 2回連続で同じ値なら安定したとみなす
61
+ return currentCount;
62
+ }
63
+ }
64
+ else {
65
+ stableCount = 0;
66
+ lastCount = currentCount;
67
+ }
68
+ await new Promise(r => setTimeout(r, pollIntervalMs));
69
+ }
70
+ // タイムアウト時は最後のカウントを返す
71
+ return lastCount >= 0 ? lastCount : 0;
72
+ }
42
73
  function getProjectName() {
43
74
  return path.basename(process.cwd()) || 'default';
44
75
  }
@@ -48,6 +79,51 @@ function getSessionPath() {
48
79
  function getHistoryPath() {
49
80
  return path.join(process.cwd(), '.local', 'chrome-ai-bridge', 'history.jsonl');
50
81
  }
82
+ function getLocalTimestamp() {
83
+ const now = new Date();
84
+ const y = now.getFullYear();
85
+ const m = String(now.getMonth() + 1).padStart(2, '0');
86
+ const d = String(now.getDate()).padStart(2, '0');
87
+ const h = String(now.getHours()).padStart(2, '0');
88
+ const min = String(now.getMinutes()).padStart(2, '0');
89
+ const s = String(now.getSeconds()).padStart(2, '0');
90
+ return `${y}-${m}-${d} ${h}:${min}:${s}`;
91
+ }
92
+ async function rotateHistoryIfNeeded() {
93
+ const historyPath = getHistoryPath();
94
+ try {
95
+ const content = await fs.readFile(historyPath, 'utf-8');
96
+ const lines = content.trim().split('\n').filter(Boolean);
97
+ // 1000件以下なら何もしない
98
+ if (lines.length <= 1000)
99
+ return;
100
+ const now = Date.now();
101
+ const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
102
+ // 30日以上古いエントリを除外
103
+ const filtered = lines.filter(line => {
104
+ try {
105
+ const entry = JSON.parse(line);
106
+ // ローカル時刻形式 "2026-02-01 00:36:02" または ISO形式 "2026-01-31T15:36:02.273Z" 両対応
107
+ const ts = new Date(entry.ts).getTime();
108
+ return ts > thirtyDaysAgo; // 30日以内は保持
109
+ }
110
+ catch {
111
+ return true; // パース失敗は保持
112
+ }
113
+ });
114
+ // 削除対象があれば書き換え
115
+ if (filtered.length < lines.length) {
116
+ await fs.writeFile(historyPath, filtered.join('\n') + '\n', 'utf-8');
117
+ console.error(`[history] Rotated: ${lines.length} -> ${filtered.length} entries`);
118
+ }
119
+ }
120
+ catch (err) {
121
+ // ファイルがない場合は無視
122
+ if (err.code !== 'ENOENT') {
123
+ console.error('[history] Rotation error:', err);
124
+ }
125
+ }
126
+ }
51
127
  async function loadSessions() {
52
128
  try {
53
129
  const data = await fs.readFile(getSessionPath(), 'utf-8');
@@ -82,13 +158,15 @@ async function saveSession(kind, url, tabId) {
82
158
  async function appendHistory(entry) {
83
159
  const project = getProjectName();
84
160
  const payload = {
85
- ts: new Date().toISOString(),
161
+ ts: getLocalTimestamp(),
86
162
  project,
87
163
  ...entry,
88
164
  };
89
165
  const targetPath = getHistoryPath();
90
166
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
91
167
  await fs.appendFile(targetPath, `${JSON.stringify(payload)}\n`, 'utf-8');
168
+ // ローテーション実行(非同期、エラーは無視)
169
+ rotateHistoryIfNeeded().catch(() => { });
92
170
  }
93
171
  async function saveDebug(kind, payload) {
94
172
  const targetDir = path.join(process.cwd(), '.local', 'chrome-ai-bridge', 'debug');
@@ -312,9 +390,13 @@ async function askChatGPTFastInternal(question) {
312
390
  )`, 30000);
313
391
  timings.waitInputMs = nowMs() - tWaitInput;
314
392
  logInfo('chatgpt', 'Input field found', { waitInputMs: timings.waitInputMs });
315
- // 初期ユーザーメッセージカウントを取得(送信成功判定に使用)
316
- const initialUserCount = await client.evaluate(`document.querySelectorAll('[data-message-author-role="user"]').length`);
317
- console.error(`[ChatGPT] Initial user count: ${initialUserCount}`);
393
+ // 初期メッセージカウントを取得(ページ読み込み完了を待ってから)
394
+ // ページが完全に読み込まれるまでカウントが安定しないため、安定するまで待機
395
+ const userCountExpr = `document.querySelectorAll('[data-message-author-role="user"]').length`;
396
+ const assistantCountExpr = `document.querySelectorAll('[data-message-author-role="assistant"]').length`;
397
+ const initialUserCount = await waitForStableCount(client, userCountExpr);
398
+ const initialAssistantCount = await waitForStableCount(client, assistantCountExpr);
399
+ console.error(`[ChatGPT] Initial counts (stable): user=${initialUserCount}, assistant=${initialAssistantCount}`);
318
400
  // createConnection で正しいURL (https://chatgpt.com/) に接続済み
319
401
  const sanitized = JSON.stringify(question);
320
402
  const tInput = nowMs();
@@ -792,6 +874,7 @@ async function askChatGPTFastInternal(question) {
792
874
  const startWait = Date.now();
793
875
  let lastLoggedState = '';
794
876
  let sawStopButton = false; // 生成中状態を検出したかどうか
877
+ let streamingText = ''; // ストリーミング中に取得したテキスト(完了後に折りたたまれる対策)
795
878
  while (Date.now() - startWait < maxWaitMs) {
796
879
  const state = await client.evaluate(`
797
880
  (() => {
@@ -865,17 +948,38 @@ async function askChatGPTFastInternal(question) {
865
948
  lastLoggedState = currentState;
866
949
  }
867
950
  // 応答完了条件(シンプル版):
868
- // 停止ボタンを一度でも見た後に消えた AND 入力欄が空 AND アシスタントメッセージが存在
869
- // カウント比較は不安定なため廃止、最後のメッセージを直接取得する方式に変更
870
- if (sawStopButton && !state.hasStopButton && !state.inputBoxHasText && state.assistantMsgCount > 0) {
871
- console.error(`[ChatGPT] Response complete - stop button disappeared, input empty, has ${state.assistantMsgCount} assistant message(s)`);
951
+ // 停止ボタンを一度でも見た後に消えた AND 入力欄が空 AND 新しいアシスタントメッセージが増えた
952
+ // initialAssistantCountとの比較で、既存の回答を新しい回答と誤判断しない
953
+ if (sawStopButton && !state.hasStopButton && !state.inputBoxHasText && state.assistantMsgCount > initialAssistantCount) {
954
+ console.error(`[ChatGPT] Response complete - stop button disappeared, input empty, assistant count increased (${initialAssistantCount} -> ${state.assistantMsgCount})`);
955
+ // ChatGPT 5.2 Thinking: 完了直後にストリーミング中のテキストをキャプチャ
956
+ // (完了後は折りたたまれてしまうため、この時点で取得)
957
+ streamingText = await client.evaluate(`
958
+ (() => {
959
+ const msgs = document.querySelectorAll('[data-message-author-role="assistant"]');
960
+ if (msgs.length === 0) return '';
961
+ const last = msgs[msgs.length - 1];
962
+ // .markdown, .result-thinking, または直接テキストを試す
963
+ const md = last.querySelector('.markdown');
964
+ if (md) {
965
+ const t = (md.innerText || md.textContent || '').trim();
966
+ if (t.length > 0) return t;
967
+ }
968
+ const rt = last.querySelector('.result-thinking');
969
+ if (rt) {
970
+ const t = (rt.innerText || rt.textContent || '').trim();
971
+ if (t.length > 0) return t;
972
+ }
973
+ return (last.innerText || last.textContent || '').trim();
974
+ })()
975
+ `);
872
976
  break;
873
977
  }
874
- // フォールバック: 5秒以上待って、stopボタンなし、入力欄空、アシスタントメッセージが存在
978
+ // フォールバック: 5秒以上待って、stopボタンなし、入力欄空、新しいアシスタントメッセージが増えた
875
979
  // (stopボタンを見逃した場合の救済)
876
980
  const elapsed = Date.now() - startWait;
877
- if (elapsed > 5000 && !state.hasStopButton && !state.inputBoxHasText && state.assistantMsgCount > 0) {
878
- console.error(`[ChatGPT] Response complete - fallback after 5s (no stop button, input empty, has ${state.assistantMsgCount} assistant message(s))`);
981
+ if (elapsed > 5000 && !state.hasStopButton && !state.inputBoxHasText && state.assistantMsgCount > initialAssistantCount) {
982
+ console.error(`[ChatGPT] Response complete - fallback after 5s (no stop button, input empty, assistant count increased ${initialAssistantCount} -> ${state.assistantMsgCount})`);
879
983
  break;
880
984
  }
881
985
  await new Promise(r => setTimeout(r, pollIntervalMs));
@@ -913,21 +1017,165 @@ async function askChatGPTFastInternal(question) {
913
1017
  console.error(`[ChatGPT] Timeout - final state: ${JSON.stringify(finalState)}`);
914
1018
  throw new Error(`Timed out waiting for ChatGPT response (8min). Final state: ${JSON.stringify(finalState)}`);
915
1019
  }
916
- // 最後のアシスタントメッセージを直接取得(シンプルにinnerTextを使用)
917
- const answer = await client.evaluate(`
1020
+ // ChatGPT 5.2 Thinking モデル対応:
1021
+ // 回答が「思考」として折りたたまれている場合は展開してからテキストを取得
1022
+ // 「思考の拡張」ボタンをページ全体から探してクリック
1023
+ const clickedExpand = await client.evaluate(`
918
1024
  (() => {
919
- const messages = document.querySelectorAll('[data-message-author-role="assistant"]');
920
- if (messages.length === 0) return '';
921
- const lastMsg = messages[messages.length - 1];
922
-
923
- // マークダウンコンテンツを優先、なければ要素全体
924
- const content = lastMsg.querySelector('.markdown') || lastMsg;
925
-
926
- // innerTextが最もシンプルで確実
927
- return (content.innerText || content.textContent || '').trim();
1025
+ // ページ全体から「思考の拡張」「Expand thinking」ボタンを探す
1026
+ const allButtons = document.querySelectorAll('button');
1027
+ for (const btn of allButtons) {
1028
+ const text = (btn.innerText || '').toLowerCase();
1029
+ if (text.includes('思考') || text.includes('thinking') || text.includes('expand')) {
1030
+ if (btn.getAttribute('aria-expanded') === 'false') {
1031
+ btn.click();
1032
+ return true;
1033
+ }
1034
+ }
1035
+ }
1036
+ return false;
928
1037
  })()
929
1038
  `);
930
- console.error(`[ChatGPT] Response extracted: ${answer.slice(0, 100)}...`);
1039
+ if (clickedExpand) {
1040
+ // 展開アニメーションとコンテンツロードを待つ
1041
+ await new Promise(resolve => setTimeout(resolve, 1000));
1042
+ console.error('[ChatGPT] Expanded thinking content');
1043
+ }
1044
+ // 最後のアシスタントメッセージを直接取得
1045
+ // ChatGPT 5.2 Thinking: .result-thinking または .markdown 内のテキスト
1046
+ // リトライロジック: CDPがReactレンダリング完了前に実行される問題に対応
1047
+ let answer = '';
1048
+ const extractMaxRetries = 5;
1049
+ const extractRetryIntervalMs = 500;
1050
+ for (let retry = 0; retry < extractMaxRetries; retry++) {
1051
+ answer = await client.evaluate(`
1052
+ (() => {
1053
+ const articles = document.querySelectorAll('article');
1054
+ let lastAssistantArticle = null;
1055
+
1056
+ // 新UI: article内のh6/h5/[role="heading"]に"ChatGPT"を含むものを探す
1057
+ for (const article of articles) {
1058
+ const heading = article.querySelector('h6, h5, [role="heading"]');
1059
+ if (heading && (heading.textContent || '').includes('ChatGPT')) {
1060
+ lastAssistantArticle = article;
1061
+ }
1062
+ }
1063
+
1064
+ // フォールバック: 旧セレクター
1065
+ if (!lastAssistantArticle) {
1066
+ const old = document.querySelectorAll('[data-message-author-role="assistant"]');
1067
+ if (old.length > 0) lastAssistantArticle = old[old.length - 1];
1068
+ }
1069
+
1070
+ if (!lastAssistantArticle) return '';
1071
+
1072
+ // ChatGPT 5.2 Thinking: 展開された思考コンテンツを取得
1073
+ // .result-thinking.markdown 内のテキストを優先
1074
+ const thinking = lastAssistantArticle.querySelector('.result-thinking.markdown, .result-thinking');
1075
+ if (thinking) {
1076
+ const text = (thinking.innerText || thinking.textContent || '').trim();
1077
+ if (text.length > 0) return text;
1078
+ }
1079
+
1080
+ // 通常のmarkdownコンテンツ
1081
+ const markdown = lastAssistantArticle.querySelector('.markdown');
1082
+ if (markdown) {
1083
+ const text = (markdown.innerText || markdown.textContent || '').trim();
1084
+ if (text.length > 0) return text;
1085
+ }
1086
+
1087
+ // Thinkingモード対応: button以外のコンテンツコンテナからテキストを抽出
1088
+ // DOM構造: article > div > div (generic) > button/"思考時間" + div (generic) > p/回答
1089
+ const contentDivs = lastAssistantArticle.querySelectorAll(':scope > div > div');
1090
+ for (const div of contentDivs) {
1091
+ // buttonはスキップ(「思考中」「今すぐ回答」「思考時間: Xs」)
1092
+ if (div.tagName === 'BUTTON') continue;
1093
+
1094
+ // div内のparagraphを探す
1095
+ const paragraphs = div.querySelectorAll('p');
1096
+ if (paragraphs.length > 0) {
1097
+ const text = Array.from(paragraphs)
1098
+ .map(p => (p.innerText || p.textContent || '').trim())
1099
+ .filter(t => t.length > 0)
1100
+ .join('\\n\\n');
1101
+ if (text.length > 0) return text;
1102
+ }
1103
+ }
1104
+
1105
+ // テキスト抽出: p要素を結合(button内のpは除外)
1106
+ const paragraphs = lastAssistantArticle.querySelectorAll('p');
1107
+ if (paragraphs.length > 0) {
1108
+ const text = Array.from(paragraphs)
1109
+ .filter(p => !p.closest('button')) // button内のpは除外
1110
+ .map(p => (p.innerText || p.textContent || '').trim())
1111
+ .filter(t => t.length > 0)
1112
+ .join('\\n\\n');
1113
+ if (text.length > 0) return text;
1114
+ }
1115
+
1116
+ // フォールバック: article全体のテキスト(ヘッダー・ボタンテキスト除去)
1117
+ const fullText = (lastAssistantArticle.innerText || lastAssistantArticle.textContent || '').trim();
1118
+ // ボタンテキストパターンを除去
1119
+ const cleaned = fullText
1120
+ .replace(/^ChatGPT:\\s*/i, '')
1121
+ .split('\\n')
1122
+ .filter(line => {
1123
+ const trimmed = line.trim();
1124
+ // Thinking関連のボタンテキストを除外
1125
+ if (/^思考時間:\\s*\\d+s?$/.test(trimmed)) return false;
1126
+ if (trimmed === '思考中') return false;
1127
+ if (trimmed === '今すぐ回答') return false;
1128
+ if (trimmed === 'Skip thinking') return false;
1129
+ return true;
1130
+ })
1131
+ .join('\\n')
1132
+ .trim();
1133
+ return cleaned;
1134
+ })()
1135
+ `);
1136
+ // 有効なテキストが取得できたら終了
1137
+ if (answer && answer.length > 0 && !answer.startsWith('ChatGPT:')) {
1138
+ if (retry > 0) {
1139
+ console.error(`[ChatGPT] Got response on retry ${retry}`);
1140
+ }
1141
+ break;
1142
+ }
1143
+ // リトライ待機
1144
+ if (retry < extractMaxRetries - 1) {
1145
+ console.error(`[ChatGPT] Response empty, retrying (${retry + 1}/${extractMaxRetries})...`);
1146
+ await new Promise(r => setTimeout(r, extractRetryIntervalMs));
1147
+ }
1148
+ }
1149
+ // リトライでも取得できない場合、body.innerTextから抽出(最終フォールバック)
1150
+ if (!answer || answer.length === 0) {
1151
+ console.error('[ChatGPT] Trying body.innerText fallback...');
1152
+ answer = await client.evaluate(`
1153
+ (() => {
1154
+ const bodyText = document.body.innerText || '';
1155
+ // 最後の"ChatGPT:"以降のテキストを抽出
1156
+ const parts = bodyText.split('ChatGPT:');
1157
+ if (parts.length < 2) return '';
1158
+ const lastPart = parts[parts.length - 1];
1159
+ // 終端マーカーまでを取得
1160
+ const endMarkers = ['あなた:', 'You:', '思考の拡張', 'cookie', 'ChatGPT は間違えることがあります'];
1161
+ let endIndex = lastPart.length;
1162
+ for (const marker of endMarkers) {
1163
+ const idx = lastPart.indexOf(marker);
1164
+ if (idx > 0 && idx < endIndex) endIndex = idx;
1165
+ }
1166
+ return lastPart.slice(0, endIndex).trim();
1167
+ })()
1168
+ `);
1169
+ if (answer && answer.length > 0) {
1170
+ console.error(`[ChatGPT] Got response from body.innerText fallback`);
1171
+ }
1172
+ }
1173
+ // streamingTextが有効な場合はそれを優先(ChatGPT 5.2 Thinking対応)
1174
+ // DOMから取得したテキストが空または見出しのみ("ChatGPT:"など)の場合はstreamingTextを使用
1175
+ const finalAnswer = (answer && answer.length > 20 && !answer.startsWith('ChatGPT:'))
1176
+ ? answer
1177
+ : (streamingText || answer);
1178
+ console.error(`[ChatGPT] Response extracted: ${finalAnswer.slice(0, 100)}...`);
931
1179
  const finalUrl = await client.evaluate('location.href');
932
1180
  if (finalUrl && finalUrl.includes('chatgpt.com')) {
933
1181
  await saveSession('chatgpt', finalUrl);
@@ -937,7 +1185,7 @@ async function askChatGPTFastInternal(question) {
937
1185
  await appendHistory({
938
1186
  provider: 'chatgpt',
939
1187
  question,
940
- answer,
1188
+ answer: finalAnswer,
941
1189
  url: finalUrl || undefined,
942
1190
  timings,
943
1191
  });
@@ -950,7 +1198,7 @@ async function askChatGPTFastInternal(question) {
950
1198
  waitResponseMs: timings.waitResponseMs ?? 0,
951
1199
  totalMs: timings.totalMs ?? 0,
952
1200
  };
953
- return { answer, timings: fullTimings };
1201
+ return { answer: finalAnswer, timings: fullTimings };
954
1202
  }
955
1203
  /**
956
1204
  * ChatGPTに質問して回答を取得(後方互換用)
@@ -1024,8 +1272,7 @@ async function askGeminiFastInternal(question) {
1024
1272
  collectDeep(selectors);
1025
1273
  return results.length;
1026
1274
  })()`;
1027
- const initialGeminiUserCount = await client.evaluate(geminiUserCountExpr);
1028
- const initialModelResponseCount = await client.evaluate(`
1275
+ const geminiModelResponseCountExpr = `
1029
1276
  (() => {
1030
1277
  const collectDeep = (selectorList) => {
1031
1278
  const results = [];
@@ -1052,8 +1299,12 @@ async function askGeminiFastInternal(question) {
1052
1299
  };
1053
1300
  return collectDeep(['model-response', '.model-response', '[data-test-id*="response"]']).length;
1054
1301
  })()
1055
- `);
1056
- console.error(`[Gemini] Initial counts BEFORE input: user=${initialGeminiUserCount}, modelResponse=${initialModelResponseCount}`);
1302
+ `;
1303
+ // ページ読み込み完了を待ってから初期カウントを取得
1304
+ // カウントが安定するまでポーリング(2回連続で同じ値になるまで)
1305
+ const initialGeminiUserCount = await waitForStableCount(client, geminiUserCountExpr);
1306
+ const initialModelResponseCount = await waitForStableCount(client, geminiModelResponseCountExpr);
1307
+ console.error(`[Gemini] Initial counts (stable): user=${initialGeminiUserCount}, modelResponse=${initialModelResponseCount}`);
1057
1308
  const sanitized = JSON.stringify(question);
1058
1309
  const tInput = nowMs();
1059
1310
  // Phase 1: 最初の入力試行
@@ -1429,12 +1680,31 @@ async function askGeminiFastInternal(question) {
1429
1680
  .filter(el => !isDisabled(el));
1430
1681
 
1431
1682
  // 「停止」ボタンがあるかチェック(応答生成中)
1432
- const hasStopButton = buttons.some(b => {
1433
- const text = (b.textContent || '').trim();
1434
- const label = (b.getAttribute('aria-label') || '').trim();
1435
- return text.includes('停止') || label.includes('停止') ||
1436
- text.includes('Stop') || label.includes('Stop');
1437
- });
1683
+ const hasStopButton = (() => {
1684
+ // 方法1: aria-labelベースの検索(最も信頼性が高い)
1685
+ const stopByLabel = buttons.some(b => {
1686
+ const label = (b.getAttribute('aria-label') || '').trim();
1687
+ return label.includes('回答を停止') || label.includes('Stop generating') ||
1688
+ label.includes('Stop streaming') || label === 'Stop';
1689
+ });
1690
+ if (stopByLabel) return true;
1691
+
1692
+ // 方法2: mat-icon要素での検出(Gemini用)
1693
+ const stopIcon = document.querySelector('mat-icon[data-mat-icon-name="stop"]');
1694
+ if (stopIcon) {
1695
+ const btn = stopIcon.closest('button');
1696
+ if (btn && isVisible(btn)) return true;
1697
+ }
1698
+
1699
+ // 方法3: img[alt="stop"] での検出(ChatGPT用)
1700
+ const stopImg = document.querySelector('img[alt="stop"]');
1701
+ if (stopImg) {
1702
+ const btn = stopImg.closest('button');
1703
+ if (btn && isVisible(btn)) return true;
1704
+ }
1705
+
1706
+ return false;
1707
+ })();
1438
1708
 
1439
1709
  // 応答生成中の場合、送信ボタンはdisabled扱い
1440
1710
  if (hasStopButton) {
@@ -1678,23 +1948,50 @@ async function askGeminiFastInternal(question) {
1678
1948
 
1679
1949
  const buttons = collectDeep(['button', '[role="button"]']).filter(isVisible);
1680
1950
 
1681
- // 停止ボタン検出(複数セレクター)
1682
- const hasStopButton = buttons.some(b => {
1683
- const text = (b.textContent || '').trim();
1684
- const label = (b.getAttribute('aria-label') || '').trim();
1685
- return text.includes('停止') || label.includes('停止') ||
1686
- text.includes('Stop') || label.includes('Stop');
1687
- });
1951
+ // 停止ボタン検出(応答生成中かどうか)
1952
+ const hasStopButton = (() => {
1953
+ // 方法1: aria-labelベースの検索(最も信頼性が高い)
1954
+ const stopByLabel = buttons.some(b => {
1955
+ const label = (b.getAttribute('aria-label') || '').trim();
1956
+ return label.includes('回答を停止') || label.includes('Stop generating') ||
1957
+ label.includes('Stop streaming') || label === 'Stop';
1958
+ });
1959
+ if (stopByLabel) return true;
1960
+
1961
+ // 方法2: mat-icon要素での検出(Gemini用)
1962
+ const stopIcon = document.querySelector('mat-icon[data-mat-icon-name="stop"]');
1963
+ if (stopIcon) {
1964
+ const btn = stopIcon.closest('button');
1965
+ if (btn && isVisible(btn)) return true;
1966
+ }
1688
1967
 
1689
- // マイクボタン検出(入力欄が空の状態で表示)
1690
- const micButton = document.querySelector('[data-node-type="speech_dictation_mic_button"]') ||
1691
- buttons.find(b => {
1692
- const label = (b.getAttribute('aria-label') || '').toLowerCase();
1693
- return label.includes('マイク') ||
1694
- label.includes('mic') ||
1695
- label.includes('microphone') ||
1696
- label.includes('voice');
1697
- });
1968
+ // 方法3: img[alt="stop"] での検出(ChatGPT用)
1969
+ const stopImg = document.querySelector('img[alt="stop"]');
1970
+ if (stopImg) {
1971
+ const btn = stopImg.closest('button');
1972
+ if (btn && isVisible(btn)) return true;
1973
+ }
1974
+
1975
+ return false;
1976
+ })();
1977
+
1978
+ // マイクボタン検出(言語非依存: img[alt="mic"]を使用)
1979
+ const micButton = (() => {
1980
+ // img[alt="mic"] を含むボタンを探す(アイコン名は言語非依存)
1981
+ const micImg = document.querySelector('img[alt="mic"]');
1982
+ if (micImg) {
1983
+ const btn = micImg.closest('button');
1984
+ if (btn && isVisible(btn)) return btn;
1985
+ }
1986
+ // フォールバック: aria-labelベースの検索
1987
+ return buttons.find(b => {
1988
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
1989
+ return label.includes('マイク') ||
1990
+ label.includes('mic') ||
1991
+ label.includes('microphone') ||
1992
+ label.includes('voice');
1993
+ });
1994
+ })();
1698
1995
 
1699
1996
  // 送信ボタン検出
1700
1997
  const sendBtn = buttons.find(b =>
@@ -1706,6 +2003,13 @@ async function askGeminiFastInternal(question) {
1706
2003
  b.querySelector('[data-icon="send"]')
1707
2004
  );
1708
2005
 
2006
+ // フィードバックボタン検出(言語非依存: thumb_up/thumb_downアイコン)
2007
+ const hasFeedbackButtons = !!(
2008
+ document.querySelector('img[alt="thumb_up"], img[alt="thumb_down"]') ||
2009
+ document.querySelector('button[aria-label*="良い回答"], button[aria-label*="悪い回答"]') ||
2010
+ document.querySelector('button[aria-label*="Good"], button[aria-label*="Bad"]')
2011
+ );
2012
+
1709
2013
  // モデルレスポンス収集(Shadow DOM対応)
1710
2014
  const allResponses = collectDeep(['model-response', '[data-test-id*="response"]', '.response', '.model-response']);
1711
2015
  const lastResponse = allResponses[allResponses.length - 1];
@@ -1728,6 +2032,7 @@ async function askGeminiFastInternal(question) {
1728
2032
  return {
1729
2033
  hasStopButton,
1730
2034
  hasMicButton: Boolean(micButton && isVisible(micButton)),
2035
+ hasFeedbackButtons,
1731
2036
  sendButtonEnabled: Boolean(sendBtn && !isDisabled(sendBtn)),
1732
2037
  modelResponseCount: allResponses.length,
1733
2038
  lastResponseTextLength,
@@ -1751,28 +2056,33 @@ async function askGeminiFastInternal(question) {
1751
2056
  const currentState = JSON.stringify(state);
1752
2057
  if (currentState !== lastLoggedState) {
1753
2058
  const elapsed = Math.round((Date.now() - startWait) / 1000);
1754
- console.error(`[Gemini] State @${elapsed}s: stop=${state.hasStopButton}, mic=${state.hasMicButton}, send=${state.sendButtonEnabled}, responses=${state.modelResponseCount}, textLen=${state.lastResponseTextLength}, inputEmpty=${state.inputBoxEmpty}, sawStop=${sawStopButton}, textStable=${textStableCount}`);
2059
+ console.error(`[Gemini] State @${elapsed}s: stop=${state.hasStopButton}, mic=${state.hasMicButton}, feedback=${state.hasFeedbackButtons}, send=${state.sendButtonEnabled}, responses=${state.modelResponseCount}, textLen=${state.lastResponseTextLength}, inputEmpty=${state.inputBoxEmpty}, sawStop=${sawStopButton}, textStable=${textStableCount}`);
1755
2060
  lastLoggedState = currentState;
1756
2061
  }
1757
- // 応答完了条件1: 停止ボタンを見た後に消えた AND マイクボタン表示
1758
- if (sawStopButton && !state.hasStopButton && state.hasMicButton) {
1759
- console.error('[Gemini] Response complete - stop button disappeared, mic button visible');
2062
+ // 応答完了条件0: 停止ボタンを見た後に消えた AND フィードバックボタン表示 AND 新しい回答が増えた
2063
+ if (sawStopButton && !state.hasStopButton && state.hasFeedbackButtons && state.modelResponseCount > initialModelResponseCount) {
2064
+ console.error(`[Gemini] Response complete - stop button disappeared, feedback buttons visible, response count increased (${initialModelResponseCount} -> ${state.modelResponseCount})`);
2065
+ break;
2066
+ }
2067
+ // 応答完了条件1: 停止ボタンを見た後に消えた AND マイクボタン表示 AND 新しい回答が増えた
2068
+ if (sawStopButton && !state.hasStopButton && state.hasMicButton && state.modelResponseCount > initialModelResponseCount) {
2069
+ console.error(`[Gemini] Response complete - stop button disappeared, mic button visible, response count increased (${initialModelResponseCount} -> ${state.modelResponseCount})`);
1760
2070
  break;
1761
2071
  }
1762
- // 応答完了条件2: 停止ボタンを見た後に消えた AND 送信ボタン有効 AND 入力欄空
1763
- if (sawStopButton && !state.hasStopButton && state.sendButtonEnabled && state.inputBoxEmpty) {
1764
- console.error('[Gemini] Response complete - stop button disappeared, send button enabled, input empty');
2072
+ // 応答完了条件2: 停止ボタンを見た後に消えた AND 送信ボタン有効 AND 入力欄空 AND 新しい回答が増えた
2073
+ if (sawStopButton && !state.hasStopButton && state.sendButtonEnabled && state.inputBoxEmpty && state.modelResponseCount > initialModelResponseCount) {
2074
+ console.error(`[Gemini] Response complete - stop button disappeared, send button enabled, input empty, response count increased (${initialModelResponseCount} -> ${state.modelResponseCount})`);
1765
2075
  break;
1766
2076
  }
1767
- // 応答完了条件3: テキスト長が5秒間安定 AND レスポンスが存在
1768
- if (textStableCount >= 5 && state.modelResponseCount > 0 && !state.hasStopButton) {
1769
- console.error(`[Gemini] Response complete - text stable for ${textStableCount}s, ${state.modelResponseCount} response(s)`);
2077
+ // 応答完了条件3: テキスト長が5秒間安定 AND 新しいレスポンスが増えた
2078
+ if (textStableCount >= 5 && state.modelResponseCount > initialModelResponseCount && !state.hasStopButton) {
2079
+ console.error(`[Gemini] Response complete - text stable for ${textStableCount}s, response count increased (${initialModelResponseCount} -> ${state.modelResponseCount})`);
1770
2080
  break;
1771
2081
  }
1772
- // フォールバック: 10秒以上経過 + 停止ボタンを見ていない + レスポンス存在 + 停止ボタンなし
2082
+ // フォールバック: 10秒以上経過 + 停止ボタンを見ていない + 新しいレスポンスが増えた + 停止ボタンなし
1773
2083
  const elapsed = Date.now() - startWait;
1774
- if (elapsed > 10000 && !sawStopButton && state.modelResponseCount > 0 && !state.hasStopButton && (state.hasMicButton || state.inputBoxEmpty)) {
1775
- console.error(`[Gemini] Response complete - fallback after 10s (no stop button seen, ${state.modelResponseCount} response(s), mic=${state.hasMicButton})`);
2084
+ if (elapsed > 10000 && !sawStopButton && state.modelResponseCount > initialModelResponseCount && !state.hasStopButton && (state.hasMicButton || state.inputBoxEmpty)) {
2085
+ console.error(`[Gemini] Response complete - fallback after 10s (no stop button seen, response count increased ${initialModelResponseCount} -> ${state.modelResponseCount}, mic=${state.hasMicButton})`);
1776
2086
  break;
1777
2087
  }
1778
2088
  await new Promise(r => setTimeout(r, pollIntervalMs));
@@ -1810,9 +2120,40 @@ async function askGeminiFastInternal(question) {
1810
2120
  console.error(`[Gemini] Timeout - final state: ${JSON.stringify(finalState)}`);
1811
2121
  throw new Error(`Timed out waiting for Gemini response (8min). sawStopButton=${sawStopButton}, textStableCount=${textStableCount}. Final state: ${JSON.stringify(finalState)}`);
1812
2122
  }
1813
- // 最後のレスポンスを取得(シンプルにinnerTextを使用)
2123
+ // 最後のレスポンスを取得(フィードバックボタン基準 + フォールバック)
1814
2124
  const rawText = await client.evaluate(`
1815
2125
  (() => {
2126
+ // 方法1: フィードバックボタン(thumb_up)を基準に応答を探す(言語非依存・最も確実)
2127
+ const thumbUpImg = document.querySelector('img[alt="thumb_up"]');
2128
+ if (thumbUpImg) {
2129
+ // ボタンの親コンテナを遡る
2130
+ let container = thumbUpImg.closest('button')?.parentElement;
2131
+ if (container) {
2132
+ // さらに親を遡って応答テキストを含む要素を探す
2133
+ const parent = container.parentElement;
2134
+ if (parent) {
2135
+ // paragraph, heading, list などのテキスト要素を収集
2136
+ const textElements = parent.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, pre, code');
2137
+ const texts = Array.from(textElements)
2138
+ .map(el => (el.innerText || el.textContent || '').trim())
2139
+ .filter(t => t.length > 0);
2140
+
2141
+ if (texts.length > 0) {
2142
+ return texts.join('\\n\\n');
2143
+ }
2144
+
2145
+ // フォールバック: 親要素全体からテキスト取得(ボタンを除外)
2146
+ const clone = parent.cloneNode(true);
2147
+ clone.querySelectorAll('button, img').forEach(el => el.remove());
2148
+ const text = (clone.innerText || clone.textContent || '').trim();
2149
+ if (text.length > 0) {
2150
+ return text;
2151
+ }
2152
+ }
2153
+ }
2154
+ }
2155
+
2156
+ // 方法2: 従来のセレクターベース(Shadow DOM対応)
1816
2157
  const collectDeep = (selectorList) => {
1817
2158
  const results = [];
1818
2159
  const seen = new Set();
@@ -1837,16 +2178,15 @@ async function askGeminiFastInternal(question) {
1837
2178
  return results;
1838
2179
  };
1839
2180
 
1840
- // Shadow DOM内のmodel-responseを収集
1841
2181
  const allResponses = collectDeep(['model-response', '[data-test-id*="response"]', '.response', '.model-response']);
1842
2182
 
1843
2183
  if (allResponses.length === 0) {
1844
- // フォールバック: aria-live="polite"
2184
+ // 方法3: aria-live="polite"
1845
2185
  const live = document.querySelector('[aria-live="polite"]');
1846
2186
  return live ? (live.innerText || live.textContent || '').trim() : '';
1847
2187
  }
1848
2188
 
1849
- // 最後のレスポンス要素を直接取得(シンプルにinnerTextを使用)
2189
+ // 最後のレスポンス要素を直接取得
1850
2190
  const lastMsg = allResponses[allResponses.length - 1];
1851
2191
 
1852
2192
  // マークダウンコンテンツを優先、なければ要素全体
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "2.0.4",
3
+ "version": "2.0.9",
4
4
  "description": "MCP server bridging Chrome extension and AI assistants (ChatGPT, Gemini). Extension-only mode - no Puppeteer.",
5
5
  "type": "module",
6
6
  "bin": "./scripts/cli.mjs",