Initial commit: 译泡 PopLingo v1.0.0
AI 划词翻译 Chrome 扩展(Manifest V3): - 支持 DeepSeek/OpenAI/Kimi/智谱/通义/Gemini/Claude/Ollama 等 10 种服务商 - 流式输出、左右分栏对照、原文一键展开收起 - 划词图标/自动翻译/Alt+T 快捷键/右键菜单四种触发方式
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: #f1f5f9; color: #111827; min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout { display: flex; max-width: 980px; margin: 0 auto; gap: 20px; padding: 28px 20px 80px; }
|
||||
|
||||
.side { width: 220px; flex: none; position: sticky; top: 28px; align-self: start; }
|
||||
.logo { display: flex; align-items: center; gap: 12px; margin-bottom: 22px; }
|
||||
.logo img { border-radius: 9px; }
|
||||
.logo h1 { font-size: 16px; }
|
||||
.logo span { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.side nav { display: grid; gap: 4px; }
|
||||
.side nav a {
|
||||
text-decoration: none; color: #4b5563; font-size: 13.5px;
|
||||
padding: 9px 12px; border-radius: 9px; transition: all .15s;
|
||||
}
|
||||
.side nav a:hover { background: #e0e7ff; color: #4338ca; }
|
||||
.side nav a.active { background: #4f46e5; color: #fff; font-weight: 600; }
|
||||
|
||||
main { flex: 1; display: grid; gap: 16px; }
|
||||
|
||||
.card {
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 16px;
|
||||
padding: 22px 24px; box-shadow: 0 1px 3px rgba(0,0,0,.05);
|
||||
}
|
||||
.card h2 { font-size: 15.5px; margin-bottom: 4px; }
|
||||
.desc { font-size: 12.5px; color: #6b7280; margin-bottom: 16px; line-height: 1.6; }
|
||||
.desc code, .about code { background: #f3f4f6; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: #4f46e5; }
|
||||
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; }
|
||||
.field { display: grid; gap: 5px; }
|
||||
.field.wide { grid-column: 1 / -1; }
|
||||
.field > span { font-size: 12px; font-weight: 600; color: #374151; }
|
||||
.field input, .field select, textarea {
|
||||
padding: 9px 12px; border: 1px solid #e5e7eb; border-radius: 9px;
|
||||
font-size: 13.5px; color: #111827; outline: none; background: #fff;
|
||||
font-family: inherit; transition: border-color .15s, box-shadow .15s; width: 100%;
|
||||
}
|
||||
.field input:focus, .field select:focus, textarea:focus {
|
||||
border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.13);
|
||||
}
|
||||
.keywrap { display: flex; gap: 6px; }
|
||||
.keywrap input { flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.ghost {
|
||||
border: 1px solid #e5e7eb; background: #fff; border-radius: 9px;
|
||||
cursor: pointer; padding: 0 10px; font-size: 14px;
|
||||
}
|
||||
.ghost:hover { background: #f9fafb; }
|
||||
|
||||
textarea { resize: vertical; line-height: 1.6; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
|
||||
.btn {
|
||||
border: none; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
padding: 9px 18px; border-radius: 9px; background: #eef2ff; color: #4f46e5;
|
||||
transition: all .15s;
|
||||
}
|
||||
.btn:hover { background: #e0e7ff; }
|
||||
.btn.primary { background: linear-gradient(135deg, #4f46e5, #8b5cf6); color: #fff; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn:disabled { opacity: .55; cursor: wait; }
|
||||
|
||||
.test-result { font-size: 12.5px; }
|
||||
.test-result.ok { color: #10b981; }
|
||||
.test-result.err { color: #ef4444; white-space: pre-wrap; }
|
||||
|
||||
.tips {
|
||||
margin-top: 14px; font-size: 12px; color: #6b7280; line-height: 1.7;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 14px;
|
||||
}
|
||||
|
||||
.about { list-style: none; display: grid; gap: 9px; font-size: 13px; color: #374151; line-height: 1.6; }
|
||||
.about li::before { content: '✓ '; color: #10b981; font-weight: 700; }
|
||||
kbd {
|
||||
background: #f3f4f6; border: 1px solid #d1d5db; border-bottom-width: 2px;
|
||||
border-radius: 5px; padding: 1px 6px; font-size: 11.5px; font-family: inherit;
|
||||
}
|
||||
|
||||
.savebar {
|
||||
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: #111827; color: #fff; padding: 10px 12px 10px 20px;
|
||||
border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.3);
|
||||
opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s;
|
||||
}
|
||||
.savebar.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(-4px); }
|
||||
#saveHint { font-size: 12.5px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.layout { flex-direction: column; }
|
||||
.side { width: auto; position: static; }
|
||||
.grid2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>译泡 PopLingo · 设置</title>
|
||||
<link rel="stylesheet" href="options.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="logo">
|
||||
<img src="../icons/icon48.png" width="34" height="34" alt="" />
|
||||
<div>
|
||||
<h1>译泡 · PopLingo</h1>
|
||||
<span>v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="#provider" class="active">🔌 模型服务</a>
|
||||
<a href="#behavior">🖱️ 翻译行为</a>
|
||||
<a href="#prompt">✨ 自定义 Prompt</a>
|
||||
<a href="#about">ℹ️ 使用说明</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<!-- 模型服务 -->
|
||||
<section id="provider" class="card">
|
||||
<h2>模型服务</h2>
|
||||
<p class="desc">选择服务商后会自动填充接口地址与推荐模型;也可以选择「自定义」接入任意 OpenAI 兼容接口。</p>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="field">
|
||||
<span>服务商</span>
|
||||
<select id="preset"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>接入协议</span>
|
||||
<select id="apiType">
|
||||
<option value="openai">OpenAI 兼容(chat/completions)</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field wide">
|
||||
<span>API Base URL</span>
|
||||
<input id="baseURL" type="text" placeholder="https://api.deepseek.com/v1" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>API Key</span>
|
||||
<div class="keywrap">
|
||||
<input id="apiKey" type="password" placeholder="sk-..." spellcheck="false" autocomplete="off" />
|
||||
<button id="eyeBtn" class="ghost" type="button" title="显示/隐藏">👁</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>模型名称</span>
|
||||
<input id="model" type="text" placeholder="deepseek-chat" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="testBtn" class="btn">测试连接</button>
|
||||
<span id="testResult" class="test-result"></span>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
💡 国内推荐 <b>DeepSeek</b>(便宜好用)或 <b>智谱 GLM-4-Flash</b>(免费);本地部署选 <b>Ollama</b>(无需 Key)。Key 仅保存在你的浏览器账号同步存储中。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 翻译行为 -->
|
||||
<section id="behavior" class="card">
|
||||
<h2>翻译行为</h2>
|
||||
<div class="grid2">
|
||||
<label class="field">
|
||||
<span>目标语言</span>
|
||||
<select id="targetLang"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>源语言</span>
|
||||
<select id="sourceLang"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>触发方式</span>
|
||||
<select id="triggerMode">
|
||||
<option value="icon">划词后显示图标,点击翻译</option>
|
||||
<option value="auto">划词后自动翻译</option>
|
||||
<option value="shortcut">仅快捷键(默认 Alt+T)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>译文字号</span>
|
||||
<input id="fontSize" type="number" min="12" max="22" step="1" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Prompt -->
|
||||
<section id="prompt" class="card">
|
||||
<h2>自定义 Prompt(可选)</h2>
|
||||
<p class="desc">留空使用内置专业翻译 Prompt。可用变量:<code>{targetLang}</code> 目标语言、<code>{sourceLang}</code> 源语言。</p>
|
||||
<textarea id="systemPrompt" rows="5" placeholder="例如:你是字幕翻译专家,将文本翻译成{targetLang},保持口语化,输出纯文本……"></textarea>
|
||||
<div class="grid2" style="margin-top:12px">
|
||||
<label class="field">
|
||||
<span>Temperature(0 严谨 ~ 1 活泼)</span>
|
||||
<input id="temperature" type="number" min="0" max="1" step="0.1" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 关于 -->
|
||||
<section id="about" class="card">
|
||||
<h2>使用说明</h2>
|
||||
<ul class="about">
|
||||
<li>划词 / 按 <kbd>Alt+T</kbd> / 右键菜单「AI 翻译」触发翻译;<kbd>Esc</kbd> 关闭弹窗。</li>
|
||||
<li>弹窗支持拖拽移动、右下角调整大小、一键复制与重新翻译。</li>
|
||||
<li>快捷键可在 <code>chrome://extensions/shortcuts</code> 中修改。</li>
|
||||
<li>API 请求由扩展后台发起,不经过任何第三方服务器。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="savebar" id="savebar">
|
||||
<span id="saveHint">设置修改后自动保存</span>
|
||||
<button id="saveBtn" class="btn primary">保存</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,150 @@
|
||||
/* global chrome */
|
||||
|
||||
const LANGS = ['简体中文', '繁體中文', 'English', '日本語', '한국어', 'Français', 'Deutsch', 'Español', 'Português', 'Italiano', 'Русский', 'العربية', 'हिन्दी', 'ไทย', 'Việt Nam', 'Indonesia'].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
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(Flash 免费)', 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: '硅基流动 SiliconFlow', 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(本地,无需 Key)', apiType: 'openai', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b' },
|
||||
custom: { name: '自定义(OpenAI 兼容)', apiType: 'openai', baseURL: '', model: '' }
|
||||
};
|
||||
|
||||
const FIELDS = ['preset', 'apiType', 'baseURL', 'apiKey', 'model', 'targetLang', 'sourceLang', 'triggerMode', 'systemPrompt', 'temperature', 'fontSize'];
|
||||
const $ = id => document.getElementById(id);
|
||||
let dirty = false;
|
||||
|
||||
async function init() {
|
||||
const cfg = await chrome.storage.sync.get({
|
||||
preset: 'deepseek', apiType: 'openai', baseURL: '', apiKey: '', model: '',
|
||||
targetLang: '简体中文', sourceLang: '自动检测', triggerMode: 'icon',
|
||||
systemPrompt: '', temperature: 0.3, fontSize: 16
|
||||
});
|
||||
|
||||
// 服务商下拉
|
||||
const presetSel = $('preset');
|
||||
Object.entries(PRESETS).forEach(([k, v]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = k; o.textContent = v.name;
|
||||
presetSel.appendChild(o);
|
||||
});
|
||||
presetSel.value = cfg.preset in PRESETS ? cfg.preset : 'custom';
|
||||
|
||||
// 语言下拉
|
||||
['targetLang', 'sourceLang'].forEach(id => {
|
||||
const sel = $(id);
|
||||
(id === 'sourceLang' ? ['自动检测', ...LANGS] : LANGS).forEach(l => {
|
||||
const o = document.createElement('option');
|
||||
o.value = l; o.textContent = l;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
});
|
||||
|
||||
FIELDS.forEach(id => { $(id).value = cfg[id]; });
|
||||
|
||||
presetSel.addEventListener('change', () => {
|
||||
const p = PRESETS[presetSel.value];
|
||||
if (!p) return;
|
||||
$('apiType').value = p.apiType;
|
||||
$('baseURL').value = p.baseURL;
|
||||
$('model').value = p.model;
|
||||
markDirty();
|
||||
});
|
||||
|
||||
$('eyeBtn').addEventListener('click', () => {
|
||||
const inp = $('apiKey');
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
|
||||
FIELDS.forEach(id => $(id).addEventListener('input', markDirty));
|
||||
|
||||
$('saveBtn').addEventListener('click', save);
|
||||
$('testBtn').addEventListener('click', testConnection);
|
||||
|
||||
// 侧边栏高亮
|
||||
const links = document.querySelectorAll('.side nav a');
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(e => {
|
||||
if (e.isIntersecting) {
|
||||
links.forEach(l => l.classList.toggle('active', l.hash === '#' + e.target.id));
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '-30% 0px -60% 0px' });
|
||||
document.querySelectorAll('main section').forEach(s => observer.observe(s));
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
dirty = true;
|
||||
$('saveHint').textContent = '有未保存的修改';
|
||||
$('savebar').classList.add('show');
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = {};
|
||||
FIELDS.forEach(id => {
|
||||
data[id] = $(id).type === 'number' ? parseFloat($(id).value) || 0 : $(id).value.trim ? $(id).value.trim() : $(id).value;
|
||||
});
|
||||
data.temperature = Math.min(1, Math.max(0, data.temperature));
|
||||
data.fontSize = Math.min(22, Math.max(12, Math.round(data.fontSize)));
|
||||
await chrome.storage.sync.set(data);
|
||||
dirty = false;
|
||||
$('saveHint').textContent = '✓ 已保存';
|
||||
$('savebar').classList.add('show');
|
||||
setTimeout(() => { if (!dirty) $('savebar').classList.remove('show'); }, 2000);
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
const btn = $('testBtn'), out = $('testResult');
|
||||
btn.disabled = true;
|
||||
out.className = 'test-result';
|
||||
out.textContent = '连接中…';
|
||||
|
||||
const cfg = {};
|
||||
FIELDS.forEach(id => cfg[id] = $(id).value.trim ? $(id).value.trim() : $(id).value);
|
||||
const base = cfg.baseURL.replace(/\/+$/, '');
|
||||
const prompt = `请把这句话翻译成${cfg.targetLang}:Hello world`;
|
||||
|
||||
try {
|
||||
let url, headers, body;
|
||||
if (cfg.apiType === 'anthropic') {
|
||||
url = `${base}/v1/messages`;
|
||||
headers = { 'content-type': 'application/json', 'x-api-key': cfg.apiKey, 'anthropic-version': '2023-06-01' };
|
||||
body = JSON.stringify({ model: cfg.model, max_tokens: 100, messages: [{ role: 'user', content: prompt }] });
|
||||
} else if (cfg.apiType === 'gemini') {
|
||||
url = `${base}/v1beta/models/${encodeURIComponent(cfg.model)}:generateContent`;
|
||||
headers = { 'content-type': 'application/json', 'x-goog-api-key': cfg.apiKey };
|
||||
body = JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }] });
|
||||
} else {
|
||||
url = `${base}/chat/completions`;
|
||||
headers = { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` };
|
||||
body = JSON.stringify({ model: cfg.model, max_tokens: 100, messages: [{ role: 'user', content: prompt }] });
|
||||
}
|
||||
|
||||
const resp = await fetch(url, { method: 'POST', headers, body });
|
||||
const json = await resp.json().catch(() => ({}));
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(json.error?.message || json.message || `HTTP ${resp.status}`);
|
||||
}
|
||||
const reply = cfg.apiType === 'anthropic' ? json.content?.[0]?.text
|
||||
: cfg.apiType === 'gemini' ? json.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
: json.choices?.[0]?.message?.content;
|
||||
|
||||
if (!reply) throw new Error('未解析到回复,请检查模型名');
|
||||
if (dirty) await save();
|
||||
out.className = 'test-result ok';
|
||||
out.textContent = `✓ 连接成功:${String(reply).trim().slice(0, 50)}`;
|
||||
} catch (err) {
|
||||
out.className = 'test-result err';
|
||||
out.textContent = '✗ ' + (err.message || err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user