// ==UserScript==
// @name Text Explainer
// @namespace http://tampermonkey.net/
// @version 0.2.0
// @description Explain selected text using LLM
// @author RoCry
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect generativelanguage.googleapis.com
// @connect *
// @run-at document-end
// @inject-into content
// @require https://update.gf.qytechs.cn/scripts/528704/1547031/SmolLLM.js
// @require https://update.gf.qytechs.cn/scripts/528703/1546610/SimpleBalancer.js
// @require https://update.gf.qytechs.cn/scripts/528763/1547386/Text%20Explainer%20Settings.js
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// Initialize settings manager with extended default config
const settingsManager = new TextExplainerSettings({
model: "gemini-2.0-flash",
apiKey: null,
baseUrl: "https://generativelanguage.googleapis.com",
provider: "gemini",
language: "Chinese", // Default language
shortcut: {
key: "d",
ctrlKey: false,
altKey: true,
shiftKey: false,
metaKey: false
},
floatingButton: {
enabled: true,
size: "medium",
position: "bottom-right"
}
});
// Get current configuration
let config = settingsManager.getAll();
// Initialize SmolLLM
let llm;
try {
llm = new SmolLLM();
} catch (error) {
console.error('Failed to initialize SmolLLM:', error);
llm = null;
}
// Check if device is touch-enabled
const isTouchDevice = () => {
return ('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0);
};
const isIOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
// Create and manage floating button
let floatingButton = null;
function createFloatingButton() {
if (floatingButton) return;
floatingButton = document.createElement('div');
floatingButton.id = 'explainer-floating-button';
// Determine size based on settings
let buttonSize;
switch (config.floatingButton.size) {
case 'small': buttonSize = '40px'; break;
case 'large': buttonSize = '60px'; break;
default: buttonSize = '50px'; // medium
}
// Position based on settings
let positionCSS;
switch (config.floatingButton.position) {
case 'top-left': positionCSS = 'top: 20px; left: 20px;'; break;
case 'top-right': positionCSS = 'top: 20px; right: 20px;'; break;
case 'bottom-left': positionCSS = 'bottom: 20px; left: 20px;'; break;
default: positionCSS = 'bottom: 20px; right: 20px;'; // bottom-right
}
floatingButton.style.cssText = `
width: ${buttonSize};
height: ${buttonSize};
border-radius: 50%;
background-color: rgba(33, 150, 243, 0.8);
color: white;
display: flex;
align-items: center;
justify-content: center;
position: fixed;
${positionCSS}
z-index: 9999;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-weight: bold;
font-size: ${parseInt(buttonSize) * 0.4}px;
opacity: 0;
transition: opacity 0.3s ease, transform 0.2s ease;
pointer-events: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
`;
// Add icon or text
floatingButton.innerHTML = '✓';
// Add to DOM
document.body.appendChild(floatingButton);
// Add click event
floatingButton.addEventListener('click', (e) => {
e.preventDefault();
processSelectedText();
});
// Prevent text selection on button
floatingButton.addEventListener('mousedown', (e) => {
e.preventDefault();
});
}
function showFloatingButton() {
if (!floatingButton || !config.floatingButton.enabled) return;
// Make visible and enable pointer events
floatingButton.style.opacity = '1';
floatingButton.style.pointerEvents = 'auto';
// Add active effect for touch
floatingButton.addEventListener('touchstart', () => {
floatingButton.style.transform = 'scale(0.95)';
});
floatingButton.addEventListener('touchend', () => {
floatingButton.style.transform = 'scale(1)';
});
}
function hideFloatingButton() {
if (!floatingButton) return;
floatingButton.style.opacity = '0';
floatingButton.style.pointerEvents = 'none';
}
// Add minimal styles for UI components
GM_addStyle(`
#explainer-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 450px;
max-width: 90vw;
max-height: 80vh;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
z-index: 10000;
overflow: auto;
padding: 16px;
}
#explainer-loading {
text-align: center;
padding: 20px 0;
display: flex;
align-items: center;
justify-content: center;
}
#explainer-loading:after {
content: "";
width: 24px;
height: 24px;
border: 3px solid #ddd;
border-top: 3px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
display: inline-block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#explainer-error {
color: #d32f2f;
padding: 8px;
border-radius: 4px;
margin-bottom: 10px;
font-size: 14px;
display: none;
}
/* Dark mode support - minimal */
@media (prefers-color-scheme: dark) {
#explainer-popup {
background: rgba(35, 35, 40, 0.7);
color: #e0e0e0;
}
#explainer-error {
background-color: rgba(100, 25, 25, 0.4);
color: #ff8a8a;
}
#explainer-floating-button {
background-color: rgba(33, 150, 243, 0.9);
}
}
`);
// Function to close the popup
function closePopup() {
const popup = document.getElementById('explainer-popup');
if (popup) {
popup.remove();
}
}
// Create popup
function createPopup() {
// Remove existing popup if any
closePopup();
const popup = document.createElement('div');
popup.id = 'explainer-popup';
popup.innerHTML = `
<div id="explainer-error"></div>
<div id="explainer-loading"></div>
<div id="explainer-content"></div>
`;
document.body.appendChild(popup);
// Add event listener for Escape key
document.addEventListener('keydown', handleEscKey);
// Add event listener for clicking outside popup
document.addEventListener('click', handleOutsideClick);
return popup;
}
// Handle Escape key to close popup
function handleEscKey(e) {
if (e.key === 'Escape') {
closePopup();
document.removeEventListener('keydown', handleEscKey);
document.removeEventListener('click', handleOutsideClick);
}
}
// Handle clicks outside popup to close it
function handleOutsideClick(e) {
const popup = document.getElementById('explainer-popup');
if (popup && !popup.contains(e.target)) {
closePopup();
document.removeEventListener('keydown', handleEscKey);
document.removeEventListener('click', handleOutsideClick);
}
}
// Function to show an error in the popup
function showError(message) {
const errorDiv = document.getElementById('explainer-error');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
document.getElementById('explainer-loading').style.display = 'none';
}
}
// Function to get text from selected element
function getSelectedText() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.toString().trim() === '') {
return { selectionText: null, paragraphText: null };
}
const selectionText = selection.toString().trim();
// Get the paragraph containing the selection
const range = selection.getRangeAt(0);
let paragraphElement = range.commonAncestorContainer;
// Navigate up to find a paragraph or meaningful content container
while (paragraphElement &&
(paragraphElement.nodeType !== Node.ELEMENT_NODE ||
!['P', 'DIV', 'ARTICLE', 'SECTION', 'LI'].includes(paragraphElement.tagName))) {
paragraphElement = paragraphElement.parentNode;
}
let paragraphText = '';
if (paragraphElement) {
// Get text content but limit to a reasonable size
paragraphText = paragraphElement.textContent.trim();
if (paragraphText.length > 500) {
paragraphText = paragraphText.substring(0, 497) + '...';
}
}
return { selectionText, paragraphText };
}
// Function to call the LLM using SmolLLM
async function callLLM(prompt, systemPrompt, progressCallback) {
if (!config.apiKey) {
throw new Error("Please set up your API key in the settings.");
}
if (!llm) {
throw new Error("SmolLLM library not initialized. Please check console for errors.");
}
console.log(`prompt: ${prompt}`);
console.log(`systemPrompt: ${systemPrompt}`);
try {
return await llm.askLLM({
prompt: prompt,
systemPrompt: systemPrompt,
model: config.model,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
providerName: config.provider,
handler: progressCallback,
timeout: 60000
});
} catch (error) {
console.error('LLM API error:', error);
throw error;
}
}
function getPrompt(selectionText, paragraphText) {
const wordsCount = selectionText.split(' ').length;
const defaultSystemPrompt = `You will response in ${config.language} with basic html format like <p> <b> <i> <a> <li> <ol> <ul> to improve readability.
Do NOT wrap your response in code block.`;
if (selectionText === paragraphText || wordsCount >= 500) {
// Summary prompt
return [
`Summarize the following text in ${config.language}, using bullet points to improve readability:\n\n${selectionText}`,
defaultSystemPrompt
];
}
if (wordsCount > 3) {
// Translate prompt
return [
`Translate the following text into ${config.language}, no extra explanation, just the translation:\n\n${selectionText}`,
defaultSystemPrompt
];
}
const pinYinExtraPrompt = config.language === "Chinese" ? ' DO NOT add Pinyin for it.' : '';
const ipaExtraPrompt = config.language === "Chinese" ? '(with IPA if necessary)' : '';
const asciiChars = selectionText.replace(/[\s\.,\-_'"!?()]/g, '')
.split('')
.filter(char => char.charCodeAt(0) <= 127).length;
const sampleSentenceLanguage = selectionText.length === asciiChars ? "English" : config.language;
// Explain words prompt
return [
`Provide an explanation for the word: "${selectionText}${ipaExtraPrompt}" in ${config.language}.${pinYinExtraPrompt}
Use the context from the surrounding paragraph to inform your explanation when relevant:
"${paragraphText}"
# Consider these scenarios:
## Names
If "${selectionText}" is a person's name, company name, or organization name, provide a brief description (e.g., who they are or what they do).
e.g.
Alan Turing was a British mathematician and computer scientist. He is widely considered to be the father of theoretical computer science and artificial intelligence.
His work was crucial to:
• Formalizing the concepts of algorithm and computation with the Turing machine.
• Breaking the German Enigma code during World War II, significantly contributing to the Allied victory.
• Developing the Turing test, a benchmark for artificial intelligence.
## Technical Terms
If "${selectionText}" is a technical term or jargon, give a concise definition and explain the use case or context where it is commonly used. No need example sentences.
e.g. GAN → 生成对抗网络
生成对抗网络(Generative Adversarial Network),是一种深度学习框架,由Ian Goodfellow在2014年提出。GAN包含两个神经网络:生成器(Generator)和判别器(Discriminator),它们相互对抗训练。生成器尝试创建看起来真实的数据,而判别器则尝试区分真实数据和生成的假数据。通过这种"博弈"过程,生成器逐渐学会创建越来越逼真的数据。
## Normal Words
- For any other word, explain its meaning and provide 1-2 example sentences with the word in ${sampleSentenceLanguage}.
e.g. jargon \\ˈdʒɑrɡən\\ → 行话,专业术语,特定领域内使用的专业词汇。在计算机科学和编程领域,指那些对外行人难以理解的专业术语和缩写。
例句: "When explaining code to beginners, try to avoid using too much technical jargon that might confuse them."(向初学者解释代码时,尽量避免使用太多可能让他们困惑的技术行话。)
# Format
- Output the words first, then the explanation, and then the example sentences in ${sampleSentenceLanguage} if necessary.
- No extra explanation
- Remember to using proper html format like <p> <b> <i> <a> <li> <ol> <ul> to improve readability.
`,
defaultSystemPrompt
];
}
// Main function to process selected text
async function processSelectedText() {
const { selectionText, paragraphText } = getSelectedText();
if (!selectionText) {
alert('Please select some text first.');
return;
}
console.log(`Selected text: '${selectionText}', Paragraph text:\n${paragraphText}`);
// Create popup
createPopup();
const contentDiv = document.getElementById('explainer-content');
const loadingDiv = document.getElementById('explainer-loading');
const errorDiv = document.getElementById('explainer-error');
// Reset display
errorDiv.style.display = 'none';
loadingDiv.style.display = 'block';
// Assemble prompt with language preference
const [prompt, systemPrompt] = getPrompt(selectionText, paragraphText);
// Variable to store ongoing response text
let responseText = '';
let responseStartTime = Date.now();
try {
// Call LLM with progress callback and await the full response
const fullResponse = await callLLM(prompt, systemPrompt, (textChunk, currentFullText) => {
// Update response text with new chunk
responseText = currentFullText || (responseText + textChunk);
// Hide loading message if this is the first chunk
if (loadingDiv.style.display !== 'none') {
loadingDiv.style.display = 'none';
}
// Update content with either HTML or markdown
updateContentDisplay(contentDiv, responseText);
});
// If we got a response
if (fullResponse && fullResponse.length > 0) {
responseText = fullResponse;
loadingDiv.style.display = 'none';
updateContentDisplay(contentDiv, fullResponse);
}
// If no response was received at all
else if (!fullResponse || fullResponse.length === 0) {
// If we've received chunks but the final response is empty, use the accumulated text
if (responseText && responseText.length > 0) {
updateContentDisplay(contentDiv, responseText);
} else {
showError("No response received from the model. Please try again.");
}
}
// Hide loading indicator if it's still visible
if (loadingDiv.style.display !== 'none') {
loadingDiv.style.display = 'none';
}
} catch (error) {
console.error('Error:', error);
// Display error in popup
showError(`Error: ${error.message}`);
}
}
// Main function to handle keyboard shortcuts
function handleKeyPress(e) {
// Get shortcut configuration from settings
const shortcut = config.shortcut || { key: 'd', ctrlKey: false, altKey: true, shiftKey: false, metaKey: false };
// Check if the pressed keys match the configured shortcut
if (e.key.toLowerCase() === shortcut.key.toLowerCase() &&
e.ctrlKey === !!shortcut.ctrlKey &&
e.altKey === !!shortcut.altKey &&
e.shiftKey === !!shortcut.shiftKey &&
e.metaKey === !!shortcut.metaKey) {
e.preventDefault();
processSelectedText();
}
}
// Helper function to update content display
function updateContentDisplay(contentDiv, text) {
if (!text) return;
if (!text.trim().startsWith('<')) {
// fallback
console.log(`Seems like the response is not HTML: ${text}`);
text = `<p>${text.replace(/\n/g, '<br>')}</p>`;
}
contentDiv.innerHTML = text;
}
// Monitor selection changes for floating button
function handleSelectionChange() {
const selection = window.getSelection();
const hasSelection = selection && selection.toString().trim() !== '';
if (hasSelection && isTouchDevice() && config.floatingButton.enabled) {
showFloatingButton();
} else {
hideFloatingButton();
}
}
// Settings update callback
function onSettingsChanged(updatedConfig) {
config = updatedConfig;
console.log('Settings updated:', config);
// Recreate floating button if settings changed
if (floatingButton) {
floatingButton.remove();
floatingButton = null;
if (isTouchDevice() && config.floatingButton.enabled) {
createFloatingButton();
handleSelectionChange(); // Check if there's already a selection
}
}
}
// Initialize the script
function init() {
// Register settings menu in Tampermonkey
GM_registerMenuCommand("Text Explainer Settings", () => {
settingsManager.openDialog(onSettingsChanged);
});
// Add keyboard shortcut listener
document.addEventListener('keydown', handleKeyPress);
// For touch devices, create floating button
if (isTouchDevice() && config.floatingButton.enabled) {
createFloatingButton();
// Monitor text selection
document.addEventListener('selectionchange', handleSelectionChange);
// Add touchend handler to show button after selection
document.addEventListener('touchend', () => {
// Small delay to ensure selection is updated
setTimeout(handleSelectionChange, 100);
});
}
console.log('Text Explainer script initialized with language: ' + config.language);
console.log('Touch device detected: ' + isTouchDevice());
}
// Run initialization
init();
})();