// ==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.5
// @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, `\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 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") {
document.execCommand("selectAll", false);
document.execCommand("insertText", false, value);
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);
})();