Initial commit: 译泡 PopLingo v1.0.0

AI 划词翻译 Chrome 扩展(Manifest V3):
- 支持 DeepSeek/OpenAI/Kimi/智谱/通义/Gemini/Claude/Ollama 等 10 种服务商
- 流式输出、左右分栏对照、原文一键展开收起
- 划词图标/自动翻译/Alt+T 快捷键/右键菜单四种触发方式
This commit is contained in:
2026-09-17 00:43:32 +08:00
commit eb00b23de8
16 changed files with 1486 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
/* 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', apiType: 'openai', baseURL: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' },
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;
});