@steipete/oracle 0.7.1 → 0.7.3
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/chromeLifecycle.js +2 -0
- package/dist/src/browser/config.js +7 -2
- package/dist/src/browser/index.js +12 -2
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/reattach.js +192 -17
- package/dist/src/browser/utils.js +10 -0
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +11 -6
- package/dist/src/cli/notifier.js +8 -2
- package/dist/src/cli/oscUtils.js +1 -19
- package/dist/src/cli/sessionDisplay.js +6 -3
- package/dist/src/cli/sessionTable.js +5 -1
- package/dist/src/oracle/files.js +8 -1
- package/dist/src/oracle/modelResolver.js +11 -4
- package/dist/src/oracle/multiModelRunner.js +3 -14
- package/dist/src/oracle/oscProgress.js +12 -61
- package/dist/src/oracle/run.js +62 -34
- package/dist/src/sessionManager.js +91 -2
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
- package/package.json +43 -26
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/vendor/oracle-notifier/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +0 -0
|
@@ -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);
|