// ==UserScript== // @name Thpilot AI 划词助手 // @namespace ThpilotAIHelper // @version 2.1.1 // @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_getResourceText // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant unsafeWindow // @connect * // @require https://fastly.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js // @require https://fastly.jsdelivr.net/npm/katex@0.18.1/dist/katex.min.js // @require https://fastly.jsdelivr.net/npm/markdown-it-task-lists@2.1.1/dist/markdown-it-task-lists.min.js // @require https://fastly.jsdelivr.net/npm/markdown-it-footnote@4.0.0/dist/markdown-it-footnote.min.js // @require https://fastly.jsdelivr.net/npm/@highlightjs/cdn-assets@11.11.1/highlight.min.js // @require https://fastly.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js // @require https://fastly.jsdelivr.net/npm/@mozilla/readability@0.6.0/Readability.js // Mermaid 11 不能 @require(沙箱挂载失败);用 @resource 扩展内缓存,运行时注入 // @resource mermaidJs https://fastly.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.min.js // @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; // Mermaid 11+ 不能 @require;优先 @resource(mermaidJs) 扩展缓存,失败再回退 CDN const mermaidResourceName = 'mermaidJs'; const mermaidCdnUrl = 'https://fastly.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.min.js'; // Windows/Linux:Ctrl+Alt+Z;Mac 会自动转换为 Cmd+Alt+Z const shortcut = 'ctrl+alt+z'; // 全局历史最多保留多少个会话 const globalHistoryNum = 500; // 会话历史每页显示数量 const historyPageSize = 10; // 网页上下文最大字符数,过长会被截断 const defaultContextMaxLength = 18000; // 单次回答最多允许模型抓取的网页数量 const maxWebFetchesPerMessage = 3; // 单次回答最多允许模型抓取的图片数量 const maxImageFetchesPerMessage = 3; // 单次回答最多允许模型发送的通用 HTTP 请求数量 const maxHttpRequestsPerMessage = 3; // 单次回答最多允许模型进行的工具调用轮数 const maxToolRoundsPerMessage = 5; // 单张远程图片最大大小 const maxFetchedImageBytes = 10 * 1024 * 1024; // 通用 HTTP 请求体和响应体最大大小 const maxHttpRequestBodyBytes = 100 * 1024; const maxHttpResponseBytes = 100 * 1024; const maxJavaScriptExecutionsPerMessage = 3; const maxJavaScriptCodeLength = 20000; const maxJavaScriptOutputLength = 50 * 1024; const javaScriptExecutionTimeout = 3000; const maxHolidayRequestsPerMessage = 3; const holidayApiBaseUrl = 'https://timor.tech/api/holiday/year/'; const holidayYearCache = new Map(); const maxDuckDuckGoQueries = 3; const duckDuckGoResultsPerQuery = 5; const maxSearchPagesToAttempt = 8; const maxSearchPagesToInclude = 5; const maxSearchCharsPerPage = 10000; const maxSearchContextChars = 40000; const webFetchTool = { type: 'function', function: { name: 'fetch_webpage', description: '抓取一个公开 HTTP/HTTPS 网页并返回正文。仅在用户明确要求抓取、阅读或分析网页,或回答确实依赖该网页的最新/具体内容时使用;不要仅因消息中出现 URL 就调用。每次只抓取一个 URL,不会跟踪页面内链接。', parameters: { type: 'object', properties: { url: { type: 'string', description: '要抓取的完整 http 或 https URL', }, }, required: ['url'], additionalProperties: false, }, }, }; const imageFetchTool = { type: 'function', function: { name: 'fetch_image', description: '抓取一张公开 HTTP/HTTPS 图片,并将其作为视觉输入提供给模型。可以读取用户消息中的 Markdown 图片地址、HTML img 标签地址或直接图片链接。仅当用户明确要求读取、查看、识别、描述或分析图片,或者完成用户任务确实依赖图片内容时调用;不要仅因为消息中出现图片地址就调用。每次调用只能抓取一张图片。', parameters: { type: 'object', properties: { url: { type: 'string', description: '完整的公开 HTTP/HTTPS 图片地址,不要传入包含图片的普通网页地址。', }, }, required: ['url'], additionalProperties: false, }, }, }; const httpRequestTool = { type: 'function', function: { name: 'http_request', description: '向公开 HTTP/HTTPS API 发送请求并获取响应。可在用户明确要求调用接口、模拟 HTTP 请求时使用,也可在完成任务确实需要公开 API 数据时使用;不要仅因消息中出现 URL 就调用。普通网页阅读应使用 fetch_webpage,图片视觉分析应使用 fetch_image。不得猜测凭据、泄露对话或网页中的敏感信息。写请求及包含鉴权信息的请求会要求用户确认。', parameters: { type: 'object', properties: { method: { type: 'string', enum: [ 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', ], description: 'HTTP 请求方法,默认 GET。', }, url: { type: 'string', description: '完整的公开 HTTP/HTTPS API 地址。', }, headers: { type: 'object', description: '可选请求头对象。不要发送 Cookie。', additionalProperties: { type: 'string', }, }, body: { type: 'string', description: '可选请求体字符串。发送 JSON 时同时设置 Content-Type: application/json。', }, }, required: ['url'], additionalProperties: false, }, }, }; const duckDuckGoSearchTool = { type: 'function', function: { name: 'duckduckgo_search', description: '使用 DuckDuckGo 搜索互联网并读取相关页面。仅在用户通过输入框菜单明确启用“DuckDuckGo 搜索”后使用。根据用户问题生成 1 至 3 条完整、核心的检索语句,不要拆成孤立单词。搜索失败时不得虚构结果。', parameters: { type: 'object', properties: { queries: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: maxDuckDuckGoQueries, description: '根据用户问题生成的 1 至 3 条完整检索语句。', }, }, required: ['queries'], additionalProperties: false, }, }, }; const currentDateTimeTool = { type: 'function', function: { name: 'get_current_datetime', description: '获取用户设备当前的本地日期、时间、星期、时区和 UTC 偏移。回答涉及“现在”“今天”“当前时间”“星期几”、日期计算或时区换算等依赖当前时间的问题时使用。结果来自用户设备时钟。', parameters: { type: 'object', properties: {}, additionalProperties: false, }, }, }; const ipLocationTool = { type: 'function', function: { name: 'get_ip_location', description: '通过第三方服务查询用户公网出口 IP 的大致国家、地区、城市、时区和网络运营商。仅在用户询问所在地区,或回答确实依赖用户大致地区时使用。每次查询前需要用户确认;结果不是 GPS 精确定位,VPN、代理、企业网络和移动网络可能导致偏差。', parameters: { type: 'object', properties: {}, additionalProperties: false, }, }, }; const javaScriptExecutionTool = { type: 'function', function: { name: 'execute_javascript', description: '在隔离的 Web Worker 沙箱中执行自包含 JavaScript,并返回 console 日志、返回值或异常。仅在用户要求运行或验证 JavaScript,或回答确实需要纯计算、数据转换、正则测试、日期计算、排序统计和算法验证时使用。代码不能访问 DOM、当前网页、油猴 API、文件、Node.js 模块或第三方库;常见网络 API 和动态 import() 会被阻止。使用 return 返回最终结果。每次执行前需要用户确认。', parameters: { type: 'object', properties: { description: { type: 'string', description: '简短说明代码用途。', }, code: { type: 'string', description: '自包含 JavaScript 函数体。可以使用 console.log,并通过 return 返回最终结果。', }, }, required: ['code'], additionalProperties: false, }, }, }; const chinaHolidayTool = { type: 'function', function: { name: 'get_china_holidays', description: '查询中国大陆某年的法定节假日、调休补班安排,或计算某个节假日距离指定日期还有多少自然日。数据来自 timor.tech。action=year 时返回指定年份全部安排;action=countdown 时需要 holiday_name,并从 from_date(默认用户设备今天)查找该节日最近一次尚未结束的放假安排。仅在问题涉及中国法定节假日、放假调休、补班或节日倒计时时使用。', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['year', 'countdown'], description: 'year 查询全年安排;countdown 计算距离目标节日的自然日数。', }, year: { type: 'integer', minimum: 2000, maximum: 2100, description: '查询年份。year 操作必填;countdown 可省略,默认从 from_date 所在年份开始查找。', }, holiday_name: { type: 'string', description: '倒计时目标节日名称,例如春节、清明节、劳动节、端午节、中秋节、国庆节、元旦。仅 countdown 必填。', }, from_date: { type: 'string', description: '倒计时起始日期,格式 YYYY-MM-DD;省略时使用用户设备本地今天。', }, }, required: ['action'], additionalProperties: false, }, }, }; const modelTools = [ webFetchTool, imageFetchTool, httpRequestTool, currentDateTimeTool, ipLocationTool, javaScriptExecutionTool, chinaHolidayTool, ]; const markdownItFactory = typeof markdownit === 'function' ? markdownit : window.markdownit; if (typeof markdownItFactory !== 'function') { throw new Error( 'Markdown-it 加载失败', ); } const markdownRenderer = markdownItFactory({ html: false, linkify: true, typographer: false, breaks: true, highlight(code, language) { const highlighter = typeof hljs !== 'undefined' ? hljs : window.hljs; if ( !highlighter || !language || !highlighter.getLanguage(language) ) { return ''; } try { return highlighter.highlight( code, { language }, ).value; } catch (_) { return ''; } }, }); const KATEX_CACHE_LIMIT = 200; const katexCache = new Map(); function getKatexEngine() { const engine = typeof katex !== 'undefined' ? katex : window.katex; if (typeof engine?.renderToString !== 'function') { throw new Error( 'KaTeX 加载失败', ); } return engine; } function renderMathCached( content, displayMode, ) { const source = String(content || '').trim(); const cacheKey = `${displayMode ? 'd' : 'i'}:${source}`; const cached = katexCache.get(cacheKey); if (cached !== undefined) { katexCache.delete(cacheKey); katexCache.set(cacheKey, cached); return cached; } let html; try { html = getKatexEngine().renderToString( source, { displayMode, throwOnError: false, }, ); } catch (_) { html = `${escapeHtml(source)}`; } katexCache.set(cacheKey, html); if (katexCache.size > KATEX_CACHE_LIMIT) { katexCache.delete( katexCache.keys().next().value, ); } return html; } markdownRenderer.use(markdownItMath); function markdownItMath(md) { getKatexEngine(); const addToken = ( state, content, displayMode, ) => { const token = state.push( displayMode ? 'math_block' : 'math_inline', 'math', 0, ); token.content = content; token.meta = { displayMode }; }; md.inline.ruler.before( 'escape', 'math_inline', (state, silent) => { const start = state.pos; const source = state.src.slice(start); let match = source.match( /^\\\(([\s\S]+?)\\\)/, ); if (!match) { match = source.match( /^\$([^$\n]+?)\$/, ); } if (!match) return false; if (!silent) { addToken( state, match[1], false, ); } state.pos += match[0].length; return true; }, ); md.block.ruler.before( 'fence', 'math_block', (state, startLine, endLine, silent) => { const firstLine = state.getLines( startLine, startLine + 1, state.tShift[startLine], true, ).trim(); const opening = firstLine === '\\[' ? '\\]' : firstLine === '$$' ? '$$' : ''; if (!opening) return false; let nextLine = startLine + 1; const lines = []; while (nextLine < endLine) { const line = state.getLines( nextLine, nextLine + 1, state.tShift[nextLine], true, ); if (line.trim() === opening) { break; } lines.push(line); nextLine += 1; } if ( nextLine >= endLine || state.getLines( nextLine, nextLine + 1, state.tShift[nextLine], true, ).trim() !== opening ) { return false; } if (silent) return true; addToken( state, lines.join(''), true, ); state.line = nextLine + 1; return true; }, ); md.renderer.rules.math_inline = (tokens, index) => renderMathCached( tokens[index].content, false, ); md.renderer.rules.math_block = (tokens, index) => `${renderMathCached( tokens[index].content, true, )}\n`; } if (typeof markdownitTaskLists === 'function') { markdownRenderer.use( markdownitTaskLists, { enabled: true }, ); } if (typeof markdownitFootnote === 'function') { markdownRenderer.use(markdownitFootnote); } const MERMAID_LIMITS = { maxSourceLength: 8000, maxLines: 200, maxPerMessage: 20, cacheSize: 30, }; markdownRenderer.renderer.rules.fence = (tokens, index, options, env = {}) => { const token = tokens[index]; const language = token.info.trim().split(/\s+/)[0].toLowerCase(); if (language === 'mermaid') { env.mermaidCount = (env.mermaidCount || 0) + 1; const lineCount = token.content .split('\n') .length; const tooLarge = token.content.length > MERMAID_LIMITS.maxSourceLength || lineCount > MERMAID_LIMITS.maxLines; const tooMany = env.mermaidCount > MERMAID_LIMITS.maxPerMessage; const interactive = env.mermaidEnabled !== false && !tooLarge && !tooMany; const status = tooLarge ? '图表规模过大,已禁用渲染' : tooMany ? '本条消息图表过多,已禁用渲染' : env.mermaidEnabled === false ? '回答生成中,完成后可渲染' : '点击渲染'; return `
Mermaid 图表 ${lineCount} 行
${status}
${escapeHtml(token.content)}
`; } const highlighter = typeof hljs !== 'undefined' ? hljs : window.hljs; let code = escapeHtml(token.content); let highlighted = false; if ( highlighter && language && highlighter.getLanguage(language) ) { try { code = highlighter.highlight( token.content, { language }, ).value; highlighted = true; } catch (_) { highlighted = false; } } const classes = [ language ? `language-${escapeHtml(language)}` : '', highlighted ? 'hljs' : '', ].filter(Boolean).join(' '); return `${code}\n`; }; const defaultLinkOpen = markdownRenderer.renderer.rules.link_open; markdownRenderer.renderer.rules.link_open = (tokens, index, options, env, self) => { tokens[index].attrSet( 'target', '_blank', ); tokens[index].attrSet( 'rel', 'noopener noreferrer', ); if (defaultLinkOpen) { return defaultLinkOpen.call( markdownRenderer.renderer, tokens, index, options, env, self, ); } return markdownRenderer.renderer.renderToken( tokens, index, options, ); }; const streamingMarkdownRenderer = markdownItFactory({ html: false, linkify: true, typographer: false, breaks: true, highlight() { return ''; }, }); streamingMarkdownRenderer.use(markdownItMath); if (typeof markdownitTaskLists === 'function') { streamingMarkdownRenderer.use( markdownitTaskLists, { enabled: true }, ); } streamingMarkdownRenderer.renderer.rules.fence = (tokens, index) => { const token = tokens[index]; const language = token.info .trim() .split(/\s+/)[0] .toLowerCase(); const languageAttr = language ? ` data-code-language="${escapeHtml(language)}"` : ''; const classAttr = language ? ` class="language-${escapeHtml(language)}"` : ''; return `${escapeHtml(token.content)}\n`; }; const defaultStreamingLinkOpen = streamingMarkdownRenderer.renderer.rules.link_open; streamingMarkdownRenderer.renderer.rules.link_open = (tokens, index, options, env, self) => { tokens[index].attrSet('target', '_blank'); tokens[index].attrSet( 'rel', 'noopener noreferrer', ); if (defaultStreamingLinkOpen) { return defaultStreamingLinkOpen.call( streamingMarkdownRenderer.renderer, tokens, index, options, env, self, ); } return streamingMarkdownRenderer.renderer.renderToken( tokens, index, options, ); }; // Mermaid 运行时加载:@resource → CDN script → GM 拉取 let mermaidApi = null; let mermaidLoadPromise = null; function getPageWindow() { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) { return unsafeWindow; } } catch (_) { // ignore } return window; } function resolveMermaidExport(candidate) { if (!candidate) { return null; } if (typeof candidate.render === 'function') { return candidate; } if ( candidate.default && typeof candidate.default.render === 'function' ) { return candidate.default; } return null; } function getPageMermaid() { const pageWindow = getPageWindow(); const fromPage = resolveMermaidExport( pageWindow?.mermaid, ); if (fromPage) { return fromPage; } try { if ( typeof globalThis !== 'undefined' && globalThis.mermaid ) { const fromGlobal = resolveMermaidExport( globalThis.mermaid, ); if (fromGlobal) { return fromGlobal; } } } catch (_) { // ignore } if (typeof mermaid !== 'undefined') { return resolveMermaidExport(mermaid); } return null; } function initializeMermaidApi(api) { if (!api || api.__tpInitialized) { return api; } api.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'base', }); api.__tpInitialized = true; return api; } function waitForPageMermaid(timeoutMs = 15000) { return new Promise((resolve, reject) => { const started = Date.now(); const tick = () => { const api = getPageMermaid(); if (api?.render) { resolve(api); return; } if (Date.now() - started >= timeoutMs) { reject( new Error( 'Mermaid 加载超时', ), ); return; } setTimeout(tick, 50); }; tick(); }); } function loadMermaidByScriptSrc() { const pageWindow = getPageWindow(); const pageDocument = pageWindow.document || document; const existing = pageDocument.querySelector( 'script[data-tp-mermaid="1"]', ); if (existing) { return waitForPageMermaid(); } return new Promise((resolve, reject) => { const script = pageDocument.createElement('script'); script.src = mermaidCdnUrl; script.async = true; script.dataset.tpMermaid = '1'; script.onload = () => { waitForPageMermaid() .then(resolve) .catch(reject); }; script.onerror = () => { script.remove(); reject( new Error( 'Mermaid 脚本标签加载失败', ), ); }; ( pageDocument.head || pageDocument.documentElement || pageDocument.body ).appendChild(script); }); } function patchMermaidUmdForScopedEval(code) { // 修复:scoped 执行时 local var 与末尾 globalThis.__esbuild... 不一致 return String(code || '').replace( /var __esbuild_esm_mermaid_nm;\(__esbuild_esm_mermaid_nm\|\|=\{\}\)(\.mermaid=)/, 'globalThis.__esbuild_esm_mermaid_nm=globalThis.__esbuild_esm_mermaid_nm||{};(globalThis.__esbuild_esm_mermaid_nm)$1', ); } function installMermaidFromCode(raw) { const code = patchMermaidUmdForScopedEval(raw); const pageWindow = getPageWindow(); const pageDocument = pageWindow.document || document; const errors = []; // 1) 页面