im_wower
·
2026-04-01
controller.js
1const LEGACY_STORAGE_KEYS = {
2 claudeTabId: "baaFirefox.claudeTabId",
3 endpoints: "baaFirefox.endpoints",
4 lastHeaders: "baaFirefox.lastHeaders",
5 lastCredentialAt: "baaFirefox.lastCredentialAt"
6};
7
8const CONTROLLER_STORAGE_KEYS = {
9 clientId: "baaFirefox.clientId",
10 wsUrl: "baaFirefox.wsUrl",
11 controlBaseUrl: "baaFirefox.controlBaseUrl",
12 controlState: "baaFirefox.controlState",
13 pageControls: "baaFirefox.pageControls",
14 statusSchemaVersion: "baaFirefox.statusSchemaVersion",
15 trackedTabs: "baaFirefox.trackedTabs",
16 desiredTabs: "baaFirefox.desiredTabs",
17 controllerRuntime: "baaFirefox.controllerRuntime",
18 endpointsByPlatform: "baaFirefox.endpointsByPlatform",
19 lastHeadersByPlatform: "baaFirefox.lastHeadersByPlatform",
20 credentialCapturedAtByPlatform: "baaFirefox.credentialCapturedAtByPlatform",
21 lastCredentialAtByPlatform: "baaFirefox.lastCredentialAtByPlatform",
22 lastCredentialUrlByPlatform: "baaFirefox.lastCredentialUrlByPlatform",
23 lastCredentialTabIdByPlatform: "baaFirefox.lastCredentialTabIdByPlatform",
24 credentialFingerprintByPlatform: "baaFirefox.credentialFingerprintByPlatform",
25 accountByPlatform: "baaFirefox.accountByPlatform",
26 chatgptSendTemplates: "baaFirefox.chatgptSendTemplates",
27 geminiSendTemplates: "baaFirefox.geminiSendTemplates",
28 geminiSendTemplate: "baaFirefox.geminiSendTemplate",
29 claudeState: "baaFirefox.claudeState",
30 finalMessageRelayCache: "baaFirefox.finalMessageRelayCache"
31};
32
33const DEFAULT_LOCAL_API_BASE = "http://100.71.210.78:4317";
34const DEFAULT_WS_URL = "ws://100.71.210.78:4317/ws/firefox";
35const DEFAULT_CONTROL_BASE_URL = "http://100.71.210.78:4317";
36const STATUS_SCHEMA_VERSION = 5;
37const CREDENTIAL_SEND_INTERVAL = 30_000;
38const CREDENTIAL_TTL = 15 * 60_000;
39const NETWORK_BODY_LIMIT = 5000;
40const LOG_LIMIT = 500;
41const PLUGIN_DIAGNOSTIC_BUFFER_LIMIT = 50;
42const PROXY_MESSAGE_RETRY = 10;
43const PROXY_MESSAGE_RETRY_DELAY = 400;
44const CONTROL_REFRESH_INTERVAL = 15_000;
45const CONTROL_RETRY_DELAYS = [1_000, 3_000, 5_000];
46const CONTROL_RETRY_SLOW_INTERVAL = 30_000;
47const CONTROL_RETRY_LOG_INTERVAL = 60_000;
48const TRACKED_TAB_REFRESH_DELAY = 150;
49const SHELL_RUNTIME_HEALTHCHECK_INTERVAL = 30_000;
50const CONTROL_STATUS_BODY_LIMIT = 12_000;
51const FINAL_MESSAGE_RELAY_CACHE_LIMIT = 20;
52const STARTUP_OPEN_AI_TAB_RELOAD_DELAY = 2_500;
53const SESSION_CONTROLLER_BOOT_MARKER_KEY = "baaFirefox.controllerSessionBootMarker";
54const CONTENT_SCRIPT_INJECTION_FILES = ["delivery-adapters.js", "content-script.js"];
55const PAGE_INTERCEPTOR_INJECTION_FILES = ["page-interceptor.js"];
56const WS_RECONNECT_DELAY = 3_000;
57const MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS = 80;
58const MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS = 60_000;
59const MANUAL_WS_RECONNECT_MAX_REPEAT_COUNT = 20;
60const MANUAL_WS_RECONNECT_MAX_REPEAT_INTERVAL_MS = 60_000;
61const MANUAL_WS_RECONNECT_OPEN_TIMEOUT = 10_000;
62const MANUAL_WS_RECONNECT_POLL_INTERVAL = 100;
63const MANUAL_WS_RECONNECT_START_DELAY_MS = 80;
64const PROXY_REQUEST_TIMEOUT = 180_000;
65const DELIVERY_COMMAND_TIMEOUT = 30_000;
66const CLAUDE_MESSAGE_LIMIT = 20;
67const CLAUDE_TOOL_PLACEHOLDER_RE = /```\n?This block is not supported on your current device yet\.?\n?```/g;
68const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|George|User |Looking at|This is a|OK[,.]|Alright|Hmm|Now |Here|So |Wait|Actually|My |Their |His |Her |We |用户|让我|我需要|我来|我想|好的|那|先|接下来)/;
69const SHELL_TAB_HASH = "#baa-shell";
70const REDACTED_CREDENTIAL_VALUE = "[redacted]";
71const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
72const CHATGPT_SEND_TEMPLATE_LIMIT = 12;
73const CHATGPT_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
74const GEMINI_SEND_TEMPLATE_LIMIT = 12;
75const GEMINI_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
76const GEMINI_SHELL_TEMPLATE_KEY = "__shell__";
77const GEMINI_FALLBACK_TEMPLATE_KEY = "__fallback__";
78const CHATGPT_SESSION_COOKIE_PATTERNS = [
79 /__secure-next-auth\.session-token=/i,
80 /__secure-authjs\.session-token=/i,
81 /next-auth\.session-token=/i
82];
83const FORBIDDEN_PROXY_HEADER_NAMES = new Set([
84 "accept-encoding",
85 "connection",
86 "content-length",
87 "cookie",
88 "host",
89 "origin",
90 "referer",
91 "user-agent"
92]);
93const DIAGNOSTIC_LOG_DEBUG_PREFIXES = ["[FM-", "[SSE]"];
94const DIAGNOSTIC_LOG_DEBUG_EVENT_RE = /\b(page_bridge_ready|interceptor_active|fetch_intercepted|sse_stream_start|sse_stream_done)\b/u;
95
96function hostnameMatches(hostname, hosts) {
97 return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
98}
99
100function isLikelyStaticPath(pathname = "") {
101 const lower = pathname.toLowerCase();
102 return lower.startsWith("/_next/")
103 || lower.startsWith("/assets/")
104 || lower.startsWith("/static/")
105 || lower.startsWith("/images/")
106 || lower.startsWith("/fonts/")
107 || lower.startsWith("/favicon")
108 || /\.(?:js|mjs|css|map|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|mp4|webm|txt)$/i.test(lower);
109}
110
111const PLATFORMS = {
112 claude: {
113 label: "Claude",
114 rootUrl: "https://claude.ai/",
115 shellUrl: `https://claude.ai/${SHELL_TAB_HASH}`,
116 urlPatterns: ["*://claude.ai/*"],
117 hosts: ["claude.ai"],
118 requestUrlPatterns: ["*://claude.ai/*"],
119 requestHosts: ["claude.ai"],
120 matchesTabHost(hostname) {
121 return hostnameMatches(hostname, this.hosts);
122 },
123 matchesRequestHost(hostname) {
124 return hostnameMatches(hostname, this.requestHosts);
125 },
126 shouldTrackPath(pathname) {
127 return pathname.includes("/api/");
128 }
129 },
130 chatgpt: {
131 label: "ChatGPT",
132 rootUrl: "https://chatgpt.com/",
133 shellUrl: `https://chatgpt.com/${SHELL_TAB_HASH}`,
134 urlPatterns: ["*://chatgpt.com/*", "*://*.chatgpt.com/*", "*://chat.openai.com/*", "*://*.chat.openai.com/*"],
135 hosts: ["chatgpt.com", "chat.openai.com"],
136 requestUrlPatterns: [
137 "*://chatgpt.com/*",
138 "*://*.chatgpt.com/*",
139 "*://openai.com/*",
140 "*://*.openai.com/*",
141 "*://chat.openai.com/*",
142 "*://*.chat.openai.com/*",
143 "*://oaiusercontent.com/*",
144 "*://*.oaiusercontent.com/*"
145 ],
146 requestHosts: ["chatgpt.com", "chat.openai.com", "openai.com", "oaiusercontent.com"],
147 matchesTabHost(hostname) {
148 return hostnameMatches(hostname, this.hosts);
149 },
150 matchesRequestHost(hostname) {
151 return hostnameMatches(hostname, this.requestHosts);
152 },
153 shouldTrackPath(pathname) {
154 const lower = pathname.toLowerCase();
155 return pathname.includes("/backend-api/")
156 || pathname.includes("/backend-anon/")
157 || pathname.includes("/public-api/")
158 || lower.includes("/conversation")
159 || lower.includes("/models")
160 || lower.includes("/files")
161 || (!isLikelyStaticPath(pathname) && (lower.includes("/api/") || lower.includes("/backend")));
162 }
163 },
164 gemini: {
165 label: "Gemini",
166 rootUrl: "https://gemini.google.com/",
167 shellUrl: `https://gemini.google.com/${SHELL_TAB_HASH}`,
168 urlPatterns: ["*://gemini.google.com/*"],
169 hosts: ["gemini.google.com"],
170 requestUrlPatterns: ["*://gemini.google.com/*"],
171 requestHosts: ["gemini.google.com"],
172 matchesTabHost(hostname) {
173 return hostnameMatches(hostname, this.hosts);
174 },
175 matchesRequestHost(hostname) {
176 return hostnameMatches(hostname, this.requestHosts);
177 },
178 shouldTrackPath(pathname) {
179 const lower = pathname.toLowerCase();
180 return pathname.startsWith("/_/")
181 || pathname.includes("/api/")
182 || lower.includes("bardchatui")
183 || lower.includes("streamgenerate")
184 || lower.includes("generatecontent")
185 || lower.includes("modelresponse")
186 || (!isLikelyStaticPath(pathname) && lower.includes("assistant"));
187 }
188 }
189};
190
191const PLATFORM_ORDER = Object.keys(PLATFORMS);
192const PLATFORM_REQUEST_URL_PATTERNS = PLATFORM_ORDER.flatMap((platform) => PLATFORMS[platform].requestUrlPatterns || PLATFORMS[platform].urlPatterns);
193const pendingProxyRequests = new Map();
194const FINAL_MESSAGE_HELPERS = globalThis.BAAFinalMessage || null;
195
196const state = {
197 clientId: null,
198 wsUrl: DEFAULT_WS_URL,
199 controlBaseUrl: DEFAULT_CONTROL_BASE_URL,
200 controlState: null,
201 pageControls: {},
202 wsState: null,
203 ws: null,
204 wsConnected: false,
205 reconnectTimer: null,
206 manualWsReconnectSequence: 0,
207 controlRefreshTimer: null,
208 controlRefreshInFlight: null,
209 lastControlFailureLogAt: 0,
210 lastControlFailureKey: "",
211 trackedTabRefreshTimer: null,
212 shellRuntimeTimer: null,
213 startupOpenAiTabReloadTimer: null,
214 shellRuntimeLastHealthCheckAt: 0,
215 trackedTabRefreshRunning: false,
216 trackedTabRefreshQueued: false,
217 trackedTabs: createPlatformMap(() => null),
218 desiredTabs: createPlatformMap((platform) => createDefaultDesiredTabState(platform)),
219 actualTabs: createPlatformMap(() => createDefaultActualTabState()),
220 endpoints: createPlatformMap(() => ({})),
221 lastHeaders: createPlatformMap(() => ({})),
222 credentialCapturedAt: createPlatformMap(() => 0),
223 lastCredentialAt: createPlatformMap(() => 0),
224 lastCredentialUrl: createPlatformMap(() => ""),
225 lastCredentialTabId: createPlatformMap(() => null),
226 credentialFingerprint: createPlatformMap(() => ""),
227 account: createPlatformMap(() => createDefaultAccountState()),
228 lastCredentialHash: createPlatformMap(() => ""),
229 lastCredentialSentAt: createPlatformMap(() => 0),
230 chatgptSendTemplates: {},
231 geminiSendTemplates: {},
232 claudeState: createDefaultClaudeState(),
233 controllerRuntime: createDefaultControllerRuntimeState(),
234 finalMessageRelayObservers: createPlatformMap((platform) => createFinalMessageRelayObserver(platform)),
235 logs: [],
236 pendingPluginDiagnosticLogs: []
237};
238
239const ui = {};
240
241function qs(id) {
242 return document.getElementById(id);
243}
244
245function isRecord(value) {
246 return !!value && typeof value === "object" && !Array.isArray(value);
247}
248
249function trimTrailingSlash(value) {
250 return String(value || "").trim().replace(/\/+$/u, "");
251}
252
253function getPlatformShellUrl(platform) {
254 return PLATFORMS[platform]?.shellUrl || PLATFORMS[platform]?.rootUrl || "";
255}
256
257function matchesPlatformFallbackShellUrl(platform, parsed) {
258 const pathname = trimTrailingSlash(parsed?.pathname || "") || "/";
259
260 switch (platform) {
261 case "claude":
262 return pathname === "/new";
263 case "chatgpt":
264 return pathname === "/";
265 case "gemini":
266 return pathname === "/app";
267 default:
268 return false;
269 }
270}
271
272function isPlatformShellUrl(platform, url, options = {}) {
273 if (!platform || !PLATFORMS[platform] || !url) return false;
274 const allowFallback = options.allowFallback === true;
275
276 try {
277 const parsed = new URL(url, PLATFORMS[platform].rootUrl);
278 const expected = new URL(getPlatformShellUrl(platform));
279 if (parsed.origin === expected.origin && parsed.pathname === expected.pathname && parsed.hash === expected.hash) {
280 return true;
281 }
282
283 return allowFallback && parsed.origin === expected.origin && matchesPlatformFallbackShellUrl(platform, parsed);
284 } catch (_) {
285 return false;
286 }
287}
288
289function normalizeSavedControlBaseUrl(value) {
290 void value;
291 return DEFAULT_CONTROL_BASE_URL;
292}
293
294function getRuntimeOrigin() {
295 try {
296 return trimToNull(new URL(browser.runtime.getURL("controller.html")).origin);
297 } catch (_) {
298 return null;
299 }
300}
301
302function normalizeSavedWsUrl(value) {
303 void value;
304 return DEFAULT_WS_URL;
305}
306
307function deriveControlBaseUrl(wsUrl) {
308 void wsUrl;
309 return DEFAULT_CONTROL_BASE_URL;
310}
311
312function createDefaultControlState(overrides = {}) {
313 return {
314 mode: "unknown",
315 leader: null,
316 leaseHolder: null,
317 queueDepth: null,
318 activeRuns: null,
319 ok: false,
320 error: null,
321 message: null,
322 statusCode: null,
323 syncedAt: 0,
324 controlConnection: "disconnected",
325 retryCount: 0,
326 lastSuccessAt: 0,
327 lastFailureAt: 0,
328 nextRetryAt: 0,
329 source: "bootstrap",
330 raw: null,
331 ...overrides
332 };
333}
334
335function cloneControlState(value) {
336 if (!isRecord(value)) return createDefaultControlState();
337 return {
338 ...createDefaultControlState(),
339 ...value
340 };
341}
342
343function normalizeConversationAutomationStatus(value) {
344 const normalized = String(value || "").trim().toLowerCase();
345 return normalized === "auto" || normalized === "manual" || normalized === "paused"
346 ? normalized
347 : null;
348}
349
350function derivePageControlPaused(automationStatus, fallbackPaused = false) {
351 const normalizedAutomationStatus = normalizeConversationAutomationStatus(automationStatus);
352
353 if (normalizedAutomationStatus == null) {
354 return fallbackPaused === true;
355 }
356
357 return normalizedAutomationStatus !== "auto";
358}
359
360function createDefaultWsState(overrides = {}) {
361 return {
362 connection: "disconnected",
363 wsUrl: DEFAULT_WS_URL,
364 localApiBase: DEFAULT_LOCAL_API_BASE,
365 clientId: null,
366 protocol: null,
367 version: null,
368 serverIdentity: null,
369 serverHost: null,
370 serverRole: null,
371 leaseState: null,
372 clientCount: 0,
373 retryCount: 0,
374 nextRetryAt: 0,
375 lastOpenAt: 0,
376 lastMessageAt: 0,
377 lastSnapshotAt: 0,
378 lastCloseCode: null,
379 lastCloseReason: null,
380 lastError: null,
381 lastSnapshotReason: null,
382 raw: null,
383 ...overrides
384 };
385}
386
387function cloneWsState(value) {
388 if (!isRecord(value)) return createDefaultWsState();
389 return {
390 ...createDefaultWsState(),
391 ...value
392 };
393}
394
395function createDefaultDesiredTabState(platform, overrides = {}) {
396 return {
397 exists: false,
398 shellUrl: getPlatformShellUrl(platform),
399 source: "bootstrap",
400 reason: null,
401 updatedAt: 0,
402 lastAction: null,
403 lastActionAt: 0,
404 ...overrides
405 };
406}
407
408function cloneDesiredTabState(platform, value) {
409 if (typeof value === "boolean") {
410 return createDefaultDesiredTabState(platform, {
411 exists: value
412 });
413 }
414
415 if (!isRecord(value)) {
416 return createDefaultDesiredTabState(platform);
417 }
418
419 return createDefaultDesiredTabState(platform, {
420 exists: value.exists === true || value.shouldExist === true,
421 shellUrl: trimToNull(value.shellUrl) || getPlatformShellUrl(platform),
422 source: trimToNull(value.source) || "storage",
423 reason: trimToNull(value.reason),
424 updatedAt: Number(value.updatedAt) || 0,
425 lastAction: trimToNull(value.lastAction),
426 lastActionAt: Number(value.lastActionAt) || 0
427 });
428}
429
430function createDefaultActualTabState(overrides = {}) {
431 return {
432 exists: false,
433 tabId: null,
434 url: null,
435 title: null,
436 windowId: null,
437 active: false,
438 status: null,
439 discarded: false,
440 hidden: false,
441 healthy: false,
442 issue: "missing",
443 lastSeenAt: 0,
444 lastReadyAt: 0,
445 updatedAt: 0,
446 candidateTabId: null,
447 candidateUrl: null,
448 candidateTitle: null,
449 candidateStatus: null,
450 ...overrides
451 };
452}
453
454function cloneActualTabState(value) {
455 if (!isRecord(value)) {
456 return createDefaultActualTabState();
457 }
458
459 const hasIssue = Object.prototype.hasOwnProperty.call(value, "issue");
460 return createDefaultActualTabState({
461 exists: value.exists === true,
462 tabId: Number.isInteger(value.tabId) ? value.tabId : null,
463 url: trimToNull(value.url),
464 title: trimToNull(value.title),
465 windowId: Number.isInteger(value.windowId) ? value.windowId : null,
466 active: value.active === true,
467 status: trimToNull(value.status),
468 discarded: value.discarded === true,
469 hidden: value.hidden === true,
470 healthy: value.healthy === true,
471 issue: hasIssue ? trimToNull(value.issue) : "missing",
472 lastSeenAt: Number(value.lastSeenAt) || 0,
473 lastReadyAt: Number(value.lastReadyAt) || 0,
474 updatedAt: Number(value.updatedAt) || 0,
475 candidateTabId: Number.isInteger(value.candidateTabId) ? value.candidateTabId : null,
476 candidateUrl: trimToNull(value.candidateUrl),
477 candidateTitle: trimToNull(value.candidateTitle),
478 candidateStatus: trimToNull(value.candidateStatus)
479 });
480}
481
482function createDefaultControllerRuntimeState(overrides = {}) {
483 return {
484 tabId: null,
485 ready: false,
486 status: "booting",
487 lastReadyAt: 0,
488 lastReloadAt: 0,
489 lastAction: null,
490 lastActionAt: 0,
491 extensionOrigin: null,
492 ...overrides
493 };
494}
495
496function cloneControllerRuntimeState(value) {
497 if (!isRecord(value)) {
498 return createDefaultControllerRuntimeState();
499 }
500
501 return createDefaultControllerRuntimeState({
502 tabId: Number.isInteger(value.tabId) ? value.tabId : null,
503 ready: value.ready === true,
504 status: trimToNull(value.status) || "booting",
505 lastReadyAt: Number(value.lastReadyAt) || 0,
506 lastReloadAt: Number(value.lastReloadAt) || 0,
507 lastAction: trimToNull(value.lastAction),
508 lastActionAt: Number(value.lastActionAt) || 0,
509 extensionOrigin: trimToNull(value.extensionOrigin)
510 });
511}
512
513function createDefaultPageControlState(overrides = {}) {
514 return {
515 key: null,
516 platform: null,
517 tabId: null,
518 conversationId: null,
519 localConversationId: null,
520 pageUrl: null,
521 pageTitle: null,
522 shellPage: false,
523 automationStatus: null,
524 lastNonPausedAutomationStatus: null,
525 paused: false,
526 pauseSource: null,
527 pauseReason: null,
528 updatedAt: 0,
529 ...overrides
530 };
531}
532
533function clonePageControlState(value) {
534 if (!isRecord(value)) {
535 return createDefaultPageControlState();
536 }
537
538 const platform = trimToNull(value.platform);
539 const normalizedPlatform = platform && PLATFORMS[platform] ? platform : null;
540
541 return createDefaultPageControlState({
542 key: trimToNull(value.key),
543 platform: normalizedPlatform,
544 tabId: Number.isInteger(value.tabId) ? value.tabId : null,
545 conversationId: trimToNull(value.conversationId),
546 localConversationId: trimToNull(value.localConversationId),
547 pageUrl: trimToNull(value.pageUrl),
548 pageTitle: trimToNull(value.pageTitle),
549 shellPage: value.shellPage === true,
550 automationStatus: normalizeConversationAutomationStatus(value.automationStatus),
551 lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.lastNonPausedAutomationStatus),
552 paused: derivePageControlPaused(
553 normalizeConversationAutomationStatus(value.automationStatus),
554 value.paused === true
555 ),
556 pauseSource: trimToNull(value.pauseSource),
557 pauseReason: trimToNull(value.pauseReason),
558 updatedAt: Number(value.updatedAt) || 0
559 });
560}
561
562function getPageControlKey(platform, tabId) {
563 if (!platform || !PLATFORMS[platform] || !Number.isInteger(tabId) || tabId < 0) {
564 return null;
565 }
566
567 return `${platform}:${tabId}`;
568}
569
570function loadPageControls(raw) {
571 const next = {};
572
573 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
574 return next;
575 }
576
577 for (const value of Object.values(raw)) {
578 const entry = clonePageControlState(value);
579 const key = getPageControlKey(entry.platform, entry.tabId);
580
581 if (!key) {
582 continue;
583 }
584
585 next[key] = createDefaultPageControlState({
586 ...entry,
587 key
588 });
589 }
590
591 return next;
592}
593
594function listPageControlStates(options = {}) {
595 const platform = trimToNull(options.platform);
596 return Object.values(state.pageControls || {})
597 .map((entry) => clonePageControlState(entry))
598 .filter((entry) => entry.platform && Number.isInteger(entry.tabId))
599 .filter((entry) => !platform || entry.platform === platform)
600 .sort((left, right) => {
601 const leftKey = `${left.platform}:${String(left.tabId).padStart(12, "0")}`;
602 const rightKey = `${right.platform}:${String(right.tabId).padStart(12, "0")}`;
603 return leftKey.localeCompare(rightKey);
604 });
605}
606
607function getPageControlState(platform, tabId) {
608 const key = getPageControlKey(platform, tabId);
609 return key ? clonePageControlState(state.pageControls[key]) : createDefaultPageControlState();
610}
611
612function findPageControlByConversation(platform, conversationId) {
613 const normalizedConversationId = trimToNull(conversationId);
614
615 if (!platform || !normalizedConversationId) {
616 return null;
617 }
618
619 return listPageControlStates({ platform }).find((entry) => entry.conversationId === normalizedConversationId) || null;
620}
621
622function findPageControlByUrl(platform, pageUrl) {
623 const normalizedPageUrl = trimToNull(pageUrl);
624
625 if (!platform || !normalizedPageUrl) {
626 return null;
627 }
628
629 return listPageControlStates({ platform }).find((entry) => entry.pageUrl === normalizedPageUrl) || null;
630}
631
632function summarizePageControls(platform) {
633 const entries = listPageControlStates({ platform });
634
635 if (entries.length === 0) {
636 return "无";
637 }
638
639 const pausedEntries = entries.filter((entry) => entry.paused);
640
641 if (pausedEntries.length === 0) {
642 return `${entries.length} 页观察中`;
643 }
644
645 const labels = pausedEntries.map((entry) => {
646 const conversation = entry.conversationId ? ` conv=${entry.conversationId.slice(0, 12)}` : "";
647 return `#${entry.tabId}${conversation}`;
648 });
649
650 return `paused(${pausedEntries.length}/${entries.length}): ${labels.join(", ")}`;
651}
652
653function serializePageControlState(entry) {
654 const normalized = clonePageControlState(entry);
655
656 if (!normalized.platform || !Number.isInteger(normalized.tabId)) {
657 return null;
658 }
659
660 return {
661 key: getPageControlKey(normalized.platform, normalized.tabId),
662 platform: normalized.platform,
663 tabId: normalized.tabId,
664 conversationId: normalized.conversationId,
665 localConversationId: normalized.localConversationId,
666 pageUrl: normalized.pageUrl,
667 pageTitle: normalized.pageTitle,
668 shellPage: normalized.shellPage,
669 automationStatus: normalized.automationStatus,
670 lastNonPausedAutomationStatus: normalized.lastNonPausedAutomationStatus,
671 paused: normalized.paused,
672 pauseSource: normalized.pauseSource,
673 pauseReason: normalized.pauseReason,
674 status: normalized.automationStatus || (normalized.paused ? "paused" : "running"),
675 updatedAt: normalized.updatedAt || 0
676 };
677}
678
679function updatePageControlState(input = {}, options = {}) {
680 const platform = trimToNull(input.platform);
681 const tabId = Number.isInteger(input.tabId) ? input.tabId : null;
682 const key = getPageControlKey(platform, tabId);
683
684 if (!key) {
685 return null;
686 }
687
688 const previous = clonePageControlState(state.pageControls[key]);
689 const next = createDefaultPageControlState({
690 ...previous,
691 key,
692 platform,
693 tabId
694 });
695 let changed = !state.pageControls[key];
696 let conversationChanged = false;
697
698 if (Object.prototype.hasOwnProperty.call(input, "conversationId")) {
699 const conversationId = trimToNull(input.conversationId);
700 if (next.conversationId !== conversationId) {
701 next.conversationId = conversationId;
702 conversationChanged = true;
703 changed = true;
704 }
705 }
706
707 if (Object.prototype.hasOwnProperty.call(input, "localConversationId")) {
708 const localConversationId = trimToNull(input.localConversationId);
709 if (next.localConversationId !== localConversationId) {
710 next.localConversationId = localConversationId;
711 changed = true;
712 }
713 }
714
715 if (Object.prototype.hasOwnProperty.call(input, "pageUrl")) {
716 const pageUrl = trimToNull(input.pageUrl);
717 if (next.pageUrl !== pageUrl) {
718 next.pageUrl = pageUrl;
719 changed = true;
720 }
721 }
722
723 if (Object.prototype.hasOwnProperty.call(input, "pageTitle")) {
724 const pageTitle = trimToNull(input.pageTitle);
725 if (next.pageTitle !== pageTitle) {
726 next.pageTitle = pageTitle;
727 changed = true;
728 }
729 }
730
731 if (Object.prototype.hasOwnProperty.call(input, "shellPage")) {
732 const shellPage = input.shellPage === true;
733 if (next.shellPage !== shellPage) {
734 next.shellPage = shellPage;
735 changed = true;
736 }
737 }
738
739 if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
740 const automationStatus = normalizeConversationAutomationStatus(input.automationStatus);
741 if (next.automationStatus !== automationStatus) {
742 next.automationStatus = automationStatus;
743 changed = true;
744 }
745 }
746
747 if (Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")) {
748 const lastNonPausedAutomationStatus = normalizeConversationAutomationStatus(input.lastNonPausedAutomationStatus);
749 if (next.lastNonPausedAutomationStatus !== lastNonPausedAutomationStatus) {
750 next.lastNonPausedAutomationStatus = lastNonPausedAutomationStatus;
751 changed = true;
752 }
753 }
754
755 if (
756 conversationChanged
757 && !Object.prototype.hasOwnProperty.call(input, "automationStatus")
758 && !Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")
759 && !Object.prototype.hasOwnProperty.call(input, "localConversationId")
760 && previous.localConversationId != null
761 ) {
762 next.localConversationId = null;
763 next.automationStatus = null;
764 next.lastNonPausedAutomationStatus = null;
765 next.paused = false;
766 next.pauseSource = null;
767 next.pauseReason = null;
768 changed = true;
769 }
770
771 if (Object.prototype.hasOwnProperty.call(input, "paused")) {
772 const paused = input.paused === true;
773 const pauseSource = paused
774 ? (trimToNull(input.pauseSource) || trimToNull(options.source) || next.pauseSource)
775 : (trimToNull(input.pauseSource) || trimToNull(options.source) || null);
776 const pauseReason = paused
777 ? (trimToNull(input.pauseReason) || trimToNull(options.reason) || next.pauseReason || "page_paused")
778 : (trimToNull(input.pauseReason) || trimToNull(options.reason) || null);
779
780 if (next.paused !== paused) {
781 next.paused = paused;
782 changed = true;
783 }
784
785 if (next.pauseSource !== pauseSource) {
786 next.pauseSource = pauseSource;
787 changed = true;
788 }
789
790 if (next.pauseReason !== pauseReason) {
791 next.pauseReason = pauseReason;
792 changed = true;
793 }
794 }
795
796 if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
797 const paused = derivePageControlPaused(next.automationStatus, next.paused);
798 const pauseSource = paused
799 ? (trimToNull(input.pauseSource) || trimToNull(options.source) || next.pauseSource)
800 : null;
801 const pauseReason =
802 next.automationStatus === "paused"
803 ? (trimToNull(input.pauseReason) || trimToNull(options.reason) || next.pauseReason || "user_pause")
804 : null;
805
806 if (next.paused !== paused) {
807 next.paused = paused;
808 changed = true;
809 }
810
811 if (next.pauseSource !== pauseSource) {
812 next.pauseSource = pauseSource;
813 changed = true;
814 }
815
816 if (next.pauseReason !== pauseReason) {
817 next.pauseReason = pauseReason;
818 changed = true;
819 }
820 }
821
822 if (!changed) {
823 return serializePageControlState(previous);
824 }
825
826 next.updatedAt = Number(input.updatedAt) || Date.now();
827 state.pageControls[key] = next;
828
829 if (options.persist !== false) {
830 persistState().catch(() => {});
831 }
832
833 if (options.render !== false) {
834 render();
835 }
836
837 return serializePageControlState(next);
838}
839
840function removePageControlState(platform, tabId, options = {}) {
841 const key = getPageControlKey(platform, tabId);
842
843 if (!key || !state.pageControls[key]) {
844 return false;
845 }
846
847 delete state.pageControls[key];
848
849 if (options.persist !== false) {
850 persistState().catch(() => {});
851 }
852
853 if (options.render !== false) {
854 render();
855 }
856
857 return true;
858}
859
860function removePageControlStatesByTabId(tabId, options = {}) {
861 if (!Number.isInteger(tabId)) {
862 return 0;
863 }
864
865 const removedKeys = Object.keys(state.pageControls || {}).filter((key) => {
866 const entry = state.pageControls[key];
867 return Number.isInteger(entry?.tabId) && entry.tabId === tabId;
868 });
869
870 if (removedKeys.length === 0) {
871 return 0;
872 }
873
874 for (const key of removedKeys) {
875 delete state.pageControls[key];
876 }
877
878 if (options.persist !== false) {
879 persistState().catch(() => {});
880 }
881
882 if (options.render !== false) {
883 render();
884 }
885
886 return removedKeys.length;
887}
888
889function findPausedPageControlByConversation(platform, conversationId) {
890 const normalizedConversationId = trimToNull(conversationId);
891
892 if (!platform || !normalizedConversationId) {
893 return null;
894 }
895
896 return listPageControlStates({ platform }).find((entry) =>
897 entry.paused && entry.conversationId === normalizedConversationId
898 ) || null;
899}
900
901function normalizeClaudeMessageRole(value) {
902 if (value === "human" || value === "user") return "user";
903 if (value === "assistant") return "assistant";
904 return typeof value === "string" && value.trim() ? value.trim() : "unknown";
905}
906
907function parseClaudeTimestamp(value) {
908 if (Number.isFinite(value)) return Number(value);
909 if (typeof value === "string" && value.trim()) {
910 const timestamp = Date.parse(value);
911 return Number.isFinite(timestamp) ? timestamp : null;
912 }
913 return null;
914}
915
916function normalizeClaudeMessageContent(message) {
917 if (Array.isArray(message?.content) && message.content.length > 0) {
918 const textBlocks = message.content
919 .filter((block) => block?.type === "text" && typeof block.text === "string")
920 .map((block) => block.text);
921 const thinkingBlocks = message.content
922 .filter((block) => block?.type === "thinking" && typeof block.thinking === "string")
923 .map((block) => block.thinking);
924 return {
925 content: textBlocks.join("").trim() || null,
926 thinking: thinkingBlocks.join("").trim() || null
927 };
928 }
929
930 let raw = typeof message?.text === "string" ? message.text : "";
931 raw = raw.replace(CLAUDE_TOOL_PLACEHOLDER_RE, "").trim();
932 raw = raw.replace(/\n{3,}/g, "\n\n");
933
934 if (raw && normalizeClaudeMessageRole(message?.sender) === "assistant" && CLAUDE_THINKING_START_RE.test(raw)) {
935 const splitIndex = raw.search(/\n\n(?:[#*\-|>]|\*\*|\d+\.|[\u4e00-\u9fff])/);
936 if (splitIndex > 20) {
937 return {
938 content: raw.slice(splitIndex + 2).trim() || null,
939 thinking: raw.slice(0, splitIndex).trim() || null
940 };
941 }
942
943 return {
944 content: null,
945 thinking: raw
946 };
947 }
948
949 return {
950 content: raw || null,
951 thinking: null
952 };
953}
954
955function normalizeClaudeMessage(message, index = 0) {
956 const parts = normalizeClaudeMessageContent(message);
957 return {
958 id: typeof message?.uuid === "string" && message.uuid ? message.uuid : null,
959 role: normalizeClaudeMessageRole(message?.sender),
960 content: parts.content,
961 thinking: parts.thinking,
962 timestamp: parseClaudeTimestamp(message?.created_at),
963 seq: Number.isFinite(index) ? Number(index) : null
964 };
965}
966
967function normalizeClaudeMessages(messages, limit = CLAUDE_MESSAGE_LIMIT) {
968 const source = Array.isArray(messages) ? messages : [];
969 const normalized = source.map((message, index) => normalizeClaudeMessage(message, index));
970 if (normalized.length <= limit) return normalized;
971 return normalized.slice(-limit);
972}
973
974function createDefaultClaudeState(overrides = {}) {
975 return {
976 organizationId: null,
977 conversationId: null,
978 title: null,
979 titleSource: null,
980 currentUrl: PLATFORMS?.claude?.rootUrl || "https://claude.ai/",
981 tabId: null,
982 tabTitle: null,
983 busy: false,
984 busyReason: null,
985 lastError: null,
986 lastActivityAt: 0,
987 lastReadAt: 0,
988 lastSendAt: 0,
989 lastConversationSource: null,
990 lastAssistantMessageUuid: null,
991 model: null,
992 messages: [],
993 ...overrides
994 };
995}
996
997function cloneClaudeState(value) {
998 if (!isRecord(value)) return createDefaultClaudeState();
999 return {
1000 ...createDefaultClaudeState(),
1001 ...value,
1002 messages: Array.isArray(value.messages)
1003 ? value.messages.map((message) => ({
1004 id: typeof message?.id === "string" ? message.id : null,
1005 role: typeof message?.role === "string" ? message.role : "unknown",
1006 content: typeof message?.content === "string" ? message.content : null,
1007 thinking: typeof message?.thinking === "string" ? message.thinking : null,
1008 timestamp: Number.isFinite(message?.timestamp) ? Number(message.timestamp) : null,
1009 seq: Number.isFinite(message?.seq) ? Number(message.seq) : null
1010 }))
1011 : []
1012 };
1013}
1014
1015function loadClaudeState(raw) {
1016 const next = cloneClaudeState(raw);
1017 next.busy = false;
1018 next.busyReason = null;
1019 next.lastError = null;
1020 return next;
1021}
1022
1023function createDefaultAccountState(overrides = {}) {
1024 return {
1025 value: null,
1026 kind: null,
1027 source: null,
1028 capturedAt: 0,
1029 lastSeenAt: 0,
1030 priority: 0,
1031 ...overrides
1032 };
1033}
1034
1035function cloneAccountState(value) {
1036 if (!isRecord(value)) {
1037 return createDefaultAccountState();
1038 }
1039
1040 return createDefaultAccountState({
1041 value: trimToNull(value.value),
1042 kind: trimToNull(value.kind),
1043 source: trimToNull(value.source),
1044 capturedAt: Number(value.capturedAt) || 0,
1045 lastSeenAt: Number(value.lastSeenAt) || 0,
1046 priority: Number(value.priority) || 0
1047 });
1048}
1049
1050function trimToNull(value) {
1051 return typeof value === "string" && value.trim() ? value.trim() : null;
1052}
1053
1054function buildRuntimeRequestId(prefix = "runtime") {
1055 if (typeof crypto?.randomUUID === "function") {
1056 return `${prefix}-${crypto.randomUUID()}`;
1057 }
1058 return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1059}
1060
1061function parseClaudeApiContext(url) {
1062 try {
1063 const parsed = new URL(url, PLATFORMS.claude.rootUrl);
1064 const pathname = parsed.pathname || "/";
1065 const organizationMatch = pathname.match(/\/api\/organizations\/([a-f0-9-]{36})(?:\/|$)/i);
1066 const conversationMatch = pathname.match(/\/chat_conversations\/([a-f0-9-]{36})(?:\/|$)/i);
1067 return {
1068 pathname,
1069 organizationId: organizationMatch ? organizationMatch[1] : null,
1070 conversationId: conversationMatch ? conversationMatch[1] : null,
1071 isConversationList: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations$/i.test(pathname),
1072 isConversationItem: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}$/i.test(pathname),
1073 isCompletion: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}\/completion$/i.test(pathname)
1074 };
1075 } catch (_) {
1076 return {
1077 pathname: "",
1078 organizationId: null,
1079 conversationId: null,
1080 isConversationList: false,
1081 isConversationItem: false,
1082 isCompletion: false
1083 };
1084 }
1085}
1086
1087function extractChatgptConversationIdFromPageUrl(url) {
1088 try {
1089 const parsed = new URL(url, PLATFORMS.chatgpt.rootUrl);
1090 const pathname = parsed.pathname || "/";
1091 const match = pathname.match(/\/c\/([^/?#]+)/u);
1092
1093 if (match?.[1]) {
1094 return match[1];
1095 }
1096
1097 return trimToNull(parsed.searchParams.get("conversation_id"));
1098 } catch (_) {
1099 return null;
1100 }
1101}
1102
1103function extractGeminiConversationIdFromPageUrl(url) {
1104 try {
1105 const parsed = new URL(url, PLATFORMS.gemini.rootUrl);
1106 const pathname = parsed.pathname || "/";
1107 const match = pathname.match(/\/app\/([^/?#]+)/u);
1108
1109 if (match?.[1]) {
1110 return match[1];
1111 }
1112
1113 return trimToNull(parsed.searchParams.get("conversation_id"));
1114 } catch (_) {
1115 return null;
1116 }
1117}
1118
1119function extractClaudeConversationIdFromPageUrl(url) {
1120 try {
1121 const parsed = new URL(url, PLATFORMS.claude.rootUrl);
1122 const match = (parsed.pathname || "").match(/([a-f0-9-]{36})(?:\/)?$/i);
1123 return match ? match[1] : null;
1124 } catch (_) {
1125 return null;
1126 }
1127}
1128
1129function extractConversationIdFromPageUrl(platform, url) {
1130 switch (platform) {
1131 case "claude":
1132 return extractClaudeConversationIdFromPageUrl(url);
1133 case "chatgpt":
1134 return extractChatgptConversationIdFromPageUrl(url);
1135 case "gemini":
1136 return extractGeminiConversationIdFromPageUrl(url);
1137 default:
1138 return null;
1139 }
1140}
1141
1142function extractChatgptConversationIdFromRequestBody(reqBody) {
1143 if (typeof reqBody !== "string" || !reqBody.trim()) {
1144 return null;
1145 }
1146
1147 try {
1148 const parsed = JSON.parse(reqBody);
1149
1150 if (!isRecord(parsed)) {
1151 return null;
1152 }
1153
1154 return trimToNull(parsed.conversation_id) || trimToNull(parsed.conversationId) || null;
1155 } catch (_) {
1156 return null;
1157 }
1158}
1159
1160function extractObservedConversationId(platform, data, context = null) {
1161 if (context?.conversationId) {
1162 return context.conversationId;
1163 }
1164
1165 switch (platform) {
1166 case "claude":
1167 return parseClaudeApiContext(data?.url || "").conversationId || null;
1168 case "chatgpt":
1169 return extractChatgptConversationIdFromRequestBody(data?.reqBody)
1170 || trimToNull(data?.conversation_id)
1171 || trimToNull(data?.conversationId)
1172 || null;
1173 case "gemini":
1174 return trimToNull(data?.conversation_id)
1175 || trimToNull(data?.conversationId)
1176 || null;
1177 default:
1178 return null;
1179 }
1180}
1181
1182function extractClaudeOrgId(headers = {}, requestUrl = "") {
1183 const headerOrgId = trimToNull(headers["x-org-id"]);
1184 if (headerOrgId) return headerOrgId;
1185
1186 const context = parseClaudeApiContext(requestUrl);
1187 if (context.organizationId) return context.organizationId;
1188
1189 const cookie = getHeaderValue(headers, "cookie");
1190 const cookieMatch = cookie.match(/(?:^|;\s*)lastActiveOrg=([a-f0-9-]{36})/i);
1191 if (cookieMatch) return cookieMatch[1];
1192
1193 return trimToNull(state.claudeState.organizationId) || null;
1194}
1195
1196function getClaudeOrgId() {
1197 return extractClaudeOrgId(state.lastHeaders.claude, state.lastCredentialUrl.claude || "");
1198}
1199
1200function getClaudeConversationIdFromState() {
1201 return trimToNull(state.claudeState.conversationId)
1202 || extractClaudeConversationIdFromPageUrl(state.claudeState.currentUrl || "")
1203 || parseClaudeApiContext(state.lastCredentialUrl.claude || "").conversationId
1204 || null;
1205}
1206
1207function getLastClaudeMessageUuid(messages) {
1208 const source = Array.isArray(messages) ? messages : [];
1209 for (let index = source.length - 1; index >= 0; index -= 1) {
1210 if (source[index]?.id) return source[index].id;
1211 }
1212 return null;
1213}
1214
1215function buildClaudeStateSnapshot() {
1216 const snapshot = cloneClaudeState(state.claudeState);
1217 return {
1218 platform: "claude",
1219 organizationId: snapshot.organizationId,
1220 conversationId: snapshot.conversationId,
1221 title: snapshot.title,
1222 currentUrl: snapshot.currentUrl,
1223 tabId: snapshot.tabId,
1224 busy: snapshot.busy,
1225 lastError: snapshot.lastError,
1226 lastActivityAt: snapshot.lastActivityAt || null,
1227 lastReadAt: snapshot.lastReadAt || null,
1228 lastSendAt: snapshot.lastSendAt || null,
1229 recentMessages: Array.isArray(snapshot.messages)
1230 ? snapshot.messages.slice(-CLAUDE_MESSAGE_LIMIT)
1231 : [],
1232 sources: {
1233 title: snapshot.titleSource || null,
1234 messages: snapshot.lastConversationSource || null,
1235 busy: snapshot.busyReason || null,
1236 currentUrl: "tab"
1237 }
1238 };
1239}
1240
1241function updateClaudeState(patch = {}, options = {}) {
1242 state.claudeState = cloneClaudeState({
1243 ...state.claudeState,
1244 ...patch
1245 });
1246 if (options.persist) {
1247 persistState().catch(() => {});
1248 }
1249 if (options.render) {
1250 render();
1251 }
1252 return state.claudeState;
1253}
1254
1255async function refreshClaudeTabState(createIfMissing = false) {
1256 const allowFallbackShell = cloneDesiredTabState("claude", state.desiredTabs.claude).exists;
1257 const tab = createIfMissing
1258 ? await ensurePlatformTab("claude", { focus: false })
1259 : (await resolveTrackedTab("claude", { requireShell: true, allowFallbackShell })) || await findPlatformShellTab("claude", null, { allowFallbackShell });
1260
1261 if (!tab) {
1262 updateClaudeState({
1263 tabId: null,
1264 currentUrl: getPlatformShellUrl("claude"),
1265 tabTitle: null
1266 }, {
1267 persist: true,
1268 render: true
1269 });
1270 return null;
1271 }
1272
1273 updateClaudeState({
1274 tabId: Number.isInteger(tab.id) ? tab.id : null,
1275 currentUrl: tab.url || state.claudeState.currentUrl || getPlatformShellUrl("claude"),
1276 tabTitle: trimToNull(tab.title),
1277 conversationId: isPlatformShellUrl("claude", tab.url || "", { allowFallback: allowFallbackShell })
1278 ? null
1279 : (extractClaudeConversationIdFromPageUrl(tab.url || "") || state.claudeState.conversationId),
1280 title: state.claudeState.title || trimToNull(tab.title),
1281 titleSource: state.claudeState.title ? state.claudeState.titleSource : (trimToNull(tab.title) ? "tab" : null)
1282 }, {
1283 persist: true,
1284 render: true
1285 });
1286
1287 return tab;
1288}
1289
1290function normalizeClaudeConversation(payload, meta = {}) {
1291 const source = isRecord(payload) ? payload : {};
1292 const messages = normalizeClaudeMessages(source.chat_messages || []);
1293 return {
1294 organizationId: trimToNull(meta.organizationId) || extractClaudeOrgId({}, meta.requestUrl || ""),
1295 conversationId: trimToNull(source.uuid) || trimToNull(meta.conversationId) || null,
1296 title: trimToNull(source.name),
1297 model: trimToNull(source.model),
1298 updatedAt: parseClaudeTimestamp(source.updated_at) || Date.now(),
1299 messages,
1300 lastAssistantMessageUuid: getLastClaudeMessageUuid(messages)
1301 };
1302}
1303
1304function applyClaudeConversation(payload, meta = {}) {
1305 const normalized = normalizeClaudeConversation(payload, meta);
1306 const fallbackTitle = trimToNull(state.claudeState.tabTitle);
1307 updateClaudeState({
1308 organizationId: normalized.organizationId || state.claudeState.organizationId,
1309 conversationId: normalized.conversationId || state.claudeState.conversationId,
1310 title: normalized.title || fallbackTitle || state.claudeState.title,
1311 titleSource: normalized.title ? "api" : (fallbackTitle ? "tab" : state.claudeState.titleSource),
1312 model: normalized.model || state.claudeState.model,
1313 messages: normalized.messages,
1314 lastAssistantMessageUuid: normalized.lastAssistantMessageUuid || state.claudeState.lastAssistantMessageUuid,
1315 lastConversationSource: meta.source || "api",
1316 lastReadAt: meta.readAt || Date.now(),
1317 lastActivityAt: normalized.updatedAt || Date.now(),
1318 lastError: null
1319 }, {
1320 persist: true,
1321 render: true
1322 });
1323 return buildClaudeStateSnapshot();
1324}
1325
1326function parseClaudeSseText(text) {
1327 let reply = "";
1328 let thinking = "";
1329 let messageUuid = null;
1330 let stopReason = null;
1331
1332 for (const line of String(text || "").split("\n")) {
1333 if (!line.startsWith("data: ")) continue;
1334 try {
1335 const data = JSON.parse(line.slice(6));
1336 if (data.type === "completion" && data.completion) {
1337 reply += data.completion;
1338 if (data.uuid) messageUuid = data.uuid;
1339 } else if (data.type === "message_start") {
1340 reply = "";
1341 thinking = "";
1342 messageUuid = data.message?.uuid || data.message?.id || messageUuid;
1343 } else if (data.type === "content_block_delta" && data.delta?.type === "text_delta") {
1344 reply += data.delta.text || "";
1345 } else if (data.type === "content_block_delta" && data.delta?.type === "thinking_delta") {
1346 thinking += data.delta.thinking || "";
1347 } else if (data.type === "thinking" && data.thinking) {
1348 thinking += data.thinking;
1349 }
1350
1351 if (!stopReason && typeof data.stop_reason === "string" && data.stop_reason) {
1352 stopReason = data.stop_reason;
1353 }
1354 if (!stopReason && data.type === "message_stop") {
1355 stopReason = "message_stop";
1356 }
1357 } catch (_) {}
1358 }
1359
1360 return {
1361 text: reply.trim(),
1362 thinking: thinking.trim() || null,
1363 messageUuid,
1364 stopReason
1365 };
1366}
1367
1368function parseStreamChunkPayload(chunk) {
1369 const source = String(chunk || "");
1370 const eventMatch = source.match(/(?:^|\n)event:\s*([^\n]+)/u);
1371 const dataLines = source
1372 .split("\n")
1373 .filter((line) => line.startsWith("data:"))
1374 .map((line) => line.slice(5).trimStart());
1375 const dataText = dataLines.join("\n");
1376 const payloadText = dataText || source;
1377
1378 try {
1379 return {
1380 data: JSON.parse(payloadText),
1381 event: eventMatch?.[1]?.trim() || null,
1382 raw: source
1383 };
1384 } catch (_) {
1385 return {
1386 data: payloadText,
1387 event: eventMatch?.[1]?.trim() || null,
1388 raw: source
1389 };
1390 }
1391}
1392
1393function createPlatformMap(factory) {
1394 const out = {};
1395 for (const platform of PLATFORM_ORDER) {
1396 out[platform] = factory(platform);
1397 }
1398 return out;
1399}
1400
1401function createFinalMessageRelayObserver(platform) {
1402 return FINAL_MESSAGE_HELPERS?.createRelayState(platform) || null;
1403}
1404
1405function normalizeRecentRelayKeys(value) {
1406 const source = Array.isArray(value) ? value : [];
1407 const seen = new Set();
1408 const normalized = [];
1409
1410 for (const entry of source) {
1411 const key = trimToNull(entry);
1412 if (!key || seen.has(key)) {
1413 continue;
1414 }
1415
1416 seen.add(key);
1417 normalized.push(key);
1418 }
1419
1420 return normalized.slice(-FINAL_MESSAGE_RELAY_CACHE_LIMIT);
1421}
1422
1423function serializeFinalMessageRelayCache() {
1424 return createPlatformMap((platform) => {
1425 const observer = state.finalMessageRelayObservers[platform];
1426 return observer ? normalizeRecentRelayKeys(observer.recentRelayKeys) : [];
1427 });
1428}
1429
1430function restoreFinalMessageRelayCache(raw) {
1431 const source = hasPlatformShape(raw) ? raw : {};
1432
1433 for (const platform of PLATFORM_ORDER) {
1434 const observer = state.finalMessageRelayObservers[platform];
1435 if (!observer) {
1436 continue;
1437 }
1438
1439 observer.activeStream = null;
1440 observer.recentRelayKeys = normalizeRecentRelayKeys(source[platform]);
1441 }
1442}
1443
1444async function persistFinalMessageRelayCache() {
1445 await browser.storage.local.set({
1446 [CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]: serializeFinalMessageRelayCache()
1447 });
1448}
1449
1450function cloneHeaderMap(value) {
1451 return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
1452}
1453
1454function hasPlatformShape(value) {
1455 return !!value
1456 && typeof value === "object"
1457 && !Array.isArray(value)
1458 && PLATFORM_ORDER.some((platform) => Object.prototype.hasOwnProperty.call(value, platform));
1459}
1460
1461function loadTrackedTabs(raw, legacyClaudeTabId) {
1462 const next = createPlatformMap(() => null);
1463 if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1464 for (const platform of PLATFORM_ORDER) {
1465 next[platform] = Number.isInteger(raw[platform]) ? raw[platform] : null;
1466 }
1467 }
1468 if (Number.isInteger(legacyClaudeTabId) && !Number.isInteger(next.claude)) {
1469 next.claude = legacyClaudeTabId;
1470 }
1471 return next;
1472}
1473
1474function loadDesiredTabs(raw, fallbackTrackedTabs = null) {
1475 const next = createPlatformMap((platform) => createDefaultDesiredTabState(platform));
1476
1477 if (hasPlatformShape(raw)) {
1478 for (const platform of PLATFORM_ORDER) {
1479 next[platform] = cloneDesiredTabState(platform, raw[platform]);
1480 }
1481 return next;
1482 }
1483
1484 for (const platform of PLATFORM_ORDER) {
1485 const shouldExist = Number.isInteger(fallbackTrackedTabs?.[platform]);
1486 next[platform] = createDefaultDesiredTabState(platform, {
1487 exists: shouldExist,
1488 source: shouldExist ? "migration" : "bootstrap",
1489 reason: shouldExist ? "tracked_tab_recovered" : null,
1490 updatedAt: 0
1491 });
1492 }
1493
1494 return next;
1495}
1496
1497function loadObjectMap(raw, legacyValue = null) {
1498 const next = createPlatformMap(() => ({}));
1499 if (hasPlatformShape(raw)) {
1500 for (const platform of PLATFORM_ORDER) {
1501 next[platform] = cloneHeaderMap(raw[platform]);
1502 }
1503 return next;
1504 }
1505
1506 if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1507 next.claude = cloneHeaderMap(raw);
1508 return next;
1509 }
1510
1511 if (legacyValue && typeof legacyValue === "object" && !Array.isArray(legacyValue)) {
1512 next.claude = cloneHeaderMap(legacyValue);
1513 }
1514 return next;
1515}
1516
1517function normalizeEndpointEntry(key, value) {
1518 const fallbackKey = typeof key === "string" ? key : "";
1519 const keyParts = fallbackKey.split(" ");
1520 const fallbackMethod = keyParts.length > 1 ? keyParts[0] : "GET";
1521 const fallbackPath = keyParts.length > 1 ? keyParts.slice(1).join(" ") : fallbackKey;
1522
1523 if (!isRecord(value)) {
1524 return {
1525 key: fallbackKey,
1526 method: fallbackMethod,
1527 path: fallbackPath,
1528 firstObservedAt: 0,
1529 lastObservedAt: 0,
1530 sampleUrl: null
1531 };
1532 }
1533
1534 return {
1535 key: fallbackKey,
1536 method: trimToNull(value.method) || fallbackMethod,
1537 path: trimToNull(value.path) || fallbackPath,
1538 firstObservedAt: Number(value.firstObservedAt) || 0,
1539 lastObservedAt: Number(value.lastObservedAt) || 0,
1540 sampleUrl: trimToNull(value.sampleUrl)
1541 };
1542}
1543
1544function loadEndpointEntries(raw, legacyValue = null) {
1545 const next = createPlatformMap(() => ({}));
1546 const source = hasPlatformShape(raw)
1547 ? raw
1548 : (isRecord(raw) ? { claude: raw } : (isRecord(legacyValue) ? { claude: legacyValue } : {}));
1549
1550 for (const platform of PLATFORM_ORDER) {
1551 const entries = isRecord(source[platform]) ? source[platform] : {};
1552 const normalized = {};
1553 for (const [key, value] of Object.entries(entries)) {
1554 const entry = normalizeEndpointEntry(key, value);
1555 normalized[entry.key] = entry;
1556 }
1557 next[platform] = normalized;
1558 }
1559
1560 return next;
1561}
1562
1563function loadNumberMap(raw, legacyValue = 0) {
1564 const next = createPlatformMap(() => 0);
1565 if (hasPlatformShape(raw)) {
1566 for (const platform of PLATFORM_ORDER) {
1567 next[platform] = Number(raw[platform]) || 0;
1568 }
1569 return next;
1570 }
1571
1572 if (Number.isFinite(raw)) {
1573 next.claude = raw;
1574 return next;
1575 }
1576
1577 if (Number.isFinite(legacyValue)) {
1578 next.claude = legacyValue;
1579 }
1580 return next;
1581}
1582
1583function loadAccountMap(raw) {
1584 const next = createPlatformMap(() => createDefaultAccountState());
1585 if (!hasPlatformShape(raw)) return next;
1586
1587 for (const platform of PLATFORM_ORDER) {
1588 next[platform] = cloneAccountState(raw[platform]);
1589 }
1590
1591 return next;
1592}
1593
1594function loadStringMap(raw, legacyValue = "") {
1595 const next = createPlatformMap(() => "");
1596 if (hasPlatformShape(raw)) {
1597 for (const platform of PLATFORM_ORDER) {
1598 next[platform] = typeof raw[platform] === "string" ? raw[platform] : "";
1599 }
1600 return next;
1601 }
1602
1603 if (typeof raw === "string") {
1604 next.claude = raw;
1605 return next;
1606 }
1607
1608 if (typeof legacyValue === "string") {
1609 next.claude = legacyValue;
1610 }
1611 return next;
1612}
1613
1614function loadTabIdMap(raw, legacyValue = null) {
1615 const next = createPlatformMap(() => null);
1616 if (hasPlatformShape(raw)) {
1617 for (const platform of PLATFORM_ORDER) {
1618 next[platform] = Number.isInteger(raw[platform]) ? raw[platform] : null;
1619 }
1620 return next;
1621 }
1622
1623 if (Number.isInteger(raw)) {
1624 next.claude = raw;
1625 return next;
1626 }
1627
1628 if (Number.isInteger(legacyValue)) {
1629 next.claude = legacyValue;
1630 }
1631 return next;
1632}
1633
1634function loadControlState(raw) {
1635 if (!isRecord(raw)) return createDefaultControlState();
1636 return cloneControlState(raw);
1637}
1638
1639function loadControllerRuntimeState(raw) {
1640 return cloneControllerRuntimeState(raw);
1641}
1642
1643function getNestedValue(source, path) {
1644 const segments = String(path || "").split(".");
1645 let current = source;
1646
1647 for (const segment of segments) {
1648 if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) {
1649 return undefined;
1650 }
1651 current = current[segment];
1652 }
1653
1654 return current;
1655}
1656
1657function getFirstDefinedValue(source, paths) {
1658 for (const path of paths) {
1659 const value = getNestedValue(source, path);
1660 if (value !== undefined && value !== null && value !== "") {
1661 return value;
1662 }
1663 }
1664 return undefined;
1665}
1666
1667function normalizeMode(value) {
1668 const lower = String(value || "").trim().toLowerCase();
1669 if (lower === "running" || lower === "paused" || lower === "draining") {
1670 return lower;
1671 }
1672 return "unknown";
1673}
1674
1675function formatModeLabel(mode) {
1676 switch (normalizeMode(mode)) {
1677 case "running":
1678 return "运行中";
1679 case "paused":
1680 return "已暂停";
1681 case "draining":
1682 return "排空中";
1683 default:
1684 return "未知";
1685 }
1686}
1687
1688function normalizeLeader(value) {
1689 if (typeof value === "string" && value.trim()) return value.trim();
1690 if (isRecord(value)) {
1691 const candidate = getFirstDefinedValue(value, ["host", "node_id", "nodeId", "lease_holder", "leaseHolder", "controller_id", "controllerId"]);
1692 return typeof candidate === "string" && candidate.trim() ? candidate.trim() : null;
1693 }
1694 return null;
1695}
1696
1697function normalizeCount(value) {
1698 if (Number.isFinite(value)) return Number(value);
1699 if (typeof value === "string" && value.trim()) {
1700 const parsed = Number(value);
1701 return Number.isFinite(parsed) ? parsed : null;
1702 }
1703 if (Array.isArray(value)) return value.length;
1704 if (isRecord(value)) {
1705 const candidate = getFirstDefinedValue(value, ["count", "depth", "total", "size", "value"]);
1706 return normalizeCount(candidate);
1707 }
1708 return null;
1709}
1710
1711function truncateControlRaw(value) {
1712 if (value == null) return null;
1713
1714 try {
1715 const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
1716 return text.length > CONTROL_STATUS_BODY_LIMIT
1717 ? `${text.slice(0, CONTROL_STATUS_BODY_LIMIT)}\n…`
1718 : text;
1719 } catch (_) {
1720 return String(value);
1721 }
1722}
1723
1724function formatSyncTime(timestamp) {
1725 if (!Number.isFinite(timestamp) || timestamp <= 0) return "未同步";
1726 return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
1727}
1728
1729function formatRetryDelay(targetTime) {
1730 if (!Number.isFinite(targetTime) || targetTime <= 0) return "";
1731 const remaining = Math.max(0, targetTime - Date.now());
1732 if (remaining < 1_000) return "不到 1 秒";
1733 if (remaining < 60_000) return `${Math.ceil(remaining / 1_000)} 秒`;
1734 return `${Math.ceil(remaining / 60_000)} 分钟`;
1735}
1736
1737function isWsEnabled() {
1738 return true;
1739}
1740
1741function normalizeWsConnection(value) {
1742 switch (String(value || "").trim().toLowerCase()) {
1743 case "connecting":
1744 case "connected":
1745 case "retrying":
1746 case "disconnected":
1747 return String(value || "").trim().toLowerCase();
1748 default:
1749 return "disconnected";
1750 }
1751}
1752
1753function formatWsConnectionLabel(snapshot) {
1754 switch (normalizeWsConnection(snapshot?.connection)) {
1755 case "connecting":
1756 return "连接中";
1757 case "connected":
1758 return "已连接";
1759 case "retrying":
1760 return "重连中";
1761 default:
1762 return "已断开";
1763 }
1764}
1765
1766function wsConnectionClass(snapshot) {
1767 switch (normalizeWsConnection(snapshot?.connection)) {
1768 case "connected":
1769 return "on";
1770 case "connecting":
1771 case "retrying":
1772 return "warn";
1773 default:
1774 return "off";
1775 }
1776}
1777
1778function formatWsMeta(snapshot) {
1779 const parts = [];
1780
1781 if (snapshot.serverIdentity) {
1782 parts.push(`服务端: ${snapshot.serverIdentity}`);
1783 } else if (snapshot.connection === "connecting") {
1784 parts.push("等待本地 bridge 握手");
1785 }
1786
1787 if (snapshot.lastSnapshotAt > 0) {
1788 parts.push(`最近快照: ${formatSyncTime(snapshot.lastSnapshotAt)}`);
1789 }
1790
1791 if (snapshot.connection === "retrying" && snapshot.nextRetryAt > 0) {
1792 parts.push(`下次重连: ${formatRetryDelay(snapshot.nextRetryAt)}`);
1793 }
1794
1795 if (snapshot.lastError) {
1796 parts.push(`错误: ${snapshot.lastError}`);
1797 }
1798
1799 return parts.length > 0 ? parts.join(" · ") : "等待本地 bridge 握手";
1800}
1801
1802function renderWsSnapshot() {
1803 const snapshot = cloneWsState(state.wsState);
1804
1805 return JSON.stringify({
1806 connection: snapshot.connection,
1807 wsUrl: snapshot.wsUrl,
1808 localApiBase: snapshot.localApiBase,
1809 clientId: snapshot.clientId,
1810 protocol: snapshot.protocol,
1811 version: snapshot.version,
1812 serverIdentity: snapshot.serverIdentity,
1813 serverHost: snapshot.serverHost,
1814 serverRole: snapshot.serverRole,
1815 leaseState: snapshot.leaseState,
1816 clientCount: snapshot.clientCount,
1817 retryCount: snapshot.retryCount,
1818 nextRetryAt: snapshot.nextRetryAt ? new Date(snapshot.nextRetryAt).toISOString() : null,
1819 lastOpenAt: snapshot.lastOpenAt ? new Date(snapshot.lastOpenAt).toISOString() : null,
1820 lastMessageAt: snapshot.lastMessageAt ? new Date(snapshot.lastMessageAt).toISOString() : null,
1821 lastSnapshotAt: snapshot.lastSnapshotAt ? new Date(snapshot.lastSnapshotAt).toISOString() : null,
1822 lastSnapshotReason: snapshot.lastSnapshotReason,
1823 lastCloseCode: snapshot.lastCloseCode,
1824 lastCloseReason: snapshot.lastCloseReason,
1825 lastError: snapshot.lastError,
1826 raw: snapshot.raw
1827 }, null, 2);
1828}
1829
1830function normalizeControlConnection(value) {
1831 switch (String(value || "").trim().toLowerCase()) {
1832 case "connecting":
1833 case "connected":
1834 case "retrying":
1835 case "disconnected":
1836 return String(value || "").trim().toLowerCase();
1837 default:
1838 return "disconnected";
1839 }
1840}
1841
1842function formatControlConnectionLabel(snapshot) {
1843 switch (normalizeControlConnection(snapshot?.controlConnection)) {
1844 case "connecting":
1845 return "连接中";
1846 case "connected":
1847 return "已连接";
1848 case "retrying":
1849 return "正在重试";
1850 default:
1851 return "已断开";
1852 }
1853}
1854
1855function controlConnectionClass(snapshot) {
1856 switch (normalizeControlConnection(snapshot?.controlConnection)) {
1857 case "connected":
1858 return "on";
1859 case "connecting":
1860 case "retrying":
1861 return "warn";
1862 default:
1863 return "off";
1864 }
1865}
1866
1867function formatControlMeta(snapshot) {
1868 const parts = [`自动化模式: ${formatModeLabel(snapshot.mode)}`];
1869 const connection = normalizeControlConnection(snapshot.controlConnection);
1870
1871 if (snapshot.lastSuccessAt > 0) {
1872 parts.push(`最近成功: ${formatSyncTime(snapshot.lastSuccessAt)}`);
1873 } else if (connection === "connecting") {
1874 parts.push("浏览器启动后自动连接中");
1875 }
1876
1877 if (connection === "retrying") {
1878 parts.push("最近同步失败");
1879 if (snapshot.lastFailureAt > 0) {
1880 parts.push(`失败时间: ${formatSyncTime(snapshot.lastFailureAt)}`);
1881 }
1882 if (snapshot.nextRetryAt > 0) {
1883 parts.push(`下次重试: ${formatRetryDelay(snapshot.nextRetryAt)}`);
1884 }
1885 }
1886
1887 if (snapshot.error && connection !== "connected") {
1888 parts.push(`错误: ${snapshot.error}`);
1889 }
1890
1891 return parts.join(" · ");
1892}
1893
1894function normalizeControlStatePayload(payload, meta = {}) {
1895 const source = isRecord(payload) ? payload : {};
1896 const mode = normalizeMode(getFirstDefinedValue(source, [
1897 "mode",
1898 "automation.mode",
1899 "system.mode",
1900 "system_state.mode",
1901 "systemState.mode",
1902 "state.mode",
1903 "data.mode",
1904 "data.automation.mode",
1905 "data.system.mode",
1906 "data.system_state.mode",
1907 "value.mode",
1908 "value_json.mode"
1909 ]));
1910 const leader = normalizeLeader(getFirstDefinedValue(source, [
1911 "leader",
1912 "leader.host",
1913 "data.leader",
1914 "data.leader.host",
1915 "leader_host",
1916 "leaderHost",
1917 "lease_holder",
1918 "leaseHolder"
1919 ]));
1920 const leaseHolder = normalizeLeader(getFirstDefinedValue(source, [
1921 "lease_holder",
1922 "leaseHolder",
1923 "leader.lease_holder",
1924 "leader.leaseHolder",
1925 "data.lease_holder",
1926 "data.leaseHolder"
1927 ]));
1928 const queueDepth = normalizeCount(getFirstDefinedValue(source, [
1929 "queue_depth",
1930 "queueDepth",
1931 "queued_tasks",
1932 "queuedTasks",
1933 "queue.depth",
1934 "queue.count",
1935 "stats.queue_depth",
1936 "stats.queueDepth",
1937 "data.queue_depth",
1938 "data.queueDepth",
1939 "data.queued_tasks",
1940 "data.queuedTasks",
1941 "data.queue.depth",
1942 "data.stats.queue_depth",
1943 "data.stats.queueDepth"
1944 ]));
1945 const activeRuns = normalizeCount(getFirstDefinedValue(source, [
1946 "active_runs",
1947 "activeRuns",
1948 "runs.active",
1949 "runs.active_count",
1950 "stats.active_runs",
1951 "stats.activeRuns",
1952 "data.active_runs",
1953 "data.activeRuns",
1954 "data.runs.active",
1955 "data.stats.active_runs",
1956 "data.stats.activeRuns"
1957 ]));
1958 const message = getFirstDefinedValue(source, ["message", "data.message"]);
1959 const apiReportedError = getFirstDefinedValue(source, ["error", "data.error"]);
1960 const ok = meta.ok !== false && source.ok !== false && !apiReportedError;
1961
1962 return createDefaultControlState({
1963 ok,
1964 mode,
1965 leader,
1966 leaseHolder,
1967 queueDepth,
1968 activeRuns,
1969 message: typeof message === "string" ? message : null,
1970 error: ok ? null : String(apiReportedError || message || meta.error || "控制面错误"),
1971 statusCode: Number.isFinite(meta.statusCode) ? meta.statusCode : null,
1972 syncedAt: Date.now(),
1973 source: meta.source || "http",
1974 raw: truncateControlRaw(payload)
1975 });
1976}
1977
1978function genClientId() {
1979 return `firefox-${Math.random().toString(36).slice(2, 8)}`;
1980}
1981
1982function platformLabel(platform) {
1983 return PLATFORMS[platform]?.label || platform;
1984}
1985
1986function detectPlatformFromUrl(url) {
1987 try {
1988 const hostname = new URL(url, location.href).hostname;
1989 for (const platform of PLATFORM_ORDER) {
1990 if (PLATFORMS[platform].matchesTabHost(hostname)) return platform;
1991 }
1992 } catch (_) {}
1993 return null;
1994}
1995
1996function detectPlatformFromRequestUrl(url) {
1997 try {
1998 const hostname = new URL(url, location.href).hostname;
1999 for (const platform of PLATFORM_ORDER) {
2000 if (PLATFORMS[platform].matchesRequestHost(hostname)) return platform;
2001 }
2002 } catch (_) {}
2003 return null;
2004}
2005
2006function isPlatformUrl(platform, url) {
2007 return detectPlatformFromUrl(url) === platform;
2008}
2009
2010function shouldTrackRequest(platform, url) {
2011 const config = PLATFORMS[platform];
2012 if (!config) return false;
2013 try {
2014 const parsed = new URL(url, location.href);
2015 if (!config.matchesRequestHost(parsed.hostname)) return false;
2016 return config.shouldTrackPath(parsed.pathname || "/");
2017 } catch (_) {
2018 return false;
2019 }
2020}
2021
2022function findTrackedPlatformByTabId(tabId) {
2023 if (!Number.isInteger(tabId)) return null;
2024 for (const platform of PLATFORM_ORDER) {
2025 if (state.trackedTabs[platform] === tabId) return platform;
2026 }
2027 return null;
2028}
2029
2030function setDesiredTabState(platform, exists, options = {}) {
2031 const now = Number(options.updatedAt) || Date.now();
2032 const previous = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2033 const nextExists = exists === true;
2034 const nextSource = trimToNull(options.source) || previous.source || "runtime";
2035 const nextReason = trimToNull(options.reason) || previous.reason;
2036 const nextAction = trimToNull(options.action);
2037 const shouldStamp = previous.exists !== nextExists
2038 || previous.source !== nextSource
2039 || previous.reason !== nextReason
2040 || nextAction != null
2041 || options.touch === true;
2042 const next = createDefaultDesiredTabState(platform, {
2043 ...previous,
2044 exists: nextExists,
2045 shellUrl: getPlatformShellUrl(platform),
2046 source: nextSource,
2047 reason: nextReason,
2048 updatedAt: shouldStamp ? now : previous.updatedAt,
2049 lastAction: nextAction || previous.lastAction,
2050 lastActionAt: nextAction ? now : previous.lastActionAt
2051 });
2052
2053 const changed = JSON.stringify(previous) !== JSON.stringify(next);
2054 if (!changed) return false;
2055
2056 state.desiredTabs[platform] = next;
2057
2058 if (options.persist) {
2059 persistState().catch(() => {});
2060 }
2061 if (options.render) {
2062 render();
2063 }
2064
2065 return true;
2066}
2067
2068function shouldAutoRestoreDesiredTab(platform) {
2069 const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2070 const actual = cloneActualTabState(state.actualTabs[platform]);
2071 return desired.exists && desired.source !== "migration" && !actual.exists;
2072}
2073
2074function shouldAdoptPlatformTabAsDesired(platform, url) {
2075 return isPlatformShellUrl(platform, url, { allowFallback: true });
2076}
2077
2078function getManagedSummaryPlatforms() {
2079 return PLATFORM_ORDER.filter((platform) => {
2080 const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2081 const actual = cloneActualTabState(state.actualTabs[platform]);
2082 return desired.exists || actual.exists;
2083 });
2084}
2085
2086function setControllerRuntimeState(patch = {}, options = {}) {
2087 state.controllerRuntime = cloneControllerRuntimeState({
2088 ...state.controllerRuntime,
2089 ...patch
2090 });
2091
2092 if (options.persist) {
2093 persistState().catch(() => {});
2094 }
2095 if (options.render) {
2096 render();
2097 }
2098
2099 return state.controllerRuntime;
2100}
2101
2102function buildActualTabSnapshot(platform, shellTab = null, candidateTab = null, previousSnapshot = null) {
2103 const now = Date.now();
2104 const previous = cloneActualTabState(previousSnapshot || state.actualTabs[platform]);
2105 const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
2106
2107 if (shellTab && Number.isInteger(shellTab.id)) {
2108 const isReady = String(shellTab.status || "").toLowerCase() === "complete";
2109 const url = trimToNull(shellTab.url);
2110 const nextBase = createDefaultActualTabState({
2111 exists: true,
2112 tabId: shellTab.id,
2113 url,
2114 title: trimToNull(shellTab.title),
2115 windowId: Number.isInteger(shellTab.windowId) ? shellTab.windowId : null,
2116 active: shellTab.active === true,
2117 status: trimToNull(shellTab.status),
2118 discarded: shellTab.discarded === true,
2119 hidden: shellTab.hidden === true,
2120 healthy: isPlatformShellUrl(platform, url || "", { allowFallback: allowFallbackShell }) && !shellTab.discarded,
2121 issue: isReady ? null : "loading",
2122 candidateTabId: null,
2123 candidateUrl: null,
2124 candidateTitle: null,
2125 candidateStatus: null
2126 });
2127 const isSame = JSON.stringify({
2128 ...nextBase,
2129 lastSeenAt: 0,
2130 lastReadyAt: 0,
2131 updatedAt: 0
2132 }) === JSON.stringify({
2133 ...previous,
2134 lastSeenAt: 0,
2135 lastReadyAt: 0,
2136 updatedAt: 0
2137 });
2138
2139 return createDefaultActualTabState({
2140 ...nextBase,
2141 lastSeenAt: isSame ? (previous.lastSeenAt || now) : now,
2142 lastReadyAt: isReady
2143 ? (isSame ? (previous.lastReadyAt || now) : now)
2144 : previous.lastReadyAt,
2145 updatedAt: isSame ? previous.updatedAt : now
2146 });
2147 }
2148
2149 const nextBase = createDefaultActualTabState({
2150 exists: false,
2151 issue: candidateTab ? "non_shell" : "missing",
2152 candidateTabId: Number.isInteger(candidateTab?.id) ? candidateTab.id : null,
2153 candidateUrl: trimToNull(candidateTab?.url),
2154 candidateTitle: trimToNull(candidateTab?.title),
2155 candidateStatus: trimToNull(candidateTab?.status)
2156 });
2157 const isSame = JSON.stringify({
2158 ...nextBase,
2159 lastSeenAt: 0,
2160 lastReadyAt: 0,
2161 updatedAt: 0
2162 }) === JSON.stringify({
2163 ...previous,
2164 lastSeenAt: 0,
2165 lastReadyAt: 0,
2166 updatedAt: 0
2167 });
2168
2169 return createDefaultActualTabState({
2170 ...nextBase,
2171 lastSeenAt: previous.lastSeenAt,
2172 lastReadyAt: previous.lastReadyAt,
2173 updatedAt: isSame ? previous.updatedAt : now
2174 });
2175}
2176
2177function buildPlatformRuntimeDrift(platform, desiredState = null, actualState = null) {
2178 const desired = cloneDesiredTabState(platform, desiredState || state.desiredTabs[platform]);
2179 const actual = cloneActualTabState(actualState || state.actualTabs[platform]);
2180 const needsRestore = desired.exists && !actual.exists;
2181 const unexpectedActual = !desired.exists && actual.exists;
2182 const loading = actual.exists && actual.issue === "loading";
2183 let reason = "aligned";
2184
2185 if (needsRestore) {
2186 reason = actual.issue === "non_shell" ? "shell_missing" : "missing_actual";
2187 } else if (unexpectedActual) {
2188 reason = "unexpected_actual";
2189 } else if (loading) {
2190 reason = "loading";
2191 }
2192
2193 return {
2194 aligned: !needsRestore && !unexpectedActual && !loading,
2195 needsRestore,
2196 unexpectedActual,
2197 reason
2198 };
2199}
2200
2201function buildPlatformRuntimeSnapshot(platform) {
2202 const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2203 const actual = cloneActualTabState(state.actualTabs[platform]);
2204 const drift = buildPlatformRuntimeDrift(platform, desired, actual);
2205
2206 return {
2207 platform,
2208 desired: {
2209 exists: desired.exists,
2210 shell_url: desired.shellUrl,
2211 source: desired.source,
2212 reason: desired.reason,
2213 updated_at: desired.updatedAt || null,
2214 last_action: desired.lastAction,
2215 last_action_at: desired.lastActionAt || null
2216 },
2217 actual: {
2218 exists: actual.exists,
2219 tab_id: actual.tabId,
2220 url: actual.url,
2221 title: actual.title,
2222 window_id: actual.windowId,
2223 active: actual.active,
2224 status: actual.status,
2225 discarded: actual.discarded,
2226 hidden: actual.hidden,
2227 healthy: actual.healthy,
2228 issue: actual.issue,
2229 last_seen_at: actual.lastSeenAt || null,
2230 last_ready_at: actual.lastReadyAt || null,
2231 candidate_tab_id: actual.candidateTabId,
2232 candidate_url: actual.candidateUrl
2233 },
2234 drift: {
2235 aligned: drift.aligned,
2236 needs_restore: drift.needsRestore,
2237 unexpected_actual: drift.unexpectedActual,
2238 reason: drift.reason
2239 }
2240 };
2241}
2242
2243function getDesiredCount() {
2244 return PLATFORM_ORDER.filter((platform) => cloneDesiredTabState(platform, state.desiredTabs[platform]).exists).length;
2245}
2246
2247function getRuntimeDriftCount() {
2248 return PLATFORM_ORDER.filter((platform) => !buildPlatformRuntimeDrift(platform).aligned).length;
2249}
2250
2251function getPlatformsNeedingShellRestore() {
2252 return PLATFORM_ORDER.filter((platform) => shouldAutoRestoreDesiredTab(platform));
2253}
2254
2255function buildPluginStatusPayload(options = {}) {
2256 const includeVolatile = options.includeVolatile !== false;
2257 const platforms = {};
2258 const pageControls = listPageControlStates()
2259 .map(serializePageControlState)
2260 .filter(Boolean);
2261
2262 for (const platform of PLATFORM_ORDER) {
2263 platforms[platform] = buildPlatformRuntimeSnapshot(platform);
2264 }
2265
2266 const controller = cloneControllerRuntimeState(state.controllerRuntime);
2267 const ws = cloneWsState(state.wsState);
2268 const payload = {
2269 schema_version: 1,
2270 client_id: state.clientId,
2271 summary: {
2272 desired_count: getDesiredCount(),
2273 actual_count: getTrackedCount(),
2274 drift_count: getRuntimeDriftCount(),
2275 paused_page_count: pageControls.filter((entry) => entry.paused).length
2276 },
2277 controller: {
2278 tab_id: controller.tabId,
2279 ready: controller.ready,
2280 status: controller.status,
2281 last_ready_at: controller.lastReadyAt || null,
2282 last_reload_at: controller.lastReloadAt || null,
2283 last_action: controller.lastAction,
2284 last_action_at: controller.lastActionAt || null,
2285 last_health_check_at: includeVolatile ? (state.shellRuntimeLastHealthCheckAt || null) : null
2286 },
2287 ws: {
2288 connected: state.wsConnected,
2289 connection: ws.connection,
2290 protocol: ws.protocol,
2291 version: ws.version,
2292 retry_count: ws.retryCount,
2293 last_open_at: ws.lastOpenAt || null,
2294 last_message_at: ws.lastMessageAt || null,
2295 last_error: ws.lastError
2296 },
2297 page_controls: pageControls.map((entry) => ({
2298 key: entry.key,
2299 platform: entry.platform,
2300 tab_id: entry.tabId,
2301 conversation_id: entry.conversationId,
2302 local_conversation_id: entry.localConversationId,
2303 page_url: entry.pageUrl,
2304 page_title: entry.pageTitle,
2305 shell_page: entry.shellPage,
2306 automation_status: entry.automationStatus,
2307 last_non_paused_automation_status: entry.lastNonPausedAutomationStatus,
2308 paused: entry.paused,
2309 pause_source: entry.pauseSource,
2310 pause_reason: entry.pauseReason,
2311 updated_at: entry.updatedAt
2312 })),
2313 platforms
2314 };
2315
2316 if (includeVolatile) {
2317 payload.generated_at = Date.now();
2318 }
2319
2320 return payload;
2321}
2322
2323function addLog(level, text, sendRemote = true) {
2324 const line = `[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${level}] ${text}`;
2325 state.logs.push(line);
2326 if (state.logs.length > LOG_LIMIT) state.logs.shift();
2327 render();
2328
2329 try {
2330 sendPluginDiagnosticLog(level, text);
2331 } catch (_) {}
2332
2333 if (sendRemote) {
2334 wsSend({
2335 type: "client_log",
2336 clientId: state.clientId,
2337 level,
2338 text
2339 });
2340 }
2341}
2342
2343function shouldForwardDiagnosticLog(level, text) {
2344 const normalizedLevel = trimToNull(level);
2345 const normalizedText = trimToNull(text);
2346
2347 if (!normalizedLevel || !normalizedText) {
2348 return false;
2349 }
2350
2351 switch (normalizedLevel.toLowerCase()) {
2352 case "error":
2353 case "warn":
2354 case "info":
2355 return true;
2356 case "debug":
2357 return DIAGNOSTIC_LOG_DEBUG_PREFIXES.some((prefix) => normalizedText.startsWith(prefix))
2358 || DIAGNOSTIC_LOG_DEBUG_EVENT_RE.test(normalizedText);
2359 default:
2360 return false;
2361 }
2362}
2363
2364function createPluginDiagnosticPayload(level, text) {
2365 if (!shouldForwardDiagnosticLog(level, text)) {
2366 return null;
2367 }
2368
2369 return {
2370 type: "plugin_diagnostic_log",
2371 ts: new Date().toISOString(),
2372 level,
2373 text,
2374 client_id: trimToNull(state.clientId)
2375 };
2376}
2377
2378function trySendPluginDiagnosticPayload(payload) {
2379 try {
2380 return wsSend(payload);
2381 } catch (_) {
2382 return false;
2383 }
2384}
2385
2386function bufferPluginDiagnosticPayload(payload) {
2387 if (!payload || typeof payload !== "object") {
2388 return 0;
2389 }
2390
2391 state.pendingPluginDiagnosticLogs.push(payload);
2392 if (state.pendingPluginDiagnosticLogs.length > PLUGIN_DIAGNOSTIC_BUFFER_LIMIT) {
2393 state.pendingPluginDiagnosticLogs.splice(
2394 0,
2395 state.pendingPluginDiagnosticLogs.length - PLUGIN_DIAGNOSTIC_BUFFER_LIMIT
2396 );
2397 }
2398
2399 return state.pendingPluginDiagnosticLogs.length;
2400}
2401
2402function flushBufferedPluginDiagnosticLogs() {
2403 if (state.pendingPluginDiagnosticLogs.length === 0) {
2404 return 0;
2405 }
2406
2407 if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
2408 return 0;
2409 }
2410
2411 const pending = state.pendingPluginDiagnosticLogs.slice();
2412 state.pendingPluginDiagnosticLogs = [];
2413 let flushedCount = 0;
2414
2415 for (const payload of pending) {
2416 if (!trySendPluginDiagnosticPayload(payload)) {
2417 state.pendingPluginDiagnosticLogs = pending.slice(flushedCount).concat(state.pendingPluginDiagnosticLogs);
2418 break;
2419 }
2420 flushedCount += 1;
2421 }
2422
2423 return flushedCount;
2424}
2425
2426function sendPluginDiagnosticLog(level, text) {
2427 const payload = createPluginDiagnosticPayload(level, text);
2428 if (!payload) {
2429 return false;
2430 }
2431
2432 if (trySendPluginDiagnosticPayload(payload)) {
2433 return true;
2434 }
2435
2436 bufferPluginDiagnosticPayload(payload);
2437 return true;
2438}
2439
2440function normalizePath(url) {
2441 try {
2442 const parsed = new URL(url);
2443 let path = parsed.pathname || "/";
2444 path = path.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi, "{id}");
2445 path = path.replace(/\/\d{6,}\b/g, "/{id}");
2446 path = path.replace(/\/[a-f0-9]{24,}\b/gi, "/{id}");
2447 return path;
2448 } catch (_) {
2449 return url;
2450 }
2451}
2452
2453function headerArrayToObject(headers = []) {
2454 const out = {};
2455 for (const header of headers) {
2456 if (!header || !header.name) continue;
2457 out[header.name.toLowerCase()] = header.value || "";
2458 }
2459 return out;
2460}
2461
2462function mergeKnownHeaders(platform, extra = {}) {
2463 const known = state.lastHeaders[platform] || {};
2464 if (Object.keys(known).length === 0) return extra;
2465 return { ...known, ...extra };
2466}
2467
2468function trimBody(body) {
2469 if (body == null) return null;
2470 const text = typeof body === "string" ? body : JSON.stringify(body);
2471 return text.length > NETWORK_BODY_LIMIT ? text.slice(0, NETWORK_BODY_LIMIT) : text;
2472}
2473
2474function normalizeStoredUrl(url, baseUrl = location.href) {
2475 try {
2476 const parsed = new URL(url, baseUrl);
2477 return `${parsed.pathname || "/"}${parsed.search || ""}`;
2478 } catch (_) {
2479 return url;
2480 }
2481}
2482
2483function getRequestPath(url, baseUrl = location.href) {
2484 try {
2485 return new URL(url, baseUrl).pathname || "/";
2486 } catch (_) {
2487 return "";
2488 }
2489}
2490
2491function getHeaderValue(headers, name) {
2492 if (!headers || typeof headers !== "object") return "";
2493 const value = headers[String(name || "").toLowerCase()];
2494 return typeof value === "string" ? value.trim() : "";
2495}
2496
2497function hasHeaderValue(headers, name) {
2498 return getHeaderValue(headers, name) !== "";
2499}
2500
2501function cookieHeaderMatches(headers, patterns) {
2502 const cookie = getHeaderValue(headers, "cookie");
2503 return !!cookie && patterns.some((pattern) => pattern.test(cookie));
2504}
2505
2506function redactCredentialHeaders(headers = {}) {
2507 const out = {};
2508
2509 for (const [name, value] of Object.entries(headers || {})) {
2510 const normalizedName = String(name || "").toLowerCase();
2511 if (!normalizedName || value == null || value === "") continue;
2512 out[normalizedName] = REDACTED_CREDENTIAL_VALUE;
2513 }
2514
2515 return out;
2516}
2517
2518function buildCredentialFingerprintInput(platform, headers = {}) {
2519 const keys = [];
2520 const pushIfPresent = (name) => {
2521 if (hasHeaderValue(headers, name)) {
2522 keys.push([name, getHeaderValue(headers, name)]);
2523 }
2524 };
2525
2526 switch (platform) {
2527 case "claude":
2528 pushIfPresent("authorization");
2529 pushIfPresent("cookie");
2530 pushIfPresent("x-csrf-token");
2531 pushIfPresent("x-org-id");
2532 break;
2533 case "chatgpt":
2534 pushIfPresent("authorization");
2535 pushIfPresent("cookie");
2536 pushIfPresent("openai-sentinel-chat-requirements-token");
2537 pushIfPresent("openai-sentinel-proof-token");
2538 pushIfPresent("x-openai-assistant-app-id");
2539 break;
2540 case "gemini":
2541 pushIfPresent("authorization");
2542 pushIfPresent("cookie");
2543 pushIfPresent("x-goog-authuser");
2544 pushIfPresent("x-same-domain");
2545 break;
2546 default:
2547 for (const name of Object.keys(headers || {}).sort()) {
2548 pushIfPresent(name);
2549 }
2550 break;
2551 }
2552
2553 return keys
2554 .sort(([left], [right]) => left.localeCompare(right))
2555 .map(([name, value]) => `${name}=${value}`)
2556 .join("\n");
2557}
2558
2559async function computeCredentialFingerprint(platform, headers = {}) {
2560 const input = buildCredentialFingerprintInput(platform, headers);
2561 if (!input) return "";
2562
2563 const encoded = new TextEncoder().encode(`${platform}\n${input}`);
2564 const digest = await crypto.subtle.digest("SHA-256", encoded);
2565 return Array.from(new Uint8Array(digest))
2566 .map((value) => value.toString(16).padStart(2, "0"))
2567 .join("");
2568}
2569
2570function normalizeAccountValue(value) {
2571 if (typeof value !== "string") return null;
2572 const trimmed = value.trim();
2573 if (!trimmed) return null;
2574 return ACCOUNT_EMAIL_RE.test(trimmed) ? trimmed.toLowerCase() : trimmed;
2575}
2576
2577function createAccountCandidate(value, meta = {}) {
2578 const normalizedValue = normalizeAccountValue(value);
2579 if (!normalizedValue) return null;
2580
2581 return {
2582 value: normalizedValue,
2583 kind: trimToNull(meta.kind),
2584 source: trimToNull(meta.source),
2585 priority: Number(meta.priority) || 0,
2586 observedAt: Number(meta.observedAt) || Date.now()
2587 };
2588}
2589
2590function selectAccountCandidate(current, next) {
2591 if (!next) return current;
2592 if (!current || !current.value) return next;
2593 if (current.value === next.value) {
2594 return {
2595 ...current,
2596 kind: next.kind || current.kind,
2597 source: next.source || current.source,
2598 priority: Math.max(current.priority || 0, next.priority || 0),
2599 observedAt: Math.max(current.observedAt || 0, next.observedAt || 0)
2600 };
2601 }
2602 if ((next.priority || 0) > (current.priority || 0)) {
2603 return next;
2604 }
2605 return current;
2606}
2607
2608function pathLooksLikeAccountMetadata(url) {
2609 const path = getRequestPath(url).toLowerCase();
2610 if (!path) return false;
2611 if (path.includes("/conversation") || path.includes("/completion") || path.includes("streamgenerate")) {
2612 return false;
2613 }
2614 return path.includes("/account")
2615 || path.includes("/accounts")
2616 || path.includes("/profile")
2617 || path.includes("/session")
2618 || path.includes("/settings")
2619 || path.includes("/user")
2620 || path.includes("/users")
2621 || path.includes("/me")
2622 || path.includes("/auth")
2623 || path.includes("/organizations");
2624}
2625
2626function extractAccountCandidateFromHeaders(platform, headers = {}, requestUrl = "") {
2627 const observedAt = Date.now();
2628 let candidate = null;
2629
2630 const authUser = getHeaderValue(headers, "x-goog-authuser");
2631 if (platform === "gemini" && authUser) {
2632 candidate = selectAccountCandidate(candidate, createAccountCandidate(`google-authuser:${authUser}`, {
2633 kind: "google_authuser",
2634 source: "request_header",
2635 priority: 40,
2636 observedAt
2637 }));
2638 }
2639
2640 const orgId = getHeaderValue(headers, "x-org-id") || trimToNull(extractClaudeOrgId(headers, requestUrl));
2641 if (platform === "claude" && orgId) {
2642 candidate = selectAccountCandidate(candidate, createAccountCandidate(`claude-org:${orgId}`, {
2643 kind: "organization_id",
2644 source: "request_header",
2645 priority: 20,
2646 observedAt
2647 }));
2648 }
2649
2650 return candidate;
2651}
2652
2653function tryParseJson(text) {
2654 if (typeof text !== "string" || !text.trim()) return null;
2655 try {
2656 return JSON.parse(text);
2657 } catch (_) {
2658 return null;
2659 }
2660}
2661
2662function collectAccountCandidates(source, candidates, meta = {}) {
2663 const queue = [{ value: source, path: "", depth: 0 }];
2664 let scanned = 0;
2665
2666 while (queue.length > 0 && scanned < 120) {
2667 const current = queue.shift();
2668 scanned += 1;
2669 if (!current) continue;
2670
2671 const { value, path, depth } = current;
2672 const lowerPath = path.toLowerCase();
2673
2674 if (typeof value === "string") {
2675 const normalized = value.trim();
2676 if (!normalized) continue;
2677
2678 if (ACCOUNT_EMAIL_RE.test(normalized)) {
2679 candidates.push(createAccountCandidate(normalized.match(ACCOUNT_EMAIL_RE)?.[0] || normalized, {
2680 kind: "email",
2681 source: meta.source,
2682 priority: 100,
2683 observedAt: meta.observedAt
2684 }));
2685 continue;
2686 }
2687
2688 if (lowerPath.includes("email")) {
2689 candidates.push(createAccountCandidate(normalized, {
2690 kind: "email_like",
2691 source: meta.source,
2692 priority: 90,
2693 observedAt: meta.observedAt
2694 }));
2695 continue;
2696 }
2697
2698 if (/(account|user|profile|login|username|handle|sub|identifier|id)$/.test(lowerPath)) {
2699 candidates.push(createAccountCandidate(normalized, {
2700 kind: "account_hint",
2701 source: meta.source,
2702 priority: /name$/.test(lowerPath) ? 35 : 60,
2703 observedAt: meta.observedAt
2704 }));
2705 }
2706 continue;
2707 }
2708
2709 if (depth >= 4) continue;
2710
2711 if (Array.isArray(value)) {
2712 for (let index = 0; index < value.length && index < 12; index += 1) {
2713 queue.push({
2714 value: value[index],
2715 path: `${path}[${index}]`,
2716 depth: depth + 1
2717 });
2718 }
2719 continue;
2720 }
2721
2722 if (!isRecord(value)) continue;
2723
2724 for (const [key, child] of Object.entries(value).slice(0, 24)) {
2725 queue.push({
2726 value: child,
2727 path: path ? `${path}.${key}` : key,
2728 depth: depth + 1
2729 });
2730 }
2731 }
2732}
2733
2734function extractAccountCandidateFromBody(body, requestUrl = "") {
2735 if (!pathLooksLikeAccountMetadata(requestUrl)) return null;
2736
2737 const observedAt = Date.now();
2738 const candidates = [];
2739 const parsed = tryParseJson(body);
2740
2741 if (parsed != null) {
2742 collectAccountCandidates(parsed, candidates, {
2743 source: "response_body",
2744 observedAt
2745 });
2746 } else if (typeof body === "string") {
2747 const match = body.match(ACCOUNT_EMAIL_RE);
2748 if (match?.[0]) {
2749 candidates.push(createAccountCandidate(match[0], {
2750 kind: "email",
2751 source: "response_body",
2752 priority: 100,
2753 observedAt
2754 }));
2755 }
2756 }
2757
2758 let best = null;
2759 for (const candidate of candidates) {
2760 best = selectAccountCandidate(best, candidate);
2761 }
2762 return best;
2763}
2764
2765function updateAccountState(platform, candidate) {
2766 if (!platform || !candidate) return false;
2767
2768 const current = cloneAccountState(state.account[platform]);
2769 const selected = selectAccountCandidate({
2770 value: current.value,
2771 kind: current.kind,
2772 source: current.source,
2773 priority: current.priority,
2774 observedAt: current.lastSeenAt || current.capturedAt || 0
2775 }, candidate);
2776
2777 if (!selected || !selected.value) return false;
2778
2779 const sameValue = current.value === selected.value;
2780 const next = createDefaultAccountState({
2781 value: selected.value,
2782 kind: selected.kind || current.kind,
2783 source: selected.source || current.source,
2784 priority: Math.max(current.priority || 0, selected.priority || 0),
2785 capturedAt: sameValue && current.capturedAt > 0 ? current.capturedAt : selected.observedAt,
2786 lastSeenAt: selected.observedAt
2787 });
2788
2789 const changed = JSON.stringify(current) !== JSON.stringify(next);
2790 if (!changed) return false;
2791
2792 state.account[platform] = next;
2793 render();
2794 persistState().catch(() => {});
2795 return true;
2796}
2797
2798function observeAccountMetadata(platform, requestUrl, body = null, headers = null) {
2799 let changed = false;
2800 const headerCandidate = extractAccountCandidateFromHeaders(platform, headers || {}, requestUrl);
2801 if (headerCandidate) {
2802 changed = updateAccountState(platform, headerCandidate) || changed;
2803 }
2804
2805 const bodyCandidate = extractAccountCandidateFromBody(body, requestUrl);
2806 if (bodyCandidate) {
2807 changed = updateAccountState(platform, bodyCandidate) || changed;
2808 }
2809
2810 return changed;
2811}
2812
2813function getCredentialFreshness(reason) {
2814 if (reason === "ok") return "fresh";
2815 if (reason === "stale") return "stale";
2816 return "lost";
2817}
2818
2819function validateCredentialSnapshot(platform, headers, requestUrl = "") {
2820 const path = getRequestPath(requestUrl).toLowerCase();
2821
2822 switch (platform) {
2823 case "chatgpt": {
2824 if (path.includes("/backend-anon/")) {
2825 return {
2826 valid: false,
2827 invalidate: true,
2828 reason: "chatgpt-anon"
2829 };
2830 }
2831
2832 const hasBearer = /^bearer\s+\S+/i.test(getHeaderValue(headers, "authorization"));
2833 const hasSessionCookie = cookieHeaderMatches(headers, CHATGPT_SESSION_COOKIE_PATTERNS);
2834 const hasSentinel = hasHeaderValue(headers, "openai-sentinel-chat-requirements-token")
2835 || hasHeaderValue(headers, "openai-sentinel-proof-token")
2836 || hasHeaderValue(headers, "x-openai-assistant-app-id");
2837
2838 if (hasBearer || hasSessionCookie || (hasSentinel && hasHeaderValue(headers, "cookie"))) {
2839 return {
2840 valid: true,
2841 invalidate: false,
2842 reason: "ok"
2843 };
2844 }
2845
2846 if (path.includes("/backend-api/") || path.includes("/public-api/")) {
2847 return {
2848 valid: false,
2849 invalidate: true,
2850 reason: "chatgpt-missing-auth"
2851 };
2852 }
2853
2854 return {
2855 valid: false,
2856 invalidate: false,
2857 reason: "chatgpt-missing-auth"
2858 };
2859 }
2860 case "claude": {
2861 const hasAuth = hasHeaderValue(headers, "authorization")
2862 || hasHeaderValue(headers, "cookie")
2863 || hasHeaderValue(headers, "x-csrf-token");
2864 return {
2865 valid: hasAuth,
2866 invalidate: false,
2867 reason: hasAuth ? "ok" : "missing-auth"
2868 };
2869 }
2870 case "gemini": {
2871 const hasAuth = hasHeaderValue(headers, "authorization")
2872 || hasHeaderValue(headers, "cookie")
2873 || hasHeaderValue(headers, "x-goog-authuser")
2874 || hasHeaderValue(headers, "x-same-domain");
2875 return {
2876 valid: hasAuth,
2877 invalidate: false,
2878 reason: hasAuth ? "ok" : "missing-auth"
2879 };
2880 }
2881 default:
2882 return {
2883 valid: false,
2884 invalidate: false,
2885 reason: "unknown-platform"
2886 };
2887 }
2888}
2889
2890function describeCredentialReason(reason) {
2891 switch (reason) {
2892 case "ok":
2893 return "可用";
2894 case "missing-headers":
2895 return "无快照";
2896 case "chatgpt-anon":
2897 return "未登录";
2898 case "chatgpt-missing-auth":
2899 case "missing-auth":
2900 return "无登录凭证";
2901 case "missing-tab":
2902 return "无空壳页";
2903 case "tab-mismatch":
2904 return "空壳页已切换";
2905 case "stale":
2906 return "已过期";
2907 case "missing-meta":
2908 return "等待新请求";
2909 default:
2910 return "不可用";
2911 }
2912}
2913
2914function getCredentialState(platform, now = Date.now()) {
2915 const headers = cloneHeaderMap(state.lastHeaders[platform]);
2916 const headerCount = Object.keys(headers).length;
2917 const fingerprint = trimToNull(state.credentialFingerprint[platform]) || null;
2918 const capturedAt = Number(state.credentialCapturedAt[platform]) || 0;
2919 const lastSeenAt = Number(state.lastCredentialAt[platform]) || 0;
2920 const account = cloneAccountState(state.account[platform]);
2921
2922 if (headerCount === 0) {
2923 return {
2924 valid: false,
2925 reason: "missing-headers",
2926 headerCount,
2927 headers,
2928 fingerprint,
2929 capturedAt,
2930 lastSeenAt,
2931 account
2932 };
2933 }
2934
2935 const trackedTabId = Number.isInteger(state.trackedTabs[platform]) ? state.trackedTabs[platform] : null;
2936 if (!Number.isInteger(trackedTabId)) {
2937 return {
2938 valid: false,
2939 reason: "missing-tab",
2940 headerCount,
2941 headers,
2942 fingerprint,
2943 capturedAt,
2944 lastSeenAt,
2945 account
2946 };
2947 }
2948
2949 const credentialTabId = Number.isInteger(state.lastCredentialTabId[platform]) ? state.lastCredentialTabId[platform] : null;
2950 if (!Number.isInteger(credentialTabId)) {
2951 return {
2952 valid: false,
2953 reason: "missing-meta",
2954 headerCount,
2955 headers,
2956 tabId: trackedTabId,
2957 fingerprint,
2958 capturedAt,
2959 lastSeenAt,
2960 account
2961 };
2962 }
2963
2964 if (credentialTabId !== trackedTabId) {
2965 return {
2966 valid: false,
2967 reason: "tab-mismatch",
2968 headerCount,
2969 headers,
2970 tabId: trackedTabId,
2971 credentialTabId,
2972 fingerprint,
2973 capturedAt,
2974 lastSeenAt,
2975 account
2976 };
2977 }
2978
2979 if (capturedAt <= 0 || lastSeenAt <= 0) {
2980 return {
2981 valid: false,
2982 reason: "missing-meta",
2983 headerCount,
2984 headers,
2985 tabId: trackedTabId,
2986 fingerprint,
2987 capturedAt,
2988 lastSeenAt,
2989 account
2990 };
2991 }
2992
2993 if (now - lastSeenAt > CREDENTIAL_TTL) {
2994 return {
2995 valid: false,
2996 reason: "stale",
2997 headerCount,
2998 headers,
2999 tabId: trackedTabId,
3000 capturedAt,
3001 lastSeenAt,
3002 fingerprint,
3003 account
3004 };
3005 }
3006
3007 const requestUrl = state.lastCredentialUrl[platform] || "";
3008 const validation = validateCredentialSnapshot(platform, headers, requestUrl);
3009 if (!validation.valid) {
3010 return {
3011 valid: false,
3012 reason: validation.reason || "missing-auth",
3013 headerCount,
3014 headers,
3015 tabId: trackedTabId,
3016 capturedAt,
3017 lastSeenAt,
3018 url: requestUrl,
3019 fingerprint,
3020 account
3021 };
3022 }
3023
3024 return {
3025 valid: true,
3026 reason: "ok",
3027 headerCount,
3028 headers,
3029 tabId: trackedTabId,
3030 capturedAt,
3031 lastSeenAt,
3032 url: requestUrl,
3033 fingerprint,
3034 account
3035 };
3036}
3037
3038function requireCredentialState(platform) {
3039 const credential = getCredentialState(platform);
3040 if (credential.valid) return credential;
3041 throw new Error(`${platformLabel(platform)} 没有有效凭证:${describeCredentialReason(credential.reason)}`);
3042}
3043
3044function buildCredentialTransportSnapshot(platform) {
3045 const credential = getCredentialState(platform);
3046 const account = cloneAccountState(state.account[platform]);
3047 const fingerprint = trimToNull(state.credentialFingerprint[platform]) || null;
3048 const runtime = buildPlatformRuntimeSnapshot(platform);
3049
3050 return {
3051 platform,
3052 account: account.value,
3053 account_kind: account.kind,
3054 account_source: account.source,
3055 account_captured_at: account.capturedAt > 0 ? account.capturedAt : null,
3056 account_last_seen_at: account.lastSeenAt > 0 ? account.lastSeenAt : null,
3057 credential_fingerprint: fingerprint,
3058 freshness: getCredentialFreshness(credential.reason),
3059 captured_at: credential.capturedAt > 0 ? credential.capturedAt : null,
3060 last_seen_at: credential.lastSeenAt > 0 ? credential.lastSeenAt : null,
3061 timestamp: credential.capturedAt || credential.lastSeenAt || account.lastSeenAt || account.capturedAt || 0,
3062 endpoint_count: getEndpointCount(platform),
3063 headers: redactCredentialHeaders(credential.headers || {}),
3064 header_names: Object.keys(credential.headers || {}).sort(),
3065 shell_runtime: runtime
3066 };
3067}
3068
3069function clearPlatformCredential(platform) {
3070 let changed = false;
3071
3072 if (Object.keys(state.lastHeaders[platform]).length > 0) {
3073 state.lastHeaders[platform] = {};
3074 changed = true;
3075 }
3076 if (state.lastCredentialUrl[platform]) {
3077 state.lastCredentialUrl[platform] = "";
3078 changed = true;
3079 }
3080 if (Number.isInteger(state.lastCredentialTabId[platform])) {
3081 state.lastCredentialTabId[platform] = null;
3082 changed = true;
3083 }
3084
3085 state.lastCredentialHash[platform] = "";
3086 state.lastCredentialSentAt[platform] = 0;
3087 return changed;
3088}
3089
3090function pruneInvalidCredentialState(now = Date.now()) {
3091 let changed = false;
3092
3093 for (const platform of PLATFORM_ORDER) {
3094 const hasSnapshot = Object.keys(state.lastHeaders[platform]).length > 0;
3095 if (!hasSnapshot) continue;
3096
3097 const credential = getCredentialState(platform, now);
3098 if (!credential.valid) {
3099 const cleared = clearPlatformCredential(platform);
3100 if (cleared) {
3101 sendCredentialSnapshot(platform, true);
3102 }
3103 changed = cleared || changed;
3104 }
3105 }
3106
3107 return changed;
3108}
3109
3110function isGeminiStreamGenerateUrl(url) {
3111 try {
3112 const parsed = new URL(url, PLATFORMS.gemini.rootUrl);
3113 return (parsed.pathname || "").toLowerCase().includes("streamgenerate");
3114 } catch (_) {
3115 return String(url || "").toLowerCase().includes("streamgenerate");
3116 }
3117}
3118
3119function hasGeminiTemplateHeaders(headers) {
3120 const source = headers && typeof headers === "object" ? headers : {};
3121 const contentType = String(source["content-type"] || "").toLowerCase();
3122 return contentType.startsWith("application/x-www-form-urlencoded")
3123 && (source["x-same-domain"] === "1" || Object.keys(source).some((name) => name.startsWith("x-goog-ext-")));
3124}
3125
3126function normalizeGeminiTemplateKey(conversationId = null, options = {}) {
3127 const normalizedConversationId = trimToNull(conversationId);
3128 if (normalizedConversationId) {
3129 return normalizedConversationId;
3130 }
3131
3132 return options.shellPage === true ? GEMINI_SHELL_TEMPLATE_KEY : GEMINI_FALLBACK_TEMPLATE_KEY;
3133}
3134
3135function describeGeminiTemplateTarget(templateKey, template = null) {
3136 const conversationId = trimToNull(template?.conversationId);
3137 if (conversationId) {
3138 return `conversation=${conversationId}`;
3139 }
3140
3141 if (templateKey === GEMINI_SHELL_TEMPLATE_KEY || template?.shellPage === true) {
3142 return "shell";
3143 }
3144
3145 return "fallback";
3146}
3147
3148function parseGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
3149 if (!isGeminiStreamGenerateUrl(url) || typeof reqBody !== "string" || !reqBody) return null;
3150
3151 try {
3152 const normalizedUrl = normalizeStoredUrl(url, PLATFORMS.gemini.rootUrl);
3153 const parsedUrl = new URL(normalizedUrl, PLATFORMS.gemini.rootUrl);
3154 const headers = cloneHeaderMap(reqHeaders);
3155 if (Object.keys(headers).length > 0 && !hasGeminiTemplateHeaders(headers)) return null;
3156 const pageUrl = trimToNull(context?.senderUrl || context?.pageUrl);
3157 const conversationId =
3158 trimToNull(context?.conversationId)
3159 || extractGeminiConversationIdFromPageUrl(pageUrl || "")
3160 || null;
3161 const shellPage =
3162 conversationId == null
3163 && (
3164 context?.isShellPage === true
3165 || (pageUrl != null && isPlatformShellUrl("gemini", pageUrl, { allowFallback: true }))
3166 );
3167 const params = new URLSearchParams(reqBody);
3168 const outerPayload = params.get("f.req");
3169 if (!outerPayload) return null;
3170
3171 const outer = JSON.parse(outerPayload);
3172 if (!Array.isArray(outer) || typeof outer[1] !== "string") return null;
3173
3174 const inner = JSON.parse(outer[1]);
3175 if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
3176
3177 return {
3178 conversationId,
3179 credentialFingerprint: trimToNull(state.credentialFingerprint.gemini),
3180 pageUrl,
3181 shellPage,
3182 url: normalizedUrl,
3183 reqBody,
3184 headers,
3185 prompt: typeof inner[0][0] === "string" ? inner[0][0] : "",
3186 reqId: Number(parsedUrl.searchParams.get("_reqid")) || null,
3187 updatedAt: Date.now()
3188 };
3189 } catch (_) {
3190 return null;
3191 }
3192}
3193
3194function normalizeGeminiSendTemplateEntry(value, templateKey = null, now = Date.now()) {
3195 if (!isRecord(value)) {
3196 return null;
3197 }
3198
3199 const reqBody = typeof value.reqBody === "string" && value.reqBody.trim()
3200 ? value.reqBody
3201 : null;
3202 const url = typeof value.url === "string" && value.url.trim()
3203 ? value.url
3204 : null;
3205 const updatedAt = Number.isFinite(Number(value.updatedAt)) && Number(value.updatedAt) > 0
3206 ? Math.round(Number(value.updatedAt))
3207 : 0;
3208
3209 if (!reqBody || !url || updatedAt <= 0 || now - updatedAt > GEMINI_SEND_TEMPLATE_TTL) {
3210 return null;
3211 }
3212
3213 const normalizedPageUrl = trimToNull(value.pageUrl);
3214 const normalizedConversationId =
3215 trimToNull(value.conversationId)
3216 || extractGeminiConversationIdFromPageUrl(normalizedPageUrl || "")
3217 || null;
3218 const shellPage = normalizedConversationId == null
3219 && (
3220 value.shellPage === true
3221 || templateKey === GEMINI_SHELL_TEMPLATE_KEY
3222 );
3223 const parsed = parseGeminiSendTemplate({
3224 conversationId: normalizedConversationId,
3225 isShellPage: shellPage,
3226 senderUrl: normalizedPageUrl
3227 }, url, reqBody, value.headers);
3228
3229 if (!parsed) {
3230 return null;
3231 }
3232
3233 return {
3234 ...parsed,
3235 conversationId: normalizedConversationId,
3236 credentialFingerprint: trimToNull(value.credentialFingerprint) || trimToNull(parsed.credentialFingerprint),
3237 pageUrl: normalizedPageUrl || parsed.pageUrl || null,
3238 shellPage,
3239 updatedAt
3240 };
3241}
3242
3243function loadGeminiSendTemplates(raw, legacyRaw = null, now = Date.now()) {
3244 const source = isRecord(raw) ? raw : {};
3245 const normalized = [];
3246
3247 for (const [templateKey, entry] of Object.entries(source)) {
3248 const template = normalizeGeminiSendTemplateEntry(entry, templateKey, now);
3249 if (!template) continue;
3250
3251 normalized.push({
3252 key: normalizeGeminiTemplateKey(template.conversationId, {
3253 shellPage: template.shellPage === true
3254 }),
3255 ...template
3256 });
3257 }
3258
3259 if (normalized.length === 0) {
3260 const legacyTemplate = normalizeGeminiSendTemplateEntry(
3261 legacyRaw,
3262 GEMINI_FALLBACK_TEMPLATE_KEY,
3263 now
3264 );
3265 if (legacyTemplate) {
3266 normalized.push({
3267 key: normalizeGeminiTemplateKey(legacyTemplate.conversationId, {
3268 shellPage: legacyTemplate.shellPage === true
3269 }),
3270 ...legacyTemplate
3271 });
3272 }
3273 }
3274
3275 normalized.sort((left, right) => right.updatedAt - left.updatedAt);
3276
3277 const next = {};
3278 for (const entry of normalized.slice(0, GEMINI_SEND_TEMPLATE_LIMIT)) {
3279 if (next[entry.key]) {
3280 continue;
3281 }
3282 next[entry.key] = {
3283 conversationId: entry.conversationId,
3284 credentialFingerprint: entry.credentialFingerprint,
3285 headers: entry.headers,
3286 pageUrl: entry.pageUrl,
3287 prompt: entry.prompt,
3288 reqBody: entry.reqBody,
3289 reqId: entry.reqId,
3290 shellPage: entry.shellPage,
3291 updatedAt: entry.updatedAt,
3292 url: entry.url
3293 };
3294 }
3295
3296 return next;
3297}
3298
3299function serializeGeminiSendTemplates(now = Date.now()) {
3300 return loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
3301}
3302
3303function serializeLegacyGeminiSendTemplate(now = Date.now()) {
3304 const entries = Object.values(serializeGeminiSendTemplates(now))
3305 .sort((left, right) => right.updatedAt - left.updatedAt);
3306 return entries[0] || null;
3307}
3308
3309function pruneGeminiSendTemplates(now = Date.now()) {
3310 state.geminiSendTemplates = loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
3311}
3312
3313function invalidateGeminiSendTemplate(templateKey) {
3314 const normalizedKey = trimToNull(templateKey);
3315
3316 if (!normalizedKey || !state.geminiSendTemplates[normalizedKey]) {
3317 return false;
3318 }
3319
3320 delete state.geminiSendTemplates[normalizedKey];
3321 persistState().catch(() => {});
3322 return true;
3323}
3324
3325function rememberGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
3326 const next = parseGeminiSendTemplate(context, url, reqBody, reqHeaders);
3327 if (!next) return false;
3328
3329 const templateKey = normalizeGeminiTemplateKey(next.conversationId, {
3330 shellPage: next.shellPage === true
3331 });
3332 const previous = normalizeGeminiSendTemplateEntry(
3333 state.geminiSendTemplates[templateKey],
3334 templateKey,
3335 next.updatedAt
3336 );
3337
3338 if (Number.isFinite(previous?.reqId) && Number.isFinite(next.reqId)) {
3339 next.reqId = Math.max(previous.reqId, next.reqId);
3340 } else if (Number.isFinite(previous?.reqId) && !Number.isFinite(next.reqId)) {
3341 next.reqId = previous.reqId;
3342 }
3343
3344 const changed = !previous
3345 || previous.url !== next.url
3346 || previous.reqBody !== next.reqBody
3347 || previous.prompt !== next.prompt
3348 || previous.reqId !== next.reqId
3349 || previous.pageUrl !== next.pageUrl
3350 || previous.conversationId !== next.conversationId
3351 || previous.credentialFingerprint !== next.credentialFingerprint
3352 || JSON.stringify(previous.headers || {}) !== JSON.stringify(next.headers || {});
3353
3354 state.geminiSendTemplates[templateKey] = next;
3355 pruneGeminiSendTemplates(next.updatedAt);
3356 persistState().catch(() => {});
3357
3358 if (changed) {
3359 addLog(
3360 "info",
3361 previous
3362 ? `已更新 Gemini 发送模板 ${describeGeminiTemplateTarget(templateKey, next)}`
3363 : `Gemini 发送模板预热完成 ${describeGeminiTemplateTarget(templateKey, next)}`,
3364 false
3365 );
3366 }
3367 return true;
3368}
3369
3370function getGeminiSendTemplateByKey(templateKey) {
3371 const normalizedKey = trimToNull(templateKey);
3372 if (!normalizedKey) {
3373 return null;
3374 }
3375
3376 const template = normalizeGeminiSendTemplateEntry(
3377 state.geminiSendTemplates[normalizedKey],
3378 normalizedKey
3379 );
3380
3381 if (!template) {
3382 invalidateGeminiSendTemplate(normalizedKey);
3383 return null;
3384 }
3385
3386 const currentFingerprint = trimToNull(state.credentialFingerprint.gemini);
3387
3388 if (
3389 template.credentialFingerprint
3390 && currentFingerprint
3391 && template.credentialFingerprint !== currentFingerprint
3392 ) {
3393 invalidateGeminiSendTemplate(normalizedKey);
3394 return null;
3395 }
3396
3397 state.geminiSendTemplates[normalizedKey] = template;
3398 return {
3399 key: normalizedKey,
3400 ...template
3401 };
3402}
3403
3404function selectMostRecentGeminiSendTemplate(excludedKeys = []) {
3405 const excluded = new Set(excludedKeys.map((key) => trimToNull(key)).filter(Boolean));
3406 const candidates = Object.keys(serializeGeminiSendTemplates())
3407 .filter((key) => !excluded.has(key))
3408 .map((key) => getGeminiSendTemplateByKey(key))
3409 .filter(Boolean)
3410 .sort((left, right) => right.updatedAt - left.updatedAt);
3411
3412 return candidates[0] || null;
3413}
3414
3415function getGeminiSendTemplate(options = {}) {
3416 const conversationId = trimToNull(options.conversationId);
3417 const allowRecentFallback = options.allowRecentFallback === true;
3418 const allowShellFallback = options.allowShellFallback === true;
3419 const attemptedKeys = [];
3420
3421 if (conversationId) {
3422 attemptedKeys.push(conversationId);
3423 const exact = getGeminiSendTemplateByKey(conversationId);
3424 if (exact) {
3425 return {
3426 match: "conversation",
3427 ...exact
3428 };
3429 }
3430 }
3431
3432 if (!conversationId || allowShellFallback) {
3433 attemptedKeys.push(GEMINI_SHELL_TEMPLATE_KEY);
3434 const shell = getGeminiSendTemplateByKey(GEMINI_SHELL_TEMPLATE_KEY);
3435 if (shell) {
3436 return {
3437 match: "shell",
3438 ...shell
3439 };
3440 }
3441 }
3442
3443 if (!allowRecentFallback) {
3444 return null;
3445 }
3446
3447 const recent = selectMostRecentGeminiSendTemplate(attemptedKeys);
3448 if (!recent) {
3449 return null;
3450 }
3451
3452 return {
3453 match: recent.key === GEMINI_FALLBACK_TEMPLATE_KEY ? "fallback" : "recent",
3454 ...recent
3455 };
3456}
3457
3458function extractGeminiXsrfToken(body) {
3459 const match = String(body || "").match(/"xsrf","([^"]+)"/);
3460 return match ? match[1] : null;
3461}
3462
3463function updateGeminiTemplateReqId(templateKey, reqId) {
3464 const normalizedKey = trimToNull(templateKey);
3465 if (!normalizedKey || !Number.isFinite(reqId)) return false;
3466
3467 const template = getGeminiSendTemplateByKey(normalizedKey);
3468 if (!template) return false;
3469
3470 state.geminiSendTemplates[normalizedKey] = {
3471 ...template,
3472 reqId,
3473 updatedAt: Date.now()
3474 };
3475 persistState().catch(() => {});
3476 return true;
3477}
3478
3479function updateGeminiTemplateXsrf(templateKey, xsrfToken) {
3480 const normalizedKey = trimToNull(templateKey);
3481 const template = normalizedKey ? getGeminiSendTemplateByKey(normalizedKey) : null;
3482
3483 if (!xsrfToken || !template?.reqBody) return false;
3484
3485 const params = new URLSearchParams(template.reqBody);
3486 params.set("at", xsrfToken);
3487 state.geminiSendTemplates[normalizedKey] = {
3488 ...template,
3489 reqBody: params.toString(),
3490 updatedAt: Date.now()
3491 };
3492 persistState().catch(() => {});
3493 return true;
3494}
3495
3496function extractPromptFromProxyBody(body) {
3497 if (typeof body === "string") {
3498 const text = body.trim();
3499 if (!text) return null;
3500 if (text.includes("f.req=") || text.includes("&at=") || text.startsWith("{") || text.startsWith("[")) {
3501 return null;
3502 }
3503 return text;
3504 }
3505 if (!body || typeof body !== "object") return null;
3506
3507 const prompt = body.text ?? body.prompt ?? body.message;
3508 return typeof prompt === "string" && prompt.trim() ? prompt.trim() : null;
3509}
3510
3511function cloneJsonValue(value) {
3512 if (value == null) {
3513 return value;
3514 }
3515
3516 try {
3517 return JSON.parse(JSON.stringify(value));
3518 } catch (_) {
3519 return null;
3520 }
3521}
3522
3523function normalizeChatgptSendTemplateEntry(value, fallbackConversationId = null, now = Date.now()) {
3524 if (!isRecord(value)) {
3525 return null;
3526 }
3527
3528 const conversationId = trimToNull(value.conversationId) || trimToNull(fallbackConversationId);
3529 const reqBody = typeof value.reqBody === "string" && value.reqBody.trim()
3530 ? value.reqBody
3531 : null;
3532 const updatedAt = Number.isFinite(Number(value.updatedAt)) && Number(value.updatedAt) > 0
3533 ? Math.round(Number(value.updatedAt))
3534 : 0;
3535
3536 if (!conversationId || !reqBody || updatedAt <= 0 || now - updatedAt > CHATGPT_SEND_TEMPLATE_TTL) {
3537 return null;
3538 }
3539
3540 try {
3541 const parsed = JSON.parse(reqBody);
3542
3543 if (!isRecord(parsed)) {
3544 return null;
3545 }
3546 } catch (_) {
3547 return null;
3548 }
3549
3550 return {
3551 conversationId,
3552 credentialFingerprint: trimToNull(value.credentialFingerprint),
3553 model: trimToNull(value.model),
3554 pageUrl: trimToNull(value.pageUrl),
3555 reqBody,
3556 updatedAt
3557 };
3558}
3559
3560function loadChatgptSendTemplates(raw, now = Date.now()) {
3561 const source = isRecord(raw) ? raw : {};
3562 const normalized = [];
3563
3564 for (const [conversationId, entry] of Object.entries(source)) {
3565 const template = normalizeChatgptSendTemplateEntry(entry, conversationId, now);
3566
3567 if (template) {
3568 normalized.push(template);
3569 }
3570 }
3571
3572 normalized.sort((left, right) => right.updatedAt - left.updatedAt);
3573
3574 const next = {};
3575 for (const entry of normalized.slice(0, CHATGPT_SEND_TEMPLATE_LIMIT)) {
3576 next[entry.conversationId] = entry;
3577 }
3578
3579 return next;
3580}
3581
3582function serializeChatgptSendTemplates(now = Date.now()) {
3583 return loadChatgptSendTemplates(state.chatgptSendTemplates, now);
3584}
3585
3586function pruneChatgptSendTemplates(now = Date.now()) {
3587 state.chatgptSendTemplates = loadChatgptSendTemplates(state.chatgptSendTemplates, now);
3588}
3589
3590function invalidateChatgptSendTemplate(conversationId) {
3591 const normalizedConversationId = trimToNull(conversationId);
3592
3593 if (!normalizedConversationId || !state.chatgptSendTemplates[normalizedConversationId]) {
3594 return false;
3595 }
3596
3597 delete state.chatgptSendTemplates[normalizedConversationId];
3598 persistState().catch(() => {});
3599 return true;
3600}
3601
3602function rememberChatgptSendTemplate(context, reqBody) {
3603 const conversationId = trimToNull(context?.conversationId) || extractChatgptConversationIdFromRequestBody(reqBody);
3604
3605 if (!conversationId || typeof reqBody !== "string" || !reqBody.trim()) {
3606 return false;
3607 }
3608
3609 let parsed = null;
3610 try {
3611 parsed = JSON.parse(reqBody);
3612 } catch (_) {
3613 return false;
3614 }
3615
3616 if (!isRecord(parsed)) {
3617 return false;
3618 }
3619
3620 const next = {
3621 conversationId,
3622 credentialFingerprint: trimToNull(state.credentialFingerprint.chatgpt),
3623 model: trimToNull(parsed.model),
3624 pageUrl: trimToNull(context?.senderUrl),
3625 reqBody,
3626 updatedAt: Date.now()
3627 };
3628 const previous = state.chatgptSendTemplates[conversationId];
3629 const hadUsableTemplate = normalizeChatgptSendTemplateEntry(previous, conversationId, next.updatedAt) != null;
3630 const changed = !previous
3631 || previous.reqBody !== next.reqBody
3632 || previous.credentialFingerprint !== next.credentialFingerprint
3633 || previous.pageUrl !== next.pageUrl
3634 || previous.model !== next.model;
3635
3636 state.chatgptSendTemplates[conversationId] = next;
3637 pruneChatgptSendTemplates(next.updatedAt);
3638 persistState().catch(() => {});
3639
3640 if (changed) {
3641 addLog(
3642 "info",
3643 hadUsableTemplate
3644 ? `已更新 ChatGPT 发送模板 conversation=${conversationId}`
3645 : `ChatGPT 发送模板预热完成 conversation=${conversationId}`,
3646 false
3647 );
3648 }
3649
3650 return true;
3651}
3652
3653function getChatgptSendTemplate(conversationId) {
3654 const normalizedConversationId = trimToNull(conversationId);
3655
3656 if (!normalizedConversationId) {
3657 return null;
3658 }
3659
3660 const template = normalizeChatgptSendTemplateEntry(
3661 state.chatgptSendTemplates[normalizedConversationId],
3662 normalizedConversationId
3663 );
3664
3665 if (!template) {
3666 invalidateChatgptSendTemplate(normalizedConversationId);
3667 return null;
3668 }
3669
3670 const currentFingerprint = trimToNull(state.credentialFingerprint.chatgpt);
3671
3672 if (
3673 template.credentialFingerprint
3674 && currentFingerprint
3675 && template.credentialFingerprint !== currentFingerprint
3676 ) {
3677 invalidateChatgptSendTemplate(normalizedConversationId);
3678 return null;
3679 }
3680
3681 state.chatgptSendTemplates[normalizedConversationId] = template;
3682 return {
3683 ...template
3684 };
3685}
3686
3687function buildChatgptDeliveryRequest(options = {}) {
3688 const conversationId = trimToNull(options.conversationId);
3689 const sourceAssistantMessageId = trimToNull(options.sourceAssistantMessageId);
3690 const messageText = trimToNull(options.messageText);
3691
3692 if (!conversationId) {
3693 throw new Error("delivery.route_missing: ChatGPT delivery requires conversation_id");
3694 }
3695
3696 if (!messageText) {
3697 throw new Error("delivery.invalid_payload: ChatGPT delivery requires message_text");
3698 }
3699
3700 if (!sourceAssistantMessageId) {
3701 throw new Error("delivery.invalid_payload: ChatGPT delivery requires assistant_message_id");
3702 }
3703
3704 const template = getChatgptSendTemplate(conversationId);
3705 if (!template?.reqBody) {
3706 throw new Error("delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first");
3707 }
3708
3709 let parsed = null;
3710 try {
3711 parsed = JSON.parse(template.reqBody);
3712 } catch (_) {
3713 throw new Error("delivery.template_invalid: stored ChatGPT send template is not valid JSON");
3714 }
3715
3716 if (!isRecord(parsed)) {
3717 throw new Error("delivery.template_invalid: stored ChatGPT send template is not an object");
3718 }
3719
3720 const messageId = typeof crypto?.randomUUID === "function"
3721 ? crypto.randomUUID()
3722 : `chatgpt-message-${Date.now()}`;
3723 const requestId = typeof crypto?.randomUUID === "function"
3724 ? crypto.randomUUID()
3725 : `chatgpt-request-${Date.now()}`;
3726 const nextBody = cloneJsonValue(parsed);
3727
3728 if (!isRecord(nextBody)) {
3729 throw new Error("delivery.template_invalid: failed to clone ChatGPT send template");
3730 }
3731
3732 const templateMessage = Array.isArray(nextBody.messages) && isRecord(nextBody.messages[0])
3733 ? cloneJsonValue(nextBody.messages[0])
3734 : {};
3735 const nextMessage = isRecord(templateMessage) ? templateMessage : {};
3736
3737 nextMessage.id = messageId;
3738 nextMessage.author = {
3739 role: "user"
3740 };
3741 nextMessage.content = {
3742 content_type: "text",
3743 parts: [messageText]
3744 };
3745
3746 nextBody.action = trimToNull(nextBody.action) || "next";
3747 nextBody.conversation_id = conversationId;
3748 nextBody.messages = [nextMessage];
3749 nextBody.parent_message_id = sourceAssistantMessageId;
3750
3751 if (Object.prototype.hasOwnProperty.call(nextBody, "websocket_request_id")) {
3752 nextBody.websocket_request_id = requestId;
3753 }
3754
3755 if (Object.prototype.hasOwnProperty.call(nextBody, "websocketRequestId")) {
3756 nextBody.websocketRequestId = requestId;
3757 }
3758
3759 return {
3760 body: nextBody,
3761 headers: {
3762 ...buildProxyHeaders("chatgpt", "/backend-api/conversation"),
3763 accept: "text/event-stream",
3764 "content-type": "application/json"
3765 },
3766 method: "POST",
3767 path: "/backend-api/conversation"
3768 };
3769}
3770
3771function sleep(ms) {
3772 return new Promise((resolve) => setTimeout(resolve, ms));
3773}
3774
3775function readReconnectActionNumber(source, paths) {
3776 const candidate = getFirstDefinedValue(source, paths);
3777 if (candidate === undefined) return null;
3778 if (Number.isFinite(candidate)) return Number(candidate);
3779 if (typeof candidate === "string" && candidate.trim()) {
3780 const parsed = Number(candidate);
3781 return Number.isFinite(parsed) ? parsed : Number.NaN;
3782 }
3783 return Number.NaN;
3784}
3785
3786function normalizeReconnectActionInteger(value, options = {}) {
3787 const {
3788 allowZero = false,
3789 defaultValue = null,
3790 label = "value",
3791 max = Number.MAX_SAFE_INTEGER,
3792 min = allowZero ? 0 : 1
3793 } = options;
3794
3795 if (value == null) {
3796 return defaultValue;
3797 }
3798
3799 if (!Number.isFinite(value) || Math.round(value) !== value) {
3800 throw new Error(`${label} 必须是整数`);
3801 }
3802
3803 const normalized = Math.round(value);
3804 const lowerBound = allowZero ? Math.max(0, min) : Math.max(1, min);
3805 if (normalized < lowerBound || normalized > max) {
3806 throw new Error(`${label} 必须在 ${lowerBound} 到 ${max} 之间`);
3807 }
3808
3809 return normalized;
3810}
3811
3812function resolveWsReconnectActionOptions(source = {}) {
3813 const disconnectMs = normalizeReconnectActionInteger(
3814 readReconnectActionNumber(source, ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"]),
3815 {
3816 allowZero: true,
3817 defaultValue: MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS,
3818 label: "disconnectMs",
3819 max: MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS
3820 }
3821 );
3822 const repeatCount = normalizeReconnectActionInteger(
3823 readReconnectActionNumber(source, ["repeatCount", "repeat_count"]),
3824 {
3825 defaultValue: 1,
3826 label: "repeatCount",
3827 max: MANUAL_WS_RECONNECT_MAX_REPEAT_COUNT
3828 }
3829 );
3830 const repeatIntervalMs = normalizeReconnectActionInteger(
3831 readReconnectActionNumber(source, ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"]),
3832 {
3833 allowZero: true,
3834 defaultValue: 0,
3835 label: "repeatIntervalMs",
3836 max: MANUAL_WS_RECONNECT_MAX_REPEAT_INTERVAL_MS
3837 }
3838 );
3839
3840 return {
3841 disconnectMs,
3842 repeatCount,
3843 repeatIntervalMs
3844 };
3845}
3846
3847function describeWsReconnectActionPlan(options) {
3848 return `disconnect_ms=${options.disconnectMs} repeat_count=${options.repeatCount} repeat_interval_ms=${options.repeatIntervalMs}`;
3849}
3850
3851function extractWsReconnectActionOverrides(source = {}) {
3852 return {
3853 disconnectMs: getFirstDefinedValue(source, ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"]),
3854 repeatCount: getFirstDefinedValue(source, ["repeatCount", "repeat_count"]),
3855 repeatIntervalMs: getFirstDefinedValue(source, ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"])
3856 };
3857}
3858
3859async function waitForWsConnected(sequenceId, timeoutMs = MANUAL_WS_RECONNECT_OPEN_TIMEOUT) {
3860 const deadline = Date.now() + timeoutMs;
3861
3862 while (Date.now() < deadline) {
3863 if (state.manualWsReconnectSequence !== sequenceId) {
3864 return false;
3865 }
3866
3867 if (state.wsConnected && normalizeWsConnection(state.wsState?.connection) === "connected") {
3868 return true;
3869 }
3870
3871 await sleep(MANUAL_WS_RECONNECT_POLL_INTERVAL);
3872 }
3873
3874 return state.manualWsReconnectSequence === sequenceId
3875 && state.wsConnected
3876 && normalizeWsConnection(state.wsState?.connection) === "connected";
3877}
3878
3879async function runWsReconnectActionSequence(sequenceId, options) {
3880 for (let cycle = 1; cycle <= options.repeatCount; cycle += 1) {
3881 if (state.manualWsReconnectSequence !== sequenceId) {
3882 return;
3883 }
3884
3885 closeWsConnection();
3886 const now = Date.now();
3887 setWsState({
3888 ...cloneWsState(state.wsState),
3889 connection: options.disconnectMs > 0 ? "retrying" : "connecting",
3890 nextRetryAt: options.disconnectMs > 0 ? now + options.disconnectMs : 0,
3891 retryCount: cycle,
3892 lastError: null
3893 });
3894 addLog("info", `本地 WS 重连测试 ${cycle}/${options.repeatCount}:${describeWsReconnectActionPlan(options)}`, false);
3895
3896 if (options.disconnectMs > 0) {
3897 await sleep(options.disconnectMs);
3898 }
3899
3900 if (state.manualWsReconnectSequence !== sequenceId) {
3901 return;
3902 }
3903
3904 connectWs({ silentWhenDisabled: true });
3905
3906 if (cycle >= options.repeatCount) {
3907 return;
3908 }
3909
3910 const connected = await waitForWsConnected(sequenceId);
3911 if (state.manualWsReconnectSequence !== sequenceId) {
3912 return;
3913 }
3914
3915 if (!connected) {
3916 addLog("warn", `本地 WS 重连测试在第 ${cycle} 轮后未恢复连接,停止后续循环`, false);
3917 return;
3918 }
3919
3920 if (options.repeatIntervalMs > 0) {
3921 await sleep(options.repeatIntervalMs);
3922 }
3923 }
3924}
3925
3926function scheduleWsReconnectActionSequence(options) {
3927 state.manualWsReconnectSequence += 1;
3928 const sequenceId = state.manualWsReconnectSequence;
3929
3930 setTimeout(() => {
3931 if (state.manualWsReconnectSequence !== sequenceId) {
3932 return;
3933 }
3934
3935 void runWsReconnectActionSequence(sequenceId, options).catch((error) => {
3936 const message = error instanceof Error ? error.message : String(error);
3937 addLog("error", `本地 WS 重连测试失败:${message}`, false);
3938 });
3939 }, MANUAL_WS_RECONNECT_START_DELAY_MS);
3940
3941 return sequenceId;
3942}
3943
3944async function persistState() {
3945 await browser.storage.local.set({
3946 [CONTROLLER_STORAGE_KEYS.clientId]: state.clientId,
3947 [CONTROLLER_STORAGE_KEYS.wsUrl]: state.wsUrl,
3948 [CONTROLLER_STORAGE_KEYS.controlBaseUrl]: state.controlBaseUrl,
3949 [CONTROLLER_STORAGE_KEYS.controlState]: state.controlState,
3950 [CONTROLLER_STORAGE_KEYS.pageControls]: state.pageControls,
3951 [CONTROLLER_STORAGE_KEYS.statusSchemaVersion]: STATUS_SCHEMA_VERSION,
3952 [CONTROLLER_STORAGE_KEYS.trackedTabs]: state.trackedTabs,
3953 [CONTROLLER_STORAGE_KEYS.desiredTabs]: state.desiredTabs,
3954 [CONTROLLER_STORAGE_KEYS.controllerRuntime]: state.controllerRuntime,
3955 [CONTROLLER_STORAGE_KEYS.endpointsByPlatform]: state.endpoints,
3956 [CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform]: state.lastHeaders,
3957 [CONTROLLER_STORAGE_KEYS.credentialCapturedAtByPlatform]: state.credentialCapturedAt,
3958 [CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform]: state.lastCredentialAt,
3959 [CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]: state.lastCredentialUrl,
3960 [CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]: state.lastCredentialTabId,
3961 [CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]: state.credentialFingerprint,
3962 [CONTROLLER_STORAGE_KEYS.accountByPlatform]: state.account,
3963 [CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]: serializeChatgptSendTemplates(),
3964 [CONTROLLER_STORAGE_KEYS.geminiSendTemplates]: serializeGeminiSendTemplates(),
3965 [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: serializeLegacyGeminiSendTemplate(),
3966 [CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]: serializeFinalMessageRelayCache(),
3967 [CONTROLLER_STORAGE_KEYS.claudeState]: {
3968 ...cloneClaudeState(state.claudeState),
3969 busy: false,
3970 busyReason: null,
3971 lastError: null
3972 }
3973 });
3974}
3975
3976function getTrackedCount() {
3977 return PLATFORM_ORDER.filter((platform) => cloneActualTabState(state.actualTabs[platform]).exists).length;
3978}
3979
3980function getAccountCount() {
3981 const managed = new Set(getManagedSummaryPlatforms());
3982 return PLATFORM_ORDER.filter((platform) => managed.has(platform) && !!trimToNull(state.account[platform]?.value)).length;
3983}
3984
3985function getCredentialCount() {
3986 const managed = new Set(getManagedSummaryPlatforms());
3987 return PLATFORM_ORDER.filter((platform) => {
3988 if (!managed.has(platform)) return false;
3989 const credential = getCredentialState(platform);
3990 return credential.valid || !!trimToNull(state.credentialFingerprint[platform]);
3991 }).length;
3992}
3993
3994function getEndpointCount(platform) {
3995 return Object.keys(state.endpoints[platform] || {}).length;
3996}
3997
3998function getTotalEndpointCount() {
3999 return PLATFORM_ORDER.reduce((sum, platform) => sum + getEndpointCount(platform), 0);
4000}
4001
4002function formatTrackedMeta() {
4003 const desiredCount = getDesiredCount();
4004 const actualCount = getTrackedCount();
4005 const driftCount = getRuntimeDriftCount();
4006 const labels = PLATFORM_ORDER
4007 .map((platform) => {
4008 const runtime = buildPlatformRuntimeSnapshot(platform);
4009 if (!runtime.desired.exists && !runtime.actual.exists) return null;
4010 return `${platformLabel(platform)}(desired=${runtime.desired.exists ? "on" : "off"}, actual=${runtime.actual.exists ? `#${runtime.actual.tab_id}` : runtime.actual.issue})`;
4011 })
4012 .filter(Boolean);
4013
4014 const summary = `desired=${desiredCount} · actual=${actualCount} · drift=${driftCount}`;
4015 return labels.length > 0 ? `${summary} · ${labels.join(" · ")}` : `${summary} · 当前没有已建立的空壳页`;
4016}
4017
4018function formatAccountMeta() {
4019 const managed = new Set(getManagedSummaryPlatforms());
4020 const labels = PLATFORM_ORDER
4021 .map((platform) => {
4022 if (!managed.has(platform)) return null;
4023 const value = trimToNull(state.account[platform]?.value);
4024 return value ? `${platformLabel(platform)}(${value})` : null;
4025 })
4026 .filter(Boolean);
4027 return labels.length > 0 ? labels.join(" · ") : "当前还没有识别到账号元数据";
4028}
4029
4030function formatCredentialMeta() {
4031 const managed = new Set(getManagedSummaryPlatforms());
4032 const labels = PLATFORM_ORDER
4033 .map((platform) => {
4034 if (!managed.has(platform)) return null;
4035 const credential = getCredentialState(platform);
4036 const fingerprint = trimToNull(state.credentialFingerprint[platform]);
4037 if (!credential.valid && !fingerprint) return null;
4038 return `${platformLabel(platform)}(${getCredentialFreshness(credential.reason)})`;
4039 })
4040 .filter(Boolean);
4041 return labels.length > 0 ? labels.join(" · ") : "当前没有可汇报的登录态元数据";
4042}
4043
4044function formatEndpointMeta() {
4045 const labels = PLATFORM_ORDER
4046 .map((platform) => {
4047 const count = getEndpointCount(platform);
4048 if (count <= 0) return null;
4049 return `${platformLabel(platform)}(${count})`;
4050 })
4051 .filter(Boolean);
4052 return labels.length > 0 ? labels.join(" · ") : "当前没有发现端点";
4053}
4054
4055function renderPlatformStatus() {
4056 const lines = [];
4057 for (const platform of PLATFORM_ORDER) {
4058 const runtime = buildPlatformRuntimeSnapshot(platform);
4059 const credential = getCredentialState(platform);
4060 const account = trimToNull(state.account[platform]?.value) || "-";
4061 const fingerprint = trimToNull(state.credentialFingerprint[platform]);
4062 const credentialLabel = `${getCredentialFreshness(credential.reason)} / ${describeCredentialReason(credential.reason)}`;
4063 const endpointCount = getEndpointCount(platform);
4064 const pageControlLabel = summarizePageControls(platform);
4065 const actualLabel = runtime.actual.exists
4066 ? `#${runtime.actual.tab_id}${runtime.actual.issue === "loading" ? "/loading" : ""}`
4067 : (runtime.actual.candidate_tab_id ? `missing(candidate#${runtime.actual.candidate_tab_id})` : runtime.actual.issue);
4068 const driftLabel = runtime.drift.reason;
4069 lines.push(
4070 `${platformLabel(platform).padEnd(8)} desired=${String(runtime.desired.exists).padEnd(5)} actual=${actualLabel.padEnd(24)} drift=${driftLabel.padEnd(16)} 账号=${account.padEnd(18)} 登录态=${credentialLabel.padEnd(18)} 指纹=${(fingerprint || "-").slice(0, 12).padEnd(12)} 端点=${endpointCount} 页面=${pageControlLabel}`
4071 );
4072 }
4073 return lines.join("\n");
4074}
4075
4076function renderHeaderSnapshot() {
4077 const snapshot = {};
4078 for (const platform of PLATFORM_ORDER) {
4079 const transport = buildCredentialTransportSnapshot(platform);
4080 const credential = getCredentialState(platform);
4081 if (!transport.credential_fingerprint && !transport.account && !credential.valid) continue;
4082 snapshot[platform] = {
4083 ...transport,
4084 tabId: credential.tabId || null,
4085 url: credential.url || null,
4086 browser_local_credentials_held: credential.valid
4087 };
4088 }
4089 return Object.keys(snapshot).length > 0
4090 ? JSON.stringify(snapshot, null, 2)
4091 : "还没有登录态元数据快照。";
4092}
4093
4094function renderAccountSnapshot() {
4095 const snapshot = {};
4096 for (const platform of PLATFORM_ORDER) {
4097 const account = cloneAccountState(state.account[platform]);
4098 if (!account.value) continue;
4099 snapshot[platform] = {
4100 value: account.value,
4101 kind: account.kind,
4102 source: account.source,
4103 capturedAt: account.capturedAt > 0 ? new Date(account.capturedAt).toISOString() : null,
4104 lastSeenAt: account.lastSeenAt > 0 ? new Date(account.lastSeenAt).toISOString() : null
4105 };
4106 }
4107
4108 return Object.keys(snapshot).length > 0
4109 ? JSON.stringify(snapshot, null, 2)
4110 : "还没有账号元数据。";
4111}
4112
4113function renderEndpointSnapshot() {
4114 const lines = [];
4115 for (const platform of PLATFORM_ORDER) {
4116 const endpoints = Object.values(state.endpoints[platform] || {})
4117 .map((entry) => normalizeEndpointEntry(entry?.key || "", entry))
4118 .sort((left, right) => left.key.localeCompare(right.key));
4119 if (endpoints.length === 0) continue;
4120 lines.push(`[${platformLabel(platform)}]`);
4121 for (const entry of endpoints) {
4122 lines.push(`${entry.method} ${entry.path} first=${formatSyncTime(entry.firstObservedAt)} last=${formatSyncTime(entry.lastObservedAt)}`);
4123 }
4124 lines.push("");
4125 }
4126 if (lines.length === 0) return "还没有发现端点。";
4127 if (lines[lines.length - 1] === "") lines.pop();
4128 return lines.join("\n");
4129}
4130
4131function renderControlSnapshot() {
4132 const snapshot = cloneControlState(state.controlState);
4133
4134 return JSON.stringify({
4135 baseUrl: state.controlBaseUrl,
4136 ok: snapshot.ok,
4137 controlConnection: snapshot.controlConnection,
4138 retryCount: snapshot.retryCount,
4139 mode: snapshot.mode,
4140 leader: snapshot.leader,
4141 leaseHolder: snapshot.leaseHolder,
4142 queueDepth: snapshot.queueDepth,
4143 activeRuns: snapshot.activeRuns,
4144 statusCode: snapshot.statusCode,
4145 syncedAt: snapshot.syncedAt ? new Date(snapshot.syncedAt).toISOString() : null,
4146 lastSuccessAt: snapshot.lastSuccessAt ? new Date(snapshot.lastSuccessAt).toISOString() : null,
4147 lastFailureAt: snapshot.lastFailureAt ? new Date(snapshot.lastFailureAt).toISOString() : null,
4148 nextRetryAt: snapshot.nextRetryAt ? new Date(snapshot.nextRetryAt).toISOString() : null,
4149 source: snapshot.source,
4150 error: snapshot.error,
4151 message: snapshot.message,
4152 raw: snapshot.raw,
4153 pageControls: listPageControlStates().map(serializePageControlState).filter(Boolean)
4154 }, null, 2);
4155}
4156
4157function render() {
4158 const wsSnapshot = cloneWsState(state.wsState);
4159 const controlSnapshot = cloneControlState(state.controlState);
4160 const trackedCount = getTrackedCount();
4161 const accountCount = getAccountCount();
4162 const credentialCount = getCredentialCount();
4163 const endpointCount = getTotalEndpointCount();
4164
4165 if (ui.wsStatus) {
4166 ui.wsStatus.textContent = formatWsConnectionLabel(wsSnapshot);
4167 ui.wsStatus.className = `value ${wsConnectionClass(wsSnapshot)}`;
4168 }
4169 if (ui.wsMeta) {
4170 ui.wsMeta.textContent = formatWsMeta(wsSnapshot);
4171 }
4172 if (ui.controlMode) {
4173 ui.controlMode.textContent = formatControlConnectionLabel(controlSnapshot);
4174 ui.controlMode.className = `value ${controlConnectionClass(controlSnapshot)}`;
4175 }
4176 if (ui.controlMeta) {
4177 ui.controlMeta.textContent = formatControlMeta(controlSnapshot);
4178 }
4179 if (ui.trackedCount) {
4180 ui.trackedCount.textContent = String(trackedCount);
4181 ui.trackedCount.className = `value ${trackedCount > 0 ? "on" : "off"}`;
4182 }
4183 if (ui.trackedMeta) {
4184 ui.trackedMeta.textContent = formatTrackedMeta();
4185 }
4186 if (ui.accountCount) {
4187 ui.accountCount.textContent = String(accountCount);
4188 ui.accountCount.className = `value ${accountCount > 0 ? "on" : "off"}`;
4189 }
4190 if (ui.accountMeta) {
4191 ui.accountMeta.textContent = formatAccountMeta();
4192 }
4193 if (ui.credentialCount) {
4194 ui.credentialCount.textContent = String(credentialCount);
4195 ui.credentialCount.className = `value ${credentialCount > 0 ? "on" : "off"}`;
4196 }
4197 if (ui.credentialMeta) {
4198 ui.credentialMeta.textContent = formatCredentialMeta();
4199 }
4200 if (ui.endpointCount) {
4201 ui.endpointCount.textContent = String(endpointCount);
4202 ui.endpointCount.className = `value ${endpointCount > 0 ? "on" : "off"}`;
4203 }
4204 if (ui.endpointMeta) {
4205 ui.endpointMeta.textContent = formatEndpointMeta();
4206 }
4207 if (ui.platformView) {
4208 ui.platformView.textContent = renderPlatformStatus();
4209 }
4210 if (ui.accountView) {
4211 ui.accountView.textContent = renderAccountSnapshot();
4212 }
4213 if (ui.credentialView) {
4214 ui.credentialView.textContent = renderHeaderSnapshot();
4215 }
4216 if (ui.endpointView) {
4217 ui.endpointView.textContent = renderEndpointSnapshot();
4218 }
4219 if (ui.wsView) {
4220 ui.wsView.textContent = renderWsSnapshot();
4221 }
4222 if (ui.controlView) {
4223 ui.controlView.textContent = renderControlSnapshot();
4224 }
4225}
4226
4227async function setControlState(next) {
4228 state.controlState = cloneControlState(next);
4229 await persistState();
4230 render();
4231}
4232
4233async function requestControlPlane(path, options = {}) {
4234 const baseUrl = trimTrailingSlash(state.controlBaseUrl || deriveControlBaseUrl(state.wsUrl));
4235 if (!baseUrl) {
4236 throw new Error("缺少控制 API 地址");
4237 }
4238
4239 const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
4240 const headers = new Headers({
4241 Accept: "application/json"
4242 });
4243
4244 if (options.body != null && !headers.has("Content-Type")) {
4245 headers.set("Content-Type", "application/json");
4246 }
4247
4248 const response = await fetch(url, {
4249 method: options.method || "GET",
4250 headers,
4251 body: options.body == null ? undefined : options.body
4252 });
4253
4254 const responseText = await response.text();
4255 let payload = null;
4256
4257 if (responseText) {
4258 try {
4259 payload = JSON.parse(responseText);
4260 } catch (_) {
4261 payload = responseText;
4262 }
4263 }
4264
4265 if (!response.ok || (isRecord(payload) && payload.ok === false)) {
4266 const message = isRecord(payload)
4267 ? String(payload.message || payload.error || `${response.status}`)
4268 : String(responseText || response.status);
4269 const error = new Error(message);
4270 error.statusCode = response.status;
4271 error.payload = payload;
4272 throw error;
4273 }
4274
4275 return {
4276 statusCode: response.status,
4277 payload
4278 };
4279}
4280
4281function getControlRetryDelay(retryCount) {
4282 const index = Math.max(0, Number(retryCount) - 1);
4283 return CONTROL_RETRY_DELAYS[index] || CONTROL_RETRY_SLOW_INTERVAL;
4284}
4285
4286function resetControlFailureLog() {
4287 state.lastControlFailureKey = "";
4288 state.lastControlFailureLogAt = 0;
4289}
4290
4291function createControlSuccessState(payload, meta = {}, previousSnapshot = null) {
4292 const normalized = normalizeControlStatePayload(payload, meta);
4293 const previous = cloneControlState(previousSnapshot || state.controlState);
4294
4295 return createDefaultControlState({
4296 ...previous,
4297 ...normalized,
4298 ok: true,
4299 error: null,
4300 controlConnection: "connected",
4301 retryCount: 0,
4302 lastSuccessAt: normalized.syncedAt,
4303 lastFailureAt: 0,
4304 nextRetryAt: 0
4305 });
4306}
4307
4308function createControlFailureState(error, meta = {}, previousSnapshot = null) {
4309 const previous = cloneControlState(previousSnapshot || state.controlState);
4310 const normalized = normalizeControlStatePayload(error.payload || error.message, {
4311 ok: false,
4312 statusCode: Number.isFinite(error.statusCode) ? error.statusCode : null,
4313 source: meta.source || "http",
4314 error: error.message
4315 });
4316 const next = createDefaultControlState({
4317 ...previous,
4318 ok: false,
4319 error: error.message,
4320 message: previous.lastSuccessAt > 0
4321 ? "最近同步失败,正在自动重试"
4322 : "控制面暂不可用,正在自动重试",
4323 statusCode: normalized.statusCode,
4324 syncedAt: normalized.syncedAt,
4325 controlConnection: "retrying",
4326 retryCount: meta.retryCount || Math.max(1, previous.retryCount || 0),
4327 lastFailureAt: normalized.syncedAt,
4328 nextRetryAt: meta.nextRetryAt || 0,
4329 source: normalized.source,
4330 raw: normalized.raw
4331 });
4332
4333 if (normalized.mode !== "unknown") next.mode = normalized.mode;
4334 if (normalized.leader) next.leader = normalized.leader;
4335 if (normalized.leaseHolder) next.leaseHolder = normalized.leaseHolder;
4336 if (normalized.queueDepth != null) next.queueDepth = normalized.queueDepth;
4337 if (normalized.activeRuns != null) next.activeRuns = normalized.activeRuns;
4338
4339 return next;
4340}
4341
4342function logControlFailure(snapshot, options = {}) {
4343 const key = `${snapshot.statusCode || "-"}:${snapshot.error || "unknown"}`;
4344 const now = Date.now();
4345 const shouldLog = !options.silent
4346 || snapshot.retryCount <= 1
4347 || key !== state.lastControlFailureKey
4348 || now - state.lastControlFailureLogAt >= CONTROL_RETRY_LOG_INTERVAL;
4349
4350 if (!shouldLog) return;
4351
4352 state.lastControlFailureKey = key;
4353 state.lastControlFailureLogAt = now;
4354
4355 const retryDelay = formatRetryDelay(snapshot.nextRetryAt);
4356 addLog(
4357 "warn",
4358 `控制面同步失败:${snapshot.error}${retryDelay ? `;${retryDelay}后继续重试` : ""}`,
4359 false
4360 );
4361}
4362
4363async function refreshControlPlaneState(options = {}) {
4364 if (state.controlRefreshInFlight) {
4365 return state.controlRefreshInFlight;
4366 }
4367
4368 const task = (async () => {
4369 const source = options.source || "http";
4370 const previousSnapshot = cloneControlState(state.controlState);
4371 const previousConnection = normalizeControlConnection(previousSnapshot.controlConnection);
4372
4373 try {
4374 const response = await requestControlPlane("/v1/system/state");
4375 const snapshot = createControlSuccessState(response.payload, {
4376 ok: true,
4377 statusCode: response.statusCode,
4378 source
4379 }, previousSnapshot);
4380 await setControlState(snapshot);
4381 resetControlFailureLog();
4382 restartControlPlaneRefreshTimer(CONTROL_REFRESH_INTERVAL, {
4383 reason: "poll"
4384 });
4385
4386 if (!options.silent) {
4387 addLog("info", `控制面状态已同步:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
4388 } else if (previousConnection !== "connected") {
4389 addLog("info", `控制面已恢复:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
4390 }
4391
4392 return snapshot;
4393 } catch (error) {
4394 const retryCount = previousConnection === "retrying"
4395 ? (Number(previousSnapshot.retryCount) || 0) + 1
4396 : 1;
4397 const retryDelay = getControlRetryDelay(retryCount);
4398 const nextRetryAt = Date.now() + retryDelay;
4399 const snapshot = createControlFailureState(error, {
4400 source,
4401 retryCount,
4402 nextRetryAt
4403 }, previousSnapshot);
4404 await setControlState(snapshot);
4405 restartControlPlaneRefreshTimer(retryDelay, {
4406 reason: "retry"
4407 });
4408 logControlFailure(snapshot, options);
4409 throw error;
4410 }
4411 })();
4412
4413 state.controlRefreshInFlight = task;
4414
4415 try {
4416 return await task;
4417 } finally {
4418 if (state.controlRefreshInFlight === task) {
4419 state.controlRefreshInFlight = null;
4420 }
4421 }
4422}
4423
4424function normalizePageControlAction(action) {
4425 const methodName = String(action || "").trim().toLowerCase();
4426 return methodName === "manual" || methodName === "pause" || methodName === "resume"
4427 ? methodName
4428 : null;
4429}
4430
4431function extractRenewalConversationData(payload) {
4432 if (!isRecord(payload)) {
4433 return null;
4434 }
4435
4436 return isRecord(payload.data) ? payload.data : payload;
4437}
4438
4439function updatePageControlFromRenewalDetail(pageControl, detail, options = {}) {
4440 if (!pageControl?.platform || !Number.isInteger(pageControl.tabId) || !isRecord(detail)) {
4441 return pageControl || null;
4442 }
4443
4444 const activeLink = isRecord(detail.active_link) ? detail.active_link : null;
4445 const nextConversationId = trimToNull(activeLink?.remote_conversation_id) || pageControl.conversationId;
4446
4447 return updatePageControlState({
4448 platform: pageControl.platform,
4449 tabId: pageControl.tabId,
4450 conversationId: nextConversationId,
4451 localConversationId: trimToNull(detail.local_conversation_id) || pageControl.localConversationId,
4452 pageUrl: trimToNull(activeLink?.page_url) || pageControl.pageUrl,
4453 pageTitle: trimToNull(activeLink?.page_title) || pageControl.pageTitle,
4454 automationStatus: detail.automation_status,
4455 lastNonPausedAutomationStatus: detail.last_non_paused_automation_status,
4456 pauseSource: options.source || "automation_sync",
4457 pauseReason: detail.automation_status === "paused" ? detail.pause_reason : null,
4458 updatedAt: Number(detail.updated_at) || Date.now()
4459 }, {
4460 persist: options.persist !== false,
4461 render: options.render !== false,
4462 reason: detail.automation_status === "paused"
4463 ? (trimToNull(detail.pause_reason) || "user_pause")
4464 : null,
4465 source: options.source || "automation_sync"
4466 });
4467}
4468
4469async function requestConversationAutomationControl(context, pageControl, action, options = {}) {
4470 const conversationId = trimToNull(pageControl?.conversationId) || trimToNull(context?.conversationId);
4471
4472 if (!context?.platform || !conversationId) {
4473 return null;
4474 }
4475
4476 const response = await requestControlPlane("/v1/internal/automation/conversations/control", {
4477 method: "POST",
4478 body: JSON.stringify({
4479 action,
4480 scope: "current",
4481 ...(action === "manual" || action === "resume"
4482 ? {
4483 action: "mode",
4484 mode: action === "manual" ? "manual" : "auto"
4485 }
4486 : {}),
4487 platform: context.platform,
4488 reason: action === "pause" ? (options.reason || "user_pause") : undefined,
4489 source_conversation_id: conversationId
4490 })
4491 });
4492
4493 return extractRenewalConversationData(response.payload);
4494}
4495
4496async function runPageControlAction(action, sender, options = {}) {
4497 const methodName = normalizePageControlAction(action);
4498
4499 if (!methodName) {
4500 throw new Error(`未知页面控制动作:${action || "-"}`);
4501 }
4502
4503 const context = getSenderContext(sender, detectPlatformFromUrl(sender?.tab?.url || ""));
4504
4505 if (!context) {
4506 throw new Error("当前页面不是受支持的 AI 页面");
4507 }
4508
4509 const currentPage = buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""));
4510 let page = currentPage;
4511
4512 if (currentPage?.conversationId) {
4513 try {
4514 const detail = await requestConversationAutomationControl(context, currentPage, methodName, {
4515 reason: methodName === "pause" ? "user_pause" : null
4516 });
4517
4518 if (detail != null) {
4519 page = updatePageControlFromRenewalDetail(currentPage, detail, {
4520 persist: true,
4521 render: true,
4522 source: options.source || "runtime"
4523 });
4524 }
4525 } catch (error) {
4526 const statusCode = Number.isFinite(Number(error?.statusCode)) ? Number(error.statusCode) : null;
4527
4528 if (methodName === "manual" || statusCode !== 404) {
4529 throw error;
4530 }
4531 }
4532 }
4533
4534 if (page == null || (methodName !== "manual" && page.automationStatus == null && page.localConversationId == null)) {
4535 if (methodName === "manual") {
4536 throw new Error("当前对话还没有 conductor 记录,暂不能设为手动");
4537 }
4538
4539 page = syncPageControlFromContext(context, {
4540 localConversationId: null,
4541 automationStatus: null,
4542 lastNonPausedAutomationStatus: null,
4543 paused: methodName === "pause",
4544 pauseSource: options.source || "runtime",
4545 pauseReason: methodName === "pause"
4546 ? (options.reason || "page_paused_by_user")
4547 : null
4548 }, {
4549 persist: true,
4550 render: true,
4551 reason: options.reason,
4552 source: options.source
4553 });
4554 }
4555
4556 const pageStateLabel = page?.automationStatus || (page?.paused ? "paused" : "auto");
4557 addLog(
4558 "info",
4559 `${platformLabel(context.platform)} 页面控制已更新:state=${pageStateLabel},tab=${context.tabId}${page?.conversationId ? ` conversation=${page.conversationId}` : ""}`,
4560 false
4561 );
4562
4563 return {
4564 action: methodName,
4565 control: cloneControlState(state.controlState),
4566 page
4567 };
4568}
4569
4570async function runControlPlaneAction(action, options = {}) {
4571 const methodName = String(action || "").trim().toLowerCase();
4572
4573 if (!["pause", "resume", "drain"].includes(methodName)) {
4574 throw new Error(`未知控制动作:${action || "-"}`);
4575 }
4576
4577 const requestId = typeof crypto?.randomUUID === "function"
4578 ? crypto.randomUUID()
4579 : `req-${Date.now()}`;
4580 const response = await requestControlPlane(`/v1/system/${methodName}`, {
4581 method: "POST",
4582 body: JSON.stringify({
4583 requested_by: "browser_admin",
4584 source: "firefox_extension",
4585 reason: `human_clicked_${methodName}`,
4586 request_id: requestId
4587 })
4588 });
4589
4590 const nextSnapshot = createControlSuccessState(response.payload, {
4591 ok: true,
4592 statusCode: response.statusCode,
4593 source: options.source || "http_action"
4594 }, state.controlState);
4595 await setControlState(nextSnapshot);
4596 resetControlFailureLog();
4597 restartControlPlaneRefreshTimer(CONTROL_REFRESH_INTERVAL, {
4598 reason: "poll"
4599 });
4600 addLog("info", `控制动作 ${methodName} 已执行(${response.statusCode})`, false);
4601
4602 try {
4603 await refreshControlPlaneState({
4604 source: options.source || "http_action",
4605 silent: true
4606 });
4607 } catch (_) {}
4608
4609 return {
4610 action: methodName,
4611 statusCode: response.statusCode,
4612 payload: response.payload,
4613 snapshot: cloneControlState(state.controlState)
4614 };
4615}
4616
4617async function runScheduledControlPlaneRefresh(reason = "poll") {
4618 await refreshTrackedTabsFromBrowser(reason).catch(() => {});
4619 await refreshControlPlaneState({
4620 source: reason,
4621 silent: true
4622 }).catch(() => {});
4623}
4624
4625function restartControlPlaneRefreshTimer(delay = CONTROL_REFRESH_INTERVAL, options = {}) {
4626 clearTimeout(state.controlRefreshTimer);
4627 state.controlRefreshTimer = setTimeout(() => {
4628 runScheduledControlPlaneRefresh(options.reason || "poll").catch(() => {});
4629 }, Math.max(0, Number(delay) || 0));
4630}
4631
4632async function prepareStartupControlState() {
4633 const previous = cloneControlState(state.controlState);
4634 await setControlState(createDefaultControlState({
4635 ...previous,
4636 ok: previous.lastSuccessAt > 0,
4637 controlConnection: "connecting",
4638 retryCount: 0,
4639 nextRetryAt: 0,
4640 lastFailureAt: 0,
4641 error: null,
4642 message: "浏览器启动后自动连接中",
4643 source: "startup"
4644 }));
4645}
4646
4647async function collapseRecoveredDesiredTabs() {
4648 let changed = false;
4649
4650 for (const platform of PLATFORM_ORDER) {
4651 const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
4652 const actual = cloneActualTabState(state.actualTabs[platform]);
4653 if (desired.source !== "migration" || actual.exists) {
4654 continue;
4655 }
4656
4657 changed = setDesiredTabState(platform, false, {
4658 source: "bootstrap",
4659 reason: "startup_migration_cleared"
4660 }) || changed;
4661 }
4662
4663 if (changed) {
4664 await persistState();
4665 render();
4666 }
4667}
4668
4669async function restoreDesiredTabsOnStartup() {
4670 const targets = getPlatformsNeedingShellRestore();
4671 if (targets.length === 0) {
4672 return null;
4673 }
4674
4675 addLog("info", `启动时自动恢复空壳页:${targets.map((platform) => platformLabel(platform)).join("、")}`, false);
4676 return await runPluginManagementAction("tab_restore", {
4677 source: "startup",
4678 reason: "startup_auto_restore"
4679 });
4680}
4681
4682function wsSend(payload) {
4683 if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
4684 state.ws.send(JSON.stringify(payload));
4685 return true;
4686}
4687
4688function sendApiResponse(id, ok, status = null, body = null, error = null) {
4689 wsSend({
4690 type: "api_response",
4691 id,
4692 ok,
4693 status,
4694 body,
4695 error
4696 });
4697}
4698
4699function sendHello() {
4700 wsSend({
4701 type: "hello",
4702 clientId: state.clientId,
4703 nodeType: "browser",
4704 nodeCategory: "proxy",
4705 nodePlatform: "firefox",
4706 capabilities: {
4707 formal_mode: "shell_tab_metadata_proxy",
4708 shell_tabs: true,
4709 page_conversation_runtime: false,
4710 credential_metadata: true,
4711 desired_actual_runtime: true,
4712 plugin_actions: [
4713 "plugin_status",
4714 "ws_reconnect",
4715 "controller_reload",
4716 "tab_open",
4717 "tab_focus",
4718 "tab_reload",
4719 "tab_restore"
4720 ]
4721 },
4722 plugin_status: buildPluginStatusPayload({
4723 includeVolatile: false
4724 })
4725 });
4726}
4727
4728function getTargetPlatforms(platform) {
4729 if (platform && PLATFORMS[platform]) return [platform];
4730 return PLATFORM_ORDER;
4731}
4732
4733function normalizePluginManagementAction(value) {
4734 switch (String(value || "").trim().toLowerCase()) {
4735 case "plugin_status":
4736 case "status":
4737 return "plugin_status";
4738 case "ws_reconnect":
4739 case "reconnect_ws":
4740 case "reconnect":
4741 return "ws_reconnect";
4742 case "controller_reload":
4743 case "reload_controller":
4744 return "controller_reload";
4745 case "tab_open":
4746 case "open_tab":
4747 return "tab_open";
4748 case "tab_focus":
4749 case "focus_tab":
4750 return "tab_focus";
4751 case "tab_reload":
4752 case "reload_tab":
4753 return "tab_reload";
4754 case "tab_restore":
4755 case "restore_tab":
4756 return "tab_restore";
4757 default:
4758 return null;
4759 }
4760}
4761
4762function readPluginActionRequestId(message) {
4763 return trimToNull(message?.requestId) || trimToNull(message?.request_id) || trimToNull(message?.id);
4764}
4765
4766function collectPluginActionShellRuntime(results = [], platform = null) {
4767 const runtimeByPlatform = new Map();
4768
4769 for (const entry of Array.isArray(results) ? results : []) {
4770 const targetPlatform = trimToNull(entry?.platform);
4771 const shellRuntime = isRecord(entry?.shell_runtime)
4772 ? entry.shell_runtime
4773 : targetPlatform
4774 ? buildPlatformRuntimeSnapshot(targetPlatform)
4775 : null;
4776
4777 if (shellRuntime?.platform) {
4778 runtimeByPlatform.set(shellRuntime.platform, shellRuntime);
4779 }
4780 }
4781
4782 if (platform && !runtimeByPlatform.has(platform)) {
4783 runtimeByPlatform.set(platform, buildPlatformRuntimeSnapshot(platform));
4784 }
4785
4786 if (!platform && runtimeByPlatform.size === 0) {
4787 for (const target of PLATFORM_ORDER) {
4788 runtimeByPlatform.set(target, buildPlatformRuntimeSnapshot(target));
4789 }
4790 }
4791
4792 return [...runtimeByPlatform.values()].sort((left, right) =>
4793 String(left?.platform || "").localeCompare(String(right?.platform || ""))
4794 );
4795}
4796
4797function normalizeDeliveryAckLevel(value) {
4798 const numeric = Number(value);
4799
4800 if (!Number.isInteger(numeric)) {
4801 return 0;
4802 }
4803
4804 return Math.min(3, Math.max(0, numeric));
4805}
4806
4807function normalizePluginDeliveryAck(value) {
4808 if (!isRecord(value)) {
4809 return null;
4810 }
4811
4812 const confirmedAt = Number.isFinite(Number(value.confirmedAt))
4813 ? Number(value.confirmedAt)
4814 : Number.isFinite(Number(value.confirmed_at))
4815 ? Number(value.confirmed_at)
4816 : null;
4817 const statusCode = Number.isFinite(Number(value.statusCode))
4818 ? Number(value.statusCode)
4819 : Number.isFinite(Number(value.status_code))
4820 ? Number(value.status_code)
4821 : null;
4822
4823 return {
4824 confirmed_at: confirmedAt,
4825 failed: value.failed === true,
4826 level: normalizeDeliveryAckLevel(value.level),
4827 reason: trimToNull(value.reason) || null,
4828 status_code: statusCode
4829 };
4830}
4831
4832function buildPluginActionResultPayload(actionResult, options = {}) {
4833 const requestId = trimToNull(options.requestId) || trimToNull(actionResult?.requestId);
4834 if (!requestId) return null;
4835
4836 const action = normalizePluginManagementAction(options.action || actionResult?.action)
4837 || trimToNull(options.action)
4838 || trimToNull(actionResult?.action)
4839 || "plugin_status";
4840 const requestedPlatform = trimToNull(options.platform || actionResult?.platform);
4841 const normalizedResults = (Array.isArray(actionResult?.results) ? actionResult.results : []).map((entry) => {
4842 const targetPlatform = trimToNull(entry?.platform);
4843 const shellRuntime = isRecord(entry?.shell_runtime)
4844 ? entry.shell_runtime
4845 : targetPlatform
4846 ? buildPlatformRuntimeSnapshot(targetPlatform)
4847 : null;
4848 const deliveryAck = normalizePluginDeliveryAck(entry?.delivery_ack || entry?.deliveryAck);
4849
4850 return {
4851 delivery_ack: deliveryAck,
4852 ok: entry?.ok !== false,
4853 platform: targetPlatform,
4854 restored: typeof entry?.restored === "boolean" ? entry.restored : null,
4855 shell_runtime: shellRuntime,
4856 skipped: trimToNull(entry?.skipped) || null,
4857 tab_id: Number.isInteger(entry?.tabId) ? entry.tabId : Number.isInteger(entry?.tab_id) ? entry.tab_id : null
4858 };
4859 });
4860 const shellRuntime = collectPluginActionShellRuntime(normalizedResults, requestedPlatform);
4861 const skippedReasons = [...new Set(normalizedResults.map((entry) => entry.skipped).filter(Boolean))].sort((left, right) =>
4862 String(left).localeCompare(String(right))
4863 );
4864
4865 return {
4866 type: "action_result",
4867 requestId,
4868 action,
4869 command_type: trimToNull(options.commandType) || trimToNull(actionResult?.commandType) || action,
4870 accepted: options.accepted !== false,
4871 completed: options.completed !== false,
4872 failed: options.failed === true || normalizedResults.some((entry) => entry.ok === false),
4873 reason: trimToNull(options.reason) || trimToNull(actionResult?.reason) || null,
4874 target: {
4875 platform: requestedPlatform,
4876 requested_client_id: state.clientId,
4877 requested_platform: requestedPlatform
4878 },
4879 result: {
4880 actual_count: shellRuntime.filter((entry) => entry?.actual?.exists).length,
4881 desired_count: shellRuntime.filter((entry) => entry?.desired?.exists).length,
4882 drift_count: shellRuntime.filter((entry) => entry?.drift?.aligned === false).length,
4883 failed_count: normalizedResults.filter((entry) => entry.ok === false).length,
4884 ok_count: normalizedResults.filter((entry) => entry.ok).length,
4885 platform_count: new Set([
4886 ...normalizedResults.map((entry) => entry.platform).filter(Boolean),
4887 ...shellRuntime.map((entry) => entry.platform).filter(Boolean)
4888 ]).size,
4889 restored_count: normalizedResults.filter((entry) => entry.restored === true).length,
4890 skipped_reasons: skippedReasons
4891 },
4892 results: normalizedResults,
4893 shell_runtime: shellRuntime
4894 };
4895}
4896
4897function sendPluginActionResult(actionResult, options = {}) {
4898 const payload = buildPluginActionResultPayload(actionResult, options);
4899 if (!payload) return false;
4900 return wsSend(payload);
4901}
4902
4903function extractPluginManagementMessage(message) {
4904 const messageType = String(message?.type || "").trim().toLowerCase();
4905 const platform = trimToNull(message?.platform);
4906 const explicitAction = normalizePluginManagementAction(message?.action);
4907 const requestId = readPluginActionRequestId(message);
4908
4909 if (messageType === "open_tab") {
4910 return {
4911 action: explicitAction || (platform ? "tab_focus" : "tab_open"),
4912 commandType: "open_tab",
4913 platform,
4914 requestId,
4915 source: "ws_open_tab"
4916 };
4917 }
4918
4919 if (messageType === "reload") {
4920 return {
4921 action: explicitAction || (platform ? "tab_reload" : "controller_reload"),
4922 commandType: "reload",
4923 platform,
4924 requestId,
4925 source: "ws_reload"
4926 };
4927 }
4928
4929 const directAction = normalizePluginManagementAction(messageType);
4930 if (directAction) {
4931 return {
4932 action: directAction,
4933 commandType: messageType,
4934 ...(directAction === "ws_reconnect" ? extractWsReconnectActionOverrides(message) : {}),
4935 platform,
4936 requestId,
4937 source: "ws_direct"
4938 };
4939 }
4940
4941 if (explicitAction && ["plugin_action", "browser_action", "tab_action", "action_request"].includes(messageType)) {
4942 return {
4943 action: explicitAction,
4944 commandType: messageType,
4945 platform,
4946 requestId,
4947 source: messageType
4948 };
4949 }
4950
4951 return null;
4952}
4953
4954function resolvePluginActionPlatforms(action, platform = null) {
4955 if (platform && !PLATFORMS[platform]) {
4956 throw new Error(`未知平台:${platform}`);
4957 }
4958
4959 if (action === "tab_focus" && !platform) {
4960 throw new Error("tab_focus 需要明确 platform");
4961 }
4962
4963 return getTargetPlatforms(platform);
4964}
4965
4966function restartShellRuntimeHealthTimer(delay = SHELL_RUNTIME_HEALTHCHECK_INTERVAL) {
4967 clearTimeout(state.shellRuntimeTimer);
4968 state.shellRuntimeTimer = setTimeout(() => {
4969 runShellRuntimeHealthCheck("poll").catch((error) => {
4970 addLog("error", `空壳页 runtime 巡检失败:${error.message}`);
4971 });
4972 }, Math.max(0, Number(delay) || 0));
4973}
4974
4975async function runShellRuntimeHealthCheck(reason = "poll") {
4976 try {
4977 await refreshTrackedTabsFromBrowser(reason);
4978 } finally {
4979 restartShellRuntimeHealthTimer(SHELL_RUNTIME_HEALTHCHECK_INTERVAL);
4980 }
4981}
4982
4983async function runPluginManagementAction(action, options = {}) {
4984 const methodName = normalizePluginManagementAction(action);
4985 if (!methodName) {
4986 throw new Error(`未知插件动作:${action || "-"}`);
4987 }
4988
4989 const source = trimToNull(options.source) || "runtime";
4990 const reason = trimToNull(options.reason) || "plugin_action";
4991 const actionAt = Date.now();
4992 const results = [];
4993
4994 if (methodName === "plugin_status" || methodName.startsWith("tab_")) {
4995 await refreshTrackedTabsFromBrowser(`${methodName}_pre`);
4996 }
4997
4998 switch (methodName) {
4999 case "plugin_status":
5000 break;
5001 case "ws_reconnect":
5002 const reconnectOptions = resolveWsReconnectActionOptions(options);
5003 addLog("info", `正在重连本地 WS:${describeWsReconnectActionPlan(reconnectOptions)}`, false);
5004 scheduleWsReconnectActionSequence(reconnectOptions);
5005 return {
5006 action: methodName,
5007 platform: trimToNull(options.platform),
5008 deferred: true,
5009 reason: `scheduled ${describeWsReconnectActionPlan(reconnectOptions)}`,
5010 results,
5011 scheduled: true,
5012 snapshot: buildPluginStatusPayload()
5013 };
5014 case "controller_reload":
5015 setControllerRuntimeState({
5016 ready: false,
5017 status: "reloading",
5018 lastReloadAt: actionAt,
5019 lastAction: methodName,
5020 lastActionAt: actionAt
5021 }, {
5022 persist: true,
5023 render: true
5024 });
5025 sendCredentialSnapshot(null, true);
5026 addLog("warn", "插件管理页即将重载", false);
5027 setTimeout(() => {
5028 window.location.reload();
5029 }, 80);
5030 return {
5031 action: methodName,
5032 platform: null,
5033 results,
5034 scheduled: true,
5035 snapshot: buildPluginStatusPayload()
5036 };
5037 case "tab_open":
5038 case "tab_focus":
5039 case "tab_reload":
5040 case "tab_restore": {
5041 const targets = resolvePluginActionPlatforms(methodName, trimToNull(options.platform));
5042 for (const target of targets) {
5043 if (methodName !== "tab_restore") {
5044 setDesiredTabState(target, true, {
5045 source,
5046 reason,
5047 action: methodName
5048 });
5049 }
5050
5051 if (methodName === "tab_restore") {
5052 const desired = cloneDesiredTabState(target, state.desiredTabs[target]);
5053 const actual = cloneActualTabState(state.actualTabs[target]);
5054 if (!desired.exists) {
5055 results.push({
5056 platform: target,
5057 ok: true,
5058 restored: false,
5059 skipped: "desired_missing"
5060 });
5061 continue;
5062 }
5063
5064 if (actual.exists) {
5065 results.push({
5066 platform: target,
5067 ok: true,
5068 restored: false,
5069 tabId: actual.tabId,
5070 skipped: "actual_present"
5071 });
5072 continue;
5073 }
5074
5075 const restoredTab = await ensurePlatformTab(target, {
5076 focus: false,
5077 reloadIfExisting: false,
5078 recordDesired: false,
5079 source,
5080 reason,
5081 action: methodName
5082 });
5083 results.push({
5084 platform: target,
5085 ok: true,
5086 restored: true,
5087 tabId: restoredTab?.id ?? null
5088 });
5089 continue;
5090 }
5091
5092 const previousActual = cloneActualTabState(state.actualTabs[target]);
5093 const tab = await ensurePlatformTab(target, {
5094 focus: methodName === "tab_focus",
5095 reloadIfExisting: methodName === "tab_reload",
5096 recordDesired: false,
5097 source,
5098 reason,
5099 action: methodName
5100 });
5101 const observerRefresh = methodName === "tab_reload"
5102 ? summarizeObserverRefresh(await reinjectPlatformTabs(target, {
5103 excludeTabIds: Number.isInteger(tab?.id) ? [tab.id] : [],
5104 source: methodName
5105 }))
5106 : null;
5107 results.push({
5108 platform: target,
5109 ok: true,
5110 tabId: tab?.id ?? null,
5111 restored: methodName === "tab_open" ? !previousActual.exists : undefined,
5112 observer_refresh: observerRefresh
5113 });
5114 }
5115 break;
5116 }
5117 default:
5118 break;
5119 }
5120
5121 if (methodName !== "plugin_status") {
5122 setControllerRuntimeState({
5123 ready: true,
5124 status: "ready",
5125 lastAction: methodName,
5126 lastActionAt: actionAt
5127 });
5128 }
5129
5130 if (methodName !== "plugin_status") {
5131 await refreshTrackedTabsFromBrowser(`${methodName}_post`);
5132 }
5133 if (methodName !== "plugin_status") {
5134 await persistState();
5135 render();
5136 sendCredentialSnapshot(null, true);
5137 } else if (source.startsWith("ws")) {
5138 sendCredentialSnapshot(null, true);
5139 }
5140
5141 if (methodName !== "plugin_status") {
5142 addLog("info", `插件动作 ${methodName} 已执行`, false);
5143 }
5144
5145 const normalizedResults = results.map((entry) => ({
5146 ...entry,
5147 shell_runtime: isRecord(entry?.shell_runtime)
5148 ? entry.shell_runtime
5149 : entry?.platform
5150 ? buildPlatformRuntimeSnapshot(entry.platform)
5151 : null
5152 }));
5153
5154 return {
5155 action: methodName,
5156 platform: trimToNull(options.platform),
5157 results: normalizedResults,
5158 snapshot: buildPluginStatusPayload()
5159 };
5160}
5161
5162function getProxyHeaderPath(apiPath) {
5163 try {
5164 return new URL(apiPath, "https://example.invalid").pathname || "/";
5165 } catch (_) {
5166 return apiPath || "/";
5167 }
5168}
5169
5170function isForbiddenProxyHeader(name) {
5171 return FORBIDDEN_PROXY_HEADER_NAMES.has(name) || name.startsWith("sec-");
5172}
5173
5174function copyProxyHeaders(sourceHeaders = {}) {
5175 const out = {};
5176
5177 for (const [name, value] of Object.entries(sourceHeaders || {})) {
5178 const lower = String(name || "").toLowerCase();
5179 if (!lower || value == null || value === "" || isForbiddenProxyHeader(lower)) continue;
5180 out[lower] = value;
5181 }
5182 return out;
5183}
5184
5185function buildProxyHeaders(platform, apiPath, sourceHeaders = null) {
5186 const targetPath = getProxyHeaderPath(apiPath);
5187 const credential = sourceHeaders ? null : requireCredentialState(platform);
5188 const out = copyProxyHeaders(sourceHeaders || credential.headers || {});
5189
5190 if (platform === "chatgpt") {
5191 out["x-openai-target-path"] = targetPath;
5192 out["x-openai-target-route"] = targetPath;
5193 if (!out["x-conduit-token"]) out["x-conduit-token"] = "no-token";
5194 if (!out["x-oai-turn-trace-id"] && typeof crypto?.randomUUID === "function") {
5195 out["x-oai-turn-trace-id"] = crypto.randomUUID();
5196 }
5197 }
5198
5199 return out;
5200}
5201
5202function buildClaudeHeaders(apiPath, overrides = {}) {
5203 const headers = buildProxyHeaders("claude", apiPath);
5204 for (const [name, value] of Object.entries(overrides || {})) {
5205 const lower = String(name || "").toLowerCase();
5206 if (!lower || value == null || value === "" || isForbiddenProxyHeader(lower)) continue;
5207 headers[lower] = String(value);
5208 }
5209 return headers;
5210}
5211
5212function buildGeminiRequestFromTemplate(templateSelection, prompt) {
5213 if (!templateSelection?.url || !templateSelection?.reqBody) {
5214 throw new Error("missing Gemini send template");
5215 }
5216 const credential = requireCredentialState("gemini");
5217
5218 try {
5219 const url = new URL(templateSelection.url, PLATFORMS.gemini.rootUrl);
5220 const params = new URLSearchParams(templateSelection.reqBody);
5221 const outerPayload = params.get("f.req");
5222 if (!outerPayload) throw new Error("template missing f.req");
5223
5224 const outer = JSON.parse(outerPayload);
5225 if (!Array.isArray(outer) || typeof outer[1] !== "string") {
5226 throw new Error("invalid Gemini outer payload");
5227 }
5228
5229 const inner = JSON.parse(outer[1]);
5230 if (!Array.isArray(inner) || !Array.isArray(inner[0])) {
5231 throw new Error("无效的 Gemini 提示词元组");
5232 }
5233
5234 inner[0][0] = prompt;
5235 outer[1] = JSON.stringify(inner);
5236 params.set("f.req", JSON.stringify(outer));
5237
5238 const currentReqId = Number(url.searchParams.get("_reqid"));
5239 const baseReqId = Number.isFinite(templateSelection.reqId) ? templateSelection.reqId : currentReqId;
5240 if (Number.isFinite(baseReqId)) {
5241 const nextReqId = baseReqId + 100000;
5242 url.searchParams.set("_reqid", String(nextReqId));
5243 updateGeminiTemplateReqId(templateSelection.key, nextReqId);
5244 }
5245
5246 const path = `${url.pathname || "/"}${url.search || ""}`;
5247 const headerSource = hasGeminiTemplateHeaders(templateSelection.headers)
5248 ? templateSelection.headers
5249 : credential.headers;
5250 const headers = buildProxyHeaders("gemini", path, headerSource);
5251 if (!hasGeminiTemplateHeaders(headers)) {
5252 throw new Error("缺少 Gemini 请求头;请先手动发送一条真实 Gemini 消息");
5253 }
5254
5255 return {
5256 body: params.toString(),
5257 headers,
5258 path,
5259 templateKey: templateSelection.key
5260 };
5261 } catch (error) {
5262 throw new Error(`构建 Gemini 请求失败:${error.message}`);
5263 }
5264}
5265
5266function buildGeminiAutoRequest(prompt, options = {}) {
5267 const conversationId = trimToNull(options.conversationId);
5268 const templateSelection = getGeminiSendTemplate({
5269 conversationId,
5270 allowRecentFallback: options.allowRecentFallback !== false,
5271 allowShellFallback: options.allowShellFallback === true
5272 });
5273
5274 if (!templateSelection?.url || !templateSelection?.reqBody) {
5275 throw new Error(
5276 conversationId
5277 ? `missing Gemini send template for conversation=${conversationId}; send one real Gemini message first`
5278 : "missing Gemini send template; send one real Gemini message first"
5279 );
5280 }
5281
5282 return buildGeminiRequestFromTemplate(templateSelection, prompt);
5283}
5284
5285function buildGeminiDeliveryRequest(options = {}) {
5286 const conversationId = trimToNull(options.conversationId);
5287 const messageText = trimToNull(options.messageText);
5288 const shellPage = options.shellPage === true;
5289
5290 if (!messageText) {
5291 throw new Error("delivery.invalid_payload: Gemini proxy_delivery requires message_text");
5292 }
5293
5294 const templateSelection = getGeminiSendTemplate({
5295 conversationId,
5296 allowRecentFallback: conversationId == null,
5297 allowShellFallback: shellPage
5298 });
5299
5300 if (!templateSelection?.url || !templateSelection?.reqBody) {
5301 throw new Error(
5302 conversationId
5303 ? `delivery.template_missing: missing Gemini send template for conversation=${conversationId}; send one real Gemini message in this conversation first`
5304 : "delivery.template_missing: missing Gemini send template; send one real Gemini message first"
5305 );
5306 }
5307
5308 const request = buildGeminiRequestFromTemplate(templateSelection, messageText);
5309
5310 return {
5311 body: request.body,
5312 headers: request.headers,
5313 method: "POST",
5314 path: request.path,
5315 templateKey: request.templateKey
5316 };
5317}
5318
5319function createPendingProxyRequest(id, meta = {}) {
5320 let ackSettled = false;
5321 let settled = false;
5322 let ackResolveFn = null;
5323 let ackRejectFn = null;
5324 let timer = null;
5325 let resolveFn = null;
5326 let rejectFn = null;
5327 const ackResponse = new Promise((resolve, reject) => {
5328 ackResolveFn = resolve;
5329 ackRejectFn = reject;
5330 });
5331 const response = new Promise((resolve, reject) => {
5332 resolveFn = resolve;
5333 rejectFn = reject;
5334 });
5335 void ackResponse.catch(() => {});
5336 void response.catch(() => {});
5337
5338 const finishAck = (callback, value) => {
5339 if (ackSettled) return false;
5340 ackSettled = true;
5341 callback(value);
5342 return true;
5343 };
5344
5345 const normalizePendingError = (error) =>
5346 error instanceof Error ? error : new Error(String(error || "proxy_failed"));
5347
5348 const finish = (callback, value) => {
5349 if (settled) return false;
5350 settled = true;
5351 clearTimeout(timer);
5352 pendingProxyRequests.delete(id);
5353 callback(value);
5354 return true;
5355 };
5356
5357 const rejectAll = (error) => {
5358 const nextError = normalizePendingError(error);
5359 finishAck(ackRejectFn, nextError);
5360 return finish(rejectFn, nextError);
5361 };
5362
5363 timer = setTimeout(() => {
5364 rejectAll(new Error(`${platformLabel(meta.platform || "claude")} 代理超时`));
5365 }, Math.max(1_000, Number(meta.timeoutMs) || PROXY_REQUEST_TIMEOUT));
5366
5367 const entry = {
5368 id,
5369 ...meta,
5370 ackResponse,
5371 response,
5372 resolve(value) {
5373 finishAck(ackResolveFn, value);
5374 return finish(resolveFn, value);
5375 },
5376 resolveAck(value) {
5377 return finishAck(ackResolveFn, value);
5378 },
5379 reject(error) {
5380 return rejectAll(error);
5381 },
5382 rejectAck(error) {
5383 const nextError = normalizePendingError(error);
5384 return finishAck(ackRejectFn, nextError);
5385 }
5386 };
5387
5388 pendingProxyRequests.set(id, entry);
5389 return entry;
5390}
5391
5392function cancelPendingProxyRequest(id, reason = "browser_request_cancelled") {
5393 const pending = pendingProxyRequests.get(id);
5394
5395 if (!pending) {
5396 return false;
5397 }
5398
5399 const tabId = Number.isInteger(pending.tabId) ? pending.tabId : null;
5400
5401 if (tabId != null) {
5402 browser.tabs.sendMessage(tabId, {
5403 type: "baa_page_proxy_cancel",
5404 data: {
5405 id,
5406 reason
5407 }
5408 }).catch(() => {});
5409 }
5410
5411 pending.reject(new Error(reason));
5412 return true;
5413}
5414
5415async function executeProxyRequest(payload, meta = {}) {
5416 const platform = payload?.platform;
5417 if (!platform || !PLATFORMS[platform]) {
5418 throw new Error(`未知平台:${platform || "-"}`);
5419 }
5420
5421 const tab = await ensurePlatformTab(platform, { focus: false });
5422 const id = payload.id || buildRuntimeRequestId(platform);
5423 const entry = createPendingProxyRequest(id, {
5424 ...meta,
5425 platform,
5426 method: payload.method,
5427 path: payload.path,
5428 tabId: tab.id
5429 });
5430
5431 try {
5432 await postProxyRequestToTab(tab.id, {
5433 ...payload,
5434 id
5435 });
5436 } catch (error) {
5437 entry.reject(error);
5438 throw error;
5439 }
5440
5441 return entry.response;
5442}
5443
5444function sendEndpointSnapshot(platform = null) {
5445 for (const target of getTargetPlatforms(platform)) {
5446 const endpointEntries = Object.values(state.endpoints[target] || {})
5447 .map((entry) => normalizeEndpointEntry(entry?.key || "", entry))
5448 .sort((left, right) => left.key.localeCompare(right.key));
5449 if (endpointEntries.length === 0) continue;
5450
5451 const account = cloneAccountState(state.account[target]);
5452 const fingerprint = trimToNull(state.credentialFingerprint[target]) || null;
5453 wsSend({
5454 type: "api_endpoints",
5455 platform: target,
5456 account: account.value,
5457 credential_fingerprint: fingerprint,
5458 updated_at: Math.max(...endpointEntries.map((entry) => entry.lastObservedAt || 0), 0) || null,
5459 endpoints: endpointEntries.map((entry) => entry.key),
5460 shell_runtime: buildPlatformRuntimeSnapshot(target),
5461 endpoint_metadata: endpointEntries.map((entry) => ({
5462 method: entry.method,
5463 path: entry.path,
5464 first_seen_at: entry.firstObservedAt || null,
5465 last_seen_at: entry.lastObservedAt || null
5466 }))
5467 });
5468 }
5469}
5470
5471function sendCredentialSnapshot(platform = null, force = false) {
5472 for (const target of getTargetPlatforms(platform)) {
5473 const payload = buildCredentialTransportSnapshot(target);
5474 if (
5475 !payload.account
5476 && !payload.credential_fingerprint
5477 && payload.header_names.length === 0
5478 && !Number.isInteger(state.trackedTabs[target])
5479 && !cloneDesiredTabState(target, state.desiredTabs[target]).exists
5480 ) {
5481 continue;
5482 }
5483
5484 const now = Date.now();
5485 const serialized = JSON.stringify(payload);
5486 if (!force && serialized === state.lastCredentialHash[target] && now - state.lastCredentialSentAt[target] < CREDENTIAL_SEND_INTERVAL) {
5487 continue;
5488 }
5489
5490 state.lastCredentialHash[target] = serialized;
5491 state.lastCredentialSentAt[target] = now;
5492
5493 wsSend({
5494 type: "credentials",
5495 ...payload
5496 });
5497 }
5498}
5499
5500function setWsState(next) {
5501 state.wsState = cloneWsState(next);
5502 render();
5503}
5504
5505function handleWsHelloAck(message) {
5506 const previous = cloneWsState(state.wsState);
5507 const version = Number.isFinite(Number(message.version)) ? Number(message.version) : null;
5508
5509 setWsState({
5510 ...previous,
5511 connection: "connected",
5512 wsUrl: typeof message.wsUrl === "string" && message.wsUrl.trim() ? message.wsUrl.trim() : state.wsUrl,
5513 localApiBase: typeof message.localApiBase === "string" && message.localApiBase.trim()
5514 ? message.localApiBase.trim()
5515 : previous.localApiBase,
5516 clientId: typeof message.clientId === "string" && message.clientId.trim()
5517 ? message.clientId.trim()
5518 : (state.clientId || previous.clientId),
5519 protocol: typeof message.protocol === "string" && message.protocol.trim() ? message.protocol.trim() : null,
5520 version,
5521 retryCount: 0,
5522 nextRetryAt: 0,
5523 lastMessageAt: Date.now(),
5524 lastError: null,
5525 raw: truncateControlRaw(message)
5526 });
5527}
5528
5529function findMatchingAutomationSnapshot(pageControl, snapshots) {
5530 if (!pageControl?.platform || !Array.isArray(snapshots) || snapshots.length === 0) {
5531 return null;
5532 }
5533
5534 const conversationId = trimToNull(pageControl.conversationId);
5535 const pageUrl = trimToNull(pageControl.pageUrl);
5536
5537 return snapshots.find((entry) =>
5538 entry?.platform === pageControl.platform
5539 && (
5540 (conversationId && entry.remoteConversationId === conversationId)
5541 || (pageUrl && entry.pageUrl === pageUrl)
5542 )
5543 ) || null;
5544}
5545
5546function normalizeAutomationConversationSnapshot(value) {
5547 if (!isRecord(value)) {
5548 return null;
5549 }
5550
5551 const activeLink = isRecord(value.active_link) ? value.active_link : null;
5552 return {
5553 automationStatus: normalizeConversationAutomationStatus(value.automation_status),
5554 lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.last_non_paused_automation_status),
5555 localConversationId: trimToNull(value.local_conversation_id),
5556 pageTitle: trimToNull(activeLink?.page_title),
5557 pageUrl: trimToNull(activeLink?.page_url),
5558 pauseReason: trimToNull(value.pause_reason),
5559 platform: trimToNull(value.platform),
5560 remoteConversationId: trimToNull(value.remote_conversation_id) || trimToNull(activeLink?.remote_conversation_id),
5561 updatedAt: Number(value.updated_at) || 0
5562 };
5563}
5564
5565function applyWsAutomationConversationSnapshot(browserSnapshot) {
5566 const rawEntries = Array.isArray(browserSnapshot?.automation_conversations)
5567 ? browserSnapshot.automation_conversations
5568 : [];
5569 const snapshots = rawEntries
5570 .map(normalizeAutomationConversationSnapshot)
5571 .filter((entry) => entry?.platform && entry.automationStatus);
5572 let changed = false;
5573
5574 for (const pageControl of listPageControlStates()) {
5575 const match = findMatchingAutomationSnapshot(pageControl, snapshots);
5576
5577 if (!match) {
5578 continue;
5579 }
5580
5581 const previous = serializePageControlState(pageControl);
5582 const next = updatePageControlState({
5583 platform: pageControl.platform,
5584 tabId: pageControl.tabId,
5585 conversationId: match.remoteConversationId || pageControl.conversationId,
5586 localConversationId: match.localConversationId,
5587 pageUrl: match.pageUrl || pageControl.pageUrl,
5588 pageTitle: match.pageTitle || pageControl.pageTitle,
5589 automationStatus: match.automationStatus,
5590 lastNonPausedAutomationStatus: match.lastNonPausedAutomationStatus,
5591 pauseReason: match.automationStatus === "paused" ? match.pauseReason : null,
5592 pauseSource: "ws_snapshot",
5593 updatedAt: match.updatedAt || Date.now()
5594 }, {
5595 persist: false,
5596 render: false,
5597 reason: match.automationStatus === "paused" ? match.pauseReason : null,
5598 source: "ws_snapshot"
5599 });
5600
5601 if (JSON.stringify(previous) !== JSON.stringify(next)) {
5602 changed = true;
5603 }
5604 }
5605
5606 return changed;
5607}
5608
5609function handleWsStateSnapshot(message) {
5610 const previousWsState = cloneWsState(state.wsState);
5611 const previousControlState = cloneControlState(state.controlState);
5612 const snapshot = isRecord(message.snapshot) ? message.snapshot : {};
5613 const server = isRecord(snapshot.server) ? snapshot.server : {};
5614 const browserSnapshot = isRecord(snapshot.browser) ? snapshot.browser : {};
5615 const clientCount = normalizeCount(getFirstDefinedValue(browserSnapshot, ["client_count", "clients"]));
5616 const nextWsState = createDefaultWsState({
5617 ...previousWsState,
5618 connection: "connected",
5619 wsUrl: typeof server.ws_url === "string" && server.ws_url.trim() ? server.ws_url.trim() : state.wsUrl,
5620 localApiBase: typeof server.local_api_base === "string" && server.local_api_base.trim()
5621 ? server.local_api_base.trim()
5622 : previousWsState.localApiBase,
5623 serverIdentity: typeof server.identity === "string" && server.identity.trim() ? server.identity.trim() : null,
5624 serverHost: typeof server.host === "string" && server.host.trim() ? server.host.trim() : null,
5625 serverRole: typeof server.role === "string" && server.role.trim() ? server.role.trim() : null,
5626 leaseState: typeof server.lease_state === "string" && server.lease_state.trim() ? server.lease_state.trim() : null,
5627 clientCount: clientCount ?? previousWsState.clientCount,
5628 retryCount: 0,
5629 nextRetryAt: 0,
5630 lastMessageAt: Date.now(),
5631 lastSnapshotAt: Date.now(),
5632 lastSnapshotReason: typeof message.reason === "string" && message.reason.trim() ? message.reason.trim() : null,
5633 lastError: null,
5634 raw: truncateControlRaw(snapshot)
5635 });
5636 let renderChanged = JSON.stringify(previousWsState) !== JSON.stringify(nextWsState);
5637 let persistChanged = false;
5638
5639 state.wsState = nextWsState;
5640
5641 if (isRecord(snapshot.system)) {
5642 const nextControlState = createControlSuccessState(snapshot.system, {
5643 ok: true,
5644 statusCode: 200,
5645 source: "ws_snapshot"
5646 }, previousControlState);
5647
5648 if (JSON.stringify(previousControlState) !== JSON.stringify(nextControlState)) {
5649 state.controlState = nextControlState;
5650 renderChanged = true;
5651 persistChanged = true;
5652 }
5653 }
5654
5655 if (applyWsAutomationConversationSnapshot(browserSnapshot)) {
5656 renderChanged = true;
5657 persistChanged = true;
5658 }
5659
5660 if (persistChanged) {
5661 persistState().catch(() => {});
5662 }
5663
5664 if (renderChanged) {
5665 render();
5666 }
5667}
5668
5669function closeWsConnection() {
5670 clearTimeout(state.reconnectTimer);
5671
5672 if (state.ws) {
5673 state.ws.onopen = null;
5674 state.ws.onmessage = null;
5675 state.ws.onclose = null;
5676 state.ws.onerror = null;
5677 try {
5678 state.ws.close();
5679 } catch (_) {}
5680 }
5681
5682 state.ws = null;
5683 state.wsConnected = false;
5684}
5685
5686function connectWs(options = {}) {
5687 const { silentWhenDisabled = false } = options;
5688 closeWsConnection();
5689 setWsState({
5690 ...cloneWsState(state.wsState),
5691 connection: "connecting",
5692 wsUrl: state.wsUrl,
5693 localApiBase: DEFAULT_LOCAL_API_BASE,
5694 clientId: state.clientId,
5695 nextRetryAt: 0,
5696 lastError: null
5697 });
5698
5699 if (!isWsEnabled()) {
5700 if (!silentWhenDisabled) {
5701 addLog("info", "本地 WS 已被禁用", false);
5702 }
5703 return;
5704 }
5705
5706 addLog("info", `正在连接本地 WS:${state.wsUrl}`, false);
5707
5708 try {
5709 state.ws = new WebSocket(state.wsUrl);
5710 } catch (error) {
5711 const message = error instanceof Error ? error.message : String(error);
5712 setWsState({
5713 ...cloneWsState(state.wsState),
5714 connection: "disconnected",
5715 lastError: message
5716 });
5717 addLog("error", `本地 WS 创建失败:${message}`, false);
5718 scheduleReconnect(message);
5719 return;
5720 }
5721
5722 state.ws.onopen = () => {
5723 state.wsConnected = true;
5724 setWsState({
5725 ...cloneWsState(state.wsState),
5726 connection: "connected",
5727 wsUrl: state.wsUrl,
5728 localApiBase: DEFAULT_LOCAL_API_BASE,
5729 clientId: state.clientId,
5730 retryCount: 0,
5731 nextRetryAt: 0,
5732 lastOpenAt: Date.now(),
5733 lastMessageAt: Date.now(),
5734 lastError: null
5735 });
5736 sendHello();
5737 flushBufferedPluginDiagnosticLogs();
5738 addLog("info", "本地 WS 已连接", false);
5739 sendCredentialSnapshot(null, true);
5740 sendEndpointSnapshot();
5741 };
5742
5743 state.ws.onmessage = (event) => {
5744 let message = null;
5745 try {
5746 message = JSON.parse(event.data);
5747 } catch (_) {
5748 return;
5749 }
5750
5751 if (!message || typeof message !== "object") return;
5752
5753 setWsState({
5754 ...cloneWsState(state.wsState),
5755 lastMessageAt: Date.now(),
5756 lastError: null
5757 });
5758
5759 if (message.type === "browser.proxy_delivery") {
5760 runProxyDeliveryAction(message).then((result) => {
5761 sendPluginActionResult(result, {
5762 action: "proxy_delivery",
5763 commandType: message.type,
5764 completed: true,
5765 platform: result.platform,
5766 requestId: readPluginActionRequestId(message)
5767 });
5768 }).catch((error) => {
5769 const messageText = error instanceof Error ? error.message : String(error);
5770 addLog("error", `proxy_delivery 失败:${messageText}`, false);
5771 sendPluginActionResult({
5772 action: "proxy_delivery",
5773 platform: trimToNull(message.platform),
5774 results: []
5775 }, {
5776 accepted: true,
5777 action: "proxy_delivery",
5778 commandType: message.type,
5779 completed: true,
5780 failed: true,
5781 platform: trimToNull(message.platform),
5782 reason: messageText,
5783 requestId: readPluginActionRequestId(message)
5784 });
5785 });
5786 return;
5787 }
5788
5789 if (message.type === "browser.inject_message" || message.type === "browser.send_message") {
5790 const command = message.type === "browser.inject_message" ? "inject_message" : "send_message";
5791
5792 runDeliveryAction(message, command).then((result) => {
5793 sendPluginActionResult(result, {
5794 action: command,
5795 commandType: message.type,
5796 completed: true,
5797 platform: result.platform,
5798 requestId: readPluginActionRequestId(message)
5799 });
5800 }).catch((error) => {
5801 const messageText = error instanceof Error ? error.message : String(error);
5802 addLog("error", `${command} 失败:${messageText}`, false);
5803 sendPluginActionResult({
5804 action: command,
5805 platform: trimToNull(message.platform),
5806 results: []
5807 }, {
5808 accepted: true,
5809 action: command,
5810 commandType: message.type,
5811 completed: true,
5812 failed: true,
5813 platform: trimToNull(message.platform),
5814 reason: messageText,
5815 requestId: readPluginActionRequestId(message)
5816 });
5817 });
5818 return;
5819 }
5820
5821 const pluginAction = extractPluginManagementMessage(message);
5822 if (pluginAction) {
5823 runPluginManagementAction(pluginAction.action, {
5824 ...(pluginAction.action === "ws_reconnect"
5825 ? extractWsReconnectActionOverrides(pluginAction)
5826 : {}),
5827 platform: pluginAction.platform,
5828 source: pluginAction.source,
5829 reason: trimToNull(message.reason) || "ws_plugin_action"
5830 }).then((result) => {
5831 sendPluginActionResult(result, {
5832 action: pluginAction.action,
5833 commandType: pluginAction.commandType,
5834 completed: result?.deferred !== true,
5835 platform: pluginAction.platform,
5836 requestId: pluginAction.requestId
5837 });
5838 }).catch((error) => {
5839 const messageText = error instanceof Error ? error.message : String(error);
5840 addLog("error", `插件动作 ${pluginAction.action} 失败:${messageText}`, false);
5841 sendPluginActionResult({
5842 action: pluginAction.action,
5843 platform: pluginAction.platform,
5844 results: []
5845 }, {
5846 accepted: true,
5847 action: pluginAction.action,
5848 commandType: pluginAction.commandType,
5849 completed: true,
5850 failed: true,
5851 platform: pluginAction.platform,
5852 reason: messageText,
5853 requestId: pluginAction.requestId
5854 });
5855 });
5856 return;
5857 }
5858
5859 switch (message.type) {
5860 case "hello_ack":
5861 handleWsHelloAck(message);
5862 break;
5863 case "state_snapshot":
5864 handleWsStateSnapshot(message);
5865 break;
5866 case "action_result":
5867 if (message.ok) {
5868 addLog("info", `本地 WS 动作确认:${message.action || "-"}`, false);
5869 } else {
5870 addLog("warn", `本地 WS 动作失败:${message.message || message.error || message.action || "-"}`, false);
5871 }
5872 break;
5873 case "open_tab": {
5874 const targets = getTargetPlatforms(message.platform);
5875 for (const target of targets) {
5876 ensurePlatformTab(target, { focus: targets.length === 1 }).catch(() => {});
5877 }
5878 break;
5879 }
5880 case "api_request":
5881 if (String(message.response_mode || message.responseMode || "buffered").toLowerCase() === "sse") {
5882 proxyApiRequest(message).catch((error) => {
5883 if (error?.streamReported === true) {
5884 addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
5885 return;
5886 }
5887
5888 const streamId = trimToNull(message.stream_id) || trimToNull(message.streamId) || message.id;
5889 const errorCode = error.message === "browser_request_cancelled" ? "request_cancelled" : "proxy_failed";
5890 wsSend({
5891 type: "stream_error",
5892 id: message.id,
5893 stream_id: streamId,
5894 status: null,
5895 code: errorCode,
5896 message: error.message
5897 });
5898 addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
5899 });
5900 break;
5901 }
5902
5903 proxyApiRequest(message).then((result) => {
5904 sendApiResponse(message.id, result.ok, result.status, result.body, result.error);
5905 if (!result.ok) {
5906 addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${result.error || result.status || "proxy_failed"}`);
5907 }
5908 }).catch((error) => {
5909 sendApiResponse(message.id, false, null, null, error.message);
5910 addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
5911 });
5912 break;
5913 case "api_request_cancel":
5914 case "request_cancel": {
5915 const requestId = trimToNull(message.requestId) || trimToNull(message.id);
5916 const cancelled = requestId
5917 ? cancelPendingProxyRequest(requestId, trimToNull(message.reason) || "browser_request_cancelled")
5918 : false;
5919
5920 if (cancelled) {
5921 addLog("info", `已取消本地代理请求 ${requestId}`, false);
5922 } else if (requestId) {
5923 addLog("warn", `待取消的本地代理请求不存在:${requestId}`, false);
5924 }
5925 break;
5926 }
5927 case "request_credentials":
5928 try {
5929 const requestedPlatform = trimToNull(message.platform);
5930 sendCredentialSnapshot(requestedPlatform || null, true);
5931 sendPluginActionResult({
5932 action: "request_credentials",
5933 platform: requestedPlatform,
5934 results: getTargetPlatforms(requestedPlatform).map((target) => ({
5935 ok: true,
5936 platform: target,
5937 shell_runtime: buildPlatformRuntimeSnapshot(target)
5938 }))
5939 }, {
5940 action: "request_credentials",
5941 commandType: "request_credentials",
5942 platform: requestedPlatform,
5943 requestId: readPluginActionRequestId(message)
5944 });
5945 } catch (error) {
5946 const messageText = error instanceof Error ? error.message : String(error);
5947 addLog("error", `刷新凭证快照失败:${messageText}`, false);
5948 sendPluginActionResult({
5949 action: "request_credentials",
5950 platform: trimToNull(message.platform),
5951 results: []
5952 }, {
5953 accepted: true,
5954 action: "request_credentials",
5955 commandType: "request_credentials",
5956 completed: true,
5957 failed: true,
5958 platform: trimToNull(message.platform),
5959 reason: messageText,
5960 requestId: readPluginActionRequestId(message)
5961 });
5962 }
5963 break;
5964 case "error":
5965 setWsState({
5966 ...cloneWsState(state.wsState),
5967 lastMessageAt: Date.now(),
5968 lastError: String(message.message || message.code || "ws_error"),
5969 raw: truncateControlRaw(message)
5970 });
5971 addLog("error", `本地 WS 返回错误:${message.message || message.code || "未知错误"}`, false);
5972 break;
5973 case "reload":
5974 addLog("warn", "收到重载命令");
5975 window.location.reload();
5976 break;
5977 default:
5978 break;
5979 }
5980 };
5981
5982 state.ws.onclose = (event) => {
5983 state.wsConnected = false;
5984 setWsState({
5985 ...cloneWsState(state.wsState),
5986 connection: "disconnected",
5987 lastCloseCode: event.code || 0,
5988 lastCloseReason: event.reason || null,
5989 lastError: event.reason || `closed_${event.code || 0}`
5990 });
5991 addLog("warn", `本地 WS 已关闭 code=${event.code || 0} reason=${event.reason || "-"}`, false);
5992 scheduleReconnect(event.reason || `closed_${event.code || 0}`);
5993 };
5994
5995 state.ws.onerror = () => {
5996 state.wsConnected = false;
5997 setWsState({
5998 ...cloneWsState(state.wsState),
5999 lastError: "连接错误"
6000 });
6001 addLog("error", `本地 WS 错误:${state.wsUrl}`, false);
6002 };
6003}
6004
6005function scheduleReconnect(reason = null) {
6006 clearTimeout(state.reconnectTimer);
6007 if (!isWsEnabled()) return;
6008 const previous = cloneWsState(state.wsState);
6009 const nextRetryAt = Date.now() + WS_RECONNECT_DELAY;
6010 setWsState({
6011 ...previous,
6012 connection: "retrying",
6013 retryCount: (Number(previous.retryCount) || 0) + 1,
6014 nextRetryAt,
6015 lastError: reason || previous.lastError
6016 });
6017 state.reconnectTimer = setTimeout(() => {
6018 connectWs({ silentWhenDisabled: true });
6019 }, WS_RECONNECT_DELAY);
6020}
6021
6022async function resolveTrackedTab(platform, options = {}) {
6023 const { requireShell = false, allowFallbackShell = false } = options;
6024 const tabId = state.trackedTabs[platform];
6025 if (!Number.isInteger(tabId)) return null;
6026 try {
6027 const tab = await browser.tabs.get(tabId);
6028 if (!tab || !tab.url || !isPlatformUrl(platform, tab.url)) return null;
6029 if (requireShell && !isPlatformShellUrl(platform, tab.url, { allowFallback: allowFallbackShell })) return null;
6030 return tab;
6031 } catch (_) {
6032 return null;
6033 }
6034}
6035
6036function sortTabsByRecency(tabs = []) {
6037 return [...tabs].sort((left, right) => {
6038 if (!!left.active !== !!right.active) return left.active ? -1 : 1;
6039 const leftAccess = Number(left.lastAccessed) || 0;
6040 const rightAccess = Number(right.lastAccessed) || 0;
6041 return rightAccess - leftAccess;
6042 });
6043}
6044
6045function dedupeTabsById(tabs = []) {
6046 const seen = new Set();
6047 const unique = [];
6048
6049 for (const tab of tabs) {
6050 if (!Number.isInteger(tab?.id) || seen.has(tab.id)) {
6051 continue;
6052 }
6053
6054 seen.add(tab.id);
6055 unique.push(tab);
6056 }
6057
6058 return unique;
6059}
6060
6061async function queryPlatformTabs(platform) {
6062 return sortTabsByRecency(await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns }));
6063}
6064
6065async function queryOpenBusinessPlatformTabs(platform) {
6066 return dedupeTabsById(await queryPlatformTabs(platform)).filter((tab) => {
6067 if (!Number.isInteger(tab?.id) || !trimToNull(tab.url)) {
6068 return false;
6069 }
6070
6071 return !isPlatformShellUrl(platform, tab.url || "", { allowFallback: false });
6072 });
6073}
6074
6075async function findPlatformTab(platform) {
6076 const tabs = await queryPlatformTabs(platform);
6077 if (tabs.length === 0) return null;
6078 return tabs[0];
6079}
6080
6081async function findPlatformShellTab(platform, preferredTabId = null, options = {}) {
6082 const allowFallbackShell = options.allowFallbackShell === true;
6083 const tabs = await queryPlatformTabs(platform);
6084 const shellTabs = tabs.filter((tab) => isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }));
6085 if (shellTabs.length === 0) return null;
6086
6087 const canonical = Number.isInteger(preferredTabId)
6088 ? (shellTabs.find((tab) => tab.id === preferredTabId) || shellTabs[0])
6089 : shellTabs[0];
6090 const duplicateIds = shellTabs
6091 .map((tab) => tab.id)
6092 .filter((tabId) => Number.isInteger(tabId) && tabId !== canonical.id);
6093
6094 if (duplicateIds.length > 0) {
6095 browser.tabs.remove(duplicateIds).catch(() => {});
6096 }
6097
6098 return canonical;
6099}
6100
6101async function injectObserverScriptsIntoTab(tabId) {
6102 const result = {
6103 tabId,
6104 ok: false,
6105 contentScriptInjected: false,
6106 interceptorInjected: false,
6107 error: null
6108 };
6109
6110 if (!browser.scripting?.executeScript) {
6111 result.error = "scripting_execute_script_unavailable";
6112 return result;
6113 }
6114
6115 const errors = [];
6116
6117 try {
6118 await browser.scripting.executeScript({
6119 target: { tabId },
6120 files: CONTENT_SCRIPT_INJECTION_FILES
6121 });
6122 result.contentScriptInjected = true;
6123 } catch (error) {
6124 errors.push(`content=${error instanceof Error ? error.message : String(error)}`);
6125 }
6126
6127 try {
6128 await browser.scripting.executeScript({
6129 target: { tabId },
6130 files: PAGE_INTERCEPTOR_INJECTION_FILES,
6131 world: "MAIN"
6132 });
6133 result.interceptorInjected = true;
6134 } catch (error) {
6135 errors.push(`interceptor=${error instanceof Error ? error.message : String(error)}`);
6136 }
6137
6138 result.ok = result.contentScriptInjected && result.interceptorInjected;
6139 result.error = errors.length > 0 ? errors.join("; ") : null;
6140 return result;
6141}
6142
6143function summarizeObserverRefresh(results = []) {
6144 const source = Array.isArray(results) ? results : [];
6145 const refreshed = source.filter((entry) => entry.ok);
6146 const failed = source.filter((entry) => !entry.ok);
6147
6148 return {
6149 attempted_count: source.length,
6150 refreshed_count: refreshed.length,
6151 failed_count: failed.length,
6152 tab_ids: source.map((entry) => entry.tabId),
6153 refreshed_tab_ids: refreshed.map((entry) => entry.tabId),
6154 failed_tab_ids: failed.map((entry) => entry.tabId)
6155 };
6156}
6157
6158async function reinjectPlatformTabs(platform, options = {}) {
6159 if (!PLATFORMS[platform]) return [];
6160
6161 const excludeTabIds = new Set(
6162 Array.isArray(options.excludeTabIds)
6163 ? options.excludeTabIds.filter((tabId) => Number.isInteger(tabId))
6164 : []
6165 );
6166 const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
6167 const tabs = dedupeTabsById(
6168 Array.isArray(options.tabs)
6169 ? sortTabsByRecency(options.tabs)
6170 : await queryPlatformTabs(platform)
6171 );
6172 const results = [];
6173
6174 for (const tab of tabs) {
6175 if (!Number.isInteger(tab?.id) || excludeTabIds.has(tab.id)) {
6176 continue;
6177 }
6178
6179 const injection = await injectObserverScriptsIntoTab(tab.id);
6180 results.push({
6181 ...injection,
6182 isShellPage: isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }),
6183 platform,
6184 url: trimToNull(tab.url)
6185 });
6186 }
6187
6188 if (results.length > 0) {
6189 const summary = summarizeObserverRefresh(results);
6190 addLog(
6191 "info",
6192 `已刷新 ${platformLabel(platform)} 页面观察脚本 ${summary.refreshed_count}/${summary.attempted_count} 个标签页,来源 ${trimToNull(options.source) || "runtime"}`,
6193 false
6194 );
6195
6196 if (summary.failed_count > 0) {
6197 const failedTabs = results
6198 .filter((entry) => !entry.ok)
6199 .map((entry) => `${entry.tabId}:${entry.error || "unknown"}`)
6200 .join(", ");
6201 addLog("warn", `${platformLabel(platform)} 页面观察脚本刷新失败:${failedTabs}`, false);
6202 }
6203 }
6204
6205 return results;
6206}
6207
6208async function reinjectAllOpenPlatformTabs(options = {}) {
6209 const results = [];
6210
6211 for (const platform of PLATFORM_ORDER) {
6212 results.push(...await reinjectPlatformTabs(platform, options));
6213 }
6214
6215 return results;
6216}
6217
6218function describeStartupOpenAiTabReloadDecision(reason) {
6219 switch (reason) {
6220 case "initial_install":
6221 return "首次安装";
6222 case "extension_reload":
6223 return "插件重载";
6224 case "browser_startup":
6225 return "浏览器启动";
6226 case "controller_reopen":
6227 return "controller 页面重新打开";
6228 case "session_storage_unavailable":
6229 return "session 存储不可用";
6230 default:
6231 return "未命中自动刷新条件";
6232 }
6233}
6234
6235async function resolveStartupOpenAiTabReloadPlan(previousExtensionOrigin = null) {
6236 const currentExtensionOrigin = getRuntimeOrigin();
6237 const sessionStorage = browser.storage?.session;
6238 let firstControllerInBrowserSession = true;
6239 let sessionStorageAvailable = false;
6240
6241 if (sessionStorage?.get && sessionStorage?.set) {
6242 sessionStorageAvailable = true;
6243
6244 try {
6245 const saved = await sessionStorage.get(SESSION_CONTROLLER_BOOT_MARKER_KEY);
6246 const existingMarker = trimToNull(saved?.[SESSION_CONTROLLER_BOOT_MARKER_KEY]);
6247 firstControllerInBrowserSession = !existingMarker;
6248
6249 if (!existingMarker) {
6250 await sessionStorage.set({
6251 [SESSION_CONTROLLER_BOOT_MARKER_KEY]: currentExtensionOrigin || String(Date.now())
6252 });
6253 }
6254 } catch (error) {
6255 firstControllerInBrowserSession = true;
6256 addLog(
6257 "warn",
6258 `无法读取 controller session 标记,自动刷新将退化为基于扩展 origin 的判断:${error instanceof Error ? error.message : String(error)}`,
6259 false
6260 );
6261 }
6262 }
6263
6264 const normalizedPreviousOrigin = trimToNull(previousExtensionOrigin);
6265 const initialInstall = !normalizedPreviousOrigin;
6266 const sameBrowserSession = !!currentExtensionOrigin && currentExtensionOrigin === normalizedPreviousOrigin;
6267 const shouldReload = firstControllerInBrowserSession && (initialInstall || sameBrowserSession);
6268 let reason = "unknown";
6269
6270 if (shouldReload) {
6271 reason = initialInstall ? "initial_install" : "extension_reload";
6272 } else if (!firstControllerInBrowserSession) {
6273 reason = "controller_reopen";
6274 } else if (!sessionStorageAvailable) {
6275 reason = "session_storage_unavailable";
6276 } else {
6277 reason = "browser_startup";
6278 }
6279
6280 return {
6281 shouldReload,
6282 reason,
6283 previousExtensionOrigin: normalizedPreviousOrigin,
6284 currentExtensionOrigin
6285 };
6286}
6287
6288async function reloadOpenBusinessPlatformTabs(options = {}) {
6289 const source = trimToNull(options.source) || "startup";
6290 const candidates = [];
6291
6292 for (const platform of PLATFORM_ORDER) {
6293 const tabs = await queryOpenBusinessPlatformTabs(platform);
6294 for (const tab of tabs) {
6295 candidates.push({
6296 platform,
6297 tabId: tab.id,
6298 url: trimToNull(tab.url)
6299 });
6300 }
6301 }
6302
6303 if (candidates.length === 0) {
6304 addLog("info", `无需刷新已打开的 AI 页面,来源 ${source}`, false);
6305 return [];
6306 }
6307
6308 const reloaded = [];
6309 for (const candidate of candidates) {
6310 try {
6311 await browser.tabs.reload(candidate.tabId);
6312 reloaded.push(candidate);
6313 addLog(
6314 "info",
6315 `已自动刷新 ${platformLabel(candidate.platform)} 标签页 ${candidate.tabId},URL ${candidate.url || "-"},来源 ${source}`,
6316 false
6317 );
6318 } catch (error) {
6319 addLog(
6320 "warn",
6321 `自动刷新 ${platformLabel(candidate.platform)} 标签页 ${candidate.tabId} 失败,URL ${candidate.url || "-"}:${error instanceof Error ? error.message : String(error)}`,
6322 false
6323 );
6324 }
6325 }
6326
6327 addLog("info", `已自动刷新 ${reloaded.length}/${candidates.length} 个 AI 业务标签页,来源 ${source}`, false);
6328 return reloaded;
6329}
6330
6331function scheduleStartupOpenAiTabReload(plan = null) {
6332 clearTimeout(state.startupOpenAiTabReloadTimer);
6333
6334 if (!plan?.shouldReload) {
6335 addLog("info", `跳过已打开 AI 页面自动刷新:${describeStartupOpenAiTabReloadDecision(plan?.reason)}`, false);
6336 return false;
6337 }
6338
6339 addLog(
6340 "info",
6341 `检测到${describeStartupOpenAiTabReloadDecision(plan.reason)},将在 ${STARTUP_OPEN_AI_TAB_RELOAD_DELAY}ms 后自动刷新已打开的 AI 业务页`,
6342 false
6343 );
6344
6345 state.startupOpenAiTabReloadTimer = setTimeout(() => {
6346 reloadOpenBusinessPlatformTabs({
6347 source: plan.reason
6348 }).catch((error) => {
6349 addLog(
6350 "error",
6351 `自动刷新已打开 AI 页面失败:${error instanceof Error ? error.message : String(error)}`,
6352 false
6353 );
6354 });
6355 }, STARTUP_OPEN_AI_TAB_RELOAD_DELAY);
6356
6357 return true;
6358}
6359
6360async function setTrackedTab(platform, tab) {
6361 state.trackedTabs[platform] = tab ? tab.id : null;
6362 state.actualTabs[platform] = buildActualTabSnapshot(
6363 platform,
6364 tab || null,
6365 null,
6366 state.actualTabs[platform]
6367 );
6368 pruneInvalidCredentialState();
6369 if (platform === "claude") {
6370 const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
6371 updateClaudeState({
6372 tabId: tab?.id ?? null,
6373 currentUrl: tab?.url || getPlatformShellUrl(platform),
6374 tabTitle: trimToNull(tab?.title),
6375 conversationId: isPlatformShellUrl(platform, tab?.url || "", { allowFallback: allowFallbackShell })
6376 ? null
6377 : (extractClaudeConversationIdFromPageUrl(tab?.url || "") || state.claudeState.conversationId)
6378 }, {
6379 render: true
6380 });
6381 }
6382 await persistState();
6383 render();
6384}
6385
6386async function ensurePlatformTab(platform, options = {}) {
6387 const {
6388 focus = false,
6389 reloadIfExisting = false,
6390 recordDesired = true
6391 } = options;
6392
6393 if (recordDesired) {
6394 setDesiredTabState(platform, true, {
6395 source: options.source || "runtime",
6396 reason: options.reason || "shell_requested",
6397 action: options.action
6398 });
6399 }
6400
6401 let tab = await resolveTrackedTab(platform);
6402 if (!tab) {
6403 tab = await findPlatformShellTab(platform, null, { allowFallbackShell: true });
6404 }
6405
6406 const shellUrl = getPlatformShellUrl(platform);
6407 let created = false;
6408 let updatedToShell = false;
6409 if (!tab) {
6410 created = true;
6411 tab = await browser.tabs.create({
6412 url: shellUrl,
6413 active: focus
6414 });
6415 addLog("info", `已打开 ${platformLabel(platform)} 空壳页 ${tab.id}`);
6416 } else if (!isPlatformShellUrl(platform, tab.url || "", { allowFallback: true })) {
6417 tab = await browser.tabs.update(tab.id, {
6418 url: shellUrl,
6419 active: focus
6420 });
6421 updatedToShell = true;
6422 addLog("info", `已将 ${platformLabel(platform)} 标签页 ${tab.id} 收口为空壳页`);
6423 } else if (focus) {
6424 tab = await findPlatformShellTab(platform, tab.id, { allowFallbackShell: true }) || tab;
6425 await browser.tabs.update(tab.id, { active: true });
6426 if (tab.windowId != null) {
6427 await browser.windows.update(tab.windowId, { focused: true });
6428 }
6429 } else {
6430 tab = await findPlatformShellTab(platform, tab.id, { allowFallbackShell: true }) || tab;
6431 }
6432
6433 await setTrackedTab(platform, tab);
6434
6435 if ((updatedToShell || reloadIfExisting) && !created) {
6436 try {
6437 await browser.tabs.reload(tab.id);
6438 addLog("info", `已重新加载 ${platformLabel(platform)} 空壳页 ${tab.id}`);
6439 } catch (error) {
6440 addLog("error", `重新加载 ${platformLabel(platform)} 空壳页失败:${error.message}`);
6441 }
6442 }
6443
6444 return tab;
6445}
6446
6447async function ensureAllPlatformTabs(options = {}) {
6448 for (const platform of PLATFORM_ORDER) {
6449 await ensurePlatformTab(platform, { focus: false, ...options });
6450 }
6451}
6452
6453async function refreshTrackedTabsFromBrowser(reason = "sync") {
6454 if (state.trackedTabRefreshRunning) {
6455 state.trackedTabRefreshQueued = true;
6456 return;
6457 }
6458
6459 state.trackedTabRefreshRunning = true;
6460
6461 try {
6462 do {
6463 state.trackedTabRefreshQueued = false;
6464 state.shellRuntimeLastHealthCheckAt = Date.now();
6465
6466 const next = createPlatformMap(() => null);
6467 const nextActual = createPlatformMap(() => createDefaultActualTabState());
6468 for (const platform of PLATFORM_ORDER) {
6469 const trackedCandidate = await resolveTrackedTab(platform);
6470 const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
6471 let shellTab = trackedCandidate && isPlatformShellUrl(platform, trackedCandidate.url || "", { allowFallback: allowFallbackShell })
6472 ? trackedCandidate
6473 : await resolveTrackedTab(platform, { requireShell: true, allowFallbackShell });
6474
6475 if (!shellTab) {
6476 shellTab = await findPlatformShellTab(platform, null, { allowFallbackShell });
6477 }
6478
6479 let candidateTab = null;
6480 if (!shellTab) {
6481 candidateTab = trackedCandidate || await findPlatformTab(platform);
6482 }
6483
6484 next[platform] = shellTab ? shellTab.id : null;
6485 nextActual[platform] = buildActualTabSnapshot(
6486 platform,
6487 shellTab || null,
6488 candidateTab,
6489 state.actualTabs[platform]
6490 );
6491 }
6492
6493 let changed = false;
6494 let actualChanged = false;
6495 for (const platform of PLATFORM_ORDER) {
6496 if (state.trackedTabs[platform] !== next[platform]) {
6497 state.trackedTabs[platform] = next[platform];
6498 changed = true;
6499 }
6500
6501 if (JSON.stringify(state.actualTabs[platform]) !== JSON.stringify(nextActual[platform])) {
6502 state.actualTabs[platform] = nextActual[platform];
6503 actualChanged = true;
6504 }
6505 }
6506
6507 const credentialChanged = pruneInvalidCredentialState();
6508 if (changed || actualChanged || credentialChanged) {
6509 await persistState();
6510 }
6511 if (changed || actualChanged || credentialChanged) {
6512 sendCredentialSnapshot(null, true);
6513 }
6514 if (changed || actualChanged || credentialChanged || reason === "poll") {
6515 render();
6516 }
6517 } while (state.trackedTabRefreshQueued);
6518 } finally {
6519 state.trackedTabRefreshRunning = false;
6520 }
6521}
6522
6523function scheduleTrackedTabRefresh(reason = "tabs") {
6524 clearTimeout(state.trackedTabRefreshTimer);
6525 state.trackedTabRefreshTimer = setTimeout(() => {
6526 refreshTrackedTabsFromBrowser(reason).catch((error) => {
6527 addLog("error", `刷新平台空壳页失败:${error.message}`);
6528 });
6529 }, TRACKED_TAB_REFRESH_DELAY);
6530}
6531
6532function collectEndpoint(platform, method, url) {
6533 if (!shouldTrackRequest(platform, url)) return;
6534
6535 const key = `${(method || "GET").toUpperCase()} ${normalizePath(url)}`;
6536 const now = Date.now();
6537 const current = state.endpoints[platform][key];
6538 const entry = normalizeEndpointEntry(key, current);
6539 const isNew = !current;
6540
6541 state.endpoints[platform][key] = {
6542 ...entry,
6543 method: (method || "GET").toUpperCase(),
6544 path: normalizePath(url),
6545 firstObservedAt: entry.firstObservedAt || now,
6546 lastObservedAt: now,
6547 sampleUrl: trimToNull(url)
6548 };
6549
6550 persistState().catch(() => {});
6551 render();
6552 if (isNew) {
6553 addLog("info", `已发现 ${platformLabel(platform)} 端点 ${key}`);
6554 }
6555 sendEndpointSnapshot(platform);
6556}
6557
6558function buildNetworkEntry(platform, data, tabId) {
6559 return {
6560 ts: new Date().toISOString(),
6561 platform,
6562 tabId,
6563 url: data.url,
6564 method: data.method,
6565 reqHeaders: redactCredentialHeaders(mergeKnownHeaders(platform, data.reqHeaders || {})),
6566 reqBody: trimBody(data.reqBody),
6567 status: data.status,
6568 resHeaders: data.resHeaders || null,
6569 resBody: trimBody(data.resBody),
6570 error: data.error || null,
6571 duration: data.duration || null,
6572 source: data.source || "page"
6573 };
6574}
6575
6576function ensureTrackedTabId(platform, tabId, source, senderUrl = "") {
6577 if (!Number.isInteger(tabId) || tabId < 0) return false;
6578 const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
6579 const adoptableShell = shouldAdoptPlatformTabAsDesired(platform, senderUrl);
6580 const allowFallbackShell = desired.exists || adoptableShell;
6581
6582 if (!desired.exists && adoptableShell) {
6583 setDesiredTabState(platform, true, {
6584 source: source || "page",
6585 reason: "page_shell_detected",
6586 action: "tab_adopt",
6587 persist: true,
6588 render: true
6589 });
6590 }
6591
6592 const current = state.trackedTabs[platform];
6593 if (current === tabId) {
6594 return senderUrl ? isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell }) : true;
6595 }
6596 if (!isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell })) return false;
6597
6598 state.trackedTabs[platform] = tabId;
6599 pruneInvalidCredentialState();
6600 persistState().catch(() => {});
6601 render();
6602
6603 if (!Number.isInteger(current)) {
6604 addLog("info", `已识别 ${platformLabel(platform)} 空壳页 ${tabId},来源 ${source}`);
6605 } else {
6606 addLog("warn", `已切换 ${platformLabel(platform)} 空壳页 ${current} -> ${tabId},来源 ${source}`);
6607 }
6608 return true;
6609}
6610
6611function isSenderShellContext(platform, senderUrl = "") {
6612 const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
6613 const adoptableShell = shouldAdoptPlatformTabAsDesired(platform, senderUrl);
6614 const allowFallbackShell = desired.exists || adoptableShell;
6615 return isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell });
6616}
6617
6618function getSenderContext(sender, fallbackPlatform = null) {
6619 const tabId = sender?.tab?.id;
6620 const senderUrl = sender?.tab?.url || "";
6621 const senderPlatform = detectPlatformFromUrl(senderUrl);
6622 const platform = senderPlatform || fallbackPlatform;
6623 if (!platform || !Number.isInteger(tabId) || tabId < 0) return null;
6624
6625 const isShellPage = isSenderShellContext(platform, senderUrl);
6626 if (isShellPage) {
6627 if (!ensureTrackedTabId(platform, tabId, "message", senderUrl)) return null;
6628 } else if (senderUrl && !isPlatformUrl(platform, senderUrl)) {
6629 return null;
6630 }
6631
6632 return {
6633 platform,
6634 tabId,
6635 senderUrl,
6636 isShellPage,
6637 conversationId: isShellPage ? null : extractConversationIdFromPageUrl(platform, senderUrl),
6638 pageTitle: trimToNull(sender?.tab?.title)
6639 };
6640}
6641
6642function syncPageControlFromContext(context, overrides = {}, options = {}) {
6643 if (!context?.platform || !Number.isInteger(context.tabId)) {
6644 return null;
6645 }
6646
6647 const input = {
6648 platform: context.platform,
6649 tabId: context.tabId,
6650 conversationId: Object.prototype.hasOwnProperty.call(overrides, "conversationId")
6651 ? overrides.conversationId
6652 : context.conversationId,
6653 pageUrl: Object.prototype.hasOwnProperty.call(overrides, "pageUrl")
6654 ? overrides.pageUrl
6655 : context.senderUrl,
6656 pageTitle: Object.prototype.hasOwnProperty.call(overrides, "pageTitle")
6657 ? overrides.pageTitle
6658 : context.pageTitle,
6659 shellPage: Object.prototype.hasOwnProperty.call(overrides, "shellPage")
6660 ? overrides.shellPage
6661 : context.isShellPage
6662 };
6663
6664 if (Object.prototype.hasOwnProperty.call(overrides, "localConversationId")) {
6665 input.localConversationId = overrides.localConversationId;
6666 }
6667
6668 if (Object.prototype.hasOwnProperty.call(overrides, "paused")) {
6669 input.paused = overrides.paused;
6670 }
6671
6672 if (Object.prototype.hasOwnProperty.call(overrides, "pauseSource")) {
6673 input.pauseSource = overrides.pauseSource;
6674 }
6675
6676 if (Object.prototype.hasOwnProperty.call(overrides, "pauseReason")) {
6677 input.pauseReason = overrides.pauseReason;
6678 }
6679
6680 if (Object.prototype.hasOwnProperty.call(overrides, "automationStatus")) {
6681 input.automationStatus = overrides.automationStatus;
6682 }
6683
6684 if (Object.prototype.hasOwnProperty.call(overrides, "lastNonPausedAutomationStatus")) {
6685 input.lastNonPausedAutomationStatus = overrides.lastNonPausedAutomationStatus;
6686 }
6687
6688 if (Object.prototype.hasOwnProperty.call(overrides, "updatedAt")) {
6689 input.updatedAt = overrides.updatedAt;
6690 }
6691
6692 return updatePageControlState(input, options);
6693}
6694
6695function buildPageControlSnapshotForSender(sender, fallbackPlatform = null) {
6696 const context = getSenderContext(sender, fallbackPlatform);
6697
6698 if (!context) {
6699 return null;
6700 }
6701
6702 return syncPageControlFromContext(context, {}, {
6703 persist: false,
6704 render: false
6705 });
6706}
6707
6708function resolvePlatformFromRequest(details) {
6709 return findTrackedPlatformByTabId(details.tabId) || null;
6710}
6711
6712function getObservedPagePlatform(sender, fallbackPlatform = null) {
6713 const senderUrl = sender?.tab?.url || "";
6714 return detectPlatformFromUrl(senderUrl) || fallbackPlatform || null;
6715}
6716
6717function buildPageDiagnosticLogText(data, sender, context = null) {
6718 const eventName = trimToNull(data?.event);
6719
6720 if (!eventName) {
6721 return null;
6722 }
6723
6724 const platform = context?.platform
6725 || getObservedPagePlatform(sender, trimToNull(data?.platform) || null)
6726 || "unknown";
6727 const tabId = Number.isInteger(context?.tabId)
6728 ? context.tabId
6729 : (Number.isInteger(sender?.tab?.id) ? sender.tab.id : null);
6730 const method = trimToNull(data?.method);
6731 const source = trimToNull(data?.source) || "page";
6732 const url = trimToNull(data?.url) || context?.senderUrl || sender?.tab?.url || "";
6733 const parts = [`[PAGE] ${eventName}`, `platform=${platform}`];
6734
6735 if (tabId != null) {
6736 parts.push(`tab=${tabId}`);
6737 }
6738
6739 if (method) {
6740 parts.push(method);
6741 }
6742
6743 if (url) {
6744 parts.push(normalizePath(url));
6745 }
6746
6747 parts.push(`source=${source}`);
6748
6749 if (eventName === "sse_stream_done") {
6750 const duration = Number.isFinite(data?.duration) ? Math.max(0, Math.round(data.duration)) : null;
6751 parts.push(`duration=${duration == null ? "-" : duration}ms`);
6752 }
6753
6754 switch (eventName) {
6755 case "page_bridge_ready":
6756 case "interceptor_active":
6757 case "fetch_intercepted":
6758 case "sse_stream_start":
6759 case "sse_stream_done":
6760 return parts.join(" ");
6761 default:
6762 return null;
6763 }
6764}
6765
6766function handlePageDiagnosticLog(data, sender) {
6767 const senderUrl = sender?.tab?.url || data?.url || "";
6768 const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || trimToNull(data?.platform) || null);
6769
6770 if (context) {
6771 syncPageControlFromContext(context, {
6772 conversationId: extractObservedConversationId(context.platform, data, context)
6773 });
6774 }
6775
6776 const text = buildPageDiagnosticLogText(data, sender, context);
6777 if (!text) {
6778 return;
6779 }
6780
6781 addLog("debug", text, false);
6782}
6783
6784function getObservedPageConversationId(context, pageControl) {
6785 return trimToNull(context?.conversationId) || trimToNull(pageControl?.conversationId) || null;
6786}
6787
6788function isObservedFinalMessageStale(relay, context, pageControl) {
6789 const relayConversationId = trimToNull(relay?.payload?.conversation_id);
6790 const currentConversationId = getObservedPageConversationId(context, pageControl);
6791
6792 if (!relayConversationId || !currentConversationId) {
6793 return false;
6794 }
6795
6796 return relayConversationId !== currentConversationId;
6797}
6798
6799function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null, routeMeta = null) {
6800 const observer = state.finalMessageRelayObservers[platform];
6801 if (!observer || !relay?.payload) return false;
6802
6803 const pageControl = context ? getPageControlState(context.platform, context.tabId) : createDefaultPageControlState();
6804
6805 if (pageControl.paused) {
6806 FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
6807 persistFinalMessageRelayCache().catch(() => {});
6808 addLog(
6809 "info",
6810 `${platformLabel(platform)} 最终消息已抑制:页面 #${context.tabId}${pageControl.conversationId ? ` conversation=${pageControl.conversationId}` : ""} 处于暂停状态`,
6811 false
6812 );
6813 return false;
6814 }
6815
6816 if (context && isObservedFinalMessageStale(relay, context, pageControl)) {
6817 const currentConversationId = getObservedPageConversationId(context, pageControl);
6818 FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
6819 persistFinalMessageRelayCache().catch(() => {});
6820 addLog(
6821 "info",
6822 `${platformLabel(platform)} 最终消息已抑制:页面 #${context.tabId} 当前 conversation=${currentConversationId},relay conversation=${relay.payload.conversation_id || "-"},判定为 stale replay`,
6823 false
6824 );
6825 return false;
6826 }
6827
6828 const payload = {
6829 ...relay.payload,
6830 ...(context
6831 ? {
6832 page_title: trimToNull(context.pageTitle),
6833 page_url: trimToNull(context.senderUrl),
6834 shell_page: context.isShellPage === true,
6835 tab_id: context.tabId
6836 }
6837 : {}),
6838 ...(isRecord(routeMeta) ? routeMeta : {})
6839 };
6840
6841 if (!wsSend(payload)) {
6842 addLog("warn", `${platformLabel(platform)} 最终消息未能转发(WS 未连接)`, false);
6843 return false;
6844 }
6845
6846 FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
6847 persistFinalMessageRelayCache().catch(() => {});
6848 addLog(
6849 "info",
6850 `${platformLabel(platform)} 最终消息已转发 assistant=${relay.payload.assistant_message_id} source=${source}`,
6851 false
6852 );
6853 return true;
6854}
6855
6856function observeFinalMessageFromPageNetwork(data, sender, context = null) {
6857 if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
6858 return;
6859 }
6860
6861 const platform = context?.platform || getObservedPagePlatform(sender, data.platform || null);
6862 const observer = platform ? state.finalMessageRelayObservers[platform] : null;
6863 if (!observer) {
6864 try { addLog("debug", `[FM-NET] ${platform || "?"} 无 observer,跳过`, false); } catch (_) {}
6865 return;
6866 }
6867
6868 const relay = FINAL_MESSAGE_HELPERS.observeNetwork(observer, data, {
6869 observedAt: Date.now(),
6870 pageUrl: sender?.tab?.url || ""
6871 });
6872
6873 try {
6874 const relevant = typeof FINAL_MESSAGE_HELPERS.isRelevantStreamUrl === "function"
6875 ? FINAL_MESSAGE_HELPERS.isRelevantStreamUrl(platform, data.url) : false;
6876 if (relevant || relay) {
6877 addLog("debug", `[FM-NET] ${platformLabel(platform)} url_relevant=${relevant} relay=${relay ? "产出" : "null"} assistant=${relay?.payload?.assistant_message_id || "-"}`, false);
6878 }
6879 } catch (_) {}
6880
6881 if (relay) {
6882 relayObservedFinalMessage(platform, relay, "page_network", context, platform === "claude"
6883 ? {
6884 organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
6885 }
6886 : null);
6887 }
6888}
6889
6890function observeFinalMessageFromPageSse(data, sender, context = null) {
6891 if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
6892 return;
6893 }
6894
6895 const platform = context?.platform || getObservedPagePlatform(sender, data.platform || null);
6896 const observer = platform ? state.finalMessageRelayObservers[platform] : null;
6897 if (!observer) {
6898 try {
6899 if (data.done === true || data.error) {
6900 addLog("debug", `[FM-SSE] ${platform || "?"} 无 observer,跳过`, false);
6901 }
6902 } catch (_) {}
6903 return;
6904 }
6905
6906 const relay = FINAL_MESSAGE_HELPERS.observeSse(observer, data, {
6907 observedAt: Date.now(),
6908 pageUrl: sender?.tab?.url || ""
6909 });
6910
6911 try {
6912 if (data.done === true || data.error) {
6913 const relevant = typeof FINAL_MESSAGE_HELPERS.isRelevantStreamUrl === "function"
6914 ? FINAL_MESSAGE_HELPERS.isRelevantStreamUrl(platform, data.url) : false;
6915 addLog("debug", `[FM-SSE] ${platformLabel(platform)} ${data.done === true ? "done" : "error"} url_relevant=${relevant} relay=${relay ? "产出" : "null"} assistant=${relay?.payload?.assistant_message_id || "-"}`, false);
6916 }
6917 } catch (_) {}
6918
6919 if (relay) {
6920 relayObservedFinalMessage(platform, relay, "page_sse", context, platform === "claude"
6921 ? {
6922 organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
6923 }
6924 : null);
6925 }
6926}
6927
6928function setClaudeBusy(isBusy, reason = null) {
6929 updateClaudeState({
6930 busy: !!isBusy,
6931 busyReason: isBusy ? reason || "proxy" : null,
6932 lastActivityAt: Date.now()
6933 }, {
6934 render: true
6935 });
6936}
6937
6938function applyObservedClaudeResponse(data, tabId) {
6939 const context = parseClaudeApiContext(data.url || "");
6940 const patch = {
6941 tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
6942 organizationId: context.organizationId || state.claudeState.organizationId,
6943 conversationId: context.conversationId || state.claudeState.conversationId,
6944 lastActivityAt: Date.now()
6945 };
6946
6947 if (context.isCompletion) {
6948 patch.busy = true;
6949 patch.busyReason = data.source === "proxy" ? "proxy" : "page_sse";
6950 }
6951
6952 if (context.isConversationItem && typeof data.resBody === "string" && data.resBody) {
6953 try {
6954 const payload = JSON.parse(data.resBody);
6955 applyClaudeConversation(payload, {
6956 source: data.source === "proxy" ? "proxy_api" : "api_observed",
6957 organizationId: context.organizationId,
6958 conversationId: context.conversationId,
6959 readAt: Date.now()
6960 });
6961 return;
6962 } catch (_) {}
6963 }
6964
6965 updateClaudeState(patch, {
6966 persist: !!(patch.organizationId || patch.conversationId),
6967 render: true
6968 });
6969}
6970
6971function applyObservedClaudeSse(data, tabId) {
6972 const context = parseClaudeApiContext(data.url || "");
6973 const parsed = typeof data.chunk === "string" && data.chunk ? parseClaudeSseText(data.chunk) : null;
6974 const patch = {
6975 tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
6976 organizationId: context.organizationId || state.claudeState.organizationId,
6977 conversationId: context.conversationId || state.claudeState.conversationId,
6978 lastActivityAt: Date.now()
6979 };
6980
6981 if (parsed?.messageUuid) {
6982 patch.lastAssistantMessageUuid = parsed.messageUuid;
6983 }
6984
6985 if (data.done || data.error) {
6986 patch.busy = false;
6987 patch.busyReason = null;
6988 patch.lastError = data.error || null;
6989 } else {
6990 patch.busy = true;
6991 patch.busyReason = "page_sse";
6992 }
6993
6994 updateClaudeState(patch, {
6995 persist: !!(patch.organizationId || patch.conversationId || patch.lastAssistantMessageUuid),
6996 render: true
6997 });
6998}
6999
7000function handlePageNetwork(data, sender) {
7001 const context = getSenderContext(sender, data?.platform || null);
7002 if (context) {
7003 syncPageControlFromContext(context, {
7004 conversationId: extractObservedConversationId(context.platform, data, context)
7005 });
7006 }
7007 observeFinalMessageFromPageNetwork(data, sender, context);
7008 if (!context || !data || !data.url || !data.method) return;
7009 try {
7010 const urlPath = normalizePath(data.url);
7011 const relevant = typeof FINAL_MESSAGE_HELPERS?.isRelevantStreamUrl === "function"
7012 ? FINAL_MESSAGE_HELPERS.isRelevantStreamUrl(context.platform, data.url) : false;
7013 addLog("debug", `[NET] tab=${context.tabId} ${context.platform} ${data.method} ${urlPath} status=${data.status || "-"} sse=${data.sse || false} stream_url=${relevant}`, false);
7014 } catch (_) {}
7015 const observedHeaders = Object.keys(data.reqHeaders || {}).length > 0
7016 ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
7017 : cloneHeaderMap(state.lastHeaders[context.platform]);
7018
7019 if (
7020 context.platform === "chatgpt"
7021 && data.source !== "proxy"
7022 && String(data.method || "GET").toUpperCase() === "POST"
7023 && getRequestPath(data.url, PLATFORMS.chatgpt.rootUrl) === "/backend-api/conversation"
7024 ) {
7025 rememberChatgptSendTemplate(context, data.reqBody);
7026 }
7027
7028 if (context.platform === "claude") {
7029 applyObservedClaudeResponse(data, context.tabId);
7030 }
7031
7032 if (context.platform === "gemini" && typeof data.reqBody === "string" && data.reqBody) {
7033 rememberGeminiSendTemplate(context, data.url, data.reqBody, observedHeaders);
7034 }
7035
7036 collectEndpoint(context.platform, data.method, data.url);
7037 observeAccountMetadata(context.platform, data.url, data.resBody, observedHeaders);
7038
7039 if (data.status >= 400 || data.error) {
7040 addLog("error", `${platformLabel(context.platform)} ${data.method} ${normalizePath(data.url)} -> ${data.status || data.error}`);
7041 }
7042}
7043
7044function handlePageSse(data, sender) {
7045 const context = getSenderContext(sender, data?.platform || null);
7046 if (context) {
7047 syncPageControlFromContext(context, {
7048 conversationId: extractObservedConversationId(context.platform, data, context)
7049 });
7050 }
7051 observeFinalMessageFromPageSse(data, sender, context);
7052 if (!context || !data || !data.url) return;
7053 try {
7054 if (data.done === true || data.error) {
7055 const urlPath = normalizePath(data.url);
7056 addLog("debug", `[SSE] tab=${context.tabId} ${context.platform} ${data.done ? "done" : "error"} ${urlPath} duration=${data.duration || "-"}ms error=${data.error || "-"}`, false);
7057 }
7058 } catch (_) {}
7059
7060 if (context.platform === "claude") {
7061 applyObservedClaudeSse(data, context.tabId);
7062 }
7063
7064 const pending = data.id ? pendingProxyRequests.get(data.id) : null;
7065 if (!pending || pending.responseMode !== "sse") {
7066 return;
7067 }
7068
7069 const streamId = trimToNull(data.stream_id) || pending.streamId || pending.id;
7070 const status = Number.isFinite(data.status) ? data.status : null;
7071
7072 if (!pending.streamOpened || data.open === true) {
7073 pending.streamOpened = true;
7074 pending.streamId = streamId;
7075 pending.resolveAck({
7076 error: status != null && status >= 400 ? `upstream_status_${status}` : null,
7077 id: pending.id,
7078 method: data.method || pending.method || null,
7079 ok: status != null ? status < 400 : data.error == null,
7080 status,
7081 url: data.url || pending.path || null
7082 });
7083 wsSend({
7084 type: "stream_open",
7085 id: pending.id,
7086 stream_id: streamId,
7087 status,
7088 meta: {
7089 method: data.method || pending.method || null,
7090 platform: context.platform,
7091 url: data.url || pending.path || null
7092 }
7093 });
7094 }
7095
7096 if (typeof data.chunk === "string" && data.chunk.trim()) {
7097 const parsedChunk = parseStreamChunkPayload(data.chunk);
7098 const seq = Number.isFinite(data.seq) && data.seq > 0
7099 ? data.seq
7100 : (Number(pending.streamSeq) || 0) + 1;
7101 pending.streamSeq = seq;
7102 wsSend({
7103 type: "stream_event",
7104 id: pending.id,
7105 stream_id: streamId,
7106 seq,
7107 event: parsedChunk.event,
7108 raw: parsedChunk.raw,
7109 data: parsedChunk.data
7110 });
7111 }
7112
7113 if (data.done === true) {
7114 wsSend({
7115 type: "stream_end",
7116 id: pending.id,
7117 stream_id: streamId,
7118 status
7119 });
7120 pending.resolve({
7121 body: null,
7122 error: null,
7123 id: pending.id,
7124 method: data.method || pending.method || null,
7125 ok: true,
7126 status,
7127 url: data.url || pending.path || null
7128 });
7129 return;
7130 }
7131
7132 if (data.error) {
7133 if (!pending.streamOpened) {
7134 if (status != null) {
7135 pending.resolveAck({
7136 error: data.error,
7137 id: pending.id,
7138 method: data.method || pending.method || null,
7139 ok: false,
7140 status,
7141 url: data.url || pending.path || null
7142 });
7143 } else {
7144 pending.rejectAck(new Error(data.error));
7145 }
7146 }
7147 const streamError = new Error(data.error);
7148 streamError.streamReported = true;
7149 wsSend({
7150 type: "stream_error",
7151 id: pending.id,
7152 stream_id: streamId,
7153 status,
7154 code: data.error === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
7155 message: data.error
7156 });
7157 pending.reject(streamError);
7158 }
7159}
7160
7161function handlePageProxyResponse(data, sender) {
7162 const context = getSenderContext(sender, data?.platform || null);
7163 if (context) {
7164 syncPageControlFromContext(context, {
7165 conversationId: extractObservedConversationId(context.platform, data, context)
7166 });
7167 }
7168 if (!context || !data || !data.id) return;
7169 const pending = pendingProxyRequests.get(data.id);
7170 if (!pending) return;
7171 observeAccountMetadata(
7172 context.platform,
7173 data.url || pending.path || "",
7174 typeof data.body === "string" ? data.body : null,
7175 cloneHeaderMap(state.lastHeaders[context.platform])
7176 );
7177
7178 if (context.platform === "claude") {
7179 const parsed = parseClaudeApiContext(data.url || pending.path || "");
7180 updateClaudeState({
7181 organizationId: parsed.organizationId || state.claudeState.organizationId,
7182 conversationId: parsed.conversationId || state.claudeState.conversationId,
7183 busy: false,
7184 busyReason: null,
7185 lastActivityAt: Date.now(),
7186 lastError: data.error || null
7187 }, {
7188 persist: !!(parsed.organizationId || parsed.conversationId),
7189 render: true
7190 });
7191 }
7192
7193 if (
7194 context.platform === "gemini"
7195 && pending.prompt
7196 && pending.attempts < 1
7197 && Number(data.status) === 400
7198 ) {
7199 const xsrfToken = extractGeminiXsrfToken(data.body);
7200 if (updateGeminiTemplateXsrf(pending.templateKey, xsrfToken)) {
7201 pending.attempts += 1;
7202 addLog("info", `Gemini xsrf 已刷新,正在重试代理 ${data.id}`);
7203 try {
7204 const retryTemplate = trimToNull(pending.templateKey)
7205 ? getGeminiSendTemplateByKey(pending.templateKey)
7206 : null;
7207 const retry = retryTemplate
7208 ? buildGeminiRequestFromTemplate(retryTemplate, pending.prompt)
7209 : buildGeminiAutoRequest(pending.prompt, {
7210 allowRecentFallback: true,
7211 allowShellFallback: pending.shellPage === true,
7212 conversationId: pending.conversationId
7213 });
7214 postProxyRequestToTab(context.tabId, {
7215 id: data.id,
7216 platform: "gemini",
7217 method: "POST",
7218 path: retry.path,
7219 body: retry.body,
7220 headers: retry.headers
7221 }).catch((error) => {
7222 pending.reject(error);
7223 addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
7224 });
7225 return;
7226 } catch (error) {
7227 pending.reject(error);
7228 addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
7229 return;
7230 }
7231 }
7232 }
7233
7234 if (pending.responseMode === "sse" && (data.error || (Number.isFinite(data.status) && data.status >= 400))) {
7235 pending.resolveAck({
7236 body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
7237 error: data.error || `upstream_status_${data.status}`,
7238 id: data.id,
7239 method: data.method || pending.method || null,
7240 ok: false,
7241 status: Number.isFinite(data.status) ? data.status : null,
7242 url: data.url || pending.path || null
7243 });
7244 const message = data.error || `upstream_status_${data.status}`;
7245 const streamError = new Error(message);
7246 streamError.streamReported = true;
7247 wsSend({
7248 type: "stream_error",
7249 id: pending.id,
7250 stream_id: pending.streamId || pending.id,
7251 status: Number.isFinite(data.status) ? data.status : null,
7252 code: message === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
7253 message
7254 });
7255 pending.reject(streamError);
7256 return;
7257 }
7258
7259 pending.resolveAck({
7260 body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
7261 error: data.error || null,
7262 id: data.id,
7263 method: data.method || pending.method || null,
7264 ok: data.ok !== false && !data.error,
7265 status: Number.isFinite(data.status) ? data.status : null,
7266 url: data.url || pending.path || null
7267 });
7268 pending.resolve({
7269 id: data.id,
7270 ok: data.ok !== false && !data.error,
7271 status: Number.isFinite(data.status) ? data.status : null,
7272 body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
7273 error: data.error || null,
7274 url: data.url || pending.path || null,
7275 method: data.method || pending.method || null
7276 });
7277
7278 if (!data.ok || (Number.isFinite(data.status) && data.status >= 400)) {
7279 addLog(
7280 "error",
7281 `${platformLabel(context.platform)} 代理 ${data.method || "GET"} ${normalizePath(data.url || data.path || "-")} -> ${data.status || data.error || "失败"}`
7282 );
7283 }
7284}
7285
7286function handlePageBridgeReady(data, sender) {
7287 const senderUrl = sender?.tab?.url || data?.url || "";
7288 const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || data?.platform || null);
7289 if (!context) return;
7290
7291 syncPageControlFromContext(context, {
7292 conversationId: context.conversationId
7293 });
7294
7295 if (context.platform === "claude") {
7296 updateClaudeState({
7297 tabId: context.tabId,
7298 currentUrl: senderUrl || getPlatformShellUrl(context.platform),
7299 tabTitle: trimToNull(sender?.tab?.title),
7300 conversationId: context.isShellPage
7301 ? null
7302 : (extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId)
7303 }, {
7304 persist: true,
7305 render: true
7306 });
7307 }
7308 addLog(
7309 "info",
7310 `${platformLabel(context.platform)} ${context.isShellPage ? "空壳页" : "页面观察"}已就绪,标签页 ${context.tabId},来源 ${data?.source || "未知"},URL ${senderUrl.slice(0, 120)},标题 ${trimToNull(sender?.tab?.title) || "-"}`
7311 );
7312}
7313
7314async function observeCredentialSnapshot(platform, headers, details = {}) {
7315 const validation = validateCredentialSnapshot(platform, headers, details.url || "");
7316
7317 if (!validation.valid) {
7318 if (validation.invalidate && clearPlatformCredential(platform)) {
7319 addLog("info", `${platformLabel(platform)} 凭证已清理:${describeCredentialReason(validation.reason)}`);
7320 render();
7321 sendCredentialSnapshot(platform, true);
7322 persistState().catch(() => {});
7323 }
7324 return;
7325 }
7326
7327 const now = Date.now();
7328 const fingerprint = await computeCredentialFingerprint(platform, headers).catch(() => "");
7329 const previousFingerprint = trimToNull(state.credentialFingerprint[platform]) || "";
7330 const isSameFingerprint = !!fingerprint && fingerprint === previousFingerprint;
7331
7332 state.lastHeaders[platform] = headers;
7333 state.lastCredentialAt[platform] = now;
7334 state.lastCredentialUrl[platform] = details.url || "";
7335 state.lastCredentialTabId[platform] = Number.isInteger(details.tabId) ? details.tabId : null;
7336 state.credentialFingerprint[platform] = fingerprint || previousFingerprint;
7337 state.credentialCapturedAt[platform] = isSameFingerprint && state.credentialCapturedAt[platform] > 0
7338 ? state.credentialCapturedAt[platform]
7339 : now;
7340
7341 observeAccountMetadata(platform, details.url || "", null, headers);
7342 render();
7343 sendCredentialSnapshot(platform, !isSameFingerprint);
7344 persistState().catch(() => {});
7345}
7346
7347function handleBeforeSendHeaders(details) {
7348 const platform = resolvePlatformFromRequest(details);
7349 if (!platform) return;
7350
7351 const headers = headerArrayToObject(details.requestHeaders);
7352 if (platform === "claude") {
7353 const orgId = extractClaudeOrgId(headers, details.url || "");
7354 if (orgId) {
7355 headers["x-org-id"] = orgId;
7356 }
7357 }
7358 if (Object.keys(headers).length === 0) return;
7359
7360 collectEndpoint(platform, details.method || "GET", details.url);
7361 observeCredentialSnapshot(platform, headers, details).catch((error) => {
7362 addLog("error", `${platformLabel(platform)} 凭证指纹计算失败:${error.message}`);
7363 });
7364}
7365
7366function handleCompleted(details) {
7367 const platform = resolvePlatformFromRequest(details);
7368 if (!platform || !shouldTrackRequest(platform, details.url)) return;
7369 collectEndpoint(platform, details.method || "GET", details.url);
7370}
7371
7372function handleErrorOccurred(details) {
7373 const platform = resolvePlatformFromRequest(details);
7374 if (!platform || !shouldTrackRequest(platform, details.url)) return;
7375 if (platform === "claude") {
7376 const context = parseClaudeApiContext(details.url || "");
7377 if (context.isCompletion) {
7378 updateClaudeState({
7379 organizationId: context.organizationId || state.claudeState.organizationId,
7380 conversationId: context.conversationId || state.claudeState.conversationId,
7381 busy: false,
7382 busyReason: null,
7383 lastError: details.error || "request_failed",
7384 lastActivityAt: Date.now()
7385 }, {
7386 persist: true,
7387 render: true
7388 });
7389 }
7390 }
7391 addLog("error", `${platformLabel(platform)} ${details.method} ${normalizePath(details.url)} 失败:${details.error}`);
7392}
7393
7394async function postProxyRequestToTab(tabId, data) {
7395 let lastError = null;
7396 for (let attempt = 0; attempt < PROXY_MESSAGE_RETRY; attempt += 1) {
7397 try {
7398 await browser.tabs.sendMessage(tabId, {
7399 type: "baa_page_proxy_request",
7400 data
7401 });
7402 return;
7403 } catch (error) {
7404 lastError = error;
7405 if (attempt < PROXY_MESSAGE_RETRY - 1) {
7406 await sleep(PROXY_MESSAGE_RETRY_DELAY);
7407 }
7408 }
7409 }
7410 throw lastError || new Error("无法连接内容脚本");
7411}
7412
7413async function sendDeliveryCommandToTab(tabId, data) {
7414 let lastError = null;
7415
7416 for (let attempt = 0; attempt < PROXY_MESSAGE_RETRY; attempt += 1) {
7417 try {
7418 return await browser.tabs.sendMessage(tabId, {
7419 type: "baa_delivery_command",
7420 data
7421 });
7422 } catch (error) {
7423 lastError = error;
7424
7425 if (attempt < PROXY_MESSAGE_RETRY - 1) {
7426 await sleep(PROXY_MESSAGE_RETRY_DELAY);
7427 }
7428 }
7429 }
7430
7431 throw lastError || new Error("无法连接内容脚本");
7432}
7433
7434async function resolveDeliveryTab(platform) {
7435 if (!platform || !["claude", "chatgpt"].includes(platform)) {
7436 throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
7437 }
7438
7439 const tab = await ensurePlatformTab(platform, {
7440 action: "delivery_bridge",
7441 focus: true,
7442 reason: "ws_delivery_command",
7443 source: "ws_delivery_bridge"
7444 });
7445
7446 if (!tab?.id) {
7447 throw new Error(`无法定位 ${platformLabel(platform)} shell tab`);
7448 }
7449
7450 await sleep(150);
7451 return tab;
7452}
7453
7454function buildDeliveryShellRuntime(platform) {
7455 try {
7456 return buildPlatformRuntimeSnapshot(platform);
7457 } catch (_) {
7458 return null;
7459 }
7460}
7461
7462function createDeliveryRouteError(code, message) {
7463 return new Error(`delivery.${trimToNull(code) || "route_error"}: ${message}`);
7464}
7465
7466function resolvePausedPageControlForDelivery(platform, conversationId, tabId = null) {
7467 const pausedByConversation = findPausedPageControlByConversation(platform, conversationId);
7468
7469 if (pausedByConversation) {
7470 return pausedByConversation;
7471 }
7472
7473 if (!Number.isInteger(tabId)) {
7474 return null;
7475 }
7476
7477 const pausedByTab = getPageControlState(platform, tabId);
7478 return pausedByTab.paused ? pausedByTab : null;
7479}
7480
7481function createPausedPageDeliveryError(command, pageControl, conversationId = null) {
7482 const targetConversationId = pageControl?.conversationId || trimToNull(conversationId);
7483 const parts = [`${command} blocked: ${platformLabel(pageControl?.platform || "-")} 页面已暂停`];
7484
7485 if (Number.isInteger(pageControl?.tabId)) {
7486 parts.push(`tab=${pageControl.tabId}`);
7487 }
7488
7489 if (targetConversationId) {
7490 parts.push(`conversation=${targetConversationId}`);
7491 }
7492
7493 return createDeliveryRouteError("page_paused", parts.join(" "));
7494}
7495
7496function readDeliveryTargetTabId(message) {
7497 const candidates = [
7498 message?.target_tab_id,
7499 message?.targetTabId,
7500 message?.tab_id,
7501 message?.tabId
7502 ];
7503
7504 for (const candidate of candidates) {
7505 if (Number.isInteger(candidate) && candidate > 0) {
7506 return candidate;
7507 }
7508 }
7509
7510 return null;
7511}
7512
7513function readDeliveryTargetPageUrl(message) {
7514 return trimToNull(message?.target_page_url || message?.targetPageUrl || message?.page_url || message?.pageUrl);
7515}
7516
7517async function resolveDeliveryTargetPage(message, command) {
7518 const platform = trimToNull(message?.platform);
7519 const conversationId = trimToNull(message?.conversation_id || message?.conversationId);
7520 const pageUrl = readDeliveryTargetPageUrl(message);
7521 const targetTabId = readDeliveryTargetTabId(message);
7522 const explicitShellPage = message?.shell_page === true || message?.shellPage === true;
7523
7524 if (!platform || !["claude", "chatgpt"].includes(platform)) {
7525 throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
7526 }
7527
7528 if (explicitShellPage) {
7529 throw createDeliveryRouteError("shell_page", `${command} target resolves to shell page`);
7530 }
7531
7532 let pageControl = null;
7533 let tab = null;
7534
7535 if (targetTabId != null) {
7536 pageControl = getPageControlState(platform, targetTabId);
7537 try {
7538 tab = await browser.tabs.get(targetTabId);
7539 } catch (_) {
7540 throw createDeliveryRouteError(
7541 "tab_missing",
7542 `${command} target tab is unavailable: platform=${platform} tab=${targetTabId}`
7543 );
7544 }
7545
7546 if (!isPlatformUrl(platform, tab?.url || "")) {
7547 throw createDeliveryRouteError(
7548 "target_mismatch",
7549 `${command} target tab is not a ${platformLabel(platform)} page: tab=${targetTabId}`
7550 );
7551 }
7552 } else if (conversationId) {
7553 pageControl = findPageControlByConversation(platform, conversationId);
7554 if (pageControl?.tabId != null) {
7555 tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
7556 }
7557 } else if (pageUrl) {
7558 pageControl = findPageControlByUrl(platform, pageUrl);
7559 if (pageControl?.tabId != null) {
7560 tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
7561 }
7562 }
7563
7564 if (!tab?.id) {
7565 if (conversationId || pageUrl || targetTabId != null) {
7566 throw createDeliveryRouteError(
7567 "route_missing",
7568 `${command} missing business-page target: platform=${platform} conversation=${conversationId || "-"}`
7569 );
7570 }
7571
7572 const shellTab = await resolveDeliveryTab(platform);
7573 return {
7574 conversationId,
7575 organizationId: platform === "claude"
7576 ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
7577 : null,
7578 pageControl: getPageControlState(platform, shellTab.id),
7579 pageTitle: trimToNull(shellTab.title),
7580 pageUrl: trimToNull(shellTab.url),
7581 shellPage: true,
7582 tab: shellTab
7583 };
7584 }
7585
7586 const resolvedPageUrl = trimToNull(tab.url);
7587 const resolvedConversationId = extractConversationIdFromPageUrl(platform, resolvedPageUrl || "") || conversationId;
7588 const resolvedShellPage = isPlatformShellUrl(platform, resolvedPageUrl || "", {
7589 allowFallback: true
7590 }) || pageControl?.shellPage === true;
7591
7592 if (resolvedShellPage) {
7593 throw createDeliveryRouteError(
7594 "shell_page",
7595 `${command} target resolves to shell page: platform=${platform} tab=${tab.id}`
7596 );
7597 }
7598
7599 if (pageUrl && resolvedPageUrl && pageUrl !== resolvedPageUrl) {
7600 throw createDeliveryRouteError(
7601 "target_mismatch",
7602 `${command} page_url mismatch: expected=${pageUrl} actual=${resolvedPageUrl}`
7603 );
7604 }
7605
7606 if (conversationId && resolvedConversationId && conversationId !== resolvedConversationId) {
7607 throw createDeliveryRouteError(
7608 "target_mismatch",
7609 `${command} conversation mismatch: expected=${conversationId} actual=${resolvedConversationId}`
7610 );
7611 }
7612
7613 await injectObserverScriptsIntoTab(tab.id).catch(() => null);
7614 await sleep(150);
7615
7616 return {
7617 conversationId: conversationId || resolvedConversationId || null,
7618 organizationId: platform === "claude"
7619 ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
7620 : null,
7621 pageControl: pageControl || getPageControlState(platform, tab.id),
7622 pageTitle: trimToNull(tab.title),
7623 pageUrl: resolvedPageUrl,
7624 shellPage: false,
7625 tab
7626 };
7627}
7628
7629function buildClaudeDeliveryProxyRequest(message, target) {
7630 const conversationId = trimToNull(target?.conversationId);
7631 const organizationId = trimToNull(target?.organizationId);
7632 const assistantMessageId = trimToNull(message?.assistant_message_id || message?.assistantMessageId);
7633 const messageText = trimToNull(message?.message_text || message?.messageText);
7634
7635 if (!conversationId) {
7636 throw createDeliveryRouteError("route_missing", "proxy_delivery missing Claude conversation_id");
7637 }
7638
7639 if (!organizationId) {
7640 throw new Error("delivery.organization_missing: Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
7641 }
7642
7643 if (!assistantMessageId) {
7644 throw new Error("delivery.invalid_payload: Claude proxy_delivery requires assistant_message_id");
7645 }
7646
7647 if (!messageText) {
7648 throw new Error("delivery.invalid_payload: Claude proxy_delivery requires message_text");
7649 }
7650
7651 const path = `/api/organizations/${organizationId}/chat_conversations/${conversationId}/completion`;
7652 return {
7653 body: {
7654 prompt: messageText,
7655 timezone: "Asia/Shanghai",
7656 attachments: [],
7657 files: [],
7658 parent_message_uuid: assistantMessageId
7659 },
7660 headers: buildClaudeHeaders(path, {
7661 accept: "text/event-stream",
7662 "content-type": "application/json"
7663 }),
7664 method: "POST",
7665 path
7666 };
7667}
7668
7669async function runProxyDeliveryAction(message) {
7670 const platform = trimToNull(message?.platform);
7671 const planId = trimToNull(message?.plan_id || message?.planId);
7672 const timeoutMs = Number.isFinite(Number(message?.timeout_ms || message?.timeoutMs))
7673 ? Number(message?.timeout_ms || message?.timeoutMs)
7674 : PROXY_REQUEST_TIMEOUT;
7675
7676 if (!platform) {
7677 throw new Error("proxy_delivery 缺少 platform");
7678 }
7679
7680 if (!planId) {
7681 throw new Error("proxy_delivery 缺少 plan_id");
7682 }
7683
7684 const target = await resolveDeliveryTargetPage(message, "proxy_delivery");
7685 const pausedPage = target?.pageControl?.paused ? target.pageControl : null;
7686
7687 if (pausedPage) {
7688 addLog("info", createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId).message, false);
7689 throw createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId);
7690 }
7691
7692 let request = null;
7693
7694 switch (platform) {
7695 case "claude":
7696 request = buildClaudeDeliveryProxyRequest(message, target);
7697 break;
7698 case "chatgpt":
7699 request = buildChatgptDeliveryRequest({
7700 conversationId: target.conversationId,
7701 messageText: message?.message_text || message?.messageText,
7702 sourceAssistantMessageId: message?.assistant_message_id || message?.assistantMessageId
7703 });
7704 break;
7705 case "gemini":
7706 request = buildGeminiDeliveryRequest({
7707 conversationId: target.conversationId,
7708 messageText: message?.message_text || message?.messageText,
7709 shellPage: target.shellPage === true
7710 });
7711 break;
7712 default:
7713 throw new Error(`未知平台:${platform}`);
7714 }
7715
7716 const proxyRequestId = `${planId}:proxy_delivery`;
7717 const pending = createPendingProxyRequest(proxyRequestId, {
7718 conversationId: target.conversationId,
7719 method: request.method,
7720 path: request.path,
7721 platform,
7722 responseMode: "sse",
7723 shellPage: target.shellPage === true,
7724 tabId: target.tab.id,
7725 templateKey: request.templateKey || null,
7726 timeoutMs
7727 });
7728
7729 try {
7730 await postProxyRequestToTab(target.tab.id, {
7731 id: proxyRequestId,
7732 ...request,
7733 platform,
7734 response_mode: "sse",
7735 source: "proxy_delivery"
7736 });
7737 } catch (error) {
7738 pending.reject(error);
7739 throw error;
7740 }
7741
7742 const deliveryAck = await pending.ackResponse.then(
7743 (response) => {
7744 const statusCode = Number.isFinite(Number(response?.status)) ? Number(response.status) : null;
7745 const reason = trimToNull(response?.error)
7746 || (statusCode != null && statusCode !== 200 ? `downstream_status_${statusCode}` : null);
7747
7748 return {
7749 confirmed_at: Date.now(),
7750 failed: reason != null || statusCode !== 200,
7751 level: statusCode == null ? 0 : 1,
7752 reason,
7753 status_code: statusCode
7754 };
7755 },
7756 (error) => ({
7757 confirmed_at: Date.now(),
7758 failed: true,
7759 level: 0,
7760 reason: trimToNull(error?.message) || String(error || "downstream_ack_failed"),
7761 status_code: Number.isFinite(Number(error?.statusCode)) ? Number(error.statusCode) : null
7762 })
7763 );
7764 addLog(
7765 deliveryAck.failed ? "warn" : "info",
7766 `proxy_delivery 下游确认:${platformLabel(platform)} status=${deliveryAck.status_code ?? "-"} level=${deliveryAck.level} reason=${deliveryAck.reason || "-"}`,
7767 false
7768 );
7769
7770 return {
7771 action: "proxy_delivery",
7772 platform,
7773 results: [
7774 {
7775 delivery_ack: deliveryAck,
7776 ok: true,
7777 platform,
7778 shell_runtime: buildDeliveryShellRuntime(platform),
7779 tabId: target.tab.id
7780 }
7781 ]
7782 };
7783}
7784
7785async function runDeliveryAction(message, command) {
7786 const platform = trimToNull(message?.platform);
7787 const planId = trimToNull(message?.plan_id || message?.planId);
7788 const conversationId = trimToNull(message?.conversation_id || message?.conversationId);
7789
7790 if (!platform) {
7791 throw new Error(`${command} 缺少 platform`);
7792 }
7793
7794 if (!planId) {
7795 throw new Error(`${command} 缺少 plan_id`);
7796 }
7797
7798 const pausedByConversation = resolvePausedPageControlForDelivery(platform, conversationId);
7799
7800 if (pausedByConversation) {
7801 addLog("info", createPausedPageDeliveryError(command, pausedByConversation, conversationId).message, false);
7802 throw createPausedPageDeliveryError(command, pausedByConversation, conversationId);
7803 }
7804
7805 const target = await resolveDeliveryTargetPage(message, command);
7806 const pausedByTab = resolvePausedPageControlForDelivery(platform, target.conversationId, target.tab.id);
7807
7808 if (pausedByTab) {
7809 addLog("info", createPausedPageDeliveryError(command, pausedByTab, target.conversationId).message, false);
7810 throw createPausedPageDeliveryError(command, pausedByTab, target.conversationId);
7811 }
7812
7813 const payload = {
7814 command,
7815 platform,
7816 planId,
7817 pollIntervalMs: Number.isFinite(Number(message?.poll_interval_ms || message?.pollIntervalMs))
7818 ? Number(message?.poll_interval_ms || message?.pollIntervalMs)
7819 : undefined,
7820 retryAttempts: Number.isFinite(Number(message?.retry_attempts || message?.retryAttempts))
7821 ? Number(message?.retry_attempts || message?.retryAttempts)
7822 : undefined,
7823 retryDelayMs: Number.isFinite(Number(message?.retry_delay_ms || message?.retryDelayMs))
7824 ? Number(message?.retry_delay_ms || message?.retryDelayMs)
7825 : undefined,
7826 text: command === "inject_message"
7827 ? trimToNull(message?.message_text || message?.messageText)
7828 : null,
7829 timeoutMs: Number.isFinite(Number(message?.timeout_ms || message?.timeoutMs))
7830 ? Number(message?.timeout_ms || message?.timeoutMs)
7831 : DELIVERY_COMMAND_TIMEOUT
7832 };
7833 const result = await sendDeliveryCommandToTab(target.tab.id, payload);
7834
7835 if (result?.ok !== true) {
7836 throw new Error(trimToNull(result?.reason) || `${command} failed`);
7837 }
7838
7839 return {
7840 action: command,
7841 platform,
7842 results: [
7843 {
7844 ok: true,
7845 platform,
7846 shell_runtime: buildDeliveryShellRuntime(platform),
7847 tabId: target.tab.id
7848 }
7849 ]
7850 };
7851}
7852
7853async function proxyApiRequest(message) {
7854 const {
7855 conversation_id: rawConversationId,
7856 conversationId: rawConversationIdCamel,
7857 id,
7858 platform,
7859 method = "GET",
7860 path: apiPath,
7861 body = null
7862 } = message || {};
7863 if (!id) throw new Error("缺少代理请求 ID");
7864 if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
7865 if (!apiPath) throw new Error("缺少代理请求路径");
7866
7867 const responseMode = String(message?.response_mode || message?.responseMode || "buffered").toLowerCase();
7868 const streamId = trimToNull(message?.stream_id) || trimToNull(message?.streamId) || id;
7869 const conversationId = trimToNull(rawConversationId) || trimToNull(rawConversationIdCamel);
7870 const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
7871 const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
7872 ? buildGeminiAutoRequest(prompt, {
7873 allowRecentFallback: true,
7874 allowShellFallback: true,
7875 conversationId
7876 })
7877 : null;
7878
7879 return executeProxyRequest({
7880 id,
7881 platform,
7882 method: geminiAutoRequest ? "POST" : String(method || "GET").toUpperCase(),
7883 path: geminiAutoRequest ? geminiAutoRequest.path : apiPath,
7884 body: geminiAutoRequest ? geminiAutoRequest.body : body,
7885 headers: geminiAutoRequest ? geminiAutoRequest.headers : buildProxyHeaders(platform, apiPath)
7886 }, {
7887 conversationId,
7888 platform,
7889 prompt,
7890 responseMode,
7891 streamId,
7892 attempts: 0,
7893 shellPage: false,
7894 templateKey: geminiAutoRequest?.templateKey || null
7895 });
7896}
7897
7898async function proxyClaudeRequest(method, path, body = null, options = {}) {
7899 const headers = buildClaudeHeaders(path, options.headers || {});
7900 const response = await executeProxyRequest({
7901 id: options.id || buildRuntimeRequestId("claude"),
7902 platform: "claude",
7903 method: String(method || "GET").toUpperCase(),
7904 path,
7905 body,
7906 headers
7907 }, {
7908 platform: "claude",
7909 timeoutMs: options.timeoutMs || PROXY_REQUEST_TIMEOUT
7910 });
7911
7912 if (!response.ok || (Number.isFinite(response.status) && response.status >= 400)) {
7913 const bodyPreview = typeof response.body === "string" && response.body
7914 ? `: ${response.body.slice(0, 240)}`
7915 : "";
7916 throw new Error(`Claude 请求失败 (${response.status || "unknown"})${bodyPreview}`);
7917 }
7918
7919 return response;
7920}
7921
7922async function proxyClaudeJson(method, path, body = null, options = {}) {
7923 const response = await proxyClaudeRequest(method, path, body, {
7924 ...options,
7925 headers: {
7926 accept: "application/json",
7927 ...(options.headers || {})
7928 }
7929 });
7930
7931 if (!response.body) return null;
7932 try {
7933 return JSON.parse(response.body);
7934 } catch (error) {
7935 throw new Error(`Claude JSON 响应解析失败:${error.message}`);
7936 }
7937}
7938
7939async function readClaudeConversation(options = {}) {
7940 await refreshClaudeTabState(options.createTab === true);
7941 const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
7942 const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
7943
7944 if (!organizationId) {
7945 throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
7946 }
7947 if (!conversationId) {
7948 throw new Error("Claude 当前没有可读取的对话 ID");
7949 }
7950
7951 const payload = await proxyClaudeJson(
7952 "GET",
7953 `/api/organizations/${organizationId}/chat_conversations/${conversationId}`,
7954 null
7955 );
7956
7957 return applyClaudeConversation(payload, {
7958 source: "api_read",
7959 organizationId,
7960 conversationId,
7961 readAt: Date.now()
7962 });
7963}
7964
7965async function createClaudeConversation(options = {}) {
7966 const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
7967 if (!organizationId) {
7968 throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
7969 }
7970
7971 const payload = await proxyClaudeJson(
7972 "POST",
7973 `/api/organizations/${organizationId}/chat_conversations`,
7974 {
7975 name: trimToNull(options.title) || "",
7976 uuid: typeof crypto?.randomUUID === "function" ? crypto.randomUUID() : undefined
7977 }
7978 );
7979
7980 const conversationId = trimToNull(payload?.uuid);
7981 if (!conversationId) {
7982 throw new Error("Claude 创建对话未返回 uuid");
7983 }
7984
7985 updateClaudeState({
7986 organizationId,
7987 conversationId,
7988 title: trimToNull(payload?.name) || trimToNull(options.title) || null,
7989 titleSource: trimToNull(payload?.name) || trimToNull(options.title) ? "api" : null,
7990 messages: [],
7991 lastAssistantMessageUuid: null,
7992 lastConversationSource: "api_create",
7993 lastActivityAt: Date.now(),
7994 lastError: null
7995 }, {
7996 persist: true,
7997 render: true
7998 });
7999
8000 return {
8001 organizationId,
8002 conversationId,
8003 title: trimToNull(payload?.name) || trimToNull(options.title) || null
8004 };
8005}
8006
8007async function resolveClaudeSendTarget(options = {}) {
8008 await refreshClaudeTabState(true);
8009 const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
8010 if (!organizationId) {
8011 throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
8012 }
8013
8014 let conversationId = trimToNull(options.conversationId);
8015 let conversationSnapshot = null;
8016
8017 if (!conversationId && options.createNew !== true) {
8018 conversationId = getClaudeConversationIdFromState();
8019 }
8020
8021 if (conversationId && options.createNew !== true) {
8022 conversationSnapshot = await readClaudeConversation({
8023 organizationId,
8024 conversationId
8025 }).catch(() => null);
8026 }
8027
8028 if (!conversationId || options.createNew === true) {
8029 const created = await createClaudeConversation({
8030 organizationId,
8031 title: options.title
8032 });
8033 conversationId = created.conversationId;
8034 }
8035
8036 const lastMessageUuid = conversationSnapshot?.recentMessages?.length
8037 ? getLastClaudeMessageUuid(conversationSnapshot.recentMessages)
8038 : (conversationId === state.claudeState.conversationId
8039 ? state.claudeState.lastAssistantMessageUuid || null
8040 : null);
8041
8042 return {
8043 organizationId,
8044 conversationId,
8045 lastMessageUuid
8046 };
8047}
8048
8049async function sendClaudePrompt(message = {}) {
8050 const prompt = trimToNull(message.prompt || message.text || message.message);
8051 if (!prompt) {
8052 throw new Error("Claude prompt 不能为空");
8053 }
8054
8055 const target = await resolveClaudeSendTarget(message);
8056 setClaudeBusy(true, "proxy");
8057
8058 try {
8059 const completionBody = {
8060 prompt,
8061 timezone: "Asia/Shanghai",
8062 attachments: [],
8063 files: []
8064 };
8065
8066 if (target.lastMessageUuid) {
8067 completionBody.parent_message_uuid = target.lastMessageUuid;
8068 }
8069
8070 const response = await proxyClaudeRequest(
8071 "POST",
8072 `/api/organizations/${target.organizationId}/chat_conversations/${target.conversationId}/completion`,
8073 completionBody,
8074 {
8075 headers: {
8076 accept: "text/event-stream",
8077 "content-type": "application/json"
8078 }
8079 }
8080 );
8081
8082 const parsed = parseClaudeSseText(response.body || "");
8083 updateClaudeState({
8084 organizationId: target.organizationId,
8085 conversationId: target.conversationId,
8086 lastAssistantMessageUuid: parsed.messageUuid || state.claudeState.lastAssistantMessageUuid,
8087 lastSendAt: Date.now(),
8088 lastActivityAt: Date.now(),
8089 lastError: null
8090 }, {
8091 persist: true,
8092 render: true
8093 });
8094
8095 let conversation = null;
8096 try {
8097 conversation = await readClaudeConversation({
8098 organizationId: target.organizationId,
8099 conversationId: target.conversationId
8100 });
8101 } catch (error) {
8102 updateClaudeState({
8103 lastError: error.message
8104 }, {
8105 persist: true,
8106 render: true
8107 });
8108 conversation = buildClaudeStateSnapshot();
8109 }
8110
8111 setClaudeBusy(false, null);
8112
8113 return {
8114 ok: true,
8115 organizationId: target.organizationId,
8116 conversationId: target.conversationId,
8117 response: {
8118 role: "assistant",
8119 content: parsed.text,
8120 thinking: parsed.thinking,
8121 timestamp: Date.now(),
8122 messageUuid: parsed.messageUuid || null,
8123 stopReason: parsed.stopReason || null
8124 },
8125 state: conversation
8126 };
8127 } catch (error) {
8128 updateClaudeState({
8129 lastError: error.message
8130 }, {
8131 persist: true,
8132 render: true
8133 });
8134 setClaudeBusy(false, null);
8135 throw error;
8136 }
8137}
8138
8139async function readClaudeState(options = {}) {
8140 const tab = await refreshClaudeTabState(options.createTab === true);
8141 const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
8142 const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
8143
8144 if (organizationId && conversationId && options.refresh !== false) {
8145 try {
8146 return await readClaudeConversation({
8147 organizationId,
8148 conversationId
8149 });
8150 } catch (error) {
8151 updateClaudeState({
8152 lastError: error.message
8153 }, {
8154 persist: true,
8155 render: true
8156 });
8157 }
8158 }
8159
8160 if (!tab) {
8161 throw new Error("Claude 标签页不存在");
8162 }
8163
8164 return buildClaudeStateSnapshot();
8165}
8166
8167function registerWebRequestListeners() {
8168 browser.webRequest.onBeforeSendHeaders.addListener(
8169 handleBeforeSendHeaders,
8170 { urls: PLATFORM_REQUEST_URL_PATTERNS },
8171 ["requestHeaders"]
8172 );
8173
8174 browser.webRequest.onCompleted.addListener(
8175 handleCompleted,
8176 { urls: PLATFORM_REQUEST_URL_PATTERNS },
8177 ["responseHeaders"]
8178 );
8179
8180 browser.webRequest.onErrorOccurred.addListener(
8181 handleErrorOccurred,
8182 { urls: PLATFORM_REQUEST_URL_PATTERNS }
8183 );
8184}
8185
8186function registerRuntimeListeners() {
8187 browser.runtime.onMessage.addListener((message, sender) => {
8188 if (!message || typeof message !== "object") return undefined;
8189
8190 switch (message.type) {
8191 case "baa_page_bridge_ready":
8192 handlePageBridgeReady(message.data, sender);
8193 break;
8194 case "baa_page_network":
8195 handlePageNetwork(message.data, sender);
8196 break;
8197 case "baa_page_sse":
8198 handlePageSse(message.data, sender);
8199 break;
8200 case "baa_diagnostic_log":
8201 handlePageDiagnosticLog(message.data, sender);
8202 break;
8203 case "baa_page_proxy_response":
8204 handlePageProxyResponse(message.data, sender);
8205 break;
8206 case "control_plane_command":
8207 return runControlPlaneAction(message.action, {
8208 source: message.source || "runtime"
8209 }).then((result) => ({
8210 ok: true,
8211 ...result
8212 })).catch((error) => ({
8213 ok: false,
8214 error: error.message,
8215 snapshot: cloneControlState(state.controlState)
8216 }));
8217 case "page_control_command":
8218 return runPageControlAction(message.action, sender, {
8219 source: message.source || "runtime",
8220 reason: message.reason || "page_overlay"
8221 }).then((result) => ({
8222 ok: true,
8223 ...result
8224 })).catch((error) => ({
8225 ok: false,
8226 error: error.message,
8227 control: cloneControlState(state.controlState),
8228 page: buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""))
8229 }));
8230 case "get_page_control_state":
8231 return Promise.resolve({
8232 ok: true,
8233 control: cloneControlState(state.controlState),
8234 page: buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""))
8235 });
8236 case "get_control_plane_state":
8237 return Promise.resolve({
8238 ok: true,
8239 snapshot: cloneControlState(state.controlState)
8240 });
8241 case "plugin_runtime_action":
8242 case "plugin_action":
8243 return runPluginManagementAction(message.action, {
8244 ...(normalizePluginManagementAction(message.action) === "ws_reconnect"
8245 ? extractWsReconnectActionOverrides(message)
8246 : {}),
8247 platform: message.platform,
8248 source: message.source || "runtime_message",
8249 reason: message.reason || "runtime_plugin_action"
8250 }).then((result) => ({
8251 ok: true,
8252 ...result
8253 })).catch((error) => ({
8254 ok: false,
8255 error: error.message,
8256 snapshot: buildPluginStatusPayload()
8257 }));
8258 case "plugin_status":
8259 case "get_plugin_status":
8260 case "get_plugin_runtime_status":
8261 return runPluginManagementAction("plugin_status", {
8262 source: message.source || "runtime_status"
8263 }).then((result) => ({
8264 ok: true,
8265 ...result
8266 })).catch((error) => ({
8267 ok: false,
8268 error: error.message,
8269 snapshot: buildPluginStatusPayload()
8270 }));
8271 case "claude_send":
8272 case "claude_read_conversation":
8273 case "claude_read_state":
8274 return Promise.resolve({
8275 ok: false,
8276 error: "page_conversation_runtime_deprecated",
8277 message: "Firefox 插件正式能力已收口到空壳页、登录态元数据上报和本地 API 代发;页面对话运行时不再作为正式能力。"
8278 });
8279 default:
8280 break;
8281 }
8282
8283 return undefined;
8284 });
8285}
8286
8287function registerTabListeners() {
8288 browser.tabs.onActivated.addListener(() => {
8289 scheduleTrackedTabRefresh("activation");
8290 });
8291
8292 browser.tabs.onCreated.addListener(() => {
8293 scheduleTrackedTabRefresh("create");
8294 });
8295
8296 browser.tabs.onRemoved.addListener((tabId) => {
8297 removePageControlStatesByTabId(tabId, {
8298 persist: true,
8299 render: true
8300 });
8301 scheduleTrackedTabRefresh("remove");
8302 });
8303
8304 browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
8305 const nextUrl = changeInfo.url || tab?.url || "";
8306 const currentPageControlEntries = listPageControlStates().filter((entry) => entry.tabId === tabId);
8307 let pageControlChanged = false;
8308
8309 for (const entry of currentPageControlEntries) {
8310 if (!isPlatformUrl(entry.platform, nextUrl)) {
8311 pageControlChanged = removePageControlState(entry.platform, tabId, {
8312 persist: false,
8313 render: false
8314 }) || pageControlChanged;
8315 continue;
8316 }
8317
8318 const nextConversationId = extractConversationIdFromPageUrl(entry.platform, nextUrl);
8319 const nextShellPage = isPlatformShellUrl(entry.platform, nextUrl, { allowFallback: true });
8320 const nextTitle = trimToNull(tab?.title);
8321
8322 if (
8323 entry.pageUrl !== trimToNull(nextUrl)
8324 || entry.pageTitle !== nextTitle
8325 || entry.conversationId !== nextConversationId
8326 || entry.shellPage !== nextShellPage
8327 ) {
8328 pageControlChanged = true;
8329 }
8330
8331 syncPageControlFromContext({
8332 conversationId: extractConversationIdFromPageUrl(entry.platform, nextUrl),
8333 isShellPage: isPlatformShellUrl(entry.platform, nextUrl, { allowFallback: true }),
8334 pageTitle: trimToNull(tab?.title),
8335 platform: entry.platform,
8336 senderUrl: nextUrl,
8337 tabId
8338 }, {}, {
8339 persist: false,
8340 render: false
8341 });
8342 }
8343
8344 if (pageControlChanged) {
8345 persistState().catch(() => {});
8346 render();
8347 }
8348
8349 const platform = findTrackedPlatformByTabId(tabId);
8350 const urlPlatform = detectPlatformFromUrl(changeInfo.url || tab?.url || "");
8351 if (!platform && !urlPlatform && changeInfo.status !== "complete") return;
8352
8353 const allowFallbackShell = platform
8354 ? cloneDesiredTabState(platform, state.desiredTabs[platform]).exists
8355 : false;
8356
8357 if (platform && changeInfo.url && !isPlatformShellUrl(platform, changeInfo.url, { allowFallback: allowFallbackShell })) {
8358 state.trackedTabs[platform] = null;
8359 persistState().catch(() => {});
8360 render();
8361 addLog("warn", `${platformLabel(platform)} 空壳页 ${tabId} 已偏离壳页 URL,等待重新收口`);
8362 }
8363
8364 if (platform && changeInfo.status === "complete" && tab?.url && isPlatformShellUrl(platform, tab.url, { allowFallback: allowFallbackShell })) {
8365 addLog("info", `${platformLabel(platform)} shell ready ${tabId}`);
8366 }
8367
8368 if (platform || urlPlatform || changeInfo.status === "complete") {
8369 scheduleTrackedTabRefresh("update");
8370 }
8371 });
8372}
8373
8374function bindUi() {
8375 ui.wsStatus = qs("ws-status");
8376 ui.wsMeta = qs("ws-meta");
8377 ui.wsView = qs("ws-view");
8378 ui.controlMode = qs("control-mode");
8379 ui.controlMeta = qs("control-meta");
8380 ui.controlView = qs("control-view");
8381 ui.trackedCount = qs("tracked-count");
8382 ui.trackedMeta = qs("tracked-meta");
8383 ui.accountCount = qs("account-count");
8384 ui.accountMeta = qs("account-meta");
8385 ui.credentialCount = qs("credential-count");
8386 ui.credentialMeta = qs("credential-meta");
8387 ui.endpointCount = qs("endpoint-count");
8388 ui.endpointMeta = qs("endpoint-meta");
8389 ui.platformView = qs("platform-view");
8390 ui.accountView = qs("account-view");
8391 ui.credentialView = qs("credential-view");
8392 ui.endpointView = qs("endpoint-view");
8393
8394 for (const action of ["pause", "resume", "drain"]) {
8395 qs(`${action}-btn`).addEventListener("click", () => {
8396 runControlPlaneAction(action, { source: "manual" }).catch((error) => {
8397 addLog("error", `控制动作 ${action} 失败:${error.message}`);
8398 });
8399 });
8400 }
8401}
8402
8403async function init() {
8404 bindUi();
8405
8406 const saved = await browser.storage.local.get([
8407 ...Object.values(CONTROLLER_STORAGE_KEYS),
8408 ...Object.values(LEGACY_STORAGE_KEYS)
8409 ]);
8410 const savedSchemaVersion = Number(saved[CONTROLLER_STORAGE_KEYS.statusSchemaVersion]) || 0;
8411 const needsStatusReset = savedSchemaVersion < STATUS_SCHEMA_VERSION;
8412
8413 state.clientId = saved[CONTROLLER_STORAGE_KEYS.clientId] || genClientId();
8414 state.wsUrl = normalizeSavedWsUrl(saved[CONTROLLER_STORAGE_KEYS.wsUrl]);
8415 state.controlBaseUrl = normalizeSavedControlBaseUrl(saved[CONTROLLER_STORAGE_KEYS.controlBaseUrl]);
8416 state.controlState = loadControlState(saved[CONTROLLER_STORAGE_KEYS.controlState]);
8417 state.pageControls = loadPageControls(saved[CONTROLLER_STORAGE_KEYS.pageControls]);
8418
8419 state.trackedTabs = loadTrackedTabs(
8420 saved[CONTROLLER_STORAGE_KEYS.trackedTabs],
8421 saved[LEGACY_STORAGE_KEYS.claudeTabId]
8422 );
8423 state.desiredTabs = loadDesiredTabs(
8424 saved[CONTROLLER_STORAGE_KEYS.desiredTabs],
8425 state.trackedTabs
8426 );
8427 state.endpoints = loadEndpointEntries(
8428 saved[CONTROLLER_STORAGE_KEYS.endpointsByPlatform],
8429 saved[LEGACY_STORAGE_KEYS.endpoints]
8430 );
8431 state.lastHeaders = loadObjectMap(
8432 saved[CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform],
8433 saved[LEGACY_STORAGE_KEYS.lastHeaders]
8434 );
8435 state.credentialCapturedAt = loadNumberMap(
8436 saved[CONTROLLER_STORAGE_KEYS.credentialCapturedAtByPlatform]
8437 );
8438 state.lastCredentialAt = loadNumberMap(
8439 saved[CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform],
8440 saved[LEGACY_STORAGE_KEYS.lastCredentialAt]
8441 );
8442 state.lastCredentialUrl = loadStringMap(
8443 saved[CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]
8444 );
8445 state.lastCredentialTabId = loadTabIdMap(
8446 saved[CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]
8447 );
8448 state.credentialFingerprint = loadStringMap(
8449 saved[CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]
8450 );
8451 state.account = loadAccountMap(
8452 saved[CONTROLLER_STORAGE_KEYS.accountByPlatform]
8453 );
8454 state.chatgptSendTemplates = loadChatgptSendTemplates(
8455 saved[CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]
8456 );
8457 state.geminiSendTemplates = loadGeminiSendTemplates(
8458 saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplates],
8459 saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate]
8460 );
8461 restoreFinalMessageRelayCache(saved[CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]);
8462 state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
8463 state.controllerRuntime = loadControllerRuntimeState(saved[CONTROLLER_STORAGE_KEYS.controllerRuntime]);
8464 const previousExtensionOrigin = trimToNull(state.controllerRuntime.extensionOrigin);
8465 const startupOpenAiTabReloadPlan = await resolveStartupOpenAiTabReloadPlan(previousExtensionOrigin);
8466 if (needsStatusReset) {
8467 state.lastHeaders = createPlatformMap(() => ({}));
8468 state.credentialCapturedAt = createPlatformMap(() => 0);
8469 state.lastCredentialAt = createPlatformMap(() => 0);
8470 state.lastCredentialUrl = createPlatformMap(() => "");
8471 state.lastCredentialTabId = createPlatformMap(() => null);
8472 state.credentialFingerprint = createPlatformMap(() => "");
8473 state.account = createPlatformMap(() => createDefaultAccountState());
8474 }
8475 state.lastCredentialHash = createPlatformMap((platform) => JSON.stringify(buildCredentialTransportSnapshot(platform)));
8476 state.wsState = createDefaultWsState({
8477 connection: "connecting",
8478 wsUrl: state.wsUrl,
8479 localApiBase: DEFAULT_LOCAL_API_BASE,
8480 clientId: state.clientId
8481 });
8482
8483 registerRuntimeListeners();
8484 registerTabListeners();
8485 registerWebRequestListeners();
8486
8487 let current = null;
8488
8489 try {
8490 current = await browser.tabs.getCurrent();
8491 } catch (error) {
8492 addLog(
8493 "warn",
8494 `控制页无法读取当前标签页:${error instanceof Error ? error.message : String(error)}`,
8495 false
8496 );
8497 }
8498
8499 setControllerRuntimeState({
8500 tabId: current?.id ?? null,
8501 ready: true,
8502 status: "ready",
8503 lastReadyAt: Date.now(),
8504 extensionOrigin: startupOpenAiTabReloadPlan.currentExtensionOrigin
8505 });
8506
8507 if (Number.isInteger(current?.id)) {
8508 try {
8509 await browser.runtime.sendMessage({ type: "controller_ready", tabId: current.id });
8510 } catch (error) {
8511 addLog(
8512 "warn",
8513 `控制页握手失败,但将继续启动:${error instanceof Error ? error.message : String(error)}`,
8514 false
8515 );
8516 }
8517 } else {
8518 addLog("warn", "控制页未拿到当前 tabId,跳过 controller_ready 握手", false);
8519 }
8520
8521 await reinjectAllOpenPlatformTabs({
8522 source: "startup"
8523 });
8524 await refreshTrackedTabsFromBrowser("startup");
8525 await collapseRecoveredDesiredTabs();
8526 await restoreDesiredTabsOnStartup();
8527 await refreshClaudeTabState(false);
8528 await persistState();
8529 render();
8530 addLog("info", `controller ready ${state.clientId}`, false);
8531 if (needsStatusReset) {
8532 addLog("info", "已清理旧版平台状态缓存,等待新的真实请求重新建立凭证", false);
8533 }
8534
8535 connectWs({ silentWhenDisabled: true });
8536 restartShellRuntimeHealthTimer(SHELL_RUNTIME_HEALTHCHECK_INTERVAL);
8537 await prepareStartupControlState();
8538 refreshControlPlaneState({ source: "startup", silent: true }).catch(() => {});
8539 scheduleStartupOpenAiTabReload(startupOpenAiTabReloadPlan);
8540}
8541
8542window.addEventListener("beforeunload", () => {
8543 clearTimeout(state.controlRefreshTimer);
8544 clearTimeout(state.trackedTabRefreshTimer);
8545 clearTimeout(state.shellRuntimeTimer);
8546 clearTimeout(state.startupOpenAiTabReloadTimer);
8547 closeWsConnection();
8548});
8549
8550function exposeControllerTestApi() {
8551 const target = globalThis.__BAA_CONTROLLER_TEST_API__;
8552 if (!target || typeof target !== "object") {
8553 return;
8554 }
8555
8556 Object.assign(target, {
8557 buildGeminiAutoRequest,
8558 buildGeminiDeliveryRequest,
8559 buildChatgptDeliveryRequest,
8560 buildPageControlSnapshotForSender,
8561 connectWs,
8562 createPluginDiagnosticPayload,
8563 flushBufferedPluginDiagnosticLogs,
8564 getChatgptSendTemplate,
8565 getGeminiSendTemplate,
8566 getSenderContext,
8567 handlePageBridgeReady,
8568 handlePageDiagnosticLog,
8569 handlePageNetwork,
8570 handlePageSse,
8571 handleWsStateSnapshot,
8572 loadChatgptSendTemplates,
8573 loadGeminiSendTemplates,
8574 persistFinalMessageRelayCache,
8575 rememberChatgptSendTemplate,
8576 rememberGeminiSendTemplate,
8577 reinjectAllOpenPlatformTabs,
8578 reinjectPlatformTabs,
8579 restoreFinalMessageRelayCache,
8580 runDeliveryAction,
8581 runProxyDeliveryAction,
8582 runPageControlAction,
8583 runPluginManagementAction,
8584 sendPluginDiagnosticLog,
8585 serializeChatgptSendTemplates,
8586 serializeGeminiSendTemplates,
8587 serializeFinalMessageRelayCache,
8588 setDesiredTabState,
8589 syncPageControlFromContext,
8590 state
8591 });
8592}
8593
8594exposeControllerTestApi();
8595
8596if (globalThis.__BAA_SKIP_CONTROLLER_INIT__ !== true) {
8597 init().catch((error) => {
8598 console.error(error);
8599 addLog("error", error.message, false);
8600 });
8601}