您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
AI-powered auto-completion with page context
当前为
// ==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); } }); })();