Bidirectional ZH↔EN chat translator for MilkyWay Idle. Google Translate + optional AI (Gemini/Gemma). Inline translations, persistent cache, WebSocket pre-translation, bilingual settings panel.
// ==UserScript==
// @name MWI Chat Translator C↔E
// @name:zh-CN MWI 聊天翻译器 C↔E
// @namespace http://tampermonkey.net/
// @version 0.4.2
// @author Star
// @description Bidirectional ZH↔EN chat translator for MilkyWay Idle. Google Translate + optional AI (Gemini/Gemma). Inline translations, persistent cache, WebSocket pre-translation, bilingual settings panel.
// @description:zh-CN MilkyWay Idle 双向 ZH↔EN 聊天翻译器。支持谷歌翻译及可选 AI 翻译(Gemini/Gemma)。行内翻译、持久缓存、WebSocket 预翻译、双语设置面板。
// @match https://www.milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @license MIT
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @connect translate.googleapis.com
// @connect www.milkywayidle.com
// @connect generativelanguage.googleapis.com
// @run-at document-start
// ==/UserScript==
/*
Bidirectional ZH↔EN chat translator for MilkyWay Idle.
Uses Google Translate by default; optional AI translation via Google Gemini / Gemma.
MilkyWay Idle 的双向 ZH↔EN 聊天翻译脚本,默认使用谷歌翻译,可选接入 Gemini / Gemma AI 翻译。
Supports browser and Steam.
── Quick start / 快速开始 ──
The script works out of the box with Google Translate - no setup required.
For better quality, click ⚙ in the chat input bar to configure an AI provider.
脚本开箱即用(谷歌翻译),无需配置。
如需更高质量翻译,点击聊天输入栏中的 ⚙ 配置 AI 提供商。
── AI Translation / AI 翻译 ──
Supported providers (all use the same Google AI Studio API key / 均使用同一 Google AI Studio 密钥):
• Gemini 3.1 Flash Lite Preview 15 RPM / 500 RPD ★ recommended / 推荐
• Gemma 3 12B 30 RPM / 14,400 RPD (slower but higher daily limit)
• Gemma 3 27B 30 RPM / 14,400 RPD (highest quality, slowest)
Get a free API key at: https://aistudio.google.com/apikey
免费 API 密钥获取地址:https://aistudio.google.com/apikey
API keys are stored securely in Tampermonkey's isolated storage (not accessible to the page).
API 密钥安全存储于 Tampermonkey 的独立存储中(页面脚本无法访问)。
── Translation indicators / 翻译标识 ──
⏳ Waiting for AI (Google Translate shown as placeholder) / 等待 AI 中(显示谷歌翻译作为占位)
🤖 AI translated (enable in ⚙ settings) / AI 翻译(可在 ⚙ 设置中启用显示)
♻ From cache, verbose mode only / 来自缓存(仅详细日志模式)
↻ Click to re-translate this message with AI on demand / 点击以按需 AI 重新翻译该条消息
On direction change, existing messages use cache/GT only.
Use ↻ on individual messages to upgrade them with AI.
切换翻译方向时,已有消息仅使用缓存或谷歌翻译。
可点击各条消息的 ↻ 按钮按需升级为 AI 翻译。
── Hotkeys / 快捷键 ──
F6 Clear translation cache / 清除翻译缓存
F7 Toggle inline / tooltip mode / 切换行内 / 悬浮提示模式
F8 Toggle verbose logging / 切换详细日志
F9 Toggle direction (ZH↔EN) / 切换翻译方向
F10 Toggle direction button visibility / 切换方向按钮显示
(right-click button to hide) (右键按钮可隐藏)
── Display modes / 显示模式 ──
Inline Translation appears below each message / 翻译显示在每条消息下方
Tooltip Translation appears on hover only / 仅在鼠标悬停时显示翻译
── Translation direction / 翻译方向 ──
ZH→EN Translates Chinese messages to English (default for English UI)
将中文消息翻译为英文(英文界面默认)
EN→ZH Translates English messages to Chinese (default for Chinese UI)
将英文消息翻译为中文(中文界面默认)
Direction auto-detected on first launch, remembered across sessions.
Toggle via F9, Tampermonkey menu, ⚙ settings panel, or the 中→EN / EN→中 button in chat.
方向在首次启动时自动检测,跨会话记忆。
可通过 F9、Tampermonkey 菜单、⚙ 设置面板或聊天标签栏的 中→EN / EN→中 按钮切换。
── Storage / 存储 ──
localStorage keys (visible in DevTools → Application → Local Storage):
mwi_translator_cache Translation cache (up to 500 entries, LRU)
mwi_translator_direction ZH->EN or EN->ZH
mwi_translator_verbose Verbose logging on/off
mwi_translator_ai_icon Show 🤖 icon on/off
mwi_translator_load_bar Show batch progress bar on/off
mwi_translator_batch_delay Custom batch delay in ms (0 = provider default)
Tampermonkey GM storage (Tampermonkey → Dashboard → Script → Storage tab):
mwi_translator_ai_key API key (isolated, not readable by page scripts)
mwi_translator_ai_enabled AI on/off
mwi_translator_ai_provider Active provider (gemini31 / gemma12b / gemma27b)
*/
(function bootstrap() {
'use strict';
if (typeof window.MWITools_marketAPI_json !== 'undefined') {
(function waitForCompat() {
if (typeof window.MWITools_marketAPI_json === 'undefined')
return setTimeout(waitForCompat, 200);
initTranslator();
})();
} else {
initTranslator();
}
})();
function initTranslator() {
'use strict';
const MSG_SEL = '[class^="ChatMessage_chatMessage"]';
const CHINESE_RE = /[\u4E00-\u9FFF]/;
// ── Per-channel LRU translation cache ──
// One cache per channel type so busy channels (e.g. chinese) can't evict
// entries from quieter ones (whisper, guild, party).
// Keys: 'id:<msgId>' when available, otherwise cleaned text content.
// AI results overwrite GT for the same key. Survives page reloads.
const CACHE_PER_CHANNEL_SIZE = 200; // entries per channel cache
const CACHE_TEXT_SIZE = 200; // shared fallback for text-keyed entries
// Known channel slugs → short storage key suffix
const CHANNEL_STORAGE = {
chinese: 'mwi_cache_chinese',
general: 'mwi_cache_general',
trade: 'mwi_cache_trade',
recruit: 'mwi_cache_recruit',
guild: 'mwi_cache_guild',
whisper: 'mwi_cache_whisper',
party: 'mwi_cache_party',
beginner: 'mwi_cache_beginner',
};
// Fallback for text-keyed lookups (tooltip hover, input translate, etc.)
const CACHE_TEXT_KEY = 'mwi_cache_text';
function makeLRU(storageKey, maxSize) {
const map = new Map((function() {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || '[]');
return Array.isArray(saved) ? saved : [];
} catch (_) { return []; }
})());
const persist = () => {
try {
const entries = [...map.entries()];
if (entries.length > maxSize) entries.splice(0, entries.length - maxSize);
localStorage.setItem(storageKey, JSON.stringify(entries));
} catch (_) {}
};
map.lruSet = (k, v) => {
if (map.has(k)) map.delete(k);
map.set(k, v);
if (map.size > maxSize) map.delete(map.keys().next().value);
persist();
};
map.lruClear = () => {
map.clear();
try { localStorage.removeItem(storageKey); } catch (_) {}
};
return map;
}
// Instantiate one LRU per channel + one for text-keyed entries
const channelCaches = {};
Object.entries(CHANNEL_STORAGE).forEach(([ch, key]) => {
channelCaches[ch] = makeLRU(key, CACHE_PER_CHANNEL_SIZE);
});
const textCache = makeLRU(CACHE_TEXT_KEY, CACHE_TEXT_SIZE);
// Extract channel slug from a chan path like '/chat_channel_types/chinese'
function chanSlug(chan) {
return chan ? chan.split('/').pop() : null;
}
// Active channel for the current message being processed
// Set by handleChatMessageReceived / installFetchInterceptor before calling processMessage
let _activeChan = null;
function _getChannelCache(chan) {
const slug = chanSlug(chan || _activeChan);
return (slug && channelCaches[slug]) ? channelCaches[slug] : textCache;
}
function cacheGet(key, chan) {
const cc = _getChannelCache(chan);
if (cc.has(key)) return { value: cc.get(key) };
// Also check text cache as fallback (e.g. previously cached without channel)
if (cc !== textCache && textCache.has(key)) return { value: textCache.get(key) };
return null;
}
function cacheSet(key, value, chan) { _getChannelCache(chan).lruSet(key, value); }
function cacheHas(key, chan) {
const cc = _getChannelCache(chan);
return cc.has(key) || (cc !== textCache && textCache.has(key));
}
function cacheDelete(key) {
// Delete from all caches (used by ↻)
Object.values(channelCaches).forEach(c => c.delete(key));
textCache.delete(key);
}
function cacheClearAll() {
Object.values(channelCaches).forEach(c => c.lruClear());
textCache.lruClear();
}
// Legacy alias so existing cache.clear() / cache.has() calls keep working
const cache = { has: k => cacheHas(k), get: k => cacheGet(k)?.value,
set: (k, v) => cacheSet(k, v), clear: cacheClearAll, delete: cacheDelete };
let translationMode = 'inline'; // "inline" or "tooltip"
// Translation maps - populated by the chunk loader at startup.
let zhToEnMap = {}; // Chinese -> English
let enToZhMap = {}; // English (lowercase) -> Chinese (for reverse mode)
let chunkDataLoaded = false;
// Translation direction: stored preference wins; on first launch auto-detect
// from game language and save it so subsequent launches use the stored value.
const STORAGE_DIRECTION = 'mwi_translator_direction';
const STORAGE_VERBOSE = 'mwi_translator_verbose';
const STORAGE_AI_ICON = 'mwi_translator_ai_icon';
const STORAGE_LOAD_BAR = 'mwi_translator_load_bar';
let showLoadBar = (function() {
try { return localStorage.getItem(STORAGE_LOAD_BAR) === 'true'; }
catch (_) { return false; }
})();
const STORAGE_BATCH_DELAY = 'mwi_translator_batch_delay';
// Custom delay: 0 = use provider default
let customBatchDelay = (function() {
try { const v = parseInt(localStorage.getItem(STORAGE_BATCH_DELAY), 10); return v > 0 ? v : 0; }
catch (_) { return 0; }
})();
let showAiIcon = (function() {
try { return localStorage.getItem(STORAGE_AI_ICON) === 'true'; }
catch (_) { return false; } // off by default
})();
let verboseLogging = (function() {
try { return localStorage.getItem(STORAGE_VERBOSE) === 'true'; }
catch (_) { return false; }
})();
const STORAGE_AI_KEY = 'mwi_translator_ai_key';
const STORAGE_AI_ENABLED = 'mwi_translator_ai_enabled';
const STORAGE_AI_PROVIDER = 'mwi_translator_ai_provider';
// Supported providers: 'gemini31' | 'gemma12b' | 'gemma27b'
const AI_PROVIDERS = {
gemini31: { label: 'Gemini 3.1 Flash Lite Preview (15 RPM / 500 RPD) ★', hint: 'aistudio.google.com', model: 'gemini-3.1-flash-lite-preview' },
gemma12b: { label: 'Gemma 3 12B (30 RPM / 14,400 RPD)', hint: 'aistudio.google.com', model: 'gemma-3-12b-it' },
gemma27b: { label: 'Gemma 3 27B (30 RPM / 14,400 RPD)', hint: 'aistudio.google.com', model: 'gemma-3-27b-it' },
};
// AI settings stored in GM storage - isolated from page scripts, not readable
// by other userscripts or the game itself.
// Only the API key is sensitive - stored in GM's isolated storage.
// Enabled state and provider are non-sensitive settings stored in localStorage.
let aiApiKey = (typeof GM_getValue !== 'undefined') ? GM_getValue(STORAGE_AI_KEY, '') : '';
let aiEnabled = (function() {
try { return localStorage.getItem(STORAGE_AI_ENABLED) === 'true'; } catch (_) { return false; }
})();
let aiProvider = (function() {
try {
const p = localStorage.getItem(STORAGE_AI_PROVIDER);
return AI_PROVIDERS[p] ? p : 'gemini31';
} catch (_) { return 'gemini31'; }
})();
function saveAiSettings() {
// API key: GM isolated storage
if (typeof GM_setValue !== 'undefined') GM_setValue(STORAGE_AI_KEY, aiApiKey);
// Enabled + provider: localStorage (visible in DevTools, not sensitive)
try {
localStorage.setItem(STORAGE_AI_ENABLED, aiEnabled ? 'true' : 'false');
localStorage.setItem(STORAGE_AI_PROVIDER, aiProvider);
} catch (_) {}
}
const STORAGE_BTN_HIDDEN = 'mwi_translator_btn_hidden';
let dirBtnHidden = (function() {
try { return localStorage.getItem(STORAGE_BTN_HIDDEN) === 'true'; }
catch (_) { return false; }
})();
function saveBtnVisibility() {
try { localStorage.setItem(STORAGE_BTN_HIDDEN, dirBtnHidden ? 'true' : 'false'); }
catch (_) {}
}
function applyDirBtnVisibility() {
document.querySelectorAll('.translator-direction-btn').forEach(b => {
b.style.display = dirBtnHidden ? 'none' : '';
});
}
function toggleDirBtnVisibility() {
dirBtnHidden = !dirBtnHidden;
saveBtnVisibility();
applyDirBtnVisibility();
}
let reverseMode = (function() {
try {
const stored = localStorage.getItem(STORAGE_DIRECTION);
if (stored === 'EN->ZH') return true;
if (stored === 'ZH->EN') return false;
// First launch - detect from game language and persist immediately
const detected = (localStorage.getItem('i18nextLng') || 'en').startsWith('zh');
localStorage.setItem(STORAGE_DIRECTION, detected ? 'EN->ZH' : 'ZH->EN');
return detected;
} catch (_) { return false; }
})();
function saveDirection() {
try { localStorage.setItem(STORAGE_DIRECTION, reverseMode ? 'EN->ZH' : 'ZH->EN'); }
catch (_) {}
}
// ── WebSocket interception ──
// Intercepts chat_message_received to pre-translate messages before DOM render.
// This means by the time React renders the message, the translation is already
// in cache and appears instantly with no perceived latency.
// Also maintains a local log of recent chat messages for debugging.
const WS_MSG_LOG_MAX = 200;
const wsMsgLog = []; // { ts, chan, sender, text, raw } - last WS_MSG_LOG_MAX entries
function handleChatMessageReceived(msg) {
const m = msg.message;
if (!m) return;
const text = m.m || '';
const sender = m.sName || '';
const chan = m.chan || '';
const ts = m.t || new Date().toISOString();
// Log all received chat messages when verbose logging is on
if (verboseLogging) {
console.log('%c[Translator:WS]%c ' + chan + ' | ' + sender + ': ' + text,
'color:#88aaff;font-weight:bold', '');
}
// Store in local log (capped)
wsMsgLog.push({ ts, chan, sender, text });
if (wsMsgLog.length > WS_MSG_LOG_MAX) wsMsgLog.shift();
if (!messageNeedsTranslation(text, null)) return;
if (m.id && cacheHas('id:' + m.id, chan)) return;
if (!m.id && cacheHas(text, chan)) return;
_activeChan = chan;
processMessage(text, () => {
if (verboseLogging) console.log('[Translator:WS] pre-translated:', JSON.stringify(text));
}, false, m.id);
_activeChan = null;
}
// ── Fetch interceptor for init data ──
// The game loads chat history in the initial API response under
// chatHistoryByChannelMap, guildChatHistory, whisperChatHistory, partyChatHistory.
// We intercept fetch at document-start to pre-translate these before DOM render.
function installFetchInterceptor() {
const targetWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const origFetch = targetWin.fetch;
if (!origFetch || origFetch.__translatorWrapped) return;
targetWin.fetch = function(...args) {
const result = origFetch.apply(this, args);
result.then(res => {
// Only intercept JSON responses from the game's own API
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if (!url.includes('milkywayidle.com') && !url.includes('/api/')) return;
if (!res.headers.get('content-type')?.includes('application/json')) return;
res.clone().json().then(data => {
const histories = [];
// Collect all chat history arrays from the response
if (data?.chatHistoryByChannelMap) {
Object.values(data.chatHistoryByChannelMap).forEach(arr => { if (Array.isArray(arr)) histories.push(...arr); });
}
['guildChatHistory', 'whisperChatHistory', 'partyChatHistory'].forEach(key => {
if (Array.isArray(data?.[key])) histories.push(...data[key]);
});
if (!histories.length) return;
if (verboseLogging) console.log('[Translator] init data: found ' + histories.length + ' historical messages');
// Pre-translate each message via GT/cache (gtOnly=true)
histories.forEach(msg => {
const text = msg.m || '';
if (!text || !messageNeedsTranslation(text, null)) return;
if (cacheHas('id:' + msg.id, msg.chan)) return;
_activeChan = msg.chan;
processMessage(text, () => {}, true, msg.id);
_activeChan = null;
});
}).catch(() => {});
}).catch(() => {});
return result;
};
targetWin.fetch.__translatorWrapped = true;
}
function installSocketListener() {
const targetWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const OrigWS = targetWin.WebSocket;
if (!OrigWS || OrigWS.__translatorWrapped) return;
class TranslatorWebSocket extends OrigWS {
constructor(...args) {
super(...args);
this.addEventListener('message', event => {
if (typeof event.data !== 'string') return;
try {
const msg = JSON.parse(event.data);
if (msg?.type === 'chat_message_received') {
handleChatMessageReceived(msg);
}
} catch (_) {}
});
}
}
TranslatorWebSocket.__translatorWrapped = true;
try { targetWin.WebSocket = TranslatorWebSocket; } catch (_) {}
}
function replaceKnownTerms(txt) {
for (const [zh, en] of Object.entries(zhToEnMap)) {
if (!txt.includes(zh)) continue;
// When two zh terms are adjacent (no space between them), their English
// replacements would collide. Insert a space only at the seam where the
// previous replacement ended and the next begins - detected by checking
// if the character immediately before/after the zh term is not already a space.
const parts = txt.split(zh);
txt = parts.reduce((acc, part, i) => {
if (i === 0) return part;
const needsSpaceBefore = acc.length > 0 && /\S$/.test(acc) && /^\S/.test(en);
const needsSpaceAfter = part.length > 0 && /^\S/.test(part) && /\S$/.test(en);
const sep = needsSpaceBefore ? ' ' : '';
const trail = needsSpaceAfter ? ' ' : '';
return acc + sep + en + trail + part;
});
}
return txt;
}
// Replace English game terms with Chinese equivalents (word-boundary aware)
function replaceKnownTermsReverse(txt) {
// Sort by length descending so longer phrases match before their substrings
const entries = Object.entries(enToZhMap)
.sort((a, b) => b[0].length - a[0].length);
for (const [enLower, zh] of entries) {
// Case-insensitive whole-word replacement
const re = new RegExp('(?<![\\w])' + enLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'gi');
txt = txt.replace(re, zh);
}
return txt;
}
function translateTextTo(text, sl, tl, cb) {
// Truncate very long texts to avoid GT 500 errors (GT has a ~5000 char limit)
const MAX_GT_CHARS = 1000;
const safe = text.length > MAX_GT_CHARS ? text.slice(0, MAX_GT_CHARS) : text;
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=' + sl + '&tl=' + tl + '&dt=t&q='
+ encodeURIComponent(safe);
fetch(url)
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(arr => cb(arr[0].map(s => s[0]).join('')))
.catch(() => cb(null));
}
// ── Multi-provider AI translation ──
// Supports Gemini Flash, OpenAI GPT-4o mini, and Anthropic Claude Haiku.
// Falls back to Google Translate on any error.
const AI_SYSTEM_PROMPT =
'You are a translator for the MMO game "MilkyWay Idle". ' +
'Translate each message into the target language defined below. ' +
'Preserve exactly: player names, @mentions, text in [brackets], commands like /w, numbers, emojis, and symbols. ' +
'Do not add, remove, or reorder information. ' +
'Use natural MMO chat style with common gaming slang (e.g., party, run, farm, carry, dungeon, aggro, buff), not formal language. ' +
'Interpret gameplay terms contextually. ' +
'If a message is already in the target language, return it unchanged. ' +
'Output only the translated text - no explanations, no quotes, no extra content.';
// ── AI batching system ──
// Collects messages for AI_BATCH_DELAY_MS (or until AI_BATCH_MAX_SIZE is reached),
// then sends them all in one API call. Reduces API calls by ~10x vs per-message.
// Each item: { key, text, sl, tl, glossary, cbs[] }
// Deduplication: identical text+direction shares one slot (multiple cbs).
const AI_BATCH_MAX_SIZE = 20;
const AI_BATCH_DELAY_MS = 3000; // default (Gemini)
const AI_BATCH_DELAY_BY_PROVIDER = { gemini31: 15000, gemma12b: 5000, gemma27b: 5000 };
function getBatchDelay() { return customBatchDelay > 0 ? customBatchDelay : (AI_BATCH_DELAY_BY_PROVIDER[aiProvider] || AI_BATCH_DELAY_MS); }
let aiBatch = [];
let aiBatchTimer = null;
let aiLockedUntil = 0; // backoff: don't flush until this timestamp
const AI_SYSTEM_BATCH = AI_SYSTEM_PROMPT +
' Return ONLY a valid JSON array of translated strings in the same order as the input.' +
' No markdown, no explanations, no extra text - just the JSON array.';
function callBatch(batch, onDone) {
if (!batch.length) return;
// Check backoff - delay this specific batch call if locked
const waitMs = aiLockedUntil - Date.now();
if (waitMs > 0) {
if (verboseLogging) console.log('[Translator] batch backoff - waiting ' + Math.round(waitMs/1000) + 's');
setTimeout(() => callBatch(batch), waitMs + 100); // +100ms safety margin
return;
}
// Build deduplicated text list (same key → same slot)
// Also enforce a character budget: if total chars exceed limit, split into two calls.
const MAX_BATCH_CHARS = 2000;
const seen = new Map(); // key → index in texts[]
const texts = [];
const slotMap = []; // batch index → texts index
let charBudget = 0;
const overflow = []; // items that exceed the char budget
batch.forEach((item, i) => {
if (seen.has(item.key)) {
slotMap.push(seen.get(item.key));
} else {
if (charBudget + item.text.length > MAX_BATCH_CHARS && texts.length > 0) {
// This item would push us over - defer to overflow batch
overflow.push(item);
slotMap.push(-1); // sentinel: handled by overflow
return;
}
seen.set(item.key, texts.length);
slotMap.push(texts.length);
texts.push(item.text);
charBudget += item.text.length;
}
});
// Schedule overflow items as a new batch
if (overflow.length) {
if (verboseLogging) console.log('[Translator] batch overflow: ' + overflow.length + ' items deferred');
overflow.forEach(item => aiBatch.push(item));
if (!aiBatchTimer) scheduleFlush();
}
// Filter out overflow sentinels
const filteredBatch = batch.filter((_, i) => slotMap[i] !== -1);
if (!filteredBatch.length) { if (onDone) onDone(); return; }
// All items share the same sl/tl (batches are grouped per direction)
const sl = (filteredBatch[0] || batch[0]).sl;
const tl = (filteredBatch[0] || batch[0]).tl;
const langFrom = sl === 'zh-CN' ? 'Chinese' : 'English';
const langTo = tl === 'zh-CN' ? 'Chinese' : 'English';
// Collect glossary hints from all items, deduplicated
const allGlossary = [...new Set(
batch.map(b => b.glossary).filter(Boolean).join(', ').split(', ').filter(Boolean)
)].join(', ');
const prompt_extras =
'Translate each message from ' + langFrom + ' to ' + langTo + '.' +
(allGlossary ? '\nGame term glossary (use these): ' + allGlossary : '') +
'\nReturn ONLY a JSON array in the same order.\n\nInput: ' +
JSON.stringify(texts);
// Gemma doesn't support system_instruction - prepend it to the user content instead
const isGemma = aiProvider === 'gemma12b' || aiProvider === 'gemma27b';
const fullPrompt = AI_SYSTEM_BATCH + '\n\n' + prompt_extras;
const reqBody = isGemma
? {
contents: [{ parts: [{ text: fullPrompt }] }],
generationConfig: { temperature: 0.1, maxOutputTokens: 1024 },
}
: {
system_instruction: { parts: [{ text: AI_SYSTEM_BATCH }] },
contents: [{ parts: [{ text: prompt_extras }] }],
generationConfig: { temperature: 0.1, maxOutputTokens: 1024 },
};
if (verboseLogging) {
console.groupCollapsed('%c[Translator:AI/' + aiProvider + ']%c ⚡ batch ' + texts.length + ' msgs (' + langFrom + '→' + langTo + ')', 'color:#a0c8ff;font-weight:bold', '');
console.log('Texts: ', texts);
if (allGlossary) console.log('Glossary:', allGlossary);
console.log('Request:', reqBody);
console.groupEnd();
}
const batchModel = AI_PROVIDERS[aiProvider]?.model || 'gemini-2.0-flash-lite';
fetch('https://generativelanguage.googleapis.com/v1beta/models/' + batchModel + ':generateContent?key=' + aiApiKey, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody),
})
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(d => {
let raw = (d?.candidates?.[0]?.content?.parts?.[0]?.text || '').trim();
// Strip markdown code fences if present
raw = raw.replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/\s*```$/, '').trim();
let parsed;
try {
parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error('not an array');
} catch (e) {
if (verboseLogging) console.log('[Translator] batch parse failed:', e.message, '| raw:', raw);
parsed = null;
}
if (verboseLogging) {
console.groupCollapsed('%c[Translator:AI/' + aiProvider + ']%c ✓ batch result', 'color:#a0c8ff;font-weight:bold', '');
console.log('Parsed: ', parsed);
console.log('Raw: ', raw);
console.log('Response:', d);
console.groupEnd();
}
filteredBatch.forEach((item, i) => {
const textIdx = slotMap[batch.indexOf(item)];
const result = parsed ? (parsed[textIdx] || null) : null;
item.cbs.forEach(cb => cb(result));
// If parse failed, fall back to GT for this item
if (!result) {
const gtText = reverseMode ? replaceKnownTermsReverse(item.text) : replaceKnownTerms(item.text);
translateTextTo(gtText, sl, tl, r => item.cbs.forEach(cb => cb(r || null)));
}
});
if (onDone) onDone();
})
.catch(err => {
const is429 = err.message.includes('429');
if (is429) {
// Rate limited - set backoff, try auto-fallback to Gemma if on Gemini
const prevLock = Math.max(aiLockedUntil - Date.now(), getBatchDelay());
aiLockedUntil = Date.now() + Math.min(prevLock * 2, 60000);
if (aiProvider === 'gemini31' && AI_PROVIDERS['gemma12b']) {
const prev = aiProvider;
aiProvider = 'gemma12b';
saveAiSettings();
// Refresh direction button labels to show new provider
document.querySelectorAll('.translator-direction-btn').forEach(b => b._refresh && b._refresh());
console.log('[Translator] 429 on ' + prev + ' - auto-switched to Gemma 3 12B for this session');
if (verboseLogging) console.log('[Translator] backing off ' + Math.round((aiLockedUntil - Date.now())/1000) + 's');
showStatusToast('⚠ Rate limited - switched to Gemma 3 12B' + avgResponseLabel(), 'error', 6000);
} else {
if (verboseLogging) console.log('[Translator] batch 429 - backing off '
+ Math.round((aiLockedUntil - Date.now()) / 1000) + 's, GT fallback');
showStatusToast('⚠ Rate limited (429) - backing off ' + Math.round((aiLockedUntil - Date.now())/1000) + 's' + avgResponseLabel(), 'error', 6000);
}
} else {
if (verboseLogging) console.log('[Translator] batch error:', err.message, '- GT fallback');
const code = err.message.match(/\d{3}/)?.[0] || '';
const label = code === '503' ? '⚠ AI unavailable (503). Recommend switching.' : '⚠ AI error' + (code ? ' (' + code + ')' : '');
showStatusToast(label + avgResponseLabel(), 'error', 5000);
}
// Fall back to GT for all items in this sub-batch
filteredBatch.forEach(item => {
const gtText = reverseMode ? replaceKnownTermsReverse(item.text) : replaceKnownTerms(item.text);
translateTextTo(gtText, sl, tl, r => item.cbs.forEach(cb => cb(r || null)));
});
if (onDone) onDone();
});
}
let aiBatchInFlight = false; // true while a batch request is in progress
let aiBatchDeferLogged = false; // rate-limits the "deferring flush" log
function flushBatch() {
if (aiBatchTimer) { clearTimeout(aiBatchTimer); aiBatchTimer = null; }
if (!aiBatch.length) return;
if (aiBatchInFlight) {
// Previous batch still in flight - reschedule to check again after a short wait
if (verboseLogging && !aiBatchDeferLogged) {
console.log('[Translator] batch in flight, deferring flush');
aiBatchDeferLogged = true;
}
aiBatchTimer = setTimeout(flushBatch, 500);
return;
}
aiBatchDeferLogged = false; // reset for next deferral
// Take only up to AI_BATCH_MAX_SIZE items - leave the rest for the next flush
const thisBatch = aiBatch.splice(0, AI_BATCH_MAX_SIZE);
// Split into groups by direction (sl+tl) so each batch is homogeneous
const groups = new Map();
for (const item of thisBatch) {
const dk = item.sl + '|' + item.tl;
if (!groups.has(dk)) groups.set(dk, []);
groups.get(dk).push(item);
}
aiBatchInFlight = true;
updateLoadBar('inflight', 0); // switch to inflight pulsing
const groupList = [...groups.values()];
let done = 0;
for (const group of groupList) {
callBatch(group, () => {
done++;
if (done === groupList.length) {
if (batchStartTime) {
recordResponseTime(Date.now() - batchStartTime - getBatchDelay());
batchStartTime = 0;
}
completeLoadBar();
aiBatchInFlight = false;
if (aiBatch.length) scheduleFlush();
}
});
}
}
// ── Load bar - rolling-average response time estimate ──
// Tracks last N response times per provider to predict inflight duration,
// so the bar fills smoothly through both the wait and inflight phases.
const RESPONSE_TIME_HISTORY = 10; // samples to average
const responseTimeSamples = {}; // { provider: [ms, ms, ...] }
let batchStartTime = 0;
let loadBarFrame = null;
function recordResponseTime(ms) {
if (!responseTimeSamples[aiProvider]) responseTimeSamples[aiProvider] = [];
const hist = responseTimeSamples[aiProvider];
hist.push(ms);
if (hist.length > RESPONSE_TIME_HISTORY) hist.shift();
}
// Minimum estimated response time per provider (ms)
const MIN_RESPONSE_MS = { gemma12b: 6000, gemma27b: 6000 };
function estimatedResponseMs() {
const hist = responseTimeSamples[aiProvider];
const minMs = MIN_RESPONSE_MS[aiProvider] || 2000;
if (!hist || !hist.length) return Math.max(minMs, 6000);
const avg = hist.reduce((a, b) => a + b, 0) / hist.length;
return Math.max(avg, minMs);
}
function ensureLoadBar() {
if (document.getElementById('translator-load-bar-wrap')) return;
const wrap = document.createElement('div');
wrap.id = 'translator-load-bar-wrap';
const bar = document.createElement('div');
bar.id = 'translator-load-bar';
wrap.appendChild(bar);
document.body.appendChild(wrap);
}
function animateLoadBar() {
// Continuously update bar width based on elapsed time vs estimated total
if (!showLoadBar) return;
const bar = document.getElementById('translator-load-bar');
if (!bar || !batchStartTime) return;
const waitMs = getBatchDelay();
const inflightMs = estimatedResponseMs();
const totalMs = waitMs + inflightMs;
const elapsed = Date.now() - batchStartTime;
// Map elapsed → 0..97% (never reach 100% - that's for the flash on complete)
const pct = Math.min(97, (elapsed / totalMs) * 97);
bar.style.transition = 'none';
bar.style.width = pct + '%';
bar.className = elapsed < waitMs ? 'waiting' : 'inflight';
loadBarFrame = requestAnimationFrame(animateLoadBar);
}
function startLoadBar() {
if (!showLoadBar) return;
ensureLoadBar();
if (loadBarFrame) cancelAnimationFrame(loadBarFrame);
batchStartTime = Date.now();
const bar = document.getElementById('translator-load-bar');
if (bar) { bar.style.transition = 'none'; bar.style.width = '0%'; bar.className = 'waiting'; }
animateLoadBar();
}
function completeLoadBar() {
if (loadBarFrame) { cancelAnimationFrame(loadBarFrame); loadBarFrame = null; }
if (!showLoadBar) return;
const bar = document.getElementById('translator-load-bar');
if (!bar) return;
bar.style.transition = 'width 0.15s ease';
bar.style.width = '100%';
bar.className = 'inflight';
setTimeout(() => {
if (bar) { bar.style.transition = 'width 0.4s ease'; bar.style.width = '0%'; bar.className = ''; }
}, 200);
}
let toastTimer = null;
function showStatusToast(msg, type, durationMs) {
// type: 'error' | 'info'
let toast = document.getElementById('translator-status-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'translator-status-toast';
document.body.appendChild(toast);
}
toast.textContent = msg;
toast.className = type + ' visible';
if (toastTimer) clearTimeout(toastTimer);
if (durationMs) toastTimer = setTimeout(() => { toast.classList.remove('visible'); }, durationMs);
}
function hideStatusToast() {
const toast = document.getElementById('translator-status-toast');
if (toast) toast.classList.remove('visible');
if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
}
function avgResponseLabel() {
const hist = responseTimeSamples[aiProvider];
if (!hist || !hist.length) return '';
const avg = Math.round(hist.reduce((a, b) => a + b, 0) / hist.length / 100) / 10;
return ' · avg ' + avg + 's';
}
function hideLoadBar() {
if (loadBarFrame) { cancelAnimationFrame(loadBarFrame); loadBarFrame = null; }
const bar = document.getElementById('translator-load-bar');
if (bar) { bar.style.transition = 'width 0.3s ease'; bar.style.width = '0%'; bar.className = ''; }
}
// Legacy alias used by scheduleFlush
function updateLoadBar(phase, durationMs) {
if (phase === 'idle') hideLoadBar();
else if (phase === 'waiting') startLoadBar();
else if (phase === 'inflight') { /* handled by rAF loop */ }
}
function scheduleFlush() {
if (aiBatchTimer !== null) return;
const wait = Math.max(0, aiLockedUntil - Date.now(), getBatchDelay());
aiBatchTimer = setTimeout(flushBatch, wait);
updateLoadBar('waiting', wait); // start countdown bar
}
function translateWithAI(text, sl, tl, cb, glossary) {
if (!aiApiKey || !aiEnabled) { cb(null); return; }
const key = sl + '|' + tl + '|' + text;
// Deduplicate within pending batch
const existing = aiBatch.find(j => j.key === key);
if (existing) { existing.cbs.push(cb); return; }
aiBatch.push({ key, text, sl, tl, glossary, cbs: [cb] });
if (aiBatch.length >= AI_BATCH_MAX_SIZE) {
flushBatch();
} else {
scheduleFlush();
}
}
// Route through AI if configured, fall back to Google Translate on failure.
// `original` = unsubstituted text (for AI); `glossary` = known term hints.
// For GT we use the pre-substituted `text`; for AI we use `original` + glossary.
function translateBest(text, sl, tl, cb, original, glossary, gtOnly) {
if (aiEnabled && aiApiKey && !gtOnly) {
const aiText = original || text;
translateTextTo(text, sl, tl, gtResult => {
if (gtResult) cb(gtResult, false, true); // GT placeholder
translateWithAI(aiText, sl, tl, aiResult => {
if (aiResult) { cb(aiResult, true, false); }
else if (!gtResult) { cb(null, false, false); }
}, glossary);
});
} else {
translateTextTo(text, sl, tl, r => cb(r, false, false));
}
}
let tip = null;
function injectTranslatorStyles() {
if (document.getElementById('mwi-translator-styles')) return;
const style = document.createElement('style');
style.id = 'mwi-translator-styles';
style.textContent = `
.translator-btn {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 0 10px;
margin: 0;
background: #0e1e3a;
border: 1px solid #4a4aaa;
border-radius: 4px;
color: #a0a8ff;
font-size: 12px;
font-weight: bold;
cursor: pointer;
white-space: nowrap;
user-select: none;
transition: background 0.15s, border-color 0.15s, color 0.15s;
flex-shrink: 0;
box-sizing: border-box;
height: var(--button-height-normal, 27px);
}
.translator-btn:hover {
background: #1a2e5a;
border-color: #7070dd;
color: #c8ccff;
}
.translator-btn:active {
background: #0a1428;
}
.translator-direction-btn {
font-size: 11px;
padding: 2px 6px;
align-self: center;
height: auto;
}
/* ── AI Settings modal ── */
#translator-settings-overlay {
position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,0.65);
display: flex; align-items: center; justify-content: center;
}
#translator-settings-modal {
background: #111828; border: 1px solid #3a3a7a;
border-radius: 8px; padding: 20px 24px; width: 360px;
max-width: 95vw; font: 13px/1.5 'Segoe UI', system-ui, sans-serif;
color: #dde; box-shadow: 0 8px 32px rgba(0,0,0,0.7);
}
#translator-settings-modal h3 {
margin: 0 0 16px; font-size: 14px; color: #a0a8ff;
border-bottom: 1px solid #2a2a5a; padding-bottom: 10px;
}
.trs-row { margin-bottom: 12px; }
.trs-row label { display: block; font-size: 11px; color: #7080bb;
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
.trs-row select, .trs-row input[type=text], .trs-row input[type=password] {
width: 100%; box-sizing: border-box; padding: 6px 8px;
background: #0d1a30; border: 1px solid #3a3a6a; border-radius: 4px;
color: #dde; font-size: 12px; outline: none;
}
.trs-row select:focus, .trs-row input:focus { border-color: #6666cc; }
.trs-key-wrap { position: relative; }
.trs-key-wrap input { padding-right: 32px; }
.trs-key-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: #6070aa; cursor: pointer;
font-size: 13px; padding: 0; line-height: 1;
}
.trs-key-toggle:hover { color: #a0a8ff; }
.trs-hint { font-size: 10px; color: #7a8aaa; margin-top: 3px; }
.trs-hint a { color: #7090cc; text-decoration: none; }
.trs-hint a:hover { text-decoration: underline; }
.trs-toggle-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.trs-toggle-row label { font-size: 12px; color: #bbc; }
.trs-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
.trs-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.trs-slider { position: absolute; inset: 0; border-radius: 999px;
background: #2a2a4a; border: 1px solid #4a4a7a; cursor: pointer;
transition: background .15s; }
.trs-slider::before { content: ''; position: absolute; width: 14px; height: 14px;
left: 2px; top: 2px; border-radius: 50%; background: #cfd3ff; transition: transform .15s; }
.trs-switch input:checked + .trs-slider { background: #1f4a2f; border-color: #3a6a3a; }
.trs-switch input:checked + .trs-slider::before { transform: translateX(16px); }
.trs-btns { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
.trs-btn-save {
padding: 6px 18px; border-radius: 4px; border: 1px solid #4a4aaa;
background: #1a1a4a; color: #a0a8ff; cursor: pointer; font-size: 12px;
font-weight: bold;
}
.trs-btn-save:hover { background: #2a2a6a; color: #fff; }
.trs-status { font-size: 11px; margin-top: 8px; min-height: 16px; color: #70cc70; }
/* ── Batch load bar ── */
#translator-load-bar-wrap {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 9998;
height: 3px; pointer-events: none;
}
#translator-load-bar {
height: 100%; width: 0%; background: #6060cc;
transition: width linear; border-radius: 0 2px 2px 0;
}
#translator-load-bar.waiting { background: #6060cc; }
#translator-load-bar.inflight { background: #40a060; }
/* ── AI status toast ── */
#translator-status-toast {
position: fixed; bottom: 8px; left: 50%; transform: translateX(-50%);
z-index: 9997; padding: 4px 12px; border-radius: 4px;
font: 11px/1.5 'Segoe UI', system-ui, sans-serif;
pointer-events: none; opacity: 0; transition: opacity 0.3s;
white-space: nowrap;
}
#translator-status-toast.error { background: rgba(80,20,20,0.92); color: #ff9999; border: 1px solid #993333; }
#translator-status-toast.info { background: rgba(10,20,40,0.92); color: #99bbff; border: 1px solid #334488; }
#translator-status-toast.visible { opacity: 1; }
`;
document.head.appendChild(style);
}
function ensureTip() {
if (tip) return;
tip = document.createElement('div');
Object.assign(tip.style, {
position: 'fixed', padding: '4px 8px', background: 'rgba(0,0,0,0.8)',
color: '#fff', borderRadius: '4px', pointerEvents: 'none', zIndex: 9999,
display: 'none', fontSize: '12px', whiteSpace: 'pre'
});
document.body.appendChild(tip);
}
function showTip(txt, x, y) {
ensureTip();
tip.textContent = txt;
tip.style.left = x + 12 + 'px';
tip.style.top = y + 12 + 'px';
tip.style.display = 'block';
}
function hideTip() { if (tip) tip.style.display = 'none'; }
function getSenderName(msg) {
const el = msg.querySelector('[data-name]') || msg.querySelector('[class*="ChatMessage_name"]');
return el ? (el.getAttribute('data-name') || el.textContent.trim()) : '';
}
function insertInlineTranslation(text, orig, fromCache, fromAI, pending) {
const sender = getSenderName(orig);
const ts = orig.querySelector('[class*="ChatMessage_timestamp"]');
const tsWidth = ts ? (ts.getBoundingClientRect().width || ts.offsetWidth) : 0;
// getBoundingClientRect returns 0 for off-screen/hidden elements.
// Fall back to a measured estimate based on the timestamp text length.
const indent = tsWidth > 0
? Math.round(tsWidth) + 'px'
: ts ? Math.max(60, ts.textContent.length * 7) + 'px' : '0px';
// If AI result arrives, remove the pending GT placeholder first
const existingPending = orig.querySelector('.translated-message.trs-pending');
if (existingPending && !pending) existingPending.remove();
// If a non-pending (final) translation already exists, don't add another
if (!pending && orig.querySelector('.translated-message:not(.trs-pending)')) return;
// If a pending translation already exists and this is also pending, skip
if (pending && orig.querySelector('.translated-message.trs-pending')) return;
const d = document.createElement('div');
d.className = 'translated-message' + (pending ? ' trs-pending' : '');
Object.assign(d.style, { marginTop:'2px', fontSize:'12px', fontStyle:'italic',
paddingLeft:indent, display:'flex', alignItems:'baseline', gap:'4px' });
// ⏳ always shown while waiting; 🤖/♻ only when showAiIcon is enabled
const icon = document.createElement('span');
icon.style.cssText = 'flex-shrink:0;font-style:normal;font-size:10px;opacity:0.6;';
if (pending) { icon.textContent = '⏳'; icon.title = 'Google Translate - waiting for AI…'; }
else if (showAiIcon && fromAI) { icon.textContent = '🤖'; icon.title = 'AI translated'; }
else if (showAiIcon && verboseLogging && fromCache) { icon.textContent = '♻'; icon.title = 'From cache'; }
if (icon.textContent) d.appendChild(icon);
const span = document.createElement('span');
span.style.color = '#90ee90';
span.textContent = (sender ? sender + ': ' : '') + text;
d.appendChild(span);
// Re-translate button - queues just this message into the next AI batch.
if (aiEnabled && aiApiKey && !fromAI) {
const btn = document.createElement('button');
btn.textContent = '↻';
btn.title = 'Re-translate with AI';
Object.assign(btn.style, {
background: 'none', border: 'none', color: '#6080cc',
cursor: 'pointer', fontSize: '11px', padding: '0 2px',
opacity: '0.5', lineHeight: '1', flexShrink: '0',
});
btn.addEventListener('mouseenter', () => btn.style.opacity = '1');
btn.addEventListener('mouseleave', () => { if (!btn.dataset.loading) btn.style.opacity = '0.5'; });
btn.addEventListener('click', () => {
if (btn.dataset.loading) return;
const raw = getChatText(orig);
if (!raw) return;
const { prefix, content: cleanedText, mentions } = parseChatCommands(raw);
const glossary = buildGlossaryHint(cleanedText, reverseMode);
const sl = reverseMode ? 'en' : 'zh-CN';
const tl = reverseMode ? 'zh-CN' : 'en';
// Invalidate all tier caches for this key so fresh AI result is stored
cacheDelete(cleanedText);
// Show loading state on button
btn.dataset.loading = '1';
btn.textContent = '⏳';
btn.style.opacity = '1';
// Queue into the batch - will fire with next scheduled flush
translateWithAI(cleanedText, sl, tl, result => {
delete btn.dataset.loading;
orig.querySelectorAll('.translated-message').forEach(e => e.remove());
orig.removeAttribute('data-translated');
if (result) {
const out = restoreChatCommands(result, prefix, mentions);
cacheSet(cleanedText, out, aiProvider); // store under current AI provider tier
insertInlineTranslation(out, orig, false, true);
} else {
processMessage(raw, (tr, fc, fromAI2) => insertInlineTranslation(tr, orig, fc, fromAI2));
}
}, glossary);
});
d.appendChild(btn);
}
orig.appendChild(d);
const scroller = orig.closest('[class*="Chat_chatMessages"],[class*="ChatPanel"],[class*="chatMessages"]') || orig.parentElement;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
}
function removeInlineTranslations() {
document.querySelectorAll(`${MSG_SEL} .translated-message`).forEach(e => e.remove());
}
// Update icons on already-displayed translations when showAiIcon or verboseLogging changes.
// Finds the icon span (first child of .translated-message) and shows/hides it.
function refreshTranslationIcons() {
document.querySelectorAll(`${MSG_SEL} .translated-message`).forEach(d => {
const icon = d.querySelector('span:first-child');
if (!icon) return;
const isPending = icon.textContent === '⏳';
const isAI = icon.title === 'AI translated';
const isCache = icon.title === 'From cache';
if (isPending) return; // never hide the ⏳
if (isAI) { icon.style.display = showAiIcon ? '' : 'none'; }
if (isCache) { icon.style.display = (showAiIcon && verboseLogging) ? '' : 'none'; }
});
}
// Extract display names from link widgets (items, abilities, skills, monsters)
// so they appear in the translated text alongside the typed message.
// Resolve a widget container to its English display name.
function getWidgetName(container) {
const resolve = (text) => zhToEnMap[text] || text;
const sel = [
'[class*="Item_name"]', '[class*="Ability_name"]', '[class*="Skill_name"]',
'[class*="BestiaryMonster_name"]', '[class*="Collection_name"]',
];
for (const s of sel) {
const el = container.querySelector(s);
if (el) return resolve(el.textContent.trim());
}
return null;
}
function getChatText(msg) {
// Walk direct children IN ORDER to preserve sentence structure.
// Text spans and item widget names are interleaved exactly as written.
// We skip: timestamp span, sender wrapper (has style attr), translated-message divs.
const parts = [];
for (const el of msg.children) {
// Skip timestamp (has class containing 'timestamp')
if (el.tagName === 'SPAN' && el.className && el.className.includes('timestamp')) continue;
// Skip sender wrapper (bare span with style attribute)
if (el.tagName === 'SPAN' && el.getAttribute('style')) continue;
// Skip our own translation div
if (el.classList && el.classList.contains('translated-message')) continue;
// Skip the name block inside the sender wrapper (div with no class or name class)
if (el.tagName === 'SPAN' && el.className && el.className.includes('ChatMessage_name')) continue;
if (el.tagName === 'SPAN' && !el.className && !el.getAttribute('style')) {
// Bare span = typed text body
const t = el.textContent.trim();
if (t) parts.push(t);
} else if (el.tagName === 'DIV' && el.className && el.className.includes('linkContainer')) {
// Item/skill/ability widget - insert name inline
const name = getWidgetName(el);
if (name) parts.push('[' + name + ']');
}
}
return parts.join(' ').trim();
}
// ─────────────────────────────────────────────────────────────
// TRANSLATION PIPELINE
// ZH->EN: replace known terms, then Google Translate remainder
// EN->ZH: replace known English terms with Chinese equivalents,
// then Google Translate remainder (sl=en, tl=zh-CN)
// ─────────────────────────────────────────────────────────────
// Parse chat commands out of text, returning the content and any prefix/mentions
// separately so they can be restored after translation.
function parseChatCommands(text) {
let prefix = ''; // e.g. "/w Sparkling " - restored verbatim at the front
let content = text;
// /w Name at start - extract and restore after translation
const whisperMatch = content.match(/^(\/w\s+\S+\s*)/i);
if (whisperMatch) {
prefix = whisperMatch[1];
content = content.slice(prefix.length);
}
// @Name mentions - replace with placeholders, restore after translation
const mentions = [];
content = content.replace(/@(\S+)/g, (match, name) => {
const idx = mentions.length;
mentions.push(match);
return 'ATREF' + idx; // word-like placeholder GT won't translate
});
return { prefix, content: content.trim(), mentions };
}
function restoreChatCommands(translated, prefix, mentions) {
let t = translated;
// Restore @mentions
mentions.forEach((mention, idx) => {
t = t.replace(new RegExp('(\\s?)ATREF' + idx + '\\b(\\s?)', 'gi'),
(_, pre, post) => (pre || ' ') + mention + (post || ' '));
});
// Re-attach /w prefix (keep a space if needed)
if (prefix) {
t = prefix + t;
}
return t.trim();
}
// Find all known term mappings that appear in `text` and return them
// as a compact glossary string for the AI prompt, e.g.:
// "炼金 = Alchemy, 魔像精华 = Golem Essence"
// For ZH->EN we scan zhToEnMap; for EN->ZH we scan enToZhMap.
function buildGlossaryHint(text, isReverse) {
const pairs = [];
if (isReverse) {
// EN->ZH: find English terms present in text
for (const [en, zh] of Object.entries(enToZhMap)) {
const re = new RegExp('(?<![\\w])' + en.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'i');
if (re.test(text)) pairs.push(en + ' = ' + zh);
if (pairs.length >= 20) break; // cap to keep prompt size reasonable
}
} else {
// ZH->EN: find Chinese terms present in text
for (const [zh, en] of Object.entries(zhToEnMap)) {
if (text.includes(zh)) pairs.push(zh + ' = ' + en);
if (pairs.length >= 20) break;
}
}
return pairs.join(', ');
}
function processMessage(raw, onDone, gtOnly, msgId) {
const { prefix, content: cleaned, mentions } = parseChatCommands(raw);
if (!cleaned) { onDone(raw, false); return; }
// Prefer message ID as cache key (stable, unique per message).
// Fall back to cleaned text content for messages without an ID.
const cacheKey = msgId ? 'id:' + msgId : cleaned;
const chan = _activeChan;
const cached = cacheGet(cacheKey, chan);
if (cached) {
if (verboseLogging) console.log('[Translator] ♻ cache hit' + (chan ? ' [' + chanSlug(chan) + ']' : '') + ':', JSON.stringify(cacheKey));
onDone(restoreChatCommands(cached.value, prefix, mentions), true, false);
return;
}
const finish = (translated, fromAI = false, pending = false) => {
const result = translated || cleaned;
if (!pending) cacheSet(cacheKey, result, chan);
onDone(restoreChatCommands(result, prefix, mentions), false, fromAI, pending);
};
if (reverseMode) {
const glossary = gtOnly ? '' : buildGlossaryHint(cleaned, true);
const pre = replaceKnownTermsReverse(cleaned);
translateBest(pre, 'en', 'zh-CN', (tr, fromAI, pending) => finish(tr || pre, fromAI, pending), cleaned, glossary, gtOnly);
} else {
const glossary = gtOnly ? '' : buildGlossaryHint(cleaned, false);
const pre = replaceKnownTerms(cleaned);
if (!CHINESE_RE.test(pre)) { finish(pre, false, false); return; }
translateBest(pre, 'zh-CN', 'en', (tr, fromAI, pending) => finish(tr || pre, fromAI, pending), cleaned, glossary, gtOnly);
}
}
function messageNeedsTranslation(raw, msg) {
if (!raw) return false; // empty string, skip
if (reverseMode) {
// EN->ZH: only trigger on Latin letters in the TYPED text.
// Strip widget names [Item Name] and @mentions - those contain Latin
// but are not the typed message content.
const typedOnly = raw
.replace(/\[([^\]]+)\]/g, '') // remove [widget names]
.replace(/@\S+/g, '') // remove @mentions
.trim();
return /[a-zA-Z]/.test(typedOnly);
}
// ZH->EN: translate only if there is actual Chinese text.
return CHINESE_RE.test(raw);
}
function scanExistingMessages(gtOnly) {
if (translationMode !== 'inline') return;
Array.from(document.querySelectorAll(MSG_SEL)).reverse().forEach(msg => {
if (msg.dataset.translated === '1') return;
const raw = getChatText(msg);
if (!messageNeedsTranslation(raw, msg)) return;
msg.dataset.translated = '1';
processMessage(raw, (tr, fromCache, fromAI, pending) => insertInlineTranslation(tr, msg, fromCache, fromAI, pending), gtOnly);
});
if (verboseLogging) console.log('[Translator] scanned existing messages' + (gtOnly ? ' (GT/cache only)' : ''));
}
const observer = new MutationObserver(mutations => {
if (translationMode !== 'inline') return;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const msg = node.matches?.(MSG_SEL) ? node : node.querySelector?.(MSG_SEL);
if (!msg || msg.dataset.translated === '1') continue;
const raw = getChatText(msg);
if (!messageNeedsTranslation(raw, msg)) continue;
msg.dataset.translated = '1';
processMessage(raw, (tr, fromCache, fromAI, pending) => insertInlineTranslation(tr, msg, fromCache, fromAI, pending));
}
}
});
function changeMode() {
translationMode = translationMode === 'inline' ? 'tooltip' : 'inline';
removeInlineTranslations();
hideTip();
document.querySelectorAll(MSG_SEL).forEach(m => m.removeAttribute('data-translated'));
scanExistingMessages();
}
function toggleReverseMode() {
reverseMode = !reverseMode;
saveDirection();
// Don't clear cache on direction change - let GT/cache serve existing messages,
// user can ↻ individual messages they want AI quality on.
removeInlineTranslations();
hideTip();
document.querySelectorAll(MSG_SEL).forEach(m => m.removeAttribute('data-translated'));
document.querySelectorAll('.translator-input-btn').forEach(b => b._updateLabel && b._updateLabel());
document.querySelectorAll('.translator-direction-btn').forEach(b => b._refresh && b._refresh());
scanExistingMessages(true); // gtOnly - no AI on direction change
}
// ── Bilingual UI strings ──
// Returns the string in the game's current language, with the opposite as tooltip.
function isGameZH() {
try { return (localStorage.getItem('i18nextLng') || 'en').startsWith('zh'); } catch (_) { return false; }
}
const UI_STRINGS = {
settingsTitle: { en: '⚙ Translator Settings', zh: '⚙ 翻译器设置' },
enableAI: { en: 'Enable AI translation', zh: '启用 AI 翻译' },
provider: { en: 'Provider', zh: '提供商' },
apiKey: { en: 'API Key', zh: 'API 密钥' },
apiKeyPlaceholder:{ en: 'Paste your API key here…', zh: '在此粘贴你的 API 密钥…' },
freeKeyAt: { en: 'Get a free key at', zh: '在以下网址获取免费密钥' },
inlineMode: { en: 'Inline mode (vs tooltip on hover)',zh: '内联模式(悬停改为气泡)' },
direction: { en: 'Direction', zh: '翻译方向' },
dirZHEN: { en: 'ZH → EN', zh: '中 → 英' },
dirENZH: { en: 'EN → ZH', zh: '英 → 中' },
verbose: { en: 'Verbose logging (F8)', zh: '详细日志 (F8)' },
showDirBtn: { en: 'Show direction button in chat', zh: '在聊天栏显示方向按钮' },
showAiIconLabel: { en: 'Show 🤖 icon on AI translations', zh: 'AI 翻译显示 🤖 图标' },
batchDelay: { en: 'Batch delay (s)', zh: '批处理延迟(秒)' },
batchDelayDefault:{ en: '0 = provider default', zh: '0 = 使用提供商默认值' },
batchDelayHint: { en: 'Lower = faster, but risks hitting rate limits sooner.',
zh: '越低越快,但越容易触发频率限制。' },
providerDefault: { en: 'Provider default', zh: '提供商默认值' },
close: { en: 'Close', zh: '关闭' },
showLoadBar: { en: 'Show batch progress bar', zh: '显示批处理进度条' },
};
function t(key) {
const zh = isGameZH();
const entry = UI_STRINGS[key];
if (!entry) return key;
const primary = zh ? entry.zh : entry.en;
const secondary = zh ? entry.en : entry.zh;
return { text: primary, tip: secondary };
}
// Build a label string with tooltip on the element
function tLabel(key) { const s = t(key); return '<label title="' + s.tip + '">' + s.text + '</label>'; }
function tText(key) { return t(key).text; }
function openAiSettingsModal() {
// Remove any existing modal
document.getElementById('translator-settings-overlay')?.remove();
const overlay = document.createElement('div');
overlay.id = 'translator-settings-overlay';
const providerOptions = Object.entries(AI_PROVIDERS).map(([k, v]) =>
'<option value="' + k + '"' + (k === aiProvider ? ' selected' : '') + '>' + v.label + '</option>'
).join('');
const currentHint = AI_PROVIDERS[aiProvider]?.hint || 'aistudio.google.com';
const dirLabel = tText('direction') + ': ' + (reverseMode ? tText('dirENZH') : tText('dirZHEN'));
const dirTip = t('direction').tip + ': ' + (reverseMode ? t('dirENZH').tip : t('dirZHEN').tip);
const batchDelayDefault = t('batchDelayDefault');
overlay.innerHTML = [
'<div id="translator-settings-modal">',
'<h3 title="' + t('settingsTitle').tip + '">' + tText('settingsTitle') + '</h3>',
'<div class="trs-toggle-row">',
tLabel('enableAI'),
'<label class="trs-switch">',
'<input type="checkbox" id="trs-enabled"' + (aiEnabled ? ' checked' : '') + '>',
'<span class="trs-slider"></span>',
'</label>',
'</div>',
'<div class="trs-row">',
tLabel('provider'),
'<select id="trs-provider">' + providerOptions + '</select>',
'</div>',
'<div class="trs-row">',
tLabel('apiKey'),
'<div class="trs-key-wrap">',
'<input type="password" id="trs-key" title="' + t('apiKey').tip + '"',
' placeholder="' + tText('apiKeyPlaceholder') + '"',
' value="' + aiApiKey.replace(/"/g, '"') + '" autocomplete="off" spellcheck="false">',
'<button class="trs-key-toggle" id="trs-key-toggle" type="button" title="Show/hide">👁</button>',
'</div>',
'<div class="trs-hint" id="trs-hint">',
tText('freeKeyAt') + ' <a href="https://' + currentHint + '" target="_blank" title="' + t('freeKeyAt').tip + '">' + currentHint + '</a>',
'</div>',
'</div>',
'<hr style="border:none;border-top:1px solid #2a2a5a;margin:14px 0 10px">',
'<div class="trs-toggle-row">',
tLabel('inlineMode'),
'<label class="trs-switch">',
'<input type="checkbox" id="trs-inline"' + (translationMode === 'inline' ? ' checked' : '') + '>',
'<span class="trs-slider"></span>',
'</label>',
'</div>',
'<div class="trs-toggle-row">',
'<label title="' + dirTip + '">' + dirLabel + '</label>',
'<label class="trs-switch">',
'<input type="checkbox" id="trs-direction"' + (reverseMode ? ' checked' : '') + '>',
'<span class="trs-slider"></span>',
'</label>',
'</div>',
'<div class="trs-toggle-row">',
tLabel('verbose'),
'<label class="trs-switch">',
'<input type="checkbox" id="trs-verbose"' + (verboseLogging ? ' checked' : '') + '>',
'<span class="trs-slider"></span>',
'</label>',
'</div>',
'<div class="trs-toggle-row">',
tLabel('showDirBtn'),
'<label class="trs-switch">',
'<input type="checkbox" id="trs-dirbtn"' + (!dirBtnHidden ? ' checked' : '') + '>',
'<span class="trs-slider"></span>',
'</label>',
'</div>',
'<div class="trs-toggle-row">',
tLabel('showAiIconLabel'),
'<label class="trs-switch">',
'<input type="checkbox" id="trs-aiicon"' + (showAiIcon ? ' checked' : '') + '>',
'<span class="trs-slider"></span>',
'</label>',
'</div>',
'<div class="trs-toggle-row">',
tLabel('showLoadBar'),
'<label class="trs-switch">',
'<input type="checkbox" id="trs-loadbar"' + (showLoadBar ? ' checked' : '') + '>',
'<span class="trs-slider"></span>',
'</label>',
'</div>',
'<div class="trs-row">',
'<label title="' + t('batchDelay').tip + '">' + tText('batchDelay') +
' <span style="color:#7a8aaa;font-size:10px" title="' + batchDelayDefault.tip + '">(' + batchDelayDefault.text + ')</span></label>',
'<input type="number" id="trs-delay" min="0" max="30" step="0.5"',
' value="' + (customBatchDelay > 0 ? customBatchDelay / 1000 : 0) + '"',
' placeholder="' + ((AI_BATCH_DELAY_BY_PROVIDER[aiProvider] || AI_BATCH_DELAY_MS) / 1000) + '"',
' title="' + tText('batchDelay') + ' - ' + t('batchDelayDefault').tip + ': ' + ((AI_BATCH_DELAY_BY_PROVIDER[aiProvider] || AI_BATCH_DELAY_MS) / 1000) + 's"',
' style="width:90px;box-sizing:border-box;padding:5px 6px;background:#0d1a30;border:1px solid #3a3a6a;border-radius:4px;color:#dde;font-size:12px;">',
'<div class="trs-hint" title="' + t('batchDelayHint').tip + '">' + tText('batchDelayHint') +
' <span style="color:#a0a8ff">(' + tText('providerDefault') + ': ' + ((AI_BATCH_DELAY_BY_PROVIDER[aiProvider] || AI_BATCH_DELAY_MS) / 1000) + 's)</span></div>',
'</div>',
'<div class="trs-btns">',
'<button class="trs-btn-save" id="trs-close" title="' + t('close').tip + '">' + tText('close') + '</button>',
'</div>',
'<div class="trs-status" id="trs-status"></div>',
'</div>',
].join('')
document.body.appendChild(overlay);
const sel = overlay.querySelector('#trs-provider');
const keyInp = overlay.querySelector('#trs-key');
const hint = overlay.querySelector('#trs-hint');
const toggle = overlay.querySelector('#trs-key-toggle');
const status = overlay.querySelector('#trs-status');
// Show/hide key toggle
toggle.addEventListener('click', () => {
keyInp.type = keyInp.type === 'password' ? 'text' : 'password';
toggle.textContent = keyInp.type === 'password' ? '👁' : '🙈';
});
// ── Live apply: called on every change ──
function applyFromModal() {
const newKey = keyInp.value.trim();
const newProvider = sel.value;
const newEnabled = overlay.querySelector('#trs-enabled').checked;
const newInline = overlay.querySelector('#trs-inline').checked;
const newReverse = overlay.querySelector('#trs-direction').checked;
const newVerbose = overlay.querySelector('#trs-verbose').checked;
const newDirBtn = overlay.querySelector('#trs-dirbtn').checked;
const newAiIcon = overlay.querySelector('#trs-aiicon').checked;
const newLoadBar = overlay.querySelector('#trs-loadbar').checked;
const newDelay = Math.round(Math.max(0, parseFloat(overlay.querySelector('#trs-delay').value) || 0) * 1000);
// AI settings
const providerChanged = newProvider !== aiProvider;
aiProvider = newProvider;
if (newKey) aiApiKey = newKey;
aiEnabled = newEnabled && aiApiKey.length > 0;
saveAiSettings();
// Flush pending batch immediately when provider changes so items go to new provider
if (providerChanged && aiBatch.length) {
if (aiBatchTimer) { clearTimeout(aiBatchTimer); aiBatchTimer = null; }
flushBatch();
}
// Update provider hint link
const h = AI_PROVIDERS[newProvider]?.hint || '';
hint.innerHTML = tText('freeKeyAt') + ' <a href="https://' + h + '" target="_blank" title="' + t('freeKeyAt').tip + '">' + h + '</a>';
// Update delay placeholder when provider changes
if (providerChanged) {
const defDelay = (AI_BATCH_DELAY_BY_PROVIDER[newProvider] || AI_BATCH_DELAY_MS) / 1000;
overlay.querySelector('#trs-delay').placeholder = String(defDelay);
const delayHint = overlay.querySelector('#trs-delay + .trs-hint, .trs-row .trs-hint');
// Update the provider default span in the hint
const spans = overlay.querySelectorAll('.trs-hint span');
spans.forEach(s => {
if (s.style.color === 'rgb(160, 168, 255)') {
s.textContent = '(' + tText('providerDefault') + ': ' + defDelay + 's)';
}
});
}
// Display mode
if ((translationMode === 'inline') !== newInline) {
translationMode = newInline ? 'inline' : 'tooltip';
removeInlineTranslations(); hideTip();
document.querySelectorAll(MSG_SEL).forEach(m => m.removeAttribute('data-translated'));
scanExistingMessages();
}
// Direction
if (reverseMode !== newReverse) {
reverseMode = newReverse;
saveDirection();
cache.clear();
removeInlineTranslations(); hideTip();
document.querySelectorAll(MSG_SEL).forEach(m => m.removeAttribute('data-translated'));
document.querySelectorAll('.translator-input-btn').forEach(b => b._updateLabel && b._updateLabel());
document.querySelectorAll('.translator-direction-btn').forEach(b => b._refresh && b._refresh());
// Update direction label in modal
const lbl = overlay.querySelector('#trs-direction').closest('.trs-toggle-row').querySelector('label:first-child');
lbl.textContent = tText('direction') + ': ' + (newReverse ? tText('dirENZH') : tText('dirZHEN'));
lbl.title = t('direction').tip + ': ' + (newReverse ? t('dirENZH').tip : t('dirZHEN').tip);
scanExistingMessages(true); // gtOnly on direction change
}
// Verbose logging
if (verboseLogging !== newVerbose) {
verboseLogging = newVerbose;
try { localStorage.setItem(STORAGE_VERBOSE, verboseLogging ? 'true' : 'false'); } catch (_) {}
refreshTranslationIcons();
}
// Direction button visibility
dirBtnHidden = !newDirBtn;
saveBtnVisibility();
applyDirBtnVisibility();
// AI icon
showAiIcon = newAiIcon;
try { localStorage.setItem(STORAGE_AI_ICON, showAiIcon ? 'true' : 'false'); } catch (_) {}
refreshTranslationIcons();
// Load bar
showLoadBar = newLoadBar;
try { localStorage.setItem(STORAGE_LOAD_BAR, showLoadBar ? 'true' : 'false'); } catch (_) {}
if (!showLoadBar) hideLoadBar();
// Batch delay
customBatchDelay = newDelay;
try { localStorage.setItem(STORAGE_BATCH_DELAY, String(customBatchDelay)); } catch (_) {}
// Refresh translate button labels
document.querySelectorAll('.translator-input-btn').forEach(b => b._updateLabel && b._updateLabel());
status.textContent = '✓';
status.style.color = '#70cc70';
}
// Wire every interactive control to applyFromModal
overlay.querySelectorAll('input[type=checkbox], select').forEach(el => {
el.addEventListener('change', applyFromModal);
});
// Delay input: apply on blur (not every keystroke to avoid thrashing)
overlay.querySelector('#trs-delay').addEventListener('change', applyFromModal);
// API key: apply on blur so user can finish typing
keyInp.addEventListener('blur', applyFromModal);
// Close on overlay click or close button
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#trs-close').addEventListener('click', () => overlay.remove());
}
const avg = avgResponseLabel();
if (avg) showStatusToast('🤖 ' + (AI_PROVIDERS[aiProvider]?.label.split(' (')[0] || aiProvider) + avg, 'info', 3000);
function initializeTampermonkeyMenu() {
if (typeof GM_registerMenuCommand === 'undefined') return;
const label = isGameZH() ? '⚙ 打开翻译器设置' : '⚙ Open Translator Settings';
GM_registerMenuCommand(label, openAiSettingsModal);
if (verboseLogging) {
GM_registerMenuCommand('🧪 Test: info toast', () => {
const avg = avgResponseLabel();
const label = '🤖 ' + (AI_PROVIDERS[aiProvider]?.label.split(' (')[0] || aiProvider) + avg;
showStatusToast(label, 'info', 3000);
});
GM_registerMenuCommand('🧪 Test: error toast', () => {
const avg = avgResponseLabel();
showStatusToast('⚠ AI unavailable (503)' + avg, 'error', 5000);
});
GM_registerMenuCommand('🧪 Test: 429 toast', () => {
const avg = avgResponseLabel();
const backoff = Math.round((aiLockedUntil - Date.now()) / 1000);
const backoffStr = backoff > 0 ? ' - backing off ' + backoff + 's' : '';
showStatusToast('⚠ Rate limited (429)' + backoffStr + avg, 'error', 6000);
});
}
}
// Hotkeys are safe to register immediately (don't need body)
document.addEventListener('keydown', e => {
if (e.key === 'F6') {
e.preventDefault(); cache.clear(); hideTip();
console.log('[Translator] cache cleared via F6');
}
if (e.key === 'F7') { e.preventDefault(); changeMode(); }
if (e.key === 'F8') {
e.preventDefault();
verboseLogging = !verboseLogging;
try { localStorage.setItem(STORAGE_VERBOSE, verboseLogging ? 'true' : 'false'); } catch (_) {}
refreshTranslationIcons();
console.log('[Translator] verbose=' + verboseLogging);
}
if (e.key === 'F9') { e.preventDefault(); toggleReverseMode(); }
if (e.key === 'F10') { e.preventDefault(); toggleDirBtnVisibility(); }
});
// ─────────────────────────────────────────────────────────────
// CHUNK LOADER - fetches game bundle to build zh↔en term maps
// ─────────────────────────────────────────────────────────────
// Named translation objects that appear as EN+ZH pairs in the chunk.
const CHUNK_TRANSLATION_OBJECTS = [
'skillNames', 'abilityNames', 'itemNames', 'monsterNames',
'actionNames', 'itemCategoryNames', 'equipmentTypeNames',
'damageTypeNames', 'combatStyleNames', 'actionTypeNames',
'buffTypeNames', 'houseRoomNames', 'shopCategoryNames',
'actionCategoryNames',
];
/** Find the URL of the main chunk from already-loaded <script> tags. */
function findMainChunkUrl() {
for (const script of document.querySelectorAll('script[src]')) {
const src = script.src || '';
if (/\/static\/js\/main\.[a-f0-9]+\.chunk\.js/.test(src) ||
/\/static\/js\/main\.[a-f0-9]+\.js/.test(src)) {
return src;
}
}
return null;
}
/**
* Extract a named object literal { key: value, ... } from raw JS source.
* Handles quoted string keys, skips string contents when counting braces.
* Returns [parsed object, index after closing brace] or [null, -1].
*/
function extractNamedObject(src, name, startFrom) {
const pattern = new RegExp(name + '\\s*:\\s*\\{');
const rel = src.slice(startFrom).search(pattern);
if (rel === -1) return [null, -1];
// Find the opening brace
const matchStart = startFrom + rel;
const braceStart = src.indexOf('{', matchStart);
let depth = 0, j = braceStart;
while (j < src.length) {
const ch = src[j];
if (ch === '{') { depth++; j++; }
else if (ch === '}') {
depth--;
if (depth === 0) {
const objStr = src.slice(braceStart, j + 1);
try {
// The chunk double-escapes unicode: \\u603b in the file text.
// JSON.parse handles \uXXXX, but the file has \\uXXXX so we
// need one round of unescape first.
const unescaped = objStr.replace(/\\\\u([0-9a-fA-F]{4})/g, '\\u$1');
return [JSON.parse(unescaped), j + 1];
} catch (_) {
try { return [JSON.parse(objStr), j + 1]; } catch (_) { return [null, j + 1]; }
}
}
j++;
}
else if (ch === '"' || ch === "'") {
const q = ch; j++;
while (j < src.length && src[j] !== q) {
if (src[j] === '\\') j++;
j++;
}
j++;
}
else { j++; }
}
return [null, -1];
}
/**
* Walk through all named EN+ZH object pairs and build zh->en map.
* Returns { map: {zh: en}, count: N }
*/
function extractTranslationsFromChunk(src) {
const map = {}; // zh -> en
const enMap = {}; // en (lowercase) -> zh
let count = 0;
for (const name of CHUNK_TRANSLATION_OBJECTS) {
// First occurrence = English, second = Chinese
const [enObj, end1] = extractNamedObject(src, name, 0);
if (!enObj) { if (verboseLogging) console.log('[Translator] chunk: EN object not found for', name); continue; }
const [zhObj, end2] = extractNamedObject(src, name, end1);
if (!zhObj) { if (verboseLogging) console.log('[Translator] chunk: ZH object not found for', name); continue; }
let pairs = 0;
for (const [hrid, zhVal] of Object.entries(zhObj)) {
const enVal = enObj[hrid];
if (enVal && typeof zhVal === 'string' && zhVal !== enVal) {
map[zhVal] = enVal;
enMap[enVal.toLowerCase()] = zhVal;
pairs++;
count++;
}
}
if (verboseLogging) console.log('[Translator] chunk:', name, '->', pairs, 'pairs');
}
return { map, enMap, count };
}
function loadTranslationsFromChunk() {
function attempt(retriesLeft) {
const url = findMainChunkUrl();
if (!url) {
if (retriesLeft > 0) { setTimeout(() => attempt(retriesLeft - 1), 400); return; }
console.log('[Translator] chunk loader: could not find main chunk URL after retries');
return;
}
console.log('[Translator] fetching game bundle for translations:', url);
fetch(url)
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
.then(src => {
const { map, enMap, count } = extractTranslationsFromChunk(src);
if (count === 0) {
console.log('[Translator] chunk: no translation pairs found.',
'Bundle format may have changed.');
return;
}
const before = Object.keys(zhToEnMap).length;
Object.assign(zhToEnMap, map);
Object.assign(enToZhMap, enMap);
chunkDataLoaded = true;
console.log('[Translator] chunk loaded: ' + count +
' zh->en pairs | map: ' + before + ' -> ' + Object.keys(zhToEnMap).length);
// Only rescan messages not yet translated - don't clear persistent cache
document.querySelectorAll(MSG_SEL + ':not([data-translated="1"])').forEach(m => {
const raw = getChatText(m);
if (!messageNeedsTranslation(raw, m)) return;
m.dataset.translated = '1';
processMessage(raw, (tr, fromCache, fromAI, pending) => insertInlineTranslation(tr, m, fromCache, fromAI, pending), true); // gtOnly
});
})
.catch(err => {
console.log('[Translator] chunk fetch failed:', err.message,
'| check @connect www.milkywayidle.com is in the header');
});
}
attempt(12);
}
// ─────────────────────────────────────────────────────────────
// CHAT INPUT TRANSLATE BUTTON
// Injects a translate button next to the Send button.
// Pressing it translates the current input text in the opposite
// direction to the current translation mode, then puts the result
// back in the input so the user can review before sending.
// ─────────────────────────────────────────────────────────────
function setReactInputValue(input, value) {
// Update the input in a way React's synthetic event system picks up
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
if (nativeSetter) nativeSetter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function injectInputTranslateButtons() {
document.querySelectorAll('[class*="Chat_buttonContainer"]').forEach(container => {
if (container.querySelector('.translator-input-btn')) return;
const btn = document.createElement('button');
btn.className = 'translator-input-btn translator-btn';
btn.type = 'button';
btn._updateLabel = () => {
btn.textContent = reverseMode ? '⇄ 翻译' : '⇄ Translate';
btn.title = reverseMode ? 'Translate input: ZH→EN' : 'Translate input: EN→ZH';
};
btn._updateLabel();
btn.addEventListener('click', () => {
const form = container.closest('form');
const input = form && form.querySelector('[class*="Chat_chatInput"]');
if (!input || !input.value.trim()) return;
btn.disabled = true;
btn.style.opacity = '0.5';
const originalText = input.value.trim();
const { prefix, content: cleaned, mentions } = parseChatCommands(originalText);
if (!cleaned) { btn.disabled = false; btn.style.opacity = '1'; return; }
const [sl, tl] = reverseMode ? ['zh-CN', 'en'] : ['en', 'zh-CN'];
const pre = reverseMode ? replaceKnownTerms(cleaned) : replaceKnownTermsReverse(cleaned);
const glossary = buildGlossaryHint(cleaned, reverseMode);
if (aiEnabled && aiApiKey) {
// AI mode: show waiting placeholder, queue into batch, replace on result.
// Bypass translateBest's GT-first path - go straight to AI queue.
const waitingText = isGameZH() ? '⏳(等待 AI 翻译…)' : '⏳ (waiting for AI…)';
setReactInputValue(input, waitingText);
input.focus();
translateWithAI(cleaned, sl, tl, result => {
btn.disabled = false;
btn.style.opacity = '1';
// Only replace if user hasn't sent or typed something new
if (input.value === waitingText || input.value === '') {
const final = restoreChatCommands(result || pre, prefix, mentions);
if (final) { setReactInputValue(input, final); input.focus(); }
}
}, glossary);
} else {
// GT mode: translate and replace directly
translateTextTo(pre, sl, tl, result => {
btn.disabled = false;
btn.style.opacity = '1';
const final = restoreChatCommands(result || pre, prefix, mentions);
if (final) { setReactInputValue(input, final); input.focus(); }
});
}
});
// Style the container and form for flex layout
Object.assign(container.style, { display:'flex', alignItems:'stretch', gap:'4px', flexShrink:'0', width:'auto' });
const form = container.closest('form');
if (form) Object.assign(form.style, { display:'flex', alignItems:'stretch' });
const inputEl = form && form.querySelector('[class*="Chat_chatInput"]');
if (inputEl) Object.assign(inputEl.style, { flex:'1 1 auto', minWidth:'0' });
const sendBtn = container.querySelector('button:not(.translator-input-btn):not(.translator-settings-btn)');
sendBtn ? container.insertBefore(btn, sendBtn) : container.appendChild(btn);
// Settings button (⚙) - opens AI settings modal
if (!container.querySelector('.translator-settings-btn')) {
const settingsBtn = document.createElement('button');
settingsBtn.className = 'translator-settings-btn translator-btn';
settingsBtn.type = 'button';
settingsBtn.title = 'AI Translation Settings';
settingsBtn.textContent = '⚙';
settingsBtn.style.padding = '0 8px';
settingsBtn.addEventListener('click', openAiSettingsModal);
sendBtn ? container.insertBefore(settingsBtn, sendBtn) : container.appendChild(settingsBtn);
}
});
document.querySelectorAll('.translator-input-btn').forEach(b => b._updateLabel && b._updateLabel());
}
// ─────────────────────────────────────────────────────────────
// STARTUP
// ─────────────────────────────────────────────────────────────
// Step 1: wrap fetch + WebSocket FIRST - must happen before the game loads.
installFetchInterceptor();
installSocketListener();
// Step 2: fetch translations from the game's own JS bundle.
loadTranslationsFromChunk();
// Step 3: everything that touches document.body is deferred until the
// DOM is ready (body is null at document-start).
function onBodyReady() {
injectTranslatorStyles();
// Hover listeners
document.body.addEventListener('mouseover', e => {
const msg = e.target.closest(MSG_SEL);
if (!msg || translationMode !== 'tooltip') return;
const raw = getChatText(msg);
if (!messageNeedsTranslation(raw, msg)) return;
processMessage(raw, tr => { if (translationMode === 'tooltip') { removeInlineTranslations(); showTip(tr, e.pageX, e.pageY); } else { insertInlineTranslation(tr, msg); } });
});
document.body.addEventListener('mouseout', e => {
if (e.target.closest(MSG_SEL)) hideTip();
});
// Start mutation observer now that body exists
observer.observe(document.body, { childList: true, subtree: true });
// When a hidden tab panel becomes visible, scan it for pre-existing
// messages that weren't processed while the tab was hidden.
// The game toggles TabPanel_hidden__* on the panel element when switching tabs.
const tabObserver = new MutationObserver(mutations => {
if (translationMode !== 'inline') return;
for (const mutation of mutations) {
if (mutation.type !== 'attributes' || mutation.attributeName !== 'class') continue;
const panel = mutation.target;
// Panel just became visible (hidden class removed)
const wasHidden = mutation.oldValue && mutation.oldValue.includes('TabPanel_hidden');
const isNowVisible = panel instanceof Element && !panel.classList.contains('TabPanel_hidden__26UM3');
if (wasHidden && isNowVisible) {
panel.querySelectorAll(MSG_SEL).forEach(msg => {
if (msg.dataset.translated === '1') return;
const raw = getChatText(msg);
if (!messageNeedsTranslation(raw, msg)) return;
msg.dataset.translated = '1';
processMessage(raw, (tr, fromCache, fromAI, pending) => insertInlineTranslation(tr, msg, fromCache, fromAI, pending));
});
}
}
});
tabObserver.observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ['class'],
attributeOldValue: true,
});
// Inject translate button into chat input area.
// Retry a few times to catch the input rendering after page load.
injectInputTranslateButtons();
setTimeout(injectInputTranslateButtons, 1000);
setTimeout(injectInputTranslateButtons, 3000);
// Inject direction toggle button as a sibling BEFORE the expand/collapse button.
// Must NOT be inside the expand/collapse container - that would bubble clicks to it.
function injectDirectionToggleButton() {
document.querySelectorAll('[class*="TabsComponent_expandCollapseButton"]').forEach(expandBtn => {
const parent = expandBtn.parentElement;
if (!parent || parent.querySelector('.translator-direction-btn')) return;
const btn = document.createElement('button');
btn.className = 'translator-direction-btn translator-btn';
btn.type = 'button';
btn._refresh = () => {
btn.textContent = reverseMode ? 'EN→中' : '中→EN';
btn.title = reverseMode ? 'Direction: EN→ZH' : 'Direction: ZH→EN';
};
btn._refresh();
btn.addEventListener('click', e => { e.stopPropagation(); toggleReverseMode(); });
// Right-click hides the button and saves state
btn.addEventListener('contextmenu', e => {
e.preventDefault();
e.stopPropagation();
dirBtnHidden = true;
saveBtnVisibility();
applyDirBtnVisibility();
});
parent.insertBefore(btn, expandBtn);
});
document.querySelectorAll('.translator-direction-btn').forEach(b => {
b._refresh && b._refresh();
b.style.display = dirBtnHidden ? 'none' : '';
});
}
injectDirectionToggleButton();
setTimeout(injectDirectionToggleButton, 1000);
setTimeout(injectDirectionToggleButton, 3000);
// Re-inject when the chat panel is expanded (collapse/expand button clicked).
// The expand button toggles TabsComponent_hidden on the panels container;
// we watch for that class being removed, then re-inject after React re-renders.
const chatExpandObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type !== 'attributes' || mutation.attributeName !== 'class') continue;
const el = mutation.target;
if (!(el instanceof Element)) continue;
if (!el.classList.contains('TabsComponent_tabPanelsContainer__26mzo')) continue;
// Was hidden, now visible - chat just expanded
const wasHidden = mutation.oldValue && mutation.oldValue.includes('TabsComponent_hidden');
const isNowVisible = !el.classList.contains('TabsComponent_hidden__255ag');
if (wasHidden && isNowVisible) {
setTimeout(injectInputTranslateButtons, 150);
setTimeout(injectDirectionToggleButton, 150);
}
}
});
chatExpandObserver.observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ['class'],
attributeOldValue: true,
});
initializeTampermonkeyMenu();
// Delay initial scan so the game has rendered the chat panel
setTimeout(() => scanExistingMessages(true), 1500); // historical: GT only, use ↻ for AI
const detectedDir = reverseMode ? 'EN->ZH' : 'ZH->EN';
console.log('[Translator] ready (v0.4.0) | direction: ' + detectedDir + ' (auto-detected from i18nextLng)'
+ ' | F6=clear cache F7=display mode F8=verbose F9=toggle direction');
}
if (document.body) {
onBodyReady();
} else {
document.addEventListener('DOMContentLoaded', onBodyReady);
}
}