ask-pro 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +30 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/assets/ask-pro_logo.png +0 -0
- package/dist/bin/ask-pro-cli.js +507 -0
- package/dist/scripts/run-cli.js +27 -0
- package/dist/src/ask-pro/atomicWrite.js +26 -0
- package/dist/src/ask-pro/browserRunner.js +796 -0
- package/dist/src/ask-pro/responseZip.js +349 -0
- package/dist/src/ask-pro/session.js +662 -0
- package/dist/src/ask-pro/sessionControllerLease.js +64 -0
- package/dist/src/ask-pro/toon.js +26 -0
- package/dist/src/ask-pro/zip.js +85 -0
- package/dist/src/browser/actions/assistantResponse.js +1245 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +140 -0
- package/dist/src/browser/actions/attachments.js +1720 -0
- package/dist/src/browser/actions/composerSendReadiness.js +369 -0
- package/dist/src/browser/actions/domEvents.js +31 -0
- package/dist/src/browser/actions/inputGuard.js +52 -0
- package/dist/src/browser/actions/modelPickerDom.js +68 -0
- package/dist/src/browser/actions/modelSelection.js +576 -0
- package/dist/src/browser/actions/navigation.js +510 -0
- package/dist/src/browser/actions/promptComposer.js +824 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingStatus.js +408 -0
- package/dist/src/browser/actions/thinkingTime.js +635 -0
- package/dist/src/browser/actions/windowState.js +47 -0
- package/dist/src/browser/attachRunning.js +31 -0
- package/dist/src/browser/chatgptModelCatalog.js +321 -0
- package/dist/src/browser/chromeLifecycle.js +807 -0
- package/dist/src/browser/config.js +110 -0
- package/dist/src/browser/constants.js +85 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +337 -0
- package/dist/src/browser/domDebug.js +72 -0
- package/dist/src/browser/errors.js +20 -0
- package/dist/src/browser/format.js +16 -0
- package/dist/src/browser/index.js +2631 -0
- package/dist/src/browser/language.js +97 -0
- package/dist/src/browser/liveTabs.js +434 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/profilePaths.js +282 -0
- package/dist/src/browser/profileState.js +413 -0
- package/dist/src/browser/providerDomFlow.js +17 -0
- package/dist/src/browser/providers/chatgptDomProvider.js +50 -0
- package/dist/src/browser/reattach.js +534 -0
- package/dist/src/browser/reattachHelpers.js +387 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/version.js +39 -0
- package/package.json +114 -0
- package/scripts/refresh-local-plugin.mjs +179 -0
- package/scripts/refresh-local-plugin.ps1 +93 -0
- package/skills/ask-pro/SKILL.md +181 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, } from "../constants.js";
|
|
2
|
+
import { delay } from "../utils.js";
|
|
3
|
+
import { logDomFailure } from "../domDebug.js";
|
|
4
|
+
import { buildClickDispatcher } from "./domEvents.js";
|
|
5
|
+
import { AssistantStoppedError } from "../errors.js";
|
|
6
|
+
import { readComposerSnapshot, submitPrompt } from "./promptComposer.js";
|
|
7
|
+
const ASSISTANT_POLL_TIMEOUT_ERROR = "assistant-response-watchdog-timeout";
|
|
8
|
+
export function createAssistantContinuation(Runtime, Input, logger, inputGuard, state = { used: false }) {
|
|
9
|
+
return async (turnIndex) => {
|
|
10
|
+
if (state.used)
|
|
11
|
+
throw new AssistantStoppedError(turnIndex);
|
|
12
|
+
// A failed/ambiguous send must not trigger another automatic paid attempt.
|
|
13
|
+
state.used = true;
|
|
14
|
+
const composer = await readComposerSnapshot(Runtime);
|
|
15
|
+
if (composer.activeValue.trim() || (inputGuard && !(await inputGuard.disable()))) {
|
|
16
|
+
throw new AssistantStoppedError(turnIndex);
|
|
17
|
+
}
|
|
18
|
+
logger('ChatGPT stopped without an answer; sending "continue" once.');
|
|
19
|
+
try {
|
|
20
|
+
await submitPrompt({
|
|
21
|
+
runtime: Runtime,
|
|
22
|
+
input: Input,
|
|
23
|
+
baselineTurns: turnIndex + 1,
|
|
24
|
+
afterSubmit: inputGuard ? () => inputGuard.enable() : undefined,
|
|
25
|
+
}, "continue", logger);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new AssistantStoppedError(turnIndex);
|
|
29
|
+
}
|
|
30
|
+
return turnIndex + 1;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function isTransientAssistantPlaceholderText(normalized) {
|
|
34
|
+
const text = normalized.replace(/\s+/g, " ").trim();
|
|
35
|
+
if (!text)
|
|
36
|
+
return false;
|
|
37
|
+
// Learned: "Pro thinking" shows a placeholder turn that contains "Answer now".
|
|
38
|
+
// That is not the final answer and must be ignored in browser automation.
|
|
39
|
+
if (text === "chatgpt said:" || text === "chatgpt said")
|
|
40
|
+
return true;
|
|
41
|
+
if (/^(chatgpt said:\s*)?stopped thinking$/.test(text))
|
|
42
|
+
return true;
|
|
43
|
+
if (text.includes("file upload request") &&
|
|
44
|
+
(text.includes("pro thinking") || text.includes("chatgpt said"))) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (text.includes("answer now") &&
|
|
48
|
+
(text.includes("pro thinking") || text.includes("chatgpt said"))) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (/^(pro thinking|thinking|reasoning|planning|drafting|summarizing|analyzing)(\s*(\.\.\.|…))?$/.test(text)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (/^(pro thinking|thinking|reasoning|planning|drafting|summarizing|analyzing)\s+for\s+\d+/.test(text)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return /^(thought|reasoned|analyzed|planned|drafted|summarized)\s+for\s+\d+/.test(text);
|
|
58
|
+
}
|
|
59
|
+
export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId, continueResponse) {
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
while (true) {
|
|
62
|
+
try {
|
|
63
|
+
return await waitForAssistantResponseOnce(Runtime, Math.max(1, deadline - Date.now()), logger, minTurnIndex, expectedConversationId);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (!(error instanceof AssistantStoppedError))
|
|
67
|
+
throw error;
|
|
68
|
+
await Runtime.evaluate({
|
|
69
|
+
expression: "window.__askProCancelResponseObserver?.()",
|
|
70
|
+
}).catch(() => undefined);
|
|
71
|
+
if (!continueResponse || Date.now() >= deadline)
|
|
72
|
+
throw error;
|
|
73
|
+
minTurnIndex = await continueResponse(error.turnIndex);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function waitForAssistantResponseOnce(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId) {
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
logger("Waiting for ChatGPT response");
|
|
80
|
+
// Learned: two paths are needed:
|
|
81
|
+
// 1) DOM observer (fast when mutations fire),
|
|
82
|
+
// 2) snapshot poller (fallback when observers miss or JS stalls).
|
|
83
|
+
const expression = buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId);
|
|
84
|
+
const evaluationPromise = Runtime.evaluate({
|
|
85
|
+
expression,
|
|
86
|
+
awaitPromise: true,
|
|
87
|
+
returnByValue: true,
|
|
88
|
+
});
|
|
89
|
+
const raceReadyEvaluation = evaluationPromise.then((value) => ({ kind: "evaluation", value }), (error) => {
|
|
90
|
+
throw { source: "evaluation", error };
|
|
91
|
+
});
|
|
92
|
+
// Use AbortController to stop the poller when the evaluation wins the race,
|
|
93
|
+
// preventing abandoned polling loops from consuming resources.
|
|
94
|
+
const pollerAbort = new AbortController();
|
|
95
|
+
const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expectedConversationId, pollerAbort.signal).then((value) => ({ kind: "poll", value }), (error) => {
|
|
96
|
+
throw { source: "poll", error };
|
|
97
|
+
});
|
|
98
|
+
let evaluation = null;
|
|
99
|
+
try {
|
|
100
|
+
const winner = await Promise.race([raceReadyEvaluation, pollerPromise]);
|
|
101
|
+
if (winner.kind === "poll") {
|
|
102
|
+
if (!winner.value) {
|
|
103
|
+
throw { source: "poll", error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
|
|
104
|
+
}
|
|
105
|
+
logger("Captured assistant response via snapshot watchdog");
|
|
106
|
+
evaluationPromise.catch(() => undefined);
|
|
107
|
+
await terminateRuntimeExecution(Runtime);
|
|
108
|
+
return winner.value;
|
|
109
|
+
}
|
|
110
|
+
// Evaluation won - abort the poller to prevent it from running until timeout
|
|
111
|
+
pollerAbort.abort();
|
|
112
|
+
evaluation = winner.value;
|
|
113
|
+
}
|
|
114
|
+
catch (wrappedError) {
|
|
115
|
+
if (wrappedError &&
|
|
116
|
+
typeof wrappedError === "object" &&
|
|
117
|
+
"source" in wrappedError &&
|
|
118
|
+
"error" in wrappedError) {
|
|
119
|
+
const { source, error } = wrappedError;
|
|
120
|
+
if (source === "poll" &&
|
|
121
|
+
error instanceof Error &&
|
|
122
|
+
error.message === ASSISTANT_POLL_TIMEOUT_ERROR) {
|
|
123
|
+
evaluation = await evaluationPromise;
|
|
124
|
+
}
|
|
125
|
+
else if (source === "poll") {
|
|
126
|
+
pollerAbort.abort();
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
else if (source === "evaluation") {
|
|
130
|
+
const recovered = await recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId);
|
|
131
|
+
if (recovered) {
|
|
132
|
+
return recovered;
|
|
133
|
+
}
|
|
134
|
+
await logDomFailure(Runtime, logger, "assistant-response");
|
|
135
|
+
throw error ?? new Error("Failed to capture assistant response");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
throw wrappedError;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!evaluation) {
|
|
143
|
+
await logDomFailure(Runtime, logger, "assistant-response");
|
|
144
|
+
throw new Error("Failed to capture assistant response");
|
|
145
|
+
}
|
|
146
|
+
const parsed = await parseAssistantEvaluationResult(Runtime, evaluation, logger);
|
|
147
|
+
if (!parsed) {
|
|
148
|
+
let remainingMs = Math.max(0, timeoutMs - (Date.now() - start));
|
|
149
|
+
if (remainingMs > 0) {
|
|
150
|
+
const recovered = await recoverAssistantResponse(Runtime, remainingMs, logger, minTurnIndex, expectedConversationId);
|
|
151
|
+
if (recovered) {
|
|
152
|
+
return recovered;
|
|
153
|
+
}
|
|
154
|
+
remainingMs = Math.max(0, timeoutMs - (Date.now() - start));
|
|
155
|
+
if (remainingMs > 0) {
|
|
156
|
+
const polled = await Promise.race([
|
|
157
|
+
pollerPromise.catch(() => null),
|
|
158
|
+
delay(remainingMs).then(() => null),
|
|
159
|
+
]);
|
|
160
|
+
if (polled && polled.kind === "poll" && polled.value) {
|
|
161
|
+
return polled.value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
await logDomFailure(Runtime, logger, "assistant-response");
|
|
166
|
+
throw new Error("Unable to capture assistant response");
|
|
167
|
+
}
|
|
168
|
+
const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger, minTurnIndex, expectedConversationId);
|
|
169
|
+
const candidate = refreshed ?? parsed;
|
|
170
|
+
// The observer can settle early, including at the deadline. Never save an active response.
|
|
171
|
+
if (await isStopButtonVisible(Runtime)) {
|
|
172
|
+
const remainingMs = Math.max(0, timeoutMs - (Date.now() - start));
|
|
173
|
+
if (remainingMs > 0) {
|
|
174
|
+
logger("Assistant still generating; waiting for completion");
|
|
175
|
+
const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
|
|
176
|
+
if (completed)
|
|
177
|
+
return completed;
|
|
178
|
+
}
|
|
179
|
+
throw new Error("Timed out waiting for assistant response");
|
|
180
|
+
}
|
|
181
|
+
return candidate;
|
|
182
|
+
}
|
|
183
|
+
export async function readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId) {
|
|
184
|
+
const { result } = await Runtime.evaluate({
|
|
185
|
+
expression: buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId),
|
|
186
|
+
returnByValue: true,
|
|
187
|
+
});
|
|
188
|
+
const value = result?.value;
|
|
189
|
+
if (value && typeof value === "object") {
|
|
190
|
+
const snapshot = value;
|
|
191
|
+
if (typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex)) {
|
|
192
|
+
const turnIndex = typeof snapshot.turnIndex === "number" ? snapshot.turnIndex : null;
|
|
193
|
+
if (turnIndex === null) {
|
|
194
|
+
return snapshot;
|
|
195
|
+
}
|
|
196
|
+
if (turnIndex < minTurnIndex) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return snapshot;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
export async function captureAssistantMarkdown(Runtime, meta, logger) {
|
|
205
|
+
const { result } = await Runtime.evaluate({
|
|
206
|
+
expression: buildCopyExpression(meta),
|
|
207
|
+
returnByValue: true,
|
|
208
|
+
awaitPromise: true,
|
|
209
|
+
});
|
|
210
|
+
if (result?.value?.success && typeof result.value.markdown === "string") {
|
|
211
|
+
return result.value.markdown;
|
|
212
|
+
}
|
|
213
|
+
const status = result?.value?.status;
|
|
214
|
+
if (status && status !== "missing-button") {
|
|
215
|
+
logger(`Copy button fallback status: ${status}`);
|
|
216
|
+
await logDomFailure(Runtime, logger, "copy-markdown");
|
|
217
|
+
}
|
|
218
|
+
if (!status) {
|
|
219
|
+
await logDomFailure(Runtime, logger, "copy-markdown");
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
export function buildAssistantExtractorForTest(name) {
|
|
224
|
+
return buildAssistantExtractor(name);
|
|
225
|
+
}
|
|
226
|
+
export function buildAssistantSnapshotExpressionForTest(minTurnIndex, expectedConversationId) {
|
|
227
|
+
return buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId);
|
|
228
|
+
}
|
|
229
|
+
export function buildMarkdownFallbackExtractorForTest(minTurnLiteral = "0") {
|
|
230
|
+
return buildMarkdownFallbackExtractor(minTurnLiteral);
|
|
231
|
+
}
|
|
232
|
+
export function buildCopyExpressionForTest(meta = {}) {
|
|
233
|
+
return buildCopyExpression(meta);
|
|
234
|
+
}
|
|
235
|
+
async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId) {
|
|
236
|
+
const recoveryTimeoutMs = Math.max(0, timeoutMs);
|
|
237
|
+
if (recoveryTimeoutMs === 0) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const recovered = await pollAssistantCompletion(Runtime, recoveryTimeoutMs, minTurnIndex, expectedConversationId);
|
|
241
|
+
if (recovered) {
|
|
242
|
+
logger("Recovered assistant response via polling fallback");
|
|
243
|
+
return recovered;
|
|
244
|
+
}
|
|
245
|
+
await logDomFailure(Runtime, logger, "assistant-response-recovery");
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
async function parseAssistantEvaluationResult(_Runtime, evaluation, _logger) {
|
|
249
|
+
const { result } = evaluation;
|
|
250
|
+
if (result.value?.stoppedWithoutAnswer) {
|
|
251
|
+
throw new AssistantStoppedError(result.value.turnIndex);
|
|
252
|
+
}
|
|
253
|
+
if (result.type === "object" &&
|
|
254
|
+
result.value &&
|
|
255
|
+
typeof result.value === "object" &&
|
|
256
|
+
"text" in result.value) {
|
|
257
|
+
const html = typeof result.value.html === "string"
|
|
258
|
+
? (result.value.html ?? undefined)
|
|
259
|
+
: undefined;
|
|
260
|
+
const turnId = typeof result.value.turnId === "string"
|
|
261
|
+
? (result.value.turnId ?? undefined)
|
|
262
|
+
: undefined;
|
|
263
|
+
const messageId = typeof result.value.messageId === "string"
|
|
264
|
+
? (result.value.messageId ?? undefined)
|
|
265
|
+
: undefined;
|
|
266
|
+
const text = cleanAssistantText(String(result.value.text ?? ""));
|
|
267
|
+
const normalized = text.toLowerCase();
|
|
268
|
+
if (isTransientAssistantPlaceholderText(normalized)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
return { text, html, meta: { turnId, messageId } };
|
|
272
|
+
}
|
|
273
|
+
const fallbackText = typeof result.value === "string" ? cleanAssistantText(result.value) : "";
|
|
274
|
+
if (!fallbackText) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (isTransientAssistantPlaceholderText(fallbackText.toLowerCase())) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
return { text: fallbackText, html: undefined, meta: {} };
|
|
281
|
+
}
|
|
282
|
+
async function refreshAssistantSnapshot(Runtime, current, logger, minTurnIndex, expectedConversationId) {
|
|
283
|
+
const deadline = Date.now() + 5_000;
|
|
284
|
+
let best = null;
|
|
285
|
+
let stableCycles = 0;
|
|
286
|
+
const stableTarget = 3;
|
|
287
|
+
while (Date.now() < deadline) {
|
|
288
|
+
// Learned: short/fast answers can race; poll a few extra cycles to pick up messageId + full text.
|
|
289
|
+
const latestSnapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId).catch(() => null);
|
|
290
|
+
const latest = normalizeAssistantSnapshot(latestSnapshot);
|
|
291
|
+
if (latest) {
|
|
292
|
+
if (!best ||
|
|
293
|
+
latest.text.length > best.text.length ||
|
|
294
|
+
(!best.meta.messageId && latest.meta.messageId)) {
|
|
295
|
+
best = latest;
|
|
296
|
+
stableCycles = 0;
|
|
297
|
+
}
|
|
298
|
+
else if (latest.text.trim() === best.text.trim()) {
|
|
299
|
+
stableCycles += 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (best && stableCycles >= stableTarget) {
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
await delay(300);
|
|
306
|
+
}
|
|
307
|
+
if (!best) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const currentLength = cleanAssistantText(current.text).trim().length;
|
|
311
|
+
const latestLength = best.text.length;
|
|
312
|
+
const hasBetterId = !current.meta?.messageId && Boolean(best.meta.messageId);
|
|
313
|
+
const isLonger = latestLength > currentLength;
|
|
314
|
+
const hasDifferentText = best.text.trim() !== current.text.trim();
|
|
315
|
+
if (isLonger || hasBetterId || hasDifferentText) {
|
|
316
|
+
logger("Refreshed assistant response via latest snapshot");
|
|
317
|
+
return best;
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
async function terminateRuntimeExecution(Runtime) {
|
|
322
|
+
if (typeof Runtime.terminateExecution !== "function") {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
await Runtime.terminateExecution();
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// ignore termination failures
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expectedConversationId, abortSignal) {
|
|
333
|
+
const watchdogDeadline = Date.now() + timeoutMs;
|
|
334
|
+
let previousLength = 0;
|
|
335
|
+
let stableCycles = 0;
|
|
336
|
+
let lastChangeAt = Date.now();
|
|
337
|
+
while (Date.now() < watchdogDeadline) {
|
|
338
|
+
// Check abort signal to stop polling when another path won the race
|
|
339
|
+
if (abortSignal?.aborted) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId);
|
|
343
|
+
const normalized = normalizeAssistantSnapshot(snapshot);
|
|
344
|
+
if (normalized) {
|
|
345
|
+
const currentLength = normalized.text.length;
|
|
346
|
+
if (currentLength > previousLength) {
|
|
347
|
+
previousLength = currentLength;
|
|
348
|
+
stableCycles = 0;
|
|
349
|
+
lastChangeAt = Date.now();
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
stableCycles += 1;
|
|
353
|
+
}
|
|
354
|
+
const [stopVisible, completionVisible] = await Promise.all([
|
|
355
|
+
isStopButtonVisible(Runtime),
|
|
356
|
+
isCompletionVisible(Runtime),
|
|
357
|
+
]);
|
|
358
|
+
const shortAnswer = currentLength > 0 && currentLength < 16;
|
|
359
|
+
const mediumAnswer = currentLength >= 16 && currentLength < 40;
|
|
360
|
+
const longAnswer = currentLength >= 40 && currentLength < 500;
|
|
361
|
+
// Learned: short answers need a longer stability window or they truncate.
|
|
362
|
+
// Learned: long streaming responses (esp. thinking models) can pause mid-stream;
|
|
363
|
+
// use progressively longer windows to avoid truncation (#71).
|
|
364
|
+
const completionStableTarget = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 6 : 8;
|
|
365
|
+
const requiredStableCycles = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 8 : 10;
|
|
366
|
+
const stableMs = Date.now() - lastChangeAt;
|
|
367
|
+
const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
|
|
368
|
+
// Require stop button to disappear before treating completion as final.
|
|
369
|
+
if (!stopVisible) {
|
|
370
|
+
const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
|
|
371
|
+
const completionEnough = completionVisible && stableCycles >= completionStableTarget && stableMs >= minStableMs;
|
|
372
|
+
if (completionEnough || stableEnough) {
|
|
373
|
+
return normalized;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
previousLength = 0;
|
|
379
|
+
stableCycles = 0;
|
|
380
|
+
}
|
|
381
|
+
await delay(400);
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
async function isStopButtonVisible(Runtime) {
|
|
386
|
+
try {
|
|
387
|
+
const { result } = await Runtime.evaluate({
|
|
388
|
+
expression: `Boolean(document.querySelector('${STOP_BUTTON_SELECTOR}'))`,
|
|
389
|
+
returnByValue: true,
|
|
390
|
+
});
|
|
391
|
+
return Boolean(result?.value);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function isCompletionVisible(Runtime) {
|
|
398
|
+
try {
|
|
399
|
+
const { result } = await Runtime.evaluate({
|
|
400
|
+
expression: `(() => {
|
|
401
|
+
// Find the LAST assistant turn to check completion status
|
|
402
|
+
// Must match the same logic as buildAssistantExtractor for consistency
|
|
403
|
+
const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
|
|
404
|
+
const isAssistantTurn = (node) => {
|
|
405
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
406
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
407
|
+
if (turnAttr === 'assistant') return true;
|
|
408
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
409
|
+
if (role === 'assistant') return true;
|
|
410
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
411
|
+
if (testId.includes('assistant')) return true;
|
|
412
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const turns = Array.from(document.querySelectorAll('${CONVERSATION_TURN_SELECTOR}'));
|
|
416
|
+
let lastAssistantTurn = null;
|
|
417
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
418
|
+
if (isAssistantTurn(turns[i])) {
|
|
419
|
+
lastAssistantTurn = turns[i];
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!lastAssistantTurn) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
// Check if the last assistant turn has finished action buttons (copy, thumbs up/down, share)
|
|
427
|
+
if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
// Also check for "Done" text in the last assistant turn's markdown
|
|
431
|
+
const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
|
|
432
|
+
return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
|
|
433
|
+
})()`,
|
|
434
|
+
returnByValue: true,
|
|
435
|
+
});
|
|
436
|
+
return Boolean(result?.value);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function normalizeAssistantSnapshot(snapshot) {
|
|
443
|
+
if (snapshot?.stoppedWithoutAnswer && typeof snapshot.turnIndex === "number") {
|
|
444
|
+
throw new AssistantStoppedError(snapshot.turnIndex);
|
|
445
|
+
}
|
|
446
|
+
const text = snapshot?.text ? cleanAssistantText(snapshot.text) : "";
|
|
447
|
+
if (!text.trim()) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const normalized = text.toLowerCase();
|
|
451
|
+
// "Pro thinking" often renders a placeholder turn containing an "Answer now" gate.
|
|
452
|
+
// Treat it as incomplete so browser mode keeps waiting for the real assistant text.
|
|
453
|
+
if (isTransientAssistantPlaceholderText(normalized)) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
// Ignore user echo turns that can show up in project view fallbacks.
|
|
457
|
+
if (normalized.startsWith("you said")) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
text,
|
|
462
|
+
html: snapshot?.html ?? undefined,
|
|
463
|
+
meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId) {
|
|
467
|
+
const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
468
|
+
? Math.floor(minTurnIndex)
|
|
469
|
+
: -1;
|
|
470
|
+
const expectedConversationLiteral = typeof expectedConversationId === "string" && expectedConversationId.trim().length > 0
|
|
471
|
+
? JSON.stringify(expectedConversationId.trim())
|
|
472
|
+
: "null";
|
|
473
|
+
return `(() => {
|
|
474
|
+
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
475
|
+
const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
|
|
476
|
+
const currentHref = typeof location === 'object' && location.href ? location.href : '';
|
|
477
|
+
const currentConversationId = currentHref.match(/\\/c\\/([a-zA-Z0-9-]+)/)?.[1] ?? null;
|
|
478
|
+
if (
|
|
479
|
+
EXPECTED_CONVERSATION_ID &&
|
|
480
|
+
currentConversationId &&
|
|
481
|
+
currentConversationId !== EXPECTED_CONVERSATION_ID
|
|
482
|
+
) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
// Learned: the default turn DOM misses project view; keep a fallback extractor.
|
|
486
|
+
${buildAssistantExtractor("extractAssistantTurn")}
|
|
487
|
+
const extracted = extractAssistantTurn();
|
|
488
|
+
if (extracted?.stoppedWithoutAnswer) return extracted;
|
|
489
|
+
${buildAssistantPlaceholderPredicate("isPlaceholder")}
|
|
490
|
+
if (extracted && extracted.text && !isPlaceholder(extracted)) {
|
|
491
|
+
return extracted;
|
|
492
|
+
}
|
|
493
|
+
// Fallback for ChatGPT project view: answers can live outside conversation turns.
|
|
494
|
+
const fallback = ${buildMarkdownFallbackExtractor("MIN_TURN_INDEX")}();
|
|
495
|
+
return fallback ?? extracted;
|
|
496
|
+
})()`;
|
|
497
|
+
}
|
|
498
|
+
function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId) {
|
|
499
|
+
const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
|
|
500
|
+
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
501
|
+
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
502
|
+
const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
503
|
+
? Math.floor(minTurnIndex)
|
|
504
|
+
: -1;
|
|
505
|
+
const expectedConversationLiteral = typeof expectedConversationId === "string" && expectedConversationId.trim().length > 0
|
|
506
|
+
? JSON.stringify(expectedConversationId.trim())
|
|
507
|
+
: "null";
|
|
508
|
+
return `(() => {
|
|
509
|
+
${buildClickDispatcher()}
|
|
510
|
+
const SELECTORS = ${selectorsLiteral};
|
|
511
|
+
const STOP_SELECTOR = '${STOP_BUTTON_SELECTOR}';
|
|
512
|
+
const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
|
|
513
|
+
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
514
|
+
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
515
|
+
const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
|
|
516
|
+
// Learned: settling avoids capturing mid-stream HTML; keep short.
|
|
517
|
+
const settleDelayMs = 800;
|
|
518
|
+
const currentConversationId = () => {
|
|
519
|
+
const href = typeof location === 'object' && location.href ? location.href : '';
|
|
520
|
+
return href.match(/\\/c\\/([a-zA-Z0-9-]+)/)?.[1] ?? null;
|
|
521
|
+
};
|
|
522
|
+
const matchesExpectedConversation = () => {
|
|
523
|
+
if (!EXPECTED_CONVERSATION_ID) return true;
|
|
524
|
+
const currentId = currentConversationId();
|
|
525
|
+
return !currentId || currentId === EXPECTED_CONVERSATION_ID;
|
|
526
|
+
};
|
|
527
|
+
${buildAssistantPlaceholderPredicate("isAnswerNowPlaceholder")}
|
|
528
|
+
|
|
529
|
+
// Helper to detect assistant turns - must match buildAssistantExtractor logic for consistency.
|
|
530
|
+
const isAssistantTurn = (node) => {
|
|
531
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
532
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
533
|
+
if (turnAttr === 'assistant') return true;
|
|
534
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
535
|
+
if (role === 'assistant') return true;
|
|
536
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
537
|
+
if (testId.includes('assistant')) return true;
|
|
538
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
542
|
+
${buildAssistantExtractor("extractFromTurns")}
|
|
543
|
+
// Learned: some layouts (project view) render markdown without assistant turn wrappers.
|
|
544
|
+
const extractFromMarkdownFallback = ${buildMarkdownFallbackExtractor("MIN_TURN_INDEX")};
|
|
545
|
+
|
|
546
|
+
const acceptSnapshot = (snapshot) => {
|
|
547
|
+
if (!snapshot) return null;
|
|
548
|
+
if (!matchesExpectedConversation()) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
const index = typeof snapshot.turnIndex === 'number' ? snapshot.turnIndex : -1;
|
|
552
|
+
if (MIN_TURN_INDEX >= 0) {
|
|
553
|
+
if (index < 0 || index < MIN_TURN_INDEX) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return snapshot;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const captureViaObserver = () =>
|
|
561
|
+
new Promise((resolve, reject) => {
|
|
562
|
+
const deadline = Date.now() + ${timeoutMs};
|
|
563
|
+
let timeoutId = null;
|
|
564
|
+
let cleanedUp = false;
|
|
565
|
+
let observer = null;
|
|
566
|
+
|
|
567
|
+
// Centralized cleanup to prevent resource leaks
|
|
568
|
+
const cleanup = () => {
|
|
569
|
+
if (cleanedUp) return;
|
|
570
|
+
cleanedUp = true;
|
|
571
|
+
delete window.__askProCancelResponseObserver;
|
|
572
|
+
if (timeoutId) {
|
|
573
|
+
clearTimeout(timeoutId);
|
|
574
|
+
timeoutId = null;
|
|
575
|
+
}
|
|
576
|
+
if (observer) {
|
|
577
|
+
try {
|
|
578
|
+
observer.disconnect();
|
|
579
|
+
} catch {
|
|
580
|
+
// ignore disconnect errors
|
|
581
|
+
}
|
|
582
|
+
observer = null;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
window.__askProCancelResponseObserver = () => { cleanup(); resolve(null); };
|
|
587
|
+
|
|
588
|
+
const observerCallback = () => {
|
|
589
|
+
if (cleanedUp) return;
|
|
590
|
+
try {
|
|
591
|
+
const extractedRaw = extractFromTurns();
|
|
592
|
+
const extractedCandidate =
|
|
593
|
+
extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
|
|
594
|
+
let extracted = acceptSnapshot(extractedCandidate);
|
|
595
|
+
if (!extracted) {
|
|
596
|
+
const fallbackRaw = extractFromMarkdownFallback();
|
|
597
|
+
const fallbackCandidate =
|
|
598
|
+
fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
|
|
599
|
+
extracted = acceptSnapshot(fallbackCandidate);
|
|
600
|
+
}
|
|
601
|
+
if (extracted) {
|
|
602
|
+
cleanup();
|
|
603
|
+
resolve(extracted);
|
|
604
|
+
} else if (Date.now() > deadline) {
|
|
605
|
+
cleanup();
|
|
606
|
+
reject(new Error('Response timeout'));
|
|
607
|
+
}
|
|
608
|
+
} catch (error) {
|
|
609
|
+
cleanup();
|
|
610
|
+
reject(error);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
observer = new MutationObserver(observerCallback);
|
|
615
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
616
|
+
|
|
617
|
+
timeoutId = setTimeout(() => {
|
|
618
|
+
cleanup();
|
|
619
|
+
reject(new Error('Response timeout'));
|
|
620
|
+
}, ${timeoutMs});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// Check if the last assistant turn has finished (scoped to avoid detecting old turns).
|
|
624
|
+
const isLastAssistantTurnFinished = () => {
|
|
625
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
626
|
+
let lastAssistantTurn = null;
|
|
627
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
628
|
+
if (isAssistantTurn(turns[i])) {
|
|
629
|
+
lastAssistantTurn = turns[i];
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (!lastAssistantTurn) return false;
|
|
634
|
+
// Check for action buttons in this specific turn
|
|
635
|
+
if (lastAssistantTurn.querySelector(FINISHED_SELECTOR)) return true;
|
|
636
|
+
// Check for "Done" text in this turn's markdown
|
|
637
|
+
const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
|
|
638
|
+
return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const waitForSettle = async (snapshot) => {
|
|
642
|
+
if (snapshot?.stoppedWithoutAnswer) return snapshot;
|
|
643
|
+
// Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation.
|
|
644
|
+
// Learned: long streaming responses (esp. thinking models) can pause mid-stream;
|
|
645
|
+
// use progressively longer windows to avoid truncation (#71).
|
|
646
|
+
const initialLength = snapshot?.text?.length ?? 0;
|
|
647
|
+
const shortAnswer = initialLength > 0 && initialLength < 16;
|
|
648
|
+
const mediumAnswer = initialLength >= 16 && initialLength < 40;
|
|
649
|
+
const longAnswer = initialLength >= 40 && initialLength < 500;
|
|
650
|
+
const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
|
|
651
|
+
const settleIntervalMs = 400;
|
|
652
|
+
const deadline = Date.now() + settleWindowMs;
|
|
653
|
+
let latest = snapshot;
|
|
654
|
+
let lastLength = snapshot?.text?.length ?? 0;
|
|
655
|
+
let stableCycles = 0;
|
|
656
|
+
const stableTarget = shortAnswer ? 6 : mediumAnswer ? 3 : longAnswer ? 5 : 6;
|
|
657
|
+
while (Date.now() < deadline) {
|
|
658
|
+
await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
|
|
659
|
+
const refreshedRaw = extractFromTurns();
|
|
660
|
+
const refreshedCandidate =
|
|
661
|
+
refreshedRaw && !isAnswerNowPlaceholder(refreshedRaw) ? refreshedRaw : null;
|
|
662
|
+
let refreshed = acceptSnapshot(refreshedCandidate);
|
|
663
|
+
if (!refreshed) {
|
|
664
|
+
const fallbackRaw = extractFromMarkdownFallback();
|
|
665
|
+
const fallbackCandidate =
|
|
666
|
+
fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
|
|
667
|
+
refreshed = acceptSnapshot(fallbackCandidate);
|
|
668
|
+
}
|
|
669
|
+
const nextLength = refreshed?.text?.length ?? lastLength;
|
|
670
|
+
if (refreshed && nextLength >= lastLength) {
|
|
671
|
+
latest = refreshed;
|
|
672
|
+
}
|
|
673
|
+
if (nextLength > lastLength) {
|
|
674
|
+
lastLength = nextLength;
|
|
675
|
+
stableCycles = 0;
|
|
676
|
+
} else {
|
|
677
|
+
stableCycles += 1;
|
|
678
|
+
}
|
|
679
|
+
const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
|
|
680
|
+
const finishedVisible = isLastAssistantTurnFinished();
|
|
681
|
+
|
|
682
|
+
if (finishedVisible || (!stopVisible && stableCycles >= stableTarget)) {
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return latest ?? snapshot;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const extractedRaw = extractFromTurns();
|
|
690
|
+
const extractedCandidate = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
|
|
691
|
+
let extracted = acceptSnapshot(extractedCandidate);
|
|
692
|
+
if (!extracted) {
|
|
693
|
+
const fallbackRaw = extractFromMarkdownFallback();
|
|
694
|
+
const fallbackCandidate = fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
|
|
695
|
+
extracted = acceptSnapshot(fallbackCandidate);
|
|
696
|
+
}
|
|
697
|
+
if (extracted) {
|
|
698
|
+
return waitForSettle(extracted);
|
|
699
|
+
}
|
|
700
|
+
return captureViaObserver().then((payload) => waitForSettle(payload));
|
|
701
|
+
})()`;
|
|
702
|
+
}
|
|
703
|
+
function buildAssistantExtractor(functionName) {
|
|
704
|
+
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
705
|
+
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
706
|
+
return `const ${functionName} = () => {
|
|
707
|
+
${buildClickDispatcher()}
|
|
708
|
+
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
709
|
+
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
710
|
+
const isAssistantTurn = (node) => {
|
|
711
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
712
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
713
|
+
if (turnAttr === 'assistant') {
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
717
|
+
if (role === 'assistant') {
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
721
|
+
if (testId.includes('assistant')) {
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const expandCollapsibles = (root) => {
|
|
728
|
+
const buttons = Array.from(root.querySelectorAll('button')).filter(
|
|
729
|
+
(button) => !button.closest?.('[data-streaming-response-status]'),
|
|
730
|
+
);
|
|
731
|
+
for (const button of buttons) {
|
|
732
|
+
const label = (button.textContent || '').toLowerCase();
|
|
733
|
+
const testid = (button.getAttribute('data-testid') || '').toLowerCase();
|
|
734
|
+
if (
|
|
735
|
+
label.includes('more') ||
|
|
736
|
+
label.includes('expand') ||
|
|
737
|
+
label.includes('show') ||
|
|
738
|
+
testid.includes('markdown') ||
|
|
739
|
+
testid.includes('toggle')
|
|
740
|
+
) {
|
|
741
|
+
dispatchClickSequence(button);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
${buildAssistantPlaceholderPredicate("isTransientAssistantPlaceholder")}
|
|
746
|
+
const readNodePayload = (node) => {
|
|
747
|
+
if (!(node instanceof HTMLElement)) return null;
|
|
748
|
+
if (node.closest?.('[data-streaming-response-status]') || node.querySelector('[data-streaming-response-status]')) return null;
|
|
749
|
+
const innerText = node.innerText ?? '';
|
|
750
|
+
const textContent = node.textContent ?? '';
|
|
751
|
+
const text = innerText.trim().length > 0 ? innerText : textContent;
|
|
752
|
+
const trimmed = text.trim();
|
|
753
|
+
if (!trimmed) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
text: trimmed,
|
|
758
|
+
html: node.innerHTML ?? '',
|
|
759
|
+
placeholder: isTransientAssistantPlaceholder(trimmed),
|
|
760
|
+
};
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
764
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
765
|
+
const turn = turns[index];
|
|
766
|
+
if (!isAssistantTurn(turn)) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) ?? turn;
|
|
770
|
+
const turnText = (turn.innerText || turn.textContent || '').replace(/^\\s*ChatGPT said:\\s*/i, '').trim();
|
|
771
|
+
const stopped = Array.from(turn.querySelectorAll('button')).some(
|
|
772
|
+
button => (button.textContent || '').trim().toLowerCase() === 'stopped thinking',
|
|
773
|
+
);
|
|
774
|
+
if (index === turns.length - 1 && stopped && turnText.toLowerCase() === 'stopped thinking' &&
|
|
775
|
+
!document.querySelector('${STOP_BUTTON_SELECTOR}')) {
|
|
776
|
+
return { text: '', stoppedWithoutAnswer: true, turnIndex: index };
|
|
777
|
+
}
|
|
778
|
+
expandCollapsibles(messageRoot);
|
|
779
|
+
const selectors = [
|
|
780
|
+
'.markdown',
|
|
781
|
+
'[data-message-content]',
|
|
782
|
+
'[data-testid*="message"]',
|
|
783
|
+
'.prose',
|
|
784
|
+
'[class*="markdown"]',
|
|
785
|
+
];
|
|
786
|
+
let contentRoots = [];
|
|
787
|
+
for (const selector of selectors) {
|
|
788
|
+
const matches = Array.from(messageRoot.querySelectorAll(selector)).filter(
|
|
789
|
+
(node) => node instanceof HTMLElement && !node.closest?.('[data-streaming-response-status]'),
|
|
790
|
+
);
|
|
791
|
+
if (matches.length > 0) {
|
|
792
|
+
contentRoots = matches;
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (contentRoots.length === 0) {
|
|
797
|
+
contentRoots = [messageRoot];
|
|
798
|
+
}
|
|
799
|
+
const payloads = contentRoots
|
|
800
|
+
.map((node) => readNodePayload(node))
|
|
801
|
+
.filter(Boolean);
|
|
802
|
+
if (payloads.length === 0) {
|
|
803
|
+
if (turn.querySelector('[data-streaming-response-status]')) return null;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const selectedPayloads = payloads.some((payload) => !payload.placeholder)
|
|
807
|
+
? payloads.filter((payload) => !payload.placeholder)
|
|
808
|
+
: [payloads[payloads.length - 1]];
|
|
809
|
+
const text = selectedPayloads.map((payload) => payload.text).join('\\n\\n').trim();
|
|
810
|
+
const html = selectedPayloads
|
|
811
|
+
.map((payload) => payload.html)
|
|
812
|
+
.filter(Boolean)
|
|
813
|
+
.join('\\n');
|
|
814
|
+
const messageId = messageRoot.getAttribute('data-message-id');
|
|
815
|
+
const turnId = messageRoot.getAttribute('data-testid');
|
|
816
|
+
if (text.trim()) {
|
|
817
|
+
return { text, html, messageId, turnId, turnIndex: index };
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return null;
|
|
821
|
+
};`;
|
|
822
|
+
}
|
|
823
|
+
function buildAssistantPlaceholderPredicate(functionName) {
|
|
824
|
+
return `const ${functionName} = (snapshot) => {
|
|
825
|
+
const normalized = String(snapshot?.text ?? snapshot ?? '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
826
|
+
if (!normalized) return false;
|
|
827
|
+
if (normalized === 'chatgpt said:' || normalized === 'chatgpt said') return true;
|
|
828
|
+
if (/^(chatgpt said:\\s*)?stopped thinking$/.test(normalized)) return true;
|
|
829
|
+
if (
|
|
830
|
+
normalized.includes('file upload request') &&
|
|
831
|
+
(normalized.includes('pro thinking') || normalized.includes('chatgpt said'))
|
|
832
|
+
) {
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
if (
|
|
836
|
+
normalized.includes('answer now') &&
|
|
837
|
+
(normalized.includes('pro thinking') || normalized.includes('chatgpt said'))
|
|
838
|
+
) {
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
if (
|
|
842
|
+
/^(pro thinking|thinking|reasoning|planning|drafting|summarizing|analyzing)(\\s*(\\.\\.\\.|…))?$/.test(
|
|
843
|
+
normalized,
|
|
844
|
+
)
|
|
845
|
+
) {
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
if (
|
|
849
|
+
/^(pro thinking|thinking|reasoning|planning|drafting|summarizing|analyzing)\\s+for\\s+\\d+/.test(
|
|
850
|
+
normalized,
|
|
851
|
+
)
|
|
852
|
+
) {
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
return /^(thought|reasoned|analyzed|planned|drafted|summarized)\\s+for\\s+\\d+/.test(normalized);
|
|
856
|
+
};`;
|
|
857
|
+
}
|
|
858
|
+
function buildMarkdownFallbackExtractor(minTurnLiteral) {
|
|
859
|
+
const turnIndexValue = minTurnLiteral
|
|
860
|
+
? `(${minTurnLiteral} >= 0 ? ${minTurnLiteral} : null)`
|
|
861
|
+
: "null";
|
|
862
|
+
return `(() => {
|
|
863
|
+
const __minTurn = ${turnIndexValue};
|
|
864
|
+
const roots = [
|
|
865
|
+
document.querySelector('section[data-testid="screen-threadFlyOut"]'),
|
|
866
|
+
document.querySelector('[data-testid="chat-thread"]'),
|
|
867
|
+
document.querySelector('main'),
|
|
868
|
+
document.querySelector('[role="main"]'),
|
|
869
|
+
].filter(Boolean);
|
|
870
|
+
if (roots.length === 0) return null;
|
|
871
|
+
const markdownSelector = '.markdown,[data-message-content],[data-testid*="message"],.prose,[class*="markdown"]';
|
|
872
|
+
const isExcluded = (node) =>
|
|
873
|
+
Boolean(
|
|
874
|
+
node?.querySelector?.('[data-streaming-response-status]') || node?.closest?.(
|
|
875
|
+
'[data-streaming-response-status], nav, aside, [data-testid*="sidebar"], [data-testid*="chat-history"], [data-testid*="composer"], form',
|
|
876
|
+
),
|
|
877
|
+
);
|
|
878
|
+
const scoreRoot = (node) => {
|
|
879
|
+
const actions = node.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}').length;
|
|
880
|
+
const assistants = node.querySelectorAll('[data-message-author-role="assistant"], [data-turn="assistant"]').length;
|
|
881
|
+
const markdowns = node.querySelectorAll(markdownSelector).length;
|
|
882
|
+
return actions * 10 + assistants * 5 + markdowns;
|
|
883
|
+
};
|
|
884
|
+
let root = roots[0];
|
|
885
|
+
let bestScore = scoreRoot(root);
|
|
886
|
+
for (let i = 1; i < roots.length; i += 1) {
|
|
887
|
+
const candidate = roots[i];
|
|
888
|
+
const score = scoreRoot(candidate);
|
|
889
|
+
if (score > bestScore) {
|
|
890
|
+
bestScore = score;
|
|
891
|
+
root = candidate;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (!root) return null;
|
|
895
|
+
const CONVERSATION_SELECTOR = '${CONVERSATION_TURN_SELECTOR}';
|
|
896
|
+
const turnNodes = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
897
|
+
const hasTurns = turnNodes.length > 0;
|
|
898
|
+
const latestStatusTurn = turnNodes.findLastIndex((turn) => turn.querySelector('[data-streaming-response-status]'));
|
|
899
|
+
const resolveTurnIndex = (node) => {
|
|
900
|
+
const turn = node?.closest?.(CONVERSATION_SELECTOR);
|
|
901
|
+
if (!turn) return null;
|
|
902
|
+
const idx = turnNodes.indexOf(turn);
|
|
903
|
+
return idx >= 0 ? idx : null;
|
|
904
|
+
};
|
|
905
|
+
const isAfterMinTurn = (node) => {
|
|
906
|
+
if (!hasTurns || (__minTurn === null && latestStatusTurn < 0)) return true;
|
|
907
|
+
const idx = resolveTurnIndex(node);
|
|
908
|
+
return idx !== null && idx >= Math.max(__minTurn ?? -1, latestStatusTurn);
|
|
909
|
+
};
|
|
910
|
+
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
911
|
+
const collectUserText = (scope) => {
|
|
912
|
+
if (!scope?.querySelectorAll) return '';
|
|
913
|
+
const userTurns = Array.from(scope.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'));
|
|
914
|
+
const lastUser = userTurns[userTurns.length - 1];
|
|
915
|
+
return lastUser ? normalize(lastUser.innerText || lastUser.textContent || '') : '';
|
|
916
|
+
};
|
|
917
|
+
const userText = collectUserText(root) || collectUserText(document);
|
|
918
|
+
const isUserEcho = (text) => {
|
|
919
|
+
if (!userText) return false;
|
|
920
|
+
const normalized = normalize(text);
|
|
921
|
+
if (!normalized) return false;
|
|
922
|
+
return normalized === userText || normalized.startsWith(userText);
|
|
923
|
+
};
|
|
924
|
+
const markdowns = Array.from(root.querySelectorAll(markdownSelector))
|
|
925
|
+
.filter((node) => !isExcluded(node))
|
|
926
|
+
.filter((node) => {
|
|
927
|
+
const container = node.closest('[data-message-author-role], [data-turn]');
|
|
928
|
+
if (!container) return true;
|
|
929
|
+
const role =
|
|
930
|
+
(container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase();
|
|
931
|
+
return role !== 'user';
|
|
932
|
+
});
|
|
933
|
+
if (markdowns.length === 0) return null;
|
|
934
|
+
const actionButtons = Array.from(root.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}'));
|
|
935
|
+
const actionMarkdowns = [];
|
|
936
|
+
for (const button of actionButtons) {
|
|
937
|
+
const container =
|
|
938
|
+
button.closest('${CONVERSATION_TURN_SELECTOR}') ||
|
|
939
|
+
button.closest('[data-message-author-role="assistant"], [data-turn="assistant"]') ||
|
|
940
|
+
button.closest('[data-message-author-role], [data-turn]') ||
|
|
941
|
+
button.closest('[data-testid*="assistant"]');
|
|
942
|
+
if (!container || container === root || container === document.body) continue;
|
|
943
|
+
const scoped = Array.from(container.querySelectorAll(markdownSelector))
|
|
944
|
+
.filter((node) => !isExcluded(node))
|
|
945
|
+
.filter((node) => {
|
|
946
|
+
const roleNode = node.closest('[data-message-author-role], [data-turn]');
|
|
947
|
+
if (!roleNode) return true;
|
|
948
|
+
const role =
|
|
949
|
+
(roleNode.getAttribute('data-message-author-role') || roleNode.getAttribute('data-turn') || '').toLowerCase();
|
|
950
|
+
return role !== 'user';
|
|
951
|
+
});
|
|
952
|
+
if (scoped.length === 0) continue;
|
|
953
|
+
for (const node of scoped) {
|
|
954
|
+
actionMarkdowns.push(node);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const assistantMarkdowns = markdowns.filter((node) => {
|
|
958
|
+
const container = node.closest('[data-message-author-role], [data-turn], [data-testid*="assistant"]');
|
|
959
|
+
if (!container) return false;
|
|
960
|
+
const role =
|
|
961
|
+
(container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase();
|
|
962
|
+
if (role === 'assistant') return true;
|
|
963
|
+
const testId = (container.getAttribute('data-testid') || '').toLowerCase();
|
|
964
|
+
return testId.includes('assistant');
|
|
965
|
+
});
|
|
966
|
+
const hasAssistantIndicators = Boolean(
|
|
967
|
+
root.querySelector('${FINISHED_ACTIONS_SELECTOR}') ||
|
|
968
|
+
root.querySelector('[data-message-author-role="assistant"], [data-turn="assistant"], [data-testid*="assistant"]'),
|
|
969
|
+
);
|
|
970
|
+
const allowMarkdownFallback = hasAssistantIndicators || hasTurns || Boolean(userText);
|
|
971
|
+
const candidates =
|
|
972
|
+
actionMarkdowns.length > 0
|
|
973
|
+
? actionMarkdowns
|
|
974
|
+
: assistantMarkdowns.length > 0
|
|
975
|
+
? assistantMarkdowns
|
|
976
|
+
: allowMarkdownFallback
|
|
977
|
+
? markdowns
|
|
978
|
+
: [];
|
|
979
|
+
for (let i = candidates.length - 1; i >= 0; i -= 1) {
|
|
980
|
+
const node = candidates[i];
|
|
981
|
+
if (!node) continue;
|
|
982
|
+
if (!isAfterMinTurn(node)) continue;
|
|
983
|
+
const text = (node.innerText || node.textContent || '').trim();
|
|
984
|
+
if (!text) continue;
|
|
985
|
+
if (isUserEcho(text)) continue;
|
|
986
|
+
const html = node.innerHTML ?? '';
|
|
987
|
+
const turnIndex = resolveTurnIndex(node);
|
|
988
|
+
return { text, html, messageId: null, turnId: null, turnIndex };
|
|
989
|
+
}
|
|
990
|
+
return null;
|
|
991
|
+
})`;
|
|
992
|
+
}
|
|
993
|
+
function buildCopyExpression(meta) {
|
|
994
|
+
return `(() => {
|
|
995
|
+
${buildClickDispatcher()}
|
|
996
|
+
const BUTTON_SELECTOR = '${COPY_BUTTON_SELECTOR}';
|
|
997
|
+
const TIMEOUT_MS = 10000;
|
|
998
|
+
const describeButton = (button) =>
|
|
999
|
+
[
|
|
1000
|
+
button?.getAttribute?.('aria-label') || '',
|
|
1001
|
+
button?.getAttribute?.('title') || '',
|
|
1002
|
+
button?.textContent || '',
|
|
1003
|
+
]
|
|
1004
|
+
.join(' ')
|
|
1005
|
+
.toLowerCase()
|
|
1006
|
+
.replace(/\\s+/g, ' ')
|
|
1007
|
+
.trim();
|
|
1008
|
+
const scoreButton = (button) => {
|
|
1009
|
+
const description = describeButton(button);
|
|
1010
|
+
let score = 0;
|
|
1011
|
+
if (!description) return score;
|
|
1012
|
+
if (description.includes('copy')) score += 10;
|
|
1013
|
+
if (description.includes('response')) score += 200;
|
|
1014
|
+
if (description.includes('message')) score += 100;
|
|
1015
|
+
if (description.includes('copied')) score += 20;
|
|
1016
|
+
return score;
|
|
1017
|
+
};
|
|
1018
|
+
const pickPreferredButton = (buttons) => {
|
|
1019
|
+
const list = Array.from(buttons || []).filter((button) => button instanceof HTMLElement);
|
|
1020
|
+
let best = null;
|
|
1021
|
+
let bestScore = -1;
|
|
1022
|
+
for (const button of list) {
|
|
1023
|
+
const score = scoreButton(button);
|
|
1024
|
+
if (score >= bestScore) {
|
|
1025
|
+
best = button;
|
|
1026
|
+
bestScore = score;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return best;
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
const locateButton = () => {
|
|
1033
|
+
const hint = ${JSON.stringify(meta ?? {})};
|
|
1034
|
+
if (hint?.messageId) {
|
|
1035
|
+
const node = document.querySelector('[data-message-id="' + hint.messageId + '"]');
|
|
1036
|
+
const button = node ? pickPreferredButton(node.querySelectorAll(BUTTON_SELECTOR)) : null;
|
|
1037
|
+
if (button) {
|
|
1038
|
+
return button;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (hint?.turnId) {
|
|
1042
|
+
const node = document.querySelector('[data-testid="' + hint.turnId + '"]');
|
|
1043
|
+
const button = node ? pickPreferredButton(node.querySelectorAll(BUTTON_SELECTOR)) : null;
|
|
1044
|
+
if (button) {
|
|
1045
|
+
return button;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
|
|
1049
|
+
const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
|
|
1050
|
+
const isAssistantTurn = (node) => {
|
|
1051
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
1052
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
1053
|
+
if (turnAttr === 'assistant') return true;
|
|
1054
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
1055
|
+
if (role === 'assistant') return true;
|
|
1056
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
1057
|
+
if (testId.includes('assistant')) return true;
|
|
1058
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
1059
|
+
};
|
|
1060
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
1061
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
1062
|
+
const turn = turns[i];
|
|
1063
|
+
if (!isAssistantTurn(turn)) continue;
|
|
1064
|
+
const button = pickPreferredButton(turn.querySelectorAll(BUTTON_SELECTOR));
|
|
1065
|
+
if (button) {
|
|
1066
|
+
return button;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const all = Array.from(document.querySelectorAll(BUTTON_SELECTOR)).filter((button) => {
|
|
1070
|
+
const turn = button?.closest?.(CONVERSATION_SELECTOR);
|
|
1071
|
+
return Boolean(turn && isAssistantTurn(turn));
|
|
1072
|
+
});
|
|
1073
|
+
const button = pickPreferredButton(all);
|
|
1074
|
+
if (button) {
|
|
1075
|
+
return button;
|
|
1076
|
+
}
|
|
1077
|
+
return null;
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
const interceptClipboard = () => {
|
|
1081
|
+
const clipboard = navigator.clipboard;
|
|
1082
|
+
const state = { text: '', updatedAt: 0 };
|
|
1083
|
+
if (!clipboard) {
|
|
1084
|
+
return { state, restore: () => {} };
|
|
1085
|
+
}
|
|
1086
|
+
const originalWriteText = clipboard.writeText;
|
|
1087
|
+
const originalWrite = clipboard.write;
|
|
1088
|
+
clipboard.writeText = (value) => {
|
|
1089
|
+
state.text = typeof value === 'string' ? value : '';
|
|
1090
|
+
state.updatedAt = Date.now();
|
|
1091
|
+
return Promise.resolve();
|
|
1092
|
+
};
|
|
1093
|
+
clipboard.write = async (items) => {
|
|
1094
|
+
try {
|
|
1095
|
+
const list = Array.isArray(items) ? items : items ? [items] : [];
|
|
1096
|
+
for (const item of list) {
|
|
1097
|
+
if (!item) continue;
|
|
1098
|
+
const types = Array.isArray(item.types) ? item.types : [];
|
|
1099
|
+
if (types.includes('text/plain') && typeof item.getType === 'function') {
|
|
1100
|
+
const blob = await item.getType('text/plain');
|
|
1101
|
+
const text = await blob.text();
|
|
1102
|
+
state.text = text ?? '';
|
|
1103
|
+
state.updatedAt = Date.now();
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} catch {
|
|
1108
|
+
state.text = '';
|
|
1109
|
+
state.updatedAt = Date.now();
|
|
1110
|
+
}
|
|
1111
|
+
return Promise.resolve();
|
|
1112
|
+
};
|
|
1113
|
+
return {
|
|
1114
|
+
state,
|
|
1115
|
+
restore: () => {
|
|
1116
|
+
clipboard.writeText = originalWriteText;
|
|
1117
|
+
clipboard.write = originalWrite;
|
|
1118
|
+
},
|
|
1119
|
+
};
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
return new Promise((resolve) => {
|
|
1123
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
1124
|
+
const waitForButton = () => {
|
|
1125
|
+
const button = locateButton();
|
|
1126
|
+
if (button) {
|
|
1127
|
+
const interception = interceptClipboard();
|
|
1128
|
+
let settled = false;
|
|
1129
|
+
let pollId = null;
|
|
1130
|
+
let timeoutId = null;
|
|
1131
|
+
const finish = (payload) => {
|
|
1132
|
+
if (settled) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
settled = true;
|
|
1136
|
+
if (pollId) {
|
|
1137
|
+
clearInterval(pollId);
|
|
1138
|
+
}
|
|
1139
|
+
if (timeoutId) {
|
|
1140
|
+
clearTimeout(timeoutId);
|
|
1141
|
+
}
|
|
1142
|
+
button.removeEventListener('copy', handleCopy, true);
|
|
1143
|
+
interception.restore?.();
|
|
1144
|
+
resolve(payload);
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const readIntercepted = () => {
|
|
1148
|
+
const markdown = interception.state.text ?? '';
|
|
1149
|
+
const updatedAt = interception.state.updatedAt ?? 0;
|
|
1150
|
+
return { success: Boolean(markdown.trim()), markdown, updatedAt };
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
let lastText = '';
|
|
1154
|
+
let stableTicks = 0;
|
|
1155
|
+
const requiredStableTicks = 3;
|
|
1156
|
+
const requiredStableMs = 250;
|
|
1157
|
+
const maybeFinish = () => {
|
|
1158
|
+
const payload = readIntercepted();
|
|
1159
|
+
if (!payload.success) return;
|
|
1160
|
+
if (payload.markdown !== lastText) {
|
|
1161
|
+
lastText = payload.markdown;
|
|
1162
|
+
stableTicks = 0;
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
stableTicks += 1;
|
|
1166
|
+
const ageMs = Date.now() - (payload.updatedAt || 0);
|
|
1167
|
+
if (stableTicks >= requiredStableTicks && ageMs >= requiredStableMs) {
|
|
1168
|
+
finish(payload);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const handleCopy = () => {
|
|
1173
|
+
maybeFinish();
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
button.addEventListener('copy', handleCopy, true);
|
|
1177
|
+
button.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
1178
|
+
dispatchClickSequence(button);
|
|
1179
|
+
pollId = setInterval(maybeFinish, 120);
|
|
1180
|
+
timeoutId = setTimeout(() => {
|
|
1181
|
+
button.removeEventListener('copy', handleCopy, true);
|
|
1182
|
+
finish({ success: false, status: 'timeout' });
|
|
1183
|
+
}, TIMEOUT_MS);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (Date.now() > deadline) {
|
|
1187
|
+
resolve({ success: false, status: 'missing-button' });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
setTimeout(waitForButton, 120);
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
waitForButton();
|
|
1194
|
+
});
|
|
1195
|
+
})()`;
|
|
1196
|
+
}
|
|
1197
|
+
const LANGUAGE_TAGS = new Set([
|
|
1198
|
+
"copy code",
|
|
1199
|
+
"markdown",
|
|
1200
|
+
"bash",
|
|
1201
|
+
"sh",
|
|
1202
|
+
"shell",
|
|
1203
|
+
"javascript",
|
|
1204
|
+
"typescript",
|
|
1205
|
+
"ts",
|
|
1206
|
+
"js",
|
|
1207
|
+
"yaml",
|
|
1208
|
+
"json",
|
|
1209
|
+
"python",
|
|
1210
|
+
"py",
|
|
1211
|
+
"go",
|
|
1212
|
+
"java",
|
|
1213
|
+
"c",
|
|
1214
|
+
"c++",
|
|
1215
|
+
"cpp",
|
|
1216
|
+
"c#",
|
|
1217
|
+
"php",
|
|
1218
|
+
"ruby",
|
|
1219
|
+
"rust",
|
|
1220
|
+
"swift",
|
|
1221
|
+
"kotlin",
|
|
1222
|
+
"html",
|
|
1223
|
+
"css",
|
|
1224
|
+
"sql",
|
|
1225
|
+
"text",
|
|
1226
|
+
].map((token) => token.toLowerCase()));
|
|
1227
|
+
function cleanAssistantText(text) {
|
|
1228
|
+
const normalized = text.replace(/\u00a0/g, " ");
|
|
1229
|
+
const lines = normalized.split(/\r?\n/);
|
|
1230
|
+
const filtered = lines.filter((line) => {
|
|
1231
|
+
const trimmed = line.trim().toLowerCase();
|
|
1232
|
+
if (LANGUAGE_TAGS.has(trimmed))
|
|
1233
|
+
return false;
|
|
1234
|
+
return true;
|
|
1235
|
+
});
|
|
1236
|
+
return filtered
|
|
1237
|
+
.join("\n")
|
|
1238
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1239
|
+
.trim();
|
|
1240
|
+
}
|
|
1241
|
+
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
1242
|
+
export const __test__ = {
|
|
1243
|
+
buildResponseObserverExpression,
|
|
1244
|
+
recoverAssistantResponse,
|
|
1245
|
+
};
|