@steipete/oracle 0.7.1 → 0.7.2
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/dist/src/browser/actions/assistantResponse.js +53 -33
- package/dist/src/browser/actions/attachments.js +276 -133
- package/dist/src/browser/actions/modelSelection.js +33 -2
- package/dist/src/browser/actions/promptComposer.js +38 -45
- package/dist/src/browser/config.js +7 -2
- package/dist/src/browser/index.js +6 -2
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/utils.js +10 -0
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +11 -6
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { logDomFailure, logConversationSnapshot, buildConversationDebugExpressio
|
|
|
4
4
|
import { buildClickDispatcher } from './domEvents.js';
|
|
5
5
|
const ASSISTANT_POLL_TIMEOUT_ERROR = 'assistant-response-watchdog-timeout';
|
|
6
6
|
export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
|
|
7
|
+
const start = Date.now();
|
|
7
8
|
logger('Waiting for ChatGPT response');
|
|
8
9
|
const expression = buildResponseObserverExpression(timeoutMs);
|
|
9
10
|
const evaluationPromise = Runtime.evaluate({ expression, awaitPromise: true, returnByValue: true });
|
|
@@ -61,7 +62,24 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
|
|
|
61
62
|
throw new Error('Unable to capture assistant response');
|
|
62
63
|
}
|
|
63
64
|
const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger);
|
|
64
|
-
|
|
65
|
+
const candidate = refreshed ?? parsed;
|
|
66
|
+
// The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
|
|
67
|
+
const elapsedMs = Date.now() - start;
|
|
68
|
+
const remainingMs = Math.max(0, timeoutMs - elapsedMs);
|
|
69
|
+
if (remainingMs > 0) {
|
|
70
|
+
const [stopVisible, completionVisible] = await Promise.all([
|
|
71
|
+
isStopButtonVisible(Runtime),
|
|
72
|
+
isCompletionVisible(Runtime),
|
|
73
|
+
]);
|
|
74
|
+
if (stopVisible && !completionVisible) {
|
|
75
|
+
logger('Assistant still generating; waiting for completion');
|
|
76
|
+
const completed = await pollAssistantCompletion(Runtime, remainingMs);
|
|
77
|
+
if (completed) {
|
|
78
|
+
return completed;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return candidate;
|
|
65
83
|
}
|
|
66
84
|
export async function readAssistantSnapshot(Runtime) {
|
|
67
85
|
const { result } = await Runtime.evaluate({ expression: buildAssistantSnapshotExpression(), returnByValue: true });
|
|
@@ -118,11 +136,14 @@ async function parseAssistantEvaluationResult(Runtime, evaluation, timeoutMs, lo
|
|
|
118
136
|
const messageId = typeof result.value.messageId === 'string'
|
|
119
137
|
? (result.value.messageId ?? undefined)
|
|
120
138
|
: undefined;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
const text = cleanAssistantText(String(result.value.text ?? ''));
|
|
140
|
+
const normalized = text.toLowerCase();
|
|
141
|
+
if (normalized.includes('answer now') &&
|
|
142
|
+
(normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
|
|
143
|
+
const recovered = await recoverAssistantResponse(Runtime, Math.min(timeoutMs, 10_000), logger);
|
|
144
|
+
return recovered ?? null;
|
|
145
|
+
}
|
|
146
|
+
return { text, html, meta: { turnId, messageId } };
|
|
126
147
|
}
|
|
127
148
|
const fallbackText = typeof result.value === 'string' ? cleanAssistantText(result.value) : '';
|
|
128
149
|
if (!fallbackText) {
|
|
@@ -261,7 +282,7 @@ function normalizeAssistantSnapshot(snapshot) {
|
|
|
261
282
|
}
|
|
262
283
|
const normalized = text.toLowerCase();
|
|
263
284
|
// "Pro thinking" often renders a placeholder turn containing an "Answer now" gate.
|
|
264
|
-
// Treat it as incomplete so browser mode keeps waiting
|
|
285
|
+
// Treat it as incomplete so browser mode keeps waiting for the real assistant text.
|
|
265
286
|
if (normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
|
|
266
287
|
return null;
|
|
267
288
|
}
|
|
@@ -303,7 +324,10 @@ function buildResponseObserverExpression(timeoutMs) {
|
|
|
303
324
|
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
304
325
|
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
305
326
|
const settleDelayMs = 800;
|
|
306
|
-
const
|
|
327
|
+
const isAnswerNowPlaceholder = (snapshot) => {
|
|
328
|
+
const normalized = String(snapshot?.text ?? '').toLowerCase();
|
|
329
|
+
return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
|
|
330
|
+
};
|
|
307
331
|
|
|
308
332
|
// Helper to detect assistant turns - matches buildAssistantExtractor logic
|
|
309
333
|
const isAssistantTurn = (node) => {
|
|
@@ -324,7 +348,8 @@ function buildResponseObserverExpression(timeoutMs) {
|
|
|
324
348
|
const deadline = Date.now() + ${timeoutMs};
|
|
325
349
|
let stopInterval = null;
|
|
326
350
|
const observer = new MutationObserver(() => {
|
|
327
|
-
const
|
|
351
|
+
const extractedRaw = extractFromTurns();
|
|
352
|
+
const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
|
|
328
353
|
if (extracted) {
|
|
329
354
|
observer.disconnect();
|
|
330
355
|
if (stopInterval) {
|
|
@@ -341,11 +366,6 @@ function buildResponseObserverExpression(timeoutMs) {
|
|
|
341
366
|
});
|
|
342
367
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
343
368
|
stopInterval = setInterval(() => {
|
|
344
|
-
// Pro thinking can gate the response behind an "Answer now" button. Keep clicking it while present.
|
|
345
|
-
const answerNow = Array.from(document.querySelectorAll('button,span')).find((el) => (el?.textContent || '').trim().toLowerCase() === ANSWER_NOW_LABEL);
|
|
346
|
-
if (answerNow) {
|
|
347
|
-
dispatchClickSequence(answerNow.closest('button') ?? answerNow);
|
|
348
|
-
}
|
|
349
369
|
const stop = document.querySelector(STOP_SELECTOR);
|
|
350
370
|
if (!stop) {
|
|
351
371
|
return;
|
|
@@ -387,28 +407,28 @@ function buildResponseObserverExpression(timeoutMs) {
|
|
|
387
407
|
const waitForSettle = async (snapshot) => {
|
|
388
408
|
const settleWindowMs = 5000;
|
|
389
409
|
const settleIntervalMs = 400;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const finishedVisible = isLastAssistantTurnFinished();
|
|
410
|
+
const deadline = Date.now() + settleWindowMs;
|
|
411
|
+
let latest = snapshot;
|
|
412
|
+
let lastLength = snapshot?.text?.length ?? 0;
|
|
413
|
+
while (Date.now() < deadline) {
|
|
414
|
+
await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
|
|
415
|
+
const refreshed = extractFromTurns();
|
|
416
|
+
if (refreshed && !isAnswerNowPlaceholder(refreshed) && (refreshed.text?.length ?? 0) >= lastLength) {
|
|
417
|
+
latest = refreshed;
|
|
418
|
+
lastLength = refreshed.text?.length ?? lastLength;
|
|
419
|
+
}
|
|
420
|
+
const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
|
|
421
|
+
const finishedVisible = isLastAssistantTurnFinished();
|
|
403
422
|
|
|
404
|
-
|
|
405
|
-
|
|
423
|
+
if (!stopVisible || finishedVisible) {
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
406
426
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
};
|
|
427
|
+
return latest ?? snapshot;
|
|
428
|
+
};
|
|
410
429
|
|
|
411
|
-
const
|
|
430
|
+
const extractedRaw = extractFromTurns();
|
|
431
|
+
const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
|
|
412
432
|
if (extracted) {
|
|
413
433
|
return waitForSettle(extracted);
|
|
414
434
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from '../constants.js';
|
|
3
3
|
import { delay } from '../utils.js';
|
|
4
4
|
import { logDomFailure } from '../domDebug.js';
|
|
5
5
|
export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
@@ -22,17 +22,11 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
22
22
|
),
|
|
23
23
|
);
|
|
24
24
|
if (chips) return true;
|
|
25
|
-
const cardTexts = Array.from(document.querySelectorAll('[aria-label
|
|
25
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]')).map((btn) =>
|
|
26
26
|
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
27
27
|
);
|
|
28
28
|
if (cardTexts.some((text) => text.includes(expected))) return true;
|
|
29
29
|
|
|
30
|
-
const filesPill = Array.from(document.querySelectorAll('button,div')).some((node) => {
|
|
31
|
-
const text = (node?.textContent || '').toLowerCase();
|
|
32
|
-
return /\bfiles\b/.test(text) && text.includes('file');
|
|
33
|
-
});
|
|
34
|
-
if (filesPill) return true;
|
|
35
|
-
|
|
36
30
|
const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
37
31
|
Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
|
|
38
32
|
);
|
|
@@ -85,10 +79,21 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
85
79
|
logger(`Attachment already present: ${path.basename(attachment.path)}`);
|
|
86
80
|
return;
|
|
87
81
|
}
|
|
88
|
-
|
|
89
|
-
const
|
|
82
|
+
const documentNode = await dom.getDocument();
|
|
83
|
+
const candidateSetup = await runtime.evaluate({
|
|
90
84
|
expression: `(() => {
|
|
91
|
-
const
|
|
85
|
+
const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
86
|
+
const locateComposerRoot = () => {
|
|
87
|
+
for (const selector of promptSelectors) {
|
|
88
|
+
const node = document.querySelector(selector);
|
|
89
|
+
if (!node) continue;
|
|
90
|
+
return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
|
|
91
|
+
}
|
|
92
|
+
return document.querySelector('form') ?? document.body;
|
|
93
|
+
};
|
|
94
|
+
const root = locateComposerRoot();
|
|
95
|
+
const localInputs = root ? Array.from(root.querySelectorAll('input[type="file"]')) : [];
|
|
96
|
+
const inputs = localInputs.length > 0 ? localInputs : Array.from(document.querySelectorAll('input[type="file"]'));
|
|
92
97
|
const acceptIsImageOnly = (accept) => {
|
|
93
98
|
if (!accept) return false;
|
|
94
99
|
const parts = String(accept)
|
|
@@ -97,73 +102,142 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
97
102
|
.filter(Boolean);
|
|
98
103
|
return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
|
|
99
104
|
};
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
const chipContainer = root ?? document;
|
|
106
|
+
const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
|
|
107
|
+
const baselineChipCount = chipContainer.querySelectorAll(chipSelector).length;
|
|
108
|
+
|
|
109
|
+
// Mark candidates with stable indices so we can select them via DOM.querySelector.
|
|
110
|
+
let idx = 0;
|
|
111
|
+
const candidates = inputs.map((el) => {
|
|
112
|
+
const accept = el.getAttribute('accept') || '';
|
|
113
|
+
const score = (el.hasAttribute('multiple') ? 100 : 0) + (!acceptIsImageOnly(accept) ? 10 : 0);
|
|
114
|
+
el.setAttribute('data-oracle-upload-candidate', 'true');
|
|
115
|
+
el.setAttribute('data-oracle-upload-idx', String(idx));
|
|
116
|
+
return { idx: idx++, score };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Prefer higher scores first.
|
|
120
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
121
|
+
return { ok: candidates.length > 0, baselineChipCount, order: candidates.map((c) => c.idx) };
|
|
107
122
|
})()`,
|
|
108
123
|
returnByValue: true,
|
|
109
124
|
});
|
|
110
|
-
const
|
|
111
|
-
|
|
125
|
+
const candidateValue = candidateSetup?.result?.value;
|
|
126
|
+
const candidateOrder = Array.isArray(candidateValue?.order) ? candidateValue.order : [];
|
|
127
|
+
const baselineChipCount = typeof candidateValue?.baselineChipCount === 'number' ? candidateValue.baselineChipCount : 0;
|
|
128
|
+
if (!candidateValue?.ok || candidateOrder.length === 0) {
|
|
112
129
|
await logDomFailure(runtime, logger, 'file-input-missing');
|
|
113
130
|
throw new Error('Unable to locate ChatGPT file attachment input.');
|
|
114
131
|
}
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
const dispatchEventsFor = (idx) => `(() => {
|
|
133
|
+
const el = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
|
|
134
|
+
if (el instanceof HTMLInputElement) {
|
|
135
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
136
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
120
137
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
.join('\\n');
|
|
133
|
-
const tryFileInput = async () => {
|
|
134
|
-
await dom.setFileInputFiles({ nodeId: resolvedNodeId, files: [attachment.path] });
|
|
135
|
-
await runtime.evaluate({ expression: `(function(){${dispatchEvents} return true;})()`, returnByValue: true });
|
|
138
|
+
return true;
|
|
139
|
+
})()`;
|
|
140
|
+
const composerSnapshotFor = (idx) => `(() => {
|
|
141
|
+
const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
142
|
+
const locateComposerRoot = () => {
|
|
143
|
+
for (const selector of promptSelectors) {
|
|
144
|
+
const node = document.querySelector(selector);
|
|
145
|
+
if (!node) continue;
|
|
146
|
+
return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
|
|
147
|
+
}
|
|
148
|
+
return document.querySelector('form') ?? document.body;
|
|
136
149
|
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const chips = Array.from(
|
|
141
|
-
.
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
const root = locateComposerRoot();
|
|
151
|
+
const chipContainer = root ?? document;
|
|
152
|
+
const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
|
|
153
|
+
const chips = Array.from(chipContainer.querySelectorAll(chipSelector))
|
|
154
|
+
.slice(0, 20)
|
|
155
|
+
.map((node) => ({
|
|
156
|
+
text: (node.textContent || '').trim(),
|
|
157
|
+
aria: node.getAttribute?.('aria-label') ?? '',
|
|
158
|
+
title: node.getAttribute?.('title') ?? '',
|
|
159
|
+
testid: node.getAttribute?.('data-testid') ?? '',
|
|
160
|
+
}));
|
|
161
|
+
const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
|
|
162
|
+
const inputNames =
|
|
163
|
+
input instanceof HTMLInputElement
|
|
164
|
+
? Array.from(input.files || []).map((f) => f?.name ?? '').filter(Boolean)
|
|
165
|
+
: [];
|
|
166
|
+
const composerText = (chipContainer.innerText || '').toLowerCase();
|
|
167
|
+
return { chipCount: chipContainer.querySelectorAll(chipSelector).length, chips, inputNames, composerText };
|
|
147
168
|
})()`;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
let finalSnapshot = null;
|
|
170
|
+
for (const idx of candidateOrder) {
|
|
171
|
+
const resultNode = await dom.querySelector({
|
|
172
|
+
nodeId: documentNode.root.nodeId,
|
|
173
|
+
selector: `input[type="file"][data-oracle-upload-idx="${idx}"]`,
|
|
174
|
+
});
|
|
175
|
+
if (!resultNode?.nodeId) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
|
|
179
|
+
await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
|
|
180
|
+
const probeDeadline = Date.now() + 4000;
|
|
181
|
+
let lastPoke = 0;
|
|
182
|
+
while (Date.now() < probeDeadline) {
|
|
183
|
+
// ChatGPT's composer can take a moment to hydrate the file-input onChange handler after navigation/model switches.
|
|
184
|
+
// If the UI hasn't reacted yet, poke the input a few times to ensure the handler fires once it's mounted.
|
|
185
|
+
if (Date.now() - lastPoke > 650) {
|
|
186
|
+
lastPoke = Date.now();
|
|
187
|
+
await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
|
|
188
|
+
}
|
|
189
|
+
const snapshot = await runtime
|
|
190
|
+
.evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
|
|
191
|
+
.then((res) => res?.result?.value)
|
|
192
|
+
.catch(() => undefined);
|
|
193
|
+
if (snapshot) {
|
|
194
|
+
finalSnapshot = {
|
|
195
|
+
chipCount: Number(snapshot.chipCount ?? 0),
|
|
196
|
+
chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
|
|
197
|
+
inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
|
|
198
|
+
composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
|
|
199
|
+
};
|
|
200
|
+
const inputHasFile = finalSnapshot.inputNames.some((name) => name.toLowerCase().includes(expectedName.toLowerCase()));
|
|
201
|
+
const expectedLower = expectedName.toLowerCase();
|
|
202
|
+
const expectedNoExt = expectedLower.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
203
|
+
const uiAcknowledged = finalSnapshot.chipCount > baselineChipCount ||
|
|
204
|
+
(expectedNoExt.length >= 6
|
|
205
|
+
? finalSnapshot.composerText.includes(expectedNoExt)
|
|
206
|
+
: finalSnapshot.composerText.includes(expectedLower));
|
|
207
|
+
if (inputHasFile && uiAcknowledged) {
|
|
208
|
+
logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(finalSnapshot.chips)} input=${JSON.stringify(finalSnapshot.inputNames)}`);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await delay(200);
|
|
213
|
+
}
|
|
214
|
+
const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
|
|
215
|
+
const uiAcknowledged = (finalSnapshot?.chipCount ?? 0) > baselineChipCount;
|
|
216
|
+
if (inputHasFile && uiAcknowledged) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
154
219
|
}
|
|
155
|
-
const inputHasFile =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
220
|
+
const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
|
|
221
|
+
const attachmentUiTimeoutMs = 25_000;
|
|
222
|
+
if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
|
|
223
|
+
await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
|
|
224
|
+
logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
|
|
159
225
|
return;
|
|
160
226
|
}
|
|
227
|
+
// If ChatGPT never reflects an attachment UI chip/remove control, the file input may be the wrong target:
|
|
228
|
+
// sending in this state often drops the attachment silently.
|
|
229
|
+
if (inputHasFile) {
|
|
230
|
+
await logDomFailure(runtime, logger, 'file-upload-missing');
|
|
231
|
+
throw new Error('Attachment input accepted the file but ChatGPT did not acknowledge it in the composer UI.');
|
|
232
|
+
}
|
|
161
233
|
await logDomFailure(runtime, logger, 'file-upload-missing');
|
|
162
234
|
throw new Error('Attachment did not register with the ChatGPT composer in time.');
|
|
163
235
|
}
|
|
164
236
|
export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
|
|
165
237
|
const deadline = Date.now() + timeoutMs;
|
|
166
238
|
const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
|
|
239
|
+
let inputMatchSince = null;
|
|
240
|
+
let attachmentMatchSince = null;
|
|
167
241
|
const expression = `(() => {
|
|
168
242
|
const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
169
243
|
let button = null;
|
|
@@ -185,8 +259,9 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
185
259
|
if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
|
|
186
260
|
return true;
|
|
187
261
|
}
|
|
262
|
+
// Avoid false positives from user prompts ("upload:") or generic UI copy; only treat explicit progress strings as uploading.
|
|
188
263
|
const text = node.textContent?.toLowerCase?.() ?? '';
|
|
189
|
-
return text
|
|
264
|
+
return /\buploading\b/.test(text) || /\bprocessing\b/.test(text);
|
|
190
265
|
});
|
|
191
266
|
});
|
|
192
267
|
const attachmentSelectors = ['[data-testid*="chip"]', '[data-testid*="attachment"]', '[data-testid*="upload"]'];
|
|
@@ -197,26 +272,31 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
197
272
|
if (text) attachedNames.push(text);
|
|
198
273
|
}
|
|
199
274
|
}
|
|
275
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
|
|
276
|
+
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
277
|
+
);
|
|
278
|
+
attachedNames.push(...cardTexts.filter(Boolean));
|
|
279
|
+
|
|
280
|
+
const inputNames = [];
|
|
200
281
|
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
|
|
201
282
|
if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
|
|
202
283
|
for (const file of Array.from(input.files)) {
|
|
203
|
-
if (file?.name)
|
|
284
|
+
if (file?.name) inputNames.push(file.name.toLowerCase());
|
|
204
285
|
}
|
|
205
286
|
}
|
|
206
|
-
const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
|
|
207
|
-
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
208
|
-
);
|
|
209
|
-
attachedNames.push(...cardTexts.filter(Boolean));
|
|
210
287
|
const filesAttached = attachedNames.length > 0;
|
|
211
|
-
return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames };
|
|
288
|
+
return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames, inputNames };
|
|
212
289
|
})()`;
|
|
213
290
|
while (Date.now() < deadline) {
|
|
214
291
|
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
215
292
|
const value = result?.value;
|
|
216
|
-
if (value
|
|
293
|
+
if (value) {
|
|
217
294
|
const attachedNames = (value.attachedNames ?? [])
|
|
218
295
|
.map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
|
|
219
296
|
.filter(Boolean);
|
|
297
|
+
const inputNames = (value.inputNames ?? [])
|
|
298
|
+
.map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
|
|
299
|
+
.filter(Boolean);
|
|
220
300
|
const matchesExpected = (expected) => {
|
|
221
301
|
const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
|
|
222
302
|
const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
@@ -242,18 +322,50 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
242
322
|
};
|
|
243
323
|
const missing = expectedNormalized.filter((expected) => !matchesExpected(expected));
|
|
244
324
|
if (missing.length === 0) {
|
|
325
|
+
const stableThresholdMs = value.uploading ? 3000 : 1500;
|
|
245
326
|
if (value.state === 'ready') {
|
|
246
|
-
|
|
327
|
+
if (attachmentMatchSince === null) {
|
|
328
|
+
attachmentMatchSince = Date.now();
|
|
329
|
+
}
|
|
330
|
+
if (Date.now() - attachmentMatchSince > stableThresholdMs) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
attachmentMatchSince = null;
|
|
247
336
|
}
|
|
248
337
|
if (value.state === 'missing' && value.filesAttached) {
|
|
249
338
|
return;
|
|
250
339
|
}
|
|
251
|
-
// If files are attached but button isn't ready yet, give it more time but don't fail immediately
|
|
340
|
+
// If files are attached but button isn't ready yet, give it more time but don't fail immediately.
|
|
252
341
|
if (value.filesAttached) {
|
|
253
342
|
await delay(500);
|
|
254
343
|
continue;
|
|
255
344
|
}
|
|
256
345
|
}
|
|
346
|
+
else {
|
|
347
|
+
attachmentMatchSince = null;
|
|
348
|
+
}
|
|
349
|
+
// Fallback: if the file input has the expected names, allow progress once that condition is stable.
|
|
350
|
+
// Some ChatGPT surfaces only render the filename after sending the message.
|
|
351
|
+
const inputMissing = expectedNormalized.filter((expected) => {
|
|
352
|
+
const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
|
|
353
|
+
const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
354
|
+
const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
355
|
+
return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
|
|
356
|
+
});
|
|
357
|
+
if (inputMissing.length === 0 && (value.state === 'ready' || value.state === 'missing')) {
|
|
358
|
+
const stableThresholdMs = value.uploading ? 3000 : 1500;
|
|
359
|
+
if (inputMatchSince === null) {
|
|
360
|
+
inputMatchSince = Date.now();
|
|
361
|
+
}
|
|
362
|
+
if (Date.now() - inputMatchSince > stableThresholdMs) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
inputMatchSince = null;
|
|
368
|
+
}
|
|
257
369
|
}
|
|
258
370
|
await delay(250);
|
|
259
371
|
}
|
|
@@ -261,6 +373,58 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
261
373
|
await logDomFailure(Runtime, logger ?? (() => { }), 'file-upload-timeout');
|
|
262
374
|
throw new Error('Attachments did not finish uploading before timeout.');
|
|
263
375
|
}
|
|
376
|
+
export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger) {
|
|
377
|
+
if (!expectedNames || expectedNames.length === 0) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
|
|
381
|
+
const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
382
|
+
const expression = `(() => {
|
|
383
|
+
const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
|
|
384
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
385
|
+
const userTurns = turns.filter((node) => {
|
|
386
|
+
const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
387
|
+
if (attr === 'user') return true;
|
|
388
|
+
return Boolean(node.querySelector('[data-message-author-role="user"]'));
|
|
389
|
+
});
|
|
390
|
+
const lastUser = userTurns[userTurns.length - 1];
|
|
391
|
+
if (!lastUser) return { ok: false };
|
|
392
|
+
const text = (lastUser.innerText || '').toLowerCase();
|
|
393
|
+
const attrs = Array.from(lastUser.querySelectorAll('[aria-label],[title]')).map((el) => {
|
|
394
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
395
|
+
const title = el.getAttribute('title') || '';
|
|
396
|
+
return (aria + ' ' + title).trim().toLowerCase();
|
|
397
|
+
}).filter(Boolean);
|
|
398
|
+
return { ok: true, text, attrs };
|
|
399
|
+
})()`;
|
|
400
|
+
const deadline = Date.now() + timeoutMs;
|
|
401
|
+
while (Date.now() < deadline) {
|
|
402
|
+
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
403
|
+
const value = result?.value;
|
|
404
|
+
if (!value?.ok) {
|
|
405
|
+
await delay(200);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
|
|
409
|
+
const missing = expectedNormalized.filter((expected) => {
|
|
410
|
+
const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
|
|
411
|
+
const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
412
|
+
const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
413
|
+
if (haystack.includes(normalizedExpected))
|
|
414
|
+
return false;
|
|
415
|
+
if (expectedNoExt.length >= 6 && haystack.includes(expectedNoExt))
|
|
416
|
+
return false;
|
|
417
|
+
return true;
|
|
418
|
+
});
|
|
419
|
+
if (missing.length === 0) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
await delay(250);
|
|
423
|
+
}
|
|
424
|
+
logger?.('Sent user message did not show expected attachment names in time.');
|
|
425
|
+
await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-missing-user-turn');
|
|
426
|
+
throw new Error('Attachment was not present on the sent user message.');
|
|
427
|
+
}
|
|
264
428
|
export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
|
|
265
429
|
// Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
|
|
266
430
|
// so respect the caller-provided timeout instead of capping at 2s.
|
|
@@ -268,6 +432,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
268
432
|
const expression = `(() => {
|
|
269
433
|
const expected = ${JSON.stringify(expectedName)};
|
|
270
434
|
const normalized = expected.toLowerCase();
|
|
435
|
+
const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
|
|
271
436
|
const matchNode = (node) => {
|
|
272
437
|
if (!node) return false;
|
|
273
438
|
const text = (node.textContent || '').toLowerCase();
|
|
@@ -275,67 +440,26 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
275
440
|
const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
|
|
276
441
|
const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
|
|
277
442
|
const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
|
|
278
|
-
|
|
443
|
+
const candidates = [text, aria, title, testId, alt].filter(Boolean);
|
|
444
|
+
return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
|
|
279
445
|
};
|
|
280
446
|
|
|
281
|
-
const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
|
|
282
|
-
const userTurns = turns.filter((node) => {
|
|
283
|
-
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
284
|
-
if (turnAttr === 'user') return true;
|
|
285
|
-
return Boolean(node.querySelector('[data-message-author-role="user"]'));
|
|
286
|
-
});
|
|
287
|
-
const lastUser = userTurns[userTurns.length - 1];
|
|
288
|
-
if (lastUser) {
|
|
289
|
-
const turnMatch = Array.from(lastUser.querySelectorAll('*')).some(matchNode);
|
|
290
|
-
if (turnMatch) return { found: true, userTurns: userTurns.length, source: 'turn' };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const composerSelectors = [
|
|
294
|
-
'[data-testid*="composer"]',
|
|
295
|
-
'form textarea',
|
|
296
|
-
'form [data-testid*="attachment"]',
|
|
297
|
-
'[data-testid*="upload"]',
|
|
298
|
-
'[data-testid*="chip"]',
|
|
299
|
-
'form',
|
|
300
|
-
'button',
|
|
301
|
-
'label'
|
|
302
|
-
];
|
|
303
|
-
const composerMatch = composerSelectors.some((selector) =>
|
|
304
|
-
Array.from(document.querySelectorAll(selector)).some(matchNode),
|
|
305
|
-
);
|
|
306
|
-
if (composerMatch) {
|
|
307
|
-
return { found: true, userTurns: userTurns.length, source: 'composer' };
|
|
308
|
-
}
|
|
309
|
-
|
|
310
447
|
const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
|
|
311
448
|
const attachmentMatch = attachmentSelectors.some((selector) =>
|
|
312
449
|
Array.from(document.querySelectorAll(selector)).some(matchNode),
|
|
313
450
|
);
|
|
314
451
|
if (attachmentMatch) {
|
|
315
|
-
return { found: true,
|
|
452
|
+
return { found: true, source: 'attachments' };
|
|
316
453
|
}
|
|
317
454
|
|
|
318
|
-
const cardTexts = Array.from(document.querySelectorAll('[aria-label
|
|
455
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
|
|
319
456
|
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
320
457
|
);
|
|
321
|
-
if (cardTexts.some((text) => text.includes(normalized))) {
|
|
322
|
-
return { found: true,
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const attrMatch = Array.from(document.querySelectorAll('[aria-label], [title], [data-testid]')).some(matchNode);
|
|
326
|
-
if (attrMatch) {
|
|
327
|
-
return { found: true, userTurns: userTurns.length, source: 'attrs' };
|
|
458
|
+
if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
|
|
459
|
+
return { found: true, source: 'attachment-cards' };
|
|
328
460
|
}
|
|
329
461
|
|
|
330
|
-
|
|
331
|
-
if (bodyMatch) {
|
|
332
|
-
return { found: true, userTurns: userTurns.length, source: 'body' };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
336
|
-
Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
|
|
337
|
-
);
|
|
338
|
-
return { found: inputHit, userTurns: userTurns.length, source: inputHit ? 'input' : undefined };
|
|
462
|
+
return { found: false };
|
|
339
463
|
})()`;
|
|
340
464
|
while (Date.now() < deadline) {
|
|
341
465
|
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
@@ -353,28 +477,47 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
|
|
|
353
477
|
const deadline = Date.now() + timeoutMs;
|
|
354
478
|
const expression = `(() => {
|
|
355
479
|
const normalized = ${JSON.stringify(expectedName.toLowerCase())};
|
|
356
|
-
const
|
|
480
|
+
const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
|
|
481
|
+
const matchesExpected = (value) => {
|
|
482
|
+
const text = (value ?? '').toLowerCase();
|
|
483
|
+
if (!text) return false;
|
|
484
|
+
if (text.includes(normalized)) return true;
|
|
485
|
+
if (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)) return true;
|
|
486
|
+
if (text.includes('…') || text.includes('...')) {
|
|
487
|
+
const escaped = text.replace(/[.*+?^$\\{\\}()|[\\]\\\\]/g, '\\\\$&');
|
|
488
|
+
const pattern = escaped.replaceAll('…', '.*').replaceAll('...', '.*');
|
|
489
|
+
try {
|
|
490
|
+
const re = new RegExp(pattern);
|
|
491
|
+
return re.test(normalized) || (normalizedNoExt.length >= 6 && re.test(normalizedNoExt));
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const selectors = [
|
|
500
|
+
'[data-testid*="attachment"]',
|
|
501
|
+
'[data-testid*="chip"]',
|
|
502
|
+
'[data-testid*="upload"]',
|
|
503
|
+
'[aria-label*="Remove"]',
|
|
504
|
+
'button[aria-label*="Remove"]',
|
|
505
|
+
];
|
|
357
506
|
for (const selector of selectors) {
|
|
358
507
|
for (const node of Array.from(document.querySelectorAll(selector))) {
|
|
359
|
-
const text =
|
|
360
|
-
|
|
361
|
-
|
|
508
|
+
const text = node?.textContent || '';
|
|
509
|
+
const aria = node?.getAttribute?.('aria-label') || '';
|
|
510
|
+
const title = node?.getAttribute?.('title') || '';
|
|
511
|
+
if ([text, aria, title].some(matchesExpected)) {
|
|
512
|
+
return { found: true, text: (text || aria || title).toLowerCase() };
|
|
362
513
|
}
|
|
363
514
|
}
|
|
364
515
|
}
|
|
365
|
-
const cards = Array.from(document.querySelectorAll('[aria-label
|
|
516
|
+
const cards = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
|
|
366
517
|
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
367
518
|
);
|
|
368
|
-
if (cards.some(
|
|
369
|
-
return { found: true, text: cards.find(
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// As a last resort, treat file inputs that hold the target name as anchored. Some UIs delay chip rendering.
|
|
373
|
-
const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
374
|
-
Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
|
|
375
|
-
);
|
|
376
|
-
if (inputHit) {
|
|
377
|
-
return { found: true, text: 'input-only' };
|
|
519
|
+
if (cards.some(matchesExpected)) {
|
|
520
|
+
return { found: true, text: cards.find(matchesExpected) };
|
|
378
521
|
}
|
|
379
522
|
return { found: false };
|
|
380
523
|
})()`;
|
|
@@ -18,7 +18,13 @@ export async function ensureModelSelection(Runtime, desiredModel, logger) {
|
|
|
18
18
|
}
|
|
19
19
|
case 'option-not-found': {
|
|
20
20
|
await logDomFailure(Runtime, logger, 'model-switcher-option');
|
|
21
|
-
|
|
21
|
+
const isTemporary = result.hint?.temporaryChat ?? false;
|
|
22
|
+
const available = (result.hint?.availableOptions ?? []).filter(Boolean);
|
|
23
|
+
const availableHint = available.length > 0 ? ` Available: ${available.join(', ')}.` : '';
|
|
24
|
+
const tempHint = isTemporary && /\bpro\b/i.test(desiredModel)
|
|
25
|
+
? ' You are in Temporary Chat mode; Pro models are not available there. Remove "temporary-chat=true" from --chatgpt-url or use a non-Pro model (e.g. gpt-5.2).'
|
|
26
|
+
: '';
|
|
27
|
+
throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.${availableHint}${tempHint}`);
|
|
22
28
|
}
|
|
23
29
|
default: {
|
|
24
30
|
await logDomFailure(Runtime, logger, 'model-switcher-button');
|
|
@@ -237,6 +243,28 @@ function buildModelSelectionExpression(targetModel) {
|
|
|
237
243
|
|
|
238
244
|
return new Promise((resolve) => {
|
|
239
245
|
const start = performance.now();
|
|
246
|
+
const detectTemporaryChat = () => {
|
|
247
|
+
try {
|
|
248
|
+
const url = new URL(window.location.href);
|
|
249
|
+
const flag = (url.searchParams.get('temporary-chat') ?? '').toLowerCase();
|
|
250
|
+
if (flag === 'true' || flag === '1' || flag === 'yes') return true;
|
|
251
|
+
} catch {}
|
|
252
|
+
const title = (document.title || '').toLowerCase();
|
|
253
|
+
if (title.includes('temporary chat')) return true;
|
|
254
|
+
const body = (document.body?.innerText || '').toLowerCase();
|
|
255
|
+
return body.includes('temporary chat');
|
|
256
|
+
};
|
|
257
|
+
const collectAvailableOptions = () => {
|
|
258
|
+
const menuRoots = Array.from(document.querySelectorAll(${menuContainerLiteral}));
|
|
259
|
+
const nodes = menuRoots.length > 0
|
|
260
|
+
? menuRoots.flatMap((root) => Array.from(root.querySelectorAll(${menuItemLiteral})))
|
|
261
|
+
: Array.from(document.querySelectorAll(${menuItemLiteral}));
|
|
262
|
+
const labels = nodes
|
|
263
|
+
.map((node) => (node?.textContent ?? '').trim())
|
|
264
|
+
.filter(Boolean)
|
|
265
|
+
.filter((label, index, arr) => arr.indexOf(label) === index);
|
|
266
|
+
return labels.slice(0, 12);
|
|
267
|
+
};
|
|
240
268
|
const ensureMenuOpen = () => {
|
|
241
269
|
const menuOpen = document.querySelector('[role="menu"], [data-radix-collection-root]');
|
|
242
270
|
if (!menuOpen && performance.now() - lastPointerClick > REOPEN_INTERVAL_MS) {
|
|
@@ -279,7 +307,10 @@ function buildModelSelectionExpression(targetModel) {
|
|
|
279
307
|
return;
|
|
280
308
|
}
|
|
281
309
|
if (performance.now() - start > MAX_WAIT_MS) {
|
|
282
|
-
resolve({
|
|
310
|
+
resolve({
|
|
311
|
+
status: 'option-not-found',
|
|
312
|
+
hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
|
|
313
|
+
});
|
|
283
314
|
return;
|
|
284
315
|
}
|
|
285
316
|
setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
|
|
@@ -135,7 +135,6 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
135
135
|
logger('Clicked send button');
|
|
136
136
|
}
|
|
137
137
|
await verifyPromptCommitted(runtime, prompt, 30_000, logger);
|
|
138
|
-
await clickAnswerNowIfPresent(runtime, logger);
|
|
139
138
|
}
|
|
140
139
|
export async function clearPromptComposer(Runtime, logger) {
|
|
141
140
|
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
@@ -186,6 +185,43 @@ async function waitForDomReady(Runtime, logger) {
|
|
|
186
185
|
}
|
|
187
186
|
logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
|
|
188
187
|
}
|
|
188
|
+
function buildAttachmentReadyExpression(attachmentNames) {
|
|
189
|
+
const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
|
|
190
|
+
return `(() => {
|
|
191
|
+
const names = ${namesLiteral};
|
|
192
|
+
const composer =
|
|
193
|
+
document.querySelector('[data-testid*="composer"]') ||
|
|
194
|
+
document.querySelector('form') ||
|
|
195
|
+
document.body ||
|
|
196
|
+
document;
|
|
197
|
+
const match = (node, name) => (node?.textContent || '').toLowerCase().includes(name);
|
|
198
|
+
|
|
199
|
+
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
200
|
+
const attachmentSelectors = [
|
|
201
|
+
'[data-testid*="chip"]',
|
|
202
|
+
'[data-testid*="attachment"]',
|
|
203
|
+
'[data-testid*="upload"]',
|
|
204
|
+
'[aria-label="Remove file"]',
|
|
205
|
+
'button[aria-label="Remove file"]',
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const chipsReady = names.every((name) =>
|
|
209
|
+
Array.from(composer.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
|
|
210
|
+
);
|
|
211
|
+
const inputsReady = names.every((name) =>
|
|
212
|
+
Array.from(composer.querySelectorAll('input[type="file"]')).some((el) =>
|
|
213
|
+
Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
|
|
214
|
+
file?.name?.toLowerCase?.().includes(name),
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return chipsReady || inputsReady;
|
|
220
|
+
})()`;
|
|
221
|
+
}
|
|
222
|
+
export function buildAttachmentReadyExpressionForTest(attachmentNames) {
|
|
223
|
+
return buildAttachmentReadyExpression(attachmentNames);
|
|
224
|
+
}
|
|
189
225
|
async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
190
226
|
const script = `(() => {
|
|
191
227
|
${buildClickDispatcher()}
|
|
@@ -215,19 +251,7 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
215
251
|
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
216
252
|
if (needAttachment) {
|
|
217
253
|
const ready = await Runtime.evaluate({
|
|
218
|
-
expression:
|
|
219
|
-
const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
|
|
220
|
-
const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
|
|
221
|
-
const chipsReady = names.every((name) =>
|
|
222
|
-
Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
|
|
223
|
-
);
|
|
224
|
-
const inputsReady = names.every((name) =>
|
|
225
|
-
Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
226
|
-
Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
|
|
227
|
-
),
|
|
228
|
-
);
|
|
229
|
-
return chipsReady || inputsReady;
|
|
230
|
-
})()`,
|
|
254
|
+
expression: buildAttachmentReadyExpression(attachmentNames),
|
|
231
255
|
returnByValue: true,
|
|
232
256
|
});
|
|
233
257
|
if (!ready?.result?.value) {
|
|
@@ -246,37 +270,6 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
246
270
|
}
|
|
247
271
|
return false;
|
|
248
272
|
}
|
|
249
|
-
async function clickAnswerNowIfPresent(Runtime, logger) {
|
|
250
|
-
const script = `(() => {
|
|
251
|
-
${buildClickDispatcher()}
|
|
252
|
-
const matchesText = (el) => (el?.textContent || '').trim().toLowerCase() === 'answer now';
|
|
253
|
-
const candidate = Array.from(document.querySelectorAll('button,span')).find(matchesText);
|
|
254
|
-
if (!candidate) return 'missing';
|
|
255
|
-
const button = candidate.closest('button') ?? candidate;
|
|
256
|
-
const style = window.getComputedStyle(button);
|
|
257
|
-
const disabled =
|
|
258
|
-
button.hasAttribute('disabled') ||
|
|
259
|
-
button.getAttribute('aria-disabled') === 'true' ||
|
|
260
|
-
style.pointerEvents === 'none' ||
|
|
261
|
-
style.display === 'none';
|
|
262
|
-
if (disabled) return 'disabled';
|
|
263
|
-
dispatchClickSequence(button);
|
|
264
|
-
return 'clicked';
|
|
265
|
-
})()`;
|
|
266
|
-
const deadline = Date.now() + 3_000;
|
|
267
|
-
while (Date.now() < deadline) {
|
|
268
|
-
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
269
|
-
const status = result.value;
|
|
270
|
-
if (status === 'clicked') {
|
|
271
|
-
logger?.('Clicked "Answer now" gate');
|
|
272
|
-
await delay(500);
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (status === 'missing')
|
|
276
|
-
return;
|
|
277
|
-
await delay(100);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
273
|
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
|
|
281
274
|
const deadline = Date.now() + timeoutMs;
|
|
282
275
|
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
2
|
-
import { normalizeChatgptUrl } from './utils.js';
|
|
2
|
+
import { isTemporaryChatUrl, normalizeChatgptUrl } from './utils.js';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
export const DEFAULT_BROWSER_CONFIG = {
|
|
@@ -32,6 +32,11 @@ export function resolveBrowserConfig(config) {
|
|
|
32
32
|
(process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? '').trim() === '1';
|
|
33
33
|
const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
|
|
34
34
|
const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
|
|
35
|
+
const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
|
|
36
|
+
if (isTemporaryChatUrl(normalizedUrl) && /\bpro\b/i.test(desiredModel)) {
|
|
37
|
+
throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
|
|
38
|
+
'Remove "temporary-chat=true" from your browser URL, or use a non-Pro model label (e.g. "GPT-5.2").');
|
|
39
|
+
}
|
|
35
40
|
const isWindows = process.platform === 'win32';
|
|
36
41
|
const manualLogin = config?.manualLogin ?? (isWindows ? true : DEFAULT_BROWSER_CONFIG.manualLogin);
|
|
37
42
|
const cookieSyncDefault = isWindows ? false : DEFAULT_BROWSER_CONFIG.cookieSync;
|
|
@@ -53,7 +58,7 @@ export function resolveBrowserConfig(config) {
|
|
|
53
58
|
headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
|
|
54
59
|
keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
|
|
55
60
|
hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
|
|
56
|
-
desiredModel
|
|
61
|
+
desiredModel,
|
|
57
62
|
chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
|
|
58
63
|
chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
|
|
59
64
|
chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
|
|
@@ -5,7 +5,7 @@ import net from 'node:net';
|
|
|
5
5
|
import { resolveBrowserConfig } from './config.js';
|
|
6
6
|
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
|
|
7
7
|
import { syncCookies } from './cookies.js';
|
|
8
|
-
import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
|
|
8
|
+
import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
9
9
|
import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
|
|
10
10
|
import { ensureExtendedThinking } from './actions/thinkingTime.js';
|
|
11
11
|
import { estimateTokenCount, withRetries, delay } from './utils.js';
|
|
@@ -14,7 +14,7 @@ import { CHATGPT_URL } from './constants.js';
|
|
|
14
14
|
import { BrowserAutomationError } from '../oracle/errors.js';
|
|
15
15
|
import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
|
|
16
16
|
export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
17
|
-
export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
|
|
17
|
+
export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
|
|
18
18
|
export async function runBrowserMode(options) {
|
|
19
19
|
const promptText = options.prompt?.trim();
|
|
20
20
|
if (!promptText) {
|
|
@@ -293,6 +293,10 @@ export async function runBrowserMode(options) {
|
|
|
293
293
|
logger('All attachments uploaded');
|
|
294
294
|
}
|
|
295
295
|
await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
|
|
296
|
+
if (attachmentNames.length > 0) {
|
|
297
|
+
await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
|
|
298
|
+
logger('Verified attachments present on sent user message');
|
|
299
|
+
}
|
|
296
300
|
};
|
|
297
301
|
try {
|
|
298
302
|
await raceWithDisconnect(submitOnce(promptText, attachments));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
|
|
2
2
|
export { ensureModelSelection } from './actions/modelSelection.js';
|
|
3
3
|
export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
|
|
4
|
-
export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
|
|
4
|
+
export { uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments } from './actions/attachments.js';
|
|
5
5
|
export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
|
|
@@ -110,3 +110,13 @@ export function normalizeChatgptUrl(raw, fallback) {
|
|
|
110
110
|
// Preserve user-provided path/query; URL#toString will normalize trailing slashes appropriately.
|
|
111
111
|
return parsed.toString();
|
|
112
112
|
}
|
|
113
|
+
export function isTemporaryChatUrl(url) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
const value = (parsed.searchParams.get('temporary-chat') ?? '').trim().toLowerCase();
|
|
117
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/src/browserMode.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, } from './browser/index.js';
|
|
1
|
+
export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { CHATGPT_URL, DEFAULT_MODEL_TARGET, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
|
|
3
|
+
import { CHATGPT_URL, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
|
|
4
4
|
import { getOracleHomeDir } from '../oracleHome.js';
|
|
5
5
|
const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
|
|
6
6
|
const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
|
|
@@ -54,6 +54,15 @@ export async function buildBrowserConfig(options) {
|
|
|
54
54
|
}
|
|
55
55
|
const rawUrl = options.chatgptUrl ?? options.browserUrl;
|
|
56
56
|
const url = rawUrl ? normalizeChatgptUrl(rawUrl, CHATGPT_URL) : undefined;
|
|
57
|
+
const desiredModel = isChatGptModel
|
|
58
|
+
? mapModelToBrowserLabel(options.model)
|
|
59
|
+
: shouldUseOverride
|
|
60
|
+
? desiredModelOverride
|
|
61
|
+
: mapModelToBrowserLabel(options.model);
|
|
62
|
+
if (url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
|
|
63
|
+
throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
|
|
64
|
+
'Remove "temporary-chat=true" from --chatgpt-url (or omit --chatgpt-url), or use a non-Pro model (e.g. --model gpt-5.2).');
|
|
65
|
+
}
|
|
57
66
|
return {
|
|
58
67
|
chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
|
|
59
68
|
chromePath: options.browserChromePath ?? null,
|
|
@@ -72,11 +81,7 @@ export async function buildBrowserConfig(options) {
|
|
|
72
81
|
keepBrowser: options.browserKeepBrowser ? true : undefined,
|
|
73
82
|
manualLogin: options.browserManualLogin ? true : undefined,
|
|
74
83
|
hideWindow: options.browserHideWindow ? true : undefined,
|
|
75
|
-
desiredModel
|
|
76
|
-
? mapModelToBrowserLabel(options.model)
|
|
77
|
-
: shouldUseOverride
|
|
78
|
-
? desiredModelOverride
|
|
79
|
-
: mapModelToBrowserLabel(options.model),
|
|
84
|
+
desiredModel,
|
|
80
85
|
debug: options.verbose ? true : undefined,
|
|
81
86
|
// Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
|
|
82
87
|
allowCookieErrors: options.browserAllowCookieErrors ?? true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steipete/oracle",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/bin/oracle-cli.js",
|