4d4y.ai

AI-powered auto-completion with page context

目前为 2025-02-19 提交的版本。查看 最新版本

// ==UserScript==
// @name         4d4y.ai
// @namespace    http://tampermonkey.net/
// @version      2.1
// @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
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/segmentit.min.js
// ==/UserScript==

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

  const ATTITUDE = "我是论坛杠精纠察员,谴责抬杠行为,指出谁是杠精,为什么是杠精"; // 描述语气
  const INCLUDE_IMG = false; // 是否分析文字图片
  const API_URL = "http://localhost:11434/api/generate";
  const MODEL_NAME = "deepseek-r1:14b";

  let lastRequest = null;
  let suggestionBox = null;
  let statusBar = null;
  let activeElement = null;
  let suggestion = "";
  let lastInputText = "";
  let debounceTimer = null;

  const segmentit = Segmentit.useDefault(new Segmentit.Segment());

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

            // 提取 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

    lastRequest = GM_xmlhttpRequest({
      method: "POST",
      url: API_URL,
      headers: { "Content-Type": "application/json" },
      data: JSON.stringify({
        model: MODEL_NAME,
        prompt: prompt,
        stream: false,
      }),
      onload: (response) => {
        const data = JSON.parse(response.responseText);
        suggestion = data.response.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();
    if (cleanText.length < 3) return false;

    // 中文分词
    let words = segmentit.doSegment(cleanText);
    // 过滤无意义的单个汉字
    words = words.filter((word) => word.w.length > 1);
    return words.length > 1; // 至少包含 1 个有意义的词
  }

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