Text Explainer

Explain selected text using LLM

当前为 2025-03-04 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.greasyfork.org/scripts/528704/1547031/SmolLLM.js
// @require      https://update.greasyfork.org/scripts/528703/1546610/SimpleBalancer.js
// @require      https://update.greasyfork.org/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();
})();