254 lines
9.2 KiB
JavaScript
254 lines
9.2 KiB
JavaScript
/* global chrome */
|
||
|
||
// ---------- 默认配置 ----------
|
||
const DEFAULTS = {
|
||
enabled: true,
|
||
preset: 'deepseek',
|
||
apiType: 'openai', // openai | anthropic | gemini
|
||
baseURL: 'https://api.deepseek.com/v1',
|
||
apiKey: '',
|
||
model: 'deepseek-chat',
|
||
targetLang: '简体中文',
|
||
sourceLang: '自动检测',
|
||
triggerMode: 'icon', // icon | auto | shortcut
|
||
systemPrompt: '',
|
||
temperature: 0.3,
|
||
fontSize: 16,
|
||
maxLength: 5000
|
||
};
|
||
|
||
const PRESETS = {
|
||
deepseek: { name: 'DeepSeek', apiType: 'openai', baseURL: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
|
||
openai: { name: 'OpenAI', apiType: 'openai', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
||
kimi: { name: '月之暗面 Kimi', apiType: 'openai', baseURL: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' },
|
||
zhipu: { name: '智谱 GLM(普通 API·按量计费)', apiType: 'openai', baseURL: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' },
|
||
'zhipu-coding': { name: '智谱 GLM Coding Plan(订阅)', apiType: 'openai', baseURL: 'https://open.bigmodel.cn/api/coding/paas/v4', model: 'glm-5.3' },
|
||
qwen: { name: '通义千问', apiType: 'openai', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' },
|
||
siliconflow: { name: '硅基流动', apiType: 'openai', baseURL: 'https://api.siliconflow.cn/v1', model: 'Qwen/Qwen2.5-7B-Instruct' },
|
||
gemini: { name: 'Google Gemini', apiType: 'gemini', baseURL: 'https://generativelanguage.googleapis.com', model: 'gemini-2.0-flash' },
|
||
anthropic: { name: 'Anthropic Claude', apiType: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-3-5-haiku-latest' },
|
||
ollama: { name: 'Ollama(本地)', apiType: 'openai', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b' },
|
||
custom: { name: '自定义(OpenAI 兼容)', apiType: 'openai', baseURL: '', model: '' }
|
||
};
|
||
|
||
async function getSettings() {
|
||
const stored = await chrome.storage.sync.get(DEFAULTS);
|
||
return { ...DEFAULTS, ...stored };
|
||
}
|
||
|
||
// ---------- Prompt ----------
|
||
function buildSystemPrompt(cfg) {
|
||
if (cfg.systemPrompt && cfg.systemPrompt.trim()) {
|
||
return cfg.systemPrompt
|
||
.replaceAll('{text}', '%TEXT%') // 防止用户模板注入原文
|
||
.replaceAll('{targetLang}', cfg.targetLang)
|
||
.replaceAll('{sourceLang}', cfg.sourceLang);
|
||
}
|
||
const src = cfg.sourceLang === '自动检测' ? '自动识别源语言' : `源语言为 ${cfg.sourceLang}`;
|
||
return (
|
||
`你是一个专业、精准的翻译引擎。${src},将用户提供的文本翻译成${cfg.targetLang}。\n` +
|
||
`要求:\n` +
|
||
`1. 只输出译文本身,不要任何解释、前缀、引号或注释。\n` +
|
||
`2. 如果文本已经是${cfg.targetLang},原样返回文本。\n` +
|
||
`3. 保留原文的换行、段落结构与 Markdown 格式。\n` +
|
||
`4. 专业术语、代码、URL、邮箱保持原样。\n` +
|
||
`5. 在准确的前提下尽量符合${cfg.targetLang}的表达习惯。`
|
||
);
|
||
}
|
||
|
||
// ---------- 各服务商请求构造 ----------
|
||
function buildRequest(cfg, userText) {
|
||
const sys = buildSystemPrompt(cfg);
|
||
const type = cfg.apiType;
|
||
const base = (cfg.baseURL || '').replace(/\/+$/, '');
|
||
|
||
if (type === 'anthropic') {
|
||
return {
|
||
url: `${base}/v1/messages`,
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
'x-api-key': cfg.apiKey,
|
||
'anthropic-version': '2023-06-01',
|
||
'anthropic-dangerous-direct-browser-access': 'true'
|
||
},
|
||
body: JSON.stringify({
|
||
model: cfg.model,
|
||
max_tokens: 4096,
|
||
temperature: cfg.temperature,
|
||
system: sys,
|
||
stream: true,
|
||
messages: [{ role: 'user', content: userText }]
|
||
})
|
||
};
|
||
}
|
||
|
||
if (type === 'gemini') {
|
||
return {
|
||
url: `${base}/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse`,
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
'x-goog-api-key': cfg.apiKey
|
||
},
|
||
body: JSON.stringify({
|
||
systemInstruction: { parts: [{ text: sys }] },
|
||
contents: [{ role: 'user', parts: [{ text: userText }] }],
|
||
generationConfig: { temperature: cfg.temperature, maxOutputTokens: 4096 }
|
||
})
|
||
};
|
||
}
|
||
|
||
// OpenAI 兼容
|
||
return {
|
||
url: `${base}/chat/completions`,
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
authorization: `Bearer ${cfg.apiKey}`
|
||
},
|
||
body: JSON.stringify({
|
||
model: cfg.model,
|
||
temperature: cfg.temperature,
|
||
stream: true,
|
||
messages: [
|
||
{ role: 'system', content: sys },
|
||
{ role: 'user', content: userText }
|
||
]
|
||
})
|
||
};
|
||
}
|
||
|
||
function extractDelta(apiType, json) {
|
||
try {
|
||
if (apiType === 'anthropic') {
|
||
return json.type === 'content_block_delta' ? (json.delta?.text ?? '') : '';
|
||
}
|
||
if (apiType === 'gemini') {
|
||
return json.candidates?.[0]?.content?.parts?.map(p => p.text || '').join('') || '';
|
||
}
|
||
return json.choices?.[0]?.delta?.content ?? '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// ---------- 流式翻译 ----------
|
||
async function streamTranslate(port, msg) {
|
||
const cfg = await getSettings();
|
||
let aborted = false;
|
||
port.onDisconnect.addListener(() => { aborted = true; });
|
||
|
||
try {
|
||
const text = String(msg.text || '').slice(0, cfg.maxLength);
|
||
if (!text.trim()) throw new Error('没有可翻译的文本');
|
||
|
||
const req = buildRequest(cfg, text);
|
||
const controller = new AbortController();
|
||
port.onDisconnect.addListener(() => controller.abort());
|
||
|
||
const resp = await fetch(req.url, {
|
||
method: 'POST',
|
||
headers: req.headers,
|
||
body: req.body,
|
||
signal: controller.signal
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
let detail = '';
|
||
try {
|
||
const j = await resp.json();
|
||
detail = j.error?.message || j.message || JSON.stringify(j).slice(0, 300);
|
||
} catch { /* ignore */ }
|
||
const hints = {
|
||
401: '(API Key 无效或未填写)',
|
||
403: '(无权限,请检查 Key 与模型授权)',
|
||
404: '(接口地址或模型名可能不正确)',
|
||
429: '(请求过于频繁或额度不足)'
|
||
};
|
||
throw new Error(`请求失败 HTTP ${resp.status} ${hints[resp.status] || ''}\n${detail}`);
|
||
}
|
||
if (!resp.body) throw new Error('响应不包含数据流');
|
||
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let full = '';
|
||
|
||
port.postMessage({ type: 'start' });
|
||
|
||
while (true) {
|
||
if (aborted) break;
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
let nl;
|
||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||
const line = buffer.slice(0, nl).replace(/\r$/, '');
|
||
buffer = buffer.slice(nl + 1);
|
||
if (!line.startsWith('data:')) continue;
|
||
const data = line.slice(5).trim();
|
||
if (!data || data === '[DONE]') continue;
|
||
try {
|
||
const json = JSON.parse(data);
|
||
const delta = extractDelta(cfg.apiType, json);
|
||
if (delta) {
|
||
full += delta;
|
||
port.postMessage({ type: 'delta', text: delta });
|
||
}
|
||
} catch { /* 忽略不完整/非 JSON 行 */ }
|
||
}
|
||
}
|
||
|
||
if (!full.trim()) throw new Error('模型未返回内容,请检查模型名称或接口配置');
|
||
port.postMessage({ type: 'done', full });
|
||
} catch (err) {
|
||
if (aborted) return;
|
||
const msgText = err.name === 'AbortError' ? '请求已取消' : (err.message || String(err));
|
||
try { port.postMessage({ type: 'error', error: msgText }); } catch { /* port closed */ }
|
||
}
|
||
}
|
||
|
||
// ---------- 消息通道 ----------
|
||
chrome.runtime.onConnect.addListener(port => {
|
||
if (port.name === 'translate') {
|
||
port.onMessage.addListener(msg => {
|
||
if (msg.type === 'translate') streamTranslate(port, msg);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---------- 快捷键 ----------
|
||
chrome.commands.onCommand.addListener(async command => {
|
||
if (command !== 'translate-selection') return;
|
||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||
if (tab?.id != null) {
|
||
chrome.tabs.sendMessage(tab.id, { type: 'translate-selection' }).catch(() => { /* 无内容脚本 */ });
|
||
}
|
||
});
|
||
|
||
// ---------- 右键菜单 ----------
|
||
chrome.runtime.onInstalled.addListener(() => {
|
||
chrome.contextMenus.removeAll(() => {
|
||
chrome.contextMenus.create({
|
||
id: 'ai-translate-selection',
|
||
title: '用译泡翻译“%s”',
|
||
contexts: ['selection']
|
||
});
|
||
});
|
||
});
|
||
|
||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||
if (info.menuItemId !== 'ai-translate-selection' || !tab?.id) return;
|
||
chrome.tabs.sendMessage(tab.id, {
|
||
type: 'translate-text',
|
||
text: info.selectionText || ''
|
||
}).catch(() => { /* 无内容脚本 */ });
|
||
});
|
||
|
||
// 供 popup / options 读取预设
|
||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||
if (msg.type === 'get-presets') {
|
||
sendResponse({ presets: PRESETS, defaults: DEFAULTS });
|
||
}
|
||
return false;
|
||
});
|