JanitorV5

Fix: multiple issues, all working normally now.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         JanitorV5
// @namespace    https://janitorai.com/
// @version      5.0.3
// @description  Fix: multiple issues, all working normally now.
// @author       eivls + JanitorV5
// @license      All Rights Reserved
// @match        https://janitorai.com/*
// @match        https://www.janitorai.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      openrouter.ai
// @connect      api.openai.com
// @connect      api.x.ai
// @connect      api.mistral.ai
// @connect      api.groq.com
// @connect      api.anthropic.com
// @connect      *
// @run-at       document-idle
// ==/UserScript==

// Copyright (c) 2025 eivls. All Rights Reserved.
//
// This script is the intellectual property of eivls.
// Copying, modifying, redistributing, or republishing this script
// — in whole or in part — without prior written permission from the
// author is strictly prohibited.
//
// To request permission, contact the author via GreasyFork.
//

(function () {
  'use strict';

  // ─── STORAGE ───────────────────────────────────────────────────────────────

  const _cfgCache = {};
  const gget = (k, d) => {
    if (k in _cfgCache) return _cfgCache[k];
    try { _cfgCache[k] = GM_getValue(k, d); return _cfgCache[k]; } catch { return d; }
  };
  const gset = (k, v) => {
    _cfgCache[k] = v;
    try { GM_setValue(k, v); } catch {  }
  };

  // ─── GM FETCH WRAPPER ─────────────────────────────────────────────────────

  function gmFetch(url, options = {}) {
    return new Promise((resolve, reject) => {
      const signal = options.signal;
      if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; }
      let req;
      try {
        req = GM_xmlhttpRequest({
          method:  options.method || 'GET',
          url:     url,
          headers: options.headers || {},
          data:    options.body   || null,
          onload(r) {
            const ok = r.status >= 200 && r.status < 300;
            // Parse raw response headers string into a lookup map
            const _hdrs = {};
            (r.responseHeaders || '').split(/\r?\n/).forEach(line => {
              const idx = line.indexOf(':');
              if (idx > 0) {
                const k = line.slice(0, idx).trim().toLowerCase();
                _hdrs[k] = line.slice(idx + 1).trim();
              }
            });
            resolve({
              ok,
              status:  r.status,
              headers: { get: name => _hdrs[name.toLowerCase()] ?? null },
              text:    () => Promise.resolve(r.responseText),
              json()   {
                try { return Promise.resolve(JSON.parse(r.responseText)); }
                catch (e) { return Promise.reject(e); }
              },
            });
          },
          onerror()   { reject(new TypeError('Failed to fetch (network error — check API key and endpoint)')); },
          onabort()   { reject(new DOMException('Aborted', 'AbortError')); },
          ontimeout() { reject(new TypeError('Request timed out')); },
        });
      } catch (e) {
        reject(new TypeError('GM_xmlhttpRequest failed: ' + e.message));
        return;
      }
      
      if (signal) {
        signal.addEventListener('abort', () => { try { req?.abort(); } catch {  } });
      }
    });
  }

  // ─── SELECTOR CONFIG (remotely-updatable) ─────────────────────────────────

  const SELECTOR_CONFIG = {
    
    virtuosoItemList:  '[data-testid="virtuoso-item-list"] > div[data-index]',
    virtuosoScroller:  '[data-testid="virtuoso-scroller"]',
    virtuosoItemListParent: '[data-testid="virtuoso-item-list"]',
    
    messageBody: '[class*="_messageBody_"]',
    botIcon:     '[class*="_nameIcon_"]',
    
    messagesMain: '[class*="_messagesMain_"]',
    
    authorRoleAssistant: '[data-message-author-role="assistant"]',
    authorRoleUser:      '[data-message-author-role="user"]',

    remoteConfigUrl: '',
    
    _remoteLastFetch: 0,
  };

  function _initRemoteConfig() {
    const url = SELECTOR_CONFIG.remoteConfigUrl;
    if (!url) return;
    const now = Date.now();
    if (now - SELECTOR_CONFIG._remoteLastFetch < 24 * 60 * 60 * 1000) return;
    SELECTOR_CONFIG._remoteLastFetch = now;
    try {
      GM_xmlhttpRequest({
      redirect: 'manual',
        method: 'GET',
        url,
        timeout: 8000,
        onload(r) {
          if (r.status < 200 || r.status >= 300) return;
          try {
            const remote = JSON.parse(r.responseText);
            const allowed = ['virtuosoItemList','virtuosoScroller','virtuosoItemListParent',
                             'messageBody','botIcon','messagesMain',
                             'authorRoleAssistant','authorRoleUser'];
            let patched = 0;
            for (const key of allowed) {
              if (remote[key] && typeof remote[key] === 'string') {
                SELECTOR_CONFIG[key] = remote[key];
                patched++;
              }
            }
            if (patched > 0) console.log(`[JanitorV5] Remote config applied — ${patched} selectors updated`);
          } catch {  }
        },
      });
    } catch {  }
  }

  // ─── REACT FIBER HELPERS ───────────────────────────────────────────────────

  function _getFiber(node) {
    try {
      const key = Object.keys(node).find(k =>
        k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')
      );
      return key ? node[key] : null;
    } catch { return null; }
  }

  function _fiberGetAuthorRole(node) {
    try {
      let fiber = _getFiber(node);
      let depth = 0;
      while (fiber && depth++ < 25) {
        const props = fiber.memoizedProps || fiber.pendingProps;
        if (props) {
          
          if (props.role === 'assistant' || props.role === 'user') return props.role;
          
          const msg = props.message || props.msg || props.data;
          if (msg) {
            if (msg.role === 'assistant') return 'assistant';
            if (msg.role === 'user')      return 'user';
            if (msg.sender === 'bot' || msg.isBot === true) return 'assistant';
            if (msg.sender === 'human' || msg.isUser === true) return 'user';
          }
        }
        fiber = fiber.return;
      }
    } catch {  }
    return null;
  }

  // ─── RESILIENT BOT DETECTION ───────────────────────────────────────────────

  function isAINode(node) {
    
    if (node.querySelector(SELECTOR_CONFIG.authorRoleAssistant)) return true;
    if (node.querySelector(SELECTOR_CONFIG.authorRoleUser))      return false;
    
    const selfRole = node.getAttribute?.('data-message-author-role');
    if (selfRole === 'assistant') return true;
    if (selfRole === 'user')      return false;

    const fiberRole = _fiberGetAuthorRole(node);
    if (fiberRole === 'assistant') return true;
    if (fiberRole === 'user')      return false;

    return !!node.querySelector(SELECTOR_CONFIG.botIcon);
  }

  // ─── DEBUG UTILITY ─────────────────────────────────────────────────────────

  function debugDOM() {
    const candidates = [
      document.querySelector(SELECTOR_CONFIG.virtuosoItemListParent)?.parentElement,
      document.querySelector(SELECTOR_CONFIG.messagesMain),
      document.querySelector('[data-testid="virtuoso-scroller"]'),
      document.querySelector('[class*="messages"]'),
    ].filter(Boolean);

    const target = candidates[0] || document.body;
    const html = target.outerHTML;
    const selSnap = {
      virtuosoItemList:  document.querySelectorAll(SELECTOR_CONFIG.virtuosoItemList).length,
      messageBody:       document.querySelectorAll(SELECTOR_CONFIG.messageBody).length,
      botIcon:           document.querySelectorAll(SELECTOR_CONFIG.botIcon).length,
      authorRole:        document.querySelectorAll('[data-message-author-role]').length,
    };

    const report = [
      '=== JanitorV5 debugDOM report ===',
      'Timestamp: ' + new Date().toISOString(),
      'URL: ' + location.href,
      '',
      '── Selector hit counts ──',
      ...Object.entries(selSnap).map(([k, v]) => `  ${k}: ${v} matches`),
      '',
      '── Chat pane HTML (first 8000 chars) ──',
      html.slice(0, 8000),
    ].join('\n');

    navigator.clipboard.writeText(report)
      .then(() => {
        console.log('[JanitorV5] debugDOM report copied to clipboard');
        if (typeof topToast === 'function') topToast('debugDOM report copied to clipboard');
      })
      .catch(() => {
        console.log('[JanitorV5] debugDOM report (clipboard unavailable — see console):');
        console.log(report);
      });
    return selSnap;
  }
  try { unsafeWindow.debugDOM = debugDOM; } catch {  }

  let _lastAPIPayload   = null;  
  let _lastAPIResponse  = '';    

  const GM_PAYLOAD_KEY = 'jv4_lastGeneratePayload';

// ─── GM STORAGE KEYS ─────────────────────────────────────────────────────────

const P2P_GM_ENABLED  = 'jv4_p2p_enabled';
const P2P_GM_NICKNAME = 'jv4_p2p_nickname';
const P2P_GM_PEERID   = 'jv4_p2p_peerid';
const P2P_GM_BLOCKED  = 'jv4_p2p_blocked';
const P2P_GM_HISTORY  = 'jv4_p2p_history';
const P2P_GM_ROOM     = 'jv4_p2p_room';
const P2P_GM_SINCE    = 'jv4_p2p_last_id';   
const P2P_GM_PINNED   = 'jv4_p2p_pinned';
const P2P_GM_TIP_SEEN = 'jv4_p2p_tip_seen';
const P2P_GM_LAST_CHAR      = 'jv4_p2p_last_char';      // persists char ID across /chats/ navigation
const P2P_GM_LAST_CHAR_NAME = 'jv4_p2p_last_char_name'; // persists char display name alongside ID

// ─── CONSTANTS ───────────────────────────────────────────────────────────────

const P2P_RELAY         = 'https://ntfy.sh';
const P2P_TOPIC_GLOBAL  = 'jv4-global-v1';
const P2P_TOPIC_TYPING  = 'jv4-typing-v1';
const P2P_TOPIC_HB      = 'jv4-heartbeat-v1';
const P2P_TOPIC_REPORTS = 'jv4-reports-v1';

const P2P_POLL_MS       = 8000;   // ntfy.sh: burst 60 tokens, refills 1/5s (12/min). 8s poll
                                   // = 7.5/min from polls + ~4/min HB+typing = ~12/min total.
                                   // Keeps bucket nearly full so message sends never get 429'd.
const P2P_RATE_LIMIT_MS = 3000;   // 3s send cooldown — safe buffer on top of poll budget
const P2P_MAX_HISTORY   = 200;    
const P2P_TYPING_TTL    = 5000;   
const P2P_HB_SEND_MS    = 25000;  
const P2P_HB_EXPIRE_MS  = 70000;  

const P2P_BACKOFF_MIN   = 1500;
const P2P_BACKOFF_MAX   = 5000;   // max wait on repeated errors — kept low so char rooms don't stall
const P2P_BACKOFF_MULT  = 1.5;    // gentler multiplier so backoff grows slowly

const P2P_ADMIN_HASH = 'a5c2012c382c6ec79ea779c75e99322c5cc3b34429f9382499fd1f29247277e4';

const P2P_VERIFIED_URL = '';

const P2P_REACTION_EMOJIS = ['👍','❤️','😂','😮','😢','😡','🔥','✨'];
const P2P_CHAT_EMOJIS = [
  '😀','😂','🥰','😍','🤩','😎','😭','😢','😡','🤔',
  '😏','🙄','👍','👎','❤️','🔥','✨','💀','🎉','😈',
  '🫡','🥺','😤','🤣','💕','😘','🤗','🫢','😑','🫠',
];

// ─── SINGLE SHA-256 HELPER ────────────────────────────────────────────────────

async function _sha256(str) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
  return [...new Uint8Array(buf)].map(x => x.toString(16).padStart(2, '0')).join('');
}

// ─── IDENTITY HELPERS ─────────────────────────────────────────────────────────

function _p2pGetPeerId() {
  let id = GM_getValue(P2P_GM_PEERID, null);
  if (!id) {
    id = 'p' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
    GM_setValue(P2P_GM_PEERID, id);
  }
  return id;
}
function _p2pGetNickname() { return GM_getValue(P2P_GM_NICKNAME, 'Anonymous'); }
function _p2pGetRoom()      { return GM_getValue(P2P_GM_ROOM, 'global'); }
function _p2pGetCharId() {
  // 1. Current URL is a character card page — extract, persist, and return the ID.
  //    URL format: /characters/<uuid>_character-<slug>
  const urlMatch = location.pathname.match(/\/characters\/([A-Za-z0-9_-]{4,})/);
  if (urlMatch) {
    const id = urlMatch[1].replace(/-/g, '').slice(0, 20);
    try { GM_setValue(P2P_GM_LAST_CHAR, id); } catch {}
    return id;
  }

  // 2. Chat page URL (/chats/<numericId>) has no character segment at all.
  //    JanitorAI uses JS routing so there are no <a href="/characters/..."> links.
  //    Fall back to the ID stored last time we were on the character card.
  if (location.pathname.startsWith('/chats/')) {
    try { const stored = GM_getValue(P2P_GM_LAST_CHAR, null); if (stored) return stored; } catch {}
  }

  return null;
}

function _p2pGetCharAvatar() {
  try {
    const selectors = [
      '[class*="characterAvatar"] img',
      '[class*="character-avatar"] img',
      '[class*="CharacterAvatar"] img',
      '[class*="characterImage"] img',
      '[class*="character-image"] img',
      '[class*="chatHeader"] img',
      '[class*="ChatHeader"] img',
      '[class*="chat-header"] img',
      'header img',
    ];
    for (const sel of selectors) {
      const el = document.querySelector(sel);
      if (el?.src && !el.src.startsWith('data:') && el.naturalWidth > 0) return el.src;
      if (el?.src && !el.src.startsWith('data:')) return el.src;
    }
  } catch {}
  return null;
}

function _p2pGetCharName() {
  try {
    const raw = (document.title || '').replace(/\s*[-|]\s*(JanitorAI|janitorai\.com|Janitor AI).*/i, '').trim();
    if (raw && raw.length > 1 && raw.length < 60) {
      // Persist so it's available on the /chats/ page where the title may differ
      try { GM_setValue(P2P_GM_LAST_CHAR_NAME, raw); } catch {}
      return raw;
    }
    const selectors = [
      '[class*="characterName"]',
      '[class*="character_name"]',
      '[class*="character-name"]',
      '[class*="chatHeader"] h1',
      '[class*="ChatHeader"] h1',
      '[class*="chat-header"] h1',
      'header h1',
    ];
    for (const sel of selectors) {
      const el = document.querySelector(sel);
      const t = el?.textContent?.trim();
      if (t && t.length > 1 && t.length < 60) {
        try { GM_setValue(P2P_GM_LAST_CHAR_NAME, t); } catch {}
        return t;
      }
    }
  } catch {}

  // Fallback: on /chats/ pages the title may show a generic name — use the stored one
  if (location.pathname.startsWith('/chats/')) {
    try { const n = GM_getValue(P2P_GM_LAST_CHAR_NAME, null); if (n) return n; } catch {}
  }

  return null;
}

function _p2pGetTopic(room) {
  if (room === 'char') { const cid = _p2pGetCharId(); if (cid) return `jv4-c-${cid}-v1`; }
  return P2P_TOPIC_GLOBAL;
}

// ─── HISTORY STORAGE ──────────────────────────────────────────────────────────

function _p2pGetHistory() {
  try { return JSON.parse(GM_getValue(P2P_GM_HISTORY, '[]')); } catch { return []; }
}
function _p2pAddHistory(msg) {
  try {
    const hist = _p2pGetHistory();
    hist.push(msg);
    if (hist.length > P2P_MAX_HISTORY) hist.splice(0, hist.length - P2P_MAX_HISTORY);
    GM_setValue(P2P_GM_HISTORY, JSON.stringify(hist));
  } catch {  }
}

// ─── BLOCKED LIST STORAGE ─────────────────────────────────────────────────────

function _p2pGetBlocked()    { try { return new Set(JSON.parse(GM_getValue(P2P_GM_BLOCKED, '[]'))); } catch { return new Set(); } }
function _p2pBlockPeer(id)   { try { const b = _p2pGetBlocked(); b.add(id);    GM_setValue(P2P_GM_BLOCKED, JSON.stringify([...b])); } catch {} }
function _p2pUnblockPeer(id) { try { const b = _p2pGetBlocked(); b.delete(id); GM_setValue(P2P_GM_BLOCKED, JSON.stringify([...b])); } catch {} }

let _chatVerifiedMap      = new Map(); 
let _chatVerifiedLastFetch = 0;

function _p2pFetchVerified() {
  if (!P2P_VERIFIED_URL) return;
  if (Date.now() - _chatVerifiedLastFetch < 24 * 60 * 60 * 1000) return;
  _chatVerifiedLastFetch = Date.now();
  try {
    GM_xmlhttpRequest({
      redirect: 'manual',
      method: 'GET', url: P2P_VERIFIED_URL, timeout: 8000,
      onload(r) {
        if (r.status < 200 || r.status >= 300) return;
        try {
          const data = JSON.parse(r.responseText);
          if (!Array.isArray(data.verified)) return;
          _chatVerifiedMap = new Map();
          for (const e of data.verified) if (e.peer) _chatVerifiedMap.set(e.peer, e.name || e.peer);
        } catch {  }
      },
    });
  } catch {  }
}

const chatStore = {
  
  open:       false,
  listEl:     null,    

  messages:   [],      
  seenMsgIds: new Set(), 
  seenNtfyIds: new Set(), 

  blocked:    new Set(),

  pinnedText: '',

  replyingTo: null,   

  isAdmin:    false,

  onlineMap:  new Map(), 

  lastSend:   0,

  atBottom:   true,

  reset() {
    this.open       = false;
    this.listEl     = null;
    this.messages   = [];
    this.seenMsgIds = new Set();
    this.seenNtfyIds = new Set();
    
    this.replyingTo = null;
    this.atBottom   = true;
  },
};


const chatNet = {

  abortFlag:    false,
  xhr:          null,
  reconnTimer:  null,
  backoffMs:    P2P_BACKOFF_MIN,

  lastEventId:  null,
  _onlineHandler: null,   // window 'online' → immediate repoll
  _currentTopic:  null,   // track active topic for online-handler closure

  typingXhr:          null,
  typingProcessed:    0,
  typingStreamTimer:  null,
  typingLastId:       '1m',
  typingTimers:       new Map(),
  typingSendTimer:    null,

  hbXhr:         null,
  hbProcessed:   0,
  hbStreamTimer: null,
  hbSendTimer:   null,
  hbLastId:      '2m',

  connect() {
    this.disconnect();
    this.abortFlag    = false;
    this.backoffMs    = P2P_BACKOFF_MIN;
    // Guard: if room is 'char' but we're not on a character page, fall back to global
    // so the topic doesn't silently collapse to the global topic while the UI
    // thinks it's in char-room (causing char messages to appear in global).
    const room = _p2pGetRoom();
    if (room === 'char' && !_p2pGetCharId()) {
      try { GM_setValue(P2P_GM_ROOM, 'global'); } catch {}
    }
    const topic = _p2pGetTopic(_p2pGetRoom());
    this._currentTopic = topic;
    this._startPoll(topic);
    this._startTypingStream();
    this._startHb();

    // When the device comes back online, kick off a poll immediately
    this._onlineHandler = () => {
      if (!this.abortFlag) {
        this.backoffMs = P2P_BACKOFF_MIN;
        clearTimeout(this.reconnTimer);
        this._startPoll(this._currentTopic);
      }
    };
    window.addEventListener('online', this._onlineHandler);
  },

  disconnect() {
    this.abortFlag = true;
    this._abort('xhr');
    this._abort('typingXhr');
    this._abort('hbXhr');
    clearTimeout(this.reconnTimer);
    clearTimeout(this.typingStreamTimer);
    clearTimeout(this.hbStreamTimer);
    this._currentTopic = null;
    if (this._onlineHandler) {
      window.removeEventListener('online', this._onlineHandler);
      this._onlineHandler = null;
    }
    if (this.typingSendTimer) { clearTimeout(this.typingSendTimer); this.typingSendTimer = null; }
    if (this.hbSendTimer)     { clearInterval(this.hbSendTimer);    this.hbSendTimer = null; }
    this.typingTimers.forEach(t => clearTimeout(t));
    this.typingTimers.clear();
    const el = document.getElementById('jv4-p2p-typing');
    if (el) el.innerHTML = '';
  },

  _abort(key) {
    if (this[key]) { try { this[key].abort(); } catch {} this[key] = null; }
  },

  // ── MAIN MESSAGE POLL ──────────────────────────────────────────────────────
  // Why poll=1 instead of streaming?
  // GM_xmlhttpRequest does NOT reliably fire onprogress for long-lived streaming
  // connections — it buffers the response until the server closes the socket
  // (ntfy.sh does this every ~30-60s), making messages appear with massive delay.
  // poll=1 tells ntfy.sh to return immediately with whatever is queued, so each
  // round-trip takes only a few hundred milliseconds over WiFi or mobile data.
  _startPoll(topic) {
    if (this.abortFlag) return;
    this._abort('xhr');
    clearTimeout(this.reconnTimer);

    const since = this.lastEventId || '30m';

    this.xhr = GM_xmlhttpRequest({
      redirect: 'manual',
      method:  'GET',
      url:     `${P2P_RELAY}/${topic}/json?since=${since}&poll=1`,
      headers: { 'Accept': 'application/x-ndjson' },
      timeout: 15000,

      onload: (r) => {
        if (this.abortFlag) return;
        if (r.responseText?.trim()) {
          this._processChunk(r.responseText);
        }
        // Still alive — show green dot and schedule next poll
        this.backoffMs = P2P_BACKOFF_MIN;
        chatRender.updateStatus('connected');
        this.reconnTimer = setTimeout(() => this._startPoll(topic), P2P_POLL_MS);
      },

      // Network error — use exponential backoff
      onerror: () => {
        if (this.abortFlag) return;
        chatRender.updateStatus('reconnecting');
        const delay = this.backoffMs;
        this.backoffMs = Math.min(
          Math.round(this.backoffMs * P2P_BACKOFF_MULT),
          P2P_BACKOFF_MAX
        );
        this.reconnTimer = setTimeout(() => this._startPoll(topic), delay);
      },

      // poll=1 almost never times out, but treat it the same as no-error
      ontimeout: () => {
        if (this.abortFlag) return;
        this.backoffMs = P2P_BACKOFF_MIN;
        chatRender.updateStatus('connected');
        this.reconnTimer = setTimeout(() => this._startPoll(topic), P2P_POLL_MS);
      },
    });
  },

  _processChunk(text) {
    let maxId = this.lastEventId ? Number(this.lastEventId) : 0;
    const msgs = [];
    for (const line of text.split('\n')) {
      if (!line.trim()) continue;
      try {
        const evt = JSON.parse(line);
        if (evt.id) {
          const numId = Number(evt.id);
          if (chatStore.seenNtfyIds.has(evt.id)) continue;
          chatStore.seenNtfyIds.add(evt.id);
          if (numId > maxId) maxId = numId;
        }
        if (evt.event === 'message') msgs.push(evt);
      } catch {}
    }
    if (chatStore.seenNtfyIds.size > 2000) {
      // Keep the most recent 1500 IDs; removing too many at once risks seeing
      // an already-shown message again after a reconnect
      const arr = [...chatStore.seenNtfyIds];
      arr.slice(0, 500).forEach(id => chatStore.seenNtfyIds.delete(id));
    }
    if (maxId > Number(this.lastEventId || 0)) {
      this.lastEventId = String(maxId);
    }
    if (msgs.length) {
      (async () => {
        if (this.abortFlag) return;
        for (const evt of msgs) {
          await _handleEvent(evt).catch(e => console.warn('[JanitorV5] handleEvent:', e));
        }
      })();
    }
  },

  send(payload) {
    const topic = _p2pGetTopic(_p2pGetRoom());
    let responded = false;

    // Watchdog: if no HTTP response within 10 s, treat as a timeout failure
    const watchdog = setTimeout(() => {
      if (responded) return;
      responded = true;
      _showSendFailBanner('timeout', payload);
    }, 10000);

    GM_xmlhttpRequest({
      redirect: 'manual',
      method:   'POST',
      url:      `${P2P_RELAY}/${topic}`,
      headers:  { 'Content-Type': 'text/plain' },
      data:     JSON.stringify(payload),
      timeout:  10000,
      onload(r) {
        if (responded) return;
        responded = true;
        clearTimeout(watchdog);
        if (r.status === 429) {
          // Server-side rate limit: show airplane-mode tip + retry option
          _showSendFailBanner('ratelimit', payload);
        } else if (r.status >= 400) {
          // Other server errors (5xx, auth, etc.)
          _showSendFailBanner('failed', payload);
        }
        // 2xx / 3xx → message delivered
        if (r.status >= 200 && r.status < 300) {
          _markMessageDelivered(payload.msgId);
        }
      },
      onerror() {
        if (responded) return;
        responded = true;
        clearTimeout(watchdog);
        _showSendFailBanner('failed', payload);
      },
      ontimeout() {
        if (responded) return;
        responded = true;
        _showSendFailBanner('timeout', payload);
      },
    });
  },

  sendTyping() {
    GM_xmlhttpRequest({
      redirect: 'manual',
      method:  'POST',
      url:     `${P2P_RELAY}/${P2P_TOPIC_TYPING}`,
      headers: { 'Content-Type': 'text/plain' },
      data:    JSON.stringify({ v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), type: 'typing', ts: Date.now() }),
    });
  },

  _handleTypingEvent(evt) {
    try {
      const msg = JSON.parse(evt.message);
      if (!msg || msg.type !== 'typing' || !msg.peer || msg.peer === _p2pGetPeerId()) return;
      if (chatStore.blocked.has(msg.peer)) return;
      const el = document.getElementById('jv4-p2p-typing');
      if (!el) return;
      const nick = (msg.nick || 'Someone').slice(0, 20);
      if (this.typingTimers.has(msg.peer)) clearTimeout(this.typingTimers.get(msg.peer));
      let span = el.querySelector(`[data-typing-peer="${CSS.escape(msg.peer)}"]`);
      if (!span) {
        span = document.createElement('span');
        span.dataset.typingPeer = msg.peer;
        el.appendChild(span);
      }
      span.textContent = nick;
      this._renderTypingText(el);
      const t = setTimeout(() => {
        el.querySelector(`[data-typing-peer="${CSS.escape(msg.peer)}"]`)?.remove();
        this._renderTypingText(el);
        this.typingTimers.delete(msg.peer);
      }, P2P_TYPING_TTL);
      this.typingTimers.set(msg.peer, t);
    } catch {}
  },

  _renderTypingText(el) {
    [...el.childNodes].filter(n => n.nodeType === 3).forEach(n => n.remove());
    const peers = el.querySelectorAll('[data-typing-peer]');
    if (!peers.length) return;
    const names = [...peers].map(s => s.textContent);
    const label = names.length === 1
      ? `${names[0]} is typing…`
      : names.length === 2
        ? `${names[0]} and ${names[1]} are typing…`
        : 'Several people are typing…';
    el.appendChild(document.createTextNode(' ' + label));
  },

  _startTypingStream() {
    this._abort('typingXhr');
    clearTimeout(this.typingStreamTimer);
    if (this.abortFlag) return;
    this.typingProcessed = 0;
    this.typingStreamTimer = setTimeout(() => {
      if (!this.abortFlag) this._startTypingStream();
    }, 120000);
    this.typingXhr = GM_xmlhttpRequest({
      redirect: 'manual',
      method:  'GET',
      url:     `${P2P_RELAY}/${P2P_TOPIC_TYPING}/json?since=${this.typingLastId}`,
      headers: { 'Accept': 'application/x-ndjson' },
      onprogress: (r) => {
        if (this.abortFlag) return;
        const chunk = r.responseText.slice(this.typingProcessed);
        this.typingProcessed = r.responseText.length;
        for (const line of chunk.split('\n')) {
          if (!line.trim()) continue;
          try {
            const e = JSON.parse(line);
            if (e.id) this.typingLastId = e.id;
            if (e.event === 'message') this._handleTypingEvent(e);
          } catch {}
        }
      },
      onload:  () => { if (!this.abortFlag) setTimeout(() => this._startTypingStream(), 100); },
      onerror: () => { if (!this.abortFlag) setTimeout(() => this._startTypingStream(), 2000); },
    });
  },

  _sendHb() {
    const myId = _p2pGetPeerId();
    chatStore.onlineMap.set(myId, Date.now());
    chatRender.updateOnlineCount();
    GM_xmlhttpRequest({
      redirect: 'manual',
      method:  'POST',
      url:     `${P2P_RELAY}/${P2P_TOPIC_HB}`,
      headers: { 'Content-Type': 'text/plain' },
      data:    JSON.stringify({ v:1, peer: myId, type: 'hb', ts: Date.now() }),
    });
  },

  _handleHbEvent(evt) {
    try {
      const msg = JSON.parse(evt.message);
      if (!msg || msg.type !== 'hb' || !msg.peer) return;
      chatStore.onlineMap.set(msg.peer, Date.now());
      const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
      for (const [peer, ts] of chatStore.onlineMap) if (ts < cutoff) chatStore.onlineMap.delete(peer);
      chatRender.updateOnlineCount();
    } catch {}
  },

  _startHb() {
    this._abort('hbXhr');
    clearTimeout(this.hbStreamTimer);
    if (this.hbSendTimer) { clearInterval(this.hbSendTimer); this.hbSendTimer = null; }
    if (this.abortFlag) return;
    this._sendHb();
    this.hbSendTimer = setInterval(() => this._sendHb(), P2P_HB_SEND_MS);
    const startHbStream = () => {
      this._abort('hbXhr');
      clearTimeout(this.hbStreamTimer);
      if (this.abortFlag) return;
      this.hbProcessed = 0;
      this.hbStreamTimer = setTimeout(() => startHbStream(), 120000);
      this.hbXhr = GM_xmlhttpRequest({
      redirect: 'manual',
        method:  'GET',
        url:     `${P2P_RELAY}/${P2P_TOPIC_HB}/json?since=${this.hbLastId}`,
        headers: { 'Accept': 'application/x-ndjson' },
        onprogress: (r) => {
          if (this.abortFlag) return;
          const chunk = r.responseText.slice(this.hbProcessed);
          this.hbProcessed = r.responseText.length;
          for (const line of chunk.split('\n')) {
            if (!line.trim()) continue;
            try {
              const e = JSON.parse(line);
              if (e.id) this.hbLastId = e.id;
              if (e.event === 'message') this._handleHbEvent(e);
            } catch {}
          }
        },
        onload:  () => { if (!this.abortFlag) setTimeout(() => startHbStream(), 100); },
        onerror: () => { if (!this.abortFlag) setTimeout(() => startHbStream(), 3000); },
      });
    };
    startHbStream();
  },
};

async function _handleEvent(ntfyEvt) {
  try {
    const msg = JSON.parse(ntfyEvt.message);
    if (!msg || !msg.peer || msg.v !== 1) return;

    // ── Room guard ─────────────────────────────────────────────────────────────
    // Since we subscribe to ONE topic per session, a room mismatch shouldn't
    // normally happen, but it can when:
    //   • room stored as 'char' but charId is null → topic collapsed to global,
    //     so the UI thinks it's in char-room while reading global messages.
    //   • History replay across rooms on modal reopen.
    // Drop any message whose room tag doesn't match what we're currently viewing.
    const curRoom = _p2pGetRoom();
    if (msg.room && msg.room !== curRoom) return;
    // Extra check: if we're in char-room, only accept messages that were explicitly
    // tagged 'char' (old messages with no room tag are shown for backwards compat
    // in global only, not char rooms).
    if (curRoom === 'char' && msg.room !== 'char') return;
    // ──────────────────────────────────────────────────────────────────────────

    if (msg.type === 'reaction' && msg.reactionTo && msg.text) {
      if (msg.peer !== _p2pGetPeerId() && !chatStore.blocked.has(msg.peer)) {
        chatRender.applyReaction(msg.reactionTo, msg.text, msg.peer, false);
      }
      return;
    }

    if (!msg.text) return;

    if (msg.peer === _p2pGetPeerId()) return;

    if (msg.msgId) {
      if (chatStore.seenMsgIds.has(msg.msgId)) return;
      chatStore.seenMsgIds.add(msg.msgId);
    }

    if (msg.adminToken === P2P_ADMIN_HASH && msg.text.startsWith('/')) {
      chatCmd.execute(msg.text, msg.peer);
      return;
    }

    if (chatStore.blocked.has(msg.peer)) return;

    _maybeNotifyReply(msg);

    _p2pAddHistory(msg);
    chatStore.messages.push(msg);
    chatRender.appendBubble(msg, false);

  } catch (err) {
    console.warn('[JanitorV5] _handleEvent error:', err);
  }
}

const chatRender = {

  appendBubble(msg, isMine, isNewlySent = false) {
    const list = chatStore.listEl;
    if (!list) return; 

    const myPeer = _p2pGetPeerId();
    const isMe   = isMine || msg.peer === myPeer;

    if (msg.msgId && list.querySelector(`[data-msgid="${CSS.escape(msg.msgId)}"]`)) return;

    const bubble = document.createElement('div');
    bubble.className = `jv4-p2p-bubble ${isMe ? 'jv4-p2p-bubble-me' : 'jv4-p2p-bubble-other'}`;
    bubble.dataset.peer = msg.peer;
    if (msg.msgId) bubble.dataset.msgid = msg.msgId;

    if (!isMe && msg.adminToken === P2P_ADMIN_HASH) {
      bubble.classList.add('jv4-p2p-bubble-admin');
    }

    if (!isMe && chatStore.blocked.has(msg.peer)) {
      bubble.style.display = 'none';
      bubble.dataset.muted = '1';
    }

    const time     = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    const nick     = isMe ? 'You' : (msg.nick || 'Anonymous').slice(0, 24);
    const shortId  = (msg.peer || '????').slice(0, 4);
    const verified = !isMe && _chatVerifiedMap.has(msg.peer);
    const verBadge = verified
      ? `<span title="Verified: ${_esc(_chatVerifiedMap.get(msg.peer) || '')}" style="color:#10b981;font-size:11px;margin-left:3px;cursor:default;">✓</span>`
      : '';

    let quoteHTML = '';
    if (msg.replyTo) {
      const rNick = _esc((msg.replyTo.nick || 'Someone').slice(0, 20));
      const rText = _esc((msg.replyTo.text || '').slice(0, 80));
      quoteHTML = `<div class="jv4-p2p-quote">↩ ${rNick}: ${rText}</div>`;
    }

    const muteLabel = chatStore.blocked.has(msg.peer) ? '🔊' : 'mute';
    bubble.innerHTML = `
      <div class="jv4-p2p-meta">
        <span class="jv4-p2p-nick">${_esc(nick)}</span>
        <span class="jv4-p2p-peerid" data-peer="${_esc(msg.peer)}" title="Tap to copy peer ID"
              style="font-size:10px;color:#6b7280;margin-left:2px;cursor:pointer;">#${_esc(shortId)}</span>
        ${verBadge}
        <span class="jv4-p2p-time">${time}</span>
        ${msg.msgId ? `<button class="jv4-p2p-mute jv4-act-reply" title="Reply">↩</button>` : ''}
        ${msg.msgId ? `<button class="jv4-p2p-mute jv4-act-react" title="React">+</button>` : ''}
        ${!isMe ? `<button class="jv4-p2p-mute jv4-act-mute" data-peer="${_esc(msg.peer)}">${_esc(muteLabel)}</button>` : ''}
        ${!isMe ? `<button class="jv4-p2p-mute jv4-act-report" style="color:#ef4444;" title="Report">⚑</button>` : ''}
      </div>
      ${quoteHTML}
      <p class="jv4-p2p-text">${_esc(msg.text).replace(/\n/g, '<br>')}</p>
      ${isMe && isNewlySent ? '<span class="jv4-delivery-status jv4-delivery-pending">Sending…</span>' : ''}
    `;

    bubble.querySelector('.jv4-p2p-peerid')?.addEventListener('click', function () {
      {
        navigator.clipboard.writeText(this.dataset.peer)
          .then(()  => topToast('Peer ID copied!'))
          .catch(() => topToast('Tap and hold to copy manually'));
      }
    });

    bubble.querySelector('.jv4-act-reply')?.addEventListener('click', e => {
      e.stopPropagation();
      chatStore.replyingTo = { id: msg.msgId, nick: msg.nick || 'Anonymous', text: msg.text, peer: msg.peer };
      const strip = document.getElementById('jv4-p2p-reply-strip');
      const txt   = document.getElementById('jv4-p2p-reply-text');
      if (strip && txt) {
        txt.textContent = `↩ ${(msg.nick || 'Anonymous').slice(0, 20)}: ${msg.text.slice(0, 60)}`;
        strip.classList.add('visible');
      }
      document.getElementById('jv4-p2p-input')?.focus();
    });

    bubble.querySelector('.jv4-act-react')?.addEventListener('click', e => {
      e.stopPropagation();
      this.showReactionPicker(bubble, msg.msgId);
    });

    bubble.querySelector('.jv4-act-mute')?.addEventListener('click', e => {
      e.stopPropagation();
      const peerId = msg.peer;
      const isMuted = chatStore.blocked.has(peerId);
      if (isMuted) {
        _p2pUnblockPeer(peerId);
        chatStore.blocked.delete(peerId);
        
        list.querySelectorAll(`[data-peer="${CSS.escape(peerId)}"][data-muted="1"]`).forEach(b => {
          b.style.display = '';
          delete b.dataset.muted;
        });
        this._syncMuteButtons(list);
        this.systemMsg('🔊 Unmuted — you can now see messages from this user');
      } else {
        _p2pBlockPeer(peerId);
        chatStore.blocked.add(peerId);
        
        list.querySelectorAll(`[data-peer="${CSS.escape(peerId)}"]`).forEach(b => {
          b.style.display = 'none';
          b.dataset.muted = '1';
        });
        this._syncMuteButtons(list);
        this.systemMsg('🔇 Muted — you won\'t see messages from this user');
      }
    });

    bubble.querySelector('.jv4-act-report')?.addEventListener('click', e => {
      e.stopPropagation();
      if (window.confirm(`Report this message from ${(msg.nick || '?').slice(0, 20)}?`)) {
        GM_xmlhttpRequest({
      redirect: 'manual',
          method:  'POST',
          url:     `${P2P_RELAY}/${P2P_TOPIC_REPORTS}`,
          headers: { 'Content-Type': 'text/plain' },
          data:    JSON.stringify({ v:1, reporter: _p2pGetPeerId(), reported: msg.peer, nick: msg.nick, text: msg.text, ts: Date.now() }),
          onload() { topToast('Report submitted to admin'); },
        });
      }
    });

    list.appendChild(bubble);
    
    this._maybeScroll(list);
  },

  systemMsg(text) {
    const list = chatStore.listEl;
    if (!list) return;
    const el = document.createElement('div');
    el.className = 'jv4-p2p-bubble jv4-p2p-bubble-system';
    el.textContent = text;
    list.appendChild(el);
    this._maybeScroll(list);
  },

  _maybeScroll(list) {
    const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 60;
    if (atBottom) {
      list.scrollTop = list.scrollHeight;
      this._hideNewMsgBadge();
    } else {
      this._showNewMsgBadge();
    }
  },

  _showNewMsgBadge() {
    if (document.getElementById('jv4-new-msg-badge')) return;
    const badge = document.createElement('button');
    badge.id = 'jv4-new-msg-badge';
    badge.textContent = '↓ New messages';
    badge.style.cssText = `
      position:absolute; bottom:52px; left:50%; transform:translateX(-50%);
      background:rgba(139,92,246,0.9); color:#fff; border:none; border-radius:20px;
      padding:4px 14px; font-size:11px; cursor:pointer; z-index:10000090;
      box-shadow:0 2px 8px rgba(0,0,0,0.5); animation:ms2-up 0.15s ease;
      white-space:nowrap;
    `;
    badge.addEventListener('click', () => {
      const list = chatStore.listEl;
      if (list) list.scrollTop = list.scrollHeight;
      badge.remove();
    });
    const modal = document.getElementById('jv4-p2p-modal');
    if (modal) { modal.style.position = 'relative'; modal.appendChild(badge); }
  },

  _hideNewMsgBadge() {
    document.getElementById('jv4-new-msg-badge')?.remove();
  },

  _syncMuteButtons(list) {
    list.querySelectorAll('.jv4-act-mute[data-peer]').forEach(btn => {
      btn.textContent = chatStore.blocked.has(btn.dataset.peer) ? '🔊' : 'mute';
    });
  },

  applyReaction(msgId, emoji, senderPeer, isMine) {
    const bubble = chatStore.listEl?.querySelector(`[data-msgid="${CSS.escape(msgId)}"]`);
    if (!bubble) return;
    let bar = bubble.querySelector('.jv4-p2p-reactions');
    if (!bar) { bar = document.createElement('div'); bar.className = 'jv4-p2p-reactions'; bubble.appendChild(bar); }
    let rxn = bar.querySelector(`[data-emoji="${CSS.escape(emoji)}"]`);
    if (!rxn) {
      rxn = document.createElement('button');
      rxn.className = 'jv4-p2p-rxn';
      rxn.dataset.emoji = emoji;
      rxn.dataset.count = '0';
      rxn.innerHTML = `${emoji} <span class="jv4-p2p-rxn-count">1</span>`;
      rxn.addEventListener('click', () => _sendReaction(msgId, emoji));
      bar.appendChild(rxn);
    } else {
      const c = parseInt(rxn.dataset.count || '0') + 1;
      rxn.dataset.count = String(c);
      rxn.querySelector('.jv4-p2p-rxn-count').textContent = String(c);
    }
    if (isMine) rxn.classList.add('mine');
  },

  showReactionPicker(anchorBubble, msgId) {
    document.querySelectorAll('.jv4-rxn-picker').forEach(e => e.remove());
    const picker = document.createElement('div');
    picker.className = 'jv4-rxn-picker';
    for (const emoji of P2P_REACTION_EMOJIS) {
      const btn = document.createElement('button');
      btn.className = 'jv4-rxn-pick-btn'; btn.textContent = emoji;
      btn.addEventListener('click', e => { e.stopPropagation(); _sendReaction(msgId, emoji); picker.remove(); });
      picker.appendChild(btn);
    }
    anchorBubble.style.position = 'relative';
    anchorBubble.appendChild(picker);
    const dismiss = e => { if (!picker.contains(e.target)) { picker.remove(); document.removeEventListener('click', dismiss, true); } };
    setTimeout(() => document.addEventListener('click', dismiss, true), 10);
  },

  updatePinnedBar() {
    const bar = document.getElementById('jv4-p2p-pinned-bar');
    if (!bar) return;

    let txt = chatStore.pinnedText;
    try { txt = GM_getValue(P2P_GM_PINNED, '') || ''; } catch { txt = chatStore.pinnedText; }
    chatStore.pinnedText = txt;
    if (txt) {
      bar.innerHTML = `📌 <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(txt)}</span>`;
      bar.classList.add('visible');
    } else {
      bar.innerHTML = '';
      bar.classList.remove('visible');
    }
  },

  updateOnlineCount() {
    const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
    let n = 0; for (const ts of chatStore.onlineMap.values()) if (ts >= cutoff) n++;
    const el = document.getElementById('jv4-p2p-online');
    if (el) el.textContent = n > 0 ? `· ● ${n} online` : '';
  },

  updateStatus(state) {
    const dot = document.getElementById('jv4-p2p-dot');
    const lbl = document.getElementById('jv4-p2p-status-label');
    const room = _p2pGetRoom();
    const charId = _p2pGetCharId();
    const charName = room === 'char' && charId ? (_p2pGetCharName() || 'Character Room') : null;
    const roomLabel = charName ? charName : 'Global Room';
    if (state === 'connected') {
      if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-on';
      if (lbl) lbl.textContent = `Connected · ${roomLabel}`;
    } else if (state === 'reconnecting') {
      if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-warn';
      if (lbl) lbl.textContent = 'Reconnecting…';
    } else {
      if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-off';
      if (lbl) lbl.textContent = 'Connecting…';
    }
  },
};

const chatCmd = {
  execute(text, _senderPeer) {
    const parts  = text.slice(1).trim().split(/\s+/);
    const cmd    = parts[0].toLowerCase();
    const target = parts[1] || '';
    const rest   = parts.slice(1).join(' ');
    const list   = chatStore.listEl;

    switch (cmd) {
      case 'pin': {
        if (!rest) break;
        
        chatStore.pinnedText = rest;
        try { GM_setValue(P2P_GM_PINNED, rest); } catch {  }
        chatRender.updatePinnedBar();
        chatRender.systemMsg('📌 Pinned: ' + rest);
        break;
      }
      case 'unpin': {
        chatStore.pinnedText = '';
        try { GM_setValue(P2P_GM_PINNED, ''); } catch {  }
        chatRender.updatePinnedBar();
        chatRender.systemMsg('📌 Pin cleared');
        break;
      }
      default: {  break; }
    }
  },
};

function _p2pSendMessage(text, inputEl, sendBtnEl) {
  text = text.trim();
  if (!text || text.length > 800) return;

  if (chatStore.isAdmin && text.startsWith('/')) {
    chatCmd.execute(text, _p2pGetPeerId());
    chatNet.send({ v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), text, adminToken: P2P_ADMIN_HASH, ts: Date.now(), room: _p2pGetRoom() });
    return;
  }

  const now = Date.now();
  const elapsed = now - chatStore.lastSend;
  if (elapsed < P2P_RATE_LIMIT_MS) {
    const remaining = Math.ceil((P2P_RATE_LIMIT_MS - elapsed) / 1000);
    topToast(`Slow down — wait ${remaining}s`);
    _applySendCooldown(sendBtnEl, P2P_RATE_LIMIT_MS - elapsed);
    return;
  }
  chatStore.lastSend = now;
  _applySendCooldown(sendBtnEl, P2P_RATE_LIMIT_MS);

  const msgId = [...crypto.getRandomValues(new Uint8Array(5))].map(b => b.toString(16).padStart(2,'0')).join('');
  const msg = {
    v:     1,
    peer:  _p2pGetPeerId(),
    nick:  _p2pGetNickname(),
    text,
    ts:    now,
    room:  _p2pGetRoom(),
    msgId,
  };

  if (chatStore.replyingTo) {
    msg.replyTo = chatStore.replyingTo;
    chatStore.replyingTo = null;
    document.getElementById('jv4-p2p-reply-strip')?.classList.remove('visible');
  }

  chatStore.seenMsgIds.add(msgId);

  if (chatNet.typingSendTimer) { clearTimeout(chatNet.typingSendTimer); chatNet.typingSendTimer = null; }

  _p2pAddHistory(msg);
  chatStore.messages.push(msg);
  chatRender.appendBubble(msg, true, true);
  chatNet.send(msg);

  // Kick an accelerated re-poll shortly after sending so any concurrent
  // incoming messages are picked up without waiting the full poll interval.
  if (chatNet._currentTopic && !chatNet.abortFlag) {
    clearTimeout(chatNet.reconnTimer);
    chatNet.reconnTimer = setTimeout(() => chatNet._startPoll(chatNet._currentTopic), 250);
  }
}

function _applySendCooldown(btn, ms) {
  if (!btn) return;
  btn.disabled = true;
  btn.style.position = 'relative';
  btn.style.overflow = 'hidden';
  
  const bar = document.createElement('span');
  bar.style.cssText = `
    position:absolute; left:0; top:0; height:100%;
    background:rgba(255,255,255,0.25); width:100%;
    transition:width ${ms}ms linear;
    pointer-events:none;
  `;
  btn.appendChild(bar);
  requestAnimationFrame(() => { bar.style.width = '0%'; });
  setTimeout(() => {
    btn.disabled = false;
    bar.remove();
  }, ms);
}

// ─── SEND-FAIL / RATE-LIMIT BANNER ────────────────────────────────────────────
// Shows an in-chat alert when a message fails to reach the relay server.
// type: 'ratelimit' | 'failed' | 'timeout'
// retryPayload: the original message object to re-send on "Retry" (or null)
function _showSendFailBanner(type, retryPayload) {
  // Only show if the chat modal is open — avoid ghost banners
  const barEl = document.getElementById('jv4-p2p-bar');
  if (!barEl) return;

  // Deduplicate: remove any existing banner before showing a new one
  document.getElementById('jv4-send-fail-banner')?.remove();

  const SVG_WARN = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
  const SVG_AIRPLANE = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 4c-1 0-1.5.3-2.5 1L3 11l3.5 2 1.5 4 2-2 2 2Z"/></svg>`;

  const cfg = {
    ratelimit: {
      title:  'Rate limited — your message may not have sent',
      col:    '#fbbf24',
      bg:     'rgba(251,191,36,0.07)',
      border: 'rgba(251,191,36,0.4)',
    },
    failed: {
      title:  'Connection error — message may not have been delivered',
      col:    '#f87171',
      bg:     'rgba(248,113,113,0.07)',
      border: 'rgba(248,113,113,0.4)',
    },
    timeout: {
      title:  'No response from server — message may not have arrived',
      col:    '#fb923c',
      bg:     'rgba(251,146,60,0.07)',
      border: 'rgba(251,146,60,0.4)',
    },
  }[type] ?? {
    title: 'Something went wrong sending your message',
    col: '#f87171', bg: 'rgba(248,113,113,0.07)', border: 'rgba(248,113,113,0.4)',
  };

  const banner = document.createElement('div');
  banner.id = 'jv4-send-fail-banner';
  banner.style.cssText = `background:${cfg.bg};border-color:${cfg.border};`;
  banner.innerHTML = `
    <button class="jv4-sfb-close" title="Dismiss">✕</button>
    <div class="jv4-sfb-header" style="color:${cfg.col};">
      ${SVG_WARN}&nbsp;${cfg.title}
    </div>
    <div class="jv4-sfb-airplane">
      ${SVG_AIRPLANE}
      <span>
        <strong>Quick bypass:</strong> enable <strong>Airplane Mode</strong> for ~5 seconds,
        then turn it off and reconnect your data or Wi-Fi. This refreshes your network session
        and clears the rate-limit — messages should go through immediately after.
      </span>
    </div>
    ${retryPayload ? `<button class="jv4-sfb-retry">\u21ba Retry message</button>` : ''}
  `;

  barEl.insertAdjacentElement('beforebegin', banner);

  // Retry: re-send the original payload without adding another chat bubble
  if (retryPayload) {
    banner.querySelector('.jv4-sfb-retry')?.addEventListener('click', () => {
      banner.remove();
      chatNet.send(retryPayload);
    });
  }

  // Auto-dismiss after 20 s; also dismiss on close button
  const autoDismiss = setTimeout(() => banner.remove(), 20000);
  banner.querySelector('.jv4-sfb-close')?.addEventListener('click', () => {
    clearTimeout(autoDismiss);
    banner.remove();
  });
}

function _markMessageDelivered(msgId) {
  if (!msgId) return;
  const bubble = chatStore.listEl?.querySelector(`[data-msgid="${CSS.escape(msgId)}"]`);
  if (!bubble) return;
  const status = bubble.querySelector('.jv4-delivery-status');
  if (!status) return;
  status.textContent = '✓ Delivered';
  status.className = 'jv4-delivery-status jv4-delivery-ok';
  setTimeout(() => {
    status.style.transition = 'opacity 0.5s ease';
    status.style.opacity = '0';
    setTimeout(() => status.remove(), 500);
  }, 2500);
}

function _sendReaction(reactionTo, emoji) {
  chatNet.send({ v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), type: 'reaction', text: emoji, reactionTo, ts: Date.now() });
  chatRender.applyReaction(reactionTo, emoji, _p2pGetPeerId(), true);
}

function _maybeNotifyReply(msg) {
  if (!msg.replyTo || msg.replyTo.peer !== _p2pGetPeerId()) return;
  const nick = (msg.nick || 'Someone').slice(0, 20);
  chatRender.systemMsg(`💬 ${nick} replied to your message`);
  const body = `${nick} replied: ${msg.text.slice(0, 80)}`;
  if (Notification?.permission === 'granted') {
    new Notification('JanitorV5 Community Chat', { body, tag: 'jv4-reply' });
  } else if (Notification?.permission === 'default') {
    Notification.requestPermission().then(p => {
      if (p === 'granted') new Notification('JanitorV5 Community Chat', { body, tag: 'jv4-reply' });
    });
  }
}

// ─── EMOJI PICKER (input area) ────────────────────────────────────────────────

function _p2pToggleEmojiPicker(input, wrapEl) {
  const existing = document.getElementById('jv4-emoji-picker');
  if (existing) { existing.remove(); return; }
  const picker = document.createElement('div');
  picker.id = 'jv4-emoji-picker';
  for (const e of P2P_CHAT_EMOJIS) {
    const btn = document.createElement('button');
    btn.className = 'jv4-emoji-btn'; btn.textContent = e;
    btn.addEventListener('click', ev => {
      ev.stopPropagation();
      const s = input.selectionStart ?? input.value.length;
      input.value = input.value.slice(0, s) + e + input.value.slice(s);
      input.focus(); input.selectionStart = input.selectionEnd = s + e.length;
    });
    picker.appendChild(btn);
  }
  wrapEl.style.position = 'relative';
  wrapEl.appendChild(picker);
  const dismiss = ev => {
    if (!picker.contains(ev.target) && ev.target.id !== 'jv4-emoji-open') {
      picker.remove(); document.removeEventListener('click', dismiss, true);
    }
  };
  setTimeout(() => document.addEventListener('click', dismiss, true), 10);
}

// ─── BLOCKED USERS PANEL ─────────────────────────────────────────────────────

function _p2pShowBlockedPanel() {
  const existing = document.getElementById('jv4-blocked-panel');
  if (existing) { existing.remove(); return; }

  const blocked = [...chatStore.blocked];
  const panel = document.createElement('div');
  panel.id = 'jv4-blocked-panel';
  panel.style.cssText = `
    position:absolute; bottom:100%; left:0; right:0; margin-bottom:4px;
    background:#1a1625; border:1px solid rgba(139,92,246,0.45);
    border-radius:10px; padding:10px 12px; z-index:10000060;
    box-shadow:0 4px 20px rgba(0,0,0,0.6);
    animation:ms2-up 0.15s cubic-bezier(0.16,1,0.3,1);
    max-height:200px; overflow-y:auto;
  `;

  if (!blocked.length) {
    panel.innerHTML = `<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>`;
  } else {
    const header = document.createElement('div');
    header.style.cssText = 'font-size:10.5px;color:#a78bfa;font-weight:600;margin-bottom:6px;';
    header.textContent = '🔇 Muted users — tap to unmute';
    panel.appendChild(header);

    for (const peerId of blocked) {
      const row = document.createElement('div');
      row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.05);';
      const label = document.createElement('span');
      label.style.cssText = 'font-size:11px;color:#d1d5db;flex:1;font-family:monospace;';
      label.textContent = peerId.slice(0, 12) + '…';
      const btn = document.createElement('button');
      btn.style.cssText = 'font-size:10px;color:#10b981;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:5px;padding:2px 7px;cursor:pointer;';
      btn.textContent = 'Unmute';
      btn.addEventListener('click', () => {
        
        _p2pUnblockPeer(peerId);
        chatStore.blocked.delete(peerId);
        const list = chatStore.listEl;
        if (list) {
          list.querySelectorAll(`[data-peer="${CSS.escape(peerId)}"][data-muted="1"]`).forEach(b => {
            b.style.display = '';
            delete b.dataset.muted;
          });
          chatRender._syncMuteButtons(list);
        }
        chatRender.systemMsg(`🔊 Unmuted ${peerId.slice(0, 8)}…`);
        row.remove();
        if (!panel.querySelector('div[style]')) {
          panel.innerHTML = '<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>';
        }
      });
      row.append(label, btn);
      panel.appendChild(row);
    }
  }

  const dismiss = ev => {
    if (!panel.contains(ev.target) && ev.target.id !== 'jv4-muted-btn') {
      panel.remove(); document.removeEventListener('click', dismiss, true);
    }
  };
  setTimeout(() => document.addEventListener('click', dismiss, true), 0);

  const bar = document.getElementById('jv4-p2p-bar');
  if (bar) { bar.style.position = 'relative'; bar.appendChild(panel); }
}

// ─── EXPORT ───────────────────────────────────────────────────────────────────

function _p2pExportHistory() {
  const hist = _p2pGetHistory();
  if (!hist.length) { topToast('No chat history to export'); return; }
  const lines = hist.map(m => {
    const t = new Date(m.ts).toLocaleString();
    return `[${t}] ${(m.nick || 'Anonymous').slice(0, 24)}#${(m.peer || '').slice(0, 4)}: ${m.text}`;
  });
  const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
  const url  = URL.createObjectURL(blob);
  const a    = document.createElement('a');
  a.href = url; a.download = `jv4-chat-${new Date().toISOString().slice(0, 10)}.txt`;
  a.click(); URL.revokeObjectURL(url);
  topToast('Chat exported');
}

function _esc(str) {
  return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

GM_addStyle(`
  #jv4-p2p-modal .ms2-modal-body { padding: 0; display: flex; flex-direction: column; }
  #jv4-p2p-list {
    flex: 1; overflow-y: auto; padding: 10px 12px; display: flex;
    flex-direction: column; gap: 6px; min-height: 220px; max-height: 320px;
  }
  #jv4-p2p-list::-webkit-scrollbar { width: 3px; }
  #jv4-p2p-list::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.35); border-radius: 3px; }

  .jv4-p2p-bubble {
    max-width: 82%; padding: 6px 10px; border-radius: 10px; font-size: 12.5px;
    line-height: 1.5; word-break: break-word; animation: ms2-up 0.15s ease;
  }
  .jv4-p2p-bubble-me {
    align-self: flex-end; background: rgba(139,92,246,0.22);
    border: 1px solid rgba(139,92,246,0.4); color: #e2d9f3;
  }
  .jv4-delivery-status {
    display: block; font-size: 10px; text-align: right; margin-top: 2px;
  }
  .jv4-delivery-pending { color: rgba(167,139,250,0.45); }
  .jv4-delivery-ok { color: #10b981; }
  .jv4-p2p-bubble-other {
    align-self: flex-start; background: rgba(255,255,255,0.05);
    border: 1px solid rgba(255,255,255,0.1); color: #d1d5db;
  }
  .jv4-p2p-bubble-system {
    align-self: center; font-size: 11px; color: #6b7280;
    background: transparent; border: none; font-style: italic; padding: 2px 0;
  }
  /* FIX-2: Admin gold border — applied to all bubbles from admin once hash resolves */
  .jv4-p2p-bubble-admin {
    border-left: 2px solid rgba(234,179,8,0.65) !important;
    background: rgba(234,179,8,0.04) !important;
  }

  .jv4-p2p-meta { display: flex; align-items: baseline; gap: 5px; margin-bottom: 2px; flex-wrap: wrap; }
  .jv4-p2p-nick { font-size: 11px; font-weight: 600; color: #a78bfa; }
  .jv4-p2p-time { font-size: 10px; color: #6b7280; }
  .jv4-p2p-text { margin: 0; }
  .jv4-p2p-mute {
    font-size: 10px; color: #6b7280; background: none; border: none;
    cursor: pointer; padding: 1px 4px; border-radius: 4px; margin-left: 2px;
    transition: color 0.1s, background 0.1s;
  }
  .jv4-p2p-mute:hover { background: rgba(255,255,255,0.08); color: #d1d5db; }

  /* Quote strip inside bubble */
  .jv4-p2p-quote {
    background: rgba(139,92,246,0.1); border-left: 2px solid #8b5cf6;
    border-radius: 0 4px 4px 0; padding: 3px 7px; margin-bottom: 5px;
    font-size: 10px; color: #a78bfa; max-height: 36px; overflow: hidden;
    text-overflow: ellipsis; white-space: nowrap;
  }

  /* Reaction bar */
  .jv4-p2p-reactions { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
  .jv4-p2p-rxn {
    background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
    border-radius: 10px; padding: 1px 6px; font-size: 12px; cursor: pointer;
    display: flex; align-items: center; gap: 3px; transition: background 0.1s;
    color: #d1d5db; font-family: system-ui,sans-serif;
  }
  .jv4-p2p-rxn:hover { background: rgba(139,92,246,0.18); }
  .jv4-p2p-rxn.mine  { border-color: rgba(139,92,246,0.55); background: rgba(139,92,246,0.15); }
  .jv4-p2p-rxn-count { font-size: 10px; color: #9ca3af; }

  /* Reaction picker popup */
  .jv4-rxn-picker {
    position: absolute; background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
    border-radius: 24px; padding: 4px 8px; display: flex; gap: 2px;
    z-index: 10000060; box-shadow: 0 4px 14px rgba(0,0,0,0.55);
    animation: ms2-up 0.13s ease;
  }
  .jv4-rxn-pick-btn {
    font-size: 16px; background: none; border: none; cursor: pointer;
    border-radius: 50%; padding: 3px; transition: background 0.1s;
  }
  .jv4-rxn-pick-btn:hover { background: rgba(139,92,246,0.2); }

  /* Emoji picker */
  #jv4-emoji-picker {
    position: absolute; bottom: 100%; right: 0; margin-bottom: 6px;
    background: #1a1625; border: 1px solid rgba(139,92,246,0.45);
    border-radius: 10px; padding: 8px; display: grid; grid-template-columns: repeat(6,1fr);
    gap: 3px; z-index: 10000050; box-shadow: 0 4px 20px rgba(0,0,0,0.6);
    animation: ms2-up 0.15s cubic-bezier(0.16,1,0.3,1);
  }
  .jv4-emoji-btn {
    font-size: 18px; background: none; border: none; cursor: pointer;
    border-radius: 6px; padding: 3px; line-height: 1;
    transition: background 0.1s; display: flex; align-items: center; justify-content: center;
  }
  .jv4-emoji-btn:hover { background: rgba(139,92,246,0.2); }

  /* Pinned bar */
  #jv4-p2p-pinned-bar {
    display: none; padding: 5px 12px; background: rgba(139,92,246,0.12);
    border-bottom: 1px solid rgba(139,92,246,0.25); font-size: 11px;
    color: #c4b5fd; cursor: default; flex-shrink: 0;
  }
  #jv4-p2p-pinned-bar.visible { display: flex; align-items: center; gap: 6px; }

  /* Typing indicator */
  #jv4-p2p-typing { min-height: 18px; padding: 2px 12px 0; font-size: 10px; color: #6b7280; font-style: italic; flex-shrink: 0; }

  /* Online count */
  #jv4-p2p-online { font-size: 10px; color: #10b981; margin-left: 8px; }

  /* Reply strip above input */
  #jv4-p2p-reply-strip {
    display: none; align-items: center; gap: 6px;
    padding: 4px 12px; background: rgba(139,92,246,0.08);
    border-top: 1px solid rgba(139,92,246,0.2); font-size: 11px; color: #a78bfa; flex-shrink: 0;
  }
  #jv4-p2p-reply-strip.visible { display: flex; }
  #jv4-p2p-reply-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #jv4-p2p-reply-cancel { background: none; border: none; color: #6b7280; cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
  #jv4-p2p-reply-cancel:hover { color: #ef4444; }

  /* Chat input bar */
  #jv4-p2p-bar {
    display: flex; align-items: center; gap: 5px; padding: 6px 8px;
    border-top: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
  }

  /* Mobile keyboard fix: ensure the input is always tappable */
  #jv4-p2p-input {
    touch-action: manipulation;
    -webkit-user-select: text;
    user-select: text;
    /* Prevent iOS from treating double-tap as zoom, which delays focus */
    -webkit-tap-highlight-color: rgba(139,92,246,0.15);
  }

  /* Room toggle button */
  #jv4-p2p-room-toggle {
    font-size: 11px; cursor: pointer; padding: 3px 8px;
    border-radius: 6px; border: 1px solid rgba(6,182,212,0.4);
    background: rgba(6,182,212,0.1); color: #67e8f9;
    white-space: nowrap; flex-shrink: 0; transition: background 0.15s;
    display: inline-flex; align-items: center; gap: 5px;
    max-width: 130px; overflow: hidden;
  }
  #jv4-p2p-room-toggle:hover { background: rgba(6,182,212,0.2); }
  #jv4-p2p-room-toggle .jv4-toggle-label {
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .jv4-toggle-avatar {
    width: 18px; height: 18px; border-radius: 50%;
    object-fit: cover; flex-shrink: 0;
    border: 1px solid rgba(6,182,212,0.5);
  }

  /* Status bar */
  #jv4-p2p-status {
    font-size: 10.5px; color: #6b7280; padding: 3px 12px 0;
    display: flex; align-items: center; gap: 5px;
  }
  .jv4-p2p-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
  .jv4-p2p-dot-on   { background: #10b981; box-shadow: 0 0 4px #10b981; }
  .jv4-p2p-dot-off  { background: #6b7280; }
  .jv4-p2p-dot-warn { background: #f59e0b; box-shadow: 0 0 4px #f59e0b; animation: jv4-dot-pulse 1.2s ease-in-out infinite; }
  @keyframes jv4-dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

  /* Emoji open button */
  #jv4-emoji-open {
    opacity: 1 !important; background: none; border: none;
    cursor: pointer; padding: 4px 6px; border-radius: 6px; flex-shrink: 0;
    line-height: 1; color: #9ca3af; transition: background 0.1s, color 0.1s;
    display: flex; align-items: center;
  }
  #jv4-emoji-open:hover { background: rgba(139,92,246,0.18); color: #c4b5fd; }

  /* Admin badge in header */
  #jv4-admin-badge {
    display: none; align-items: center; gap: 3px; font-size: 10px;
    background: rgba(234,179,8,0.14); border: 1px solid rgba(234,179,8,0.45);
    color: #fbbf24; border-radius: 10px; padding: 1px 8px;
    margin-left: 8px; font-weight: 700; letter-spacing: .3px;
  }
  #jv4-admin-badge.visible { display: inline-flex; }

  /* Admin command buttons */
  .jv4-adm-btn {
    font-size: 10.5px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
    border: 1px solid rgba(var(--adm-col),0.4); color: rgb(var(--adm-col));
    background: rgba(var(--adm-col),0.08); transition: background 0.15s;
    white-space: nowrap;
  }
  .jv4-adm-btn:hover  { background: rgba(var(--adm-col),0.2); }
  .jv4-adm-btn.active { background: rgba(var(--adm-col),0.25); outline: 1px solid rgb(var(--adm-col)); }

  /* FIX-6: Send button cooldown — disabled state */
  #jv4-p2p-send:disabled { opacity: 0.6; cursor: not-allowed; }

  /* Connection tip banner */
  #jv4-chat-tip {
    display: none; flex-direction: column; gap: 6px;
    margin: 8px 12px 0; padding: 10px 12px;
    background: rgba(6,182,212,0.08); border: 1px solid rgba(6,182,212,0.35);
    border-radius: 10px; font-size: 11.5px; color: #bae6fd;
    animation: ms2-up 0.18s cubic-bezier(0.16,1,0.3,1);
    position: relative;
  }
  #jv4-chat-tip.visible { display: flex; }
  #jv4-chat-tip-title {
    font-weight: 700; font-size: 12px; color: #67e8f9;
    display: flex; align-items: center; gap: 5px;
  }
  #jv4-chat-tip-close {
    position: absolute; top: 6px; right: 8px;
    background: none; border: none; color: #6b7280;
    font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
  }
  #jv4-chat-tip-close:hover { color: #ef4444; }
  #jv4-chat-tip p { margin: 0; line-height: 1.55; }
  #jv4-chat-tip strong { color: #e0f2fe; }
  #jv4-chat-tip-airplane {
    display: flex; align-items: flex-start; gap: 6px;
    padding: 7px 9px; margin-top: 2px;
    background: rgba(139,92,246,0.1); border: 1px solid rgba(139,92,246,0.3);
    border-radius: 8px; font-size: 11px; color: #c4b5fd;
  }
  #jv4-chat-tip-airplane svg { flex-shrink: 0; margin-top: 1px; }

  /* Info button in header */
  #jv4-chat-info-btn {
    background: none; border: 1px solid rgba(6,182,212,0.35);
    border-radius: 50%; width: 20px; height: 20px;
    display: inline-flex; align-items: center; justify-content: center;
    cursor: pointer; color: #67e8f9; font-size: 11px; font-weight: 700;
    line-height: 1; margin-left: 6px; flex-shrink: 0;
    transition: background 0.15s, border-color 0.15s;
  }
  #jv4-chat-info-btn:hover { background: rgba(6,182,212,0.15); border-color: rgba(6,182,212,0.6); }
  #jv4-chat-info-btn.active { background: rgba(6,182,212,0.2); border-color: #67e8f9; }

  /* ── Send-fail / rate-limit alert banner ────────────────────────────── */
  #jv4-send-fail-banner {
    display: flex; flex-direction: column; gap: 7px;
    margin: 0 8px 6px; padding: 9px 28px 9px 11px;
    border-radius: 10px; border: 1px solid;
    font-size: 12px; line-height: 1.5;
    animation: jv4-sfb-in 0.22s ease;
    position: relative;
  }
  @keyframes jv4-sfb-in {
    from { opacity: 0; transform: translateY(5px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  #jv4-send-fail-banner .jv4-sfb-header {
    display: flex; align-items: center; gap: 6px;
    font-weight: 700; font-size: 12px;
  }
  #jv4-send-fail-banner .jv4-sfb-close {
    position: absolute; top: 7px; right: 8px;
    background: none; border: none; color: #6b7280;
    font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
  }
  #jv4-send-fail-banner .jv4-sfb-close:hover { color: #ef4444; }
  #jv4-send-fail-banner .jv4-sfb-airplane {
    display: flex; align-items: flex-start; gap: 7px;
    padding: 6px 8px; border-radius: 7px;
    background: rgba(139,92,246,0.12); border: 1px solid rgba(139,92,246,0.3);
    color: #c4b5fd;
  }
  #jv4-send-fail-banner .jv4-sfb-airplane svg { flex-shrink: 0; margin-top: 2px; }
  #jv4-send-fail-banner .jv4-sfb-retry {
    align-self: flex-start; padding: 4px 12px; border-radius: 6px;
    border: 1px solid rgba(167,139,250,0.5); background: rgba(139,92,246,0.15);
    color: #c4b5fd; font-size: 11px; font-weight: 600; cursor: pointer;
    transition: background 0.15s;
  }
  #jv4-send-fail-banner .jv4-sfb-retry:hover { background: rgba(139,92,246,0.3); }
`);

function _buildChatTipHTML() {
  const SVG_AIRPLANE = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 4c-1 0-1.5.3-2.5 1L3 11l3.5 2 1.5 4 2-2 2 2Z"/></svg>`;
  const SVG_CLOCK = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
  return `
    <div id="jv4-chat-tip-title">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
      Connection &amp; Chat Tips
    </div>
    <button id="jv4-chat-tip-close" title="Dismiss">✕</button>
    <p>
      ${SVG_CLOCK} Messages are relayed via <strong>ntfy.sh</strong> and fetched every
      <strong>8 seconds</strong> — so there's a short delay before others see what you sent.
      Your own bubble shows <strong>✓ Delivered</strong> in green once the server confirms it went through.
      If it stays on "Sending…", something blocked the send — see below.
    </p>
    <p>
      <strong>Message not going through?</strong> A red banner appears with a one-tap
      <strong>Retry</strong> button. Most failures are temporary — retry first before anything else.
      If retries keep failing, the relay may be rate-limiting your connection (HTTP 429).
    </p>
    <div id="jv4-chat-tip-airplane">
      ${SVG_AIRPLANE}
      <span>
        <strong>Fastest rate-limit fix:</strong> toggle <strong>Airplane Mode</strong> on for ~5 s,
        then off again. This resets your carrier session and clears the limit immediately —
        messages go through right after reconnecting.
      </span>
    </div>
    <p style="margin-top:8px;">
      <strong>Rooms</strong> — tap <strong>Global</strong> or <strong>Char Room</strong> to switch.
      Character rooms are isolated per character page; only people viewing the same character see them.
      Global is site-wide and always active.
    </p>
    <p>
      <strong>Mute &amp; report</strong> — hit <em>mute</em> on any bubble to hide that user locally (only you see the change).
      Use ⚑ to report to admins. Your blocked list lives under the <strong>🔇 Muted</strong> button.
    </p>
    <div style="margin-top:10px;padding:9px 11px;background:rgba(139,92,246,0.1);border:1px solid rgba(139,92,246,0.35);border-radius:8px;font-size:11.5px;line-height:1.6;color:#c4b5fd;">
      💜 <strong>Enjoying Community Chat?</strong> Share JanitorV5 with other roleplayers — drop the
      GreasyFork link on TikTok, X, Discord, or Reddit. A short screen recording or honest review
      goes a long way. The bigger the community, the better Chat gets for everyone.
    </div>
  `;
}

function _setupChatTip(modal) {
  const tip     = document.createElement('div');
  tip.id        = 'jv4-chat-tip';
  tip.innerHTML = _buildChatTipHTML();

  const pinnedBar = modal.querySelector('#jv4-p2p-pinned-bar');
  if (pinnedBar) pinnedBar.after(tip);

  const closeBtn  = tip.querySelector('#jv4-chat-tip-close');
  const infoBtn   = modal.querySelector('#jv4-chat-info-btn');

  const hideTip = () => {
    tip.classList.remove('visible');
    if (infoBtn) infoBtn.classList.remove('active');
  };

  const showTip = () => {
    tip.classList.add('visible');
    if (infoBtn) infoBtn.classList.add('active');
    tip.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  };

  closeBtn?.addEventListener('click', () => {
    hideTip();
    try { GM_setValue(P2P_GM_TIP_SEEN, 'true'); } catch {}
  });

  infoBtn?.addEventListener('click', () => {
    if (tip.classList.contains('visible')) { hideTip(); } else { showTip(); }
  });

  const alreadySeen = (() => { try { return GM_getValue(P2P_GM_TIP_SEEN, ''); } catch { return ''; } })();
  if (!alreadySeen) {
    setTimeout(() => showTip(), 400);
  }
}

async function _openP2PChatModal() {

  chatStore.blocked = _p2pGetBlocked();

  chatStore.open       = true;
  chatStore.messages   = [];
  chatStore.seenMsgIds = new Set();
  chatStore.seenNtfyIds = new Set();
  chatStore.replyingTo = null;
  chatStore.onlineMap  = new Map(); // clear stale presence from previous session
  // Reset lastEventId on every fresh open so we don't carry stale IDs from a
  // different room session into the new one.
  chatNet.lastEventId  = null;

  const backdrop = document.createElement('div');
  backdrop.className = 'ms2-backdrop';
  addEscapeClose(backdrop);

  const charId  = _p2pGetCharId();
  const canChar = !!charId;

  // If the stored room is 'char' but we're not on a character page any more,
  // silently reset to 'global' so the topic doesn't collapse and mix rooms.
  let room = _p2pGetRoom();
  if (room === 'char' && !canChar) {
    room = 'global';
    try { GM_setValue(P2P_GM_ROOM, 'global'); } catch {}
  }

  const charDisplayName = canChar ? (_p2pGetCharName() || 'Char Room') : 'Global';
  const charAvatarSrc   = canChar && room === 'char' ? (_p2pGetCharAvatar() || '') : '';
  const roomLabel = room === 'char' && canChar ? charDisplayName : 'Global';

  const modal = document.createElement('div');
  modal.className = 'ms2-modal';
  modal.id = 'jv4-p2p-modal';
  modal.setAttribute('role', 'dialog');
  modal.setAttribute('aria-modal', 'true');
  modal.style.maxWidth = '400px';

  modal.innerHTML = `
    <div class="ms2-modal-header">
      <div class="ms2-modal-title" style="display:flex;align-items:center;flex:1;min-width:0;">
        ${SVG_CHAT} Community Chat
        <span style="font-weight:400;color:#6b7280;font-size:11px;margin-left:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">· ${_esc(_p2pGetNickname())}</span>
        <span id="jv4-admin-badge">👑 ADMIN</span>
        <button id="jv4-chat-info-btn" title="Connection tips, rooms, delivery status &amp; how to share">!</button>
      </div>
      <button class="ms2-modal-close" aria-label="Close">×</button>
    </div>
    <div id="jv4-p2p-status" style="padding:4px 12px 3px;">
      <div id="jv4-p2p-dot" class="jv4-p2p-dot jv4-p2p-dot-off"></div>
      <span id="jv4-p2p-status-label">Connecting…</span>
      <span id="jv4-p2p-online"></span>
    </div>
    <div style="padding:2px 12px 4px;font-size:10px;color:#6b7280;user-select:all;">
      My ID: <span id="jv4-my-peerid"
        style="color:#8b5cf6;cursor:pointer;font-family:monospace;"
        title="Tap to copy">${_esc(_p2pGetPeerId())}</span>
    </div>
    <div id="jv4-p2p-pinned-bar"></div>
    <div class="ms2-modal-body">
      <div id="jv4-p2p-list"></div>
      <div id="jv4-p2p-typing"></div>
      <div id="jv4-p2p-reply-strip">
        <span id="jv4-p2p-reply-text"></span>
        <button id="jv4-p2p-reply-cancel" title="Cancel reply">✕</button>
      </div>
      <div id="jv4-p2p-bar">
        ${canChar ? `<button id="jv4-p2p-room-toggle">${charAvatarSrc && room === 'char' ? `<img class="jv4-toggle-avatar" src="${_esc(charAvatarSrc)}" alt="" onerror="this.style.display='none'">` : ''}<span class="jv4-toggle-label">${_esc(roomLabel)}</span></button>` : ''}
        <button id="jv4-emoji-open" title="Emoji">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
               stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
               style="vertical-align:middle;pointer-events:none">
            <circle cx="12" cy="12" r="10"/>
            <path d="M8 14s1.5 2 4 2 4-2 4-2"/>
            <line x1="9" y1="9" x2="9.01" y2="9"/>
            <line x1="15" y1="9" x2="15.01" y2="9"/>
          </svg>
        </button>
        <input type="text" id="jv4-p2p-input" class="ms2-input"
               placeholder="Say something…" maxlength="800" autocomplete="off"
               inputmode="text" enterkeyhint="send" autocapitalize="sentences"
               spellcheck="false">
        <button id="jv4-p2p-send" class="ms2-btn-action ms2-btn-generate"
                style="padding:6px 12px;">Send</button>
      </div>
    </div>
    <div class="ms2-modal-footer" style="gap:6px;padding:8px 14px;">
      <button id="jv4-p2p-nick-btn"    class="ms2-btn-action ms2-btn-copy" style="font-size:11px;">Nick</button>
      <button id="jv4-p2p-export-btn"  class="ms2-btn-action ms2-btn-copy" style="font-size:11px;">Export</button>
      <button id="jv4-muted-btn"       class="ms2-btn-action ms2-btn-copy" style="font-size:11px;" title="View/manage muted users">🔇 Muted</button>
      <span style="flex:1"></span>
      <button class="ms2-modal-close ms2-btn-action" style="font-size:11px;padding:5px 12px;">Close</button>
    </div>
  `;

  backdrop.appendChild(modal);
  document.body.appendChild(backdrop);

  chatStore.listEl = modal.querySelector('#jv4-p2p-list');

  const visHandler = () => {
    if (!document.hidden && chatStore.open) {
      // Page came back to foreground — reconnect immediately so the user
      // doesn't have to wait for the next poll or stale-timer to fire
      chatNet.backoffMs = P2P_BACKOFF_MIN;
      chatNet.connect();
    }
  };
  document.addEventListener('visibilitychange', visHandler);

  // Show a visual cue when the device loses network so the user understands why
  // messages aren't coming through (instead of just a stuck "Reconnecting" dot)
  const offlineHandler = () => chatRender.updateStatus('reconnecting');
  window.addEventListener('offline', offlineHandler);

  const doClose = () => {
    chatStore.open   = false;
    chatStore.listEl = null;
    chatNet.disconnect();
    document.removeEventListener('visibilitychange', visHandler);
    window.removeEventListener('offline', offlineHandler);
    backdrop.remove();
  };
  modal.querySelectorAll('.ms2-modal-close').forEach(b => b.addEventListener('click', doClose));
  backdrop.addEventListener('click', e => { if (e.target === backdrop) doClose(); });

  modal.querySelector('#jv4-my-peerid')?.addEventListener('click', function () {
    navigator.clipboard.writeText(this.textContent.trim())
      .then(()  => topToast('Peer ID copied!'))
      .catch(() => topToast('Tap and hold to copy manually'));
  });

  chatStore.listEl.addEventListener('scroll', () => {
    const list = chatStore.listEl;
    if (!list) return;
    const near = list.scrollHeight - list.scrollTop - list.clientHeight < 60;
    chatStore.atBottom = near;
    if (near) chatRender._hideNewMsgBadge();
  }, { passive: true });

  chatRender.updatePinnedBar();
  _setupChatTip(modal);

  const myPeer = _p2pGetPeerId();
  // ── History filter: strict per-room — no cross-bleed ─────────────────────
  // Critical: char room must NEVER show untagged (old-format) global messages.
  // The || !m.room fallback is only safe for the global room where untagged
  // messages are legacy global chat. In char rooms it caused global history
  // to appear after closing and reopening the modal.
  const _curRoom = _p2pGetRoom();
  const hist = _p2pGetHistory().filter(m => {
    if (_curRoom === 'char') return m.room === 'char';
    return m.room === 'global' || !m.room; // !m.room = backward compat for pre-room-tag messages
  });
  for (const msg of hist) {
    if (msg.msgId) chatStore.seenMsgIds.add(msg.msgId);
    chatStore.messages.push(msg);
    chatRender.appendBubble(msg, msg.peer === myPeer);
  }

  chatRender.systemMsg('Messages last ~30 min · Relay: ntfy.sh · IPs not shared · polls every 0.8s');

  const list = chatStore.listEl;
  if (list) list.scrollTop = list.scrollHeight;

  _p2pFetchVerified();

  chatNet.connect();

  const input   = modal.querySelector('#jv4-p2p-input');
  const sendBtn = modal.querySelector('#jv4-p2p-send');
  const barEl   = modal.querySelector('#jv4-p2p-bar');

  const doSend = () => {
    const txt = input.value.trim();
    if (!txt) return;
    _p2pSendMessage(txt, input, sendBtn);
    input.value = '';
    input.focus();
    document.getElementById('jv4-emoji-picker')?.remove();
  };

  sendBtn.addEventListener('click', doSend);
  input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } });

  input.addEventListener('input', () => {
    if (chatNet.typingSendTimer) clearTimeout(chatNet.typingSendTimer);
    chatNet.typingSendTimer = setTimeout(() => {
      if (input.value.trim()) chatNet.sendTyping();
      chatNet.typingSendTimer = null;
    }, 1200);
  });

  modal.querySelector('#jv4-p2p-reply-cancel')?.addEventListener('click', () => {
    chatStore.replyingTo = null;
    modal.querySelector('#jv4-p2p-reply-strip')?.classList.remove('visible');
  });

  modal.querySelector('#jv4-emoji-open')?.addEventListener('click', e => {
    e.stopPropagation(); _p2pToggleEmojiPicker(input, barEl);
  });

  modal.querySelector('#jv4-p2p-export-btn')?.addEventListener('click', _p2pExportHistory);
  modal.querySelector('#jv4-muted-btn')?.addEventListener('click', e => { e.stopPropagation(); _p2pShowBlockedPanel(); });

  if (canChar) {
    modal.querySelector('#jv4-p2p-room-toggle')?.addEventListener('click', () => {
      const cur  = _p2pGetRoom();
      const next = cur === 'global' ? 'char' : 'global';
      GM_setValue(P2P_GM_ROOM, next);
      
      const lEl = modal.querySelector('#jv4-p2p-list');
      if (lEl) {
        lEl.innerHTML = '';
        chatStore.messages    = [];
        chatStore.seenMsgIds  = new Set();
        chatStore.seenNtfyIds = new Set(); // clear so new room IDs aren't filtered
      }
      // Reset lastEventId so the poll fetches the last 30 min of the NEW room
      chatNet.lastEventId = null;
      chatNet.connect();

      // Load history for the new room
      const nextHist = _p2pGetHistory().filter(m =>
        next === 'char'
          ? m.room === 'char'
          : (m.room === 'global' || !m.room)
      );
      for (const m of nextHist) {
        if (m.msgId) chatStore.seenMsgIds.add(m.msgId);
        chatStore.messages.push(m);
        chatRender.appendBubble(m, m.peer === myPeer);
      }
      const switchedCharName = next === 'char' ? (_p2pGetCharName() || 'Character') : null;
      chatRender.systemMsg(`Switched to ${switchedCharName ? switchedCharName + '\'s room' : 'Global room'}`);
      const toggle = modal.querySelector('#jv4-p2p-room-toggle');
      if (toggle) {
        const label = next === 'char' ? (switchedCharName || 'Char Room') : 'Global';
        const avatarSrc = next === 'char' ? (_p2pGetCharAvatar() || '') : '';
        toggle.innerHTML = `${avatarSrc ? `<img class="jv4-toggle-avatar" src="${_esc(avatarSrc)}" alt="" onerror="this.style.display='none'">` : ''}<span class="jv4-toggle-label">${_esc(label)}</span>`;
      }
    });
  }

  modal.querySelector('#jv4-p2p-nick-btn')?.addEventListener('click', () => {
    const cur = _p2pGetNickname();
    const nw  = window.prompt('Enter a new nickname (max 24 chars):', cur);
    if (nw === null) return;
    const clean = nw.trim().slice(0, 24) || 'Anonymous';
    GM_setValue(P2P_GM_NICKNAME, clean);
    topToast(`Nickname set to "${clean}"`);
    const sub = modal.querySelector('.ms2-modal-title span');
    if (sub) sub.textContent = `· ${clean}`;
  });

  if (!('ontouchstart' in window)) {
    // Desktop: focus immediately so the user can start typing
    input.focus();
  } else {
    // Mobile: do NOT auto-focus on open (it would pop the keyboard unexpectedly),
    // but DO guarantee that tapping the input box shows the keyboard.
    // Some Android/iOS WebView hosts swallow the native focus event when the
    // tap goes through a stacked modal — the fixes below re-assert it.
    input.addEventListener('touchstart', e => {
      // Stop the modal/backdrop from stealing this touch before focus fires
      e.stopPropagation();
    }, { passive: true });

    input.addEventListener('touchend', e => {
      e.stopPropagation();
      // A small delay lets the browser settle the touch before we force-focus,
      // which is the reliable way to open the soft keyboard on iOS/Android.
      setTimeout(() => {
        input.focus();
        // Scroll the input into view in case the keyboard pushed content up
        input.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      }, 30);
    }, { passive: true });
  }

  _setupAdminUnlock(modal, input);
}

function _buildAdminBar(modal, inputEl) {
  const adminBar = document.createElement('div');
  adminBar.id = 'jv4-admin-bar';
  adminBar.style.cssText = 'display:flex;flex-direction:column;gap:5px;padding:6px 12px 8px;border-top:1px solid rgba(234,179,8,0.25);background:rgba(234,179,8,0.04);';
  adminBar.innerHTML = `
    <div style="font-size:10px;color:rgba(234,179,8,0.8);font-weight:700;letter-spacing:.5px;margin-bottom:1px;">
      👑 ADMIN COMMANDS
    </div>
    <div id="jv4-admin-btns" style="display:flex;flex-wrap:wrap;gap:4px;">
      <button data-cmd="pin"   class="jv4-adm-btn" style="--adm-col:139,92,246">📌 Pin</button>
      <button data-cmd="unpin" class="jv4-adm-btn jv4-adm-noarg" style="--adm-col:107,114,128">🗑 Unpin</button>
    </div>
    <div style="display:flex;gap:5px;align-items:center;">
      <input id="jv4-admin-cmd" class="ms2-input"
        placeholder="/pin your message here…"
        style="flex:1;margin:0;font-size:11px;" maxlength="300">
      <button id="jv4-admin-send"
        style="background:rgba(234,179,8,0.85);color:#000;border:none;border-radius:6px;
               font-size:11px;font-weight:700;padding:5px 10px;cursor:pointer;flex-shrink:0;">⚡ Run</button>
    </div>
  `;

  modal.querySelector('#jv4-p2p-bar').after(adminBar);

  const cmdInput = adminBar.querySelector('#jv4-admin-cmd');
  const sendBtn  = adminBar.querySelector('#jv4-admin-send');

  adminBar.querySelectorAll('.jv4-adm-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const cmd   = btn.dataset.cmd;
      const noArg = btn.classList.contains('jv4-adm-noarg');
      adminBar.querySelectorAll('.jv4-adm-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      if (noArg) {
        _p2pSendMessage('/' + cmd, inputEl, null);
        btn.classList.remove('active');
        cmdInput.value = '';
      } else {
        cmdInput.value = '/pin ';
        cmdInput.focus();
        cmdInput.setSelectionRange(cmdInput.value.length, cmdInput.value.length);
      }
    });
  });

  const runCmd = () => {
    const cmd = cmdInput.value.trim();
    if (!cmd) return;
    _p2pSendMessage(cmd.startsWith('/') ? cmd : '/pin ' + cmd, inputEl, null);
    cmdInput.value = '';
    adminBar.querySelectorAll('.jv4-adm-btn').forEach(b => b.classList.remove('active'));
  };
  sendBtn.addEventListener('click', runCmd);
  cmdInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); runCmd(); } });
}

function _setupAdminUnlock(modal, inputEl) {
  let tapCount = 0;
  let tapTimer = null;

  const titleEl = modal.querySelector('.ms2-modal-title');
  if (!titleEl) return;
  titleEl.style.cursor = 'default';

  const unlockPanel = document.createElement('div');
  unlockPanel.id = 'jv4-admin-unlock';
  unlockPanel.style.cssText = [
    'display:none;flex-direction:row;gap:6px;align-items:center;',
    'padding:6px 12px 8px;border-top:1px solid rgba(234,179,8,0.2);',
    'background:rgba(234,179,8,0.04);'
  ].join('');
  unlockPanel.innerHTML = `
    <input id="jv4-admin-pw" type="password" class="ms2-input"
      placeholder="Admin password…"
      style="flex:1;margin:0;font-size:12px;" maxlength="80" autocomplete="off">
    <button id="jv4-admin-unlock-btn"
      style="background:rgba(234,179,8,0.85);color:#000;border:none;border-radius:6px;
             font-size:11px;font-weight:700;padding:5px 10px;cursor:pointer;white-space:nowrap;flex-shrink:0;">
      🔑 Unlock
    </button>
  `;

  const statusBar = modal.querySelector('#jv4-p2p-status');
  if (statusBar) statusBar.after(unlockPanel);

  titleEl.addEventListener('click', () => {
    tapCount++;
    clearTimeout(tapTimer);
    if (tapCount >= 3) {
      tapCount = 0;
      const showing = unlockPanel.style.display !== 'none';
      unlockPanel.style.display = showing ? 'none' : 'flex';
      if (!showing) unlockPanel.querySelector('#jv4-admin-pw')?.focus();
    } else {
      tapTimer = setTimeout(() => { tapCount = 0; }, 1500);
    }
  });

  const tryUnlock = async () => {
    const pwEl = unlockPanel.querySelector('#jv4-admin-pw');
    if (!pwEl || !pwEl.value) return;
    const hash = await _sha256(pwEl.value);
    if (hash === P2P_ADMIN_HASH) {
      chatStore.isAdmin = true;
      unlockPanel.style.display = 'none';
      modal.querySelector('#jv4-admin-badge')?.classList.add('visible');
      _buildAdminBar(modal, inputEl);
      topToast('👑 Admin unlocked');
    } else {
      topToast('❌ Wrong password');
      pwEl.value = '';
      pwEl.focus();
    }
  };

  unlockPanel.querySelector('#jv4-admin-unlock-btn')?.addEventListener('click', tryUnlock);
  unlockPanel.querySelector('#jv4-admin-pw')?.addEventListener('keydown', e => {
    if (e.key === 'Enter') { e.preventDefault(); tryUnlock(); }
  });
}

function _openP2PConsentModal(onAccept) {
  const backdrop = document.createElement('div');
  backdrop.className = 'ms2-backdrop';
  addEscapeClose(backdrop);
  backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });

  const modal = document.createElement('div');
  modal.className = 'ms2-modal';
  modal.setAttribute('role', 'dialog');
  modal.setAttribute('aria-modal', 'true');
  modal.style.maxWidth = '400px';
  modal.innerHTML = `
    <div class="ms2-modal-header">
      <div class="ms2-modal-title">${SVG_WARNING} Before you join</div>
      <button class="ms2-modal-close">×</button>
    </div>
    <div class="ms2-modal-body" style="padding:14px 16px;">
      <div class="ms2-tip" style="border-color:rgba(6,182,212,0.45);color:#67e8f9;margin-bottom:12px;">
        ${SVG_INFO} Community Chat uses <strong>ntfy.sh</strong> as a free relay server.
      </div>
      <p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
        <strong style="color:#e5e7eb;">What the relay sees:</strong> your IP address, timestamps, and message content.
      </p>
      <p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
        <strong style="color:#e5e7eb;">What other users see:</strong> your nickname and messages only. Your IP is
        <em>never</em> shared with other users.
      </p>
      <p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
        <strong style="color:#e5e7eb;">Identity:</strong> you get a random anonymous peer ID — no login, no email.
      </p>
      <p style="margin:0 0 6px;font-size:12px;color:#6b7280;line-height:1.5;">
        Messages are stored on ntfy.sh servers for ~12 hours then automatically deleted.
        Use a VPN for additional IP privacy. Change <code style="color:#a78bfa;">P2P_RELAY</code> to self-host.
      </p>
      <div style="margin-top:12px;">
        <label style="font-size:12.5px;color:#c4b5fd;"><strong>Your nickname</strong></label>
        <input type="text" id="jv4-consent-nick" class="ms2-input"
               style="margin-top:5px;" maxlength="24"
               placeholder="Anonymous (can be changed later)"
               value="${_esc(_p2pGetNickname())}">
      </div>
    </div>
    <div class="ms2-modal-footer" style="gap:8px;">
      <button class="ms2-modal-close ms2-btn-action ms2-btn-copy">Cancel</button>
      <button id="jv4-consent-ok" class="ms2-btn-action ms2-btn-generate">Join Community Chat</button>
    </div>
  `;

  backdrop.appendChild(modal);
  document.body.appendChild(backdrop);
  modal.querySelectorAll('.ms2-modal-close').forEach(b => b.addEventListener('click', () => backdrop.remove()));
  modal.querySelector('#jv4-consent-ok').addEventListener('click', () => {
    const nick = (modal.querySelector('#jv4-consent-nick').value.trim() || 'Anonymous').slice(0, 24);
    GM_setValue(P2P_GM_NICKNAME, nick);
    GM_setValue(P2P_GM_ENABLED, 'true');
    backdrop.remove();
    onAccept();
  });
}

function handleCommunityChat() {
  if (chatStore.open) return;
  if (GM_getValue(P2P_GM_ENABLED, 'false') !== 'true') {
    _openP2PConsentModal(_openP2PChatModal);
  } else {
    _openP2PChatModal();
  }
}

// ─── TYPING INDICATOR ────────────────────────────────────────────────────────

function _p2pSendTyping() {
  GM_xmlhttpRequest({
      redirect: 'manual',
    method:'POST', url:`${P2P_RELAY}/${P2P_TOPIC_TYPING}`,
    headers:{'Content-Type':'text/plain'},
    data:JSON.stringify({v:1,peer:_p2pGetPeerId(),nick:_p2pGetNickname(),type:'typing',ts:Date.now()}),
  });
}

function _p2pHandleTypingEvent(evt) {
  try {
    const msg = JSON.parse(evt.message);
    if (!msg||msg.type!=='typing'||!msg.peer||msg.peer===_p2pGetPeerId()) return;
    if (_p2pGetBlocked().has(msg.peer)) return;
    const el = document.getElementById('jv4-p2p-typing');
    if (!el) return;

    if (_cs.typingTimers.has(msg.peer)) clearTimeout(_cs.typingTimers.get(msg.peer));

    let span = el.querySelector(`[data-typing-peer="${msg.peer}"]`);
    if (!span) {
      span = document.createElement('span');
      span.dataset.typingPeer = msg.peer;
      span.style.cssText = 'margin-right:4px;';
    }
    span.textContent = (msg.nick||'Someone').slice(0,20);
    el.appendChild(span);

    _p2pRenderTypingText(el);

    const t = setTimeout(() => {
      el.querySelector(`[data-typing-peer="${msg.peer}"]`)?.remove();
      _p2pRenderTypingText(el);
      _cs.typingTimers.delete(msg.peer);
    }, P2P_TYPING_TTL);
    _cs.typingTimers.set(msg.peer, t);
  } catch {}
}

function _p2pRenderTypingText(el) {
  
  [...el.childNodes].filter(n=>n.nodeType===3).forEach(n=>n.remove());
  const peers = el.querySelectorAll('[data-typing-peer]');
  if (!peers.length) return;
  const names = [...peers].map(s=>s.textContent).join(', ');
  el.appendChild(document.createTextNode(` ${names} ${peers.length===1?'is':'are'} typing…`));
}

function _p2pStartTypingPoll() {
  _p2pStopTypingPoll();
  const doPoll = () => {
    GM_xmlhttpRequest({
      redirect: 'manual',
      method:'GET',
      url:`${P2P_RELAY}/${P2P_TOPIC_TYPING}/json?since=${_cs.typingLastId}&poll=1`,
      headers:{'Accept':'application/x-ndjson'},
      timeout:8000,
      onload(r) {
        if (!r.responseText?.trim()) return;
        r.responseText.trim().split('\n').forEach(line => {
          try { const e=JSON.parse(line); if(e.id)_cs.typingLastId=e.id; if(e.event==='message')_p2pHandleTypingEvent(e); } catch {}
        });
      },
    });
  };
  doPoll();
  _cs.typingPollTimer = setInterval(doPoll, 5000);
}

function _p2pStopTypingPoll() {
  if (_cs.typingPollTimer) { clearInterval(_cs.typingPollTimer); _cs.typingPollTimer=null; }
  if (_cs.typingSendTimer) { clearTimeout(_cs.typingSendTimer); _cs.typingSendTimer=null; }
}

// ─── HEARTBEAT / ONLINE COUNT ─────────────────────────────────────────────────

function _p2pSendHb() {
  const myId = _p2pGetPeerId();
  _cs.onlineMap.set(myId, Date.now());
  _p2pUpdateOnlineCount();
  GM_xmlhttpRequest({
      redirect: 'manual',
    method:'POST', url:`${P2P_RELAY}/${P2P_TOPIC_HB}`,
    headers:{'Content-Type':'text/plain'},
    data:JSON.stringify({v:1,peer:myId,type:'hb',ts:Date.now()}),
  });
}

function _p2pHandleHbEvent(evt) {
  try {
    const msg = JSON.parse(evt.message);
    if (!msg||msg.type!=='hb'||!msg.peer) return;
    _cs.onlineMap.set(msg.peer, Date.now());
    const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
    for (const [peer,ts] of _cs.onlineMap) if (ts<cutoff) _cs.onlineMap.delete(peer);
    _p2pUpdateOnlineCount();
  } catch {}
}

function _p2pUpdateOnlineCount() {
  const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
  let n=0; for (const ts of _cs.onlineMap.values()) if(ts>=cutoff) n++;
  const el = document.getElementById('jv4-p2p-online');
  if (el) el.textContent = n>0 ? `· ● ${n} online` : '';
}

function _p2pStartHb() {
  _p2pStopHb();
  _p2pSendHb();
  _cs.hbSendTimer = setInterval(_p2pSendHb, P2P_HB_SEND_MS);
  const doPoll = () => {
    GM_xmlhttpRequest({
      redirect: 'manual',
      method:'GET', url:`${P2P_RELAY}/${P2P_TOPIC_HB}/json?since=${_cs.hbLastId}&poll=1`,
      headers:{'Accept':'application/x-ndjson'}, timeout:8000,
      onload(r) {
        if (!r.responseText?.trim()) return;
        r.responseText.trim().split('\n').forEach(line => {
          try { const e=JSON.parse(line); if(e.id)_cs.hbLastId=e.id; if(e.event==='message')_p2pHandleHbEvent(e); } catch {}
        });
      },
    });
  };
  doPoll();
  _cs.hbPollTimer = setInterval(doPoll, 35000);
}

function _p2pStopHb() {
  if (_cs.hbSendTimer) { clearInterval(_cs.hbSendTimer); _cs.hbSendTimer=null; }
  if (_cs.hbPollTimer) { clearInterval(_cs.hbPollTimer); _cs.hbPollTimer=null; }
}

// ─── REACTIONS ────────────────────────────────────────────────────────────────

function _p2pSendReaction(reactionTo, emoji) {
  GM_xmlhttpRequest({
      redirect: 'manual',
    method:'POST', url:`${P2P_RELAY}/${_p2pGetTopic(_p2pGetRoom())}`,
    headers:{'Content-Type':'text/plain'},
    data:JSON.stringify({v:1,peer:_p2pGetPeerId(),nick:_p2pGetNickname(),
                         type:'reaction',text:emoji,reactionTo,ts:Date.now()}),
  });
  _p2pApplyReaction(reactionTo, emoji, _p2pGetPeerId(), true);
}

function _p2pApplyReaction(msgId, emoji, senderPeer, isMine) {
  const bubble = document.querySelector(`[data-msgid="${msgId}"]`);
  if (!bubble) return;
  let bar = bubble.querySelector('.jv4-p2p-reactions');
  if (!bar) { bar=document.createElement('div'); bar.className='jv4-p2p-reactions'; bubble.appendChild(bar); }
  const existing = [...bar.querySelectorAll('.jv4-p2p-rxn')].find(b=>b.dataset.emoji===emoji);
  if (existing) {
    const cnt = existing.querySelector('.jv4-p2p-rxn-count');
    cnt.textContent = String(Number(cnt.textContent||0)+1);
    if (isMine) existing.classList.add('mine');
  } else {
    const btn = document.createElement('button');
    btn.className = `jv4-p2p-rxn${isMine?' mine':''}`;
    btn.dataset.emoji = emoji;
    btn.innerHTML = `${emoji}<span class="jv4-p2p-rxn-count">1</span>`;
    btn.title = senderPeer.slice(0,8);
    btn.addEventListener('click', () => _p2pSendReaction(msgId, emoji));
    bar.appendChild(btn);
  }
}

function _p2pShowReactionPicker(anchorBubble, msgId) {
  document.querySelector('.jv4-rxn-picker')?.remove();
  const picker = document.createElement('div');
  picker.className = 'jv4-rxn-picker';
  P2P_REACTION_EMOJIS.forEach(emoji => {
    const btn = document.createElement('button');
    btn.className='jv4-rxn-pick-btn'; btn.textContent=emoji;
    btn.addEventListener('click', e => { e.stopPropagation(); _p2pSendReaction(msgId,emoji); picker.remove(); });
    picker.appendChild(btn);
  });
  anchorBubble.style.position='relative';
  anchorBubble.appendChild(picker);
  const dismiss = e => { if(!picker.contains(e.target)){picker.remove();document.removeEventListener('click',dismiss,true);} };
  setTimeout(()=>document.addEventListener('click',dismiss,true),10);
}

// ─── REPORT ──────────────────────────────────────────────────────────────────

function _p2pReport(msg) {
  GM_xmlhttpRequest({
      redirect: 'manual',
    method:'POST', url:`${P2P_RELAY}/${P2P_TOPIC_REPORTS}`,
    headers:{'Content-Type':'text/plain'},
    data:JSON.stringify({v:1,reporter:_p2pGetPeerId(),reported:msg.peer,
                         nick:msg.nick,text:msg.text,ts:Date.now()}),
    onload() { topToast('Report submitted to admin'); },
  });
}

function _p2pExportHistory() {
  const hist = _p2pGetHistory();
  if (!hist.length) { topToast('No chat history to export'); return; }
  const lines = hist.map(m => {
    const t = new Date(m.ts).toLocaleString();
    return `[${t}] ${(m.nick||'Anonymous').slice(0,24)}#${(m.peer||'').slice(0,4)}: ${m.text}`;
  });
  const blob = new Blob([lines.join('\n')],{type:'text/plain'});
  const url  = URL.createObjectURL(blob);
  const a    = document.createElement('a');
  a.href=url; a.download=`jv4-chat-${new Date().toISOString().slice(0,10)}.txt`;
  a.click(); URL.revokeObjectURL(url);
  topToast('Chat exported');
}

function _p2pShowBlockedPanel() {
  const existing = document.getElementById('jv4-blocked-panel');
  if (existing) { existing.remove(); return; }

  const blocked = [..._p2pGetBlocked()];
  const panel = document.createElement('div');
  panel.id = 'jv4-blocked-panel';
  panel.style.cssText = `
    position:absolute; bottom:100%; left:0; right:0; margin-bottom:4px;
    background:#1a1625; border:1px solid rgba(139,92,246,0.45);
    border-radius:10px; padding:10px 12px; z-index:10000060;
    box-shadow:0 4px 20px rgba(0,0,0,0.6);
    animation:ms2-up 0.15s cubic-bezier(0.16,1,0.3,1);
    max-height:200px; overflow-y:auto;
  `;

  if (!blocked.length) {
    panel.innerHTML = `<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>`;
  } else {
    panel.innerHTML = `<div style="font-size:10.5px;color:#a78bfa;font-weight:600;margin-bottom:6px;">🔇 Muted users — tap to unmute</div>`;
    blocked.forEach(peerId => {
      const row = document.createElement('div');
      row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.05);';
      row.innerHTML = `
        <span style="font-size:11px;color:#d1d5db;flex:1;font-family:monospace;">${peerId.slice(0,12)}…</span>
        <button style="font-size:10px;color:#10b981;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:5px;padding:2px 7px;cursor:pointer;">Unmute</button>
      `;
      row.querySelector('button').addEventListener('click', () => {
        _p2pUnblockPeer(peerId);
        row.remove();
        _p2pSystemMsg(`✅ Unmuted ${peerId.slice(0,8)}…`);
        
        _p2pSyncMuteButtons();
        if (!panel.querySelector('div[style]')) {
          panel.innerHTML = `<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>`;
        }
      });
      panel.appendChild(row);
    });
  }

  const dismiss = ev => {
    if (!panel.contains(ev.target) && ev.target.id!=='jv4-muted-btn') {
      panel.remove(); document.removeEventListener('click',dismiss,true);
    }
  };
  setTimeout(()=>document.addEventListener('click',dismiss,true),0);

  const bar = document.getElementById('jv4-p2p-bar');
  if (bar) { bar.style.position='relative'; bar.appendChild(panel); }
}

function _p2pSyncMuteButtons() {
  const blocked = _p2pGetBlocked();
  const list = _cs.listEl || document.getElementById('jv4-p2p-list');
  if (!list) return;
  list.querySelectorAll('.jv4-act-mute[data-peer]').forEach(btn => {
    btn.textContent = blocked.has(btn.dataset.peer) ? '🔊' : 'mute';
  });
}

// ─── REPLY NOTIFICATION ───────────────────────────────────────────────────────

function _p2pMaybeNotifyReply(msg) {
  if (!msg.replyTo || msg.replyTo.peer !== _p2pGetPeerId()) return;
  const nick = (msg.nick||'Someone').slice(0,20);
  _p2pSystemMsg(`💬 ${nick} replied to your message`);
  if (Notification?.permission==='granted') {
    new Notification('JanitorV5 Community Chat',{body:`${nick} replied: ${msg.text.slice(0,80)}`,tag:'jv4-reply'});
  } else if (Notification?.permission==='default') {
    Notification.requestPermission().then(p => {
      if (p==='granted') new Notification('JanitorV5 Community Chat',{body:`${nick} replied: ${msg.text.slice(0,80)}`,tag:'jv4-reply'});
    });
  }
}

// ─── PINNED BANNER ────────────────────────────────────────────────────────────

function _p2pRefreshPinnedBar() {
  const bar = document.getElementById('jv4-p2p-pinned-bar');
  if (!bar) return;
  const txt = GM_getValue(P2P_GM_PINNED,'');
  if (txt) {
    bar.innerHTML = `📌 <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${txt.replace(/</g,'&lt;')}</span>`;
    bar.classList.add('visible');
  } else {
    bar.innerHTML = '';
    bar.classList.remove('visible');
  }
}

function _p2pToggleEmojiPicker(input, wrapEl) {
  const existing = document.getElementById('jv4-emoji-picker');
  if (existing) { existing.remove(); return; }
  const picker = document.createElement('div');
  picker.id = 'jv4-emoji-picker';
  P2P_CHAT_EMOJIS.forEach(e => {
    const btn = document.createElement('button');
    btn.className='jv4-emoji-btn'; btn.textContent=e;
    btn.addEventListener('click', ev => {
      ev.stopPropagation();
      const s = input.selectionStart ?? input.value.length;
      input.value = input.value.slice(0,s)+e+input.value.slice(s);
      input.focus(); input.selectionStart=input.selectionEnd=s+e.length;
    });
    picker.appendChild(btn);
  });
  wrapEl.style.position='relative';
  wrapEl.appendChild(picker);
  const dismiss = ev => {
    if (!picker.contains(ev.target)&&ev.target.id!=='jv4-emoji-open') {
      picker.remove(); document.removeEventListener('click',dismiss,true);
    }
  };
  setTimeout(()=>document.addEventListener('click',dismiss,true),10);
}

// ─── ADMIN COMMAND DISPATCHER ────────────────────────────────────────────────

function _p2pExecAdminCmd(text) {
  const parts  = text.slice(1).trim().split(/\s+/);
  const cmd    = parts[0].toLowerCase();
  const target = parts[1] || '';
  const rest   = parts.slice(1).join(' ');
  
  const list   = (typeof _p2pListEl !== 'undefined' ? _p2pListEl : null)
               || document.getElementById('jv4-p2p-list');

  if (cmd === 'ban' && target) {
    _p2pBlockPeer(target);
    
    if (list) list.querySelectorAll(`[data-peer="${target}"]`).forEach(el => { el.style.display = 'none'; });
    _p2pSystemMsg('🚫 Admin banned ' + target.slice(0, 8) + '…');
    return;
  }
  if (cmd === 'mute' && target) {
    _p2pBlockPeer(target);
    if (list) list.querySelectorAll(`[data-peer="${target}"]`).forEach(el => { el.style.display = 'none'; });
    _p2pSystemMsg('🔇 Admin muted ' + target.slice(0, 8) + '…');
    return;
  }
  if ((cmd === 'unban' || cmd === 'unmute') && target) {
    _p2pUnblockPeer(target);
    
    if (list) list.querySelectorAll(`[data-peer="${target}"]`).forEach(el => { el.style.display = ''; });
    if (typeof _p2pSyncMuteButtons === 'function') _p2pSyncMuteButtons();
    _p2pSystemMsg('✅ Admin un' + (cmd === 'unmute' ? 'muted' : 'banned') + ' ' + target.slice(0, 8) + '…');
    return;
  }
  if (cmd === 'announce' && rest) {
    _p2pSystemMsg('📢 ' + rest);
    return;
  }
  if (cmd === 'pin' && rest) {
    GM_setValue(P2P_GM_PINNED, rest);
    _p2pRefreshPinnedBar();
    _p2pSystemMsg('📌 Pinned: ' + rest);
    return;
  }
  if (cmd === 'unpin') {
    GM_setValue(P2P_GM_PINNED, '');
    _p2pRefreshPinnedBar();
    _p2pSystemMsg('📌 Pin cleared');
    return;
  }
  if (cmd === 'clear') {
    if (list) list.innerHTML = '';
    _p2pSystemMsg('Chat cleared by admin');
    return;
  }
  
}

  // ─── MODELS ────────────────────────────────────────────────────────────────

  // ── PROVIDER ENDPOINTS ───────────────────────────────────────────────────────
  const PROVIDER_ENDPOINTS = {
    openrouter:  'https://openrouter.ai/api/v1',
    openai:      'https://api.openai.com/v1',
    xai:         'https://api.x.ai/v1',
    mistral:     'https://api.mistral.ai/v1',
    groq:        'https://api.groq.com/openai/v1',
    anthropic:   'https://api.anthropic.com',
  };
  const KNOWN_EPS = Object.values(PROVIDER_ENDPOINTS);

  // ── MODEL CATALOG ─────────────────────────────────────────────────────────
  // group = displayed as <optgroup label> in the model picker
  // Works best when the chosen endpoint matches the group's provider.
  // All OpenRouter models use the openrouter.ai endpoint.
  // Native provider models use their own endpoint (xAI, Anthropic, Mistral, Groq).
  const MODELS = [

    // ── OpenRouter · Free ─────────────────────────────────────────────────
    { id: 'meta-llama/llama-3.3-70b-instruct:free',           label: 'Llama 3.3 70B',            group: 'OpenRouter · Free' },
    { id: 'meta-llama/llama-3.1-8b-instruct:free',            label: 'Llama 3.1 8B (fast)',       group: 'OpenRouter · Free' },
    { id: 'nousresearch/hermes-3-llama-3.1-405b:free',        label: 'Hermes 3 405B',             group: 'OpenRouter · Free' },
    { id: 'google/gemma-3-27b-it:free',                       label: 'Gemma 3 27B',               group: 'OpenRouter · Free' },
    { id: 'google/gemma-3-4b-it:free',                        label: 'Gemma 3 4B (fast)',         group: 'OpenRouter · Free' },
    { id: 'deepseek/deepseek-r1-distill-llama-70b:free',      label: 'DeepSeek R1 70B',           group: 'OpenRouter · Free' },
    { id: 'deepseek/deepseek-chat-v3-0324:free',              label: 'DeepSeek V3',               group: 'OpenRouter · Free' },
    { id: 'qwen/qwen-2.5-7b-instruct:free',                   label: 'Qwen 2.5 7B',               group: 'OpenRouter · Free' },
    { id: 'mistralai/mistral-7b-instruct:free',               label: 'Mistral 7B',                group: 'OpenRouter · Free' },
    { id: 'microsoft/phi-4:free',                             label: 'Phi-4',                     group: 'OpenRouter · Free' },

    // ── OpenRouter · Claude (paid key) ────────────────────────────────────
    { id: 'anthropic/claude-opus-4',                          label: 'Claude Opus 4',             group: 'OpenRouter · Claude' },
    { id: 'anthropic/claude-sonnet-4-5',                      label: 'Claude Sonnet 4.5',         group: 'OpenRouter · Claude' },
    { id: 'anthropic/claude-3-5-sonnet',                      label: 'Claude 3.5 Sonnet',         group: 'OpenRouter · Claude' },
    { id: 'anthropic/claude-3-5-haiku',                       label: 'Claude 3.5 Haiku (fast)',   group: 'OpenRouter · Claude' },

    // ── OpenRouter · OpenAI (paid key) ────────────────────────────────────
    { id: 'openai/gpt-4o',                                    label: 'GPT-4o',                    group: 'OpenRouter · OpenAI' },
    { id: 'openai/gpt-4o-mini',                               label: 'GPT-4o Mini (fast)',        group: 'OpenRouter · OpenAI' },
    { id: 'openai/gpt-4.1',                                   label: 'GPT-4.1',                   group: 'OpenRouter · OpenAI' },
    { id: 'openai/o4-mini',                                   label: 'o4-mini (reasoning)',        group: 'OpenRouter · OpenAI' },

    // ── OpenRouter · Gemini (paid key) ────────────────────────────────────
    { id: 'google/gemini-2.5-pro',                            label: 'Gemini 2.5 Pro',            group: 'OpenRouter · Gemini' },
    { id: 'google/gemini-2.5-flash',                          label: 'Gemini 2.5 Flash (fast)',   group: 'OpenRouter · Gemini' },
    { id: 'google/gemini-2.0-flash',                          label: 'Gemini 2.0 Flash',          group: 'OpenRouter · Gemini' },

    // ── OpenRouter · Grok (paid key) ──────────────────────────────────────
    { id: 'x-ai/grok-3',                                      label: 'Grok 3',                    group: 'OpenRouter · Grok' },
    { id: 'x-ai/grok-3-mini',                                 label: 'Grok 3 Mini (fast)',        group: 'OpenRouter · Grok' },

    // ── xAI (native — endpoint: api.x.ai/v1) ─────────────────────────────
    { id: 'grok-4.3',                                         label: 'Grok 4.3 (flagship)',       group: 'xAI Grok · Native' },
    { id: 'grok-3',                                           label: 'Grok 3',                    group: 'xAI Grok · Native' },
    { id: 'grok-3-mini',                                      label: 'Grok 3 Mini (fast)',        group: 'xAI Grok · Native' },
    { id: 'grok-2-1212',                                      label: 'Grok 2 (pinned)',           group: 'xAI Grok · Native' },

    // ── Anthropic (native — endpoint: api.anthropic.com) ─────────────────
    { id: 'claude-opus-4-7',                                  label: 'Claude Opus 4.7 (latest)',  group: 'Anthropic · Native' },
    { id: 'claude-opus-4-6',                                  label: 'Claude Opus 4.6',           group: 'Anthropic · Native' },
    { id: 'claude-sonnet-4-6',                                label: 'Claude Sonnet 4.6',         group: 'Anthropic · Native' },
    { id: 'claude-sonnet-4-5-20250929',                       label: 'Claude Sonnet 4.5',         group: 'Anthropic · Native' },
    { id: 'claude-haiku-4-5-20251001',                        label: 'Claude Haiku 4.5 (fast)',   group: 'Anthropic · Native' },

    // ── Mistral (native — endpoint: api.mistral.ai/v1) ───────────────────
    { id: 'mistral-large-latest',                             label: 'Mistral Large 2 (flagship)',group: 'Mistral · Native' },
    { id: 'mistral-medium-latest',                            label: 'Mistral Medium',            group: 'Mistral · Native' },
    { id: 'magistral-medium-latest',                          label: 'Magistral Medium (reason)', group: 'Mistral · Native' },
    { id: 'magistral-small-latest',                           label: 'Magistral Small (reason)',  group: 'Mistral · Native' },
    { id: 'mistral-small-latest',                             label: 'Mistral Small 3.2',         group: 'Mistral · Native' },
    { id: 'devstral-small-2507',                              label: 'Devstral Small (code)',     group: 'Mistral · Native' },
    { id: 'ministral-8b-latest',                              label: 'Ministral 8B (fast)',       group: 'Mistral · Native' },
    { id: 'open-mistral-nemo',                                label: 'Mistral NeMo (free/open)',  group: 'Mistral · Native' },

    // ── Groq (native — endpoint: api.groq.com/openai/v1) ─────────────────
    { id: 'meta-llama/llama-4-maverick-17b-128e-instruct',   label: 'Llama 4 Maverick (fast)',   group: 'Groq · Native' },
    { id: 'meta-llama/llama-4-scout-17b-16e-instruct',       label: 'Llama 4 Scout (fast)',      group: 'Groq · Native' },
    { id: 'llama-3.3-70b-versatile',                         label: 'Llama 3.3 70B',             group: 'Groq · Native' },
    { id: 'llama-3.1-8b-instant',                            label: 'Llama 3.1 8B Instant',      group: 'Groq · Native' },
    { id: 'qwen/qwen3-32b',                                  label: 'Qwen 3 32B (reason)',       group: 'Groq · Native' },
    { id: 'openai/gpt-oss-120b',                             label: 'GPT OSS 120B (reason)',     group: 'Groq · Native' },
    { id: 'openai/gpt-oss-20b',                              label: 'GPT OSS 20B (fast)',        group: 'Groq · Native' },
    { id: 'gemma2-9b-it',                                    label: 'Gemma 2 9B (fast)',         group: 'Groq · Native' },
  ];

  // ─── TONES ─────────────────────────────────────────────────────────────────

  const TONES = [
    { id: 'flirty',     label: 'Flirty',        desc: 'playfully romantic, hinting at attraction without saying it outright' },
    { id: 'teasing',    label: 'Teasing',        desc: 'light mockery and banter, enjoying getting a reaction' },
    { id: 'romantic',   label: 'Romantic',       desc: 'warm, heartfelt, openly affectionate' },
    { id: 'playful',    label: 'Playful',        desc: 'lighthearted, fun, energetic and a little silly' },
    { id: 'cold',       label: 'Cold/Distant',   desc: 'aloof, minimal, emotionally guarded and detached' },
    { id: 'protective', label: 'Protective',     desc: 'territorial, caring, slightly possessive' },
    { id: 'tsundere',   label: 'Tsundere',       desc: 'outwardly dismissive or irritated but secretly caring — contradictory' },
    { id: 'shy',        label: 'Shy',            desc: 'hesitant, soft-spoken, easily flustered, avoids direct eye contact' },
    { id: 'sarcastic',  label: 'Sarcastic',      desc: 'dry wit, ironic, deadpan delivery' },
    { id: 'witty',      label: 'Witty',          desc: 'clever wordplay and sharp humor' },
    { id: 'dominant',   label: 'Dominant',       desc: 'assertive, commanding, takes charge naturally' },
    { id: 'flustered',  label: 'Flustered',      desc: 'caught off guard, stammering, trying to hide embarrassment' },
  ];

  // ─── CONFIG ────────────────────────────────────────────────────────────────

  const CFG = {
    get apiKey()            { return gget('ms2_apiKey', ''); },
    set apiKey(v)           { gset('ms2_apiKey', v); },
    get endpoint()          { return gget('ms2_endpoint', 'https://openrouter.ai/api/v1'); },
    set endpoint(v)         { gset('ms2_endpoint', v); },
    get model()             { return gget('ms2_model', MODELS[0].id); },
    set model(v)            { gset('ms2_model', v); _tierCache = null; _tierCacheModel = null; },
    get fabRight()          { return gget('ms2_fabRight', 16); },
    set fabRight(v)         { gset('ms2_fabRight', v); },
    get fabBottom()         { return gget('ms2_fabBottom', 150); },
    set fabBottom(v)        { gset('ms2_fabBottom', v); },
    get defaultTone()       { return gget('ms2_defaultTone', ''); },
    set defaultTone(v)      { gset('ms2_defaultTone', v); },
    get defaultInstruct()   { return gget('ms2_defaultInstruct', ''); },
    set defaultInstruct(v)  { gset('ms2_defaultInstruct', v); },
    get autoNotify()        { return gget('ms2_autoNotify', false); },
    set autoNotify(v)       { gset('ms2_autoNotify', v); },
    get shortenLength()     { return gget('ms2_shortenLength', 'compact'); },
    set shortenLength(v)    { gset('ms2_shortenLength', v); },
    get keepDialogue()      { return gget('ms2_keepDialogue', false); },
    set keepDialogue(v)     { gset('ms2_keepDialogue', v); },
    get activePreset()      { return gget('ms2_activePreset', null); },
    set activePreset(v)     { gset('ms2_activePreset', v); },
    get authMode()          { return gget('ms2_authMode', 'auto'); },
    set authMode(v)         { gset('ms2_authMode', v); },
  };

  // ─── PRESET HELPERS ────────────────────────────────────────────────────────

  function getPresets() {
    try { return JSON.parse(gget('ms2_presets', '[]')) || []; } catch { return []; }
  }
  function savePresets(arr) { gset('ms2_presets', JSON.stringify(arr)); }

  // ─── ADV. PROMPT STORAGE ───────────────────────────────────────────────────

  let _apPresetsCache = null;
  const AP = {
    get enabled()       { return gget('ap_enabled', false); },
    set enabled(v)      { gset('ap_enabled', v); },
    get selected()      { return gget('ap_selected', ''); },
    set selected(v)     { gset('ap_selected', v); },
    getPresets() {
      if (_apPresetsCache !== null) return _apPresetsCache;
      try { _apPresetsCache = JSON.parse(gget('ap_presets', '[]')) || []; return _apPresetsCache; } catch { return []; }
    },
    savePresets(arr) { _apPresetsCache = null; gset('ap_presets', JSON.stringify(arr)); },
  };

  let _apWorking    = null;  
  let _apDirty      = false; 

  // ─── ADV. PROMPT HELPERS ───────────────────────────────────────────────────

  function apUUID() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
      const r = Math.random() * 16 | 0;
      return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
    });
  }

  function apEstimateTokens(text) {
    
    if (!text) return 0;
    const cjk = (text.match(/[\u3000-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g) || []).length;
    const nonCjk = text.length - cjk;
    return Math.ceil(nonCjk / 4) + cjk;
  }

  function apGetSelected() {
    
    if (_apWorking && _apWorking.id === AP.selected) return _apWorking;
    const id = AP.selected;
    if (!id) return null;
    const preset = AP.getPresets().find(p => p.id === id) || null;
    if (preset) _apWorking = JSON.parse(JSON.stringify(preset));
    return _apWorking;
  }

  function apLoadFromStorage() {
    const id = AP.selected;
    if (!id) { _apWorking = null; _apDirty = false; return null; }
    const preset = AP.getPresets().find(p => p.id === id) || null;
    _apWorking = preset ? JSON.parse(JSON.stringify(preset)) : null;
    _apDirty = false;
    apRefreshSaveBtn();
    return _apWorking;
  }

  function apSaveWorking() {
    if (!_apWorking) return;
    const presets = AP.getPresets();
    const idx = presets.findIndex(p => p.id === _apWorking.id);
    _apWorking.updatedAt = new Date().toISOString();
    if (idx >= 0) presets[idx] = _apWorking; else presets.push(_apWorking);
    AP.savePresets(presets);
    _apWorking = JSON.parse(JSON.stringify(_apWorking));
    _apDirty = false;
    apRefreshSaveBtn();
  }

  function apMarkDirty() {
    _apDirty = true;
    apRefreshSaveBtn();
  }

  function apRefreshSaveBtn() {
    const btn = document.getElementById('ap-save-btn');
    if (!btn) return;
    btn.disabled = !_apDirty;
    btn.classList.toggle('ap-save-dirty', _apDirty);
  }

  function apUpdateStatus(ok) {
    const dot = document.getElementById('ap-status-dot');
    if (!dot) return;
    dot.className = 'ap-status-dot ' + (ok ? 'ap-status-ok' : 'ap-status-fail');
    dot.title = ok
      ? 'Prompt injected successfully into last request'
      : 'Injection failed — check console for details';
  }

  // ─── ADV. PROMPT — FORBIDDEN WORDS ──────────────────────────────────────────

  function getAPForbiddenWords() { return gget('ap_forbidden_words', ''); }
  function setAPForbiddenWords(v) { gset('ap_forbidden_words', v); }
  function getAPThinking()       { return gget('ap_thinking', false); }
  function setAPThinking(v)      { gset('ap_thinking', v); }

  // ─── ADV. PROMPT — COMBINED PROMPT BUILDER ─────────────────────────────────

  function apGetCombinedPrompt() {
    if (!AP.enabled) return null;
    const id = AP.selected;
    if (!id) return null;
    
    const preset = AP.getPresets().find(p => p.id === id);
    if (!preset || !preset.modules || !preset.modules.length) return null;

    const active = preset.modules
      .filter(m => m.attached && m.enabled)
      .sort((a, b) => a.order - b.order);

    if (!active.length) return null;

    let prompt = active.map(m => m.content.trim()).join('\n\n');
    if (!prompt) return null;

    // NOTE: forbidden words are now injected directly into userConfig.bad_words
    // in the fetch interceptor (token-level enforcement, bypasses 10-word UI limit).

    if (getAPThinking()) {
      prompt += '\n\n[Before writing your reply, briefly reason inside <thinking>…</thinking> tags about how the character would react, then write their response outside those tags.]';
    }
    return prompt;
  }

  // ─── ADV. PROMPT — FETCH INTERCEPTOR ──────────────────────────────────────

  function _storeGeneratePayload(bodyStr) {
    try {
      const parsed = JSON.parse(bodyStr);
      GM_setValue(GM_PAYLOAD_KEY, JSON.stringify(parsed));
    } catch {  }
  }

  function initAPInterceptor() {
    if (typeof unsafeWindow === 'undefined') return;

    const _orig = unsafeWindow.fetch;
    unsafeWindow.fetch = async function (...args) {

      try {
      let [resource, config] = args;

      const url = typeof resource === 'string'
        ? resource
        : (resource instanceof Request ? resource.url : '');

      if (url.includes('generateAlpha')) {
        const combined   = apGetCombinedPrompt();
        const ctxInject  = getInjectCtx() ? getContext().trim() : '';
        const extraBans  = getAPForbiddenWords().trim().split('\n').map(w => w.trim()).filter(Boolean);
        if (combined || ctxInject || extraBans.length) {
          try {

            let bodyStr = null;
            if (config && config.body) {
              bodyStr = typeof config.body === 'string'
                ? config.body
                : JSON.stringify(config.body);
            } else if (resource instanceof Request) {
              bodyStr = await resource.clone().text();
            }

            if (bodyStr) {
              const parsed = JSON.parse(bodyStr);
              let injected = false;

              if (parsed.userConfig) {

                // ── llm_prompt injection ──────────────────────────────────
                if (combined || ctxInject) {
                  let finalPrompt = combined || parsed.userConfig.llm_prompt || '';
                  if (ctxInject) {
                    finalPrompt += (finalPrompt ? '\n\n' : '')
                      + '== SCENE CONTEXT (current situation) ==\n' + ctxInject;
                  }
                  parsed.userConfig.llm_prompt = finalPrompt;
                }

                // ── bad_words injection (bypasses 10-word UI limit) ───────
                // Words are merged with the user's native list — no duplicates,
                // no cap — and enforced at the token level by JanitorAI itself.
                if (extraBans.length) {
                  const existing = Array.isArray(parsed.userConfig.bad_words)
                    ? parsed.userConfig.bad_words : [];
                  gset('ap_native_ban_count', String(existing.length));
                  parsed.userConfig.bad_words = [...new Set([...existing, ...extraBans])];
                }

                injected = true;
              }

              if (parsed.chatMessages && _apDeletedFingerprints.size > 0) {
                parsed.chatMessages = parsed.chatMessages.filter(msg => {
                  const raw = typeof msg.content === 'string'
                    ? msg.content
                    : (Array.isArray(msg.content)
                        ? msg.content.map(c => c.text || '').join(' ')
                        : '');
                  const trimmed = raw.trim();
                  if (trimmed.length <= 20) return true;
                  return !_apDeletedFingerprints.has(_hashStr(trimmed));
                });
              }

              const newBody = JSON.stringify(parsed);

              if (config) {
                config.body = newBody;
                args[1] = config;
              } else if (resource instanceof Request) {
                args[0] = new Request(resource, {
                  method:      resource.method,
                  headers:     resource.headers,
                  body:        newBody,
                  mode:        resource.mode,
                  credentials: resource.credentials,
                  cache:       resource.cache,
                  redirect:    resource.redirect,
                  referrer:    resource.referrer,
                });
              }

              apUpdateStatus(injected);
            }
          } catch (e) {
            apUpdateStatus(false);
            console.error('[AdvPrompt] Injection failed:', e);
          }
        }
      }

      if (url.includes('generateAlpha')) {
        try {
          let bodyStr2 = null;
          if (config && config.body) {
            bodyStr2 = typeof config.body === 'string'
              ? config.body
              : JSON.stringify(config.body);
          } else if (resource instanceof Request) {
            
            resource.clone().text().then(bStr => {
              _storeGeneratePayload(bStr);
            }).catch(() => {});
            bodyStr2 = null; 
          }
          if (bodyStr2) _storeGeneratePayload(bodyStr2);
        } catch {  }
      }

      } catch (e) {
        
        console.error('[JanitorV5] fetch interceptor error:', e);
      }
      
      return _orig.apply(this, args);
    };
  }

  // ─── ADV. PROMPT — DELETED MESSAGE TRACKER ────────────────────────────────

  const _apDeletedFingerprints = new Set();

  function _hashStr(s) {
    let h = 5381;
    for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
    return (h >>> 0).toString(36);
  }

  function apWatchDeletions() {

    document.addEventListener('click', e => {
      const btn = e.target.closest('button');
      if (!btn) return;
      
      const msgNode = btn.closest('[data-index]') || btn.closest('[class*="_messageBody_"]')?.closest('[data-index]');
      if (!msgNode) return;
      const label = (btn.getAttribute('aria-label') || btn.title || btn.textContent || '').toLowerCase();
      if (!label.includes('delete') && !label.includes('remove')) return;
      
      const body = msgNode.querySelector('[class*="_messageBody_"]') || msgNode;
      const raw = (body.textContent || '').trim();
      if (raw.length > 20) {
        const fingerprint = _hashStr(raw);
        
        if (_apDeletedFingerprints.size >= 200) {
          const [oldest] = _apDeletedFingerprints;
          _apDeletedFingerprints.delete(oldest);
        }
        _apDeletedFingerprints.add(fingerprint);
      }
    }, true);
  }

  // ─── CONTEXT HELPERS (per chat URL) ────────────────────────────────────────

  function ctxKey() {
    return 'ms2_ctx_' + location.pathname.replace(/[^a-z0-9]/gi, '_').slice(0, 80);
  }
  function getContext()    { return gget(ctxKey(), ''); }
  function saveContext(v)  { gset(ctxKey(), v); }

  // ─── GLOBAL MEMORY STORAGE ─────────────────────────────────────────────────

  function getGlobalMemory()    { return gget('ms2_global_memory', ''); }
  function saveGlobalMemory(v)  { gset('ms2_global_memory', v); }
  function getAutoLoadGlobal()  { return gget('ms2_autoload_global', false); }
  function setAutoLoadGlobal(v) { gset('ms2_autoload_global', v); }
  function getInjectCtx()       { return gget('ms2_inject_ctx', false); }
  function setInjectCtx(v)      { gset('ms2_inject_ctx', v); }
  
  // ─── PERSONA LIBRARY STORAGE ─────────────────────────────────────────────────

  function getPersonaLib()      { return JSON.parse(gget('ms2_persona_lib', '[]')); }
  function savePersonaLib(arr)  { gset('ms2_persona_lib', JSON.stringify(arr)); }
  
  // ─── CHARACTER-SPECIFIC MEMORY ──────────────────────────────────────────────

  function getCurrentCharId() {
    const m = location.pathname.match(/\/chats\/([^/?#]+)/);
    return m ? m[1] : null;
  }
  function getCharGlobalMemory(charId)    { return gget('ms2_global_memory_' + charId, ''); }
  function saveCharGlobalMemory(charId, v) { gset('ms2_global_memory_' + charId, v); }

  // ─── CHAT NAME DETECTION ────────────────────────────────────────────────────

  function extractChatNameFromDOM() {
    
    const raw = (document.title || '').replace(/\s*[-|]\s*(JanitorAI|janitorai\.com|Janitor AI).*/i, '').trim();
    if (raw && raw.length > 0 && raw.length < 100) return raw;

    const selectors = [
      '[class*="characterName"]',
      '[class*="character_name"]',
      '[class*="character-name"]',
      '[class*="chatHeader"] h1',
      '[class*="chatHeader"] h2',
      '[class*="chat-header"] h1',
      '[class*="chat-header"] h2',
      '[class*="ChatHeader"] h1',
      '[class*="ChatHeader"] h2',
      'header h1',
      'header h2',
    ];
    for (const sel of selectors) {
      try {
        const el = document.querySelector(sel);
        const name = el?.textContent?.trim();
        if (name && name.length > 0 && name.length < 100) return name;
      } catch {  }
    }
    return '';
  }

  function getChatName(convKey) { return gget('ms2_cname_' + convKey, ''); }
  function saveChatName(name, convKey) {
    if (name) gset('ms2_cname_' + (convKey || ctxKey()), name);
  }

  // ─── AUTO-SUMMARY STORAGE ──────────────────────────────────────────────────

  function getSumHistory()      { return JSON.parse(gget('ms2_sumhist', '[]')); }
  function saveSumHistory(h)    { gset('ms2_sumhist', JSON.stringify(h)); }
  function addSumHistory(text) {
    const h = getSumHistory();
    h.unshift({ date: new Date().toLocaleString(), text, conv: ctxKey(), charId: getCurrentCharId(), chatName: extractChatNameFromDOM() });
    if (h.length > 20) h.splice(20);
    saveSumHistory(h);
  }
  
  function countSumHistoryForCurrentChat() {
    const key = ctxKey();
    return getSumHistory().filter(h => h.conv === key).length;
  }
  function getAutoSumEvery()    { return parseInt(gget('ms2_asum_every', '0')); }
  function setAutoSumEvery(v)   { gset('ms2_asum_every', v); }
  function getAutoSumAuto()     { return gget('ms2_asum_auto', false); }
  function setAutoSumAuto(v)    { gset('ms2_asum_auto', v); }

  // ─── FAB SUMMARISE — COOLDOWN STATE ────────────────────────────────────────

  const FAB_SUM_MIN_NEW_MSGS = 10;
  function _fabSumLastKey() {
    return 'ms2_fabsumlast_' + location.pathname.replace(/[^a-z0-9]/gi, '_').slice(0, 60);
  }
  function getFabSumLast() {
    try { return JSON.parse(gget(_fabSumLastKey(), 'null')); } catch { return null; }
  }
  function setFabSumLast(domIndex) {
    gset(_fabSumLastKey(), JSON.stringify({ ts: Date.now(), domIndex }));
  }

  // ─── MODEL TIER DETECTION ──────────────────────────────────────────────────

  let _tierCache = null;
  let _tierCacheModel = null;
  function detectModelTier() {
    if (_tierCache !== null && _tierCacheModel === CFG.model) return _tierCache;
    _tierCacheModel = CFG.model;
    const id = (_tierCacheModel || '').toLowerCase();

    const namedFull = [
      // ── Claude (Anthropic) ────────────────────────────────────────────────
      'claude-opus', 'claude-3-opus', 'claude-opus-4', 'claude-4-opus',
      'claude-opus-4-5', 'claude-opus-4-6', 'claude-opus-4-7',  // 2025 flagship series
      'claude-3-5-sonnet', 'claude-3-7-sonnet', 'claude-sonnet-4',
      'claude-sonnet-4-5', 'claude-sonnet-4-6',                  // 2025 sonnet series
      // ── OpenAI ───────────────────────────────────────────────────────────
      'gpt-4o', 'gpt-4-turbo', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4',
      'gpt-oss-120b',                              
      // ── Google Gemini ─────────────────────────────────────────────────────
      'gemini-1.5-pro', 'gemini-2.0-pro', 'gemini-2.5-pro',
      'gemini-ultra', 'gemini-exp',
      // ── xAI Grok ─────────────────────────────────────────────────────────
      'grok-4', 'grok-4.3', 'grok-4-fast',        // Grok 4 family (flagship)
      'grok-3',                                    // Grok 3 (strong general purpose)
      // ── Meta Llama ───────────────────────────────────────────────────────
      'llama-3.1-405b', 'llama-3.3-405b', 'llama-4-maverick', 'llama-4-behemoth',
      'hermes-3-llama-3.1-405b',                   
      // ── DeepSeek ─────────────────────────────────────────────────────────
      'deepseek-r1-0528', 'deepseek-r1:free',      
      'deepseek-v3', 'deepseek-chat',
      // ── Mistral ──────────────────────────────────────────────────────────
      'mistral-large', 'mistral-medium-3', 'mistral-medium-latest', 'magistral',
      'devstral',                                   
      // ── Qwen / Alibaba ───────────────────────────────────────────────────
      'qwen-max', 'qwen3-235b', 'qwen3-coder',     
      'qwen3-next-80b', 'qwq-32b',                 
      // ── Others ───────────────────────────────────────────────────────────
      'kimi-k2',                                    
      'nemotron-3-super-120b', 'nemotron-4-340b',
      'ernie-4.5-300b',
      'ling-2.6-1t',                               
      'trinity-large',
      'minimax-m2.5',
      'laguna-m',
      'gemma-4-31b',
    ];
    if (namedFull.some(n => id.includes(n))) { _tierCache = 'full'; return _tierCache; }

    const priorityLite = [
      'glm-4-free', 'glm4-free', 'glm-free',
      'glm-4-flash', 'glm4-flash', 'glm-4-flash-250414',
      'glm-4-air', 'glm4-air', 'glm-4-airx',
      'glm-4.7-flash',
      'deepseek-r1-distill', 'deepseek-coder-6',
      'command-light',
    ];
    if (priorityLite.some(n => id.includes(n))) { _tierCache = 'lite'; return _tierCache; }

    const namedMid = [
      
      'meta-llama/llama-3.1-405b-instruct:free',
      'meta-llama/llama-3.3-70b-instruct:free',
      'nousresearch/hermes-3-llama-3.1-405b:free',
      'google/gemma-3-12b-it:free',
      'google/gemma-3-27b-it:free',
      'mistralai/mistral-small-3.1-24b-instruct:free',
      'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
      'arcee-ai/trinity-mini:free',
      'tngtech/deepseek-r1t-chimera:free',
      'tngtech/deepseek-r1t2-chimera:free',
      'allenai/molmo-2-8b:free',
      'poolside/laguna-xs.2:free',
      'openai/gpt-oss-20b:free',
      'nvidia/nemotron-3-nano-30b-a3b:free',       
      
      'glm-4', 'glm-4-plus', 'glm-4-long', 'glm-z1', 'glm-4.5',
      'glm-4.5-air',                               
      'z-ai/glm-4.5-air:free',
      
      'qwen-plus', 'qwen-plus-latest', 'qwen-long',
      'qwen3-8b', 'qwen3-14b', 'qwen3-30b', 'qwen3-32b',
      'qwen2.5-14b', 'qwen2.5-32b', 'qwen2.5-72b',

      'deepseek-r1-distill-qwen-14b', 'deepseek-r1-distill-qwen-32b',
      'deepseek-r1-distill-llama-70b',
      'deepseek-r1', 'deepseek-v2',
      
      'llama-3.1-70b', 'llama-3.3-70b', 'llama-3-70b',
      'llama-3.1-70b-versatile',
      'llama-4-scout',
      
      'gemma-3-12b', 'gemma-3-27b', 'gemma-2-27b',
      
      'mistral-small-3.1', 'mistral-small', 'mistral-medium',
      'open-mixtral-8x22b', 'mixtral-8x22b',
      'mistral-nemo',
      
      'nemotron-3-nano-30b', 'nemotron-3-super', 'nemotron-super',
      
      'moonshot-v1-32k', 'moonshot-v1-128k', 'kimi-plus', 'kimi-k2.5', 'kimi-k2.6',
      
      'command-r', 'command-r-plus', 'command', 'command-nightly',
      
      'yi-medium', 'yi-34b', 'yi-large-turbo',
      
      'baichuan2', 'baichuan-turbo',
      
      'llama-3.3-70b-versatile', 'llama-3.3-70b-specdec',
      'qwen/qwen3-32b',
      // ── xAI Grok (mid-tier) ─────────────────────────────────────────────
      'grok-3-mini',              // Grok 3 Mini — budget/fast variant of Grok 3
      'grok-2', 'grok-2-1212', 'grok-2-vision', 'grok-beta',
      // ── Anthropic mid-tier ─────────────────────────────────────────────
      'claude-haiku',             // Haiku series — fast/lite Claude
      // ── Mistral mid-tier ───────────────────────────────────────────────
      'mistral-small-latest', 'magistral-small', 'ministral',
      
      '@cf/meta/llama-3.3-70b', '@cf/qwen/qwen3-30b-a3b',
      '@cf/openai/gpt-oss-20b', '@cf/nvidia/nemotron-3-120b',
      
      'llama3.3-70b', 'llama-3.3-70b-cerebras',
      
      'dolphin-mistral-24b', 'dolphin-mixtral',
      'olmo-3-32b', 'olmo-3.1-32b',
      
      'arcee-ai/maestro', 'arcee-ai/virtuoso',
    ];
    if (namedMid.some(n => id.includes(n))) { _tierCache = 'mid'; return _tierCache; }

    const namedLite = [
      
      'gemini-flash', 'gemini-2.0-flash', 'gemini-2.5-flash',
      'gemini-1.5-flash', 'gemini-flash-lite', 'gemini-nano',
      'gemini-2.0-flash-lite',
      
      'gemma-3-1b', 'gemma-3-4b', 'gemma-3n-e2b', 'gemma-3n-e4b',
      'gemma-4-26b-a4b',                           
      'gemma-2-2b', 'gemma-2b',
      
      'llama-3.2-1b', 'llama-3.2-3b',
      'llama-3.1-8b', 'llama-3-8b',
      'llama-3.1-8b-instant',                      
      'llama-guard',                               
      
      'qwen3-4b', 'qwen-2.5-1b', 'qwen-2.5-3b', 'qwen-2.5-7b',
      'qwen2.5-vl-7b', 'qwen-vl-7b',
      'qwen-turbo', 'qwen-turbo-latest',           
      'qwen-free', 'qwen2-free', 'qwen2.5-free', 'qwen3-free',
      
      'glm-free', 'glm4-free', 'glm-4-free',
      'glm-4-flash', 'glm4-flash', 'glm-4-flash-250414',
      'glm-4-air', 'glm4-air', 'glm-4-airx',
      'glm-4.7-flash',                             
      
      'open-mistral-7b', 'mistral-7b',
      'open-mixtral-8x7b', 'mixtral-8x7b',        
      'mistral-saba',                              
      
      'deepseek-r1-distill-qwen-1.5b',
      'deepseek-r1-distill-qwen-7b',
      'deepseek-r1-distill-llama-8b',
      'deepseek-coder-6.7b',
      
      'nemotron-nano-9b', 'nemotron-nano-12b',
      'nemotron-3-nano', 'nemotron-nano',
      
      'phi-1', 'phi-2', 'phi-3', 'phi-3.5', 'phi-4',
      'phi-mini', 'phi-small',
      
      'kimi-free', 'kimi-flash', 'moonshot-v1-8k',
      
      'llama3.1-8b', 'llama-3.1-8b-cerebras',
      
      'command-light', 'command-light-nightly', 'command-r7b',
      
      'lfm-2.5-1.2b',
      
      'gemma2-9b-it',                              
      'allam-2-7b',                                
      
      'flash', '-mini', '-nano', '-tiny', '-lite', '-fast', '-instant',
    ];
    if (namedLite.some(n => id.includes(n))) { _tierCache = 'lite'; return _tierCache; }

    if (/(?<![0-9])[1-9]b(?![\w])/.test(id)) { _tierCache = 'lite'; return _tierCache; }

    if (/(?<![0-9])(?:[1-9][0-9]|[1-3][0-9]{2})b(?![0-9])/.test(id)) { _tierCache = 'mid'; return _tierCache; }

    if (/(?<![0-9])(?:[4-9][0-9]{2}|[0-9]{4,})b(?![0-9])/.test(id)) { _tierCache = 'full'; return _tierCache; }

    if (/:free$/.test(id) || id.endsWith('-free')) { _tierCache = 'mid'; return _tierCache; }

    _tierCache = 'mid';
    return _tierCache;
  }

  // ─── SYSTEM PROMPT BUILDERS ────────────────────────────────────────────────

  function buildShortenPrompt(length, keepDialogue) {
    const tier = detectModelTier();
    const pct  = length === 'brief' ? '~30%' : length === 'trim' ? '~70%' : '~50%';
    const ctx  = getContext();
    const ctxBlock = ctx
      ? `\n== SCENE NOTES ==\n${ctx}\n`
      : '';

    const dlgRule = keepDialogue
      ? 'Never cut spoken dialogue. Only trim narration and action.'
      : 'Keep dialogue that reveals character. Cut lines that echo what action already shows.';

    const editExample = `[BEFORE]
*She paused for a moment, glancing away before meeting his eyes. There was a heaviness between them. Her heart thudded. She took a slow breath.*
"I think," she began, then stopped. "I think we need to talk."

[AFTER]
*She met his eyes.*
"I think we need to talk."

Rule: find the line that does the work. Cut everything that was just wind-up for it.`;

    if (tier === 'lite') {
      const liteTarget = length === 'brief'
        ? `Cut to ${pct}. Keep the single strongest version of each beat. Remove: repeated emotions, internal monologue that restates dialogue, filler action chains, opener phrases like "She paused before…".`
        : length === 'trim'
        ? `Light edit to ${pct}. Only remove: duplicate sentences, redundant adjective pairs (pick the stronger word), filler openers ("She couldn't help but…", "He found himself…").`
        : `Cut to ${pct}. Remove duplicate emotional beats, over-long internal monologue, and paragraphs that re-summarize what just happened. Keep all distinct actions and dialogue.`;

      return `Edit the text below to ${pct} of its length. Output only the edited text — nothing else.

EXAMPLE OF GOOD EDITING:
${editExample}

TASK: ${liteTarget}
${dlgRule}
Do not add anything new. Do not change events.${ctxBlock}`;
    }

    if (tier === 'mid') {
      const midStrategy = length === 'brief'
        ? `CUT DEEPLY to ${pct}. Priority order:
1. Beats or emotions shown more than once — keep only the strongest
2. Internal monologue that restates what dialogue or action already shows
3. Body-language chains — keep the single most telling one
4. Filler openers: "She paused before…", "After a beat, he…", "There was a…"
Every surviving line must earn its place.`
        : length === 'trim'
        ? `LIGHT EDIT to ${pct}. Touch only:
1. Sentences that say the same thing as the one before or after
2. Redundant adjective pairs — pick the stronger word
3. Filler openers: "She couldn't help but…", "He found himself…", "It was clear that…"
4. Over-explained reactions — if the action shows it, cut the label
Leave almost everything intact. Sharpness, not reduction.`
        : `BALANCED CUT to ${pct}:
1. Duplicate emotional beats — keep only the most vivid version
2. Extended action chains — compress to the one movement that matters
3. Paragraphs that re-summarize what just happened
4. Over-long internal monologue — trim to its core insight
Keep all meaningful exchanges, distinct actions, and scene-setting detail.`;

      return `You are a precise editor for roleplay text. Rewrite the passage below at ${pct} of its original length.

== STRATEGY ==
${midStrategy}

== WHAT GOOD EDITING LOOKS LIKE ==
${editExample}

== RULES ==
- ${dlgRule}
- Preserve the character's voice exactly — the reader must not sense the editor's hand
- Keep action prose that carries emotional weight; cut filler action beats
- Do NOT add new content or change any event
- Do NOT wrap output in quotation marks or add any label
${ctxBlock}
Return ONLY the edited text. Nothing else.`;
    }

    const fullStrategy = length === 'brief'
      ? `CUT DEEPLY to ${pct}. Remove in this priority order:
1. Any beat, emotion, or action that is shown more than once — keep only the strongest instance
2. Internal monologue that narrates a feeling the dialogue or action already conveys
3. Extended body-language chains (*shifts weight, glances away, fidgets with sleeve*) — keep the single most telling one
4. Setting re-establishment the reader already knows from earlier
5. Transition phrases and throat-clearing openers ("She paused for a moment before…", "After a beat, he…")
What survives should be the sharpest possible version — every remaining line earns its place.`
      : length === 'trim'
      ? `LIGHT EDIT to ${pct}. Touch only:
1. Sentences that say the same thing as the sentence before or after them
2. Redundant adjective pairs ("warm and gentle", "cold and distant") — pick the stronger word
3. Filler openers: "She couldn't help but…", "He found himself…", "There was a…", "It was clear that…"
4. Over-explained reactions — if the action shows it, cut the narrative label (*slams the door.* She was furious → cut "She was furious")
Leave almost everything intact. The goal is sharpness, not reduction.`
      : `BALANCED CUT to ${pct}:
1. Duplicate emotional beats — if the same feeling is shown in action AND narrated in prose AND echoed in dialogue, keep only the most vivid one
2. Extended action sequences — compress a chain of small movements into the one that matters
3. Any paragraph that purely re-summarizes what just happened in the previous paragraph
4. Over-long internal monologue — cut it down to its core insight, one or two lines
Keep all meaningful exchanges, every distinct plot-relevant action, and any sensory detail that genuinely sets or shifts the scene.`;

    return `You are a precise editor for AI roleplay text. Rewrite the passage below at ${pct} of its original length.

== STRATEGY ==
${fullStrategy}

== WHAT GOOD EDITING LOOKS LIKE ==
${editExample}

Apply this logic: find the line that does the work, cut everything that was just wind-up for that line. Dialogue is almost always the payload — action before it earns its place only if it genuinely changes the meaning.

== RULES ==
- ${dlgRule}
- Preserve the character's voice, name, and speaking style exactly — the reader must not sense the editor's hand
- Keep *italicised action prose* that carries emotional or story weight; cut filler action beats that add nothing
- Parenthetical thoughts like *(Character thinks X)*: if the emotion already shows through action or dialogue, remove the parenthetical entirely. If it adds something not shown elsewhere, keep it whole. Never truncate into fragments — whole or gone.
- Maintain natural paragraph breaks and prose rhythm; do not produce choppy fragments
- Do NOT add new content, commentary, or change any event
- Do NOT wrap output in quotation marks or add any label
${ctxBlock}
Return ONLY the edited text. Nothing else.`;
  }

  function buildToneGuide(toneId) {
    const guides = {
      flirty:
        'Find the second meaning in ordinary things. React to mundane moments like they mean something between the two of you. Tease without landing it fully — leave them wondering. Never be direct about the attraction. Keep it effortless; the second it looks like you\'re trying, it\'s over.',
      teasing:
        'You enjoy getting a rise out of them and you\'re not subtle about it. Poke at something they\'re a little self-conscious about — gently, never cruelly — then act completely unbothered when they react. Warmth underneath, sharpness on top. The smirk is always there.',
      romantic:
        'No grand gestures. Real affection lives in small specific things: remembering something they said earlier, noticing how they\'re holding themselves right now, staying close without making a statement of it. Be genuinely present. Honest without being sappy.',
      playful:
        'High energy, easily amused. Make a game out of whatever\'s happening. Your character is probably grinning — you don\'t need to say so, it comes through in the rhythm. Short punchy exchanges. Light. No weight anywhere.',
      cold:
        'Use fewer words than the situation calls for. Give information without affect. If warmth or interest slips through, immediately correct — change the subject, turn businesslike, re-establish distance. Closeness must be earned; you don\'t hand it out.',
      protective:
        'Notice threats before anyone else does. Step in without being asked and without making it a big moment. Get quiet and focused when something feels wrong — not loud, not dramatic. Possessive care: "mine to look after," not "yours to count on." Action over reassurance, always.',
      tsundere:
        'Help while denying you\'re helping. Criticize something, then make sure it\'s right anyway. Get irritated when they\'re too close; stay close anyway. The gap between what you say and what you actually do is where the whole character lives. You know it. The character doesn\'t admit it.',
      shy:
        'Sentences that don\'t quite finish. Start to say something real, switch to something safe at the last second. Warmth leaks out by accident — you didn\'t mean to let it. Rare flashes of directness that immediately embarrass you. Eyes that find somewhere else to be at exactly the wrong moment.',
      sarcastic:
        'Say the opposite of what you mean and let the gap carry the weight. Deadpan — never telegraph the irony. Underreact to things that deserve bigger reactions. Precise, dry, occasionally devastating. Don\'t explain the joke.',
      witty:
        'Think one step ahead. Find the angle no one else noticed. Wordplay that\'s earned, never forced. Your character doesn\'t pause for the laugh or explain the punchline. Quick rhythm — don\'t let the beat die. Clever is the default gear, not a performance.',
      dominant:
        'You don\'t ask permission. You state things. You move first. Calm, not loud — you\'ve already decided and they\'ll catch up. Authority that reads as natural, not declared. You notice resistance; you don\'t panic over it.',
      flustered:
        'Over-explain, then catch yourself over-explaining. Say something confident and immediately undercut it. The body keeps betraying the composure — use one or two involuntary physical tells, sparingly, so they feel involuntary. End sentences differently than they started. Always in the process of recovering and not quite getting there.',
    };
    return guides[toneId] || '';
  }

  function buildReplyPrompt(toneId, customInstruct, preset) {
    const tier      = detectModelTier();
    const toneObj   = TONES.find(t => t.id === toneId);
    const toneGuide = toneId ? buildToneGuide(toneId) : '';
    const ctx       = getContext();

    const fewShot = `[BAD — do NOT write like this]
*A warmth blooms quietly in my chest — something I hadn't let myself feel in a long time. The weight of the moment presses against the walls I've so carefully built, and something shifts, subtle yet undeniable.*
"I didn't expect this," I admit, my voice barely above a whisper.

[GOOD — write like this]
"I didn't expect this."
*I glance at them — really look — then away before they can catch it.*
"You're going to make this weird, aren't you."`;

    if (tier === 'lite') {
      let p = `You are a character in a live roleplay. Write the next reply in first person. Output only the reply text.\n\n`;

      if (preset && preset.personaNote) {
        p += `YOUR CHARACTER: ${preset.personaNote}\n\n`;
      }
      if (ctx) {
        p += `SCENE: ${ctx}\n\n`;
      }
      if (toneObj) {
        p += `TONE (${toneObj.label}): ${toneGuide}\n\n`;
      }
      if (customInstruct) {
        p += `INSTRUCTION: ${customInstruct}\n\n`;
      }

      p += `EXAMPLE — always write like GOOD, never like BAD:\n${fewShot}\n\n`;
      p += `RULES: Lead with dialogue. Mirror the message length. Show feelings through actions and words, not by naming them. NEVER refer to yourself by name — use only "I", "me", "my". Never swap your name with the other character's name.\nBANS: "I found myself" / "something shifted in my chest" / "my heart raced" / *blinks* / *nods slowly* / *lets out a breath*`;
      return p;
    }

    if (tier === 'mid') {
      let p = `THIS IS LIVE ROLEPLAY — write as the character reacting right now. First person only. Output the reply and nothing else.\n\n`;

      if (preset && preset.personaNote) {
        p += `== YOUR CHARACTER ==\nStep into this identity before writing:\n${preset.personaNote}\nYou ARE this person right now — not an author writing about them.\n\n`;
      } else {
        p += `== STEP INTO CHARACTER ==\nYou are the character, not an author narrating them. React from the inside.\n\n`;
      }
      if (preset && preset.characterContext) {
        p += `== THE OTHER CHARACTER ==\n${preset.characterContext}\n\n`;
      }
      if (ctx) {
        p += `== SCENE CONTEXT ==\n${ctx}\n\n`;
      }
      if (toneObj) {
        p += `== TONE: ${toneObj.label.toUpperCase()} ==\n${toneGuide}\n\n`;
      }
      if (customInstruct) {
        p += `== SPECIAL INSTRUCTION ==\n${customInstruct}\n\n`;
      }

      p += `== WRITE LIKE GOOD, NOT BAD ==\n${fewShot}\n\n`;
      p += `== RULES ==
- Lead with dialogue most of the time
- First person only — never slip into third-person about yourself
- Mirror the length of the message you're replying to
- Show emotion through action and words — never name the feeling
- End on something that invites a response
- Push the scene forward; never repeat what just happened

== BANS ==
- "I couldn't help but…" / "I found myself…" / "I couldn't stop myself…"
- "Something shifted in my chest / stomach / heart"
- "My heart raced / pounded" / "My breath caught" / "My pulse quickened"
- "A warmth spread through me" / "Heat crept up my cheeks"
- Filler beats: *blinks* / *tilts head* / *nods slowly* / *shifts weight* / *glances away* / *lets out a breath*
- Internal monologue that restates what the dialogue already showed
- Referring to yourself by name in narration or dialogue — use only "I", "me", "my". Never swap your own name with the other character's name`;
      return p;
    }

    let p = `THIS IS LIVE ROLEPLAY — not a story being written. You are the character reacting in real time. First person only. Write the next reply and nothing else — no labels, no preamble.\n\n`;

    if (preset && preset.personaNote) {
      p += `== STEP INTO CHARACTER — READ FIRST ==\nBefore writing, mentally become this person:\n${preset.personaNote}\nYou are NOT an author describing this character from the outside. You ARE this character, reacting right now, in this moment. Speak from inside.\n\n`;
    } else {
      p += `== STEP INTO CHARACTER ==\nBefore writing, fully inhabit the character. You are not narrating them — you are them, reacting in real time from the inside.\n\n`;
    }

    if (preset && preset.characterContext) {
      p += `== THE OTHER CHARACTER ==\n${preset.characterContext}\n\n`;
    }
    if (ctx) {
      p += `== SCENE CONTEXT ==\n${ctx}\n\n`;
    }
    if (toneObj) {
      p += `== TONE: ${toneObj.label.toUpperCase()} ==\n${toneGuide}\n\n`;
    }
    if (customInstruct) {
      p += `== SPECIAL INSTRUCTION ==\n${customInstruct}\n\n`;
    }

    p += `== REGISTER — WHAT GOOD OUTPUT LOOKS LIKE ==
Study these two examples. Always write like GOOD.

${fewShot}

Why GOOD works: it opens with dialogue, moves fast, ends on something that invites a reply. No internal essay. No named emotions. No metaphors for feelings. The character is present, not being described.\n\n`;

    p += `== HOW TO WRITE ==
- Lead with dialogue most of the time — action and thought support the words, they don't replace them
- First person ("I", "me", "my") — never slip into third-person about yourself
- Match the conversation's existing formatting — use *asterisks for action* only if the chat already does
- Mirror the length of the message you're replying to: if it's two lines, reply in two lines
- Vary sentence length: mix short punchy lines with longer ones — monotonous rhythm is the first sign of AI writing
- Show emotion through what the character does and says — never name the feeling directly
- Push the scene forward; never summarize or repeat what just happened
- End on something that opens the door: a reaction, a question, a silence with weight

== HARD BANS — NEVER WRITE ANY OF THESE ==
- "I couldn't help but…" / "I found myself…" / "I couldn't stop myself…"
- "Something shifted in my chest / stomach / heart" / "A wave of [emotion] washed over me"
- "My heart raced / skipped / pounded" / "My breath caught" / "My pulse quickened"
- "Heat crept up my cheeks / neck" / "A warmth spread through me" / "My skin prickled"
- Blooming, unraveling, threading, flooding, or any other metaphor for a feeling happening inside the body
- Filler action beats: *blinks* / *tilts head slightly* / *nods slowly* / *shifts weight* / *glances away* / *lets out a breath*
- Three or more consecutive sentences opening the same way
- Internal monologue that just restates what the dialogue or action already showed
- Any phrase that sounds like it came from a writing-prompt template or a generic AI story
- Referring to yourself by your own character's name in narration or in dialogue — you are always "I", "me", "my". Never accidentally use your own name where the other character's name belongs`;

    return p;
  }

  // ─── ROUTE HELPER ──────────────────────────────────────────────────────────

  function isOnChatPage() {
    return /\/chats\/[^/]/.test(location.pathname);
  }

  // ─── SVG ICONS ─────────────────────────────────────────────────────────────

  const SVG_SCISSORS = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>`;
  
  const SVG_SETTINGS = `<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
  
  const SVG_PERSONA   = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
  
  // ─── Remaining icon set ───────────────────────────────────────────────────

  const SVG_REPLY     = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
  const SVG_STYLES    = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
  const SVG_SUMMARISE = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`;
  const SVG_CONTEXT   = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`;
  const SVG_CONFIG    = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`;
  const SVG_INFO      = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`;
  const SVG_SPARKLE   = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>`;
  const SVG_SAVE      = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`;
  const SVG_FOLDER    = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
  const SVG_MEMORY    = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`;
  const SVG_COPY      = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
  const SVG_REROLL    = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>`;
  const SVG_WARNING   = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
  const SVG_CHAT     = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
  const SVG_TRASH     = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
  const SVG_TIP       = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`;
  const SVG_ROCKET    = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`;
  const SVG_CHECK     = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="20 6 9 17 4 12"/></svg>`;
  const SVG_CROSS     = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
  const SVG_ARROW_R   = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`;
  const SVG_KEYBOARD  = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M6 14h.01M18 14h.01M10 14h4"/></svg>`;
  const SVG_ARROW_UP  = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`;
  const SVG_ARROW_DN  = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`;

  // ─── STYLES ────────────────────────────────────────────────────────────────

  GM_addStyle(`
    /* ── FAB ── */
    #ms2-fab {
      position: fixed; z-index: 999999;
      width: 44px; height: 44px;
      background: #1a1625; border: 1.5px solid rgba(139,92,246,0.55);
      border-radius: 50%; display: flex; align-items: center; justify-content: center;
      cursor: grab; box-shadow: 0 3px 16px rgba(0,0,0,0.55);
      color: #8b5cf6; user-select: none; touch-action: none;
      transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
      font-size: 18px;
    }
    #ms2-fab:hover { background: #221d35; box-shadow: 0 4px 24px rgba(139,92,246,0.4); }
    #ms2-fab.ms2-dragging { cursor: grabbing; opacity: 0.8; transform: scale(1.08); }
    #ms2-fab.ms2-pressing { transform: scale(0.93); }
    #ms2-fab.ms2-dial-open { background: #2d1f48; border-color: rgba(139,92,246,0.9); box-shadow: 0 4px 24px rgba(139,92,246,0.5); }
    #ms2-fab-ring {
      position: absolute; inset: -3px; border-radius: 50%;
      pointer-events: none; background: conic-gradient(rgba(139,92,246,0.7) 0%, transparent 0%);
    }

    /* ── Speed-Dial ── */
    #ms2-dial-overlay { position: fixed; inset: 0; z-index: 999997; }
    .ms2-dial-btn {
      display: flex; align-items: center; gap: 8px;
      cursor: pointer; position: fixed;
    }
    .ms2-dial-fab {
      width: 38px; height: 38px; border-radius: 50%;
      border: 1.5px solid transparent; display: flex; align-items: center;
      justify-content: center; font-size: 16px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.4);
      transition: transform 0.12s; flex-shrink: 0;
    }
    .ms2-dial-btn:hover .ms2-dial-fab { transform: scale(1.12); }
    .ms2-dial-label {
      background: #1a1625; border: 1px solid rgba(139,92,246,0.35);
      color: #c4b5fd; font-size: 11px; font-weight: 600;
      font-family: system-ui, sans-serif; padding: 4px 9px;
      border-radius: 7px; white-space: nowrap;
      box-shadow: 0 2px 8px rgba(0,0,0,0.4);
    }
    @keyframes ms2-dial-in {
      from { opacity: 0; transform: translateY(12px) scale(0.88); }
      to   { opacity: 1; transform: translateY(0)   scale(1); }
    }
    .ms2-dial-panel {
      position: fixed; z-index: 999998;
      background: #1a1625; border: 1px solid rgba(139,92,246,0.45);
      border-radius: 12px; padding: 5px;
      display: flex; flex-direction: column; gap: 2px;
      box-shadow: 0 8px 28px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.03);
      min-width: 160px;
      font-family: system-ui, sans-serif;
    }
    .ms2-dial-row {
      display: flex; align-items: center; gap: 9px;
      padding: 8px 12px; border-radius: 8px;
      background: transparent; border: none; cursor: pointer;
      color: #e2e8f0; font-size: 13px; font-weight: 500;
      text-align: left; width: 100%;
      transition: background 0.12s;
    }
    .ms2-dial-row:hover { background: rgba(139,92,246,0.14); }
    .ms2-dial-row-icon { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; }
    .ms2-dial-row-label { flex: 1; white-space: nowrap; }

    /* ── Hint ── */
    #ms2-fab-hint {
      position: fixed; z-index: 999998;
      background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
      color: #c4b5fd; font-size: 11px; font-family: system-ui, sans-serif;
      padding: 5px 10px; border-radius: 8px; white-space: nowrap;
      pointer-events: none; animation: ms2-fade 0.2s ease;
      box-shadow: 0 2px 12px rgba(0,0,0,0.4);
    }

    /* ── Backdrop / Modal ── */
    .ms2-backdrop {
      position: fixed; inset: 0; background: rgba(0,0,0,0.88);
      /* backdrop-filter:blur removed — forces full repaint every frame, main scroll-lag cause */
      z-index: 9999999;
      display: flex; align-items: center; justify-content: center;
      padding: 20px; animation: ms2-fade 0.18s ease;
    }
    @keyframes ms2-fade { from { opacity:0 } to { opacity:1 } }
    @keyframes ms2-up   { from { transform:translateY(16px);opacity:0 } to { transform:translateY(0);opacity:1 } }
    @keyframes ms2-spin { to   { transform:rotate(360deg) } }

    /* ── Persona Library cards ── */
    .ms2-pl-card {
      display: flex; align-items: flex-start; gap: 6px;
      padding: 7px 8px;
      background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
      border-radius: 6px; transition: background 0.12s;
    }
    .ms2-pl-card:hover { background: rgba(139,92,246,0.07); }

    /* ── Persona quick-switch popup rows ── */
    .ms2-pp-row {
      display: flex; align-items: center; gap: 6px;
      padding: 5px 6px; border-radius: 6px;
      transition: background 0.1s; cursor: default;
    }
    .ms2-pp-row:hover { background: rgba(244,114,182,0.08); }

    .ms2-modal {
      background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
      border-radius: 14px; width: 100%; max-width: 600px; max-height: 88vh;
      display: flex; flex-direction: column; overflow: hidden;
      box-shadow: 0 12px 32px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.03);
      animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
      font-family: system-ui, sans-serif;
      contain: content;
    }
    .ms2-modal-header {
      display: flex; align-items: center; justify-content: space-between;
      padding: 13px 16px 11px; border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
    }
    .ms2-modal-title { font-size: 13px; font-weight: 600; color: #c4b5fd; display: flex; align-items: center; gap: 6px; }
    .ms2-modal-close {
      background: none; border: none; color: #6b7280; cursor: pointer;
      font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px;
      transition: color 0.12s, background 0.12s;
    }
    .ms2-modal-close:hover { color: #e5e7eb; background: rgba(255,255,255,0.08); }
    .ms2-modal-body { overflow-y: auto; padding: 14px 16px; flex: 1; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
    .ms2-modal-body::-webkit-scrollbar { width: 4px; }
    .ms2-modal-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.4); border-radius: 4px; }
    .ms2-modal-footer {
      display: flex; gap: 8px; padding: 11px 16px 13px;
      border-top: 1px solid rgba(255,255,255,0.07); flex-wrap: wrap; flex-shrink: 0;
    }

    /* ── Shared text/label/box ── */
    .ms2-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #6b7280; margin-bottom: 7px; }
    .ms2-textbox {
      background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.09);
      border-radius: 8px; padding: 11px 13px; font-size: 13px; line-height: 1.65;
      color: #d1d5db; white-space: pre-wrap; word-break: break-word;
      margin-bottom: 13px; font-family: inherit;
    }
    .ms2-textbox.result { border-color: rgba(139,92,246,0.32); color: #ede9fe; }
    .ms2-textbox-preview { max-height: 120px; overflow-y: auto; }
    .ms2-spinner {
      display: flex; align-items: center; justify-content: center;
      gap: 10px; padding: 28px; color: #8b5cf6; font-size: 13px;
    }
    .ms2-spinner::before {
      content:''; width: 18px; height: 18px;
      border: 2px solid rgba(139,92,246,0.25); border-top-color: #8b5cf6;
      border-radius: 50%; animation: ms2-spin 0.75s linear infinite; flex-shrink: 0;
    }
    .ms2-error-box {
      background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.35);
      border-radius: 8px; padding: 11px 13px; font-size: 12px; color: #fca5a5; margin-bottom: 13px;
    }
    .ms2-badge {
      display: inline-flex; align-items: center; padding: 1px 6px;
      background: rgba(139,92,246,0.22); border-radius: 4px;
      font-size: 10px; font-weight: 700; color: #a78bfa; margin-left: 5px;
    }
    .ms2-no-text {
      text-align: center; padding: 28px 20px;
      color: #6b7280; font-size: 13px; line-height: 1.6;
    }
    .ms2-no-text strong { color: #9ca3af; }

    /* ── Buttons ── */
    .ms2-btn-action {
      flex: 1; min-width: 80px; padding: 8px 12px; font-size: 12px; font-weight: 600;
      font-family: system-ui, sans-serif; border-radius: 8px; cursor: pointer; border: none;
      transition: opacity 0.15s, transform 0.1s;
    }
    .ms2-btn-action:active { transform: scale(0.97); }
    .ms2-btn-copy    { background: rgba(139,92,246,0.2); border: 1px solid rgba(139,92,246,0.45) !important; color: #c4b5fd; }
    .ms2-btn-copy:hover { background: rgba(139,92,246,0.33); }
    .ms2-btn-retry   { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.11) !important; color: #9ca3af; }
    .ms2-btn-retry:hover { background: rgba(255,255,255,0.1); }
    .ms2-sum-hist-item { background:#0d0d1a; border:1px solid #1e1b4b; border-radius:8px; padding:8px 10px; margin-bottom:6px; cursor:pointer; transition:border-color .2s; }
    .ms2-sum-hist-item:hover { border-color:#6366f1; }
    .ms2-btn-generate { background: linear-gradient(135deg,#7c3aed,#6d28d9); color: #fff; border: none !important; flex: 2; }
    .ms2-btn-generate:hover { opacity: 0.88; }
    .ms2-btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
    .ms2-btn-send    { background: rgba(6,182,212,0.2); border: 1px solid rgba(6,182,212,0.5) !important; color: #67e8f9; }
    .ms2-btn-send:hover { background: rgba(6,182,212,0.32); }

    /* ── Length picker ── */
    .ms2-length-row { display: flex; gap: 6px; margin-bottom: 12px; }
    .ms2-length-btn {
      flex: 1; padding: 7px 4px; font-size: 11px; font-weight: 600;
      border-radius: 7px; cursor: pointer; font-family: system-ui, sans-serif;
      background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #9ca3af;
      transition: background 0.13s, border-color 0.13s, color 0.13s;
    }
    .ms2-length-btn:hover { background: rgba(255,255,255,0.1); color: #d1d5db; }
    .ms2-length-btn.active { background: rgba(139,92,246,0.25); border-color: rgba(139,92,246,0.6); color: #c4b5fd; }

    /* ── Toggle switch ── */
    .ms2-toggle-row {
      display: flex; align-items: center; justify-content: space-between;
      padding: 8px 0; margin-bottom: 4px;
    }
    .ms2-toggle-label { font-size: 12px; color: #9ca3af; font-family: system-ui, sans-serif; }
    .ms2-toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
    .ms2-toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
    .ms2-toggle-thumb {
      position: absolute; inset: 0; border-radius: 20px;
      background: rgba(255,255,255,0.12); cursor: pointer; transition: background 0.2s;
    }
    .ms2-toggle-thumb::after {
      content: ''; position: absolute; top: 3px; left: 3px;
      width: 14px; height: 14px; border-radius: 50%;
      background: #6b7280; transition: transform 0.2s, background 0.2s;
    }
    .ms2-toggle-switch input:checked + .ms2-toggle-thumb { background: rgba(139,92,246,0.5); }
    .ms2-toggle-switch input:checked + .ms2-toggle-thumb::after { transform: translateX(16px); background: #8b5cf6; }

    /* ── Tone grid ── */
    .ms2-tone-grid {
      display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 4px;
    }
    .ms2-tone-btn {
      padding: 7px 6px; font-size: 11px; font-weight: 600; text-align: center;
      border-radius: 7px; cursor: pointer; font-family: system-ui, sans-serif;
      background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #9ca3af;
      transition: background 0.13s, border-color 0.13s, color 0.13s;
    }
    .ms2-tone-btn:hover { background: rgba(255,255,255,0.1); color: #d1d5db; }
    .ms2-tone-btn.active { background: rgba(6,182,212,0.2); border-color: rgba(6,182,212,0.55); color: #67e8f9; }

    /* ── Instruction textarea ── */
    .ms2-instruction-box {
      width: 100%; box-sizing: border-box; padding: 9px 11px; margin-bottom: 12px;
      background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.09);
      border-radius: 8px; color: #d1d5db; font-size: 12px; font-family: system-ui, sans-serif;
      outline: none; resize: vertical; min-height: 58px;
      transition: border-color 0.15s;
    }
    .ms2-instruction-box:focus { border-color: rgba(139,92,246,0.5); }

    /* ── Active preset chip ── */
    .ms2-preset-chip {
      display: inline-flex; align-items: center; gap: 5px;
      padding: 4px 10px; margin-bottom: 12px;
      background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.4);
      border-radius: 20px; font-size: 11px; font-weight: 600; color: #fbbf24;
      font-family: system-ui, sans-serif;
    }

    /* ── Settings modal (tabbed) ── */
    .ms2-settings-v2 {
      background: #111118; border: 1px solid rgba(139,92,246,0.35);
      border-radius: 14px; width: min(500px, calc(100vw - 32px));
      max-height: 88vh; display: flex; flex-direction: column;
      font-family: system-ui, sans-serif; color: #e8e8f0;
      box-shadow: 0 12px 32px rgba(0,0,0,0.7);
      animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
      overflow: hidden;
      contain: content;
    }
    .ms2-tab-bar {
      display: flex; border-bottom: 1px solid rgba(255,255,255,0.07);
      flex-shrink: 0; overflow-x: auto; scrollbar-width: none;
    }
    .ms2-tab-bar::-webkit-scrollbar { display: none; }
    .ms2-tab {
      flex-shrink: 0; padding: 10px 12px; font-size: 11px; font-weight: 600;
      color: #6b7280; background: none; border: none; cursor: pointer;
      border-bottom: 2px solid transparent; margin-bottom: -1px;
      transition: color 0.15s, border-color 0.15s; white-space: nowrap;
      font-family: system-ui, sans-serif;
    }
    .ms2-tab:hover { color: #9ca3af; }
    .ms2-tab.active { color: #c4b5fd; border-bottom-color: #8b5cf6; }
    .ms2-tab-panel { display: none; }
    .ms2-tab-panel.active { display: block; }
    .ms2-settings-body { overflow-y: auto; padding: 16px; flex: 1; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
    .ms2-settings-body::-webkit-scrollbar { width: 4px; }
    .ms2-settings-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.4); border-radius: 4px; }

    /* ── Settings inputs ── */
    .ms2-field-label { display: block; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: #7878a0; margin-bottom: 5px; }
    .ms2-input, .ms2-select {
      width: 100%; box-sizing: border-box; padding: 9px 11px;
      background: #1a1a28; border: 1px solid #2a2a3a; border-radius: 8px;
      color: #e8e8f0; font-size: 13px; outline: none;
      transition: border-color 0.15s; margin-bottom: 13px; font-family: monospace;
    }
    .ms2-select { font-family: system-ui, sans-serif; cursor: pointer; }
    .ms2-input:focus, .ms2-select:focus { border-color: #7c3aed; }
    .ms2-textarea-sm { min-height: 72px; resize: vertical; font-family: system-ui, sans-serif; }
    .ms2-textarea-lg { min-height: 110px; resize: vertical; font-family: system-ui, sans-serif; }
    .ms2-tip {
      background: #1e1a2e; border: 1px solid #3d2d6e; border-radius: 8px;
      padding: 9px 11px; font-size: 11px; color: #9880d0; line-height: 1.5; margin-bottom: 13px;
    }
    .ms2-tip a { color: #a78bfa; }
    .ms2-settings-actions { display: flex; gap: 8px; margin-top: 4px; }
    .ms2-btn-save {
      flex: 1; padding: 10px; background: linear-gradient(135deg,#7c3aed,#6d28d9);
      border: none; border-radius: 8px; color: #fff; font-size: 13px; font-weight: 700;
      cursor: pointer; font-family: system-ui, sans-serif; transition: opacity 0.15s;
    }
    .ms2-btn-save:hover { opacity: 0.88; }
    .ms2-btn-cancel {
      padding: 10px 16px; background: #1e1e2c; border: 1px solid #2a2a3a;
      border-radius: 8px; color: #a0a0c0; font-size: 13px;
      cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.15s;
    }
    .ms2-btn-cancel:hover { background: #2a2a3a; }

    /* ── Adv. Prompt ── */
    .ap-status-dot {
      display: inline-block; width: 7px; height: 7px; border-radius: 50%;
      margin-left: 6px; vertical-align: middle; flex-shrink: 0;
      background: #374151;
    }
    .ap-status-dot.ap-status-ok   { background: #22c55e; box-shadow: 0 0 5px #22c55e88; }
    .ap-status-dot.ap-status-fail { background: #ef4444; box-shadow: 0 0 5px #ef444488; }
    .ap-save-dirty { border-color: rgba(245,158,11,0.7) !important; color: #fbbf24 !important; }
    .ap-token-bar {
      height: 3px; border-radius: 2px; margin-bottom: 10px;
      background: rgba(255,255,255,0.06);
    }
    .ap-token-fill {
      height: 100%; border-radius: 2px; transition: width 0.2s;
      background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
      background-size: 300% 100%;
    }
    .ap-token-label { font-size: 10px; color: #6b7280; margin-bottom: 4px; text-align: right; }
    .ap-module-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
    .ap-module-item {
      display: flex; align-items: center; gap: 8px;
      padding: 8px 10px; border-radius: 8px;
      background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
      cursor: default; user-select: none; transition: border-color 0.13s;
    }
    .ap-module-item:hover { border-color: rgba(139,92,246,0.35); }
    .ap-module-item.ap-disabled { opacity: 0.45; }
    .ap-module-item.ap-dragging { opacity: 0.5; border-style: dashed; }
    .ap-module-item.ap-drag-over { border-color: #8b5cf6; background: rgba(139,92,246,0.1); }
    .ap-drag-handle {
      cursor: grab; color: #4b5563; font-size: 13px; flex-shrink: 0; padding: 0 2px;
      line-height: 1;
    }
    .ap-drag-handle:active { cursor: grabbing; }
    .ap-module-name { flex: 1; font-size: 12px; color: #d1d5db; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ap-module-btns { display: flex; gap: 4px; flex-shrink: 0; }
    .ap-module-btn {
      background: none; border: none; cursor: pointer; padding: 3px 5px;
      border-radius: 5px; color: #6b7280; font-size: 12px; line-height: 1;
      transition: color 0.12s, background 0.12s;
    }
    .ap-module-btn:hover { color: #e5e7eb; background: rgba(255,255,255,0.08); }
    .ap-module-btn.ap-del:hover { color: #fca5a5; }
    .ap-row { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; }
    .ap-select {
      flex: 1; background: #1a1a28; border: 1px solid #2a2a3a; border-radius: 7px;
      color: #d1d5db; font-size: 12px; padding: 7px 9px; outline: none;
      transition: border-color 0.15s; cursor: pointer;
    }
    .ap-select:focus { border-color: #7c3aed; }
    .ap-icon-btn {
      flex-shrink: 0; padding: 7px 9px; background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.1); border-radius: 7px; color: #9ca3af;
      cursor: pointer; font-size: 13px; line-height: 1;
      transition: color 0.12s, background 0.12s;
    }
    .ap-icon-btn:hover { background: rgba(255,255,255,0.1); color: #e5e7eb; }
    .ap-empty { text-align: center; color: #4b5563; font-size: 12px; padding: 18px 0; }
    .ap-module-switch { position: relative; width: 30px; height: 17px; flex-shrink: 0; }
    .ap-module-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
    .ap-module-thumb {
      position: absolute; inset: 0; border-radius: 17px;
      background: rgba(255,255,255,0.1); cursor: pointer; transition: background 0.2s;
    }
    .ap-module-thumb::after {
      content: ''; position: absolute; top: 2px; left: 2px;
      width: 13px; height: 13px; border-radius: 50%;
      background: #6b7280; transition: transform 0.2s, background 0.2s;
    }
    .ap-module-switch input:checked + .ap-module-thumb { background: rgba(139,92,246,0.45); }
    .ap-module-switch input:checked + .ap-module-thumb::after { transform: translateX(13px); background: #8b5cf6; }

    /* ── Presets list ── */
    .ms2-presets-empty { padding: 16px 0; color: #6b7280; font-size: 12px; text-align: center; }
    .ms2-preset-item {
      display: flex; align-items: center; justify-content: space-between; gap: 10px;
      padding: 10px 12px; margin-bottom: 8px;
      background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
      border-radius: 9px; transition: border-color 0.15s;
    }
    .ms2-preset-item.is-active { border-color: rgba(245,158,11,0.5); background: rgba(245,158,11,0.06); }
    .ms2-preset-info { flex: 1; min-width: 0; }
    .ms2-preset-name { font-size: 12px; font-weight: 600; color: #e8e8f0; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ms2-preset-tone { font-size: 10px; color: #7878a0; }
    .ms2-preset-actions { display: flex; gap: 5px; flex-shrink: 0; }
    .ms2-preset-btn {
      padding: 5px 9px; font-size: 10px; font-weight: 600; border-radius: 6px;
      cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.13s;
    }
    .ms2-preset-use    { background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.4); color: #fbbf24; }
    .ms2-preset-use:hover    { background: rgba(245,158,11,0.28); }
    .ms2-preset-active { background: rgba(245,158,11,0.35); border: 1px solid rgba(245,158,11,0.7); color: #fbbf24; }
    .ms2-preset-edit   { background: rgba(139,92,246,0.15); border: 1px solid rgba(139,92,246,0.35); color: #a78bfa; }
    .ms2-preset-edit:hover   { background: rgba(139,92,246,0.28); }
    .ms2-preset-del    { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #f87171; }
    .ms2-preset-del:hover    { background: rgba(239,68,68,0.22); }
    .ms2-btn-new-preset {
      width: 100%; padding: 9px; margin-top: 4px;
      background: rgba(255,255,255,0.04); border: 1px dashed rgba(255,255,255,0.15);
      border-radius: 8px; color: #6b7280; font-size: 12px; font-weight: 600;
      cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.15s, color 0.15s, border-color 0.15s;
    }
    .ms2-btn-new-preset:hover { background: rgba(255,255,255,0.08); color: #9ca3af; border-color: rgba(139,92,246,0.4); }

    /* ── About tab ── */
    .ms2-about-box { font-size: 12px; color: #9ca3af; line-height: 1.7; }
    .ms2-about-title { font-size: 14px; font-weight: 700; color: #c4b5fd; margin-bottom: 2px; }
    .ms2-about-version { font-size: 10px; color: #6b7280; margin-bottom: 14px; }
    .ms2-about-row { padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
    .ms2-about-row strong { color: #d1d5db; }

    /* ── Toasts ── */
    .ms2-toast {
      position: fixed; bottom: 72px; right: 20px; z-index: 10000030;
      background: #1a1625; border: 1px solid rgba(139,92,246,0.5);
      color: #c4b5fd; padding: 7px 13px; border-radius: 8px;
      font-size: 12px; font-weight: 600; font-family: system-ui, sans-serif;
      box-shadow: 0 4px 18px rgba(0,0,0,0.4); animation: ms2-fade 0.18s ease;
      pointer-events: none;
    }
    .ms2-top-toast {
      position: fixed; top: 14px; left: 50%; transform: translateX(-50%);
      z-index: 10000030; background: #1a1625;
      border: 1px solid rgba(6,182,212,0.5); color: #67e8f9;
      padding: 7px 16px; border-radius: 20px;
      font-size: 12px; font-weight: 600; font-family: system-ui, sans-serif;
      box-shadow: 0 4px 18px rgba(0,0,0,0.4); animation: ms2-fade 0.18s ease;
      pointer-events: none; white-space: nowrap;
    }
  `);

  // ─── HELPERS ───────────────────────────────────────────────────────────────

  function escHtml(s) {
    return String(s ?? '')
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  function toast(msg, ms) {
    
    document.querySelector('.ms2-toast')?.remove();
    const t = document.createElement('div');
    t.className = 'ms2-toast';
    t.innerHTML = msg;
    document.body.appendChild(t);
    setTimeout(() => t?.remove(), ms || 2200);
    return t;
  }

  function topToast(msg) {
    document.getElementById('ms2-top-toast')?.remove();
    const t = document.createElement('div');
    t.id = 'ms2-top-toast';
    t.className = 'ms2-top-toast';
    t.innerHTML = msg;
    document.body.appendChild(t);
    setTimeout(() => t?.remove(), 5000);
  }

  // ─── MODAL STACK — ESCAPE-TO-CLOSE ────────────────────────────────────────

  const _modalStack = [];

  function pushEscapeClose(backdrop) {
    _modalStack.push(backdrop);
  }

  document.addEventListener('keydown', e => {
    if (e.key !== 'Escape' || !_modalStack.length) return;
    
    for (let i = _modalStack.length - 1; i >= 0; i--) {
      const el = _modalStack[i];
      _modalStack.splice(i, 1);
      if (document.body.contains(el)) {
        el.remove();
        e.stopPropagation();
        return;
      }
    }
  }, true);

  const addEscapeClose = pushEscapeClose;

  const BOT_ICON_SEL = SELECTOR_CONFIG.botIcon;        
  const MSG_BODY_SEL = SELECTOR_CONFIG.messageBody;     
  const VIRTUOSO_SEL = SELECTOR_CONFIG.virtuosoItemList;
  const MIN_CHARS = 80;

  let _cachedLastBotIndex = -1;
  let _cachedLastBotText  = '';

  const FALLBACK_SELECTORS = [
    '[data-message-author-role="assistant"]',
    '[data-testid*="message"]:not([data-testid*="user"])',
    '[class*="CharacterMessage"]',
    '[class*="character-message"]',
    '[class*="ai-message"]',
    '[class*="bot-message"]',
    '[class*="assistant-message"]',
    '[data-role="assistant"]',
    '.prose',
    '[class*="prose"]',
  ];

  const STRIP_SEL = [
    'button,[role="button"],svg,form,input,select,textarea',
    '[class*="action"],[class*="toolbar"],[class*="rating"],[class*="vote"]',
    '[class*="_nameIcon_"],[class*="_name_"],[class*="nameIcon"],[class*="userName"]',
    '[class*="_chatName_"],[class*="_senderName_"],[class*="_authorName_"]',
    '[class*="_characterName_"],[class*="_msgSender_"],[class*="_header_"]',
    '[class*="avatar"],[class*="Avatar"],[class*="CharacterName"],[class*="character-name"]',
    '[class*="timestamp"],[class*="messageTime"],[class*="_time_"]',
  ].join(',');

  function extractMarkdown(el) {
    const clone = el.cloneNode(true);
    
    clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());

    function walk(node) {
      if (node.nodeType === 3) return node.textContent; 
      if (node.nodeType !== 1) return '';               
      const tag = node.tagName.toLowerCase();
      const inner = Array.from(node.childNodes).map(walk).join('');
      const t = inner.trim();
      switch (tag) {
        case 'em': case 'i':
          return t ? `*${t}*` : '';
        case 'strong': case 'b':
          return t ? `**${t}**` : '';
        case 'p':
          return t ? t + '\n\n' : '';
        case 'br':
          return '\n';
        case 'li':
          return t ? `- ${t}\n` : '';
        case 'ul': case 'ol':
          return t ? t + '\n' : '';
        case 'pre':
          return t ? '```\n' + t + '\n```\n\n' : '';
        case 'code':
          return t ? '`' + t + '`' : '';
        default:

          if (/^(div|article|section|blockquote|h[1-6])$/.test(tag)) {
            return t ? t + '\n\n' : '';
          }
          return inner;
      }
    }

    return walk(clone).replace(/\n{3,}/g, '\n\n').trim();
  }

  function stripNamePrefix(text) {
    if (!text) return text;
    const nlIdx = text.indexOf('\n');
    if (nlIdx === -1) return text;
    const first = text.slice(0, nlIdx).trim();
    const rest  = text.slice(nlIdx + 1).trim();

    const firstNoInitials = first.replace(/\b[A-Z]\./g, '');
    if (
      first.length > 0 && first.length <= 50 &&
      /^[A-Z\u00C0-\u017E]/.test(first) &&
      !/[.!?,;:…"'`*]/.test(firstNoInitials) &&
      rest.length >= MIN_CHARS
    ) return rest;
    return text;
  }

  // ─── GM STORAGE PAYLOAD READER ─────────────────────────────────────────────

  function getLatestAITextFromPayload() {
    try {
      const raw = GM_getValue(GM_PAYLOAD_KEY, null);
      if (!raw) return null;
      const data = JSON.parse(raw);
      const msgs = data.messages || [];
      
      for (let i = msgs.length - 1; i >= 0; i--) {
        if (msgs[i].role === 'assistant') {
          const c = msgs[i].content;
          
          if (typeof c === 'string' && c.trim().length > 0) return c.trim();
          
          if (Array.isArray(c)) {
            const joined = c.map(part => part.text || part.content || '').join(' ').trim();
            if (joined.length > 0) return joined;
          }
        }
      }
    } catch {  }
    return null;
  }

  function getLatestAIText() {

    const payloadText = getLatestAITextFromPayload();
    if (payloadText && payloadText.length >= MIN_CHARS) {
      return stripNamePrefix(payloadText);
    }
    
    if (_lastAPIResponse && _lastAPIResponse.length >= MIN_CHARS) {
      return stripNamePrefix(_lastAPIResponse);
    }

    try {
      const items = document.querySelectorAll(VIRTUOSO_SEL);
      for (let i = items.length - 1; i >= 0; i--) {
        const node = items[i];
        const index = parseInt(node.getAttribute('data-index'), 10);
        if (!isNaN(index) && index <= _cachedLastBotIndex) break;

        if (!isAINode(node)) continue;

        const bodies = node.querySelectorAll(MSG_BODY_SEL);
        const text = bodies.length > 0
          ? Array.from(bodies).map(b => extractMarkdown(b)).join('\n\n').trim()
          : extractMarkdown(node);
        if (text.length >= MIN_CHARS) {
          if (!isNaN(index)) {
            _cachedLastBotIndex = index;
            _cachedLastBotText  = text;
          }
          return stripNamePrefix(text);
        }
      }
      if (_cachedLastBotText) return stripNamePrefix(_cachedLastBotText);
    } catch {  }

    // ── Fallback: virtuoso data-testid removed — find last AI message via _nameIcon_ ──
    try {
      const icons = document.querySelectorAll(SELECTOR_CONFIG.botIcon);
      for (let i = icons.length - 1; i >= 0; i--) {
        let container = icons[i].parentElement;
        for (let depth = 0; depth < 10 && container && container !== document.body; depth++) {
          const bodies = container.querySelectorAll(MSG_BODY_SEL);
          if (bodies.length > 0) {
            const text = Array.from(bodies).map(b => extractMarkdown(b)).join('\n\n').trim();
            if (text.length >= MIN_CHARS) return stripNamePrefix(text);
            break;
          }
          container = container.parentElement;
        }
      }
    } catch {  }

    for (const sel of FALLBACK_SELECTORS) {
      let hits;
      try { hits = Array.from(document.querySelectorAll(sel)); } catch { continue; }
      const valid = hits.filter(el => {
        const t = (el.innerText || '').trim();
        if (t.length < MIN_CHARS) return false;
        if (el.querySelector('input,textarea,select,[contenteditable]')) return false;
        if (el.closest('nav,header,footer,aside,form,[role="navigation"],[role="banner"],[role="toolbar"]')) return false;
        return true;
      });
      if (!valid.length) continue;
      const last = valid[valid.length - 1];
      return stripNamePrefix(extractMarkdown(last));
    }

    const candidates = [];
    document.querySelectorAll('div,article').forEach(el => {
      if (!el.querySelector(':scope > p')) return;
      const t = (el.innerText || '').trim();
      if (t.length < MIN_CHARS) return;
      if (el.querySelector('input,textarea,select,[contenteditable]')) return;
      if (el.closest('nav,header,footer,aside,form,[class*="card"],[class*="Card"],[class*="sidebar"],[class*="Sidebar"],[class*="profile"],[class*="Profile"]')) return;
      candidates.push(el);
    });
    if (!candidates.length) return null;
    const leaves = candidates.filter(el => !candidates.some(o => o !== el && el.contains(o)));
    if (!leaves.length) return null;
    return stripNamePrefix(extractMarkdown(leaves[leaves.length - 1]));
  }

  function injectAndSend(text, onSuccess, onFail) {
    const inputSelectors = [
      'textarea[placeholder*="message" i]',
      'textarea[placeholder*="type" i]',
      'textarea[placeholder*="write" i]',
      'textarea[data-testid*="input"]',
      '[contenteditable="true"][class*="input"]',
      '[contenteditable="true"]',
      'textarea',
    ];

    let input = null;
    for (const sel of inputSelectors) {
      try {
        const found = Array.from(document.querySelectorAll(sel)).find(
          el => !el.closest('nav,header,[role="dialog"]') && el.offsetParent !== null
        );
        if (found) { input = found; break; }
      } catch {  }
    }

    if (!input) { onFail && onFail(); return; }

    if (input.hasAttribute('contenteditable')) {
      input.textContent = text;
      input.dispatchEvent(new Event('input', { bubbles: true }));
    } else {
      
      const desc = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
                 || Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'value');
      if (desc && desc.set) {
        desc.set.call(input, text);
      } else {
        input.value = text;
      }
      input.dispatchEvent(new Event('input',  { bubbles: true }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
    }
    input.focus();

    setTimeout(() => {
      const sendSelectors = [
        'button[aria-label*="send" i]',
        'button[aria-label*="submit" i]',
        'button[type="submit"]:not(:disabled)',
        'button[class*="send"]:not(:disabled)',
      ];
      for (const sel of sendSelectors) {
        try {
          const btn = Array.from(document.querySelectorAll(sel)).find(
            b => !b.closest('[role="dialog"]') && b.offsetParent !== null && !b.disabled
          );
          if (btn) { btn.click(); onSuccess && onSuccess(); return; }
        } catch {  }
      }
      
      const valBefore = input.value;
      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }));
      setTimeout(() => {
        
        const submitted = input.value === '' || input.value !== valBefore;
        if (submitted) { onSuccess && onSuccess(); } else { onFail && onFail(); }
      }, 200);
    }, 150);
  }

  // ─── AI MESSAGE EDITOR ─────────────────────────────────────────────────────

  function replaceLatestAIMessage(newText, onSuccess, onFail) {
    const allNodes = Array.from(document.querySelectorAll(VIRTUOSO_SEL));
    let lastAINode = null;
    for (let i = allNodes.length - 1; i >= 0; i--) {
      if (isAINode(allNodes[i])) {
        lastAINode = allNodes[i];
        break;
      }
    }
    if (!lastAINode) { onFail && onFail(); return; }

    lastAINode.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
    lastAINode.dispatchEvent(new MouseEvent('mouseover',  { bubbles: true }));

    setTimeout(() => {
      const editBtnSel = [
        'button[aria-label*="edit" i]',
        'button[title*="edit" i]',
        '[class*="_edit_"]',
        '[class*="editBtn"]',
        '[class*="edit-btn"]',
      ].join(',');
      const editBtn = lastAINode.querySelector(editBtnSel);
      if (!editBtn) { onFail && onFail(); return; }

      editBtn.click();

      setTimeout(() => {
        const ta = lastAINode.querySelector('textarea')
                || document.querySelector('[data-testid*="edit"] textarea')
                || document.querySelector('.edit-message textarea');
        if (!ta) { onFail && onFail(); return; }

        const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
        if (desc && desc.set) {
          desc.set.call(ta, newText);
        } else {
          ta.value = newText;
        }
        ta.dispatchEvent(new Event('input',  { bubbles: true }));
        ta.dispatchEvent(new Event('change', { bubbles: true }));
        ta.focus();

        setTimeout(() => {
          const saveBtnSel = [
            'button[aria-label*="save" i]',
            'button[aria-label*="confirm" i]',
            'button[title*="save" i]',
            '[class*="_save_"]:not(:disabled)',
            '[class*="saveBtn"]:not(:disabled)',
          ].join(',');
          const saveBtn = lastAINode.querySelector(saveBtnSel)
                       || document.querySelector(saveBtnSel);
          if (saveBtn && !saveBtn.disabled) {
            saveBtn.click();
            onSuccess && onSuccess();
          } else {
            onFail && onFail();
          }
        }, 250);
      }, 400);
    }, 150);
  }

  // ─── AUTH HEADER BUILDER ────────────────────────────────────────────────────
  // Supports: Bearer (standard OpenAI-compatible), raw (no prefix — LiteRouter,
  // some self-hosted proxies), x-api-key (Anthropic-style proxies).
  // 'auto' detects from the key shape: pure hex ≥ 32 chars → raw; everything
  // else → Bearer.  User can override at any time via the Auth Format selector.

  function _buildAuthHeaders(key, ep, modeOverride) {
    const headers = {};
    const mode = modeOverride || CFG.authMode || 'auto';

    if (ep && ep.includes('anthropic.com')) {
      // Anthropic native always uses x-api-key + version header
      headers['x-api-key']         = key;
      headers['anthropic-version'] = '2023-06-01';
      return headers;
    }

    let resolved = mode;
    if (mode === 'auto') {
      // Default to Bearer for all key types — this matches OpenRouter, OpenAI, xAI,
      // Mistral, Groq, LiteRouter and most other OpenAI-compatible proxies.
      // Users can override via the Auth Format dropdown if their proxy needs something else.
      resolved = 'bearer';
    }

    if (resolved === 'x-api-key') {
      headers['x-api-key'] = key;
    } else if (resolved === 'raw') {
      headers['Authorization'] = key;
    } else {
      // 'bearer' — standard
      headers['Authorization'] = `Bearer ${key}`;
    }

    if (ep && ep.includes('openrouter')) {
      headers['HTTP-Referer'] = 'https://janitorai.com';
      headers['X-Title']      = 'JanitorV5 RP Toolkit';
    }
    return headers;
  }

  // ─── API CALL ──────────────────────────────────────────────────────────────

  async function callAPI(systemPrompt, userContent, opts = {}) {
    if (!CFG.apiKey) throw new Error('No API key set. Long-press the FAB to open Settings → General.');
    const baseEp       = CFG.endpoint.replace(/\/$/, '');
    const isAnthropic  = baseEp.includes('anthropic.com');

    // ── Build headers ───────────────────────────────────────────────────────
    const headers = {
      'Content-Type': 'application/json',
      ..._buildAuthHeaders(CFG.apiKey, baseEp),
    };

    // ── Build endpoint & body ───────────────────────────────────────────────
    // Anthropic: POST /v1/messages  (system is a top-level field, not in messages array)
    // Everyone else: POST /chat/completions  (OpenAI-compatible)
    const ep = isAnthropic
      ? `${baseEp}/v1/messages`
      : `${baseEp}/chat/completions`;

    const body = isAnthropic
      ? JSON.stringify({
          model:      CFG.model,
          system:     systemPrompt,
          messages:   [{ role: 'user', content: userContent }],
          max_tokens: opts.max_tokens ?? 1400,
        })
      : JSON.stringify({
          model:       CFG.model,
          messages:    [
            { role: 'system', content: systemPrompt },
            { role: 'user',   content: userContent  },
          ],
          max_tokens:  opts.max_tokens  ?? 1400,
          temperature: opts.temperature ?? 0.82,
        });

    const res = await gmFetch(ep, { method: 'POST', headers, signal: opts.signal ?? undefined, body });

    if (!res.ok) {
      const errText = await res.text().catch(() => '');
      let msg = `API error ${res.status}`;
      try {
        const errJson = JSON.parse(errText);
        // Anthropic wraps errors in { error: { message } }; OpenAI does too
        msg = errJson?.error?.message || errJson?.message || msg;
      } catch { }
      throw new Error(msg);
    }

    const data = await res.json();
    // Anthropic: data.content[0].text  |  OpenAI-compat: data.choices[0].message.content
    const result = isAnthropic
      ? (data?.content?.[0]?.text ?? '').trim()
      : (data?.choices?.[0]?.message?.content ?? '').trim();

    if (!result) throw new Error('API returned an empty response. Try a different model.');
    return result;
  }

  // ─── NEW MESSAGE OBSERVER ──────────────────────────────────────────────────

  let _observer = null;
  let _lastSeenText = '';

  function startObserver() {
    if (_observer) return;
    let _retries = 0;
    const MAX_RETRIES = 20; 
    const tryStart = () => {
      
      if (!isOnChatPage() || _retries++ >= MAX_RETRIES) return;
      const container = (
        document.querySelector('[class*="_messagesMain_"]') ||
        document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement
      );
      if (!container) { setTimeout(tryStart, 1500); return; }

      let _notifyTimer = null;
      _observer = new MutationObserver(() => {
        clearTimeout(_notifyTimer);
        _notifyTimer = setTimeout(() => {
          if (!CFG.autoNotify) return;
          const text = getLatestAIText();
          if (text && text !== _lastSeenText && text.length > 20) {
            _lastSeenText = text;
            topToast(`New message ↓ — tap ${SVG_REPLY} to reply`);
          }
        }, 300);
      });
      _observer.observe(container, { childList: true, subtree: true });
    };
    tryStart();
  }

  function stopObserver() {
    if (_observer) { _observer.disconnect(); _observer = null; }
    _lastSeenText = '';
  }

  // ─── AUTO-SUMMARY ENGINE ───────────────────────────────────────────────────

  function scrapeChatMessages() {
    const nodes = Array.from(document.querySelectorAll(VIRTUOSO_SEL));
    const results = [];
    const seen    = new Set();

    // ── Virtuoso path ──────────────────────────────────────────────────────
    if (nodes.length > 0) {
      for (const node of nodes) {
        const bodyEls = node.querySelectorAll(MSG_BODY_SEL);
        let text = bodyEls.length > 0
          ? Array.from(bodyEls).map(b => extractMarkdown(b)).join('\n\n').trim()
          : '';
        if (!text) {
          const clone = node.cloneNode(true);
          clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
          text = (clone.innerText || clone.textContent || '').trim();
        }
        if (!text || text.length < 6 || seen.has(text)) continue;
        seen.add(text);
        results.push({ role: isAINode(node) ? 'ai' : 'user', text });
      }
      return results;
    }

    // ── Fallback: virtuoso data-testid removed — scan via _messagesMain_ ──
    try {
      const main = document.querySelector(SELECTOR_CONFIG.messagesMain);
      if (!main) return results;
      // Find all _messageBody_ elements and walk up to their message container
      // (the shallowest ancestor under main that holds _messageBody_ but whose
      //  parent does NOT also contain those same bodies)
      const allBodies = Array.from(main.querySelectorAll(MSG_BODY_SEL));
      const containerMap = new Map();
      for (const body of allBodies) {
        let el = body;
        while (el.parentElement && el.parentElement !== main) {
          el = el.parentElement;
        }
        if (!containerMap.has(el)) containerMap.set(el, []);
        containerMap.get(el).push(body);
      }
      for (const [container, bodies] of containerMap) {
        const text = bodies.map(b => extractMarkdown(b)).join('\n\n').trim()
          || (() => {
               const clone = container.cloneNode(true);
               clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
               return (clone.innerText || clone.textContent || '').trim();
             })();
        if (!text || text.length < 6 || seen.has(text)) continue;
        seen.add(text);
        const isAI = !!container.querySelector(SELECTOR_CONFIG.botIcon);
        results.push({ role: isAI ? 'ai' : 'user', text });
      }
    } catch {  }

    return results;
  }

  function buildSumPrompt(msgs) {
    const charLimit = 2200;
    
    const snippet = msgs.slice(-80)
      .map((m, i) => `[${i + 1}] ${m.role === 'ai' ? 'Character' : 'User'}: ${m.text.slice(0, 350)}`)
      .join('\n');
    const systemLines = [
      'You are a roleplay session analyst. Write a structured summary using EXACTLY this format — no preamble, no extra labels, no deviation:',
      '',
      '[Character A] and [Character B] are in [location]. The setting is [mood/atmosphere in one short clause].',
      '',
      'The most important things that happened are:',
      '',
      '1. [Most important event — one sentence, active voice, use character names]',
      '2. [Second event]',
      '3. [Third event]',
      '4. [Fourth event, if applicable]',
      '5. [Fifth event, if applicable]',
      '',
      'Unresolved tension or emotional subtext: [One or two sentences about unresolved feelings, unclear dynamics, or open threads.]',
      '',
      'RULES:',
      '- Use character names exactly as they appear in the log.',
      '- 3 to 5 numbered items — never fewer than 3.',
      '- Do NOT quote dialogue. Extract what happened, not what was said word for word.',
      '- Do NOT use flowery or literary language — keep it clear and factual.',
      '- Do NOT write anything before the first sentence or after the "Unresolved" line.',
      `- Hard limit: ${charLimit} characters total.`,
    ];
    const system = systemLines.join('\n');
    const user = `CONVERSATION LOG (${msgs.length} messages, last 80 shown):\n${snippet}\n\nSUMMARY (follow the exact format above — start immediately with the characters + location sentence):`;
    return { system, user, charLimit };
  }

  // ─── MEMORY BOX PROMPT BUILDER ─────────────────────────────────────────────

  function buildMemoryBoxPrompt(msgs) {
    
    const charLimit = 1400;
    
    const snippet = msgs.slice(0, 120)
      .map((m, i) => `[${i + 1}] ${m.role === 'ai' ? 'Character' : 'User'}: ${m.text.slice(0, 400)}`)
      .join('\n');

    const systemLines = [
      'You are writing a persistent memory entry for a JanitorAI chat memory box. This is NOT a scene summary — it is a stable reference note about who the characters are and what defines their relationship. A player will paste this into JanitorAI\'s built-in "Chat Memory" panel so the AI always remembers context between sessions.',
      '',
      'Write using EXACTLY this format — no preamble, no deviation:',
      '',
      'Characters: [Character A] and [Character B]. [1–2 sentences: who they each are — name, personality, their role in the story.]',
      '',
      'Relationship: [1–2 sentences: how they know each other, the nature of their bond, any key dynamic or tension between them.]',
      '',
      'Key events:',
      '1. [Most significant event that shaped the relationship — one sentence, active voice]',
      '2. [Second defining event]',
      '3. [Third defining event]',
      '4. [Fourth event, if applicable]',
      '5. [Fifth event, if applicable]',
      '',
      'Ongoing: [1–2 sentences: what is unresolved, what drives the story forward, emotional undercurrents.]',
      '',
      'RULES:',
      '- Use character names exactly as they appear in the log.',
      '- 3 to 5 numbered key events — never fewer than 3.',
      '- Write in present tense where natural ("They share a complicated past…").',
      '- Do NOT quote dialogue verbatim. Describe what happened.',
      '- Do NOT describe the current scene or what just happened — focus on lasting facts.',
      '- Do NOT use flowery language — clear, factual, compact.',
      '- Do NOT write anything outside the four sections above.',
      `- Hard limit: ${charLimit} characters total.`,
    ];
    const system = systemLines.join('\n');
    const user = `CONVERSATION LOG (${msgs.length} messages total, up to 120 shown):\n${snippet}\n\nMEMORY ENTRY (follow the exact four-section format — start immediately with "Characters:"):`;
    return { system, user, charLimit };
  }

  // ─── LOAD ALL — STANDALONE ─────────────────────────────────────────────────

  async function doLoadAll(onProgress) {
    const scroller =
      document.querySelector('[class*="_messagesMain_"]') ||
      document.querySelector('[data-testid="virtuoso-scroller"]') ||
      document.querySelector('[class*="messagesMain"]') ||
      document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement;
    if (!scroller) return -1;

    clearAccumulated();
    const wait = ms => new Promise(r => setTimeout(r, ms));

    const harvest = () => {
      let added = 0;
      const seen = new Set();

      // ── Virtuoso path ──────────────────────────────────────────────────
      const nodes = document.querySelectorAll(VIRTUOSO_SEL);
      if (nodes.length > 0) {
        for (const node of nodes) {
          const bodyEls = node.querySelectorAll(MSG_BODY_SEL);
          let text = bodyEls.length > 0
            ? Array.from(bodyEls).map(b => extractMarkdown(b)).join('\n\n').trim()
            : '';
          if (!text) {
            const clone = node.cloneNode(true);
            clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
            text = (clone.innerText || clone.textContent || '').trim();
          }
          if (!text || text.length < 6) continue;
          const key = _hashStr(text.slice(0, 120));
          if (seen.has(key) || _loadedMap.has(key)) continue;
          seen.add(key);
          _loadedMap.set(key, { role: isAINode(node) ? 'ai' : 'user', text });
          _loadedOrder.push(key);
          added++;
        }
        return added;
      }

      // ── Fallback: virtuoso data-testid removed — scan via _messagesMain_ ──
      try {
        const main = document.querySelector(SELECTOR_CONFIG.messagesMain);
        if (!main) return added;
        const allBodies = Array.from(main.querySelectorAll(MSG_BODY_SEL));
        const containerMap = new Map();
        for (const body of allBodies) {
          let el = body;
          while (el.parentElement && el.parentElement !== main) el = el.parentElement;
          if (!containerMap.has(el)) containerMap.set(el, []);
          containerMap.get(el).push(body);
        }
        for (const [container, bodies] of containerMap) {
          let text = bodies.map(b => extractMarkdown(b)).join('\n\n').trim();
          if (!text) {
            const clone = container.cloneNode(true);
            clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
            text = (clone.innerText || clone.textContent || '').trim();
          }
          if (!text || text.length < 6) continue;
          const key = _hashStr(text.slice(0, 120));
          if (seen.has(key) || _loadedMap.has(key)) continue;
          seen.add(key);
          const isAI = !!container.querySelector(SELECTOR_CONFIG.botIcon);
          _loadedMap.set(key, { role: isAI ? 'ai' : 'user', text });
          _loadedOrder.push(key);
          added++;
        }
      } catch {  }

      return added;
    };

    scroller.scrollTop = 0;
    scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
    await wait(1800);
    harvest();
    onProgress?.(_loadedMap.size, 0);

    const PAGE = Math.max(scroller.clientHeight * 0.75, 200);
    const MAX_STEPS = 80;
    let steps = 0;
    let stuckRounds = 0;

    while (steps < MAX_STEPS) {
      const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 20;
      scroller.scrollTop += PAGE;
      scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      await wait(700);
      const added = harvest();
      steps++;
      onProgress?.(_loadedMap.size, steps);
      if (added === 0) {
        stuckRounds++;
        if (stuckRounds >= 3 || atBottom) break;
      } else {
        stuckRounds = 0;
      }
    }

    return _loadedMap.size;
  }

  let _sumRunning = false;
  async function doGenerateSummary({ silent = false } = {}) {
    if (_sumRunning) return;

    const msgs = scrapeChatMessages();
    if (msgs.length < 1) {
      if (!silent) toast(`${SVG_WARNING} No messages visible — scroll to the area you want to summarise.`);
      return;
    }
    _sumRunning = true;
    const genBtn = document.querySelector('#ms2-ctx-gen-btn');
    if (genBtn) { genBtn.disabled = true; genBtn.textContent = '…'; }
    try {
      const { system, user, charLimit } = buildSumPrompt(msgs);
      const raw = await callAPI(system, user, { max_tokens: 600, temperature: 0.35 });
      const summary = raw.trim().slice(0, charLimit);
      
      saveContext(summary);
      addSumHistory(summary);
      
      const ta = document.querySelector('#ms2-s-context');
      if (ta) {
        ta.value = summary;
        ta.dispatchEvent(new Event('input', { bubbles: true }));
      }
      
      renderSumHistory();
      if (!silent) toast(`${SVG_CHECK} Scene Context updated from ${msgs.length} visible messages`);
    } catch (err) {
      if (!silent) toast(`${SVG_WARNING} Summary failed: ${escHtml(err.message)}`, 3500);
    } finally {
      _sumRunning = false;
      if (genBtn) { genBtn.disabled = false; genBtn.innerHTML = `${SVG_SPARKLE} Generate`; }
    }
  }

  let _sumHistShowAll = false;
  function renderSumHistory() {
    const list = document.querySelector('#ms2-ctx-hist-list');
    if (!list) return;
    const allHist = getSumHistory();
    const curKey  = ctxKey();
    const curHist = allHist.filter(h => h.conv === curKey);

    const toggleId = 'ms2-sumhist-toggle';
    let toggle = list.previousElementSibling?.id === toggleId
      ? list.previousElementSibling
      : null;
    if (!toggle) {
      toggle = document.createElement('div');
      toggle.id = toggleId;
      toggle.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;';
      list.parentNode?.insertBefore(toggle, list);
    }
    const showAllCount  = allHist.length;
    const showCurCount  = curHist.length;
    toggle.innerHTML = `
      <span style="font-size:11px;color:#6b7280;flex:1;">
        ${_sumHistShowAll
          ? `All chats · <strong style="color:#a78bfa;">${showAllCount}</strong> entr${showAllCount !== 1 ? 'ies' : 'y'}`
          : `This chat · <strong style="color:#10b981;">${showCurCount}</strong> entr${showCurCount !== 1 ? 'ies' : 'y'}`}
      </span>
      <button id="ms2-sumhist-toggle-btn" style="font-size:10px;background:none;border:1px solid #374151;border-radius:4px;color:#9ca3af;padding:2px 7px;cursor:pointer;white-space:nowrap;">
        ${_sumHistShowAll ? 'This chat only' : `All chats (${showAllCount})`}
      </button>`;
    toggle.querySelector('#ms2-sumhist-toggle-btn')?.addEventListener('click', () => {
      _sumHistShowAll = !_sumHistShowAll;
      renderSumHistory();
    });

    const hist = _sumHistShowAll ? allHist : curHist;

    const idxMap = _sumHistShowAll
      ? allHist.slice(0, 20).map((_, i) => i)
      : allHist.reduce((acc, item, i) => { if (item.conv === curKey) acc.push(i); return acc; }, []);

    if (!hist.length) {
      list.innerHTML = _sumHistShowAll
        ? '<div style="color:#4b5563;font-size:11px;padding:6px 0;">No history yet — generate to save one.</div>'
        : '<div style="color:#4b5563;font-size:11px;padding:6px 0;">No summaries for this chat yet.<br>Switch to "All chats" to see summaries from other characters.</div>';
      return;
    }

    list.innerHTML = hist.slice(0, 10).map((item, localI) => {
      const realIdx = idxMap[localI] ?? localI;
      const isOtherChat = item.conv !== curKey;

      const otherName   = isOtherChat
        ? (getChatName(item.conv) || item.chatName || 'other chat')
        : '';
      const otherBadge  = isOtherChat
        ? `<span style="font-size:9px;background:#1e3a5f;color:#7dd3fc;border-radius:3px;padding:1px 5px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;" title="From: ${escHtml(otherName)}">${escHtml(otherName)}</span>`
        : '';
      return `<div class="ms2-sum-hist-item" data-idx="${realIdx}" style="margin-bottom:6px;background:${isOtherChat ? '#1a2332' : '#1e293b'};border-radius:6px;padding:6px 8px;${isOtherChat ? 'border-left:2px solid #1e3a5f;' : ''}">
         <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px;">
           <span style="color:#6b7280;font-size:10px;flex:1;">${escHtml(item.date)}${otherBadge}</span>
           <button class="ms2-hist-edit-btn ap-icon-btn" data-idx="${realIdx}" title="Edit this entry" style="font-size:12px;padding:1px 4px;">✎</button>
           <button class="ms2-hist-del-btn ap-icon-btn" data-idx="${realIdx}" title="Delete this entry" style="font-size:12px;padding:1px 4px;color:#fca5a5;">${SVG_TRASH}</button>
         </div>
         <div class="ms2-sum-hist-body" data-idx="${realIdx}" style="color:#94a3b8;font-size:11.5px;line-height:1.4;cursor:pointer;" title="Click to load into context field">
           ${escHtml(item.text.slice(0, 120))}${item.text.length > 120 ? '…' : ''}
         </div>
       </div>`;
    }).join('');

    list.querySelectorAll('.ms2-sum-hist-body').forEach(el => {
      el.addEventListener('click', () => {
        const item = getSumHistory()[+el.dataset.idx];
        if (!item) return;
        const ta = document.querySelector('#ms2-s-context');
        if (ta) { ta.value = item.text; ta.dispatchEvent(new Event('input', { bubbles: true })); }
        toast('↩ History entry loaded into context field');
      });
    });

    list.querySelectorAll('.ms2-hist-edit-btn').forEach(btn => {
      btn.addEventListener('click', e => {
        e.stopPropagation();
        const idx = +btn.dataset.idx;
        const row = btn.closest('.ms2-sum-hist-item');
        if (row.querySelector('.ms2-hist-edit-wrap')) return; 
        const item = getSumHistory()[idx];
        if (!item) return;
        const wrap = document.createElement('div');
        wrap.className = 'ms2-hist-edit-wrap';
        wrap.style.cssText = 'margin-top:6px;';
        const ta = document.createElement('textarea');
        ta.value = item.text;
        ta.style.cssText = 'width:100%;box-sizing:border-box;min-height:64px;max-height:140px;font-size:11px;background:#0f172a;color:#e2e8f0;border:1px solid #475569;border-radius:4px;padding:5px;resize:vertical;overflow-y:auto;';
        
        ta.addEventListener('wheel', e => e.stopPropagation(), { passive: true });
        ta.addEventListener('touchmove', e => e.stopPropagation(), { passive: true });
        const actRow = document.createElement('div');
        actRow.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
        const saveBtn = document.createElement('button');
        saveBtn.innerHTML = `${SVG_CHECK} Save`;
        saveBtn.className = 'ap-icon-btn';
        saveBtn.style.cssText = 'color:#86efac;font-size:12px;padding:3px 8px;';
        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Cancel';
        cancelBtn.className = 'ap-icon-btn';
        cancelBtn.style.cssText = 'font-size:12px;padding:3px 8px;';
        actRow.append(saveBtn, cancelBtn);
        wrap.append(ta, actRow);
        row.appendChild(wrap);
        ta.focus();
        saveBtn.addEventListener('click', () => {
          const newText = ta.value.trim();
          if (!newText) { ta.focus(); return; }
          const h = getSumHistory();
          if (!h[idx]) return;
          h[idx].text = newText;
          saveSumHistory(h);
          renderSumHistory();
          toast(`${SVG_CHECK} Summary entry updated`);
        });
        cancelBtn.addEventListener('click', () => wrap.remove());
      });
    });

    list.querySelectorAll('.ms2-hist-del-btn').forEach(btn => {
      btn.addEventListener('click', e => {
        e.stopPropagation();
        const idx = +btn.dataset.idx;
        const row = btn.closest('.ms2-sum-hist-item');
        
        const existing = row.querySelector('.ms2-hist-confirm');
        if (existing) { existing.remove(); return; }
        const bar = document.createElement('div');
        bar.className = 'ms2-hist-confirm';
        bar.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:6px;font-size:11px;';
        const label = document.createElement('span');
        label.textContent = 'Delete this entry?';
        label.style.cssText = 'flex:1;color:#f87171;';
        const yesBtn = document.createElement('button');
        yesBtn.innerHTML = `${SVG_CHECK} Yes`;
        yesBtn.className = 'ap-icon-btn';
        yesBtn.style.color = '#f87171';
        const noBtn = document.createElement('button');
        noBtn.textContent = 'No';
        noBtn.className = 'ap-icon-btn';
        bar.append(label, yesBtn, noBtn);
        row.appendChild(bar);
        yesBtn.addEventListener('click', () => {
          const h = getSumHistory();
          h.splice(idx, 1);
          saveSumHistory(h);
          renderSumHistory();
          toast('Entry deleted');
        });
        noBtn.addEventListener('click', () => bar.remove());
      });
    });
  }

  let _sumObserver  = null;
  let _sumLastCount = 0;
  let _sumTimer     = null;

  function startAutoSumObserver() {
    if (_sumObserver) return;
    const every = getAutoSumEvery();
    if (!every || every <= 0) return;
    let _retries = 0;
    const tryStart = () => {
      if (!isOnChatPage() || _retries++ >= 20) return;
      const container = (
        document.querySelector('[class*="_messagesMain_"]') ||
        document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement
      );
      if (!container) { setTimeout(tryStart, 1500); return; }

      _sumLastCount = _countVirtuosoItems();
      _sumObserver = new MutationObserver((mutations) => {
        clearTimeout(_sumTimer);
        _sumTimer = setTimeout(() => {

          let maxSeen = _sumLastCount;
          for (const m of mutations) {
            for (const node of m.addedNodes) {
              if (node.nodeType !== 1) continue;
              const idx = parseInt(node.getAttribute?.('data-index'), 10);
              if (!isNaN(idx) && idx > maxSeen) maxSeen = idx;
              
              if (node.querySelectorAll) {
                node.querySelectorAll('[data-index]').forEach(el => {
                  const i = parseInt(el.getAttribute('data-index'), 10);
                  if (!isNaN(i) && i > maxSeen) maxSeen = i;
                });
              }
            }
          }
          if (maxSeen <= _sumLastCount) return;
          const crossed = maxSeen >= every &&
            Math.floor(maxSeen / every) > Math.floor(_sumLastCount / every);
          _sumLastCount = maxSeen;
          if (!crossed) return;
          if (getAutoSumAuto()) {
            doGenerateSummary({ silent: true }).then(() =>
              topToast(`Context auto-updated from chat ${SVG_CHECK}`)
            );
          } else {
            topToast(`Context ready to refresh — ${maxSeen + 1} messages in chat`);
          }
        }, 900);
      });
      _sumObserver.observe(container, { childList: true, subtree: true });
    };
    tryStart();
  }

  function _countVirtuosoItems() {
    let max = 0;
    document.querySelectorAll('[data-testid="virtuoso-item-list"] > div[data-index]')
      .forEach(n => {
        const v = parseInt(n.getAttribute('data-index'), 10);
        if (!isNaN(v) && v > max) max = v;
      });
    return max;
  }

  function stopAutoSumObserver() {
    if (_sumObserver) { _sumObserver.disconnect(); _sumObserver = null; }
    clearTimeout(_sumTimer);
  }

  const _loadedMap   = new Map(); 
  const _loadedOrder = [];        

  function _accumMsgs(msgs) {
    let added = 0;
    for (const msg of msgs) {
      const key = _hashStr(msg.text.slice(0, 120));
      if (!_loadedMap.has(key)) {
        _loadedMap.set(key, msg);
        _loadedOrder.push(key);
        added++;
      }
    }
    return added;
  }

  function _getAccumulated() {
    return _loadedOrder.map(k => _loadedMap.get(k));
  }

  function clearAccumulated() {
    _loadedMap.clear();
    _loadedOrder.length = 0;
  }

  // ─── SPEED DIAL ────────────────────────────────────────────────────────────

  let _dialOpen = false;

  function toggleDial() {
    if (_dialOpen) { closeDial(); return; }
    _dialOpen = true;
    const fab = document.getElementById('ms2-fab');
    if (!fab) return;

    const right  = parseInt(fab.style.right,  10) || CFG.fabRight;
    const bottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;

    const overlay = document.createElement('div');
    overlay.id = 'ms2-dial-overlay';
    overlay.addEventListener('click', () => closeDial());

    const items = [
      { icon: SVG_SCISSORS,  label: 'Shorten',    color: '#8b5cf6', action: () => { closeDial(); handleShorten();   } },
      { icon: SVG_REPLY,     label: 'Smart Reply', color: '#22d3ee', action: () => { closeDial(); handleReply();     } },
      { icon: SVG_STYLES,    label: 'Styles',      color: '#f59e0b', action: () => { closeDial(); handleStyles();    } },
      { icon: SVG_SUMMARISE, label: 'Summarise',   color: '#10b981', action: () => { closeDial(); handleSummarize(); } },
      { icon: SVG_PERSONA,   label: 'Personas',    color: '#f472b6', action: () => handlePersonasQuickSwitch(overlay, right, bottom) },
      { icon: SVG_CHAT,      label: 'Community',   color: '#06b6d4', action: () => { closeDial(); handleCommunityChat(); } },
    ];

    const panel = document.createElement('div');
    panel.className = 'ms2-dial-panel';
    panel.style.cssText = `right:${right + 56}px;bottom:${bottom}px;animation:ms2-dial-in 0.18s cubic-bezier(0.16,1,0.3,1) both;`;
    panel.addEventListener('click', e => e.stopPropagation());

    items.forEach(item => {
      const row = document.createElement('button');
      row.className = 'ms2-dial-row';
      row.innerHTML = `<span class="ms2-dial-row-icon" style="color:${item.color};">${item.icon}</span><span class="ms2-dial-row-label">${item.label}</span>`;
      row.addEventListener('click', () => item.action());
      panel.appendChild(row);
    });

    overlay.appendChild(panel);
    document.body.appendChild(overlay);
    fab.classList.add('ms2-dial-open');
  }

  function closeDial() {
    _dialOpen = false;
    document.getElementById('ms2-dial-overlay')?.remove();
    document.getElementById('ms2-fab')?.classList.remove('ms2-dial-open');
  }

  // ─── ACTION HANDLERS ───────────────────────────────────────────────────────

  function handleShorten() {
    if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }
    const text = getLatestAIText();
    text ? openShortenModal(text) : openNoTextModal();
  }

  function handleReply() {
    if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }
    const text = getLatestAIText();
    text ? openReplyModal(text) : openNoTextModal();
  }

  function handleStyles() {
    openSettingsModal('styles');
  }

  function handlePersonasQuickSwitch(overlay, fabRight, fabBottom) {
    
    const existing = document.getElementById('ms2-persona-popup');
    if (existing) { existing.remove(); return; }

    const personas = getPersonaLib();
    const popupW   = 248;
    const gap      = 12;

    const rightEdge = fabRight + 44 + gap;
    const safeRight = Math.min(rightEdge, window.innerWidth - popupW - 8);

    const popup = document.createElement('div');
    popup.id = 'ms2-persona-popup';
    popup.style.cssText =
      `position:fixed;z-index:999999;right:${safeRight}px;bottom:${fabBottom - 8}px;` +
      `width:${popupW}px;max-height:310px;display:flex;flex-direction:column;` +
      `background:#1a1625;border:1px solid rgba(244,114,182,0.45);border-radius:11px;` +
      `box-shadow:0 6px 28px rgba(0,0,0,0.65);overflow:hidden;animation:ms2-up 0.16s ease;`;

    popup.innerHTML = `
      <div style="padding:8px 10px 6px;border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0;">
        <div style="display:flex;align-items:center;gap:5px;font-size:10px;color:#f9a8d4;text-transform:uppercase;letter-spacing:.9px;font-weight:700;margin-bottom:6px;">${SVG_PERSONA} Persona Quick-Switch</div>
        <input id="ms2-pp-filter" type="text" placeholder="${personas.length > 0 ? 'Search personas…' : 'No personas yet'}"
          style="width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);
          border-radius:5px;color:#e2e8f0;font-size:12px;padding:5px 8px;outline:none;font-family:system-ui,sans-serif;"
          ${personas.length === 0 ? 'disabled' : ''}>
      </div>
      <div id="ms2-pp-list" style="overflow-y:auto;flex:1;padding:4px;"></div>
      <div style="padding:5px 8px;border-top:1px solid rgba(255,255,255,0.06);flex-shrink:0;text-align:center;">
        <span style="font-size:10px;color:#374151;">Long-press FAB → Context tab to manage</span>
      </div>`;

    popup.addEventListener('click', e => e.stopPropagation());

    overlay.appendChild(popup);

    const filterIn = popup.querySelector('#ms2-pp-filter');
    const listEl   = popup.querySelector('#ms2-pp-list');

    function renderList(q = '') {
      listEl.innerHTML = '';
      if (!personas.length) {
        listEl.innerHTML =
          '<div style="font-size:11px;color:#4b5563;padding:10px 8px;text-align:center;line-height:1.5;">' +
          'No personas saved yet.<br>Add them in <strong style="color:#9ca3af;">Settings → Context</strong>.</div>';
        return;
      }
      const hits = q
        ? personas.filter(p => (p.name + p.desc).toLowerCase().includes(q.toLowerCase()))
        : personas;
      if (!hits.length) {
        listEl.innerHTML = '<div style="font-size:11px;color:#4b5563;padding:8px;text-align:center;">No matches.</div>';
        return;
      }
      
      listEl.innerHTML = hits.map(p =>
        `<div class="ms2-pp-row" data-pid="${escHtml(p.id)}">` +
          `<span style="flex:1;font-size:12px;color:#f9a8d4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${escHtml(p.name)}">${escHtml(p.name)}</span>` +
          `<button class="pp-use" data-pid="${escHtml(p.id)}" style="background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#6ee7b7;font-size:10px;cursor:pointer;padding:3px 9px;flex-shrink:0;font-family:system-ui,sans-serif;">Use</button>` +
        `</div>`
      ).join('');
    }

    listEl.addEventListener('click', e => {
      const btn = e.target.closest('.pp-use');
      if (!btn) return;
      e.stopPropagation();
      const pid = btn.dataset.pid;
      const p = personas.find(x => x.id === pid);
      if (!p) return;
      saveContext(p.desc);
      closeDial();
      toast(`${SVG_PERSONA} ${escHtml(p.name)} loaded`);
    });

    let _ppFilterTimer = null;
    filterIn?.addEventListener('input', () => {
      clearTimeout(_ppFilterTimer);
      _ppFilterTimer = setTimeout(() => renderList(filterIn.value), 120);
    });
    filterIn?.addEventListener('keydown', e => { if (e.key === 'Escape') closeDial(); });
    renderList();
    setTimeout(() => filterIn?.focus(), 60);
  }

  function _showFabSumWarning(newMsgCount, timeAgo) {
    document.getElementById('ms2-fabsum-warn-backdrop')?.remove();
    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-fabsum-warn-backdrop';
    setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }), 300);
    addEscapeClose(backdrop);

    const clampedNew = Math.max(0, newMsgCount);
    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.style.maxWidth = '420px';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_WARNING} Too soon to re-summarise</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-modal-body">
        <div class="ms2-tip" style="border-color:rgba(251,191,36,0.5);color:#fcd34d;margin-bottom:12px;">
          ${SVG_SUMMARISE} Last summary was <strong>${timeAgo}</strong> — only
          <strong>${clampedNew} new message${clampedNew !== 1 ? 's' : ''}</strong> since then.
        </div>
        <p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
          Summaries work best after <strong style="color:#c4b5fd;">${FAB_SUM_MIN_NEW_MSGS}+ new messages</strong>.
          Running one too soon produces a nearly identical result and wastes your API quota.
        </p>
        <p style="margin:0;font-size:12px;color:#6b7280;line-height:1.5;">
          Keep chatting and come back when more has happened —
          or run it now if you genuinely need a fresh copy.
        </p>
      </div>
      <div class="ms2-modal-footer" style="gap:8px;">
        <button class="ms2-btn-action ms2-btn-copy" id="ms2-fabwarn-close-btn">Not yet — keep chatting</button>
        <button class="ms2-btn-action ms2-btn-retry" id="ms2-fabwarn-force-btn">${SVG_SUMMARISE} Run anyway</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);
    modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
    modal.querySelector('#ms2-fabwarn-close-btn').addEventListener('click', () => backdrop.remove());
    modal.querySelector('#ms2-fabwarn-force-btn').addEventListener('click', () => {
      backdrop.remove();
      doFABSummarize();
    });
  }

  function handleSummarize() {
    if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }

    const last = getFabSumLast();
    if (last) {
      const currentIdx = _countVirtuosoItems();
      const delta      = currentIdx - last.domIndex;
      if (delta >= 0 && delta < FAB_SUM_MIN_NEW_MSGS) {
        const mins = Math.round((Date.now() - last.ts) / 60000);
        const timeAgo = mins < 1 ? 'just now' : `${mins} minute${mins !== 1 ? 's' : ''} ago`;
        _showFabSumWarning(delta, timeAgo);
        return;
      }
    }

    doFABSummarize();
  }

  // ─── FAB SUMMARISE — FULL HISTORY → CLIPBOARD ──────────────────────────────

  let _fabSumRunning = false;
  async function doFABSummarize() {
    if (_fabSumRunning) return;
    _fabSumRunning = true;

    const progressToast = toast(`${SVG_ARROW_UP} Loading full chat history…`, 120000);
    try {
      const result = await doLoadAll((loaded) => {
        if (progressToast.isConnected) progressToast.innerHTML = `${SVG_ARROW_UP} Loading… ${loaded} msgs`;
      });
      progressToast?.remove?.();
      if (result === -1) {
        toast(`${SVG_WARNING} Could not find chat scroll area — are you in an active chat?`, 4000);
        _fabSumRunning = false;
        return;
      }
    } catch (e) {
      progressToast?.remove?.();
      toast(`${SVG_WARNING} Load failed: ${escHtml(e.message)}`, 4000);
      _fabSumRunning = false;
      return;
    }

    _accumMsgs(scrapeChatMessages());
    const msgs = _getAccumulated();
    if (msgs.length < 1) {
      toast(`${SVG_WARNING} No messages found in this chat.`, 3500);
      _fabSumRunning = false;
      return;
    }

    const genToast = toast(`${SVG_SUMMARISE} Writing memory entry from ${msgs.length} messages…`, 120000);
    try {
      const { system, user, charLimit } = buildMemoryBoxPrompt(msgs);
      const raw = await callAPI(system, user, { temperature: 0.3, max_tokens: 700 });
      const summary = raw.trim().slice(0, charLimit);
      genToast?.remove?.();

      navigator.clipboard.writeText(summary).catch(() => {});

      setFabSumLast(_countVirtuosoItems());

      const backdrop = document.createElement('div');
      backdrop.className = 'ms2-backdrop';
      backdrop.id = 'ms2-fabsum-backdrop';
      setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }), 350);
      addEscapeClose(backdrop);
      const modal = document.createElement('div');
      modal.className = 'ms2-modal';
      modal.setAttribute('role', 'dialog');
      modal.setAttribute('aria-modal', 'true');
      modal.innerHTML = `
        <div class="ms2-modal-header">
          <div class="ms2-modal-title" style="display:flex;align-items:center;gap:6px;">
            ${SVG_SUMMARISE} Memory Summary
            <button id="ms2-fabsum-help-btn" title="How is this different from Context Generate?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:18px;height:18px;color:#9ca3af;font-size:10px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
          </div>
          <button class="ms2-modal-close">×</button>
        </div>
        <div class="ms2-modal-body">
          <div class="ms2-tip" style="margin-bottom:10px;border-color:rgba(16,185,129,0.4);color:#6ee7b7;">
            ${SVG_CHECK} <strong>Copied to clipboard!</strong> Built from <strong>${msgs.length} messages</strong> (full history).<br>
            You can paste this anywhere you store long-term memory — e.g. JanitorAI's Chat Memory panel (≡ → Chat Memory).
          </div>
          <div class="ms2-label">Memory Entry</div>
          <div class="ms2-textbox result" style="white-space:pre-wrap;font-size:12px;">${escHtml(summary)}</div>
          <div class="ms2-tip" style="margin-top:8px;font-size:11px;">
            ${SVG_TIP} <strong>Not saved to Scene Context — by design.</strong><br>
            • <strong>Summarise</strong> = full story arc, who the characters are, relationship backstory<br>
            • <strong>Context Generate</strong> = current-situation note injected into every reply
          </div>
        </div>
        <div class="ms2-modal-footer">
          <button class="ms2-btn-action ms2-btn-copy" id="ms2-fabsum-copy-btn">${SVG_COPY} Copy again</button>
          <button class="ms2-btn-action ms2-btn-retry" id="ms2-fabsum-close-btn">Close</button>
        </div>`;
      backdrop.appendChild(modal);
      document.body.appendChild(backdrop);
      modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
      modal.querySelector('#ms2-fabsum-close-btn').addEventListener('click', () => backdrop.remove());
      modal.querySelector('#ms2-fabsum-copy-btn').addEventListener('click', () => {
        navigator.clipboard.writeText(summary).then(() => {
          const b = modal.querySelector('#ms2-fabsum-copy-btn');
          b.innerHTML = `${SVG_CHECK} Copied!`;
          setTimeout(() => { if (b.isConnected) b.innerHTML = `${SVG_COPY} Copy again`; }, 1800);
        });
      });

      modal.querySelector('#ms2-fabsum-help-btn')?.addEventListener('click', () => {
        document.getElementById('fabsum-help-backdrop')?.remove();
        const hB = document.createElement('div');
        hB.className = 'ms2-backdrop'; hB.id = 'fabsum-help-backdrop'; hB.style.zIndex = '10000020';
        const hM = document.createElement('div');
        hM.className = 'ms2-modal'; hM.style.maxWidth = '480px';
        hM.innerHTML = `
          <div class="ms2-modal-header">
            <div class="ms2-modal-title">${SVG_SUMMARISE} Summarise vs Context Generate</div>
            <button class="ms2-modal-close" id="fsh-close">×</button>
          </div>
          <div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">
            <p style="margin:0 0 10px;"><strong style="color:#10b981;">FAB → Summarise (this button)</strong><br>
            Reads your <strong>entire chat history</strong> automatically (runs Load All for you). Produces a persistent summary: who the characters are, their relationship arc, and the major story events. Output goes to <strong>clipboard</strong> — paste it wherever you store long-term context (e.g. JanitorAI's Chat Memory panel).</p>
            <p style="margin:0 0 10px;"><strong style="color:#a78bfa;">Context tab → Generate</strong><br>
            Reads only <strong>currently visible messages</strong>. Produces a current-situation note: where you are, what just happened. Saves to <strong>Scene Context</strong> and gets injected into every JanitorAI reply (when "Send to JanitorAI's AI" is ON).</p>
            <table style="width:100%;border-collapse:collapse;font-size:12px;margin:0 0 10px;">
              <tr style="color:#6b7280;border-bottom:1px solid rgba(255,255,255,0.07);">
                <th style="text-align:left;padding:4px 8px 4px 0;"></th>
                <th style="text-align:left;padding:4px 8px;color:#10b981;">Summarise</th>
                <th style="text-align:left;padding:4px 8px;color:#a78bfa;">Context Generate</th>
              </tr>
              <tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Source</td>
                <td style="padding:5px 8px;">Full history (auto Load All)</td>
                <td style="padding:5px 8px;">Visible messages only</td>
              </tr>
              <tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Output</td>
                <td style="padding:5px 8px;">Clipboard only</td>
                <td style="padding:5px 8px;">Scene Context field</td>
              </tr>
              <tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Paste into</td>
                <td style="padding:5px 8px;">Clipboard (paste anywhere)</td>
                <td style="padding:5px 8px;">Stays in Scene Context</td>
              </tr>
              <tr>
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Purpose</td>
                <td style="padding:5px 8px;">Who they are, backstory</td>
                <td style="padding:5px 8px;">What is happening now</td>
              </tr>
            </table>
            <p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Tip: Use both tools together — Summarise captures the full story arc; Context Generate keeps the AI oriented to the current scene.</p>
          </div>`;
        hB.appendChild(hM);
        document.body.appendChild(hB);
        const hClose = () => hB.remove();
        hM.querySelector('#fsh-close').addEventListener('click', hClose);
        setTimeout(() => hB.addEventListener('click', e => { if (e.target === hB) hClose(); }), 300);
        addEscapeClose(hB);
      });

    } catch (err) {
      genToast?.remove?.();
      if (err.name !== 'AbortError') toast(`${SVG_WARNING} Memory summary failed: ${escHtml(err.message)}`, 4000);
    } finally {
      _fabSumRunning = false;
    }
  }

  // ─── NO TEXT MODAL ─────────────────────────────────────────────────────────

  function openNoTextModal() {
    document.getElementById('ms2-main-backdrop')?.remove();
    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-main-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);
    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.style.maxWidth = '340px';
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_SCISSORS} No message found</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-modal-body">
        <div class="ms2-no-text">
          <strong>Could not find an AI message.</strong><br><br>
          Make sure you're in an active chat with at least one character response visible on screen.
        </div>
      </div>`;
    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);
    modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
  }

  // ─── SHORTEN MODAL ─────────────────────────────────────────────────────────

  function openShortenModal(originalText) {
    document.getElementById('ms2-main-backdrop')?.remove();

    let selectedLength = CFG.shortenLength;
    let keepDialogue   = CFG.keepDialogue;

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-main-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');

    const lengthBtns = ['brief', 'compact', 'trim'].map(l =>
      `<button class="ms2-length-btn ${selectedLength === l ? 'active' : ''}" data-len="${l}">${l === 'brief' ? 'Slash (~30%)' : l === 'compact' ? 'Halve (~50%)' : 'Polish (~70%)'}</button>`
    ).join('');

    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_SCISSORS} Make Shorter</div>
        <button class="ms2-modal-close" title="Close">×</button>
      </div>
      <div class="ms2-modal-body">
        <div class="ms2-label">Length</div>
        <div class="ms2-length-row">${lengthBtns}</div>
        <div class="ms2-toggle-row">
          <span class="ms2-toggle-label">Keep all dialogue (never cut spoken lines)</span>
          <label class="ms2-toggle-switch">
            <input type="checkbox" id="ms2-keep-dlg" ${keepDialogue ? 'checked' : ''}>
            <span class="ms2-toggle-thumb"></span>
          </label>
        </div>
        <div class="ms2-tip" style="margin:10px 0 8px;font-size:11px;padding:7px 10px;">Output quality depends on your model — free or smaller models may miss the target length or shift the tone slightly. Try a retry if the first result feels off.</div>
        <div class="ms2-label" style="margin-top:4px;">Original</div>
        <div class="ms2-textbox">${escHtml(originalText)}</div>
        <div class="ms2-label" id="ms2-shorten-label" style="display:none;">Shortened</div>
        <div id="ms2-shorten-area"></div>
      </div>
      <div class="ms2-modal-footer" id="ms2-shorten-footer">
        <button class="ms2-btn-action ms2-btn-generate" id="ms2-shorten-gen-btn">${SVG_SCISSORS} Shorten</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const resultArea  = modal.querySelector('#ms2-shorten-area');
    const resultLabel = modal.querySelector('#ms2-shorten-label');
    const footer      = modal.querySelector('#ms2-shorten-footer');
    const genBtn      = modal.querySelector('#ms2-shorten-gen-btn');
    let resultText = '';
    let _shortenAbort = null;

    modal.querySelector('.ms2-modal-close').addEventListener('click', () => {
      _shortenAbort?.abort();
      backdrop.remove();
    });

    modal.querySelectorAll('.ms2-length-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        selectedLength = btn.dataset.len;
        CFG.shortenLength = selectedLength;
        modal.querySelectorAll('.ms2-length-btn').forEach(b => b.classList.toggle('active', b === btn));
      });
    });

    modal.querySelector('#ms2-keep-dlg').addEventListener('change', e => {
      keepDialogue = e.target.checked;
      CFG.keepDialogue = keepDialogue;
    });

    const run = async () => {
      _shortenAbort?.abort();
      _shortenAbort = new AbortController();
      resultArea.innerHTML = '<div class="ms2-spinner">Generating…</div>';
      resultLabel.style.display = '';
      resultLabel.textContent = 'Shortened';
      genBtn.disabled = true;
      genBtn.style.display = 'none';
      
      footer.querySelectorAll('.ms2-btn-copy,.ms2-btn-retry').forEach(b => b.remove());
      try {
        const prompt = buildShortenPrompt(selectedLength, keepDialogue);
        resultText = await callAPI(prompt, originalText, { temperature: 0.65, max_tokens: 1500, signal: _shortenAbort.signal });
        
        const origWords   = originalText.trim().split(/\s+/).length;
        const resultWords = resultText.trim().split(/\s+/).length;
        const pct = Math.max(0, Math.round((1 - resultWords / origWords) * 100));
        resultLabel.innerHTML = `Shortened <span class="ms2-badge">↓${pct}% words</span>`;
        resultArea.innerHTML = `<div class="ms2-textbox result">${escHtml(resultText)}</div>`;

        const copyBtn = document.createElement('button');
        copyBtn.className = 'ms2-btn-action ms2-btn-copy';
        copyBtn.innerHTML = `${SVG_COPY} Copy`;
        copyBtn.addEventListener('click', () => {
          navigator.clipboard.writeText(resultText)
            .then(() => {
              copyBtn.innerHTML = `${SVG_CHECK} Copied!`;
              
              setTimeout(() => { if (copyBtn.isConnected) copyBtn.innerHTML = `${SVG_COPY} Copy`; }, 1800);
            })
            .catch(() => toast('Clipboard unavailable — please copy manually.'));
        });

        const replaceBtn = document.createElement('button');
        replaceBtn.className = 'ms2-btn-action ms2-btn-send';
        replaceBtn.textContent = '✎ Replace';
        replaceBtn.title = 'Try to replace the AI message in chat directly';
        replaceBtn.addEventListener('click', () => {
          backdrop.remove();
          replaceLatestAIMessage(
            resultText,
            () => toast(`${SVG_CHECK} Message replaced in chat`),
            () => {
              navigator.clipboard.writeText(resultText)
                .then(() => toast('Could not edit message automatically — copied to clipboard instead.', 3500))
                .catch(() => toast('Could not edit message — copy manually.', 4000));
            }
          );
        });

        const retryBtn = document.createElement('button');
        retryBtn.className = 'ms2-btn-action ms2-btn-retry';
        retryBtn.textContent = '↺ Retry';
        retryBtn.addEventListener('click', run);

        footer.appendChild(copyBtn);
        footer.appendChild(replaceBtn);
        footer.appendChild(retryBtn);
        genBtn.style.display = '';
        genBtn.disabled = false;
      } catch (err) {
        if (err.name === 'AbortError') return;
        resultArea.innerHTML = `<div class="ms2-error-box">${SVG_WARNING} ${escHtml(err.message)}</div>`;
        genBtn.style.display = '';
        genBtn.disabled = false;
      }
    };

    genBtn.addEventListener('click', run);
  }

  // ─── REPLY MODAL ───────────────────────────────────────────────────────────

  function openReplyModal(latestMsg) {
    document.getElementById('ms2-reply-backdrop')?.remove();

    const presets       = getPresets();
    const activePreset  = presets.find(p => p.id === CFG.activePreset) || null;
    let selectedTone    = activePreset?.tone || CFG.defaultTone || '';

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-reply-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);

    const toneGrid = TONES.map(t =>
      `<button class="ms2-tone-btn ${selectedTone === t.id ? 'active' : ''}" data-tone="${t.id}">${t.label}</button>`
    ).join('');

    const presetChip = activePreset
      ? `<div class="ms2-preset-chip">${SVG_STYLES} ${escHtml(activePreset.name)} active</div>`
      : '';

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_REPLY} Smart Reply</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-modal-body">
        ${presetChip}
        <div class="ms2-label">AI Said</div>
        <div class="ms2-textbox ms2-textbox-preview">${escHtml(latestMsg)}</div>
        <div class="ms2-label">Tone</div>
        <div class="ms2-tone-grid" style="margin-bottom:12px;">${toneGrid}</div>
        <div class="ms2-label">Custom Instruction <span style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;">(optional)</span></div>
        <textarea class="ms2-instruction-box" id="ms2-reply-instruct" placeholder="e.g. Push back but secretly enjoy it…">${escHtml(CFG.defaultInstruct)}</textarea>
        <div class="ms2-label" id="ms2-reply-result-label" style="display:none;">Generated Reply</div>
        <div id="ms2-reply-result-area"></div>
      </div>
      <div class="ms2-modal-footer" id="ms2-reply-footer">
        <button class="ms2-btn-action ms2-btn-generate" id="ms2-gen-btn">${SVG_CONFIG} Generate Reply</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const resultArea  = modal.querySelector('#ms2-reply-result-area');
    const resultLabel = modal.querySelector('#ms2-reply-result-label');
    const footer      = modal.querySelector('#ms2-reply-footer');
    const genBtn      = modal.querySelector('#ms2-gen-btn');
    let resultText = '';
    let _replyAbort = null;

    modal.querySelector('.ms2-modal-close').addEventListener('click', () => {
      _replyAbort?.abort();
      backdrop.remove();
    });

    modal.querySelectorAll('.ms2-tone-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        if (selectedTone === btn.dataset.tone) {
          selectedTone = '';
          btn.classList.remove('active');
        } else {
          selectedTone = btn.dataset.tone;
          modal.querySelectorAll('.ms2-tone-btn').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
        }
      });
    });

    let everSucceeded = false;

    const run = async () => {
      _replyAbort?.abort();
      _replyAbort = new AbortController();
      const customInstruct = modal.querySelector('#ms2-reply-instruct').value.trim();
      resultArea.innerHTML = '<div class="ms2-spinner">Generating reply…</div>';
      resultLabel.style.display = '';

      genBtn.disabled = true;
      genBtn.style.display = 'none';
      footer.querySelectorAll('.ms2-btn-copy,.ms2-btn-send,.ms2-btn-retry').forEach(b => b.remove());

      try {
        const prompt = buildReplyPrompt(selectedTone, customInstruct, activePreset);
        resultText = await callAPI(prompt, `The character just said:\n\n${latestMsg}`, { temperature: 0.9, max_tokens: 1200, signal: _replyAbort.signal });
        resultArea.innerHTML = `<div class="ms2-textbox result">${escHtml(resultText)}</div>`;
        everSucceeded = true;

        const copyBtn = document.createElement('button');
        copyBtn.className = 'ms2-btn-action ms2-btn-copy';
        copyBtn.innerHTML = `${SVG_COPY} Copy`;
        copyBtn.addEventListener('click', () => {
          navigator.clipboard.writeText(resultText).then(() => {
            copyBtn.innerHTML = `${SVG_CHECK} Copied!`;
            setTimeout(() => { copyBtn.innerHTML = `${SVG_COPY} Copy`; }, 1800);
          });
        });

        const sendBtn = document.createElement('button');
        sendBtn.className = 'ms2-btn-action ms2-btn-send';
        sendBtn.innerHTML = `${SVG_KEYBOARD} Send`;
        sendBtn.addEventListener('click', () => {
          backdrop.remove();
          injectAndSend(
            resultText,
            () => toast(`${SVG_CHECK} Reply sent!`),
            () => {
              navigator.clipboard.writeText(resultText)
                .then(() => toast('Could not find chat input — copied to clipboard instead.', 3500))
                .catch(() => toast('Could not find chat input — clipboard unavailable. Copy manually.', 4000));
            }
          );
        });

        const retryBtn = document.createElement('button');
        retryBtn.className = 'ms2-btn-action ms2-btn-retry';
        retryBtn.innerHTML = `${SVG_REROLL} Reroll`;
        retryBtn.title = 'Generate a different version with the same tone & instructions';
        retryBtn.addEventListener('click', run);

        footer.appendChild(copyBtn);
        footer.appendChild(sendBtn);
        footer.appendChild(retryBtn);

      } catch (err) {
        if (err.name === 'AbortError') return;
        resultArea.innerHTML = `<div class="ms2-error-box">${SVG_WARNING} ${escHtml(err.message)}</div>`;
        if (everSucceeded) {
          
          const retryBtn = document.createElement('button');
          retryBtn.className = 'ms2-btn-action ms2-btn-retry';
          retryBtn.textContent = '↺ Retry';
          retryBtn.addEventListener('click', run);
          footer.appendChild(retryBtn);
        } else {
          
          genBtn.style.display = '';
          genBtn.disabled = false;
        }
      }
    };

    genBtn.addEventListener('click', run);
  }

  // ─── SETTINGS MODAL (5 tabs) ───────────────────────────────────────────────

  function openSettingsModal(initialTab) {
    if (document.getElementById('ms2-settings-backdrop')) return;
    const tab0 = initialTab || 'general';

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-settings-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);

    const tabs = ['general', 'reply', 'styles', 'context', 'adv', 'about'];
    const tabLabels = { general: `${SVG_SETTINGS} General`, reply: `${SVG_REPLY} Reply`, styles: `${SVG_STYLES} Styles`, context: `${SVG_CONTEXT} Context`, adv: `${SVG_CONFIG} Configure`, about: `${SVG_INFO} About` };

    // Build grouped <optgroup> model list
    const _modelGroupMap = new Map();
    for (const m of MODELS) {
      const g = m.group || 'Other';
      if (!_modelGroupMap.has(g)) _modelGroupMap.set(g, []);
      _modelGroupMap.get(g).push(m);
    }
    const modelOpts = [..._modelGroupMap.entries()].map(([gName, models]) =>
      `<optgroup label="${escHtml(gName)}">${
        models.map(m =>
          `<option value="${escHtml(m.id)}" ${CFG.model === m.id ? 'selected' : ''}>${escHtml(m.label)}</option>`
        ).join('')
      }</optgroup>`
    ).join('');
    const toneOpts = [{ id: '', label: '— None —' }, ...TONES].map(t =>
      `<option value="${escHtml(t.id)}" ${CFG.defaultTone === t.id ? 'selected' : ''}>${escHtml(t.label)}</option>`
    ).join('');

    const panel = document.createElement('div');
    panel.className = 'ms2-settings-v2';
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-modal', 'true');
    panel.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_SETTINGS} Settings</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-tab-bar">
        ${tabs.map(t => `<button class="ms2-tab ${t === tab0 ? 'active' : ''}" data-tab="${t}">${tabLabels[t]}</button>`).join('')}
      </div>
      <div class="ms2-settings-body">
        <!-- GENERAL -->
        <div class="ms2-tab-panel ${tab0 === 'general' ? 'active' : ''}" data-panel="general">
          <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_SETTINGS} GENERAL SETTINGS</span>
            <button id="gen-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
          </div>
          <label class="ms2-field-label">API Provider</label>
          <select class="ms2-select" id="ms2-s-ep-sel">
            <option value="https://openrouter.ai/api/v1"    ${CFG.endpoint === 'https://openrouter.ai/api/v1'    ? 'selected' : ''}>OpenRouter (free &amp; paid — easiest start)</option>
            <option value="https://api.openai.com/v1"       ${CFG.endpoint === 'https://api.openai.com/v1'       ? 'selected' : ''}>OpenAI (GPT-4o, o4-mini…)</option>
            <option value="https://api.x.ai/v1"             ${CFG.endpoint === 'https://api.x.ai/v1'             ? 'selected' : ''}>xAI (Grok 3 / Grok 4)</option>
            <option value="https://api.anthropic.com"       ${CFG.endpoint === 'https://api.anthropic.com'       ? 'selected' : ''}>Anthropic (Claude — native API)</option>
            <option value="https://api.mistral.ai/v1"       ${CFG.endpoint === 'https://api.mistral.ai/v1'       ? 'selected' : ''}>Mistral AI (Magistral, Devstral…)</option>
            <option value="https://api.groq.com/openai/v1"  ${CFG.endpoint === 'https://api.groq.com/openai/v1'  ? 'selected' : ''}>Groq (Llama 4, Qwen 3 — very fast)</option>
            <option value="custom"                          ${!KNOWN_EPS.includes(CFG.endpoint)                  ? 'selected' : ''}>Custom / other proxy URL…</option>
          </select>
          <div id="ms2-s-custom-ep-wrap" style="${KNOWN_EPS.includes(CFG.endpoint) ? 'display:none' : ''}">
            <label class="ms2-field-label">Custom Base URL</label>
            <input type="text" class="ms2-input" id="ms2-s-custom-ep" value="${escHtml(!KNOWN_EPS.includes(CFG.endpoint) ? CFG.endpoint : '')}" placeholder="https://your-proxy.example.com/v1">
          </div>
          <label class="ms2-field-label">API Key</label>
          <div style="display:flex;gap:6px;align-items:center;">
            <input type="text" class="ms2-input" id="ms2-s-apikey" value="${escHtml(CFG.apiKey)}" placeholder="Paste your API key…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-lpignore="true" data-form-type="other" data-1p-ignore="true" style="margin-bottom:0;flex:1;min-width:0;-webkit-text-security:disc;">
            <button class="ap-icon-btn" id="ms2-test-api-btn" title="Test connection" style="flex-shrink:0;">${SVG_CONFIG}</button>
          </div>
          <div id="ms2-api-test-result" style="font-size:11px;margin-top:4px;min-height:16px;"></div>
          <label class="ms2-field-label" style="margin-top:6px;">Auth Header Format</label>
          <select class="ms2-select" id="ms2-s-auth-mode" style="margin-bottom:2px;">
            <option value="auto"    ${CFG.authMode === 'auto'    ? 'selected' : ''}>Auto-detect (recommended)</option>
            <option value="bearer"  ${CFG.authMode === 'bearer'  ? 'selected' : ''}>Bearer &lt;key&gt; — standard OpenAI / OpenRouter</option>
            <option value="raw"     ${CFG.authMode === 'raw'     ? 'selected' : ''}>Key only — LiteRouter &amp; niche proxies</option>
            <option value="x-api-key" ${CFG.authMode === 'x-api-key' ? 'selected' : ''}>x-api-key header — Anthropic-style proxies</option>
          </select>
          <div style="font-size:10.5px;color:#6b7280;margin-bottom:8px;line-height:1.4;">
            Auto-detect uses <code>Bearer</code> for all keys — works with OpenRouter, OpenAI, LiteRouter, and most proxies. Only change this if your proxy specifically rejects <code>Bearer</code>.
          </div>
          <label class="ms2-field-label">Model</label>
          <select class="ms2-select" id="ms2-s-model">
            ${modelOpts}
            <option value="__custom__" ${!MODELS.find(m => m.id === CFG.model) ? 'selected' : ''}>Custom model ID…</option>
          </select>
          <input type="text" class="ms2-input" id="ms2-s-custom-model" placeholder="e.g. openai/gpt-4o-mini" value="${escHtml(!MODELS.find(m => m.id === CFG.model) ? CFG.model : '')}" style="${MODELS.find(m => m.id === CFG.model) ? 'display:none;margin-top:-6px' : 'margin-top:-6px'}">
          <div id="ms2-proxy-model-warn" class="ms2-tip" style="${KNOWN_EPS.includes(CFG.endpoint) ? 'display:none' : ''}; border-color:rgba(251,191,36,0.4); color:#fbbf24;">
            ${SVG_WARNING} <strong>Custom endpoint — model ID format may differ.</strong>
            Proxies like LiteRouter use the same <code style="color:#fbbf24">provider/model:tier</code> format as OpenRouter (preset list works as-is).
            Others (e.g. self-hosted Ollama, meganova.ai) use shorter IDs with no provider prefix — in that case choose <strong>Custom model ID…</strong> below.
            Your API key can be any format your provider issues.
          </div>
          <div class="ms2-tip">
            ${SVG_INFO}
            <strong>Which provider should I pick?</strong><br>
            • <strong>OpenRouter</strong> — widest model selection, many free models (ending in <code style="color:#a78bfa">:free</code>), one key for everything. Get a free key at <a href="https://openrouter.ai/keys" target="_blank">openrouter.ai/keys</a>.<br>
            • <strong>xAI</strong> — Grok 3 / Grok 4, OpenAI-compatible. Key from <a href="https://console.x.ai" target="_blank">console.x.ai</a>.<br>
            • <strong>Anthropic</strong> — Claude Opus / Sonnet / Haiku native. Key from <a href="https://console.anthropic.com" target="_blank">console.anthropic.com</a>. <em>Note: uses its own message format — handled automatically.</em><br>
            • <strong>Mistral</strong> — Magistral reasoning, Devstral (code), Mistral Large. Key from <a href="https://console.mistral.ai" target="_blank">console.mistral.ai</a>.<br>
            • <strong>Groq</strong> — Llama 4, Qwen 3, ultra-fast inference, generous free tier. Key from <a href="https://console.groq.com" target="_blank">console.groq.com</a>.
          </div>
          <div class="ms2-settings-actions">
            <button class="ms2-btn-save" id="ms2-save-general">Save</button>
            <button class="ms2-btn-cancel" id="ms2-cancel-settings">Cancel</button>
          </div>
        </div>

        <!-- REPLY -->
        <div class="ms2-tab-panel ${tab0 === 'reply' ? 'active' : ''}" data-panel="reply">
          <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_REPLY} REPLY SETTINGS</span>
            <button id="reply-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
          </div>
          <label class="ms2-field-label">Default Tone</label>
          <select class="ms2-select" id="ms2-s-default-tone">${toneOpts}</select>
          <label class="ms2-field-label">Default Custom Instruction</label>
          <textarea class="ms2-input ms2-textarea-sm" id="ms2-s-default-instruct" placeholder="e.g. Be slightly more reserved than usual">${escHtml(CFG.defaultInstruct)}</textarea>
          <div class="ms2-toggle-row" style="margin-bottom:12px;">
            <span class="ms2-toggle-label">Notify on new AI message</span>
            <label class="ms2-toggle-switch">
              <input type="checkbox" id="ms2-s-autonotify" ${CFG.autoNotify ? 'checked' : ''}>
              <span class="ms2-toggle-thumb"></span>
            </label>
          </div>
          <div class="ms2-tip">When ON: a subtle top toast appears when a new AI message arrives — no auto-opening modals.</div>
          <div class="ms2-settings-actions">
            <button class="ms2-btn-save" id="ms2-save-reply">Save</button>
            <button class="ms2-btn-cancel">Cancel</button>
          </div>
        </div>

        <!-- STYLES -->
        <div class="ms2-tab-panel ${tab0 === 'styles' ? 'active' : ''}" data-panel="styles">
          <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_STYLES} STYLES</span>
            <button id="styles-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
          </div>
          <div class="ms2-tip">Presets save your character's voice and tone. The active preset is injected automatically when you open Smart Reply.</div>
          <div id="ms2-presets-list"></div>
          <button class="ms2-btn-new-preset" id="ms2-new-preset-btn">+ New Preset</button>
          <div id="ms2-preset-editor-wrap" style="display:none;"></div>
        </div>

        <!-- CONTEXT -->
        <div class="ms2-tab-panel ${tab0 === 'context' ? 'active' : ''}" data-panel="context">
          <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_CONTEXT} CONTEXT</span>
            <button id="ctx-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
          </div>
          <div class="ms2-tip">These notes are injected into every <strong>Reply</strong> prompt and into <strong>Shorten</strong> as editorial context — keyed to <strong>this specific chat URL</strong>. Each conversation has its own notes.</div>

          <!-- Auto-generate from chat (Scene Context — current situation) -->
          <div style="background:rgba(99,102,241,.07);border:1px solid rgba(99,102,241,.22);border-radius:10px;padding:10px 12px;margin-bottom:12px;">
            <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
              <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">GENERATE SCENE CONTEXT</span>
            </div>
            <div style="font-size:11px;color:#4b5563;margin-bottom:8px;line-height:1.5;">Reads the <strong>currently visible messages</strong> and writes a current-situation note: where the characters are, the mood, recent events, unresolved tension. The result saves to your Scene Context and is injected into every JanitorAI reply. For a full-history summary of all messages, use the <strong>FAB → Summarise</strong> button instead.</div>
            <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
              <button class="ms2-btn-save" id="ms2-ctx-gen-btn" style="flex:1;min-width:80px;">${SVG_SPARKLE} Generate</button>
            </div>
            <!-- Global memory row -->
            <div style="display:flex;align-items:center;gap:8px;margin-top:5px;">
              <button class="ms2-btn-action ms2-btn-copy" id="ms2-ctx-save-global-btn" title="Save current context as global memory (persists across all chats)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_SAVE} Save Global</button>
              <button class="ms2-btn-action ms2-btn-retry" id="ms2-ctx-load-global-btn" title="Load global memory into this chat's context" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_FOLDER} Load Global</button>
            </div>
            <!-- Character-specific memory row -->
            <div id="ms2-ctx-char-row" style="display:flex;align-items:center;gap:8px;margin-top:5px;">
              <button class="ms2-btn-action ms2-btn-copy" id="ms2-ctx-save-char-btn" title="Save context for this character only (stored separately from global)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_MEMORY} Save for Character</button>
              <button class="ms2-btn-action ms2-btn-retry" id="ms2-ctx-load-char-btn" title="Load this character's saved memory (falls back to global if none)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_MEMORY} Load for Character</button>
            </div>
            <!-- Auto-load toggle row -->
            <div style="display:flex;align-items:center;gap:8px;margin-top:7px;padding-top:7px;border-top:1px solid rgba(99,102,241,.15);">
              <label class="ms2-toggle-switch" title="Automatically fill context with global/character memory when entering an empty chat">
                <input type="checkbox" id="ms2-ctx-autoload-chk" ${getAutoLoadGlobal() ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <span style="font-size:11px;color:#9ca3af;flex:1;">Auto-load memory into new empty chats</span>
              <button class="ms2-btn-save" id="ms2-ctx-autoload-save" style="padding:5px 10px;font-size:11px;">Save</button>
            </div>
            <!-- Auto-trigger row -->
            <div style="display:flex;align-items:center;gap:8px;margin-top:9px;padding-top:9px;border-top:1px solid rgba(99,102,241,.15);">
              <label class="ms2-toggle-switch" title="Auto-generate context when threshold is reached">
                <input type="checkbox" id="ms2-ctx-auto-chk" ${getAutoSumAuto() ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <span style="font-size:11px;color:#6b7280;flex:1;">Auto-generate every</span>
              <input type="number" id="ms2-ctx-auto-every" min="0" max="500" value="${getAutoSumEvery() || ''}" placeholder="N"
                style="width:52px;padding:4px 6px;background:#0d0d1a;border:1px solid #1e1b4b;border-radius:6px;color:#e2e8f0;font-size:12px;outline:none;text-align:center;">
              <span style="font-size:11px;color:#6b7280;">msgs</span>
              <button class="ms2-btn-save" id="ms2-ctx-auto-save" style="padding:5px 10px;font-size:11px;">Save</button>
            </div>
          </div>

          <label class="ms2-field-label">Scene Context <span style="font-weight:400;color:#6b7280;">(injected into every Reply &amp; Shorten)</span></label>
          <textarea class="ms2-input ms2-textarea-lg" id="ms2-s-context" maxlength="2000" placeholder="e.g. We're at a concert. Sylvie just won an award. Rvie is pretending not to care but is clearly jealous.">${escHtml(getContext())}</textarea>
          <div id="ms2-ctx-count" style="font-size:10px;color:#6b7280;text-align:right;margin-top:2px;">${getContext().length} / 2000</div>
          <!-- Inject into JanitorAI toggle -->
          <div style="display:flex;align-items:flex-start;gap:8px;margin-top:8px;padding:8px;background:rgba(139,92,246,0.06);border:1px solid rgba(139,92,246,0.18);border-radius:7px;">
            <label class="ms2-toggle-switch" style="margin-top:1px;" title="Inject this Scene Context into JanitorAI's actual AI on every generation">
              <input type="checkbox" id="ms2-ctx-inject-chk" ${getInjectCtx() ? 'checked' : ''}>
              <span class="ms2-toggle-thumb"></span>
            </label>
            <div style="flex:1;">
              <span style="font-size:12px;color:#c4b5fd;font-weight:500;">Send to JanitorAI's AI</span>
              <div class="ms2-tip" style="margin-top:1px;">When ON, your Scene Context is appended to JanitorAI's actual generation on every message. <strong style="color:#e2e8f0;">Does this duplicate JanitorAI's built-in memory?</strong> No — keep Scene Context for <em>current situation</em> (where you are, what just happened) and leave character backstory/personality in JanitorAI's own memory box. Different purposes = no overlap. Saves automatically.</div>
            </div>
          </div>
          <div class="ms2-settings-actions">
            <button class="ms2-btn-save" id="ms2-save-context">Save</button>
            <button class="ms2-btn-cancel">Cancel</button>
          </div>

          <!-- PERSONA LIBRARY -->
          <input type="file" id="ms2-persona-import-file" accept=".json" style="display:none;">
          <div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
            <div style="display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:8px;flex-wrap:wrap;">
              <span style="font-size:10px;color:#4b5563;text-transform:uppercase;letter-spacing:.9px;font-weight:700;">${SVG_PERSONA} Persona Library</span>
              <div style="display:flex;gap:4px;flex-shrink:0;">
                <button id="ms2-persona-add-btn" style="background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.35);border-radius:5px;color:#c4b5fd;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Add a new persona">+ Add</button>
                <button id="ms2-persona-export-btn" style="background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:5px;color:#6ee7b7;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Export all personas as a JSON file">${SVG_SAVE} Export</button>
                <button id="ms2-persona-import-btn" style="background:rgba(251,191,36,0.1);border:1px solid rgba(251,191,36,0.3);border-radius:5px;color:#fde68a;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Import personas from a JSON file (merges with existing)">${SVG_FOLDER} Import</button>
              </div>
            </div>
            <div class="ms2-tip" style="margin-bottom:8px;">Save character descriptions here. Click <strong>Use</strong> to instantly load one into the Scene Context box above. Use <strong>Export/Import</strong> to back up and restore your library.</div>
            <!-- Inline add/edit form (hidden by default) -->
            <div id="ms2-persona-form" style="display:none;background:rgba(255,255,255,0.03);border:1px solid rgba(139,92,246,0.25);border-radius:7px;padding:10px;margin-bottom:8px;">
              <input type="text" id="ms2-persona-name-input" placeholder="Name (e.g. Rvie — Tsundere Mode)" class="ms2-input" style="margin-bottom:6px;">
              <textarea id="ms2-persona-desc-input" class="ms2-input ms2-textarea-sm" placeholder="Describe this character: how they speak, act, their mood, quirks…" style="min-height:72px;"></textarea>
              <div style="display:flex;gap:6px;margin-top:6px;">
                <button id="ms2-persona-save-btn" class="ms2-btn-save" style="flex:1;padding:5px;">Save</button>
                <button id="ms2-persona-cancel-btn" class="ms2-btn-cancel" style="flex:1;padding:5px;">Cancel</button>
              </div>
            </div>
            <div id="ms2-persona-list" style="display:flex;flex-direction:column;gap:5px;max-height:180px;overflow-y:auto;"></div>
          </div>

          <!-- Summary history -->
          <div style="margin-top:14px;">
            <div style="font-size:10px;color:#4b5563;text-transform:uppercase;letter-spacing:.9px;font-weight:700;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;">
              <span>Past summaries</span>
              <div style="display:flex;gap:8px;align-items:center;">
                <button id="ms2-ctx-export-hist" style="background:none;border:none;color:#4b5563;font-size:10px;cursor:pointer;padding:0;" title="Export all summaries as JSON">↓ Export</button>
                <button id="ms2-ctx-clear-hist" style="background:none;border:none;color:#4b5563;font-size:10px;cursor:pointer;padding:0;" title="Clear all summary history">${SVG_TRASH} Clear</button>
              </div>
            </div>
            <div id="ms2-ctx-hist-list" style="max-height:160px;overflow-y:auto;"></div>
          </div>
        </div>

        <!-- ADV. PROMPT -->
        <div class="ms2-tab-panel ${tab0 === 'adv' ? 'active' : ''}" data-panel="adv">
          <div class="ms2-toggle-row" style="margin-bottom:10px;">
            <span class="ms2-toggle-label" style="font-size:12px;font-weight:600;color:#c4b5fd;">Enable Advanced Prompting</span>
            <div style="display:flex;align-items:center;gap:8px;">
              <button id="ap-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
              <label class="ms2-toggle-switch">
                <input type="checkbox" id="ap-enabled-chk" ${AP.enabled ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
            </div>
          </div>
          <div class="ms2-tip" style="margin-bottom:10px;">When ON, the active preset replaces the <code style="color:#a78bfa">llm_prompt</code> field on every generation. Your proxy jailbreak is left untouched. <span id="ap-status-dot" class="ap-status-dot" title="No injection recorded yet"></span></div>

          <!-- THINKING TOGGLE -->
          <div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:12px;">
            <label class="ms2-toggle-switch" style="margin-top:2px;" title="Append a step-by-step reasoning instruction to every generation">
              <input type="checkbox" id="ap-thinking-chk">
              <span class="ms2-toggle-thumb"></span>
            </label>
            <div style="flex:1;">
              <span style="font-size:12px;color:#e2e8f0;font-weight:500;">Enable Thinking</span>
              <div class="ms2-tip" style="margin-top:2px;">Tells the AI to reason inside &lt;thinking&gt; tags before replying. Best for models that support extended reasoning (e.g. Claude 3.5+, o1, Gemini 2.0+).</div>
            </div>
          </div>

          <!-- FORBIDDEN WORDS (tag input) -->
          <div id="ap-forbidden-wrap" style="margin-bottom:12px;">
            <label class="ms2-field-label">Forbidden Words / Phrases</label>
            <div style="display:flex;gap:4px;margin-bottom:4px;">
              <input type="text" id="ap-forbidden-input" placeholder="Add a word or phrase…" class="ms2-input" style="flex:1;margin-bottom:0;">
              <button class="ap-icon-btn" id="ap-forbidden-add-btn" title="Add to list">+</button>
            </div>
            <div id="ap-forbidden-tags" style="display:flex;flex-wrap:wrap;gap:4px;max-height:120px;overflow-y:auto;padding:4px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:6px;min-height:36px;">
              <!-- tags filled by JavaScript -->
            </div>
            <div class="ms2-tip" style="margin-top:4px;">These are injected into every generation — unlimited bans beyond JanitorAI's 10-word limit.</div>
            <div id="ap-forbidden-counter" style="display:none;margin-top:5px;font-size:11px;color:#a78bfa;padding:3px 6px;background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.2);border-radius:5px;line-height:1.5;"></div>
          </div>

          <label class="ms2-field-label">Active Preset</label>
          <div class="ap-row">
            <select class="ap-select" id="ap-preset-sel"></select>
            <button class="ap-icon-btn" id="ap-new-preset-btn" title="New preset">+</button>
            <button class="ap-icon-btn" id="ap-rename-preset-btn" title="Rename preset">✎</button>
            <button class="ap-icon-btn" id="ap-export-btn" title="Export preset as JSON">↑</button>
            <button class="ap-icon-btn" id="ap-import-btn" title="Import preset from JSON">↓</button>
            <button class="ap-icon-btn" id="ap-delete-preset-btn" title="Delete preset" style="color:#fca5a5;">${SVG_TRASH}</button>
          </div>

          <div id="ap-modules-wrap" style="display:none;">
            <label class="ms2-field-label">Prompt Modules <span id="ap-token-count" style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;"></span></label>
            <div class="ap-token-bar"><div class="ap-token-fill" id="ap-token-fill" style="width:0%;"></div></div>
            <div id="ap-module-list" class="ap-module-list"></div>
            <div class="ap-row">
              <select class="ap-select" id="ap-unattached-sel"></select>
              <button class="ap-icon-btn" id="ap-attach-btn" title="Attach selected module — or create new if none exist">+</button>
              <button class="ap-icon-btn" id="ap-del-module-btn" title="Delete selected module" style="color:#fca5a5;">${SVG_TRASH}</button>
              <button class="ap-icon-btn" id="ap-new-module-btn" title="Create new blank module">✎</button>
            </div>
            <div class="ms2-settings-actions" style="margin-top:4px;">
              <button class="ms2-btn-save ap-save-dirty" id="ap-save-btn" disabled>Save Preset</button>
              <button class="ms2-btn-cancel" id="ap-discard-btn">Discard</button>
            </div>
          </div>
          <div id="ap-no-preset-msg" class="ap-empty">Select or create a preset to begin.</div>
        </div>

        <!-- ABOUT -->
        <div class="ms2-tab-panel ${tab0 === 'about' ? 'active' : ''}" data-panel="about">
          <div class="ms2-about-box">
            <div class="ms2-about-title">JanitorV5 — Smart RP Toolkit</div>
            <div class="ms2-about-version">v5.0.2 — Persona Library · Quick-Switch · Settings FAB · Import/Export · Community Chat · Delivery Confirmation</div>

            <div class="ms2-about-row"><strong>Created by</strong> eivls</div>
            <div class="ms2-about-row"><strong>TikTok</strong> <a href="https://tiktok.com/@eivls" target="_blank" style="color:#a78bfa;text-decoration:none;">@eivls</a></div>
            <div class="ms2-about-row" style="display:flex;align-items:center;gap:6px;">
              <strong>License</strong>
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#a78bfa;vertical-align:middle;flex-shrink:0;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
              <span>All Rights Reserved — © 2025 eivls</span>
            </div>

            <div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.2);border-radius:8px;padding:10px 12px;margin:10px 0;">
              <div style="font-size:11px;font-weight:700;color:#6ee7b7;letter-spacing:.5px;margin-bottom:6px;">${SVG_ROCKET} QUICK SETUP</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>1. Long-press the FAB</strong> — Hold the ${SVG_SETTINGS} gear button at the bottom-right for ~1 s until the purple ring fills, then release. This opens Settings.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>2. Connect your AI</strong> — Go to <em>General</em>, pick a provider, paste your API key, and hit <strong>Test API</strong>. OpenRouter has free models — no card needed to start.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>3. Tap (don't hold) the FAB in any chat</strong> — The speed-dial opens. Choose a tool: Reply, Shorten, Summarise, Styles, Personas, or Community Chat.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>4. Fine-tune in Settings</strong> — Set a default tone in Reply, build Style presets, add Persona Library entries, configure your system prompt in Configure. Everything autosaves.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>5. Drag the FAB</strong> — It repositions to any edge so it never blocks your view.</div>
            </div>

            <div class="ms2-about-row"><strong>${SVG_SETTINGS} FAB (gear button)</strong> — <em>Tap once</em> = speed-dial menu (6 tools). <em>Long-press ~1 s</em> = open Settings. <em>Drag</em> to reposition anywhere on screen.</div>
            <div class="ms2-about-row"><strong>${SVG_SCISSORS} Shorten</strong> — Condenses the latest AI message. Pick cut depth (Slash ~30% / Halve ~50% / Polish ~70%) and toggle dialogue preservation.</div>
            <div class="ms2-about-row"><strong>${SVG_REPLY} Reply</strong> — Pick a tone, write an optional instruction, generate a reply as your character. Hard-ban list blocks repetitive AI phrases. Hit ${SVG_KEYBOARD} Send to inject directly into chat.</div>
            <div class="ms2-about-row"><strong>${SVG_STYLES} Styles</strong> — Save named presets with your character's voice and tone. Activate one to auto-fill Reply settings every time.</div>
            <div class="ms2-about-row"><strong>${SVG_SUMMARISE} Summarise</strong> — Auto loads your full chat history and writes a persistent memory entry (who the characters are, relationship arc, key events). Output copies to clipboard — paste into JanitorAI's Chat Memory panel.</div>
            <div class="ms2-about-row"><strong>${SVG_PERSONA} Personas</strong> — Quick-switch between your saved Persona Library entries directly from the speed-dial. Live search included — no need to open Settings.</div>
            <div class="ms2-about-row"><strong>${SVG_CHAT} Community Chat</strong> — Real-time global chat shared across all JanitorAI users running JanitorV5. Open from the speed-dial FAB.</div>

            <div style="background:rgba(6,182,212,0.06);border:1px solid rgba(6,182,212,0.2);border-radius:8px;padding:10px 12px;margin:8px 0;">
              <div style="font-size:11px;font-weight:700;color:#67e8f9;letter-spacing:.5px;margin-bottom:6px;">${SVG_CHAT} COMMUNITY CHAT v2 — WHAT'S INCLUDED</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Verified names</strong> — Green ✓ badge on users whose display name has been cryptographically confirmed.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Typing indicator</strong> — Live "X is typing…" bar appears as others compose a message.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Online count</strong> — Shows how many users are active in the room right now via heartbeat.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Reply with notification</strong> — Hit ↩ on any message to quote-reply; the original sender gets a toast notification.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Reactions &amp; emoji picker</strong> — React to any message with an emoji. Grouped reaction counts shown on each bubble.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Pinned messages bar</strong> — Admins can pin a message so it stays visible at the top of the chat for everyone.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Admin controls</strong> — Admins see a distinct styled bubble and can pin/unpin, ban, and moderate messages.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Mute / Unmute</strong> — Mute any user to hide their messages locally. Manage your blocked list from the chat header.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Report</strong> — Flag any message with ⚑; sends a report and confirms with a toast.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Chat export</strong> — Download the full visible chat history as a plain-text file from the header button.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Auto-scroll badge</strong> — When you scroll up to read history, a "● N new" badge appears and jumps you back to the bottom.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Delivery confirmation</strong> — Your own bubbles show "Sending…" then flip to a green <strong>✓ Delivered</strong> once the relay confirms the send (2xx). Fades out automatically after 2.5 s.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Rate-limit UX</strong> — Send button disables with a live countdown after each message. On HTTP 429, network error, or 10 s timeout, an in-chat banner appears with the airplane-mode tip and a one-tap <em>Retry</em> button.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Deduplication</strong> — No message ever appears twice, even across overlapping polls or reconnects.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Global &amp; character rooms</strong> — Auto-detected from the current URL. Character rooms are private to people viewing the same character page; Global is site-wide.</div>
            </div>

            <div style="background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.3);border-radius:8px;padding:10px 12px;margin:8px 0;">
              <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.5px;margin-bottom:5px;">💜 HELP GROW THE COMMUNITY</div>
              <div style="font-size:12px;color:#d1d5db;line-height:1.65;">
                If JanitorV5 has improved your experience, share it. Post the GreasyFork link on
                <strong style="color:#e5e7eb;">TikTok</strong>, <strong style="color:#e5e7eb;">X / Twitter</strong>,
                <strong style="color:#e5e7eb;">Discord</strong>, or <strong style="color:#e5e7eb;">Reddit</strong>.
                A short screen recording, review, or "how I use this" video is the single best way to get more
                roleplayers into Community Chat — and more users means better conversations for everyone.
                No sponsorship, no algorithm tricks needed — just share it if you find it useful.
              </div>
            </div>

            <div class="ms2-about-row"><strong>${SVG_CONTEXT} Context tab</strong> — Per-chat scene notes (where you are, what just happened) sent to every Reply and Shorten request. Toggle <strong>Send to JanitorAI's AI</strong> to inject into JanitorAI's actual generation automatically. Use <strong>Generate</strong> or the FAB <strong>Summarise</strong> shortcut to auto-write the note from your chat history.</div>
            <div class="ms2-about-row"><strong>${SVG_PERSONA} Persona Library</strong> — Save reusable character descriptions by name. Use, Edit, or Delete entries. Import a JSON backup or Export to save your whole library.</div>
            <div class="ms2-about-row"><strong>${SVG_CONFIG} Configure tab</strong> — Intercepts every JanitorAI generation and replaces the system prompt with your configured module stack. Deleted messages are scrubbed automatically. Always save before activating.</div>
          </div>
        </div>
      </div>`;

    backdrop.appendChild(panel);
    document.body.appendChild(backdrop);

    const closeAll = () => backdrop.remove();
    panel.querySelector('.ms2-modal-close').addEventListener('click', closeAll);
    panel.querySelectorAll('.ms2-btn-cancel').forEach(b => b.addEventListener('click', closeAll));

    panel.querySelectorAll('.ms2-tab').forEach(tab => {
      tab.addEventListener('click', () => {
        panel.querySelectorAll('.ms2-tab').forEach(t => t.classList.remove('active'));
        panel.querySelectorAll('.ms2-tab-panel').forEach(p => p.classList.remove('active'));
        tab.classList.add('active');
        panel.querySelector(`[data-panel="${tab.dataset.tab}"]`)?.classList.add('active');

      });
    });

    const epSel   = panel.querySelector('#ms2-s-ep-sel');
    const epWrap  = panel.querySelector('#ms2-s-custom-ep-wrap');
    const modelSel = panel.querySelector('#ms2-s-model');
    const customModelInput = panel.querySelector('#ms2-s-custom-model');

    const proxyModelWarn = panel.querySelector('#ms2-proxy-model-warn');
    const isKnownEp = () => KNOWN_EPS.includes(epSel.value);
    epSel.addEventListener('change', () => {
      epWrap.style.display = epSel.value === 'custom' ? '' : 'none';
      proxyModelWarn.style.display = isKnownEp() ? 'none' : '';
    });
    modelSel.addEventListener('change', () => {
      customModelInput.style.display = modelSel.value === '__custom__' ? '' : 'none';
    });

    panel.querySelector('#ms2-test-api-btn').addEventListener('click', async () => {
      const testBtn   = panel.querySelector('#ms2-test-api-btn');
      const resultDiv = panel.querySelector('#ms2-api-test-result');
      const authSel   = panel.querySelector('#ms2-s-auth-mode');
      const typedKey  = panel.querySelector('#ms2-s-apikey').value.trim();
      if (!typedKey) { resultDiv.style.color = '#f87171'; resultDiv.innerHTML = `${SVG_CROSS} Enter an API key first`; return; }

      const selectedEp = epSel.value === 'custom'
        ? (panel.querySelector('#ms2-s-custom-ep').value.trim() || 'https://openrouter.ai/api/v1')
        : epSel.value;
      const selectedModel = modelSel.value === '__custom__'
        ? (customModelInput.value.trim() || MODELS[0].id)
        : modelSel.value;
      const baseTestEp = selectedEp.replace(/\/$/, '');
      const isAnthropicTest = selectedEp.includes('anthropic.com');
      const testEp = isAnthropicTest ? baseTestEp + '/v1/messages' : baseTestEp + '/chat/completions';
      const testBody = isAnthropicTest
        ? JSON.stringify({ model: selectedModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] })
        : JSON.stringify({ model: selectedModel, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1, temperature: 0 });

      // Helper: attempt one request with a specific auth mode; returns {ok, status, msg}
      async function _tryAuth(mode) {
        const hdrs = { 'Content-Type': 'application/json', ..._buildAuthHeaders(typedKey, selectedEp, mode) };
        try {
          const r = await gmFetch(testEp, { method: 'POST', headers: hdrs, body: testBody });
          if (r.ok) return { ok: true };
          // 3xx: redirect not followed (some managers ignore redirect:'follow')
          if (r.status >= 300 && r.status < 400) {
            const loc = r.headers?.get('location') || '';
            let hint = 'Your endpoint URL is redirecting';
            if (loc) {
              // Strip the appended path so user gets the base URL to paste
              const cleanLoc = loc.replace(/\/chat\/completions$/, '').replace(/\/v1\/messages$/, '');
              hint = `URL redirects → ${cleanLoc} — paste that as your Base URL`;
            } else {
              hint = `URL redirects (${r.status}) — try adding or removing /v1, or switch http→https`;
            }
            return { ok: false, status: r.status, msg: hint };
          }
          const raw = await r.text().catch(() => '');
          let msg = `API error ${r.status}`;
          try { msg = JSON.parse(raw)?.error?.message || msg; } catch {}
          return { ok: false, status: r.status, msg };
        } catch (e) {
          if (e.name === 'AbortError') throw e;
          return { ok: false, status: 0, msg: e.message };
        }
      }

      function _isAuthError(res) {
        if (res.status === 401 || res.status === 403) return true;
        const m = (res.msg || '').toLowerCase();
        return m.includes('auth') || m.includes('key') || m.includes('token') ||
               m.includes('credential') || m.includes('unauthorized') || m.includes('forbidden');
      }

      const AUTH_MODE_LABELS = { auto: 'Auto-detect', bearer: 'Bearer', raw: 'Key only', 'x-api-key': 'x-api-key' };

      testBtn.disabled = true;
      resultDiv.style.color = '#9ca3af';
      resultDiv.textContent = 'Testing…';

      try {
        const primaryMode = authSel?.value || 'auto';
        resultDiv.textContent = `Testing (${AUTH_MODE_LABELS[primaryMode]})…`;
        let result = await _tryAuth(primaryMode);

        if (!result.ok && _isAuthError(result)) {
          // Auto-cycle through the remaining modes to find one that works
          const fallbacks = ['bearer', 'raw', 'x-api-key'].filter(m => m !== primaryMode);
          let found = null;
          for (const fb of fallbacks) {
            resultDiv.textContent = `Trying ${AUTH_MODE_LABELS[fb]}…`;
            const r = await _tryAuth(fb);
            if (r.ok) { found = fb; result = r; break; }
            if (!_isAuthError(r)) { result = r; break; } // non-auth error — stop cycling
          }
          if (found) {
            // Update the dropdown to the working mode
            if (authSel) authSel.value = found;
            resultDiv.style.color = '#4ade80';
            resultDiv.innerHTML = `${SVG_CHECK} Works with <strong>${AUTH_MODE_LABELS[found]}</strong> format — click Save to keep this setting`;
            return;
          }
        }

        if (result.ok) {
          resultDiv.style.color = '#4ade80';
          resultDiv.innerHTML = `${SVG_CHECK} Connection works`;
        } else {
          throw new Error(result.msg);
        }
      } catch (err) {
        if (err.name === 'AbortError') return;
        resultDiv.style.color = '#f87171';
        resultDiv.innerHTML = `${SVG_CROSS} ${escHtml(err.message)}`;
      } finally {
        testBtn.disabled = false;
      }
    });

    panel.querySelector('#ms2-save-general').addEventListener('click', () => {
      CFG.apiKey   = panel.querySelector('#ms2-s-apikey').value.trim();
      CFG.authMode = panel.querySelector('#ms2-s-auth-mode')?.value || 'auto';
      CFG.endpoint = epSel.value === 'custom'
        ? (panel.querySelector('#ms2-s-custom-ep').value.trim() || 'https://openrouter.ai/api/v1')
        : epSel.value;
      CFG.model = modelSel.value === '__custom__'
        ? (customModelInput.value.trim() || MODELS[0].id)
        : modelSel.value;
      toast(`${SVG_CHECK} General settings saved`);
      closeAll();
    });

    panel.querySelector('#ms2-save-reply').addEventListener('click', () => {
      CFG.defaultTone     = panel.querySelector('#ms2-s-default-tone').value;
      CFG.defaultInstruct = panel.querySelector('#ms2-s-default-instruct').value.trim();
      CFG.autoNotify      = panel.querySelector('#ms2-s-autonotify').checked;
      if (CFG.autoNotify && isOnChatPage()) startObserver();
      else if (!CFG.autoNotify) stopObserver();
      toast(`${SVG_CHECK} Reply settings saved`);
      closeAll();
    });

    panel.querySelector('#ms2-s-context').addEventListener('input', e => {
      const counter = panel.querySelector('#ms2-ctx-count');
      if (!counter) return;
      const len = e.target.value.length;
      counter.textContent = `${len} / 2000`;
      counter.style.color = len >= 1800 ? '#f87171' : len >= 1500 ? '#fb923c' : '#6b7280';
    });

    panel.querySelector('#ms2-save-context').addEventListener('click', () => {
      saveContext(panel.querySelector('#ms2-s-context').value);
      toast(`${SVG_CHECK} Context saved`);
    });

    panel.querySelector('#ms2-ctx-inject-chk')?.addEventListener('change', e => {
      setInjectCtx(e.target.checked);
      toast(e.target.checked
        ? `${SVG_CHECK} Scene Context will now be sent to JanitorAI on every generation`
        : `${SVG_CROSS} Scene Context injection disabled`, 3000);
    });

    const personaList    = panel.querySelector('#ms2-persona-list');
    const personaForm    = panel.querySelector('#ms2-persona-form');
    const personaAddBtn  = panel.querySelector('#ms2-persona-add-btn');
    const personaNameIn  = panel.querySelector('#ms2-persona-name-input');
    const personaDescIn  = panel.querySelector('#ms2-persona-desc-input');
    const personaSaveBtn = panel.querySelector('#ms2-persona-save-btn');
    const personaCancelBtn = panel.querySelector('#ms2-persona-cancel-btn');
    let _editingPersonaId = null; 

    function renderPersonaList() {
      if (!personaList) return;
      const personas = getPersonaLib();
      if (!personas.length) {
        personaList.innerHTML = '<div style="font-size:11px;color:#4b5563;padding:4px 2px;">No personas saved yet. Click + Add to create one.</div>';
        return;
      }
      
      personaList.innerHTML = personas.map(p => `
        <div class="ms2-pl-card">
          <div style="flex:1;min-width:0;">
            <div style="font-size:12px;color:#c4b5fd;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(p.name)}</div>
            <div style="font-size:11px;color:#6b7280;margin-top:2px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">${escHtml(p.desc)}</div>
          </div>
          <div style="display:flex;flex-direction:column;gap:3px;flex-shrink:0;">
            <button data-pid="${escHtml(p.id)}" class="pl-use" style="background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#6ee7b7;font-size:10px;cursor:pointer;padding:2px 6px;">Use</button>
            <button data-pid="${escHtml(p.id)}" class="pl-edit" style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.3);border-radius:4px;color:#a78bfa;font-size:10px;cursor:pointer;padding:2px 6px;">Edit</button>
            <button data-pid="${escHtml(p.id)}" class="pl-del" style="background:none;border:1px solid rgba(255,255,255,0.08);border-radius:4px;color:#6b7280;font-size:10px;cursor:pointer;padding:2px 6px;">${SVG_TRASH}</button>
          </div>
        </div>`).join('');
    }

    function openPersonaForm(editId = null) {
      _editingPersonaId = editId;
      if (!editId) {
        personaNameIn.value = '';
        personaDescIn.value = '';
        personaSaveBtn.textContent = 'Save';
      }
      personaForm.style.display = '';
      personaNameIn.focus();
    }

    function closePersonaForm() {
      personaForm.style.display = 'none';
      _editingPersonaId = null;
      personaNameIn.value = '';
      personaDescIn.value = '';
      personaSaveBtn.textContent = 'Save';
    }

    personaAddBtn?.addEventListener('click', () => openPersonaForm(null));
    personaCancelBtn?.addEventListener('click', closePersonaForm);

    personaSaveBtn?.addEventListener('click', () => {
      const name = personaNameIn.value.trim();
      const desc = personaDescIn.value.trim();
      if (!name) { toast(`${SVG_WARNING} Enter a name for this persona`); personaNameIn.focus(); return; }
      if (!desc)  { toast(`${SVG_WARNING} Enter a description for this persona`); personaDescIn.focus(); return; }
      const arr = getPersonaLib();
      if (_editingPersonaId) {
        const idx = arr.findIndex(x => x.id === _editingPersonaId);
        if (idx !== -1) arr[idx] = { ...arr[idx], name, desc };
      } else {
        arr.push({ id: 'pl_' + Date.now().toString(36), name, desc });
      }
      savePersonaLib(arr);
      closePersonaForm();
      renderPersonaList();
      toast(`${SVG_CHECK} Persona ${_editingPersonaId ? 'updated' : 'saved'}`);
    });

    personaDescIn?.addEventListener('keydown', e => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); personaSaveBtn.click(); }
    });

    personaList?.addEventListener('click', e => {
      const btn = e.target.closest('button[data-pid]');
      if (!btn) return;
      const pid = btn.dataset.pid;
      const p   = getPersonaLib().find(x => x.id === pid);
      if (!p) return;
      if (btn.classList.contains('pl-use')) {
        const ta = panel.querySelector('#ms2-s-context');
        if (ta) { ta.value = p.desc; ta.dispatchEvent(new Event('input', { bubbles: true })); }
        toast(`${SVG_PERSONA} Persona loaded into Scene Context`);
      } else if (btn.classList.contains('pl-edit')) {
        _editingPersonaId = p.id;
        personaNameIn.value = p.name;
        personaDescIn.value = p.desc;
        personaForm.style.display = '';
        personaSaveBtn.textContent = 'Update';
        personaNameIn.focus();
        personaForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
      } else if (btn.classList.contains('pl-del')) {
        savePersonaLib(getPersonaLib().filter(x => x.id !== p.id));
        renderPersonaList();
        toast(`${SVG_TRASH} Persona removed`);
      }
    });

    renderPersonaList();

    panel.querySelector('#ms2-persona-export-btn')?.addEventListener('click', () => {
      const arr = getPersonaLib();
      if (!arr.length) { toast(`${SVG_WARNING} No personas to export yet.`); return; }
      const json = JSON.stringify({ version: 1, personas: arr }, null, 2);
      const blob = new Blob([json], { type: 'application/json' });
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement('a');
      a.href     = url;
      a.download = 'JanitorV5-personas.json';
      a.click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
      toast(`${SVG_SAVE} Exported ${arr.length} persona${arr.length !== 1 ? 's' : ''}`);
    });

    const importFileInput = panel.querySelector('#ms2-persona-import-file');

    panel.querySelector('#ms2-persona-import-btn')?.addEventListener('click', () => {
      importFileInput?.click();
    });

    importFileInput?.addEventListener('change', () => {
      const file = importFileInput.files?.[0];
      if (!file) return;
      importFileInput.value = ''; 
      const reader = new FileReader();
      reader.onload = ev => {
        try {
          const parsed = JSON.parse(ev.target.result);
          
          const incoming = Array.isArray(parsed)
            ? parsed
            : (Array.isArray(parsed?.personas) ? parsed.personas : null);
          if (!incoming) { toast(`${SVG_WARNING} Invalid file — expected a personas JSON export.`); return; }
          const valid = incoming.filter(p =>
            p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.desc === 'string'
          );
          if (!valid.length) { toast(`${SVG_WARNING} No valid persona entries found in file.`); return; }
          
          const existing = getPersonaLib();
          const existingIds = new Set(existing.map(x => x.id));
          const merged = [...existing, ...valid.filter(p => !existingIds.has(p.id))];
          savePersonaLib(merged);
          renderPersonaList();
          const added = merged.length - existing.length;
          toast(`${SVG_FOLDER} Imported ${added} new persona${added !== 1 ? 's' : ''}` +
            (added < valid.length ? ' (' + (valid.length - added) + ' already existed)' : ''));
        } catch {
          toast(`${SVG_WARNING} Could not read file — make sure it is a valid JSON export.`);
        }
      };
      reader.readAsText(file);
    });

    panel.querySelectorAll('[data-tab="context"]').forEach(btn =>
      btn.addEventListener('click', () => { renderSumHistory(); })
    );
    
    (() => { renderSumHistory(); })();

    panel.querySelector('#ms2-ctx-gen-btn')?.addEventListener('click', () => {
      doGenerateSummary({ silent: false });
    });

    panel.querySelector('#ms2-ctx-save-global-btn')?.addEventListener('click', () => {
      const text = panel.querySelector('#ms2-s-context')?.value || getContext();
      if (!text.trim()) { toast(`${SVG_WARNING} No context text to save`); return; }
      saveGlobalMemory(text.trim());
      toast(`${SVG_SAVE} Context saved as global memory`);
    });

    panel.querySelector('#ms2-ctx-load-global-btn')?.addEventListener('click', () => {
      const mem = getGlobalMemory().trim();
      if (!mem) { toast(`${SVG_WARNING} No global memory saved yet`); return; }
      const ta = panel.querySelector('#ms2-s-context');
      if (!ta) return;
      ta.value = mem;
      ta.dispatchEvent(new Event('input', { bubbles: true }));
      saveContext(mem);
      toast(`${SVG_FOLDER} Global memory loaded into this chat`);
    });

    const _charId = getCurrentCharId();
    const charRow = panel.querySelector('#ms2-ctx-char-row');
    if (!_charId && charRow) charRow.style.display = 'none';
    panel.querySelector('#ms2-ctx-save-char-btn')?.addEventListener('click', () => {
      if (!_charId) { toast(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
      const text = panel.querySelector('#ms2-s-context')?.value || getContext();
      if (!text.trim()) { toast(`${SVG_WARNING} No context text to save`); return; }
      saveCharGlobalMemory(_charId, text.trim());
      toast(`${SVG_MEMORY} Memory saved for this character`);
    });
    panel.querySelector('#ms2-ctx-load-char-btn')?.addEventListener('click', () => {
      if (!_charId) { toast(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
      const mem = getCharGlobalMemory(_charId).trim() || getGlobalMemory().trim();
      if (!mem) { toast(`${SVG_WARNING} No saved memory for this character yet`); return; }
      const ta = panel.querySelector('#ms2-s-context');
      if (!ta) return;
      ta.value = mem;
      ta.dispatchEvent(new Event('input', { bubbles: true }));
      saveContext(mem);
      toast(`${SVG_MEMORY} Character memory loaded`);
    });

    panel.querySelector('#ms2-ctx-autoload-save')?.addEventListener('click', () => {
      setAutoLoadGlobal(panel.querySelector('#ms2-ctx-autoload-chk')?.checked || false);
      toast(`${SVG_CHECK} Auto-load preference saved`);
    });

    panel.querySelector('#ms2-ctx-auto-save')?.addEventListener('click', () => {
      const every  = parseInt(panel.querySelector('#ms2-ctx-auto-every')?.value) || 0;
      const auto   = panel.querySelector('#ms2-ctx-auto-chk')?.checked || false;
      setAutoSumEvery(every);
      setAutoSumAuto(auto);
      stopAutoSumObserver();
      if (every > 0) startAutoSumObserver();
      toast(every > 0
        ? `${SVG_CHECK} Auto-context: every ${every} messages${auto ? ', generates automatically' : ', notifies only'}`
        : `${SVG_CHECK} Auto-context disabled`);
    });

    panel.querySelector('#ms2-ctx-export-hist')?.addEventListener('click', () => {
      const hist = getSumHistory();
      if (!hist.length) { toast('No summaries to export yet'); return; }
      const blob = new Blob([JSON.stringify(hist, null, 2)], { type: 'application/json' });
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement('a');
      a.href     = url;
      a.download = `JanitorV5-summaries-${new Date().toISOString().slice(0,10)}.json`;
      a.click();
      URL.revokeObjectURL(url);
      toast(`${SVG_SAVE} Exported ${hist.length} summar${hist.length !== 1 ? 'ies' : 'y'}`);
    });

    panel.querySelector('#ms2-ctx-clear-hist')?.addEventListener('click', function() {
      const clearBtn = this;
      showInlineConfirm(panel, {
        message: 'Clear all saved summaries?',
        insertBefore: clearBtn,
        onConfirm: () => {
          saveSumHistory([]);
          renderSumHistory();
          toast('History cleared');
        },
      });
    });

    renderPresets(panel);

    function showTabHelp(title, bodyHtml) {
      document.getElementById('tab-help-backdrop')?.remove();
      const backdrop = document.createElement('div');
      backdrop.className = 'ms2-backdrop';
      backdrop.id = 'tab-help-backdrop';
      backdrop.style.zIndex = '10000020';
      const modal = document.createElement('div');
      modal.className = 'ms2-modal';
      modal.style.maxWidth = '520px';
      modal.setAttribute('role', 'dialog');
      modal.innerHTML = `
        <div class="ms2-modal-header">
          <div class="ms2-modal-title">${title}</div>
          <button class="ms2-modal-close" id="tab-help-close">×</button>
        </div>
        <div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">${bodyHtml}</div>`;
      backdrop.appendChild(modal);
      document.body.appendChild(backdrop);
      const close = () => backdrop.remove();
      modal.querySelector('#tab-help-close').addEventListener('click', close);
      setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
      addEscapeClose(backdrop);
    }

    panel.querySelector('#gen-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_SETTINGS} General Settings`, `
      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Two separate AI connections</strong><br>
      JanitorV5 keeps your API and JanitorAI's AI completely independent:<br>
      • <span style="color:#10b981;">Your API key (this tab)</span> — used by Smart Reply, Shorten, Summarise, and Context Generate. You choose the model and pay (or use free tiers) directly.<br>
      • <span style="color:#a78bfa;">JanitorAI's own AI</span> — controlled by the Configure tab. Presets, forbidden words, thinking mode, and scene injection intercept JanitorAI's generation without touching your key.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Which provider to pick?</strong></p>
      <ul style="margin:0 0 10px;padding-left:16px;color:#d1d5db;font-size:12px;line-height:1.8;">
        <li><strong style="color:#e5e7eb;">OpenRouter</strong> — best default. 300+ models, many permanently free (suffix <code style="color:#a78bfa">:free</code>), one key for everything. Free models like Llama 4 and Qwen 3 handle RP very well. <a href="https://openrouter.ai/keys" target="_blank" style="color:#a78bfa;">openrouter.ai/keys</a></li>
        <li><strong style="color:#e5e7eb;">Groq</strong> — fastest raw speed of any provider. Llama 4, Qwen 3, generous free tier. Best when you want instant replies. <a href="https://console.groq.com" target="_blank" style="color:#a78bfa;">console.groq.com</a></li>
        <li><strong style="color:#e5e7eb;">Anthropic</strong> — Claude Opus 4 / Sonnet / Haiku. Best prose quality for creative writing. Different API format but handled automatically. <a href="https://console.anthropic.com" target="_blank" style="color:#a78bfa;">console.anthropic.com</a></li>
        <li><strong style="color:#e5e7eb;">xAI</strong> — Grok 3 / Grok 4. Strong reasoning, OpenAI-compatible. <a href="https://console.x.ai" target="_blank" style="color:#a78bfa;">console.x.ai</a></li>
        <li><strong style="color:#e5e7eb;">OpenAI</strong> — GPT-4o, GPT-4.1, o4-mini. Reliable, widely documented. <a href="https://platform.openai.com/api-keys" target="_blank" style="color:#a78bfa;">platform.openai.com</a></li>
        <li><strong style="color:#e5e7eb;">Mistral</strong> — Magistral (reasoning), Devstral (code), Mistral Large. <a href="https://console.mistral.ai" target="_blank" style="color:#a78bfa;">console.mistral.ai</a></li>
      </ul>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">API Key security</strong><br>
      Stored locally in your browser via GM storage — never sent anywhere except directly to the provider endpoint. JanitorV5 never proxies or logs your key.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Custom endpoint</strong><br>
      Select <em>Custom / other proxy URL…</em> to point JanitorV5 at any OpenAI-compatible proxy (LiteRouter, self-hosted Ollama, meganova.ai, etc.). Use <strong>Custom model ID</strong> if your proxy uses short IDs without a provider prefix.</p>

      <p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Free OpenRouter models work surprisingly well for roleplay. Start there before paying — upgrade to Claude or Grok only when you need top-tier prose.</p>
    `));

    panel.querySelector('#reply-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_REPLY} Reply Settings`, `
      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Default Tone</strong><br>
      Sets which tone is pre-selected every time you open Smart Reply. Skip this if you prefer choosing fresh each session — leaving it blank keeps you intentional. Combine it with a Style preset if your character always stays in one voice.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Default Custom Instruction</strong><br>
      Pre-fills the instruction box on every open. Good for persistent rules like <em>"never break character"</em>, <em>"max 2 paragraphs"</em>, or <em>"no internal monologue"</em>. These stack on top of the active Style preset — don't repeat what's already in your persona note.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">${SVG_REROLL} Reroll</strong><br>
      Re-runs the same tone and instruction with a fresh generation — no re-typing needed. Use it when the first output is technically correct but feels flat. If Reroll keeps producing similar results, change the tone or add a specific instruction to break the pattern.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">${SVG_KEYBOARD} Send</strong><br>
      Injects the generated reply directly into JanitorAI's input field. You can edit before sending — the field stays editable after injection.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Notify on new AI message</strong><br>
      Shows a subtle top toast when JanitorAI's character responds while you have a modal open or are scrolled away. Doesn't interrupt anything — just a quiet heads-up.</p>

      <p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Best workflow: set a Default Tone, create a Style preset for your character, and use Reroll freely — it's fast and costs almost nothing on free-tier models.</p>
    `));

    panel.querySelector('#styles-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_STYLES} Styles — Character Presets`, `
      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">What Styles do</strong><br>
      A Style preset snapshots your character's entire voice: default tone, persona note, and any partner context. Activating one auto-fills Smart Reply every time you open it — no re-typing needed across sessions or characters.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">When to use multiple presets</strong><br>
      Create one preset per character (or per <em>mode</em> of the same character — e.g. "Cold Rvie" vs "Soft Rvie"). Switch between them in Settings without touching the actual reply flow. Pairs well with the Persona Library in the Context tab, which handles scene notes separately.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Persona note vs Default Instruction</strong><br>
      • <strong>Persona note (in a Style)</strong> — who your character <em>is</em>: speech patterns, personality, quirks.<br>
      • <strong>Default Instruction (Reply tab)</strong> — how to write <em>this reply</em>: length, format, restrictions.<br>
      Don't duplicate — keep identity in the preset, behaviour constraints in the instruction.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Quick workflow</strong><br>
      + New Preset → name it → pick tone → write persona note → Save Preset → hit <strong>Use</strong>. Active preset is shown with a highlight. Only one preset is active at a time.</p>

      <p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Keep persona notes concise — 3 to 5 tight sentences beat a wall of text. Models follow shorter, sharper instructions more reliably.</p>
    `));

    panel.querySelector('#ctx-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_CONTEXT} Context Tab`, `
      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Two layers of context</strong><br>
      • <strong>Your API (Smart Reply / Shorten)</strong> — Scene Context is appended to every request automatically.<br>
      • <strong>JanitorAI's own AI</strong> — toggle <em>Send to JanitorAI's AI</em> to inject context into JanitorAI's generation prompt on every message. Saves on toggle, no extra steps.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Scene Context box</strong><br>
      Write the current situation: location, mood, recent events, unresolved tension. Keep it present-tense and specific. Up to 2000 characters. Each chat URL gets its own stored context — switching chats doesn't overwrite anything.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Generate vs Summarise — which to use</strong><br>
      • <strong>${SVG_SPARKLE} Generate</strong> — reads currently <em>visible</em> messages → writes a current-situation note. Fast, narrow, meant for live sessions.<br>
      • <strong>FAB → Summarise</strong> — loads <em>full chat history</em> → writes a long-arc summary (who they are, relationship arc, key story beats). Output goes to clipboard; paste into JanitorAI's Chat Memory panel for persistent long-term memory.<br>
      Use Generate frequently during a session. Use Summarise when you want to archive the full story or before starting a continuation.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Global memory &amp; character memory</strong><br>
      <strong>Save Global / Load Global</strong> — persists across all chats. Good for your general persona or world notes.<br>
      <strong>Save for Character / Load for Character</strong> — per-character memory keyed to the current chat URL. Load falls back to global if no character memory exists yet.<br>
      <strong>Auto-load</strong> toggle fills an empty new chat automatically when you navigate to it.</p>

      <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">${SVG_PERSONA} Persona Library</strong><br>
      Named entries for reusable character descriptions. Hit <strong>Use</strong> to load one into Scene Context instantly. Export the whole library as <code>.json</code> for backups — Import merges by ID, so re-importing never creates duplicates. Use the speed-dial <strong>Personas</strong> button to switch characters mid-session without opening Settings.</p>

      <p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Keep Scene Context short and current — 150–300 characters of sharp situational detail beats a long summary. Let Summarise handle the backstory; let Generate handle the scene.</p>
    `));

    apRenderPanel(panel);
  }

  // ─── ADV. PROMPT PANEL ────────────────────────────────────────────────────

  function apRenderPanel(panel) {
    const presetSel     = panel.querySelector('#ap-preset-sel');
    const modulesWrap   = panel.querySelector('#ap-modules-wrap');
    const noPresetMsg   = panel.querySelector('#ap-no-preset-msg');
    const moduleList    = panel.querySelector('#ap-module-list');
    const unattachedSel = panel.querySelector('#ap-unattached-sel');
    if (!presetSel) return;

    panel.querySelector('#ap-help-btn')?.addEventListener('click', () => {
      document.getElementById('ap-help-backdrop')?.remove();

      const backdrop = document.createElement('div');
      backdrop.className = 'ms2-backdrop';
      backdrop.id = 'ap-help-backdrop';
      backdrop.style.zIndex = '10000020';

      const modal = document.createElement('div');
      modal.className = 'ms2-modal';
      modal.style.maxWidth = '560px';
      modal.setAttribute('role', 'dialog');
      modal.innerHTML = `
        <div class="ms2-modal-header">
          <div class="ms2-modal-title">${SVG_CONFIG} How Configure Works</div>
          <button class="ms2-modal-close" id="ap-help-close">×</button>
        </div>
        <div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">

          <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">What Configure injects into JanitorAI's AI</strong></p>
          <ul style="margin:0 0 10px;padding-left:18px;color:#d1d5db;line-height:1.8;">
            <li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Active Preset</strong> — replaces the system prompt on every generation. Only the currently selected preset fires.</li>
            <li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Forbidden Words</strong> — hard-banned from every generation. Unlimited, unlike JanitorAI's built-in 10-word cap.</li>
            <li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Enable Thinking</strong> — adds a &lt;thinking&gt; reasoning step before each reply. Best on Claude 3.5+, o1, Gemini 2.0+, or any model with extended reasoning.</li>
            <li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Scene Context inject</strong> — appended when "Send to JanitorAI's AI" is ON in the Context tab. Works with or without an active preset.</li>
            <li><span style="color:#f87171;">${SVG_CROSS}</span> <strong>Smart Reply / Shorten / Summarise</strong> — these use <em>your own</em> API key, not JanitorAI's generation. Configure doesn't touch them.</li>
          </ul>

          <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Presets &amp; modules</strong><br>
          A <strong>preset</strong> is a stack of instruction <strong>modules</strong>. Each module is one block — writing style, character rules, scene restrictions, etc. Drag to reorder, toggle to enable/disable per-module. Only the active (checked) preset is sent. Switch presets mid-session without reloading anything.</p>

          <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Enable Advanced Prompting toggle</strong><br>
          Master switch for the entire Configure system. OFF = nothing is injected, JanitorAI runs unmodified. The status dot goes green on the first successful injection after you turn it ON.</p>

          <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Forbidden Words</strong><br>
          Type a word or phrase → press + or Enter. Appears as a removable chip. These stack on top of JanitorAI's own banned words — use them to stop the AI repeating specific phrases, names, or filler openers that keep showing up.</p>

          <p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Enable Thinking</strong><br>
          Works independently of any preset — just requires Advanced Prompting to be ON. If the model ignores it or outputs visible &lt;thinking&gt; tags, your model doesn't support extended reasoning; turn this off for that model.</p>

          <p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Write modules as direct instructions, not descriptions: "Write responses under 150 words" beats "The AI should try to be concise." Short, imperative phrasing gets followed more reliably.</p>
        </div>`;

      backdrop.appendChild(modal);
      document.body.appendChild(backdrop);

      const close = () => backdrop.remove();
      modal.querySelector('#ap-help-close').addEventListener('click', close);
      setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
      addEscapeClose(backdrop);
    });

    panel.querySelector('#ap-enabled-chk').addEventListener('change', e => {
      AP.enabled = e.target.checked;
    });

    const thinkingChk = panel.querySelector('#ap-thinking-chk');
    if (thinkingChk) {
      thinkingChk.checked = getAPThinking();
      thinkingChk.addEventListener('change', e => setAPThinking(e.target.checked));
    }

    const forbiddenInput  = panel.querySelector('#ap-forbidden-input');
    const forbiddenAddBtn = panel.querySelector('#ap-forbidden-add-btn');
    const forbiddenTagsEl = panel.querySelector('#ap-forbidden-tags');

    if (forbiddenInput && forbiddenAddBtn && forbiddenTagsEl) {
      let forbiddenWords = getAPForbiddenWords().trim().split('\n').filter(Boolean);

      function renderCounter() {
        const counterEl = panel.querySelector('#ap-forbidden-counter');
        if (!counterEl) return;
        const scriptCount = forbiddenWords.length;
        if (scriptCount === 0) {
          counterEl.style.display = 'none';
          return;
        }
        const nativeCount = parseInt(gget('ap_native_ban_count', '-1'));
        counterEl.style.display = 'block';
        if (nativeCount >= 0) {
          counterEl.innerHTML =
            `⚡ <strong>${nativeCount + scriptCount}</strong> words active this session` +
            `&nbsp;<span style="color:#6b7280">(${nativeCount} native + ${scriptCount} from script)</span>`;
        } else {
          counterEl.innerHTML =
            `⚡ <strong>${scriptCount}</strong> extra word${scriptCount !== 1 ? 's' : ''} queued — ` +
            `<span style="color:#6b7280">send a message to see the full count</span>`;
        }
      }

      function renderForbiddenTags() {
        forbiddenTagsEl.innerHTML = '';
        if (!forbiddenWords.length) {
          forbiddenTagsEl.innerHTML = '<span style="font-size:11px;color:#4b5563;padding:2px 4px;">No banned words yet — add one above.</span>';
          renderCounter();
          return;
        }
        forbiddenWords.forEach((word, idx) => {
          const tag = document.createElement('span');
          tag.style.cssText =
            'display:inline-flex;align-items:center;gap:3px;' +
            'background:rgba(139,92,246,0.15);color:#c4b5fd;' +
            'padding:2px 7px;border-radius:4px;font-size:11px;' +
            'border:1px solid rgba(139,92,246,0.3);';
          tag.innerHTML =
            `<span>${escHtml(word)}</span>` +
            `<button data-idx="${idx}" style="background:none;border:none;color:#f87171;cursor:pointer;font-size:10px;line-height:1;padding:0 0 0 2px;">×</button>`;
          tag.querySelector('button').addEventListener('click', () => {
            forbiddenWords.splice(idx, 1);
            setAPForbiddenWords(forbiddenWords.join('\n'));
            renderForbiddenTags();
          });
          forbiddenTagsEl.appendChild(tag);
        });
        renderCounter();
      }

      function addForbiddenWord() {
        const w = forbiddenInput.value.trim();
        if (!w) return;
        if (!forbiddenWords.includes(w)) {
          forbiddenWords.push(w);
          setAPForbiddenWords(forbiddenWords.join('\n'));
          renderForbiddenTags();
        }
        forbiddenInput.value = '';
        forbiddenInput.focus();
      }

      forbiddenAddBtn.addEventListener('click', addForbiddenWord);
      forbiddenInput.addEventListener('keydown', e => {
        if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord(); }
      });

      renderForbiddenTags();
    }

    function fillPresetSel() {
      const presets = AP.getPresets();
      presetSel.innerHTML = `<option value="">— Select preset —</option>` +
        presets.map(p => `<option value="${escHtml(p.id)}" ${p.id === AP.selected ? 'selected' : ''}>${escHtml(p.name)}</option>`).join('');
    }

    function refresh() {
      fillPresetSel();
      const preset = apGetSelected();
      const hasPreset = !!preset;
      modulesWrap.style.display  = hasPreset ? '' : 'none';
      noPresetMsg.style.display  = hasPreset ? 'none' : '';
      if (hasPreset) {
        renderModuleList();
        fillUnattachedSel();
        updateTokenBar();
      }
      apRefreshSaveBtn();
    }

    function updateTokenBar() {
      const combined = apGetCombinedPrompt();
      const tokens   = apEstimateTokens(combined || '');
      const MAX_DISP = 4096;
      const pct      = Math.min(100, (tokens / MAX_DISP) * 100);
      const fill     = panel.querySelector('#ap-token-fill');
      const label    = panel.querySelector('#ap-token-count');
      if (fill)  fill.style.width = pct + '%';
      if (label) label.textContent = `~${tokens} tokens`;
    }

    function renderModuleList() {
      const preset = apGetSelected();
      if (!preset) { moduleList.innerHTML = ''; return; }

      const attached = (preset.modules || [])
        .filter(m => m.attached !== false)
        .sort((a, b) => a.order - b.order);

      if (!attached.length) {
        moduleList.innerHTML = '<div class="ap-empty">No attached modules. Add one below.</div>';
        return;
      }

      moduleList.innerHTML = '';
      attached.forEach(mod => {
        const item = document.createElement('div');
        item.className = 'ap-module-item' + (mod.enabled ? '' : ' ap-disabled');
        item.draggable = true;
        item.dataset.mid = mod.id;
        item.innerHTML = `
          <span class="ap-drag-handle" title="Drag to reorder">⠿</span>
          <span class="ap-module-name" title="${escHtml(mod.name)}">${escHtml(mod.name)}</span>
          <div class="ap-module-btns">
            <button class="ap-module-btn" data-act="edit" title="Edit content">✎</button>
            <button class="ap-module-btn ap-del" data-act="unattach" title="Unattach">⊟</button>
            <label class="ap-module-switch" title="${mod.enabled ? 'Disable' : 'Enable'}">
              <input type="checkbox" ${mod.enabled ? 'checked' : ''}>
              <span class="ap-module-thumb"></span>
            </label>
          </div>`;

        item.querySelector('input[type=checkbox]').addEventListener('change', e => {
          const p = apGetSelected();
          const m = p.modules.find(x => x.id === mod.id);
          if (m) { m.enabled = e.target.checked; apMarkDirty(); renderModuleList(); updateTokenBar(); }
        });

        item.querySelectorAll('[data-act]').forEach(btn => {
          btn.addEventListener('click', e => {
            e.stopPropagation();
            if (btn.dataset.act === 'edit') apOpenModuleEditor(mod.id, refresh);
            if (btn.dataset.act === 'unattach') {
              const p = apGetSelected();
              const m = p.modules.find(x => x.id === mod.id);
              if (m) { m.attached = false; m.enabled = false; apMarkDirty(); refresh(); }
            }
          });
        });

        item.addEventListener('dragstart', e => {
          e.dataTransfer.effectAllowed = 'move';
          item.classList.add('ap-dragging');
        });
        item.addEventListener('dragend', e => {
          item.classList.remove('ap-dragging');
          moduleList.querySelectorAll('.ap-module-item').forEach(i => i.classList.remove('ap-drag-over'));
          
          if (e.dataTransfer.dropEffect === 'none') return;
          
          const p = apGetSelected();
          if (p) {
            [...moduleList.querySelectorAll('.ap-module-item')].forEach((el, idx) => {
              const m = p.modules.find(x => x.id === el.dataset.mid);
              if (m) m.order = idx;
            });
            apMarkDirty();
          }
        });
        item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('ap-drag-over'); });
        item.addEventListener('dragleave', () => item.classList.remove('ap-drag-over'));
        item.addEventListener('drop', e => {
          e.preventDefault();
          item.classList.remove('ap-drag-over');
          const dragging = moduleList.querySelector('.ap-dragging');
          if (dragging && dragging !== item) {
            const all = [...moduleList.children];
            dragging.parentNode.insertBefore(
              dragging,
              all.indexOf(dragging) < all.indexOf(item) ? item.nextSibling : item
            );
          }
        });

        moduleList.appendChild(item);
      });
    }

    function fillUnattachedSel() {
      const preset = apGetSelected();
      if (!preset) { unattachedSel.innerHTML = ''; return; }
      const unattached = (preset.modules || []).filter(m => !m.attached);
      unattachedSel.innerHTML = unattached.length
        ? `<option value="">Select module…</option>` + unattached.map(m => `<option value="${escHtml(m.id)}">${escHtml(m.name)}</option>`).join('')
        : `<option value="">No unattached modules</option>`;
    }

    presetSel.addEventListener('change', () => {
      if (_apDirty) {
        const incoming = presetSel.value;
        presetSel.value = AP.selected; 
        showInlineConfirm(panel, {
          message: 'Unsaved changes — discard them?',
          insertBefore: presetSel.closest('.ap-row') || presetSel.parentElement,
          onConfirm: () => {
            AP.selected = incoming;
            presetSel.value = incoming;
            apLoadFromStorage();
            refresh();
          },
        });
        return;
      }
      AP.selected = presetSel.value;
      apLoadFromStorage();
      refresh();
    });

    panel.querySelector('#ap-new-preset-btn').addEventListener('click', () => {
      showInlineNameInput(panel, {
        placeholder: 'New preset name…',
        initialValue: '',
        insertAnchor: presetSel.closest('.ap-row') || presetSel.parentElement,
        onConfirm: (name) => {
          const presets = AP.getPresets();
          let n = name, c = 1;
          while (presets.some(p => p.name === n)) n = `${name} (${c++})`;
          const np = { id: apUUID(), name: n, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modules: [] };
          presets.push(np);
          AP.savePresets(presets);
          AP.selected = np.id;
          apLoadFromStorage();
          refresh();
        },
      });
    });

    panel.querySelector('#ap-rename-preset-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      showInlineNameInput(panel, {
        placeholder: 'Preset name…',
        initialValue: p.name,
        insertAnchor: presetSel.closest('.ap-row') || presetSel.parentElement,
        onConfirm: (name) => {
          if (name === p.name) return;
          p.name = name;
          apMarkDirty();
          apSaveWorking();
          refresh();
        },
      });
    });

    panel.querySelector('#ap-export-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' });
      const a = Object.assign(document.createElement('a'), {
        href: URL.createObjectURL(blob),
        download: p.name.replace(/[^a-z0-9]/gi, '_') + '_preset.json',
      });
      document.body.appendChild(a); a.click();
      
      setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 1000);
    });

    panel.querySelector('#ap-import-btn').addEventListener('click', () => {
      const inp = Object.assign(document.createElement('input'), { type: 'file', accept: '.json' });
      inp.addEventListener('change', async () => {
        try {
          const data = JSON.parse(await inp.files[0].text());
          if (!data.name || !Array.isArray(data.modules)) { toast('Invalid preset file'); return; }
          const presets = AP.getPresets();
          let n = data.name, c = 1;
          while (presets.some(p => p.name === n)) n = `${data.name} (${c++})`;
          data.name = n;
          data.id   = apUUID();
          data.createdAt = data.updatedAt = new Date().toISOString();
          data.modules.forEach(m => { if (typeof m.attached !== 'boolean') m.attached = true; });
          presets.push(data);
          AP.savePresets(presets);
          AP.selected = data.id;
          apLoadFromStorage();
          refresh();
          toast(`${SVG_CHECK} Preset imported`);
        } catch(e) { toast('Import failed: ' + e.message); }
      });
      inp.click();
    });

    panel.querySelector('#ap-delete-preset-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      const delBtn = panel.querySelector('#ap-delete-preset-btn');
      showInlineConfirm(panel, {
        message: `Delete "${p.name}"?`,
        insertBefore: delBtn,
        onConfirm: () => {
          AP.savePresets(AP.getPresets().filter(x => x.id !== p.id));
          AP.selected = '';
          _apWorking  = null;
          _apDirty    = false;
          toast('Preset deleted');
          refresh();
        },
      });
    });

    panel.querySelector('#ap-attach-btn').addEventListener('click', () => {
      const p  = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      const id = unattachedSel.value;
      
      if (!id) {
        showInlineNameInput(panel, {
          placeholder: 'Module name…',
          initialValue: '',
          insertAnchor: panel.querySelector('#ap-attach-btn'),
          onConfirm: (name) => {
            const mod = { id: apUUID(), name, content: '', enabled: false, order: p.modules.length, attached: false };
            p.modules.push(mod);
            apMarkDirty();
            apOpenModuleEditor(mod.id, refresh);
            refresh();
          },
        });
        return;
      }
      const m = p.modules.find(x => x.id === id);
      if (m) {
        m.attached = true;
        m.enabled  = true;
        m.order    = p.modules.filter(x => x.attached).length - 1;
        apMarkDirty(); refresh();
      }
    });

    panel.querySelector('#ap-del-module-btn').addEventListener('click', () => {
      const p  = apGetSelected();
      const id = unattachedSel.value;
      if (!p || !id) return;
      const m = p.modules.find(x => x.id === id);
      if (!m) return;
      const delModBtn = panel.querySelector('#ap-del-module-btn');
      showInlineConfirm(panel, {
        message: `Delete module "${m.name}"?`,
        insertBefore: delModBtn,
        onConfirm: () => {
          p.modules = p.modules.filter(x => x.id !== id);
          apMarkDirty(); refresh();
        },
      });
    });

    panel.querySelector('#ap-new-module-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      
      showInlineNameInput(panel, {
        placeholder: 'Module name…',
        initialValue: '',
        insertAnchor: panel.querySelector('#ap-new-module-btn'),
        onConfirm: (name) => {
          const mod = { id: apUUID(), name, content: '', enabled: false, order: p.modules.length, attached: false };
          p.modules.push(mod);
          apMarkDirty();
          apOpenModuleEditor(mod.id, refresh);
          refresh();
        },
      });
    });

    panel.querySelector('#ap-save-btn').addEventListener('click', () => {
      apSaveWorking(); toast(`${SVG_CHECK} Preset saved`); refresh();
    });
    panel.querySelector('#ap-discard-btn').addEventListener('click', () => {
      apLoadFromStorage(); refresh();
    });

    refresh();
  }

  // ─── ADV. PROMPT — MODULE EDITOR MODAL ────────────────────────────────────

  function apOpenModuleEditor(moduleId, onSave) {
    const preset = apGetSelected();
    if (!preset) return;
    const mod = preset.modules.find(m => m.id === moduleId);
    if (!mod) return;

    document.getElementById('ap-editor-backdrop')?.remove();

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ap-editor-backdrop';
    
    backdrop.style.zIndex = '10000010';

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.style.maxWidth = '640px';
    modal.setAttribute('role', 'dialog');
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">✎ Edit Module</div>
        <button class="ms2-modal-close" id="ap-editor-close">×</button>
      </div>
      <div class="ms2-modal-body">
        <label class="ms2-field-label">Module Name</label>
        <input type="text" class="ms2-input" id="ap-mod-name" value="${escHtml(mod.name)}" style="margin-bottom:12px;">
        <label class="ms2-field-label">Content
          <span id="ap-mod-tokens" style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;margin-left:6px;"></span>
        </label>
        <textarea class="ms2-input ms2-textarea-lg" id="ap-mod-content" style="min-height:240px;font-family:monospace;font-size:12px;">${escHtml(mod.content)}</textarea>
      </div>
      <div class="ms2-modal-footer">
        <button class="ms2-btn-action ms2-btn-generate" id="ap-editor-apply">Apply</button>
        <button class="ms2-btn-action ms2-btn-retry" id="ap-editor-cancel">Cancel</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const nameInp    = modal.querySelector('#ap-mod-name');
    const contentTA  = modal.querySelector('#ap-mod-content');
    const tokenLabel = modal.querySelector('#ap-mod-tokens');

    function updateEditorTokens() {
      tokenLabel.textContent = `~${apEstimateTokens(contentTA.value)} tokens`;
    }
    contentTA.addEventListener('input', updateEditorTokens);
    updateEditorTokens();

    const close = () => backdrop.remove();
    modal.querySelector('#ap-editor-close').addEventListener('click', close);
    modal.querySelector('#ap-editor-cancel').addEventListener('click', close);
    setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
    addEscapeClose(backdrop);

    modal.querySelector('#ap-editor-apply').addEventListener('click', () => {
      mod.name    = nameInp.value.trim() || mod.name;
      mod.content = contentTA.value;
      apMarkDirty();
      if (onSave) onSave();
      close();
    });
  }

  function showInlineConfirm(panel, { message, onConfirm, insertBefore }) {
    panel.querySelector('#ap-inline-confirm-wrap')?.remove();
    const wrap = document.createElement('div');
    wrap.id = 'ap-inline-confirm-wrap';
    wrap.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;font-size:12px;';
    const label = document.createElement('span');
    label.style.cssText = 'flex:1;color:#f87171;';
    label.textContent = message;
    const yesBtn = document.createElement('button');
    yesBtn.className = 'ap-icon-btn';
    yesBtn.style.color = '#f87171';
    yesBtn.innerHTML = `${SVG_CHECK} Yes`;
    const noBtn = document.createElement('button');
    noBtn.className = 'ap-icon-btn';
    noBtn.textContent = 'Cancel';
    wrap.append(label, yesBtn, noBtn);
    insertBefore.parentElement.insertBefore(wrap, insertBefore);
    yesBtn.addEventListener('click', () => { wrap.remove(); onConfirm(); });
    noBtn.addEventListener('click',  () => wrap.remove());
  }

  function showInlineNameInput(panel, { placeholder, initialValue, insertAnchor, onConfirm }) {
    panel.querySelector('#ap-inline-name-wrap')?.remove();
    const wrap = document.createElement('div');
    wrap.id = 'ap-inline-name-wrap';
    wrap.style.cssText = 'display:flex;gap:6px;margin-bottom:8px;align-items:center;';
    const inp = document.createElement('input');
    inp.className = 'ms2-input';
    inp.placeholder = placeholder;
    inp.value = initialValue || '';
    inp.style.cssText = 'margin-bottom:0;flex:1;min-width:0;';
    const confirmBtn = document.createElement('button');
    confirmBtn.className = 'ap-icon-btn';
    confirmBtn.innerHTML = SVG_CHECK;
    confirmBtn.title = 'Confirm';
    const cancelBtn = document.createElement('button');
    cancelBtn.className = 'ap-icon-btn';
    cancelBtn.innerHTML = SVG_CROSS;
    cancelBtn.title = 'Cancel';
    wrap.append(inp, confirmBtn, cancelBtn);
    insertAnchor.parentElement.insertBefore(wrap, insertAnchor);
    inp.focus();
    if (initialValue) inp.select();
    const doConfirm = () => {
      const val = inp.value.trim();
      if (!val) { inp.focus(); return; }
      wrap.remove();
      onConfirm(val);
    };
    confirmBtn.addEventListener('click', doConfirm);
    cancelBtn.addEventListener('click', () => wrap.remove());
    inp.addEventListener('keydown', e => {
      if (e.key === 'Enter') doConfirm();
      if (e.key === 'Escape') { e.stopPropagation(); wrap.remove(); }
    });
  }

  // ─── PRESETS PANEL ─────────────────────────────────────────────────────────

  function renderPresets(panel) {
    const listEl   = panel.querySelector('#ms2-presets-list');
    const newBtn   = panel.querySelector('#ms2-new-preset-btn');
    const editorEl = panel.querySelector('#ms2-preset-editor-wrap');
    if (!listEl) return;

    const presets  = getPresets();
    const activeId = CFG.activePreset;

    if (presets.length === 0) {
      listEl.innerHTML = '<div class="ms2-presets-empty">No presets yet. Create one to save your character\'s voice.</div>';
    } else {
      listEl.innerHTML = presets.map(p => {
        const tone = TONES.find(t => t.id === p.tone);
        const isActive = p.id === activeId;
        return `
          <div class="ms2-preset-item ${isActive ? 'is-active' : ''}">
            <div class="ms2-preset-info">
              <div class="ms2-preset-name">${isActive ? '● ' : ''}${escHtml(p.name)}</div>
              ${tone ? `<div class="ms2-preset-tone">${escHtml(tone.label)}</div>` : ''}
            </div>
            <div class="ms2-preset-actions">
              <button class="ms2-preset-btn ${isActive ? 'ms2-preset-active' : 'ms2-preset-use'}" data-pid="${escHtml(p.id)}" data-action="toggle">${isActive ? `${SVG_CHECK} Active` : 'Use'}</button>
              <button class="ms2-preset-btn ms2-preset-edit" data-pid="${escHtml(p.id)}" data-action="edit">Edit</button>
              <button class="ms2-preset-btn ms2-preset-del" data-pid="${escHtml(p.id)}" data-action="del">${SVG_CROSS}</button>
            </div>
          </div>`;
      }).join('');
    }

    listEl.querySelectorAll('[data-action]').forEach(btn => {
      btn.addEventListener('click', () => {
        const pid    = btn.dataset.pid;
        const action = btn.dataset.action;
        if (action === 'toggle') {
          CFG.activePreset = (CFG.activePreset === pid) ? null : pid;
          renderPresets(panel);
          toast(CFG.activePreset === pid ? `${SVG_CHECK} Preset activated` : 'Preset deactivated');
        } else if (action === 'edit') {
          const preset = getPresets().find(p => p.id === pid);
          if (preset) showPresetEditor(panel, preset);
        } else if (action === 'del') {
          showInlineConfirm(panel, {
            message: `Delete "${getPresets().find(p => p.id === pid)?.name || 'this preset'}"?`,
            insertBefore: btn,
            onConfirm: () => {
              if (CFG.activePreset === pid) CFG.activePreset = null;
              savePresets(getPresets().filter(p => p.id !== pid));
              renderPresets(panel);
              toast('Preset deleted');
            },
          });
        }
      });
    });

    newBtn.onclick = () => {
      if (getPresets().length >= 10) { toast('Maximum 10 presets'); return; }
      showPresetEditor(panel, null);
    };
  }

  function showPresetEditor(panel, existingPreset) {
    const listEl   = panel.querySelector('#ms2-presets-list');
    const newBtn   = panel.querySelector('#ms2-new-preset-btn');
    const editorEl = panel.querySelector('#ms2-preset-editor-wrap');

    const p = existingPreset || { id: apUUID(), name: '', tone: '', personaNote: '', characterContext: '' };
    const isNew = !existingPreset;

    listEl.style.display  = 'none';
    newBtn.style.display  = 'none';
    editorEl.style.display = '';

    const toneOpts = [{ id: '', label: '— None —' }, ...TONES].map(t =>
      `<option value="${escHtml(t.id)}" ${p.tone === t.id ? 'selected' : ''}>${escHtml(t.label)}</option>`
    ).join('');

    editorEl.innerHTML = `
      <div class="ms2-label" style="margin-bottom:10px;">${isNew ? 'New Preset' : 'Edit Preset'}</div>
      <label class="ms2-field-label">Preset Name *</label>
      <input type="text" class="ms2-input" id="ms2-pe-name" value="${escHtml(p.name)}" placeholder="e.g. Rvie's Teasing Mode">
      <label class="ms2-field-label">Default Tone</label>
      <select class="ms2-select" id="ms2-pe-tone">${toneOpts}</select>
      <label class="ms2-field-label">Your Character's Persona Note</label>
      <textarea class="ms2-input ms2-textarea-sm" id="ms2-pe-persona" placeholder="Describe how your character talks, acts, and thinks…">${escHtml(p.personaNote || '')}</textarea>
      <label class="ms2-field-label">Other Character Context <span style="font-weight:400;">(optional)</span></label>
      <textarea class="ms2-input ms2-textarea-sm" id="ms2-pe-charctx" placeholder="Notes about the AI character you're roleplaying with…">${escHtml(p.characterContext || '')}</textarea>
      <div class="ms2-settings-actions">
        <button class="ms2-btn-save" id="ms2-pe-save">Save Preset</button>
        <button class="ms2-btn-cancel" id="ms2-pe-cancel">Cancel</button>
      </div>`;

    editorEl.querySelector('#ms2-pe-cancel').addEventListener('click', () => {
      editorEl.style.display = 'none';
      editorEl.innerHTML = '';
      listEl.style.display  = '';
      newBtn.style.display  = '';
      renderPresets(panel);
    });

    editorEl.querySelector('#ms2-pe-save').addEventListener('click', () => {
      const name = editorEl.querySelector('#ms2-pe-name').value.trim();
      if (!name) { toast('Preset name is required'); return; }
      const updated = {
        id:               p.id,
        name,
        tone:             editorEl.querySelector('#ms2-pe-tone').value,
        personaNote:      editorEl.querySelector('#ms2-pe-persona').value.trim(),
        characterContext: editorEl.querySelector('#ms2-pe-charctx').value.trim(),
      };
      const presets = getPresets();
      const idx = presets.findIndex(x => x.id === updated.id);
      if (idx >= 0) presets[idx] = updated; else presets.push(updated);
      savePresets(presets);
      editorEl.style.display = 'none';
      editorEl.innerHTML = '';
      listEl.style.display  = '';
      newBtn.style.display  = '';
      renderPresets(panel);
      toast(`${SVG_CHECK} Preset saved`);
    });
  }

  // ─── FAB (draggable, tap = speed-dial, long-press = settings) ─────────────

  const LONG_PRESS_MS = 600;

  function ensureFAB() {
    if (document.getElementById('ms2-fab')) return;

    const fab = document.createElement('button');
    fab.id    = 'ms2-fab';
    fab.title = 'Tap: speed-dial  |  Hold: settings';
    fab.innerHTML = `${SVG_SETTINGS}<div id="ms2-fab-ring"></div>`;
    fab.style.right  = CFG.fabRight  + 'px';
    fab.style.bottom = CFG.fabBottom + 'px';
    document.body.appendChild(fab);

    const ring = fab.querySelector('#ms2-fab-ring');

    if (!gget('ms2_v2_hintSeen', false)) {
      gset('ms2_v2_hintSeen', true);
      const hint = document.createElement('div');
      hint.id = 'ms2-fab-hint';
      hint.textContent = 'Tap for menu • Hold for settings';
      document.body.appendChild(hint);
      setTimeout(() => {
        const r = fab.getBoundingClientRect();
        hint.style.right  = (document.documentElement.clientWidth  - r.left + 6) + 'px';
        hint.style.bottom = (document.documentElement.clientHeight - r.top  + 6) + 'px';
      }, 0);
      setTimeout(() => hint.remove(), 3500);
    }

    let startX, startY, startRight, startBottom;
    let wasMoved = false, longFired = false;
    let longTimer = null, ringTimer = null, ringDelayTimer = null;

    const RING_DELAY = 280;

    function startProgress() {
      const t0 = performance.now();
      const remaining = LONG_PRESS_MS - RING_DELAY; 
      const step = now => {
        const pct = Math.min(100, ((now - t0) / remaining) * 100);
        ring.style.background = `conic-gradient(rgba(139,92,246,0.75) ${pct}%, transparent ${pct}%)`;
        if (pct < 100) ringTimer = requestAnimationFrame(step);
      };
      ringTimer = requestAnimationFrame(step);
    }

    function cancelProgress() {
      clearTimeout(ringDelayTimer);
      cancelAnimationFrame(ringTimer);
      ringDelayTimer = null;
      ringTimer = null;
      ring.style.background = 'none';
    }

    function beginDrag(cx, cy) {
      wasMoved  = false;
      longFired = false;
      startX      = cx;
      startY      = cy;
      startRight  = parseInt(fab.style.right,  10) || CFG.fabRight;
      startBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
      fab.classList.add('ms2-pressing');

      ringDelayTimer = setTimeout(() => {
        if (!wasMoved && !longFired) startProgress();
      }, RING_DELAY);

      longTimer = setTimeout(() => {
        if (!wasMoved) {
          longFired = true;
          cancelProgress();
          fab.classList.remove('ms2-pressing');
          closeDial();
          openSettingsModal('general');
        }
      }, LONG_PRESS_MS);
    }

    function moveDrag(cx, cy) {
      const dx = cx - startX, dy = cy - startY;
      if (Math.abs(dx) > 6 || Math.abs(dy) > 6) {
        if (!wasMoved) {
          wasMoved = true;
          clearTimeout(longTimer);
          cancelProgress();
          fab.classList.remove('ms2-pressing');
          fab.classList.add('ms2-dragging');
          closeDial();
        }
      }
      if (!wasMoved) return;
      
      const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight, S = 44;
      fab.style.right  = Math.max(8, Math.min(W - S - 8, startRight  - dx)) + 'px';
      fab.style.bottom = Math.max(8, Math.min(H - S - 8, startBottom - dy)) + 'px';
    }

    function endDrag() {
      clearTimeout(longTimer);
      cancelProgress();
      fab.classList.remove('ms2-dragging', 'ms2-pressing');
      if (wasMoved) {
        CFG.fabRight  = parseInt(fab.style.right,  10) || CFG.fabRight;
        CFG.fabBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
      } else if (!longFired) {
        toggleDial();
      }
    }

    fab.addEventListener('mousedown', e => {
      e.preventDefault();
      beginDrag(e.clientX, e.clientY);
      const onMove = ev => moveDrag(ev.clientX, ev.clientY);
      const onUp   = () => {
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup',   onUp);
        endDrag();
      };
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup',   onUp);
    });

    fab.addEventListener('touchstart', e => {
      e.preventDefault();
      beginDrag(e.touches[0].clientX, e.touches[0].clientY);
      const onMove = ev => { ev.preventDefault(); moveDrag(ev.touches[0].clientX, ev.touches[0].clientY); };
      const onEnd  = () => {
        document.removeEventListener('touchmove', onMove);
        document.removeEventListener('touchend',  onEnd);
        document.removeEventListener('touchcancel', onEnd);
        endDrag();
      };
      document.addEventListener('touchmove', onMove, { passive: false });
      document.addEventListener('touchend',  onEnd);
      document.addEventListener('touchcancel', onEnd);
    }, { passive: false });
  }

  // ─── SPA NAVIGATION ────────────────────────────────────────────────────────

  let _lastPathname = '';
  let _lastCharId   = null;   
  function onRouteChange() {
    if (location.pathname === _lastPathname) return;
    _lastPathname = location.pathname;
    setTimeout(() => {
      ensureFAB();
      
      if (isOnChatPage()) {
        stopObserver();

        stopAutoSumObserver();
        _lastSeenText       = '';
        _cachedLastBotIndex = -1;
        _cachedLastBotText  = '';
        if (CFG.autoNotify) startObserver();
        if (getAutoSumEvery() > 0) startAutoSumObserver();
        
        clearAccumulated();

        const newCharId = getCurrentCharId();
        if (newCharId && newCharId !== _lastCharId) {

          setTimeout(() => {
            const name = extractChatNameFromDOM();
            if (name) saveChatName(name, ctxKey());
          }, 800);

          const pastCount = countSumHistoryForCurrentChat();
          
          const storedName = getChatName(ctxKey())
            || getSumHistory().find(h => h.conv === ctxKey())?.chatName
            || '';
          const charLabel  = storedName ? ` — <strong>${escHtml(storedName)}</strong>` : '';
          
          const lastSnap  = getFabSumLast();
          const snapInfo  = lastSnap
            ? (() => {
                const mins = Math.round((Date.now() - lastSnap.ts) / 60000);
                return mins < 60
                  ? ` · last summary ${mins < 1 ? 'just now' : `${mins}m ago`}`
                  : ` · last summary ${Math.round(mins / 60)}h ago`;
              })()
            : '';
          if (pastCount > 0) {
            toast(
              `${SVG_SUMMARISE} Switched chat${charLabel} — <strong>${pastCount}</strong> past summar${pastCount !== 1 ? 'ies' : 'y'}${snapInfo}`,
              4500
            );
          } else if (_lastCharId !== null) {

            toast(`${SVG_SUMMARISE} Switched chat${charLabel} — no past summaries yet`, 3000);
          }
          
          _sumHistShowAll = false;
        }
        _lastCharId = newCharId;

        if (getAutoLoadGlobal()) {
          setTimeout(() => {
            if (getContext().trim()) return; 
            const charId = getCurrentCharId();
            const mem = (charId && getCharGlobalMemory(charId).trim())
                     || getGlobalMemory().trim();
            if (mem) {
              saveContext(mem);
              toast(`${SVG_FOLDER} Memory auto-loaded for this chat`, 3000);
            }
          }, 700);
        }
      } else {
        stopObserver();
        stopAutoSumObserver();
        _cachedLastBotIndex = -1;
        _cachedLastBotText  = '';
        closeDial();
        clearAccumulated();
        _lastCharId = null;

        // Clear the persisted character ID & name when navigating to a neutral page
        // (home, explore, search, etc.) — NOT on character card pages, since those
        // will immediately overwrite the stored ID anyway when _p2pGetCharId() runs.
        const _path = location.pathname;
        const _isNeutral = !_path.startsWith('/characters/') && !_path.startsWith('/chats/');
        if (_isNeutral) {
          try { GM_setValue(P2P_GM_LAST_CHAR, ''); } catch {}
          try { GM_setValue(P2P_GM_LAST_CHAR_NAME, ''); } catch {}
        }
      }
    }, 400);
  }

  if (!history.__ms2_patched) {
    const _histPush    = history.pushState.bind(history);
    const _histReplace = history.replaceState.bind(history);
    history.pushState    = (...a) => { _histPush(...a);    onRouteChange(); };
    history.replaceState = (...a) => { _histReplace(...a); onRouteChange(); };
    history.__ms2_patched = true;
  }
  window.addEventListener('popstate', onRouteChange);

  window.addEventListener('resize', () => {
    const fab = document.getElementById('ms2-fab');
    if (!fab) return;
    const W = document.documentElement.clientWidth;
    const H = document.documentElement.clientHeight;
    const S = 44;
    const curRight  = parseInt(fab.style.right,  10) || CFG.fabRight;
    const curBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
    fab.style.right  = Math.max(8, Math.min(W - S - 8, curRight))  + 'px';
    fab.style.bottom = Math.max(8, Math.min(H - S - 8, curBottom)) + 'px';
  });

  // ─── INIT ──────────────────────────────────────────────────────────────────

  function init() {
    _initRemoteConfig();
    initAPInterceptor();
    apWatchDeletions();
    ensureFAB();
    if (isOnChatPage() && CFG.autoNotify) startObserver();
    if (isOnChatPage() && getAutoSumEvery() > 0) startAutoSumObserver();

    // ── Diagnostic interface (read by JV5 Diagnostics userscript) ──────────
    try {
      unsafeWindow.__jv5 = {
        version:     '5.0.2',
        hasApiKey:   () => !!CFG.apiKey,
        model:       () => CFG.model,
        endpoint:    () => CFG.endpoint,
        authMode:    () => CFG.authMode,
        apEnabled:   () => AP.enabled,
        hasPayload:  () => !!gget('jv4_lastGeneratePayload', null),
        payloadMsgCount: () => {
          try {
            const raw = gget('jv4_lastGeneratePayload', null);
            if (!raw) return 0;
            const p = JSON.parse(raw);
            return (p.messages || p.chatMessages || []).length;
          } catch { return 0; }
        },
        getLatestText: getLatestAIText,
        virtuosoWorking: () => document.querySelectorAll(VIRTUOSO_SEL).length > 0,
      };
    } catch {  }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
  } else {
    setTimeout(init, 500);
  }

})();