加入翻译引擎、配置存储和单元测试

依赖钉在 koffi 3.3.2、Electron 44.5.1、electron-builder 26.15.3。
微软免费接口不再走已经 404 的 Edge auth,改为读取 Bing 翻译页上的短期 token。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hopechen067andClaude Opus 5.5 committed 2026-10-02 01:25:17 -04:00
1 parent b86b18f9f3
commit 6ff0b6a2cc
15 files changed
+5220 -1

No files matched your search

+3955
View File
File diff suppressed because it is too large. Load diff
+36 -1
View File
@@ -7,6 +7,41 @@
"private": true,
"scripts": {
"start": "electron .",
"test": "node --test test/"
"test": "node --test ./test/*.test.js",
"dist": "electron-builder"
},
"dependencies": {
"koffi": "3.3.2"
},
"devDependencies": {
"electron": "44.5.1",
"electron-builder": "26.15.3"
},
"build": {
"appId": "com.xtranslate.app",
"productName": "Xtranslate",
"asar": true,
"directories": {
"output": "dist"
},
"files": [
"src/**/*",
"assets/**/*",
"package.json",
"!docs/**",
"!test/**"
],
"win": {
"target": [
"nsis",
"portable"
],
"icon": "assets/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"shortcutName": "Xtranslate"
}
}
}
+159
View File
@@ -0,0 +1,159 @@
'use strict';
const fs = require('fs');
const path = require('path');
const DEFAULT_CONFIG = {
hotkey: 'Alt+Q',
engine: 'free',
free: { provider: 'microsoft' },
llm: {
provider: 'deepseek',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiKey: '',
},
livePreview: true,
previewDelayMs: 500,
restoreClipboard: true,
launchAtLogin: false,
};
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
/**
* 深合并。数组和标量直接覆盖。
* llm.apiKey 为空字符串、null 或缺省时保留原来的 Key。
*/
function deepMerge(base, patch) {
if (patch === undefined) return clone(base);
if (!isPlainObject(patch)) return clone(patch);
const out = isPlainObject(base) ? clone(base) : {};
for (const [key, value] of Object.entries(patch)) {
if (key === 'apiKey' && (value === '' || value == null)) continue;
if (isPlainObject(value)) out[key] = deepMerge(out[key], value);
else out[key] = clone(value);
}
return out;
}
function applyDefaults(config) {
return deepMerge(DEFAULT_CONFIG, config || {});
}
/** 给渲染进程的配置:没有明文 Key,只有 apiKeySet。 */
function toPublicConfig(config) {
const pub = applyDefaults(config);
const key = pub.llm && pub.llm.apiKey;
pub.llm.apiKeySet = typeof key === 'string' && key.length > 0;
delete pub.llm.apiKey;
return pub;
}
function encryptionAvailable(safeStorage) {
try {
return !!(safeStorage && safeStorage.isEncryptionAvailable());
} catch {
return false;
}
}
function sealApiKey(plain, safeStorage, warn, state) {
if (!plain) return '';
if (encryptionAvailable(safeStorage)) {
try {
const buf = safeStorage.encryptString(plain);
return `enc:${Buffer.from(buf).toString('base64')}`;
} catch {
// 落到明文分支
}
}
if (warn && state && !state.warnedPlain) {
state.warnedPlain = true;
warn('safeStorage 不可用,API Key 将以明文保存');
}
return `plain:${plain}`;
}
function openApiKey(stored, safeStorage, warn) {
if (!stored) return '';
if (stored.startsWith('enc:')) {
try {
return safeStorage.decryptString(Buffer.from(stored.slice(4), 'base64'));
} catch {
if (warn) warn('API Key 解密失败,需要在设置里重新填写');
return '';
}
}
if (stored.startsWith('plain:')) return stored.slice(6);
return stored;
}
function createConfigStore({ filePath, safeStorage, warn }) {
const state = { warnedPlain: false };
let current = applyDefaults(null);
function persist(config) {
const disk = clone(config);
disk.llm.apiKey = sealApiKey(config.llm.apiKey, safeStorage, warn, state);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(disk, null, 2)}\n`, 'utf8');
fs.renameSync(tmp, filePath);
}
function load() {
let disk = null;
try {
disk = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (err) {
if (err && err.code !== 'ENOENT' && warn) warn('配置文件读不了,已改用默认配置');
disk = null;
}
const next = applyDefaults(disk);
const storedKey = disk && disk.llm && disk.llm.apiKey;
next.llm.apiKey = openApiKey(storedKey, safeStorage, warn);
if (storedKey && String(storedKey).startsWith('plain:') && !encryptionAvailable(safeStorage) && warn && !state.warnedPlain) {
state.warnedPlain = true;
warn('safeStorage 不可用,API Key 将以明文保存');
}
current = next;
if (storedKey && !String(storedKey).startsWith('enc:') && next.llm.apiKey && encryptionAvailable(safeStorage)) {
persist(current);
}
}
function get() {
return clone(current);
}
function getPublic() {
return toPublicConfig(current);
}
function set(partial) {
const next = applyDefaults(deepMerge(current, partial || {}));
persist(next);
current = next;
return getPublic();
}
load();
return { get, getPublic, set, load };
}
module.exports = {
DEFAULT_CONFIG,
deepMerge,
applyDefaults,
toPublicConfig,
sealApiKey,
openApiKey,
createConfigStore,
};
+54
View File
@@ -0,0 +1,54 @@
'use strict';
const { fetchWithTimeout, httpFail } = require('./http');
const { fail } = require('../errors');
const ENDPOINT = 'https://translate.googleapis.com/translate_a/single';
const DEFAULT_TIMEOUT = 8000;
function languages(direction) {
if (direction === 'en2zh') return { sl: 'en', tl: 'zh-CN' };
return { sl: 'zh-CN', tl: 'en' };
}
function parseGoogle(json) {
const segments = json && json[0];
if (!Array.isArray(segments) || segments.length === 0) {
throw fail('BAD_RESPONSE', { service: 'google' });
}
let out = '';
for (const seg of segments) {
if (Array.isArray(seg) && typeof seg[0] === 'string') out += seg[0];
}
if (!out) throw fail('BAD_RESPONSE', { service: 'google' });
return out;
}
/**
* @param {{text: string, direction: 'zh2en'|'en2zh', signal?: AbortSignal, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
*/
async function translateGoogle(opts) {
const { sl, tl } = languages(opts.direction);
const url = new URL(ENDPOINT);
url.searchParams.set('client', 'gtx');
url.searchParams.set('sl', sl);
url.searchParams.set('tl', tl);
url.searchParams.set('dt', 't');
url.searchParams.set('q', opts.text);
const res = await fetchWithTimeout(url, { method: 'GET', signal: opts.signal }, {
timeoutMs: opts.timeoutMs || DEFAULT_TIMEOUT,
service: 'google',
fetchImpl: opts.fetchImpl,
});
if (!res.ok) throw httpFail(res.status, 'google');
let json;
try {
json = await res.json();
} catch {
throw fail('BAD_RESPONSE', { service: 'google' });
}
return parseGoogle(json);
}
module.exports = { translateGoogle, parseGoogle, languages, ENDPOINT };
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const { fail } = require('../errors');
/**
* 带超时的 fetch。用户取消和超时分开标记,避免把取消说成断网。
*/
async function fetchWithTimeout(url, options, { timeoutMs, service, fetchImpl }) {
const fetchFn = fetchImpl || globalThis.fetch;
if (typeof fetchFn !== 'function') {
throw fail('NETWORK', { service, message: 'no fetch' });
}
const userSignal = options && options.signal;
if (userSignal && userSignal.aborted) throw fail('CANCEL', { service });
const ctrl = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ctrl.abort();
}, timeoutMs);
const onUserAbort = () => ctrl.abort();
if (userSignal) userSignal.addEventListener('abort', onUserAbort, { once: true });
try {
return await fetchFn(url, { ...options, signal: ctrl.signal });
} catch (err) {
if (timedOut) throw fail('TIMEOUT', { service });
if (userSignal && userSignal.aborted) throw fail('CANCEL', { service });
throw fail('NETWORK', { service, cause: err });
} finally {
clearTimeout(timer);
if (userSignal) userSignal.removeEventListener('abort', onUserAbort);
}
}
function httpFail(status, service) {
return fail('HTTP', { status, service });
}
module.exports = { fetchWithTimeout, httpFail };
+80
View File
@@ -0,0 +1,80 @@
'use strict';
const { detectDirection } = require('../prompt');
const { userMessage } = require('../errors');
const { translateMicrosoft } = require('./microsoft');
const { translateGoogle } = require('./google');
const { translateLLM } = require('./llm');
function resolveDirection(text, direction) {
if (direction === 'zh2en' || direction === 'en2zh') return direction;
return detectDirection(text);
}
function asUserError(err, ctx) {
if (err && err.code === 'CANCEL') {
const cancel = new Error('已取消');
cancel.code = 'CANCEL';
throw cancel;
}
const wrapped = new Error(userMessage(err, ctx));
wrapped.code = err && err.code;
throw wrapped;
}
/**
* @param {{text: string, direction?: 'auto'|'zh2en'|'en2zh', engine?: 'free'|'llm', signal?: AbortSignal, onPartial?: (text: string) => void, config?: object, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
* @returns {Promise<{text: string, direction: 'zh2en'|'en2zh'}>}
*/
async function translate(opts) {
const raw = String(opts.text ?? '');
const engine = opts.engine || (opts.config && opts.config.engine) || 'free';
if (raw.trim() === '') {
return { text: '', direction: resolveDirection(raw, opts.direction) };
}
const direction = resolveDirection(raw, opts.direction);
const config = opts.config || {};
try {
if (engine === 'llm') {
const text = await translateLLM({
text: raw,
direction,
llm: config.llm || {},
signal: opts.signal,
onPartial: opts.onPartial,
fetchImpl: opts.fetchImpl,
timeoutMs: opts.timeoutMs,
});
return { text, direction };
}
const provider = (config.free && config.free.provider) || 'microsoft';
const run = provider === 'google' ? translateGoogle : translateMicrosoft;
const service = provider === 'google' ? 'google' : 'microsoft';
let text;
try {
text = await run({
text: raw,
direction,
signal: opts.signal,
fetchImpl: opts.fetchImpl,
timeoutMs: opts.timeoutMs,
});
} catch (err) {
if (err && !err.kind) err.kind = 'free';
if (err && !err.service) err.service = service;
throw err;
}
if (opts.onPartial) opts.onPartial(text);
return { text, direction };
} catch (err) {
const provider = (config.free && config.free.provider) || 'microsoft';
const ctx = engine === 'llm'
? { kind: 'llm', service: err && err.service }
: { kind: 'free', service: provider === 'google' ? 'google' : 'microsoft' };
asUserError(err, ctx);
}
}
module.exports = { translate, resolveDirection };
+114
View File
@@ -0,0 +1,114 @@
'use strict';
const { getProvider } = require('../providers');
const { buildPrompt, cleanOutput } = require('../prompt');
const { fetchWithTimeout, httpFail } = require('./http');
const { fail } = require('../errors');
const { readSSE, takeOpenAIDelta, takeAnthropicDelta } = require('./sse');
const DEFAULT_TIMEOUT = 30000;
function joinUrl(base, path) {
const b = String(base || '').replace(/\/+$/, '');
const p = path.startsWith('/') ? path : `/${path}`;
return b + p;
}
function maxTokensFor(text) {
return Math.min(4096, String(text).length * 4 + 64);
}
function hostOf(baseUrl) {
try {
return new URL(baseUrl).host || '大模型接口';
} catch {
return '大模型接口';
}
}
/**
* @param {{text: string, direction: 'zh2en'|'en2zh', llm: object, signal?: AbortSignal, onPartial?: (text: string) => void, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
* @returns {Promise<string>} cleanOutput 之后的译文
*/
async function translateLLM(opts) {
const llm = opts.llm || {};
const provider = getProvider(llm.provider);
const kind = provider.kind === 'anthropic' ? 'anthropic' : 'openai';
const baseUrl = llm.baseUrl || provider.baseUrl;
const model = llm.model || provider.model;
const apiKey = llm.apiKey || '';
if (!apiKey && !provider.keyOptional) throw fail('NO_KEY', { service: hostOf(baseUrl), kind: 'llm' });
if (!baseUrl) throw fail('NO_BASE_URL', { kind: 'llm' });
if (!model) throw fail('NO_MODEL', { kind: 'llm' });
const prompt = buildPrompt(opts.text, opts.direction);
const maxTokens = maxTokensFor(opts.text);
const service = hostOf(baseUrl);
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT;
let url;
let headers;
let body;
if (kind === 'anthropic') {
url = joinUrl(baseUrl, '/v1/messages');
headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
};
body = {
model,
max_tokens: maxTokens,
temperature: prompt.temperature,
system: prompt.system,
messages: [{ role: 'user', content: prompt.user }],
stream: true,
};
} else {
url = joinUrl(baseUrl, '/chat/completions');
headers = { 'content-type': 'application/json' };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
body = {
model,
temperature: prompt.temperature,
max_tokens: maxTokens,
messages: [
{ role: 'system', content: prompt.system },
{ role: 'user', content: prompt.user },
],
stream: true,
};
}
const res = await fetchWithTimeout(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: opts.signal,
}, { timeoutMs, service, fetchImpl: opts.fetchImpl });
if (!res.ok) throw httpFail(res.status, service);
let accumulated = '';
try {
await readSSE(res.body, (data) => {
if (!String(data).trim()) return;
if (kind === 'anthropic') {
accumulated += takeAnthropicDelta(data);
} else {
const piece = takeOpenAIDelta(data);
if (piece.done) return;
accumulated += piece.delta;
}
if (opts.onPartial) opts.onPartial(accumulated);
});
} catch (err) {
if (err && (err.code === 'CANCEL' || err.code === 'TIMEOUT' || err.code === 'NETWORK')) throw err;
if (err instanceof SyntaxError) throw fail('BAD_RESPONSE', { service, kind: 'llm' });
throw err;
}
return cleanOutput(accumulated);
}
module.exports = { translateLLM, joinUrl, maxTokensFor, hostOf };
+134
View File
@@ -0,0 +1,134 @@
'use strict';
const { fetchWithTimeout, httpFail } = require('./http');
const { fail } = require('../errors');
// edge.microsoft.com/translate/auth 已在 2026-07-30 下线,任何请求都是 404。
// 现在和 Bing 翻译网页一样:先读 translator 页面里的短期 token,再 POST /ttranslatev3。
const PAGE_URL = 'https://www.bing.com/translator';
const DEFAULT_TRANSLATE_URL = 'https://www.bing.com/ttranslatev3';
const DEFAULT_TIMEOUT = 8000;
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0';
let cached = null;
function resetTokenCache() {
cached = null;
}
function languages(direction) {
if (direction === 'en2zh') return { from: 'en', to: 'zh-Hans' };
return { from: 'zh-Hans', to: 'en' };
}
function translateUrlFor(pageUrl) {
try {
const url = new URL(pageUrl || '');
if (url.protocol === 'https:' && (url.host === 'bing.com' || url.host.endsWith('.bing.com'))) {
return `https://${url.host}/ttranslatev3`;
}
} catch {
// 落回 www
}
return DEFAULT_TRANSLATE_URL;
}
function parseBingAuth(html, now = Date.now()) {
const abuse = String(html).match(/params_AbusePreventionHelper\s*=\s*\[\s*(\d+)\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\]/);
const ig = String(html).match(/IG\s*:\s*"([A-Fa-f0-9]+)"/);
const iid = String(html).match(/data-iid\s*=\s*"([^"]+)"/);
if (!abuse || !ig || !iid) throw fail('BAD_RESPONSE', { service: 'microsoft' });
const ttl = Number(abuse[3]);
return {
ig: ig[1],
iid: iid[1],
key: abuse[1],
token: abuse[2],
exp: now + Math.max(ttl - 60_000, 0),
};
}
function parseMicrosoft(json) {
if (json && typeof json.statusCode === 'number' && json.statusCode !== 200) {
const err = fail('HTTP', { status: json.statusCode === 205 ? 401 : json.statusCode, service: 'microsoft', kind: 'free' });
err.bingStatus = json.statusCode;
throw err;
}
const text = json && json[0] && json[0].translations && json[0].translations[0] && json[0].translations[0].text;
if (typeof text !== 'string') throw fail('BAD_RESPONSE', { service: 'microsoft' });
return text;
}
async function getSession(fetchImpl, signal, timeoutMs, force) {
const now = Date.now();
if (!force && cached && cached.exp > now + 15_000) return cached;
const res = await fetchWithTimeout(PAGE_URL, {
method: 'GET',
signal,
redirect: 'follow',
headers: { 'User-Agent': USER_AGENT },
}, { timeoutMs, service: 'microsoft', fetchImpl });
if (!res.ok) throw httpFail(res.status, 'microsoft');
const html = await res.text();
const auth = parseBingAuth(html, now);
cached = { auth, translateUrl: translateUrlFor(res.url), exp: auth.exp };
return cached;
}
/**
* @param {{text: string, direction: 'zh2en'|'en2zh', signal?: AbortSignal, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
*/
async function translateMicrosoft(opts) {
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT;
const { from, to } = languages(opts.direction);
async function once(force) {
const session = await getSession(opts.fetchImpl, opts.signal, timeoutMs, force);
const url = new URL(session.translateUrl);
url.searchParams.set('isVertical', '1');
url.searchParams.set('IG', session.auth.ig);
url.searchParams.set('IID', session.auth.iid);
const body = new URLSearchParams({
fromLang: from,
text: opts.text,
to,
token: session.auth.token,
key: session.auth.key,
});
const res = await fetchWithTimeout(url, {
method: 'POST',
signal: opts.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT,
Referer: PAGE_URL,
},
body: body.toString(),
}, { timeoutMs, service: 'microsoft', fetchImpl: opts.fetchImpl });
if (!res.ok) throw httpFail(res.status, 'microsoft');
let json;
try {
json = await res.json();
} catch {
throw fail('BAD_RESPONSE', { service: 'microsoft' });
}
return parseMicrosoft(json);
}
try {
return await once(false);
} catch (err) {
if (err && (err.bingStatus === 205 || err.status === 401)) return once(true);
throw err;
}
}
module.exports = {
translateMicrosoft,
parseMicrosoft,
parseBingAuth,
translateUrlFor,
languages,
resetTokenCache,
PAGE_URL,
};
+79
View File
@@ -0,0 +1,79 @@
'use strict';
/** 从缓冲区切出完整 SSE 事件,返回 data 负载(不含 "data:" 前缀)。 */
function extractSSEEvents(buffer) {
const text = String(buffer).replace(/\r\n/g, '\n');
const events = [];
let rest = text;
let idx;
while ((idx = rest.indexOf('\n\n')) >= 0) {
const block = rest.slice(0, idx);
rest = rest.slice(idx + 2);
const data = dataFromBlock(block);
if (data != null) events.push(data);
}
return { events, rest };
}
function dataFromBlock(block) {
const lines = [];
for (const line of block.split('\n')) {
if (!line || line.startsWith(':')) continue;
if (line.startsWith('data:')) lines.push(line.slice(5).replace(/^ /, ''));
}
if (!lines.length) return null;
return lines.join('\n');
}
/**
* 读取 fetch body,逐个交出 SSE data 字符串。
* @param {ReadableStream} body
* @param {(data: string) => void} onData
*/
async function readSSE(body, onData) {
if (!body || typeof body.getReader !== 'function') {
throw Object.assign(new Error('no stream'), { code: 'BAD_RESPONSE' });
}
const reader = body.getReader();
const decoder = new TextDecoder();
let pending = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const extracted = extractSSEEvents(pending);
pending = extracted.rest;
for (const ev of extracted.events) onData(ev);
}
pending += decoder.decode();
if (pending.trim()) {
const flushed = extractSSEEvents(pending + '\n\n');
for (const ev of flushed.events) onData(ev);
}
}
/** OpenAI 兼容流:choices[0].delta.content,[DONE] 结束。 */
function takeOpenAIDelta(dataStr) {
const trimmed = String(dataStr).trim();
if (trimmed === '[DONE]') return { done: true, delta: '' };
const json = JSON.parse(trimmed);
const choice = json && json.choices && json.choices[0];
const delta = choice && choice.delta && choice.delta.content;
return { done: false, delta: typeof delta === 'string' ? delta : '' };
}
/** Anthropic 流:content_block_delta 里的 delta.text。 */
function takeAnthropicDelta(dataStr) {
const json = JSON.parse(String(dataStr).trim());
if (json && json.type === 'content_block_delta' && json.delta && typeof json.delta.text === 'string') {
return json.delta.text;
}
return '';
}
module.exports = {
extractSSEEvents,
readSSE,
takeOpenAIDelta,
takeAnthropicDelta,
};
+78
View File
@@ -0,0 +1,78 @@
'use strict';
/**
* 把引擎/网络错误收成给用户看的中文短句。
* 不读取请求头,避免把 API Key 带进文案。
*/
function networkHint(service, timedOut) {
if (service === 'google') {
return timedOut
? '连接 Google 超时,换微软或大模型试试'
: '网络连不上 Google,换微软或大模型试试';
}
if (service === 'microsoft') {
return timedOut
? '连接微软翻译超时,换 Google 或大模型试试'
: '网络连不上微软翻译,换 Google 或大模型试试';
}
const name = service || '大模型接口';
return timedOut
? `连接 ${name} 超时,检查网络后再试`
: `网络连不上 ${name},检查网络后再试`;
}
/**
* @param {any} err
* @param {{service?: string, kind?: 'free'|'llm'}} [ctx]
* @returns {string}
*/
function userMessage(err, ctx = {}) {
if (!err) return '翻译失败';
if (err.code === 'CANCEL') return '已取消';
if (err.code === 'NO_KEY') return '还没填大模型的 API Key,去设置里填一下';
if (err.code === 'NO_BASE_URL') return '还没填写大模型的接口地址';
if (err.code === 'NO_MODEL') return '还没填写模型名';
const status = Number(err.status || err.statusCode || 0);
const kind = ctx.kind || err.kind;
if (status === 401 || status === 403) {
if (kind === 'free') {
const who = (ctx.service || err.service) === 'google' ? 'Google' : '微软翻译';
return `${who}拒绝了这次请求,换一个引擎或改用大模型试试`;
}
return 'API Key 无效或没有权限';
}
if (status === 404) {
if (kind === 'free') {
const who = (ctx.service || err.service) === 'google' ? 'Google' : '微软翻译';
return `${who}的免费接口不可用,换一个引擎或改用大模型试试`;
}
return '模型名或接口地址不对';
}
if (status === 429) return '请求太频繁或额度用完了';
if (status >= 500 && status <= 599) return '翻译服务暂时不可用,过一会儿再试';
if (status) return `翻译请求失败(HTTP ${status})`;
const service = ctx.service || err.service;
if (err.code === 'TIMEOUT') return networkHint(service, true);
if (err.code === 'NETWORK') return networkHint(service, false);
if (err.code === 'BAD_RESPONSE') {
if (service === 'google') return 'Google 翻译返回了无法识别的结果,换微软或大模型试试';
if (service === 'microsoft') return '微软翻译返回了无法识别的结果,换 Google 或大模型试试';
return '翻译结果无法识别';
}
const message = typeof err.message === 'string' ? err.message : '';
if (/[\u4e00-\u9fff]/.test(message)) return message;
return '翻译失败';
}
function fail(code, extra = {}) {
const err = new Error(code);
err.code = code;
Object.assign(err, extra);
return err;
}
module.exports = { userMessage, fail, networkHint };
+98
View File
@@ -0,0 +1,98 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { deepMerge, toPublicConfig, createConfigStore, DEFAULT_CONFIG } = require('../src/main/config');
const SECRET = 'sk-config-secret-value';
function xorStorage(available) {
return {
isEncryptionAvailable: () => available,
encryptString: (plain) => Buffer.from(String(plain).split('').map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ 0x5a)).join(''), 'utf8'),
decryptString: (buf) => Buffer.from(buf).toString('utf8').split('').map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ 0x5a)).join(''),
};
}
function tmpFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xt-cfg-'));
return path.join(dir, 'config.json');
}
test('深合并保留未出现的字段,空 apiKey 不覆盖', () => {
const base = deepMerge(DEFAULT_CONFIG, {
llm: { apiKey: SECRET, model: 'old' },
previewDelayMs: 800,
});
const merged = deepMerge(base, { llm: { model: 'new', apiKey: '' }, engine: 'llm' });
assert.equal(merged.llm.apiKey, SECRET);
assert.equal(merged.llm.model, 'new');
assert.equal(merged.llm.baseUrl, DEFAULT_CONFIG.llm.baseUrl);
assert.equal(merged.previewDelayMs, 800);
assert.equal(merged.engine, 'llm');
assert.equal(merged.free.provider, 'microsoft');
const omitted = deepMerge(base, { llm: { provider: 'openai' } });
assert.equal(omitted.llm.apiKey, SECRET);
assert.equal(omitted.llm.provider, 'openai');
});
test('公开配置只有 apiKeySet,序列化结果里没有明文 Key', () => {
const pub = toPublicConfig({ llm: { apiKey: SECRET, model: 'm' } });
assert.equal(pub.llm.apiKeySet, true);
assert.equal('apiKey' in pub.llm, false);
assert.equal(JSON.stringify(pub).includes(SECRET), false);
const empty = toPublicConfig({ llm: { apiKey: '' } });
assert.equal(empty.llm.apiKeySet, false);
});
test('加密可用时落盘不含明文,读回后内存里有 Key,公开接口没有', () => {
const file = tmpFile();
const warnings = [];
const store = createConfigStore({
filePath: file,
safeStorage: xorStorage(true),
warn: (message) => warnings.push(message),
});
const pub = store.set({ llm: { apiKey: SECRET } });
const disk = fs.readFileSync(file, 'utf8');
assert.equal(disk.includes(SECRET), false);
assert.match(disk, /"apiKey": "enc:/);
assert.equal(pub.llm.apiKeySet, true);
assert.equal(JSON.stringify(pub).includes(SECRET), false);
assert.equal(store.get().llm.apiKey, SECRET);
assert.equal(warnings.length, 0);
const reloaded = createConfigStore({
filePath: file,
safeStorage: xorStorage(true),
warn: (message) => warnings.push(message),
});
assert.equal(reloaded.get().llm.apiKey, SECRET);
assert.equal(reloaded.getPublic().llm.apiKeySet, true);
assert.equal(JSON.stringify(reloaded.getPublic()).includes(SECRET), false);
});
test('加密不可用时降级明文并警告一次', () => {
const file = tmpFile();
const warnings = [];
const store = createConfigStore({
filePath: file,
safeStorage: xorStorage(false),
warn: (message) => warnings.push(message),
});
store.set({ llm: { apiKey: SECRET } });
const disk = fs.readFileSync(file, 'utf8');
assert.match(disk, new RegExp(`plain:${SECRET.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
assert.equal(warnings.length, 1);
assert.match(warnings[0], /明文/);
store.set({ previewDelayMs: 900 });
assert.equal(warnings.length, 1);
assert.equal(store.getPublic().llm.apiKeySet, true);
assert.equal(JSON.stringify(store.getPublic()).includes(SECRET), false);
});
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { userMessage } = require('../src/main/errors');
const { translate } = require('../src/main/engines');
test('HTTP 状态映射成中文短句', () => {
assert.equal(userMessage({ status: 401 }, { kind: 'llm' }), 'API Key 无效或没有权限');
assert.equal(userMessage({ status: 403 }, { kind: 'llm' }), 'API Key 无效或没有权限');
assert.equal(userMessage({ status: 404 }, { kind: 'llm' }), '模型名或接口地址不对');
assert.equal(
userMessage({ status: 404 }, { kind: 'free', service: 'microsoft' }),
'微软翻译的免费接口不可用,换一个引擎或改用大模型试试',
);
assert.equal(userMessage({ status: 429 }, { kind: 'llm' }), '请求太频繁或额度用完了');
assert.equal(userMessage({ code: 'NO_KEY' }), '还没填大模型的 API Key,去设置里填一下');
});
test('免费引擎的网络错误点名服务,并建议换引擎', () => {
assert.equal(
userMessage({ code: 'NETWORK', service: 'google' }, { kind: 'free', service: 'google' }),
'网络连不上 Google,换微软或大模型试试',
);
assert.equal(
userMessage({ code: 'TIMEOUT', service: 'microsoft' }, { kind: 'free', service: 'microsoft' }),
'连接微软翻译超时,换 Google 或大模型试试',
);
assert.equal(
userMessage({ status: 401 }, { kind: 'free', service: 'microsoft' }),
'微软翻译拒绝了这次请求,换一个引擎或改用大模型试试',
);
});
test('抛出的错误里不包含 API Key', async () => {
const secret = 'sk-super-secret-key';
await assert.rejects(
() => translate({
text: '你好',
direction: 'zh2en',
engine: 'llm',
config: {
llm: {
provider: 'deepseek',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiKey: secret,
},
},
fetchImpl: async () => {
const err = new Error(`connect ${secret} failed`);
throw err;
},
}),
(err) => {
assert.equal(err.message.includes(secret), false);
assert.match(err.message, /网络连不上/);
return true;
},
);
});
+144
View File
@@ -0,0 +1,144 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { translate } = require('../src/main/engines');
const {
translateMicrosoft,
parseMicrosoft,
parseBingAuth,
translateUrlFor,
resetTokenCache,
} = require('../src/main/engines/microsoft');
const { parseGoogle } = require('../src/main/engines/google');
const PAGE_HTML = [
'var params_AbusePreventionHelper = [123456,"tok-abc",3600000];',
'IG:"AABBCCDD"',
'data-iid="translator.5026"',
].join('\n');
function jsonResponse(status, body, extra = {}) {
return {
ok: status >= 200 && status < 300,
status,
url: extra.url || '',
json: async () => body,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
};
}
function pageResponse() {
return jsonResponse(200, PAGE_HTML, { url: 'https://cn.bing.com/translator' });
}
test('空白输入不发请求', async () => {
let called = 0;
const result = await translate({
text: ' \n',
engine: 'free',
config: { free: { provider: 'microsoft' } },
fetchImpl: () => {
called += 1;
throw new Error('should not fetch');
},
});
assert.equal(result.text, '');
assert.equal(called, 0);
});
test('微软翻译:中到英、英到中,页面 token 会缓存,并跟着跳转到区域域名', async () => {
resetTokenCache();
const calls = [];
const fetchImpl = async (url, options) => {
const href = String(url);
calls.push({ href, body: options && options.body });
if (href.includes('/translator') && !href.includes('ttranslatev3')) return pageResponse();
const params = new URLSearchParams(options.body);
const translated = params.get('to') === 'en' ? 'hello' : '你好';
return jsonResponse(200, [{ translations: [{ text: translated }] }]);
};
const zh = await translateMicrosoft({ text: '你好', direction: 'zh2en', fetchImpl });
const en = await translateMicrosoft({ text: 'hello', direction: 'en2zh', fetchImpl });
assert.equal(zh, 'hello');
assert.equal(en, '你好');
assert.equal(calls.filter((call) => call.href.includes('/translator') && !call.href.includes('ttranslate')).length, 1);
assert.match(calls[1].href, /^https:\/\/cn\.bing\.com\/ttranslatev3/);
assert.match(calls[1].href, /IG=AABBCCDD/);
assert.match(calls[1].href, /IID=translator\.5026/);
assert.match(calls[1].body, /fromLang=zh-Hans/);
assert.match(calls[1].body, /to=en/);
assert.match(calls[2].body, /fromLang=en/);
assert.match(calls[2].body, /to=zh-Hans/);
assert.equal(parseMicrosoft([{ translations: [{ text: 'ok' }] }]), 'ok');
assert.equal(parseBingAuth(PAGE_HTML, 1000).token, 'tok-abc');
assert.equal(translateUrlFor('https://cn.bing.com/translator'), 'https://cn.bing.com/ttranslatev3');
});
test('微软翻译 token 失效(statusCode 205)时重取一次', async () => {
resetTokenCache();
let pages = 0;
let posts = 0;
const fetchImpl = async (url, options) => {
const href = String(url);
if (href.includes('/translator') && !href.includes('ttranslatev3')) {
pages += 1;
return pageResponse();
}
posts += 1;
if (posts === 1) return jsonResponse(200, { statusCode: 205 });
assert.equal(options.body.includes('tok-abc'), true);
return jsonResponse(200, [{ translations: [{ text: 'hi' }] }]);
};
const text = await translateMicrosoft({ text: '你好', direction: 'zh2en', fetchImpl });
assert.equal(text, 'hi');
assert.equal(pages, 2);
});
test('Google 翻译解析多段结果,并按方向设置 tl', async () => {
assert.equal(parseGoogle([[['Hello', '你好'], ['!', '']]]), 'Hello!');
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
return jsonResponse(200, [[['Hello', '你好']]]);
};
const result = await translate({
text: '你好',
direction: 'zh2en',
engine: 'free',
config: { free: { provider: 'google' } },
fetchImpl,
});
assert.equal(result.text, 'Hello');
assert.match(calls[0], /tl=en/);
assert.match(calls[0], /sl=zh-CN/);
await translate({
text: 'hello',
direction: 'en2zh',
engine: 'free',
config: { free: { provider: 'google' } },
fetchImpl,
});
assert.match(calls[1], /tl=zh-CN/);
assert.match(calls[1], /sl=en/);
});
test('Google 连不上时给出可切换的提示', async () => {
await assert.rejects(
() => translate({
text: 'hi',
direction: 'en2zh',
engine: 'free',
config: { free: { provider: 'google' } },
fetchImpl: async () => {
throw new Error('getaddrinfo');
},
}),
(err) => {
assert.equal(err.message, '网络连不上 Google,换微软或大模型试试');
return true;
},
);
});
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { detectDirection, buildPrompt, cleanOutput } = require('../src/main/prompt');
test('detectDirection 按汉字和拉丁字母的大致词数判断', () => {
assert.equal(detectDirection(''), 'zh2en');
assert.equal(detectDirection(' '), 'zh2en');
assert.equal(detectDirection('你好'), 'zh2en');
assert.equal(detectDirection('Hello'), 'en2zh');
assert.equal(detectDirection('Hello world, this is a long sentence'), 'en2zh');
assert.equal(detectDirection('我明天有个 meeting'), 'zh2en');
});
test('buildPrompt 包住原文,并按方向选系统提示', () => {
const zh = buildPrompt('我先撤了', 'auto');
assert.equal(zh.direction, 'zh2en');
assert.equal(zh.temperature, 0.3);
assert.match(zh.system, /英语母语者/);
assert.equal(zh.user, '<text>\n我先撤了\n</text>');
const en = buildPrompt('see you', 'en2zh');
assert.equal(en.direction, 'en2zh');
assert.match(en.system, /发微信/);
assert.equal(en.user, '<text>\nsee you\n</text>');
const forced = buildPrompt('Hello', 'zh2en');
assert.equal(forced.direction, 'zh2en');
});
test('cleanOutput 去掉包装、前缀和外层引号', () => {
assert.equal(cleanOutput(' 译文:你好 '), '你好');
assert.equal(cleanOutput('Translation: hello'), 'hello');
assert.equal(cleanOutput('<text>\nhi\n</text>'), 'hi');
assert.equal(cleanOutput('"hello"'), 'hello');
assert.equal(cleanOutput('“你好”'), '你好');
assert.equal(cleanOutput('「好的」'), '好的');
assert.equal(cleanOutput('say "hi" now'), 'say "hi" now');
assert.equal(cleanOutput(''), '');
});
+145
View File
@@ -0,0 +1,145 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { extractSSEEvents, takeOpenAIDelta, takeAnthropicDelta } = require('../src/main/engines/sse');
const { translateLLM, joinUrl, maxTokensFor } = require('../src/main/engines/llm');
function streamFrom(chunks) {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(Buffer.from(chunk));
controller.close();
},
});
}
function sseResponse(chunks, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
body: streamFrom(chunks),
text: async () => '',
};
}
test('SSE 事件可以跨分片,OpenAI 与 Anthropic 各取各的增量', () => {
const split = extractSSEEvents('data: {"choices":[{"delta":{"content":"He"');
assert.equal(split.events.length, 0);
const rest = extractSSEEvents(split.rest + '}}]}\n\ndata: [DONE]\n\n');
assert.deepEqual(rest.events, ['{"choices":[{"delta":{"content":"He"}}]}', '[DONE]']);
assert.deepEqual(takeOpenAIDelta(rest.events[0]), { done: false, delta: 'He' });
assert.deepEqual(takeOpenAIDelta(rest.events[1]), { done: true, delta: '' });
const anthropic = extractSSEEvents(
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"你"}}\n\n' +
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
);
assert.equal(takeAnthropicDelta(anthropic.events[0]), '你');
assert.equal(takeAnthropicDelta(anthropic.events[1]), '');
});
test('OpenAI 兼容流:累计 partial,结束时清理前缀,baseUrl 末尾斜杠可有可无', async () => {
const partials = [];
let seen;
const text = await translateLLM({
text: '你好',
direction: 'zh2en',
llm: {
provider: 'deepseek',
baseUrl: 'https://example.com/v1/',
model: 'demo',
apiKey: 'sk-test',
},
onPartial: (value) => partials.push(value),
fetchImpl: async (url, options) => {
seen = { url: String(url), body: JSON.parse(options.body), headers: options.headers };
return sseResponse([
'data: {"choices":[{"delta":{"content":"译文:"}}]}\n\n',
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
'data: [DONE]\n\n',
]);
},
});
assert.equal(seen.url, 'https://example.com/v1/chat/completions');
assert.equal(seen.headers.Authorization, 'Bearer sk-test');
assert.equal(seen.body.stream, true);
assert.equal(seen.body.max_tokens, maxTokensFor('你好'));
assert.equal(seen.body.temperature, 0.3);
assert.equal(seen.body.messages[0].role, 'system');
assert.equal(seen.body.messages[1].content, '<text>\n你好\n</text>');
assert.deepEqual(partials, ['译文:', '译文:Hello']);
assert.equal(text, 'Hello');
assert.equal(joinUrl('https://example.com/v1/', '/chat/completions'), 'https://example.com/v1/chat/completions');
});
test('Anthropic 流解析 content_block_delta,并带上要求的头', async () => {
const partials = [];
let seen;
const text = await translateLLM({
text: 'hello',
direction: 'en2zh',
llm: {
provider: 'anthropic',
baseUrl: 'http://127.0.0.1:9',
model: 'claude-test',
apiKey: 'sk-ant',
},
onPartial: (value) => partials.push(value),
fetchImpl: async (url, options) => {
seen = { url: String(url), body: JSON.parse(options.body), headers: options.headers };
return sseResponse([
'event: content_block_delta\n',
'data: {"type":"content_block_delta","delta":{"text":"你"}}\n\n',
'data: {"type":"content_block_delta","delta":{"text":"好"}}\n\n',
]);
},
});
assert.equal(seen.url, 'http://127.0.0.1:9/v1/messages');
assert.equal(seen.headers['x-api-key'], 'sk-ant');
assert.equal(seen.headers['anthropic-version'], '2023-06-01');
assert.equal(seen.body.stream, true);
assert.equal(seen.body.system.includes('发微信'), true);
assert.deepEqual(seen.body.messages, [{ role: 'user', content: '<text>\nhello\n</text>' }]);
assert.deepEqual(partials, ['你', '你好']);
assert.equal(text, '你好');
assert.equal(maxTokensFor('a'.repeat(2000)), 4096);
});
test('没填 Key 时不发请求', async () => {
let called = 0;
await assert.rejects(
() => translateLLM({
text: '你好',
direction: 'zh2en',
llm: { provider: 'deepseek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', apiKey: '' },
fetchImpl: () => {
called += 1;
throw new Error('no');
},
}),
(err) => {
assert.equal(err.code, 'NO_KEY');
return true;
},
);
assert.equal(called, 0);
});
test('AbortSignal 会取消流式请求', async () => {
const ac = new AbortController();
await assert.rejects(
() => translateLLM({
text: '你好',
direction: 'zh2en',
signal: ac.signal,
llm: { provider: 'openai', baseUrl: 'https://example.com/v1', model: 'm', apiKey: 'sk' },
fetchImpl: async (_url, options) => {
ac.abort();
if (options.signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
return sseResponse(['data: [DONE]\n\n']);
},
}),
(err) => err.code === 'CANCEL' || err.message === 'cancel',
);
});