([\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 answer = assistant.flatMap(message => message.content.parts || []).filter(value => typeof value === "string").join("\n");
const final = assistant.filter(message => message.channel === "final").at(-1);
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") {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
selection?.removeAllRanges();
selection?.addRange(range);
// Chromium's insertText handles newlines individually. Insert one escaped
// fragment instead so large prompts do not repeatedly relayout the editor.
if (value.length > 1000 || /[\r\n]/.test(value)) {
const html = value.replace(/\r\n?/g, "\n").split("\n")
.map(line => `${line.replace(/&/g, "&").replace(//g, ">") || "
"}
`).join("");
if (!document.execCommand("insertHTML", false, html)) return false;
} else document.execCommand("insertText", false, value);
selection?.removeAllRanges();
if (element.textContent !== value && "value" in element) nativeValue(element, value);
} 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);
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);
})();