Initial commit: 译泡 PopLingo v1.0.0
AI 划词翻译 Chrome 扩展(Manifest V3): - 支持 DeepSeek/OpenAI/Kimi/智谱/通义/Gemini/Claude/Ollama 等 10 种服务商 - 流式输出、左右分栏对照、原文一键展开收起 - 划词图标/自动翻译/Alt+T 快捷键/右键菜单四种触发方式
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# 构建产物
|
||||
*.zip
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# 依赖
|
||||
node_modules/
|
||||
@@ -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/模型名是否对、额度是否充足,设置页有「测试连接」可一键诊断
|
||||
+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;
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
/* 样式全部注入在 closed Shadow DOM 中(见 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 = `
|
||||
<button class="ait-fab" title="译泡翻译(Alt+T)" aria-label="译泡翻译">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none">
|
||||
<path d="M4 5h10M9 3v2c0 4.5-2.2 8.4-5 10.5M6.5 9.5c1.5 3 3.8 5.2 6.5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 20.5l4-9 4 9M14.4 17.3h5.2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>译</span>
|
||||
</button>
|
||||
|
||||
<div class="ait-panel" hidden>
|
||||
<div class="ait-header">
|
||||
<div class="ait-lang">
|
||||
<span class="ait-src">自动检测</span>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span class="ait-dst">简体中文</span>
|
||||
</div>
|
||||
<span class="ait-model"></span>
|
||||
<div class="ait-actions">
|
||||
<button class="ait-btn ait-src-toggle ait-active" title="收起原文">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none">
|
||||
<path d="M4 6h16M4 10h16M4 14h9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="ait-btn ait-copy" title="复制译文">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<button class="ait-btn ait-retry" title="重新翻译">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="ait-btn ait-close" title="关闭 (Esc)">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ait-body">
|
||||
<div class="ait-source" data-label="原文"></div>
|
||||
<div class="ait-result" data-label="译文">
|
||||
<span class="ait-spinner"></span>
|
||||
<span class="ait-loading-text">正在翻译…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ait-resizer"></div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<span class="ait-spinner"></span><span class="ait-loading-text">正在翻译…</span>';
|
||||
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; }
|
||||
}
|
||||
`;
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 518 B |
Binary file not shown.
|
After Width: | Height: | Size: 1001 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"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": "翻译当前选中的文本"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="head">
|
||||
<div class="brand">
|
||||
<img src="../icons/icon32.png" alt="" width="22" height="22" />
|
||||
<div>
|
||||
<h1>译泡 PopLingo</h1>
|
||||
<span id="statusText" class="status">已开启</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="enabledToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<section class="provider">
|
||||
<div class="row">
|
||||
<span class="label">服务商</span>
|
||||
<span id="providerName" class="value">DeepSeek</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">模型</span>
|
||||
<span id="modelName" class="value mono">deepseek-chat</span>
|
||||
</div>
|
||||
<div class="row" id="keyRow">
|
||||
<span class="label">API Key</span>
|
||||
<span id="keyStatus" class="value warn">未配置</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form">
|
||||
<label class="field">
|
||||
<span>目标语言</span>
|
||||
<select id="targetLang"></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>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<button id="openOptions" class="btn primary">⚙ 模型设置</button>
|
||||
<span class="hint">选中文字试试 · Esc 关闭弹窗</span>
|
||||
</footer>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user