加入翻译引擎、配置存储和单元测试
依赖钉在 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:
1 parent
b86b18f9f3
commit
6ff0b6a2cc
15 files changed
+5220
-1
No files matched your search
Generated
+3955
File diff suppressed because it is too large.
Load diff
+36
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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(''), '');
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user