// ==UserScript== // @name Zotero GPT Connector // @description Zotero GPT Pro: Supports virtually all the AI platforms you know. // @namespace http://tampermonkey.net/ // @icon https://github.com/MuiseDestiny/zotero-gpt/blob/bootstrap/addon/chrome/content/icons/favicon.png?raw=true // @noframes // @author Polygon // @version 6.0.28 // @match https://chatgpt.com/* // @match https://chatgtp.chat/* // @match https://iai.aichatos8.com.cn/* // @match https://share.mosha.cloud/* // @match https://node.leadyven.com/* // @match https://*.bestaistore.com/* // @match https://www.chatgptnet.org/* // @match https://node3.leadyven.com/* // @match https://gemini.google.com/* // @match https://poe.com/* // @match https://www.kimi.com/* // @match https://kimi.moonshot.cn/* // @match https://chatglm.cn/* // @match https://chat.z.ai/* // @match https://yiyan.baidu.com/* // @match https://qianwen.aliyun.com/* // @match https://www.qianwen.com/* // @match https://claude.ai/* // @match https://claude.ai0.cn/* // @match https://mytan.maiseed.com.cn/* // @match https://mychandler.bet/* // @match https://chat.deepseek.com/* // @match https://www.doubao.com/chat/* // @match https://aistudio.google.com/* // @match https://yuanbao.tencent.com/* // @match https://*.chatshare.biz/* // @match https://chat.kelaode.ai/* // @match https://chat.rawchat.cn/* // @match https://node.dawuai.buzz/* // @match https://china.aikeji.vip/* // @match https://chat.dakeai.de/* // @match https://grok.com/* // @match https://github.com/copilot/* // @match https://shareai.cfd/* // @match https://*.mjpic.cc/* // @match https://leopard-x.memofun.net/* // @match https://chat.aite.lol/* // @match https://www.zaiwen.top/chat/* // @match https://chatgptup.com/* // @match https://ihe5u7.aitianhu2.top/* // @match https://cc01.plusai.io/* // @match https://arc.aizex.me/* // @match https://www.chatwb.com/* // @match https://www.xixichat.top/* // @match https://zchat.tech/* // @match https://*.sorryios.*/* // @match https://gptsdd.com/* // @match https://max.bpjgpt.top/* // @match https://nbai.tech/ // @match https://x.liaobots.work/* // @match https://x.liaox.ai/* // @match https://chat.qwenlm.ai/* // @match https://dazi.co/* // @match https://www.techopens.com/* // @match https://copilot.microsoft.com/* // @match https://chat.baidu.com/* // @match https://share.zhangsan.cool/* // @match https://qrms.com/* // @match https://sx.xiaoai.shop/* // @match https://oai.liuliangbang.vip/* // @match https://*.dftianyi.com/* // @match https://chat.qwen.ai/* // @match https://notebooklm.google.com/notebook/* // @match https://www.perplexity.ai/* // @match https://sider.ai/* // @match https://aistudio.xiaomimimo.com/* // @match https://lmarena.ai/* // @match https://arena.ai/* // @match https://monica.im/* // @match https://saas.ai1.bar/* // @match https://www.wenxiaobai.com/* // @match https://xiaoyi.huawei.com/* // @match https://chat.bpjgpt.top/* // @match https://*.plusai.io/* // @match https://*.plusai.me/* // @match https://*.yrai.cc/* // @match https://next-three.soruxnet.com/* // @match https://lke.cloud.tencent.com/* // @include /.+gpt2share.+/ // @include /.+rawchat.+/ // @include /.+sharedchat.+/ // @include /.+freeoai.+/ // @include /.+sharesai.+/ // @include /.+qwen.+/ // @include /.+coze.+/ // @include /.+grok.+/ // @include /.+qianwen.+/ // @include /.+chatopens.+/ // @include /.+kelaode.+/ // @include /.+askmanyai.+/ // @include /.+4399ai.+/ // @include /.+minimaxi.+/ // @connect 127.0.0.1 // @connect localhost // @connect scriptcat.org // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_openInTab // @grant unsafeWindow // @run-at document-start // @license All Rights Reserved // ==/UserScript== (() => { "use strict"; /* ------------------------------------------------------------------------ * * 0. Runtime constants and small, dependency-free utilities * ------------------------------------------------------------------------ */ const page = typeof unsafeWindow === "undefined" ? window : unsafeWindow; const TAB_ID = Math.random().toString(36).slice(2, 11); const DEFAULT_CONNECTOR_PORT = 23119; const CONNECTOR_PORT_MIN = 23100; const CONNECTOR_PORT_MAX = 23199; const CONNECTOR_PORT_KEY = "zotero_gpt_connector_port"; const CONNECTOR_PROBE_TIMEOUT = 1_500; const CONNECTOR_PROBE_CONCURRENCY = 20; const LOCK_KEY = "gpt_connector_running"; const AUTO_CONNECT_KEY = "gpt_connector_auto_connect_on_refresh"; const UPDATE_CHECK_KEY = "zotero_gpt_last_update_check"; const UPDATE_CHECK_INTERVAL = 12 * 60 * 60 * 1000; const POLL_TIMEOUT = 30_000; const SEND_TIMEOUT = 30_000; const NETWORK_IDLE = 3_500; const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // BEGIN SHARED RESPONSE PARSERS (source for browser-extension/sites.js) const own = (value, key) => value !== null && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key); const text = value => String(value ?? ""); const json = value => { try { return JSON.parse(text(value).trim()); } catch { return null; } }; const normalize = value => text(value) .replace(/\u00a0/g, " ") .replace(/[ \t]+/g, " ") .replace(/ *\n */g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); const mergeStream = (current, next) => { const left = text(current); const right = text(next); if (!right || left === right) return left || right; if (!left || right.startsWith(left) || left.endsWith(right)) return right.startsWith(left) ? right : left; for (let size = Math.min(left.length, right.length); size > 0; size -= 1) { if (left.endsWith(right.slice(0, size))) return left + right.slice(size); } return left + right; }; const withThinking = (answer = "", thinking = "") => thinking ? (answer ? `${thinking}\n${answer}` : `${thinking}`) : answer; const sseRecords = raw => { let event = ""; const records = []; for (const rawLine of text(raw).split(/\r?\n/)) { const line = rawLine.replace(/\r$/, ""); if (!line) { event = ""; continue; } if (/^event\s*:/.test(line)) event = line.replace(/^event\s*:/, "").trim(); else if (/^data\s*:/.test(line)) records.push({ event, data: line.replace(/^data\s*:/, "").trim() }); } return records; }; const dataPayloads = raw => sseRecords(raw).map(record => record.data).filter(Boolean); const doneMarker = raw => /(?:^|\n)\s*(?:data\s*:\s*\[DONE\]|event\s*:\s*(?:done|complete|completed|SSE_REPLY_END))\s*(?:$|\n)/im.test(text(raw)) || /"(?:done|complete|completed|finished|finished_successfully)"\s*:\s*true/i.test(text(raw)); const log = { info: (...args) => console.info("%c[ZoteroGPT]", "color:#2196f3", ...args), warn: (...args) => console.warn("%c[ZoteroGPT]", "color:#ff9800", ...args), error: (...args) => console.error("%c[ZoteroGPT]", "color:#f44336", ...args), ui: (...args) => console.info("%c[ZoteroGPT UI]", "color:#00a8b5", ...args), }; /* ------------------------------------------------------------------------ * * 1. Response parsers * * Every parser returns the same value: { text, done, waiting }. Providers * may send cumulative snapshots or deltas; the transport layer is the only * place that deals with either form. Adding a provider therefore only * requires one parser and one profile entry below. * ------------------------------------------------------------------------ */ function framedObjects(raw, opener = "{", closer = "}") { const source = text(raw); const output = []; for (let start = source.indexOf(opener); start >= 0;) { let depth = 0; let quoted = false; let escaped = false; let end = -1; for (let cursor = start; cursor < source.length; cursor += 1) { const character = source[cursor]; if (quoted) { if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === '"') quoted = false; continue; } if (character === '"') quoted = true; else if (character === opener) depth += 1; else if (character === closer && --depth === 0) { end = cursor + 1; break; } } if (end < 0) { start = source.indexOf(opener, start + 1); continue; } const candidate = source.slice(start, end); const parsed = json(candidate) || json(candidate.replace(/^\{\{/, "{").replace(/\}\}$/, "}")); if (parsed !== null) { output.push(parsed); start = end; } else start = source.indexOf(opener, start + 1); } return output; } function framedArrays(raw) { return framedObjects(raw, "[", "]").filter(Array.isArray); } const GENERIC_KEYS = new Set([ "answer", "body", "completion", "content", "data", "delta", "markdown", "message", "msg", "output", "response", "result", "text", "token", "value", ]); function collectText(value, path, result, depth = 0) { if (depth > 10 || value === null || value === undefined) return; if (typeof value === "string") { const key = text(path.at(-1)).toLowerCase().replace(/[\s_-]/g, ""); if (GENERIC_KEYS.has(key) && normalize(value).length > 0 && !isResponseControlText(value)) { const group = path.map(text).join("."); result.set(group, [...(result.get(group) || []), normalize(value)]); } return; } if (Array.isArray(value)) { value.forEach((item, index) => collectText(item, [...path, index], result, depth + 1)); return; } if (typeof value === "object") { Object.entries(value).forEach(([key, child]) => collectText(child, [...path, key], result, depth + 1)); } } function parseGeneric(raw) { const groups = new Map(); const payloads = dataPayloads(raw).map(json).filter(value => value !== null); const whole = json(raw); if (whole !== null) payloads.unshift(whole); payloads.forEach(value => collectText(value, [], groups)); let best = ""; for (const values of groups.values()) { const candidate = values.reduce(mergeStream, ""); if (candidate.length > best.length) best = candidate; } if (!best && !payloads.length && !/^(?:data|event|id|retry)\s*:/im.test(text(raw))) { best = normalize(text(raw).replace(/^data\s*:\s*/gim, "")); } const done = doneMarker(raw); return best ? { text: best, done } : payloads.length ? { text: "", done, ignoredReason: "json-without-answer-fields" } : { text: "", done }; } // ChatGPT's internal citations/widgets are not portable Markdown. Keep readable // labels, but hide citation IDs, markup and unfinished streaming tails. const cleanChatGPTText = value => text(value) .replace(/\ue200([^\ue200\ue201]*)\ue201/g, (_match, marker) => { const separator = marker.indexOf("\ue202"); const kind = marker.slice(0, separator); if (separator < 0 || kind === "filecite" || kind === "cite") return ""; const fields = json(marker.slice(separator + 1)); if (!Array.isArray(fields)) return ""; const label = kind === "entity" ? fields[1] : fields.find(field => typeof field === "string"); return typeof label === "string" ? label : ""; }) .replace(/\ue200[^\ue201]*$/g, "") .replace(/[\ue200-\ue2ff]/g, ""); function parseChatGPT(raw) { const messages = new Map(); let current = null; let streamDone = false; for (const payload of dataPayloads(raw)) { if (payload === "[DONE]") { streamDone = true; continue; } const data = json(payload); if (!data) continue; const message = data.message || data.v?.message; if (message) { current = { ...message, author: { ...message.author, role: message.author?.role || "assistant" } }; // Replace repeated snapshots of a message, retaining earlier reasoning // messages when the stream switches to commentary or the final answer. const key = message.id || message.message_id || JSON.stringify([current.author.role, message.channel, message.recipient, message.content?.content_type]); messages.set(key, current); } for (const patch of Array.isArray(data.v) && !data.p && !data.path ? data.v : [data]) { const path = patch?.p || patch?.path; const value = own(patch, "v") ? patch.v : patch?.value; const operation = patch?.o || patch?.op; if (operation && !["append", "replace", "add"].includes(operation)) continue; if (!current && path === "/message/content/parts/0") { // Legacy mirrors can send only unlabelled answer deltas. current = { author: { role: "assistant" }, content: { content_type: "text", parts: [""] } }; messages.set("legacy-deltas", current); } if (!current) continue; const partIndex = /^\/message\/content\/parts\/(\d+)$/.exec(path || ""); if (partIndex && typeof value === "string" && ["text", "multimodal_text"].includes(current.content?.content_type)) { if (!Array.isArray(current.content.parts)) current.content.parts = []; const index = Number(partIndex[1]); if (index > 10000) continue; current.content.parts[index] = operation === "append" ? text(current.content.parts[index]) + value : ["replace", "add"].includes(operation) ? value : mergeStream(current.content.parts[index], value); } else if (path === "/message/content/parts" && Array.isArray(value) && current.content) { current.content.parts = operation === "append" ? [...(current.content.parts || []), ...value] : value; } else if (chatGPTReferencePatch(current, path, value, operation)) { // Reference payloads may arrive after the placeholder text. } else if (path === "/message/end_turn" && (typeof value === "boolean" || value === null)) { current.end_turn = value; } else if (typeof value === "string" && ["/message/channel", "/message/status", "/message/recipient"].includes(path)) { current[path.split("/").at(-1)] = value; } } } const parsed = parseChatGPTTurn([...messages.values()], streamDone); return { text: parsed.text, done: parsed.done, ...(parsed.completionKnown && !parsed.done ? { waiting: true } : {}), }; } const isChatGPTAnswerMessage = message => (!message?.author?.role || message.author.role === "assistant") && (!message?.channel || message.channel === "final") && (!message?.recipient || message.recipient === "all"); const isChatGPTThinkingMessage = message => (!message?.recipient || message.recipient === "all") && (["analysis", "commentary"].includes(message.channel) || ["thoughts", "reasoning_recap"].includes(message.content?.content_type)); // Writing cards use a ChatGPT-only container syntax. Translate the container // to a Markdown code block without interpreting its body or touching examples. function chatGPTWritingMarkdown(value, done) { const lines = value.match(/[^\n]*(?:\n|$)/g) || []; const output = []; let writing = null; let codeFence = null; const flushWriting = (closed, ending = "") => { const body = writing.lines.join(""); const longest = (body.match(/`+/g) || []).reduce((size, run) => Math.max(size, run.length), 2); const fence = "`".repeat(longest + 1); output.push(fence + "markdown" + writing.newline + body); if (closed) { if (body && !body.endsWith("\n")) output.push(writing.newline); output.push(fence + ending); } writing = null; }; for (const rawLine of lines) { if (!rawLine) continue; const ending = rawLine.match(/\r?\n$/)?.[0] || ""; const line = ending ? rawLine.slice(0, -ending.length) : rawLine; if (codeFence) { (writing ? writing.lines : output).push(rawLine); const closing = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/); if (closing && closing[1][0] === codeFence[0] && closing[1].length >= codeFence.length) codeFence = null; continue; } // Also protect code examples nested in Markdown lists or indentation. const fence = line.match(/^[ \t]*(?:(?:[-+*]|\d+[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/); if (fence && (fence[1][0] !== "`" || !fence[2].includes("`"))) { codeFence = fence[1]; (writing ? writing.lines : output).push(rawLine); continue; } if (writing) { if (/^ {0,3}:::[ \t]*$/.test(line)) { flushWriting(true, ending); } else if (done || ending || !/^ {0,3}:{1,2}[ \t]*$/.test(line)) { writing.lines.push(rawLine); } continue; } // Quoted metadata may contain braces or escaped quotes. Require the whole // header line so ordinary prose mentioning the protocol remains unchanged. const header = line.match(/^ {0,3}:::writing[ \t]*\{(?:[^{}"'\r\n]|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')*\}/); if (header && /^[ \t]*$/.test(line.slice(header[0].length))) { writing = { lines: [], newline: ending || "\n" }; continue; } if (!ending && !header) { const tail = line.replace(/^ {0,3}/, ""); if ((!done && tail.trimEnd() && ":::writing".startsWith(tail.trimEnd())) || /^:::writing[ \t]*\{.*$/.test(tail)) continue; } output.push(rawLine); } // An open Markdown fence renders streamed body text already; only close it // when the card or message finishes, keeping normal snapshots append-only. if (writing) flushWriting(done); return output.join(""); } // Bracket-style inline references have no portable target. Remove only the // known numeric-index token outside code; brace directives still need DOM recovery. function chatGPTInlineReferences(value, done) { const output = []; let codeFence = null; let inlineFence = null; for (const rawLine of value.match(/[^\n]*(?:\n|$)/g) || []) { if (!rawLine) continue; const ending = rawLine.match(/\r?\n$/)?.[0] || ""; const line = ending ? rawLine.slice(0, -ending.length) : rawLine; if (codeFence) { output.push(rawLine); const closing = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/); if (closing && closing[1][0] === codeFence[0] && closing[1].length >= codeFence.length) codeFence = null; continue; } const fence = line.match(/^[ \t]*(?:(?:[-+*]|\d+[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/); if (fence && (fence[1][0] !== "`" || !fence[2].includes("`"))) { codeFence = fence[1]; output.push(rawLine); continue; } if (/^(?: {4}|\t)/.test(line)) { output.push(rawLine); continue; } let cleaned = ""; for (let cursor = 0; cursor < line.length;) { if (line[cursor] === "\\" && cursor + 1 < line.length) { cleaned += line.slice(cursor, cursor + 2); cursor += 2; continue; } const ticks = line[cursor] === "`" ? /^`+/.exec(line.slice(cursor)) : null; if (ticks) { if (inlineFence === ticks[0]) inlineFence = null; else if (!inlineFence) inlineFence = ticks[0]; cleaned += ticks[0]; cursor += ticks[0].length; continue; } if (!inlineFence && /[_:]/.test(line[cursor])) { const tail = line.slice(cursor); const marker = /^(?:_|:{1,2})chatgpt-content-reference\[index=(?:"\d+"|'\d+')\]/.exec(tail); if (marker) { cursor += marker[0].length; continue; } // Hide only a terminal streaming prefix of the known attribute grammar. const header = /^(?:_|:{1,2})(chatgpt-[\w-]*)$/.exec(tail); const attributes = /^(?:_|:{1,2})chatgpt-content-reference\[(.*)$/.exec(tail); if (!done && !ending && ((header && "chatgpt-content-reference".startsWith(header[1])) || (attributes && ("index=".startsWith(attributes[1]) || /^index=(?:"\d*"?|'\d*'?)?$/.test(attributes[1]))))) break; } cleaned += line[cursor++]; } output.push(cleaned + ending); } return output.join(""); } const chatGPTReferenceData = (object, key) => { try { return Object.getOwnPropertyDescriptor(object || {}, key)?.value; } catch { return undefined; } }; function chatGPTReferencePatch(message, path, value, operation) { if (!["append", "replace", "add"].includes(operation)) return false; const match = /^\/message\/metadata\/(content_references|sidebar_source_references)(?:\/(\d+)(?:\/model_dil_v2\/fallbackMarkdown)?)?$/.exec(path || ""); if (!match) return false; const field = match[1]; if (match[2] === undefined) { if (!Array.isArray(value)) return false; message.metadata ||= {}; const previous = message.metadata[field]; message.metadata[field] = operation === "append" && Array.isArray(previous) ? [...previous, ...value] : value; return true; } const index = Number(match[2]); if (!Number.isSafeInteger(index) || index >= 10000) return false; const references = chatGPTReferenceData(chatGPTReferenceData(message, "metadata"), field); if (!Array.isArray(references)) return false; if (path.endsWith("/fallbackMarkdown")) { const model = chatGPTReferenceData(chatGPTReferenceData(references, String(index)), "model_dil_v2"); if (!model || typeof model !== "object" || typeof value !== "string") return false; model.fallbackMarkdown = operation === "append" ? text(chatGPTReferenceData(model, "fallbackMarkdown")) + value : value; } else { if (!value || typeof value !== "object" || Array.isArray(value)) return false; references[index] = value; } return true; } // DIL is executable UI code, but its explicit Markdown fallback is plain data. // Resolve only embedded, matching references; never execute code or walk IDs // into other turns. Leave unresolved directives for the existing DOM fallback. function chatGPTReferenceMarkdown(value, references, sidebarReferences) { if (!value.includes("::chatgpt-content-reference")) return value; const fallback = (reference, source) => { if (chatGPTReferenceData(reference, "type") !== "dil" || (source && chatGPTReferenceData(reference, "source_message_id") !== source)) return null; const model = chatGPTReferenceData(reference, "model_dil_v2"); const version = chatGPTReferenceData(model, "fallbackMarkdownVersion"); const markdown = chatGPTReferenceData(model, "fallbackMarkdown"); return (version === undefined || version === 1) && typeof markdown === "string" && markdown.trim() ? markdown : null; }; const resolve = (index, source) => { if (!Number.isSafeInteger(index) || index < 0 || index >= 10000) return null; const primary = Array.isArray(references) ? fallback(chatGPTReferenceData(references, String(index)), source) : null; if (primary !== null) return primary; if (!source || !Array.isArray(sidebarReferences)) return null; const matches = new Set(); for (let i = 0; i < Math.min(sidebarReferences.length, 1000); i++) { const markdown = fallback(chatGPTReferenceData(sidebarReferences, String(i)), source); if (markdown !== null) matches.add(markdown); } return matches.size === 1 ? [...matches][0] : null; }; const output = []; let codeFence = null; for (const rawLine of value.match(/[^\n]*(?:\n|$)/g) || []) { if (!rawLine) continue; const ending = rawLine.match(/\r?\n$/)?.[0] || ""; const line = ending ? rawLine.slice(0, -ending.length) : rawLine; if (codeFence) { output.push(rawLine); const closing = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/); if (closing && closing[1][0] === codeFence[0] && closing[1].length >= codeFence.length) codeFence = null; continue; } const fence = line.match(/^[ \t]*(?:(?:[-+*]|\d+[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/); if (fence && (fence[1][0] !== "`" || !fence[2].includes("`"))) { codeFence = fence[1]; output.push(rawLine); continue; } // Only a standalone known directive is protocol, not prose/code examples. const marker = /^ {0,3}::chatgpt-content-reference\{([^{}\r\n]*)\}[ \t]*$/.exec(line); if (!marker) { output.push(rawLine); continue; } const attributes = new Map(); let rest = marker[1]; let valid = true; while (rest.trim()) { const attribute = /^[ \t]*([a-z_]+)=(?:"([^"\\]*)"|'([^'\\]*)')[ \t]*/.exec(rest); if (!attribute || attributes.has(attribute[1]) || !["index", "source_message_id"].includes(attribute[1])) { valid = false; break; } attributes.set(attribute[1], attribute[2] ?? attribute[3]); rest = rest.slice(attribute[0].length); } const index = attributes.get("index"); const markdown = valid && /^\d+$/.test(index || "") ? resolve(Number(index), attributes.get("source_message_id")) : null; output.push(markdown === null ? rawLine : markdown + ending); } return output.join(""); } // Keep extraction, classification and Markdown rendering separate. Unknown // parts and reference metadata remain available without becoming answer text. function normalizeChatGPTMessage(message, streamDone = false) { const content = message.content; let parts = []; if (["text", "multimodal_text"].includes(content?.content_type)) parts = Array.isArray(content.parts) ? content.parts : []; else if (content?.content_type === "thoughts" && Array.isArray(content.thoughts)) { parts = content.thoughts.map(thought => typeof thought === "string" ? thought : thought?.content || thought?.summary); } else if (content?.content_type === "reasoning_recap") parts = [content.content]; const rawText = parts.map(part => { if (typeof part === "string") return part; if ((part?.type === "text" || part?.content_type === "text") && typeof part.text === "string") return part.text; return null; }).filter(part => typeof part === "string").join("\n"); const metadata = chatGPTReferenceData(message, "metadata"); const references = chatGPTReferenceData(metadata, "content_references") || chatGPTReferenceData(message, "contentReferences") || []; const sidebar = chatGPTReferenceData(metadata, "sidebar_source_references") || chatGPTReferenceData(message, "sidebarSourceReferences") || []; const resolved = chatGPTReferenceMarkdown(rawText, references, sidebar); return { source: message, id: text(message.id || message.message_id), role: message.author?.role || message.role || "", channel: message.channel || "", recipient: message.recipient || "all", contentType: content?.content_type || "", parts, references, rawText, markdown: chatGPTWritingMarkdown(chatGPTInlineReferences(cleanChatGPTText(resolved), streamDone || message.status === "finished_successfully" || message.end_turn === true), streamDone || message.status === "finished_successfully" || message.end_turn === true), status: message.status || "", endTurn: typeof message.end_turn === "boolean" ? message.end_turn : null, }; } function normalizeChatGPTTurn(messages, streamDone = false) { const unique = new Map(); for (const [index, message] of (Array.isArray(messages) ? messages : []).entries()) { if (!message || typeof message !== "object") continue; unique.set(message.id || message.message_id || index, message); } const normalized = [...unique.values()].map(message => normalizeChatGPTMessage(message, streamDone)); const assistants = normalized.filter(message => message.role === "assistant"); const hasChannels = assistants.some(message => Boolean(message.channel)); for (const message of normalized) { message.kind = "ignored"; if (message.role !== "assistant" || message.recipient !== "all" || message.source.metadata?.is_visually_hidden_from_conversation) continue; if (isChatGPTThinkingMessage(message.source)) message.kind = "thinking"; else if (["text", "multimodal_text"].includes(message.contentType) && isChatGPTAnswerMessage(message.source) && (!hasChannels || message.channel === "final")) message.kind = "answer"; } const answers = normalized.filter(message => message.kind === "answer"); const final = answers.at(-1); const latest = assistants.at(-1); const hasEndTurn = assistants.some(message => own(message.source, "end_turn")); // Work can finish an individual final message and then start another stage. // Explicit turn state wins over message status; status is legacy fallback only. const done = streamDone || (hasEndTurn ? latest?.kind === "answer" && latest.endTurn === true && latest.status !== "in_progress" : final?.status === "finished_successfully" && latest?.status !== "in_progress" && latest?.recipient === "all"); return { messages: normalized, answer: answers.map(message => message.markdown).filter(Boolean).join("\n\n"), thinking: normalized.filter(message => message.kind === "thinking") .map(message => message.markdown).filter(Boolean).join("\n"), done: Boolean(done), completionKnown: hasEndTurn || hasChannels || Boolean(final?.status), message: latest?.source, }; } function parseChatGPTTurn(messages, streamDone = false) { const normalized = normalizeChatGPTTurn(messages, streamDone); const { answer, thinking, done } = normalized; return { ...normalized, // Match Zotero's existing streaming protocol: leave the thinking block // open while reasoning continues, then close it before the answer. text: withThinking(answer, thinking) + (done && thinking && !answer ? "" : ""), done, completionKnown: normalized.completionKnown || Boolean(thinking), }; } // Stream references only with an explicit final-answer identity. Empty answers // still require completion. Never infer turn completion from a text pause. function createChatGPTDomFallback(document, { streamReferences = false } = {}) { const assistantSelector = '[data-content-search-unit-key$=":assistant"], [data-message-author-role="assistant"]'; const streamingSelector = '[data-is-streaming="true"], [data-testid="stop-button"], [data-testid="composer-stop-button"]'; const excluded = 'button, select, option, input, textarea, script, style, svg, noscript, template, details, summary, .sr-only, [hidden], [aria-hidden="true"], [role="button"], [role="combobox"], [role="menu"], [role="toolbar"], [contenteditable="true"], [data-message-author-role="tool"], [data-message-channel="analysis"], [data-message-channel="commentary"], [data-channel="analysis"], [data-channel="commentary"], [data-testid*="reasoning"], [data-testid*="thinking"], [data-testid*="feedback"]'; const query = (root, selector) => { try { return [...root.querySelectorAll(selector)]; } catch { return []; } }; const attr = (node, name) => node?.getAttribute?.(name) || ''; const roots = () => { const nodes = query(document, assistantSelector).filter(node => node?.nodeType === 1 && node.isConnected !== false); return nodes.filter(node => !nodes.some(parent => parent !== node && parent.contains(node))); }; const turnKey = node => attr(node.closest?.('[data-content-search-unit-key]'), 'data-content-search-unit-key') || attr(node.closest?.('[data-testid^="conversation-turn-"]'), 'data-testid'); const messageSelector = '[data-message-id], [data-chatgpt-selection-message-id], [data-dil-source-message-id]'; const messageKey = node => attr(node, 'data-message-id') || attr(node, 'data-chatgpt-selection-message-id') || attr(node, 'data-dil-source-message-id') || attr(node.closest?.('[data-message-id]'), 'data-message-id'); const visible = (node, filterControls = true) => { for (let current = node; current?.nodeType === 1; current = current.parentElement) { if (current.matches?.(filterControls ? excluded : '[hidden], [aria-hidden="true"]')) return false; const style = document.defaultView?.getComputedStyle?.(current); if (style?.display === 'none' || style?.visibility === 'hidden') return false; } return true; }; let primed = false; let baselineNodes = new Set(); let baselineKeys = new Set(); let pendingIdentity = ''; let startedAt = 0; let candidateText = ''; let changedAt = 0; const reset = () => { pendingIdentity = ''; candidateText = ''; startedAt = 0; changedAt = 0; }; const withoutThinking = value => String(value || '').replace(/[\s\S]*?(?:<\/think>|$)/gi, ''); const answerBody = value => withoutThinking(value).trim(); // This protocol-only line refers to content rendered elsewhere in the card; // it is not an answer. Never follow source_message_id into earlier turns. // Require a standalone directive and preserve code/quoted/prose examples. function referenceBody(value, done) { const output = []; let codeFence = null; let unresolved = false; for (const rawLine of value.match(/[^\n]*(?:\n|$)/g) || []) { if (!rawLine) continue; const ending = rawLine.match(/\r?\n$/)?.[0] || ''; const line = ending ? rawLine.slice(0, -ending.length) : rawLine; if (codeFence) { output.push(rawLine); const closing = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/); if (closing && closing[1][0] === codeFence[0] && closing[1].length >= codeFence.length) codeFence = null; continue; } const fence = line.match(/^[ \t]*(?:(?:[-+*]|\d+[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/); if (fence && (fence[1][0] !== '`' || !fence[2].includes('`'))) { codeFence = fence[1]; output.push(rawLine); continue; } const header = /^ {0,3}::chatgpt-content-reference[ \t]*\{(?:[^{}"'\r\n]|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')*\}[ \t]*$/.test(line); const tail = line.replace(/^ {0,3}/, ''); const partial = !ending && ((!done && tail.startsWith('::chatgpt-') && '::chatgpt-content-reference'.startsWith(tail)) || /^::chatgpt-content-reference[ \t]*(?:$|\{[^}]*$)/.test(tail)); if (header || partial) unresolved = true; else output.push(rawLine); } return { text: output.join('').trim(), unresolved }; } // Traverse the existing DOM rather than reinserting innerHTML. This preserves // paragraphs/formatting without executing markup or including card controls. function markdown(root) { const block = value => value.trim() ? `\n\n${value.trim()}\n\n` : ''; const children = node => [...(node.childNodes || [])].map(convert).join(''); function convert(node) { if (node.nodeType === 3) return String(node.nodeValue || '').replace(/\u00a0/g, ' '); if (node.nodeType !== 1 || !visible(node)) return ''; const tag = String(node.tagName).toUpperCase(); if (node.matches?.('.katex-display, .katex, math')) { const annotation = node.querySelector('annotation[encoding="application/x-tex"]'); const latex = String(annotation?.textContent || '').trim(); if (latex) { const display = node.matches('.katex-display') || attr(node, 'display') === 'block'; return display ? block(`\\[\n${latex}\n\\]`) : `\\(${latex}\\)`; } } if (tag === 'BR') return '\n'; if (tag === 'HR') return '\n\n---\n\n'; if (tag === 'PRE') { const code = node.querySelector('code'); const value = String(code?.textContent || node.textContent || '').replace(/^\n+|\n+$/g, ''); const language = [...(code?.classList || [])].find(value => /^language-[\w-]+$/.test(value))?.slice(9) || ''; const fence = '`'.repeat(Math.max(3, ...[...value.matchAll(/`+/g)].map(match => match[0].length + 1))); return value ? `\n\n${fence}${language}\n${value}\n${fence}\n\n` : ''; } if (tag === 'TABLE') { const rows = query(node, 'tr').filter(node => visible(node)).map(row => [...row.children] .filter(cell => ['TH', 'TD'].includes(cell.tagName) && visible(cell)) .map(cell => children(cell).trim().replace(/\n+/g, ' ').replace(/\|/g, '\\|'))).filter(row => row.length); if (!rows.length) return ''; const width = Math.max(...rows.map(row => row.length)); const line = row => `| ${Array.from({ length: width }, (_, i) => row[i] || '').join(' | ')} |`; return block([line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n')); } if (tag === 'UL' || tag === 'OL') { return block([...node.children].filter(item => item.tagName === 'LI' && visible(item)) .map((item, index) => `${tag === 'OL' ? `${index + 1}.` : '-'} ${children(item).trim().replace(/\n/g, '\n ')}`).join('\n')); } const value = children(node); if (/^H[1-6]$/.test(tag)) return block(`${'#'.repeat(Number(tag[1]))} ${value.trim()}`); if (['P', 'DIV', 'SECTION', 'ARTICLE'].includes(tag)) return block(value); if (tag === 'STRONG' || tag === 'B') return value.trim() ? `**${value.trim()}**` : ''; if (tag === 'EM' || tag === 'I') return value.trim() ? `*${value.trim()}*` : ''; if (tag === 'DEL' || tag === 'S') return value.trim() ? `~~${value.trim()}~~` : ''; if (tag === 'CODE') { const fence = '`'.repeat(Math.max(1, ...[...value.matchAll(/`+/g)].map(match => match[0].length + 1))); return value ? `${fence} ${value} ${fence}` : ''; } if (tag === 'BLOCKQUOTE') return block(value.trim().replace(/^/gm, '> ')); if (tag === 'A') { const href = attr(node, 'href'); return value.trim() && /^https?:\/\//i.test(href) ? `[${value.trim()}](${href.replace(/\(/g, '%28').replace(/\)/g, '%29')})` : value; } return value; } return convert(root).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); } return { prime() { const nodes = roots(); baselineNodes = new Set(nodes); baselineKeys = new Set(nodes.flatMap(node => [turnKey(node), ...[node, ...query(node, messageSelector)].map(messageKey)]).filter(Boolean)); primed = true; reset(); }, read(snapshot, now = Date.now()) { const thinking = String(snapshot?.text || '').match(/([\s\S]*?)(?:<\/think>|$)/i)?.[1] || ''; const reference = referenceBody(withoutThinking(snapshot?.text), Boolean(snapshot?.done)); const pending = reference.unresolved ? { ...snapshot, text: withThinking(reference.text, thinking), done: false, completionKnown: true } : snapshot; const streamReference = streamReferences && reference.unresolved && snapshot?.finalAnswer === true && Boolean(snapshot.identity); if (!primed || (!snapshot?.done && !streamReference) || !snapshot.completionKnown || (!reference.unresolved && answerBody(snapshot.text))) { reset(); return pending; } // A completed message is not sufficient while the page is still streaming. const streaming = query(document, streamingSelector).some(node => visible(node, false)); if (streaming && !streamReference) { reset(); return { ...pending, done: false }; } const identity = String(snapshot.identity || snapshot.message?.id || snapshot.message?.message_id || 'completed'); if (pendingIdentity !== identity) { reset(); pendingIdentity = identity; startedAt = now; } let root = roots().at(-1); // Never choose a previous turn, even after its DOM node is replaced. const oldRoot = root && (baselineNodes.has(root) || [turnKey(root), messageKey(root)].some(key => key && baselineKeys.has(key))); if (root && !visible(root)) root = null; if (root) { const messages = [root, ...query(root, messageSelector)].filter(node => messageKey(node)); if (messages.length) { root = messages.find(node => { const id = messageKey(node); return identity === id || identity.endsWith(`:${id}`); }) || null; } } // Virtualized turns can reuse a turn container. A new matching message // identity, not its old parent, determines whether the body is current. if (root && (baselineKeys.has(messageKey(root)) || (oldRoot && !messageKey(root)))) root = null; // A live reference must match an explicit message ID. Never stream a // whole turn that could contain sibling analysis/tool stages. if (streamReference && root && !messageKey(root)) root = null; const body = root?.matches?.('[data-markdown-copy="contents"], [data-markdown-text-style="assistant-message"], .markdown') ? root : query(root, '[data-markdown-copy="contents"], [data-markdown-text-style="assistant-message"], .markdown')[0]; let rendered = root ? markdown(body || root) : ''; const plain = value => value.replace(/\s+/g, ' ').trim(); if (!answerBody(rendered) || referenceBody(rendered, true).unresolved || (reference.unresolved && plain(answerBody(rendered)) === plain(reference.text)) || (thinking && plain(thinking).includes(plain(rendered)))) rendered = ''; if (rendered) { if (rendered !== candidateText) { candidateText = rendered; changedAt = now; } // Give the card a chance to finish mounting, especially in the userscript. return { ...snapshot, text: withThinking(rendered, thinking), done: snapshot.done === true && !streaming && now - changedAt >= 800, domFallback: true }; } candidateText = ''; // Empty answers keep their bounded grace period. An unresolved reference // must never become a successful empty/partial answer when it expires; // leave completion pending for the normal task timeout or later DOM. return reference.unresolved || now - startedAt < 2000 ? { ...pending, done: false } : snapshot; }, }; } function parseKimi(raw) { const frames = framedObjects(raw); const messages = new Map(); const owners = new Map(); const parents = new Map(); let order = 0; let done = false; for (const frame of frames) { done ||= own(frame, "done") && frame.done !== false && frame.done !== null; const message = frame.message; const messageId = text(message?.id || message?.messageId || message?.message_id); if (messageId) messages.set(messageId, { order: messages.get(messageId)?.order ?? order++, role: message.role, }); const block = frame.block; const blockId = text(block?.id); if (blockId && block?.messageId) owners.set(blockId, text(block.messageId)); if (blockId && block?.message_id) owners.set(blockId, text(block.message_id)); if (blockId && (block.parentId || block.parent_id)) parents.set(blockId, text(block.parentId || block.parent_id)); } const ownerOf = id => { const seen = new Set(); let current = text(id); while (current && !seen.has(current)) { seen.add(current); if (owners.has(current)) return owners.get(current); current = parents.get(current) || ""; } return ""; }; const assistantIds = [...messages.entries()] .filter(([, value]) => value.role === 3 || /(?:^|_)assistant$/i.test(text(value.role))) .sort((left, right) => left[1].order - right[1].order) .map(([id]) => id); const selected = assistantIds.at(-1) || ""; const hasRoles = [...messages.values()].some(value => value.role !== undefined); const blocks = new Map(); const blockOrder = []; let activeMessage = ""; for (const frame of frames) { const messageId = text(frame.message?.id || frame.message?.messageId || frame.message?.message_id); if (messageId) activeMessage = messageId; const block = frame.block; if (!block) continue; const id = text(block.id); const owner = text(block.messageId || block.message_id || ownerOf(id) || activeMessage); if (hasRoles && (!selected || owner !== selected)) continue; const paths = typeof frame.mask === "string" ? [frame.mask] : frame.mask?.paths || []; const kind = paths.some(path => text(path).startsWith("block.think")) || block.think || block.content?.case === "think" ? "think" : paths.some(path => text(path).startsWith("block.text")) || block.text || block.content?.case === "text" ? "text" : ""; if (!kind) continue; const direct = block[kind]?.content; const value = typeof direct === "string" ? direct : block.content?.case === kind ? (typeof block.content.value === "string" ? block.content.value : block.content.value?.content) : ""; if (!value) continue; const key = `${kind}:${id || owner || "legacy"}`; if (!blocks.has(key)) blockOrder.push(key); blocks.set(key, mergeStream(blocks.get(key), value)); } const think = blockOrder.filter(key => key.startsWith("think:")).map(key => blocks.get(key)).join(""); const answer = blockOrder.filter(key => key.startsWith("text:")).map(key => blocks.get(key)).join(""); const waiting = Boolean(think && !answer); return { text: withThinking(answer.replace(/-\| /g, "-|\n|"), think), done, waiting, waitingForResponse: waiting, }; } function extractGeminiFrames(raw) { const source = text(raw); const frames = []; for (let index = 0; index < source.length;) { const start = source.indexOf("[", index); if (start < 0) break; let depth = 0; let quoted = false; let escaped = false; let end = -1; for (let cursor = start; cursor < source.length; cursor += 1) { const character = source[cursor]; if (quoted) { if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === '"') quoted = false; continue; } if (character === '"') quoted = true; else if (character === "[") depth += 1; else if (character === "]" && --depth === 0) { end = cursor + 1; break; } } if (end < 0) { index = start + 1; continue; } const parsed = json(source.slice(start, end)); if (Array.isArray(parsed)) { frames.push(parsed); index = end; } else { index = start + 1; } } return frames; } function parseGemini(raw) { let answer = ""; let think = ""; let done = false; for (const frame of extractGeminiFrames(raw)) { for (const record of frame) { if (!Array.isArray(record)) continue; if (record[0] === "e" && Number.isFinite(Number(record[1]))) { done = true; continue; } if (record[0] !== "wrb.fr") continue; const result = json(record[2])?.[4]?.[0]; if (typeof result?.[1]?.[0] === "string" && result[1][0]) answer = result[1][0]; if (typeof result?.[37]?.[0]?.[0] === "string") think = result[37][0][0]; } } return { text: withThinking(answer.replace(/\[cite.+?\]/g, ""), think), done, waiting: !done, waitingForResponse: !done, }; } function parseDoubao(raw) { let answer = ""; let think = ""; let done = false; let thinking = false; const valueOf = value => typeof value === "string" ? value : value && typeof value === "object" ? [value.text, value.thinking, value.content, value.delta, value.value, value.text_block].map(text).find(Boolean) || "" : ""; const records = sseRecords(raw); if (!records.length) records.push(...framedObjects(raw).map(data => ({ event: "", data: JSON.stringify(data) }))); for (const record of records) { done ||= record.event === "SSE_REPLY_END"; const data = json(record.data); if (!data) continue; const blocks = []; if (Array.isArray(data.content?.content_block)) blocks.push(...data.content.content_block); if (Array.isArray(data.content_block)) blocks.push(...data.content_block); if (Array.isArray(data.patch_op)) data.patch_op.forEach(op => blocks.push(...op?.patch_value?.content_block || [])); for (const wrapper of blocks) { const content = wrapper?.content || wrapper; if (!content) continue; if (own(content, "thinking_block")) { const value = valueOf(content.thinking_block); if (value) think = mergeStream(think, value); else thinking = !thinking; } const value = valueOf(content.text_block); if (value) { if (thinking) think = mergeStream(think, value); else answer = mergeStream(answer, value); } } if (typeof data.text === "string" && data.text) { if (record.event === "CHUNK_DELTA") answer = mergeStream(answer, data.text); else if (thinking) think = mergeStream(think, data.text); else answer = mergeStream(answer, data.text); } } return { text: withThinking(answer, think), done }; } function parseDeepSeek(raw) { let answer = ""; let think = ""; let kind = "system"; let path = ""; const contentPath = p => !p || /^response\/fragments\/-?\d+\/content$/.test(p); const fold = (fragments) => { for (const block of fragments) { if (block?.type) kind = block.type; const content = typeof block?.content === "string" ? block.content : ""; if (!content) continue; if (kind === "RESPONSE") answer = mergeStream(answer, content); if (kind === "THINK") think = mergeStream(think, content); } }; for (const payload of dataPayloads(raw)) { const data = json(payload); if (!data) continue; // Bare `{v}` frames continue the previous patch path. Citation, search // and usage patches must not reset the fragment type, otherwise all text // after an inline citation is dropped. if (typeof data.p === "string") path = data.p; if (Array.isArray(data.v?.response?.fragments)) fold(data.v.response.fragments); else if (Array.isArray(data.v) && (!path || path === "response/fragments")) fold(data.v); else if (Array.isArray(data.v) && path === "response" && data.o === "BATCH") { for (const op of data.v) { if (op?.p === "fragments" && Array.isArray(op.v)) fold(op.v); } } else if (typeof data.v === "string" && contentPath(path)) fold([{ content: data.v }]); } return { text: withThinking(answer, think), done: doneMarker(raw) }; } function parseYuanbao(raw) { let answer = ""; let think = ""; for (const payload of dataPayloads(raw)) { const data = json(payload); if (!data) continue; if (data.type === "text") answer = mergeStream(answer, data.msg || ""); else if (data.type === "think" || data.type === "deepSearch") think = mergeStream(think, data.contents?.[0]?.msg || ""); else if (data.type === "replace") { const media = data.replace?.multimedias?.[0]; if (media) answer = mergeStream(answer, `![](${media.url})\n${media.desc || ""}`); } } return { text: withThinking(answer, think).replace(/\[\]\(@mark[^)]*\)/gi, ""), done: doneMarker(raw) }; } function parseClaude(raw) { let answer = ""; for (const payload of dataPayloads(raw)) { const data = json(payload); if (data?.type === "completion") answer = mergeStream(answer, data.completion || ""); if (data?.type === "content_block_delta") answer = mergeStream(answer, data.delta?.text || ""); } return { text: answer, done: doneMarker(raw) }; } // Provider-specific SSE readers only describe their payload shape. The // folding, de-duplication, and completion handling stay in one place. function foldSSE(raw, read) { let answer = ""; let thinking = ""; let done = false; for (const record of sseRecords(raw)) { if (record.data === "[DONE]") { done = true; continue; } const value = read(json(record.data), record.event); if (typeof value === "string") answer = mergeStream(answer, value); else if (value) { answer = mergeStream(answer, value.answer || ""); thinking = mergeStream(thinking, value.thinking || ""); done ||= Boolean(value.done); } } return { text: withThinking(answer, thinking), done: done || doneMarker(raw) }; } function parseTongyi(raw) { return foldSSE(raw, data => { const message = data?.data?.messages?.at(-1); if (!message?.content) return ""; const deepThink = message.content.startsWith("[(deep_think)]"); return { answer: message.content.replace(/^\[\(deep_think\)\]/, ""), thinking: deepThink ? message.meta_data?.multi_load?.[0]?.content?.think_content || "" : "", }; }); } function parseChatGLM(raw) { return foldSSE(raw, data => { const part = data?.parts?.[0]?.content?.[0]; return part?.type === "text" ? part.text || "" : ""; }); } function parseYiyan(raw) { return foldSSE(raw, data => data?.thoughts ? { thinking: data.thoughts.replace(/(.+?)/, "$1") } : data?.data?.content || ""); } function parseChandler(raw) { return foldSSE(raw, data => data?.delta || ""); } function parseMyTan(raw) { return foldSSE(raw, data => data?.choices?.[0]?.delta?.content || ""); } function parseCoze(raw) { return foldSSE(raw, data => data?.message?.type === "answer" ? data.message.content || "" : ""); } function parseBaidu(raw) { return foldSSE(raw, data => { const message = data?.data?.message; if (message?.metaData?.state !== "generating-resp") return ""; const generator = message.content?.generator; if (generator?.component === "reasoningContent") return { thinking: generator.data?.value || "" }; return generator?.component === "markdown-yiyan" ? generator.data?.value || "" : ""; }); } function parsePerplexity(raw) { return foldSSE(raw, data => { let answer = ""; for (const block of data?.blocks || []) { if (block.intended_usage !== "ask_text") continue; if (block.markdown_block?.answer) answer = block.markdown_block.answer; for (const patch of block.diff_block?.patches || []) { if (patch.op === "replace" && patch.path === "/answer") answer = patch.value || ""; else if (patch.op === "add") answer = mergeStream(answer, patch.value || ""); } } return answer; }); } function parseSider(raw) { return foldSSE(raw, data => { if (data?.data?.type === "reasoning_content") return { thinking: data.data.reasoning_content?.text || "" }; return data?.data?.type === "text" ? data.data.text || "" : ""; }); } function parseQwen(raw) { return foldSSE(raw, data => { const delta = data?.choices?.[0]?.delta; if (delta?.phase === "think") return { thinking: delta.content || "" }; return delta?.phase === "answer" ? delta.content || "" : ""; }); } function parseAskMany(raw) { return foldSSE(raw, data => { if (!data?.content || data.content.startsWith("[HIT-REF]")) return ""; return data.event === "thinking" ? { thinking: data.content } : data.event === "resp" ? data.content : ""; }); } function parseGitHubCopilot(raw) { return foldSSE(raw, data => data?.type === "content" ? data.body || "" : ""); } function parseMimo(raw) { return foldSSE(raw, data => data?.type === "text" ? text(data.content).replace("\u0000", "") : ""); } function parseMonica(raw) { return foldSSE(raw, data => ({ answer: data?.text || "", thinking: data?.agent_status?.type === "thinking_detail_stream" ? data.agent_status.metadata?.reasoning_detail || "" : "", })); } function foldLines(raw, read) { let answer = ""; let thinking = ""; for (const line of text(raw).split(/\r?\n/)) { const value = read(json(line.replace(/^\s*(?:data|message)\s*:\s*/, "").trim()), line); if (typeof value === "string") answer = mergeStream(answer, value); else if (value) { answer = mergeStream(answer, value.answer || ""); thinking = mergeStream(thinking, value.thinking || ""); } } return { text: withThinking(answer, thinking), done: doneMarker(raw) }; } function parseGrok(raw) { return foldLines(raw, data => { const result = data?.result?.response || data?.result || data; if (result?.isThinking) return { thinking: result.token || "" }; return result?.token || ""; }); } function parseWenxiaobai(raw) { const parsed = foldLines(raw.replace(/event:message\ndata/g, "message\ndata"), data => data?.content || ""); return { ...parsed, text: parsed.text.replace(/^```ys_think[\s\S]+?\n\n```\n/, "") .replace(/[\s\S]+?```ys_think/, "```ys_think"), }; } function parseNotebookLM(raw) { let answer = ""; for (const part of text(raw).split(/\n\d+\n/)) { const data = json(part); if (data?.[0]?.[0] !== "wrb.fr") continue; answer = mergeStream(answer, json(data[0][2])?.[0]?.[0] || ""); } return { text: answer, done: doneMarker(raw) }; } function parseMiniMax(raw) { const content = dataPayloads(raw).reduce((value, payload) => { const next = json(payload)?.data?.messageResult?.content; return typeof next === "string" ? mergeStream(value, next) : value; }, ""); const match = content.match(/^([\s\S]*?)<\/think>([\s\S]*)$/); return { text: match ? withThinking(match[2], match[1]) : content, done: doneMarker(raw), }; } function parseLMArena(raw) { return foldLines(raw, (_data, line) => { try { if (line.startsWith("ag:")) return { thinking: json(line.slice(3)) || "" }; if (line.startsWith("a0:")) return json(line.slice(3)) || ""; } catch { /* ignore malformed stream frames */ } return ""; }); } function parseZAI(raw) { let answer = ""; let think = ""; let done = false; for (const payload of dataPayloads(raw)) { if (payload === "[DONE]") { done = true; continue; } const event = json(payload); if (!event) continue; const data = event.data && typeof event.data === "object" ? event.data : event; const phase = data.phase || "other"; done ||= Boolean(data.done || event.done || phase === "done"); const blocks = Array.isArray(data.content_blocks) ? data.content_blocks : null; if (blocks) { let snapshotAnswer = ""; for (const block of blocks) { const value = typeof block.content === "string" ? block.content : ""; if (block.type === "reasoning" || block.type === "think") think = mergeStream(think, value); else snapshotAnswer = mergeStream(snapshotAnswer, value); } if (snapshotAnswer) answer = snapshotAnswer; } else { const value = typeof data.delta_content === "string" ? data.delta_content : typeof data.content === "string" ? data.content : ""; if (phase === "thinking") think = mergeStream(think, value); else if (!/tool|done/i.test(phase)) answer = mergeStream(answer, value); } } return { text: withThinking(answer, think), done }; } function parseAIStudio(raw) { let parsed = null; for (let candidate = text(raw), attempts = 0; !parsed && attempts < 100; attempts += 1) { parsed = json(candidate); candidate += "]"; } let answer = ""; let think = ""; for (const item of parsed?.[0] || []) { const value = item?.[0]?.[0]?.[0]?.[0]?.[0]; if (!value?.[1]) continue; if (value[12]) think = mergeStream(think, value[1]); else answer = mergeStream(answer, value[1]); } return { text: withThinking(answer, think), done: doneMarker(raw) }; } // The browser extension uses these stable names while the userscript keeps // the provider-facing ChatGPT/Generic spellings internally. const parseChatGpt = parseChatGPT; const parseGenericNetwork = parseGeneric; const isResponseControlText = value => /^(?:ok|success|true|false|null|done|complete|pending|loading|new chat|new conversation|全新对话|新对话|开始新对话|开始新的对话)$/i.test(normalize(value)); // END SHARED RESPONSE PARSERS const PARSERS = Object.freeze({ chatgpt: parseChatGPT, kimi: parseKimi, gemini: parseGemini, doubao: parseDoubao, deepseek: parseDeepSeek, yuanbao: parseYuanbao, claude: parseClaude, zai: parseZAI, aistudio: parseAIStudio, tongyi: parseTongyi, chatglm: parseChatGLM, yiyan: parseYiyan, chandler: parseChandler, mytan: parseMyTan, coze: parseCoze, baidu: parseBaidu, perplexity: parsePerplexity, sider: parseSider, qwen: parseQwen, askmany: parseAskMany, grok: parseGrok, wenxiaobai: parseWenxiaobai, notebooklm: parseNotebookLM, minimax: parseMiniMax, lmarena: parseLMArena, github: parseGitHubCopilot, mimo: parseMimo, monica: parseMonica, generic: parseGeneric, }); /* ------------------------------------------------------------------------ * * 2. Site profiles * ------------------------------------------------------------------------ */ // Every profile declares its send control explicitly; a generic default hid missing adapters. const input = (textSelector, textMethod, fileSelector, fileMethod, send) => ({ text: { selector: textSelector, method: textMethod }, file: { selector: fileSelector, method: fileMethod }, send, }); const network = (name, hosts, controls, pattern, parser = "generic", options = {}) => ({ name, hosts, input: controls, output: { type: "network", pattern, parser: PARSERS[parser] || PARSERS.generic, ...(parser === "chatgpt" ? { snapshot: true } : {}), }, ...options, }); const dom = (name, hosts, controls, selector, parser, options = {}) => ({ name, hosts, input: controls, output: { type: "dom", selector, parser }, ...options, }); // New ChatGPT renders assistant items in host-element React props instead // of turn.messages. Read only committed DOM props; stale Fiber alternates // can contain a longer, already completed answer from an earlier render. function collectChatGPTAssistantItems(document) { const nodes = [...document.querySelectorAll('[data-content-search-unit-key$=":assistant"]')]; const groups = new Map(); const messageIds = new Set(); const unresolvedTurnKeys = new Set(); let itemCount = 0; let latestGroupId = ""; let latestHydrated = false; const data = (object, key) => { try { return Object.getOwnPropertyDescriptor(object || {}, key)?.value; } catch { return undefined; } }; for (const node of nodes) { if (!node || node.isConnected === false) continue; const nodeKey = node.getAttribute?.("data-content-search-unit-key") || ""; if (!nodeKey) continue; const key = Object.getOwnPropertyNames(node).find(value => value.startsWith("__reactProps$")); const props = key ? data(node, key) : null; const stack = [{ value: props, depth: 0 }]; const seen = new Set(); const entries = []; // Children may contain fragments/arrays. Do not rely on children[2] or // inspect arbitrary application state/getters to locate a message. while (stack.length && seen.size < 200) { const { value, depth } = stack.pop(); if (!value || typeof value !== "object" || seen.has(value) || depth > 8) continue; seen.add(value); const item = data(value, "item"); if (data(item, "type") === "assistant-message" && typeof data(item, "content") === "string") { entries.push(Object.fromEntries( ["content", "phase", "completed", "messageId", "latestMessageId", "contentReferences", "sidebarSourceReferences"] .map(field => [field, data(item, field)]))); } if (Array.isArray(value)) { for (let i = Math.min(value.length, 100) - 1; i >= 0; i--) stack.push({ value: data(value, String(i)), depth: depth + 1 }); } else { stack.push({ value: data(value, "children"), depth: depth + 1 }); stack.push({ value: data(value, "props"), depth: depth + 1 }); } } // The rendered DOM key owns the turn. Props-level IDs alone must not // merge separate rendered exchanges into the latest answer. latestGroupId = nodeKey.replace(/:assistant$/, ""); latestHydrated = entries.length > 0; const group = groups.get(latestGroupId) || new Map(); groups.set(latestGroupId, group); if (!entries.length) unresolvedTurnKeys.add(latestGroupId); for (const item of entries) { const id = item.messageId || item.latestMessageId; if (!id) unresolvedTurnKeys.add(latestGroupId); for (const value of [item.messageId, item.latestMessageId]) { if (value) messageIds.add(value); } group.set(id || nodeKey, item); itemCount++; } } return { nodes, groups, messageIds, unresolvedTurnKeys, itemCount, latestGroupId, latestHydrated }; } function readChatGPTAssistantItems(document, baseline = null) { const { nodes, groups, itemCount, latestGroupId, latestHydrated } = collectChatGPTAssistantItems(document); // DOM markers alone do not establish the schema; older pages may reuse them. if (!itemCount) return null; // Unhydrated latest containers and unresolved pre-send turns must wait. const latestItems = latestHydrated ? [...(groups.get(latestGroupId)?.values() || [])] : []; const items = baseline?.unresolvedTurnKeys.has(latestGroupId) ? [] : latestItems.filter(item => { const ids = [item.messageId, item.latestMessageId].filter(Boolean); return !(baseline?.turnKeys.has(latestGroupId) && !ids.length) && !ids.some(id => baseline?.messageIds.has(id)); }); const messages = items.map((item, index) => { const channel = item.phase === "final_answer" ? "final" : ["analysis", "reasoning"].includes(item.phase) ? "analysis" : item.phase === "commentary" ? "commentary" : ""; return { id: item.messageId || item.latestMessageId || String(index), author: { role: "assistant" }, channel: channel || "unknown", recipient: "all", content: { content_type: "text", parts: [channel ? item.content : ""] }, metadata: { content_references: item.contentReferences, sidebar_source_references: item.sidebarSourceReferences }, status: item.completed === true ? "finished_successfully" : "in_progress", // Completing reasoning or a tool stage must not finish the answer. end_turn: channel === "final" && item.completed === true, }; }); const parsed = parseChatGPTTurn(messages); return { text: parsed.text, done: parsed.done, completionKnown: true, finalAnswer: messages.at(-1)?.channel === "final", identity: `${latestGroupId}:${messages.at(-1)?.id || ""}`, timestamp: 0, order: nodes.length, diagnostics: { schema: "assistant-item", assistantNodeCount: nodes.length, assistantItemCount: itemCount, turnCount: groups.size, latestItemCount: items.length, finalItemCount: items.filter(item => item.phase === "final_answer").length, baselineExcludedItemCount: latestItems.length - items.length, completed: items.at(-1)?.completed === true }, }; } readChatGPTAssistantItems.prime = document => { const { groups, messageIds, unresolvedTurnKeys } = collectChatGPTAssistantItems(document); // Keep identities, not text: rehydration and edits cannot make history new. return { turnKeys: new Set(groups.keys()), messageIds, unresolvedTurnKeys }; }; let chatGPTItemPageDetected = false; let chatGPTItemBaseline = null; let chatGPTDomFallback = null; let chatGPTBaseline = null; let chatGPTLegacyTurnDetected = false; let chatGPTLegacyBaseline = null; const chatGPTPageDocument = () => typeof page !== "undefined" && page.document ? page.document : document; const chatGPTSnapshotSignature = snapshot => snapshot ? `${snapshot.identity || snapshot.message?.id || ""}:${snapshot.text}` : ""; const chatGPTData = (object, key) => { try { return Object.getOwnPropertyDescriptor(object || {}, key)?.value; } catch { return undefined; } }; const chatGPTQuery = (root, selector) => { try { return [...root.querySelectorAll(selector)].filter(node => node && node.isConnected !== false); } catch { return []; } }; const chatGPTAttr = (node, name) => node?.getAttribute?.(name) || ""; const chatGPTLegacyScope = pageDocument => { let main; try { main = pageDocument.querySelector?.("#main") || pageDocument.querySelector?.("main") || pageDocument.querySelector?.('[role="main"]'); } catch {} const root = main || pageDocument; let turns = chatGPTQuery(root, '[data-testid^="conversation-turn-"]'); if (!turns.length) turns = chatGPTQuery(root, "[data-turn-id]"); if (!turns.length) turns = chatGPTQuery(root, main ? "section" : "#main section"); if (!turns.length) turns = chatGPTQuery(root, '[data-message-author-role="assistant"], [data-message-author-role="user"]'); const anchor = turns.at(-1); const scope = anchor || main || pageDocument.body || pageDocument.documentElement || pageDocument; return { anchor, turns, nodes: [scope, ...chatGPTQuery(scope, "*")].filter(Boolean) }; }; const readChatGPTLegacyMessages = (root, output, seenArrays) => { const stack = [{ value: root, depth: 0, turnId: "" }]; const seen = new Set(); while (stack.length && seen.size < 500) { const { value, depth, turnId } = stack.pop(); if (!value || typeof value !== "object" || seen.has(value) || depth > 10) continue; seen.add(value); const id = chatGPTData(value, "turnId") || turnId; if (Array.isArray(value)) { for (let index = Math.min(value.length, 100) - 1; index >= 0; index--) { stack.push({ value: chatGPTData(value, String(index)), depth: depth + 1, turnId: id }); } continue; } for (const key of ["turn", "data", "value", "children", "props"]) { const child = chatGPTData(value, key); const childTurnId = key === "turn" ? chatGPTData(child, "id") || id : id; const messages = key === "turn" ? chatGPTData(child, "messages") : null; if (Array.isArray(messages) && !seenArrays.has(messages)) { seenArrays.add(messages); output.push({ messages, turnId: String(childTurnId || "") }); } stack.push({ value: child, depth: depth + 1, turnId: childTurnId }); } } }; const readChatGPTLegacyTurn = (pageDocument, scope = chatGPTLegacyScope(pageDocument)) => { const { anchor, nodes } = scope; if (anchor) chatGPTLegacyTurnDetected = true; else if (chatGPTLegacyTurnDetected) return null; const scopeNodes = new Set(nodes); const ancestorNodes = new Set(); // React owners can sit above intervening host wrappers. Read only the // ancestor path, and require a rendered identity before trusting its data. for (let node = anchor?.parentElement, depth = 0; node && depth < 12; node = node.parentElement, depth++) { if (node === pageDocument.body || node === pageDocument.documentElement) break; ancestorNodes.add(node); if (node.matches?.('#main, main, [role="main"]')) break; } const messageIds = new Set(nodes.map(node => chatGPTAttr(node, "data-message-id")).filter(Boolean)); const turnId = chatGPTAttr(anchor, "data-turn-id"); const roles = nodes.map(node => chatGPTAttr(node, "data-message-author-role")); if (roles.includes("user") && !roles.includes("assistant")) return null; const propsCandidates = []; const fiberCandidates = []; const propsArrays = new Set(); const fiberArrays = new Set(); const roots = new Map(); const rememberRoot = (handle, localContainer) => { let fiber = chatGPTData(handle, "current") || handle; const seen = new Set(); while (fiber && typeof fiber === "object" && !seen.has(fiber) && seen.size < 500) { seen.add(fiber); const current = chatGPTData(chatGPTData(fiber, "stateNode"), "current"); if (current) { roots.set(current, roots.get(current) || localContainer || false); return; } fiber = chatGPTData(fiber, "return"); } }; for (const node of [...nodes, ...ancestorNodes]) { let keys = []; try { keys = Object.getOwnPropertyNames(node); } catch {} for (const key of keys) { if (key.startsWith("__reactProps$")) { const candidates = []; readChatGPTLegacyMessages(chatGPTData(node, key), candidates, propsArrays); propsCandidates.push(...candidates.map(candidate => ({ ...candidate, outsideTurn: ancestorNodes.has(node) }))); } else if (key.startsWith("__reactContainer$")) { rememberRoot(chatGPTData(node, key), !ancestorNodes.has(node)); } else if (key.startsWith("__reactFiber$")) { rememberRoot(chatGPTData(node, key), false); } } } const select = candidates => { const snapshots = new Map(); for (const candidate of candidates) { if (turnId && candidate.turnId && turnId !== candidate.turnId) continue; const ids = candidate.messages.map(message => String(chatGPTData(message, "id") || chatGPTData(message, "message_id") || "")); if (messageIds.size && ![...messageIds].every(id => ids.includes(id))) continue; if (candidate.outsideTurn && !(turnId && candidate.turnId === turnId)) { const assistants = candidate.messages.filter(message => (chatGPTData(chatGPTData(message, "author"), "role") || chatGPTData(message, "role")) === "assistant"); if (!assistants.length || !assistants.every(message => messageIds.has(String( chatGPTData(message, "id") || chatGPTData(message, "message_id") || "")))) continue; } const parsed = parseChatGPTTurn(candidate.messages); const identity = String(parsed.message?.id || parsed.message?.message_id || ""); const snapshot = { ...parsed, identity, turnNode: anchor, turnId, legacyMessageIds: ids }; // Separate arrays without stable message IDs remain ambiguous, even // when different turns happen to have identical rendered text. const key = ids.length && ids.every(Boolean) ? JSON.stringify([candidate.turnId, ids, parsed.done, parsed.text]) : candidate.messages; snapshots.set(key, snapshot); } // Different turns or inconsistent copies cannot be ordered by timestamps, // text length, completion, or traversal order. Wait for an unambiguous turn. return snapshots.size === 1 ? [...snapshots.values()][0] : snapshots.size ? null : undefined; }; if (propsCandidates.length) { const snapshot = select(propsCandidates); if (snapshot !== undefined) return snapshot; } for (const [root, localContainer] of roots) { const stack = [{ fiber: root, parent: null }]; const seen = new Set(); let matched = false; while (stack.length && seen.size < 5000) { const entry = stack.pop(); const fiber = entry.fiber; if (!fiber || typeof fiber !== "object" || seen.has(fiber)) continue; seen.add(fiber); const host = chatGPTData(fiber, "stateNode"); if (scopeNodes.has(host)) { matched = true; readChatGPTLegacyMessages(chatGPTData(fiber, "memoizedProps"), fiberCandidates, fiberArrays); // Follow the actual committed DFS path, not a possibly stale return // pointer on a child reused by a React bailout. let outsideTurn = false; for (let owner = entry.parent; owner; owner = owner.parent) { const parentHost = chatGPTData(owner.fiber, "stateNode"); if (parentHost?.nodeType === 1 && !scopeNodes.has(parentHost) && !ancestorNodes.has(parentHost)) break; outsideTurn ||= ancestorNodes.has(parentHost); const props = chatGPTData(owner.fiber, "memoizedProps"); const direct = []; for (const value of [props, chatGPTData(props, "data"), chatGPTData(props, "value")]) { const turn = chatGPTData(value, "turn"); if (turn) readChatGPTLegacyMessages({ turn }, direct, fiberArrays); } if (direct.length) { fiberCandidates.push(...direct.map(candidate => ({ ...candidate, outsideTurn }))); } } } const sibling = chatGPTData(fiber, "sibling"); const child = chatGPTData(fiber, "child"); if (sibling) stack.push({ fiber: sibling, parent: entry.parent }); if (child) stack.push({ fiber: child, parent: entry }); } if (stack.length) return null; // A nested React container owns only its own tree. Unmarked legacy pages // may also use a single root, but never choose among multiple turns. if (!matched && !anchor && localContainer) { const scan = [root]; const visited = new Set(); while (scan.length && visited.size < 5000) { const fiber = scan.pop(); if (!fiber || typeof fiber !== "object" || visited.has(fiber)) continue; visited.add(fiber); readChatGPTLegacyMessages(chatGPTData(fiber, "memoizedProps"), fiberCandidates, fiberArrays); const child = chatGPTData(fiber, "child"); const siblings = new Set(); for (let next = child; next && !siblings.has(next) && siblings.size < 5000; next = chatGPTData(next, "sibling")) { siblings.add(next); scan.push(next); } } if (scan.length) return null; } } return select(fiberCandidates) || null; }; const readChatGPTDomSnapshot = () => { const pageDocument = chatGPTPageDocument(); const itemSnapshot = readChatGPTAssistantItems(pageDocument, chatGPTItemBaseline); if (itemSnapshot) { chatGPTItemPageDetected = true; return itemSnapshot; } if (chatGPTItemPageDetected) return { text: "", done: false, completionKnown: true }; const legacySnapshot = readChatGPTLegacyTurn(pageDocument); if (legacySnapshot) return legacySnapshot; // A section also contains reasoning and tool progress; it is not an answer. return { text: "", done: false, completionKnown: true }; }; const parseChatGPTDom = () => { const snapshot = readChatGPTDomSnapshot(); if (chatGPTLegacyBaseline && snapshot?.legacyMessageIds) { const old = chatGPTLegacyBaseline; if (old.unresolvedNodes.has(snapshot.turnNode) || (snapshot.turnId && old.unresolvedTurnIds.has(snapshot.turnId)) || (snapshot.identity ? old.messageIds.has(snapshot.identity) : old.nodes.has(snapshot.turnNode) || (snapshot.turnId && old.turnIds.has(snapshot.turnId)))) { return { text: "", done: false, completionKnown: true }; } } if (chatGPTBaseline !== null && chatGPTSnapshotSignature(snapshot) === chatGPTBaseline) { return { text: "", done: false, completionKnown: true }; } return chatGPTDomFallback?.read(snapshot) || snapshot; }; parseChatGPTDom.prime = () => { const pageDocument = chatGPTPageDocument(); chatGPTDomFallback ||= createChatGPTDomFallback(pageDocument); chatGPTDomFallback.prime(); chatGPTItemPageDetected = false; chatGPTLegacyTurnDetected = false; chatGPTItemBaseline = null; const baseline = readChatGPTDomSnapshot(); chatGPTBaseline = chatGPTSnapshotSignature(baseline); chatGPTItemBaseline = readChatGPTAssistantItems.prime(pageDocument); const { turns } = chatGPTLegacyScope(pageDocument); const messageIds = new Set((baseline?.legacyMessageIds || []).filter(Boolean)); const unresolvedNodes = new Set(); const unresolvedTurnIds = new Set(); // Record every existing turn before sending. Keeping only the last answer // lets older answers resurface after React temporarily removes newer turns. for (const anchor of turns) { const nodes = [anchor, ...chatGPTQuery(anchor, "*")]; const snapshot = readChatGPTLegacyTurn(pageDocument, { anchor, nodes }); const ids = [...nodes.map(node => chatGPTAttr(node, "data-message-id")), ...(snapshot?.legacyMessageIds || [])].filter(Boolean); for (const id of ids) messageIds.add(id); if (!snapshot?.identity) { unresolvedNodes.add(anchor); const id = chatGPTAttr(anchor, "data-turn-id"); if (id) unresolvedTurnIds.add(id); } } chatGPTLegacyBaseline = { nodes: new Set(turns), turnIds: new Set(turns.map(node => chatGPTAttr(node, "data-turn-id")).filter(Boolean)), messageIds, unresolvedNodes, unresolvedTurnIds, }; }; const parsePoeDom = () => { const nodes = [...document.querySelectorAll('[class^="ChatMessage_chatMessage"] [class^="Message_selectableText"]')]; const node = nodes.at(-1); return node ? { text: node.innerText || node.textContent || "", done: Boolean(node.closest('[class^="ChatMessagesView_messageTuple"]')?.querySelector('[class^="ChatMessageActionBar_actionBar"]')) } : null; }; const parseTencentDom = () => { const node = document.querySelector(".client-chat"); const message = node?.__vue__?.msgList?.at(-1); if (!message) return null; return { text: message.content || message.agent_thought?.procedures?.[0]?.debugging?.content || "", done: Boolean(message.is_final) }; }; const parseXiaoyiDom = () => { const node = [...document.querySelectorAll(".receive-box")].at(-1); if (!node) return null; return { text: node.querySelector(".answer-cont")?.innerHTML || node.innerText || "", done: Boolean(node.closest(".msg-content")?.querySelector(".tool-bar")) }; }; // Gemini no longer streams through StreamGenerate; read the newest // model-response created after send. Its turns carry no stable IDs. let geminiBaseline = null; const geminiVisible = node => Boolean(node?.getClientRects().length); const parseGeminiDom = () => { const responses = [...document.querySelectorAll("model-response")]; const node = responses.at(-1); // A remounted old answer keeps its text; only a new turn counts. if (!node || node === geminiBaseline?.last || (geminiBaseline && responses.length <= geminiBaseline.count && (node.innerText || "") === geminiBaseline.text)) return { text: "", done: false, completionKnown: true }; const content = node.querySelector("message-content .markdown, .model-response-text, message-content"); const value = (content?.innerText || "").trim(); const streaming = [...document.querySelectorAll('.stop-button, button[aria-label*="Stop response" i], button[aria-label*="停止回答"]')].some(geminiVisible); const copied = [...node.querySelectorAll('button.copy-button, [data-test-id="copy-button"], button[aria-label*="Copy" i], button[aria-label*="复制"]')].some(geminiVisible); return { text: value, done: Boolean(value && copied && !streaming), completionKnown: true }; }; parseGeminiDom.prime = () => { const responses = [...document.querySelectorAll("model-response")]; geminiBaseline = { count: responses.length, last: responses.at(-1) || null, text: responses.at(-1)?.innerText || "" }; }; const parseCopilotDom = () => { const node = [...document.querySelectorAll('[data-content="ai-message"]')].at(-1); if (!node) return null; const value = node[Object.keys(node)[0]]?.pendingProps?.children?.[1]?.[0]?.props; return { text: value?.item?.text || node.innerText || "", done: Boolean(value?.isStreamingComplete) }; }; const SITES = [ dom("ChatGPT", ["chatgpt.com"], input("#prompt-textarea", "chatgpt", "#upload-files, #upload-photos", "input", "#composer-submit-button"), "#main section", parseChatGPTDom, { connectionNotice: "请使用Chat模式,不要使用Work模式。" }), network("ChatGPT Mirror", ["chat.dakeai.de", "leopard-x.memofun.net", "share.zhangsan.cool", "chatopens.com", "98355118.4omini.xyz", "chat.rawchat.cn", "node.dawuai.buzz", "china.aikeji.vip"], input("#prompt-textarea", "paste", "input[type=file]", "paste", '[data-testid="send-button"]'), /backend-api\/f\/conversation$/, "chatgpt"), network("Kimi", ["www.kimi.com", "kimi.moonshot.cn"], input('[contenteditable="true"]', "lexical", ".chat-input-editor-container", "paste", ".send-button-container"), /ChatService\/Chat(?:\?|$)/, "kimi"), network("Tongyi", ["www.qianwen.com", "qianwen.aliyun.com"], input('[role=textbox]', "paste", '[role="textbox"]', "paste", 'button[data-session-switch-target="send-query"], button[aria-label="发送消息"], [data-icon-type="qwpcicon-sendChat"]'), /qianwen.com\/api\/v2\/chat/, "tongyi"), network("Claude", ["claude.ai", "claude.ai0.cn", "chat.kelaode.ai"], input('[contenteditable="true"]', "div", "input[type=file]", "input", 'button[aria-label="Send message"], button[aria-label="Send Message"], button[aria-label="发送消息"], button[aria-label="發送訊息"]'), /chat_conversations\/.+\/completion/, "claude"), dom("Gemini", ["gemini.google.com"], input("rich-textarea .textarea", "gemini", ".text-input-field", "paste", ".send-button"), "model-response", parseGeminiDom), dom("Poe", ["poe.com"], input('textarea[class*=GrowingTextArea_textArea]', "textarea", "input[type=file]", "input", "[data-button-send=true]"), '[class^="ChatMessage_chatMessage"] [class^="Message_selectableText"]', parsePoeDom), network("Doubao", ["www.doubao.com"], input('.ProseMirror[contenteditable="true"], textarea.semi-input-textarea, textarea[placeholder*="发消息"], [contenteditable="true"][role="textbox"]', "doubao", "input[type=file]", "input", "button#flow-end-msg-send"), /chat\/completion/, "doubao"), network("DeepSeek", ["chat.deepseek.com"], input("textarea", "react", ".bf38813a", "drag", "._52c986b"), /completion$/, "deepseek"), network("Yuanbao", ["yuanbao.tencent.com"], input('[contenteditable="true"]', "div", ".agent-chat__input-box", "yuanbao", "#yuanbao-send-btn"), /api\/chat\/.+/, "yuanbao"), network("AIStudio", ["aistudio.google.com"], input(".text-wrapper textarea", "standard", ".text-wrapper", "drag", "ms-run-button button"), /GenerateContent$/, "aistudio"), network("ChatGLM", ["chatglm.cn"], input(".input-box-inner textarea", "standard", "input[type=file]", "input", ".enter .enter-icon-container:not(.empty)"), /backend-api\/assistant\/stream/, "chatglm"), network("Z.ai", ["chat.z.ai"], input("#chat-input", "standard", "input[type=file]", "input", "#send-message-button"), /api\/(?:agent\/)?(?:v\d+\/)?chat\/completions/, "zai"), network("Yiyan", ["yiyan.baidu.com"], input(".yc-editor", "standard", ".UxLYHqhv", "drag", "[class^=sendInner]"), /chat\/conversation\/v2$/, "yiyan"), network("Zaiwen", ["www.zaiwen.top"], input("textarea.arco-textarea", "standard", ".arco-upload-draggable", "drag", "img.send"), /admin\/chatbot$/, "generic"), network("Chandler", ["mychandler.bet"], input(".chandler-content_input-area", "standard", "input[type=file]", "input", ".send"), /api\/chat\/Chat$/, "chandler"), network("MyTan", ["mytan.maiseed.com.cn"], input(".talk-textarea", "standard", "input[type=file]", "input", ".send-icon"), /messages$/, "mytan"), network("Coze", ["coze"], input("textarea.rc-textarea", "react", "input[type=file]", "input", 'button[data-testid="bot-home-chart-send-button"]'), /conversation\/chat/, "coze"), network("Grok", ["grok.com"], input('div[contenteditable="true"]', "paste", "input[type=file]", "input", 'button[type="submit"]'), /\/responses$/, "grok"), network("Baidu", ["chat.baidu.com"], input("#chat-input-box", "standard", "[class^=chat-bottom-wrapper]", "drag", ".send-icon"), /conversation$/, "baidu"), network("Perplexity", ["www.perplexity.ai"], input("#ask-input", "lexical", "input[type=file]", "input", 'button[aria-label="提交"], button[aria-label="Submit"]'), /perplexity_ask$/, "perplexity"), network("Sider", ["sider.ai"], input("textarea.chatBox-input", "textarea", "input[type=file]", "input", ".send-btn"), /(completions|chat\/wisebase)/, "sider"), network("Qwen", ["chat.qwen.ai", "chat.qwenlm.ai"], input(".message-input-container-area textarea", "textarea", ".message-input-container-area", "drag", "button.send-button"), /chat\/completions/, "qwen"), network("AskManyAI", ["askmanyai.chat"], input(".editor", "paste", "input[type=file]", "input", ".fs_button"), /engine\/sseQuery/, "askmany"), network("Wenxiaobai", ["www.wenxiaobai.com"], input('[class^=MsgInput_input_box] textarea', "textarea", "[class^=botChatPage_input_content_container]", "drag", "#j-input-send-msg"), /conversation\/chat\/v\d$/, "wenxiaobai"), network("NotebookLM", ["notebooklm.google.com"], input("textarea.query-box-input", "standard", "input[type=file]", "input", 'button[type="submit"]'), /GenerateFreeFormStreamed/, "notebooklm"), network("MinMax", ["minimaxi"], input('.chat-input-container [contenteditable]', "paste", "input[type=file]", "input", "#input-send-icon div"), /v1\/chat\/get_chat_detail/, "minimax"), network("LMArena", ["lmarena.ai", "arena.ai"], input("form textarea", "textarea", "input[type=file]", "input", 'button[type="submit"]'), /stream\/post-to-evaluation/, "lmarena"), network("GitHub Copilot", ["github.com"], input("textarea#copilot-chat-textarea", "standard", "input[type=file]", "input", '[class^="ChatInput-module__toolbarButtons"] button'), /github\/chat\/threads\/.+\/messages/, "github"), network("MIMO", ["aistudio.xiaomimimo.com"], input("textarea", "textarea", "input[type=file]", "input", ".dialogue-container > div:nth-child(2) button:nth-child(3)"), /open-apis\/bot\/chat/, "mimo"), dom("Tencent DeepSeek", ["lke.cloud.tencent.com"], input(".question-input-inner__textarea", "standard", "input[type=file]", "input", ".question-input button, .question-input [role=button]"), ".client-chat", parseTencentDom), dom("Xiaoyi", ["xiaoyi.huawei.com"], input("textarea", "standard", "input[type=file]", "input", ".send-button"), ".receive-box", parseXiaoyiDom), dom("Copilot", ["copilot.microsoft.com"], input("textarea#userInput", "standard", "input[type=file]", "input", '[data-testid="submit-button"], button[type="submit"]'), '[data-content="ai-message"]', parseCopilotDom), network("Monica", ["monica.im"], input("textarea.ant-input", "textarea", "[class^=chat-input-v2]", "drag", '[class^="input-msg-btn"], button[type="submit"]'), /api.monica.im\/api\/custom_bot\/chat/, "monica"), ]; const FALLBACK = network("ChatGPT Mirror", ["*"], input("#prompt-textarea", "paste", "input[type=file]", "paste", '[data-testid="send-button"]'), /backend-api\/f\/conversation$/, "chatgpt"); const hostMatches = (hostname, candidate) => candidate === "*" || hostname === candidate || hostname.endsWith(`.${candidate}`) || (candidate.length > 4 && hostname.includes(candidate)); const resolveSite = () => SITES.find(site => site.hosts.some(host => hostMatches(location.hostname, host))) || FALLBACK; /* ------------------------------------------------------------------------ * * 3. HTTP bridge and cross-tab ownership * ------------------------------------------------------------------------ */ function request(payload, timeout = 10_000, port = DEFAULT_CONNECTOR_PORT) { let requestObject; let settled = false; let resolvePromise; let rejectPromise; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); const finish = (callback, value) => { if (settled) return; settled = true; callback(value); }; const fail = (message, status) => { const error = new Error(message); error.code = "CONNECTOR_TRANSPORT"; if (status !== undefined) error.status = status; finish(rejectPromise, error); }; const isProbe = payload.action === "probe" && !payload.client; try { if (normalizeConnectorPort(port) === null) throw new Error("Invalid connector port"); requestObject = GM_xmlhttpRequest({ method: isProbe ? "GET" : "POST", url: `http://127.0.0.1:${port}/zoterogpt`, anonymous: true, headers: isProbe ? { Accept: "application/json" } : { "Content-Type": "application/json", Accept: "application/json" }, ...(isProbe ? {} : { data: JSON.stringify(payload) }), timeout, onload: response => { if (response.status >= 200 && response.status < 300) { const value = json(response.responseText); !value || typeof value !== "object" || Array.isArray(value) ? fail("Invalid connector JSON") : finish(resolvePromise, value); } else { fail(`HTTP ${response.status}`, response.status); } }, onerror: () => fail("Connector request failed"), onabort: () => finish(rejectPromise, new DOMException("Aborted", "AbortError")), ontimeout: () => payload.action === "poll" ? finish(resolvePromise, {}) : fail("Connector request timed out"), }); } catch { fail("Connector request could not start"); } return { promise, abort: () => { try { requestObject?.abort(); } catch {} finish(rejectPromise, new DOMException("Aborted", "AbortError")); }, }; } function normalizeConnectorPort(value) { const port = Number(value); return Number.isInteger(port) && port >= CONNECTOR_PORT_MIN && port <= CONNECTOR_PORT_MAX ? port : null; } function discoverConnectorPort() { let cancelled = false; let winner = null; const pending = new Set(); const probe = async port => { if (cancelled || winner !== null) return; const operation = request({ action: "probe" }, CONNECTOR_PROBE_TIMEOUT, port); pending.add(operation); try { const result = await operation.promise; if (!cancelled && winner === null && ["available", "ok"].includes(result.status)) { winner = port; for (const other of pending) if (other !== operation) other.abort(); } } catch { // Closed ports and non-connector services are expected during discovery. } finally { pending.delete(operation); } }; const promise = (async () => { let remembered = null; try { remembered = normalizeConnectorPort(GM_getValue(CONNECTOR_PORT_KEY, null)); } catch {} const preferred = [...new Set([remembered, DEFAULT_CONNECTOR_PORT].filter(port => port !== null))]; for (const port of preferred) await probe(port); const candidates = []; for (let offset = 1; offset <= CONNECTOR_PORT_MAX - CONNECTOR_PORT_MIN; offset += 1) { for (const port of [DEFAULT_CONNECTOR_PORT - offset, DEFAULT_CONNECTOR_PORT + offset]) { if (normalizeConnectorPort(port) !== null && !preferred.includes(port)) candidates.push(port); } } let cursor = 0; const worker = async () => { while (!cancelled && winner === null && cursor < candidates.length) await probe(candidates[cursor++]); }; await Promise.all(Array.from({ length: CONNECTOR_PROBE_CONCURRENCY }, worker)); if (cancelled) throw new DOMException("Discovery cancelled", "AbortError"); if (winner === null) { const error = new Error(`No Zotero connector on ports ${CONNECTOR_PORT_MIN}-${CONNECTOR_PORT_MAX}`); error.code = "CONNECTOR_NOT_FOUND"; throw error; } try { GM_setValue(CONNECTOR_PORT_KEY, winner); } catch {} return winner; })(); return { promise, abort() { cancelled = true; for (const operation of pending) operation.abort(); } }; } const endpointFailure = error => error?.code === "CONNECTOR_TRANSPORT" || Number(error?.status) === 404 || /HTTP\s+404|Not Found/i.test(text(error?.message || error)); const readLock = () => json(GM_getValue(LOCK_KEY, "{}")) || {}; const ownsLock = () => { const value = readLock(); return Boolean(value.isLocked && value.tabId === TAB_ID); }; const acquireLock = force => { const value = readLock(); if (!force && value.isLocked && value.tabId !== TAB_ID) return false; GM_setValue(LOCK_KEY, JSON.stringify({ isLocked: true, tabId: TAB_ID })); return true; }; const releaseLock = () => { if (ownsLock()) GM_setValue(LOCK_KEY, JSON.stringify({ isLocked: false, tabId: null })); }; /* ------------------------------------------------------------------------ * * 4. Browser-side input and upload strategies * ------------------------------------------------------------------------ */ const usable = (element, enabled = false) => { if (!element || !element.isConnected || element.hidden || element.getAttribute("aria-hidden") === "true") return false; if (enabled && (element.disabled || element.getAttribute("aria-disabled") === "true" || element.closest?.(':disabled, [aria-disabled="true"]'))) return false; const style = getComputedStyle(element); return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && element.getClientRects().length > 0; }; const find = async (selector, timeout = 5_000, enabled = false, isActive = () => true) => { const deadline = Date.now() + timeout; while (Date.now() < deadline && isActive()) { const element = typeof selector === "function" ? selector() : [...document.querySelectorAll(selector || "")].find(candidate => usable(candidate, enabled)); if (element) return element; await sleep(100); } return null; }; const nativeValue = (element, value) => { if ("value" in element) { const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value")?.set || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; if (setter) setter.call(element, value); else element.value = value; } else { element.textContent = value; } element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); element.dispatchEvent(new Event("change", { bubbles: true })); }; const chatGPTControlDocument = () => typeof page !== "undefined" && page.document ? page.document : document; const chatGPTUsableControl = element => { if (!usable(element, true) || element.readOnly || element.closest?.('[hidden], [aria-hidden="true"], [inert]')) return false; // ChatGPT can keep the old composer mounted after a conversation switch. for (let node = element; node; node = node.parentElement) { const style = chatGPTControlDocument().defaultView?.getComputedStyle?.(node) || getComputedStyle(node); if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false; } if (getComputedStyle(element).pointerEvents === "none") return false; return true; }; const selectChatGPTInput = selector => [...chatGPTControlDocument().querySelectorAll(selector || "#prompt-textarea")] .find(chatGPTUsableControl) || null; const normalizeChatGPTInput = value => text(value).replace(/\r\n?/g, "\n").trim(); const chatGPTInputText = element => { if (!element) return ""; if ("value" in element) return normalizeChatGPTInput(element.value); const read = node => { if (node.nodeType === 3) return node.nodeValue || ""; if (node.nodeName?.toUpperCase() === "BR") return /\bProseMirror-trailingBreak\b/.test(node.getAttribute("class") || "") ? "" : "\n"; const children = [...(node.childNodes || [])]; return children.map(read).join(children.some(child => ["P", "DIV"].includes(child.nodeName?.toUpperCase())) ? "\n" : ""); }; return normalizeChatGPTInput(element.childNodes ? read(element) : element.textContent); }; const chatGPTControlSemantics = element => ["id", "data-testid", "aria-label", "title", "name"] .map(name => element.getAttribute?.(name) || "").concat(element.textContent || "").join(" "); const chatGPTNegativeControl = element => /stop|cancel|upload|attach|delete|remove|regenerate|停止|取消|上传|附件|删除|重新生成/i .test(chatGPTControlSemantics(element)); const selectChatGPTSend = (selector, inputSelector) => { const input = selectChatGPTInput(inputSelector); if (!input) return null; const form = input.closest?.("form"); for (let scope = form || input.parentElement, depth = 0; scope && depth < 6; scope = scope.parentElement, depth++) { // Match the extension's nearby-control search, never a page-wide button. if (scope === chatGPTControlDocument().body || scope.matches?.('main, [role="main"], [data-message-author-role]')) break; const preferred = [...scope.querySelectorAll(selector)]; const candidates = [...new Set([...preferred, ...scope.querySelectorAll('button, input[type="submit"], [role="button"]')])]; const button = candidates.find(candidate => candidate.closest?.("form") === form && chatGPTUsableControl(candidate) && !chatGPTNegativeControl(candidate) && (preferred.includes(candidate) || candidate.getAttribute("type") === "submit" || /send|submit|发送|提交/i.test(chatGPTControlSemantics(candidate)))); if (button || form) return button || null; } return null; }; const chatGPTSubmissionState = inputSelector => { const pageDocument = chatGPTControlDocument(); return { draft: chatGPTInputText(selectChatGPTInput(inputSelector)), turns: new Set([...pageDocument.querySelectorAll('[data-content-search-unit-key], [data-testid^="conversation-turn-"], [data-message-id]')] .map(node => node.getAttribute("data-message-id") || node.getAttribute("data-content-search-unit-key") || node.getAttribute("data-testid")).filter(Boolean)), streaming: [...pageDocument.querySelectorAll('[data-is-streaming="true"], [data-testid="stop-button"], [data-testid="composer-stop-button"], button[aria-label*="Stop generating" i], button[aria-label*="停止生成"]')] .some(chatGPTUsableControl), }; }; const isDoubaoProseMirror = element => !!element?.matches?.('.ProseMirror[contenteditable="true"]'); const selectDoubaoInput = selector => { const candidates = [...document.querySelectorAll(selector)].filter(candidate => usable(candidate, true) && !candidate.readOnly); // The live Tiptap/ProseMirror composer has no role=textbox. Keep native // textarea and legacy editors as fallbacks, not preferred stale matches. return candidates.find(isDoubaoProseMirror) || candidates.find(candidate => candidate.tagName === "TEXTAREA") || candidates.find(candidate => candidate.isContentEditable) || null; }; const normalizeDoubaoText = value => text(value).replace(/\r\n?/g, "\n").trim(); const doubaoInputText = element => { if (!isDoubaoProseMirror(element)) { return normalizeDoubaoText(element && ("value" in element ? element.value : element.innerText || element.textContent)); } // innerText inserts layout spacing between

s; textContent drops all // paragraph boundaries. Read logical lines without losing empty

s. const read = node => { if (node.nodeType === 3) return node.nodeValue || ""; if (node.nodeName === "BR") { return /\bProseMirror-trailingBreak\b/.test(node.getAttribute("class") || "") ? "" : "\n"; } return [...node.childNodes].map(read).join(""); }; const nodes = [...element.childNodes]; return normalizeDoubaoText(nodes.map(read).join(nodes.some(node => node.nodeName === "P") ? "\n" : "")); }; const doubaoParagraphs = value => { const container = document.createElement("div"); // ProseMirror's structured clipboard payload preserves whitespace and // empty paragraphs, unlike plain-text paste on the live Doubao composer. container.setAttribute("data-pm-slice", "0 0 []"); for (const line of text(value).replace(/\r\n?/g, "\n").split("\n")) { const paragraph = document.createElement("p"); paragraph.textContent = line; // Never interpret prompt text as HTML. container.appendChild(paragraph); } return container; }; async function fillDoubaoInput(element, selector, value) { const expected = normalizeDoubaoText(value); if (element.tagName === "TEXTAREA") nativeValue(element, value); else { // Replace the entire draft instead of appending at the current caret. const range = document.createRange(); range.selectNodeContents(element); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); try { const transfer = new DataTransfer(); transfer.setData("text/plain", value); if (isDoubaoProseMirror(element)) transfer.setData("text/html", doubaoParagraphs(value).outerHTML); element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer })); } catch (error) { log.warn("豆包粘贴失败,改用输入回退", error); } await sleep(50); element = selectDoubaoInput(selector); if (!element) return false; if (doubaoInputText(element) !== expected) { element.focus(); if (isDoubaoProseMirror(element)) { // Keep valid paragraph DOM for the editor's mutation observer. Do // not flatten accepted markup or replay the entire prompt as data. element.replaceChildren(...doubaoParagraphs(value).childNodes); element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: null })); element.dispatchEvent(new Event("change", { bubbles: true })); } else nativeValue(element, value); } } await sleep(50); const current = selectDoubaoInput(selector); return !!current && doubaoInputText(current) === expected; } async function fillInput(config, value, isActive = () => true) { const doubao = config?.method === "doubao"; const chatgpt = config?.method === "chatgpt"; const element = await find(chatgpt ? () => isActive() && selectChatGPTInput(config.selector) : doubao ? () => selectDoubaoInput(config.selector) : config?.selector, 5_000, false, isActive); if (!element || !isActive()) return false; element.focus(); try { if (doubao) return await fillDoubaoInput(element, config.selector, value); if (["textarea", "standard", "react"].includes(config.method)) nativeValue(element, value); else if (config.method === "chatgpt") { // Both execCommand insertion paths block on large multiline prompts. // Notify the editor of the DOM replacement without replaying the text. if ("value" in element) { const view = chatGPTControlDocument().defaultView; const prototype = element.tagName === "TEXTAREA" ? view?.HTMLTextAreaElement?.prototype || HTMLTextAreaElement.prototype : view?.HTMLInputElement?.prototype || HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; if (setter) setter.call(element, value); else element.value = value; } else element.textContent = value; element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: null, })); element.dispatchEvent(new Event("change", { bubbles: true })); } else if (["div", "gemini", "contenteditable", "lexical", "paste"].includes(config.method)) { const transfer = new DataTransfer(); transfer.setData("text/plain", value); element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer })); if (!text(element.innerText || element.textContent).trim()) nativeValue(element, value); } else nativeValue(element, value); await sleep(50); if (config.method === "chatgpt") { const current = await find(() => { if (!isActive()) return null; const candidate = selectChatGPTInput(config.selector); return candidate && chatGPTInputText(candidate) === normalizeChatGPTInput(value) ? candidate : null; }, 3_000, false, isActive); return !!current && isActive(); } return text("value" in element ? element.value : element.innerText || element.textContent).replace(/\s/g, "") .includes(text(value).replace(/\s/g, "")); } catch (error) { log.warn("输入失败", error); return false; } } function filesFromMessages(messages, transfers) { return messages.map(message => { const transfer = message.transferId ? transfers.get(message.transferId) : null; if (message.transferId && (!transfer || !transfer.done)) throw new Error(`Incomplete file transfer: ${message.transferId}`); const bytes = transfer ? transfer.parts : [decodeBase64(message.base64String)]; return new File(bytes, message.name, { type: fileType(message.name) }); }); } function decodeBase64(value) { const binary = atob(text(value)); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return bytes.buffer; } const fileType = name => /\.pdf$/i.test(name) ? "application/pdf" : /\.png$/i.test(name) ? "image/png" : /\.(?:jpe?g)$/i.test(name) ? "image/jpeg" : /\.gif$/i.test(name) ? "image/gif" : /\.webp$/i.test(name) ? "image/webp" : /\.(?:txt|md)$/i.test(name) ? "text/plain" : "application/octet-stream"; const acceptsFile = (input, file) => { const accept = text(input?.accept).trim(); if (!accept) return true; const type = text(file?.type).toLowerCase(); const name = text(file?.name).toLowerCase(); return accept.split(",").map(value => value.trim().toLowerCase()).filter(Boolean).some(token => { if (token === "*/*") return true; if (token.startsWith(".")) return name.endsWith(token); if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1)); return token === type; }); }; const selectFileInput = (selector, files) => { const candidates = [...document.querySelectorAll(selector || 'input[type="file"]')] .filter(element => element.matches?.('input[type="file"]') && !element.hasAttribute("capture")); const pool = candidates.filter(candidate => files.every(file => acceptsFile(candidate, file))); const score = candidate => { const id = text(candidate.id).toLowerCase(); const types = files.map(file => text(file.type).toLowerCase()); let value = 0; if (types.every(type => type.startsWith("image/")) && id === "upload-photos") value += 40; if (types.some(type => !type.startsWith("image/")) && id === "upload-files") value += 40; if (candidate.multiple) value += 5; return value; }; return pool.sort((left, right) => score(right) - score(left))[0] || null; }; const waitForFileInput = async (selector, files, timeout = 30_000) => { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const inputElement = selectFileInput(selector, files) || selectFileInput('input[type="file"]', files); if (inputElement) return inputElement; await sleep(100); } return null; }; class YuanbaoUploader { constructor() { this.require = null; this.bus = null; this.emit = null; } getRequire() { if (this.require?.m) return this.require; const chunks = page.webpackChunk_N_E; if (!chunks?.push) return null; const chunkId = `zotero-${Date.now()}-${Math.random().toString(36).slice(2)}`; const resultKey = `__zotero_gpt_require_${Date.now()}`; let captured = null; try { page.Function("chunkId", "resultKey", ` let captured; window.webpackChunk_N_E.push([[chunkId], {}, require => { captured = require; }]); window[resultKey] = captured; `)(chunkId, resultKey); captured = page[resultKey]; } catch { try { chunks.push([[chunkId], {}, value => { captured = value; }]); } catch { return null; } } finally { try { delete page[resultKey]; } catch { /* page cleanup is best effort */ } } if (captured?.m) this.require = captured; return this.require; } locateBus() { if (this.bus && this.emit) { try { if (this.bus.listenerCount("input-file-upload-multiple") > 0) return this.bus; } catch { /* the page may have replaced its event bus */ } this.bus = null; this.emit = null; } const webpack = this.getRequire(); if (!webpack?.m) return null; for (const [id, factory] of Object.entries(webpack.m)) { let source = ""; try { source = Function.prototype.toString.call(factory); } catch { continue; } if (!source.includes("emitSticky") || !source.includes("addStickyListener")) continue; let exports; try { exports = webpack(id); } catch { continue; } const bus = Object.values(exports || {}).find(value => { try { return value && typeof value.emit === "function" && typeof value.listenerCount === "function" && value.listenerCount("input-file-upload-multiple") > 0; } catch { return false; } }); if (!bus) continue; this.bus = bus; this.emit = files => bus.emit("input-file-upload-multiple", files.map(file => ({ type: file.type.startsWith("image/") ? "image" : "file", name: file.name, size: file.size, url: "", raw: file, width: 0, height: 0, uploadAction: 1, }))); return bus; } return null; } async upload(files) { const bus = this.locateBus(); if (!bus || !this.emit) return false; const pageFiles = await Promise.all(files.map(async file => { if (file instanceof page.File) return file; return new page.File([new page.Uint8Array(await file.arrayBuffer())], file.name, { type: file.type }); })); try { this.emit(pageFiles); return true; } catch (error) { log.warn("元宝上传事件失败", error); return false; } } } class Uploader { constructor(transfers) { this.transfers = transfers; this.yuanbao = new YuanbaoUploader(); } async upload(messages, config) { const files = filesFromMessages(messages, this.transfers); const transfer = new DataTransfer(); files.forEach(file => transfer.items.add(file)); const method = config?.method || "input"; if (method === "yuanbao" && await this.yuanbao.upload(files)) return true; if (method === "input" || method === "yuanbao") { const inputElement = await waitForFileInput(config?.selector, files); if (!inputElement) throw new Error(`文件输入框未找到: ${config?.selector || 'input[type="file"]'}`); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "files")?.set; if (setter) setter.call(inputElement, transfer.files); else inputElement.files = transfer.files; inputElement.dispatchEvent(new Event("input", { bubbles: true })); inputElement.dispatchEvent(new Event("change", { bubbles: true })); return true; } const target = await find(config?.selector, 30_000); if (!target) throw new Error(`上传目标未找到: ${config?.selector || "unknown"}`); if (method === "drag") { for (const type of ["dragstart", "dragenter", "dragover", "drop"]) target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer })); return true; } if (method === "paste") { target.focus(); const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer }); Object.defineProperty(event, "clipboardData", { value: transfer }); target.dispatchEvent(event); return true; } throw new Error(`Unsupported upload method: ${method}`); } } /* ------------------------------------------------------------------------ * * 5. Network capture and response delivery * ------------------------------------------------------------------------ */ class NetworkCapture { constructor(connector) { this.connector = connector; this.idleTimers = new Map(); this.patchFetch(); this.patchXHR(); } matches(url) { const output = this.connector.site.output; return output.type === "network" && output.pattern?.test(text(url)) && !text(url).includes("zoterogpt"); } clearIdle(taskId) { const timer = this.idleTimers.get(taskId); if (timer) clearTimeout(timer); this.idleTimers.delete(taskId); } idle(taskId) { this.clearIdle(taskId); this.idleTimers.set(taskId, setTimeout(() => { this.idleTimers.delete(taskId); if (this.connector.taskId === taskId && this.connector.response) this.connector.push(this.connector.response, true); }, NETWORK_IDLE)); } consume(raw, taskId) { if (this.connector.taskId !== taskId) return; const parsed = this.connector.site.output.parser(raw) || {}; const value = typeof parsed === "string" ? { text: parsed } : parsed; if (value.text) { // ChatGPT reparses the entire captured stream. Formatting can change an // earlier fence, so merging that snapshot as a delta duplicates the body. const next = this.connector.site.output.snapshot ? value.text : mergeStream(this.connector.response, value.text); this.connector.push(next, Boolean(value.done)); } else if (value.done && this.connector.response) this.connector.push(this.connector.response, true); if (value.done || value.waiting) this.clearIdle(taskId); else if (value.text) this.idle(taskId); } patchFetch() { if (typeof page.fetch !== "function") return; const original = page.fetch; const self = this; page.fetch = new Proxy(original, { apply(target, thisArg, args) { const promise = Reflect.apply(target, thisArg, args); const inputValue = args[0]; const url = typeof inputValue === "string" ? inputValue : inputValue?.url || inputValue?.href || ""; if (!self.connector.active || !self.matches(url)) return promise; const taskId = self.connector.taskId; promise.then(response => { if (!response.ok) return; try { const clone = response.clone(); setTimeout(() => self.read(clone.body, taskId), 0); } catch {} }).catch(() => {}); return promise; }}); page.fetch.toString = () => "function fetch() { [native code] }"; } async read(body, taskId) { if (!body?.getReader) return; const reader = body.getReader(); const decoder = new TextDecoder(); let raw = ""; try { while (true) { const next = await reader.read(); if (next.done) break; if (this.connector.taskId !== taskId) return; raw += decoder.decode(next.value, { stream: true }); this.consume(raw, taskId); } raw += decoder.decode(); this.consume(raw, taskId); if (this.connector.taskId === taskId && this.connector.response && !this.connector.done) this.idle(taskId); } catch (error) { if (error?.name !== "AbortError") this.idle(taskId); } } patchXHR() { const XHR = page.XMLHttpRequest || globalThis.XMLHttpRequest; if (!XHR?.prototype?.open) return; const originalOpen = XHR.prototype.open; const self = this; XHR.prototype.open = function (method, url) { const urlValue = typeof url === "string" ? url : url?.href || ""; if (self.connector.active && self.matches(urlValue)) { const taskId = self.connector.taskId; this.addEventListener("readystatechange", () => { if (self.connector.taskId !== taskId || ![3, 4].includes(this.readyState)) return; try { self.consume(this.responseText || "", taskId); } catch {} if (this.readyState === 4 && self.connector.response && !self.connector.done) self.idle(taskId); }); } return originalOpen.apply(this, arguments); }; XHR.prototype.open.toString = () => "function open() { [native code] }"; } } /* ------------------------------------------------------------------------ * * 6. Connector state machine * ------------------------------------------------------------------------ */ class Connector { constructor(site) { this.site = site; this.secret = Math.random().toString(36).slice(2); this.running = false; this.connected = false; this.standby = ""; this.connectionNoticeShown = false; this.active = false; this.taskId = null; this.response = ""; this.done = false; this.pending = false; this.sending = false; this.poll = null; this.pollTimer = null; this.reconnectTimer = null; this.handshake = null; this.port = null; this.reconnectAttempts = 0; this.fileTransfers = new Map(); this.uploader = new Uploader(this.fileTransfers); this.domTimer = null; this.menuIds = []; this.proxy = site.output.type === "network" ? new NetworkCapture(this) : null; } pageIconUrl() { const link = document.querySelector('link[rel~="icon"], link[rel="apple-touch-icon"]'); try { const fallback = new URL("/favicon.ico", location.href).href; const url = new URL(link?.href || fallback, location.href); return ["http:", "https:"].includes(url.protocol) ? url.href : fallback; } catch { return ""; } } icon() { return this.pageIconUrl(); } async connect(silent = false, manual = false) { if (!this.running) return; if (manual) { this.handshake?.abort(); this.handshake = null; this.standby = ""; } if (this.handshake) return this.handshake; const takeoverId = manual ? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` : undefined; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.stopPolling(); const probing = Boolean(this.standby); if (!silent) this.reconnectAttempts = 0; let operation; try { if (this.port === null) { operation = discoverConnectorPort(); this.handshake = operation; const port = await operation.promise; if (this.handshake !== operation || !this.running || !ownsLock()) return; this.port = port; } operation = request(probing ? { action: "probe", client: "userscript", sessionSecret: this.secret } : { action: "connect", client: "userscript", ai: this.site.name, icon: this.pageIconUrl(), url: location.href, sessionSecret: this.secret, version: GM_info.script.version, capabilities: ["chunked-files-v1"], ...(takeoverId ? { takeoverId } : {}) }, 5_000, this.port); this.handshake = operation; const result = await operation.promise; if (this.handshake !== operation || !this.running || !ownsLock()) return; if (this.enterStandby(result)) return; if (result.error) throw new Error(result.error); if (probing) { if (!["available", "ok"].includes(result.status)) throw new Error("Invalid connector probe response"); this.standby = ""; this.scheduleReconnect(0); return; } if (result.status === "connected") { this.reconnectAttempts = 0; this.connected = true; this.active = true; const notice = !this.connectionNoticeShown && this.site.connectionNotice; if (!silent || notice) { Notice.showConnection(notice || "", 3_000); } if (notice) this.connectionNoticeShown = true; log.info(`连接成功:${this.site.name}`); this.pollOnce(); if (this.pending) this.flush(); } else throw new Error("Invalid connector handshake"); } catch (error) { if (error?.name === "AbortError" || this.handshake !== operation || !this.running || !ownsLock()) return; this.connected = false; // Never move an in-flight answer to another Zotero instance. if (!this.taskId) this.port = null; if (!silent) Notice.show(error?.code === "CONNECTOR_NOT_FOUND" ? "Zotero:未找到联动服务(23100–23199),请确认 Zotero 已启动并启用联动" : "Zotero:联动连接失败,将自动重试", "error"); this.scheduleReconnect(this.standby ? 15_000 : Math.min(30_000, 1_000 * 2 ** Math.min(this.reconnectAttempts++, 5))); log.warn("连接失败", error?.message); } finally { if (this.handshake === operation) this.handshake = null; } } async disconnect() { const owned = ownsLock(); const port = this.port; this.port = null; this.running = false; this.connected = false; this.standby = ""; this.connectionNoticeShown = false; this.active = false; this.sending = false; this.stopPolling(); this.stopDomWatch(); this.resetResponse(); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; if (this.handshake) this.handshake.abort(); this.handshake = null; this.fileTransfers.clear(); releaseLock(); if (!owned || port === null) return; try { await request({ action: "disconnect", sessionSecret: this.secret }, 2_000, port).promise; } catch {} } enterStandby(result) { if (!["CONNECTOR_MODE_MISMATCH", "CONNECTOR_BUSY"].includes(result?.error)) return false; const changed = this.standby !== result.error; this.standby = result.error; this.connected = false; this.active = false; this.sending = false; this.stopPolling(); this.stopDomWatch(); this.resetResponse(); this.fileTransfers.clear(); if (changed) Notice.show(this.standby === "CONNECTOR_BUSY" ? "另一端正在完成当前任务,完成后将自动连接" : "Zotero 当前使用浏览器扩展,脚本已待机;切换联动模式后将自动连接"); this.scheduleReconnect(15_000); return true; } scheduleReconnect(delay = 1_000) { if (!this.running || this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(true); }, delay); } stopPolling() { if (this.poll) this.poll.abort(); this.poll = null; if (this.pollTimer) clearTimeout(this.pollTimer); this.pollTimer = null; } schedulePolling(delay = 3_000) { if (this.pollTimer) clearTimeout(this.pollTimer); this.pollTimer = setTimeout(() => { this.pollTimer = null; this.pollOnce(); }, delay); } async pollOnce() { if (this.taskId || this.sending || this.poll || !this.running || !this.connected || !ownsLock()) return; const operation = request({ action: "poll", sessionSecret: this.secret }, POLL_TIMEOUT, this.port); this.poll = operation; try { const result = await operation.promise; if (this.poll !== operation || !this.running || !ownsLock()) return; this.poll = null; if (result.error === "SESSION_EXPIRED") { this.connected = false; this.scheduleReconnect(); return; } if (this.enterStandby(result)) return; if (result.fileChunk) { this.receiveChunk(result.fileChunk); this.pollOnce(); return; } if (result.task) this.execute(result.task); else this.pollOnce(); } catch (error) { if (this.poll !== operation || !this.running || !ownsLock()) return; this.poll = null; if (error?.name === "AbortError") return; if (endpointFailure(error)) { this.connected = false; if (!this.taskId) this.port = null; this.scheduleReconnect(); } else if (this.running) this.schedulePolling(1_000); } } receiveChunk(chunk) { const transfer = this.fileTransfers.get(chunk.transferId) || { parts: [], done: false }; transfer.parts.push(decodeBase64(chunk.data)); transfer.done = Boolean(chunk.done); this.fileTransfers.set(chunk.transferId, transfer); } resetResponse() { this.proxy?.clearIdle(this.taskId); this.taskId = null; this.response = ""; this.done = false; this.pending = false; } async execute(task) { if (!task?.id || this.taskId || !this.running) return; this.stopPolling(); this.taskId = task.id; this.response = ""; this.done = false; this.pending = false; this.sending = true; log.info(`执行任务:${task.id}`); try { const fileMessages = (task.messages || []).filter(message => message.type === "file"); if (fileMessages.length) { await this.uploader.upload(fileMessages, this.site.input.file); fileMessages.forEach(message => { if (message.transferId) this.fileTransfers.delete(message.transferId); }); await sleep(this.site.input.file?.timeout || 500); } const prompt = (task.messages || []).filter(message => message.type !== "file").map(message => message.text || "").join("\n\n"); if (!prompt) { this.sending = false; this.push("", true); return; } const isActive = () => this.running && this.taskId === task.id; if (!isActive()) return; if (!await fillInput(this.site.input.text, prompt, isActive)) throw new Error("输入框未接受文本"); if (!isActive()) return; this.sending = false; await this.send(); } catch (error) { if (!this.running || this.taskId !== task.id) return; this.sending = false; log.error("任务执行失败", error); this.fileTransfers.clear(); this.resetResponse(); this.schedulePolling(); } } async send() { const taskId = this.taskId; const isActive = () => this.running !== false && this.taskId === taskId; const chatgpt = this.site.input.text?.method === "chatgpt"; if (this.site.output.type === "dom") this.site.output.parser?.prime?.(); const sendSelector = this.site.input.send; const selector = chatgpt ? () => isActive() && selectChatGPTSend(sendSelector, this.site.input.text.selector) : sendSelector; const button = typeof selector === "string" ? await find(selector, SEND_TIMEOUT, true) : typeof selector === "function" ? await find(selector, SEND_TIMEOUT, true, isActive) : null; if (!isActive()) return; if (!button) { log.warn("发送按钮未出现,保持监听,用户可手动发送"); if (this.site.output.type === "dom") this.startDomWatch(); this.schedulePolling(); return; } const submission = chatgpt ? chatGPTSubmissionState(this.site.input.text.selector) : null; button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); button.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); if (typeof button.click === "function") button.click(); else button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); log.ui("已触发发送"); if (this.site.output.type === "dom") this.startDomWatch(); if (submission) { // A synthetic click can be ignored. Do not retry blindly and duplicate // a prompt; preserve manual-send monitoring if submission is unconfirmed. const confirmed = await find(() => { if (!isActive()) return null; const current = chatGPTSubmissionState(this.site.input.text.selector); const input = selectChatGPTInput(this.site.input.text.selector); return (submission.draft && input && !current.draft) || (!submission.streaming && current.streaming) || [...current.turns].some(id => !submission.turns.has(id)); }, 3_000, false, isActive); if (!isActive()) return; if (!confirmed) log.warn("ChatGPT 未确认提交,请在当前网页检查或手动发送;继续监听本轮回答"); } this.schedulePolling(); } push(value, isDone = false) { if (!this.running || !this.taskId) return; const next = text(value); const changed = next !== this.response || (isDone && !this.done); if (!changed) return; this.response = next; this.done ||= Boolean(isDone); this.pending = true; this.flush(); } async flush() { if (this.sending || !this.pending || !this.taskId) return; this.pending = false; const taskId = this.taskId; const answer = this.response; const isDone = this.done; this.sending = true; try { const result = await request({ action: "update", id: taskId, text: answer, isDone, sessionSecret: this.secret }, 8_000, this.port).promise; if (!this.running || this.taskId !== taskId) return; if (result.error === "SESSION_EXPIRED") { this.connected = false; this.pending = true; this.sending = false; this.scheduleReconnect(); return; } log.info(`发送更新:${answer.length} 字符${isDone ? "(结束)" : ""}`); if (isDone) { this.stopDomWatch(); this.resetResponse(); this.sending = false; this.pollOnce(); } else { this.sending = false; if (this.pending) this.flush(); else this.schedulePolling(); } } catch (error) { if (!this.running || this.taskId !== taskId) return; this.sending = false; this.pending = true; if (endpointFailure(error)) this.scheduleReconnect(); else setTimeout(() => this.flush(), 500); } } startDomWatch() { this.stopDomWatch(); let previous = ""; let stable = 0; this.domTimer = setInterval(() => { if (!this.running || !this.taskId) return this.stopDomWatch(); let result; try { result = this.site.output.parser(); } catch { result = null; } if (!result || typeof result.text !== "string") return; if (result.text === previous) stable += 1; else stable = 0; previous = result.text; if (result.text || result.done) this.push(result.text, Boolean(result.done || (!result.completionKnown && stable >= 8))); }, 200); } stopDomWatch() { if (this.domTimer) clearInterval(this.domTimer); this.domTimer = null; } mount() { this.initMenu(); if (typeof GM_addValueChangeListener === "function") GM_addValueChangeListener(LOCK_KEY, (_key, _old, value, remote) => { if (remote && json(value)?.tabId !== TAB_ID && json(value)?.isLocked) this.disconnect(); }); if (GM_getValue(AUTO_CONNECT_KEY, true) !== false && acquireLock(false)) { this.running = true; this.connect(); } } initMenu() { if (typeof GM_unregisterMenuCommand === "function") { this.menuIds.splice(0).forEach(id => GM_unregisterMenuCommand(id)); } const register = (label, callback) => this.menuIds.push(GM_registerMenuCommand(label, callback)); register("🔗 连接", () => { acquireLock(true); this.running = true; this.connect(false, true); }); register("🎊 断开", async () => { await this.disconnect(); Notice.show("已停止联动", "success", 2_000); }); const autoConnect = GM_getValue(AUTO_CONNECT_KEY, true) !== false; register(autoConnect ? "⚙️ 关闭刷新后自动连接" : "⚙️ 启用刷新后自动连接", () => { GM_setValue(AUTO_CONNECT_KEY, !autoConnect); this.initMenu(); Notice.show(autoConnect ? "已关闭刷新后自动连接" : "已启用刷新后自动连接", "success", 2_000); }); register("✨ 更新", () => checkUpdate(true)); const showNotify = GM_getValue("showNotify", true) !== false; register(showNotify ? "⚙️ 关闭弹窗" : "⚙️ 开启弹窗", () => { GM_setValue("showNotify", !showNotify); this.initMenu(); if (!showNotify) Notice.show("已开启弹窗", "success", 2_000); }); register(`⚙️ 关于 ${GM_info.script.version}`, () => { const state = this.connected ? "联动中" : this.standby ? "待机(等待 Zotero 联动模式)" : "未联动"; const platform = GM_info?.userAgentData?.platform || navigator.platform || ""; Notice.show([GM_info.script.version, this.site.name, state, platform].filter(Boolean).join(" "), "success"); }); } } /* ------------------------------------------------------------------------ * * 7. Small notification surface and update helper * ------------------------------------------------------------------------ */ // BEGIN SHARED CONNECTION NOTICE (keep identical in both connectors) let activeSiteConnectionNotice = null; function showSiteConnectionNotice(message, duration = 3_000) { activeSiteConnectionNotice?.dismiss(); const messageText = String(message ?? "").trim(); const host = document.createElement("div"); host.id = "zotero-gpt-connection-notice"; const scheme = getComputedStyle(document.documentElement).colorScheme; if (scheme && scheme !== "normal") host.style.colorScheme = scheme; const shadow = host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); // This is browser-page UI: Zotero Reader variables are unavailable here. // System colors follow the page's color scheme; the red artwork is branding. // There is no system success color, so use the extension popup's green pair. style.textContent = ` :host { all: initial; position: fixed; top: 32px; left: 50%; transform: translateX(-50%); z-index: 2147483647; width: 390px; max-width: calc(100vw - 24px); color-scheme: light dark; pointer-events: none; } * { box-sizing: border-box; } .notice { display: grid; grid-template-columns: 28px minmax(0, 1fr) 22px; align-items: start; column-gap: 12px; padding: 15px 12px 15px 16px; border: 1px solid color-mix(in srgb, CanvasText 10%, Canvas); border-radius: 14px; color: CanvasText; background: Canvas; box-shadow: 0 5px 20px color-mix(in srgb, CanvasText 8%, transparent), 0 1px 3px color-mix(in srgb, CanvasText 8%, transparent); font: 13px/21px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; pointer-events: auto; } .icon { width: 28px; height: 28px; align-self: center; object-fit: contain; } .copy { min-width: 0; } .heading { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 12px; min-height: 20px; margin-bottom: 5px; } .brand { font-size: 13px; line-height: 20px; font-weight: 500; } .status { display: inline-flex; align-items: center; gap: 5px; color: color-mix(in srgb, CanvasText 55%, Canvas); font-size: 11px; line-height: 20px; white-space: nowrap; } .status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: #2f9e6e; background: light-dark(#2f9e6e, #4fc08d); } .message { margin: 0; overflow-wrap: anywhere; white-space: pre-line; } .message:empty { display: none; } .notice.compact { align-items: center; padding-top: 10px; padding-bottom: 10px; } .compact .heading { margin-bottom: 0; } button { appearance: none; display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 6px; color: color-mix(in srgb, CanvasText 55%, Canvas); background: transparent; cursor: pointer; } button:hover { color: CanvasText; background: color-mix(in srgb, CanvasText 6%, Canvas); } button svg { width: 14px; height: 14px; } @media (max-width: 420px) { .notice { column-gap: 10px; padding-left: 12px; } .heading { column-gap: 8px; } } @media (pointer: coarse) { button { width: 44px; height: 44px; margin: -8px -11px; } } `; const notice = document.createElement("div"); notice.className = messageText ? "notice" : "notice compact"; const icon = document.createElement("img"); icon.className = "icon"; icon.alt = "Connector"; // Embedded from browser-extension/icons/icon-red-128.png; no network request. icon.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAGtUlEQVR42u2d363rLAzAO0JG6Aioz33ICBkhI2QENsgIGYGHMwAjZARGYIReVXL0oX6kSYr5YzASutI5t6fAz9jGgLnduHDhwoULFy6tlL/Hs/t7PAWPBMPn0iD8gUeiXfgTj0S78GXLAyD+Hs8e6vQejI86bb+vFP7y/rd2yHeAKKHD+u/xtH+P5+uHauDz7781vP82Yfi6SvgA/D1zVQDoK9XCd0kKmsKBL2pTZxPM0FcBVUF77gWO01qNxw+qfSkE+l5dSxAGB/5UA/gROvMiVlWO2efAX2oAbwiC9/kNcwo77MBfyTp94HHXAD6piXDgW5IrFvDodaXgvwmDQIT/Iun0wUDYhuD7zMRPMYcP+DPVIMWL665QzOAP9QfwV4rLOsugT9flC/wXqWAPqHyGer6OB/AlJZW/MNBLZuAIviWx5PM0nOvxZpQ4MYYzBfiC7f3lKGJ3cgIJhn9ctbPMkptX7anbOQGVSVtZX/z+C3zL8P0BlhlrDx/6MIHvYiPP+vtF06kY/n/2MskuHPRrRgxX672zBSf8Jtm6w6dzHswAYZA/BrPUt7afHMO+VAFYE4AXBfa7B/9i8yO0U5UT6u2QJlBfIvwlsn2v7tDmr9qz1D38ZN5xy/CLE4CITp+meiI3tt/UgtPXxEWGgPHrS+nAHEHl9wz/sE4ldKCP4OgJhn9uKVlCJzQy/I7hX6r3nJ0YGX52n0nm7IhBtPkMn9LYIc5+yzafoBZAtP1jI/BFxPsOabUAouc/NwQ/9s6oTNkhxU5fUfDTrQjgBg9GYwXDj3t0vOSo39wA/GEHvv68vgX/V5HQAggSXf2Sb2eFdLiVDZ+zxWoBkFT2+q/Bt1ccNAQHO94EQzjsYRqD/9NWNpwcKm9FgKCexorhT5gQAmMGJkYHB7b9pzSjQbrzHzreQ8xO0ty6jA8fNRljoBZYsDtqWP3vwrcx+he45LaYDekQvP+uIvDuLedoB1ggkBQy5j1WQ0KXJmtl8NcYKj+C5p2xGiE58ve/TFxjou+cs088BAdwqAh+0jOLCNq3w2iEbtn+O/BVjr5kn3yBASBDHL7IfTMpcALOuSVQE4dvct9RCPQDNIb6a+52D8Av4qGFQCfchn5531oAaEsCUVB7hmw+GIIA9MTgj6WtWrIyaEkAYKaVmniCBSCB2u8KbVufzQ9rzQSwAOCHgVkAiJuAKgWA0gPK7APgD+id0v4EC0ADnn7JAtDVshOY6vWuSLGJfJMwcDNIFjCApJ9QRfDDutAGaKqHQUB9Gsp3EUO347FUJ7ndQDirv1ZwHsFkPRwauhmRSeWrSuB32bfjERrRJxwwQf75VMTJh3Y/IDC3zZxosLbbtdUkn0C4ji9LaIhJoCaXVEe1EwvAWsvdgJiXJ9ZkGTJo2X/cA7mB8YA5wgC5CRWqgo8UADLYDVpKaIznAcrq4N9wknGhXxDNfmXZk29vqhR+h5CPYYjRsGyJCzwh0ZoTToSqfxurYcnTl8D2rW4s15ApSv0jq6b+osDZxhNNlbULi3BZ9PB2LSw7V8/nhsrhY0wwG7uRWNlC1U7SRN1qVnEyiTgjvw/YKnyBNF6kcgYzfNy3BFTKRsvI8E1Dj0lgvbzWp5baWNmwW3pDCOv1FZ2j8ZLhB9t9rEk05ugAthZo7fUwrLEzOTsiGX42py9/cAxpRcDwKafhCdy6bM3mYz8d15fQsYnhH45RH2HVNJfUOYbf8mqJ4e/6R7qJCCnD95rFWIGyscQOM/z9rWxa7wNG8gEO4cMSaQCbqUB9blXBz8dCE0+ISOq+/NT7JwTgK3z4/C9LSQ1CITL3XSfYGS1Xex54ubsNRx48kyoJBDh3U8QXwWlti3+ZvV74MIAq8oAtAEkgAR9AwNaEh2FonInY8Xb34E+JH1V227P5ERNon886wu+l43u8MlYS8MUZ+M69/RfXik5DeU6z+OCLhPayhkpjuezZ0/bBHzOpfKqVzhX3D+9/Dz5DPV/p3HX8sP0++AsDvWTvaeVVdpZDe97+5lXP4E2zGdgPZnXU4M+/OCvOelqyUOR9jSwE/oDpqYIpmTIEWHLP+jtF+PeYmbicEGutwmDIXnD9eD61S/B9AkyNrQQ87Wvt4NWvmZ5PHYhGEdcq8hmUkneXkIlYqnkuB2ZfcaFJRxhUQWp+qurEU0lPqJ48mLGdJLKJlnEKoN9vtRXnoYWOaPs/Yw4mALQGtS5B0OoD7oG/1HiA09n7n5y9f+k5J9BEboJvmzzdjUuT8CeG3y78geG3C18wfIbPhQsXLly4cGmj/ANBJHMpfhlg+QAAAABJRU5ErkJggg=="; const label = document.createElement("div"); label.className = "copy"; label.setAttribute("role", "status"); label.setAttribute("aria-live", "polite"); const heading = document.createElement("div"); heading.className = "heading"; const brand = document.createElement("span"); brand.className = "brand"; brand.textContent = "Connector"; const status = document.createElement("span"); status.className = "status"; status.textContent = "联动成功"; heading.append(brand, status); label.append(heading); if (messageText) { const text = document.createElement("p"); text.className = "message"; text.textContent = messageText; label.append(text); } const close = document.createElement("button"); close.type = "button"; close.setAttribute("aria-label", "关闭连接提示"); const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); closeIcon.setAttribute("viewBox", "0 0 24 24"); closeIcon.setAttribute("aria-hidden", "true"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); // Lucide X. path.setAttribute("d", "M18 6 6 18M6 6l12 12"); path.setAttribute("fill", "none"); path.setAttribute("stroke", "currentColor"); path.setAttribute("stroke-width", "1.6"); path.setAttribute("stroke-linecap", "round"); closeIcon.appendChild(path); close.appendChild(closeIcon); notice.append(icon, label, close); shadow.append(style, notice); (document.body || document.documentElement).appendChild(host); const offscreen = "translateY(calc(-100% - 48px))"; const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; let animation = null; let closeTimer = null; let removeTimer = null; let closing = false; const controller = { dismiss }; activeSiteConnectionNotice = controller; if (!reducedMotion() && typeof notice.animate === "function") { animation = notice.animate([ { transform: offscreen, opacity: 0 }, { transform: "translateY(0)", opacity: 1 }, ], { duration: 300, easing: "cubic-bezier(.22, 1, .36, 1)" }); } closeTimer = setTimeout(dismiss, duration); close.addEventListener("click", dismiss); function remove() { clearTimeout(closeTimer); clearTimeout(removeTimer); animation?.cancel(); host.remove(); if (activeSiteConnectionNotice === controller) activeSiteConnectionNotice = null; } function dismiss() { if (closing) return; closing = true; clearTimeout(closeTimer); host.removeAttribute("id"); close.disabled = true; notice.style.pointerEvents = "none"; if (reducedMotion() || typeof notice.animate !== "function") { remove(); return; } // Start at the current position even if dismissed during the entrance. const current = getComputedStyle(notice); const from = { transform: current.transform, opacity: current.opacity }; animation?.cancel(); animation = notice.animate([from, { transform: offscreen, opacity: 0 }], { duration: 220, easing: "cubic-bezier(.4, 0, 1, 1)", fill: "forwards", }); animation.onfinish = remove; // Background tabs may delay animation events; never leave a stale overlay. removeTimer = setTimeout(remove, 320); } } // END SHARED CONNECTION NOTICE const Notice = { closeTimer: null, removeTimer: null, showConnection(message, duration) { if (GM_getValue("showNotify", true) === false) return; clearTimeout(this.closeTimer); clearTimeout(this.removeTimer); document.querySelector("#zotero-gpt-notice-layer")?.remove(); showSiteConnectionNotice(message, duration); }, ensureStyle() { if (document.querySelector("#zotero-gpt-notice-style")) return; const style = document.createElement("style"); style.id = "zotero-gpt-notice-style"; style.textContent = ` #zotero-gpt-notice-layer { position: fixed; top: 20px; left: 50%; z-index: 2147483647; max-width: calc(100vw - 32px); transform: translateX(-50%); pointer-events: none; } #zotero-gpt-notice { display: flex; box-sizing: border-box; align-items: center; justify-content: center; max-width: 100%; min-height: 42px; padding: 8px 18px; overflow: hidden; color: #374151; background: #fff; border: 1px solid rgba(0, 0, 0, 0.06); border-radius: 999px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; animation: zotero-gpt-notice-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.15) forwards; pointer-events: auto; } #zotero-gpt-notice.zotero-gpt-notice-exit { animation: zotero-gpt-notice-out 0.4s cubic-bezier(0.6, -0.28, 0.735, 0.045) forwards; } .zotero-gpt-notice-content { display: flex; align-items: center; min-width: 0; gap: 8px; animation: zotero-gpt-notice-content-in 0.3s ease forwards; } .zotero-gpt-notice-content svg { flex: 0 0 auto; } .zotero-gpt-notice-text { min-width: 0; white-space: pre-line; overflow-wrap: anywhere; color: #374151; font-size: 15px; font-weight: 600; line-height: 24px; } .zotero-gpt-notice-spinner { animation: zotero-gpt-notice-spin 1s linear infinite; } @keyframes zotero-gpt-notice-in { from { transform: translateY(-100px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes zotero-gpt-notice-out { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-100px); opacity: 0; } } @keyframes zotero-gpt-notice-content-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes zotero-gpt-notice-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { #zotero-gpt-notice, #zotero-gpt-notice.zotero-gpt-notice-exit, .zotero-gpt-notice-content, .zotero-gpt-notice-spinner { animation-duration: 0.01ms; } } `; (document.head || document.documentElement).appendChild(style); }, icon(type) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const add = (tag, attributes) => { const node = document.createElementNS("http://www.w3.org/2000/svg", tag); Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, value)); svg.appendChild(node); }; Object.entries({ width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", "stroke-width": "2.5", "stroke-linecap": "round", "stroke-linejoin": "round" }) .forEach(([key, value]) => svg.setAttribute(key, value)); if (type === "error" || type === "fail" || type === "cancel") { svg.setAttribute("stroke", "#ef4444"); add("circle", { cx: "12", cy: "12", r: "10" }); add("line", { x1: "15", y1: "9", x2: "9", y2: "15" }); add("line", { x1: "9", y1: "9", x2: "15", y2: "15" }); } else if (type === "waiting") { svg.setAttribute("stroke", "#6b7280"); svg.setAttribute("class", "zotero-gpt-notice-spinner"); add("circle", { cx: "12", cy: "12", r: "9", opacity: "0.25" }); add("path", { d: "M21 12a9 9 0 0 0-9-9" }); } else { svg.setAttribute("stroke", "#10b981"); add("polyline", { points: "20 6 9 17 4 12" }); } svg.setAttribute("aria-hidden", "true"); return svg; }, show(message, type = "success", duration = 3_000) { if (GM_getValue("showNotify", true) === false) return; activeSiteConnectionNotice?.dismiss(); this.ensureStyle(); clearTimeout(this.closeTimer); clearTimeout(this.removeTimer); let layer = document.querySelector("#zotero-gpt-notice-layer"); let box = layer?.querySelector("#zotero-gpt-notice"); if (!layer || !box) { layer = document.createElement("div"); layer.id = "zotero-gpt-notice-layer"; layer.setAttribute("role", "status"); layer.setAttribute("aria-live", type === "error" || type === "fail" ? "assertive" : "polite"); box = document.createElement("div"); box.id = "zotero-gpt-notice"; layer.appendChild(box); (document.body || document.documentElement).appendChild(layer); } else { box.classList.remove("zotero-gpt-notice-exit"); layer.setAttribute("aria-live", type === "error" || type === "fail" ? "assertive" : "polite"); } const content = document.createElement("div"); content.className = "zotero-gpt-notice-content"; const label = document.createElement("span"); label.className = "zotero-gpt-notice-text"; label.textContent = message; content.append(this.icon(type), label); box.replaceChildren(content); this.closeTimer = setTimeout(() => { box.classList.add("zotero-gpt-notice-exit"); this.removeTimer = setTimeout(() => { if (layer.isConnected) layer.remove(); }, 400); }, duration); }, }; function isNewerVersion(current, latest) { const currentParts = String(current ?? "").split(/[.-]/).map(value => Number(value) || 0); const latestParts = String(latest ?? "").split(/[.-]/).map(value => Number(value) || 0); for (let index = 0; index < Math.max(currentParts.length, latestParts.length); index += 1) { if ((latestParts[index] || 0) > (currentParts[index] || 0)) return true; if ((latestParts[index] || 0) < (currentParts[index] || 0)) return false; } return false; } function checkUpdate(force = false) { const script = typeof GM_info === "object" && GM_info?.script ? GM_info.script : {}; const updateURL = script.updateURL || script.downloadURL; if (!updateURL) { if (force) Notice.show("未配置更新地址", "error"); return; } const now = Date.now(); if (!force && now - Number(GM_getValue(UPDATE_CHECK_KEY, 0)) < UPDATE_CHECK_INTERVAL) return; if (force) Notice.show("检查更新中...", "waiting", 10_000); GM_xmlhttpRequest({ method: "GET", url: updateURL, onload: response => { if (response.status < 200 || response.status >= 300) { if (force) Notice.show("检查更新失败", "error"); return; } GM_setValue(UPDATE_CHECK_KEY, now); const latest = response.responseText?.match(/@version\s+([\w.-]+)/)?.[1]; if (!latest) { if (force) Notice.show("无法识别最新版本", "error"); return; } if (!isNewerVersion(script.version, latest)) { if (force) Notice.show("已经是最新版", "success", 2_500); return; } if (force) Notice.show(`发现新版本 v${latest},即将打开更新页面...`, "waiting", 2_500); if (typeof GM_openInTab === "function") { setTimeout(() => GM_openInTab(script.downloadURL || updateURL, { active: true }), force ? 2_500 : 0); } }, onerror: () => { if (force) Notice.show("检查更新失败", "error"); } }); } /* ------------------------------------------------------------------------ * * 8. Bootstrap * ------------------------------------------------------------------------ */ const connector = new Connector(resolveSite()); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => connector.mount(), { once: true }); else connector.mount(); window.addEventListener("beforeunload", () => connector.disconnect()); setTimeout(checkUpdate, 2_000); })();