Initial commit: 译泡 PopLingo v1.0.0
AI 划词翻译 Chrome 扩展(Manifest V3): - 支持 DeepSeek/OpenAI/Kimi/智谱/通义/Gemini/Claude/Ollama 等 10 种服务商 - 流式输出、左右分栏对照、原文一键展开收起 - 划词图标/自动翻译/Alt+T 快捷键/右键菜单四种触发方式
This commit is contained in:
+252
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user