DS MCP Bridge

AI Chat 增强 — MCP 工具调用 + TTS 朗读 + 多站点适配

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         DS MCP Bridge
// @namespace    https://github.com/calendar0917/ds-enhance
// @version      4.2.0
// @description  AI Chat 增强 — MCP 工具调用 + TTS 朗读 + 多站点适配
// @author       ds-enhance
// @match        https://chat.deepseek.com/*
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @run-at       document-start
// @license      GPL-3.0
// ==/UserScript==

(function () {
  'use strict';

  const SCRIPT_PREFIX = '[Bridge]';
  const DEFAULT_MCP_URL = 'http://localhost:8024/mcp';
  const TOOL_CALL_RE = /```mcp:([\w.-]+)\n([\s\S]*?)```/g;
  const SYSTEM_HINT_START = '[系统指令]';
  const SYSTEM_HINT_END = '[系统指令结束]';
  const AUTO_SEND_KEY = 'auto_send';
  const DEFAULT_AUTO_SEND = true;

  // ═══════════════════════════════════════════════════════════════
  //  Module Toggles (top-level so XHR/fetch hooks can access)
  // ═══════════════════════════════════════════════════════════════
  const MODULE_DEFAULTS = { mcp: true, tts: true, ttsAutoPlay: false };
  function getModuleEnabled(mod) { return GM_getValue('mod_' + mod, MODULE_DEFAULTS[mod]); }
  function setModuleEnabled(mod, val) { GM_setValue('mod_' + mod, val); }
  function getAutoSendEnabled() { return GM_getValue(AUTO_SEND_KEY, DEFAULT_AUTO_SEND); }

  // ═══════════════════════════════════════════════════════════════
  //  Adapter Registry — Multi-site support
  // ═══════════════════════════════════════════════════════════════
  const ADAPTERS = {
    deepseek: {
      id: 'deepseek',
      name: 'DeepSeek Chat',
      match: (url) => /chat\.deepseek\.com/.test(url),
      selectors: {
        assistantMessages: '.ds-markdown--block, [class*="markdown"]',
        inputBox: 'textarea, [contenteditable="true"][placeholder]',
      },
      getRequestPattern: () => /completion/,
    },
    chatgpt: {
      id: 'chatgpt',
      name: 'ChatGPT',
      match: (url) => /chat\.openai\.com|chatgpt\.com/.test(url),
      selectors: {
        assistantMessages: '[data-message-author-role="assistant"]',
        inputBox: 'textarea[id="prompt-textarea"], #prompt-textarea',
      },
      getRequestPattern: () => /backend-api\/conversation/,
    },
  };

  function detectAdapter() {
    const url = location.href;
    for (const [id, adapter] of Object.entries(ADAPTERS)) {
      if (adapter.match(url)) {
        console.log(`${SCRIPT_PREFIX} Detected adapter: ${adapter.name}`);
        return adapter;
      }
    }
    console.log(`${SCRIPT_PREFIX} No adapter matched for: ${url}`);
    return null;
  }

  const currentAdapter = detectAdapter();

  // ═══════════════════════════════════════════════════════════════
  //  File Context (tool results only — native upload handled by DS)
  // ═══════════════════════════════════════════════════════════════
  const _toolFiles = [];

  function addToolFileResult(filename, text, mimeType) {
    _toolFiles.push({ filename, _textContent: text, mime_type: mimeType || 'text/plain' });
  }

  function injectToolFileContext(bodyStr) {
    if (!_toolFiles.length || !bodyStr) return bodyStr;
    try {
      const parsed = JSON.parse(bodyStr);
      let ctx = '\n\n[上传文件内容]\n';
      for (const f of _toolFiles) {
        ctx += `\n--- ${f.filename} ---\n${f._textContent}\n`;
      }
      if (parsed.prompt && typeof parsed.prompt === 'string') {
        parsed.prompt += ctx;
      } else if (parsed.messages?.length) {
        const lastMsg = parsed.messages[parsed.messages.length - 1];
        if (typeof lastMsg.content === 'string') lastMsg.content += ctx;
        else if (Array.isArray(lastMsg.content)) {
          const tp = lastMsg.content.find(p => p.type === 'text');
          if (tp) tp.text += ctx;
        }
      }
      console.log(`${SCRIPT_PREFIX} Injected ${_toolFiles.length} tool file context(s)`);
      _toolFiles.length = 0;
      return JSON.stringify(parsed);
    } catch { return bodyStr; }
  }

  // ═══════════════════════════════════════════════════════════════
  //  MCP Client (GM_xmlhttpRequest to bypass CORS)
  // ═══════════════════════════════════════════════════════════════
  class MCPClient {
    constructor(url) {
      this.url = url;
      this.sessionId = null;
      this._nextId = 1;
      this.connected = false;
    }

    _post(body) {
      return new Promise((resolve, reject) => {
        const headers = {
          'Content-Type': 'application/json',
          'Accept': 'application/json, text/event-stream',
        };
        if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
        GM_xmlhttpRequest({
          method: 'POST', url: this.url, headers,
          data: JSON.stringify(body),
          onload: (resp) => {
            try {
              const text = resp.responseText;
              if (resp.responseHeaders?.includes('text/event-stream')) {
                for (const line of text.split('\n')) {
                  if (line.startsWith('data: ')) { resolve(JSON.parse(line.slice(6))); return; }
                }
                reject(new Error('No data in SSE response'));
              } else {
                resolve(JSON.parse(text));
              }
            } catch (e) { reject(new Error(`Parse error: ${e.message}`)); }
          },
          onerror: (e) => reject(new Error(`Network error: ${e.error || 'connection refused'}`)),
          ontimeout: () => reject(new Error('Request timed out')),
          timeout: 30000,
        });
      });
    }

    async _rpc(method, params = {}) {
      const id = this._nextId++;
      const resp = await this._post({ jsonrpc: '2.0', id, method, params });
      if (resp.error) throw new Error(`MCP error: ${resp.error.message}`);
      return resp.result;
    }

    async initialize() {
      try {
        const result = await this._rpc('initialize', {
          protocolVersion: '2025-03-26', capabilities: {},
          clientInfo: { name: 'ds-mcp-bridge', version: '2.0.0' },
        });
        this.sessionId = result.sessionId;
        this.connected = true;
        await this._post({ jsonrpc: '2.0', method: 'notifications/initialized' });
        console.log(`${SCRIPT_PREFIX} MCP connected: ${this.sessionId}`);
        return true;
      } catch (e) { console.error(`${SCRIPT_PREFIX} Init failed:`, e.message); this.connected = false; return false; }
    }

    async listTools() {
      if (!this.connected) await this.initialize();
      const result = await this._rpc('tools/list');
      return result.tools || [];
    }

    async callTool(name, args = {}) {
      if (!this.connected) await this.initialize();
      return this._rpc('tools/call', { name, arguments: args });
    }

    async checkHealth() {
      try {
        const resp = await new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method: 'GET', url: this.url.replace('/mcp', '/health'),
            onload: (r) => resolve(JSON.parse(r.responseText)),
            onerror: (e) => reject(e), timeout: 5000,
          });
        });
        return resp.status === 'ok';
      } catch { return false; }
    }
  }

  // ═══════════════════════════════════════════════════════════════
  //  TTS Client — Text-to-Speech via server API
  // ═══════════════════════════════════════════════════════════════
  class TTSClient {
    constructor() {
      this.audio = null;
      this.playing = false;
      this.currentText = '';
    }

    getBaseUrl() {
      const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
      try { return new URL(mcpUrl).origin; }
      catch { return mcpUrl.replace(/\/[^/]*$/, ''); }
    }

    async synthesize(text) {
      const voice = GM_getValue('tts_voice', 'zh-CN-XiaoxiaoNeural');
      const provider = GM_getValue('tts_provider', 'edge');
      const body = { text, voice, provider };
      if (provider === 'openai') {
        body.api_key = GM_getValue('tts_api_key', '');
        body.base_url = GM_getValue('tts_base_url', 'https://api.openai.com/v1');
        body.model = GM_getValue('tts_model', 'tts-1');
      }
      const url = this.getBaseUrl() + '/api/tts';
      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: 'POST', url,
          headers: { 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
          data: JSON.stringify(body),
          responseType: 'blob',
          onload: (resp) => {
            if (resp.status !== 200) {
              try { const err = JSON.parse(resp.responseText); reject(new Error(err.error || 'TTS failed')); }
              catch { reject(new Error('TTS HTTP ' + resp.status)); }
              return;
            }
            resolve(URL.createObjectURL(resp.response));
          },
          onerror: () => reject(new Error('TTS 网络错误')),
          ontimeout: () => reject(new Error('TTS 超时')),
          timeout: 60000,
        });
      });
    }

    async play(text) {
      this.stop();
      this.currentText = text;
      try {
        const audioUrl = await this.synthesize(text);
        this.audio = new Audio(audioUrl);
        this.playing = true;
        this.audio.onended = () => { this.playing = false; };
        await this.audio.play();
      } catch (e) {
        console.warn(`${SCRIPT_PREFIX} TTS server failed, falling back to Web Speech:`, e.message);
        this._fallbackPlay(text);
      }
    }

    _fallbackPlay(text) {
      if (!('speechSynthesis' in window)) { toast('TTS 不可用', 'error'); return; }
      window.speechSynthesis.cancel();
      const u = new SpeechSynthesisUtterance(text);
      u.lang = 'zh-CN';
      u.rate = 1.0;
      this.playing = true;
      u.onend = () => { this.playing = false; };
      window.speechSynthesis.speak(u);
    }

    stop() {
      if (this.audio) { this.audio.pause(); this.audio.currentTime = 0; this.audio = null; this.playing = false; }
      if (window.speechSynthesis) { window.speechSynthesis.cancel(); this.playing = false; }
    }
  }

  const ttsClient = new TTSClient();

  // ═══════════════════════════════════════════════════════════════
  //  Tool Registry & Hint Builder
  // ═══════════════════════════════════════════════════════════════
  let toolRegistry = [];

  function buildToolHint() {
    if (!toolRegistry.length) return '';
    let hint = `${SYSTEM_HINT_START} 你拥有以下 MCP 工具。当用户的需求可以用工具完成时,你必须在回复中调用工具。`;
    hint += ' 调用格式:用代码块写 ```mcp:工具名``` 后紧跟一个 JSON 代码块写参数。\n\n';
    hint += '示例:\n```mcp:execute_command\n{"command": "ls -la"}\n```\n\n';
    hint += '可用工具列表:\n';
    toolRegistry.forEach(t => {
      hint += `- ${t.name}: ${t.description || ''}`;
      const req = t.inputSchema?.required;
      if (req?.length) hint += ` (参数: ${req.join(', ')})`;
      hint += '\n';
    });
    hint += '\n如果不需要工具就正常回答。需要工具时一定要调用。';
    hint += '\n\n当收到用户发送的 <tool_result> 包裹的文本时,这是你之前调用的工具的执行结果。请基于结果继续回答用户的问题。';
    hint += `\n${SYSTEM_HINT_END}`;
    return hint;
  }

  function modifyRequest(bodyStr) {
    if (!toolRegistry.length || !bodyStr) return bodyStr;
    try {
      const parsed = JSON.parse(bodyStr);
      const hint = buildToolHint();
      if (!hint) return bodyStr;
      if (bodyStr.includes(`${SYSTEM_HINT_START} 你拥有以下 MCP 工具`)) return bodyStr;

      if (parsed.prompt && typeof parsed.prompt === 'string') {
        parsed.prompt = hint + '\n\n' + parsed.prompt;
        return injectToolFileContext(JSON.stringify(parsed));
      }
      if (parsed.messages?.length) {
        const lastMsg = parsed.messages[parsed.messages.length - 1];
        const content = lastMsg?.content;
        if (typeof content === 'string') {
          lastMsg.content = hint + '\n\n' + content;
          return injectToolFileContext(JSON.stringify(parsed));
        }
        if (Array.isArray(content)) {
          const textPart = content.find(p => p.type === 'text');
          if (textPart && !textPart.text.includes('[系统指令]')) {
            textPart.text = hint + '\n\n' + textPart.text;
            return injectToolFileContext(JSON.stringify(parsed));
          }
        }
      }
    } catch { /* not JSON */ }
    return bodyStr;
  }

  // ═══════════════════════════════════════════════════════════════
  //  SSE Parsing — DeepSeek native format + OpenAI compatible
  // ═══════════════════════════════════════════════════════════════
  const executedCalls = new Set();
  let _streamDebounce = null;

  function checkForToolCalls(content) {
    if (!content || !toolRegistry.length) return;

    // Strategy 1: Match ```mcp:tool_name\n{...}\n```
    const re = new RegExp(TOOL_CALL_RE.source, 'g');
    let match;
    while ((match = re.exec(content)) !== null) {
      const toolName = match[1];
      const rawArgs = match[2].trim();
      let args = {};
      try { args = JSON.parse(rawArgs); }
      catch { args = { input: rawArgs }; }

      const key = toolName + ':' + JSON.stringify(args);
      if (executedCalls.has(key)) continue;
      executedCalls.add(key);

      console.log(`${SCRIPT_PREFIX} Tool call: ${toolName}`, args);
      executeToolCall(toolName, args);
    }

    // Strategy 2: Match registered tool names directly
    // Handles SSE token boundary truncation
    for (const tool of toolRegistry) {
      const name = tool.name;
      const idx = content.indexOf(name);
      if (idx === -1) continue;

      const afterName = content.substring(idx + name.length);
      const braceStart = afterName.indexOf('{');
      if (braceStart === -1) continue;

      const braceEnd = afterName.indexOf('}', braceStart);
      if (braceEnd === -1) continue;

      const jsonStr = afterName.substring(braceStart, braceEnd + 1);
      let args = {};
      try { args = JSON.parse(jsonStr); }
      catch { args = { input: jsonStr }; }

      const key = name + ':' + JSON.stringify(args);
      if (executedCalls.has(key)) continue;
      executedCalls.add(key);

      console.log(`${SCRIPT_PREFIX} Tool call: ${name}`, args);
      executeToolCall(name, args);
    }
  }

  function parseSSEChunk(rawText) {
    let content = '';
    const lines = rawText.split('\n');

    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed || !trimmed.startsWith('data: ')) continue;

      const jsonStr = trimmed.slice(6).trim();
      if (jsonStr === '[DONE]') continue;

      try {
        const obj = JSON.parse(jsonStr);

        // DeepSeek native: {"p":"response/content","o":"SET","v":"text"}
        const v = obj.v;
        if (typeof v === 'string' && v.length > 0) {
          const p = obj.p || '';
          if (!p.includes('fragments') && !p.includes('status')) {
            content += v;
          }
          continue;
        }

        // OpenAI streaming: choices[0].delta.content
        const c = obj?.choices?.[0]?.delta?.content;
        if (c) { content += c; continue; }

        // OpenAI non-streaming: choices[0].message.content
        const mc = obj?.choices?.[0]?.message?.content;
        if (mc) { content += mc; continue; }

      } catch { /* not JSON, skip */ }
    }

    return content;
  }

  // ═══════════════════════════════════════════════════════════════
  //  XHR Hook — SSE stream reading via progress events
  // ═══════════════════════════════════════════════════════════════
  const XHRProto = unsafeWindow.XMLHttpRequest.prototype;
  const origOpen = XHRProto.open;
  const origSend = XHRProto.send;
  const xhrMeta = new WeakMap();

  XHRProto.open = function (method, url, ...rest) {
    xhrMeta.set(this, { url, method });
    return origOpen.apply(this, [method, url, ...rest]);
  };

  XHRProto.send = function (body) {
    const meta = xhrMeta.get(this);
    if (!meta) return origSend.apply(this, [body]);

    const isCompletion = meta.url.includes('completion');
    if (isCompletion && getModuleEnabled('mcp')) {
      if (body) body = modifyRequest(body);

      let requestContent = '';
      let requestLastParsed = 0;

      this.addEventListener('progress', function () {
        try {
          const rt = this.responseText || '';
          if (rt.length <= requestLastParsed) return;
          const newPart = rt.substring(requestLastParsed);
          // Only parse up to the last complete line to avoid dropping
          // incomplete `data:` lines split across progress events
          const lastNewline = newPart.lastIndexOf('\n');
          if (lastNewline < 0) return; // no complete line yet
          requestLastParsed += lastNewline + 1;
          const completePart = newPart.substring(0, lastNewline + 1);
          const newContent = parseSSEChunk(completePart);
          if (newContent) requestContent += newContent;

          if (_streamDebounce) clearTimeout(_streamDebounce);
          _streamDebounce = setTimeout(() => {
            if (requestContent) checkForToolCalls(requestContent);
          }, 1000);
        } catch { /* ignore */ }
      });

      this.addEventListener('load', function () {
        try {
          const rt = this.responseText || '';
          if (rt.length > requestLastParsed) {
            const newPart = rt.substring(requestLastParsed);
            const finalContent = parseSSEChunk(newPart);
            if (finalContent) requestContent += finalContent;
          }
        } catch { /* ignore */ }
        if (_streamDebounce) clearTimeout(_streamDebounce);
        checkForToolCalls(requestContent);
      });
    }

    return origSend.apply(this, [body]);
  };

  // ── Hook fetch (backup) ──
  const origFetch = unsafeWindow.fetch;

  unsafeWindow.fetch = async function (...args) {
    const url = (typeof args[0] === 'string') ? args[0] : args[0]?.url;

    if (url && url.includes('completion') && getModuleEnabled('mcp')) {
      if (args[1]?.body) {
        args[1].body = modifyRequest(args[1].body);
      }

      const response = await origFetch.apply(this, args);
      const contentType = response.headers?.get('content-type') || '';

      const clone = response.clone();
      clone.text().then(text => {
        const content = parseSSEChunk(text);
        if (content) checkForToolCalls(content);
      }).catch(() => { });

      return response;
    }

    return origFetch.apply(this, args);
  };

  // ═══════════════════════════════════════════════════════════════
  //  Tool Execution & Result Injection
  // ═══════════════════════════════════════════════════════════════
  async function executeToolCall(toolName, args) {
    const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
    const client = new MCPClient(mcpUrl);

    try {
      toast(`调用工具: ${toolName}...`, 'info');
      const result = await client.callTool(toolName, args);
      const resultText = result?.content?.[0]?.text || '(no result)';
      const isError = result?.isError;

      toast(isError ? `${toolName} 失败` : `${toolName} 完成`, isError ? 'error' : 'success');

      if (!isError && (toolName === 'read_file' || toolName === 'list_directory')) {
        const filename = (args.path || args.filename || 'tool_result.txt').split('/').pop().split('\\').pop();
        addToolFileResult(filename, resultText, 'text/plain');
        toast(`📁 ${filename} 已添加到文件列表`, 'success');
        injectResultToChat(`[工具 ${toolName} 的结果已作为文件添加,共 ${resultText.length} 字符。发送下条消息时会自动附带文件内容。]`);
      } else {
        injectResultToChat(isError ? `Error: ${resultText}` : resultText);
      }
    } catch (e) {
      toast(`工具调用失败: ${e.message}`, 'error');
      console.error(`${SCRIPT_PREFIX} Tool error:`, e);
      injectResultToChat(`Error: ${e.message}`);
    }
  }

  function injectResultToChat(resultText) {
    setTimeout(async () => {
      const wrappedText = `<tool_result>\n${resultText}\n</tool_result>`;

      const input = findInputElement();
      if (!input) {
        toast('找不到聊天输入框', 'error');
        return;
      }

      input.focus();
      await sleep(200);
      setInputValue(input, wrappedText);
      await sleep(500);

      if (!getAutoSendEnabled()) {
        toast('工具结果已填入输入框', 'info');
        return;
      }

      simulateEnter(input);
      await sleep(300);
      const sendBtn = findSendButton();
      if (sendBtn) sendBtn.click();

      toast('工具结果已发送', 'success');
    }, 1500);
  }

  function findInputElement() {
    for (const ta of document.querySelectorAll('textarea')) {
      if (isVisible(ta)) return ta;
    }
    const editables = document.querySelectorAll('[contenteditable="true"]');
    for (const el of editables) {
      if (isVisible(el) && el.getAttribute('placeholder')) return el;
    }
    for (const el of editables) {
      if (isVisible(el)) return el;
    }
    return null;
  }

  function findSendButton() {
    const selectors = [
      'button[aria-label*="send"]', 'button[aria-label*="Send"]',
      'button[aria-label*="发送"]', 'button[aria-label*="Submit"]',
      'button[type="submit"]', 'div[role="button"][aria-label*="send"]',
      'div[role="button"][aria-label*="发送"]',
    ];
    for (const sel of selectors) {
      const btn = document.querySelector(sel);
      if (btn && isVisible(btn)) return btn;
    }
    return null;
  }

  function isVisible(el) {
    return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
  }

  function setInputValue(element, value) {
    const isCE = element.contentEditable === 'true';

    if (isCE) {
      element.focus();
      const sel = unsafeWindow.getSelection();
      const range = document.createRange();
      range.selectNodeContents(element);
      sel.removeAllRanges();
      sel.addRange(range);

      element.dispatchEvent(new InputEvent('beforeinput', {
        bubbles: true, cancelable: true, inputType: 'insertText', data: value,
      }));

      try { document.execCommand('insertText', false, value); }
      catch { element.textContent = value; }

      range.selectNodeContents(element);
      range.collapse(false);
      sel.removeAllRanges();
      sel.addRange(range);
    } else {
      const setter = Object.getOwnPropertyDescriptor(
        unsafeWindow.HTMLTextAreaElement.prototype, 'value'
      )?.set || Object.getOwnPropertyDescriptor(
        unsafeWindow.HTMLInputElement.prototype, 'value'
      )?.set;

      if (setter) setter.call(element, value);
      else element.value = value;
    }

    [
      new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: value }),
      new Event('change', { bubbles: true }),
      new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Unidentified' }),
      new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key: 'Unidentified' }),
    ].forEach(e => element.dispatchEvent(e));
  }

  function simulateEnter(element) {
    const init = {
      key: 'Enter', code: 'Enter', keyCode: 13, which: 13,
      bubbles: true, cancelable: true,
    };
    element.dispatchEvent(new KeyboardEvent('keydown', init));
    element.dispatchEvent(new KeyboardEvent('keypress', init));
    element.dispatchEvent(new KeyboardEvent('keyup', init));
  }

  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

  // ═══════════════════════════════════════════════════════════════
  //  Toast
  // ═══════════════════════════════════════════════════════════════
  function toast(msg, type = 'info') {
    if (!document.body) return;
    const colors = { info: '#2a2a3e', success: '#0d3320', error: '#3d0f0f' };
    const el = document.createElement('div');
    el.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:1000001;background:${colors[type]};color:#eee;padding:12px 22px;border-radius:10px;font-size:14px;box-shadow:0 4px 20px rgba(0,0,0,.5);font-family:system-ui;transition:opacity .3s;`;
    el.textContent = msg;
    document.body.appendChild(el);
    setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3500);
  }

  // ═══════════════════════════════════════════════════════════════
  //  CSS
  // ═══════════════════════════════════════════════════════════════
  const PANEL_CSS = `
    #mcp-fab{position:fixed;z-index:999999;width:48px;height:48px;border-radius:50%;background:#16a34a;color:#fff;border:none;font-size:22px;cursor:grab;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 12px rgba(22,163,74,.4);user-select:none;-webkit-user-select:none;touch-action:none}
    #mcp-fab:active{cursor:grabbing}
    #mcp-fab:hover{transform:scale(1.1);box-shadow:0 4px 20px rgba(22,163,74,.6)}
    #mcp-fab.disconnected{background:#dc2626;box-shadow:0 2px 12px rgba(220,38,38,.4)}
    #mcp-fab.disconnected:hover{box-shadow:0 4px 20px rgba(220,38,38,.6)}

    #mcp-panel{position:fixed;z-index:999998;width:460px;max-height:min(75vh, calc(100vh - 20px));background:#16161e;color:#eee;border:1px solid #333;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.6);font-family:system-ui;font-size:14px;display:none;flex-direction:column;overflow:hidden}
    #mcp-panel.open{display:flex}
    #mcp-panel .hd{padding:14px 18px;border-bottom:1px solid #2a2a3a;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
    #mcp-panel .hd h3{margin:0;font-size:15px;font-weight:600}
    #mcp-panel .hd .ver{font-size:11px;color:#666;margin-left:8px}
    #mcp-panel .hd .cls{background:none;border:none;color:#888;font-size:20px;cursor:pointer;padding:0 4px}
    #mcp-panel .hd .cls:hover{color:#fff}

    #mcp-tabs{display:flex;border-bottom:1px solid #2a2a3a;overflow-x:auto;scrollbar-width:none;flex-shrink:0}
    #mcp-tabs::-webkit-scrollbar{display:none}
    #mcp-tabs button{flex:0 0 auto;padding:9px 14px;background:none;border:none;color:#888;font-size:12px;cursor:pointer;border-bottom:2px solid transparent;transition:color .15s,border-color .15s;white-space:nowrap}
    #mcp-tabs button.active{color:#7aa2f7;border-bottom-color:#7aa2f7}
    #mcp-tabs button:hover{color:#ccc}

    .mcp-bd{flex:1;overflow-y:auto;padding:12px 14px}
    .mcp-sec{display:none}.mcp-sec.active{display:block}

    .mcp-btn{padding:6px 12px;border-radius:8px;border:1px solid #444;background:#222;color:#eee;font-size:12px;cursor:pointer;transition:background .15s}
    .mcp-btn:hover{background:#333}
    .mcp-btn.pri{background:#16a34a;border-color:#16a34a;color:#fff}
    .mcp-btn.pri:hover{background:#15803d}
    .mcp-input{width:100%;padding:8px 12px;border-radius:8px;border:1px solid #444;background:#1a1a28;color:#eee;font-size:13px;box-sizing:border-box;outline:none}
    .mcp-input:focus{border-color:#7aa2f7}
    .mcp-input::placeholder{color:#555}
    .mcp-sel{width:100%;padding:7px 10px;border:1px solid #444;border-radius:8px;background:#1a1a28;color:#eee;font-size:13px;outline:none}
    .mcp-sel option{background:#1a1a28}

    .mcp-tool{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:8px;transition:background .1s;font-size:13px}
    .mcp-tool:hover{background:#1e1e2e}
    .mcp-tool .name{color:#7aa2f7;font-weight:500}
    .mcp-tool .desc{color:#888;font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}

    .mcp-result{margin-top:10px;padding:10px;background:#1a1a28;border-radius:8px;font-size:12px;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow-y:auto;color:#aaa;font-family:monospace}
    .mcp-result.error{color:#f87171}
    .mcp-label{font-size:12px;color:#888;margin-bottom:4px;display:block}
    .mcp-label-row{display:flex;align-items:center;gap:8px;margin-bottom:6px}
    .mcp-status{font-size:13px;padding:8px 0}
    .mcp-status .ok{color:#4ade80}
    .mcp-status .err{color:#f87171}

    .ext-card{padding:10px 12px;border:1px solid #333;border-radius:10px;margin-bottom:8px;background:#1a1a28}
    .ext-card-hd{display:flex;align-items:center;justify-content:space-between;gap:8px}
    .ext-card-name{font-weight:600;color:#7aa2f7;font-size:14px}
    .ext-card-transport{font-size:11px;color:#666;background:#222;padding:2px 6px;border-radius:4px}
    .ext-card-status{font-size:12px;display:flex;align-items:center;gap:4px}
    .ext-card-status .dot{width:7px;height:7px;border-radius:50%;display:inline-block}
    .ext-card-status .dot-green{background:#4ade80}.ext-card-status .dot-red{background:#f87171}.ext-card-status .dot-gray{background:#666}
    .ext-card-tools{font-size:11px;color:#888;margin-top:6px}
    .ext-card-actions{display:flex;gap:6px;margin-top:8px}
    .ext-card-actions .mcp-btn{font-size:11px;padding:4px 10px}
    .ext-form-row{margin-bottom:8px}
    .ext-form-row label{font-size:11px;color:#888;display:block;margin-bottom:3px}
    .ext-form-row input{font-size:12px}
    .ext-add-toggle{font-size:12px;color:#7aa2f7;cursor:pointer;border:none;background:none;padding:0;margin-top:6px}
    .ext-add-toggle:hover{text-decoration:underline}
    .ext-section{margin-top:10px;padding-top:10px;border-top:1px solid #2a2a3a}

    /* TTS Button */
    .mcp-tts-btn {
      display:inline-flex;align-items:center;gap:4px;
      padding:4px 8px;border-radius:6px;border:1px solid transparent;
      background:transparent;color:#999;font-size:12px;cursor:pointer;
      transition:background .15s,color .15s,border-color .15s;user-select:none;
      white-space:nowrap;line-height:1;height:28px;box-sizing:border-box;
    }
    .mcp-tts-btn:hover { background:#2a2a3e;color:#e0e0e0;border-color:#555; }
    .mcp-tts-btn.playing { color:#4ade80;background:#1a3a28;border-color:#16a34a; }
    .mcp-tts-btn.paused { color:#fbbf24;background:#3a2a18;border-color:#d97706; }

    /* Module toggles */
    .mcp-toggle-row { display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #2a2a3a; }
    .mcp-toggle-label { font-size:13px;color:#ccc; }
    .mcp-toggle-desc { font-size:11px;color:#666;margin-top:2px; }
    .mcp-switch { position:relative;width:36px;height:20px;flex-shrink:0; }
    .mcp-switch input { opacity:0;width:0;height:0; }
    .mcp-switch .slider { position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#333;border-radius:10px;transition:.2s; }
    .mcp-switch .slider:before { position:absolute;content:"";height:16px;width:16px;left:2px;bottom:2px;background:#888;border-radius:50%;transition:.2s; }
    .mcp-switch input:checked + .slider { background:#16a34a; }
    .mcp-switch input:checked + .slider:before { transform:translateX(16px);background:#fff; }

    .mcp-code-action {
      display:inline-flex;align-items:center;justify-content:center;
      height:24px;padding:0 8px;margin-left:4px;border-radius:5px;
      border:1px solid #444;background:#222;color:#ddd;font-size:12px;
      line-height:1;cursor:pointer;white-space:nowrap;box-sizing:border-box;
    }
    .mcp-code-action:hover { background:#333;color:#fff;border-color:#666; }
    .mcp-code-action.resend { background:#14351f;border-color:#16a34a;color:#8df0ad; }
    .mcp-code-action.resend:hover { background:#166534;color:#fff; }
    pre.mcp-code-hidden, code.mcp-code-hidden, .mcp-code-hidden pre, .mcp-code-hidden code { display:none !important; }
    .mcp-sys-fold {
      margin:8px 0;padding:8px 10px;border:1px solid #333;border-radius:8px;
      background:#15151f;color:#ccc;font-size:13px;line-height:1.55;
    }
    .mcp-sys-fold-hd { display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;user-select:none; }
    .mcp-sys-fold-title { color:#7aa2f7;font-weight:600; }
    .mcp-sys-fold-toggle { color:#999;font-size:12px; }
    .mcp-sys-fold-body { display:none;margin-top:8px;white-space:pre-wrap;color:#999;font-size:12px; }
    .mcp-sys-fold.open .mcp-sys-fold-body { display:block; }

  `;

  // ═══════════════════════════════════════════════════════════════
  //  FAB + Panel
  // ═══════════════════════════════════════════════════════════════
  function waitForDOM() {
    return new Promise(resolve => {
      if (document.body) resolve();
      else new MutationObserver(() => { if (document.body) resolve(); })
        .observe(document.documentElement, { childList: true });
    });
  }

  waitForDOM().then(() => {
    const style = document.createElement('style');
    style.textContent = PANEL_CSS;
    document.head.appendChild(style);

    // FAB
    const fab = document.createElement('button');
    fab.id = 'mcp-fab';
    fab.innerHTML = '&#9881;';
    fab.title = 'DS MCP Bridge (可拖动)';
    document.body.appendChild(fab);

    // Panel
    const panel = document.createElement('div');
    panel.id = 'mcp-panel';
    panel.innerHTML = `
      <div class="hd">
        <h3>DS MCP Bridge <span class="ver">v4.2.0</span></h3>
        <button class="cls">&times;</button>
      </div>
      <div id="mcp-tabs">
        <button class="active" data-tab="status">状态</button>
        <button data-tab="test">测试</button>
        <button data-tab="ext">MCP 服务器</button>
        <button data-tab="settings">设置</button>
      </div>
      <div class="mcp-bd">
        <div class="mcp-sec active" id="mcp-sec-status"></div>
        <div class="mcp-sec" id="mcp-sec-test"></div>
        <div class="mcp-sec" id="mcp-sec-ext"></div>
        <div class="mcp-sec" id="mcp-sec-settings"></div>
      </div>
    `;
    document.body.appendChild(panel);

    // Close button
    panel.querySelector('.cls').onclick = () => panel.classList.remove('open');

    // ── Drag ──
    let fabDragged = false, fabSX, fabSY, fabOX, fabOY;
    const DRAG_TH = 5;

    function posPanel() {
      const r = fab.getBoundingClientRect();
      const TOP_MARGIN = 50; // Never cover the top bar

      // Horizontal: right-align with FAB, clamp to viewport
      let l = r.right - 460;
      if (l + 460 > window.innerWidth - 10) l = window.innerWidth - 470;
      if (l < 10) l = 10;
      panel.style.left = l + 'px';

      // Try to place panel above the FAB
      const gap = 10;
      const spaceAbove = r.top - gap - TOP_MARGIN;
      const maxH = Math.min(window.innerHeight * 0.75, window.innerHeight - 2 * TOP_MARGIN);

      if (spaceAbove >= 200) {
        // Enough space above FAB — open upward
        panel.style.bottom = (window.innerHeight - r.top + gap) + 'px';
        panel.style.top = 'auto';
      } else {
        // Not enough space above — pin to top margin, let content scroll
        panel.style.top = TOP_MARGIN + 'px';
        panel.style.bottom = 'auto';
      }
      panel.style.maxHeight = maxH + 'px';
    }

    fab.addEventListener('pointerdown', (e) => {
      if (e.button) return;
      fabDragged = false; fabSX = e.clientX; fabSY = e.clientY;
      const r = fab.getBoundingClientRect();
      fabOX = e.clientX - r.left; fabOY = e.clientY - r.top;
      const mv = (e) => {
        if (!fabDragged && Math.abs(e.clientX - fabSX) + Math.abs(e.clientY - fabSY) < DRAG_TH) return;
        fabDragged = true;
        fab.style.left = Math.max(0, Math.min(innerWidth - 48, e.clientX - fabOX)) + 'px';
        fab.style.top = Math.max(0, Math.min(innerHeight - 48, e.clientY - fabOY)) + 'px';
        fab.style.bottom = 'auto';
      };
      const up = () => {
        document.removeEventListener('pointermove', mv);
        document.removeEventListener('pointerup', up);
        if (!fabDragged) { panel.classList.toggle('open'); if (panel.classList.contains('open')) { posPanel(); refreshStatus(); } }
        else if (panel.classList.contains('open')) posPanel();
      };
      document.addEventListener('pointermove', mv);
      document.addEventListener('pointerup', up);
      e.preventDefault();
    });

    fab.style.right = '20px';
    fab.style.left = 'auto';
    fab.style.top = (innerHeight - 68) + 'px';

    // ── Tabs ──
    panel.querySelectorAll('#mcp-tabs button').forEach(btn => {
      btn.onclick = () => {
        panel.querySelectorAll('#mcp-tabs button').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        const tab = btn.dataset.tab;
        panel.querySelectorAll('.mcp-sec').forEach(s => s.classList.remove('active'));
        panel.querySelector(`#mcp-sec-${tab}`).classList.add('active');
      };
    });

    // ── Shortcut ──
    document.addEventListener('keydown', (e) => {
      if (e.ctrlKey && e.shiftKey && e.key === 'M') {
        e.preventDefault();
        panel.classList.toggle('open');
        if (panel.classList.contains('open')) { posPanel(); refreshStatus(); }
      }
    });

    // ═══════════════════════════════════════════════════════════════
    //  Tab: Status
    // ═══════════════════════════════════════════════════════════════
    const secStatus = panel.querySelector('#mcp-sec-status');

    async function refreshStatus() {
      const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
      secStatus.innerHTML = '<div class="mcp-status">连接中...</div>';
      const client = new MCPClient(mcpUrl);
      const healthy = await client.checkHealth();

      if (!healthy) {
        fab.classList.add('disconnected');
        toolRegistry = [];
        secStatus.innerHTML = `
          <div class="mcp-status"><span class="err">未连接</span> — 服务器未运行</div>
          <div style="font-size:12px;color:#666;margin-top:8px">
            请先启动 MCP 服务器:<br>
            <code style="color:#7aa2f7">cd server && python server.py</code>
          </div>
          <div style="margin-top:12px">
            <button class="mcp-btn pri" id="mcp-retry">重试连接</button>
          </div>
        `;
        secStatus.querySelector('#mcp-retry').onclick = refreshStatus;
        return;
      }

      // Fetch health info for external server status
      let healthInfo = null;
      try {
        const resp = await new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method: 'GET', url: mcpUrl.replace('/mcp', '/health'),
            onload: (r) => resolve(JSON.parse(r.responseText)),
            onerror: (e) => reject(e), timeout: 5000,
          });
        });
        healthInfo = resp;
      } catch { }

      const tools = await client.listTools();
      toolRegistry = tools;
      fab.classList.remove('disconnected');

      // Separate builtin vs external tools
      const extServers = healthInfo?.external_servers || [];
      const extToolNames = new Set();
      extServers.forEach(s => s.tools?.forEach(t => extToolNames.add(t)));

      let toolList = '';
      tools.forEach(t => {
        const desc = t.description || '';
        const req = t.inputSchema?.required;
        const params = req?.length ? ` (${req.join(', ')})` : '';
        const badge = extToolNames.has(t.name)
          ? '<span style="font-size:10px;color:#f0ad4e;margin-left:4px">ext</span>' : '';
        toolList += `<div class="mcp-tool"><span class="name">${esc(t.name)}${esc(params)}${badge}</span><span class="desc">${esc(desc)}</span></div>`;
      });

      // External servers info
      let extInfo = '';
      if (extServers.length > 0) {
        extInfo = '<div style="margin-top:12px;padding-top:10px;border-top:1px solid #2a2a3a">';
        extInfo += '<div style="font-size:12px;color:#888;margin-bottom:6px">外部 MCP 服务器</div>';
        extServers.forEach(s => {
          const icon = s.connected ? '&#10003;' : '&#10007;';
          const color = s.connected ? '#4ade80' : '#f87171';
          extInfo += `<div style="font-size:12px;color:#aaa;margin-bottom:4px"><span style="color:${color}">${icon}</span> <strong>${esc(s.name)}</strong> (${s.transport}) — ${s.tools?.length || 0} tools</div>`;
        });
        extInfo += '</div>';
      }

      const builtinCount = tools.length - extToolNames.size;
      const summary = extServers.length > 0
        ? `${tools.length} 个工具 (${builtinCount} 内置 + ${extToolNames.size} 外部)`
        : `${tools.length} 个工具`;

      secStatus.innerHTML = `
        <div class="mcp-status"><span class="ok">已连接</span> — ${summary}</div>
        ${extInfo}
        <div style="margin-top:8px">${toolList || '<div style="color:#665">无可用工具</div>'}</div>
        <div style="margin-top:12px">
          <button class="mcp-btn pri" id="mcp-refresh">刷新</button>
        </div>
      `;
      secStatus.querySelector('#mcp-refresh').onclick = refreshStatus;
      console.log(`${SCRIPT_PREFIX} ready — ${tools.length} tools (${extToolNames.size} external)`);
    }

    // ═══════════════════════════════════════════════════════════════
    //  Health Check — 自动轮询 + 断线提示 + 自动重连
    // ═══════════════════════════════════════════════════════════════
    let _healthConnected = null; // null = unknown, true/false = last known state

    async function checkConnection() {
      const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
      try {
        const resp = await new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method: 'GET',
            url: mcpUrl.replace('/mcp', '/health'),
            onload: (r) => {
              try { resolve(JSON.parse(r.responseText)); }
              catch { reject(new Error('invalid response')); }
            },
            onerror: () => reject(new Error('network error')),
            timeout: 5000,
          });
        });

        const nowOk = resp.status === 'ok';
        if (_healthConnected === false && nowOk) {
          // 刚恢复连接 — 自动重新加载工具列表
          toast('服务器已恢复连接,正在重新加载...', 'success');
          refreshStatus();
        } else if (_healthConnected === null && nowOk) {
          // 首次检测到已连接
        }
        _healthConnected = nowOk;
        fab.classList.toggle('disconnected', !nowOk);
      } catch {
        if (_healthConnected !== false) {
          _healthConnected = false;
          fab.classList.add('disconnected');
          toolRegistry = [];
          toast('服务器连接断开,请检查 MCP 服务器是否运行中', 'error');
        }
      }
    }

    // 首次检测 + 每 30 秒轮询
    checkConnection();
    setInterval(checkConnection, 30000);

    // ═══════════════════════════════════════════════════════════════
    //  Tab: Test
    // ═══════════════════════════════════════════════════════════════
    const secTest = panel.querySelector('#mcp-sec-test');

    function renderTestTab() {
      if (!toolRegistry.length) {
        secTest.innerHTML = '<div style="color:#665;font-size:13px">请先在"状态"页连接服务器</div>';
        return;
      }

      let opts = '<option value="">选择工具...</option>';
      toolRegistry.forEach(t => { opts += `<option value="${t.name}">${t.name}</option>`; });

      secTest.innerHTML = `
        <div class="mcp-label-row">
          <label class="mcp-label" style="margin:0">工具</label>
        </div>
        <select class="mcp-sel" id="mcp-test-sel">${opts}</select>
        <div id="mcp-test-info" style="margin-top:8px;font-size:12px;color:#666"></div>
        <div id="mcp-test-args" style="margin-top:10px"></div>
        <div style="margin-top:10px">
          <button class="mcp-btn pri" id="mcp-test-run">执行</button>
        </div>
        <div id="mcp-test-result"></div>
      `;

      const sel = secTest.querySelector('#mcp-test-sel');
      const info = secTest.querySelector('#mcp-test-info');
      const argsDiv = secTest.querySelector('#mcp-test-args');
      const resultDiv = secTest.querySelector('#mcp-test-result');

      sel.onchange = () => {
        const tool = toolRegistry.find(t => t.name === sel.value);
        if (!tool) { info.textContent = ''; argsDiv.innerHTML = ''; return; }
        info.textContent = tool.description || '';
        const schema = tool.inputSchema || {};
        const props = schema.properties || {};
        const required = schema.required || [];
        let fields = '';
        for (const [key, prop] of Object.entries(props)) {
          const req = required.includes(key) ? ' *' : '';
          const ph = prop.description || prop.type || '';
          fields += `<div style="margin-bottom:6px">
            <label class="mcp-label">${key}${req}</label>
            <input class="mcp-input" data-arg="${key}" placeholder="${ph}" />
          </div>`;
        }
        if (!fields) fields = '<div style="color:#666;font-size:12px">此工具无需参数</div>';
        argsDiv.innerHTML = fields;
      };

      secTest.querySelector('#mcp-test-run').onclick = async () => {
        const toolName = sel.value;
        if (!toolName) { toast('请选择工具', 'error'); return; }
        const args = {};
        argsDiv.querySelectorAll('.mcp-input').forEach(inp => {
          const key = inp.dataset.arg;
          const val = inp.value.trim();
          if (val) {
            try { args[key] = JSON.parse(val); }
            catch { args[key] = val; }
          }
        });

        resultDiv.innerHTML = '<div class="mcp-result">执行中...</div>';
        const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
        const client = new MCPClient(mcpUrl);
        try {
          const result = await client.callTool(toolName, args);
          const text = result?.content?.[0]?.text || '(no result)';
          const isErr = result?.isError;
          resultDiv.innerHTML = `<div class="mcp-result${isErr ? ' error' : ''}">${esc(text)}</div>`;
        } catch (e) {
          resultDiv.innerHTML = `<div class="mcp-result error">Error: ${esc(e.message)}</div>`;
        }
      };
    }

    // Watch for tab switch to render test tab
    panel.querySelectorAll('#mcp-tabs button').forEach(btn => {
      btn.addEventListener('click', () => {
        if (btn.dataset.tab === 'test') renderTestTab();
        if (btn.dataset.tab === 'ext') renderExtTab();
      });
    });

    // ═══════════════════════════════════════════════════════════════
    //  Tab: External MCP Servers
    // ═══════════════════════════════════════════════════════════════
    const secExt = panel.querySelector('#mcp-sec-ext');

    function getExtBaseUrl() {
      const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
      try { return new URL(mcpUrl).origin; }
      catch { return mcpUrl.replace(/\/[^/]*$/, ''); }
    }

    function getBaseUrl() {
      const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
      // Extract origin from any MCP URL: http://host:port/mcp → http://host:port
      try {
        const u = new URL(mcpUrl);
        return u.origin;
      } catch {
        // Fallback: strip last path segment
        return mcpUrl.replace(/\/[^/]*$/, '');
      }
    }

    function extApiUrl(path) {
      return getBaseUrl() + path;
    }

    async function extApiCall(path, method = 'GET', body) {
      const url = extApiUrl(path);
      return new Promise((resolve, reject) => {
        const opts = {
          method, url, timeout: 15000,
          headers: { 'Content-Type': 'application/json' },
          onload: (r) => {
            try { resolve(JSON.parse(r.responseText)); }
            catch { reject(new Error('Invalid JSON')); }
          },
          onerror: (e) => reject(new Error(e.error || 'Network error')),
          ontimeout: () => reject(new Error('Timeout')),
        };
        if (body) opts.data = JSON.stringify(body);
        GM_xmlhttpRequest(opts);
      });
    }

    let extFormOpen = false;

    let presetParamForm = null; // {presetId, preset} when param form is open

    async function renderExtTab() {
      secExt.innerHTML = '<div style="color:#888;font-size:13px">加载中...</div>';

      // Fetch presets and servers in parallel
      let presets = [], servers = [];
      try {
        const [presetData, serverData] = await Promise.all([
          extApiCall('/api/presets'),
          extApiCall('/api/external-servers'),
        ]);
        presets = presetData.presets || [];
        servers = serverData.servers || [];
      } catch (e) {
        secExt.innerHTML = `<div style="color:#f87171;font-size:13px">连接失败: ${esc(e.message)}</div>`;
        return;
      }

      const installedIds = new Set(servers.map(s => s.name));
      let html = '';

      // ═══ Preset Marketplace ═══
      html += '<div style="font-size:12px;font-weight:600;color:#ccc;margin-bottom:8px">工具预设</div>';

      // Group by category
      const categories = {};
      presets.forEach(p => {
        const cat = p.category || '其他';
        if (!categories[cat]) categories[cat] = [];
        categories[cat].push(p);
      });

      for (const [cat, items] of Object.entries(categories)) {
        html += `<div style="font-size:10px;color:#666;margin:6px 0 3px">${esc(cat)}</div>`;
        html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:6px">';
        items.forEach(p => {
          const installed = installedIds.has(p.id);
          const hasParams = p.params?.length > 0;
          const btnText = installed ? (hasParams ? '重新配置' : '已启用') : (hasParams ? '配置' : '启用');
          const btnClass = 'ext-preset-install';
          const btnStyle = installed
            ? (hasParams
              ? 'background:#222;color:#7aa2f7;border-color:#7aa2f7'
              : 'background:#1a3a2a;color:#4ade80;border-color:#4ade80;pointer-events:none')
            : 'background:#222;color:#7aa2f7;border-color:#7aa2f7';
          html += `
            <div class="ext-preset-card ext-preset-install" data-preset-id="${esc(p.id)}" style="padding:6px 8px;border:1px solid ${installed ? '#2a4a3a' : '#333'};border-radius:6px;background:${installed ? '#1a2a22' : '#1a1a28'}">
              <div style="display:flex;justify-content:space-between;align-items:center">
                <span style="font-size:12px;font-weight:500;color:#ccc">${esc(p.name)}</span>
                <button class="${btnClass} mcp-btn" data-preset-id="${esc(p.id)}" style="${btnStyle};font-size:10px;padding:1px 7px">${btnText}</button>
              </div>
              <div style="font-size:10px;color:#888;margin-top:2px">${esc(p.description)}</div>
            </div>
          `;
        });
        html += '</div>';
      }

      // ═══ Param form (shown when configuring a preset) ═══
      if (presetParamForm) {
        const p = presetParamForm;
        html += `
          <div class="ext-section" id="ext-param-form">
            <div style="font-size:13px;font-weight:600;color:#ccc;margin-bottom:8px">配置: ${esc(p.name)}</div>
        `;
        p.params.forEach(param => {
          const req = param.required ? ' *' : '';
          const inputType = param.secret ? 'password' : 'text';
          html += `
            <div style="margin-bottom:6px">
              <label style="font-size:11px;color:#888;display:block;margin-bottom:2px">${esc(param.label)}${req}</label>
              <input class="mcp-input ext-param-input" data-key="${esc(param.key)}" type="${inputType}"
                     placeholder="${esc(param.placeholder || '')}" style="font-size:12px" />
            </div>
          `;
        });
        html += `
            <div style="margin-top:8px;display:flex;gap:6px">
              <button class="mcp-btn pri" id="ext-param-submit">安装</button>
              <button class="mcp-btn" id="ext-param-cancel">取消</button>
            </div>
          </div>
        `;
      }

      // ═══ Installed Servers ═══
      if (servers.length > 0) {
        html += '<div class="ext-section">';
        html += '<div style="font-size:12px;font-weight:600;color:#ccc;margin-bottom:6px">已安装</div>';
        servers.forEach(s => {
          const dotClass = s.status === 'running' ? 'dot-green' : s.status === 'stopped' ? 'dot-gray' : 'dot-red';
          const statusText = s.status === 'running' ? '运行中' : s.status === 'stopped' ? '已停止' : '异常';
          const statusColor = s.status === 'running' ? '#4ade80' : s.status === 'stopped' ? '#888' : '#f87171';
          const toolsStr = s.tools?.length ? s.tools.join(', ') : '—';

          let actions = '';
          if (s.status === 'running') {
            actions = `<button class="mcp-btn ext-stop" data-name="${esc(s.name)}" style="font-size:11px;padding:3px 8px">停止</button>`;
          } else {
            actions = `<button class="mcp-btn pri ext-start" data-name="${esc(s.name)}" style="font-size:11px;padding:3px 8px">启动</button>`;
          }
          actions += `<button class="mcp-btn ext-remove" data-name="${esc(s.name)}" style="color:#f87171;border-color:#f87171;font-size:11px;padding:3px 8px">删除</button>`;

          html += `
            <div class="ext-card" style="padding:8px 10px;margin-bottom:6px">
              <div class="ext-card-hd">
                <div style="display:flex;align-items:center;gap:8px">
                  <span class="ext-card-name" style="font-size:13px">${esc(s.name)}</span>
                  <span class="ext-card-transport">${s.transport}</span>
                </div>
                <span class="ext-card-status"><span class="dot ${dotClass}"></span><span style="color:${statusColor}">${statusText}</span></span>
              </div>
              <div class="ext-card-tools" style="font-size:10px">工具: ${esc(toolsStr)}</div>
              <div class="ext-card-actions" style="margin-top:6px">${actions}</div>
            </div>
          `;
        });
        html += '</div>';
      }

      // Add form — JSON import
      const defaultJson = JSON.stringify({
        "mcpServers": {
          "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] },
          "fetch": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fetch"] }
        }
      }, null, 2);

      html += `<div style="margin-top:10px"><button class="ext-add-toggle" id="ext-add-btn">+ 导入 JSON 配置</button></div>`;
      html += `<div id="ext-add-form" style="display:${extFormOpen ? 'block' : 'none'};margin-top:6px">`;
      html += `
        <div style="font-size:10px;color:#888;margin-bottom:4px">
          支持粘贴任意格式的 MCP 配置 JSON,可同时导入多个
        </div>
        <textarea id="ext-f-json" style="width:100%;height:120px;padding:6px;border-radius:6px;border:1px solid #444;background:#0d0d18;color:#a0a0c0;font-size:10px;font-family:monospace;resize:vertical;box-sizing:border-box;outline:none;line-height:1.4" spellcheck="false">${esc(defaultJson)}</textarea>
        <div style="margin-top:6px;display:flex;gap:6px">
          <button class="mcp-btn pri" id="ext-add-submit" style="font-size:11px;padding:4px 10px">导入并启动</button>
          <button class="mcp-btn" id="ext-add-cancel" style="font-size:11px;padding:4px 10px">取消</button>
        </div>
      </div>`;

      html += `<div style="margin-top:8px"><button class="mcp-btn" id="ext-refresh" style="font-size:11px">刷新</button></div>`;

      secExt.innerHTML = html;

      // ── Preset install/configure buttons ──
      secExt.querySelectorAll('.ext-preset-install').forEach(btn => {
        btn.onclick = async (e) => {
          e.stopPropagation();
          const presetId = btn.dataset.presetId;
          const preset = presets.find(p => p.id === presetId);
          if (!preset) return;

          if (preset.params?.length > 0) {
            // Show param form (works for both new install and re-configure)
            presetParamForm = preset;
            renderExtTab();
          } else if (!installedIds.has(presetId)) {
            // One-click install (only for no-params presets that aren't installed)
            try {
              const result = await extApiCall(`/api/presets/${presetId}/install`, 'POST', {});
              if (result.ok) {
                toast(`${preset.name} 已安装,${result.tools?.length || 0} 个工具`, 'success');
                renderExtTab();
                refreshStatus();
              } else {
                toast(result.error || '安装失败', 'error');
              }
            } catch (e) { toast(e.message, 'error'); }
          }
        };
      });

      // ── Param form submit ──
      const paramSubmit = secExt.querySelector('#ext-param-submit');
      if (paramSubmit) {
        paramSubmit.onclick = async () => {
          const params = {};
          secExt.querySelectorAll('.ext-param-input').forEach(inp => {
            params[inp.dataset.key] = inp.value.trim();
          });
          try {
            const result = await extApiCall(`/api/presets/${presetParamForm.id}/install`, 'POST', { params });
            if (result.ok) {
              toast(`${presetParamForm.name} 已安装,${result.tools?.length || 0} 个工具`, 'success');
              presetParamForm = null;
              renderExtTab();
              refreshStatus();
            } else {
              toast(result.error || '安装失败', 'error');
            }
          } catch (e) { toast(e.message, 'error'); }
        };
      }
      const paramCancel = secExt.querySelector('#ext-param-cancel');
      if (paramCancel) {
        paramCancel.onclick = () => { presetParamForm = null; renderExtTab(); };
      }

      // ── Existing server management bindings ──

      // Add form toggle
      secExt.querySelector('#ext-add-btn')?.addEventListener('click', () => {
        extFormOpen = !extFormOpen;
        secExt.querySelector('#ext-add-form').style.display = extFormOpen ? 'block' : 'none';
      });

      // Cancel
      secExt.querySelector('#ext-add-cancel')?.addEventListener('click', () => {
        extFormOpen = false;
        secExt.querySelector('#ext-add-form').style.display = 'none';
      });

      // Submit JSON import
      secExt.querySelector('#ext-add-submit')?.addEventListener('click', async () => {
        const raw = secExt.querySelector('#ext-f-json').value.trim();
        if (!raw) { toast('请粘贴 JSON 配置', 'error'); return; }

        let parsed;
        try { parsed = JSON.parse(raw); }
        catch (e) { toast(`JSON 解析失败: ${e.message}`, 'error'); return; }

        // Auto-unwrap common wrappers
        if (parsed.mcpServers && typeof parsed.mcpServers === 'object') parsed = parsed.mcpServers;
        else if (parsed.servers && typeof parsed.servers === 'object') parsed = parsed.servers;

        let entries;
        if (parsed.name && typeof parsed.name === 'string') {
          const { name, ...cfg } = parsed;
          entries = { [name]: cfg };
        } else if (parsed.command || parsed.url) {
          toast('缺少 name 字段', 'error'); return;
        } else {
          entries = parsed;
        }

        try {
          const result = await extApiCall('/api/external-servers', 'POST', { mcpServers: entries });
          let added = 0, errors = [];
          for (const r of (result.results || [])) {
            if (r.ok) added++;
            else errors.push(`${r.name}: ${r.error || '未知错误'}`);
          }
          if (added > 0) {
            toast(`已添加 ${added} 个服务器`, 'success');
            extFormOpen = false;
            renderExtTab();
            refreshStatus();
          }
          errors.forEach(e => toast(e, 'error'));
        } catch (e) { toast(`请求失败: ${e.message || '网络错误'}`, 'error'); }
      });

      // Start/Stop/Remove
      secExt.querySelectorAll('.ext-start').forEach(btn => {
        btn.onclick = async () => {
          const name = btn.dataset.name;
          try {
            const result = await extApiCall(`/api/external-servers/${name}/start`, 'POST');
            toast(result.ok ? `${name} 已启动` : result.error, result.ok ? 'success' : 'error');
            renderExtTab(); refreshStatus();
          } catch (e) { toast(e.message, 'error'); }
        };
      });

      secExt.querySelectorAll('.ext-stop').forEach(btn => {
        btn.onclick = async () => {
          const name = btn.dataset.name;
          try {
            const result = await extApiCall(`/api/external-servers/${name}/stop`, 'POST');
            toast(result.ok ? `${name} 已停止` : result.error, result.ok ? 'success' : 'error');
            renderExtTab(); refreshStatus();
          } catch (e) { toast(e.message, 'error'); }
        };
      });

      secExt.querySelectorAll('.ext-remove').forEach(btn => {
        btn.onclick = async () => {
          const name = btn.dataset.name;
          if (!confirm(`确定删除 ${name}?`)) return;
          try {
            const result = await extApiCall(`/api/external-servers/${name}`, 'DELETE');
            toast(result.ok ? `${name} 已删除` : result.error, result.ok ? 'success' : 'error');
            renderExtTab(); refreshStatus();
          } catch (e) { toast(e.message, 'error'); }
        };
      });

      // Refresh
      secExt.querySelector('#ext-refresh')?.addEventListener('click', renderExtTab);

      // Start/Stop/Remove
      secExt.querySelectorAll('.ext-start').forEach(btn => {
        btn.onclick = async () => {
          const name = btn.dataset.name;
          try {
            const result = await extApiCall(`/api/external-servers/${name}/start`, 'POST');
            toast(result.ok ? `${name} 已启动` : result.error, result.ok ? 'success' : 'error');
            renderExtTab(); refreshStatus();
          } catch (e) { toast(e.message, 'error'); }
        };
      });

      secExt.querySelectorAll('.ext-stop').forEach(btn => {
        btn.onclick = async () => {
          const name = btn.dataset.name;
          try {
            const result = await extApiCall(`/api/external-servers/${name}/stop`, 'POST');
            toast(result.ok ? `${name} 已停止` : result.error, result.ok ? 'success' : 'error');
            renderExtTab(); refreshStatus();
          } catch (e) { toast(e.message, 'error'); }
        };
      });

      secExt.querySelectorAll('.ext-remove').forEach(btn => {
        btn.onclick = async () => {
          const name = btn.dataset.name;
          if (!confirm(`确定删除 ${name}?`)) return;
          try {
            const result = await extApiCall(`/api/external-servers/${name}`, 'DELETE');
            toast(result.ok ? `${name} 已删除` : result.error, result.ok ? 'success' : 'error');
            renderExtTab(); refreshStatus();
          } catch (e) { toast(e.message, 'error'); }
        };
      });

      // Refresh
      secExt.querySelector('#ext-refresh').onclick = renderExtTab;
    }

    // ═══════════════════════════════════════════════════════════════
    //  Tab: Settings
    // ═══════════════════════════════════════════════════════════════
    const secSettings = panel.querySelector('#mcp-sec-settings');
    secSettings.innerHTML = `
      <div>
        <label class="mcp-label">MCP 服务器地址</label>
        <input class="mcp-input" id="mcp-cfg-url" value="${GM_getValue('mcp_url', DEFAULT_MCP_URL)}" />
      </div>
      <div style="margin-top:16px">
        <label class="mcp-label">模块开关</label>
        <div class="mcp-toggle-row">
          <div><div class="mcp-toggle-label">🔧 MCP 工具调用</div><div class="mcp-toggle-desc">拦截 AI 回复并执行本地工具</div></div>
          <label class="mcp-switch"><input type="checkbox" id="mod-toggle-mcp" ${getModuleEnabled('mcp') ? 'checked' : ''} /><span class="slider"></span></label>
        </div>
        <div class="mcp-toggle-row">
          <div><div class="mcp-toggle-label">📨 工具结果自动发送</div><div class="mcp-toggle-desc">工具结果填入输入框后自动提交</div></div>
          <label class="mcp-switch"><input type="checkbox" id="mcp-cfg-autosend" ${getAutoSendEnabled() ? 'checked' : ''} /><span class="slider"></span></label>
        </div>
        <div class="mcp-toggle-row">
          <div><div class="mcp-toggle-label">🔊 TTS 朗读</div><div class="mcp-toggle-desc">AI 回复旁显示朗读按钮</div></div>
          <label class="mcp-switch"><input type="checkbox" id="mod-toggle-tts" ${getModuleEnabled('tts') ? 'checked' : ''} /><span class="slider"></span></label>
        </div>
        <div class="mcp-toggle-row">
          <div><div class="mcp-toggle-label">🔊 自动朗读</div><div class="mcp-toggle-desc">AI 回复完成后自动 TTS 播放</div></div>
          <label class="mcp-switch"><input type="checkbox" id="mod-toggle-ttsAutoPlay" ${getModuleEnabled('ttsAutoPlay') ? 'checked' : ''} /><span class="slider"></span></label>
        </div>
      </div>
      <div style="margin-top:16px">
        <label class="mcp-label">TTS 设置</label>
        <div class="mcp-toggle-row">
          <div><div class="mcp-toggle-label">引擎</div><div class="mcp-toggle-desc">TTS 提供者</div></div>
          <select class="mcp-sel" id="mcp-tts-provider" style="width:auto">
            <option value="edge" ${GM_getValue('tts_provider', 'edge') === 'edge' ? 'selected' : ''}>Edge TTS (免费)</option>
            <option value="openai" ${GM_getValue('tts_provider', 'edge') === 'openai' ? 'selected' : ''}>OpenAI 兼容</option>
          </select>
        </div>
        <div style="margin-top:8px">
          <label class="mcp-label">语音</label>
          <div style="display:flex;gap:6px;margin-bottom:6px">
            <select class="mcp-sel" id="mcp-tts-voice-filter-locale" style="width:auto;font-size:11px">
              <option value="">全部语言</option>
              <option value="zh-">中文</option>
              <option value="en-">英语</option>
              <option value="ja-">日语</option>
              <option value="ko-">韩语</option>
            </select>
            <select class="mcp-sel" id="mcp-tts-voice-filter-gender" style="width:auto;font-size:11px">
              <option value="">全部性别</option>
              <option value="Female">女声</option>
              <option value="Male">男声</option>
            </select>
          </div>
          <select class="mcp-sel" id="mcp-tts-voice" style="width:100%">
            <option value="${GM_getValue('tts_voice', 'zh-CN-XiaoxiaoNeural')}">加载中...</option>
          </select>
          <div id="mcp-tts-voice-status" style="font-size:10px;color:#666;margin-top:4px"></div>
        </div>
        <div id="mcp-tts-adv" style="display:${GM_getValue('tts_provider', 'edge') !== 'edge' ? 'block' : 'none'};margin-top:8px;padding:8px;background:#1a1a28;border-radius:8px;border:1px solid #333">
          <div style="font-size:11px;color:#888;margin-bottom:6px">OpenAI 兼容配置</div>
          <div style="margin-bottom:6px"><label class="mcp-label">API Key</label><input class="mcp-input" id="mcp-tts-apikey" type="password" value="${GM_getValue('tts_api_key', '')}" placeholder="sk-..." style="font-size:12px" /></div>
          <div style="margin-bottom:6px"><label class="mcp-label">Base URL</label><input class="mcp-input" id="mcp-tts-baseurl" value="${GM_getValue('tts_base_url', 'https://api.openai.com/v1')}" style="font-size:12px" /></div>
          <div><label class="mcp-label">模型</label><input class="mcp-input" id="mcp-tts-model" value="${GM_getValue('tts_model', 'tts-1')}" placeholder="tts-1 / tts-1-hd" style="font-size:12px" /></div>
        </div>
      </div>
      <div style="margin-top:8px;font-size:11px;color:#555">适配器: ${currentAdapter ? currentAdapter.name : '无'}</div>
      <div style="margin-top:12px"><button class="mcp-btn pri" id="mcp-cfg-save">保存</button></div>
    `;

    secSettings.querySelector('#mcp-cfg-save').onclick = () => {
      const url = secSettings.querySelector('#mcp-cfg-url').value.trim();
      if (!url) { toast('地址不能为空', 'error'); return; }
      GM_setValue('mcp_url', url);
      ['mcp', 'tts', 'ttsAutoPlay'].forEach(mod => {
        const toggle = secSettings.querySelector('#mod-toggle-' + mod);
        if (toggle) setModuleEnabled(mod, toggle.checked);
      });
      const autoSendToggle = secSettings.querySelector('#mcp-cfg-autosend');
      if (autoSendToggle) GM_setValue(AUTO_SEND_KEY, autoSendToggle.checked);
      const providerSel = secSettings.querySelector('#mcp-tts-provider');
      if (providerSel) GM_setValue('tts_provider', providerSel.value);
      const voiceInput = secSettings.querySelector('#mcp-tts-voice');
      if (voiceInput) GM_setValue('tts_voice', voiceInput.value.trim());
      const apiKeyInput = secSettings.querySelector('#mcp-tts-apikey');
      if (apiKeyInput) GM_setValue('tts_api_key', apiKeyInput.value.trim());
      const baseUrlInput = secSettings.querySelector('#mcp-tts-baseurl');
      if (baseUrlInput) GM_setValue('tts_base_url', baseUrlInput.value.trim());
      const modelInput = secSettings.querySelector('#mcp-tts-model');
      if (modelInput) GM_setValue('tts_model', modelInput.value.trim());
      toast('已保存', 'success');
      refreshStatus();
    };

    secSettings.querySelector('#mcp-tts-provider')?.addEventListener('change', (e) => {
      const adv = secSettings.querySelector('#mcp-tts-adv');
      if (adv) adv.style.display = e.target.value !== 'edge' ? 'block' : 'none';
    });

    // Load Edge TTS voices — with built-in fallback list
    (function loadVoices() {
      const voiceSel = secSettings.querySelector('#mcp-tts-voice');
      const localeFilter = secSettings.querySelector('#mcp-tts-voice-filter-locale');
      const genderFilter = secSettings.querySelector('#mcp-tts-voice-filter-gender');
      const status = secSettings.querySelector('#mcp-tts-voice-status');
      if (!voiceSel) return;

      // Built-in Chinese voices (works without server)
      const BUILTIN_VOICES = [
        { id: 'zh-CN-XiaoxiaoNeural', gender: 'Female', locale: 'zh-CN' },
        { id: 'zh-CN-XiaoyiNeural', gender: 'Female', locale: 'zh-CN' },
        { id: 'zh-CN-YunjianNeural', gender: 'Male', locale: 'zh-CN' },
        { id: 'zh-CN-YunxiNeural', gender: 'Male', locale: 'zh-CN' },
        { id: 'zh-CN-YunxiaNeural', gender: 'Male', locale: 'zh-CN' },
        { id: 'zh-CN-YunyangNeural', gender: 'Male', locale: 'zh-CN' },
        { id: 'zh-CN-liaoning-XiaobeiNeural', gender: 'Female', locale: 'zh-CN-liaoning' },
        { id: 'zh-CN-shaanxi-XiaoniNeural', gender: 'Female', locale: 'zh-CN-shaanxi' },
        { id: 'zh-HK-HiuGaaiNeural', gender: 'Female', locale: 'zh-HK' },
        { id: 'zh-HK-HiuMaanNeural', gender: 'Female', locale: 'zh-HK' },
        { id: 'zh-HK-WanLungNeural', gender: 'Male', locale: 'zh-HK' },
        { id: 'zh-TW-HsiaoChenNeural', gender: 'Female', locale: 'zh-TW' },
        { id: 'zh-TW-YunJheNeural', gender: 'Male', locale: 'zh-TW' },
        { id: 'zh-TW-HsiaoYuNeural', gender: 'Female', locale: 'zh-TW' },
        { id: 'en-US-JennyNeural', gender: 'Female', locale: 'en-US' },
        { id: 'en-US-GuyNeural', gender: 'Male', locale: 'en-US' },
        { id: 'en-US-AriaNeural', gender: 'Female', locale: 'en-US' },
        { id: 'ja-JP-NanamiNeural', gender: 'Female', locale: 'ja-JP' },
        { id: 'ja-JP-KeitaNeural', gender: 'Male', locale: 'ja-JP' },
        { id: 'ko-KR-SunHiNeural', gender: 'Female', locale: 'ko-KR' },
        { id: 'ko-KR-InJoonNeural', gender: 'Male', locale: 'ko-KR' },
      ];

      let allVoices = BUILTIN_VOICES;
      const currentVoice = GM_getValue('tts_voice', 'zh-CN-XiaoxiaoNeural');

      function renderVoices() {
        const locale = localeFilter?.value || '';
        const gender = genderFilter?.value || '';
        const filtered = allVoices.filter(v =>
          (!locale || v.id.includes(locale) || v.id.startsWith(locale)) &&
          (!gender || v.gender === gender)
        );
        voiceSel.innerHTML = '';
        if (!filtered.length) {
          voiceSel.innerHTML = '<option value="">无匹配语音</option>';
          return;
        }
        for (const v of filtered) {
          const opt = document.createElement('option');
          opt.value = v.id;
          const g = v.gender === 'Female' ? '♀' : '♂';
          opt.textContent = g + ' ' + v.id;
          if (v.id === currentVoice) opt.selected = true;
          voiceSel.appendChild(opt);
        }
        status.textContent = filtered.length + ' / ' + allVoices.length + ' 个语音' + (allVoices.length > BUILTIN_VOICES.length ? '' : ' (内置列表)');
      }

      // Render builtin list immediately
      renderVoices();

      // Try loading full list from server in background
      const url = getExtBaseUrl() + '/api/tts/voices';
      GM_xmlhttpRequest({
        method: 'GET', url, timeout: 10000,
        onload: (resp) => {
          try {
            const data = JSON.parse(resp.responseText);
            if (data.voices?.length) {
              allVoices = data.voices;
              renderVoices();
            }
          } catch { }
        },
        onerror: () => { }, ontimeout: () => { },
      });

      localeFilter?.addEventListener('change', renderVoices);
      genderFilter?.addEventListener('change', renderVoices);
    })();


    // ═══════════════════════════════════════════════════════════════
    //  MCP Code Block Actions + System Instruction Folding
    // ═══════════════════════════════════════════════════════════════
    function parseToolArgs(rawArgs) {
      const text = (rawArgs || '').trim();
      if (!text) return {};
      try { return JSON.parse(text); }
      catch { return { input: text }; }
    }

    function getMCPCodeInfo(block) {
      const banner = block.querySelector?.('.md-code-block-banner, [class*="code-block-banner"], [class*="code-header"]');
      const blockText = block.textContent || '';
      const langText = banner?.textContent || '';
      let match = langText.match(/(?:^|\s)mcp:([\w.-]+)(?:\s|$)/);
      if (!match) match = blockText.match(/```mcp:([\w.-]+)\s*([\s\S]*?)```/);
      if (!match) match = blockText.match(/^\s*mcp:([\w.-]+)(?:\s|$)/);
      if (!match) return null;

      const toolName = match[1];
      let rawArgs = '';
      if (match[2]) rawArgs = match[2].trim();
      else {
        const pre = block.matches?.('pre') ? block : block.querySelector?.('pre') || block.querySelector?.('code');
        rawArgs = (pre?.innerText || pre?.textContent || '').trim();
      }
      rawArgs = rawArgs.replace(new RegExp(`^mcp:${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'i'), '').trim();
      return { toolName, rawArgs };
    }

    function getCodeActionContainer(block) {
      const banner = block.querySelector?.('.md-code-block-banner, [class*="code-block-banner"], [class*="code-header"]');
      if (banner) {
        return banner.querySelector('[class*="action"], [class*="copy"], [class*="toolbar"], .efa13877') || banner;
      }
      const pre = block.matches?.('pre') ? block : block.querySelector?.('pre');
      if (!pre) return null;
      const bar = document.createElement('div');
      bar.style.cssText = 'display:flex;justify-content:flex-end;gap:4px;margin:4px 0;';
      pre.parentNode.insertBefore(bar, pre);
      return bar;
    }

    function enhanceMCPCodeBlocks() {
      const candidates = new Set();
      document.querySelectorAll('.md-code-block, [class*="code-block"], pre').forEach(el => {
        if (el.closest('#mcp-panel')) return;
        candidates.add(el.closest('.md-code-block, [class*="code-block"]') || el);
      });

      candidates.forEach(block => {
        if (!block || block.dataset?.mcpEnhanced === '1') return;
        const info = getMCPCodeInfo(block);
        if (!info) return;
        const actionContainer = getCodeActionContainer(block);
        if (!actionContainer) return;

        block.dataset.mcpEnhanced = '1';
        block.classList.add('mcp-code-hidden');

        const collapseBtn = document.createElement('button');
        collapseBtn.type = 'button';
        collapseBtn.className = 'mcp-code-action collapse';
        collapseBtn.textContent = '展开';
        collapseBtn.title = '展开或折叠 MCP 指令';
        collapseBtn.onclick = (e) => {
          e.preventDefault();
          e.stopPropagation();
          const hidden = block.classList.toggle('mcp-code-hidden');
          collapseBtn.textContent = hidden ? '展开' : '折叠';
        };

        const resendBtn = document.createElement('button');
        resendBtn.type = 'button';
        resendBtn.className = 'mcp-code-action resend';
        resendBtn.textContent = '重发';
        resendBtn.title = `重新执行 ${info.toolName}`;
        resendBtn.onclick = (e) => {
          e.preventDefault();
          e.stopPropagation();
          const latest = getMCPCodeInfo(block) || info;
          executeToolCall(latest.toolName, parseToolArgs(latest.rawArgs));
          toast(`已重发: ${latest.toolName}`, 'info');
        };

        actionContainer.appendChild(collapseBtn);
        actionContainer.appendChild(resendBtn);
      });
    }

    function findTightSystemInstructionElement(root) {
      let node = root;
      while (node?.children?.length) {
        const children = Array.from(node.children).filter(child => {
          if (child.closest?.('.mcp-sys-fold')) return false;
          const text = child.textContent || '';
          return text.includes(SYSTEM_HINT_START) && text.includes(SYSTEM_HINT_END);
        });
        if (children.length !== 1) break;
        node = children[0];
      }
      return node;
    }

    function foldSystemInstructionElement(target) {
      if (!target || target.dataset?.mcpSystemFolded === '1') return;
      if (target.querySelector?.('.mcp-sys-fold')) return;
      const text = target.textContent || '';
      const start = text.indexOf(SYSTEM_HINT_START);
      const endStart = text.indexOf(SYSTEM_HINT_END, start);
      if (start < 0 || endStart < 0) return;

      const end = endStart + SYSTEM_HINT_END.length;
      const before = text.slice(0, start).trim();
      const hidden = text.slice(start, end).trim();
      const after = text.slice(end).trim();
      if (hidden.length < 80) return;

      const frag = document.createDocumentFragment();
      const addTextBlock = (content) => {
        if (!content) return;
        const el = document.createElement('div');
        el.style.whiteSpace = 'pre-wrap';
        el.textContent = content;
        frag.appendChild(el);
      };

      addTextBlock(before);

      const fold = document.createElement('div');
      fold.className = 'mcp-sys-fold';
      fold.innerHTML = `
        <div class="mcp-sys-fold-hd"><span class="mcp-sys-fold-title">${SYSTEM_HINT_START}</span><span class="mcp-sys-fold-toggle">展开</span></div>
        <div class="mcp-sys-fold-body"></div>
      `;
      fold.querySelector('.mcp-sys-fold-body').textContent = hidden;
      fold.querySelector('.mcp-sys-fold-hd').onclick = () => {
        const open = fold.classList.toggle('open');
        fold.querySelector('.mcp-sys-fold-toggle').textContent = open ? '折叠' : '展开';
      };
      frag.appendChild(fold);

      addTextBlock(after);
      target.dataset.mcpSystemFolded = '1';
      target.replaceChildren(frag);
    }

    function collapseSystemInstructions() {
      const roots = document.querySelectorAll('.ds-markdown, .ds-markdown--block, [class*="markdown"], [data-message-author-role="user"]');
      roots.forEach(root => {
        if (root.closest('#mcp-panel, .mcp-sys-fold')) return;
        if (root.dataset?.mcpSystemFolded === '1' || root.querySelector?.('.mcp-sys-fold')) return;
        const text = root.textContent || '';
        if (!text.includes(SYSTEM_HINT_START) || !text.includes(SYSTEM_HINT_END)) return;
        const target = findTightSystemInstructionElement(root);
        if (!target || target === document.body || target === document.documentElement) return;
        foldSystemInstructionElement(target);
      });
    }

    function enhanceChatUI() {
      enhanceMCPCodeBlocks();
      collapseSystemInstructions();
      injectTTSButtons();
    }


    // ═══════════════════════════════════════════════════════════════
    //  TTS Button Injection — Add 🔊 to assistant messages
    // ═══════════════════════════════════════════════════════════════
    const _ttsSeenMessages = new WeakSet();
    const _ttsAutoPlayContainers = new Set();
    const _ttsAutoPlayTimers = new Map();

    /** Find the action bar (copy/regenerate buttons area) within a message container */
    function _findActionBar(container) {
      // Try common class patterns first
      const byClass = container.querySelector('[class*="action"], [class*="toolbar"], [class*="buttons"], [class*="footer"]');
      if (byClass) return byClass;
      // Fallback: find any element with direct button children (likely the action bar)
      const allEls = Array.from(container.querySelectorAll('*'));
      for (let i = allEls.length - 1; i >= 0; i--) {
        const el = allEls[i];
        if (el.querySelectorAll(':scope > button').length >= 1) return el;
      }
      return null;
    }

    function injectTTSButtons() {
      if (!getModuleEnabled('tts')) return;
      const selectors = currentAdapter?.selectors?.assistantMessages || '.ds-markdown--block, [class*="markdown"]';
      const messages = document.querySelectorAll(selectors);
      messages.forEach(msg => {
        const text = msg.textContent?.trim();
        if (!text || text.length < 10) return;
        const isNew = !_ttsSeenMessages.has(msg);
        _ttsSeenMessages.add(msg);
        const container = msg.closest('[class*="message"]') || msg.parentElement || msg;

        // Check for existing button on the container (not just msg, since we insert into action bar)
        if (container.querySelector('.mcp-tts-btn')) return;

        const btn = document.createElement('button');
        btn.className = 'mcp-tts-btn';
        btn.innerHTML = '🔊 朗读';
        btn.title = '朗读';
        btn.onclick = (e) => {
          e.stopPropagation();
          const freshText = msg.textContent?.trim() || text;
          if (ttsClient.playing && ttsClient.currentText === freshText) {
            ttsClient.stop();
            btn.innerHTML = '🔊 朗读';
            btn.classList.remove('playing', 'paused');
            return;
          }
          btn.innerHTML = '⏳ 合成中...';
          btn.style.minWidth = (btn.offsetWidth || 56) + 'px';
          ttsClient.play(freshText).then(() => {
            btn.innerHTML = '⏸ 暂停';
            btn.classList.add('playing');
            const check = setInterval(() => {
              if (!ttsClient.playing) {
                btn.innerHTML = '🔊 朗读';
                btn.classList.remove('playing', 'paused');
                btn.style.minWidth = '';
                clearInterval(check);
              }
            }, 500);
          }).catch(err => {
            btn.innerHTML = '🔊 朗读';
            btn.style.minWidth = '';
            toast('TTS 失败: ' + err.message, 'error');
          });
        };

        // Insert into action bar at the bottom of the message
        const actionBar = _findActionBar(container);
        if (actionBar) {
          actionBar.appendChild(btn);
        } else {
          container.appendChild(btn);
        }

        // Auto-play: track by container, wait for text to stabilize
        if (isNew && getModuleEnabled('ttsAutoPlay') && !_ttsAutoPlayContainers.has(container)) {
          _ttsAutoPlayContainers.add(container);
          _watchTextStable(container, selectors);
        }
      });
    }

    // Watch a container until its text stops changing, then play TTS
    function _watchTextStable(container, selectors) {
      const selectorStr = selectors.split(',').map(s => s.trim()).join(',');
      let lastLen = 0;
      let stableCount = 0;
      const check = setInterval(() => {
        const md = container.querySelector(selectorStr);
        if (!md) return;
        const len = md.textContent.length;
        if (len > 0 && len === lastLen) {
          stableCount++;
        } else {
          stableCount = 0;
          lastLen = len;
        }
        if (stableCount >= 2 && lastLen > 10) {
          clearInterval(check);
          _ttsAutoPlayTimers.delete(container);
          ttsClient.play(md.textContent.trim()).catch(err => console.warn('Auto TTS:', err.message));
        }
      }, 1000);
      setTimeout(() => clearInterval(check), 60000);
      _ttsAutoPlayTimers.set(container, check);
    }

    // ═══════════════════════════════════════════════════════════════
    //  MutationObserver — TTS button injection
    // ═══════════════════════════════════════════════════════════════
    let _uiDebounce = null;
    let _isPageVisible = true;

    const uiObserver = new MutationObserver((mutations) => {
      let hasNewNodes = false;
      for (const m of mutations) {
        if (m.addedNodes.length > 0) { hasNewNodes = true; break; }
      }
      if (!hasNewNodes) return;
      if (_uiDebounce) clearTimeout(_uiDebounce);
      _uiDebounce = setTimeout(() => {
        if (!_isPageVisible) return;
        const _run = () => enhanceChatUI();
        if ('requestIdleCallback' in window) {
          requestIdleCallback(_run, { timeout: 2000 });
        } else {
          setTimeout(_run, 200);
        }
      }, 1000);
    });

    function _reconnectObserver() {
      const chatContainer = document.querySelector('[class*="chat-message-list"]')
        || document.querySelector('[class*="message-list"]')
        || document.querySelector('main')
        || document.body;
      uiObserver.observe(chatContainer, { childList: true, subtree: true });
    }

    setTimeout(() => {
      _reconnectObserver();
      enhanceChatUI();
    }, 2000);

    // ── Visibility change: pause/resume to prevent deferred-callback pile-up ──
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        _isPageVisible = false;
        uiObserver.disconnect();
        if (_uiDebounce) { clearTimeout(_uiDebounce); _uiDebounce = null; }
        // Clear all auto-play timers so they don't pile up while hidden
        for (const [container, timer] of _ttsAutoPlayTimers) {
          clearInterval(timer);
        }
        _ttsAutoPlayTimers.clear();
        // Stop any active TTS playback
        if (ttsClient.playing) ttsClient.stop();
      } else {
        _isPageVisible = true;
        _reconnectObserver();
        enhanceChatUI();
      }
    });

    // ── Auto-connect on load ──
    refreshStatus();

    function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
  });
})();