231 lines
6.2 KiB
JavaScript
231 lines
6.2 KiB
JavaScript
const SYSTEM_PROMPT = "你是翻译引擎,只输出译文,不解释不分析不扩展";
|
||
|
||
function setupContextMenu() {
|
||
chrome.contextMenus.removeAll(() => {
|
||
chrome.contextMenus.create({
|
||
id: "transly-translate-selection",
|
||
title: "Transly: 翻译选中文本",
|
||
contexts: ["selection"]
|
||
});
|
||
});
|
||
}
|
||
|
||
chrome.runtime.onInstalled.addListener((details) => {
|
||
setupContextMenu();
|
||
if (details.reason === "install") {
|
||
chrome.runtime.openOptionsPage();
|
||
}
|
||
});
|
||
chrome.runtime.onStartup.addListener(setupContextMenu);
|
||
|
||
async function getConfig() {
|
||
const syncData = await chrome.storage.sync.get({
|
||
baseUrl: "https://api.openai.com",
|
||
model: "gpt-4o-mini",
|
||
targetLang: "zh-CN"
|
||
});
|
||
const localData = await chrome.storage.local.get({ apiKey: "" });
|
||
|
||
return normalizeConfig({
|
||
...syncData,
|
||
...localData
|
||
});
|
||
}
|
||
|
||
function normalizeConfig(config) {
|
||
return {
|
||
baseUrl: String(config?.baseUrl || "").trim().replace(/\/+$/, ""),
|
||
apiKey: String(config?.apiKey || "").trim(),
|
||
model: String(config?.model || "").trim(),
|
||
targetLang: String(config?.targetLang || "zh-CN").trim() || "zh-CN"
|
||
};
|
||
}
|
||
|
||
function parseContent(content) {
|
||
if (Array.isArray(content)) {
|
||
return content.map((item) => item?.text || "").join("").trim();
|
||
}
|
||
return String(content || "").trim();
|
||
}
|
||
|
||
async function requestTranslate(text, useReasoningEffort, configOverride) {
|
||
const cfg = configOverride ? normalizeConfig(configOverride) : await getConfig();
|
||
const { baseUrl, apiKey, model, targetLang } = cfg;
|
||
if (!baseUrl || !apiKey || !model) {
|
||
throw new Error("请先在 options 填写 Base URL / API Key / Model");
|
||
}
|
||
|
||
const body = {
|
||
model,
|
||
temperature: 0,
|
||
messages: [
|
||
{ role: "system", content: SYSTEM_PROMPT },
|
||
{ role: "user", content: `请翻译为 ${targetLang},只输出译文:\n${text}` }
|
||
]
|
||
};
|
||
|
||
if (useReasoningEffort) {
|
||
body.reasoning_effort = "low";
|
||
}
|
||
|
||
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${apiKey}`
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errText = (await res.text()).slice(0, 200);
|
||
throw new Error(`请求失败(${res.status}) ${errText}`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
const translated = parseContent(data?.choices?.[0]?.message?.content);
|
||
if (!translated) {
|
||
throw new Error("未收到有效译文");
|
||
}
|
||
|
||
return translated;
|
||
}
|
||
|
||
async function translate(text, configOverride) {
|
||
if (!text || !text.trim()) {
|
||
throw new Error("没有可翻译文本");
|
||
}
|
||
|
||
try {
|
||
return await requestTranslate(text.trim(), true, configOverride);
|
||
} catch (err) {
|
||
const message = String(err?.message || "");
|
||
if (message.includes("400") || message.includes("reasoning")) {
|
||
return requestTranslate(text.trim(), false, configOverride);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function testConfig(configInput) {
|
||
const cfg = normalizeConfig(configInput);
|
||
if (!cfg.baseUrl || !cfg.apiKey || !cfg.model) {
|
||
throw new Error("请先填写 Base URL / API Key / Model");
|
||
}
|
||
|
||
await translate("hello world", cfg);
|
||
return "连接成功,API Key 与模型可用";
|
||
}
|
||
|
||
async function getSelectedTextFromTab(tabId) {
|
||
const results = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: () => {
|
||
const selected = String(window.getSelection() || "").trim();
|
||
if (selected) {
|
||
return selected;
|
||
}
|
||
|
||
const el = document.activeElement;
|
||
if (!el) {
|
||
return "";
|
||
}
|
||
|
||
const isInput = el.tagName === "INPUT" || el.tagName === "TEXTAREA";
|
||
if (!isInput || typeof el.value !== "string") {
|
||
return "";
|
||
}
|
||
|
||
const start = el.selectionStart ?? 0;
|
||
const end = el.selectionEnd ?? 0;
|
||
return el.value.slice(Math.min(start, end), Math.max(start, end)).trim();
|
||
}
|
||
});
|
||
|
||
return String(results?.[0]?.result || "").trim();
|
||
}
|
||
|
||
async function showTextInTab(tabId, text, isError) {
|
||
const message = String(text || "").trim();
|
||
if (!message) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: (msg, error) => {
|
||
alert(error ? `Transly: ${msg}` : msg);
|
||
},
|
||
args: [message, !!isError]
|
||
});
|
||
return true;
|
||
} catch (_err) {
|
||
// 某些页面禁止注入,降级尝试 content script 消息。
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
chrome.tabs.sendMessage(
|
||
tabId,
|
||
isError ? { action: "showError", error: message } : { action: "showTranslation", text: message },
|
||
(response) => {
|
||
if (chrome.runtime.lastError || !response?.ok) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
resolve(true);
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
async function translateSelectionInTab(tabId) {
|
||
const selectedText = await getSelectedTextFromTab(tabId);
|
||
if (!selectedText) {
|
||
throw new Error("请先选中文本");
|
||
}
|
||
|
||
const text = await translate(selectedText);
|
||
const shown = await showTextInTab(tabId, text, false);
|
||
return { text, shown };
|
||
}
|
||
|
||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||
if (message?.action === "translate") {
|
||
translate(message.text)
|
||
.then((text) => sendResponse({ ok: true, text }))
|
||
.catch((err) => sendResponse({ ok: false, error: err.message || "翻译失败" }));
|
||
|
||
return true;
|
||
}
|
||
|
||
if (message?.action === "translateSelectionInTab") {
|
||
translateSelectionInTab(message.tabId)
|
||
.then((result) => sendResponse({ ok: true, text: result.text, shown: result.shown }))
|
||
.catch((err) => sendResponse({ ok: false, error: err.message || "翻译失败" }));
|
||
|
||
return true;
|
||
}
|
||
|
||
if (message?.action === "testConfig") {
|
||
testConfig(message.config)
|
||
.then((text) => sendResponse({ ok: true, text }))
|
||
.catch((err) => sendResponse({ ok: false, error: err.message || "测试失败" }));
|
||
|
||
return true;
|
||
}
|
||
});
|
||
|
||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||
if (info.menuItemId !== "transly-translate-selection" || !info.selectionText || !tab?.id) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const text = await translate(info.selectionText);
|
||
await showTextInTab(tab.id, text, false);
|
||
} catch (err) {
|
||
await showTextInTab(tab.id, err.message || "翻译失败", true);
|
||
}
|
||
});
|