将 X 上的 Grok AI 翻译替换为谷歌翻译或 Gemini API。添加一键翻译悬浮按钮,支持滚动自动翻译与多模型选择。
// ==UserScript==
// @name X translation
// @name:zh-CN X 一键翻译
// @namespace https://x.com
// @author Rove24
// @version 3.7
// @description 将 X 上的 Grok AI 翻译替换为谷歌翻译或 Gemini API。添加一键翻译悬浮按钮,支持滚动自动翻译与多模型选择。
// @match https://x.com/*
// @match https://twitter.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=x.com
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect translate.googleapis.com
// @connect generativelanguage.googleapis.com
// @connect *
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const browserLang = navigator.language.split("-")[0];
let isAllTranslated = GM_getValue("isAllTranslated", false);
let lastUrl = location.href;
const DEFAULT_GEMINI_MODEL = "gemini-flash-lite-latest";
const GEMINI_MODELS = [
{ value: "gemini-flash-lite-latest", label: "Gemini Flash Lite Latest (默认)" },
{ value: "gemini-flash-latest", label: "Gemini Flash Latest" },
{ value: "gemini-3.5-flash-lite", label: "Gemini 3.5 Flash Lite" },
{ value: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite" },
{ value: "gemini-3.7-flash", label: "Gemini 3.7 Flash" },
{ value: "gemini-3.6-flash", label: "Gemini 3.6 Flash" },
{ value: "gemini-3.5-flash", label: "Gemini 3.5 Flash" },
{ value: "gemini-3-flash", label: "Gemini 3 Flash" },
{ value: "custom", label: "自定义模型 (Custom Model)..." },
];
// ✅ 修复1:使用正确的模型名格式
const DEFAULT_AGNES_MODEL = "agnes-2.0-flash";
const AGNES_MODELS = [
{ value: "agnes-2.0-flash", label: "Agnes 2.0 Flash" },
{ value: "agnes-2.5-flash", label: "Agnes 2.5 Flash (推荐)" },
{ value: "agnes-2.5-pro", label: "Agnes 2.5 Pro" },
{ value: "agnes-2.5-pro-alpha", label: "Agnes 2.5 Pro Alpha" },
{ value: "custom", label: "自定义模型 (Custom Model)..." },
];
const DEFAULT_GEMINI_PROMPT =
"请将以下内容完整翻译为 {targetLang}(简体中文)。\n" +
"要求:\n" +
"1. 忠实原文含义,保持自然的口语表达,网络梗与俚语需意译为贴切的对应语言;\n" +
"2. 严格保留原有的换行、编号列表(如 1位、2位、排名)、标点符号和表情符号;\n" +
"3. 保持类似 {{0}}、@用户名、#话题 占位标签原样不动;\n" +
"4. 仅输出翻译后的结果,绝不要输出任何解释、前后缀或多余文字。\n\n" +
"待翻译内容:\n{text}";
function getTargetLang() {
return GM_getValue("targetLang", browserLang);
}
const LANGUAGES = [
["af","南非语 (Afrikaans)"],["sq","阿尔巴尼亚语 (Albanian)"],["am","阿姆哈拉语 (Amharic)"],["ar","阿拉伯语 (Arabic)"],
["hy","亚美尼亚语 (Armenian)"],["az","阿塞拜疆语 (Azerbaijani)"],["eu","巴斯克语 (Basque)"],["be","白俄罗斯语 (Belarusian)"],
["bn","孟加拉语 (Bengali)"],["bs","波斯尼亚语 (Bosnian)"],["bg","保加利亚语 (Bulgarian)"],["my","缅甸语 (Burmese)"],
["ca","加泰罗尼亚语 (Catalan)"],["zh","中文 (Chinese)"],["hr","克罗地亚语 (Croatian)"],["cs","捷克语 (Czech)"],
["da","丹麦语 (Danish)"],["nl","荷兰语 (Dutch)"],["en","英语 (English)"],["et","爱沙尼亚语 (Estonian)"],
["fil","菲律宾语 (Filipino)"],["fi","芬兰语 (Finnish)"],["fr","法语 (French)"],["fy","弗里斯兰语 (Frisian)"],
["gl","加利西亚语 (Galician)"],["ka","格鲁吉亚语 (Georgian)"],["de","德语 (German)"],["el","希腊语 (Greek)"],
["gn","瓜拉尼语 (Guarani)"],["gu","古吉拉特语 (Gujarati)"],["he","希伯来语 (Hebrew)"],["hi","印地语 (Hindi)"],
["hu","匈牙利语 (Hungarian)"],["is","冰岛语 (Icelandic)"],["id","印尼语 (Indonesian)"],["ga","爱尔兰语 (Irish)"],
["it","意大利语 (Italian)"],["ja","日语 (Japanese)"],["kn","卡纳达语 (Kannada)"],["kk","哈萨克语 (Kazakh)"],
["km","高棉语 (Khmer)"],["ko","韩语 (Korean)"],["ky","吉尔吉斯语 (Kyrgyz)"],["lo","老挝语 (Lao)"],
["lv","拉脱维亚语 (Latvian)"],["ln","林加拉语 (Lingala)"],["lt","立陶宛语 (Lithuanian)"],["mk","马其顿语 (Macedonian)"],
["ms","马来语 (Malay)"],["ml","马拉雅拉姆语 (Malayalam)"],["mt","马耳他语 (Maltese)"],["mr","马拉地语 (Marathi)"],
["mn","蒙古语 (Mongolian)"],["ne","尼泊尔语 (Nepali)"],["no","挪威语 (Norwegian)"],["nb","挪威博克马尔语 (Norwegian Bokmål)"],
["fa","波斯语 (Persian)"],["pl","波兰语 (Polish)"],["pt","葡萄牙语 (Portuguese)"],["pa","旁遮普语 (Punjabi)"],
["ro","罗马尼亚语 (Romanian)"],["ru","俄语 (Russian)"],["sr","塞尔维亚语 (Serbian)"],["sk","斯洛伐克语 (Slovak)"],
["sl","斯洛文尼亚语 (Slovenian)"],["es","西班牙语 (Spanish)"],["sw","斯瓦希里语 (Swahili)"],["sv","瑞典语 (Swedish)"],
["ta","泰米尔语 (Tamil)"],["te","泰卢固语 (Telugu)"],["th","泰语 (Thai)"],["tr","土耳其语 (Turkish)"],
["uk","乌克兰语 (Ukrainian)"],["ur","乌尔都语 (Urdu)"],["uz","乌兹别克语 (Uzbek)"],["vi","越南语 (Vietnamese)"],
["cy","威尔士语 (Welsh)"],["zu","祖鲁语 (Zulu)"],
];
// ✅ 新增:解析源语言代码的辅助函数
const LANG_CODE_REGEX = /^([a-z]{2})(-[A-Z]{2})?$/;
function parseSourceLang(fullText) {
const lines = fullText.split("\n").map(l => l.trim()).filter(Boolean);
if (lines.length >= 2 && LANG_CODE_REGEX.test(lines[0])) {
return { sourceLang: lines[0].toLowerCase(), translatedText: lines.slice(1).join("\n").trim() };
}
return { sourceLang: "", translatedText: fullText.trim() };
}
function getThemeColors() {
const bg = window.getComputedStyle(document.body).backgroundColor;
const isDark = bg === "rgb(0, 0, 0)" || bg === "rgb(21, 32, 43)" || bg.includes("21, 32") || bg.includes("30, 41, 59");
if (bg === "rgb(0, 0, 0)") {
return {
bg: "rgb(0, 0, 0)",
border: "rgb(47, 51, 54)",
icon: "#e7e9ea",
secondaryText: "#71767b",
hoverBg: "rgba(231, 233, 234, 0.1)",
shadow: "0 0 10px rgba(255, 255, 255, 0.1)",
activeBg: "rgb(29, 155, 240)",
activeIcon: "#ffffff",
activeHoverBg: "rgb(26, 140, 216)"
};
} else if (isDark) {
return {
bg: "rgb(21, 32, 43)",
border: "rgb(56, 68, 77)",
icon: "#8899a6",
secondaryText: "#8899a6",
hoverBg: "rgba(29, 155, 240, 0.1)",
shadow: "0 0 12px rgba(0, 0, 0, 0.5)",
activeBg: "rgb(29, 155, 240)",
activeIcon: "#ffffff",
activeHoverBg: "rgb(26, 140, 216)"
};
} else {
return {
bg: "#ffffff",
border: "rgb(207, 217, 222)",
icon: "#0f1419",
secondaryText: "#536471",
hoverBg: "rgba(15, 20, 25, 0.1)",
shadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
activeBg: "rgb(29, 155, 240)",
activeIcon: "#ffffff",
activeHoverBg: "rgb(26, 140, 216)"
};
}
}
function getLanguageName(code) {
if (!code) return "";
const baseCode = code.split("-")[0].toLowerCase();
const lang = LANGUAGES.find(l => l[0] === baseCode || l[0] === code.toLowerCase());
if (lang) {
return lang[1].split(" (")[0];
}
if (baseCode === "zh") return "中文";
if (baseCode === "iw") return "希伯来语";
if (baseCode === "jw") return "爪哇语";
return code;
}
function showLanguagePicker() {
document.getElementById("gt-lang-picker")?.remove();
const current = getTargetLang();
const overlay = document.createElement("div");
overlay.id = "gt-lang-picker";
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:999999;" +
"display:flex;align-items:center;justify-content:center;";
const dialog = document.createElement("div");
dialog.style.cssText =
"background:#fff;border-radius:16px;padding:24px;width:340px;max-height:80vh;" +
"display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,sans-serif;" +
"color:#0f1419;box-shadow:0 8px 28px rgba(0,0,0,0.25);";
const title = document.createElement("div");
title.textContent = "选择翻译目标语言";
title.style.cssText = "font-size:18px;font-weight:700;margin-bottom:16px;";
const search = document.createElement("input");
search.type = "text";
search.placeholder = "搜索语言...";
search.style.cssText =
"width:100%;padding:10px 12px;border:1px solid #cfd9de;border-radius:8px;" +
"font-size:14px;outline:none;box-sizing:border-box;margin-bottom:12px;";
const list = document.createElement("div");
list.style.cssText = "overflow-y:auto;flex:1;max-height:50vh;";
function renderList(filter = "") {
list.innerHTML = "";
const lf = filter.toLowerCase();
for (const [code, name] of LANGUAGES) {
if (lf && !name.toLowerCase().includes(lf) && !code.includes(lf)) continue;
const item = document.createElement("div");
item.textContent = `${name} (${code})`;
item.style.cssText =
"padding:10px 12px;border-radius:8px;cursor:pointer;font-size:14px;" +
(code === current ? "background:#e8f5fd;font-weight:600;color:#1d9bf0;" : "");
item.addEventListener("mouseenter", () => {
if (code !== current) item.style.background = "#f7f9f9";
});
item.addEventListener("mouseleave", () => {
if (code !== current) item.style.background = "";
});
item.addEventListener("click", () => {
GM_setValue("targetLang", code);
overlay.remove();
location.reload();
});
list.appendChild(item);
}
}
search.addEventListener("input", () => renderList(search.value));
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
dialog.append(title, search, list);
const resetBtn = document.createElement("button");
resetBtn.textContent = `重置为浏览器默认语言 (${browserLang})`;
resetBtn.style.cssText =
"margin-top:12px;padding:8px;border:1px solid #cfd9de;border-radius:8px;" +
"background:none;cursor:pointer;font-size:13px;color:#536471;width:100%;";
resetBtn.addEventListener("click", () => {
GM_setValue("targetLang", browserLang);
overlay.remove();
location.reload();
});
dialog.appendChild(resetBtn);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
search.focus();
renderList();
}
function showSettingsModal() {
document.getElementById("gt-settings-modal")?.remove();
const colors = getThemeColors();
const currentSource = GM_getValue("translateSource", "google");
const currentApiKey = GM_getValue("geminiApiKey", "");
const currentAgnesApiKey = GM_getValue("agnesApiKey", "");
const currentModel = GM_getValue("geminiModel", DEFAULT_GEMINI_MODEL);
const currentAgnesModel = GM_getValue("agnesModel", DEFAULT_AGNES_MODEL);
const currentPrompt = GM_getValue("geminiPrompt", DEFAULT_GEMINI_PROMPT);
const enableGrok = GM_getValue("enableGrok", false);
const overlay = document.createElement("div");
overlay.id = "gt-settings-modal";
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:999999;" +
"display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px);";
const dialog = document.createElement("div");
dialog.style.cssText =
`background:${colors.bg};border:1px solid ${colors.border};border-radius:16px;padding:24px;width:400px;max-width:90vw;` +
`display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,sans-serif;color:${colors.icon};` +
`box-shadow:0 8px 30px rgba(0,0,0,0.35);box-sizing:border-box;`;
// Gemini 模型选项
const isKnownGeminiModel = GEMINI_MODELS.some(m => m.value === currentModel && m.value !== "custom");
const selectedGeminiValue = isKnownGeminiModel ? currentModel : (currentModel ? "custom" : DEFAULT_GEMINI_MODEL);
const geminiOptionsHtml = GEMINI_MODELS.map(m =>
`<option value="${m.value}" ${m.value === selectedGeminiValue ? "selected" : ""} style="background:${colors.bg};color:${colors.icon};padding:8px;">${m.label}</option>`
).join("");
// Agnes 模型选项
const isKnownAgnesModel = AGNES_MODELS.some(m => m.value === currentAgnesModel && m.value !== "custom");
const selectedAgnesValue = isKnownAgnesModel ? currentAgnesModel : (currentAgnesModel ? "custom" : DEFAULT_AGNES_MODEL);
const agnesOptionsHtml = AGNES_MODELS.map(m =>
`<option value="${m.value}" ${m.value === selectedAgnesValue ? "selected" : ""} style="background:${colors.bg};color:${colors.icon};padding:8px;">${m.label}</option>`
).join("");
dialog.innerHTML = `
<div style="font-size:18px;font-weight:700;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:10px;">
<span>翻译设置</span>
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;user-select:none;" title="启用/关闭 X 自带的 Grok 翻译机制">
<span style="font-size:12px;font-weight:600;color:${colors.secondaryText};">Grok翻译</span>
<input id="gt-toggle-grok" type="checkbox" ${enableGrok ? "checked" : ""} style="display:none;">
<span id="gt-switch-slider" style="width:34px;height:18px;background:${enableGrok ? "#1d9bf0" : colors.border};border-radius:10px;position:relative;display:inline-block;transition:background 0.2s;">
<span id="gt-switch-knob" style="position:absolute;top:2px;left:${enableGrok ? "18px" : "2px"};width:14px;height:14px;border-radius:50%;background:#fff;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.3);"></span>
</span>
</label>
</div>
<span id="gt-modal-close" style="cursor:pointer;font-size:18px;color:${colors.secondaryText};padding:4px;">✕</span>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:13px;font-weight:600;color:${colors.secondaryText};display:block;margin-bottom:8px;">翻译引擎 / 翻译源</label>
<div style="display:flex;gap:14px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
<input type="radio" name="gt-source" value="google" ${currentSource === "google" ? "checked" : ""}> Google 翻译
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
<input type="radio" name="gt-source" value="gemini" ${currentSource === "gemini" ? "checked" : ""}> Gemini API
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
<input type="radio" name="gt-source" value="agnes" ${currentSource === "agnes" ? "checked" : ""}> Agnes API
</label>
</div>
</div>
<!-- Gemini 配置区域 -->
<div id="gt-gemini-config" style="display:${currentSource === "gemini" ? "flex" : "none"};flex-direction:column;gap:14px;margin-bottom:16px;">
<div>
<label style="font-size:13px;font-weight:600;color:${colors.secondaryText};display:block;margin-bottom:6px;">1. Gemini API Key</label>
<input id="gt-input-api" type="password" placeholder="AIzaSy..." value="${escapeHtml(currentApiKey)}"
style="width:100%;padding:9px 12px;border:1px solid ${colors.border};border-radius:8px;background:transparent;color:inherit;font-size:13px;outline:none;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;font-weight:600;color:${colors.secondaryText};display:block;margin-bottom:6px;">Gemini 模型版本</label>
<div style="position:relative;width:100%;">
<select id="gt-select-model"
style="width:100%;padding:10px 32px 10px 12px;border:1px solid ${colors.border};border-radius:8px;background:${colors.bg};color:${colors.icon};font-size:13px;outline:none;box-sizing:border-box;cursor:pointer;appearance:none;-webkit-appearance:none;">
${geminiOptionsHtml}
</select>
<div style="position:absolute;right:12px;top:50%;transform:translateY(-50%);pointer-events:none;color:${colors.secondaryText};display:flex;align-items:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</div>
</div>
<input id="gt-input-custom-model" type="text" placeholder="输入自定义模型名称,例如 gemini-2.0-flash" value="${!isKnownGeminiModel ? escapeHtml(currentModel) : ""}"
style="width:100%;padding:8px 12px;border:1px solid ${colors.border};border-radius:8px;background:transparent;color:inherit;font-size:13px;outline:none;box-sizing:border-box;margin-top:8px;display:${selectedGeminiValue === "custom" ? "block" : "none"};">
</div>
</div>
<!-- Agnes 配置区域 -->
<div id="gt-agnes-config" style="display:${currentSource === "agnes" ? "flex" : "none"};flex-direction:column;gap:14px;margin-bottom:16px;">
<div>
<label style="font-size:13px;font-weight:600;color:${colors.secondaryText};display:block;margin-bottom:6px;">1. Agnes API Key</label>
<input id="gt-input-agnes-api" type="password" placeholder="填写 Agnes API Key..." value="${escapeHtml(currentAgnesApiKey)}"
style="width:100%;padding:9px 12px;border:1px solid ${colors.border};border-radius:8px;background:transparent;color:inherit;font-size:13px;outline:none;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;font-weight:600;color:${colors.secondaryText};display:block;margin-bottom:6px;">Agnes 模型版本</label>
<div style="position:relative;width:100%;">
<select id="gt-select-agnes-model"
style="width:100%;padding:10px 32px 10px 12px;border:1px solid ${colors.border};border-radius:8px;background:${colors.bg};color:${colors.icon};font-size:13px;outline:none;box-sizing:border-box;cursor:pointer;appearance:none;-webkit-appearance:none;">
${agnesOptionsHtml}
</select>
<div style="position:absolute;right:12px;top:50%;transform:translateY(-50%);pointer-events:none;color:${colors.secondaryText};display:flex;align-items:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</div>
</div>
<input id="gt-input-custom-agnes-model" type="text" placeholder="输入自定义模型名称" value="${!isKnownAgnesModel ? escapeHtml(currentAgnesModel) : ""}"
style="width:100%;padding:8px 12px;border:1px solid ${colors.border};border-radius:8px;background:transparent;color:inherit;font-size:13px;outline:none;box-sizing:border-box;margin-top:8px;display:${selectedAgnesValue === "custom" ? "block" : "none"};">
</div>
</div>
<!-- 公共提示词配置 -->
<div id="gt-prompt-config" style="display:${currentSource !== "google" ? "block" : "none"};margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<label style="font-size:13px;font-weight:600;color:${colors.secondaryText};">提示词 (Prompt)</label>
<span id="gt-btn-reset-prompt" style="font-size:12px;color:#1d9bf0;cursor:pointer;">重置默认</span>
</div>
<textarea id="gt-input-prompt" rows="4"
style="width:100%;padding:8px 12px;border:1px solid ${colors.border};border-radius:8px;background:transparent;color:inherit;font-size:12px;outline:none;resize:vertical;box-sizing:border-box;">${escapeHtml(currentPrompt)}</textarea>
<div style="font-size:11px;color:${colors.secondaryText};margin-top:4px;">支持占位符:<code>{targetLang}</code> 目标语言,<code>{text}</code> 待翻文本</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<span id="gt-btn-open-lang" style="font-size:13px;color:#1d9bf0;cursor:pointer;">目标语言 (${getTargetLang()})</span>
<div style="display:flex;gap:8px;">
<button id="gt-btn-cancel" style="padding:7px 14px;border:1px solid ${colors.border};border-radius:18px;background:transparent;color:${colors.icon};cursor:pointer;font-size:13px;font-weight:600;">取消</button>
<button id="gt-btn-save" style="padding:7px 18px;border:none;border-radius:18px;background:#1d9bf0;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">保存</button>
</div>
</div>
`;
const grokCheckbox = dialog.querySelector("#gt-toggle-grok");
const grokSlider = dialog.querySelector("#gt-switch-slider");
const grokKnob = dialog.querySelector("#gt-switch-knob");
grokCheckbox.addEventListener("change", () => {
if (grokCheckbox.checked) {
grokSlider.style.background = "#1d9bf0";
grokKnob.style.left = "18px";
} else {
grokSlider.style.background = colors.border;
grokKnob.style.left = "2px";
}
});
const radios = dialog.querySelectorAll('input[name="gt-source"]');
const geminiBox = dialog.querySelector("#gt-gemini-config");
const agnesBox = dialog.querySelector("#gt-agnes-config");
const promptBox = dialog.querySelector("#gt-prompt-config");
radios.forEach(r => {
r.addEventListener("change", () => {
geminiBox.style.display = r.value === "gemini" && r.checked ? "flex" : "none";
agnesBox.style.display = r.value === "agnes" && r.checked ? "flex" : "none";
promptBox.style.display = r.value !== "google" && r.checked ? "block" : "none";
});
});
// Gemini 自定义模型切换
const selectModel = dialog.querySelector("#gt-select-model");
const customModelInput = dialog.querySelector("#gt-input-custom-model");
selectModel.addEventListener("change", () => {
customModelInput.style.display = selectModel.value === "custom" ? "block" : "none";
});
// Agnes 自定义模型切换
const selectAgnesModel = dialog.querySelector("#gt-select-agnes-model");
const customAgnesModelInput = dialog.querySelector("#gt-input-custom-agnes-model");
selectAgnesModel.addEventListener("change", () => {
customAgnesModelInput.style.display = selectAgnesModel.value === "custom" ? "block" : "none";
});
dialog.querySelector("#gt-modal-close").onclick = () => overlay.remove();
dialog.querySelector("#gt-btn-cancel").onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
dialog.querySelector("#gt-btn-open-lang").onclick = () => {
overlay.remove();
showLanguagePicker();
};
dialog.querySelector("#gt-btn-reset-prompt").onclick = () => {
dialog.querySelector("#gt-input-prompt").value = DEFAULT_GEMINI_PROMPT;
};
dialog.querySelector("#gt-btn-save").onclick = () => {
const selectedSource = dialog.querySelector('input[name="gt-source"]:checked').value;
let finalGeminiModel = selectModel.value;
if (finalGeminiModel === "custom") {
finalGeminiModel = customModelInput.value.trim() || DEFAULT_GEMINI_MODEL;
}
let finalAgnesModel = selectAgnesModel.value;
if (finalAgnesModel === "custom") {
finalAgnesModel = customAgnesModelInput.value.trim() || DEFAULT_AGNES_MODEL;
}
GM_setValue("enableGrok", grokCheckbox.checked);
GM_setValue("translateSource", selectedSource);
GM_setValue("geminiApiKey", dialog.querySelector("#gt-input-api").value.trim());
GM_setValue("geminiModel", finalGeminiModel);
GM_setValue("agnesApiKey", dialog.querySelector("#gt-input-agnes-api").value.trim());
GM_setValue("agnesModel", finalAgnesModel);
GM_setValue("geminiPrompt", dialog.querySelector("#gt-input-prompt").value.trim() || DEFAULT_GEMINI_PROMPT);
overlay.remove();
patchAll();
};
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
GM_registerMenuCommand("⚙️ 翻译与 API 设置", showSettingsModal);
GM_registerMenuCommand("🌐 设置翻译语言", showLanguagePicker);
function googleTranslate(text) {
return new Promise((resolve, reject) => {
const url =
"https://translate.googleapis.com/translate_a/single?" +
new URLSearchParams({
client: "gtx",
sl: "auto",
tl: getTargetLang(),
dt: "t",
q: text,
});
GM_xmlhttpRequest({
method: "GET",
url,
onload(res) {
try {
const data = JSON.parse(res.responseText);
const translated = data[0].map((s) => s[0]).join("");
const detectedLang = data[2];
resolve({ text: translated, sourceLang: detectedLang });
} catch (e) {
reject(e);
}
},
onerror: reject,
});
});
}
function geminiTranslate(text) {
return new Promise((resolve, reject) => {
const apiKey = GM_getValue("geminiApiKey", "").trim();
if (!apiKey) {
showSettingsModal();
reject(new Error("请先在设置中填写 Gemini API Key!"));
return;
}
const model = GM_getValue("geminiModel", DEFAULT_GEMINI_MODEL).trim() || DEFAULT_GEMINI_MODEL;
const cleanModel = model.replace(/^models\//, "");
const targetLangCode = getTargetLang();
const targetLangName = getLanguageName(targetLangCode) || targetLangCode;
const safeText = text.replace(/\uE000(\d+)\uE001/g, "{{$1}}");
let customPrompt = GM_getValue("geminiPrompt", DEFAULT_GEMINI_PROMPT);
let promptContent = customPrompt.replace("{targetLang}", targetLangName);
if (promptContent.includes("{text}")) {
promptContent = promptContent.replace("{text}", safeText);
} else {
promptContent = `${promptContent}\n\n${safeText}`;
}
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cleanModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;
// ✅ 修改系统提示词:要求第一行输出源语言代码
const requestBody = {
system_instruction: {
parts: [{
text: "你是一个专业的全语种翻译引擎。你的唯一任务是把用户输入的任何外语(英语、日语、西班牙语等)准确翻译为目标语言。严禁原样照抄输出原文,遇到 {{0}} 等占位符保持不变,仅输出最终翻译结果。**你必须先在回复的第一行单独输出源语言的 ISO 639-1 代码(如 en、ja、es、fr、de、ko 等),然后换行,再输出翻译内容。**"
}]
},
contents: [
{
parts: [{ text: promptContent }]
}
],
generationConfig: {
temperature: 0.3
}
};
GM_xmlhttpRequest({
method: "POST",
url,
headers: {
"Content-Type": "application/json"
},
data: JSON.stringify(requestBody),
async onload(res) {
try {
const data = JSON.parse(res.responseText);
if (data.error) {
console.error("Gemini API 错误:", data.error);
reject(new Error(data.error.message || "Gemini API 返回错误"));
return;
}
const candidate = data.candidates?.[0];
const fullResponse = candidate?.content?.parts?.[0]?.text?.trim() || "";
if (!fullResponse) {
reject(new Error("Gemini 未返回翻译内容"));
return;
}
// ✅ 解析源语言代码和翻译内容
const { sourceLang, translatedText } = parseSourceLang(fullResponse);
// 【防漏兜底】:若小模型原样照抄输出,自动切换 Google 翻译兜底
if (translatedText.trim().toLowerCase() === safeText.trim().toLowerCase()) {
const gResult = await googleTranslate(text);
resolve(gResult);
return;
}
resolve({ text: translatedText, sourceLang });
} catch (e) {
reject(e);
}
},
onerror: reject,
});
});
}
function agnesTranslate(text) {
return new Promise((resolve, reject) => {
const apiKey = GM_getValue("agnesApiKey", "").trim();
if (!apiKey) {
showSettingsModal();
reject(new Error("请先在设置中填写 Agnes API Key!"));
return;
}
const model = GM_getValue("agnesModel", DEFAULT_AGNES_MODEL).trim() || DEFAULT_AGNES_MODEL;
const targetLangCode = getTargetLang();
const targetLangName = getLanguageName(targetLangCode) || targetLangCode;
const safeText = text.replace(/\uE000(\d+)\uE001/g, "{{$1}}");
let customPrompt = GM_getValue("geminiPrompt", DEFAULT_GEMINI_PROMPT);
let promptContent = customPrompt.replace("{targetLang}", targetLangName);
if (promptContent.includes("{text}")) {
promptContent = promptContent.replace("{text}", safeText);
} else {
promptContent = `${promptContent}\n\n${safeText}`;
}
const url = "https://apihub.agnes-ai.com/v1/chat/completions";
// ✅ 修改系统提示词:要求第一行输出源语言代码
const requestBody = {
model: model,
messages: [
{
role: "system",
content: "你是一个专业的全语种翻译引擎。你的唯一任务是把用户输入的任何外语(英语、日语、西班牙语等)准确翻译为目标语言。严禁原样照抄输出原文,遇到 {{0}} 等占位符保持不变,仅输出最终翻译结果。**你必须先在回复的第一行单独输出源语言的 ISO 639-1 代码(如 en、ja、es、fr、de、ko 等),然后换行,再输出翻译内容。**"
},
{
role: "user",
content: promptContent
}
],
temperature: 0.3
};
GM_xmlhttpRequest({
method: "POST",
url,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
},
data: JSON.stringify(requestBody),
async onload(res) {
try {
const data = JSON.parse(res.responseText);
if (data.error) {
console.error("Agnes API 错误:", data.error);
reject(new Error(data.error.message || "Agnes API 返回错误"));
return;
}
const fullResponse = data.choices?.[0]?.message?.content?.trim() || "";
if (!fullResponse) {
reject(new Error("Agnes API 未返回翻译内容"));
return;
}
// ✅ 解析源语言代码和翻译内容
const { sourceLang, translatedText } = parseSourceLang(fullResponse);
// 防漏兜底
if (translatedText.trim().toLowerCase() === safeText.trim().toLowerCase()) {
const gResult = await googleTranslate(text);
resolve(gResult);
return;
}
resolve({ text: translatedText, sourceLang });
} catch (e) {
reject(e);
}
},
onerror: reject,
});
});
}
function translateText(text) {
const source = GM_getValue("translateSource", "google");
if (source === "gemini") {
return geminiTranslate(text);
} else if (source === "agnes") {
return agnesTranslate(text);
}
return googleTranslate(text);
}
function getTweetText(button) {
const birdwatch = button.closest('[data-testid="birdwatch-pivot"]');
if (birdwatch) {
let container = button.parentElement;
while (container && container.parentElement !== birdwatch) {
container = container.parentElement;
}
let sibling = container?.nextElementSibling;
while (sibling) {
if (sibling.tagName === "DIV") {
return { element: sibling, text: sibling.innerText };
}
sibling = sibling.nextElementSibling;
}
}
let node = button.parentElement;
while (node) {
const textEl = node.querySelector('[data-testid="tweetText"], [data-testid="UserDescription"]');
if (textEl) return { element: textEl, text: textEl.innerText };
node = node.parentElement;
}
return null;
}
const GT_PATH = "M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0 0 14.07 6H17V4h-7V2H8v2H1v2h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z";
const GEAR_PATH = "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z";
function escapeHtml(s) {
return (s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function buildTranslationSource(root) {
const placeholders = [];
let text = "";
const walk = (node) => {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
text += child.textContent;
} else if (child.nodeType === Node.ELEMENT_NODE) {
const tag = child.tagName;
if (tag === "A" || tag === "IMG") {
const idx = placeholders.length;
placeholders.push(child.outerHTML);
text += `\uE000${idx}\uE001`;
} else if (tag === "BR") {
text += "\n";
} else {
walk(child);
}
}
}
};
walk(root);
return { text, placeholders };
}
function updateSourceLangText(langSpan, sourceLangCode, isVisible) {
if (!langSpan) return;
if (isVisible && sourceLangCode) {
const langName = getLanguageName(sourceLangCode);
if (langName) {
langSpan.textContent = `翻译自 ${langName}`;
langSpan.style.display = "inline";
} else {
langSpan.style.display = "none";
}
} else {
langSpan.style.display = "none";
}
}
async function doTranslate(tweetTextEl, btnSpan, langSpan, isManual = false) {
if (tweetTextEl.dataset.gtTranslated === "true") {
tweetTextEl.innerHTML = tweetTextEl.dataset.gtOriginalHtml;
tweetTextEl.dataset.gtTranslated = "";
if (btnSpan) btnSpan.textContent = "显示翻译";
if (langSpan) updateSourceLangText(langSpan, "", false);
if (isManual) {
tweetTextEl.dataset.gtManualUntranslated = "true";
}
return;
}
if (tweetTextEl.dataset.gtTranslating === "true") return;
tweetTextEl.dataset.gtTranslating = "true";
if (isManual) {
delete tweetTextEl.dataset.gtManualUntranslated;
}
const origBtnText = btnSpan?.textContent;
if (btnSpan) btnSpan.textContent = "翻译中...";
try {
const { text: source, placeholders } = buildTranslationSource(tweetTextEl);
const result = await translateText(source);
const html = escapeHtml(result.text)
.replace(/\n/g, "<br>")
.replace(/\{\{\s*(\d+)\s*\}\}/g, (m, i) => placeholders[+i] ?? m)
.replace(/\uE000\s*(\d+)\s*\uE001/g, (m, i) => placeholders[+i] ?? m)
.replace(/\\uE000\s*(\d+)\s*\\uE001/gi, (m, i) => placeholders[+i] ?? m);
tweetTextEl.dataset.gtOriginalHtml = tweetTextEl.innerHTML;
tweetTextEl.dataset.gtTranslated = "true";
tweetTextEl.dataset.gtSourceLang = result.sourceLang;
tweetTextEl.innerHTML = html;
if (btnSpan) btnSpan.textContent = "显示原文";
if (langSpan) updateSourceLangText(langSpan, result.sourceLang, true);
} catch (err) {
console.error("翻译出错:", err);
if (btnSpan) btnSpan.textContent = origBtnText || "显示翻译";
} finally {
tweetTextEl.dataset.gtTranslating = "";
}
}
function revertGrokTranslation(article) {
if (!article) return false;
const spans = article.querySelectorAll("span");
for (const span of spans) {
if (span.closest('[data-gt-wrapper="true"]')) continue;
const text = span.textContent.trim();
if (text === "显示原文" || text === "Show original" || text === "元の言語で表示") {
const btn = span.closest('[role="button"]') || span.closest("button") || span;
if (btn && !btn.dataset.gtReverting) {
btn.dataset.gtReverting = "true";
btn.click();
return true;
}
}
}
return false;
}
function findAndPatchButtons() {
const enableGrok = GM_getValue("enableGrok", false);
const allSpans = document.querySelectorAll("span");
// 1. 处理官方/Grok "显示翻译" 按钮
const targetTexts = ["Show translation", "Translate post", "Translate", "显示翻译", "翻译帖子", "翻译"];
for (const span of allSpans) {
if (span.closest('[data-gt-wrapper="true"]')) continue;
const text = span.textContent.trim();
if (targetTexts.includes(text)) {
const btn = span.closest('[role="button"]') || span.closest("a") || span.parentElement;
if (btn) {
if (!enableGrok) {
btn.dataset.googleTranslate = "patched";
btn.style.setProperty("display", "none", "important");
const tweet = getTweetText(btn);
if (tweet && tweet.element) {
delete tweet.element.dataset.gtFeedPatched;
}
} else {
btn.style.removeProperty("display");
}
}
}
}
// 2. 如果关闭了 Grok:彻底清除评价栏 与 [Grok图标 + 设置齿轮 ⚙] 这一整行
if (!enableGrok) {
// 屏蔽 "评价此翻译" 区域
const rateTexts = ["评价此翻译", "Rate this translation", "Rate translation"];
for (const span of allSpans) {
const text = span.textContent.trim();
if (rateTexts.some(t => text.startsWith(t))) {
const container = span.closest('div[dir="ltr"]') || span.parentElement?.parentElement || span.parentElement;
if (container) {
container.style.setProperty("display", "none", "important");
}
}
}
// 精确锁定并隐藏 用户名 与 推文正文 之间的 Grok 属性行(包含 Grok 图标与齿轮)
const tweetTexts = document.querySelectorAll('[data-testid="tweetText"]');
for (const tEl of tweetTexts) {
let prev = tEl.previousElementSibling;
while (prev) {
if (prev.dataset?.gtWrapper === "true") {
prev = prev.previousElementSibling;
continue;
}
if (prev.querySelector('[data-testid="User-Name"]') || prev.dataset?.testid === "User-Name") {
break;
}
if (prev.querySelector('svg') || prev.querySelector('button') || prev.textContent.includes('翻译') || prev.textContent.includes('Translate') || prev.textContent.includes('原文')) {
prev.style.setProperty("display", "none", "important");
}
prev = prev.previousElementSibling;
}
}
}
}
function isOfficialTranslated(article) {
if (!article) return false;
const rawText = article.textContent;
if (!rawText.includes("显示原文") && !rawText.includes("Show original") && !rawText.includes("元の言語で表示")) {
return false;
}
const spans = article.querySelectorAll("span");
for (const span of spans) {
const text = span.textContent.trim();
if (text === "显示原文" || text === "Show original" || text === "元の言語で表示") {
if (!span.closest('[data-gt-wrapper="true"]')) {
return true;
}
}
}
return false;
}
function injectFeedButtons() {
const enableGrok = GM_getValue("enableGrok", false);
const targetLang = getTargetLang();
const tweetTexts = document.querySelectorAll('[data-testid="tweetText"]');
for (const el of tweetTexts) {
if (el.dataset.gtFeedPatched) continue;
const article = el.closest("article");
if (article && isOfficialTranslated(article)) {
if (!enableGrok) {
revertGrokTranslation(article);
} else {
el.dataset.gtFeedPatched = "true";
continue;
}
}
const lang = el.getAttribute("lang");
if (!lang || lang === "und") continue;
if (lang === targetLang) {
el.dataset.gtFeedPatched = "true";
continue;
}
let existingWrapper = el.previousElementSibling;
if (existingWrapper && existingWrapper.dataset && existingWrapper.dataset.gtWrapper === "true") {
el.dataset.gtFeedPatched = "true";
continue;
}
const isTranslated = el.dataset.gtTranslated === "true";
const sourceLang = el.dataset.gtSourceLang || "";
const wrapper = document.createElement("div");
wrapper.dataset.gtWrapper = "true";
wrapper.style.cssText = "display:flex; align-items:center; margin-top:4px; margin-bottom:4px;";
const btn = document.createElement("div");
btn.dataset.googleTranslate = "patched";
btn.style.cssText =
"display:flex;align-items:center;gap:4px;cursor:pointer;padding:4px 0;padding-right:8px;" +
"color:rgb(29,155,240);font-size:13px;width:fit-content;";
btn.innerHTML =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" ` +
`width="1.1em" height="1.1em"><path d="${GT_PATH}"/></svg>` +
`<span>${isTranslated ? "显示原文" : "显示翻译"}</span>`;
const langSpan = document.createElement("span");
langSpan.className = "gt-source-text";
langSpan.style.cssText = `color:${getThemeColors().secondaryText}; font-size:13px; font-weight:normal; display:none; pointer-events:none;`;
wrapper.appendChild(btn);
wrapper.appendChild(langSpan);
updateSourceLangText(langSpan, sourceLang, isTranslated);
btn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const span = btn.querySelector("span");
doTranslate(el, span, langSpan, true);
});
el.parentElement.insertBefore(wrapper, el);
el.dataset.gtFeedPatched = "true";
}
}
function getSpansForTweet(el) {
let node = el.previousElementSibling;
let limit = 3;
while (node && limit > 0) {
if (node.dataset && node.dataset.gtWrapper === "true") {
return {
btnSpan: node.querySelector("div > span"),
langSpan: node.querySelector(".gt-source-text")
};
}
node = node.previousElementSibling;
limit--;
}
return { btnSpan: null, langSpan: null };
}
async function toggleAllTranslations(enable) {
isAllTranslated = enable;
GM_setValue("isAllTranslated", enable);
updateFloatingButtonState();
const targetLang = getTargetLang();
const tweetTexts = document.querySelectorAll('[data-testid="tweetText"]');
for (const el of tweetTexts) {
if (!enable) {
delete el.dataset.gtManualUntranslated;
}
const lang = el.getAttribute("lang");
if (!lang || lang === targetLang || lang === "und") continue;
const article = el.closest("article");
if (article && isOfficialTranslated(article)) continue;
const isTranslated = el.dataset.gtTranslated === "true";
if (enable && !isTranslated) {
const { btnSpan, langSpan } = getSpansForTweet(el);
doTranslate(el, btnSpan, langSpan);
} else if (!enable && isTranslated) {
const { btnSpan, langSpan } = getSpansForTweet(el);
doTranslate(el, btnSpan, langSpan);
}
}
}
function autoTranslateNewTweets() {
const enableGrok = GM_getValue("enableGrok", false);
const targetLang = getTargetLang();
const tweetTexts = document.querySelectorAll('[data-testid="tweetText"]');
for (const el of tweetTexts) {
const lang = el.getAttribute("lang");
if (!lang || lang === targetLang || lang === "und") continue;
if (el.dataset.gtManualUntranslated === "true") continue;
const article = el.closest("article");
if (article && isOfficialTranslated(article)) {
if (!enableGrok) {
revertGrokTranslation(article);
}
continue;
}
if (el.dataset.gtTranslated !== "true" && el.dataset.gtTranslating !== "true") {
const { btnSpan, langSpan } = getSpansForTweet(el);
doTranslate(el, btnSpan, langSpan);
}
}
}
function checkUrlChange() {
if (location.href !== lastUrl) {
lastUrl = location.href;
const elements = document.querySelectorAll('[data-testid="tweetText"]');
for (const el of elements) {
delete el.dataset.gtFeedPatched;
delete el.dataset.gtTranslated;
delete el.dataset.gtOriginalHtml;
delete el.dataset.gtManualUntranslated;
delete el.dataset.gtSourceLang;
}
}
}
function checkDrawerState() {
const dmDrawer = document.querySelector('[data-testid="DMDrawerOuter"]') ||
document.querySelector('[data-testid="DMDrawer"]') ||
document.querySelector('[data-testid="SideSheet_Tab_chat"]');
if (dmDrawer) {
const rect = dmDrawer.getBoundingClientRect();
if (rect.height > 100) return true;
}
const isGrokOpen = !!document.querySelector('[data-testid="SideSheet_Root"]') ||
!!document.querySelector('[data-testid="GrokSideSheet"]') ||
!!document.querySelector('[data-testid="GrokSideDrawer"]') ||
!!document.querySelector('div[role="dialog"] [aria-label*="Grok" i]') ||
!!document.querySelector('div[role="dialog"] [aria-label*="grok" i]');
const isGrokPage = location.pathname === "/grok" || location.pathname.startsWith("/grok/");
const isMessagePage = location.pathname.startsWith("/messages");
if (isGrokOpen || isGrokPage || isMessagePage) return true;
return false;
}
function findNativeFloatingButtons() {
const buttons = [];
const candidates = document.querySelectorAll('[role="button"], button, a, div[style*="cursor"]');
const widthThreshold = window.innerWidth - 150;
const heightThreshold = window.innerHeight - 250;
for (const el of candidates) {
if (el.id === "gt-global-toggle-btn" || el.id === "gt-settings-btn") continue;
const rect = el.getBoundingClientRect();
if (rect.width >= 35 && rect.width <= 65 && rect.height >= 35 && rect.height <= 65) {
if (rect.right > widthThreshold && rect.bottom > heightThreshold) {
const style = window.getComputedStyle(el);
if (style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0") {
const isDuplicate = buttons.some(b => b.el.contains(el) || el.contains(b.el));
if (!isDuplicate) {
buttons.push({ el, rect, top: rect.top });
}
}
}
}
}
buttons.sort((a, b) => b.top - a.top);
return buttons;
}
function positionFloatingButton() {
const btn = document.getElementById("gt-global-toggle-btn");
const settingsBtn = document.getElementById("gt-settings-btn");
if (!btn) return;
const isDrawerOpen = checkDrawerState();
if (isDrawerOpen) {
btn.style.display = "none";
if (settingsBtn) settingsBtn.style.display = "none";
return;
}
const nativeButtons = findNativeFloatingButtons();
if (nativeButtons.length > 0) {
btn.style.display = "flex";
if (settingsBtn) settingsBtn.style.display = "flex";
const targetBtn = nativeButtons[nativeButtons.length - 1].el;
const rect = targetBtn.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
btn.style.width = `${w}px`;
btn.style.height = `${h}px`;
btn.style.borderRadius = "16px";
btn.style.borderWidth = "1px";
btn.style.borderStyle = "solid";
const bottomOffset = window.innerHeight - rect.top + 12;
btn.style.bottom = `${bottomOffset}px`;
const leftOffset = rect.left + rect.width / 2 - w / 2;
btn.style.left = `${leftOffset}px`;
btn.style.right = "auto";
if (settingsBtn) {
settingsBtn.style.bottom = `${bottomOffset + h - 10}px`;
settingsBtn.style.left = `${leftOffset - 8}px`;
}
} else {
btn.style.display = "flex";
if (settingsBtn) settingsBtn.style.display = "flex";
btn.style.width = "50px";
btn.style.height = "50px";
btn.style.borderRadius = "16px";
btn.style.borderWidth = "1px";
btn.style.borderStyle = "solid";
btn.style.bottom = "20px";
btn.style.right = "16px";
btn.style.left = "auto";
if (settingsBtn) {
settingsBtn.style.bottom = "58px";
settingsBtn.style.right = "54px";
settingsBtn.style.left = "auto";
}
}
}
function updateFloatingButtonState() {
const btn = document.getElementById("gt-global-toggle-btn");
const settingsBtn = document.getElementById("gt-settings-btn");
if (!btn) return;
const colors = getThemeColors();
const svg = btn.querySelector("svg");
if (isAllTranslated) {
btn.style.backgroundColor = colors.activeBg;
btn.style.borderColor = colors.activeBg;
if (svg) svg.style.color = colors.activeIcon;
} else {
btn.style.backgroundColor = colors.bg;
btn.style.borderColor = colors.border;
if (svg) svg.style.color = colors.icon;
}
if (settingsBtn) {
settingsBtn.style.backgroundColor = colors.bg;
settingsBtn.style.borderColor = colors.border;
const sSvg = settingsBtn.querySelector("svg");
if (sSvg) sSvg.style.color = colors.secondaryText;
}
}
function injectFloatingButton() {
if (document.getElementById("gt-global-toggle-btn")) {
positionFloatingButton();
updateFloatingButtonState();
return;
}
const colors = getThemeColors();
const btn = document.createElement("div");
btn.id = "gt-global-toggle-btn";
btn.title = "点击切换全局翻译\n右键点击打开设置";
btn.style.cssText =
"position:fixed;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:99999;" +
"border:1px solid;border-radius:16px;" +
"transition:background-color 0.2s, border-color 0.2s;box-shadow: " + colors.shadow + ";";
btn.innerHTML =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" ` +
`width="24px" height="24px" style="transition: color 0.2s;"><path d="${GT_PATH}"/></svg>`;
btn.addEventListener("mouseenter", () => {
const currentColors = getThemeColors();
if (isAllTranslated) {
btn.style.backgroundColor = currentColors.activeHoverBg;
} else {
btn.style.backgroundColor = currentColors.hoverBg;
}
});
btn.addEventListener("mouseleave", () => {
const currentColors = getThemeColors();
if (isAllTranslated) {
btn.style.backgroundColor = currentColors.activeBg;
} else {
btn.style.backgroundColor = currentColors.bg;
}
});
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
toggleAllTranslations(!isAllTranslated);
});
btn.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
showSettingsModal();
});
const settingsBtn = document.createElement("div");
settingsBtn.id = "gt-settings-btn";
settingsBtn.title = "翻译源与 API 设置";
settingsBtn.style.cssText =
"position:fixed;width:24px;height:24px;display:flex;align-items:center;justify-content:center;" +
"cursor:pointer;z-index:100000;border-radius:50%;border:1px solid;box-shadow:0 2px 6px rgba(0,0,0,0.2);" +
"transition:transform 0.2s, background-color 0.2s;";
settingsBtn.innerHTML =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" ` +
`width="14px" height="14px"><path d="${GEAR_PATH}"/></svg>`;
settingsBtn.addEventListener("mouseenter", () => {
settingsBtn.style.transform = "scale(1.15)";
settingsBtn.style.backgroundColor = getThemeColors().hoverBg;
});
settingsBtn.addEventListener("mouseleave", () => {
settingsBtn.style.transform = "scale(1)";
settingsBtn.style.backgroundColor = getThemeColors().bg;
});
settingsBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
showSettingsModal();
});
document.body.appendChild(btn);
document.body.appendChild(settingsBtn);
positionFloatingButton();
updateFloatingButtonState();
window.addEventListener("resize", triggerRecalibration);
const themeObserver = new MutationObserver(() => {
positionFloatingButton();
updateFloatingButtonState();
});
themeObserver.observe(document.body, { attributes: true, attributeFilter: ["style", "class"] });
}
function triggerRecalibration() {
const intervals = [50, 100, 200, 300, 500, 800];
intervals.forEach(delay => {
setTimeout(() => {
positionFloatingButton();
updateFloatingButtonState();
}, delay);
});
}
document.addEventListener("click", triggerRecalibration, true);
function patchAll() {
checkUrlChange();
findAndPatchButtons();
injectFeedButtons();
injectFloatingButton();
if (isAllTranslated) {
autoTranslateNewTweets();
}
}
const observer = new MutationObserver(() => patchAll());
observer.observe(document.body, { childList: true, subtree: true });
patchAll();
})();