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.
    });
})();