// ==UserScript== // @name TransLite+ // @namespace https://github.com/GeorgeChou17/TransLitePlus // @version 26.2.8 // @description TransLite 的 Tampermonkey 移植版:基于 GM 存储实现跨网页配置保存,支持 LLM/百度/MyMemory/自定义引擎、双语模式、可拖动按钮与菜单命令。 // @author rewwoxv. (原作者) / GeorgeChou (Tampermonkey 移植) // @license AGPL-3.0-or-later; https://www.gnu.org/licenses/agpl-3.0.html // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect * // @run-at document_idle // ==/UserScript== (function() { 'use strict'; // ==================== 默认配置 ==================== const CONFIG_VERSION = 3; // 配置版本:结构/默认值变更时 +1;版本落后的存储配置触发一次性迁移 const DEFAULT_CONFIG = { cfgVersion: CONFIG_VERSION, engine: 'llm', // 'llm'(需自填 OpenAI 兼容 API)|'mymemory'|'baidu'|'custom' apiBase: '', // ★ 用户自行填写 API 地址(OpenAI 兼容,如 https://xxx/v1)——脚本不内置任何默认服务商 apiKey: '', // ★ 用户自行填写 API Key model: '', // ★ 用户自行填写模型名称 baiduAppId: '', baiduSecret: '', baiduFrom: 'auto', baiduTo: 'zh', mmFrom: 'en', mmTo: 'zh', customName: '', customApiUrl: '', customMethod: 'POST', customBodyTemplate: '', customHeaders: '', customResponsePath: '', customFrom: 'auto', customTo: 'zh', systemPrompt: 'You are a precise translator. I will give you multiple texts, each prefixed with [N] where N is a number. Translate ALL of them into {{targetLang}}. Output each translation on its own line, prefixed with the SAME [N] marker. Do NOT add any extra text, explanations, or notes. Output ONLY [N] translation lines. Example:\nInput:\n[0] Hello world\n[1] Good morning\nOutput:\n[0] 你好世界\n[1] 早上好', targetLang: 'Simplified Chinese', proxyPrefix: '', enableThinking: false, llmTemperature: 0.3, llmMaxTokens: 4096, llmTimeout: 120, enableStreaming: true, // 流式输出(默认开启,首 token 到达即开始累积;可在设置中关闭) rateLimitRetries: 3, // 429/限流重试次数(默认 3,可在设置中自定义;耗尽后弹窗提示并停止翻译) translateMode: 'replace', btnColor: '#2563eb', btnSize: 46, btnOpacity: 0.9, toastDuration: 2500, enableFourFinger: true, enableFloatBtn: true, // 默认开启悬浮翻译按钮(🌐) enableSettingsBtn: false, // 默认隐藏悬浮设置按钮(⚙️,入口走 Tampermonkey 菜单) fourFingerDelay: 300, maxBatchChars: 5000, maxBatchParagraphs: 15, batchDelayMs: 3000, maxRetries: 3, retryTimes: 2, retryDelayBase: 2000, githubAuto: true, // 在 github.com / *.github.io 等域名使用 AI 翻译时自动切换 GitHub 模式 enableShortcut: true, // 启用键盘/外接键盘快捷键 shortcutKey: 'ctrl+alt+t', // 默认翻译快捷键(可在设置中录制) enableMouseShortcut: false // 双击页面空白区域翻译(默认关闭,避免与文字选择冲突) }; // ==================== GitHub 模式(参考沉浸式翻译:github 域名下 AI 翻译自动切换) ==================== // 原理:在 github.com / gist.github.com / *.github.io / *.github.dev 等域名使用 AI 翻译时, // 自动启用「GitHub 模式」——改用专门的系统提示词(保留代码/命令/文件路径/链接不翻译), // 并跳过
/ 代码块,仅翻译 README / Issue / PR / 评论等正文,效果对齐沉浸式翻译。
var githubActive = false; // 本次翻译是否处于 GitHub 模式(运行时标志)
var GITHUB_SKIP = { PRE:1, CODE:1 };
const DEFAULT_GITHUB_PROMPT = 'You are translating a GitHub page (README, issues, pull requests, discussions, comments). Translate the prose into {{targetLang}}. STRICT RULES: 1) Do NOT translate code, shell/terminal commands, file paths, URLs, package names, API names, or programming identifiers. 2) Keep Markdown, HTML tags and all formatting intact. 3) For the numbered [N] segments I provide, translate each independently and output ONLY the [N] translated lines, keeping the [N] markers, with no extra text, headings, or explanations.';
function isGitHubDomain() {
var h = location.hostname || '';
return /(^|\.)github\.com$/i.test(h) || /(^|\.)github\.io$/i.test(h) || /(^|\.)github\.dev$/i.test(h);
}
function getSkipTags() {
return githubActive ? Object.assign({}, SKIP_TAGS, GITHUB_SKIP) : SKIP_TAGS;
}
// GitHub 模式:仅跳过文件/目录名的单元格(文件名不翻译),commit message、日期等正文照常翻译
// 新版:.react-directory-filename-cell;旧版文件列表:table.files td.content
var GITHUB_FILENAME_CELL_SEL = '.react-directory-filename-cell, table.files td.content';
function isGitHubFileNameCell(el) {
return githubActive && el.closest && el.closest(GITHUB_FILENAME_CELL_SEL);
}
// ==================== 翻译角色(系统提示词预设) ====================
// 内置 9 个预设,均遵循「默认通用」的编号格式:[N] 编号 + {{targetLang}} 占位 + 输出 ONLY [N] 行。
// 自定义角色由用户按相同格式修改,持久化于 GM 键 translite_custom_roles(跨网页保存)。
var ROLE_PRESETS = [
{ name: '默认通用', prompt: 'You are a precise translator. I will give you multiple texts, each prefixed with [N] where N is a number. Translate ALL of them into {{targetLang}}. Output each translation on its own line, prefixed with the SAME [N] marker. Do NOT add any extra text, explanations, or notes. Output ONLY [N] translation lines. Example:\nInput:\n[0] Hello world\n[1] Good morning\nOutput:\n[0] 你好世界\n[1] 早上好' },
{ name: '科技编程', prompt: 'You are a precise translator specialized in tech and programming content. I will give you multiple texts, each prefixed with [N]. Translate ALL of them into {{targetLang}}. Output each translation on its own line, prefixed with the SAME [N] marker, and nothing else. Rules: keep code, shell commands, file paths, URLs, package names, API names and identifiers UNTRANSLATED; use common industry-standard terms for technical jargon.' },
{ name: '医学论文', prompt: 'You are a precise translator specialized in medical research papers. I will give you multiple texts, each prefixed with [N]. Translate ALL of them into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use standard Chinese medical terminology; keep drug generic names, Latin binomials, gene/protein symbols and reference numbers as-is; keep statistics and measurement units accurate.' },
{ name: '机械论文', prompt: 'You are a precise translator specialized in mechanical engineering papers. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use standard engineering terminology; keep units, model numbers, parameters, standard codes (e.g. ISO, GB) and equations as-is.' },
{ name: '新闻媒体', prompt: 'You are a precise translator for news media. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use a concise, objective journalistic style; use common Chinese renderings for person and place names; keep official institution names and direct quotes accurate.' },
{ name: '法律译者', prompt: 'You are a precise legal translator. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use accurate legal terminology; keep article/section numbers and citations as-is; use official names for laws and treaties; keep the tone formal and precise.' },
{ name: '小说译者', prompt: 'You are a literary translator for novels. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: preserve the literary tone, style and rhythm; use common renderings for names and places; make dialogue natural; keep proper nouns or coined terms when appropriate.' },
{ name: '游戏译者', prompt: 'You are a game localization translator. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use short, punchy phrasing suited to UI and quest text; keep skill/item/quest names consistent; use official or established translations for proper nouns.' },
{ name: '论文通用', prompt: 'You are a precise academic translator for research papers. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use a formal, rigorous academic style; keep terminology consistent; keep formulas, figure/table/reference numbers and citations as-is.' }
];
function getCustomRoles() {
var r = readStored('translite_custom_roles');
return Array.isArray(r) ? r : [];
}
function saveCustomRole(role) {
var list = getCustomRoles();
list.push(role);
writeStored('translite_custom_roles', list);
}
function deleteCustomRole(index) {
var list = getCustomRoles();
list.splice(index, 1);
writeStored('translite_custom_roles', list);
}
// 生成角色下拉框选项,并按当前提示词自动选中匹配的预设/自定义角色
function buildRoleOptionsHTML(currentPrompt) {
var html = '';
var found = false;
ROLE_PRESETS.forEach(function(p, i) {
var sel = (currentPrompt === p.prompt) ? ' selected' : '';
if (sel) found = true;
html += '';
});
getCustomRoles().forEach(function(r, i) {
var sel = (currentPrompt === r.prompt) ? ' selected' : '';
if (sel) found = true;
html += '';
});
if (!found) html += '';
return html;
}
// 按下拉框 value 取对应提示词(b0=内置,c0=自定义,none=不填充)
function getRolePromptByValue(v) {
if (v === 'none' || !v) return null;
if (v.charAt(0) === 'b') return ROLE_PRESETS[parseInt(v.slice(1), 10)] ? ROLE_PRESETS[parseInt(v.slice(1), 10)].prompt : null;
if (v.charAt(0) === 'c') {
var roles = getCustomRoles();
return roles[parseInt(v.slice(1), 10)] ? roles[parseInt(v.slice(1), 10)].prompt : null;
}
return null;
}
function isCustomRoleValue(v) {
return !!v && v.charAt(0) === 'c';
}
// 返回当前提示词对应的下拉框 value(内置 bN / 自定义 cN / 未匹配 null)
function getSelectedRoleValue(currentPrompt) {
for (var i = 0; i < ROLE_PRESETS.length; i++) if (currentPrompt === ROLE_PRESETS[i].prompt) return 'b' + i;
var roles = getCustomRoles();
for (var j = 0; j < roles.length; j++) if (currentPrompt === roles[j].prompt) return 'c' + j;
return null;
}
// ==================== 配置读写(GM 存储 · 跨网页保存) ====================
// 作用域说明:
// global —— 配置保存在单一 GM 键下,对所有网页生效(真正的跨网页保存)
// site —— 配置保存在按域名隔离的 GM 键下,仅对当前站点生效,并覆盖全局配置
function getScope() {
try { return GM_getValue('translite_scope', 'global'); } catch (e) { return 'global'; }
}
function setScope(s) {
try { GM_setValue('translite_scope', s); } catch (e) {}
}
function readStored(key) {
try {
var v = GM_getValue(key);
if (typeof v === 'string') { try { return JSON.parse(v); } catch (e) { return v; } }
return v;
} catch (e) { return null; }
}
function writeStored(key, val) {
try {
GM_setValue(key, (typeof val === 'object' && val !== null) ? JSON.stringify(val) : val);
} catch (e) {}
}
function getGlobalConfig() { return readStored('translite_cfg_global') || null; }
function getSiteConfig(host) { return readStored('translite_cfg_site_' + host) || null; }
// 一次性配置迁移(版本门槛):仅当存储配置版本落后于当前版本时才执行旧数据清理,
// 处理完成后写入当前版本号。这样用户迁移后重新填写的服务(即使值与旧默认相同)
// 携带了新版本号,之后加载绝不会再被清空,开关/超时等其它设置也始终保留。
// 清单仅保留「确属历史内置默认」的七牛云 qnaigc / OpenAI 兜底值;用户实际在用的服务
//(如 askdiandian + dots3-note-prev)不在清单内,迁移与后续加载都不会触碰。
var LEGACY_API_BASES = ['https://api.qnaigc.com/v1', 'https://api.openai.com/v1'];
var LEGACY_MODELS = ['z-ai/glm-4.5-air-free'];
function cleanBuiltinProviderDefaults(stored) {
var changed = false;
if (stored && typeof stored === 'object' && stored.cfgVersion !== CONFIG_VERSION) {
if (LEGACY_API_BASES.indexOf(stored.apiBase) !== -1) {
stored.apiBase = ''; changed = true;
}
if (LEGACY_MODELS.indexOf(stored.model) !== -1) {
stored.model = ''; changed = true;
}
if (stored.llmMaxTokens === 8192) {
stored.llmMaxTokens = 4096; changed = true;
}
stored.cfgVersion = CONFIG_VERSION;
changed = true; // 版本号变更同样需要回写存储
}
return changed;
}
function getEffectiveConfig() {
var cfg = Object.assign({}, DEFAULT_CONFIG);
var g = getGlobalConfig();
if (g && typeof g === 'object') {
if (cleanBuiltinProviderDefaults(g)) writeStored('translite_cfg_global', g);
cfg = Object.assign(cfg, g);
}
if (getScope() === 'site') {
var siteKey = 'translite_cfg_site_' + location.hostname;
var s = getSiteConfig(location.hostname);
if (s && typeof s === 'object') {
if (cleanBuiltinProviderDefaults(s)) writeStored(siteKey, s);
cfg = Object.assign(cfg, s);
}
}
return cfg;
}
function saveConfig(cfg) {
if (getScope() === 'site') writeStored('translite_cfg_site_' + location.hostname, cfg);
else writeStored('translite_cfg_global', cfg);
}
var config = getEffectiveConfig();
// ==================== 状态管理 ====================
var translating = false;
var cancelRequested = false; // 翻译进行中再次触发(快捷键/悬浮按钮等)→ 置位取消
var progressEl = null;
var backupMap = null;
var bilingualElements = [];
var hasTranslated = false;
var currentHost = location.hostname;
// ==================== MD5 工具(百度翻译签名用) ====================
function md5(string) {
function md5cycle(x, k) {
var a = x[0], b = x[1], c = x[2], d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -403257723);
c = ff(c, d, a, b, k[14], 17, 1236535329);
b = ff(b, c, d, a, k[15], 22, -374384042);
a = gg(a, b, c, d, k[1], 5, -168207062);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
function cmn(q, a, b, x, s, t) {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t); }
function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t); }
function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); }
function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t); }
function md5blk(s) {
var md5blks = [], i;
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i+1) << 8) + (s.charCodeAt(i+2) << 16) + (s.charCodeAt(i+3) << 24);
}
return md5blks;
}
var hex_chr = '0123456789abcdef'.split('');
function rhex(n) {
var s = '', j = 0;
for (; j < 4; j++)
s += hex_chr[(n >> (j * 8 + 4)) & 0x0F] + hex_chr[(n >> (j * 8)) & 0x0F];
return s;
}
function hex(x) {
for (var i = 0; i < x.length; i++)
x[i] = rhex(x[i]);
return x.join('');
}
function add32(a, b) {
return (a + b) & 0xFFFFFFFF;
}
function md5str(s) {
var n = s.length, state = [1732584193, -271733879, -1732584194, 271733878], i;
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)));
}
s = s.substring(i - 64);
var tail = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];
for (i = 0; i < s.length; i++)
tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3);
tail[i >> 2] |= 0x80 << ((i % 4) << 3);
if (i > 55) { md5cycle(state, tail); tail = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]; }
tail[14] = n * 8;
md5cycle(state, tail);
return state;
}
return hex(md5str(string));
}
// ==================== HTML/属性 转义 ====================
function escAttr(s) {
if (!s) return '';
return String(s).replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(//g, '>');
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g, '&')
.replace(//g, '>');
}
// ==================== 统一的翻译入口 ====================
async function doTranslate() {
if (translating) {
// 翻译进行中:再次触发 = 取消当前翻译任务
cancelRequested = true;
showToast('正在取消翻译…(等待当前批次返回)', 'info');
return;
}
// GitHub 模式自动切换(仅对 AI 类引擎生效)
githubActive = !!(config.githubAuto && isGitHubDomain() &&
(config.engine === 'llm' || !config.engine || config.engine === 'custom'));
if (githubActive) {
showToast('已自动启用 GitHub 模式(保留代码/命令不翻译)', 'info');
}
if (config.engine === 'llm' || !config.engine || config.engine === 'google') {
if (!config.apiBase) {
showToast('请先在设置面板「LLM 配置」中填写 API 地址(脚本不内置默认服务商)', 'warn');
return;
}
if (!config.apiKey) {
showToast('请先在设置面板「LLM 配置」中填写 API Key', 'warn');
return;
}
if (!config.model) {
showToast('请先在设置面板「LLM 配置」中填写模型名称', 'warn');
return;
}
}
if (config.engine === 'baidu') {
if (!config.baiduAppId || !config.baiduSecret) {
showToast('百度翻译需要填写 APP ID 和密钥,请进入设置', 'warn'); return;
}
}
if (config.engine === 'custom') {
if (!config.customApiUrl) {
showToast('自定义翻译平台需要填写 API 地址,请进入设置', 'warn'); return;
}
if (!config.customResponsePath) {
showToast('自定义翻译平台需要填写响应结果提取路径', 'warn'); return;
}
}
translating = true;
cancelRequested = false; // 新一轮翻译重置取消标志
translateStopped = false; // 新一轮翻译重置停止标志
updateBtnState('⏳', '#d97706');
try {
if (config.translateMode === 'bilingual') {
await translateBilingual();
} else {
await translateReplace();
}
if (cancelRequested) { // 用户取消
hideProgress();
resetBtnState();
showToast('已取消翻译', 'warn');
} else if (!translateStopped) {
updateBtnState('✅', '#16a34a');
hasTranslated = true;
showToast('翻译完成', 'success');
}
} catch (err) {
if (!translateStopped && !cancelRequested) { // 429 停止/用户取消场景已提示,不再重复报错
console.error('[TransLite+]', err);
updateBtnState('❌', '#dc2626');
showToast('翻译出错:' + err.message, 'error');
}
} finally {
setTimeout(resetBtnState, 2000);
translating = false;
cancelRequested = false;
translateStopped = false;
}
}
// ==================== 工具:URL 处理 ====================
function safeHost(url) {
try { return new URL(url).host; } catch (e) {
var m = String(url).match(/^https?:\/\/([^/]+)/i);
return m ? m[1] : String(url).slice(0, 60);
}
}
// 归一化 LLM 请求地址:若用户粘贴的地址已含 /chat/completions,则不重复拼接
function normalizeLLMUrl(base) {
var u = String(base || '').trim().replace(/\/+$/, '');
if (/\/chat\/completions$/i.test(u)) return u;
return u + '/chat/completions';
}
// ==================== 通用 HTTP(优先 GM_xmlhttpRequest 以绕过 CORS,回退 fetch) ====================
function gmHttp(opts) {
return new Promise(function(resolve, reject) {
var method = (opts.method || 'GET').toUpperCase();
var url = opts.url;
var headers = opts.headers || {};
var body = opts.body || null;
var timeout = opts.timeout || 60000;
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: method,
url: url,
headers: headers,
data: body,
timeout: timeout,
responseType: 'text',
onprogress: opts.onprogress || null, // 流式输出:增量接收
onload: function(resp) {
resolve({ ok: resp.status >= 200 && resp.status < 300, status: resp.status, text: resp.responseText || '' });
},
onerror: function(e) { reject(new Error('网络错误:' + (e.error || 'connection failed') + ' → ' + safeHost(url))); },
ontimeout: function() { reject(new Error('请求超时(' + (timeout / 1000) + '秒)→ ' + safeHost(url))); },
onabort: function() { reject(new Error('请求被中止')); }
});
} else if (typeof fetch === 'function') {
var fopts = { method: method, headers: headers };
if (body) fopts.body = body;
fetch(url, fopts)
.then(function(r) { return r.text().then(function(t) { resolve({ ok: r.ok, status: r.status, text: t }); }); })
.catch(function(err) { reject(err); });
} else {
reject(new Error('环境不支持网络请求'));
}
});
}
async function httpWithRetry(url, opts, retries) {
retries = retries || config.maxRetries || 3;
var rlRetries = config.rateLimitRetries || 3; // 429/5xx 重试次数(可自定义)
var lastErr = null;
for (var attempt = 0; attempt <= retries; attempt++) {
try {
var resp = await gmHttp(Object.assign({ method: (opts && opts.method) || 'GET', url: url }, opts || {}));
if (resp.ok) return resp;
if ((resp.status === 429 || resp.status >= 500) && attempt < rlRetries) {
var delay = (config.retryDelayBase || 2000) * Math.pow(2, attempt);
console.warn('[TransLite+] ' + resp.status + ' 限流/错误,' + (delay / 1000) + 's 后重试 (第' + (attempt + 1) + '/' + rlRetries + '次)');
await sleep(delay);
continue;
}
if (resp.status === 429) notifyRateLimitAndStop(); // 429 重试耗尽:弹窗提示并停止
throw new Error('请求失败 (' + resp.status + ')');
} catch (err) {
if (err.message && err.message.indexOf('请求失败') === 0) throw err;
lastErr = err;
if (attempt < retries) {
var d = (config.retryDelayBase || 2000) * Math.pow(2, attempt);
console.warn('[TransLite+] 网络错误,' + (d / 1000) + 's 后重试', err.message);
await sleep(d);
}
}
}
throw lastErr || new Error('重试耗尽');
}
// ==================== UI:悬浮翻译按钮(可拖动,位置持久化) ====================
var btn = null;
function createFloatBtn() {
if (btn) return btn;
btn = document.createElement('div');
btn.id = 'translite-plus-btn';
btn.title = '点击翻译 / 拖动移动';
btn.style.cssText =
'position:fixed;bottom:80px;right:20px;z-index:99999;touch-action:none;' +
'width:' + (config.btnSize || 46) + 'px;height:' + (config.btnSize || 46) + 'px;line-height:' + (config.btnSize || 46) + 'px;text-align:center;' +
'background:' + (config.btnColor || '#2563eb') + ';color:#fff;border-radius:50%;' +
'font-size:20px;cursor:grab;box-shadow:0 4px 14px rgba(0,0,0,.3);' +
'user-select:none;-webkit-user-select:none;' +
'transition:background .2s;opacity:' + (config.btnOpacity || 0.9) + ';';
btn.textContent = '🌐';
makeDraggable(btn, 'translite_btn_pos', function() { doTranslate(); });
return btn;
}
function removeFloatBtn() {
if (btn && btn.parentNode) { btn.parentNode.removeChild(btn); btn = null; }
}
function updateBtnState(icon, color) {
if (btn) { btn.textContent = icon; btn.style.background = color; }
}
function resetBtnState() {
if (btn) { btn.textContent = '🌐'; btn.style.background = config.btnColor || '#2563eb'; }
}
function applyFloatBtn() {
if (config.enableFloatBtn) {
var b = createFloatBtn();
if (!b.parentNode && document.body) document.body.appendChild(b);
} else {
removeFloatBtn();
}
}
// ==================== UI:设置齿轮按钮(可开关,可拖动,位置持久化) ====================
var settingsBtn = null;
function createSettingsBtn() {
if (settingsBtn) return settingsBtn;
settingsBtn = document.createElement('div');
settingsBtn.id = 'translite-plus-settings-btn';
settingsBtn.title = '翻译设置 / 拖动移动';
settingsBtn.style.cssText =
'position:fixed;bottom:20px;right:20px;z-index:99999;touch-action:none;' +
'width:36px;height:36px;line-height:36px;text-align:center;' +
'background:rgba(80,80,80,0.75);color:#fff;border-radius:50%;' +
'font-size:18px;cursor:grab;box-shadow:0 2px 8px rgba(0,0,0,.3);' +
'user-select:none;-webkit-user-select:none;';
settingsBtn.textContent = '⚙️';
makeDraggable(settingsBtn, 'translite_settings_pos', function(e) {
if (e) e.stopPropagation();
openSettingsModal();
});
return settingsBtn;
}
function removeSettingsBtn() {
if (settingsBtn && settingsBtn.parentNode) { settingsBtn.parentNode.removeChild(settingsBtn); settingsBtn = null; }
}
function applySettingsBtn() {
if (config.enableSettingsBtn) {
var sb = createSettingsBtn();
if (!sb.parentNode && document.body) document.body.appendChild(sb);
} else {
removeSettingsBtn();
}
}
function injectUI() {
if (!document.body) { setTimeout(injectUI, 100); return; }
applyFloatBtn();
applySettingsBtn();
// ⚙️ 默认隐藏时,提示设置入口(仅首次且未配置 API 时提示一次)
if (!config.enableSettingsBtn && !config.apiBase && !readStored('translite_hint_settings_hidden')) {
writeStored('translite_hint_settings_hidden', true);
setTimeout(function() {
showToast('⚙️ 设置按钮已隐藏:可在 Tampermonkey 菜单「⚙️ TransLite+ 设置」中配置,或开启悬浮设置按钮', 'info');
}, 1500);
}
}
injectUI();
// ==================== 通用拖动逻辑(点击/拖动区分 + 边缘吸附 + 位置持久化) ====================
function makeDraggable(el, storeKey, onClick) {
var dragging = false, moved = false, startX = 0, startY = 0, origLeft = 0, origTop = 0;
try {
var saved = GM_getValue(storeKey);
if (saved) {
var p = (typeof saved === 'string') ? JSON.parse(saved) : saved;
if (p && p.left != null) { el.style.left = p.left; el.style.right = 'auto'; }
if (p && p.top != null) { el.style.top = p.top; el.style.bottom = 'auto'; }
}
} catch (e) {}
el.addEventListener('pointerdown', function(e) {
dragging = true; moved = false;
startX = e.clientX; startY = e.clientY;
var r = el.getBoundingClientRect();
origLeft = r.left; origTop = r.top;
el.style.cursor = 'grabbing';
if (el.setPointerCapture && e.pointerId != null) {
try { el.setPointerCapture(e.pointerId); } catch (err) {}
}
e.preventDefault();
});
el.addEventListener('pointermove', function(e) {
if (!dragging) return;
var dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) moved = true;
var nx = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, origLeft + dx));
var ny = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, origTop + dy));
el.style.left = nx + 'px'; el.style.right = 'auto';
el.style.top = ny + 'px'; el.style.bottom = 'auto';
});
el.addEventListener('pointerup', function(e) {
if (!dragging) return;
dragging = false;
el.style.cursor = 'grab';
if (moved) {
snapEdge(el, storeKey);
try { GM_setValue(storeKey, JSON.stringify({ left: el.style.left, top: el.style.top })); } catch (err) {}
} else if (onClick) {
onClick(e);
}
});
}
function snapEdge(el, storeKey) {
var r = el.getBoundingClientRect();
var mid = r.left + r.width / 2;
if (mid < window.innerWidth / 2) {
el.style.left = '8px'; el.style.right = 'auto';
} else {
el.style.left = 'auto'; el.style.right = '8px';
}
try { GM_setValue(storeKey, JSON.stringify({ left: el.style.left, top: el.style.top })); } catch (err) {}
}
// ==================== Toast 提示 ====================
function showToast(msg, type) {
var colors = { success: '#16a34a', warn: '#d97706', error: '#dc2626', info: '#2563eb' };
var toast = document.createElement('div');
toast.textContent = msg;
toast.style.cssText =
'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:100002;' +
'background:' + (colors[type] || colors.info) + ';color:#fff;' +
'padding:8px 20px;border-radius:20px;font-size:14px;' +
'font-family:system-ui,sans-serif;white-space:nowrap;' +
'box-shadow:0 4px 12px rgba(0,0,0,.25);' +
'opacity:0;transition:opacity .3s;';
if (!document.body) return;
document.body.appendChild(toast);
if (window.requestAnimationFrame) {
window.requestAnimationFrame(function() { toast.style.opacity = '1'; });
} else {
toast.style.opacity = '1';
}
setTimeout(function() {
toast.style.opacity = '0';
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
}, config.toastDuration || 2500);
}
// ==================== 四指手势翻译(双保险注册:document + documentElement) ====================
var fourFingerTimer = null;
var fourFingerTriggered = false;
function registerFourFinger(target) {
if (!target || !target.addEventListener) return;
target.addEventListener('touchstart', function(e) {
if (!config.enableFourFinger) return;
if (e.touches.length === 4) {
if (fourFingerTriggered) return;
fourFingerTriggered = true;
fourFingerTimer = setTimeout(function() { doTranslate(); }, config.fourFingerDelay || 300);
}
}, { passive: true });
target.addEventListener('touchend', function(e) {
if (e.touches.length < 4) {
if (fourFingerTimer) { clearTimeout(fourFingerTimer); fourFingerTimer = null; }
fourFingerTriggered = false;
}
});
target.addEventListener('touchmove', function() {
if (fourFingerTimer) { clearTimeout(fourFingerTimer); fourFingerTimer = null; }
fourFingerTriggered = false;
}, { passive: true });
}
registerFourFinger(document);
registerFourFinger(document.documentElement);
// ==================== 动态内容监听 ====================
var mutationObserver = null;
var mutationDebounceTimer = null;
function startMutationObserver() {
if (mutationObserver || !window.MutationObserver) return;
mutationObserver = new MutationObserver(function(mutations) {
var addedNodes = 0;
for (var m = 0; m < mutations.length; m++) {
addedNodes += mutations[m].addedNodes.length;
}
if (addedNodes > 3 && hasTranslated) {
if (mutationDebounceTimer) clearTimeout(mutationDebounceTimer);
mutationDebounceTimer = setTimeout(function() {
showToast('检测到新内容加载,可再次点击翻译按钮', 'info');
}, 1500);
}
});
mutationObserver.observe(document.documentElement, { childList: true, subtree: true });
}
if (document.body) {
startMutationObserver();
} else {
document.addEventListener('DOMContentLoaded', startMutationObserver);
}
// ==================== 设置弹窗 ====================
var modalOverlay = null;
function openSettingsModal() {
if (modalOverlay) { closeSettingsModal(); return; }
var cfg = getEffectiveConfig();
var cfgScope = getScope();
modalOverlay = document.createElement('div');
modalOverlay.id = 'translite-plus-modal';
modalOverlay.style.cssText =
'position:fixed;top:0;left:0;width:100%;height:100%;z-index:999999;' +
'background:rgba(0,0,0,0.5);display:flex;align-items:flex-start;justify-content:center;' +
'overflow-y:auto;padding:12px 8px 40px;box-sizing:border-box;';
var panel = document.createElement('div');
panel.style.cssText =
'background:#f3f4f6;border-radius:16px;padding:16px;width:100%;max-width:480px;' +
'font-family:system-ui,-apple-system,sans-serif;color:#1f2937;box-sizing:border-box;' +
'box-shadow:0 8px 32px rgba(0,0,0,0.3);';
panel.innerHTML = buildSettingsHTML(cfg, cfgScope);
modalOverlay.appendChild(panel);
modalOverlay.addEventListener('click', function(e) {
if (e.target === modalOverlay) closeSettingsModal();
});
document.body.appendChild(modalOverlay);
bindSettingsEvents(cfg);
}
function closeSettingsModal() {
if (modalOverlay && modalOverlay.parentNode) {
modalOverlay.parentNode.removeChild(modalOverlay);
}
modalOverlay = null;
}
function buildSettingsHTML(cfg, cfgScope) {
var engineLLM = cfg.engine === 'llm' ? ' selected' : '';
var engineMyMemory = cfg.engine === 'mymemory'? ' selected' : '';
var engineBaidu = cfg.engine === 'baidu' ? ' selected' : '';
var engineCustom = cfg.engine === 'custom' ? ' selected' : '';
var modeReplace = cfg.translateMode === 'replace' ? ' selected' : '';
var modeBilingual = cfg.translateMode === 'bilingual' ? ' selected' : '';
var fourFingerChecked = cfg.enableFourFinger ? 'checked' : '';
var floatBtnChecked = cfg.enableFloatBtn ? 'checked' : '';
var scopeGlobal = cfgScope === 'global' ? ' selected' : '';
var scopeSite = cfgScope === 'site' ? ' selected' : '';
var llmSectionDisplay = (cfg.engine === 'llm' || !cfg.engine) ? 'block' : 'none';
var mmSectionDisplay = cfg.engine === 'mymemory' ? 'block' : 'none';
var baiduSectionDisplay = cfg.engine === 'baidu' ? 'block' : 'none';
var customSectionDisplay = cfg.engine === 'custom' ? 'block' : 'none';
return '' +
'' +
'⚙ TransLite+ 设置 v26.2.8
' +
'' +
'配置作用域(跨网页保存)' +
'' +
'TransLite+ 使用 Tampermonkey 的 GM 存储实现跨网页配置保存。选"全局"后,你在任意网页修改的设置对所有网页生效;选"站点"则仅对 ' + escHtml(currentHost) + ' 生效并覆盖全局配置。
' +
'' +
'' +
'翻译引擎' +
'' +
'' +
'LLM 配置(OpenAI 兼容,请填写你自己的服务)' +
'脚本不内置任何默认服务商。请自行填写你的 OpenAI 兼容 API:地址(含 /v1)、API Key、模型名称,三项均必填。
' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'选择预设会自动填充提示词,可再手动修改。自定义角色请按「默认通用」格式:保持 [N] 编号与 {{targetLang}} 占位符、输出 ONLY [N] 行。
' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'模型思考模式 (reasoning)' +
'' +
'流式输出(stream,更快看到译文)' +
'' +
'' +
'填写地址/Key/模型后点此测试,可快速定位网络或鉴权问题。' +
'' +
'' +
'MyMemory 翻译配置' +
'' +
'' +
'' +
'' +
'完全免费,无需注册,支持 ISO 639-1 语言代码。每日约1000字符限额。
' +
'' +
'' +
'百度翻译配置' +
'申请地址:https://api.fanyi.baidu.com
' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'自定义翻译平台' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'翻译模式' +
'' +
'' +
'界面与快捷操作' +
'' +
'' +
'' +
'' +
'' +
'' +
'三指/四指手势翻译' +
'' +
'悬浮翻译按钮(右下角 🌐)' +
'' +
'悬浮设置按钮(右下角 ⚙️,默认隐藏)' +
'' +
'GitHub 自动模式(访问 github 域名自动切换)' +
'' +
'开启后,在 github.com / *.github.io 等域名使用 AI 翻译时会自动启用 GitHub 模式:保留代码/命令/链接不翻译,仅翻译正文。
' +
'' +
'' +
'快捷键(迅速触发翻译)' +
'启用键盘快捷键' +
'' +
'' +
'双击页面空白区域翻译' +
'' +
'键盘快捷键在电脑键盘与移动端外接键盘上均有效;在输入框/文本框内不会触发。双击翻译默认关闭,避免与双击选词冲突(双击选中文字时也不触发)。
' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'';
}
function bindSettingsEvents(cfg) {
var engineSel = document.getElementById('ls-engine');
if (engineSel) {
engineSel.addEventListener('change', function() {
var v = this.value;
var secMap = { llm:'ls-llm-sec', mymemory:'ls-mm-sec', baidu:'ls-baidu-sec', custom:'ls-custom-sec' };
Object.keys(secMap).forEach(function(k) {
var el = document.getElementById(secMap[k]);
if (el) el.style.display = (k === v) ? 'block' : 'none';
});
});
}
// 「测试连接」:读取当前输入框的值发一条最小请求,快速定位网络/鉴权/地址问题(不保存配置)
var testBtn = document.getElementById('ls-test-btn');
if (testBtn) {
testBtn.addEventListener('click', async function() {
function gv(id) { var el = document.getElementById(id); return el ? el.value : ''; }
var apiBase = gv('ls-api-base').trim();
var apiKey = gv('ls-api-key').trim();
var model = gv('ls-model').trim();
var proxy = gv('ls-proxy-prefix').trim();
var resEl = document.getElementById('ls-test-result');
if (!resEl) return;
if (!apiBase || !model) {
resEl.textContent = '❌ 请先填写 API 地址和模型名称(Key 可为空再试一次确认是否必须)';
resEl.style.color = '#dc2626';
return;
}
var url = proxy + normalizeLLMUrl(apiBase);
resEl.textContent = '⏳ 正在请求 ' + safeHost(url) + '(最长 15 秒)…';
resEl.style.color = '#2563eb';
var t0 = Date.now();
try {
var resp = await gmHttp({
method: 'POST',
url: url,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify({ model: model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 5 }),
timeout: 15000
});
var cost = ((Date.now() - t0) / 1000).toFixed(1);
if (resp.ok) {
resEl.textContent = '✅ 连接成功(HTTP ' + resp.status + ',' + safeHost(url) + ',用时 ' + cost + 's)';
resEl.style.color = '#16a34a';
} else {
resEl.textContent = '❌ HTTP ' + resp.status + '(' + safeHost(url) + ',用时 ' + cost + 's)' + (resp.text ? ':' + resp.text.slice(0, 150) : '');
resEl.style.color = '#dc2626';
}
} catch (e) {
resEl.textContent = '❌ ' + e.message + '(用时 ' + ((Date.now() - t0) / 1000).toFixed(1) + 's)';
resEl.style.color = '#dc2626';
}
});
}
// 快捷键录制:点击「录制」后,捕获用户按下的组合键写入输入框
var recordBtn = document.getElementById('ls-shortcut-record');
var shortcutInput = document.getElementById('ls-shortcut-key');
if (recordBtn && shortcutInput) {
recordBtn.addEventListener('click', function() {
if (recordingShortcut) return;
recordingShortcut = true;
recordBtn.textContent = '⏳ 请按组合键…(Esc 取消)';
function onRecordKey(e) {
e.preventDefault(); // 录制期间按键不落到页面;全局监听由 recordingShortcut 标志拦截
if (e.key === 'Escape') { finish(null); return; }
var combo = normalizeKey(e);
if (!combo) return; // 纯修饰键(Ctrl/Alt/Shift…),等待下一个键
finish(combo);
}
function finish(combo) {
recordingShortcut = false;
document.removeEventListener('keydown', onRecordKey, true);
recordBtn.textContent = '🎬 录制';
if (combo) {
shortcutInput.value = combo;
shortcutInput.style.color = '#16a34a';
showToast('快捷键已设为 ' + combo + '(保存后生效)', 'success');
}
}
document.addEventListener('keydown', onRecordKey, true);
});
}
// 翻译角色:选择预设自动填充提示词;保存/删除自定义角色
var roleSel = document.getElementById('ls-role-select');
var roleDelBtn = document.getElementById('ls-role-del');
var roleTa = document.getElementById('ls-system-prompt');
if (roleSel) {
roleSel.addEventListener('change', function() {
var p = getRolePromptByValue(this.value);
if (p && roleTa) roleTa.value = p;
if (roleDelBtn) roleDelBtn.style.display = isCustomRoleValue(this.value) ? '' : 'none';
});
}
var roleSaveBtn = document.getElementById('ls-role-save');
if (roleSaveBtn) {
roleSaveBtn.addEventListener('click', function() {
if (!roleTa) return;
var prompt = roleTa.value.trim();
if (!prompt) { showToast('提示词不能为空', 'warn'); return; }
var name = window.prompt('请输入该角色的名称(例如:我的翻译助手)\n提示:自定义角色请按「默认通用」格式修改([N] 编号 + {{targetLang}} 占位符,输出 ONLY [N] 行):');
if (name === null) return; // 用户取消
name = name.trim();
if (!name) { showToast('角色名称不能为空', 'warn'); return; }
saveCustomRole({ name: name, prompt: prompt });
if (roleSel) roleSel.innerHTML = buildRoleOptionsHTML(prompt);
if (roleDelBtn) roleDelBtn.style.display = '';
showToast('已保存角色「' + name + '」(跨网页持久化)', 'success');
});
}
if (roleDelBtn) {
roleDelBtn.addEventListener('click', function() {
if (!roleSel) return;
var v = roleSel.value;
if (!isCustomRoleValue(v)) return;
var idx = parseInt(v.slice(1), 10);
var roles = getCustomRoles();
if (!roles[idx]) return;
if (!window.confirm('确定删除自定义角色「' + roles[idx].name + '」吗?')) return;
deleteCustomRole(idx);
if (roleSel) roleSel.innerHTML = buildRoleOptionsHTML(ROLE_PRESETS[0].prompt);
if (roleTa) roleTa.value = ROLE_PRESETS[0].prompt;
if (roleDelBtn) roleDelBtn.style.display = 'none';
showToast('已删除角色,提示词已恢复为「默认通用」', 'success');
});
}
var saveBtn = document.getElementById('ls-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', function() {
var newScope = (function() { var el = document.getElementById('ls-scope'); return el ? el.value : 'global'; })();
setScope(newScope);
var newCfg = Object.assign({}, getEffectiveConfig());
function gv(id) { var el = document.getElementById(id); return el ? el.value : ''; }
function gc(id) { var el = document.getElementById(id); return el ? el.checked : false; }
newCfg.engine = gv('ls-engine');
newCfg.mmFrom = gv('ls-mm-from').trim() || 'en';
newCfg.mmTo = gv('ls-mm-to').trim() || 'zh';
newCfg.baiduAppId = gv('ls-baidu-appid').trim();
newCfg.baiduSecret = gv('ls-baidu-secret').trim();
newCfg.baiduFrom = gv('ls-baidu-from').trim();
newCfg.baiduTo = gv('ls-baidu-to').trim();
newCfg.customName = gv('ls-custom-name').trim();
newCfg.customApiUrl = gv('ls-custom-url').trim();
newCfg.customMethod = gv('ls-custom-method');
newCfg.customBodyTemplate = gv('ls-custom-body').trim();
newCfg.customHeaders = gv('ls-custom-headers').trim();
newCfg.customResponsePath = gv('ls-custom-respath').trim();
newCfg.customFrom = gv('ls-custom-from').trim();
newCfg.customTo = gv('ls-custom-to').trim();
newCfg.apiBase = gv('ls-api-base').trim();
newCfg.apiKey = gv('ls-api-key').trim();
newCfg.model = gv('ls-model').trim();
newCfg.targetLang = gv('ls-target-lang').trim();
newCfg.systemPrompt = gv('ls-system-prompt').trim();
newCfg.llmTemperature = parseFloat(gv('ls-llm-temp')) || 0.3;
newCfg.llmMaxTokens = parseInt(gv('ls-llm-max')) || 4096;
newCfg.llmTimeout = parseInt(gv('ls-llm-timeout')) || 120;
var rlr = parseInt(gv('ls-ratelimit-retries'), 10);
newCfg.rateLimitRetries = (isNaN(rlr) || rlr < 0) ? 3 : rlr;
newCfg.proxyPrefix = gv('ls-proxy-prefix').trim();
newCfg.translateMode = gv('ls-translate-mode');
newCfg.enableFourFinger = gc('ls-four-finger');
newCfg.enableFloatBtn = gc('ls-float-btn');
newCfg.enableSettingsBtn = gc('ls-settings-btn');
newCfg.githubAuto = gc('ls-github-auto');
newCfg.enableShortcut = gc('ls-enable-shortcut');
newCfg.shortcutKey = gv('ls-shortcut-key').trim().toLowerCase();
newCfg.enableMouseShortcut = gc('ls-mouse-shortcut');
newCfg.enableThinking = gc('ls-enable-thinking');
newCfg.enableStreaming = gc('ls-enable-streaming');
newCfg.btnColor = gv('ls-btn-color');
newCfg.btnSize = parseInt(gv('ls-btn-size')) || 46;
newCfg.toastDuration = parseInt(gv('ls-toast-duration')) || 2500;
newCfg.fourFingerDelay = parseInt(gv('ls-four-finger-delay')) || 300;
saveConfig(newCfg);
config = newCfg;
applyFloatBtn();
applySettingsBtn();
closeSettingsModal();
showToast('设置已保存(' + (newScope === 'site' ? '仅 ' + currentHost : '全局') + '),立即生效', 'success');
});
}
var closeBtn = document.getElementById('ls-close-btn');
if (closeBtn) closeBtn.addEventListener('click', closeSettingsModal);
var restoreBtn = document.getElementById('ls-restore-btn');
if (restoreBtn) restoreBtn.addEventListener('click', function() {
closeSettingsModal();
restoreOriginal();
});
var resetBtn = document.getElementById('ls-reset-btn');
if (resetBtn) resetBtn.addEventListener('click', function() {
if (!window.confirm('确定将当前作用域的配置重置为默认值吗?此操作不可撤销。')) return;
var scope = getScope();
var def = Object.assign({}, DEFAULT_CONFIG);
writeStored(scope === 'site' ? ('translite_cfg_site_' + location.hostname) : 'translite_cfg_global', def);
config = def;
applyFloatBtn();
applySettingsBtn();
closeSettingsModal();
showToast('已重置为默认配置', 'success');
});
}
function openSettings() { openSettingsModal(); }
// ==================== 恢复原文 ====================
function restoreOriginal() {
var restored = 0;
if (backupMap && backupMap.size > 0) {
backupMap.forEach(function(originalText, el) {
if (el && el.parentNode) { el.textContent = originalText; restored++; }
});
backupMap = null;
}
if (bilingualElements.length > 0) {
for (var i = 0; i < bilingualElements.length; i++) {
if (bilingualElements[i].parentNode) {
bilingualElements[i].parentNode.removeChild(bilingualElements[i]);
restored++;
}
}
bilingualElements = [];
}
if (restored > 0) {
hasTranslated = false;
showToast('已恢复 ' + restored + ' 处原文', 'success');
} else {
showToast('没有可恢复的原文', 'info');
}
}
// ==================== 翻译引擎:MyMemory ====================
async function callMyMemoryAPI(text, from, to) {
from = from || config.mmFrom || 'en';
to = to || config.mmTo || 'zh';
var langpair = encodeURIComponent(from + '|' + to);
if (text.length > 500) {
var segs = [];
for (var i = 0; i < text.length; i += 450) {
segs.push(text.slice(i, i + 450));
}
var results = [];
for (var s = 0; s < segs.length; s++) {
results.push(await callMyMemorySingle(segs[s], langpair));
if (s < segs.length - 1) await sleep(600);
}
return results.join('');
}
return await callMyMemorySingle(text, langpair);
}
async function callMyMemorySingle(text, langpair) {
var url = 'https://api.mymemory.translated.net/get?q=' + encodeURIComponent(text) + '&langpair=' + langpair;
var resp = await httpWithRetry(url);
if (!resp.ok) throw new Error('MyMemory 请求失败 (' + resp.status + ')');
var data; try { data = JSON.parse(resp.text); } catch (e) { throw new Error('MyMemory 返回非 JSON'); }
if (data.responseStatus !== 200) {
throw new Error('MyMemory 翻译错误: ' + (data.responseDetails || data.responseStatus));
}
return data.responseData.translatedText;
}
// ==================== 翻译引擎:百度 ====================
async function callBaiduAPI(text, from, to) {
var appid = config.baiduAppId;
var secret = config.baiduSecret;
var salt = Date.now().toString();
var sign = md5(appid + text + salt + secret);
var params = new URLSearchParams();
params.append('q', text);
params.append('from', from || 'auto');
params.append('to', to || 'zh');
params.append('appid', appid);
params.append('salt', salt);
params.append('sign', sign);
var resp = await gmHttp({ method: 'GET', url: 'https://fanyi-api.baidu.com/api/trans/vip/translate?' + params.toString() });
if (!resp.ok) throw new Error('百度翻译请求失败 (' + resp.status + ')');
var data; try { data = JSON.parse(resp.text); } catch (e) { throw new Error('百度翻译返回非 JSON'); }
if (data.error_code) throw new Error('百度翻译错误 ' + data.error_code + ': ' + data.error_msg);
if (data.trans_result && data.trans_result[0]) {
return data.trans_result.map(function(item) { return item.dst; }).join('\n');
}
throw new Error('百度翻译返回格式异常');
}
// ==================== 工具:按 JSON 路径提取值 ====================
function getByPath(obj, pathStr) {
if (!pathStr) return undefined;
var parts = pathStr.split('.');
var current = obj;
for (var i = 0; i < parts.length; i++) {
if (current == null) return undefined;
var part = parts[i];
if (Array.isArray(current) && /^\d+$/.test(part)) {
current = current[parseInt(part)];
} else {
current = current[part];
}
}
return current;
}
// ==================== 翻译引擎:自定义平台 ====================
async function callCustomAPI(text, from, to) {
var url = config.customApiUrl;
var method = config.customMethod || 'POST';
var headers = {};
var body = null;
if (config.customHeaders) {
try { headers = JSON.parse(config.customHeaders); }
catch (e) { console.warn('[TransLite+] 自定义请求头解析失败:', e.message); }
}
if (method === 'POST' && config.customBodyTemplate) {
var bodyStr = config.customBodyTemplate
.replace(/\{\{text\}\}/g, text)
.replace(/\{\{from\}\}/g, from || '')
.replace(/\{\{to\}\}/g, to || '');
try {
JSON.parse(bodyStr);
body = bodyStr;
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json';
} catch (e) {
throw new Error('自定义平台请求体 JSON 格式错误:' + e.message);
}
}
if (method === 'GET' && config.customBodyTemplate) {
var getParamsStr = config.customBodyTemplate
.replace(/\{\{text\}\}/g, encodeURIComponent(text))
.replace(/\{\{from\}\}/g, encodeURIComponent(from || ''))
.replace(/\{\{to\}\}/g, encodeURIComponent(to || ''));
try {
var paramObj = JSON.parse(getParamsStr);
var sep = url.indexOf('?') >= 0 ? '&' : '?';
var kparts = [];
var keys = Object.keys(paramObj);
for (var k = 0; k < keys.length; k++) {
kparts.push(encodeURIComponent(keys[k]) + '=' + encodeURIComponent(paramObj[keys[k]]));
}
url += sep + kparts.join('&');
} catch (e) {
url += (url.indexOf('?') >= 0 ? '&' : '?') + getParamsStr;
}
}
var fetchOpts = { method: method, headers: headers };
if (body) fetchOpts.body = body;
var resp = await gmHttp(fetchOpts);
if (!resp.ok) {
throw new Error('自定义平台请求失败 (' + resp.status + '): ' + (resp.text || '').slice(0, 200));
}
var data; try { data = JSON.parse(resp.text); } catch (e) { throw new Error('自定义平台返回非 JSON'); }
var result = getByPath(data, config.customResponsePath);
if (result == null || result === '') {
throw new Error('响应提取失败,路径 "' + config.customResponsePath + '"。响应:' + JSON.stringify(data).slice(0, 200));
}
return String(result);
}
// ==================== LLM 引擎(支持流式输出,默认开启,可在设置中关闭) ====================
// 流式:请求带 stream:true,通过 GM_xmlhttpRequest 的 onprogress 增量解析 SSE,
// 首 token 到达即开始累积;解析按「事件块」(\n\n 分隔,块内多行 data: 拼接后整体 JSON.parse)
// 处理,兼容标准 SSE;onload 时若增量内容为空,会对完整响应文本再做全量解析兜底
//(覆盖 onprogress 未触发/增量丢失等情况),仍为空则打印诊断日志后报错。
// 服务端不支持流式(400/415/501)→ 自动回退非流式重发一次。
// 429 限流重试次数由 rateLimitRetries 控制(默认 3,可自定义),耗尽后弹窗提示并停止当前翻译任务。
function parseSSEBody(text) {
var norm = String(text || '').replace(/\r\n/g, '\n');
var content = '', done = false;
var blocks = norm.split('\n\n');
for (var i = 0; i < blocks.length; i++) {
var lines = blocks[i].split('\n');
var payloads = [];
for (var j = 0; j < lines.length; j++) {
var line = lines[j];
if (line.indexOf('data:') === 0) payloads.push(line.slice(5).trim());
}
if (payloads.length === 0) continue;
var joined = payloads.join('\n');
if (joined === '[DONE]') { done = true; continue; }
try {
var o = JSON.parse(joined);
var ch = o.choices && o.choices[0];
var c = ch && (ch.delta ? ch.delta.content : (ch.message ? ch.message.content : null));
if (c) content += c;
} catch (e) {}
}
return { content: content, done: done };
}
function callLLM(systemPrompt, userText, cfg, onStream) {
var MAX_RETRIES = cfg.rateLimitRetries || 3;
return doLLMRequest(0, !!(cfg.enableStreaming && typeof GM_xmlhttpRequest === 'function'));
function doLLMRequest(attempt, useStream) {
return new Promise(function(resolve, reject) {
if (!cfg.apiBase) { reject(new Error('请先在设置面板「LLM 配置」中填写 API 地址')); return; }
if (!cfg.model) { reject(new Error('请先在设置面板「LLM 配置」中填写模型名称')); return; }
var url = (cfg.proxyPrefix || '') + normalizeLLMUrl(cfg.apiBase);
console.log('[TransLite+] 请求地址: ' + url + (useStream ? ' (流式)' : ''));
var postBody = JSON.stringify({
model: cfg.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userText }
],
temperature: cfg.llmTemperature || 0.1,
max_tokens: cfg.llmMaxTokens || 4096,
stream: useStream ? true : false
});
// 流式增量:按事件块(\n\n)累积,processedLen 记录已消费的字符位置
var streamFull = '', streamDone = false, processedLen = 0;
function onStreamProgress(resp) {
if (!useStream) return;
var norm = (resp.responseText || '').replace(/\r\n/g, '\n');
var pos = processedLen;
while (true) {
var end = norm.indexOf('\n\n', pos);
if (end === -1) break; // 尚无下一个完整事件块
var r = parseSSEBody(norm.substring(pos, end));
streamFull += r.content;
if (r.done) streamDone = true;
pos = end + 2;
}
processedLen = pos;
if (typeof onStream === 'function') onStream(streamFull.length);
}
gmHttp({
method: 'POST',
url: url,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (cfg.apiKey || '') },
body: postBody,
timeout: (cfg.llmTimeout || 120) * 1000,
onprogress: useStream ? onStreamProgress : null
}).then(function(resp) {
if (resp.ok) {
var content;
if (useStream) {
content = streamFull.trim();
if (!content) {
// 兜底1:onprogress 可能未触发/增量丢失 → 对完整响应文本全量解析 SSE
content = parseSSEBody(resp.text).content.trim();
if (!content) {
// 兜底2:服务端可能忽略了 stream 参数直接返回完整 JSON
try {
var d2 = JSON.parse(resp.text);
content = d2.choices && d2.choices[0] && d2.choices[0].message && d2.choices[0].message.content;
} catch (e2) { content = null; }
}
}
} else {
try {
var data = JSON.parse(resp.text);
content = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
} catch (e) {
reject(new Error('API 返回格式错误:' + e.message));
return;
}
}
if (content === null || content === undefined || content === '') {
console.warn('[TransLite+] LLM 流式返回内容为空,响应前 300 字符: ' + String(resp.text || '').slice(0, 300));
reject(new Error('API 返回内容为空'));
} else {
resolve(content.trim());
}
} else {
// 服务端不支持流式 → 回退非流式重发一次(不占用重试次数)
if (useStream && (resp.status === 400 || resp.status === 415 || resp.status === 501)) {
console.log('[TransLite+] 服务端不支持流式(HTTP ' + resp.status + '),已回退非流式重发');
doLLMRequest(attempt, false).then(resolve, reject);
return;
}
var errText = (resp.text || '').slice(0, 200).toLowerCase();
if (resp.status === 401 || resp.status === 403) {
reject(new Error('API Key 无效或未填写(HTTP ' + resp.status + ')。请检查设置面板「LLM 配置」中的 API 地址、Key 与模型名称是否正确'));
return;
}
var isRetryable = resp.status === 429 || resp.status === 503 ||
errText.indexOf('rate limit') !== -1 ||
errText.indexOf('rpm') !== -1 ||
errText.indexOf('busy') !== -1 ||
errText.indexOf('overloaded') !== -1;
if (isRetryable) {
if (attempt < MAX_RETRIES) {
var delay = Math.pow(2, attempt + 1) * 3000;
console.log('[TransLite+] 服务器繁忙/限流,' + (delay / 1000) + '秒后重试(第' + (attempt + 1) + '次)...');
setTimeout(function() { doLLMRequest(attempt + 1, useStream).then(resolve, reject); }, delay);
return;
}
notifyRateLimitAndStop(); // 429 重试耗尽:弹窗提示并停止当前翻译任务
reject(new Error('429 多并发限流,已重试' + MAX_RETRIES + '次,请稍候重试'));
return;
}
reject(new Error('API 请求失败 (' + resp.status + '): ' + errText));
}
}).catch(function(err) {
if (attempt < MAX_RETRIES) {
var d = Math.pow(2, attempt + 1) * 3000;
console.warn('[TransLite+] 网络错误,' + (d / 1000) + 's 后重试', err.message);
setTimeout(function() { doLLMRequest(attempt + 1, useStream).then(resolve, reject); }, d);
return;
}
reject(err);
});
});
}
}
function sleep(ms) {
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
// ==================== 通用翻译调用(LLM 优先,传统 API 作为备选) ====================
async function translateText(text, onStream) {
if (config.engine === 'mymemory') {
return await callMyMemoryAPI(text, config.mmFrom, config.mmTo);
} else if (config.engine === 'baidu') {
return await callBaiduAPI(text, config.baiduFrom || 'auto', config.baiduTo || 'zh');
} else if (config.engine === 'custom') {
return await callCustomAPI(text, config.customFrom || 'auto', config.customTo || 'zh');
} else {
var prompt = (githubActive ? DEFAULT_GITHUB_PROMPT : config.systemPrompt).replace(/\{\{targetLang\}\}/g, config.targetLang);
try {
return await callLLM(prompt, text, config, onStream);
} catch (e) {
console.warn('[TransLite+] LLM失败: ' + e.message + ',自动降级到 MyMemory');
return await callMyMemoryAPI(text, config.mmFrom, config.mmTo);
}
}
}
// ==================== 文本容器收集 ====================
var INLINE_TAGS = { A:1, SPAN:1, B:1, STRONG:1, I:1, EM:1, U:1, S:1, SUP:1, SUB:1, CODE:1, KBD:1, SMALL:1, MARK:1, ABBR:1, CITE:1, Q:1, LABEL:1, TIME:1, DFN:1, VAR:1, SAMP:1, FONT:1, DEL:1, INS:1 };
var SKIP_TAGS = { SCRIPT:1, STYLE:1, NOSCRIPT:1, SVG:1, IFRAME:1, TEXTAREA:1, INPUT:1, SELECT:1, OPTION:1, BUTTON:1, CANVAS:1, VIDEO:1, AUDIO:1, OBJECT:1, EMBED:1, MAP:1, AREA:1, LINK:1, META:1, BR:1, HR:1, IMG:1, FORM:1, FIELDSET:1 };
var STRIP_TAGS = { NAV:1, HEADER:1, FOOTER:1, ASIDE:1, NOSCRIPT:1 };
function isLeafBlock(el) {
if (!el || !el.tagName) return false;
var st = getSkipTags();
if (st[el.tagName]) return false;
if (STRIP_TAGS[el.tagName]) return false;
var cs = window.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
var d = cs.display;
if (d.indexOf('inline') === 0 && d !== 'inline-block') return false;
for (var i = 0; i < el.children.length; i++) {
var c = el.children[i];
if (st[c.tagName]) continue;
var cs2 = window.getComputedStyle(c);
var cd = cs2.display;
if (cd.indexOf('block') !== -1 || cd.indexOf('flex') !== -1 || cd.indexOf('grid') !== -1 ||
cd === 'table' || cd === 'table-row' || cd === 'table-cell' || cd === 'list-item') {
return false;
}
}
var txt = el.textContent.trim();
return txt.length > 1;
}
function collectTextContainers() {
var containers = [];
function walk(el) {
if (!el || !el.tagName) return;
if (isGitHubFileNameCell(el)) return; // GitHub 文件/目录名单元格跳过,不翻译
var st = getSkipTags();
if (st[el.tagName]) return;
if (STRIP_TAGS[el.tagName]) return;
var cs = window.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return;
if (isLeafBlock(el)) {
containers.push({ el: el, text: el.textContent.trim() });
return;
}
for (var i = 0; i < el.children.length; i++) {
walk(el.children[i]);
}
}
if (document.body) {
var mainAreas = document.querySelectorAll('main, article, [role="main"], .content, .article, .post, #content, #article, #main');
if (mainAreas.length > 0) {
for (var a = 0; a < mainAreas.length; a++) walk(mainAreas[a]);
}
if (containers.length === 0) walk(document.body);
}
return containers;
}
// ==================== 分批:替换模式 ====================
function createBatches(containers) {
var MAX_CHARS = config.maxBatchChars || 5000;
var batches = [];
var batch = { containers: [], texts: [] };
var charCount = 0;
for (var i = 0; i < containers.length; i++) {
var c = containers[i];
var len = c.text.length;
if (batch.containers.length > 0 && charCount + len > MAX_CHARS) {
batches.push({ containers: batch.containers, text: batch.texts.join('\n|||SEP|||\n') });
batch = { containers: [c], texts: [c.text] };
charCount = len;
} else {
batch.containers.push(c);
batch.texts.push(c.text);
charCount += len;
}
}
if (batch.containers.length > 0) {
batches.push({ containers: batch.containers, text: batch.texts.join('\n|||SEP|||\n') });
}
return batches;
}
// ==================== 分批:双语模式 ====================
function createParagraphBatches(containers) {
var MAX_PARAS = config.maxBatchParagraphs || 10;
var batches = [];
for (var i = 0; i < containers.length; i += MAX_PARAS) {
var batch = containers.slice(i, i + MAX_PARAS);
batches.push({
containers: batch,
text: batch.map(function(c) { return c.text; }).join('\n|||SEP|||\n')
});
}
return batches;
}
// ==================== LLM 编号格式:合并为单条消息 ====================
// 单批上限调小(4000 字符):减小单次请求的生成量,避免大请求超时(慢模型可一次通过)
var LLM_BATCH_MAX_CHARS = 4000;
function buildLLMBatches(containers) {
var batches = [];
var batch = [];
var charCount = 0;
for (var i = 0; i < containers.length; i++) {
var marker = '[' + batch.length + '] ';
var line = marker + containers[i].text;
if (batch.length > 0 && charCount + line.length > LLM_BATCH_MAX_CHARS) {
batches.push({ containers: batch, text: batch.map(function(c, idx) { return '[' + idx + '] ' + c.text; }).join('\n') });
batch = [containers[i]];
charCount = line.length;
} else {
batch.push(containers[i]);
charCount += line.length;
}
}
if (batch.length > 0) {
batches.push({ containers: batch, text: batch.map(function(c, idx) { return '[' + idx + '] ' + c.text; }).join('\n') });
}
return batches;
}
function parseLLMResponse(text) {
var results = [];
var lines = text.split('\n');
var re = /^\s*\[(\d+)\]\s*/;
for (var i = 0; i < lines.length; i++) {
var m = lines[i].match(re);
if (m) {
var idx = parseInt(m[1], 10);
results[idx] = lines[i].substring(m[0].length).trim();
}
}
var out = [];
for (var j = 0; j < results.length; j++) {
if (results[j] !== undefined) out[j] = results[j];
}
return out;
}
// ==================== 翻译模式:直接替换 ====================
async function translateReplace() {
var containers = collectTextContainers();
if (containers.length === 0) throw new Error('页面没有可翻译的文本');
backupMap = new Map();
for (var i = 0; i < containers.length; i++) {
backupMap.set(containers[i].el, containers[i].el.textContent);
}
if (config.engine === 'mymemory') {
showProgress(0, containers.length);
for (var mi = 0; mi < containers.length; mi++) {
if (translateStopped || cancelRequested) break;
var c = containers[mi];
try {
var t = await translateText(c.text);
c.el.textContent = t.trim();
} catch (e) {
console.warn('[TransLite+] 容器' + mi + '翻译失败:', e.message);
}
showProgress(mi + 1, containers.length);
if (mi < containers.length - 1) await sleep(2000);
}
} else if (config.engine === 'llm' || !config.engine) {
var batches = buildLLMBatches(containers);
showProgress(0, batches.length);
for (var b = 0; b < batches.length; b++) {
if (translateStopped || cancelRequested) break;
var translated = await translateText(batches[b].text, function(n) { streamChars = n; });
var parts = parseLLMResponse(translated);
for (var j = 0; j < batches[b].containers.length; j++) {
if (parts[j]) {
batches[b].containers[j].el.textContent = parts[j];
}
}
showProgress(b + 1, batches.length);
if (b < batches.length - 1) await sleep(2000);
}
} else {
var oldBatches = createBatches(containers);
showProgress(0, oldBatches.length);
for (var b2 = 0; b2 < oldBatches.length; b2++) {
if (translateStopped || cancelRequested) break;
var batch = oldBatches[b2];
var translated2 = await translateText(batch.text);
var parts2 = translated2.split(/\n?\|\|\|SEP\|\|\|\n?/);
for (var j2 = 0; j2 < batch.containers.length; j2++) {
if (j2 < parts2.length) {
var trimmed = parts2[j2].trim();
if (trimmed) {
batch.containers[j2].el.textContent = trimmed;
}
}
}
showProgress(b2 + 1, oldBatches.length);
if (b2 < oldBatches.length - 1) await sleep(config.batchDelayMs || 800);
}
}
hideProgress();
}
// ==================== 双语译文插入 ====================
function appendBilingualElement(origEl, translatedText) {
if (!translatedText) return;
var origCs = window.getComputedStyle(origEl);
var transEl = document.createElement('div');
transEl.textContent = translatedText;
transEl.className = 'translite-plus-bilingual';
var s = transEl.style;
s.display = 'block';
s.width = '100%';
s.boxSizing = 'border-box';
s.clear = 'both';
s.fontSize = origCs.fontSize;
s.fontFamily = origCs.fontFamily;
s.fontWeight = origCs.fontWeight;
s.color = origCs.color;
s.fontStyle = origCs.fontStyle;
s.lineHeight = origCs.lineHeight;
s.textAlign = origCs.textAlign;
s.wordBreak = 'break-word';
s.overflowWrap = 'break-word';
s.backgroundColor = 'rgba(248,250,252,0.95)';
s.padding = '4px 8px';
s.marginBottom = '2px';
s.borderRadius = '4px';
s.borderLeft = '3px solid #94a3b8';
if (origEl.insertAdjacentElement) {
origEl.insertAdjacentElement('afterend', transEl);
} else if (origEl.parentNode) {
origEl.parentNode.insertBefore(transEl, origEl.nextSibling);
}
bilingualElements.push(transEl);
}
// ==================== 翻译模式:双语对照 ====================
async function translateBilingual() {
var containers = collectTextContainers();
if (containers.length === 0) throw new Error('页面没有可翻译的段落');
for (var c = 0; c < bilingualElements.length; c++) {
if (bilingualElements[c].parentNode) bilingualElements[c].parentNode.removeChild(bilingualElements[c]);
}
bilingualElements = [];
if (config.engine === 'mymemory') {
showProgress(0, containers.length);
for (var mi = 0; mi < containers.length; mi++) {
if (translateStopped || cancelRequested) break;
var container = containers[mi];
try {
var transText = await translateText(container.text);
appendBilingualElement(container.el, transText.trim());
} catch (e) {
console.warn('[TransLite+] 容器' + mi + '翻译失败:', e.message);
}
showProgress(mi + 1, containers.length);
if (mi < containers.length - 1) await sleep(2000);
}
} else if (config.engine === 'llm' || !config.engine) {
var batches = buildLLMBatches(containers);
showProgress(0, batches.length);
for (var b = 0; b < batches.length; b++) {
if (translateStopped || cancelRequested) break;
var translated = await translateText(batches[b].text);
var parts = parseLLMResponse(translated);
for (var j = 0; j < batches[b].containers.length; j++) {
if (parts[j]) {
appendBilingualElement(batches[b].containers[j].el, parts[j]);
}
}
showProgress(b + 1, batches.length);
if (b < batches.length - 1) await sleep(2000);
}
} else {
var batches2 = createParagraphBatches(containers);
showProgress(0, batches2.length);
for (var b2 = 0; b2 < batches2.length; b2++) {
if (translateStopped || cancelRequested) break;
var batch = batches2[b2];
var translated2 = await translateText(batch.text);
var parts2 = translated2.split(/\n?\|\|\|SEP\|\|\|\n?/);
for (var j2 = 0; j2 < batch.containers.length; j2++) {
if (j2 >= parts2.length) continue;
var container2 = batch.containers[j2];
var trimmed = parts2[j2].trim();
if (!trimmed) continue;
appendBilingualElement(container2.el, trimmed);
}
showProgress(b2 + 1, batches2.length);
if (b2 < batches2.length - 1) await sleep(config.batchDelayMs || 800);
}
}
hideProgress();
}
// ==================== 进度指示 ====================
var progressTimer = null;
var progressStart = 0;
var streamChars = 0; // 流式输出已接收字符数(展示用)
function showProgress(current, total) {
if (!progressEl) {
progressEl = document.createElement('div');
progressEl.style.cssText =
'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:100001;' +
'background:rgba(0,0,0,.78);color:#fff;padding:8px 20px;' +
'border-radius:20px;font-size:13px;font-family:system-ui,sans-serif;' +
'box-shadow:0 4px 12px rgba(0,0,0,.3);pointer-events:none;';
if (document.body) document.body.appendChild(progressEl);
}
var pct = total > 0 ? Math.round(current / total * 100) : 0;
progressStart = Date.now();
streamChars = 0;
if (progressTimer) clearInterval(progressTimer);
function renderProgress() {
var elapsed = Math.floor((Date.now() - progressStart) / 1000);
var extra = streamChars > 0 ? ' · 已接收 ' + streamChars + ' 字符' : '(慢模型可能需 1-2 分钟,请耐心等待)';
progressEl.textContent = '翻译中 ' + current + '/' + total + ' (' + pct + '%) · 已等待 ' + elapsed + 's' + extra;
}
renderProgress();
progressTimer = setInterval(renderProgress, 1000);
progressEl.style.display = '';
}
function hideProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null; }
if (progressEl) progressEl.style.display = 'none';
}
// 429 限流多次重试失败:弹窗提示用户并立即停止当前翻译任务(清除计时与进度)
var translateStopped = false;
function notifyRateLimitAndStop() {
if (translateStopped) return;
translateStopped = true;
hideProgress(); // 清除计时器与进度显示(任务计数随之停止)
resetBtnState();
try { window.alert('429 多并发限流,请稍候重试。\n\n已停止当前翻译任务,避免无效重试。'); } catch (e) {}
showToast('429 多并发限流,已停止翻译', 'warn');
}
// ==================== 快捷键(电脑键盘 / 移动端外接键盘 + 鼠标双击) ====================
var recordingShortcut = false; // 正在录制快捷键时,全局监听不触发翻译
// 将 keydown 事件归一化为 'ctrl+alt+t' 形式的组合键字符串(录制与匹配共用,保证一致)
function normalizeKey(e) {
var parts = [];
if (e.ctrlKey) parts.push('ctrl');
if (e.altKey) parts.push('alt');
if (e.shiftKey) parts.push('shift');
if (e.metaKey) parts.push('meta');
var k = e.key;
if (!k || k === 'Control' || k === 'Alt' || k === 'Shift' || k === 'Meta' ||
k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null; // 纯修饰/锁定键
parts.push(k === ' ' ? 'space' : String(k).toLowerCase());
return parts.join('+');
}
function registerShortcut() {
// 键盘快捷键(输入框/文本框内不触发)
document.addEventListener('keydown', function(e) {
if (recordingShortcut) return;
if (!config.enableShortcut || !config.shortcutKey) return;
var t = e.target;
if (t && t.closest && t.closest('input,textarea,select,[contenteditable]')) return;
if (normalizeKey(e) === String(config.shortcutKey).trim().toLowerCase()) {
e.preventDefault();
doTranslate();
}
});
// 鼠标双击页面空白区域翻译(交互元素及其内部不触发;双击选中文字也不触发)
document.addEventListener('dblclick', function(e) {
if (!config.enableMouseShortcut || translating) return;
var t = e.target;
if (!t || !t.closest) return;
if (t.closest('a,button,input,textarea,select,label,option,[contenteditable]')) return;
var sel = window.getSelection ? window.getSelection().toString() : '';
if (sel && sel.length > 0) return;
doTranslate();
});
}
registerShortcut();
// ==================== Tampermonkey 菜单命令 ====================
if (typeof GM_registerMenuCommand === 'function') {
try { GM_registerMenuCommand('⚙️ TransLite+ 设置', function() { openSettingsModal(); }); } catch (e) {}
try { GM_registerMenuCommand('🌐 翻译当前页面', function() { doTranslate(); }); } catch (e) {}
try { GM_registerMenuCommand('↩️ 恢复当前页原文', function() { restoreOriginal(); }); } catch (e) {}
}
})();
/*
* TransLite+ — Open Source License (AGPL-3.0)
* This program is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later version.
*
* TransLite+ is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see .
*
* ---------------------------------------------------------------------------
* Full AGPL-3.0 license text:/n GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community to the greatest extent possible.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public Licenses for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
Developers that receive your program in Executable form may convert it
into source code, even if they do not have the source code of your program.
The GNU Affero General Public License specifically affirms the right of
users who interact with the program over a network to receive a copy of
the source code.
When we speak of free software, we are referring to freedom, not price.
Our General Public Licenses are designed to make sure that you have the
freedom to distribute copies of free software (and charge for them if you
wish), that you receive source code or can get it if you want it, that
you can change the software or use pieces of it in new free programs, and
that you know you can do these things.
To protect your rights, we need to prevent others from denying you these
rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that receive your program in Executable form may convert it
into source code, even if they do not have the source code of your program.
The GNU Affero General Public License specifically affirms the right of
users who interact with the program over a network to receive a copy of
the source code.
By "modified version" of the work, we mean either the work, or any
derivative work under copyright law: that is to say, a work containing
the program or a portion of it, either verbatim or with modifications
and/or translated into another language.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of
the work. "Executable code" means the source code, or a portion of it,
after it has been processed by a special program (compiler, interpreter,
etc.) so that it can be directly executed by a computer.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major Component,
and (b) serves only to enable use of the work with that Major Component,
or to implement the Standard Interface of an operating system (if it is
a library that does that).
A "Major Component", in this context, means the kernel, compiler, or
other core component of a specific operating system (if any), or a
compiler or interpreter for a specific programming language.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for infringement
under applicable copyright law, except executing it on a computer or
modifying a private copy. Propagation includes copying, distribution
(with or without modification), making available to the public, and
in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can
regenerate automatically from other parts of the Corresponding Source.
"AGPL" also means the GNU Affero General Public License.
"AGPL Cover" means the GNU Affero General Public License, either
version 3 of the License, or (at your option) any later version.
A work is "covered" by this License if it is subject to AGPL Cover.
A work is "uncovered" if it is not covered by AGPL Cover.
If you develop a new work, and you want it to be free software, the
most effective way to do so is to state that it is subject to this
License. The following guidelines will help you do so.
a) You should put a notice like this near the beginning of each file:
This file is part of [name of your program].
[name of your program] is free software: you can redistribute it
and/or modify it under the terms of the GNU Affero General
Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later
version.
[name of your program] is distributed in the hope that it will
be useful, but WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public
License along with [name of your program]. If not, see
.
b) You should also include a copy of the GNU Affero General Public
License in a file named "LICENSE" in the same directory as this
file.
c) If the program is interactive, you should make it output a short
notice like this when it starts in an interactive mode:
[name of your program] Copyright (C) [year] [your name]
This program comes with ABSOLUTELY NO WARRANTY; for details
type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the
appropriate parts of the General Public License. Of course, the
commands you use may be different; for a GUI interface, you would
use an "about box".
d) You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program,
if necessary. For more information on this, see
.
e) You should also include a copy of the GNU Affero General Public
License in a file named "COPYING" in the same directory as this
file.
If you develop a new program, and you want it to be free software, the
most effective way to do so is to state that it is subject to the GNU
Affero General Public License. The following guidelines will help you
do so.
a) Put a notice like this near the beginning of each file:
This file is part of [name of your program].
[name of your program] is free software: you can redistribute it
and/or modify it under the terms of the GNU Affero General
Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later
version.
[name of your program] is distributed in the hope that it will
be useful, but WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public
License along with [name of your program]. If not, see
.
b) Also include a copy of the GNU Affero General Public License in a
file named "LICENSE" in the same directory as this file.
c) If the program is interactive, make it output a short notice like
this when it starts in an interactive mode:
[name of your program] Copyright (C) [year] [your name]
This program comes with ABSOLUTELY NO WARRANTY; for details
type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the
appropriate parts of the General Public License. Of course, the
commands you use may be different; for a GUI interface, you would
use an "about box".
d) You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program,
if necessary. For more information on this, see
.
e) Also include a copy of the GNU Affero General Public License in a
file named "COPYING" in the same directory as this file.
=============================================================
END OF TERMS AND CONDITIONS
=============================================================
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be free software, the
most effective way to do so is to state that it is subject to this
License. The following guidelines will help you do so.
Attach these notices to the program. It is safest to attach them to
the start of each source file to most effectively state the exclusion
of warranty; and each file should have at least the "copyright" line
and a pointer to where the full notice is found.
TransLite
Copyright (C) 2026 rewwoxv.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public
License along with this program. If not, see
.
Also add information on how to contact you by electronic and paper mail.
If your program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
TransLite Copyright (C) 2026 rewwoxv.
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the relevant
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. For more information on this, see
.
*/