// ==UserScript== // @name Thpilot AI 划词助手 // @namespace ThpilotAIHelper // @version 2.0.7 // @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 = `[ ${defaultButtons.map(button => { const lines = [' {']; lines.push(` enable: ${button.enable !== false},`); lines.push(` id: ${JSON.stringify(button.id)},`); lines.push(` name: ${JSON.stringify(button.name)},`); lines.push(` icon: ${JSON.stringify(compactIcon(button.icon || ''))},`); lines.push(` prompt: ${jsString(button.prompt)},`); lines.push(` system: ${jsString(button.system)},`); lines.push(` context: ${JSON.stringify(button.context || '')},`); if (button.useSelectedHtml) { lines.push(' useSelectedHtml: true,'); } if (button.isChat) { lines.push(' isChat: true,'); } if (button.isAutoSend === false) { lines.push(' isAutoSend: false,'); } if (button.pin) { lines.push(' pin: true,'); } if (button.replaceCallback === genericReplaceCallback) { lines.push(' replaceCallback: genericReplaceCallback,'); } lines.push(' }'); return lines.join('\n'); }).join(',\n')} ]`; /////////////////////////// 代码区 /////////////////////////// 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: item.beforeCallback, afterCallback: item.afterCallback, replaceCallback: item.replaceCallback || null, 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 parseButtonsConfig(source, fallback) { if (!source) return fallback; const text = String(source).trim(); if (!text) return fallback; try { if (/^(?:const|let|var)\s+buttons\s*=/.test(text)) { return Function( 'genericReplaceCallback', `${text}; return buttons;`, )(genericReplaceCallback); } return Function( 'genericReplaceCallback', `return (${text});`, )(genericReplaceCallback); } catch (_) { return fallback; } } function parseButtonsConfigStrict(source) { const parsed = parseButtonsConfig(source, null); if (!Array.isArray(parsed)) { throw new Error('请填写按钮数组,例如 [{ 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 jsString(value) { const text = String(value || ''); if (text.includes('\n')) { return `\`${text .replace(/\\/g, '\\\\') .replace(/`/g, '\\`') .replace(/\$\{/g, '\\${')}\``; } return JSON.stringify(text); } function jsProperty(key, value, level) { const indent = ' '.repeat(level); return `${indent}${key}: ${value},`; } function serializeButtonsConfig(input) { const chunks = input.map(item => { const lines = [' {']; lines.push(jsProperty('enable', item.enable !== false, 4)); lines.push(jsProperty('id', jsString(item.id), 4)); lines.push(jsProperty('name', jsString(item.name), 4)); lines.push(jsProperty('icon', jsString(compactIcon(item.icon)), 4)); lines.push(jsProperty('prompt', jsString(item.prompt), 4)); lines.push(jsProperty('system', jsString(item.system), 4)); lines.push(jsProperty('context', jsString(item.context), 4)); if (item.replaceCallback) { const callback = item.replaceCallback === genericReplaceCallback ? 'genericReplaceCallback' : item.replaceCallback.toString(); lines.push(jsProperty('replaceCallback', callback, 4)); } if (item.useSelectedHtml) { lines.push(jsProperty('useSelectedHtml', true, 4)); } if (item.isChat) { lines.push(jsProperty('isChat', true, 4)); } if (item.isAutoSend) { lines.push(jsProperty('isAutoSend', true, 4)); } if (item.pin) { lines.push(jsProperty('pin', true, 4)); } lines.push(' }'); return lines.join('\n'); }); return `[ ${chunks.join(',\n')} ]`; } 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); 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 `
${ isChat ? `
${formatTime(message.createdAt)}   ${escapeHtml(settings.userName || '用户')} 👤
` : '' }
${renderPlainText(text)}
${renderMessageImages(message.images)}
`; } 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(message.error)}
` : ` ${renderThinking(reasoning)}
${ message.pending && !content ? '正在生成' : renderMarkdown( stripReplaceWrapper( content, ), ) }
`; return `
${ isChat ? `
🤖 ${escapeHtml( message.modelName || currentModel.modelName || currentModel.model, )} ${formatTime(message.createdAt)}
` : '' }
${body}
${renderVariantTabs(message)} ${ !message.pending && !message.error ? `
${ state.currentButton?.replaceCallback ? ` ` : '' }
` : '' }
`; } return ''; } function renderVariantTabs( message, ) { if ( !message.variants || message.variants.length <= 1 ) { return ''; } return `
${ message.variants .map( (_, index) => ` `, ) .join('') }
`; } function updateAssistantMessageDOM( message, ) { const article = messageList.querySelector( `[data-message-id="${cssEscape(message.id)}"]`, ); if (!article) { return renderSession(); } const body = article.querySelector( '.tp-message-body', ); if (!body) { return; } body.innerHTML = ` ${renderThinking(message.reasoning)}
${ message.content ? renderMarkdown( stripReplaceWrapper( message.content, ), ) : '正在生成' }
`; wireCodeCopyButtons(); if (isNearBottom()) { scrollToBottom(); } } async function handleMessageAction( event, ) { const button = event.target.closest( '[data-action]', ); if (!button) { return; } const article = button.closest( '[data-message-id]', ); if (!article) { return; } const message = state.session?.messages.find( item => item.id === article.dataset.messageId, ); if (!message) { return; } const action = button.dataset.action; if (action === 'copy') { return copyMessage( message, event.shiftKey, ); } if (action === 'edit') { return startEditMessage( message, article, ); } if (action === 'replace') { return replaceFromMessage( message, ); } if ( action === 'regenerate' ) { return regenerateMessage( message, ); } if (action === 'delete') { return deleteMessage( message, ); } if (action === 'variant') { message.activeVariant = Number( button.dataset .variantIndex, ); renderSession(); persistSession(); } } function startEditMessage( message, article, ) { if ( article.querySelector( '.tp-edit-area', ) ) { return; } const variant = getActiveVariant(message); const original = variant?.content ?? message.content ?? ''; const body = article.querySelector( '.tp-message-body', ); body.innerHTML = `
`; const textarea = body.querySelector( 'textarea', ); textarea.value = original; textarea.focus(); body.querySelector( '[data-edit-save]', ).onclick = () => { if (variant) { variant.content = textarea.value; } else { message.content = textarea.value; } message.edited = true; renderSession(); persistSession(); }; body.querySelector( '[data-edit-cancel]', ).onclick = renderSession; } async function copyMessage( message, copyAll, ) { let text; if (copyAll) { text = conversationToMarkdown( state.session, ); } else { text = message.role === 'assistant' ? ( getActiveVariant( message, )?.content ?? message.content ) : ( message.displayContent ?? message.content ); } await copyText( text || '', ); toast( copyAll ? '已复制全部对话' : '已复制', ); } async function replaceFromMessage( message, ) { const raw = getActiveVariant( message, )?.content ?? message.content ?? ''; const replaceResult = extractReplaceResult(raw) || raw; try { if ( typeof state.currentButton ?.replaceCallback === 'function' ) { await state.currentButton .replaceCallback( replaceResult, raw, state.selection, ); toast( '已写入选区或光标位置', ); } } catch (error) { await copyText( replaceResult, ); toast( `无法写入当前网页,已复制结果:${error.message}`, true, ); } } async function genericReplaceCallback( replaceResult, ) { const target = state.selection?.editable || state.lastFocusedEditable; if ( !target?.element ?.isConnected ) { await copyText( replaceResult, ); throw new Error( '原选区不是可编辑区域', ); } if ( target.type === 'text-control' ) { const el = target.element; const start = target.start; const end = target.end; const plain = htmlToText( replaceResult, ); el.focus(); el.setRangeText( plain, start, end, 'end', ); el.dispatchEvent( new InputEvent( 'input', { bubbles: true, inputType: 'insertText', data: plain, }, ), ); return; } if ( target.type === 'contenteditable' ) { const range = target.ranges?.[0]; if (!range) { throw new Error( '选区已失效', ); } target.element.focus(); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); range.deleteContents(); const fragment = range.createContextualFragment( sanitizeReplacementHtml( replaceResult, ), ); const lastNode = fragment.lastChild; range.insertNode( fragment, ); if (lastNode) { range.setStartAfter( lastNode, ); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } target.element.dispatchEvent( new InputEvent( 'input', { bubbles: true, inputType: 'insertText', }, ), ); return; } throw new Error( '当前网页不允许编辑', ); } async function regenerateMessage( message, ) { if (state.sending) { return; } const index = state.session.messages.indexOf( message, ); const prior = state.session.messages .slice(0, index) .filter( item => !item.error, ); const lastUser = [...prior] .reverse() .find( item => item.role === 'user', ); if (!lastUser) { return; } message.pending = true; message.error = ''; message.content = ''; message.reasoning = ''; renderSession(); setSending(true); try { const result = await callModel( currentModel, buildApiMessages( prior, ), partial => { message.content = partial.content; message.reasoning = partial.reasoning; updateAssistantMessageDOM( message, ); }, ); message.pending = false; message.content = result.content; message.reasoning = result.reasoning; message.variants ||= []; message.variants.push({ content: result.content, reasoning: result.reasoning, createdAt: Date.now(), }); message.activeVariant = message.variants.length - 1; message.modelName = currentModel.modelName || currentModel.model; renderSession(); persistSession(); } catch (error) { if ( error.name !== 'AbortError' ) { message.pending = false; message.error = error.message; renderSession(); } } finally { setSending(false); } } function deleteMessage( message, ) { const index = state.session.messages.indexOf( message, ); if (index < 0) { return; } if ( message.role === 'assistant' && message.variants?.length > 1 ) { message.variants.splice( message.activeVariant, 1, ); message.activeVariant = Math.max( 0, Math.min( message.activeVariant, message.variants .length - 1, ), ); } else { state.session.messages.splice( index, 1, ); } renderSession(); persistSession(); } function getActiveVariant( message, ) { if ( !message.variants?.length ) { return null; } return ( message.variants[ message.activeVariant ] || message.variants.at(-1) ); } function renderThinking( reasoning, ) { const mode = currentModel?.thinking ?? 'auto'; if ( mode === false || mode === 'false' ) { return ''; } if ( !reasoning && mode !== true && mode !== 'true' ) { return ''; } return `
深度思考
${ renderMarkdown( reasoning || '模型未返回单独的思考内容', ) }
`; } function togglePin() { state.pinned = !state.pinned; updatePinButton(); } function updatePinButton() { pinButton.classList.toggle( 'tp-active', state.pinned, ); pinButton.title = state.pinned ? '取消固定' : '固定窗口'; } function toggleModelMenu() { modelMenu.classList.toggle( 'tp-hidden', ); hideContextMenu(); } function hideModelMenu() { modelMenu.classList.add( 'tp-hidden', ); } function renderModelMenu() { modelMenu.innerHTML = ` ${ models .map( (model, index) => ` `, ) .join('') }
`; } function selectModel(index) { if (!Number.isInteger(index) || !models[index]) return; currentModelIndex = index; currentModel = clone(models[index]); GM_setValue( 'thpilot-web-current-model', index, ); renderModelMenu(); updateModelButton(); toast( `已切换到 ${ currentModel.modelName || currentModel.model }`, ); } function updateModelButton() { $('#tp-model-label').textContent = 'AI'; modelButton.title = currentModel ? `当前模型:${ currentModel.modelName || currentModel.model }` : '选择模型'; } function openInputContextMenu( event, ) { event.preventDefault(); contextMenu.classList.remove( 'tp-hidden', ); const menuRect = contextMenu.getBoundingClientRect(); const dialogRect = dialog.getBoundingClientRect(); let left = event.clientX - dialogRect.left; let top = event.clientY - dialogRect.top; left = clamp( left, 8, dialogRect.width - menuRect.width - 8, ); top = clamp( top, 48, dialogRect.height - menuRect.height - 8, ); contextMenu.style.left = `${left}px`; contextMenu.style.top = `${top}px`; hideModelMenu(); } function hideContextMenu() { contextMenu.classList.add( 'tp-hidden', ); } async function handleInputPaste( event, ) { const files = [ ...( event.clipboardData ?.items || [] ), ] .filter( item => item.kind === 'file' && item.type.startsWith( 'image/', ), ) .map( item => item.getAsFile(), ) .filter(Boolean); if (files.length) { event.preventDefault(); await addImageFiles( files, ); } } async function addImageFiles( files, ) { for (const file of files) { if ( !file.type.startsWith( 'image/', ) ) { continue; } if ( file.size > 12 * 1024 * 1024 ) { toast( `${file.name} 超过 12MB,已跳过`, true, ); continue; } const dataUrl = await fileToDataUrl( file, ); state.attachedImages.push({ name: file.name || '剪贴板图片', type: file.type, dataUrl, }); } renderAttachments(); } function togglePageContext() { state.attachPageContext = !state.attachPageContext; renderAttachments(); } function renderAttachments() { attachmentsBar.innerHTML = state.attachedImages .map( ( image, index, ) => `
${escapeHtml(image.name)}
`, ) .join(''); pageContextChip.classList.toggle( 'tp-hidden', !state.attachPageContext, ); attachmentsBar.classList.toggle( 'tp-hidden', state.attachedImages.length === 0, ); } function getPageContextText() { const main = document.querySelector( 'article, main, [role="main"]', ); const source = main?.innerHTML || document.body?.innerHTML || ''; const container = document.createElement('div'); container.innerHTML = source; container .querySelectorAll('img') .forEach(image => { const src = image.getAttribute('src') || image.getAttribute('data-src') || ''; const alt = image.getAttribute('alt') || image.getAttribute('title') || '图片'; image.replaceWith( document.createTextNode( src ? `\n![${alt}](${src})\n` : `\n![${alt}]()\n`, ), ); }); const text = ( container.textContent || '' ) .replace( /\n{3,}/g, '\n\n', ) .trim(); return truncate( text, Number( settings.contextMaxLength, ) || defaultContextMaxLength, ); } async function getContext( button, selection, ) { if (!button?.context) { return ''; } const contexts = String(button.context) .split(/[,,]/) .map( item => item.trim(), ) .filter(Boolean); const output = []; for ( const type of contexts ) { if ( type === 'blockText' || type === 'blockHtml' ) { const block = findReadableBlock( selection.anchorElement, ); if (!block) { continue; } output.push( type === 'blockText' ? `当前选区所在文本块:\n${truncate( block.innerText || block.textContent || '', settings.contextMaxLength, )}` : `当前选区所在文本块 HTML:\n${truncate( block.outerHTML || '', settings.contextMaxLength, )}`, ); } else if ( type === 'editorText' ) { output.push( `当前可见网页文本:\n${truncate( getVisiblePageText(), settings.contextMaxLength, )}`, ); } else if ( type === 'editorHtml' ) { output.push( `当前网页 HTML:\n${truncate( document.body ?.innerHTML || '', settings.contextMaxLength, )}`, ); } else if ( type === 'currentMd' ) { output.push( `当前网页正文:\n${getPageContextText()}`, ); } else if ( type === 'bodyHtml' ) { output.push( `document.body 源码:\n${truncate( document.body ?.outerHTML || '', settings.contextMaxLength, )}`, ); } } return output.length ? `\n\n---\n\n以下内容仅作为上下文参考,无需单独解释:\n\n${output.join( '\n\n', )}` : ''; } function findReadableBlock( element, ) { if (!element) { return null; } const selectors = 'pre, blockquote, li, p, h1, h2, h3, h4, h5, h6, article, section, td, div'; let block = element.closest?.( selectors, ); while ( block && ( block.innerText || '' ).trim().length > 5000 && block.parentElement ) { const child = [...block.children] .find( item => item.contains( element, ) && ( item.innerText || '' ).trim(), ); if (!child) { break; } block = child; } return block; } function getVisiblePageText() { const pieces = []; const walker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { const el = node.parentElement; if (!el) { return NodeFilter .FILTER_REJECT; } if ( [ 'SCRIPT', 'STYLE', 'NOSCRIPT', ].includes( el.tagName, ) ) { return NodeFilter .FILTER_REJECT; } const rect = el.getBoundingClientRect(); if ( rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth ) { return NodeFilter .FILTER_REJECT; } return node.textContent .trim() ? NodeFilter .FILTER_ACCEPT : NodeFilter .FILTER_REJECT; }, }, ); let node; while ( ( node = walker.nextNode() ) ) { pieces.push( node.textContent.trim(), ); } return pieces.join('\n'); } function openSettings() { $('#tp-settings-models').value = stringifySettingsJSON( models, ); $('#tp-settings-buttons').value = getButtonsSource(); $('#tp-settings-theme').value = settings.theme || 'auto'; $('#tp-settings-user').value = settings.userName || '用户'; $('#tp-settings-shortcut').value = settings.shortcut || shortcut; $('#tp-settings-context-length').value = settings.contextMaxLength || defaultContextMaxLength; $('#tp-settings-close-outside').checked = Boolean( settings.closeOnOutside, ); $('#tp-settings-source').checked = Boolean( settings.showPageSource, ); settingsOverlay.classList.remove( 'tp-hidden', ); hideModelMenu(); } function closeSettings() { settingsOverlay.classList.add( 'tp-hidden', ); } function resetSettingsUI() { $('#tp-settings-models').value = stringifySettingsJSON( defaultModels, ); $('#tp-settings-buttons').value = defaultButtonsSource; $('#tp-settings-theme').value = defaultSettings.theme; $('#tp-settings-user').value = defaultSettings.userName; $('#tp-settings-shortcut').value = shortcut; $('#tp-settings-context-length').value = defaultContextMaxLength; $('#tp-settings-close-outside').checked = defaultSettings.closeOnOutside; $('#tp-settings-source').checked = defaultSettings.showPageSource; } function saveSettingsFromUI() { let parsedModels; let parsedButtons; let buttonsSource; try { parsedModels = normalizeModels( JSON.parse( $('#tp-settings-models') .value, ), ); if (!parsedModels.length) { throw new Error( '至少需要一个模型', ); } } catch (error) { toast( `模型 JSON 无效:${error.message}`, true, ); return; } try { buttonsSource = $('#tp-settings-buttons') .value .trim(); if (!buttonsSource) { parsedButtons = normalizeButtons( defaultButtons, ); } else { parsedButtons = normalizeButtons( parseButtonsConfigStrict( buttonsSource, ), ); if (!parsedButtons.length) { throw new Error( '至少需要一个按钮', ); } } } catch (error) { toast( `按钮配置无效:${error.message}`, true, ); return; } models = parsedModels; buttons = parsedButtons; settings = { ...settings, theme: $('#tp-settings-theme') .value, userName: $('#tp-settings-user') .value .trim() || '用户', shortcut: $('#tp-settings-shortcut') .value .trim() || shortcut, contextMaxLength: clamp( Number( $('#tp-settings-context-length') .value, ) || defaultContextMaxLength, 1000, 100000, ), closeOnOutside: $('#tp-settings-close-outside') .checked, showPageSource: $('#tp-settings-source') .checked, }; if (buttonsSource) { settings.buttonsSource = buttonsSource; delete settings.buttons; } else { delete settings.buttonsSource; delete settings.buttons; } GM_setValue( STORAGE.models, models, ); GM_setValue( STORAGE.settings, settings, ); currentModelIndex = clamp( currentModelIndex, 0, models.length - 1, ); currentModel = clone( models[ currentModelIndex ], ); renderModelMenu(); renderToolbarButtons(); updateModelButton(); applyTheme(); closeSettings(); toast( '设置已保存,快捷键修改后建议刷新网页', ); } function applyTheme() { const dark = settings.theme === 'dark' || ( settings.theme === 'auto' && state.themeMedia ?.matches ); host.dataset.theme = dark ? 'dark' : 'light'; } function openHistory() { renderHistory(); historyOverlay.classList.remove( 'tp-hidden', ); hideModelMenu(); } function closeHistory() { historyOverlay.classList.add( 'tp-hidden', ); } function getHistory() { const history = GM_getValue( STORAGE.history, [], ); return Array.isArray( history, ) ? history : []; } function renderHistory() { const history = getHistory(); const list = $('#tp-history-list'); list.innerHTML = history.length ? history .map( session => `
${escapeHtml( session.title || '聊天', )} ${formatDate( session.updatedAt || session.createdAt, )}

${escapeHtml( historyPreview( session, ), )}

`, ) .join('') : `
暂无历史会话
`; } function persistSession() { if (!state.session) { return; } const safeSession = sanitizeSessionForStorage( state.session, ); GM_setValue( STORAGE.lastSession, safeSession, ); const history = getHistory(); const index = history.findIndex( item => item.id === safeSession.id, ); if (index >= 0) { history.splice( index, 1, ); } history.unshift( safeSession, ); GM_setValue( STORAGE.history, history.slice( 0, globalHistoryNum, ), ); } function sanitizeSessionForStorage( session, ) { const copy = clone(session); copy.messages = copy.messages.map( message => { if ( message.images?.length ) { message.images = message.images.map( image => ({ name: image.name, dataUrl: '', }), ); } if ( Array.isArray( message.content, ) ) { message.content = extractContent( message.content, ); } return message; }, ); return copy; } function openLastSession() { const last = GM_getValue( STORAGE.lastSession, null, ); if (!last) { return toast( '没有可恢复的会话', ); } loadSession(last); } function loadHistorySession( id, ) { const session = getHistory().find( item => item.id === id, ); if (!session) { return; } closeHistory(); loadSession(session); } function loadSession(session) { abortRequest(); state.session = clone(session); state.currentButton = buttons.find( button => button.id === session.buttonId, ) || buttons.find( button => button.isChat, ); state.pinned = Boolean( state.currentButton ?.pin, ); resetInput(); setFooterVisible(true); showDialog(); hideWelcome(); renderSession(); } function deleteHistorySession( id, ) { const history = getHistory().filter( item => item.id !== id, ); GM_setValue( STORAGE.history, history, ); renderHistory(); } function clearHistory() { if ( !confirm( '确定清空全部 Thpilot Web 历史会话吗?', ) ) { return; } GM_setValue( STORAGE.history, [], ); GM_setValue( STORAGE.lastSession, null, ); renderHistory(); toast( '历史会话已清空', ); } function downloadConversation() { if (!state.session) { return; } const text = conversationToMarkdown( state.session, ); const blob = new Blob( [text], { type: 'text/markdown;charset=utf-8', }, ); const url = URL.createObjectURL( blob, ); const a = document.createElement( 'a', ); a.href = url; a.download = `${safeFilename( state.session.title || 'AI聊天', )}-${ new Date() .toISOString() .slice(0, 19) .replaceAll( ':', '-', ) }.md`; document.body.appendChild(a); a.click(); a.remove(); setTimeout( () => URL.revokeObjectURL( url, ), 1000, ); toast( '聊天记录已导出为 Markdown', ); } function conversationToMarkdown( session, ) { const lines = [ `# ${session.title || 'AI聊天'}`, '', `- 时间:${new Date( session.createdAt, ).toLocaleString()}`, `- 网页:${session.source?.title || ''}`, `- 地址:${session.source?.url || ''}`, '', ]; for ( const message of session.messages ) { if ( message.role === 'system' || message.hidden ) { continue; } const name = message.role === 'user' ? ( settings.userName || '用户' ) : ( message.modelName || 'AI' ); const content = message.role === 'assistant' ? ( getActiveVariant( message, )?.content ?? message.content ) : ( message.displayContent ?? message.content ); lines.push( `## ${name}`, '', content || '', '', ); } return lines.join('\n'); } function toggleCollapse() { state.collapsed = !state.collapsed; dialog.classList.toggle( 'tp-collapsed', state.collapsed, ); updateCollapseButton(); } function updateCollapseButton() { const collapseButton = $('#tp-collapse'); const label = state.collapsed ? '还原窗口' : '最小化窗口'; collapseButton.innerHTML = state.collapsed ? iconRestore() : iconMinimize(); collapseButton.title = label; collapseButton.setAttribute( 'aria-label', label, ); } function toggleMaximize() { if (!state.maximized) { if (state.collapsed) { state.collapsed = false; dialog.classList.remove( 'tp-collapsed', ); updateCollapseButton(); } const rect = dialog.getBoundingClientRect(); state.beforeMaxRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height, heightStyle: dialog.style.height, maxHeightStyle: dialog.style.maxHeight, }; dialog.style.left = '6px'; dialog.style.top = '6px'; dialog.style.width = 'calc(100vw - 12px)'; dialog.style.height = 'calc(100vh - 12px)'; dialog.style.maxHeight = 'calc(100vh - 12px)'; $('#tp-collapse') .classList .remove( 'tp-hidden-control', ); state.maximized = true; } else { const rect = state.beforeMaxRect || { left: 40, top: 40, width, height: maxHeight, heightStyle: '', maxHeightStyle: '', }; dialog.style.left = `${rect.left}px`; dialog.style.top = `${rect.top}px`; dialog.style.width = `${rect.width}px`; dialog.style.height = rect.heightStyle || ''; dialog.style.maxHeight = rect.maxHeightStyle || ''; $('#tp-collapse') .classList .add( 'tp-hidden-control', ); state.collapsed = false; dialog.classList.remove( 'tp-collapsed', ); state.maximized = false; updateCollapseButton(); } } function startDrag(event) { event.stopPropagation(); if ( event.button !== 0 || event.target.closest( 'button', ) ) { return; } header.classList.add('tp-dragging'); const rect = dialog.getBoundingClientRect(); state.drag = { pointerId: event.pointerId, dx: event.clientX - rect.left, dy: event.clientY - rect.top, }; try { header.setPointerCapture?.( event.pointerId, ); } catch (_) { // Pointer capture is unavailable in some shadow DOM implementations. } event.preventDefault(); } function dragDialog(event) { if (!state.drag) { return; } const rect = dialog.getBoundingClientRect(); dialog.style.left = `${clamp( event.clientX - state.drag.dx, 0, innerWidth - rect.width, )}px`; dialog.style.top = `${clamp( event.clientY - state.drag.dy, 0, innerHeight - Math.min( rect.height, 48, ), )}px`; } function stopDrag() { state.drag = null; header.classList.remove('tp-dragging'); } function selectionToMarkdownDisplay(selection) { if (!selection?.html) { return selection?.text || ''; } const container = document.createElement('div'); container.innerHTML = selection.html; container .querySelectorAll('img') .forEach(image => { const src = image.getAttribute('src') || ''; const alt = image.getAttribute('alt') || image.getAttribute('title') || '图片'; image.replaceWith( document.createTextNode( src ? `\n![${alt}](${src})\n` : `\n![${alt}]()\n`, ), ); }); const markdown = container.textContent || ''; return markdown.trim() || selection.text || ''; } function centerDialog() { const w = Math.min( width, innerWidth - 20, ); dialog.style.width = `${w}px`; dialog.style.height = ''; dialog.style.left = `${Math.max( 10, ( innerWidth - w ) / 2, )}px`; dialog.style.top = `${Math.max( 10, ( innerHeight - Math.min( maxHeight, innerHeight - 20, ) ) / 2, )}px`; } function keepDialogVisible() { if ( dialog.classList.contains( 'tp-hidden', ) || state.maximized ) { return; } const rect = dialog.getBoundingClientRect(); const w = Math.min( rect.width, innerWidth - 8, ); dialog.style.width = `${w}px`; dialog.style.left = `${clamp( rect.left, 0, innerWidth - w, )}px`; dialog.style.top = `${clamp( rect.top, 0, innerHeight - Math.min( rect.height, 48, ), )}px`; } function renderToolbarButtons() { toolbar.innerHTML = buttons .filter( button => button.enable, ) .map( button => ` `, ) .join(''); } function autoGrowInput() { const defaultHeight = 30; const maxInputHeight = 150; input.style.height = `${defaultHeight}px`; const shouldGrow = Boolean(input.value) && ( input.value.includes('\n') || input.scrollHeight > defaultHeight + 12 ); input.style.height = `${shouldGrow ? clamp( input.scrollHeight, defaultHeight, maxInputHeight, ) : defaultHeight}px`; input.style.overflowY = input.scrollHeight > maxInputHeight ? 'auto' : 'hidden'; } function updateInputPlaceholder() { input.placeholder = state.session?.mode === 'chat' ? '开始提问' : '继续提问'; } function resetInput(value = '') { input.value = value; autoGrowInput(); } function setFooterVisible(visible) { footer.classList.toggle( 'tp-hidden', !visible, ); if (!visible) { hideContextMenu(); } } function scrollToBottom() { messageList.scrollTop = messageList.scrollHeight; scrollDownButton.classList.add( 'tp-hidden', ); } function scrollToBottomSoon() { requestAnimationFrame( scrollToBottom, ); } function isNearBottom() { return ( messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight ) < 120; } function updateScrollDownButton() { scrollDownButton.classList.toggle( 'tp-hidden', isNearBottom(), ); } function wireCodeCopyButtons() { $$('.tp-code-copy') .forEach( button => { if ( button.dataset.bound ) { return; } button.dataset.bound = '1'; button.addEventListener( 'click', async () => { const code = button .closest( '.tp-code-wrap', ) ?.querySelector( 'code', ) ?.textContent || ''; await copyText( code, ); toast( '代码已复制', ); }, ); }, ); } function toast( message, error = false, ) { toastEl.textContent = message; toastEl.classList.toggle( 'tp-toast-error', error, ); toastEl.classList.remove( 'tp-hidden', ); clearTimeout( toast.timer, ); toast.timer = setTimeout( () => toastEl .classList .add( 'tp-hidden', ), 3000, ); } async function copyText(text) { try { if ( typeof GM_setClipboard === 'function' ) { GM_setClipboard( text, 'text', ); return; } await navigator .clipboard .writeText( text, ); } catch (_) { const textarea = document.createElement( 'textarea', ); textarea.value = text; textarea.style.cssText = 'position:fixed;' + 'opacity:0;' + 'pointer-events:none;'; document.body.appendChild( textarea, ); textarea.select(); document.execCommand( 'copy', ); textarea.remove(); } } function renderMarkdown( markdown, ) { const source = String( markdown || '', ).replace( /\r\n/g, '\n', ); const codeBlocks = []; let text = source.replace( /```([^\n]*)\n([\s\S]*?)```/g, ( _, lang, code, ) => { const token = `@@TP_CODE_${codeBlocks.length}@@`; codeBlocks.push({ lang: lang.trim(), code, }); return token; }, ); text = escapeHtml(text); const lines = text.split('\n'); const out = []; let paragraph = []; let listType = null; let listItems = []; const flushParagraph = () => { if ( !paragraph.length ) { return; } out.push( `

${ paragraph .map( inlineMarkdown, ) .join( '
', ) }

`, ); paragraph = []; }; const flushList = () => { if ( !listItems.length ) { return; } const tag = listType === 'ol' ? 'ol' : 'ul'; out.push( `<${tag}>${ listItems .map( item => `
  • ${inlineMarkdown(item)}
  • `, ) .join('') }`, ); listItems = []; listType = null; }; for ( const line of lines ) { if ( /^@@TP_CODE_\d+@@$/ .test( line.trim(), ) ) { flushParagraph(); flushList(); out.push( line.trim(), ); continue; } const heading = line.match( /^(#{1,6})\s+(.+)$/, ); const ul = line.match( /^\s*[-*+]\s+(.+)$/, ); const ol = line.match( /^\s*\d+[.)]\s+(.+)$/, ); const quoteLine = line.match( /^>\s?(.+)$/, ); if (heading) { flushParagraph(); flushList(); out.push( `` + `${inlineMarkdown(heading[2])}` + ``, ); } else if ( ul || ol ) { flushParagraph(); const type = ol ? 'ol' : 'ul'; if ( listType && listType !== type ) { flushList(); } listType = type; listItems.push( (ul || ol)[1], ); } else if ( quoteLine ) { flushParagraph(); flushList(); out.push( `
    ${inlineMarkdown( quoteLine[1], )}
    `, ); } else if ( /^\s*([-*_])\1\1+\s*$/ .test(line) ) { flushParagraph(); flushList(); out.push( '
    ', ); } else if ( !line.trim() ) { flushParagraph(); flushList(); } else { if ( listItems.length ) { flushList(); } paragraph.push( line, ); } } flushParagraph(); flushList(); let html = out.join(''); html = html.replace( /@@TP_CODE_(\d+)@@/g, ( _, index, ) => { const block = codeBlocks[ Number(index) ]; return `
    ${escapeHtml( block.lang || 'code', )}
    ${escapeHtml(block.code)}
    `; }, ); return ( html || '

    ' ); } function inlineMarkdown(text) { return String(text) .replace( /`([^`]+)`/g, '$1', ) .replace( /\*\*([^*]+)\*\*/g, '$1', ) .replace( /__([^_]+)__/g, '$1', ) .replace( /(?$1', ) .replace( /~~([^~]+)~~/g, '$1', ) .replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1', ); } function renderPlainText(text) { return escapeHtml( String( text || '', ), ).replace( /\n/g, '
    ', ); } function stripReplaceWrapper( text, ) { return String( text || '', ) .replace( //gi, '', ) .replace( /<\/div>\s*$/i, '', ); } function extractReplaceResult( text, ) { const match = String( text || '', ).match( /([\s\S]*?)<\/div>/i, ); return match ? match[1].trim() : ''; } function sanitizeReplacementHtml( html, ) { const template = document.createElement( 'template', ); template.innerHTML = String( html || '', ); template.content .querySelectorAll( 'script, style, iframe, object, embed, link, meta', ) .forEach( node => node.remove(), ); template.content .querySelectorAll('*') .forEach( node => { [...node.attributes] .forEach( attr => { const name = attr.name .toLowerCase(); const value = attr.value .trim() .toLowerCase(); if ( name.startsWith( 'on', ) || ( ( name === 'href' || name === 'src' ) && value.startsWith( 'javascript:', ) ) ) { node.removeAttribute( attr.name, ); } }, ); }, ); return template.innerHTML; } function htmlToText(html) { const div = document.createElement( 'div', ); div.innerHTML = html; return ( div.textContent || '' ); } function getApiUrl(url) { const value = String( url || '', ).trim(); if ( /\/$/.test(value) ) { const clean = value.replace( /\/+$/, '', ); if ( /\/chat\/completions$/i .test(clean) ) { return clean; } return ( `${clean}/chat/completions` ); } if ( /#$/.test(value) ) { return value.slice( 0, -1, ); } if ( /\/chat\/completions$/i .test(value) ) { return value; } return ( `${value}/chat/completions` ); } function getApiError(text) { try { const json = JSON.parse( text || '{}', ); return ( json?.error?.message || json?.message || String(text) .slice( 0, 500, ) ); } catch (_) { return String( text || '', ).slice( 0, 500, ); } } function extractContent(value) { if ( typeof value === 'string' ) { return value; } if ( Array.isArray(value) ) { return value .map( item => item?.text || item?.content || item?.value || '', ) .join(''); } if ( value && typeof value === 'object' ) { return ( value.text || value.content || value.value || '' ); } return ''; } function getExternalModels() { try { const candidate = unsafeWindow ?.llmModels || unsafeWindow ?.thpilotWebModels; return ( Array.isArray( candidate, ) && candidate.length ) ? candidate : null; } catch (_) { return null; } } function normalizeModels(input) { if ( !Array.isArray(input) ) { return []; } return input .filter( item => item && typeof item === 'object', ) .map( ( item, index, ) => ({ url: String( item.url || '', ).trim(), model: String( item.model || '', ).trim(), modelName: String( item.modelName || item.name || item.model || `模型 ${index + 1}`, ), apiKey: String( item.apiKey || '', ).trim(), stream: item.stream !== false, temperature: Number.isFinite( Number( item.temperature, ), ) ? Number( item.temperature, ) : 0.7, thinking: item.thinking ?? 'auto', headers: item.headers && typeof item.headers === 'object' ? item.headers : undefined, extraBody: item.extraBody && typeof item.extraBody === 'object' ? item.extraBody : undefined, timeout: item.timeout, max_tokens: item.max_tokens, }), ); } function onKeyPress( shortcutText, callback, ) { let value = shortcutText || shortcut; if (isMac()) { value = value.replace( /ctrl|control/i, 'meta', ); } else { value = value.replace( /meta|cmd|command/i, 'ctrl', ); } const keys = value .toLowerCase() .split('+') .map( key => key.trim(), ); const modifiers = { ctrl: keys.includes( 'ctrl', ) || keys.includes( 'control', ), alt: keys.includes( 'alt', ), shift: keys.includes( 'shift', ), meta: keys.includes( 'meta', ) || keys.includes( 'cmd', ) || keys.includes( 'command', ), }; const normal = keys.find( key => ![ 'ctrl', 'control', 'alt', 'shift', 'meta', 'cmd', 'command', ].includes(key), ); document.addEventListener( 'keydown', event => { const modifierMatch = event.ctrlKey === modifiers.ctrl && event.altKey === modifiers.alt && event.shiftKey === modifiers.shift && event.metaKey === modifiers.meta; const keyMatch = !normal || event.key .toLowerCase() === normal || event.code .toLowerCase() === `key${normal}`; if ( modifierMatch && keyMatch ) { event.preventDefault(); callback(event); } }, true, ); } function fileToDataUrl(file) { return new Promise( ( resolve, reject, ) => { const reader = new FileReader(); reader.onload = () => resolve( reader.result, ); reader.onerror = reject; reader.readAsDataURL( file, ); }, ); } function historyPreview( session, ) { const message = session.messages ?.find( item => item.role === 'user' && !item.hidden, ) || session.messages ?.find( item => item.role === 'assistant', ); const text = message?.displayContent || message?.content || session.selection || ''; return truncate( String(text) .replace( /\s+/g, ' ', ), 90, ); } function renderMessageImages( images, ) { if ( !images?.length ) { return ''; } return `
    ${ images .map( image => image.dataUrl ? ` ${escapeHtml(image.name || '')} ` : ` 🖼 ${escapeHtml(image.name || '图片')} `, ) .join('') }
    `; } function formatTime( timestamp, ) { return new Date( timestamp || Date.now(), ).toLocaleTimeString( [], { hour: '2-digit', minute: '2-digit', }, ); } function formatDate( timestamp, ) { return new Date( timestamp || Date.now(), ).toLocaleString(); } function safeFilename(value) { return String(value) .replace( /[\\/:*?"<>|]/g, '_', ) .slice( 0, 80, ); } function truncate( value, max, ) { const text = String( value || '', ); return ( text.length > max ) ? `${text.slice(0, max)}\n\n[内容过长,已截断]` : text; } function clone(value) { return JSON.parse( JSON.stringify( value, ), ); } function newId() { return ( crypto.randomUUID?.() || `m-${Date.now()}-${Math.random() .toString(16) .slice(2)}` ); } function clamp( value, min, max, ) { return Math.min( Math.max( Number(value) || 0, min, ), max, ); } function cssEscape(value) { return globalThis.CSS?.escape ? CSS.escape(value) : String(value) .replace( /["\\]/g, '\\$&', ); } function isMac() { return /Mac|iPhone|iPad|iPod/i .test( navigator.platform || navigator.userAgent, ); } function escapeHtml(value) { return String( value ?? '', ).replace( /[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', })[char], ); } function buildUI() { return `
    聊天

    开始与 AI 对话吧!

    Enter 提交

    Shift+Enter 换行

    📄 已关联当前网页

    模型与界面设置

    支持 OpenAI 兼容的 /chat/completions 接口。 API Key 仅保存在 Tampermonkey 存储中。
    按原脚本 buttons 数组格式编辑,支持 icon 和 replaceCallback。 可直接使用 genericReplaceCallback。
    Mac 会自动把 ctrl 转为 meta/cmd。

    会话历史

    `; } function iconBook() { return ` `; } function iconTranslate() { return ` `; } function iconCorrect() { return ` `; } function iconSummary() { return ` `; } function iconChat() { return ` `; } function iconPlus() { return ` `; } function iconMinimize() { return ` `; } function iconRestore() { return ` `; } function iconDownload() { return ` `; } function iconHistory() { return ` `; } function iconChevron() { return ` `; } function iconPin() { return ` `; } function iconClose() { return ` `; } function iconSend() { return ` `; } function iconStop() { return ` `; } function iconCopy() { return ` `; } function iconEdit() { return ` `; } function iconInsert() { return ` `; } function iconRegenerate() { return ` `; } function iconDelete() { return ` `; } })();