Greasy Fork

Greasy Fork is available in English.

饺子 AI 网页摘要 + 连续对话

指定网站自动弹出 AI 网页摘要,支持全局配置、网址规则管理、提示词模板、停止/重试、正文预览、模型拉取。

当前为 2026-04-27 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         饺子 AI 网页摘要 + 连续对话
// @namespace    https://space.bilibili.com/38389107
// @version      2.0.0
// @description  指定网站自动弹出 AI 网页摘要,支持全局配置、网址规则管理、提示词模板、停止/重试、正文预览、模型拉取。
// @author       次元饺子
// @icon         https://img.icons8.com/?size=100&id=90385&format=png&color=000000
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @connect      *
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  /******************************************************************
   * 固定配置 Key
   ******************************************************************/

  const STORAGE_KEY = 'tabbit_ai_summary_config';

  const LEGACY_STORAGE_KEYS = [
    'tabbit_ai_summary_config_v24',
    'tabbit_ai_summary_config_v23',
    'tabbit_ai_summary_config_v22',
    'tabbit_ai_summary_config_v21',
    'tabbit_ai_summary_config_v2'
  ];

  const DEFAULT_PROMPT_TEXT =
    '我是一个有轻微理解障碍的人,没有耐心,不想动脑子。请你用很短、很直白的话解释这个网页到底在说什么。' +
    '\n\n请输出:' +
    '\n1. 这个网页一句话总结' +
    '\n2. 关键点' +
    '\n3. 对我有什么用' +
    '\n4. 原始链接';

  const DEFAULT_CONFIG = {
    apiUrl: 'https://api.xiaomimimo.com/v1/chat/completions',
    apiKey: '',

    currentModel: 'mimo-v2-flash',
    temperature: 0.7,
    maxTokens: 2000,

    models: [
      {
        name: 'mimo-v2-flash',
        value: 'mimo-v2-flash',
        temperature: '',
        maxTokens: ''
      }
    ],

    promptTemplates: [
      {
        id: 'default',
        name: '默认总结',
        text: DEFAULT_PROMPT_TEXT
      },
      {
        id: 'plain',
        name: '大白话解释',
        text:
          '请用非常简单、直白、短句的方式解释这个网页。不要绕弯子,不要讲废话。' +
          '\n\n请输出:' +
          '\n1. 一句话说明它在说什么' +
          '\n2. 三个最重要的点' +
          '\n3. 普通人应该怎么理解'
      },
      {
        id: 'forum',
        name: '论坛讨论总结',
        text:
          '请总结这个帖子或讨论页面。重点提炼楼主观点、主要争议、支持方观点、反对方观点,以及最后值得关注的结论。'
      },
      {
        id: 'investment',
        name: '投资视角',
        text:
          '请从投资和商业角度总结这个网页。重点关注公司、行业、数据、增长、风险、市场预期,以及对普通投资者有什么参考价值。'
      }
    ],

    defaultPromptTemplateId: 'default',

    urlRules: [
      'https://mp.weixin.qq.com/*',
      'https://nga.178.com/read.php*',
      'https://www.jisilu.cn/*',
      'https://www.gelonghui.com/*',
      'https://bbs.nga.cn/read.php*',
      'https://www.youxituoluo.com/*',
      'https://www.vrtuoluo.cn/*',
      'https://sspai.com/post/*',
      'https://www.ifanr.com/*',
      'http://www.gamelook.com.cn/*'
    ],

    rulePromptBindings: [],

    autoRun: true,

    floatButton: {
      side: 'right',
      y: null,
      opacity: 0.55
    },

    panel: {
      width: 460,
      heightRatio: 0.82
    },

    extractMaxChars: 16000
  };

  let config = loadConfig();

  let panelEl = null;
  let floatBtnEl = null;
  let settingsEl = null;
  let addRuleModalEl = null;
  let previewModalEl = null;

  let chatMessages = [];
  let summaryStarted = false;
  let currentPageUrl = location.href;
  let lastUrl = location.href;

  let isRequesting = false;
  let currentRequest = null;
  let currentReject = null;
  let lastRequestPayload = null;
  let lastExtractedText = '';

  init();

  /******************************************************************
   * 初始化
   ******************************************************************/

  function init() {
    createStyles();
    createFloatButton();
    registerMenus();

    if (config.autoRun && isUrlMatched(location.href, config.urlRules)) {
      openPanel(true);
    }

    watchUrlChange();
  }

  function registerMenus() {
    if (typeof GM_registerMenuCommand !== 'function') return;

    GM_registerMenuCommand('饺子 AI:打开面板', () => openPanel(false));
    GM_registerMenuCommand('饺子 AI:设置', openSettings);
    GM_registerMenuCommand('饺子 AI:加入当前网址', () => openAddUrlRuleModal());
    GM_registerMenuCommand('饺子 AI:导出配置文件', exportConfigToFile);
    GM_registerMenuCommand('饺子 AI:导入配置文件', importConfigFromFile);
    GM_registerMenuCommand('饺子 AI:重置配置', resetConfig);
  }

  function watchUrlChange() {
    setInterval(() => {
      if (location.href === lastUrl) return;

      lastUrl = location.href;
      currentPageUrl = location.href;
      summaryStarted = false;
      chatMessages = [];
      lastRequestPayload = null;
      lastExtractedText = '';

      if (panelEl) {
        const list = panelEl.querySelector('#tabbit-chat-list');
        if (list) list.innerHTML = '';
        setStatus('');
      }

      if (config.autoRun && isUrlMatched(location.href, config.urlRules)) {
        openPanel(true);
      }
    }, 1000);
  }

  /******************************************************************
   * 配置读写:只使用 GM_getValue / GM_setValue
   ******************************************************************/

  function loadConfig() {
    try {
      if (typeof GM_getValue !== 'function') {
        console.warn('[饺子 AI] 当前环境不支持 GM_getValue。');
        return clone(DEFAULT_CONFIG);
      }

      let raw = GM_getValue(STORAGE_KEY, '');

      if (!raw) {
        for (const key of LEGACY_STORAGE_KEYS) {
          const legacyRaw = GM_getValue(key, '');
          if (legacyRaw) {
            raw = legacyRaw;
            GM_setValue(STORAGE_KEY, legacyRaw);
            console.log('[饺子 AI] 已迁移旧配置:', key);
            break;
          }
        }
      }

      if (!raw) return clone(DEFAULT_CONFIG);

      const saved = JSON.parse(raw);
      return mergeConfig(clone(DEFAULT_CONFIG), saved);
    } catch (err) {
      console.warn('[饺子 AI] 配置读取失败:', err);
      return clone(DEFAULT_CONFIG);
    }
  }

  function saveConfig() {
    try {
      if (typeof GM_setValue !== 'function') {
        alert('当前环境不支持 GM_setValue,配置无法保存。');
        return;
      }

      config.urlRules = normalizeUrlRules(config.urlRules);
      config.rulePromptBindings = normalizeRulePromptBindings(config.rulePromptBindings);
      config.promptTemplates = normalizePromptTemplates(config.promptTemplates);
      config.models = normalizeModels(config.models);

      GM_setValue(STORAGE_KEY, JSON.stringify(config));
    } catch (err) {
      console.warn('[饺子 AI] 配置保存失败:', err);
      alert('配置保存失败:' + err.message);
    }
  }

  function resetConfig() {
    if (!confirm('确定要重置 饺子 AI 的所有配置吗?')) return;

    try {
      if (typeof GM_deleteValue === 'function') {
        GM_deleteValue(STORAGE_KEY);
      }

      config = clone(DEFAULT_CONFIG);
      saveConfig();

      alert('配置已重置。');
      location.reload();
    } catch (err) {
      alert('重置失败:' + err.message);
    }
  }

  function mergeConfig(base, saved) {
    const result = {
      ...base,
      ...saved
    };

    if (!Array.isArray(result.models)) result.models = base.models;
    if (!Array.isArray(result.urlRules)) result.urlRules = base.urlRules;
    if (!Array.isArray(result.promptTemplates)) result.promptTemplates = base.promptTemplates;
    if (!Array.isArray(result.rulePromptBindings)) result.rulePromptBindings = [];

    // 兼容旧 promptText。
    if (saved.promptText && !saved.promptTemplates) {
      result.promptTemplates = [
        {
          id: 'default',
          name: '默认总结',
          text: saved.promptText
        },
        ...base.promptTemplates.filter(t => t.id !== 'default')
      ];
    }

    result.models = normalizeModels(result.models);
    result.urlRules = normalizeUrlRules(result.urlRules);
    result.promptTemplates = normalizePromptTemplates(result.promptTemplates);
    result.rulePromptBindings = normalizeRulePromptBindings(result.rulePromptBindings);

    result.floatButton = {
      ...base.floatButton,
      ...(saved.floatButton || {})
    };

    result.panel = {
      ...base.panel,
      ...(saved.panel || {})
    };

    if (!result.defaultPromptTemplateId) {
      result.defaultPromptTemplateId = result.promptTemplates[0]?.id || 'default';
    }

    if (!result.promptTemplates.some(t => t.id === result.defaultPromptTemplateId)) {
      result.defaultPromptTemplateId = result.promptTemplates[0]?.id || 'default';
    }

    if (!result.currentModel && result.models.length) {
      result.currentModel = result.models[0].value;
    }

    if (!result.models.some(m => m.value === result.currentModel)) {
      result.currentModel = result.models[0]?.value || '';
    }

    result.extractMaxChars = Number(result.extractMaxChars || 16000);

    return result;
  }

  function clone(obj) {
    return JSON.parse(JSON.stringify(obj));
  }

  /******************************************************************
   * URL 规则
   ******************************************************************/

  function normalizeUrlRules(rules) {
    if (!Array.isArray(rules)) return [];

    const result = [];

    rules
      .map(rule => String(rule || '').trim())
      .filter(Boolean)
      .forEach(rule => {
        if (!result.includes(rule)) result.push(rule);
      });

    return result;
  }

  function normalizeRulePromptBindings(bindings) {
    if (!Array.isArray(bindings)) return [];

    const result = [];

    bindings.forEach(item => {
      const rule = String(item?.rule || '').trim();
      const templateId = String(item?.templateId || '').trim();

      if (!rule || !templateId) return;

      const old = result.find(x => x.rule === rule);
      if (old) {
        old.templateId = templateId;
      } else {
        result.push({ rule, templateId });
      }
    });

    return result;
  }

  function isUrlMatched(url, rules) {
    return findMatchedUrlRules(url, rules).length > 0;
  }

  function findMatchedUrlRules(url, rules = config.urlRules) {
    return normalizeUrlRules(rules).filter(rule => testUrlRule(url, rule));
  }

  function testUrlRule(url, rule) {
    rule = String(rule || '').trim();
    if (!rule) return false;

    if (rule.includes('*')) {
      const escaped = rule
        .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
        .replace(/\*/g, '.*');

      return new RegExp('^' + escaped).test(url);
    }

    return url.startsWith(rule);
  }

  function getMatchedRuleForCurrentPage() {
    return findMatchedUrlRules(location.href)[0] || '';
  }

  function buildUrlRuleCandidates(rawUrl) {
    let url;

    try {
      url = new URL(rawUrl);
    } catch (err) {
      return [rawUrl.split('#')[0].split('?')[0]];
    }

    const origin = url.origin;
    const fullNoHash = rawUrl.split('#')[0];
    const path = url.pathname || '/';
    const pathNoSlash = path.replace(/\/+$/, '') || '/';
    const segments = pathNoSlash.split('/').filter(Boolean);
    const last = segments[segments.length - 1] || '';

    const candidates = [];

    const recommended = buildSmartUrlRule(rawUrl);
    pushUnique(candidates, recommended);

    pushUnique(candidates, fullNoHash);

    if (path !== '/') {
      pushUnique(candidates, origin + pathNoSlash + '*');
    }

    if (segments.length > 1) {
      pushUnique(candidates, origin + '/' + segments.slice(0, -1).join('/') + '/*');
    }

    pushUnique(candidates, origin + '/*');
    pushUnique(candidates, origin + '/');

    if (/\.(php|asp|aspx|jsp|html|htm)$/i.test(last)) {
      pushUnique(candidates, origin + pathNoSlash + '*');
    }

    return candidates;
  }

  function buildSmartUrlRule(rawUrl) {
    let url;

    try {
      url = new URL(rawUrl);
    } catch (err) {
      return rawUrl.split('#')[0].split('?')[0];
    }

    const origin = url.origin;
    let pathname = url.pathname || '/';

    pathname = pathname.replace(/\/{2,}/g, '/');

    if (!pathname || pathname === '/') {
      return origin + '/';
    }

    const pathWithoutTrailingSlash = pathname.replace(/\/+$/, '');
    const segments = pathWithoutTrailingSlash.split('/').filter(Boolean);
    const last = segments[segments.length - 1] || '';

    const hasQuery = !!url.search;
    const isFileLike = /\.[a-z0-9]{2,8}$/i.test(last);
    const isPhpLike = /\.(php|asp|aspx|jsp|html|htm)$/i.test(last);

    const isNumericId = /^\d+$/.test(last);
    const isHexId = /^[a-f0-9]{8,}$/i.test(last);
    const isSlugWithId = /(^|[-_])\d{3,}($|[-_])/i.test(last);
    const isLongToken = last.length >= 24 && /^[a-z0-9_-]+$/i.test(last);
    const isIdLike = isNumericId || isHexId || isSlugWithId || isLongToken;

    if (isPhpLike || isFileLike) {
      return origin + pathWithoutTrailingSlash + '*';
    }

    if (isIdLike) {
      if (segments.length <= 1) return origin + '/*';
      return origin + '/' + segments.slice(0, -1).join('/') + '/*';
    }

    if (hasQuery) {
      return origin + pathWithoutTrailingSlash + '*';
    }

    if (pathname.endsWith('/')) {
      return origin + pathname;
    }

    return origin + pathWithoutTrailingSlash + '*';
  }

  function pushUnique(arr, value) {
    value = String(value || '').trim();
    if (value && !arr.includes(value)) arr.push(value);
  }

  function addUrlRule(rule, templateId = '') {
    rule = String(rule || '').trim();
    if (!rule) return false;

    config.urlRules = normalizeUrlRules([...config.urlRules, rule]);

    if (templateId) {
      setRuleTemplateBinding(rule, templateId);
    }

    saveConfig();

    if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) {
      renderSettingsUrlRules();
    }

    setStatus('已加入网址规则', 'ok', 1800);
    return true;
  }

  function setRuleTemplateBinding(rule, templateId) {
    rule = String(rule || '').trim();
    templateId = String(templateId || '').trim();

    config.rulePromptBindings = normalizeRulePromptBindings(config.rulePromptBindings);

    config.rulePromptBindings = config.rulePromptBindings.filter(x => x.rule !== rule);

    if (templateId) {
      config.rulePromptBindings.push({
        rule,
        templateId
      });
    }
  }

  function getTemplateIdForRule(rule) {
    const binding = config.rulePromptBindings.find(x => x.rule === rule);
    return binding?.templateId || '';
  }

  /******************************************************************
   * 提示词模板
   ******************************************************************/

  function normalizePromptTemplates(templates) {
    if (!Array.isArray(templates)) templates = [];

    const result = [];

    templates.forEach(item => {
      const id = String(item?.id || '').trim() || makeId('tpl');
      const name = String(item?.name || '').trim() || '未命名模板';
      const text = String(item?.text || '').trim();

      if (!text) return;

      if (!result.some(t => t.id === id)) {
        result.push({ id, name, text });
      }
    });

    if (!result.length) {
      result.push({
        id: 'default',
        name: '默认总结',
        text: DEFAULT_PROMPT_TEXT
      });
    }

    return result;
  }

  function getPromptTemplateById(id) {
    return config.promptTemplates.find(t => t.id === id) || config.promptTemplates[0];
  }

  function getPromptForCurrentPage() {
    const matchedRule = getMatchedRuleForCurrentPage();
    const boundTemplateId = matchedRule ? getTemplateIdForRule(matchedRule) : '';
    const templateId = boundTemplateId || config.defaultPromptTemplateId;
    return getPromptTemplateById(templateId)?.text || DEFAULT_PROMPT_TEXT;
  }

  function makeId(prefix) {
    return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
  }

  /******************************************************************
   * 模型
   ******************************************************************/

  function normalizeModels(models) {
    if (!Array.isArray(models)) models = [];

    const result = [];

    models.forEach(model => {
      const value = String(model?.value || '').trim();
      if (!value) return;

      const item = {
        name: String(model?.name || value).trim(),
        value,
        temperature: model?.temperature ?? '',
        maxTokens: model?.maxTokens ?? ''
      };

      if (!result.some(m => m.value === value)) {
        result.push(item);
      }
    });

    if (!result.length) {
      result.push({
        name: 'mimo-v2-flash',
        value: 'mimo-v2-flash',
        temperature: '',
        maxTokens: ''
      });
    }

    return result;
  }

  function getCurrentModelConfig() {
    return config.models.find(m => m.value === config.currentModel) || config.models[0] || {};
  }

  function getCurrentModelDisplayName() {
    const model = getCurrentModelConfig();
    return model?.name || model?.value || config.currentModel || '未知模型';
  }

  function getCurrentTemperature() {
    const model = getCurrentModelConfig();
    const value = model?.temperature !== '' && model?.temperature !== undefined
      ? model.temperature
      : config.temperature;

    return Number(value || 0.7);
  }

  function getCurrentMaxTokens() {
    const model = getCurrentModelConfig();
    const value = model?.maxTokens !== '' && model?.maxTokens !== undefined
      ? model.maxTokens
      : config.maxTokens;

    return Number(value || 2000);
  }

  /******************************************************************
   * 悬浮按钮
   ******************************************************************/

  function createFloatButton() {
    const old = document.querySelector('#tabbit-ai-float-btn');
    if (old) old.remove();

    floatBtnEl = document.createElement('button');
    floatBtnEl.id = 'tabbit-ai-float-btn';
    floatBtnEl.innerHTML = '<span>AI</span>';
    floatBtnEl.title = '打开 饺子 AI。可拖拽,右键恢复默认位置。';

    document.body.appendChild(floatBtnEl);

    applyFloatButtonPosition();

    let dragging = false;
    let moved = false;
    let startX = 0;
    let startY = 0;
    let startLeft = 0;
    let startTop = 0;

    floatBtnEl.addEventListener('mousedown', e => {
      if (e.button !== 0) return;

      dragging = true;
      moved = false;

      const rect = floatBtnEl.getBoundingClientRect();

      startX = e.clientX;
      startY = e.clientY;
      startLeft = rect.left;
      startTop = rect.top;

      document.body.classList.add('tabbit-dragging');
      e.preventDefault();
    });

    document.addEventListener('mousemove', e => {
      if (!dragging) return;

      const dx = e.clientX - startX;
      const dy = e.clientY - startY;

      if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;

      let left = startLeft + dx;
      let top = startTop + dy;

      const rect = floatBtnEl.getBoundingClientRect();
      const maxLeft = window.innerWidth - rect.width;
      const maxTop = window.innerHeight - rect.height;

      left = Math.max(0, Math.min(maxLeft, left));
      top = Math.max(0, Math.min(maxTop, top));

      floatBtnEl.style.left = left + 'px';
      floatBtnEl.style.top = top + 'px';
      floatBtnEl.style.right = 'auto';
      floatBtnEl.style.bottom = 'auto';
      floatBtnEl.style.transform = 'none';
    });

    document.addEventListener('mouseup', () => {
      if (!dragging) return;

      dragging = false;
      document.body.classList.remove('tabbit-dragging');

      const rect = floatBtnEl.getBoundingClientRect();
      const stickToRight = rect.left + rect.width / 2 > window.innerWidth / 2;

      config.floatButton.side = stickToRight ? 'right' : 'left';
      config.floatButton.y = Math.round(rect.top);

      saveConfig();
      applyFloatButtonPosition();
    });

    floatBtnEl.addEventListener('click', e => {
      if (moved) {
        e.preventDefault();
        e.stopPropagation();
        return;
      }

      openPanel(false);
    });

    floatBtnEl.addEventListener('contextmenu', e => {
      e.preventDefault();

      config.floatButton = {
        side: 'right',
        y: null,
        opacity: 0.55
      };

      saveConfig();
      applyFloatButtonPosition();
    });

    window.addEventListener('resize', applyFloatButtonPosition);
  }

  function applyFloatButtonPosition() {
    if (!floatBtnEl) return;

    const fb = config.floatButton || {};

    floatBtnEl.style.left = 'auto';
    floatBtnEl.style.right = 'auto';
    floatBtnEl.style.top = 'auto';
    floatBtnEl.style.bottom = 'auto';
    floatBtnEl.style.transform = 'none';

    if (typeof fb.y === 'number') {
      const btnHeight = 72;
      const safeY = Math.max(10, Math.min(window.innerHeight - btnHeight - 10, fb.y));

      floatBtnEl.style.top = safeY + 'px';

      if (fb.side === 'left') {
        floatBtnEl.style.left = '0px';
        floatBtnEl.classList.add('tabbit-float-left');
        floatBtnEl.classList.remove('tabbit-float-right');
      } else {
        floatBtnEl.style.right = '0px';
        floatBtnEl.classList.add('tabbit-float-right');
        floatBtnEl.classList.remove('tabbit-float-left');
      }
    } else {
      floatBtnEl.style.top = '50%';
      floatBtnEl.style.right = '0px';
      floatBtnEl.style.transform = 'translateY(-50%)';
      floatBtnEl.classList.add('tabbit-float-right');
      floatBtnEl.classList.remove('tabbit-float-left');
    }

    floatBtnEl.style.opacity = String(fb.opacity ?? 0.55);
  }

  /******************************************************************
   * 主面板
   ******************************************************************/

  function openPanel(autoRun) {
    if (!panelEl) {
      panelEl = createPanel();
      document.body.appendChild(panelEl);
    }

    panelEl.classList.remove('tabbit-hidden');
    renderModelSelect();

    if (autoRun && !summaryStarted) {
      runSummary();
    }
  }

  function closePanel() {
    if (panelEl) {
      panelEl.classList.add('tabbit-hidden');
    }
  }

  function createPanel() {
    const panel = document.createElement('div');
    panel.id = 'tabbit-ai-panel';

    const width = Number(config.panel?.width || 460);
    const heightRatio = Number(config.panel?.heightRatio || 0.82);

    panel.style.width = width + 'px';
    panel.style.height = Math.round(window.innerHeight * heightRatio) + 'px';

    panel.innerHTML = `
      <div class="tabbit-header">
        <div class="tabbit-title">📖 饺子 AI</div>

        <div class="tabbit-header-actions">
          <select id="tabbit-model-select" class="tabbit-model-select"></select>
          <button id="tabbit-settings-btn" class="tabbit-icon-btn" title="设置">⚙️</button>
          <button id="tabbit-close-btn" class="tabbit-icon-btn" title="关闭">×</button>
        </div>
      </div>

      <div class="tabbit-toolbar">
        <button id="tabbit-summary-btn" class="tabbit-primary-btn">总结</button>
        <button id="tabbit-stop-btn" class="tabbit-secondary-btn" disabled>停止</button>
        <button id="tabbit-retry-btn" class="tabbit-secondary-btn" disabled>重试</button>
        <button id="tabbit-preview-btn" class="tabbit-secondary-btn">正文</button>
        <button id="tabbit-add-url-rule-btn" class="tabbit-secondary-btn">加入网址</button>
        <button id="tabbit-clear-btn" class="tabbit-secondary-btn">清空</button>
        <button id="tabbit-copy-btn" class="tabbit-secondary-btn">复制</button>
      </div>

      <div id="tabbit-status-bar" class="tabbit-status-bar tabbit-hidden"></div>

      <div id="tabbit-chat-list" class="tabbit-chat-list"></div>

      <div class="tabbit-input-area">
        <textarea id="tabbit-user-input" placeholder="继续追问。Enter 发送,Shift + Enter 换行"></textarea>
        <button id="tabbit-send-btn">发送</button>
      </div>
    `;

    panel.querySelector('#tabbit-close-btn').addEventListener('click', closePanel);
    panel.querySelector('#tabbit-settings-btn').addEventListener('click', openSettings);
    panel.querySelector('#tabbit-summary-btn').addEventListener('click', runSummary);
    panel.querySelector('#tabbit-stop-btn').addEventListener('click', stopCurrentRequest);
    panel.querySelector('#tabbit-retry-btn').addEventListener('click', retryLastRequest);
    panel.querySelector('#tabbit-preview-btn').addEventListener('click', openPreviewModal);
    panel.querySelector('#tabbit-add-url-rule-btn').addEventListener('click', () => openAddUrlRuleModal());
    panel.querySelector('#tabbit-clear-btn').addEventListener('click', clearChat);
    panel.querySelector('#tabbit-copy-btn').addEventListener('click', copyChat);
    panel.querySelector('#tabbit-send-btn').addEventListener('click', sendUserMessage);

    const input = panel.querySelector('#tabbit-user-input');

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

    return panel;
  }

  function renderModelSelect() {
    if (!panelEl) return;

    const select = panelEl.querySelector('#tabbit-model-select');
    if (!select) return;

    select.innerHTML = '';

    normalizeModels(config.models).forEach(model => {
      const option = document.createElement('option');
      option.value = model.value;
      option.textContent = model.name || model.value;

      if (model.value === config.currentModel) {
        option.selected = true;
      }

      select.appendChild(option);
    });

    select.onchange = function () {
      config.currentModel = this.value;
      saveConfig();
    };
  }

  function setStatus(text, type = '', autoHideMs = 0) {
    if (!panelEl) return;

    const bar = panelEl.querySelector('#tabbit-status-bar');
    if (!bar) return;

    if (!text) {
      bar.textContent = '';
      bar.className = 'tabbit-status-bar tabbit-hidden';
      return;
    }

    bar.textContent = text;
    bar.className = `tabbit-status-bar tabbit-status-${type || 'normal'}`;

    if (autoHideMs) {
      setTimeout(() => {
        if (bar.textContent === text) setStatus('');
      }, autoHideMs);
    }
  }

  /******************************************************************
   * 总结与对话
   ******************************************************************/

  async function runSummary() {
    if (isRequesting) return;

    summaryStarted = true;
    currentPageUrl = location.href;

    if (!checkApiConfig()) return;

    const pageContent = extractPageContent();
    lastExtractedText = pageContent;

    if (!pageContent || pageContent.length < 80) {
      appendErrorMessage('没有提取到足够的网页正文。');
      setStatus('正文过短', 'error', 2500);
      return;
    }

    const modelLabel = getCurrentModelDisplayName();

    const fullPrompt = `${getPromptForCurrentPage()}

网页标题:
${document.title || ''}

网页 URL:
${currentPageUrl}

网页正文:
${pageContent}`;

    const userMsg = {
      role: 'user',
      content: fullPrompt
    };

    const payloadMessages = [...chatMessages, userMsg];

    lastRequestPayload = {
      messages: payloadMessages,
      modelLabel
    };

    try {
      setInputLoading(true);
      setStatus(`分析中 · ${pageContent.length} 字 · ${modelLabel}`, 'loading');

      const answer = await callChatApi(payloadMessages);

      chatMessages.push(userMsg);
      chatMessages.push({
        role: 'assistant',
        content: answer
      });

      appendAssistantMessage(answer, modelLabel);
      setStatus('完成', 'ok', 1200);
    } catch (err) {
      if (String(err?.message || '').includes('请求已取消')) {
        setStatus('已停止', 'normal', 1600);
      } else {
        appendErrorMessage(err.message || String(err));
        setStatus('请求失败,可重试', 'error');
      }
    } finally {
      setInputLoading(false);
    }
  }

  async function sendUserMessage() {
    if (isRequesting) return;
    if (!checkApiConfig()) return;
    if (!panelEl) return;

    const input = panelEl.querySelector('#tabbit-user-input');
    const text = input.value.trim();

    if (!text) return;

    input.value = '';
    appendUserMessage(text);

    const userMsg = {
      role: 'user',
      content: text
    };

    const modelLabel = getCurrentModelDisplayName();
    const payloadMessages = [...chatMessages, userMsg];

    lastRequestPayload = {
      messages: payloadMessages,
      modelLabel
    };

    try {
      setInputLoading(true);
      setStatus(`请求中 · ${modelLabel}`, 'loading');

      const answer = await callChatApi(payloadMessages);

      chatMessages.push(userMsg);
      chatMessages.push({
        role: 'assistant',
        content: answer
      });

      appendAssistantMessage(answer, modelLabel);
      setStatus('完成', 'ok', 1200);
    } catch (err) {
      if (String(err?.message || '').includes('请求已取消')) {
        setStatus('已停止', 'normal', 1600);
      } else {
        appendErrorMessage(err.message || String(err));
        setStatus('请求失败,可重试', 'error');
      }
    } finally {
      setInputLoading(false);
    }
  }

  async function retryLastRequest() {
    if (isRequesting) return;
    if (!lastRequestPayload) return;
    if (!checkApiConfig()) return;

    try {
      setInputLoading(true);
      setStatus(`重试中 · ${lastRequestPayload.modelLabel || getCurrentModelDisplayName()}`, 'loading');

      const answer = await callChatApi(lastRequestPayload.messages);

      const lastUser = [...lastRequestPayload.messages].reverse().find(m => m.role === 'user');

      if (lastUser && !chatMessages.includes(lastUser)) {
        chatMessages.push(lastUser);
      }

      chatMessages.push({
        role: 'assistant',
        content: answer
      });

      appendAssistantMessage(answer, lastRequestPayload.modelLabel || getCurrentModelDisplayName());
      setStatus('重试完成', 'ok', 1200);
    } catch (err) {
      if (String(err?.message || '').includes('请求已取消')) {
        setStatus('已停止', 'normal', 1600);
      } else {
        appendErrorMessage(err.message || String(err));
        setStatus('重试失败', 'error');
      }
    } finally {
      setInputLoading(false);
    }
  }

  function stopCurrentRequest() {
    if (!isRequesting) return;

    try {
      if (currentRequest && typeof currentRequest.abort === 'function') {
        currentRequest.abort();
      }

      if (typeof currentReject === 'function') {
        currentReject(new Error('请求已取消'));
      }
    } catch (err) {
      console.warn('[饺子 AI] 停止请求失败:', err);
    } finally {
      currentRequest = null;
      currentReject = null;
      setInputLoading(false);
      setStatus('已停止', 'normal', 1600);
    }
  }

  function checkApiConfig() {
    if (!config.apiUrl || !config.apiKey || !config.currentModel) {
      openSettings();
      setStatus('请先配置 API', 'error', 2500);
      return false;
    }

    return true;
  }

  function callChatApi(messages) {
    const body = {
      model: config.currentModel,
      messages,
      temperature: getCurrentTemperature(),
      max_tokens: getCurrentMaxTokens()
    };

    return new Promise((resolve, reject) => {
      currentReject = reject;

      currentRequest = GM_xmlhttpRequest({
        method: 'POST',
        url: config.apiUrl,
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${config.apiKey}`
        },
        data: JSON.stringify(body),
        timeout: 120000,

        onload(res) {
          currentRequest = null;
          currentReject = null;

          try {
            if (res.status < 200 || res.status >= 300) {
              reject(new Error(formatApiError(res.status, res.responseText)));
              return;
            }

            const data = JSON.parse(res.responseText);
            const content = data?.choices?.[0]?.message?.content;

            if (!content) {
              reject(new Error('API 响应格式异常:没有找到 choices[0].message.content。'));
              return;
            }

            resolve(content);
          } catch (err) {
            reject(err);
          }
        },

        onerror(err) {
          currentRequest = null;
          currentReject = null;
          reject(new Error('网络请求失败:' + JSON.stringify(err)));
        },

        ontimeout() {
          currentRequest = null;
          currentReject = null;
          reject(new Error('API 请求超时。'));
        }
      });
    });
  }

  function formatApiError(status, text) {
    const map = {
      400: '400:请求参数可能有误。',
      401: '401:API Key 可能不正确。',
      403: '403:当前 API Key 可能没有权限。',
      404: '404:API 地址或模型名可能错误。',
      429: '429:请求过于频繁或额度不足。',
      500: '500:服务商内部错误。',
      502: '502:服务商网关错误。',
      503: '503:服务暂不可用。'
    };

    return `${map[status] || `API 请求失败:${status}`}\n${text || ''}`;
  }

  function extractPageContent() {
    const clonedBody = document.body.cloneNode(true);

    const removeSelectors = [
      'script',
      'style',
      'noscript',
      'iframe',
      'svg',
      'canvas',
      'video',
      'audio',
      'nav',
      'header',
      'footer',
      'form',
      'button',
      'input',
      'textarea',
      'select',
      '#tabbit-ai-panel',
      '#tabbit-ai-float-btn',
      '#tabbit-settings-modal',
      '#tabbit-add-rule-modal',
      '#tabbit-preview-modal'
    ];

    removeSelectors.forEach(selector => {
      clonedBody.querySelectorAll(selector).forEach(el => el.remove());
    });

    const candidates = [
      'article',
      'main',
      '.rich_media_content',
      '#js_content',
      '.post-content',
      '.article-content',
      '.entry-content',
      '.content',
      '#content',
      '.post',
      '.article',
      '.articleBody',
      '.article-body'
    ];

    let bestText = '';

    for (const selector of candidates) {
      const nodes = clonedBody.querySelectorAll(selector);

      nodes.forEach(node => {
        const text = cleanText(node.innerText || node.textContent || '');
        if (text.length > bestText.length) bestText = text;
      });
    }

    if (!bestText || bestText.length < 200) {
      bestText = cleanText(clonedBody.innerText || clonedBody.textContent || '');
    }

    return bestText.substring(0, Number(config.extractMaxChars || 16000));
  }

  function cleanText(text) {
    return String(text || '')
      .replace(/\r/g, '')
      .replace(/[ \t]{2,}/g, ' ')
      .replace(/\n[ \t]+/g, '\n')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
  }

  /******************************************************************
   * 消息展示
   ******************************************************************/

  function appendUserMessage(text) {
    appendMessage({
      type: 'user',
      label: '我',
      text
    });
  }

  function appendAssistantMessage(text, modelLabel) {
    appendMessage({
      type: 'assistant',
      label: modelLabel || getCurrentModelDisplayName(),
      text
    });
  }

  function appendErrorMessage(text) {
    appendMessage({
      type: 'error',
      label: '错误',
      text: `⚠️ ${text}`
    });
  }

  function appendMessage({ type, label, text }) {
    if (!panelEl) openPanel(false);

    const list = panelEl.querySelector('#tabbit-chat-list');
    if (!list) return;

    const item = document.createElement('div');
    item.className = `tabbit-msg tabbit-msg-${type}`;
    item.dataset.msgType = type;
    item.dataset.msgLabel = label || '';

    item.innerHTML = `
      <div class="tabbit-msg-label">${escapeHtml(label || '')}</div>
      <div class="tabbit-msg-body">${renderMarkdown(text)}</div>
    `;

    list.appendChild(item);
    list.scrollTop = list.scrollHeight;
  }

  function clearChat() {
    chatMessages = [];
    summaryStarted = false;
    lastRequestPayload = null;

    const list = panelEl?.querySelector('#tabbit-chat-list');
    if (list) list.innerHTML = '';

    setStatus('');
    updateRetryButton();
  }

  function copyChat() {
    if (!panelEl) return;

    const nodes = [
      ...panelEl.querySelectorAll('.tabbit-msg-user, .tabbit-msg-assistant')
    ];

    const text = nodes
      .map(el => {
        const label = el.dataset.msgLabel || '';
        const body = el.querySelector('.tabbit-msg-body')?.innerText?.trim() || '';
        return `${label}:\n${body}`;
      })
      .filter(Boolean)
      .join('\n\n');

    copyText(text || '');
    setStatus('已复制', 'ok', 1200);
  }

  function setInputLoading(loading) {
    isRequesting = loading;

    if (!panelEl) return;

    const sendBtn = panelEl.querySelector('#tabbit-send-btn');
    const summaryBtn = panelEl.querySelector('#tabbit-summary-btn');
    const stopBtn = panelEl.querySelector('#tabbit-stop-btn');
    const retryBtn = panelEl.querySelector('#tabbit-retry-btn');
    const input = panelEl.querySelector('#tabbit-user-input');

    if (sendBtn) {
      sendBtn.disabled = loading;
      sendBtn.textContent = loading ? '等待' : '发送';
    }

    if (summaryBtn) summaryBtn.disabled = loading;
    if (stopBtn) stopBtn.disabled = !loading;
    if (retryBtn) retryBtn.disabled = loading || !lastRequestPayload;
    if (input) input.disabled = loading;
  }

  function updateRetryButton() {
    if (!panelEl) return;
    const retryBtn = panelEl.querySelector('#tabbit-retry-btn');
    if (retryBtn) retryBtn.disabled = isRequesting || !lastRequestPayload;
  }

  /******************************************************************
   * 加入网址规则弹窗
   ******************************************************************/

  function openAddUrlRuleModal() {
    if (!addRuleModalEl) {
      addRuleModalEl = createAddRuleModal();
      document.body.appendChild(addRuleModalEl);
    }

    renderAddRuleModal();
    addRuleModalEl.classList.remove('tabbit-hidden');
  }

  function closeAddRuleModal() {
    if (addRuleModalEl) addRuleModalEl.classList.add('tabbit-hidden');
  }

  function createAddRuleModal() {
    const modal = document.createElement('div');
    modal.id = 'tabbit-add-rule-modal';

    modal.innerHTML = `
      <div class="tabbit-small-card">
        <div class="tabbit-settings-header">
          <div class="tabbit-settings-title">加入当前网址</div>
          <button id="tabbit-add-rule-close" class="tabbit-icon-btn">×</button>
        </div>

        <div class="tabbit-settings-body">
          <div class="tabbit-field">
            <span>选择规则</span>
            <div id="tabbit-rule-candidates" class="tabbit-rule-candidates"></div>
          </div>

          <label class="tabbit-field">
            <span>自定义规则</span>
            <input id="tabbit-custom-rule-input" type="text">
            <small>不带 * 时按前缀匹配;带 * 时按通配符匹配。</small>
          </label>

          <label class="tabbit-field">
            <span>绑定提示词模板,可选</span>
            <select id="tabbit-add-rule-template"></select>
          </label>
        </div>

        <div class="tabbit-settings-footer">
          <button id="tabbit-confirm-add-rule" class="tabbit-primary-btn">添加</button>
          <button id="tabbit-cancel-add-rule" class="tabbit-secondary-btn">取消</button>
        </div>
      </div>
    `;

    modal.querySelector('#tabbit-add-rule-close').addEventListener('click', closeAddRuleModal);
    modal.querySelector('#tabbit-cancel-add-rule').addEventListener('click', closeAddRuleModal);

    modal.querySelector('#tabbit-confirm-add-rule').addEventListener('click', () => {
      const input = modal.querySelector('#tabbit-custom-rule-input');
      const templateSelect = modal.querySelector('#tabbit-add-rule-template');
      const rule = input.value.trim();
      const templateId = templateSelect.value;

      if (!rule) return;

      addUrlRule(rule, templateId);
      closeAddRuleModal();
    });

    modal.addEventListener('click', e => {
      if (e.target === modal) closeAddRuleModal();
    });

    return modal;
  }

  function renderAddRuleModal() {
    const candidates = buildUrlRuleCandidates(location.href);
    const box = addRuleModalEl.querySelector('#tabbit-rule-candidates');
    const input = addRuleModalEl.querySelector('#tabbit-custom-rule-input');
    const templateSelect = addRuleModalEl.querySelector('#tabbit-add-rule-template');

    box.innerHTML = '';

    candidates.forEach((rule, index) => {
      const item = document.createElement('label');
      item.className = 'tabbit-rule-candidate';

      item.innerHTML = `
        <input type="radio" name="tabbit-rule-candidate" ${index === 0 ? 'checked' : ''}>
        <span>${escapeHtml(rule)}</span>
      `;

      item.querySelector('input').addEventListener('change', () => {
        input.value = rule;
      });

      box.appendChild(item);
    });

    input.value = candidates[0] || '';

    templateSelect.innerHTML = `<option value="">使用默认提示词</option>`;
    config.promptTemplates.forEach(t => {
      const option = document.createElement('option');
      option.value = t.id;
      option.textContent = t.name;
      templateSelect.appendChild(option);
    });
  }

  /******************************************************************
   * 正文预览
   ******************************************************************/

  function openPreviewModal() {
    if (!previewModalEl) {
      previewModalEl = createPreviewModal();
      document.body.appendChild(previewModalEl);
    }

    const text = lastExtractedText || extractPageContent();
    lastExtractedText = text;

    previewModalEl.querySelector('#tabbit-preview-count').textContent = `${text.length} 字符`;
    previewModalEl.querySelector('#tabbit-preview-text').value = text;

    previewModalEl.classList.remove('tabbit-hidden');
  }

  function closePreviewModal() {
    if (previewModalEl) previewModalEl.classList.add('tabbit-hidden');
  }

  function createPreviewModal() {
    const modal = document.createElement('div');
    modal.id = 'tabbit-preview-modal';

    modal.innerHTML = `
      <div class="tabbit-preview-card">
        <div class="tabbit-settings-header">
          <div class="tabbit-settings-title">正文预览 <span id="tabbit-preview-count"></span></div>
          <button id="tabbit-preview-close" class="tabbit-icon-btn">×</button>
        </div>

        <div class="tabbit-settings-body">
          <textarea id="tabbit-preview-text" readonly></textarea>
        </div>

        <div class="tabbit-settings-footer">
          <button id="tabbit-copy-preview" class="tabbit-secondary-btn">复制正文</button>
          <button id="tabbit-close-preview" class="tabbit-primary-btn">关闭</button>
        </div>
      </div>
    `;

    modal.querySelector('#tabbit-preview-close').addEventListener('click', closePreviewModal);
    modal.querySelector('#tabbit-close-preview').addEventListener('click', closePreviewModal);
    modal.querySelector('#tabbit-copy-preview').addEventListener('click', () => {
      copyText(modal.querySelector('#tabbit-preview-text').value || '');
      setStatus('正文已复制', 'ok', 1200);
    });

    modal.addEventListener('click', e => {
      if (e.target === modal) closePreviewModal();
    });

    return modal;
  }

  /******************************************************************
   * 设置页面
   ******************************************************************/

  function openSettings() {
    if (!settingsEl) {
      settingsEl = createSettingsModal();
      document.body.appendChild(settingsEl);
    }

    fillSettingsForm();
    settingsEl.classList.remove('tabbit-hidden');
  }

  function closeSettings() {
    if (settingsEl) settingsEl.classList.add('tabbit-hidden');
  }

  function createSettingsModal() {
    const modal = document.createElement('div');
    modal.id = 'tabbit-settings-modal';

    modal.innerHTML = `
      <div class="tabbit-settings-card">
        <div class="tabbit-settings-header">
          <div class="tabbit-settings-title">⚙️ 饺子 AI 设置</div>
          <button id="tabbit-settings-close" class="tabbit-icon-btn">×</button>
        </div>

        <div class="tabbit-settings-body">
          <label class="tabbit-field">
            <span>API 地址</span>
            <input id="tabbit-set-api-url" type="text" placeholder="https://api.openai.com/v1/chat/completions">
          </label>

          <label class="tabbit-field">
            <span>API Key</span>
            <input id="tabbit-set-api-key" type="password" placeholder="sk-xxxx">
            <small>配置只保存到油猴全局存储 GM_getValue / GM_setValue。</small>
          </label>

          <div class="tabbit-settings-actions">
            <button id="tabbit-test-api" class="tabbit-secondary-btn" type="button">测试 API</button>
            <button id="tabbit-fetch-models" class="tabbit-secondary-btn" type="button">获取模型列表</button>
          </div>

          <div class="tabbit-row-2">
            <label class="tabbit-field">
              <span>默认 temperature</span>
              <input id="tabbit-set-temperature" type="number" step="0.1" min="0" max="2">
            </label>

            <label class="tabbit-field">
              <span>默认 max_tokens</span>
              <input id="tabbit-set-max-tokens" type="number" min="100">
            </label>
          </div>

          <div class="tabbit-row-2">
            <label class="tabbit-field">
              <span>面板宽度 px</span>
              <input id="tabbit-set-panel-width" type="number" min="320" max="900">
            </label>

            <label class="tabbit-field">
              <span>发送正文最大字符数</span>
              <input id="tabbit-set-extract-max" type="number" min="1000" max="80000">
            </label>
          </div>

          <label class="tabbit-field tabbit-checkbox-field">
            <input id="tabbit-set-auto-run" type="checkbox">
            <span>打开匹配网址时自动弹出并总结</span>
          </label>

          <div class="tabbit-section-title">模型预设</div>
          <div id="tabbit-model-list" class="tabbit-model-list"></div>
          <div class="tabbit-settings-actions">
            <button id="tabbit-add-model" class="tabbit-secondary-btn" type="button">+ 添加模型</button>
          </div>

          <div class="tabbit-section-title">提示词模板</div>
          <label class="tabbit-field">
            <span>默认模板</span>
            <select id="tabbit-default-template"></select>
          </label>
          <div id="tabbit-template-list" class="tabbit-template-list"></div>
          <div class="tabbit-settings-actions">
            <button id="tabbit-add-template" class="tabbit-secondary-btn" type="button">+ 添加模板</button>
          </div>

          <div class="tabbit-section-title">指定网址</div>
          <small class="tabbit-help">一行一条规则。不带 * 时按前缀匹配;带 * 时按通配符匹配。每条规则可绑定提示词模板。</small>
          <div id="tabbit-url-rule-list" class="tabbit-url-rule-list"></div>
          <div class="tabbit-settings-actions">
            <button id="tabbit-settings-add-current-url" class="tabbit-secondary-btn" type="button">加入当前网址</button>
            <button id="tabbit-settings-add-empty-url" class="tabbit-secondary-btn" type="button">+ 添加空规则</button>
            <button id="tabbit-settings-dedupe-url" class="tabbit-secondary-btn" type="button">去重整理</button>
          </div>

          <div class="tabbit-section-title">配置文件</div>
          <div class="tabbit-settings-actions">
            <button id="tabbit-export-file" class="tabbit-secondary-btn" type="button">导出配置文件</button>
            <button id="tabbit-import-file" class="tabbit-secondary-btn" type="button">导入配置文件</button>
            <button id="tabbit-reset-config" class="tabbit-danger-btn" type="button">重置配置</button>
          </div>
        </div>

        <div class="tabbit-settings-footer">
          <button id="tabbit-save-settings" class="tabbit-primary-btn">保存设置</button>
          <button id="tabbit-cancel-settings" class="tabbit-secondary-btn">取消</button>
        </div>
      </div>
    `;

    modal.querySelector('#tabbit-settings-close').addEventListener('click', closeSettings);
    modal.querySelector('#tabbit-cancel-settings').addEventListener('click', closeSettings);
    modal.querySelector('#tabbit-save-settings').addEventListener('click', saveSettingsFromForm);

    modal.querySelector('#tabbit-add-model').addEventListener('click', () => {
      syncModelsFromSettings();
      config.models.push({
        name: '新模型',
        value: '',
        temperature: '',
        maxTokens: ''
      });
      renderSettingsModels();
    });

    modal.querySelector('#tabbit-add-template').addEventListener('click', () => {
      syncTemplatesFromSettings();
      config.promptTemplates.push({
        id: makeId('tpl'),
        name: '新模板',
        text: '请总结这个网页的核心内容。'
      });
      renderSettingsTemplates();
      renderSettingsUrlRules();
    });

    modal.querySelector('#tabbit-settings-add-current-url').addEventListener('click', () => {
      openAddUrlRuleModal();
    });

    modal.querySelector('#tabbit-settings-add-empty-url').addEventListener('click', () => {
      syncUrlRulesFromSettings();
      config.urlRules.push('');
      renderSettingsUrlRules();
    });

    modal.querySelector('#tabbit-settings-dedupe-url').addEventListener('click', () => {
      syncUrlRulesFromSettings();
      config.urlRules = normalizeUrlRules(config.urlRules);
      config.rulePromptBindings = config.rulePromptBindings.filter(b => config.urlRules.includes(b.rule));
      renderSettingsUrlRules();
    });

    modal.querySelector('#tabbit-test-api').addEventListener('click', testApiConnection);
    modal.querySelector('#tabbit-fetch-models').addEventListener('click', fetchModelsFromApi);

    modal.querySelector('#tabbit-export-file').addEventListener('click', exportConfigToFile);
    modal.querySelector('#tabbit-import-file').addEventListener('click', importConfigFromFile);
    modal.querySelector('#tabbit-reset-config').addEventListener('click', resetConfig);

    modal.addEventListener('click', e => {
      if (e.target === modal) closeSettings();
    });

    return modal;
  }

  function fillSettingsForm() {
    if (!settingsEl) return;

    settingsEl.querySelector('#tabbit-set-api-url').value = config.apiUrl || '';
    settingsEl.querySelector('#tabbit-set-api-key').value = config.apiKey || '';
    settingsEl.querySelector('#tabbit-set-temperature').value = config.temperature ?? 0.7;
    settingsEl.querySelector('#tabbit-set-max-tokens').value = config.maxTokens ?? 2000;
    settingsEl.querySelector('#tabbit-set-panel-width').value = config.panel?.width || 460;
    settingsEl.querySelector('#tabbit-set-extract-max').value = config.extractMaxChars || 16000;
    settingsEl.querySelector('#tabbit-set-auto-run').checked = !!config.autoRun;

    renderSettingsModels();
    renderSettingsTemplates();
    renderSettingsUrlRules();
  }

  function renderSettingsModels() {
    if (!settingsEl) return;

    const box = settingsEl.querySelector('#tabbit-model-list');
    box.innerHTML = '';

    config.models.forEach((model, index) => {
      const row = document.createElement('div');
      row.className = 'tabbit-model-row';

      row.innerHTML = `
        <input class="tabbit-model-name" type="text" placeholder="显示名称" value="${escapeAttr(model.name || '')}">
        <input class="tabbit-model-value" type="text" placeholder="模型名" value="${escapeAttr(model.value || '')}">
        <input class="tabbit-model-temp" type="number" step="0.1" placeholder="temp" value="${escapeAttr(model.temperature ?? '')}">
        <input class="tabbit-model-tokens" type="number" placeholder="tokens" value="${escapeAttr(model.maxTokens ?? '')}">
        <label class="tabbit-current-model">
          <input type="radio" name="tabbit-current-model" ${model.value === config.currentModel ? 'checked' : ''}>
          当前
        </label>
        <button class="tabbit-remove-model" type="button">×</button>
      `;

      row.querySelector('.tabbit-remove-model').addEventListener('click', () => {
        syncModelsFromSettings();

        config.models.splice(index, 1);

        if (!config.models.length) {
          config.models.push({
            name: 'mimo-v2-flash',
            value: 'mimo-v2-flash',
            temperature: '',
            maxTokens: ''
          });
        }

        if (!config.models.some(m => m.value === config.currentModel)) {
          config.currentModel = config.models[0].value;
        }

        renderSettingsModels();
      });

      box.appendChild(row);
    });
  }

  function syncModelsFromSettings() {
    if (!settingsEl) return;

    const rows = [...settingsEl.querySelectorAll('.tabbit-model-row')];
    let nextCurrent = config.currentModel;

    const models = rows
      .map(row => {
        const name = row.querySelector('.tabbit-model-name').value.trim();
        const value = row.querySelector('.tabbit-model-value').value.trim();
        const temperature = row.querySelector('.tabbit-model-temp').value.trim();
        const maxTokens = row.querySelector('.tabbit-model-tokens').value.trim();
        const checked = row.querySelector('input[type="radio"]').checked;

        if (checked && value) nextCurrent = value;

        return {
          name: name || value,
          value,
          temperature,
          maxTokens
        };
      })
      .filter(model => model.value);

    config.models = normalizeModels(models);

    if (!config.models.some(m => m.value === nextCurrent)) {
      nextCurrent = config.models[0]?.value || '';
    }

    config.currentModel = nextCurrent;
  }

  function renderSettingsTemplates() {
    if (!settingsEl) return;

    const select = settingsEl.querySelector('#tabbit-default-template');
    const box = settingsEl.querySelector('#tabbit-template-list');

    select.innerHTML = '';
    box.innerHTML = '';

    config.promptTemplates.forEach(t => {
      const option = document.createElement('option');
      option.value = t.id;
      option.textContent = t.name;
      option.selected = t.id === config.defaultPromptTemplateId;
      select.appendChild(option);
    });

    config.promptTemplates.forEach((tpl, index) => {
      const row = document.createElement('div');
      row.className = 'tabbit-template-row';
      row.dataset.templateId = tpl.id;

      row.innerHTML = `
        <div class="tabbit-template-head">
          <input class="tabbit-template-name" type="text" placeholder="模板名称" value="${escapeAttr(tpl.name)}">
          <button class="tabbit-remove-template" type="button">×</button>
        </div>
        <textarea class="tabbit-template-text" rows="5">${escapeHtml(tpl.text)}</textarea>
      `;

      row.querySelector('.tabbit-remove-template').addEventListener('click', () => {
        syncTemplatesFromSettings();

        if (config.promptTemplates.length <= 1) {
          alert('至少保留一个提示词模板。');
          return;
        }

        const removed = config.promptTemplates[index];
        config.promptTemplates.splice(index, 1);

        if (removed?.id === config.defaultPromptTemplateId) {
          config.defaultPromptTemplateId = config.promptTemplates[0]?.id || 'default';
        }

        config.rulePromptBindings = config.rulePromptBindings.filter(b => b.templateId !== removed?.id);

        renderSettingsTemplates();
        renderSettingsUrlRules();
      });

      box.appendChild(row);
    });

    select.onchange = () => {
      config.defaultPromptTemplateId = select.value;
    };
  }

  function syncTemplatesFromSettings() {
    if (!settingsEl) return;

    const rows = [...settingsEl.querySelectorAll('.tabbit-template-row')];

    config.promptTemplates = normalizePromptTemplates(
      rows.map(row => ({
        id: row.dataset.templateId || makeId('tpl'),
        name: row.querySelector('.tabbit-template-name').value.trim(),
        text: row.querySelector('.tabbit-template-text').value.trim()
      }))
    );

    const defaultSelect = settingsEl.querySelector('#tabbit-default-template');
    if (defaultSelect?.value) {
      config.defaultPromptTemplateId = defaultSelect.value;
    }

    if (!config.promptTemplates.some(t => t.id === config.defaultPromptTemplateId)) {
      config.defaultPromptTemplateId = config.promptTemplates[0]?.id || 'default';
    }
  }

  function renderSettingsUrlRules() {
    if (!settingsEl) return;

    const box = settingsEl.querySelector('#tabbit-url-rule-list');
    box.innerHTML = '';

    config.urlRules.forEach((rule, index) => {
      const row = document.createElement('div');
      row.className = 'tabbit-url-rule-row';

      const matched = rule && testUrlRule(location.href, rule);

      row.innerHTML = `
        <input class="tabbit-url-rule-input" type="text" placeholder="https://example.com/path/*" value="${escapeAttr(rule)}">
        <select class="tabbit-url-rule-template">
          <option value="">默认模板</option>
        </select>
        <span class="tabbit-rule-match ${matched ? 'matched' : ''}">${matched ? '匹配当前页' : ''}</span>
        <button class="tabbit-remove-url-rule" type="button">×</button>
      `;

      const select = row.querySelector('.tabbit-url-rule-template');
      const currentTemplateId = getTemplateIdForRule(rule);

      config.promptTemplates.forEach(t => {
        const option = document.createElement('option');
        option.value = t.id;
        option.textContent = t.name;
        option.selected = t.id === currentTemplateId;
        select.appendChild(option);
      });

      row.querySelector('.tabbit-remove-url-rule').addEventListener('click', () => {
        syncUrlRulesFromSettings();

        const removedRule = config.urlRules[index];
        config.urlRules.splice(index, 1);
        config.rulePromptBindings = config.rulePromptBindings.filter(b => b.rule !== removedRule);

        renderSettingsUrlRules();
      });

      box.appendChild(row);
    });
  }

  function syncUrlRulesFromSettings() {
    if (!settingsEl) return;

    const rows = [...settingsEl.querySelectorAll('.tabbit-url-rule-row')];
    const rules = [];
    const bindings = [];

    rows.forEach(row => {
      const rule = row.querySelector('.tabbit-url-rule-input').value.trim();
      const templateId = row.querySelector('.tabbit-url-rule-template').value;

      if (!rule) return;

      rules.push(rule);

      if (templateId) {
        bindings.push({
          rule,
          templateId
        });
      }
    });

    config.urlRules = normalizeUrlRules(rules);
    config.rulePromptBindings = normalizeRulePromptBindings(bindings);
  }

  function saveSettingsFromForm() {
    syncModelsFromSettings();
    syncTemplatesFromSettings();
    syncUrlRulesFromSettings();

    config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim();
    config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim();
    config.temperature = Number(settingsEl.querySelector('#tabbit-set-temperature').value || 0.7);
    config.maxTokens = Number(settingsEl.querySelector('#tabbit-set-max-tokens').value || 2000);
    config.autoRun = settingsEl.querySelector('#tabbit-set-auto-run').checked;
    config.extractMaxChars = Number(settingsEl.querySelector('#tabbit-set-extract-max').value || 16000);

    config.panel = {
      ...config.panel,
      width: Math.max(320, Number(settingsEl.querySelector('#tabbit-set-panel-width').value || 460))
    };

    if (!config.currentModel && config.models.length) {
      config.currentModel = config.models[0].value;
    }

    saveConfig();

    renderModelSelect();
    applyFloatButtonPosition();

    if (panelEl) {
      panelEl.style.width = config.panel.width + 'px';
    }

    closeSettings();
    setStatus('设置已保存', 'ok', 1200);
  }

  /******************************************************************
   * API 测试与模型拉取
   ******************************************************************/

  async function testApiConnection() {
    syncModelsFromSettings();

    config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim();
    config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim();
    config.temperature = Number(settingsEl.querySelector('#tabbit-set-temperature').value || 0.7);
    config.maxTokens = Number(settingsEl.querySelector('#tabbit-set-max-tokens').value || 2000);

    if (!config.apiUrl || !config.apiKey || !config.currentModel) {
      alert('请先填写 API 地址、API Key,并设置当前模型。');
      return;
    }

    try {
      const btn = settingsEl.querySelector('#tabbit-test-api');
      btn.disabled = true;
      btn.textContent = '测试中…';

      await callChatApi([
        {
          role: 'user',
          content: '请只回复 OK'
        }
      ]);

      alert('API 测试成功。');
    } catch (err) {
      alert('API 测试失败:\n\n' + (err.message || String(err)));
    } finally {
      const btn = settingsEl.querySelector('#tabbit-test-api');
      btn.disabled = false;
      btn.textContent = '测试 API';
      setInputLoading(false);
    }
  }

  function buildModelsUrl(apiUrl) {
    const url = new URL(apiUrl);

    if (/\/chat\/completions\/?$/i.test(url.pathname)) {
      url.pathname = url.pathname.replace(/\/chat\/completions\/?$/i, '/models');
      return url.toString();
    }

    if (/\/completions\/?$/i.test(url.pathname)) {
      url.pathname = url.pathname.replace(/\/completions\/?$/i, '/models');
      return url.toString();
    }

    url.pathname = url.pathname.replace(/\/+$/, '') + '/models';
    return url.toString();
  }

  function fetchModelsFromApi() {
    config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim();
    config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim();

    if (!config.apiUrl || !config.apiKey) {
      alert('请先填写 API 地址和 API Key。');
      return;
    }

    let modelsUrl = '';

    try {
      modelsUrl = buildModelsUrl(config.apiUrl);
    } catch (err) {
      alert('API 地址格式不正确。');
      return;
    }

    const btn = settingsEl.querySelector('#tabbit-fetch-models');
    btn.disabled = true;
    btn.textContent = '获取中…';

    GM_xmlhttpRequest({
      method: 'GET',
      url: modelsUrl,
      headers: {
        Authorization: `Bearer ${config.apiKey}`
      },
      timeout: 60000,

      onload(res) {
        btn.disabled = false;
        btn.textContent = '获取模型列表';

        try {
          if (res.status < 200 || res.status >= 300) {
            alert(`获取失败:${res.status}\n${res.responseText || ''}`);
            return;
          }

          const data = JSON.parse(res.responseText);
          const ids = Array.isArray(data?.data)
            ? data.data.map(x => x.id || x.name || x.model).filter(Boolean)
            : [];

          if (!ids.length) {
            alert('没有从响应中识别到模型列表。');
            return;
          }

          syncModelsFromSettings();

          ids.forEach(id => {
            if (!config.models.some(m => m.value === id)) {
              config.models.push({
                name: id,
                value: id,
                temperature: '',
                maxTokens: ''
              });
            }
          });

          if (!config.currentModel) {
            config.currentModel = config.models[0]?.value || '';
          }

          renderSettingsModels();
          alert(`已获取 ${ids.length} 个模型。`);
        } catch (err) {
          alert('解析模型列表失败:' + err.message);
        }
      },

      onerror(err) {
        btn.disabled = false;
        btn.textContent = '获取模型列表';
        alert('获取模型列表失败:' + JSON.stringify(err));
      },

      ontimeout() {
        btn.disabled = false;
        btn.textContent = '获取模型列表';
        alert('获取模型列表超时。');
      }
    });
  }

  /******************************************************************
   * 文件导入导出
   ******************************************************************/

  function exportConfigToFile() {
    try {
      saveConfig();

      const data = JSON.stringify(config, null, 2);

      const blob = new Blob([data], {
        type: 'application/json;charset=utf-8'
      });

      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');

      const date = new Date();
      const pad = n => String(n).padStart(2, '0');

      const fileName =
        `tabbit-ai-config-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}.json`;

      a.href = url;
      a.download = fileName;

      document.body.appendChild(a);
      a.click();

      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    } catch (err) {
      alert('导出失败:' + err.message);
    }
  }

  function importConfigFromFile() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.json,application/json';
    input.style.cssText = 'position:fixed;left:-9999px;top:-9999px;';

    document.body.appendChild(input);

    input.addEventListener('change', () => {
      const file = input.files && input.files[0];

      if (!file) {
        input.remove();
        return;
      }

      const reader = new FileReader();

      reader.onload = e => {
        try {
          const imported = JSON.parse(e.target.result);

          config = mergeConfig(clone(DEFAULT_CONFIG), imported);
          saveConfig();

          if (settingsEl) fillSettingsForm();
          if (panelEl) renderModelSelect();

          applyFloatButtonPosition();

          alert('配置导入成功。');
        } catch (err) {
          alert('导入失败:JSON 格式错误。\n\n' + err.message);
        } finally {
          input.remove();
        }
      };

      reader.onerror = () => {
        alert('读取文件失败。');
        input.remove();
      };

      reader.readAsText(file, 'utf-8');
    });

    input.click();
  }

  /******************************************************************
   * 工具函数
   ******************************************************************/

  function copyText(text) {
    if (typeof GM_setClipboard === 'function') {
      GM_setClipboard(text);
      return;
    }

    if (navigator.clipboard) {
      navigator.clipboard.writeText(text);
      return;
    }

    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;';
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    ta.remove();
  }

  function renderMarkdown(text) {
    let html = escapeHtml(text || '');

    html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>');
    html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>');
    html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
    html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
    html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
    html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');

    html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
    html = html.replace(/`([^`]+)`/g, '<code>$1</code>');

    html = html.replace(
      /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
      '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
    );

    html = html
      .split('\n')
      .map(line => {
        if (/^<h\d/.test(line)) return line;

        if (/^\s*[-*]\s+/.test(line)) {
          return `<div class="tabbit-list-item">• ${line.replace(/^\s*[-*]\s+/, '')}</div>`;
        }

        if (!line.trim()) return '<br>';

        return `<p>${line}</p>`;
      })
      .join('');

    return html;
  }

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

  function escapeAttr(str) {
    return escapeHtml(str).replace(/`/g, '&#096;');
  }

  /******************************************************************
   * 样式
   ******************************************************************/

  function createStyles() {
    if (document.querySelector('#tabbit-ai-style')) return;

    const style = document.createElement('style');
    style.id = 'tabbit-ai-style';

    style.textContent = `
      #tabbit-ai-float-btn {
        position: fixed;
        z-index: 2147483645;
        width: 28px;
        height: 72px;
        border: none;
        padding: 0;
        margin: 0;
        cursor: pointer;
        background: linear-gradient(160deg, #667eea 0%, #764ba2 100%);
        color: #fff;
        font-size: 13px;
        font-weight: 700;
        box-shadow: 0 4px 16px rgba(0, 0, 0, .18);
        transition: opacity .2s ease, width .2s ease, filter .2s ease, box-shadow .2s ease;
        user-select: none;
        writing-mode: vertical-rl;
        letter-spacing: 2px;
      }

      #tabbit-ai-float-btn.tabbit-float-right {
        border-radius: 10px 0 0 10px;
      }

      #tabbit-ai-float-btn.tabbit-float-left {
        border-radius: 0 10px 10px 0;
      }

      #tabbit-ai-float-btn:hover {
        opacity: 1 !important;
        width: 38px;
        filter: brightness(1.05);
        box-shadow: 0 6px 22px rgba(0, 0, 0, .25);
      }

      #tabbit-ai-float-btn span {
        pointer-events: none;
      }

      body.tabbit-dragging,
      body.tabbit-dragging * {
        cursor: grabbing !important;
      }

      #tabbit-ai-panel {
        position: fixed;
        top: 20px;
        right: 20px;
        max-width: calc(100vw - 40px);
        max-height: calc(100vh - 40px);
        background: #fff;
        color: #222;
        border-radius: 16px;
        box-shadow: 0 8px 32px rgba(0,0,0,.18);
        z-index: 2147483646;
        display: flex;
        flex-direction: column;
        overflow: hidden;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      }

      .tabbit-hidden {
        display: none !important;
      }

      .tabbit-header,
      .tabbit-settings-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 12px 14px;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        color: #fff;
      }

      .tabbit-title,
      .tabbit-settings-title {
        font-weight: 700;
        font-size: 16px;
      }

      .tabbit-header-actions {
        display: flex;
        align-items: center;
        gap: 8px;
      }

      .tabbit-model-select {
        max-width: 150px;
        border: none;
        border-radius: 8px;
        padding: 5px 8px;
        font-size: 12px;
      }

      .tabbit-icon-btn {
        border: none;
        border-radius: 8px;
        background: rgba(255,255,255,.18);
        color: inherit;
        cursor: pointer;
        font-size: 16px;
        line-height: 1;
        padding: 6px 9px;
      }

      .tabbit-icon-btn:hover {
        background: rgba(255,255,255,.28);
      }

      .tabbit-toolbar {
        display: flex;
        flex-wrap: wrap;
        gap: 7px;
        padding: 9px 10px;
        border-bottom: 1px solid #eee;
        background: #fafafa;
      }

      .tabbit-primary-btn,
      .tabbit-secondary-btn,
      .tabbit-danger-btn {
        border: none;
        border-radius: 8px;
        padding: 7px 10px;
        cursor: pointer;
        font-size: 13px;
      }

      .tabbit-primary-btn {
        background: #667eea;
        color: #fff;
      }

      .tabbit-primary-btn:hover {
        background: #5a6fd6;
      }

      .tabbit-secondary-btn {
        background: #f0f0f5;
        color: #444;
      }

      .tabbit-secondary-btn:hover {
        background: #e6e6ef;
      }

      .tabbit-danger-btn {
        background: #fff1f1;
        color: #c00000;
        border: 1px solid #ffcaca;
      }

      .tabbit-danger-btn:hover {
        background: #ffe1e1;
      }

      .tabbit-primary-btn:disabled,
      .tabbit-secondary-btn:disabled {
        opacity: .55;
        cursor: not-allowed;
      }

      .tabbit-status-bar {
        padding: 6px 12px;
        font-size: 12px;
        border-bottom: 1px solid #eee;
        background: #f8f8fc;
        color: #666;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      .tabbit-status-loading {
        color: #5a43c8;
        background: #f4f1ff;
      }

      .tabbit-status-ok {
        color: #0d7a3a;
        background: #effaf3;
      }

      .tabbit-status-error {
        color: #b00000;
        background: #fff1f1;
      }

      .tabbit-chat-list {
        flex: 1;
        overflow-y: auto;
        padding: 14px;
        background: #f7f8fb;
      }

      .tabbit-msg {
        margin-bottom: 12px;
      }

      .tabbit-msg-label {
        font-size: 12px;
        color: #777;
        margin-bottom: 4px;
      }

      .tabbit-msg-body {
        border-radius: 12px;
        padding: 10px 12px;
        font-size: 14px;
        line-height: 1.65;
        word-break: break-word;
      }

      .tabbit-msg-body p {
        margin: 6px 0;
      }

      .tabbit-msg-body h1,
      .tabbit-msg-body h2,
      .tabbit-msg-body h3,
      .tabbit-msg-body h4 {
        margin: 10px 0 6px;
        line-height: 1.4;
      }

      .tabbit-msg-body code {
        background: #ececf4;
        padding: 2px 5px;
        border-radius: 4px;
        color: #c7254e;
      }

      .tabbit-msg-body a {
        color: #667eea;
      }

      .tabbit-list-item {
        margin: 4px 0;
      }

      .tabbit-msg-user .tabbit-msg-body {
        background: #e8f0ff;
        border: 1px solid #d8e4ff;
      }

      .tabbit-msg-assistant .tabbit-msg-body {
        background: #fff;
        border: 1px solid #e9e9ef;
      }

      .tabbit-msg-error .tabbit-msg-body {
        background: #fff1f1;
        border: 1px solid #ffc9c9;
        color: #b00000;
      }

      .tabbit-input-area {
        display: flex;
        gap: 8px;
        padding: 12px;
        background: #fff;
        border-top: 1px solid #eee;
      }

      #tabbit-user-input {
        flex: 1;
        resize: none;
        min-height: 42px;
        max-height: 120px;
        border: 1px solid #ddd;
        border-radius: 10px;
        padding: 9px 10px;
        font-size: 14px;
        line-height: 1.5;
        outline: none;
      }

      #tabbit-user-input:focus {
        border-color: #667eea;
        box-shadow: 0 0 0 2px rgba(102,126,234,.12);
      }

      #tabbit-send-btn {
        width: 72px;
        border: none;
        border-radius: 10px;
        background: #667eea;
        color: #fff;
        cursor: pointer;
      }

      #tabbit-send-btn:disabled {
        opacity: .65;
        cursor: not-allowed;
      }

      #tabbit-settings-modal,
      #tabbit-add-rule-modal,
      #tabbit-preview-modal {
        position: fixed;
        inset: 0;
        background: rgba(0,0,0,.35);
        z-index: 2147483647;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      }

      .tabbit-settings-card {
        width: 860px;
        max-width: calc(100vw - 36px);
        max-height: 88vh;
        display: flex;
        flex-direction: column;
        background: #fff;
        border-radius: 16px;
        overflow: hidden;
        box-shadow: 0 12px 40px rgba(0,0,0,.28);
      }

      .tabbit-small-card {
        width: 620px;
        max-width: calc(100vw - 36px);
        max-height: 86vh;
        display: flex;
        flex-direction: column;
        background: #fff;
        border-radius: 16px;
        overflow: hidden;
        box-shadow: 0 12px 40px rgba(0,0,0,.28);
      }

      .tabbit-preview-card {
        width: 820px;
        max-width: calc(100vw - 36px);
        height: 80vh;
        display: flex;
        flex-direction: column;
        background: #fff;
        border-radius: 16px;
        overflow: hidden;
        box-shadow: 0 12px 40px rgba(0,0,0,.28);
      }

      .tabbit-settings-body {
        padding: 16px 18px;
        overflow-y: auto;
      }

      .tabbit-field {
        display: flex;
        flex-direction: column;
        gap: 6px;
        margin-bottom: 14px;
        font-size: 13px;
        color: #444;
      }

      .tabbit-field small,
      .tabbit-help {
        color: #888;
        line-height: 1.5;
        font-size: 12px;
      }

      .tabbit-checkbox-field {
        flex-direction: row;
        align-items: center;
      }

      .tabbit-checkbox-field input {
        width: auto !important;
      }

      .tabbit-field input,
      .tabbit-field textarea,
      .tabbit-field select,
      .tabbit-url-rule-row input,
      .tabbit-url-rule-row select,
      .tabbit-model-row input,
      .tabbit-template-row input,
      .tabbit-template-row textarea {
        width: 100%;
        box-sizing: border-box;
        border: 1px solid #ddd;
        border-radius: 10px;
        padding: 8px 9px;
        font-size: 13px;
        outline: none;
        font-family: inherit;
      }

      .tabbit-field textarea,
      .tabbit-template-row textarea {
        resize: vertical;
      }

      .tabbit-field input:focus,
      .tabbit-field textarea:focus,
      .tabbit-field select:focus,
      .tabbit-url-rule-row input:focus,
      .tabbit-url-rule-row select:focus,
      .tabbit-model-row input:focus,
      .tabbit-template-row input:focus,
      .tabbit-template-row textarea:focus {
        border-color: #667eea;
        box-shadow: 0 0 0 2px rgba(102,126,234,.12);
      }

      .tabbit-row-2 {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 12px;
      }

      .tabbit-section-title {
        margin: 18px 0 10px;
        font-weight: 700;
        font-size: 14px;
        color: #333;
      }

      .tabbit-model-list,
      .tabbit-template-list,
      .tabbit-url-rule-list {
        display: flex;
        flex-direction: column;
        gap: 8px;
      }

      .tabbit-model-row {
        display: grid;
        grid-template-columns: 1fr 1.3fr 90px 100px auto 34px;
        gap: 8px;
        align-items: center;
      }

      .tabbit-current-model {
        display: flex;
        align-items: center;
        gap: 4px;
        white-space: nowrap;
        font-size: 12px;
      }

      .tabbit-remove-model,
      .tabbit-remove-url-rule,
      .tabbit-remove-template {
        width: 34px;
        height: 34px;
        border: none;
        border-radius: 8px;
        background: #f2f2f2;
        cursor: pointer;
        font-size: 18px;
        color: #666;
      }

      .tabbit-remove-model:hover,
      .tabbit-remove-url-rule:hover,
      .tabbit-remove-template:hover {
        background: #ffe8e8;
        color: #c00;
      }

      .tabbit-template-row {
        padding: 10px;
        border: 1px solid #eee;
        border-radius: 12px;
        background: #fafafa;
      }

      .tabbit-template-head {
        display: grid;
        grid-template-columns: 1fr 34px;
        gap: 8px;
        margin-bottom: 8px;
      }

      .tabbit-url-rule-row {
        display: grid;
        grid-template-columns: 1.6fr 150px 84px 34px;
        gap: 8px;
        align-items: center;
        padding: 8px;
        border: 1px solid #eee;
        border-radius: 12px;
        background: #fafafa;
      }

      .tabbit-rule-match {
        font-size: 12px;
        color: #aaa;
        white-space: nowrap;
      }

      .tabbit-rule-match.matched {
        color: #0d7a3a;
      }

      .tabbit-settings-actions {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
        margin: 8px 0 14px;
      }

      .tabbit-settings-footer {
        display: flex;
        justify-content: flex-end;
        gap: 10px;
        padding: 12px 18px;
        border-top: 1px solid #eee;
        background: #fafafa;
      }

      .tabbit-rule-candidates {
        display: flex;
        flex-direction: column;
        gap: 8px;
      }

      .tabbit-rule-candidate {
        display: flex;
        gap: 8px;
        align-items: flex-start;
        padding: 8px 10px;
        border: 1px solid #eee;
        border-radius: 10px;
        background: #fafafa;
        font-size: 13px;
        word-break: break-all;
        cursor: pointer;
      }

      #tabbit-preview-text {
        width: 100%;
        height: calc(80vh - 150px);
        box-sizing: border-box;
        resize: none;
        border: 1px solid #ddd;
        border-radius: 10px;
        padding: 12px;
        font-size: 13px;
        line-height: 1.6;
        font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      }

      #tabbit-preview-count {
        font-size: 12px;
        opacity: .85;
        margin-left: 8px;
      }

      @media (max-width: 640px) {
        #tabbit-ai-panel {
          top: 10px;
          right: 10px;
          left: 10px;
          width: auto !important;
          max-width: none;
          height: 82vh !important;
        }

        .tabbit-row-2,
        .tabbit-model-row,
        .tabbit-url-rule-row {
          grid-template-columns: 1fr;
        }

        .tabbit-settings-card,
        .tabbit-small-card,
        .tabbit-preview-card {
          max-width: calc(100vw - 20px);
          max-height: 92vh;
        }
      }
    `;

    document.head.appendChild(style);
  }
})();