Greasy Fork 支持简体中文。

4d4y.ai

AI-powered auto-completion with page context

目前為 2025-02-21 提交的版本,檢視 最新版本

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

(() => {
    // 自动模式配置
    const AUTO_MODE = false; // 仅自动模式有效:载入页面自动开始准备回复,并插入回复框
    const AUTO_LEADING_WORDS = "根据楼上的回复,我已经找到以下杠精:"; // 仅自动模式有效:引导词,不会出现在回复中
    const AUTO_PREFIX = "论坛杠精纠察员:\n"; // 仅自动模式有效:回复前缀,可以用来表明AI身份

    // 全局变量
    const ATTITUDE = ""; // 描述语气
    const INCLUDE_IMG = true; // 是否分析文字图片
    const OLLAMA_URL = "http://localhost:11434/api/generate";
    const MODEL_NAME = "deepseek-r1:14b";

    // OpenAI 风格配置
    const OpenAIConfig = {
        enabled: false, // 默认关闭,使用本地ollama模型;改成true,就会调用在线API
        API_URL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", // 火山引擎,阿里云等
        apiKey: "", // API密匙,请妥善保管
        model: "deepseek-r1-distill-qwen-32b-250120", // 模型名称, 确保已经在控制台开启
        options: {} // 高级配置项,比如温度等
    }

    let lastRequest = null;
    let suggestionBox = null;
    let statusBar = null;
    let activeElement = null;
    let suggestion = "";
    let lastInputText = "";
    let debounceTimer = 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") || ""; // Extract id attribute
                        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());

                        // 提取 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();

                        // 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({
                                id: postId,
                                poster,
                                content,
                                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,
        };
        const 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, ${ATTITUDE}`;
        console.log(prompt);

        statusBar.innerText = "Fetching...";
        statusBar.classList.add("glowing"); // Start glowing effect

        function responseHandler(url, response) {
            // 根据URL来判断API供应商,并进行相应处理,返回content
            if (url === 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 (OpenAIConfig.enabled) {
            // 在线AI
            url = OpenAIConfig.API_URL;
            headers = { "Content-Type": "application/json", "Authorization": `Bearer ${OpenAIConfig.apiKey}` };
            payload = {
                model: OpenAIConfig.model,
                messages: [
                    { "role": "system", "content": "你是人工智能助手,精通网络术语,按照我的要求生成回复" },
                    { "role": "user", "content": prompt },
                ],
                stream: false,
                ...OpenAIConfig.options
            }
        } else {
            // 本地 ollama
            url = OLLAMA_URL;
            headers = { "Content-Type": "application/json" };
            payload = {
                model: 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();
                    updateSuggestionBox({
                        left: rect.left + cursorPosition * 7, // Estimate cursor X pos
                        top: rect.top + window.scrollY,
                    });
                }
                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 handleKeyDown(event) {
        if (event.key === "Tab" && suggestion) {
            event.preventDefault();
            event.stopPropagation();

            if (activeElement.tagName === "TEXTAREA") {
                const start = activeElement.selectionStart;
                const end = activeElement.selectionEnd;
                activeElement.value =
                    activeElement.value.substring(0, start) +
                    suggestion +
                    activeElement.value.substring(end);
                activeElement.selectionStart = activeElement.selectionEnd =
                    start + suggestion.length;
            }

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

    function initListeners() {
        document.addEventListener("focusin", (event) => {
            if (event.target.matches("textarea, [contenteditable='true']")) {
                activeElement = event.target;
                activeElement.addEventListener("input", handleInput);
                activeElement.addEventListener("keydown", handleKeyDown);
                lastInputText = getTextFromElement(activeElement);
            }
        });

        document.addEventListener("focusout", () => {
            suggestionBox.style.display = "none";
        });
    }
    // 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: { text } }) => resolve(cleanText(text)))
                .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 (!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();
        });
    }

    createStatusBar();
    processImages(() => {
        createSuggestionBox();
        initListeners();
        activeElement = document.querySelector("#fastpostmessage");
        if (AUTO_MODE && activeElement) {
            setTimeout(() => {
                fetchCompletion(AUTO_LEADING_WORDS);
                const s = setInterval(() => {
                    if (suggestion !== "") {
                        activeElement.value = AUTO_PREFIX + suggestion;
                        suggestionBox.style.display = "none";
                        suggestion = "";
                        clearInterval(s);
                    }
                }, 100);
            }, 1000);
        }
    });
})();