From eb00b23de8e3b9e3a2eb10fc59e402939f7499fc Mon Sep 17 00:00:00 2001 From: zzzjy Date: Thu, 17 Sep 2026 00:43:32 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E8=AF=91=E6=B3=A1=20PopLin?= =?UTF-8?q?go=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 划词翻译 Chrome 扩展(Manifest V3): - 支持 DeepSeek/OpenAI/Kimi/智谱/通义/Gemini/Claude/Ollama 等 10 种服务商 - 流式输出、左右分栏对照、原文一键展开收起 - 划词图标/自动翻译/Alt+T 快捷键/右键菜单四种触发方式 --- .gitignore | 14 ++ README.md | 80 +++++++ background.js | 252 +++++++++++++++++++++ content/content.css | 1 + content/content.js | 527 +++++++++++++++++++++++++++++++++++++++++++ icons/icon128.png | Bin 0 -> 3555 bytes icons/icon16.png | Bin 0 -> 518 bytes icons/icon32.png | Bin 0 -> 1001 bytes icons/icon48.png | Bin 0 -> 1455 bytes manifest.json | 63 ++++++ options/options.css | 97 ++++++++ options/options.html | 133 +++++++++++ options/options.js | 150 ++++++++++++ popup/popup.css | 62 +++++ popup/popup.html | 59 +++++ popup/popup.js | 48 ++++ 16 files changed, 1486 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 background.js create mode 100644 content/content.css create mode 100644 content/content.js create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon32.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 options/options.css create mode 100644 options/options.html create mode 100644 options/options.js create mode 100644 popup/popup.css create mode 100644 popup/popup.html create mode 100644 popup/popup.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67116b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 构建产物 +*.zip + +# 系统文件 +.DS_Store +Thumbs.db + +# 编辑器 +.vscode/ +.idea/ +*.swp + +# 依赖 +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e45a4bc --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# 译泡 · PopLingo + +> AI 划词翻译 · Chrome 扩展 —— 划词即译,大模型随身翻译气泡。 + +接入大模型(LLM)的浏览器划词翻译插件。选中网页文本 → 点击浮出的「译」按钮(或按 `Alt+T` / 右键菜单)→ 流式显示译文。 + +## ✨ 功能 + +- **划词翻译**:选中文本后显示图标,点击翻译;也支持划词自动翻译、仅快捷键两种触发模式 +- **多服务商接入**: + - **OpenAI 兼容协议**:DeepSeek、OpenAI、月之暗面 Kimi、智谱 GLM、通义千问、硅基流动、Ollama(本地)、任意自定义兼容接口 + - **Google Gemini**(原生协议) + - **Anthropic Claude**(原生协议) +- **流式输出**:逐字显示译文,跟 ChatGPT 一样的打字机效果 +- **左右分栏对照**:左原文、右译文,带「原文 / 译文」栏目标签与竖向分隔线,字号更大更易读 +- **精致弹窗**:拖拽移动、右下角调整大小、一键复制、重新翻译、「原文」按钮一键展开/收起(收起后译文独占全宽)、`Esc` 关闭、自动适配暗色模式 +- **快捷入口**:`Alt+T` 快捷键、右键菜单「AI 翻译」、工具栏 Popup 快捷开关 +- **可定制**:目标/源语言、译文字号、Temperature、自定义 Prompt(支持 `{targetLang}` `{sourceLang}` 变量) +- **隐私安全**:API 请求由扩展后台直接发给你配置的服务商,不经过任何第三方中转 + +## 📦 安装(开发者模式) + +1. 打开 Chrome,访问 `chrome://extensions/` +2. 右上角打开 **开发者模式** +3. 点击 **加载已解压的扩展程序**,选择本目录(`fanyi/`) +4. 点击工具栏拼图图标,把「AI 划词翻译」固定到工具栏 + +## ⚙️ 配置模型 + +1. 点击工具栏图标 → **⚙ 模型设置**(或在扩展管理页进入「扩展程序选项」) +2. 选择服务商(会自动填好接口地址和推荐模型) +3. 填入 **API Key**(Ollama 本地无需 Key) +4. 点击 **测试连接** 验证,保存 + +### 推荐服务商 + +| 服务商 | 获取 Key | 推荐模型 | 备注 | +|---|---|---|---| +| DeepSeek | platform.deepseek.com | `deepseek-chat` | 便宜好用,国内直连 | +| 智谱 GLM | open.bigmodel.cn | `glm-4-flash` | 免费额度 | +| 月之暗面 Kimi | platform.moonshot.cn | `moonshot-v1-8k` | 长上下文 | +| 通义千问 | bailian.aliyun.com | `qwen-plus` | 阿里云百炼 | +| Google Gemini | aistudio.google.com | `gemini-2.0-flash` | 有免费额度 | +| Ollama | 本地 `ollama serve` | `qwen2.5:7b` | 无需 Key,完全本地 | + +### Ollama 用户注意 + +启动时需允许跨域:`OLLAMA_ORIGINS='*' ollama serve`,接口地址填 `http://localhost:11434/v1`,模型名用 `ollama list` 里的名字。 + +## 🖱️ 使用 + +| 操作 | 效果 | +|---|---| +| 选中文字 | 出现紫色「译」按钮,点击翻译 | +| `Alt+T` | 翻译当前选中文字(可在 `chrome://extensions/shortcuts` 改键) | +| 右键选中文本 | 菜单「AI 翻译"…"」 | +| `Esc` | 关闭翻译弹窗 | +| 拖弹窗标题栏 / 右下角 | 移动 / 调整大小 | +| 点弹窗顶部「原文」按钮 | 展开 / 收起左栏原文(收起后译文独占全宽,长文各自独立滚动) | + +## 📁 目录结构 + +``` +fanyi/ +├── manifest.json # MV3 清单 +├── background.js # Service Worker:LLM API 调用 + SSE 流解析 + 快捷键/右键菜单 +├── content/ +│ ├── content.js # 内容脚本:划词检测、FAB、翻译弹窗(Shadow DOM 隔离) +│ └── content.css # 占位(样式在 Shadow DOM 内) +├── popup/ # 工具栏弹窗:开关、语言、触发方式 +├── options/ # 设置页:服务商、Key、模型、Prompt +├── icons/ # 图标 +└── README.md +``` + +## ❓ 常见问题 + +- **本地 HTML 文件不生效**:在 `chrome://extensions/` → 本插件 → 详情 → 打开「允许访问文件网址」 +- **API Key 存在哪**:Chrome 账号的 `storage.sync`,随浏览器账号同步,不上传到任何第三方 +- **请求失败 401/404/429**:分别检查 Key 是否正确、Base URL/模型名是否对、额度是否充足,设置页有「测试连接」可一键诊断 diff --git a/background.js b/background.js new file mode 100644 index 0000000..bfb9edb --- /dev/null +++ b/background.js @@ -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; +}); diff --git a/content/content.css b/content/content.css new file mode 100644 index 0000000..5861d3f --- /dev/null +++ b/content/content.css @@ -0,0 +1 @@ +/* 样式全部注入在 closed Shadow DOM 中(见 content.js),此文件保留用于确保内容脚本能尽早注入。 */ diff --git a/content/content.js b/content/content.js new file mode 100644 index 0000000..75105a6 --- /dev/null +++ b/content/content.js @@ -0,0 +1,527 @@ +/* global chrome */ +(() => { + if (window.__AI_TRANSLATE_LOADED__) return; + window.__AI_TRANSLATE_LOADED__ = true; + + const MAX_LEN = 5000; + const $ = (sel, root = document) => root.querySelector(sel); + + let settings = { + enabled: true, triggerMode: 'icon', targetLang: '简体中文', + sourceLang: '自动检测', fontSize: 16, preset: '', model: '' + }; + + let host = null; // shadow 宿主 + let shadow = null; + let ui = {}; // 缓存元素 + let currentPort = null; + let currentText = ''; + let autoTimer = null; + let lastMouseEvent = null; + let dragState = null; + let sourcePinned = false; // 用户手动固定原文显示时,不再自动收起 + + // ---------------- 初始化 ---------------- + chrome.storage.sync.get(settings).then(s => { settings = { ...settings, ...s }; }); + chrome.storage.onChanged.addListener(changes => { + for (const k of Object.keys(changes)) { + if (k in settings) settings[k] = changes[k].newValue; + } + applyFontSize(); + }); + + // ---------------- 工具 ---------------- + function getSelectionText() { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return ''; + const text = sel.toString().replace(/\u00a0/g, ' ').trim(); + return text; + } + + function selectionRect() { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return null; + const rect = sel.getRangeAt(0).getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return null; + return rect; + } + + function isOurElement(node) { + return host && (node === host || (node instanceof Node && host.contains(node))); + } + + function clampPanel(pos, w, h) { + const vw = window.innerWidth, vh = window.innerHeight, m = 10; + let { x, y } = pos; + x = Math.max(m, Math.min(x, vw - w - m)); + y = Math.max(m, Math.min(y, vh - h - m)); + return { x, y }; + } + + // ---------------- UI 构建 ---------------- + function ensureUI() { + if (host) return; + host = document.createElement('div'); + host.id = '__ai-translate-host-' + Math.random().toString(36).slice(2, 8); + host.style.cssText = 'all:initial;position:fixed;top:0;left:0;width:0;height:0;z-index:2147483646;'; + shadow = host.attachShadow({ mode: 'closed' }); + + const style = document.createElement('style'); + style.textContent = cssText(); + shadow.appendChild(style); + + const wrap = document.createElement('div'); + wrap.className = 'ait-wrap'; + wrap.innerHTML = ` + + + + `; + shadow.appendChild(wrap); + + ui = { + wrap, fab: $('.ait-fab', wrap), panel: $('.ait-panel', wrap), + src: $('.ait-src', wrap), dst: $('.ait-dst', wrap), model: $('.ait-model', wrap), + source: $('.ait-source', wrap), result: $('.ait-result', wrap), + srcToggle: $('.ait-src-toggle', wrap), + copy: $('.ait-copy', wrap), retry: $('.ait-retry', wrap), close: $('.ait-close', wrap), + header: $('.ait-header', wrap), resizer: $('.ait-resizer', wrap) + }; + + ui.fab.addEventListener('mousedown', e => e.stopPropagation()); + ui.fab.addEventListener('click', e => { + e.preventDefault(); e.stopPropagation(); + hideFab(); + translateSelection(); + }); + + ui.copy.addEventListener('click', async () => { + const text = ui.result.innerText; + try { + await navigator.clipboard.writeText(text); + toast('已复制'); + } catch { + toast('复制失败', true); + } + }); + ui.retry.addEventListener('click', () => translate(currentText, true)); + ui.close.addEventListener('click', hidePanel); + + // 展开 / 收起原文 + ui.srcToggle.addEventListener('click', () => { + sourcePinned = true; + setSourceVisible(ui.source.hidden); + }); + + // 拖拽 + ui.header.addEventListener('pointerdown', e => { + if (e.target.closest('.ait-btn')) return; + const rect = ui.panel.getBoundingClientRect(); + dragState = { sx: e.clientX, sy: e.clientY, px: rect.left, py: rect.top }; + ui.header.setPointerCapture(e.pointerId); + }); + ui.header.addEventListener('pointermove', e => { + if (!dragState) return; + const x = dragState.px + e.clientX - dragState.sx; + const y = dragState.py + e.clientY - dragState.sy; + setPanelPos(x, y); + }); + ui.header.addEventListener('pointerup', () => { dragState = null; }); + + // 右下角调整大小 + ui.resizer.addEventListener('pointerdown', e => { + e.preventDefault(); + const startX = e.clientX, startY = e.clientY; + const rect = ui.panel.getBoundingClientRect(); + const move = ev => { + ui.panel.style.width = Math.max(260, rect.width + ev.clientX - startX) + 'px'; + ui.panel.style.maxHeight = Math.min(Math.max(160, rect.height + ev.clientY - startY), window.innerHeight - 20) + 'px'; + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }); + + document.documentElement.appendChild(host); + applyFontSize(); + } + + function setPanelPos(x, y) { + const rect = ui.panel.getBoundingClientRect(); + const p = clampPanel({ x, y }, rect.width, rect.height); + ui.panel.style.left = p.x + 'px'; + ui.panel.style.top = p.y + 'px'; + } + + function applyFontSize() { + if (!ui.result) return; + ui.result.style.fontSize = (settings.fontSize || 16) + 'px'; + if (ui.source) ui.source.style.fontSize = Math.max(13, (settings.fontSize || 16) - 1) + 'px'; + } + + function setSourceVisible(visible) { + if (!ui.source) return; + ui.source.hidden = !visible; + ui.srcToggle.classList.toggle('ait-active', visible); + ui.srcToggle.title = visible ? '收起原文' : '显示原文'; + if (visible) ui.source.scrollTop = 0; + } + + // 译文较长时自动收起原文;用户点过按钮则保持展开 + function autoCollapseSource(resultLen) { + if (resultLen > 80 && !sourcePinned) setSourceVisible(false); + } + + function showFab() { + ensureUI(); + const rect = selectionRect(); + if (!rect) return hideFab(); + const x = Math.min(rect.right + 6, window.innerWidth - 48); + let y = rect.bottom + 6; + if (y > window.innerHeight - 40) y = rect.top - 34; + ui.fab.style.left = Math.max(6, x) + 'px'; + ui.fab.style.top = Math.max(6, y) + 'px'; + ui.fab.classList.add('ait-show'); + } + + function hideFab() { + if (ui.fab) ui.fab.classList.remove('ait-show'); + } + + // ---------------- 翻译 ---------------- + function translateSelection() { + const text = getSelectionText(); + if (!text) { toast('请先选择要翻译的文本', true); return; } + translate(text); + } + + function translate(text, isRetry = false) { + ensureUI(); + currentText = text; + + if (!settings.enabled) { toast('插件已停用,请在弹窗中开启', true); return; } + if (!text || text.length > MAX_LEN) { toast('文本过长或为空', true); return; } + + // 打断上一次请求 + if (currentPort) { try { currentPort.disconnect(); } catch {} currentPort = null; } + + ui.panel.hidden = false; + ui.panel.classList.add('ait-visible'); + ui.src.textContent = settings.sourceLang || '自动检测'; + ui.dst.textContent = settings.targetLang; + ui.model.textContent = settings.model ? String(settings.model).split('/').pop() : ''; + sourcePinned = false; + ui.source.textContent = text; // 完整原文,展开时内部滚动查看 + setSourceVisible(true); + ui.result.className = 'ait-result ait-loading'; + ui.result.innerHTML = '正在翻译…'; + hideFab(); + + // 定位:优先选区附近,重试时保持原位 + if (!isRetry) { + const rect = selectionRect() || (lastMouseEvent ? { left: lastMouseEvent.clientX, bottom: lastMouseEvent.clientY, width: 0 } : null); + if (rect) { + const pw = Math.min(680, Math.max(480, Math.round(window.innerWidth * 0.5))); + let x = rect.left + rect.width / 2 - pw / 2; + let y = rect.bottom + 10; + if (y > window.innerHeight * 0.65) y = rect.top - 10 - 280; + const pos = clampPanel({ x, y }, pw, 240); + ui.panel.style.width = pw + 'px'; + ui.panel.style.left = pos.x + 'px'; + ui.panel.style.top = pos.y + 'px'; + } + } + + const port = chrome.runtime.connect({ name: 'translate' }); + currentPort = port; + + port.onMessage.addListener(msg => { + if (msg.type === 'start') { + ui.result.className = 'ait-result'; + ui.result.textContent = ''; + } else if (msg.type === 'delta') { + if (ui.result.classList.contains('ait-loading')) { + ui.result.className = 'ait-result'; + ui.result.textContent = ''; + } + ui.result.textContent += msg.text; + // 智能显示源文本 + autoCollapseSource(ui.result.textContent.length); + ui.result.scrollTop = ui.result.scrollHeight; + } else if (msg.type === 'done') { + ui.result.textContent = msg.full; + autoCollapseSource(ui.result.textContent.length); + ui.result.classList.add('ait-done'); + currentPort = null; + try { port.disconnect(); } catch {} + } else if (msg.type === 'error') { + ui.result.className = 'ait-result ait-error'; + ui.result.textContent = '⚠ ' + (msg.error || '未知错误'); + currentPort = null; + try { port.disconnect(); } catch {} + } + }); + + port.onDisconnect.addListener(() => { + if (currentPort === port) { + if (ui.result.classList.contains('ait-loading')) { + ui.result.className = 'ait-result ait-error'; + ui.result.textContent = '⚠ 连接中断,请重试'; + } + currentPort = null; + } + }); + + port.postMessage({ type: 'translate', text }); + } + + function hidePanel() { + if (!ui.panel) return; + ui.panel.classList.remove('ait-visible'); + ui.panel.hidden = true; + if (currentPort) { try { currentPort.disconnect(); } catch {} currentPort = null; } + } + + function toast(message, isError = false) { + ensureUI(); + const t = document.createElement('div'); + t.className = 'ait-toast' + (isError ? ' ait-toast-error' : ''); + t.textContent = message; + ui.wrap.appendChild(t); + requestAnimationFrame(() => t.classList.add('ait-show')); + setTimeout(() => { + t.classList.remove('ait-show'); + setTimeout(() => t.remove(), 300); + }, 1800); + } + + // ---------------- 事件 ---------------- + document.addEventListener('mouseup', e => { + lastMouseEvent = e; + if (isOurElement(e.target)) return; + if (!settings.enabled || settings.triggerMode === 'shortcut') return; + // 等待选区稳定 + setTimeout(() => { + const text = getSelectionText(); + if (text && text.length >= 2 && text.length <= MAX_LEN) { + if (settings.triggerMode === 'auto') { + clearTimeout(autoTimer); + autoTimer = setTimeout(() => translate(getSelectionText()), 450); + } else { + showFab(); + } + } else { + hideFab(); + } + }, 10); + }, true); + + document.addEventListener('mousedown', e => { + if (isOurElement(e.target)) return; + hideFab(); + }, true); + + // 键盘选区(Shift+方向键) + document.addEventListener('keyup', e => { + if (e.shiftKey || e.key === 'Shift') { + if (!settings.enabled || settings.triggerMode === 'icon') { + const text = getSelectionText(); + if (text && text.length >= 2) setTimeout(showFab, 50); + } + } + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && ui.panel && !ui.panel.hidden) hidePanel(); + }, true); + + // 滚动/ resize 时隐藏图标 + window.addEventListener('scroll', () => hideFab(), { passive: true, capture: true }); + + // 来自 background 的消息(快捷键 / 右键菜单) + chrome.runtime.onMessage.addListener(msg => { + if (msg.type === 'translate-selection') translateSelection(); + else if (msg.type === 'translate-text' && msg.text) translate(msg.text); + }); + + // ---------------- 样式 ---------------- + function cssText() { return CONTENT_CSS; } + const CONTENT_CSS = ` + :host { all: initial; } + * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } + + .ait-fab { + all: unset; position: fixed; left: -100px; top: -100px; + display: inline-flex; visibility: hidden; pointer-events: none; + align-items: center; gap: 3px; cursor: pointer; + padding: 4px 10px 4px 8px; border-radius: 999px; + background: linear-gradient(135deg, #4f46e5, #8b5cf6); + color: #fff; font-size: 12px; font-weight: 600; line-height: 1; + box-shadow: 0 4px 14px rgba(79, 70, 229, .45); + opacity: 0; transform: translateY(4px) scale(.9); + transition: opacity .15s ease, transform .15s ease; + -webkit-user-select: none; user-select: none; z-index: 2147483647; + } + .ait-fab.ait-show { visibility: visible; pointer-events: auto; opacity: 1; transform: translateY(0) scale(1); } + .ait-fab:hover { filter: brightness(1.1); } + .ait-fab:active { transform: scale(.95); } + + .ait-panel { + position: fixed; width: 560px; max-width: calc(100vw - 24px); + max-height: min(60vh, 420px); display: flex; flex-direction: column; + background: #ffffff; color: #1f2937; + border: 1px solid rgba(0,0,0,.08); border-radius: 14px; + box-shadow: 0 12px 40px rgba(0,0,0,.18), 0 2px 8px rgba(0,0,0,.08); + opacity: 0; transform: translateY(6px) scale(.98); + transition: opacity .16s ease, transform .16s ease; + z-index: 2147483647; overflow: hidden; + font-size: 14px; + } + .ait-panel[hidden] { display: none; } + .ait-panel.ait-visible { opacity: 1; transform: translateY(0) scale(1); } + + .ait-header { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px 8px 14px; cursor: grab; + background: linear-gradient(135deg, rgba(79,70,229,.08), rgba(139,92,246,.08)); + border-bottom: 1px solid rgba(0,0,0,.06); + } + .ait-header:active { cursor: grabbing; } + .ait-lang { display: flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: #4f46e5; } + .ait-lang svg { opacity: .7; } + .ait-model { + font-size: 10.5px; color: #9ca3af; background: rgba(0,0,0,.05); + padding: 2px 7px; border-radius: 99px; max-width: 120px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .ait-actions { margin-left: auto; display: flex; gap: 2px; } + .ait-btn { + all: unset; cursor: pointer; display: grid; place-items: center; + width: 26px; height: 26px; border-radius: 7px; color: #6b7280; + } + .ait-btn:hover { background: rgba(0,0,0,.07); color: #374151; } + .ait-btn.ait-active { background: rgba(79,70,229,.12); color: #4f46e5; } + .ait-btn.ait-active:hover { background: rgba(79,70,229,.18); color: #4338ca; } + + .ait-body { + padding: 14px 16px 16px; overflow: hidden; flex: 1; min-height: 80px; + display: flex; flex-direction: row; align-items: stretch; + } + + .ait-source { + flex: 1 1 50%; min-height: 0; min-width: 0; + font-size: 15px; color: #6b7280; line-height: 1.75; + padding-right: 16px; margin-right: 16px; + border-right: 1px solid rgba(0,0,0,.09); + overflow-y: auto; + white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; + } + .ait-source[hidden] { display: none; } + + .ait-result { + flex: 1 1 50%; min-height: 0; min-width: 0; + font-size: 16px; line-height: 1.8; color: #111827; + white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; + overflow-y: auto; + } + .ait-result.ait-loading { display: block; color: #6b7280; } + .ait-result.ait-error { color: #dc2626; font-size: 13.5px; line-height: 1.6; } + .ait-done { animation: ait-fadein .25s ease; } + + /* 左右分栏的小标签(原文 / 译文),滚动时吸顶 */ + .ait-source::before, .ait-result::before { + content: attr(data-label); + position: sticky; top: 0; z-index: 1; display: block; + background: #ffffff; + font-size: 11px; font-weight: 700; letter-spacing: .08em; + color: #9ca3af; padding-bottom: 6px; margin-bottom: 8px; + } + .ait-result::before { color: #4f46e5; } + + .ait-spinner { + display: inline-block; vertical-align: -2px; + width: 14px; height: 14px; margin-right: 8px; border-radius: 50%; + border: 2px solid rgba(79,70,229,.25); border-top-color: #4f46e5; + animation: ait-spin .7s linear infinite; + } + + .ait-resizer { + position: absolute; right: 2px; bottom: 2px; width: 14px; height: 14px; + cursor: nwse-resize; opacity: .35; + } + .ait-resizer::after { + content: ''; position: absolute; right: 3px; bottom: 3px; + width: 7px; height: 7px; + border-right: 2px solid #9ca3af; border-bottom: 2px solid #9ca3af; + border-bottom-right-radius: 2px; + } + + .ait-toast { + position: fixed; left: 50%; bottom: 40px; transform: translate(-50%, 8px); + background: #111827; color: #fff; font-size: 13px; + padding: 8px 16px; border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,.25); + opacity: 0; transition: all .25s ease; z-index: 2147483647; pointer-events: none; + } + .ait-toast.ait-show { opacity: 1; transform: translate(-50%, 0); } + .ait-toast-error { background: #dc2626; } + + @keyframes ait-spin { to { transform: rotate(360deg); } } + @keyframes ait-fadein { from { opacity: .4; } to { opacity: 1; } } + + @media (prefers-color-scheme: dark) { + .ait-panel { background: #1e1e24; color: #d1d5db; border-color: rgba(255,255,255,.1); box-shadow: 0 12px 40px rgba(0,0,0,.5); } + .ait-header { background: linear-gradient(135deg, rgba(79,70,229,.16), rgba(139,92,246,.16)); border-bottom-color: rgba(255,255,255,.08); } + .ait-model { background: rgba(255,255,255,.08); color: #9ca3af; } + .ait-btn { color: #9ca3af; } + .ait-btn:hover { background: rgba(255,255,255,.1); color: #e5e7eb; } + .ait-btn.ait-active { background: rgba(129,140,248,.2); color: #a5b4fc; } + .ait-btn.ait-active:hover { background: rgba(129,140,248,.28); color: #c7d2fe; } + .ait-source { color: #9ca3af; border-right-color: rgba(255,255,255,.09); } + .ait-result { color: #f3f4f6; } + .ait-source::before, .ait-result::before { background: #1e1e24; } + } + `; +})(); diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..70379bcf701fc3488a693c7285a3ea19296ee801 GIT binary patch literal 3555 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 IM6N<$f}G&@tpET3 literal 0 HcmV?d00001 diff --git a/icons/icon32.png b/icons/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..bf779ff33f7426f638ea840f17bcae85abf2baf0 GIT binary patch literal 1001 zcmVO_{nmPh6{ zc?O4$)(I6rQc#+CgLv}Mi&tg!8K_dYufBGfg2yWv9@5JWECfgirs+n&CD6)M9(J@< zF%hg(-CK)(gnz)og;3UI?28p=Mb+rmxpQ%I&fo+4H z%2m!Z(!Voe|M$x0myD0uj`5`w;3Tn#Mc2M408NioP~V_(GMJ!c^B9M_VgOtiF?j9y zFfE(M*#Geqrjcgw?SUxc|Co;P%Q^$0-;xZi(>e7=iqZ2X8J`~x#;K`OxaUDHZGmcj z4<>p1iG>IO!6VcB@?!$G_~N;BV`X)LRKO~7pi~uF1AcD2NzLj`{G8y+PomjUI6;*T z1Xrxoc(uJMPuKeF6(+(KLcqF57=-rnB?y>I)>p$pfrPG1FEK0 zWy5w4x89-Qt4{ZjZ9~tmNqT#9nm2m6p;2XZvzOJ)Ui6gBzCe_|P^tuh(&RWhVDR26 zlPvZLcC@G4ZZd4Ke1(dyT4Lxnz1=!TzKL_^{T><`6*~4txxQZE?j{en+^#XuZ*V%4 zqEsHo9nh0-@vKRuKj&^4aA<#=r8Sa2Po$WRr3<}gy%$XroH=FC->uW%t>dW>s49*J zQ7xkhm~AuAt5fyB0^aTLXLXvEHhS@`N-Qf)Dl%cS`-O=D|2zWawBbyf%m7iO$R?c$ zMFB-g>s8HmZ9~^l4CK|%vjCPXm4t#RLc!d@3LNyKYJyL9M;Pogiux9f?KXqE)WE~70274VQ zOR*#lv0V?L5U^&G7l1^}=0G6M@Lz?iY47x*YVgILI75Af1WV;{9K{)oo-Q6Pb?yHroJ$CRJ=-Ez zWf$K3y7C&h7i1pZ-yWUUU3k4~aqeoC!3R&p=7mIS=c2;P=}dh1bbL;T?D}e9LB4+h XzC<@xK~ac600000NkvXXu0mjfHk#v1 literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..dabfa2f54897d1447658f389964a2d20e5eaf558 GIT binary patch literal 1455 zcmV;g1yK5lP)(z&;SN8!4NE9jAFqkqEHlsAmRsNAUyowqu~`_JS_MmLi~V| zD2PDtfz?Q3OKT#~HbM=R2GT+iN+@sI(#P)3?(B{qw!39^w`FHqwd;R#lbL(Yx%0n! z&pG$p89^6#W?H@0mHgkrk3|`xGg(Bzv%Q3PDDoprB&=h0FC`zq4Hv$eLEG_1xx)T0Tey1J8?}ujF!swBE@SWOkDSa=GP9pg(#boiPNG8ALpG+( zN;Jer5(130!h8qkYCOS*&X||Tun}@l-W@dD^zr@fRzrNmqM6qXr_uCb7S?Y|MV9^d zXHI$8yrxN~GikDo<*QNvP*vEmrI}+#l%U*d6RcRB!nldnVBgk_x2ZhlHpE4egg=sv zw5ylA9Qw%-93yG=BZrh2}G#0L0Q+(XT+>j(+qX7@p(nYE?Gp zHtG7}5(JMt5yxXW@ua48U9i;GsT?g*s4Q1#yw&xJhQeSe(YdY=0((D+u~~h%f1;H! z#G$z7KAG1y**kTG z+S4BLcRBd0Mn#ff(i9sr7bJwOPTCL)%}pA&u6d&hYD1;OJ4+jb*U!C_$kd!Tz`ri% zt5qrvD@@F`>1;06sub*ZQdg@oeO`P}ez?HJhL@XoaH@?-Q*4YJ> zLhWu7=&8euKyYxMldQ=$oNXEvhZV|;73wee-C$sbh3u!}g8Egzd$?ZjqpV1wtVkg> zO=7~MHriT!G~LvqK4clnLOQ~C+O==%4cC2aeY2TM=e&5m{@h?`cXc@=_9aV)4B&Qy zkCJ>RK7$#l8!MonL13H<)n3LwY-LeyVwct{-cAndt3U7MgWP6AEDV>^0fa1o&R@-r zX~x!c77DfNscdF+oE|$;S}cO0qbyuJ>*dcf54mMcI{CnKiPhUug7@3kw{o^hB@{;a zm_F3s`Iv-6!SZ!U3>YG@X<0Mg2>VYcEvdbA7h>hyMpMOH&Z2lG&#>KH(FD$&Qu%CM zYgD?Xvrv3OPkDVf{0GzM#y9m=LI9Z&%HzVlF4V|HsbTzMZzTX8$hMM}(c!^fFC5tJ zqw-g7R>Vgp$GWb?P2*}^=MAJZVYV6E&aJEv7%1%$v_;VvQQ!7wc?W3aDL*)?< z7i)Y`{acZAdZLgtT}mAM!iAtshtZgR`5i;3( z3)_12B-ZRn)NzjJ{O|YDNvsYb4|my1K77&9D<-n?>&_1!e*=@_sNcueIWYhL002ov JPDHLkV1lkM(S!g1 literal 0 HcmV?d00001 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7cd5e92 --- /dev/null +++ b/manifest.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 3, + "name": "译泡 · PopLingo — AI 划词翻译", + "version": "1.0.0", + "description": "划词即译的大模型翻译气泡:DeepSeek / OpenAI / Kimi / 智谱 / 通义 / Gemini / Claude / Ollama,流式输出。", + "minimum_chrome_version": "116", + "permissions": [ + "storage", + "contextMenus", + "commands" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "http://*/*", + "https://*/*", + "file://*/*" + ], + "js": [ + "content/content.js" + ], + "css": [ + "content/content.css" + ], + "run_at": "document_idle", + "all_frames": true, + "match_about_blank": true + } + ], + "action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": true + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "commands": { + "translate-selection": { + "suggested_key": { + "default": "Alt+T" + }, + "description": "翻译当前选中的文本" + } + } +} \ No newline at end of file diff --git a/options/options.css b/options/options.css new file mode 100644 index 0000000..7b18341 --- /dev/null +++ b/options/options.css @@ -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; } +} diff --git a/options/options.html b/options/options.html new file mode 100644 index 0000000..9bc4006 --- /dev/null +++ b/options/options.html @@ -0,0 +1,133 @@ + + + + + + 译泡 PopLingo · 设置 + + + +
+ + +
+ +
+

模型服务

+

选择服务商后会自动填充接口地址与推荐模型;也可以选择「自定义」接入任意 OpenAI 兼容接口。

+ +
+ + + + + +
+ +
+ + +
+ +
+ 💡 国内推荐 DeepSeek(便宜好用)或 智谱 GLM-4-Flash(免费);本地部署选 Ollama(无需 Key)。Key 仅保存在你的浏览器账号同步存储中。 +
+
+ + +
+

翻译行为

+
+ + + + +
+
+ + +
+

自定义 Prompt(可选)

+

留空使用内置专业翻译 Prompt。可用变量:{targetLang} 目标语言、{sourceLang} 源语言。

+ +
+ +
+
+ + +
+

使用说明

+
    +
  • 划词 / 按 Alt+T / 右键菜单「AI 翻译」触发翻译;Esc 关闭弹窗。
  • +
  • 弹窗支持拖拽移动、右下角调整大小、一键复制与重新翻译。
  • +
  • 快捷键可在 chrome://extensions/shortcuts 中修改。
  • +
  • API 请求由扩展后台发起,不经过任何第三方服务器。
  • +
+
+ +
+ 设置修改后自动保存 + +
+
+
+ + + + diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..2538df5 --- /dev/null +++ b/options/options.js @@ -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(); diff --git a/popup/popup.css b/popup/popup.css new file mode 100644 index 0000000..c87e980 --- /dev/null +++ b/popup/popup.css @@ -0,0 +1,62 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + width: 300px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + background: #f8fafc; + color: #111827; +} + +.head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px 12px; + background: linear-gradient(135deg, #4f46e5, #8b5cf6); + color: #fff; +} +.brand { display: flex; align-items: center; gap: 10px; } +.brand img { border-radius: 6px; } +.brand h1 { font-size: 14px; font-weight: 700; } +.status { font-size: 11px; opacity: .85; } + +.switch { position: relative; width: 40px; height: 22px; flex: none; } +.switch input { opacity: 0; width: 0; height: 0; } +.slider { + position: absolute; inset: 0; background: rgba(255,255,255,.35); + border-radius: 99px; cursor: pointer; transition: background .2s; +} +.slider::before { + content: ''; position: absolute; left: 3px; top: 3px; + width: 16px; height: 16px; border-radius: 50%; background: #fff; + transition: transform .2s; +} +.switch input:checked + .slider { background: #22c55e; } +.switch input:checked + .slider::before { transform: translateX(18px); } + +.provider { margin: 10px 12px 0; padding: 4px 12px; background: #fff; border-radius: 10px; border: 1px solid #e5e7eb; } +.row { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 0; font-size: 12.5px; +} +.row + .row { border-top: 1px solid #f3f4f6; } +.label { color: #6b7280; } +.value { font-weight: 600; color: #111827; max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; font-weight: 500; color: #4f46e5; } +.warn { color: #f59e0b; } +.ok { color: #10b981; } + +.form { display: grid; gap: 8px; padding: 10px 12px 4px; } +.field { display: grid; gap: 4px; font-size: 12px; color: #6b7280; } +.field select { + padding: 7px 10px; border-radius: 8px; border: 1px solid #e5e7eb; + background: #fff; font-size: 13px; color: #111827; outline: none; cursor: pointer; +} +.field select:focus { border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.15); } + +.foot { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px 14px; } +.btn { + border: none; cursor: pointer; font-size: 12.5px; font-weight: 600; + padding: 8px 14px; border-radius: 8px; background: #eef2ff; color: #4f46e5; + transition: all .15s; +} +.btn:hover { background: #e0e7ff; transform: translateY(-1px); } +.hint { font-size: 10.5px; color: #9ca3af; } diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 0000000..f5a3594 --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,59 @@ + + + + + + + +
+
+ +
+

译泡 PopLingo

+ 已开启 +
+
+ +
+ +
+
+ 服务商 + DeepSeek +
+
+ 模型 + deepseek-chat +
+
+ API Key + 未配置 +
+
+ +
+ + +
+ +
+ + 选中文字试试 · Esc 关闭弹窗 +
+ + + + diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 0000000..49ca040 --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,48 @@ +/* global chrome */ + +const LANGS = ['简体中文', '繁體中文', 'English', '日本語', '한국어', 'Français', 'Deutsch', 'Español', 'Português', 'Italiano', 'Русский', 'العربية', 'हिन्दी', 'ไทย', 'Việt Nam', 'Indonesia']; + +const PRESETS = { + deepseek: 'DeepSeek', openai: 'OpenAI', kimi: '月之暗面 Kimi', zhipu: '智谱 GLM', + qwen: '通义千问', siliconflow: '硅基流动', gemini: 'Google Gemini', + anthropic: 'Anthropic Claude', ollama: 'Ollama(本地)', custom: '自定义(OpenAI 兼容)' +}; + +const $ = id => document.getElementById(id); + +async function init() { + const cfg = await chrome.storage.sync.get({ + enabled: true, preset: 'deepseek', model: '', apiKey: '', + targetLang: '简体中文', triggerMode: 'icon' + }); + + const langSel = $('targetLang'); + LANGS.forEach(l => { + const o = document.createElement('option'); + o.value = l; o.textContent = l; + langSel.appendChild(o); + }); + langSel.value = cfg.targetLang; + $('triggerMode').value = cfg.triggerMode; + + $('enabledToggle').checked = cfg.enabled; + updateStatus(cfg.enabled); + $('providerName').textContent = PRESETS[cfg.preset] || cfg.preset; + $('modelName').textContent = cfg.model || '(未设置)'; + $('keyStatus').textContent = cfg.apiKey ? '已配置' : '未配置'; + $('keyStatus').className = 'value ' + (cfg.apiKey ? 'ok' : 'warn'); + + $('enabledToggle').addEventListener('change', e => { + chrome.storage.sync.set({ enabled: e.target.checked }); + updateStatus(e.target.checked); + }); + langSel.addEventListener('change', () => chrome.storage.sync.set({ targetLang: langSel.value })); + $('triggerMode').addEventListener('change', () => chrome.storage.sync.set({ triggerMode: $('triggerMode').value })); + $('openOptions').addEventListener('click', () => chrome.runtime.openOptionsPage()); +} + +function updateStatus(on) { + $('statusText').textContent = on ? 'AI 划词翻译 · 已开启' : 'AI 划词翻译 · 已停用'; +} + +init();