';
}
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 scopeSel = document.getElementById('ls-scope');
if (scopeSel) {
scopeSel.addEventListener('change', function() {
var v = this.value;
var wlEl = document.getElementById('ls-whitelist-sec');
var blEl = document.getElementById('ls-blacklist-sec');
if (wlEl) wlEl.style.display = (v === 'whitelist') ? 'block' : 'none';
if (blEl) blEl.style.display = (v === 'blacklist') ? 'block' : 'none';
});
}
// 翻译角色:选择预设自动填充提示词;保存/删除自定义角色
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 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 地址和模型名称';
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 xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer ' + apiKey);
xhr.timeout = 15000;
var body = JSON.stringify({ model: model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 5 });
var done = false;
xhr.onload = function() {
var cost = ((Date.now() - t0) / 1000).toFixed(1);
if (xhr.status >= 200 && xhr.status < 300) {
resEl.textContent = '✅ 连接成功(HTTP ' + xhr.status + ',' + safeHost(url) + ',用时 ' + cost + 's)';
resEl.style.color = '#16a34a';
} else {
resEl.textContent = '❌ HTTP ' + xhr.status + '(' + safeHost(url) + ',用时 ' + cost + 's)' + (xhr.responseText ? ':' + xhr.responseText.slice(0, 150) : '');
resEl.style.color = '#dc2626';
}
};
xhr.onerror = function() { resEl.textContent = '❌ 网络错误:无法连接 ' + safeHost(url) + '(用时 ' + ((Date.now() - t0) / 1000).toFixed(1) + 's)'; resEl.style.color = '#dc2626'; };
xhr.ontimeout = function() { resEl.textContent = '❌ 请求超时(15 秒)→ ' + safeHost(url); resEl.style.color = '#dc2626'; };
xhr.send(body);
} catch (e) {
resEl.textContent = '❌ ' + e.message;
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 saveBtn = document.getElementById('ls-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', function() {
var newCfg = Object.assign({}, loadConfig());
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.enableStreaming = gc('ls-enable-streaming');
newCfg.proxyPrefix = gv('ls-proxy-prefix').trim();
newCfg.translateMode = gv('ls-translate-mode');
newCfg.enableThreeFingerLongPress = gc('ls-three-finger');
newCfg.enableFloatBtn = gc('ls-float-btn');
newCfg.enableSettingsBtn = gc('ls-settings-btn');
newCfg.enableThinking = gc('ls-enable-thinking');
newCfg.btnColor = gv('ls-btn-color');
newCfg.btnSize = parseInt(gv('ls-btn-size')) || 46;
newCfg.toastDuration = parseInt(gv('ls-toast-duration')) || 2500;
// ★ 新增字段
newCfg.settingsScope = gv('ls-scope');
newCfg.autoTranslate = gc('ls-auto-translate');
newCfg.whitelistPages = gv('ls-whitelist').split(',').map(function(s){return s.trim();}).filter(Boolean);
newCfg.blacklistPages = gv('ls-blacklist').split(',').map(function(s){return s.trim();}).filter(Boolean);
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.settingsBtnSide = settingsSnapSide;
newCfg.settingsBtnY = Math.round(settingsY / window.innerHeight * 100);
saveConfig(newCfg);
config = newCfg;
applyFloatBtn();
applySettingsBtn();
closeSettingsModal();
showToast('设置已保存,立即生效', '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();
});
// 恢复默认配置:清除面板保存(localStorage),让「源码配置区 DEFAULT_CONFIG + HARDCODED」接管
var resetBtn = document.getElementById('ls-reset-btn');
if (resetBtn) resetBtn.addEventListener('click', function() {
if (!window.confirm('确定清除面板保存的配置,改用脚本源码配置区(DEFAULT_CONFIG / HARDCODED)吗?此操作不可撤销。')) return;
try {
localStorage.removeItem(GLOBAL_KEY);
localStorage.removeItem(GLOBAL_KEY + '_' + getHostname());
localStorage.removeItem(SCOPE_KEY);
} catch (e) {}
config = loadConfig();
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 fetchWithRetry(url);
if (!resp.ok) throw new Error('MyMemory 请求失败 (' + resp.status + ')');
var data = await resp.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 fetch('https://fanyi-api.baidu.com/api/trans/vip/translate?' + params.toString());
if (!resp.ok) throw new Error('百度翻译请求失败 (' + resp.status + ')');
var data = await resp.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('[LLM翻译] 自定义请求头解析失败:', 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 fetch(url, fetchOpts);
if (!resp.ok) {
var errText = await resp.text().catch(function() { return ''; });
throw new Error('自定义平台请求失败 (' + resp.status + '): ' + errText.slice(0, 200));
}
var data = await resp.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(XHR 流式版 + 429 可配重试) ====================
// 流式:stream:true + XHR onprogress 增量解析 SSE(事件块 \n\n 分隔,兼容 CRLF),
// 首 token 到达即开始累积;onload 时若增量为空 → 对完整响应文本全量解析兜底 → 完整 JSON 兜底。
// 服务端不支持流式(400/415/501)→ 自动回退非流式重发一次。
// 429 重试次数由 rateLimitRetries 控制(默认 3),耗尽后弹窗提示并停止当前翻译任务。
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);
}
}
function normalizeLLMUrl(base) {
var u = String(base || '').trim().replace(/\/+$/, '');
if (/\/chat\/completions$/i.test(u)) return u;
return u + '/chat/completions';
}
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));
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('[LLM翻译] 请求地址: ' + url + (useStream ? ' (流式)' : ''));
// 流式增量:按事件块(\n\n)累积,processedLen 记录已消费的字符位置
var streamFull = '', processedLen = 0;
function onStreamProgress() {
var norm = (xhr.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;
pos = end + 2;
}
processedLen = pos;
if (typeof onStream === 'function') onStream(streamFull.length);
}
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer ' + (cfg.apiKey || ''));
xhr.timeout = (cfg.llmTimeout || 120) * 1000;
if (useStream) xhr.onprogress = onStreamProgress;
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
var content;
if (useStream) {
content = streamFull.trim();
if (!content) {
// 兜底1:onprogress 可能未触发/增量丢失 → 对完整响应文本全量解析 SSE
content = parseSSEBody(xhr.responseText).content.trim();
if (!content) {
// 兜底2:服务端可能忽略了 stream 参数直接返回完整 JSON
try {
var d2 = JSON.parse(xhr.responseText);
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(xhr.responseText);
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('[LLM翻译] 流式返回内容为空,响应前 300 字符: ' + String(xhr.responseText || '').slice(0, 300));
reject(new Error('API 返回内容为空'));
} else {
resolve(content.trim());
}
} else {
// 服务端不支持流式 → 回退非流式重发一次(不占用重试次数)
if (useStream && (xhr.status === 400 || xhr.status === 415 || xhr.status === 501)) {
console.log('[LLM翻译] 服务端不支持流式(HTTP ' + xhr.status + '),已回退非流式重发');
doLLMRequest(attempt, false).then(resolve, reject);
return;
}
var errText = (xhr.responseText || '').slice(0, 200).toLowerCase();
var isRetryable = xhr.status === 429 || xhr.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('[LLM翻译] 服务器繁忙/限流,' + (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 请求失败 (' + xhr.status + '): ' + errText));
}
};
xhr.onerror = function() { reject(new Error('网络错误:无法连接 ' + safeHost(url))); };
xhr.ontimeout = function() { reject(new Error('请求超时(' + (cfg.llmTimeout || 120) + '秒)→ ' + safeHost(url))); };
var body = {
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
};
xhr.send(JSON.stringify(body));
});
}
}
function sleep(ms) {
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
// ==================== 带重试的 fetch ====================
async function fetchWithRetry(url, options, 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 fetch(url, options);
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('[LLM翻译] ' + resp.status + ' 限流/错误,' + (delay/1000) + 's 后重试');
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('[LLM翻译] 网络错误,' + (d/1000) + 's 后重试', err.message);
await sleep(d);
}
}
}
throw lastErr || new Error('重试耗尽');
}
// ==================== 通用翻译调用 ====================
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) {
if (translateStopped || cancelRequested) throw e; // 429 停止/取消场景不再降级重试
console.warn('[LLM翻译] 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;
if (SKIP_TAGS[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 (SKIP_TAGS[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 skip = getSkipTags();
if (skip[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 编号格式:合并为单条消息 ====================
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('[LLM翻译] 容器' + 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 = 'llm-translated-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('[LLM翻译] 容器' + 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]) { appendBilingualElement(batches[b].containers[j].el, parts[j]); }
}
showProgress(b + 1, batches.length);
if (b < batches.length - 1) await sleep(2000);
}
} else {
var pBatches = createParagraphBatches(containers);
showProgress(0, pBatches.length);
for (var b2 = 0; b2 < pBatches.length; b2++) {
if (translateStopped || cancelRequested) break;
var batch = pBatches[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, pBatches.length);
if (b2 < pBatches.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';
}
// ==================== 自动翻译检查(页面加载后触发) ====================
checkAutoTranslate();
})();
/*
* TransLite(Via 专版) — 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(Via 专版) 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
.
*/