// ==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 `
${escapeHtml(token.content)}
${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) 页面