// ==UserScript== // @name 选中文字问 ChatGPT(可拖动+新聊天) // @namespace https://chatgpt.com/ // @version 1.2.1 // @description 选中文字后发送到 ChatGPT;支持补充提示词、拖动弹层,并在新聊天中提交 // @match http://*/* // @match https://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // @grant GM_registerMenuCommand // @grant GM_openInTab // @run-at document-idle // @noframes // @license MIT // ==/UserScript== (() => { 'use strict'; const CONFIG = { GPT_URL: 'https://chatgpt.com/', // 填入内容后自动点击发送 AUTO_SEND: true, // 每次发送前创建新聊天 ALWAYS_NEW_CHAT: true, // 用户没有输入提示词时使用 DEFAULT_PROMPT: '请解释这段内容。', // 自动添加到内容前后的文字 PREFIX: '', SUFFIX: '', // 消息有效时间:2分钟 MESSAGE_TTL: 2 * 60 * 1000 }; const MESSAGE_KEY = 'ask_gpt_message_v1'; const RECEIVED_KEY = 'ask_gpt_received_v1'; const FINISHED_KEY = 'ask_gpt_finished_v1'; const targetUrl = new URL(CONFIG.GPT_URL); const isGPTPage = location.hostname === targetUrl.hostname; let selectionPanel = null; let savedSelection = ''; let savedSourceUrl = ''; let processingId = ''; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms) ); } function parseMessage(raw) { if (!raw) return null; try { return typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; } } function createId() { return `${Date.now()}_${Math.random() .toString(36) .slice(2)}`; } /** * 组合提交内容: * * > 选中文字 * * 用户补充提示词 */ function buildSubmittedText( selectedText, userPrompt = '', sourceUrl = '' ) { const text = String(selectedText || '').trim(); const customPrompt = String(userPrompt || '').trim(); const prompt = customPrompt || String(CONFIG.DEFAULT_PROMPT || '').trim(); const source = String(sourceUrl || '').trim(); const quotedText = text .split(/\r?\n/) .map(line => `> ${line}`) .join('\n'); const sections = [quotedText]; if (prompt) { sections.push(prompt); } if (source) { sections.push( `来源网址:\n${source}` ); } return sections.join('\n\n'); } /** * 获取网页当前选中的文字。 */ function getSelectedText() { const activeElement = document.activeElement; if ( activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement ) { const start = activeElement.selectionStart; const end = activeElement.selectionEnd; if ( typeof start === 'number' && typeof end === 'number' && end > start ) { return activeElement.value .slice(start, end) .trim(); } } return ( window .getSelection() ?.toString() .trim() || '' ); } /** * 获取选中文字所在位置。 */ function getSelectionRect() { const activeElement = document.activeElement; if ( activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement ) { const start = activeElement.selectionStart; const end = activeElement.selectionEnd; if ( typeof start === 'number' && typeof end === 'number' && end > start ) { return activeElement .getBoundingClientRect(); } } const selection = window.getSelection(); if ( !selection || selection.rangeCount === 0 ) { return null; } return selection .getRangeAt(0) .getBoundingClientRect(); } function showToast( message, duration = 2500 ) { document .getElementById('ask-gpt-toast') ?.remove(); const toast = document.createElement('div'); toast.id = 'ask-gpt-toast'; toast.textContent = message; Object.assign(toast.style, { position: 'fixed', left: '50%', bottom: '30px', transform: 'translateX(-50%)', zIndex: '2147483647', padding: '10px 16px', borderRadius: '9px', background: 'rgba(25,25,25,.94)', color: '#fff', fontSize: '14px', lineHeight: '20px', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', boxShadow: '0 4px 20px rgba(0,0,0,.25)', pointerEvents: 'none' }); document.documentElement .appendChild(toast); setTimeout(() => { toast.remove(); }, duration); } async function waitForValue( key, expectedValue, timeout ) { const start = Date.now(); while ( Date.now() - start < timeout ) { if ( GM_getValue(key, '') === expectedValue ) { return true; } await sleep(80); } return false; } /** * 将内容传给 ChatGPT 页面。 */ async function askGPT(text) { text = String(text || '').trim(); if (!text) { showToast( '请先选择需要咨询的文字' ); return; } const id = createId(); const message = { id, text: `${CONFIG.PREFIX}` + `${text}` + `${CONFIG.SUFFIX}`, createdAt: Date.now(), autoSend: CONFIG.AUTO_SEND, newChat: CONFIG.ALWAYS_NEW_CHAT }; GM_setValue( MESSAGE_KEY, JSON.stringify(message) ); showToast( '正在发送到 ChatGPT…' ); /* * 等待已经打开的 ChatGPT 标签页响应。 */ const existingTabResponded = await waitForValue( RECEIVED_KEY, id, 900 ); /* * 没有 ChatGPT 标签页时,新建标签页。 */ if (!existingTabResponded) { GM_openInTab( CONFIG.GPT_URL, { active: true, insert: true, setParent: true } ); } const finished = await waitForValue( FINISHED_KEY, id, 45000 ); if (finished) { showToast( CONFIG.AUTO_SEND ? '已在新聊天中提交给 ChatGPT' : '已填入新聊天输入框' ); } else { showToast( '内容已转交,请检查 ChatGPT 页面', 4000 ); } } function removeSelectionPanel() { selectionPanel?.remove(); selectionPanel = null; } /** * 防止弹层被拖到窗口外面。 */ function clampPanelToViewport(panel) { const rect = panel.getBoundingClientRect(); const margin = 8; const left = Math.min( Math.max(margin, rect.left), Math.max( margin, window.innerWidth - rect.width - margin ) ); const top = Math.min( Math.max(margin, rect.top), Math.max( margin, window.innerHeight - rect.height - margin ) ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; } /** * 给弹层增加拖动功能。 */ function makePanelDraggable( panel, dragHandle ) { let dragging = false; let pointerId = null; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; dragHandle.addEventListener( 'pointerdown', event => { if ( event.button !== 0 && event.pointerType !== 'touch' ) { return; } event.preventDefault(); event.stopPropagation(); const rect = panel.getBoundingClientRect(); dragging = true; pointerId = event.pointerId; startX = event.clientX; startY = event.clientY; startLeft = rect.left; startTop = rect.top; dragHandle.style.cursor = 'grabbing'; panel.style.userSelect = 'none'; try { dragHandle.setPointerCapture( pointerId ); } catch { // 部分网页可能不支持。 } } ); dragHandle.addEventListener( 'pointermove', event => { if ( !dragging || event.pointerId !== pointerId ) { return; } event.preventDefault(); event.stopPropagation(); const panelWidth = panel.offsetWidth; const panelHeight = panel.offsetHeight; const margin = 8; const nextLeft = Math.min( Math.max( margin, startLeft + event.clientX - startX ), Math.max( margin, window.innerWidth - panelWidth - margin ) ); const nextTop = Math.min( Math.max( margin, startTop + event.clientY - startY ), Math.max( margin, window.innerHeight - panelHeight - margin ) ); panel.style.left = `${nextLeft}px`; panel.style.top = `${nextTop}px`; } ); const stopDragging = event => { if (!dragging) return; if ( event.pointerId !== undefined && event.pointerId !== pointerId ) { return; } dragging = false; pointerId = null; dragHandle.style.cursor = 'grab'; panel.style.userSelect = ''; clampPanelToViewport(panel); }; dragHandle.addEventListener( 'pointerup', stopDragging ); dragHandle.addEventListener( 'pointercancel', stopDragging ); } /** * 创建选中文字后的悬浮弹层。 */ function showSelectionPanel() { if (isGPTPage) return; const text = getSelectedText(); if (!text) { removeSelectionPanel(); return; } savedSelection = text; savedSourceUrl = location.href; removeSelectionPanel(); const rect = getSelectionRect(); if ( !rect || (!rect.width && !rect.height) ) { return; } const panel = document.createElement('div'); const panelWidth = 320; const left = Math.max( 8, Math.min( rect.left, window.innerWidth - panelWidth - 8 ) ); const estimatedHeight = 110; let top = rect.bottom + 8; if ( top + estimatedHeight > window.innerHeight ) { top = Math.max( 8, rect.top - estimatedHeight - 8 ); } Object.assign(panel.style, { position: 'fixed', left: `${left}px`, top: `${top}px`, zIndex: '2147483647', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', touchAction: 'none' }); /* * 顶部按钮行。 */ const buttonRow = document.createElement('div'); Object.assign(buttonRow.style, { display: 'flex', alignItems: 'stretch', width: 'fit-content', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,.25))' }); /* * 左侧拖动手柄。 */ const dragHandle = document.createElement('div'); dragHandle.textContent = '⋮⋮'; dragHandle.title = '拖动弹层'; Object.assign(dragHandle.style, { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '17px', minWidth: '17px', borderRadius: '8px 0 0 8px', background: '#2a2a2a', color: '#bdbdbd', fontSize: '13px', lineHeight: '20px', cursor: 'grab', userSelect: 'none', touchAction: 'none' }); /* * 主按钮。 */ const mainButton = document.createElement('button'); mainButton.type = 'button'; mainButton.textContent = '问 GPT'; mainButton.title = '直接发送选中的文字'; Object.assign(mainButton.style, { appearance: 'none', border: 'none', borderRadius: '0', padding: '7px 12px', margin: '0', background: '#111', color: '#fff', fontSize: '13px', fontWeight: '500', lineHeight: '20px', cursor: 'pointer', whiteSpace: 'nowrap' }); /* * 右侧提示词展开按钮。 */ const dropdownButton = document.createElement('button'); dropdownButton.type = 'button'; dropdownButton.textContent = '▼'; dropdownButton.title = '添加提示词'; Object.assign( dropdownButton.style, { appearance: 'none', border: 'none', borderLeft: '1px solid rgba(255,255,255,.25)', borderRadius: '0 8px 8px 0', padding: '7px 7px', margin: '0', background: '#111', color: '#fff', fontSize: '10px', lineHeight: '20px', cursor: 'pointer' } ); /* * 提示词输入区域。 */ const promptContainer = document.createElement('div'); Object.assign( promptContainer.style, { display: 'none', width: `${panelWidth}px`, marginTop: '6px', padding: '7px', boxSizing: 'border-box', borderRadius: '9px', background: '#fff', boxShadow: '0 4px 20px rgba(0,0,0,.25)' } ); const promptInput = document.createElement('input'); promptInput.type = 'text'; promptInput.placeholder = '输入提示词,按 Enter 发送'; promptInput.autocomplete = 'off'; promptInput.spellcheck = false; Object.assign( promptInput.style, { display: 'block', width: '100%', height: '36px', boxSizing: 'border-box', border: '1px solid #d0d0d0', borderRadius: '7px', padding: '0 10px', margin: '0', outline: 'none', background: '#fff', color: '#222', fontSize: '13px', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif' } ); promptInput.addEventListener( 'focus', () => { promptInput.style.borderColor = '#111'; promptInput.style.boxShadow = '0 0 0 2px rgba(0,0,0,.08)'; } ); promptInput.addEventListener( 'blur', () => { promptInput.style.borderColor = '#d0d0d0'; promptInput.style.boxShadow = 'none'; } ); /* * 防止点击按钮后丢失网页选区。 */ for ( const element of [ mainButton, dropdownButton ] ) { element.addEventListener( 'mousedown', event => { event.preventDefault(); event.stopPropagation(); } ); } /* * 点击主按钮: * 只提交引用文字。 */ mainButton.addEventListener( 'click', async event => { event.preventDefault(); event.stopPropagation(); const textToSend = buildSubmittedText( savedSelection, '', savedSourceUrl ); removeSelectionPanel(); await askGPT(textToSend); } ); /* * 点击箭头: * 展开或收起提示词输入框。 */ dropdownButton.addEventListener( 'click', event => { event.preventDefault(); event.stopPropagation(); const isExpanded = promptContainer.style .display !== 'none'; if (isExpanded) { promptContainer.style.display = 'none'; dropdownButton.textContent = '▼'; dropdownButton.title = '添加提示词'; } else { promptContainer.style.display = 'block'; dropdownButton.textContent = '▲'; dropdownButton.title = '收起提示词'; requestAnimationFrame( () => { clampPanelToViewport( panel ); promptInput.focus(); } ); } } ); /* * 输入提示词后按 Enter 提交。 */ promptInput.addEventListener( 'keydown', async event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); promptContainer.style.display = 'none'; dropdownButton.textContent = '▼'; dropdownButton.title = '添加提示词'; requestAnimationFrame( () => { clampPanelToViewport( panel ); } ); return; } /* * 中文输入法正在选字时, * 不执行回车提交。 */ if ( event.key !== 'Enter' || event.isComposing ) { return; } event.preventDefault(); event.stopPropagation(); const textToSend = buildSubmittedText( savedSelection, promptInput.value.trim(), savedSourceUrl ); removeSelectionPanel(); await askGPT(textToSend); } ); /* * 防止输入框事件传递给网页。 */ for ( const eventName of [ 'mousedown', 'mouseup', 'click', 'pointerdown' ] ) { promptInput.addEventListener( eventName, event => { event.stopPropagation(); } ); } promptContainer.appendChild( promptInput ); buttonRow.appendChild( dragHandle ); buttonRow.appendChild( mainButton ); buttonRow.appendChild( dropdownButton ); panel.appendChild(buttonRow); panel.appendChild( promptContainer ); document.documentElement .appendChild(panel); selectionPanel = panel; makePanelDraggable( panel, dragHandle ); clampPanelToViewport(panel); } /* * ChatGPT 输入框选择器。 */ const COMPOSER_SELECTORS = [ '#prompt-textarea', 'textarea[data-id="root"]', 'textarea[placeholder]', 'div[contenteditable="true"][role="textbox"]', 'div[contenteditable="true"][data-lexical-editor="true"]' ]; function isVisible(element) { if (!element) return false; const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return ( rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !element.closest( '[aria-hidden="true"]' ) ); } function findVisibleComposer() { for ( const selector of COMPOSER_SELECTORS ) { const elements = document.querySelectorAll( selector ); for (const element of elements) { if (isVisible(element)) { return element; } } } return null; } async function waitForComposer( timeout = 30000 ) { const start = Date.now(); while ( Date.now() - start < timeout ) { const composer = findVisibleComposer(); if (composer) { return composer; } await sleep(250); } throw new Error( '找不到 ChatGPT 输入框' ); } /** * 获取按钮的名称。 */ function getControlLabel(element) { return [ element.getAttribute( 'aria-label' ), element.getAttribute('title'), element.textContent ] .filter(Boolean) .join(' ') .replace(/\s+/g, ' ') .trim(); } /** * 查找 ChatGPT 的“新聊天”按钮。 */ function findNewChatControl() { const preciseSelectors = [ '[data-testid="create-new-chat-button"]', '[data-testid="new-chat-button"]', 'a[aria-label="New chat"]', 'button[aria-label="New chat"]', 'a[aria-label*="新聊天"]', 'button[aria-label*="新聊天"]', 'a[aria-label*="新对话"]', 'button[aria-label*="新对话"]', 'a[title="New chat"]', 'button[title="New chat"]' ]; /* * 优先使用稳定属性查找。 */ for ( const selector of preciseSelectors ) { const elements = document.querySelectorAll( selector ); for (const element of elements) { if (isVisible(element)) { return element; } } } /* * 再按照按钮文字查找。 */ const labelPattern = /^(new chat|start new chat|新聊天|新对话|新建聊天|开始新聊天)$/i; const candidates = document.querySelectorAll( 'a,button,[role="button"]' ); for ( const element of candidates ) { if (!isVisible(element)) { continue; } const label = getControlLabel(element); if ( labelPattern.test(label) ) { return element; } } /* * 最后的后备方式: * 点击指向首页的链接。 */ const homeLinks = document.querySelectorAll( 'a[href="/"]' ); for ( const element of homeLinks ) { if (isVisible(element)) { return element; } } return null; } async function waitForNewChatControl( timeout = 6000 ) { const start = Date.now(); while ( Date.now() - start < timeout ) { const control = findNewChatControl(); if (control) { return control; } await sleep(250); } return null; } /** * 判断当前是否位于已有聊天页面。 */ function isConversationRoute() { return ( /\/c\/[^/]+/.test( location.pathname ) || location.pathname.startsWith( '/share/' ) ); } /** * 每次提交前创建新聊天。 */ async function prepareFreshChat() { if (!CONFIG.ALWAYS_NEW_CHAT) { return waitForComposer(); } const oldUrl = location.href; const newChatControl = await waitForNewChatControl(); if (newChatControl) { newChatControl.click(); /* * 等待 ChatGPT 切换到新聊天。 */ await sleep(500); const start = Date.now(); const timeout = 10000; while ( Date.now() - start < timeout ) { const composer = findVisibleComposer(); const routeChanged = location.href !== oldUrl; const nowFresh = !isConversationRoute(); if ( composer && ( routeChanged || nowFresh ) ) { return composer; } await sleep(250); } } /* * 找不到“新聊天”按钮,且仍在旧聊天页面时, * 直接返回 ChatGPT 首页。 * * 页面重新加载后,会自动继续处理未完成的消息。 */ if (isConversationRoute()) { location.assign( CONFIG.GPT_URL ); await new Promise(() => {}); } return waitForComposer(); } function setNativeValue( element, value ) { const prototype = Object.getPrototypeOf( element ); const descriptor = Object.getOwnPropertyDescriptor( prototype, 'value' ); if (descriptor?.set) { descriptor.set.call( element, value ); } else { element.value = value; } } /** * 将内容写入 ChatGPT 输入框。 */ function fillComposer( composer, text ) { composer.focus(); if ( composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement ) { setNativeValue( composer, text ); composer.dispatchEvent( new InputEvent( 'input', { bubbles: true, inputType: 'insertText', data: text } ) ); composer.dispatchEvent( new Event( 'change', { bubbles: true } ) ); return; } /* * contenteditable 输入框处理。 */ const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents( composer ); selection.removeAllRanges(); selection.addRange(range); let inserted = false; try { inserted = document.execCommand( 'insertText', false, text ); } catch { inserted = false; } if (!inserted) { composer.textContent = text; composer.dispatchEvent( new InputEvent( 'input', { bubbles: true, inputType: 'insertText', data: text } ) ); } } /** * 查找 ChatGPT 发送按钮。 */ async function findSendButton( composer, timeout = 10000 ) { const selectors = [ 'button[data-testid="send-button"]', 'button[data-testid="composer-send-button"]', 'button[aria-label*="Send"]', 'button[aria-label*="发送"]', 'button[aria-label*="提交"]' ]; const start = Date.now(); while ( Date.now() - start < timeout ) { for ( const selector of selectors ) { const buttons = document.querySelectorAll( selector ); for ( const button of buttons ) { if ( isVisible(button) && !button.disabled && button.getAttribute( 'aria-disabled' ) !== 'true' ) { return button; } } } /* * 表单提交按钮后备方案。 */ const form = composer.closest('form'); const submitButton = form?.querySelector( 'button[type="submit"]:not([disabled])' ); if ( submitButton && isVisible(submitButton) ) { return submitButton; } await sleep(250); } return null; } /** * ChatGPT 页面接收消息。 */ async function processGPTMessage( rawMessage ) { if (!isGPTPage) return; const message = parseMessage(rawMessage); if ( !message?.id || !message.text ) { return; } if ( processingId === message.id ) { return; } if ( Date.now() - message.createdAt > CONFIG.MESSAGE_TTL ) { return; } if ( GM_getValue( FINISHED_KEY, '' ) === message.id ) { return; } processingId = message.id; /* * 告诉来源网页: * 已经检测到 ChatGPT 标签页。 */ GM_setValue( RECEIVED_KEY, message.id ); /* * 尝试切换到 ChatGPT 标签页。 * 浏览器可能会阻止后台网页强制抢焦点。 */ try { window.focus(); } catch { // 忽略焦点错误。 } try { /* * 先进入新聊天,再获取输入框。 */ const composer = message.newChat === false ? await waitForComposer() : await prepareFreshChat(); fillComposer( composer, message.text ); await sleep(600); if (message.autoSend) { const sendButton = await findSendButton( composer ); if (!sendButton) { throw new Error( '找不到发送按钮' ); } sendButton.click(); } GM_setValue( FINISHED_KEY, message.id ); } catch (error) { console.error( '[问 GPT]', error ); showToast( `问 GPT 失败:${error.message}`, 5000 ); } finally { processingId = ''; } } /* * ChatGPT 页面监听其他网页发送的消息。 */ if (isGPTPage) { GM_addValueChangeListener( MESSAGE_KEY, ( _name, _oldValue, newValue ) => { processGPTMessage( newValue ); } ); /* * ChatGPT 刚打开时, * 检查是否存在待处理消息。 */ setTimeout(() => { processGPTMessage( GM_getValue( MESSAGE_KEY, '' ) ); }, 800); } /* * 油猴菜单备用入口。 */ GM_registerMenuCommand( '问 GPT:发送选中文字', () => { const selectedText = getSelectedText(); askGPT( buildSubmittedText( selectedText, '', location.href ) ); } ); /* * 普通网页监听文字选择。 */ if (!isGPTPage) { document.addEventListener( 'mouseup', event => { if ( selectionPanel && selectionPanel.contains( event.target ) ) { return; } setTimeout( showSelectionPanel, 30 ); }, true ); document.addEventListener( 'keyup', event => { if ( selectionPanel && selectionPanel.contains( event.target ) ) { return; } if ( event.key === 'Shift' || event.key.startsWith( 'Arrow' ) ) { setTimeout( showSelectionPanel, 30 ); } }, true ); /* * 点击弹层外部时关闭。 */ document.addEventListener( 'mousedown', event => { if ( selectionPanel && !selectionPanel.contains( event.target ) ) { removeSelectionPanel(); } }, true ); /* * 页面滚动时关闭弹层。 */ window.addEventListener( 'scroll', removeSelectionPanel, true ); /* * 浏览器窗口尺寸变化时, * 防止弹层跑到窗口外。 */ window.addEventListener( 'resize', () => { if (selectionPanel) { clampPanelToViewport( selectionPanel ); } } ); } })();