// ==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.8 // @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 ENDPOINT = "http://127.0.0.1:23119/zoterogpt"; 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) { let answer = ""; let done = false; for (const payload of dataPayloads(raw)) { if (payload === "[DONE]") { done = true; continue; } const data = json(payload); if (!data) continue; const message = data.message; if ((!message?.author?.role || message.author.role === "assistant") && message?.content?.content_type === "text") { const parts = Array.isArray(message.content.parts) ? message.content.parts : []; answer = parts.filter(part => typeof part === "string").join("\n"); done ||= message.status === "finished_successfully"; } for (const patch of Array.isArray(data.v) ? data.v : [data]) { if (patch?.p === "/message/content/parts/0" && typeof patch.v === "string") answer = mergeStream(answer, patch.v); if (patch?.path === "/message/content/parts/0" && typeof patch.value === "string") answer = mergeStream(answer, patch.value); } } return { text: cleanChatGPTText(answer), done }; } 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"; for (const payload of dataPayloads(raw)) { const data = json(payload); if (!data) continue; const block = data.v?.response?.fragments?.[0] || (Array.isArray(data.v) ? data.v[0] : typeof data.v === "string" ? { content: data.v } : {}); if (block.type) kind = block.type; if (!block.content) kind = "system"; if (kind === "RESPONSE") answer = mergeStream(answer, block.content || ""); if (kind === "THINK") think = mergeStream(think, block.content || ""); } 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") => ({ name, hosts, input: controls, output: { type: "network", pattern, parser: PARSERS[parser] || PARSERS.generic }, }); const dom = (name, hosts, controls, selector, parser) => ({ name, hosts, input: controls, output: { type: "dom", selector, parser }, }); const parseChatGPTDom = () => { const sections = [...document.querySelectorAll("#main section")]; const node = sections.at(-1); if (!node) return null; const key = Object.keys(node).find(value => value.startsWith("__reactProps$")); const messages = key ? node[key]?.children?.props?.children?.props?.turn?.messages : null; if (Array.isArray(messages)) { const assistant = messages.filter(message => message?.author?.role === "assistant" && message.content?.content_type === "text"); const latest = assistant.at(-1); const answer = (latest?.content?.parts || []).filter(value => typeof value === "string").join("\n"); const final = latest; return { text: cleanChatGPTText(answer), done: final?.status === "finished_successfully" }; } return { text: cleanChatGPTText(node.innerText || ""), done: false }; }; 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")) }; }; 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), 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"), network("Gemini", ["gemini.google.com"], input("rich-textarea .textarea", "gemini", ".text-input-field", "paste", ".send-button"), /BardFrontendService\/StreamGenerate/, "gemini"), 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('[role="textbox"]', "paste", "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) { 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); }; requestObject = GM_xmlhttpRequest({ method: "POST", url: ENDPOINT, anonymous: true, headers: { "Content-Type": "application/json", Accept: "application/json" }, data: JSON.stringify(payload), timeout, onload: response => { if (response.status >= 200 && response.status < 300) { const value = json(response.responseText); value === null ? finish(rejectPromise, new Error("Invalid connector JSON")) : finish(resolvePromise, value); } else { const error = new Error(response.statusText || `HTTP ${response.status}`); error.status = response.status; finish(rejectPromise, error); } }, onerror: error => finish(rejectPromise, error || new Error("Connector request failed")), ontimeout: () => payload.action === "poll" ? finish(resolvePromise, {}) : finish(rejectPromise, new Error("Connector request timed out")), }); return { promise, abort: () => { try { requestObject?.abort(); } catch {} finish(rejectPromise, new DOMException("Aborted", "AbortError")); }, }; } const endpointFailure = error => 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) => { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const element = [...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 })); }; async function fillInput(config, value) { const element = await find(config?.selector, 5_000); if (!element) return false; element.focus(); try { 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. 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 = document.querySelector(config.selector); return !!current && text(current.textContent).replace(/\s/g, "") === text(value).replace(/\s/g, ""); } 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) this.connector.push(mergeStream(this.connector.response, value.text), 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.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.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) { if (!this.running || this.handshake) return this.handshake; this.stopPolling(); const operation = request({ action: "connect", ai: this.site.name, icon: this.pageIconUrl(), url: location.href, sessionSecret: this.secret, version: GM_info.script.version, capabilities: ["chunked-files-v1"] }, 5_000); this.handshake = operation; try { const result = await operation.promise; if (!this.running || !ownsLock()) return; if (result.status === "connected") { this.connected = true; this.active = true; if (!silent) Notice.show("Zotero:联动成功", "success"); log.info(`连接成功:${this.site.name}`); this.pollOnce(); } } catch (error) { this.connected = false; if (!silent) Notice.show("Zotero:联动失败,请检查插件", "error"); if (this.running) this.scheduleReconnect(); log.warn("连接失败", error); } finally { if (this.handshake === operation) this.handshake = null; } } async disconnect() { const owned = ownsLock(); this.running = false; this.connected = false; this.active = false; this.stopPolling(); this.stopDomWatch(); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; if (this.handshake) this.handshake.abort(); this.handshake = null; this.fileTransfers.clear(); releaseLock(); if (!owned) return; try { await request({ action: "disconnect", sessionSecret: this.secret }, 2_000).promise; } catch {} } scheduleReconnect() { if (!this.running || this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(true); }, 1_000); } 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; this.poll = request({ action: "poll", sessionSecret: this.secret }, POLL_TIMEOUT); try { const result = await this.poll.promise; this.poll = null; if (result.error === "SESSION_EXPIRED") { this.connected = false; this.scheduleReconnect(); return; } if (result.fileChunk) { this.receiveChunk(result.fileChunk); this.pollOnce(); return; } if (result.task) this.execute(result.task); else this.pollOnce(); } catch (error) { this.poll = null; if (error?.name === "AbortError") return; if (endpointFailure(error)) 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; } if (!await fillInput(this.site.input.text, prompt)) throw new Error("输入框未接受文本"); this.sending = false; await this.send(); } catch (error) { this.sending = false; log.error("任务执行失败", error); this.fileTransfers.clear(); this.resetResponse(); this.schedulePolling(); } } async send() { const selector = this.site.input.send; const button = typeof selector === "string" ? await find(selector, SEND_TIMEOUT, true) : null; if (!button) { log.warn("发送按钮未出现,保持监听,用户可手动发送"); if (this.site.output.type === "dom") this.startDomWatch(); this.schedulePolling(); return; } 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(); 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).promise; 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) { 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 || 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(); }); 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 ? "联动中" : "未联动"; 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 * ------------------------------------------------------------------------ */ const Notice = { closeTimer: null, removeTimer: null, 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; 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; 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); })();