qq-codex-bridge 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.
Files changed (45) hide show
  1. package/.env.example +58 -0
  2. package/LICENSE +21 -0
  3. package/README.md +453 -0
  4. package/bin/qq-codex-bridge.js +11 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +100 -0
  6. package/dist/apps/bridge-daemon/src/cli.js +141 -0
  7. package/dist/apps/bridge-daemon/src/config.js +109 -0
  8. package/dist/apps/bridge-daemon/src/debug-codex-workers.js +309 -0
  9. package/dist/apps/bridge-daemon/src/dev-launch.js +73 -0
  10. package/dist/apps/bridge-daemon/src/dev.js +28 -0
  11. package/dist/apps/bridge-daemon/src/http-server.js +36 -0
  12. package/dist/apps/bridge-daemon/src/main.js +57 -0
  13. package/dist/apps/bridge-daemon/src/thread-command-handler.js +197 -0
  14. package/dist/packages/adapters/codex-desktop/src/cdp-session.js +189 -0
  15. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +1259 -0
  16. package/dist/packages/adapters/codex-desktop/src/composer-heuristics.js +11 -0
  17. package/dist/packages/adapters/codex-desktop/src/health.js +7 -0
  18. package/dist/packages/adapters/codex-desktop/src/reply-parser.js +10 -0
  19. package/dist/packages/adapters/qq/src/qq-api-client.js +232 -0
  20. package/dist/packages/adapters/qq/src/qq-channel-adapter.js +22 -0
  21. package/dist/packages/adapters/qq/src/qq-gateway-client.js +295 -0
  22. package/dist/packages/adapters/qq/src/qq-gateway-session-store.js +64 -0
  23. package/dist/packages/adapters/qq/src/qq-gateway.js +62 -0
  24. package/dist/packages/adapters/qq/src/qq-media-downloader.js +246 -0
  25. package/dist/packages/adapters/qq/src/qq-media-parser.js +144 -0
  26. package/dist/packages/adapters/qq/src/qq-normalizer.js +35 -0
  27. package/dist/packages/adapters/qq/src/qq-sender.js +241 -0
  28. package/dist/packages/adapters/qq/src/qq-stt.js +189 -0
  29. package/dist/packages/domain/src/driver.js +7 -0
  30. package/dist/packages/domain/src/message.js +7 -0
  31. package/dist/packages/domain/src/session.js +7 -0
  32. package/dist/packages/orchestrator/src/bridge-orchestrator.js +143 -0
  33. package/dist/packages/orchestrator/src/job-runner.js +5 -0
  34. package/dist/packages/orchestrator/src/media-context.js +90 -0
  35. package/dist/packages/orchestrator/src/qq-outbound-draft.js +38 -0
  36. package/dist/packages/orchestrator/src/qq-outbound-format.js +51 -0
  37. package/dist/packages/orchestrator/src/qqbot-skill-context.js +13 -0
  38. package/dist/packages/orchestrator/src/session-key.js +6 -0
  39. package/dist/packages/ports/src/conversation.js +1 -0
  40. package/dist/packages/ports/src/qq.js +1 -0
  41. package/dist/packages/ports/src/store.js +1 -0
  42. package/dist/packages/store/src/message-repo.js +53 -0
  43. package/dist/packages/store/src/session-repo.js +80 -0
  44. package/dist/packages/store/src/sqlite.js +64 -0
  45. package/package.json +60 -0
@@ -0,0 +1,1259 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { DesktopDriverError } from "../../../domain/src/driver.js";
3
+ import { MediaArtifactKind } from "../../../domain/src/message.js";
4
+ import { isLikelyComposerSubmitButton } from "./composer-heuristics.js";
5
+ import { parseAssistantReply } from "./reply-parser.js";
6
+ const TARGET_REF_PREFIX = "cdp-target:";
7
+ const THREAD_REF_PREFIX = "codex-thread:";
8
+ export class CodexDesktopDriver {
9
+ cdp;
10
+ replyPollAttempts;
11
+ maxReplyPollAttempts;
12
+ replyPollIntervalMs;
13
+ replyStablePolls;
14
+ partialReplyStablePolls;
15
+ sleep;
16
+ pendingReplyBaselines = new Map();
17
+ constructor(cdp, options = {}) {
18
+ this.cdp = cdp;
19
+ this.replyPollAttempts = Math.max(1, options.replyPollAttempts ?? 60);
20
+ this.maxReplyPollAttempts = Math.max(this.replyPollAttempts, options.maxReplyPollAttempts ?? this.replyPollAttempts * 10);
21
+ this.replyPollIntervalMs = options.replyPollIntervalMs ?? 500;
22
+ this.replyStablePolls = Math.max(1, options.replyStablePolls ?? 3);
23
+ this.partialReplyStablePolls = Math.max(1, options.partialReplyStablePolls ?? 2);
24
+ this.sleep =
25
+ options.sleep ??
26
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
27
+ }
28
+ async ensureAppReady() {
29
+ await this.cdp.connect();
30
+ const targets = await this.cdp.listTargets();
31
+ const hasPageTarget = targets.some((target) => target.type === "page");
32
+ if (!hasPageTarget) {
33
+ throw new DesktopDriverError("Codex desktop app is not exposing any inspectable page target", "app_not_ready");
34
+ }
35
+ }
36
+ async openOrBindSession(sessionKey, binding) {
37
+ const pageTarget = await this.resolvePageTarget();
38
+ const pageId = pageTarget.id;
39
+ if (binding?.codexThreadRef === `${TARGET_REF_PREFIX}${pageId}`) {
40
+ return binding;
41
+ }
42
+ if (binding?.codexThreadRef?.startsWith(THREAD_REF_PREFIX)) {
43
+ const locator = this.decodeThreadRef(binding.codexThreadRef);
44
+ if (locator && locator.pageId === pageId) {
45
+ const threads = await this.listRecentThreads(200);
46
+ const matched = threads.find((thread) => thread.threadRef === binding.codexThreadRef);
47
+ if (matched) {
48
+ return binding;
49
+ }
50
+ }
51
+ }
52
+ const currentThread = (await this.listRecentThreads(200)).find((thread) => thread.isCurrent);
53
+ if (currentThread) {
54
+ return {
55
+ sessionKey,
56
+ codexThreadRef: currentThread.threadRef
57
+ };
58
+ }
59
+ return {
60
+ sessionKey,
61
+ codexThreadRef: `${TARGET_REF_PREFIX}${pageId}`
62
+ };
63
+ }
64
+ async listRecentThreads(limit) {
65
+ const pageTarget = await this.resolvePageTarget();
66
+ const rawThreads = (await this.cdp.evaluateOnPage(this.buildThreadListScript(), pageTarget.id));
67
+ if (!Array.isArray(rawThreads)) {
68
+ return [];
69
+ }
70
+ return rawThreads
71
+ .sort((left, right) => this.compareThreadActivity(left, right))
72
+ .slice(0, limit)
73
+ .map((thread, index) => ({
74
+ index: index + 1,
75
+ title: thread.title,
76
+ projectName: thread.projectName,
77
+ relativeTime: thread.relativeTime,
78
+ isCurrent: thread.isCurrent,
79
+ threadRef: this.encodeThreadRef({
80
+ pageId: pageTarget.id,
81
+ title: thread.title,
82
+ projectName: thread.projectName
83
+ })
84
+ }));
85
+ }
86
+ async switchToThread(sessionKey, threadRef) {
87
+ const locator = this.decodeThreadRef(threadRef);
88
+ if (!locator) {
89
+ throw new DesktopDriverError("Codex thread binding is invalid", "session_not_found");
90
+ }
91
+ const result = (await this.cdp.evaluateOnPage(this.buildSelectThreadScript(locator), locator.pageId));
92
+ if (!result?.ok) {
93
+ throw new DesktopDriverError(`Codex desktop thread switch failed: ${result?.reason ?? "unknown"}`, "session_not_found");
94
+ }
95
+ return {
96
+ sessionKey,
97
+ codexThreadRef: threadRef
98
+ };
99
+ }
100
+ async createThread(sessionKey, seedPrompt) {
101
+ const pageTarget = await this.resolvePageTarget();
102
+ const clickResult = (await this.cdp.evaluateOnPage(this.buildNewThreadScript(), pageTarget.id));
103
+ if (!clickResult?.ok) {
104
+ throw new DesktopDriverError(`Codex desktop new thread failed: ${clickResult?.reason ?? "unknown"}`, "session_not_found");
105
+ }
106
+ await this.waitForFreshThreadContext(pageTarget.id);
107
+ const temporaryBinding = {
108
+ sessionKey,
109
+ codexThreadRef: `${TARGET_REF_PREFIX}${pageTarget.id}`
110
+ };
111
+ if (seedPrompt.trim()) {
112
+ await this.sendUserMessage(temporaryBinding, {
113
+ messageId: `thread-seed:${randomUUID()}`,
114
+ accountKey: "qqbot:default",
115
+ sessionKey,
116
+ peerKey: "qq:c2c:thread-control",
117
+ chatType: "c2c",
118
+ senderId: "thread-control",
119
+ text: seedPrompt,
120
+ receivedAt: new Date().toISOString()
121
+ });
122
+ }
123
+ return temporaryBinding;
124
+ }
125
+ async sendUserMessage(binding, message) {
126
+ const targetId = await this.ensureThreadSelected(binding);
127
+ const baselineReply = await this.readLatestAssistantSnapshot(targetId);
128
+ this.pendingReplyBaselines.set(binding.sessionKey, baselineReply);
129
+ const focusResult = (await this.cdp.evaluateOnPage(this.buildFocusComposerScript(), targetId));
130
+ if (!focusResult?.ok) {
131
+ this.pendingReplyBaselines.delete(binding.sessionKey);
132
+ throw new DesktopDriverError(`Codex desktop input box not found: ${focusResult?.reason ?? "unknown"}`, "input_not_found");
133
+ }
134
+ await this.cdp.dispatchKeyEvent({
135
+ type: "keyDown",
136
+ commands: ["selectAll"]
137
+ }, targetId);
138
+ await this.cdp.dispatchKeyEvent({
139
+ type: "keyDown",
140
+ key: "Backspace",
141
+ code: "Backspace",
142
+ windowsVirtualKeyCode: 8,
143
+ nativeVirtualKeyCode: 8
144
+ }, targetId);
145
+ await this.cdp.dispatchKeyEvent({
146
+ type: "keyUp",
147
+ key: "Backspace",
148
+ code: "Backspace",
149
+ windowsVirtualKeyCode: 8,
150
+ nativeVirtualKeyCode: 8
151
+ }, targetId);
152
+ await this.cdp.insertText(message.text, targetId);
153
+ const result = (await this.cdp.evaluateOnPage(this.buildSubmitComposerScript(), targetId));
154
+ if (result?.ok) {
155
+ return;
156
+ }
157
+ await this.cdp.dispatchKeyEvent({
158
+ type: "keyDown",
159
+ key: "Enter",
160
+ code: "Enter",
161
+ windowsVirtualKeyCode: 13,
162
+ nativeVirtualKeyCode: 13
163
+ }, targetId);
164
+ await this.cdp.dispatchKeyEvent({
165
+ type: "keyUp",
166
+ key: "Enter",
167
+ code: "Enter",
168
+ windowsVirtualKeyCode: 13,
169
+ nativeVirtualKeyCode: 13
170
+ }, targetId);
171
+ const retryResult = (await this.cdp.evaluateOnPage(this.buildComposerSubmissionStateScript(), targetId));
172
+ if (retryResult?.submitted) {
173
+ return;
174
+ }
175
+ this.pendingReplyBaselines.delete(binding.sessionKey);
176
+ throw new DesktopDriverError(`Codex desktop composer submit failed: ${retryResult?.reason ?? result?.reason ?? "unknown"}`, "submit_failed");
177
+ }
178
+ async collectAssistantReply(binding, options = {}) {
179
+ const targetId = await this.ensureThreadSelected(binding);
180
+ const baselineReply = this.pendingReplyBaselines.get(binding.sessionKey);
181
+ let candidateReply = null;
182
+ let latestNewReply = null;
183
+ let stablePolls = 0;
184
+ let emittedReplyText = "";
185
+ const emittedMediaReferences = new Set();
186
+ for (let attempt = 0; attempt < this.maxReplyPollAttempts; attempt += 1) {
187
+ const reply = await this.readLatestAssistantSnapshot(targetId);
188
+ const hasReplyText = typeof reply.reply === "string" && reply.reply.trim() !== "";
189
+ const hasReplyContent = hasReplyText || reply.mediaReferences.length > 0;
190
+ const isNewReply = hasReplyContent &&
191
+ (baselineReply === undefined || this.isNewAssistantReply(reply, baselineReply));
192
+ if (isNewReply) {
193
+ latestNewReply = reply;
194
+ if (!this.isSameAssistantReply(reply, candidateReply)) {
195
+ candidateReply = reply;
196
+ stablePolls = reply.isStreaming ? 0 : 1;
197
+ }
198
+ else {
199
+ stablePolls += 1;
200
+ }
201
+ if (candidateReply &&
202
+ this.hasAssistantContent(candidateReply) &&
203
+ !reply.isStreaming &&
204
+ stablePolls >= this.replyStablePolls) {
205
+ this.pendingReplyBaselines.delete(binding.sessionKey);
206
+ if (options.onDraft) {
207
+ const finalDeltaDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, candidateReply, emittedReplyText, emittedMediaReferences);
208
+ if (finalDeltaDraft) {
209
+ emittedReplyText = this.mergeObservedReply(emittedReplyText, candidateReply.reply ?? "");
210
+ this.mergeObservedMediaReferences(emittedMediaReferences, candidateReply.mediaReferences);
211
+ await options.onDraft(finalDeltaDraft);
212
+ }
213
+ return [];
214
+ }
215
+ return [this.buildOutboundDraftFromSnapshot(binding.sessionKey, candidateReply)];
216
+ }
217
+ if (options.onDraft &&
218
+ candidateReply &&
219
+ this.hasAssistantContent(candidateReply) &&
220
+ stablePolls >= this.partialReplyStablePolls) {
221
+ const deltaDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, candidateReply, emittedReplyText, emittedMediaReferences);
222
+ if (deltaDraft) {
223
+ emittedReplyText = this.mergeObservedReply(emittedReplyText, candidateReply.reply ?? "");
224
+ this.mergeObservedMediaReferences(emittedMediaReferences, candidateReply.mediaReferences);
225
+ await options.onDraft(deltaDraft);
226
+ stablePolls = 0;
227
+ }
228
+ }
229
+ }
230
+ else if (candidateReply) {
231
+ stablePolls = 0;
232
+ }
233
+ if (attempt + 1 < this.maxReplyPollAttempts) {
234
+ await this.sleep(this.replyPollIntervalMs);
235
+ }
236
+ }
237
+ this.pendingReplyBaselines.delete(binding.sessionKey);
238
+ if (latestNewReply && this.hasAssistantContent(latestNewReply)) {
239
+ if (options.onDraft) {
240
+ const timeoutDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, latestNewReply, emittedReplyText, emittedMediaReferences);
241
+ if (timeoutDraft) {
242
+ await options.onDraft(timeoutDraft);
243
+ }
244
+ return [];
245
+ }
246
+ return [this.buildOutboundDraftFromSnapshot(binding.sessionKey, latestNewReply)];
247
+ }
248
+ throw new DesktopDriverError("Codex desktop reply did not arrive before timeout", "reply_timeout");
249
+ }
250
+ buildOutboundDraftFromSnapshot(sessionKey, snapshot) {
251
+ return {
252
+ draftId: randomUUID(),
253
+ sessionKey,
254
+ text: snapshot.reply ?? "",
255
+ ...(snapshot.mediaReferences.length > 0
256
+ ? {
257
+ mediaArtifacts: snapshot.mediaReferences.map((reference) => buildMediaArtifactFromReference(reference))
258
+ }
259
+ : {}),
260
+ createdAt: new Date().toISOString()
261
+ };
262
+ }
263
+ buildIncrementalDraftFromSnapshot(sessionKey, snapshot, emittedReplyText, emittedMediaReferences) {
264
+ const fullReply = snapshot.reply ?? "";
265
+ const deltaText = this.extractReplyDelta(emittedReplyText, fullReply).trim();
266
+ const incrementalMediaReferences = snapshot.mediaReferences.filter((reference) => !emittedMediaReferences.has(reference));
267
+ if (!deltaText && incrementalMediaReferences.length === 0) {
268
+ return null;
269
+ }
270
+ return {
271
+ draftId: randomUUID(),
272
+ sessionKey,
273
+ text: deltaText,
274
+ ...(incrementalMediaReferences.length > 0
275
+ ? {
276
+ mediaArtifacts: incrementalMediaReferences.map((reference) => buildMediaArtifactFromReference(reference))
277
+ }
278
+ : {}),
279
+ createdAt: new Date().toISOString()
280
+ };
281
+ }
282
+ extractReplyDelta(previous, next) {
283
+ if (!previous) {
284
+ return next;
285
+ }
286
+ if (next.startsWith(previous)) {
287
+ return next.slice(previous.length);
288
+ }
289
+ return next;
290
+ }
291
+ mergeObservedReply(previous, next) {
292
+ if (!previous) {
293
+ return next;
294
+ }
295
+ if (next.startsWith(previous)) {
296
+ return next;
297
+ }
298
+ return next;
299
+ }
300
+ mergeObservedMediaReferences(emittedMediaReferences, mediaReferences) {
301
+ for (const reference of mediaReferences) {
302
+ emittedMediaReferences.add(reference);
303
+ }
304
+ }
305
+ hasAssistantContent(snapshot) {
306
+ return Boolean(snapshot.reply && snapshot.reply.trim()) || snapshot.mediaReferences.length > 0;
307
+ }
308
+ async markSessionBroken(_sessionKey, _reason) {
309
+ return;
310
+ }
311
+ async ensureThreadSelected(binding) {
312
+ const targetId = await this.resolveTargetId(binding);
313
+ const locator = binding.codexThreadRef
314
+ ? this.decodeThreadRef(binding.codexThreadRef)
315
+ : null;
316
+ if (!locator) {
317
+ return targetId;
318
+ }
319
+ const threads = await this.listRecentThreads(200);
320
+ const currentThread = threads.find((thread) => thread.isCurrent);
321
+ if (currentThread?.threadRef === binding.codexThreadRef) {
322
+ return targetId;
323
+ }
324
+ const switchResult = (await this.cdp.evaluateOnPage(this.buildSelectThreadScript(locator), targetId));
325
+ if (!switchResult?.ok) {
326
+ throw new DesktopDriverError(`Codex desktop thread switch failed: ${switchResult?.reason ?? "unknown"}`, "session_not_found");
327
+ }
328
+ await this.sleep(100);
329
+ return targetId;
330
+ }
331
+ async readLatestAssistantSnapshot(targetId) {
332
+ const structuredReply = await this.cdp.evaluateOnPage(this.buildAssistantReplyProbeScript(), targetId);
333
+ if (structuredReply &&
334
+ typeof structuredReply === "object" &&
335
+ "reply" in structuredReply) {
336
+ const rawReply = structuredReply.reply;
337
+ const normalizedReply = typeof rawReply === "string" ? rawReply.trim() : "";
338
+ const unitKey = "unitKey" in structuredReply && typeof structuredReply.unitKey === "string"
339
+ ? structuredReply.unitKey
340
+ : null;
341
+ const mediaReferences = "mediaReferences" in structuredReply && Array.isArray(structuredReply.mediaReferences)
342
+ ? structuredReply.mediaReferences.filter((reference) => typeof reference === "string" && reference.trim().length > 0)
343
+ : [];
344
+ const isStreaming = "isStreaming" in structuredReply && typeof structuredReply.isStreaming === "boolean"
345
+ ? structuredReply.isStreaming
346
+ : false;
347
+ return {
348
+ unitKey,
349
+ reply: normalizedReply || null,
350
+ mediaReferences,
351
+ isStreaming
352
+ };
353
+ }
354
+ const snapshotText = await this.cdp.evaluateOnPage("document.body.innerText", targetId);
355
+ if (typeof snapshotText !== "string") {
356
+ throw new DesktopDriverError("Codex desktop reply snapshot was not a string", "reply_parse_failed");
357
+ }
358
+ const parsedReply = parseAssistantReply(snapshotText).trim();
359
+ return {
360
+ unitKey: null,
361
+ reply: parsedReply || null,
362
+ mediaReferences: [],
363
+ isStreaming: false
364
+ };
365
+ }
366
+ isNewAssistantReply(current, baseline) {
367
+ if (current.unitKey && baseline.unitKey) {
368
+ return current.unitKey !== baseline.unitKey;
369
+ }
370
+ return current.reply !== baseline.reply;
371
+ }
372
+ isSameAssistantReply(current, candidate) {
373
+ if (!candidate) {
374
+ return false;
375
+ }
376
+ if (current.unitKey && candidate.unitKey) {
377
+ return (current.unitKey === candidate.unitKey &&
378
+ current.reply === candidate.reply &&
379
+ current.isStreaming === candidate.isStreaming &&
380
+ JSON.stringify(current.mediaReferences) === JSON.stringify(candidate.mediaReferences));
381
+ }
382
+ return (current.reply === candidate.reply &&
383
+ current.isStreaming === candidate.isStreaming &&
384
+ JSON.stringify(current.mediaReferences) === JSON.stringify(candidate.mediaReferences));
385
+ }
386
+ async waitForFreshThreadContext(targetId) {
387
+ for (let attempt = 0; attempt < 20; attempt += 1) {
388
+ const probe = (await this.cdp.evaluateOnPage(this.buildFreshThreadProbeScript(), targetId));
389
+ if (probe?.ok) {
390
+ return;
391
+ }
392
+ if (attempt + 1 < 20) {
393
+ await this.sleep(100);
394
+ }
395
+ }
396
+ throw new DesktopDriverError("Codex desktop new thread did not become active", "session_not_found");
397
+ }
398
+ async resolvePageTarget() {
399
+ const targets = await this.cdp.listTargets();
400
+ const pageTarget = targets.find((target) => target.type === "page");
401
+ if (!pageTarget) {
402
+ throw new DesktopDriverError("Codex desktop app is not exposing any inspectable page target", "session_not_found");
403
+ }
404
+ return pageTarget;
405
+ }
406
+ async resolveTargetId(binding) {
407
+ if (binding.codexThreadRef?.startsWith(THREAD_REF_PREFIX)) {
408
+ const locator = this.decodeThreadRef(binding.codexThreadRef);
409
+ if (locator) {
410
+ return locator.pageId;
411
+ }
412
+ }
413
+ if (binding.codexThreadRef?.startsWith(TARGET_REF_PREFIX)) {
414
+ return binding.codexThreadRef.slice(TARGET_REF_PREFIX.length);
415
+ }
416
+ const rebound = await this.openOrBindSession(binding.sessionKey, binding);
417
+ return this.resolveTargetId(rebound);
418
+ }
419
+ encodeThreadRef(locator) {
420
+ const encoded = Buffer.from(JSON.stringify({
421
+ title: locator.title,
422
+ projectName: locator.projectName
423
+ }), "utf8").toString("base64url");
424
+ return `${THREAD_REF_PREFIX}${locator.pageId}:${encoded}`;
425
+ }
426
+ decodeThreadRef(threadRef) {
427
+ if (!threadRef.startsWith(THREAD_REF_PREFIX)) {
428
+ return null;
429
+ }
430
+ const payload = threadRef.slice(THREAD_REF_PREFIX.length);
431
+ const separatorIndex = payload.indexOf(":");
432
+ if (separatorIndex <= 0) {
433
+ return null;
434
+ }
435
+ const pageId = payload.slice(0, separatorIndex);
436
+ const encodedLocator = payload.slice(separatorIndex + 1);
437
+ try {
438
+ const locator = JSON.parse(Buffer.from(encodedLocator, "base64url").toString("utf8"));
439
+ if (typeof locator.title !== "string" || locator.title.trim() === "") {
440
+ return null;
441
+ }
442
+ return {
443
+ pageId,
444
+ title: locator.title,
445
+ projectName: typeof locator.projectName === "string" && locator.projectName.trim() !== ""
446
+ ? locator.projectName
447
+ : null
448
+ };
449
+ }
450
+ catch {
451
+ return null;
452
+ }
453
+ }
454
+ buildThreadListScript() {
455
+ return `(() => {
456
+ const toText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
457
+ const extractProjectName = (titleNode) => {
458
+ const row = titleNode.closest('[role="button"]');
459
+ if (!(row instanceof HTMLElement)) {
460
+ return null;
461
+ }
462
+ const candidates = [
463
+ row.closest('[role="listitem"]'),
464
+ row.parentElement,
465
+ row.parentElement?.parentElement,
466
+ row.parentElement?.parentElement?.parentElement
467
+ ];
468
+ for (const candidate of candidates) {
469
+ if (!(candidate instanceof HTMLElement)) {
470
+ continue;
471
+ }
472
+ const aria = toText(candidate.getAttribute('aria-label'));
473
+ if (!aria) {
474
+ continue;
475
+ }
476
+ const quotedMatch = aria.match(/[“"]([^”"]+)[”"]中的自动化操作/);
477
+ if (quotedMatch) {
478
+ return quotedMatch[1];
479
+ }
480
+ const plainMatch = aria.match(/^(.+?)中的自动化操作$/);
481
+ if (plainMatch) {
482
+ return plainMatch[1];
483
+ }
484
+ }
485
+ return null;
486
+ };
487
+ const rows = Array.from(document.querySelectorAll('[data-thread-title="true"]'))
488
+ .map((titleNode) => {
489
+ if (!(titleNode instanceof HTMLElement)) {
490
+ return null;
491
+ }
492
+ const row = titleNode.closest('[role="button"]');
493
+ if (!(row instanceof HTMLElement)) {
494
+ return null;
495
+ }
496
+ const timeNode = row.querySelector('.text-token-description-foreground');
497
+ return {
498
+ title: toText(titleNode.innerText),
499
+ projectName: extractProjectName(titleNode),
500
+ relativeTime: timeNode instanceof HTMLElement ? toText(timeNode.innerText) || null : null,
501
+ isCurrent: row.getAttribute('aria-current') === 'page'
502
+ };
503
+ })
504
+ .filter((thread) => thread && thread.title);
505
+ return rows;
506
+ })();`;
507
+ }
508
+ compareThreadActivity(left, right) {
509
+ const leftRank = this.parseRelativeActivityRank(left.relativeTime);
510
+ const rightRank = this.parseRelativeActivityRank(right.relativeTime);
511
+ if (leftRank !== rightRank) {
512
+ return leftRank - rightRank;
513
+ }
514
+ if (left.isCurrent !== right.isCurrent) {
515
+ return left.isCurrent ? -1 : 1;
516
+ }
517
+ return left.title.localeCompare(right.title, "zh-CN");
518
+ }
519
+ parseRelativeActivityRank(relativeTime) {
520
+ if (!relativeTime) {
521
+ return Number.POSITIVE_INFINITY;
522
+ }
523
+ const value = relativeTime.trim().toLowerCase();
524
+ if (!value) {
525
+ return Number.POSITIVE_INFINITY;
526
+ }
527
+ if (value === "刚刚" ||
528
+ value === "现在" ||
529
+ value === "just now" ||
530
+ value === "now" ||
531
+ value === "today") {
532
+ return 0;
533
+ }
534
+ const minuteMatch = value.match(/(\d+)\s*(分钟|分|min|mins|minute|minutes)/i);
535
+ if (minuteMatch) {
536
+ return Number(minuteMatch[1]);
537
+ }
538
+ const hourMatch = value.match(/(\d+)\s*(小时|时|hr|hrs|hour|hours)/i);
539
+ if (hourMatch) {
540
+ return Number(hourMatch[1]) * 60;
541
+ }
542
+ const dayMatch = value.match(/(\d+)\s*(天|day|days)/i);
543
+ if (dayMatch) {
544
+ return Number(dayMatch[1]) * 24 * 60;
545
+ }
546
+ const weekMatch = value.match(/(\d+)\s*(周|week|weeks)/i);
547
+ if (weekMatch) {
548
+ return Number(weekMatch[1]) * 7 * 24 * 60;
549
+ }
550
+ const monthMatch = value.match(/(\d+)\s*(月|month|months)/i);
551
+ if (monthMatch) {
552
+ return Number(monthMatch[1]) * 30 * 24 * 60;
553
+ }
554
+ return Number.POSITIVE_INFINITY;
555
+ }
556
+ buildSelectThreadScript(locator) {
557
+ const expectedTitle = JSON.stringify(locator.title);
558
+ const expectedProject = JSON.stringify(locator.projectName);
559
+ return `(() => {
560
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
561
+ const extractProjectName = (titleNode) => {
562
+ const row = titleNode.closest('[role="button"]');
563
+ if (!(row instanceof HTMLElement)) {
564
+ return null;
565
+ }
566
+ const candidates = [
567
+ row.closest('[role="listitem"]'),
568
+ row.parentElement,
569
+ row.parentElement?.parentElement,
570
+ row.parentElement?.parentElement?.parentElement
571
+ ];
572
+ for (const candidate of candidates) {
573
+ if (!(candidate instanceof HTMLElement)) {
574
+ continue;
575
+ }
576
+ const aria = normalize(candidate.getAttribute('aria-label'));
577
+ if (!aria) {
578
+ continue;
579
+ }
580
+ const quotedMatch = aria.match(/[“"]([^”"]+)[”"]中的自动化操作/);
581
+ if (quotedMatch) {
582
+ return quotedMatch[1];
583
+ }
584
+ const plainMatch = aria.match(/^(.+?)中的自动化操作$/);
585
+ if (plainMatch) {
586
+ return plainMatch[1];
587
+ }
588
+ }
589
+ return null;
590
+ };
591
+ const target = Array.from(document.querySelectorAll('[data-thread-title="true"]'))
592
+ .find((titleNode) => {
593
+ if (!(titleNode instanceof HTMLElement)) {
594
+ return false;
595
+ }
596
+ const row = titleNode.closest('[role="button"]');
597
+ if (!(row instanceof HTMLElement)) {
598
+ return false;
599
+ }
600
+ const projectName = extractProjectName(titleNode);
601
+ return normalize(titleNode.innerText) === normalize(${expectedTitle})
602
+ && normalize(projectName) === normalize(${expectedProject});
603
+ });
604
+ if (!(target instanceof HTMLElement)) {
605
+ return { ok: false, reason: 'thread_not_found' };
606
+ }
607
+ const row = target.closest('[role="button"]');
608
+ if (!(row instanceof HTMLElement)) {
609
+ return { ok: false, reason: 'row_not_found' };
610
+ }
611
+ if (row.getAttribute('aria-current') === 'page') {
612
+ return { ok: true, reason: 'already_current' };
613
+ }
614
+ row.focus();
615
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
616
+ row.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
617
+ }
618
+ return { ok: true, reason: 'clicked_thread' };
619
+ })();`;
620
+ }
621
+ buildNewThreadScript() {
622
+ return `(() => {
623
+ const controls = Array.from(document.querySelectorAll('button, [role="button"]'));
624
+ const button = controls.find((candidate) => {
625
+ if (!(candidate instanceof HTMLElement)) {
626
+ return false;
627
+ }
628
+ const text = (candidate.textContent || '').replace(/\\s+/g, ' ').trim();
629
+ const aria = candidate.getAttribute('aria-label') || '';
630
+ return text === '新线程' || aria.includes('开始新线程');
631
+ });
632
+ if (!(button instanceof HTMLElement)) {
633
+ return { ok: false, reason: 'new_thread_button_not_found' };
634
+ }
635
+ button.focus();
636
+ if (typeof button.click === 'function') {
637
+ button.click();
638
+ }
639
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
640
+ button.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
641
+ }
642
+ return { ok: true, reason: 'clicked_new_thread' };
643
+ })();`;
644
+ }
645
+ buildFreshThreadProbeScript() {
646
+ return `(() => {
647
+ const composer = document.querySelector(
648
+ '[data-codex-composer="true"], textarea, input[type="text"], [contenteditable="true"]'
649
+ );
650
+ const readComposerText = (node) => {
651
+ if (!(node instanceof HTMLElement)) {
652
+ return '';
653
+ }
654
+ if ('value' in node && typeof node.value === 'string') {
655
+ return node.value;
656
+ }
657
+ return node.textContent || '';
658
+ };
659
+ const assistantUnits = document.querySelectorAll('[data-content-search-unit-key]').length;
660
+ const composerText = readComposerText(composer).trim();
661
+ const fresh = assistantUnits === 0 && composerText.length === 0;
662
+ return { ok: fresh, reason: fresh ? 'fresh_thread' : 'thread_not_ready' };
663
+ })();`;
664
+ }
665
+ buildFocusComposerScript() {
666
+ return `(() => {
667
+ const resolveComposer = () => {
668
+ const selectors = [
669
+ '[data-codex-composer="true"]',
670
+ 'textarea',
671
+ 'input[type="text"]',
672
+ '[contenteditable="true"]',
673
+ '[role="textbox"]'
674
+ ];
675
+ const candidates = selectors
676
+ .flatMap((selector) => Array.from(document.querySelectorAll(selector)))
677
+ .filter((candidate) => {
678
+ if (!(candidate instanceof HTMLElement)) {
679
+ return false;
680
+ }
681
+ if (candidate.hasAttribute('disabled') || candidate.getAttribute('aria-disabled') === 'true') {
682
+ return false;
683
+ }
684
+ const rect = candidate.getBoundingClientRect();
685
+ return rect.width > 0 && rect.height > 0;
686
+ });
687
+ const activeElement = document.activeElement;
688
+ if (activeElement instanceof HTMLElement && candidates.includes(activeElement)) {
689
+ return activeElement;
690
+ }
691
+ return candidates
692
+ .sort((left, right) => right.getBoundingClientRect().y - left.getBoundingClientRect().y)
693
+ .at(0) ?? null;
694
+ };
695
+ const input = resolveComposer();
696
+ if (!input) {
697
+ return { ok: false, reason: 'input_not_found' };
698
+ }
699
+ input.focus();
700
+ return { ok: true, reason: 'focused_input' };
701
+ })();`;
702
+ }
703
+ buildSubmitComposerScript() {
704
+ const submitButtonMatcher = isLikelyComposerSubmitButton
705
+ .toString()
706
+ .replace(/^function\s+isLikelyComposerSubmitButton/, "function isLikelyComposerSubmitButton");
707
+ return `(() => {
708
+ ${submitButtonMatcher}
709
+ const resolveComposer = () => {
710
+ const selectors = [
711
+ '[data-codex-composer="true"]',
712
+ 'textarea',
713
+ 'input[type="text"]',
714
+ '[contenteditable="true"]',
715
+ '[role="textbox"]'
716
+ ];
717
+ const candidates = selectors
718
+ .flatMap((selector) => Array.from(document.querySelectorAll(selector)))
719
+ .filter((candidate) => {
720
+ if (!(candidate instanceof HTMLElement)) {
721
+ return false;
722
+ }
723
+ if (candidate.hasAttribute('disabled') || candidate.getAttribute('aria-disabled') === 'true') {
724
+ return false;
725
+ }
726
+ const rect = candidate.getBoundingClientRect();
727
+ return rect.width > 0 && rect.height > 0;
728
+ });
729
+ const activeElement = document.activeElement;
730
+ if (activeElement instanceof HTMLElement && candidates.includes(activeElement)) {
731
+ return activeElement;
732
+ }
733
+ return candidates
734
+ .sort((left, right) => right.getBoundingClientRect().y - left.getBoundingClientRect().y)
735
+ .at(0) ?? null;
736
+ };
737
+ const readComposerText = (node) => {
738
+ if (!(node instanceof HTMLElement)) {
739
+ return '';
740
+ }
741
+ if ('value' in node && typeof node.value === 'string') {
742
+ return node.value;
743
+ }
744
+ return node.textContent || '';
745
+ };
746
+ const input = resolveComposer();
747
+ if (!(input instanceof HTMLElement)) {
748
+ return { ok: false, reason: 'input_not_found' };
749
+ }
750
+ const inputRect = input.getBoundingClientRect();
751
+ const currentText = readComposerText(input).trim();
752
+ if (!currentText) {
753
+ return { ok: false, reason: 'empty_input' };
754
+ }
755
+ const sendButton = Array.from(document.querySelectorAll('button, [role="button"]'))
756
+ .filter((candidate) => {
757
+ if (!(candidate instanceof HTMLElement)) {
758
+ return false;
759
+ }
760
+ const rect = candidate.getBoundingClientRect();
761
+ if (rect.width <= 0 || rect.height <= 0) {
762
+ return false;
763
+ }
764
+ if (candidate.hasAttribute('disabled') || candidate.getAttribute('aria-disabled') === 'true') {
765
+ return false;
766
+ }
767
+ return isLikelyComposerSubmitButton({
768
+ text: candidate.textContent ?? '',
769
+ aria: candidate.getAttribute('aria-label'),
770
+ title: candidate.getAttribute('title'),
771
+ className: candidate.className ?? ''
772
+ });
773
+ })
774
+ .sort((left, right) => {
775
+ const leftRect = left.getBoundingClientRect();
776
+ const rightRect = right.getBoundingClientRect();
777
+ const leftDistance = Math.abs(leftRect.y - inputRect.y) + Math.max(0, inputRect.x - leftRect.x);
778
+ const rightDistance = Math.abs(rightRect.y - inputRect.y) + Math.max(0, inputRect.x - rightRect.x);
779
+ return leftDistance - rightDistance;
780
+ })
781
+ .find((candidate) => {
782
+ const rect = candidate.getBoundingClientRect();
783
+ return rect.x >= inputRect.x - 24 && Math.abs(rect.y - inputRect.y) <= 120;
784
+ });
785
+ const beforeButtonHtml = sendButton instanceof HTMLElement ? sendButton.innerHTML : '';
786
+ const confirmSubmission = (reason) => new Promise((resolve) => {
787
+ window.setTimeout(() => {
788
+ const afterText = readComposerText(input).trim();
789
+ const afterButtonHtml = sendButton instanceof HTMLElement ? sendButton.innerHTML : '';
790
+ const buttonChanged = beforeButtonHtml !== '' && beforeButtonHtml !== afterButtonHtml;
791
+ resolve({
792
+ ok: afterText.length === 0 || buttonChanged,
793
+ reason: afterText.length === 0
794
+ ? reason
795
+ : (buttonChanged ? 'entered_streaming_state' : 'submit_not_confirmed')
796
+ });
797
+ }, 300);
798
+ });
799
+ const form = input.closest('form');
800
+ if (form && typeof form.requestSubmit === 'function') {
801
+ form.requestSubmit();
802
+ return confirmSubmission('submitted_form');
803
+ }
804
+ if (sendButton instanceof HTMLElement) {
805
+ if (typeof sendButton.click === 'function') {
806
+ sendButton.click();
807
+ }
808
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
809
+ sendButton.dispatchEvent(
810
+ new MouseEvent(type, {
811
+ bubbles: true,
812
+ cancelable: true,
813
+ view: window
814
+ })
815
+ );
816
+ }
817
+ return confirmSubmission('clicked_send_button');
818
+ }
819
+ input.focus();
820
+ const keyboardEventInit = {
821
+ bubbles: true,
822
+ cancelable: true,
823
+ key: 'Enter',
824
+ code: 'Enter',
825
+ keyCode: 13,
826
+ which: 13
827
+ };
828
+ input.dispatchEvent(new KeyboardEvent('keydown', keyboardEventInit));
829
+ input.dispatchEvent(new KeyboardEvent('keypress', keyboardEventInit));
830
+ input.dispatchEvent(new KeyboardEvent('keyup', keyboardEventInit));
831
+ return confirmSubmission('pressed_enter');
832
+ })();`;
833
+ }
834
+ buildComposerSubmissionStateScript() {
835
+ return `(() => {
836
+ const selectors = [
837
+ '[data-codex-composer="true"]',
838
+ 'textarea',
839
+ 'input[type="text"]',
840
+ '[contenteditable="true"]',
841
+ '[role="textbox"]'
842
+ ];
843
+ const input = selectors
844
+ .flatMap((selector) => Array.from(document.querySelectorAll(selector)))
845
+ .find((candidate) => {
846
+ if (!(candidate instanceof HTMLElement)) {
847
+ return false;
848
+ }
849
+ if (candidate.hasAttribute('disabled') || candidate.getAttribute('aria-disabled') === 'true') {
850
+ return false;
851
+ }
852
+ const rect = candidate.getBoundingClientRect();
853
+ return rect.width > 0 && rect.height > 0;
854
+ });
855
+ if (!(input instanceof HTMLElement)) {
856
+ return { submitted: false, reason: 'input_not_found' };
857
+ }
858
+ const currentText =
859
+ 'value' in input && typeof input.value === 'string'
860
+ ? input.value.trim()
861
+ : (input.textContent || '').trim();
862
+ const sendButton = Array.from(document.querySelectorAll('button, [role="button"]')).find((candidate) => {
863
+ if (!(candidate instanceof HTMLElement)) {
864
+ return false;
865
+ }
866
+ const className = typeof candidate.className === 'string' ? candidate.className : '';
867
+ return className.includes('size-token-button-composer') && className.includes('bg-token-foreground');
868
+ });
869
+ const buttonHtml = sendButton instanceof HTMLElement ? sendButton.innerHTML : '';
870
+ const isStreamingButton = buttonHtml.includes('M4.5 5.75C4.5 5.05964');
871
+ return {
872
+ submitted: currentText.length === 0 || isStreamingButton,
873
+ reason: currentText.length === 0 ? 'composer_cleared' : (isStreamingButton ? 'entered_streaming_state' : 'submit_not_confirmed')
874
+ };
875
+ })();`;
876
+ }
877
+ buildAssistantReplyProbeScript() {
878
+ return `(() => {
879
+ const assistantUnits = Array.from(
880
+ document.querySelectorAll('[data-content-search-unit-key$=":assistant"]')
881
+ );
882
+ const latestAssistantUnit = assistantUnits.at(-1);
883
+ if (!(latestAssistantUnit instanceof HTMLElement)) {
884
+ return null;
885
+ }
886
+ const normalizeReference = (value) => {
887
+ if (!value || typeof value !== 'string') {
888
+ return null;
889
+ }
890
+ if (value.startsWith('file://')) {
891
+ try {
892
+ return decodeURIComponent(new URL(value).pathname);
893
+ } catch {
894
+ return value;
895
+ }
896
+ }
897
+ if (
898
+ value.startsWith('http://') ||
899
+ value.startsWith('https://') ||
900
+ value.startsWith('/') ||
901
+ value.startsWith('data:')
902
+ ) {
903
+ return value;
904
+ }
905
+ return null;
906
+ };
907
+ const isLocalReference = (value) =>
908
+ typeof value === 'string' &&
909
+ (
910
+ value.startsWith('/') ||
911
+ value.startsWith('./') ||
912
+ value.startsWith('../') ||
913
+ /^[A-Za-z]:[\\\\/]/.test(value)
914
+ );
915
+ const serializeRichContent = (root) => {
916
+ const clone = root.cloneNode(true);
917
+ if (!(clone instanceof HTMLElement)) {
918
+ return root.innerText.trim();
919
+ }
920
+ clone.querySelectorAll('a[href]').forEach((link) => {
921
+ if (!(link instanceof HTMLAnchorElement)) {
922
+ return;
923
+ }
924
+ const href = normalizeReference(link.href) || link.getAttribute('href') || '';
925
+ const text = (link.textContent || '').trim();
926
+ const replacement = href && text && text !== href
927
+ ? text + '\\n' + href
928
+ : (href || text);
929
+ link.textContent = replacement;
930
+ });
931
+ const serializeNode = (node, listContext) => {
932
+ if (node instanceof HTMLBRElement) {
933
+ return '\\n';
934
+ }
935
+ if (node.nodeType === Node.TEXT_NODE) {
936
+ return node.textContent || '';
937
+ }
938
+ if (!(node instanceof HTMLElement)) {
939
+ return '';
940
+ }
941
+
942
+ const tagName = node.tagName;
943
+ if (
944
+ tagName === 'DIV' &&
945
+ typeof node.className === 'string' &&
946
+ node.className.includes('bg-token-text-code-block-background')
947
+ ) {
948
+ const codeElement = node.querySelector('code');
949
+ if (codeElement instanceof HTMLElement) {
950
+ const codeSource = codeElement.innerText || '';
951
+ const normalizedCode = codeSource
952
+ .replace(/\\r\\n/g, '\\n')
953
+ .replace(/\\u00a0/g, ' ')
954
+ .replace(/\\n+$/g, '');
955
+ if (normalizedCode.trim()) {
956
+ const languageNode = node.querySelector('.min-w-0.truncate');
957
+ const languageText = languageNode instanceof HTMLElement
958
+ ? (languageNode.textContent || '').trim()
959
+ : '';
960
+ const language = /^[A-Za-z0-9_+#.-]{1,24}$/.test(languageText) ? languageText : '';
961
+ return '\`\`\`' + language + '\\n' + normalizedCode + '\\n\`\`\`' + '\\n';
962
+ }
963
+ }
964
+ }
965
+
966
+ if (tagName === 'PRE') {
967
+ const codeElement = node.querySelector('code');
968
+ const codeSource = codeElement instanceof HTMLElement ? codeElement.innerText : node.innerText;
969
+ const normalizedCode = (codeSource || '')
970
+ .replace(/\\r\\n/g, '\\n')
971
+ .replace(/\\u00a0/g, ' ')
972
+ .replace(/\\n+$/g, '');
973
+ if (!normalizedCode.trim()) {
974
+ return '';
975
+ }
976
+
977
+ let language = '';
978
+ if (codeElement instanceof HTMLElement) {
979
+ const classNames = Array.from(codeElement.classList.values());
980
+ const languageClass = classNames.find((value) => /^language[-:]/i.test(value));
981
+ if (languageClass) {
982
+ language = languageClass.replace(/^language[-:]/i, '').trim();
983
+ }
984
+ }
985
+
986
+ const lines = normalizedCode.split('\\n');
987
+ if (!language && lines.length > 1) {
988
+ const firstLine = lines[0].trim();
989
+ if (/^[A-Za-z0-9_+#.-]{1,24}$/.test(firstLine)) {
990
+ language = firstLine;
991
+ lines.shift();
992
+ }
993
+ }
994
+
995
+ const fencedBody = lines.join('\\n').replace(/\\n+$/g, '');
996
+ return '\`\`\`' + language + '\\n' + fencedBody + '\\n\`\`\`' + '\\n';
997
+ }
998
+
999
+ if (tagName === 'TABLE') {
1000
+ const rows = Array.from(node.querySelectorAll('tr'))
1001
+ .map((row) =>
1002
+ Array.from(row.querySelectorAll('th, td'))
1003
+ .map((cell) => (cell.textContent || '').replace(/\\s+/g, ' ').trim())
1004
+ )
1005
+ .filter((cells) => cells.length > 0);
1006
+ if (!rows.length) {
1007
+ return '';
1008
+ }
1009
+
1010
+ const header = rows[0];
1011
+ const separator = header.map(() => '---');
1012
+ const bodyRows = rows.slice(1);
1013
+ const markdownRows = [
1014
+ '| ' + header.join(' | ') + ' |',
1015
+ '| ' + separator.join(' | ') + ' |',
1016
+ ...bodyRows.map((cells) => '| ' + cells.join(' | ') + ' |')
1017
+ ];
1018
+ return markdownRows.join('\\n') + '\\n';
1019
+ }
1020
+
1021
+ if (tagName === 'OL') {
1022
+ return Array.from(node.children)
1023
+ .map((child, index) => serializeNode(child, { type: 'ol', index }))
1024
+ .filter(Boolean)
1025
+ .join('\\n');
1026
+ }
1027
+
1028
+ if (tagName === 'UL') {
1029
+ return Array.from(node.children)
1030
+ .map((child) => serializeNode(child, { type: 'ul' }))
1031
+ .filter(Boolean)
1032
+ .join('\\n');
1033
+ }
1034
+
1035
+ if (tagName === 'LI') {
1036
+ const content = Array.from(node.childNodes)
1037
+ .map((child) => serializeNode(child, null))
1038
+ .join('')
1039
+ .replace(/\\s+\\n/g, '\\n')
1040
+ .replace(/\\n\\s+/g, '\\n')
1041
+ .replace(/[ \\t]+/g, ' ')
1042
+ .trim();
1043
+ if (!content) {
1044
+ return '';
1045
+ }
1046
+ if (listContext?.type === 'ol') {
1047
+ const index = typeof listContext.index === 'number' ? listContext.index : 0;
1048
+ return String(index + 1) + '. ' + content;
1049
+ }
1050
+ return '- ' + content;
1051
+ }
1052
+
1053
+ const serializedChildren = Array.from(node.childNodes)
1054
+ .map((child) => serializeNode(child, null))
1055
+ .join('');
1056
+ if (['P', 'DIV', 'SECTION', 'ARTICLE', 'BLOCKQUOTE'].includes(tagName)) {
1057
+ return serializedChildren.trim() ? serializedChildren.trim() + '\\n' : '';
1058
+ }
1059
+ return serializedChildren;
1060
+ };
1061
+ return serializeNode(clone, null)
1062
+ .replace(/[ \\t]+\\n/g, '\\n')
1063
+ .replace(/\\n{3,}/g, '\\n\\n')
1064
+ .trim();
1065
+ };
1066
+ const mediaReferences = Array.from(
1067
+ latestAssistantUnit.querySelectorAll('img[src], audio[src], audio source[src], video[src], video source[src], a[href]')
1068
+ )
1069
+ .map((node) => {
1070
+ if (!(node instanceof HTMLElement)) {
1071
+ return null;
1072
+ }
1073
+ if ('src' in node && typeof node.src === 'string' && node.src) {
1074
+ return normalizeReference(node.src);
1075
+ }
1076
+ if ('href' in node && typeof node.href === 'string' && node.href) {
1077
+ const normalizedHref = normalizeReference(node.href);
1078
+ return normalizedHref && isLocalReference(normalizedHref)
1079
+ ? normalizedHref
1080
+ : null;
1081
+ }
1082
+ return null;
1083
+ })
1084
+ .filter((value, index, values) => typeof value === 'string' && values.indexOf(value) === index);
1085
+ const composer = document.querySelector(
1086
+ '[data-codex-composer="true"], textarea, input[type="text"], [contenteditable="true"], [role="textbox"]'
1087
+ );
1088
+ const composerRect = composer instanceof HTMLElement
1089
+ ? composer.getBoundingClientRect()
1090
+ : null;
1091
+ const streamingMatcher = /(\\bstop\\b|\\bthinking\\b|\\bworking\\b|\\brunning\\b|停止|中止|取消|思考中|生成中)/i;
1092
+ const assistantStatusMatcher = /(Reconnecting\\.{3}|Searching\\.{3}|Running\\.{3}|Working\\.{3}|连接中\\.{0,3}|重新连接中\\.{0,3}|搜索中\\.{0,3}|执行中\\.{0,3}|处理中\\.{0,3})/i;
1093
+ const isComposerBusyButton = (node) => {
1094
+ if (!(node instanceof HTMLElement)) {
1095
+ return false;
1096
+ }
1097
+ const className = String(node.className || '');
1098
+ if (!className.includes('size-token-button-composer')) {
1099
+ return false;
1100
+ }
1101
+ const html = node.innerHTML || '';
1102
+ return html.includes('M4.5 5.75C4.5 5.05964')
1103
+ || html.includes('M4.5 5.75C4.5 5.0596');
1104
+ };
1105
+ const isStreaming = Array.from(document.querySelectorAll('button, [role="button"], [aria-busy="true"]'))
1106
+ .some((node) => {
1107
+ if (!(node instanceof HTMLElement)) {
1108
+ return false;
1109
+ }
1110
+ const rect = node.getBoundingClientRect();
1111
+ const isNearComposer = composerRect
1112
+ ? rect.y >= composerRect.y - 48 && rect.y <= composerRect.bottom + 48
1113
+ : rect.y >= window.innerHeight - 160;
1114
+ if (node.getAttribute('aria-busy') === 'true') {
1115
+ return true;
1116
+ }
1117
+ if (isComposerBusyButton(node)) {
1118
+ return true;
1119
+ }
1120
+ if (!isNearComposer) {
1121
+ return false;
1122
+ }
1123
+ const label = [
1124
+ node.textContent || '',
1125
+ node.getAttribute('aria-label') || '',
1126
+ node.getAttribute('title') || ''
1127
+ ].join(' ').trim();
1128
+ return streamingMatcher.test(label);
1129
+ });
1130
+ const assistantStatusText = Array.from(
1131
+ latestAssistantUnit.querySelectorAll('.text-xs, [aria-live], [data-state], [class*="status"], [class*="loading"]')
1132
+ )
1133
+ .map((node) => (node instanceof HTMLElement ? node.innerText || '' : ''))
1134
+ .join('\\n');
1135
+ const hasAssistantActivity = assistantStatusMatcher.test(assistantStatusText)
1136
+ || assistantStatusMatcher.test(latestAssistantUnit.innerText || '');
1137
+
1138
+ const richContent = latestAssistantUnit.querySelector('[class*="_markdownContent_"]');
1139
+ if (richContent instanceof HTMLElement) {
1140
+ const text = serializeRichContent(richContent);
1141
+ if (text) {
1142
+ return {
1143
+ unitKey: latestAssistantUnit.getAttribute('data-content-search-unit-key'),
1144
+ reply: text,
1145
+ mediaReferences,
1146
+ isStreaming: isStreaming || hasAssistantActivity
1147
+ };
1148
+ }
1149
+ }
1150
+
1151
+ const sanitizedUnit = latestAssistantUnit.cloneNode(true);
1152
+ if (!(sanitizedUnit instanceof HTMLElement)) {
1153
+ return null;
1154
+ }
1155
+ sanitizedUnit
1156
+ .querySelectorAll('button, [role="button"], [aria-label], .text-xs')
1157
+ .forEach((node) => node.remove());
1158
+ const text = sanitizedUnit.innerText
1159
+ .split('\\n')
1160
+ .map((line) => line.trim())
1161
+ .filter(Boolean)
1162
+ .join('\\n')
1163
+ .trim();
1164
+ return text || mediaReferences.length > 0
1165
+ ? {
1166
+ unitKey: latestAssistantUnit.getAttribute('data-content-search-unit-key'),
1167
+ reply: text || null,
1168
+ mediaReferences,
1169
+ isStreaming: isStreaming || hasAssistantActivity
1170
+ }
1171
+ : null;
1172
+ })();`;
1173
+ }
1174
+ }
1175
+ function buildMediaArtifactFromReference(reference) {
1176
+ const normalizedReference = reference.trim();
1177
+ const strippedReference = normalizedReference.split("?")[0] ?? normalizedReference;
1178
+ const lowerReference = strippedReference.toLowerCase();
1179
+ const originalName = inferOriginalName(strippedReference);
1180
+ const mimeType = inferMimeType(lowerReference);
1181
+ return {
1182
+ kind: inferMediaArtifactKind(lowerReference, mimeType),
1183
+ sourceUrl: normalizedReference,
1184
+ localPath: normalizedReference,
1185
+ mimeType,
1186
+ fileSize: 0,
1187
+ originalName
1188
+ };
1189
+ }
1190
+ function inferMediaArtifactKind(reference, mimeType) {
1191
+ if (mimeType.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp|bmp)$/i.test(reference)) {
1192
+ return MediaArtifactKind.Image;
1193
+ }
1194
+ if (mimeType.startsWith("audio/") || /\.(mp3|wav|ogg|aac|flac|silk)$/i.test(reference)) {
1195
+ return MediaArtifactKind.Audio;
1196
+ }
1197
+ if (mimeType.startsWith("video/") || /\.(mp4|mov|avi|mkv|webm)$/i.test(reference)) {
1198
+ return MediaArtifactKind.Video;
1199
+ }
1200
+ return MediaArtifactKind.File;
1201
+ }
1202
+ function inferMimeType(reference) {
1203
+ if (reference.startsWith("data:image/")) {
1204
+ const match = reference.match(/^data:(image\/[^;]+);/i);
1205
+ return match?.[1] ?? "image/png";
1206
+ }
1207
+ if (/\.png$/i.test(reference))
1208
+ return "image/png";
1209
+ if (/\.(jpg|jpeg)$/i.test(reference))
1210
+ return "image/jpeg";
1211
+ if (/\.gif$/i.test(reference))
1212
+ return "image/gif";
1213
+ if (/\.webp$/i.test(reference))
1214
+ return "image/webp";
1215
+ if (/\.bmp$/i.test(reference))
1216
+ return "image/bmp";
1217
+ if (/\.mp3$/i.test(reference))
1218
+ return "audio/mpeg";
1219
+ if (/\.wav$/i.test(reference))
1220
+ return "audio/wav";
1221
+ if (/\.ogg$/i.test(reference))
1222
+ return "audio/ogg";
1223
+ if (/\.aac$/i.test(reference))
1224
+ return "audio/aac";
1225
+ if (/\.flac$/i.test(reference))
1226
+ return "audio/flac";
1227
+ if (/\.silk$/i.test(reference))
1228
+ return "audio/silk";
1229
+ if (/\.mp4$/i.test(reference))
1230
+ return "video/mp4";
1231
+ if (/\.mov$/i.test(reference))
1232
+ return "video/quicktime";
1233
+ if (/\.avi$/i.test(reference))
1234
+ return "video/x-msvideo";
1235
+ if (/\.mkv$/i.test(reference))
1236
+ return "video/x-matroska";
1237
+ if (/\.webm$/i.test(reference))
1238
+ return "video/webm";
1239
+ if (/\.pdf$/i.test(reference))
1240
+ return "application/pdf";
1241
+ return "application/octet-stream";
1242
+ }
1243
+ function inferOriginalName(reference) {
1244
+ try {
1245
+ if (reference.startsWith("data:image/")) {
1246
+ return "codex-inline-image";
1247
+ }
1248
+ const url = reference.startsWith("http://") || reference.startsWith("https://")
1249
+ ? new URL(reference)
1250
+ : null;
1251
+ const pathname = url?.pathname ?? reference;
1252
+ const segments = pathname.split("/");
1253
+ return segments.at(-1) || "codex-media";
1254
+ }
1255
+ catch {
1256
+ const segments = reference.split("/");
1257
+ return segments.at(-1) || "codex-media";
1258
+ }
1259
+ }