Text Explainer

Explain selected text using LLM

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

  1. // ==UserScript==
  2. // @name Text Explainer
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.1
  5. // @description Explain selected text using LLM
  6. // @author RoCry
  7. // @match *://*/*
  8. // @grant GM_setValue
  9. // @grant GM_getValue
  10. // @grant GM_addStyle
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_registerMenuCommand
  13. // @connect generativelanguage.googleapis.com
  14. // @connect *
  15. // @run-at document-end
  16. // @inject-into content
  17. // @require https://update.greasyfork.org/scripts/528704/1547031/SmolLLM.js
  18. // @require https://update.greasyfork.org/scripts/528703/1546610/SimpleBalancer.js
  19. // @require https://update.greasyfork.org/scripts/528763/1547395/Text%20Explainer%20Settings.js
  20. // @license MIT
  21. // ==/UserScript==
  22.  
  23. (function () {
  24. 'use strict';
  25.  
  26. // Initialize settings manager with extended default config
  27. const settingsManager = new TextExplainerSettings({
  28. model: "gemini-2.0-flash",
  29. apiKey: null,
  30. baseUrl: "https://generativelanguage.googleapis.com",
  31. provider: "gemini",
  32. language: "Chinese", // Default language
  33. shortcut: {
  34. key: "d",
  35. ctrlKey: false,
  36. altKey: true,
  37. shiftKey: false,
  38. metaKey: false
  39. },
  40. floatingButton: {
  41. enabled: true,
  42. size: "medium",
  43. position: "bottom-right"
  44. }
  45. });
  46. // Get current configuration
  47. let config = settingsManager.getAll();
  48.  
  49. // Initialize SmolLLM
  50. let llm;
  51. try {
  52. llm = new SmolLLM();
  53. } catch (error) {
  54. console.error('Failed to initialize SmolLLM:', error);
  55. llm = null;
  56. }
  57.  
  58. // Check if device is touch-enabled
  59. const isTouchDevice = () => {
  60. return ('ontouchstart' in window) ||
  61. (navigator.maxTouchPoints > 0) ||
  62. (navigator.msMaxTouchPoints > 0);
  63. };
  64. const isIOS = () => {
  65. return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
  66. };
  67. // Create and manage floating button
  68. let floatingButton = null;
  69. function createFloatingButton() {
  70. if (floatingButton) return;
  71. floatingButton = document.createElement('div');
  72. floatingButton.id = 'explainer-floating-button';
  73. // Determine size based on settings
  74. let buttonSize;
  75. switch (config.floatingButton.size) {
  76. case 'small': buttonSize = '40px'; break;
  77. case 'large': buttonSize = '60px'; break;
  78. default: buttonSize = '50px'; // medium
  79. }
  80. // Position based on settings
  81. let positionCSS;
  82. switch (config.floatingButton.position) {
  83. case 'top-left': positionCSS = 'top: 20px; left: 20px;'; break;
  84. case 'top-right': positionCSS = 'top: 20px; right: 20px;'; break;
  85. case 'bottom-left': positionCSS = 'bottom: 20px; left: 20px;'; break;
  86. default: positionCSS = 'bottom: 20px; right: 20px;'; // bottom-right
  87. }
  88. floatingButton.style.cssText = `
  89. width: ${buttonSize};
  90. height: ${buttonSize};
  91. border-radius: 50%;
  92. background-color: rgba(33, 150, 243, 0.8);
  93. color: white;
  94. display: flex;
  95. align-items: center;
  96. justify-content: center;
  97. position: fixed;
  98. ${positionCSS}
  99. z-index: 9999;
  100. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  101. cursor: pointer;
  102. font-weight: bold;
  103. font-size: ${parseInt(buttonSize) * 0.4}px;
  104. opacity: 0;
  105. transition: opacity 0.3s ease, transform 0.2s ease;
  106. pointer-events: none;
  107. touch-action: manipulation;
  108. -webkit-tap-highlight-color: transparent;
  109. `;
  110. // Add icon or text
  111. floatingButton.innerHTML = '✓';
  112. // Add to DOM
  113. document.body.appendChild(floatingButton);
  114. // Add click event
  115. floatingButton.addEventListener('click', (e) => {
  116. e.preventDefault();
  117. processSelectedText();
  118. });
  119. // Prevent text selection on button
  120. floatingButton.addEventListener('mousedown', (e) => {
  121. e.preventDefault();
  122. });
  123. }
  124. function showFloatingButton() {
  125. if (!floatingButton || !config.floatingButton.enabled) return;
  126. // Make visible and enable pointer events
  127. floatingButton.style.opacity = '1';
  128. floatingButton.style.pointerEvents = 'auto';
  129. // Add active effect for touch
  130. floatingButton.addEventListener('touchstart', () => {
  131. floatingButton.style.transform = 'scale(0.95)';
  132. });
  133. floatingButton.addEventListener('touchend', () => {
  134. floatingButton.style.transform = 'scale(1)';
  135. });
  136. }
  137. function hideFloatingButton() {
  138. if (!floatingButton) return;
  139. floatingButton.style.opacity = '0';
  140. floatingButton.style.pointerEvents = 'none';
  141. }
  142.  
  143. // Add minimal styles for UI components
  144. GM_addStyle(`
  145. #explainer-popup {
  146. position: fixed;
  147. top: 50%;
  148. left: 50%;
  149. transform: translate(-50%, -50%);
  150. width: 450px;
  151. max-width: 90vw;
  152. max-height: 80vh;
  153. background: rgba(255, 255, 255, 0.3);
  154. backdrop-filter: blur(15px);
  155. -webkit-backdrop-filter: blur(15px);
  156. border-radius: 8px;
  157. box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
  158. z-index: 10000;
  159. overflow: auto;
  160. padding: 16px;
  161. }
  162. #explainer-loading {
  163. text-align: center;
  164. padding: 20px 0;
  165. display: flex;
  166. align-items: center;
  167. justify-content: center;
  168. }
  169. #explainer-loading:after {
  170. content: "";
  171. width: 24px;
  172. height: 24px;
  173. border: 3px solid #ddd;
  174. border-top: 3px solid #2196F3;
  175. border-radius: 50%;
  176. animation: spin 1s linear infinite;
  177. display: inline-block;
  178. }
  179. @keyframes spin {
  180. 0% { transform: rotate(0deg); }
  181. 100% { transform: rotate(360deg); }
  182. }
  183. #explainer-error {
  184. color: #d32f2f;
  185. padding: 8px;
  186. border-radius: 4px;
  187. margin-bottom: 10px;
  188. font-size: 14px;
  189. display: none;
  190. }
  191. /* Dark mode support - minimal */
  192. @media (prefers-color-scheme: dark) {
  193. #explainer-popup {
  194. background: rgba(35, 35, 40, 0.7);
  195. color: #e0e0e0;
  196. }
  197. #explainer-error {
  198. background-color: rgba(100, 25, 25, 0.4);
  199. color: #ff8a8a;
  200. }
  201. #explainer-floating-button {
  202. background-color: rgba(33, 150, 243, 0.9);
  203. }
  204. }
  205. `);
  206.  
  207. // Function to close the popup
  208. function closePopup() {
  209. const popup = document.getElementById('explainer-popup');
  210. if (popup) {
  211. popup.remove();
  212. }
  213. }
  214.  
  215. // Create popup
  216. function createPopup() {
  217. // Remove existing popup if any
  218. closePopup();
  219.  
  220. const popup = document.createElement('div');
  221. popup.id = 'explainer-popup';
  222.  
  223. popup.innerHTML = `
  224. <div id="explainer-error"></div>
  225. <div id="explainer-loading"></div>
  226. <div id="explainer-content"></div>
  227. `;
  228.  
  229. document.body.appendChild(popup);
  230.  
  231. // Add event listener for Escape key
  232. document.addEventListener('keydown', handleEscKey);
  233. // Add event listener for clicking outside popup
  234. document.addEventListener('click', handleOutsideClick);
  235.  
  236. return popup;
  237. }
  238.  
  239. // Handle Escape key to close popup
  240. function handleEscKey(e) {
  241. if (e.key === 'Escape') {
  242. closePopup();
  243. document.removeEventListener('keydown', handleEscKey);
  244. document.removeEventListener('click', handleOutsideClick);
  245. }
  246. }
  247. // Handle clicks outside popup to close it
  248. function handleOutsideClick(e) {
  249. const popup = document.getElementById('explainer-popup');
  250. if (popup && !popup.contains(e.target)) {
  251. closePopup();
  252. document.removeEventListener('keydown', handleEscKey);
  253. document.removeEventListener('click', handleOutsideClick);
  254. }
  255. }
  256.  
  257. // Function to show an error in the popup
  258. function showError(message) {
  259. const errorDiv = document.getElementById('explainer-error');
  260. if (errorDiv) {
  261. errorDiv.textContent = message;
  262. errorDiv.style.display = 'block';
  263. document.getElementById('explainer-loading').style.display = 'none';
  264. }
  265. }
  266.  
  267. // Function to get text from selected element
  268. function getSelectedText() {
  269. const selection = window.getSelection();
  270. if (!selection || selection.rangeCount === 0 || selection.toString().trim() === '') {
  271. return { selectionText: null, paragraphText: null };
  272. }
  273.  
  274. const selectionText = selection.toString().trim();
  275.  
  276. // Get the paragraph containing the selection
  277. const range = selection.getRangeAt(0);
  278. let paragraphElement = range.commonAncestorContainer;
  279.  
  280. // Navigate up to find a paragraph or meaningful content container
  281. while (paragraphElement &&
  282. (paragraphElement.nodeType !== Node.ELEMENT_NODE ||
  283. !['P', 'DIV', 'ARTICLE', 'SECTION', 'LI'].includes(paragraphElement.tagName))) {
  284. paragraphElement = paragraphElement.parentNode;
  285. }
  286.  
  287. let paragraphText = '';
  288. if (paragraphElement) {
  289. // Get text content but limit to a reasonable size
  290. paragraphText = paragraphElement.textContent.trim();
  291. if (paragraphText.length > 500) {
  292. paragraphText = paragraphText.substring(0, 497) + '...';
  293. }
  294. }
  295.  
  296. return { selectionText, paragraphText };
  297. }
  298.  
  299. // Function to call the LLM using SmolLLM
  300. async function callLLM(prompt, systemPrompt, progressCallback) {
  301. if (!config.apiKey) {
  302. throw new Error("Please set up your API key in the settings.");
  303. }
  304.  
  305. if (!llm) {
  306. throw new Error("SmolLLM library not initialized. Please check console for errors.");
  307. }
  308.  
  309. console.log(`prompt: ${prompt}`);
  310. console.log(`systemPrompt: ${systemPrompt}`);
  311. try {
  312. return await llm.askLLM({
  313. prompt: prompt,
  314. systemPrompt: systemPrompt,
  315. model: config.model,
  316. apiKey: config.apiKey,
  317. baseUrl: config.baseUrl,
  318. providerName: config.provider,
  319. handler: progressCallback,
  320. timeout: 60000
  321. });
  322. } catch (error) {
  323. console.error('LLM API error:', error);
  324. throw error;
  325. }
  326. }
  327.  
  328. function getPrompt(selectionText, paragraphText) {
  329. const wordsCount = selectionText.split(' ').length;
  330. const defaultSystemPrompt = `You will response in ${config.language} with basic html format like <p> <b> <i> <a> <li> <ol> <ul> to improve readability.
  331. Do NOT wrap your response in code block.`;
  332.  
  333. if (selectionText === paragraphText || wordsCount >= 500) {
  334. // Summary prompt
  335. return [
  336. `Summarize the following text in ${config.language}, using bullet points to improve readability:\n\n${selectionText}`,
  337. defaultSystemPrompt
  338. ];
  339. }
  340.  
  341. if (wordsCount > 3) {
  342. // Translate prompt
  343. return [
  344. `Translate the following text into ${config.language}, no extra explanation, just the translation:\n\n${selectionText}`,
  345. defaultSystemPrompt
  346. ];
  347. }
  348.  
  349. const pinYinExtraPrompt = config.language === "Chinese" ? ' DO NOT add Pinyin for it.' : '';
  350. const ipaExtraPrompt = config.language === "Chinese" ? '(with IPA if necessary)' : '';
  351. const asciiChars = selectionText.replace(/[\s\.,\-_'"!?()]/g, '')
  352. .split('')
  353. .filter(char => char.charCodeAt(0) <= 127).length;
  354. const sampleSentenceLanguage = selectionText.length === asciiChars ? "English" : config.language;
  355. // Explain words prompt
  356. return [
  357. `Provide an explanation for the word: "${selectionText}${ipaExtraPrompt}" in ${config.language}.${pinYinExtraPrompt}
  358.  
  359. Use the context from the surrounding paragraph to inform your explanation when relevant:
  360.  
  361. "${paragraphText}"
  362.  
  363. # Consider these scenarios:
  364.  
  365. ## Names
  366. 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).
  367. e.g.
  368. Alan Turing was a British mathematician and computer scientist. He is widely considered to be the father of theoretical computer science and artificial intelligence.
  369. His work was crucial to:
  370. Formalizing the concepts of algorithm and computation with the Turing machine.
  371. Breaking the German Enigma code during World War II, significantly contributing to the Allied victory.
  372. Developing the Turing test, a benchmark for artificial intelligence.
  373.  
  374.  
  375. ## Technical Terms
  376. 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.
  377. e.g. GAN 生成对抗网络
  378. 生成对抗网络(Generative Adversarial Network),是一种深度学习框架,由Ian Goodfellow2014年提出。GAN包含两个神经网络:生成器(Generator)和判别器(Discriminator),它们相互对抗训练。生成器尝试创建看起来真实的数据,而判别器则尝试区分真实数据和生成的假数据。通过这种"博弈"过程,生成器逐渐学会创建越来越逼真的数据。
  379.  
  380. ## Normal Words
  381. - For any other word, explain its meaning and provide 1-2 example sentences with the word in ${sampleSentenceLanguage}.
  382. e.g. jargon \\ˈdʒɑrɡən\\ 行话,专业术语,特定领域内使用的专业词汇。在计算机科学和编程领域,指那些对外行人难以理解的专业术语和缩写。
  383. 例句: "When explaining code to beginners, try to avoid using too much technical jargon that might confuse them."(向初学者解释代码时,尽量避免使用太多可能让他们困惑的技术行话。)
  384.  
  385. # Format
  386.  
  387. - Output the words first, then the explanation, and then the example sentences in ${sampleSentenceLanguage} if necessary.
  388. - No extra explanation
  389. - Remember to using proper html format like <p> <b> <i> <a> <li> <ol> <ul> to improve readability.
  390. `,
  391. defaultSystemPrompt
  392. ];
  393. }
  394.  
  395. // Main function to process selected text
  396. async function processSelectedText() {
  397. const { selectionText, paragraphText } = getSelectedText();
  398.  
  399. if (!selectionText) {
  400. alert('Please select some text first.');
  401. return;
  402. }
  403.  
  404. console.log(`Selected text: '${selectionText}', Paragraph text:\n${paragraphText}`);
  405. // Create popup
  406. createPopup();
  407. const contentDiv = document.getElementById('explainer-content');
  408. const loadingDiv = document.getElementById('explainer-loading');
  409. const errorDiv = document.getElementById('explainer-error');
  410.  
  411. // Reset display
  412. errorDiv.style.display = 'none';
  413. loadingDiv.style.display = 'block';
  414.  
  415. // Assemble prompt with language preference
  416. const [prompt, systemPrompt] = getPrompt(selectionText, paragraphText);
  417.  
  418. // Variable to store ongoing response text
  419. let responseText = '';
  420. let responseStartTime = Date.now();
  421.  
  422. try {
  423. // Call LLM with progress callback and await the full response
  424. const fullResponse = await callLLM(prompt, systemPrompt, (textChunk, currentFullText) => {
  425. // Update response text with new chunk
  426. responseText = currentFullText || (responseText + textChunk);
  427.  
  428. // Hide loading message if this is the first chunk
  429. if (loadingDiv.style.display !== 'none') {
  430. loadingDiv.style.display = 'none';
  431. }
  432.  
  433. // Update content with either HTML or markdown
  434. updateContentDisplay(contentDiv, responseText);
  435. });
  436.  
  437. // If we got a response
  438. if (fullResponse && fullResponse.length > 0) {
  439. responseText = fullResponse;
  440. loadingDiv.style.display = 'none';
  441. updateContentDisplay(contentDiv, fullResponse);
  442. }
  443. // If no response was received at all
  444. else if (!fullResponse || fullResponse.length === 0) {
  445. // If we've received chunks but the final response is empty, use the accumulated text
  446. if (responseText && responseText.length > 0) {
  447. updateContentDisplay(contentDiv, responseText);
  448. } else {
  449. showError("No response received from the model. Please try again.");
  450. }
  451. }
  452.  
  453. // Hide loading indicator if it's still visible
  454. if (loadingDiv.style.display !== 'none') {
  455. loadingDiv.style.display = 'none';
  456. }
  457. } catch (error) {
  458. console.error('Error:', error);
  459. // Display error in popup
  460. showError(`Error: ${error.message}`);
  461. }
  462. }
  463.  
  464. // Main function to handle keyboard shortcuts
  465. function handleKeyPress(e) {
  466. // Get shortcut configuration from settings
  467. const shortcut = config.shortcut || { key: 'd', ctrlKey: false, altKey: true, shiftKey: false, metaKey: false };
  468. // More robust shortcut detection using both key and code properties
  469. if (isShortcutMatch(e, shortcut)) {
  470. e.preventDefault();
  471. processSelectedText();
  472. }
  473. }
  474. // Helper function for more robust shortcut detection
  475. function isShortcutMatch(event, shortcutConfig) {
  476. // Check all modifier keys first
  477. if (event.ctrlKey !== !!shortcutConfig.ctrlKey ||
  478. event.altKey !== !!shortcutConfig.altKey ||
  479. event.shiftKey !== !!shortcutConfig.shiftKey ||
  480. event.metaKey !== !!shortcutConfig.metaKey) {
  481. return false;
  482. }
  483. const key = shortcutConfig.key.toLowerCase();
  484. // Method 1: Direct key match (works for most standard keys)
  485. if (event.key.toLowerCase() === key) {
  486. return true;
  487. }
  488. // Method 2: Key code match (more reliable for letter keys)
  489. // This handles the physical key position regardless of keyboard layout
  490. if (key.length === 1 && /^[a-z]$/.test(key) &&
  491. event.code === `Key${key.toUpperCase()}`) {
  492. return true;
  493. }
  494. // Method 3: Handle known special characters from Option/Alt key combinations
  495. // These are the most common mappings on macOS when using Option+key
  496. const macOptionKeyMap = {
  497. 'a': 'å', 'b': '∫', 'c': 'ç', 'd': '∂', 'e': '´', 'f': 'ƒ',
  498. 'g': '©', 'h': '˙', 'i': 'ˆ', 'j': '∆', 'k': '˚', 'l': '¬',
  499. 'm': 'µ', 'n': '˜', 'o': 'ø', 'p': 'π', 'q': 'œ', 'r': '®',
  500. 's': 'ß', 't': '†', 'u': '¨', 'v': '√', 'w': '∑', 'x': '≈',
  501. 'y': '¥', 'z': 'Ω'
  502. };
  503. if (shortcutConfig.altKey && macOptionKeyMap[key] === event.key) {
  504. return true;
  505. }
  506. return false;
  507. }
  508.  
  509. // Helper function to update content display
  510. function updateContentDisplay(contentDiv, text) {
  511. if (!text) return;
  512.  
  513. try {
  514. if (!text.trim().startsWith('<')) {
  515. // fallback
  516. console.log(`Seems like the response is not HTML: ${text}`);
  517. text = `<p>${text.replace(/\n/g, '<br>')}</p>`;
  518. }
  519. } catch (e) {
  520. // Fallback if parsing fails
  521. console.error(`Error parsing content: ${e.message}`);
  522. contentDiv.innerHTML = `<p>${text.replace(/\n/g, '<br>')}</p>`;
  523. }
  524. }
  525. // Monitor selection changes for floating button
  526. function handleSelectionChange() {
  527. const selection = window.getSelection();
  528. const hasSelection = selection && selection.toString().trim() !== '';
  529. if (hasSelection && isTouchDevice() && config.floatingButton.enabled) {
  530. showFloatingButton();
  531. } else {
  532. hideFloatingButton();
  533. }
  534. }
  535.  
  536. // Settings update callback
  537. function onSettingsChanged(updatedConfig) {
  538. config = updatedConfig;
  539. console.log('Settings updated:', config);
  540. // Recreate floating button if settings changed
  541. if (floatingButton) {
  542. floatingButton.remove();
  543. floatingButton = null;
  544. if (isTouchDevice() && config.floatingButton.enabled) {
  545. createFloatingButton();
  546. handleSelectionChange(); // Check if there's already a selection
  547. }
  548. }
  549. }
  550.  
  551. // Initialize the script
  552. function init() {
  553. // Register settings menu in Tampermonkey
  554. GM_registerMenuCommand("Text Explainer Settings", () => {
  555. settingsManager.openDialog(onSettingsChanged);
  556. });
  557.  
  558. // Add keyboard shortcut listener
  559. document.addEventListener('keydown', handleKeyPress);
  560. // For touch devices, create floating button
  561. if (isTouchDevice() && config.floatingButton.enabled) {
  562. createFloatingButton();
  563. // Monitor text selection
  564. document.addEventListener('selectionchange', handleSelectionChange);
  565. // Add touchend handler to show button after selection
  566. document.addEventListener('touchend', () => {
  567. // Small delay to ensure selection is updated
  568. setTimeout(handleSelectionChange, 100);
  569. });
  570. }
  571.  
  572. console.log('Text Explainer script initialized with language: ' + config.language);
  573. console.log('Touch device detected: ' + isTouchDevice());
  574. }
  575.  
  576. // Run initialization
  577. init();
  578. })();