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,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"; 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); })();