开始与 AI 对话吧!
Enter 提交
Shift+Enter 换行
// ==UserScript== // @name Thpilot AI 划词助手 // @namespace ThpilotAIHelper // @version 2.0.9 // @description 通用网页划词助手:任意网页划词解释、翻译、纠错、总结和连续聊天 // @author Wilsons / Web adaptation // @match http://*/* // @match https://*/* // @match file:///* // @run-at document-idle // @noframes // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant unsafeWindow // @connect * // @license MIT // ==/UserScript== /* // @require file:///Users/wish/workspace/ThpilotAI/user.js?r=1 */ (async () => { 'use strict'; if (window.top !== window.self) return; if (document.documentElement.dataset.thpilotWebLoaded === '1') return; document.documentElement.dataset.thpilotWebLoaded = '1'; /////////////////////////// 用户配置区 /////////////////////////// // 对话框默认尺寸 const width = 420; const maxHeight = 468; // Windows/Linux:Ctrl+Alt+Z;Mac 会自动转换为 Cmd+Alt+Z const shortcut = 'ctrl+alt+z'; // 全局历史最多保留多少个会话 const globalHistoryNum = 200; // 网页上下文最大字符数,过长会被截断 const defaultContextMaxLength = 18000; // 可直接在这里填写模型;也可以在油猴菜单“模型与界面设置”中配置 // URL 规则: // 1. 以 /chat/completions 结尾:原样使用 // 2. 以 # 结尾:去掉 # 后原样使用 // 3. 其他情况:自动补上 /chat/completions const defaultModels = [ { url: 'https://api.openai.com/v1', model: 'gpt-4o-mini', modelName: 'GPT-4o mini', apiKey: '', stream: true, temperature: 0.7, thinking: 'auto', }, { url: 'https://api-inference.modelscope.cn/v1', model: 'Qwen/Qwen3-Coder-480B-A35B-Instruct', modelName: 'Qwen/Qwen3-Coder', apiKey: '', stream: true, temperature: 0.7, thinking: 'auto', }, ]; // 自定义划词按钮。 // context 在通用网页中的含义: // blockText 选区所在的最近文本块 // blockHtml 选区所在的最近文本块 HTML // editorText 当前可见页面文本 // editorHtml 当前页面 body HTML // currentMd 当前网页正文文本(通用网页没有真正 Markdown,使用正文文本替代) // bodyHtml 整个 body HTML const defaultButtons = [ { enable: true, id: 'aiExplain', name: '解释', icon: iconBook(), prompt: `请对以下文本提供一个全面而清晰的解释。要求如下: 1. 如果文中包含不易理解的术语或复杂概念,请根据需要进行解释。 2. 直接解释即可,不要有任何形式的前缀。 3. 先解释常用含义,如果还有其他场景下的含义也简单介绍。 --- 待解释的文本: \`\`\`\`\`\` {{selection}}{{context}} \`\`\`\`\`\``, system: '你是一位知识渊博的分析师,擅长将复杂信息用通俗易懂的方式解释清楚。回答应结构清晰、逻辑严谨。', context: 'blockText', }, { enable: true, id: 'aiTranslate', name: '翻译', icon: iconTranslate(), prompt: `请智能识别以下文本的源语言,并将其翻译成最合适的目标语言(中文通常翻译为英文,其他语言通常翻译为中文)。 要求: 1. 只输出译文,不要解释。 2. 尽可能保留原文语气、风格、段落、换行和格式。 3. 专有名词、代码以及不应翻译的内容保留原文。 --- 待翻译的文本: \`\`\`\`\`\` {{selection}}{{context}} \`\`\`\`\`\``, system: '你是当地的母语者,也是一名顶级专业翻译家,追求准确、自然、忠于原文。', context: '', replaceCallback: genericReplaceCallback, }, { enable: true, id: 'aiSpellCheck', name: '纠错', icon: iconCorrect(), prompt: `请仔细检查以下文本中的拼写、语法和标点错误。 要求: 1. 只修正客观错误,不改写句子,不改变原意和风格。 2. 尽量保持原始段落、换行和格式。 3. 在修正后的文本前,用列表简要说明做了哪些修改。 输出格式: 如果发现错误: <列出具体修改> 以下是修正后的完整内容:
如果没有错误,只输出: 未发现任何错误。 --- 待检查文本: \`\`\`\`\`\`html {{selection}}{{context}} \`\`\`\`\`\``, system: '你是一名严谨细致的编辑和校对专家。', context: '', replaceCallback: genericReplaceCallback, useSelectedHtml: true, }, { enable: true, id: 'aiSummary', name: '总结', icon: iconSummary(), prompt: `请对以下文本进行总结和摘要。 1. 直接输出结果,不要开场白。 2. 只根据原文总结,不添加个人观点或推测。 3. 用尽量少的文字表达核心信息。 --- 待总结文本: \`\`\`\`\`\` {{selection}}{{context}} \`\`\`\`\`\``, system: '你是专业的文本分析师和摘要提炼专家。', context: '', replaceCallback: genericReplaceCallback, }, { enable: true, id: 'aiChat', name: '聊天', icon: iconChat(), prompt: '{{selection}}{{context}}', system: '你是一个全能的 AI 助手,知识渊博、乐于助人。请提供准确、清晰、有条理的回答,必要时使用 Markdown。', context: '', isChat: true, isAutoSend: false, pin: true, }, ]; const defaultButtonsSource = serializeButtonsConfig(defaultButtons); /////////////////////////// 代码区 /////////////////////////// const STORAGE = { models: 'thpilot-web-models-v2', settings: 'thpilot-web-settings-v2', history: 'thpilot-web-history-v2', lastSession: 'thpilot-web-last-session-v2', }; const defaultSettings = { theme: 'auto', // auto / light / dark userName: '用户', shortcut, contextMaxLength: defaultContextMaxLength, closeOnOutside: true, showPageSource: true, }; let settings = { ...defaultSettings, ...(GM_getValue(STORAGE.settings, {}) || {}), }; let buttons; function normalizeButtons(input) { if (!Array.isArray(input)) { return normalizeButtons(defaultButtons); } const knownButtons = { aiExplain: { icon: iconBook(), }, aiTranslate: { icon: iconTranslate(), }, aiSpellCheck: { icon: iconCorrect(), }, aiSummary: { icon: iconSummary(), }, aiChat: { icon: iconChat(), }, }; return input .filter( item => item && typeof item === 'object', ) .map(item => { const id = String(item.id || '').trim(); const preset = knownButtons[id] || {}; return { enable: item.enable !== false, id, name: String( item.name || item.label || id, ).trim(), prompt: String(item.prompt || ''), system: String(item.system || ''), context: String(item.context || ''), useSelectedHtml: Boolean(item.useSelectedHtml), isChat: Boolean(item.isChat), isAutoSend: Boolean(item.isAutoSend), pin: Boolean(item.pin), beforeCallback: resolveButtonCallback( item.beforeCallback, ), afterCallback: resolveButtonCallback( item.afterCallback, ), replaceCallback: resolveButtonCallback( item.replaceCallback, ), icon: item.icon || preset.icon || '', }; }) .filter(item => item.id); } function compactIcon(input) { return String(input || '') .replace(/>\s+<') .replace(/\s+/g, ' ') .trim(); } function stringifySettingsJSON(input) { return JSON.stringify(input, null, 2); } function resolveButtonCallback(value) { if ( value === genericReplaceCallback || value === 'genericReplaceCallback' ) { return genericReplaceCallback; } return null; } function parseButtonsConfig(source, fallback) { if (!source) return fallback; const text = String(source).trim(); if (!text) return fallback; try { return JSON.parse(text); } catch (_) { return fallback; } } function parseButtonsConfigStrict(source) { const text = String(source || '').trim(); let parsed; try { parsed = JSON.parse(text); } catch (error) { throw new Error( `JSON 解析失败:${error.message}`, ); } if (!Array.isArray(parsed)) { throw new Error( '请填写 JSON 按钮数组,例如 [{"id":"aiChat"}]', ); } return parsed; } function getButtonsSource() { if ( typeof settings.buttonsSource === 'string' && settings.buttonsSource.trim() ) { return settings.buttonsSource.trim(); } if (Array.isArray(settings.buttons)) { return serializeButtonsConfig(settings.buttons); } return defaultButtonsSource; } function serializeButtonsConfig(input) { const serializable = input.map(item => { const output = { enable: item.enable !== false, id: String(item.id || ''), name: String(item.name || ''), icon: compactIcon(item.icon), prompt: String(item.prompt || ''), system: String(item.system || ''), context: String(item.context || ''), }; for (const key of [ 'beforeCallback', 'afterCallback', 'replaceCallback', ]) { if (item[key] === genericReplaceCallback) { output[key] = 'genericReplaceCallback'; } } for (const key of [ 'useSelectedHtml', 'isChat', 'isAutoSend', 'pin', ]) { if (item[key]) { output[key] = true; } } return output; }); return stringifySettingsJSON(serializable); } const externalModels = getExternalModels(); let models = normalizeModels( GM_getValue(STORAGE.models, null) || externalModels || defaultModels, ); if (!models.length) { models = normalizeModels(defaultModels); } let currentModelIndex = clamp( Number(GM_getValue('thpilot-web-current-model', 0)) || 0, 0, Math.max(0, models.length - 1), ); let currentModel = clone(models[currentModelIndex]); buttons = normalizeButtons( parseButtonsConfig( getButtonsSource(), defaultButtons, ), ); const state = { selection: null, currentButton: null, session: null, pinned: false, maximized: false, collapsed: false, sending: false, requestHandle: null, streamParser: null, attachedImages: [], attachPageContext: false, lastFocusedEditable: null, suppressOutsideCloseUntil: 0, drag: null, beforeMaxRect: null, themeMedia: window.matchMedia?.('(prefers-color-scheme: dark)'), }; const host = document.createElement('div'); host.id = 'thpilot-web-host'; host.style.cssText = 'position:fixed;' + 'inset:0;' + 'z-index:2147483647;' + 'pointer-events:none;' + 'contain:layout style;'; document.documentElement.appendChild(host); const root = host.attachShadow({ mode: 'open', }); root.innerHTML = buildUI(); const $ = selector => root.querySelector(selector); const $$ = selector => [...root.querySelectorAll(selector)]; const toolbar = $('#tp-toolbar'); const dialog = $('#tp-dialog'); const header = $('#tp-header'); const titleEl = $('#tp-title'); const messageList = $('#tp-messages'); const welcome = $('#tp-welcome'); const input = $('#tp-input'); const footer = $('#tp-footer'); const sendButton = $('#tp-send'); const stopButton = $('#tp-stop'); const pinButton = $('#tp-pin'); const modelButton = $('#tp-model-button'); const modelMenu = $('#tp-model-menu'); const contextMenu = $('#tp-context-menu'); const attachmentsBar = $('#tp-attachments'); const pageContextChip = $('#tp-page-context-chip'); const scrollDownButton = $('#tp-scroll-down'); const settingsOverlay = $('#tp-settings-overlay'); const historyOverlay = $('#tp-history-overlay'); const toastEl = $('#tp-toast'); const fileInput = $('#tp-file-input'); applyTheme(); renderToolbarButtons(); renderModelMenu(); updateModelButton(); installEvents(); registerMenuCommands(); function installEvents() { toolbar.addEventListener( 'pointerdown', event => event.preventDefault(), ); toolbar.addEventListener( 'click', async event => { const buttonEl = event.target.closest('[data-button-id]'); if (!buttonEl) return; const button = buttons.find( item => item.id === buttonEl.dataset.buttonId, ); if (button) { await openByButton(button); } }, ); $('#tp-new').addEventListener( 'click', () => openNewChat(true), ); $('#tp-save').addEventListener( 'click', downloadConversation, ); $('#tp-close').addEventListener( 'click', closeDialog, ); $('#tp-collapse').addEventListener( 'click', toggleCollapse, ); pinButton.addEventListener( 'click', togglePin, ); modelButton.addEventListener( 'click', toggleModelMenu, ); modelMenu.addEventListener( 'click', event => { const item = event.target.closest( '[data-model-index]', ); if (item) { selectModel( Number(item.dataset.modelIndex), ); hideModelMenu(); return; } if ( event.target.closest( '[data-open-history]', ) ) { hideModelMenu(); openHistory(); return; } if ( event.target.closest( '[data-open-settings]', ) ) { hideModelMenu(); openSettings(); } }, ); sendButton.addEventListener( 'click', sendInputMessage, ); stopButton.addEventListener( 'click', abortRequest, ); input.addEventListener( 'keydown', event => { if ( event.key === 'Enter' && !event.shiftKey && !event.isComposing ) { event.preventDefault(); sendInputMessage(); } }, ); input.addEventListener( 'input', autoGrowInput, ); input.addEventListener( 'contextmenu', openInputContextMenu, ); input.addEventListener( 'paste', handleInputPaste, ); contextMenu.addEventListener( 'click', event => { const action = event.target .closest('[data-context-action]') ?.dataset.contextAction; if (!action) return; hideContextMenu(); if (action === 'upload') { fileInput.click(); } if (action === 'page') { togglePageContext(); } }, ); fileInput.addEventListener( 'change', async () => { await addImageFiles( [...fileInput.files], ); fileInput.value = ''; }, ); attachmentsBar.addEventListener( 'click', event => { const remove = event.target.closest( '[data-remove-image]', ); if (!remove) return; state.attachedImages.splice( Number( remove.dataset.removeImage, ), 1, ); renderAttachments(); }, ); pageContextChip.addEventListener( 'click', () => { state.attachPageContext = false; renderAttachments(); }, ); messageList.addEventListener( 'click', handleMessageAction, ); messageList.addEventListener( 'dblclick', event => { if ( event.target.closest('pre') ) { return; } const body = event.target.closest( '.tp-message-body', ); if (body) { body.classList.toggle( 'tp-expanded', ); } }, ); scrollDownButton.addEventListener( 'click', scrollToBottom, ); messageList.addEventListener( 'scroll', updateScrollDownButton, ); header.addEventListener( 'pointerdown', startDrag, true, ); header.addEventListener( 'pointermove', dragDialog, true, ); header.addEventListener( 'pointerup', stopDrag, true, ); header.addEventListener( 'dblclick', event => { if ( event.target.closest('button') ) { return; } toggleMaximize(); }, ); window.addEventListener('pointermove', dragDialog, true); window.addEventListener('pointerup', stopDrag, true); window.addEventListener('resize', () => { keepDialogVisible(); hideModelMenu(); hideContextMenu(); }); document.addEventListener('mouseup', handleSelectionEvent, true); document.addEventListener('keyup', handleSelectionEvent, true); document.addEventListener('selectionchange', rememberEditableSelection, true); document.addEventListener('scroll', hideToolbar, true); document.addEventListener('pointerdown', (event) => { const path = event.composedPath(); if (path.includes(host)) { const inModelMenu = path.includes(modelMenu); const inModelButton = path.includes(modelButton); const inContextMenu = path.includes(contextMenu); if (!inModelMenu && !inModelButton) { hideModelMenu(); } if (!inContextMenu) { hideContextMenu(); } return; } hideToolbar(); hideModelMenu(); hideContextMenu(); if ( settings.closeOnOutside && !state.pinned && !dialog.classList.contains('tp-hidden') && Date.now() > state.suppressOutsideCloseUntil ) { closeDialog(); } }, true); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { if (!settingsOverlay.classList.contains('tp-hidden')) { return closeSettings(); } if (!historyOverlay.classList.contains('tp-hidden')) { return closeHistory(); } if (!modelMenu.classList.contains('tp-hidden')) { return hideModelMenu(); } if (!contextMenu.classList.contains('tp-hidden')) { return hideContextMenu(); } if (!dialog.classList.contains('tp-hidden')) { return closeDialog(); } hideToolbar(); } }, true); settingsOverlay.addEventListener('click', (event) => { if (event.target === settingsOverlay) { closeSettings(); } }); $('#tp-settings-cancel').addEventListener( 'click', closeSettings, ); $('#tp-settings-save').addEventListener( 'click', saveSettingsFromUI, ); $('#tp-settings-reset').addEventListener( 'click', resetSettingsUI, ); historyOverlay.addEventListener('click', (event) => { if (event.target === historyOverlay) { closeHistory(); } const item = event.target.closest('[data-history-id]'); if ( item && !event.target.closest('[data-delete-history]') ) { loadHistorySession(item.dataset.historyId); } const del = event.target.closest('[data-delete-history]'); if (del) { deleteHistorySession( del.dataset.deleteHistory, ); } }); $('#tp-history-close').addEventListener( 'click', closeHistory, ); $('#tp-history-clear').addEventListener( 'click', clearHistory, ); state.themeMedia?.addEventListener?.( 'change', applyTheme, ); onKeyPress( settings.shortcut || shortcut, () => openNewChat(false), ); } function registerMenuCommands() { GM_registerMenuCommand( 'Thpilot Web:打开聊天窗口', () => openNewChat(false), ); GM_registerMenuCommand( 'Thpilot Web:打开最近会话', openLastSession, ); GM_registerMenuCommand( 'Thpilot Web:会话历史', openHistory, ); GM_registerMenuCommand( 'Thpilot Web:模型与界面设置', openSettings, ); } async function handleSelectionEvent(event) { if ( event.type === 'keyup' && ![ 'Shift', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', ].includes(event.key) ) { return; } if ( event.composedPath?.().includes(host) ) { return; } setTimeout(() => { const captured = captureSelection(event); if ( !captured || !captured.text.trim() ) { hideToolbar(); return; } state.selection = captured; showToolbar(captured.rect); }, 0); } function captureSelection(event) { const active = document.activeElement; if ( active && /^(TEXTAREA|INPUT)$/.test(active.tagName) && typeof active.selectionStart === 'number' && active.selectionStart !== active.selectionEnd ) { const start = active.selectionStart; const end = active.selectionEnd; const text = active.value.slice(start, end); const rect = active.getBoundingClientRect(); return { text, html: escapeHtml(text), ranges: [], rect: event?.clientX ? { left: event.clientX, right: event.clientX, top: event.clientY, bottom: event.clientY, width: 0, height: 0, } : rect, source: { title: document.title, url: location.href, }, editable: { type: 'text-control', element: active, start, end, }, anchorElement: active, }; } const selection = window.getSelection(); if ( !selection || selection.rangeCount === 0 || selection.isCollapsed ) { return null; } const ranges = []; const fragments = []; const rects = []; for ( let i = 0; i < selection.rangeCount; i++ ) { const range = selection.getRangeAt(i); ranges.push( range.cloneRange(), ); const box = range.getBoundingClientRect(); if ( box && (box.width || box.height) ) { rects.push(box); } const container = document.createElement('div'); container.appendChild( range.cloneContents(), ); fragments.push( container.innerHTML, ); } const text = selection .toString() .trim(); if (!text) { return null; } const rect = rects.at(-1) || ranges[0]?.getBoundingClientRect(); const anchorNode = selection.anchorNode; const anchorElement = anchorNode?.nodeType === Node.TEXT_NODE ? anchorNode.parentElement : anchorNode; const editableRoot = anchorElement?.closest?.( '[contenteditable="true"], [contenteditable="plaintext-only"]', ); return { text, html: fragments.join('\n'), ranges, rect, source: { title: document.title, url: location.href, }, editable: editableRoot ? { type: 'contenteditable', element: editableRoot, ranges, } : null, anchorElement, }; } function rememberEditableSelection() { const selection = window.getSelection(); if ( !selection?.rangeCount || selection.isCollapsed ) { return; } const node = selection.anchorNode; const el = node?.nodeType === Node.TEXT_NODE ? node.parentElement : node; const editable = el?.closest?.( '[contenteditable="true"], [contenteditable="plaintext-only"]', ); if (editable) { state.lastFocusedEditable = { type: 'contenteditable', element: editable, ranges: [...Array(selection.rangeCount)] .map( (_, i) => selection .getRangeAt(i) .cloneRange(), ), }; } } function showToolbar(rect) { if (!rect) return; toolbar.classList.remove( 'tp-hidden', ); toolbar.style.visibility = 'hidden'; requestAnimationFrame(() => { const gap = 8; const margin = 8; const tw = toolbar.offsetWidth; const th = toolbar.offsetHeight; let left = rect.left + (rect.width || 0) / 2 - tw / 2; let top = rect.top - th - gap; if (top < margin) { top = rect.bottom + gap; } left = clamp( left, margin, innerWidth - tw - margin, ); top = clamp( top, margin, innerHeight - th - margin, ); toolbar.style.left = `${left}px`; toolbar.style.top = `${top}px`; toolbar.style.visibility = 'visible'; }); } function hideToolbar() { toolbar.classList.add( 'tp-hidden', ); } async function openByButton(button) { if (!state.selection?.text) { return; } state.currentButton = button; state.pinned = Boolean(button.pin); state.suppressOutsideCloseUntil = Date.now() + 250; updatePinButton(); hideToolbar(); hideModelMenu(); if ( typeof button.beforeCallback === 'function' ) { try { await button.beforeCallback( state.selection, ); } catch (error) { console.error(error); } } const context = await getContext( button, state.selection, ); const selectionValue = button.useSelectedHtml ? state.selection.html : state.selection.text; const prompt = String( button.prompt || '{{selection}}', ) .replaceAll( '{{selection}}', selectionValue || '', ) .replaceAll( '{{context}}', context || '', ); const session = createSession({ mode: button.isChat ? 'chat' : 'action', title: button.name, system: button.system || '', buttonId: button.id, source: state.selection.source, selection: state.selection.text, }); state.session = session; state.attachedImages = []; state.attachPageContext = false; renderAttachments(); resetInput(); setFooterVisible(Boolean(button.isChat)); showDialog(); if (button.isChat) { showWelcome( '开始与 AI 对话吧!', ); resetInput( selectionToMarkdownDisplay( state.selection, ), ); input.focus(); if ( button.isAutoSend && state.selection.text ) { await sendMessage( prompt, { displayText: selectionToMarkdownDisplay( state.selection, ), }, ); } } else { hideWelcome(); await sendMessage( prompt, { hiddenUser: true, displayText: selectionToMarkdownDisplay( state.selection, ), }, ); } } function openNewChat( clearInput = true, ) { abortRequest(); const chatButton = buttons.find( button => button.isChat, ) || { id: 'aiChat', name: '聊天', system: '你是一个全能的 AI 助手。', pin: true, }; state.currentButton = chatButton; state.pinned = Boolean(chatButton.pin); updatePinButton(); state.session = createSession({ mode: 'chat', title: '聊天', system: chatButton.system, buttonId: chatButton.id, source: { title: document.title, url: location.href, }, selection: state.selection?.text || '', }); state.attachedImages = []; state.attachPageContext = false; renderAttachments(); setFooterVisible(true); showDialog(); showWelcome( '开始与 AI 对话吧!', ); if (clearInput) { resetInput(); } else if ( state.selection?.text ) { resetInput( selectionToMarkdownDisplay( state.selection, ), ); } else { resetInput(); } input.focus(); } function createSession({ mode, title, system, buttonId, source, selection, }) { return { id: crypto.randomUUID?.() || `tp-${Date.now()}-${Math.random() .toString(16) .slice(2)}`, mode, title, buttonId, system, source, selection, createdAt: Date.now(), updatedAt: Date.now(), messages: system ? [ { id: newId(), role: 'system', content: system, hidden: true, }, ] : [], }; } function showDialog() { if (!state.maximized) { dialog.style.height = ''; } dialog.classList.remove( 'tp-hidden', ); state.collapsed = false; dialog.classList.remove( 'tp-collapsed', ); dialog.classList.toggle( 'tp-chat-mode', state.session?.mode === 'chat', ); updateInputPlaceholder(); updateCollapseButton(); updateHeader(); renderSession(); if (!dialog.style.left) { centerDialog(); } keepDialogVisible(); } function closeDialog() { abortRequest(); persistSession(); resetInput(); state.attachedImages = []; state.attachPageContext = false; renderAttachments(); dialog.classList.add( 'tp-hidden', ); hideModelMenu(); hideContextMenu(); } function updateHeader() { titleEl.textContent = state.session?.title || '聊天'; updatePinButton(); updateModelButton(); $('#tp-save').classList.toggle( 'tp-hidden-control', !state.session, ); } function showWelcome(text) { dialog.classList.add( 'tp-welcome-mode', ); welcome.classList.remove( 'tp-hidden', ); $('#tp-welcome-title').textContent = text; messageList.classList.add( 'tp-with-welcome', ); } function hideWelcome() { dialog.classList.remove( 'tp-welcome-mode', ); welcome.classList.add( 'tp-hidden', ); messageList.classList.remove( 'tp-with-welcome', ); } async function sendInputMessage() { if (state.sending) { return; } const text = input.value.trim(); if ( !text && state.attachedImages.length === 0 && !state.attachPageContext ) { return; } resetInput(); hideContextMenu(); await sendMessage( text || '请分析附件内容。', { displayText: text || '附件', }, ); } async function sendMessage( text, options = {}, ) { if (!state.session) { openNewChat(true); } if ( !currentModel?.url || !currentModel?.model || !currentModel?.apiKey ) { if ( state.session?.mode === 'action' ) { setFooterVisible(true); } toast( '请先配置 API 地址、模型名称和 API Key', true, ); openSettings(); return; } if (state.sending) { return; } hideWelcome(); const imagesForMessage = state.attachedImages.map( item => ({ ...item }), ); const pageContext = state.attachPageContext ? getPageContextText() : ''; let finalText = text; if (pageContext) { finalText += `\n\n---\n` + `以下是当前网页内容,仅作为上下文参考:\n` + `网页标题:${document.title}\n` + `网页地址:${location.href}\n\n` + pageContext; } else if ( settings.showPageSource && state.session.messages.filter( message => message.role === 'user', ).length === 0 ) { finalText += `\n\n---\n` + `网页来源:${document.title}\n` + `${location.href}`; } const userMessage = { id: newId(), role: 'user', content: finalText, displayContent: (() => { const displayText = options.displayText ?? text; return displayText === state.session?.selection ? selectionToMarkdownDisplay( state.selection, ) : displayText; })(), hidden: Boolean( options.hiddenUser, ), images: imagesForMessage.map( image => ({ name: image.name, dataUrl: image.dataUrl, }), ), createdAt: Date.now(), }; state.session.messages.push( userMessage, ); const assistantMessage = { id: newId(), role: 'assistant', content: '', reasoning: '', variants: [], activeVariant: 0, modelName: currentModel.modelName || currentModel.model, createdAt: Date.now(), pending: true, }; state.session.messages.push( assistantMessage, ); state.attachedImages = []; state.attachPageContext = false; renderAttachments(); renderSession(); setSending(true); scrollToBottom(); const apiMessages = buildApiMessages( state.session.messages, imagesForMessage, ); try { const result = await callModel( currentModel, apiMessages, partial => { assistantMessage.content = partial.content; assistantMessage.reasoning = partial.reasoning; updateAssistantMessageDOM( assistantMessage, ); }, ); assistantMessage.content = result.content || assistantMessage.content; assistantMessage.reasoning = result.reasoning || assistantMessage.reasoning; assistantMessage.pending = false; assistantMessage.variants = [ { content: assistantMessage.content, reasoning: assistantMessage.reasoning, createdAt: Date.now(), }, ]; assistantMessage.activeVariant = 0; state.session.updatedAt = Date.now(); renderSession(); persistSession(); if ( typeof state.currentButton ?.afterCallback === 'function' ) { try { await state.currentButton .afterCallback( assistantMessage.content, assistantMessage, ); } catch (error) { console.error(error); } } } catch (error) { assistantMessage.pending = false; if ( error?.name === 'AbortError' ) { if ( assistantMessage.content || assistantMessage.reasoning ) { assistantMessage.variants = [ { content: assistantMessage.content, reasoning: assistantMessage.reasoning, createdAt: Date.now(), }, ]; } renderSession(); } else { assistantMessage.error = error.message || String(error); renderSession(); toast( assistantMessage.error, true, ); } } finally { setSending(false); if ( state.session?.mode === 'action' ) { setFooterVisible(true); } state.requestHandle = null; persistSession(); scrollToBottom(); } } function buildApiMessages( sessionMessages, latestImages = [], ) { return sessionMessages .filter( message => [ 'system', 'user', 'assistant', ].includes( message.role, ) && !message.error, ) .map(message => { let content = message.content; if ( message.role === 'assistant' && message.variants?.length ) { const variant = message.variants[ message.activeVariant ] || message.variants.at(-1); content = variant?.content ?? content; } const latestUserMessage = [...sessionMessages] .reverse() .find( item => item.role === 'user', ); if ( message.role === 'user' && message === latestUserMessage && latestImages.length ) { content = [ { type: 'text', text: content, }, ...latestImages.map( image => ({ type: 'image_url', image_url: { url: image.dataUrl, }, }), ), ]; } return { role: message.role, content, }; }); } function callModel( model, apiMessages, onPartial, ) { const url = getApiUrl(model.url); const stream = model.stream !== false; if (stream) { return callModelWithFetch( model, url, apiMessages, onPartial, ); } return callModelWithGM( model, url, apiMessages, false, ); } function callModelWithGM( model, url, apiMessages, stream, ) { const body = { model: model.model, messages: apiMessages, temperature: Number.isFinite( Number( model.temperature, ), ) ? Number( model.temperature, ) : 0.7, stream, }; if (model.max_tokens) { body.max_tokens = model.max_tokens; } if ( model.extraBody && typeof model.extraBody === 'object' ) { Object.assign( body, model.extraBody, ); } return new Promise( (resolve, reject) => { let processedLength = 0; let sseBuffer = ''; let fullContent = ''; let fullReasoning = ''; let finished = false; const finish = result => { if (finished) { return; } finished = true; resolve(result); }; const parseSSE = ( newText, final = false, ) => { sseBuffer += newText; const blocks = sseBuffer.split( /\r?\n\r?\n/, ); sseBuffer = final ? '' : blocks.pop(); for ( const block of blocks ) { const lines = block.split( /\r?\n/, ); for ( const line of lines ) { if ( !line.startsWith( 'data:', ) ) { continue; } const data = line .slice(5) .trim(); if (!data) { continue; } if ( data === '[DONE]' ) { finish({ content: fullContent, reasoning: fullReasoning, }); return; } try { const json = JSON.parse( data, ); const delta = json ?.choices ?.[0] ?.delta || json ?.choices ?.[0] ?.message || {}; const contentPart = extractContent( delta.content, ); const reasoningPart = extractContent( delta.reasoning_content ?? delta.reasoning ?? delta.thinking, ); if ( contentPart ) { fullContent += contentPart; } if ( reasoningPart ) { fullReasoning += reasoningPart; } onPartial?.({ content: fullContent, reasoning: fullReasoning, }); } catch (_) { // 忽略不完整或非 JSON 的 SSE 行 } } } }; const request = GM_xmlhttpRequest({ method: 'POST', url, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${model.apiKey}`, ...( model.headers || {} ), }, data: JSON.stringify( body, ), responseType: 'text', timeout: Number( model.timeout || 180000, ), onprogress: response => { if ( !stream ) { return; } const text = response .responseText || ''; const chunk = text.slice( processedLength, ); processedLength = text.length; if (chunk) { parseSSE( chunk, false, ); } }, onload: async response => { if ( response.status < 200 || response.status >= 300 ) { reject( new Error( `请求失败(HTTP ${response.status}):${getApiError( response.responseText, )}`, ), ); return; } if ( stream ) { const text = response .responseText || ''; const chunk = text.slice( processedLength, ); parseSSE( chunk, true, ); if ( !finished ) { finish({ content: fullContent, reasoning: fullReasoning, }); } return; } try { const json = JSON.parse( response.responseText || '{}', ); const message = json ?.choices ?.[0] ?.message || {}; const content = extractContent( message.content, ) || extractContent( json?.output_text, ) || extractContent( json ?.choices ?.[0] ?.text, ); const reasoning = extractContent( message.reasoning_content ?? message.reasoning ?? message.thinking, ); if ( !content && !reasoning ) { throw new Error( '接口没有返回可识别的内容', ); } finish({ content, reasoning, }); } catch ( error ) { reject( new Error( `解析接口返回失败:${error.message}`, ), ); } }, onerror: () => reject( new Error( '网络请求失败,请检查 API 地址、代理或接口权限。', ), ), ontimeout: () => reject( new Error( '请求超时。', ), ), onabort: () => { const error = new Error( '已停止生成', ); error.name = 'AbortError'; reject(error); }, }); state.requestHandle = request; }, ); } async function callModelWithFetch( model, url, apiMessages, onPartial, ) { const body = { model: model.model, messages: apiMessages, temperature: Number.isFinite(Number(model.temperature)) ? Number(model.temperature) : 0.7, stream: true, }; if (model.max_tokens) { body.max_tokens = model.max_tokens; } if ( model.extraBody && typeof model.extraBody === 'object' ) { Object.assign(body, model.extraBody); } const controller = new AbortController(); state.requestHandle = controller; const fetchImpl = typeof unsafeWindow?.fetch === 'function' ? unsafeWindow.fetch.bind( unsafeWindow, ) : fetch; let response; try { response = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${model.apiKey}`, ...(model.headers || {}), }, body: JSON.stringify(body), signal: controller.signal, }); } catch (error) { if (error?.name === 'AbortError') { throw error; } return callModelWithGM( model, url, apiMessages, true, ); } if (!response.ok) { let detail = ''; try { detail = getApiError( await response.text(), ); } catch (_) { detail = response.statusText; } throw new Error( `请求失败(HTTP ${response.status}):${detail}`, ); } if (!response.body?.getReader) { throw new Error( '当前浏览器不支持流式读取,请升级浏览器。', ); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let content = ''; let reasoning = ''; let done = false; const emit = data => { if (data === '[DONE]') { done = true; return; } let json; try { json = JSON.parse(data); } catch (_) { return; } if (json?.error) { throw new Error( json.error.message || String(json.error), ); } const delta = json?.choices?.[0]?.delta || json?.choices?.[0]?.message || {}; const contentPart = extractContent( delta.content, ); const reasoningPart = extractContent( delta.reasoning_content ?? delta.reasoning ?? delta.thinking, ); if (contentPart) { content += contentPart; } if (reasoningPart) { reasoning += reasoningPart; } if (contentPart || reasoningPart) { onPartial?.({ content, reasoning }); } }; const consumeLine = line => { const value = line.trim(); if (!value) return; if (value.startsWith('data:')) { emit(value.slice(5).trim()); } else if (!done) { emit(value); } }; try { while (!done) { const result = await reader.read(); if (result.done) { buffer += decoder.decode(); break; } buffer += decoder.decode( result.value, { stream: true }, ); const lines = buffer.split(/\r?\n/); buffer = lines.pop() || ''; for (const line of lines) { consumeLine(line); if (done) break; } } if (!done && buffer.trim()) { consumeLine(buffer); } } finally { reader.releaseLock(); } return { content, reasoning }; } function abortRequest() { if ( state.requestHandle?.abort ) { state.requestHandle.abort(); } state.requestHandle = null; setSending(false); if ( state.session?.mode === 'action' && !dialog.classList.contains( 'tp-hidden', ) ) { setFooterVisible(true); } } function setSending(sending) { state.sending = sending; sendButton.classList.toggle( 'tp-hidden', sending, ); stopButton.classList.toggle( 'tp-hidden', !sending, ); input.disabled = sending; } function renderSession() { if (!state.session) { messageList.innerHTML = ''; return; } updateHeader(); const visible = state.session.messages.filter( message => !message.hidden && message.role !== 'system', ); messageList.innerHTML = visible .map(renderMessage) .join(''); wireCodeCopyButtons(); scrollToBottomSoon(); } function renderMessage(message) { const isChat = state.session?.mode === 'chat' || state.session ?.messages .filter( item => item.role === 'user' && !item.hidden, ).length > 0; if ( message.role === 'user' ) { const text = message.displayContent ?? message.content; return ` `; } if ( message.role === 'assistant' ) { const variant = message.variants?.length ? ( message.variants[ message.activeVariant ] || message.variants.at(-1) ) : null; const content = variant?.content ?? message.content ?? ''; const reasoning = variant?.reasoning ?? message.reasoning ?? ''; const body = message.error ? `${escapeHtml( historyPreview( session, ), )}
${
paragraph
.map(
inlineMarkdown,
)
.join(
'
',
)
}
${inlineMarkdown( quoteLine[1], )}`, ); } else if ( /^\s*([-*_])\1\1+\s*$/ .test(line) ) { flushParagraph(); flushList(); out.push( '
${escapeHtml(block.code)}
$1',
)
.replace(
/\*\*([^*]+)\*\*/g,
'$1',
)
.replace(
/__([^_]+)__/g,
'$1',
)
.replace(
/(?$1',
)
.replace(
/~~([^~]+)~~/g,
'Enter 提交
Shift+Enter 换行