Text Explainer

Explain selected text using LLM

目前为 2025-03-04 提交的版本。查看 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址