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