Greasy Fork

来自缓存

Greasy Fork is available in English.

AI4Paper Connector

AI4Paper 浏览器联动脚本,支持 DeepSeek / 豆包 / ChatGPT / Claude / 通义 / Kimi 等

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         AI4Paper Connector
// @description  AI4Paper 浏览器联动脚本,支持 DeepSeek / 豆包 / ChatGPT / Claude / 通义 / Kimi 等
// @namespace    https://ai4paper.pro
// @version      1.2.13
// @author       AI4Paper
// @license      MIT
// @homepageURL  https://ai4paper.pro
// @supportURL   https://github.com/wdcpclover/ai4paper/issues
// @match        https://chat.deepseek.com/*
// @match        https://www.doubao.com/chat/*
// @match        https://chatgpt.com/*
// @match        https://claude.ai/*
// @match        https://kimi.moonshot.cn/*
// @match        https://www.kimi.com/*
// @match        https://yuanbao.tencent.com/chat/*
// @match        https://qianwen.aliyun.com/*
// @match        https://chat.qwenlm.ai/*
// @match        https://chat.qwen.ai/*
// @match        https://www.qianwen.com/*
// @match        https://gemini.google.com/*
// @match        https://www.perplexity.ai/*
// @match        https://grok.com/*
// @include      /.+deepseek.+/
// @include      /.+doubao.+/
// @include      /.+claude.+/
// @include      /.+kimi.+/
// @include      /.+qwen.+/
// @include      /.+qianwen.+/
// @connect      ai4paper.pro
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(async function () {
    'use strict';

    const SCRIPT_VERSION = "1.2.13";
    const SERVER_BASE = "https://ai4paper.pro/api/browser-task";
    const LOGIN_URL = "https://ai4paper.pro/api/user/login";
    const TOKEN_STORE = "ai4paper.userToken";
    const RUNNING_KEY = "ai4paper.connector.running";
    const SESSION_STORE = "ai4paper.connector.sessionId";
    const POLL_INTERVAL = 1200;
    const FINAL_PUSH_RETRY_DELAY = 10000;
    const FINAL_PUSH_RETRY_ATTEMPTS = 3;
    const PREFERRED_CONTEXT_WAIT_MS = 15000;
    const MIN_TASK_TIMEOUT_MS = 15000;
    const MAX_TASK_TIMEOUT_MS = 5 * 60 * 1000;
    const DEBUG = true;

    const AI = detectPlatform();
    let isRunning = GM_getValue(RUNNING_KEY, true) !== false;
    let userToken = GM_getValue(TOKEN_STORE, "");
    let activeTask = null;

    // ── 页面状态条 ─────────────────────────────────────────────────────────
    let _statusEl = null;
    function showStatus(msg, color) {
        if (!_statusEl) {
            _statusEl = document.createElement("div");
            _statusEl.style.cssText = "position:fixed;bottom:12px;right:12px;z-index:2147483647;padding:6px 12px;border-radius:6px;font-size:12px;font-family:monospace;max-width:320px;word-break:break-all;box-shadow:0 2px 8px rgba(0,0,0,.2);";
            document.documentElement.appendChild(_statusEl);
        }
        _statusEl.style.background = color || "#1e293b";
        _statusEl.style.color = "#fff";
        _statusEl.textContent = "🔗 AI4Paper: " + msg;
        clearTimeout(_statusEl._hide);
        _statusEl._hide = setTimeout(() => { if (_statusEl) _statusEl.style.opacity = "0.3"; }, 5000);
        _statusEl.style.opacity = "1";
    }

    // ── 后台保活 ──────────────────────────────────────────────────────────
    function initBackgroundKeepAlive() {
        try {
            const blob = new Blob([`setInterval(()=>postMessage(1),400);`], { type: "text/javascript" });
            const w = new Worker(URL.createObjectURL(blob));
            w.onmessage = () => {};
        } catch (_e) { /**/ }
        try {
            Object.defineProperty(document, "hidden", { get: () => false, configurable: true });
            Object.defineProperty(document, "visibilityState", { get: () => "visible", configurable: true });
            document.addEventListener("visibilitychange", e => e.stopImmediatePropagation(), true);
        } catch (_e) { /**/ }
    }
    initBackgroundKeepAlive();

    function dbg(...args) {
        if (DEBUG) console.log("[AI4Paper]", ...args);
    }

    function makeId(prefix) {
        try {
            if (window.crypto?.randomUUID) return prefix + "_" + window.crypto.randomUUID();
        } catch (_e) { /**/ }
        return prefix + "_" + Math.random().toString(36).slice(2) + Date.now().toString(36);
    }

    function detectPlatform() {
        const host = location.host;
        if (host === "chat.deepseek.com") return "DeepSeek";
        if (host === "www.doubao.com") return "Doubao";
        if (host === "chatgpt.com") return "ChatGPT";
        if (host.includes("claude")) return "Claude";
        if (host === "kimi.moonshot.cn" || host === "www.kimi.com") return "Kimi";
        if (host === "yuanbao.tencent.com") return "Yuanbao";
        if (host.includes("qwen") || host === "qianwen.aliyun.com" || host === "www.qianwen.com") return "Qwen";
        if (host === "gemini.google.com") return "Gemini";
        if (host === "www.perplexity.ai") return "Perplexity";
        if (host.includes("grok")) return "Grok";
        return "Unknown";
    }

    function getWorkerSessionId() {
        try {
            const cached = sessionStorage.getItem(SESSION_STORE);
            if (cached) return cached;
            const nextId = makeId("ws");
            sessionStorage.setItem(SESSION_STORE, nextId);
            return nextId;
        } catch (_e) {
            if (!window.__ai4paperWorkerSessionId) {
                window.__ai4paperWorkerSessionId = makeId("ws");
            }
            return window.__ai4paperWorkerSessionId;
        }
    }

    function getCurrentThreadId() {
        try {
            const path = location.pathname || "/";
            const search = location.search || "";
            const hash = location.hash || "";
            const normalizedPath = path.replace(/\/+$/, "") || "/";
            const query = search.replace(/^\?/, "");

            if (AI === "ChatGPT") {
                const match = normalizedPath.match(/^\/c\/([^/]+)$/);
                return match ? "chatgpt:c:" + match[1] : "";
            }
            if (AI === "Claude") {
                const match = normalizedPath.match(/^\/chat\/([^/]+)$/);
                return match ? "claude:chat:" + match[1] : "";
            }
            if (AI === "Doubao" || AI === "Yuanbao") {
                if (normalizedPath !== "/") return AI.toLowerCase() + ":" + normalizedPath + search;
                return "";
            }
            if (AI === "DeepSeek" || AI === "Kimi" || AI === "Qwen" || AI === "Gemini" || AI === "Perplexity" || AI === "Grok") {
                if (normalizedPath !== "/") return AI.toLowerCase() + ":" + normalizedPath + search;
                if (query.includes("conversation") || query.includes("chat")) return AI.toLowerCase() + ":?" + query;
                if (hash.length > 1) return AI.toLowerCase() + ":" + hash;
                return "";
            }
            if (normalizedPath !== "/") return location.host + ":" + normalizedPath + search;
            return "";
        } catch (_e) {
            return "";
        }
    }

    function getWorkerContext() {
        return {
            sessionId: getWorkerSessionId(),
            threadId: getCurrentThreadId(),
            url: location.href,
            title: document.title || "",
        };
    }

    function describePreferredContext(task) {
        const parts = [];
        if (task?.preferredSessionId) parts.push("会话");
        if (task?.preferredThreadId) parts.push("线程");
        return parts.join(" / ") || "页面上下文";
    }

    function checkPreferredContext(task) {
        const ctx = getWorkerContext();
        if (task?.preferredSessionId && task.preferredSessionId !== ctx.sessionId) {
            return {
                ok: false,
                reason: "当前标签页不是任务绑定的会话",
                current: ctx,
            };
        }
        if (task?.preferredThreadId && task.preferredThreadId !== ctx.threadId) {
            return {
                ok: false,
                reason: "当前页面不是任务绑定的对话线程",
                current: ctx,
            };
        }
        return { ok: true, current: ctx };
    }

    async function waitForPreferredContext(task) {
        const deadline = Date.now() + PREFERRED_CONTEXT_WAIT_MS;
        while (Date.now() < deadline) {
            const result = checkPreferredContext(task);
            if (result.ok) return true;
            showStatus("等待切回已绑定的" + describePreferredContext(task) + "…", "#b45309");
            await sleep(400);
        }
        return false;
    }

    function getTaskTimeoutMs(task) {
        const expiresAt = Number(task?.expiresAt || 0);
        if (!expiresAt) return MAX_TASK_TIMEOUT_MS;
        const remainingMs = Math.floor(expiresAt * 1000 - Date.now() - 5000);
        return Math.max(MIN_TASK_TIMEOUT_MS, Math.min(MAX_TASK_TIMEOUT_MS, remainingMs));
    }

    // ── JWT 解析(不验签,仅读 exp) ───────────────────────────────────────
    function decodeJWTPayload(token) {
        const raw = String(token || "").split(".")[1] || "";
        if (!raw) throw new Error("missing jwt payload");
        const normalized = raw.replace(/-/g, "+").replace(/_/g, "/");
        const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
        return JSON.parse(atob(padded));
    }

    function isTokenExpired(token) {
        try {
            const payload = decodeJWTPayload(token);
            return Date.now() / 1000 > payload.exp - 60; // 提前 60s 视为过期
        } catch (_e) { return true; }
    }

    // ── 登录弹窗 ──────────────────────────────────────────────────────────
    GM_addStyle(`
        #a4p-login-overlay {
            position: fixed; inset: 0; background: rgba(0,0,0,.45);
            z-index: 2147483647; display: flex; align-items: center; justify-content: center;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        }
        #a4p-login-box {
            background: #fff; border-radius: 12px; padding: 32px 28px;
            width: 340px; box-shadow: 0 8px 40px rgba(0,0,0,.18);
            display: flex; flex-direction: column; gap: 14px;
        }
        #a4p-login-box h2 {
            margin: 0; font-size: 18px; font-weight: 700; color: #111;
            display: flex; align-items: center; gap: 8px;
        }
        #a4p-login-box p { margin: 0; font-size: 13px; color: #666; line-height: 1.5; }
        #a4p-login-box input {
            width: 100%; box-sizing: border-box; padding: 9px 12px;
            border: 1px solid #d1d5db; border-radius: 7px; font-size: 14px; outline: none;
        }
        #a4p-login-box input:focus { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,.15); }
        #a4p-login-btn {
            background: #6366f1; color: #fff; border: none; border-radius: 7px;
            padding: 10px; font-size: 14px; font-weight: 600; cursor: pointer;
            transition: background .15s;
        }
        #a4p-login-btn:hover { background: #4f46e5; }
        #a4p-login-btn:disabled { background: #a5b4fc; cursor: not-allowed; }
        #a4p-login-err { color: #ef4444; font-size: 13px; display: none; }
        #a4p-login-close {
            position: absolute; top: 12px; right: 16px; cursor: pointer;
            font-size: 20px; color: #9ca3af; background: none; border: none;
        }
    `);

    function showLoginDialog() {
        return new Promise((resolve) => {
            if (document.getElementById("a4p-login-overlay")) return;

            const overlay = document.createElement("div");
            overlay.id = "a4p-login-overlay";
            overlay.innerHTML = `
                <div id="a4p-login-box" style="position:relative">
                    <button id="a4p-login-close" title="关闭">✕</button>
                    <h2>🔗 AI4Paper 登录</h2>
                    <p>请登录您的 AI4Paper 账号,Connector 将自动帮您把 Zotero 任务发送到 AI 并取回结果。</p>
                    <input id="a4p-email" type="email" placeholder="邮箱" autocomplete="email" />
                    <input id="a4p-pass" type="password" placeholder="密码" autocomplete="current-password" />
                    <div id="a4p-login-err"></div>
                    <button id="a4p-login-btn">登录</button>
                </div>
            `;
            document.documentElement.appendChild(overlay);

            const emailEl = overlay.querySelector("#a4p-email");
            const passEl = overlay.querySelector("#a4p-pass");
            const btnEl = overlay.querySelector("#a4p-login-btn");
            const errEl = overlay.querySelector("#a4p-login-err");
            const closeEl = overlay.querySelector("#a4p-login-close");

            function close(token) {
                overlay.remove();
                resolve(token || null);
            }

            closeEl.addEventListener("click", () => close(null));
            overlay.addEventListener("click", e => { if (e.target === overlay) close(null); });

            async function doLogin() {
                const email = emailEl.value.trim();
                const pass = passEl.value;
                if (!email || !pass) { showErr("请填写邮箱和密码"); return; }
                btnEl.disabled = true;
                btnEl.textContent = "登录中…";
                errEl.style.display = "none";

                GM_xmlhttpRequest({
                    method: "POST",
                    url: LOGIN_URL,
                    headers: { "Content-Type": "application/json" },
                    data: JSON.stringify({ email, password: pass }),
                    timeout: 15000,
                    onload(resp) {
                        let data;
                        try { data = JSON.parse(resp.responseText); } catch (_e) { data = {}; }
                        if (resp.status === 200 && data.access_token) {
                            userToken = data.access_token;
                            GM_setValue(TOKEN_STORE, userToken);
                            dbg("login ok, token stored");
                            close(userToken);
                        } else {
                            showErr(data.detail || "登录失败,请检查邮箱和密码");
                            btnEl.disabled = false;
                            btnEl.textContent = "登录";
                        }
                    },
                    onerror() { showErr("网络错误,请重试"); btnEl.disabled = false; btnEl.textContent = "登录"; },
                    ontimeout() { showErr("请求超时,请重试"); btnEl.disabled = false; btnEl.textContent = "登录"; },
                });
            }

            function showErr(msg) { errEl.textContent = msg; errEl.style.display = "block"; }

            btnEl.addEventListener("click", doLogin);
            passEl.addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); });
            emailEl.addEventListener("keydown", e => { if (e.key === "Enter") passEl.focus(); });

            setTimeout(() => emailEl.focus(), 100);
        });
    }

    // ── 确保有有效 token,否则弹登录框 ────────────────────────────────────
    async function ensureAuth() {
        if (userToken && !isTokenExpired(userToken)) return true;
        userToken = "";
        GM_setValue(TOKEN_STORE, "");
        dbg("no valid token, showing login dialog");
        const token = await showLoginDialog();
        return !!token;
    }

    // ── 服务器通信 ─────────────────────────────────────────────────────────
    function serverRequest(method, path, body) {
        return new Promise(resolve => {
            if (!userToken) { resolve(null); return; }
            GM_xmlhttpRequest({
                method,
                url: SERVER_BASE + path,
                headers: {
                    "Content-Type": "application/json",
                    "Authorization": "Bearer " + userToken,
                },
                data: body ? JSON.stringify(body) : undefined,
                onload(resp) {
                    if (resp.status === 401 || resp.status === 403) {
                        // token 失效,清掉,下次轮询触发登录
                        userToken = "";
                        GM_setValue(TOKEN_STORE, "");
                        resolve(null);
                        return;
                    }
                    try {
                        resolve(typeof resp.response === "object" && resp.response
                            ? resp.response
                            : JSON.parse(resp.responseText || "null"));
                    } catch (_e) { resolve(null); }
                },
                onerror: () => resolve(null),
                ontimeout: () => resolve(null),
                timeout: 15000,
            });
        });
    }

    async function pullTask() {
        const ctx = getWorkerContext();
        const query = new URLSearchParams({
            platform: AI,
            sessionId: ctx.sessionId,
            url: ctx.url,
            title: ctx.title,
        });
        if (ctx.threadId) query.set("threadId", ctx.threadId);
        return serverRequest("GET", "/poll?" + query.toString());
    }

    function clearActiveTask() {
        if (activeTask) {
            if (activeTask.timeout) clearTimeout(activeTask.timeout);
            if (activeTask.finishRetryTimer) clearTimeout(activeTask.finishRetryTimer);
            if (activeTask.reader) { try { activeTask.reader.cancel(); } catch (_e) { /**/ } }
        }
        activeTask = null;
    }

    async function pushFinalResult(taskId, text, error) {
        let result = null;
        for (let attempt = 0; attempt < FINAL_PUSH_RETRY_ATTEMPTS; attempt++) {
            result = await serverRequest("POST", "/push", {
                taskId,
                text,
                done: true,
                error,
                ...getWorkerContext(),
            });
            if (result) return true;
            if (attempt < FINAL_PUSH_RETRY_ATTEMPTS - 1) {
                await sleep(1200 * (attempt + 1));
            }
        }
        return false;
    }

    function scheduleFinishRetry(taskId, text, error) {
        if (!activeTask || activeTask.id !== taskId) return;
        if (activeTask.finishRetryTimer) clearTimeout(activeTask.finishRetryTimer);
        activeTask.pendingFinish = { text, error };
        activeTask.finishRetryTimer = setTimeout(() => {
            if (!activeTask || activeTask.id !== taskId) return;
            const pending = activeTask.pendingFinish || { text: activeTask.lastText || "", error: "" };
            finishTask(taskId, pending.text, pending.error).catch(() => {});
        }, FINAL_PUSH_RETRY_DELAY);
    }

    async function finishTask(taskId, text, error) {
        if (!taskId || !activeTask || activeTask.id !== taskId) return;
        if (activeTask.finishInFlight) {
            activeTask.pendingFinish = { text: String(text || activeTask.lastText || ""), error: error || "" };
            return;
        }
        dbg("finishTask", taskId, "len:", String(text || "").length, error || "");
        activeTask.finishInFlight = true;
        if (activeTask.finishRetryTimer) {
            clearTimeout(activeTask.finishRetryTimer);
            activeTask.finishRetryTimer = null;
        }
        const finalText = String(text || activeTask.lastText || "");
        activeTask.lastText = finalText;
        const ok = await pushFinalResult(taskId, finalText, error || "");
        if (!activeTask || activeTask.id !== taskId) return;
        activeTask.finishInFlight = false;
        if (ok) {
            activeTask.pendingFinish = null;
            clearActiveTask();
            return;
        }
        showStatus("⚠️ 结果回传失败,稍后重试…", "#dc2626");
        scheduleFinishRetry(taskId, finalText, error || "");
    }

    async function pushTaskText(taskId, text, done) {
        if (!activeTask || activeTask.id !== taskId) return;
        const nextText = String(text || "");
        if (done) {
            const finalText = mergeText(activeTask.lastText || "", nextText);
            activeTask.lastText = finalText;
            await finishTask(taskId, finalText, "");
            return;
        }
        if (!nextText) return;
        const mergedText = mergeText(activeTask.lastText || "", nextText);
        if (mergedText === activeTask.lastText) return;
        activeTask.lastText = mergedText;
        await serverRequest("POST", "/push", {
            taskId,
            text: mergedText,
            done: false,
            error: "",
            ...getWorkerContext(),
        });
    }

    // ── 流式拦截 ──────────────────────────────────────────────────────────
    const PATCH_DEFS = [
        {
            AI: "DeepSeek",
            regex: /completion$/,
            extract(chunk, all) {
                let resp = "", think = "";
                for (const line of all.split("\n")) {
                    if (!line.startsWith("data: {")) continue;
                    let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; }
                    if (data.choices) {
                        const delta = data.choices[0]?.delta;
                        if (delta?.type === "thinking") think += delta.content || "";
                        else if (delta?.type === "text") resp += delta.content || "";
                    } else {
                        if (Array.isArray(data.v) && data.v[0]?.type) { this._type = data.v[0].type; data.v = data.v[0].content; }
                        if (typeof data.v === "string") {
                            if (this._type === "THINK") think += data.v;
                            else if (this._type === "RESPONSE") resp += data.v;
                        }
                    }
                }
                this.thinkingText = think;
                this.text = resp;
            }
        },
        {
            AI: "Doubao",
            regex: /samantha\/chat\/(v\d+\/)?completion|chat\/completions|\/api\/chat/,
            extract(chunk, all) {
                let resp = "", think = "";
                for (const line of all.split("\n")) {
                    if (!line.startsWith("data:")) continue;
                    const raw = line.slice(5).trim();
                    if (!raw || raw === "[DONE]") continue;
                    let outer; try { outer = JSON.parse(raw); } catch (_e) { continue; }
                    if (outer.event_data) {
                        try {
                            const inner = typeof outer.event_data === "string" ? JSON.parse(outer.event_data) : outer.event_data;
                            const msgContent = inner?.message?.content;
                            if (msgContent) {
                                try {
                                    const content = typeof msgContent === "string" ? JSON.parse(msgContent) : msgContent;
                                    if (![1, 5, 6].includes(content.type)) {
                                        if (content.text) resp += content.text;
                                        if (content.think) think += content.think;
                                    }
                                } catch (_e) { if (typeof msgContent === "string") resp += msgContent; }
                            }
                        } catch (_e) { /**/ }
                        continue;
                    }
                    const delta = outer.choices?.[0]?.delta;
                    if (delta) { resp += delta.content || delta.text || ""; continue; }
                    if (outer.text) resp += outer.text;
                    if (outer.content) resp += outer.content;
                }
                this.thinkingText = think;
                this.text = resp;
            }
        },
        {
            AI: "ChatGPT",
            regex: /(conversation|backend-api\/conversation|backend-anon\/conversation|backend-api\/responses|backend-anon\/responses)/,
            extract(chunk, all) {
                for (const line of chunk.split("\n")) {
                    if (line.startsWith('data: {"message')) {
                        let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; }
                        if (data.message?.content?.content_type === "text") {
                            this.text = data.message.content.parts?.[0] || "";
                        }
                        continue;
                    }
                    if (!line.startsWith("data: {")) continue;
                    let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; }
                    const direct = extractAnyText(data);
                    if (direct) this.text = mergeText(this.text, direct);
                    const streamPath = "/message/content/parts/0";
                    if (Object.keys(data).length === 1 && typeof data.v === "string" && this._path === streamPath) {
                        this.text += data.v;
                    } else if (this._path === streamPath || data.p === streamPath) {
                        this._path = streamPath;
                        if (data.o === "add") this.text = "";
                        if (typeof data.v === "string") this.text += data.v;
                    } else { this._path = ""; }
                }
                if (!this.text) { const fb = extractChatGPTFromAll(all); if (fb) this.text = fb; }
            }
        },
        {
            AI: "Claude",
            regex: /chat_conversations\/.+\/completion/,
            extract(chunk) {
                for (const line of chunk.split("\n")) {
                    if (!line.startsWith("data: {")) continue;
                    let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; }
                    if (data.type === "completion") this.text += data.completion || "";
                    else if (data.type === "content_block_delta") this.text += data.delta?.text || "";
                }
            }
        },
        {
            AI: "Kimi",
            regex: /ChatService\/Chat/,
            extract(_chunk, all) {
                let think = "", resp = "";
                for (const item of all.split(/\x00\x00\x00\x00[^\{]+/).filter(Boolean)) {
                    let data; try { data = JSON.parse(item); } catch (_e) { continue; }
                    if (data.op !== "append") continue;
                    if (data.mask === "block.think.content") think += data.block?.think?.content || "";
                    else if (data.mask === "block.text.content") resp += data.block?.text?.content || "";
                }
                this.thinkingText = think;
                this.text = resp;
            }
        },
        {
            AI: "Yuanbao",
            regex: /api\/chat\/.+/,
            extract(_chunk, all) {
                let think = "", resp = "";
                for (const line of all.split("\n")) {
                    if (!line.startsWith("data: {")) continue;
                    let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; }
                    if (data.type === "text") resp += data.msg || "";
                    else if (data.type === "think") think += data.content || "";
                }
                this.thinkingText = think;
                this.text = resp;
            }
        },
        {
            AI: "Qwen",
            regex: /chat\/completions/,
            extract(_chunk, all) {
                let think = "", resp = "";
                for (const line of all.split("\n")) {
                    if (!line.startsWith("data: {")) continue;
                    let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; }
                    const delta = data.choices?.[0]?.delta;
                    if (!delta) continue;
                    if (delta.phase === "think") think += delta.content || "";
                    else if (delta.phase === "answer") resp += delta.content || "";
                }
                this.thinkingText = think;
                this.text = resp;
            }
        },
        {
            AI: "Grok",
            regex: /(conversations\/new|responses)$/,
            extract(_chunk, all) {
                let think = "", resp = "";
                for (const line of all.split("\n")) {
                    let data; try { data = JSON.parse(line).result; } catch (_e) { continue; }
                    if (!data) continue;
                    if (data.response) data = data.response;
                    if (data.isThinking) think += data.token || "";
                    else resp += data.token || "";
                }
                this.thinkingText = think;
                this.text = resp;
            }
        },
    ];

    function createPatchState(def) {
        return { AI: def.AI, regex: def.regex, text: "", allText: "", _type: "", _path: "", extract: def.extract };
    }

    function extractAnyText(payload) {
        if (payload == null) return "";
        if (typeof payload === "string" || typeof payload === "number" || typeof payload === "boolean") return String(payload);
        if (Array.isArray(payload)) return payload.map(extractAnyText).filter(Boolean).join("");
        if (typeof payload === "object") {
            const pieces = [];
            const push = (v) => { if (v == null || v === payload) return; const t = extractAnyText(v); if (t) pieces.push(t); };
            push(payload.text); push(payload.output_text); push(payload.content);
            push(payload.parts); push(payload.value); push(payload.delta);
            push(payload.message); push(payload.response);
            if (pieces.length) return pieces.join("");
        }
        return "";
    }

    function mergeText(prev, next) {
        const a = String(prev || ""), b = String(next || "");
        if (!a) return b; if (!b) return a;
        if (b.startsWith(a)) return b; if (a.startsWith(b)) return a;
        if (a.includes(b)) return a;
        return a + b;
    }

    function hasMeaningfulText(text) {
        return String(text || "").trim().length > 0;
    }

    function extractChatGPTFromAll(all) {
        let text = "";
        for (const line of String(all || "").split("\n")) {
            if (!line.startsWith("data:")) continue;
            const raw = line.slice(5).trim();
            if (!raw || raw === "[DONE]") continue;
            let data; try { data = JSON.parse(raw); } catch (_e) { continue; }
            const part = extractAnyText(data);
            if (part) text = mergeText(text, part);
        }
        return text;
    }

    function getPatchDef(url) {
        return PATCH_DEFS.find(def => def.AI === AI && def.regex.test(url || ""));
    }

    function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

    // ── DOM 轮询提取(ChatGPT Service Worker 绕过 fetch 拦截时的降级方案)──────
    const DOM_EXTRACTORS = {
        ChatGPT() {
            const last = getChatGPTAssistantElement();
            if (!last) return "";
            const article = last.closest('article') || last.closest('[data-testid^="conversation-turn"]') || last.parentElement;
            if (!article) return (last?.innerText || last?.textContent || "").trim();
            const clone = article.cloneNode(true);
            if (clone && clone.querySelectorAll) {
                clone.querySelectorAll('button, nav, textarea, input, form, svg, img, picture').forEach((el) => el.remove());
            }
            return (clone?.innerText || clone?.textContent || "").trim();
        },
        DeepSeek() {
            const msgs = [...document.querySelectorAll('.ds-markdown, .markdown-body')];
            const last = msgs[msgs.length - 1];
            return (last?.innerText || last?.textContent || "").trim();
        },
        Doubao() {
            const msgs = [...document.querySelectorAll('[class*="flow-markdown-body"]')];
            const last = msgs[msgs.length - 1];
            return (last?.innerText || last?.textContent || "").trim();
        },
        Kimi() {
            const msgs = [...document.querySelectorAll('.segment.segment-assistant .markdown, .segment-assistant .markdown-container')];
            const last = msgs[msgs.length - 1];
            return (last?.innerText || last?.textContent || "").trim();
        },
        Qwen() {
            // www.qianwen.com
            const qkMsgs = [...document.querySelectorAll('[class*="qk-markdown"]')];
            if (qkMsgs.length) {
                const last = qkMsgs[qkMsgs.length - 1];
                return (last?.innerText || last?.textContent || "").trim();
            }
            // chat.qwen.ai
            const msgs = [...document.querySelectorAll('.response-message-content, .custom-qwen-markdown, .qwen-markdown')];
            const last = msgs[msgs.length - 1];
            return (last?.innerText || last?.textContent || "").trim();
        },
    };

    function getChatGPTAssistantElement() {
        const selectors = [
            '[data-message-author-role="assistant"] .markdown',
            '[data-message-author-role="assistant"] .prose',
            '[data-message-author-role="assistant"]',
            'article[data-testid^="conversation-turn"] [data-message-author-role="assistant"]',
        ];
        for (const sel of selectors) {
            const msgs = [...document.querySelectorAll(sel)];
            if (msgs.length) return msgs[msgs.length - 1];
        }
        return null;
    }

    function getDoubaoAssistantElement() {
        const msgs = [...document.querySelectorAll('[class*="flow-markdown-body"]')];
        return msgs[msgs.length - 1] || null;
    }

    function isVisibleElement(el) {
        if (!el) return false;
        const style = window.getComputedStyle(el);
        if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
        const rect = el.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0;
    }

    function getActionButtonsNearContent(contentEl, minCount = 1, maxDistance = 120) {
        if (!contentEl || !isVisibleElement(contentEl)) return [];
        const contentRect = contentEl.getBoundingClientRect();
        let scope = contentEl;
        for (let depth = 0; depth < 6 && scope; depth++, scope = scope.parentElement) {
            const buttons = [...scope.querySelectorAll("button, [role='button']")].filter(el => {
                if (!(el instanceof HTMLElement) || !isVisibleElement(el)) return false;
                if (contentEl.contains(el)) return false;
                const rect = el.getBoundingClientRect();
                const isBelowContent = rect.top >= contentRect.bottom - 24 && rect.top <= contentRect.bottom + maxDistance;
                const overlapsHorizontally = rect.right >= contentRect.left - 32 && rect.left <= contentRect.right + 32;
                return isBelowContent && overlapsHorizontally;
            });
            if (buttons.length >= minCount) return buttons;
        }
        return [];
    }

    // 返回 true = 仍在生成中;false = 已完成
    const STREAMING_INDICATORS = {
        // ChatGPT:最后一条 assistant 消息下方出现操作按钮(复制/点赞/…)即为完成
        ChatGPT: () => {
            const last = getChatGPTAssistantElement();
            if (!last) return true;
            const article = last.closest('article') || last.parentElement;
            // 操作按钮栏出现 = 完成
            const actionBar = article?.querySelector(
                'button[data-testid="copy-turn-action-button"], button[aria-label="Copy"], button[aria-label*="thumb"], [class*="action"] button'
            );
            if (actionBar) return false;
            return getActionButtonsNearContent(last, 2).length === 0; // 没有操作按钮 = 还在生成
        },
        DeepSeek: () => !!document.querySelector('.stop-btn, [class*="stop"]'),
        Doubao: () => {
            const last = getDoubaoAssistantElement();
            if (last && getActionButtonsNearContent(last, 4).length >= 4) return false;
            return document.querySelector('.send-btn-wrapper button')?.getAttribute('data-disabled') === 'true';
        },
        Kimi: () => document.querySelector('.send-button-container svg')?.getAttribute('name') === 'Stop',
        Qwen: () => !!document.querySelector('button[aria-label*="停止"]'),
    };

    function startDOMExtractor(taskId) {
        const extract = DOM_EXTRACTORS[AI];
        if (!extract) return null;
        const isStreaming = STREAMING_INDICATORS[AI] || (() => false);

        // 快照:记录发送前最后一条 assistant 消息的文字,避免误读旧回复
        const snapshotText = extract();
        dbg("DOM extractor snapshot len:", snapshotText.length);

        let lastText = "";
        let stableMs = 0;
        let foundNew = false;          // 是否已检测到新回复开始生成
        const STABLE_DONE = 3000;
        const INTERVAL = 400;

        dbg("DOM extractor started for", AI, taskId);
        showStatus("等待 AI 回复…", "#0f172a");

        const timer = setInterval(async () => {
            if (!activeTask || activeTask.id !== taskId) {
                clearInterval(timer);
                return;
            }
            const text = extract();

            // 等待新回复出现:文字和快照不同
            if (!foundNew) {
                if (!text || text === snapshotText) return;
                const streaming = isStreaming();
                if (streaming) {
                    // 正在生成中,确认是新回复
                    foundNew = true;
                    lastText = "";
                    dbg("new response detected (streaming), text len:", text.length);
                } else {
                    // 操作按钮已出现,响应可能已快速完成
                    // 如果文字长度有实质性变化,认为是新的完整回复
                    if (Math.abs(text.length - snapshotText.length) > 20 || text.length > 100) {
                        clearInterval(timer);
                        dbg("new response detected (already done), len:", text.length);
                        showStatus("✅ 完成,推送给 Zotero…", "#16a34a");
                        if (activeTask && activeTask.id === taskId) {
                            await finishTask(taskId, text, "");
                        }
                        return;
                    }
                    // 变化太小,可能是上一条懒加载,继续等待
                    return;
                }
            }

            if (text !== lastText) {
                lastText = text;
                stableMs = 0;
                showStatus("提取中… " + text.length + " 字", "#0369a1");
                if (text !== activeTask.lastText) {
                    activeTask.lastText = text;
                    const r = await serverRequest("POST", "/push", {
                        taskId,
                        text,
                        done: false,
                        error: "",
                        ...getWorkerContext(),
                    });
                    if (!r) showStatus("⚠️ 推送失败(网络/认证)", "#dc2626");
                }
                return;
            }

            // 文本未变:检查是否已完成(操作按钮出现 = 完成)
            stableMs += INTERVAL;
            const done = !isStreaming();
            if (done || stableMs >= STABLE_DONE) {
                clearInterval(timer);
                dbg("DOM extractor done, len:", text.length, "byActionBar:", done);
                showStatus("✅ 完成,推送给 Zotero…", "#16a34a");
                if (activeTask && activeTask.id === taskId) {
                    await finishTask(taskId, text, "");
                }
            }
        }, INTERVAL);

        return timer;
    }

    function monitorFetchResponse(response, taskId) {
        const def = getPatchDef(response.url);
        if (!def || !response.body || !activeTask || activeTask.id !== taskId) return;
        const state = createPatchState(def);
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        if (activeTask && activeTask.id === taskId) activeTask.reader = reader;
        const read = () => reader.read().then(async ({ done, value }) => {
            if (!activeTask || activeTask.id !== taskId) { reader.cancel().catch(() => {}); return; }
            if (done) {
                const finalText = mergeText(activeTask.lastText || "", state.text || "");
                if (hasMeaningfulText(finalText)) {
                    await pushTaskText(taskId, finalText, true);
                } else {
                    dbg("fetch stream ended without text, waiting for DOM extractor:", taskId, response.url);
                }
                return;
            }
            const chunk = decoder.decode(value, { stream: true });
            state.allText += chunk;
            try { state.extract.call(state, chunk, state.allText); await pushTaskText(taskId, state.text, false); } catch (_e) { /**/ }
            read();
        }).catch(async (error) => {
            const msg = String(error?.name || error || "");
            if (msg.includes("AbortError") || msg.includes("abort") || msg.includes("cancel")) return;
            if (activeTask && activeTask.id === taskId) {
                const finalText = mergeText(activeTask.lastText || "", state.text || "");
                if (hasMeaningfulText(finalText)) {
                    await finishTask(taskId, finalText, "");
                } else {
                    dbg("fetch stream error without text, waiting for DOM extractor:", taskId, response.url, msg);
                }
            }
        });
        read();
    }

    const nativeFetch = (unsafeWindow.fetch || window.fetch).bind(unsafeWindow || window);
    unsafeWindow.fetch = async function (...args) {
        const response = await nativeFetch(...args);
        if (!activeTask) return response;
        const clone = response.clone();
        window.setTimeout(() => { if (activeTask) monitorFetchResponse(clone, activeTask.id); }, 0);
        return response;
    };

    // SPA 导航检测
    function onNavigate() {
        if (!activeTask) return;
        const runningMs = Date.now() - activeTask.startedAt;
        // 任务刚开始且还没收到任何文本时,忽略导航(ChatGPT 发送后会跳到新对话 URL)
        if (runningMs < 8000 && !activeTask.lastText) {
            dbg("navigation ignored (task fresh, no text yet):", activeTask.id);
            return;
        }
        const savedId = activeTask.id;
        const savedText = activeTask.lastText || "";
        dbg("SPA navigation, finishing task:", savedId);
        finishTask(savedId, savedText, "").catch(() => {});
    }
    try {
        const _pushState = history.pushState.bind(history);
        history.pushState = function (...args) { _pushState(...args); setTimeout(onNavigate, 50); };
        window.addEventListener("popstate", () => setTimeout(onNavigate, 50));
    } catch (_e) { /**/ }

    const nativeXHROpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (method, url) {
        const def = getPatchDef(url);
        if (def) {
            const taskId = activeTask?.id || null;
            const state = createPatchState(def);
            this.addEventListener("readystatechange", async function () {
                if (!taskId || !activeTask || activeTask.id !== taskId) return;
                if (this.readyState !== 3 && this.readyState !== 4) return;
                try {
                    state.allText = this.responseText || "";
                    state.extract.call(state, state.allText, state.allText);
                    if (this.readyState === 4) {
                        const finalText = mergeText(activeTask.lastText || "", state.text || "");
                        if (hasMeaningfulText(finalText)) {
                            await pushTaskText(taskId, finalText, true);
                        } else {
                            dbg("xhr stream ended without text, waiting for DOM extractor:", taskId, url);
                        }
                    } else {
                        await pushTaskText(taskId, state.text, false);
                    }
                } catch (_e) { /**/ }
            });
        }
        return nativeXHROpen.apply(this, arguments);
    };

    // ── 发送到 AI ─────────────────────────────────────────────────────────
    function setNativeValue(el, value) {
        const proto = Object.getPrototypeOf(el);
        const desc = Object.getOwnPropertyDescriptor(proto, "value")
            || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement?.prototype || {}, "value")
            || Object.getOwnPropertyDescriptor(window.HTMLInputElement?.prototype || {}, "value");
        if (desc && typeof desc.set === "function") desc.set.call(el, value);
        else el.value = value;
    }

    function dispatchInput(el, text) {
        if (!el) return false;
        if ("value" in el) {
            setNativeValue(el, text);
            el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, data: text }));
            el.dispatchEvent(new Event("change", { bubbles: true }));
            return true;
        }
        if (el.isContentEditable) {
            el.focus();
            // execCommand 方式:触发真实浏览器事件,React/Lexical 均可捕获
            let ok = false;
            try {
                document.execCommand("selectAll", false, null);
                ok = document.execCommand("insertText", false, text);
            } catch (_e) { /**/ }
            if (!ok) {
                // 回退:直接设置 textContent(不用 innerHTML 以避免论文中 <> 字符污染)
                el.textContent = text;
            }
            el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, data: text }));
            return true;
        }
        return false;
    }

    function reactSet(el, text) {
        if (!el) return false;
        setNativeValue(el, text);
        const keys = Object.keys(el).filter(k => k.startsWith("__reactProps") || k.startsWith("__reactEventHandlers"));
        for (const key of keys) {
            const props = el[key];
            if (props?.onChange) { props.onChange({ target: el, currentTarget: el, type: "change" }); return true; }
            if (props?.onInput) { props.onInput({ target: el, currentTarget: el, type: "input" }); return true; }
        }
        el.dispatchEvent(new Event("input", { bubbles: true }));
        return true;
    }

    async function setLexical(text) {
        try {
            const editorEl = document.querySelector('[data-lexical-editor][role="textbox"]');
            const editor = editorEl && editorEl.__lexicalEditor;
            if (!editor) return false;
            editor.setEditorState(editor.parseEditorState({
                root: { children: [{ children: [{ detail: 0, format: 0, mode: "normal", style: "", text, type: "text", version: 1 }], direction: null, format: "", indent: 0, type: "paragraph", version: 1, textFormat: 0 }], direction: null, format: "", indent: 0, type: "root", version: 1 }
            }));
            return true;
        } catch (_e) { return false; }
    }

    function clickFirst(selectors) {
        for (const sel of selectors) { const el = document.querySelector(sel); if (el) { el.click(); return true; } }
        return false;
    }

    function submitByEnter(el) {
        if (!el) return false;
        try { el.focus(); } catch (_e) { /**/ }
        const eventInit = {
            key: "Enter",
            code: "Enter",
            keyCode: 13,
            which: 13,
            bubbles: true,
            cancelable: true,
        };
        try {
            el.dispatchEvent(new KeyboardEvent("keydown", eventInit));
            el.dispatchEvent(new KeyboardEvent("keypress", eventInit));
            el.dispatchEvent(new KeyboardEvent("keyup", eventInit));
            return true;
        } catch (_e) {
            return false;
        }
    }

    async function waitForResponseStart(ai, snapshotText, beforeThreadId, timeoutMs = 5000) {
        const extract = DOM_EXTRACTORS[ai];
        const deadline = Date.now() + timeoutMs;
        while (Date.now() < deadline) {
            await sleep(200);
            const currentThreadId = getCurrentThreadId();
            if (beforeThreadId && currentThreadId && currentThreadId !== beforeThreadId) {
                dbg("response start detected by thread change:", beforeThreadId, "→", currentThreadId);
                return true;
            }
            if (extract) {
                const text = String(extract() || "");
                if (text && text !== snapshotText && (Math.abs(text.length - snapshotText.length) > 8 || text.length > snapshotText.length)) {
                    dbg("response start detected by DOM delta, len:", text.length);
                    return true;
                }
            }
        }
        return false;
    }

    function buildTaskInput(task) {
        if (!task) return "";
        if ((task.kind || "prompt") !== "chat") {
            return String(task.prompt || task.message || "").trim();
        }

        const message = String(task.message || "").trim();
        const paperContext = String(task.paperContext || "").trim();
        if (!paperContext) return message;

        return [
            "以下是当前论文的上下文信息,请在相关时基于这些信息回答;如果上下文不足,请明确说明。",
            "",
            "[论文上下文]",
            paperContext,
            "",
            "[用户问题]",
            message,
        ].join("\n");
    }

    async function sendToAI(prompt) {
        try { unsafeWindow.focus(); } catch (_e) { /**/ }
        await sleep(300);
        const adapters = {
            DeepSeek: async () => { const el = document.querySelector("textarea"); if (!el) return false; reactSet(el, prompt); await sleep(200); return clickFirst(["button[type=submit]", "button[aria-label*='发送']", "._7436101"]); },
            Doubao: async () => {
                const el = document.querySelector('textarea.semi-input-textarea')
                    || document.querySelector('textarea');
                if (!el) return false;
                if (!reactSet(el, prompt)) return false;
                await sleep(prompt.length > 3000 ? 600 : 250);
                return clickFirst([
                    '.send-btn-wrapper button',
                    'button[aria-label*="发送"]',
                    'button[title*="发送"]',
                ]);
            },
            ChatGPT: async () => {
                const el = document.querySelector("#prompt-textarea");
                if (!el) return false;
                const beforeThreadId = getCurrentThreadId();
                const beforeText = (DOM_EXTRACTORS.ChatGPT?.() || "").trim();
                dispatchInput(el, prompt);
                // 长提示词(如含全文的 Ask PDF)需要更多时间让 React 更新状态
                await sleep(prompt.length > 3000 ? 600 : 250);
                const clicked = clickFirst(['[data-testid="send-button"]', 'button[aria-label*="Send"]', 'button[aria-label*="send"]']);
                if (clicked) return true;
                const entered = submitByEnter(el);
                if (entered) {
                    const started = await waitForResponseStart("ChatGPT", beforeText, beforeThreadId, 5000);
                    if (started) return true;
                }
                return waitForResponseStart("ChatGPT", beforeText, beforeThreadId, 2500);
            },
            Claude: async () => { const el = document.querySelector('[contenteditable="true"][data-testid*="input"]') || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[aria-label='Send message']", "button[type=submit]"]); },
            Kimi: async () => {
                const ok = await setLexical(prompt);
                if (!ok) { const el = document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); }
                await sleep(200);
                return clickFirst([".send-button-container", ".send-button", "button[type=submit]"]);
            },
            Yuanbao: async () => { const el = document.querySelector(".chat-input-editor .ql-editor") || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst([".icon-send", "button[type=submit]"]); },
            Qwen: async () => {
                // www.qianwen.com — contenteditable with React fiber (no textarea)
                const ceEl = document.querySelector('div[contenteditable="true"]');
                if (ceEl) {
                    ceEl.focus();
                    document.execCommand('selectAll', false, null);
                    document.execCommand('insertText', false, prompt);
                    const propsKey = Object.keys(ceEl).find(k => k.startsWith('__reactProps'));
                    const props = propsKey ? ceEl[propsKey] : null;
                    if (props?.onInput) props.onInput({ target: ceEl, currentTarget: ceEl, nativeEvent: new InputEvent('input') });
                    ceEl.dispatchEvent(new InputEvent('input', { bubbles: true }));
                    await sleep(prompt.length > 3000 ? 600 : 300);
                    return clickFirst(['button[aria-label="发送消息"]', 'button.send-button', 'button[type=submit]']);
                }
                // chat.qwen.ai — textarea
                const el = document.querySelector('textarea.message-input-textarea') || document.querySelector('textarea');
                if (!el) return false;
                reactSet(el, prompt);
                await sleep(prompt.length > 3000 ? 600 : 200);
                return clickFirst(['button.send-button', '#send-message-button', 'button[type=submit]']);
            },
            Gemini: async () => { const el = document.querySelector("rich-textarea textarea") || document.querySelector("textarea") || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[aria-label*='Send']", "button[type=submit]"]); },
            Perplexity: async () => { const el = document.querySelector("textarea") || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[aria-label*='Submit']", "button[aria-label*='Send']", "button[type=submit]"]); },
            Grok: async () => { const el = document.querySelector("form [contenteditable]") || document.querySelector("textarea"); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[type=submit]", "button[aria-label*='Send']"]); },
        };
        const adapter = adapters[AI];
        if (adapter) return !!(await adapter().catch(() => false));
        const genericEditor = document.querySelector("textarea, [contenteditable='true']");
        if (!genericEditor) return false;
        dispatchInput(genericEditor, prompt);
        await sleep(200);
        return clickFirst(["button[type=submit]", "button[aria-label*='Send']", "button[aria-label*='发送']"]);
    }

    // ── GM 菜单 ───────────────────────────────────────────────────────────
    GM_registerMenuCommand("👤 登录 / 切换账号", async () => {
        userToken = ""; GM_setValue(TOKEN_STORE, "");
        await showLoginDialog();
    });

    GM_registerMenuCommand("📊 AI4Paper 状态", () => {
        let tokenInfo = "未登录";
        if (userToken) {
            try { const p = decodeJWTPayload(userToken); tokenInfo = "已登录 (sub:" + p.sub + ")"; } catch (_e) { tokenInfo = "已登录"; }
        }
        window.alert([
            "平台: " + AI,
            "脚本: " + SCRIPT_VERSION,
            "账号: " + tokenInfo,
            "运行: " + (isRunning ? "是" : "否"),
            "任务: " + (activeTask?.id || "空闲"),
            "会话: " + getWorkerSessionId(),
            "线程: " + (getCurrentThreadId() || "未识别"),
        ].join("\n"));
    });

    GM_registerMenuCommand(isRunning ? "⏸ 暂停 Connector" : "▶️ 启用 Connector", () => {
        isRunning = !isRunning; GM_setValue(RUNNING_KEY, isRunning);
        window.alert(isRunning ? "AI4Paper Connector 已启用" : "AI4Paper Connector 已暂停");
    });

    // ── 主循环 ────────────────────────────────────────────────────────────
    while (true) {
        await sleep(POLL_INTERVAL);
        if (!isRunning) continue;
        try {
            if (activeTask) continue;

            // 确保已登录
            const authed = await ensureAuth();
            if (!authed) { await sleep(5000); continue; }

            const resp = await pullTask();
            if (!resp?.task) continue;

            const task = resp.task;
            const taskInput = buildTaskInput(task);
            const newTask = {
                id: task.id,
                prompt: taskInput,
                kind: task.kind || "prompt",
                preferredSessionId: task.preferredSessionId || "",
                preferredThreadId: task.preferredThreadId || "",
                expiresAt: Number(task.expiresAt || 0),
                lastText: "",
                startedAt: Date.now(),
                reader: null,
                timeout: null,
                finishRetryTimer: null,
                finishInFlight: false,
                pendingFinish: null
            };
            const taskTimeoutMs = getTaskTimeoutMs(newTask);
            newTask.timeout = setTimeout(() => {
                if (activeTask && activeTask.id === newTask.id) {
                    dbg("task timeout:", newTask.id);
                    const text = activeTask.lastText || "";
                    finishTask(newTask.id, text, "").catch(() => {});
                }
            }, taskTimeoutMs);
            activeTask = newTask;
            const contextReady = await waitForPreferredContext(newTask);
            if (!contextReady) {
                showStatus("❌ 未回到绑定对话,任务取消", "#dc2626");
                await finishTask(task.id, "", "当前页面不是绑定的会话/线程,请切回原对话后重试");
                continue;
            }
            dbg(
                "task:", task.id,
                "kind:", newTask.kind,
                "platform:", AI,
                "session:", getWorkerSessionId(),
                "thread:", getCurrentThreadId() || "-",
                "preferredSession:", newTask.preferredSessionId || "-",
                "preferredThread:", newTask.preferredThreadId || "-",
                "timeoutMs:", taskTimeoutMs,
                "prompt len:", taskInput.length
            );
            showStatus("收到任务,发送中…", "#7c3aed");
            const ok = await sendToAI(taskInput);
            if (!ok) {
                showStatus("❌ 发送失败", "#dc2626");
                await finishTask(task.id, "", "发送失败,请检查页面状态");
            } else {
                showStatus("✉️ 已发送,等待回复…", "#0369a1");
                // 启动 DOM 轮询提取器(与 fetch 拦截并行,哪个先完成都可以)
                startDOMExtractor(task.id);
            }
        } catch (error) {
            const text = String(error || "");
            if (!text.includes("Network")) console.warn("[AI4Paper] loop error", error);
            await sleep(1000);
        }
    }
})();