Greasy Fork 支持简体中文。

4d4y.ai

AI-powered auto-completion with page context

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

// ==UserScript==
// @name         4d4y.ai
// @namespace    http://tampermonkey.net/
// @version      4.0
// @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 FAST_REPLY_BTN = true; // 回复/引用的时候,避免跳转到富文本编辑器,让AI有上下文
  const ATTITUDE = ""; // 描述语气
  const INCLUDE_IMG = true; // 是否分析文字图片
  const OLLAMA_URL = "http://localhost:11434/api/generate";
  const MODEL_NAME = "deepseek-r1:14b";
  const DOUBLE_TAB_MODE = true; // 双击TAB才请求自动补全,体验更丝滑
  let LAST_TAB_TIME = 0;

  // OpenAI 风格配置
  const OpenAIConfig = {
    enabled: false, // 默认关闭,使用本地ollama模型;改成true,就会调用在线API
    API_URL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", // 火山引擎,阿里云等
    apiKey: "", // API密匙,请妥善保管
    model: "ep-20250221224443-5q7rb", // 模型名称, 确保已经在控制台开启
    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());
            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();

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

          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 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")) {
        activeElement = event.target;
        // query 激活 listener
        if (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: { 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();
    });
  }

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

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

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