4d4y.ai

AI-powered auto-completion with page context

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         4d4y.ai
// @namespace    http://tampermonkey.net/
// @version      5.1.0
// @description  AI-powered auto-completion with page context
// @author       屋大维
// @license      MIT
// @match        *://www.4d4y.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @require      https://cdnjs.cloudflare.com/ajax/libs/tesseract.js/4.0.2/tesseract.min.js
// ==/UserScript==

(() => {
    // =======================
    // Config Menu
    // =======================
    // Default settings
    const defaultSettings = {
        FAST_REPLY_BTN: true,
        ATTITUDE: "",
        INCLUDE_IMG: true,
        OLLAMA_URL: "http://localhost:11434/api/generate",
        MODEL_NAME: "deepseek-r1:14b",
        DOUBLE_TAB_MODE: true,
        ENABLE_TOKEN_SAVER: true,
        LOCAL_GATE_ENABLED: true,
        LOCAL_GATE_LEVEL: 2,
        OpenAIConfig: {
            enabled: false,
            API_URL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
            apiKey: "",
            model: "",
            options: {}
        }
    };

    // Load stored settings or use defaults
    let settings = GM_getValue("scriptSettings", defaultSettings);

    // Available LLM models
    function getOllamaModels() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: "http://localhost:11434/api/tags",
                onload: function (response) {
                    try {
                        const data = JSON.parse(response.responseText);
                        if (data.models) {
                            resolve(data.models.map(m => m.name)); // Return list of model names
                        } else {
                            resolve([]); // No models found
                        }
                    } catch (e) {
                        reject("Failed to parse response.");
                    }
                },
                onerror: function () {
                    reject("Failed to connect to Ollama.");
                }
            });
        });
    }

    let OLLAMA_MODELS = [];
    async function queryAvailableOllamaModels() {
        OLLAMA_MODELS = await getOllamaModels();
    }

    // Create settings modal
    function createSettingsModal() {
        let modal = document.createElement("div");
        modal.id = "tampermonkey-settings-modal";
        modal.style = `
          position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
          background: white; padding: 20px; border-radius: 10px; z-index: 10000;
          box-shadow: 0px 4px 10px rgba(0,0,0,0.3); width: 400px; max-width: 90%;
          text-align: left;
          font-family: Arial, sans-serif;
          max-height: 90vh; /* Ensures the modal does not exceed the viewport height */
          overflow: auto; /* Enables scrolling if content overflows */
      `;

        modal.innerHTML = `
          <h3 style="margin-top: 0;">Script Settings</h3>

          <h4>🔧 General Settings</h4>
          <label><input type="checkbox" id="FAST_REPLY_BTN"> Fast Reply Button</label><br>
          <label>Attitude: <input type="text" id="ATTITUDE" style="width: 100%;"></label><br>
          <label><input type="checkbox" id="INCLUDE_IMG"> Include Images</label><br>

          <h4>🧠 Model & API Settings</h4>
          <label>Ollama URL: <input type="text" id="OLLAMA_URL" style="width: 100%;"></label><br>
          <label>Model:
              <select id="MODEL_NAME" style="width: 100%;">
                  ${OLLAMA_MODELS.map(model => `<option value="${model}">${model}</option>`).join("")}
              </select>
          </label><br>

          <h4>⚙️ OpenAI API Settings</h4>
          <label><input type="checkbox" id="OpenAIConfig_enabled"> Use OpenAI API</label><br>
          <label>API URL: <input type="text" id="OpenAIConfig_API_URL" style="width: 100%;"></label><br>
          <label>API Key: <input type="text" id="OpenAIConfig_apiKey" style="width: 100%;"></label><br>
          <label>Model:
              <input type="text" id="OpenAIConfig_model" style="width: 100%;" placeholder="Enter Model Name">
          </label><br>
          <label>Options (JSON): <textarea id="OpenAIConfig_options" style="width: 100%; height: 60px;"></textarea></label><br>

          <h4>🚀 Performance & UI Tweaks</h4>
          <label><input type="checkbox" id="DOUBLE_TAB_MODE"> Double Tab Mode</label><br>
          <label><input type="checkbox" id="ENABLE_TOKEN_SAVER"> Enable Token Saver</label><br>
          <label><input type="checkbox" id="LOCAL_GATE_ENABLED"> Smart Trigger</label><br>
          <label>Smart Trigger Level:
              <select id="LOCAL_GATE_LEVEL" style="width: 100%;">
                  <option value="1">Relaxed</option>
                  <option value="2">Balanced</option>
                  <option value="3">Strict</option>
              </select>
          </label><br>

          <button id="save-settings" style="width: 100%; padding: 5px; background: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer;">Save</button>
          <button id="reset-settings" style="width: 100%; margin-top: 5px; padding: 5px; background: #ff4d4d; color: white; border: none; border-radius: 5px; cursor: pointer;">Reset to Default</button>
          <button id="close-settings" style="width: 100%; margin-top: 5px; padding: 5px; background: #ccc; border: none; border-radius: 5px; cursor: pointer;">Cancel</button>
      `;

        document.body.appendChild(modal);

        // Load current values into the form
        Object.keys(settings).forEach(key => {
            let elem = document.getElementById(key);
            if (elem) elem.type === "checkbox" ? (elem.checked = settings[key]) : (elem.value = settings[key]);
        });

        Object.keys(settings.OpenAIConfig).forEach(key => {
            let elem = document.getElementById("OpenAIConfig_" + key);
            if (elem) elem.type === "checkbox" ? (elem.checked = settings.OpenAIConfig[key]) : (elem.value = settings.OpenAIConfig[key]);
        });

        document.getElementById("OpenAIConfig_options").value = JSON.stringify(settings.OpenAIConfig.options, null, 2);

        // Save settings
        document.getElementById("save-settings").onclick = () => {
            Object.keys(settings).forEach(key => {
                let elem = document.getElementById(key);
                if (elem) settings[key] = elem.type === "checkbox" ? elem.checked : elem.value;
            });

            Object.keys(settings.OpenAIConfig).forEach(key => {
                let elem = document.getElementById("OpenAIConfig_" + key);
                if (elem) settings.OpenAIConfig[key] = elem.type === "checkbox" ? elem.checked : elem.value;
            });

            try {
                settings.OpenAIConfig.options = JSON.parse(document.getElementById("OpenAIConfig_options").value);
            } catch (e) {
                alert("Invalid JSON format in options!");
                return;
            }

            GM_setValue("scriptSettings", settings);
            modal.remove();
            location.reload();
        };

        // Reset settings to default
        document.getElementById("reset-settings").onclick = () => {
            GM_setValue("scriptSettings", defaultSettings);
            location.reload();
        };

        document.getElementById("close-settings").onclick = () => modal.remove();
    }

    // Register context menu command
    GM_registerMenuCommand("Edit Script Settings", createSettingsModal);

    // =======================
    // MAIN
    // =======================
    let lastRequest = null;
    let statusBar = null;
    let activeElement = null;
    let suggestion = "";
    let lastInputText = "";
    let debounceTimer = null;
    let LAST_TAB_TIME = 0;
    let lastTriggerAt = 0;
    let lastTriggeredText = "";
    let lastInputAt = 0;
    let gateWorker = null;
    let gateRequestId = 0;
    let lastGatePayload = null;
    let inlineOverlay = null;
    let inlineOverlayInput = null;
    let inlineOverlaySuggestion = null;
    const SHORT_SUGGESTION_MAX_CHARS = 24;
    const DEBUG_LOG = true;
    const OCR_CACHE_KEY = "ocrCache";
    const OCR_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
    const OCR_CACHE_MAX_ENTRIES = 200;

    function debugLog(...args) {
        if (DEBUG_LOG) {
            console.log("[4d4y.ai]", ...args);
        }
    }

    function tokenSaver(input) {
        // 节省input token
        let processed = input
            .replace(/\r/g, "") // 移除回车符
            .replace(/<br\s*\/?>|<\/?p>/gi, "\n") // HTML 换行符转换为换行
            .replace(/[\u200B-\u200D\uFEFF]/g, "") // 移除零宽字符
            .replace(/[\u00A0\u3000\u202F\u2009]/g, " ") // **替换所有特殊空格**
            .replace(/\t/g, " ") // 制表符变成空格
            .replace(/[ ]{2,}/g, " ") // **合并多个空格**
            .replace(/\n{3,}/g, "\n\n") // **合并多余的换行**
            .replace(/([!?。!?,,;;:])\1{2,}/g, "$1$1") // 合并重复标点
            .trim(); // 去除首尾空白
        debugLog(`Token Saver: saved ${input.length - processed.length} tokens`);
        return processed;
    }

    function loadOCRCache() {
        return GM_getValue(OCR_CACHE_KEY, { entries: {}, order: [] });
    }

    function saveOCRCache(cache) {
        GM_setValue(OCR_CACHE_KEY, cache);
    }

    function pruneOCRCache(cache) {
        const now = Date.now();
        let changed = false;
        cache.order = cache.order.filter((key) => {
            const entry = cache.entries[key];
            if (!entry) {
                changed = true;
                return false;
            }
            if (now - entry.ts > OCR_CACHE_TTL_MS) {
                delete cache.entries[key];
                changed = true;
                return false;
            }
            return true;
        });

        while (cache.order.length > OCR_CACHE_MAX_ENTRIES) {
            const oldest = cache.order.shift();
            if (oldest && cache.entries[oldest]) {
                delete cache.entries[oldest];
                changed = true;
            }
        }

        if (changed) {
            debugLog(`OCR cache pruned. size=${cache.order.length}`);
            saveOCRCache(cache);
        }
    }

    function getOCRCache(id) {
        const cache = loadOCRCache();
        const entry = cache.entries[id];
        if (!entry) return null;
        if (Date.now() - entry.ts > OCR_CACHE_TTL_MS) {
            delete cache.entries[id];
            cache.order = cache.order.filter((key) => key !== id);
            saveOCRCache(cache);
            return null;
        }
        return entry.text;
    }

    function setOCRCache(id, text) {
        const cache = loadOCRCache();
        cache.entries[id] = { text, ts: Date.now() };
        cache.order = cache.order.filter((key) => key !== id);
        cache.order.push(id);
        pruneOCRCache(cache);
        saveOCRCache(cache);
    }

    function getTidFromUrl() {
        let match = window.location.href.match(/tid=(\d+)/);
        return match ? match[1] : null;
    }

    function getPageContext() {
        let context = { title: "", posts: [] };

        try {
            // Get thread title (Updated XPath)
            const titleNode = document.evaluate(
                "/html/body/div[4]/text()[3]",
                document,
                null,
                XPathResult.STRING_TYPE,
                null,
            );
            context.title = titleNode.stringValue.trim().replace(/^»\s*/, ""); // Remove "» " from title

            // Get posts (only direct children of #postlist)
            const postList = document.querySelector("#postlist");
            if (postList) {
                const posts = postList.children; // Only direct child divs
                for (let post of posts) {
                    if (post.tagName === "DIV") {
                        let postId = post.getAttribute("id")?.replace("post_", "") || "";
                        let poster =
                            post.querySelector(".postauthor .postinfo")?.innerText.trim() ||
                            "Unknown";
                        let contentElement = post.querySelector(
                            ".postcontent td.t_msgfont",
                        );

                        if (!contentElement) continue;

                        // 克隆 contentElement,避免修改 DOM
                        let clonedContent = contentElement.cloneNode(true);
                        clonedContent
                            .querySelectorAll('div[style="display: none;"]')
                            .forEach((div) => div.remove());
                        clonedContent
                            .querySelectorAll("span.ocr-result")
                            .forEach((span) => span.remove()); // content不包括span.ocr-result

                        // 提取 quote 内容
                        let quoteElements = Array.from(
                            clonedContent.querySelectorAll("div.quote"),
                        );
                        let quotes = quoteElements.map((quote) => quote.innerText.trim());

                        // 从克隆节点删除 quote 但不影响原始 DOM
                        quoteElements.forEach((quote) => quote.remove());

                        let content = clonedContent.innerText.trim();
                        let postTimeElement = post.querySelector(`em[id^="authorposton"]`);
                        let postTime = postTimeElement
                            ? postTimeElement.textContent.replace("发表于 ", "").trim()
                            : "未知";

                        // Extract text from all <span class="ocr-result"> elements
                        let ocrResults = Array.from(
                            post.querySelectorAll("span.ocr-result"),
                        )
                            .map((span) => span.innerText.trim())
                            .join("\n\n"); // Join all texts

                        if (content || ocrResults) {
                            context.posts.push({
                                tid: getTidFromUrl(),
                                id: postId,
                                poster,
                                content,
                                postTime,
                                quote: quotes.length > 0 ? quotes : undefined, // Only add if non-empty
                                img: ocrResults,
                            });
                        }
                    }
                }
            }
        } catch (error) {
            console.error("Error extracting page context:", error);
        }

        return context;
    }

    function createStatusBar() {
        statusBar = document.createElement("div");
        statusBar.id = "ai-status-bar";
        statusBar.innerText = "Idle";
        document.body.appendChild(statusBar);
        GM_addStyle(`
                @keyframes auroraGlow {
                    0% { box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); }
                    25% { box-shadow: 0 0 8px rgba(255, 165, 0, 0.6); }
                    50% { box-shadow: 0 0 8px rgba(0, 255, 0, 0.6); }
                    75% { box-shadow: 0 0 8px rgba(0, 0, 255, 0.6); }
                    100% { box-shadow: 0 0 8px rgba(255, 0, 255, 0.6); }
                }
                #ai-status-bar {
                    position: fixed;
                    bottom: 10px;
                    right: 10px;
                    background: rgba(20, 20, 20, 0.85);
                    color: white;
                    padding: 8px 12px;
                    border-radius: 8px;
                    font-size: 12px;
                    font-weight: bold;
                    z-index: 9999;
                }
                #ai-status-bar.glowing {
                    animation: auroraGlow 1.5s infinite alternate ease-in-out;
                }
            `);
    }

    function createInlineOverlay() {
        inlineOverlay = document.createElement("div");
        inlineOverlay.id = "ai-inline-overlay";
        inlineOverlayInput = document.createElement("span");
        inlineOverlayInput.id = "ai-inline-input";
        inlineOverlaySuggestion = document.createElement("span");
        inlineOverlaySuggestion.id = "ai-inline-suggestion";
        inlineOverlay.appendChild(inlineOverlayInput);
        inlineOverlay.appendChild(inlineOverlaySuggestion);
        document.body.appendChild(inlineOverlay);
        GM_addStyle(`
                #ai-inline-overlay {
                    position: absolute;
                    pointer-events: none;
                    white-space: pre-wrap;
                    overflow: hidden;
                    color: transparent;
                    z-index: 9998;
                }
                #ai-inline-input {
                    color: transparent;
                }
                #ai-inline-suggestion {
                    color: rgba(140, 140, 140, 0.9);
                }
            `);
    }

    function countWords(text) {
        return Array.from(text).filter((c) => /\S/.test(c)).length; // Count non-space characters
    }

    function getGateProfile(level) {
        switch (Number(level)) {
            case 1:
                return {
                    minChars: 4,
                    minDelta: 2,
                    idleMs: 300,
                    cooldownMs: 2500,
                    threshold: 3,
                };
            case 3:
                return {
                    minChars: 8,
                    minDelta: 6,
                    idleMs: 700,
                    cooldownMs: 6000,
                    threshold: 4,
                };
            case 2:
            default:
                return {
                    minChars: 6,
                    minDelta: 4,
                    idleMs: 500,
                    cooldownMs: 4000,
                    threshold: 4,
                };
        }
    }

    function initGateWorker() {
        if (gateWorker) return;
        if (typeof Worker === "undefined") return;
        const workerSource = `
self.onmessage = (event) => {
    const { id, text, lastTriggeredText, cursorAtEnd, now, lastTriggerAt, profile } = event.data;
    const result = { id, shouldTrigger: false, score: 0 };
    if (!cursorAtEnd) return postMessage(result);
    if (!text || text.length < profile.minChars) return postMessage(result);
    if (now - lastTriggerAt < profile.cooldownMs) return postMessage(result);
    const delta = text.length - (lastTriggeredText || "").length;
    if (delta < profile.minDelta) return postMessage(result);
    const lastChar = text.slice(-1);
    if (/[^a-zA-Z0-9\\u4e00-\\u9fa5]/.test(lastChar)) return postMessage(result);
    let score = 0;
    score += Math.min(3, Math.floor(text.length / profile.minChars));
    score += Math.min(2, Math.floor(delta / profile.minDelta));
    score += /[a-zA-Z0-9\\u4e00-\\u9fa5]/.test(lastChar) ? 1 : 0;
    result.score = score;
    result.shouldTrigger = score >= profile.threshold;
    postMessage(result);
};
        `;
        const blob = new Blob([workerSource], { type: "application/javascript" });
        gateWorker = new Worker(URL.createObjectURL(blob));
        gateWorker.onmessage = (event) => {
            const { id, shouldTrigger } = event.data;
            if (!lastGatePayload || id !== lastGatePayload.id) return;
            if (shouldTrigger) {
                lastTriggerAt = lastGatePayload.now;
                lastTriggeredText = lastGatePayload.text;
                fetchCompletion(lastGatePayload.text, "short");
            }
        };
    }

    function requestShortCompletion(text) {
        const profile = getGateProfile(settings.LOCAL_GATE_LEVEL);
        const now = Date.now();
        if (now - lastInputAt < profile.idleMs) return;
        if (!activeElement) return;

        if (!settings.LOCAL_GATE_ENABLED || !gateWorker) {
            lastTriggerAt = now;
            lastTriggeredText = text;
            fetchCompletion(text, "short");
            return;
        }

        const cursorAtEnd =
            activeElement.selectionStart === activeElement.value.length &&
            activeElement.selectionEnd === activeElement.value.length;
        const id = ++gateRequestId;
        lastGatePayload = { id, text, now };
        gateWorker.postMessage({
            id,
            text,
            lastTriggeredText,
            cursorAtEnd,
            now,
            lastTriggerAt,
            profile,
        });
    }

    function getTextFromElement(element) {
        return element.tagName === "TEXTAREA"
            ? element.value
            : element.innerText.replace(/\n/g, " "); // Remove HTML formatting
    }

    function syncOverlayToTextarea(textarea) {
        if (!inlineOverlay || !textarea) return;
        const rect = textarea.getBoundingClientRect();
        const style = window.getComputedStyle(textarea);
        inlineOverlay.style.left = rect.left + window.scrollX + "px";
        inlineOverlay.style.top = rect.top + window.scrollY + "px";
        inlineOverlay.style.width = rect.width + "px";
        inlineOverlay.style.height = rect.height + "px";
        inlineOverlay.style.fontFamily = style.fontFamily;
        inlineOverlay.style.fontSize = style.fontSize;
        inlineOverlay.style.lineHeight = style.lineHeight;
        inlineOverlay.style.letterSpacing = style.letterSpacing;
        inlineOverlay.style.padding = style.padding;
        inlineOverlay.style.borderRadius = style.borderRadius;
        inlineOverlay.style.boxSizing = style.boxSizing;
        inlineOverlay.scrollTop = textarea.scrollTop;
        inlineOverlay.scrollLeft = textarea.scrollLeft;
        inlineOverlay.style.display = "block";
    }

    function updateInlineSuggestion(inputText, inlineText) {
        if (!inlineOverlay || !activeElement) return;
        if (!inlineText) {
            clearInlineSuggestion();
            return;
        }
        inlineOverlayInput.textContent = inputText;
        inlineOverlaySuggestion.textContent = inlineText || "";
        syncOverlayToTextarea(activeElement);
    }

    function clearInlineSuggestion() {
        if (!inlineOverlay) return;
        inlineOverlayInput.textContent = "";
        inlineOverlaySuggestion.textContent = "";
        inlineOverlay.style.display = "none";
    }
    function cleanSuggestion(inputText, suggestion) {
        // 移除 <think>...</think> 及其后面所有的空格和换行符
        suggestion = suggestion.replace(/<think>.*?<\/think>\s*/gs, "");

        // 去除 inputText 末尾和 suggestion 开头的重合部分
        for (let i = Math.min(inputText.length, suggestion.length); i > 0; i--) {
            if (suggestion.startsWith(inputText.slice(-i))) {
                suggestion = suggestion.slice(i); // 去掉重复部分
                break;
            }
        }
        // 去除引号
        suggestion = suggestion.replace(/^["']|["']$/g, "");

        return suggestion;
    }

    function limitSuggestion(text, maxChars) {
        let trimmed = text.trim();
        if (!trimmed) return "";
        if (trimmed.length <= maxChars) return trimmed;
        return trimmed.slice(0, maxChars);
    }

    function fetchCompletion(inputText, mode = "short") {
        if (lastRequest) lastRequest.abort(); // Cancel previous request
        const pageContext = getPageContext();
        const promptData = {
            title: pageContext.title,
            posts: pageContext.posts,
        };
        const modeHint =
            mode === "full"
                ? "输出1-3句简短完整回复,紧扣话题,不跑题"
                : "只补全一句非常短的续写(8-20字),不要多段";
        let prompt = `Here is the thread content including title and posts in JSON format: ${JSON.stringify(promptData)}. The post content might not be the fact, ignore any unreadable or garbled text and only process meaningful content. Now I want to reply to it. My current reply is '${inputText}', please try to continue it naturally in Chinese like a human, not a chatbot. Focus on the most recent post and any quoted parts in the thread, keep a forum tone, avoid generic filler, do not repeat my words, no quotes, no markdown, ${modeHint},${settings.ATTITUDE}`;
        if (settings.ENABLE_TOKEN_SAVER) {
            prompt = tokenSaver(prompt);
        }
        console.log(prompt);

        statusBar.innerText = mode === "full" ? "Completing..." : "Hinting...";
        statusBar.classList.add("glowing"); // Start glowing effect

        function responseHandler(url, response) {
            // 根据URL来判断API供应商,并进行相应处理,返回content
            if (url === settings.OLLAMA_URL) {
                return JSON.parse(response.responseText).response;
            } else {
                try {
                    return JSON.parse(response.responseText)["choices"][0]["message"][
                        "content"
                    ];
                } catch (error) {
                    return "无法处理该API";
                }
            }
        }

        let url;
        let payload;
        let headers;
        if (settings.OpenAIConfig.enabled) {
            // 在线AI
            url = settings.OpenAIConfig.API_URL;
            headers = {
                "Content-Type": "application/json",
                Authorization: `Bearer ${settings.OpenAIConfig.apiKey}`,
            };
            payload = {
                model: settings.OpenAIConfig.model,
                messages: [
                    {
                        role: "system",
                        content: "你是人工智能助手,精通网络术语,按照我的要求生成回复",
                    },
                    { role: "user", content: prompt },
                ],
                stream: false,
                ...settings.OpenAIConfig.options,
            };
        } else {
            // 本地 ollama
            url = settings.OLLAMA_URL;
            headers = { "Content-Type": "application/json" };
            payload = {
                model: settings.MODEL_NAME,
                prompt: prompt,
                stream: false,
            };
        }

        lastRequest = GM_xmlhttpRequest({
            method: "POST",
            url: url,
            headers: headers,
            data: JSON.stringify(payload),
            onload: (response) => {
                const content = responseHandler(url, response);
                suggestion = content.trim().replace(/^"(.*)"$/, "$1"); // Remove surrounding quotes
                console.log("AI Response:", suggestion);
                suggestion = cleanSuggestion(inputText, suggestion);
                if (mode === "short") {
                    suggestion = limitSuggestion(suggestion, SHORT_SUGGESTION_MAX_CHARS);
                }

                updateInlineSuggestion(inputText, suggestion);

                statusBar.innerText = "Ready";
                statusBar.classList.remove("glowing"); // Stop glowing effect
            },
            onerror: () => {
                statusBar.innerText = "Error!";
                statusBar.classList.remove("glowing"); // Stop glowing effect
            },
        });
    }

    function handleInput(event) {
        if (!activeElement) return;
        const text = getTextFromElement(activeElement);
        const wordCount = countWords(text);
        const profile = getGateProfile(settings.LOCAL_GATE_LEVEL);
        if (text !== lastInputText && suggestion) {
            suggestion = "";
            clearInlineSuggestion();
        }

        if (wordCount < 3) {
            statusBar.innerText = "Waiting for more input...";
            clearInlineSuggestion();
            lastInputText = text;
            return;
        }

        const lastChar = text.slice(-1);
        const isPunctuation = /[\s.,!?;:(),。;()!?【】「」『』、]/.test(
            lastChar,
        );
        const isDeleteOnlyPunctuation =
            lastInputText.length > text.length &&
            !/[a-zA-Z0-9\u4e00-\u9fa5]/.test(lastInputText.replace(text, ""));

        if (isPunctuation || isDeleteOnlyPunctuation) {
            lastInputText = text;
            return;
        }

        if (debounceTimer) clearTimeout(debounceTimer);
        lastInputAt = Date.now();
        debounceTimer = setTimeout(() => requestShortCompletion(text), profile.idleMs);

        lastInputText = text;
    }

    function insertTextAtCursor(textarea, text) {
        let start = textarea.selectionStart;
        let end = textarea.selectionEnd;
        let before = textarea.value.substring(0, start);
        let after = textarea.value.substring(end);
        textarea.value = before + text + after;
        textarea.selectionStart = textarea.selectionEnd = start + text.length;
        textarea.focus();
    }

    function handleKeyDown(event) {
        if (event.key !== "Tab") return;

        if (settings.DOUBLE_TAB_MODE) {
            event.preventDefault();
            event.stopPropagation();
            const currentTime = new Date().getTime();

            if (suggestion) {
                if (activeElement.tagName === "TEXTAREA") {
                    insertTextAtCursor(activeElement, suggestion);
                }
                clearInlineSuggestion();
                suggestion = "";
                LAST_TAB_TIME = currentTime;
                return;
            }

            const timeSinceLastTab = currentTime - LAST_TAB_TIME;
            if (timeSinceLastTab < 300 && timeSinceLastTab > 0) {
                console.log("Double Tab detected!");
                let text = getTextFromElement(activeElement);
                if (text.length === 0) {
                    text = "根据楼上的发言,";
                }
                fetchCompletion(text, "full");
            }
            LAST_TAB_TIME = currentTime;
        } else if (suggestion) {
            event.preventDefault();
            event.stopPropagation();
            if (activeElement.tagName === "TEXTAREA") {
                insertTextAtCursor(activeElement, suggestion);
            }
            clearInlineSuggestion();
            suggestion = "";
        }
    }

    function initListeners() {
        document.addEventListener("focusin", (event) => {
            if (event.target.matches("textarea")) {
                activeElement = event.target;
                // 输入引发短补全
                activeElement.addEventListener("input", handleInput);
                activeElement.addEventListener("input", () => {
                    updateInlineSuggestion(getTextFromElement(activeElement), suggestion);
                });
                activeElement.addEventListener("scroll", () => {
                    syncOverlayToTextarea(activeElement);
                });
                // 插入补全 listener
                activeElement.addEventListener("keydown", handleKeyDown);
                lastInputText = getTextFromElement(activeElement);
                updateInlineSuggestion(lastInputText, suggestion);
            }
        });

        document.addEventListener("focusout", () => {
            clearInlineSuggestion();
            suggestion = "";
        });

        window.addEventListener("scroll", () => {
            syncOverlayToTextarea(activeElement);
        });
        window.addEventListener("resize", () => {
            syncOverlayToTextarea(activeElement);
        });
    }
    // OCR Module
    function hashString(str) {
        let hash = 0;
        for (let i = 0; i < str.length; i++) {
            hash = (hash << 5) - hash + str.charCodeAt(i);
            hash |= 0; // Convert to 32-bit integer
        }
        return `ocr-${Math.abs(hash)}`;
    }

    function fetchImageAsDataURL(url) {
        console.log(`Fetching image: ${url}`);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                responseType: "blob",
                onload: function (response) {
                    let reader = new FileReader();
                    reader.onloadend = () => resolve(reader.result);
                    reader.readAsDataURL(response.response);
                },
                onerror: (err) => {
                    console.error(`Failed to fetch image: ${url}`, err);
                    reject(err);
                },
            });
        });
    }

    function isValidOCR(text) {
        let cleanText = text.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, "").trim();
        return cleanText.length >= 3;
    }

    function cleanOCRText(ocrText) {
        let lines = ocrText.split("\n").filter(isValidOCR);
        return lines.join("\n");
    }

    function cleanText(text) {
        let cleaned = text.replace(
            /\s*(?=[\p{Script=Han},。!?:“”‘’;()【】])/gu,
            "",
        );
        cleaned = cleanOCRText(cleaned);
        debugLog(`Cleaned OCR text: ${cleaned}`);
        return cleaned;
    }

    function isMeaningfulOCRText(text) {
        if (!text) return false;
        const compact = text.replace(/\s+/g, "");
        if (compact.length < 6) return false;
        const validChars = compact.match(/[a-zA-Z0-9\u4e00-\u9fa5]/g) || [];
        if (validChars.length < 6) return false;
        const uniqueChars = new Set(validChars).size;
        if (uniqueChars < 3) return false;
        const ratio = validChars.length / compact.length;
        return ratio >= 0.6;
    }

    function quickHasText(imageDataURL) {
        return new Promise((resolve) => {
            const img = new Image();
            img.onload = () => {
                if (img.width < 3 || img.height < 3) {
                    debugLog(
                        `Image too small to scan (${img.width}x${img.height}), skipping OCR.`,
                    );
                    resolve(false);
                    return;
                }
                const maxWidth = 220;
                const scale = Math.min(1, maxWidth / img.width);
                const width = Math.max(1, Math.floor(img.width * scale));
                const height = Math.max(1, Math.floor(img.height * scale));
                if (width < 3 || height < 3) {
                    debugLog(
                        `Image too small after scale (${width}x${height}), skipping OCR.`,
                    );
                    resolve(false);
                    return;
                }
                const canvas = document.createElement("canvas");
                canvas.width = width;
                canvas.height = height;
                const ctx = canvas.getContext("2d");
                ctx.drawImage(img, 0, 0, width, height);
                const data = ctx.getImageData(0, 0, width, height).data;
                let sum = 0;
                let sumSq = 0;
                let edgeCount = 0;
                const total = width * height;
                const lum = new Array(total);
                for (let i = 0, p = 0; i < total; i++, p += 4) {
                    const value =
                        0.2126 * data[p] + 0.7152 * data[p + 1] + 0.0722 * data[p + 2];
                    lum[i] = value;
                    sum += value;
                    sumSq += value * value;
                }
                const mean = sum / total;
                const variance = sumSq / total - mean * mean;
                for (let y = 0; y < height - 1; y++) {
                    for (let x = 0; x < width - 1; x++) {
                        const idx = y * width + x;
                        const diff =
                            Math.abs(lum[idx] - lum[idx + 1]) +
                            Math.abs(lum[idx] - lum[idx + width]);
                        if (diff > 50) edgeCount++;
                    }
                }
                const edgeDensity = edgeCount / total;
                debugLog(
                    `Quick text scan: variance=${variance.toFixed(
                        1,
                    )} edge=${edgeDensity.toFixed(3)}`,
                );
                resolve(variance > 200 && edgeDensity > 0.02);
            };
            img.onerror = () => {
                debugLog("Quick text scan failed to load image, falling back to OCR.");
                resolve(true);
            };
            img.src = imageDataURL;
        });
    }

    function runOCR(imageDataURL) {
        statusBar.innerText = "Analyzing...";
        statusBar.classList.add("glowing"); // Start glowing effect
        return new Promise((resolve, reject) => {
            Tesseract.recognize(imageDataURL, "chi_sim", {
                logger: (m) => {
                    // console.log(m)
                    statusBar.innerText = `Analyzing (${(m.progress * 100).toFixed(2)}%)`;
                },
            })
                .then(({ data: { words } }) => {
                    const minConfidence = 85; // 置信度阈值,可调整
                    const filteredText = words
                        .filter((word) => word.confidence >= minConfidence)
                        .map((word) => word.text)
                        .join(" ");
                    const cleaned = cleanText(filteredText);
                    if (!cleaned) {
                        debugLog("OCR produced empty text.");
                        resolve("(No text detected)");
                        return;
                    }
                    if (!isMeaningfulOCRText(cleaned)) {
                        debugLog("OCR text not meaningful, skipping.");
                        resolve("(No text detected)");
                        return;
                    }
                    resolve(cleaned);
                })
                .catch((err) => {
                    console.error("OCR Error:", err);
                    resolve("(OCR Failed)");
                })
                .finally(() => {
                    statusBar.innerText = "Idle";
                    statusBar.classList.remove("glowing"); // Stop glowing effect
                });
        });
    }

    function processImages(callback) {
        if (!settings.INCLUDE_IMG) {
            return callback();
        }
        let images = document.querySelectorAll('img[onclick^="zoom"]');
        debugLog(`Found ${images.length} images for OCR processing.`);
        let tasks = [];

        images.forEach((img) => {
            let match = img.getAttribute("onclick").match(/zoom\(this, '([^']+)'\)/);
            if (match && match[1]) {
                let fullImageUrl = match[1];
                let spanId = hashString(fullImageUrl); // Generate unique ID
                debugLog(`Processing image: ${fullImageUrl} (ID: ${spanId})`);

                let existingSpan = document.getElementById(spanId);
                if (!existingSpan) {
                    let resultSpan = document.createElement("span");
                    resultSpan.className = "ocr-result";
                    resultSpan.id = spanId; // Assign unique ID
                    resultSpan.style.display = "none";
                    img.insertAdjacentElement("afterend", resultSpan);
                }

                let cachedText = getOCRCache(spanId);
                if (cachedText) {
                    debugLog(`OCR cache hit for #${spanId}`);
                    let resultSpan = document.getElementById(spanId);
                    if (resultSpan) {
                        resultSpan.textContent = cachedText;
                    }
                    return;
                }

                let task = fetchImageAsDataURL(fullImageUrl)
                    .then((dataURL) =>
                        quickHasText(dataURL).then((hasText) => ({
                            dataURL,
                            hasText,
                        })),
                    )
                    .then(({ dataURL, hasText }) => {
                        if (!hasText) {
                            return "(No text detected)";
                        }
                        return runOCR(dataURL);
                    })
                    .then((ocrText) => {
                        let resultSpan = document.getElementById(spanId); // Query by ID before updating
                        if (resultSpan) {
                            resultSpan.textContent = ocrText.trim()
                                ? ocrText
                                : "(No text detected)";
                            debugLog(`Inserted OCR text into #${spanId}`);
                            setOCRCache(spanId, resultSpan.textContent);
                        } else {
                            console.warn(
                                `⚠️ Could not find span #${spanId} to insert OCR text.`,
                            );
                        }
                    });

                tasks.push(task);
            }
        });

        Promise.all(tasks).then(() => {
        debugLog(`OCR completed for ${images.length} images.`);
            if (typeof callback === "function") callback();
        });
    }

    // 快速回复/快速引用,防止进入富文本模式(AI会失去上下文)

    function simpleReply(user, pid, ptid) {
        let textarea = document.querySelector("#fastpostmessage");
        if (textarea) {
            let replyText = `[b]回复 [url=https://www.4d4y.com/forum/redirect.php?goto=findpost&pid=${pid}&ptid=${ptid}]#${pid}[/url] [i]${user}[/i] [/b]\n\n`;
            textarea.value = replyText; // 直接替换,清空原内容
            textarea.focus();
        }
    }

    function simpleQuote(user, postTime, pid, ptid, content) {
        let textarea = document.querySelector("#fastpostmessage");
        if (textarea) {
            let trimmedContent =
                content.length > 200 ? content.substring(0, 200) + " ..." : content;
            let quoteText = `[quote]${trimmedContent}\n[size=2][color=#999999]${user} 发表于 ${postTime}[/color] [url=https://www.4d4y.com/forum/redirect.php?goto=findpost&pid=${pid}&ptid=${ptid}][img]https://www.4d4y.com/forum/images/common/back.gif[/img][/url][/size][/quote]\n\n`;
            insertTextAtCursor(textarea, quoteText); // 插入到光标位置,不清空
        }
    }

    function modifyReplyButtons() {
        let tid = getTidFromUrl();
        if (!tid) return;
        let context = getPageContext();
        document.querySelectorAll("a.fastreply").forEach((btn) => {
            let match = btn.href.match(/reppost=(\d+)/);
            if (match) {
                let pid = match[1];
                let post = context.posts.find((p) => p.id === pid);
                if (post) {
                    let newBtn = document.createElement("a");
                    newBtn.innerText = btn.innerText; // 保持原文本
                    newBtn.style.cursor = "pointer";
                    newBtn.style.color = btn.style.color;
                    newBtn.style.fontSize = btn.style.fontSize;
                    newBtn.style.textDecoration = btn.style.textDecoration;

                    newBtn.addEventListener("click", function (e) {
                        e.preventDefault();
                        simpleReply(post.poster, pid, tid);
                    });

                    btn.replaceWith(newBtn); // 替换原来的<a>
                }
            }
        });

        document.querySelectorAll("a.repquote").forEach((btn) => {
            let match = btn.href.match(/repquote=(\d+)/);
            if (match) {
                let pid = match[1];
                let post = context.posts.find((p) => p.id === pid);
                if (post) {
                    let newBtn = document.createElement("a");
                    newBtn.innerText = btn.innerText; // 保持原文本
                    newBtn.style.cursor = "pointer";
                    newBtn.style.color = btn.style.color;
                    newBtn.style.fontSize = btn.style.fontSize;
                    newBtn.style.textDecoration = btn.style.textDecoration;

                    newBtn.addEventListener("click", function (e) {
                        e.preventDefault();
                        simpleQuote(post.poster, post.postTime, pid, tid, post.content);
                    });

                    btn.replaceWith(newBtn); // 替换原来的<a>
                }
            }
        });
    }

    if (settings.FAST_REPLY_BTN) {
        modifyReplyButtons();
    }
    queryAvailableOllamaModels();
    createStatusBar();
    initGateWorker();
    processImages(() => {
        createInlineOverlay();
        initListeners();
        activeElement = document.querySelector("#fastpostmessage");
        // Auto mode removed; user manually triggers completion.
    });
})();