Files
PopLingo/background.js
T
zzzjy d660c204c2
CI / check (push) Successful in 10s
Release / release (push) Successful in 7s
feat: 区分智谱 GLM 两个接入点(普通 API / GLM Coding Plan 订阅专属接入点)
2026-09-17 23:34:28 +08:00

254 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
});