Greasy Fork 支持简体中文。

4d4y.ai

AI-powered auto-completion with page context

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

// ==UserScript==
// @name         4d4y.ai
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  AI-powered auto-completion with page context
// @author       屋大维
// @license      MIT
// @match        *://www.4d4y.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// ==/UserScript==

(() => {
  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;
  let pageContext = getPageContext();

  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 content =
              post
                .querySelector(".postcontent td.t_msgfont")
                ?.innerText.trim() || "";

            if (content) {
              context.posts.push({ id: postId, poster, content });
            }
          }
        }
      }
    } 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;
            }
        `);
  }

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

    return suggestion;
  }

  function fetchCompletion(inputText) {
    if (lastRequest) lastRequest.abort(); // Cancel previous request

    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, but we can always check if they are logical. 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`;

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

  createStatusBar();
  createSuggestionBox();
  initListeners();
})();