Greasy Fork 支持简体中文。

4d4y.ai

AI-powered auto-completion with page context

// ==UserScript==
// @name         4d4y.ai
// @namespace    http://tampermonkey.net/
// @version      5.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 = {
        AUTO_MODE: false,
        AUTO_LEADING_WORDS: "根据楼上的回复,我已经找到以下杠精:",
        AUTO_PREFIX: "论坛杠精纠察员:\n",
        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,
        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>⚡ Auto Mode Settings</h4>
          <label><input type="checkbox" id="AUTO_MODE"> Enable Auto Mode</label><br>
          <label>Leading Words: <input type="text" id="AUTO_LEADING_WORDS" style="width: 100%;"></label><br>
          <label>Reply Prefix: <input type="text" id="AUTO_PREFIX" style="width: 100%;"></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>

          <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 suggestionBox = null;
    let statusBar = null;
    let activeElement = null;
    let suggestion = "";
    let lastInputText = "";
    let debounceTimer = null;
    let LAST_TAB_TIME = 0;

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

    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 createSuggestionBox() {
        suggestionBox = document.createElement("div");
        suggestionBox.id = "ai-suggestion-box";
        document.body.appendChild(suggestionBox);
        GM_addStyle(`
                #ai-suggestion-box {
                    position: absolute;
                    background: rgba(50, 50, 50, 0.9);
                    color: #fff;
                    padding: 5px 10px;
                    font-size: 14px;
                    border-radius: 5px;
                    box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.3);
                    display: none;
                }
            `);
    }

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

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

    function updateSuggestionBox(position) {
        if (!suggestion || !suggestionBox) return;
        suggestionBox.style.left = position.left + "px";
        suggestionBox.style.top = position.top + 20 + "px";
        suggestionBox.innerText = suggestion;
        suggestionBox.style.display = "block";
    }
    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 fetchCompletion(inputText) {
        if (lastRequest) lastRequest.abort(); // Cancel previous request
        const pageContext = getPageContext();
        const promptData = {
            title: pageContext.title,
            posts: pageContext.posts,
        };
        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, just continue it, be concise, be short, no quotes, avoid markdown, do not repeat my words, ${settings.ATTITUDE}`;
        if (settings.ENABLE_TOKEN_SAVER) {
            prompt = tokenSaver(prompt);
        }
        console.log(prompt);

        statusBar.innerText = "Fetching...";
        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 (suggestion) {
                    const cursorPosition = activeElement.selectionStart || 0;
                    const rect = activeElement.getBoundingClientRect();

                    let left = rect.left + cursorPosition * 7; // Estimate cursor X position
                    let top = rect.top + window.scrollY;

                    // Ensure `left` does not exceed the right boundary of the textarea
                    const maxLeft = rect.right; // Right edge of textarea
                    const minLeft = rect.left; // Left edge of textarea
                    left = Math.min(Math.max(left, minLeft), maxLeft);

                    // Ensure `top` stays within the viewport
                    const maxTop = window.innerHeight + window.scrollY - 50; // Avoid bottom overflow
                    top = Math.max(0, Math.min(top, maxTop));

                    updateSuggestionBox({ left, top });
                }

                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);

        if (wordCount < 3) {
            statusBar.innerText = "Waiting for more input...";
            suggestionBox.style.display = "none";
            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);
        debounceTimer = setTimeout(() => fetchCompletion(text), 1000);

        lastInputText = text;
    }

    function handleDoubleTab(event) {
        if (event.key === "Tab") {
            event.preventDefault();
            event.stopPropagation();

            const currentTime = new Date().getTime();
            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);
            }

            LAST_TAB_TIME = currentTime;
        }
    }

    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" && suggestion) {
            event.preventDefault();
            event.stopPropagation();

            if (activeElement.tagName === "TEXTAREA") {
                insertTextAtCursor(activeElement, suggestion);
            }

            suggestionBox.style.display = "none";
            suggestion = "";
        }
    }

    function initListeners() {
        document.addEventListener("focusin", (event) => {
            if (event.target.matches("textarea")) {
                activeElement = event.target;
                // query 激活 listener
                if (settings.DOUBLE_TAB_MODE) {
                    // 双击TAB引发query
                    activeElement.addEventListener("keydown", handleDoubleTab);
                } else {
                    // 用户输入引发query
                    activeElement.addEventListener("input", handleInput);
                }
                // 插入补全 listener
                activeElement.addEventListener("keydown", handleKeyDown);
                lastInputText = getTextFromElement(activeElement);
            }
        });

        document.addEventListener("focusout", () => {
            suggestionBox.style.display = "none";
            suggestion = "";
        });
    }
    // 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);
        console.log(`Cleaned OCR text: ${cleaned}`);
        return cleaned;
    }

    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(" ");
                    resolve(cleanText(filteredText));
                })
                .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"]');
        console.log(`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
                console.log(`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 task = fetchImageAsDataURL(fullImageUrl)
                    .then((dataURL) => runOCR(dataURL))
                    .then((ocrText) => {
                        let resultSpan = document.getElementById(spanId); // Query by ID before updating
                        if (resultSpan) {
                            resultSpan.textContent = ocrText.trim()
                                ? ocrText
                                : "(No text detected)";
                            console.log(`✅ Inserted OCR text into #${spanId}`);
                        } else {
                            console.warn(
                                `⚠️ Could not find span #${spanId} to insert OCR text.`,
                            );
                        }
                    });

                tasks.push(task);
            }
        });

        Promise.all(tasks).then(() => {
            console.log(`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();
    processImages(() => {
        createSuggestionBox();
        initListeners();
        activeElement = document.querySelector("#fastpostmessage");
        if (settings.AUTO_MODE && activeElement) {
            setTimeout(() => {
                fetchCompletion(settings.AUTO_LEADING_WORDS);
                const s = setInterval(() => {
                    if (suggestion !== "") {
                        activeElement.value = settings.AUTO_PREFIX + suggestion;
                        suggestionBox.style.display = "none";
                        suggestion = "";
                        clearInterval(s);
                    }
                }, 100);
            }, 1000);
        }
    });
})();