MWI Chat Translator C↔E

Bidirectional ZH↔EN chat translator for MilkyWay Idle. Google Translate + optional AI (Gemini/Gemma). Inline translations, persistent cache, WebSocket pre-translation, bilingual settings panel.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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, '&quot;') + '" 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);
  }
}