Linux.do Agent

OpenAI Chat格式可配置baseUrl/model/key;多会话跨刷新;消息操作(重试/编辑/复制/删除);现代化弹窗UI;点击外部关闭窗口;Discourse工具:搜索/抓话题全帖/查用户近期帖子/分类/最新话题/Top话题/Tag话题/用户Summary(含热门帖子)/单帖/按(topicId+postNumber)完整抓取指定楼(<=10000)/站点最新帖子列表;模型JSON输出自动find/rfind修复并回写history;final.refs 显示到UI;AG悬浮球支持拖动并记忆位置。

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Linux.do Agent
// @namespace    https://example.com/linuxdo-agent
// @version      0.3.2
// @description  OpenAI Chat格式可配置baseUrl/model/key;多会话跨刷新;消息操作(重试/编辑/复制/删除);现代化弹窗UI;点击外部关闭窗口;Discourse工具:搜索/抓话题全帖/查用户近期帖子/分类/最新话题/Top话题/Tag话题/用户Summary(含热门帖子)/单帖/按(topicId+postNumber)完整抓取指定楼(<=10000)/站点最新帖子列表;模型JSON输出自动find/rfind修复并回写history;final.refs 显示到UI;AG悬浮球支持拖动并记忆位置。
// @author       IronMan (原作者: Bytebender, 出处: https://linux.do/t/topic/1341629)
// @match        https://linux.do/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      *
// @require      https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js
// @run-at       document-idle
// @license      GPL-3.0-or-later
// ==/UserScript==

(() => {
  'use strict';

  /******************************************************************
   * 0) 常量 / 存储 Key
   ******************************************************************/
  const APP_PREFIX = 'ldagent-';
  const STORE_KEYS = {
    CONF:   'ld_agent_conf_v2',
    SESS:   'ld_agent_sessions_v2',
    ACTIVE: 'ld_agent_active_session_v2',
    FABPOS: 'ld_agent_fab_pos_v1', // AG 悬浮球位置
    WINPOS: 'ld_agent_win_pos_v1', // 悬浮窗口位置
    SIDECOLLAPSED: 'ld_agent_side_collapsed_v1', // 侧边栏折叠状态
  };

  const FSM = {
    IDLE: 'IDLE',
    RUNNING: 'RUNNING',
    WAITING_MODEL: 'WAITING_MODEL',
    WAITING_TOOL: 'WAITING_TOOL',
    DONE: 'DONE',
    ERROR: 'ERROR',
  };

  const now = () => Date.now();
  const uid = () => 'S' + now().toString(36) + Math.random().toString(36).slice(2, 8);
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  function clamp(str, max = 20000) {
    str = String(str ?? '');
    return str.length > max ? (str.slice(0, max) + '\n...(截断)') : str;
  }
  function stripHtml(html) {
    const div = document.createElement('div');
    div.innerHTML = html || '';
    return (div.textContent || '').trim();
  }
  function safeTitle(t, fb) {
    const s = String(t ?? '').trim();
    return s ? s : (fb || '无题');
  }

  // 轻量 Markdown 文本转义(用于 refs title)
  function mdEscapeText(s) {
    s = String(s ?? '');
    return s.replace(/[\[\]\(\)]/g, (m) => '\\' + m);
  }

  function safeJsonParse(s, fb = null) {
    try { return JSON.parse(s); } catch { return fb; }
  }

  /******************************************************************
   * 1) 配置:OpenAI Chat Completions 兼容
   ******************************************************************/
  const DEFAULT_CONF = {
    baseUrl: 'https://api.openai.com/v1',
    model: 'gpt-4o-mini',
    apiKey: '',
    temperature: 0.2,
    maxTurns: 8,
    maxContextChars: 24000,

    // 是否把“工具结果”作为对话上下文喂给模型
    includeToolContext: true,

    // 系统提示词(工具协议 + 行为约束)
    systemPrompt: `# Role
你不是一个聊天助手,你是一个运行在 linux.do 论坛后端的 **JSON 协议路由引擎**。你的唯一任务是接收用户意图,并严格按照指定协议输出 JSON 数据流。

# Available Tools
你可以调用以下工具(通过输出 JSON 指令):
[搜索, 获取话题全部帖子, 查询用户近期帖子, 分类列表, 最新话题, Top话题, Tag话题, 用户概览(含热门帖子), 单帖详情, 按(话题ID+楼层号)完整获取指定楼, 站点最新帖子列表]

# Protocol Rules (Highest Priority)
1. **禁止废话**:严禁输出任何"好的"、"正在搜索"、"以下是结果"等自然语言。
2. **禁止 Markdown**:严禁使用 \`\`\`json 或 \`\`\` 包裹输出。直接输出原始 JSON 字符串。
3. **二选一输出**:每次响应必须且只能是以下两种 JSON 格式中的一种。

# Output Formats

## Case 1: 当需要获取数据/调用工具时
输出结构:
{
  "type": "tool",
  "name": "工具名称",
  "args": { "参数名": "参数值" }
}

## Case 2: 当拥有足够信息/生成最终回复时
输出结构:
{
  "type": "final",
  "answer": "这里填写回答内容,支持简单Markdown格式,注意转义换行符",
  "refs": [ {"title": "引用标题", "url": "引用链接"} ]
}

# Examples (Strictly Imitate)

User: 帮我找一下关于 Docker 的教程
Agent: {"type": "tool", "name": "搜索", "args": {"keyword": "Docker 教程"}}

User: (系统注入工具返回的 Docker 教程数据)
Agent: {"type": "final", "answer": "在 linux.do 上关于 Docker 的教程主要集中在... \\n\\n你可以参考以下内容...", "refs": [{"title": "Docker入门", "url": "https://..."}]}

# Critical Constraints
- \`refs\` 必须基于工具返回的真实数据,严禁编造 URL。
- 如果工具返回结果为空,\`type\` 必须为 \`final\`,并在 \`answer\` 中告知用户未找到信息。
- 输出必须是合法的单行或多行 JSON 字符串,能够直接被 \`JSON.parse()\` 解析。

开始处理用户输入:`,
  };

  class ConfigStore {
    constructor() {
      const saved = GM_getValue(STORE_KEYS.CONF, null);
      this.conf = { ...DEFAULT_CONF, ...(saved || {}) };
    }
    get() { return this.conf; }
    save(c) { this.conf = { ...this.conf, ...(c || {}) }; GM_setValue(STORE_KEYS.CONF, this.conf); }
  }

  /******************************************************************
   * 2) 多会话存储(跨刷新)
   ******************************************************************/
  class SessionStore {
    constructor() {
      this.sessions = GM_getValue(STORE_KEYS.SESS, []);
      this.activeId = GM_getValue(STORE_KEYS.ACTIVE, null);

      if (!Array.isArray(this.sessions) || !this.sessions.length) {
        const id = uid();
        this.sessions = [this._newSessionObj(id, '新会话')];
        this.activeId = id;
        this._persist();
      }
      if (!this.sessions.some(s => s.id === this.activeId)) {
        this.activeId = this.sessions[0].id;
        this._persist();
      }
    }

    _newSessionObj(id, title) {
      return {
        id,
        title: title || '新会话',
        createdAt: now(),
        fsm: { state: FSM.IDLE, step: 0, lastError: null, isRunning: false },
        chat: [],  // {role:'user'|'assistant', content, ts}
        agent: [], // {role:'agent'|'tool', kind, content, ts}
      };
    }

    all() { return this.sessions; }
    active() { return this.sessions.find(s => s.id === this.activeId) || this.sessions[0]; }
    setActive(id) { this.activeId = id; GM_setValue(STORE_KEYS.ACTIVE, id); }

    create(title='新会话') {
      const s = this._newSessionObj(uid(), title);
      this.sessions.unshift(s);
      this.activeId = s.id;
      this._persist();
      return s;
    }

    rename(id, title) {
      const s = this.sessions.find(x => x.id === id);
      if (!s) return;
      s.title = String(title || '').trim() || '新会话';
      this._persist();
    }

    remove(id) {
      const idx = this.sessions.findIndex(x => x.id === id);
      if (idx < 0) return;
      this.sessions.splice(idx, 1);
      if (!this.sessions.length) {
        const s = this._newSessionObj(uid(), '新会话');
        this.sessions = [s];
        this.activeId = s.id;
      } else if (this.activeId === id) {
        this.activeId = this.sessions[0].id;
      }
      this._persist();
    }

    pushChat(id, msg) {
      const s = this.sessions.find(x => x.id === id);
      if (!s) return;
      s.chat.push(msg);
      this._persist();
    }
    pushAgent(id, msg) {
      const s = this.sessions.find(x => x.id === id);
      if (!s) return;
      s.agent.push(msg);
      this._persist();
    }
    setFSM(id, patch) {
      const s = this.sessions.find(x => x.id === id);
      if (!s) return;
      s.fsm = { ...(s.fsm || {}), ...(patch || {}) };
      this._persist();
    }

    updateLastAgent(id, predicateFn, updaterFn) {
      const s = this.sessions.find(x => x.id === id);
      if (!s || !Array.isArray(s.agent)) return;
      for (let i = s.agent.length - 1; i >= 0; i--) {
        if (predicateFn(s.agent[i])) {
          s.agent[i] = updaterFn(s.agent[i]) || s.agent[i];
          this._persist();
          return;
        }
      }
    }

    clearSession(id) {
      const s = this.sessions.find(x => x.id === id);
      if (!s) return;
      s.chat = [];
      s.agent = [];
      s.fsm = { state: FSM.IDLE, step: 0, lastError: null, isRunning: false };
      this._persist();
    }

    // 删除指定索引的聊天消息
    deleteChatAt(id, index) {
      const s = this.sessions.find(x => x.id === id);
      if (!s || index < 0 || index >= s.chat.length) return;
      s.chat.splice(index, 1);
      this._persist();
    }

    // 编辑指定索引的聊天消息
    editChatAt(id, index, newContent) {
      const s = this.sessions.find(x => x.id === id);
      if (!s || index < 0 || index >= s.chat.length) return;
      s.chat[index].content = newContent;
      this._persist();
    }

    // 从指定索引截断聊天记录(用于重试功能)
    truncateChatFrom(id, index) {
      const s = this.sessions.find(x => x.id === id);
      if (!s || index < 0 || index >= s.chat.length) return;
      s.chat = s.chat.slice(0, index + 1);
      s.agent = []; // 清空 agent 记录
      s.fsm = { state: FSM.IDLE, step: 0, lastError: null, isRunning: false };
      this._persist();
    }

    _persist() {
      GM_setValue(STORE_KEYS.SESS, this.sessions);
      GM_setValue(STORE_KEYS.ACTIVE, this.activeId);
    }
  }

  /******************************************************************
   * 3) Discourse 工具(linux.do 标准 JSON 接口)
   ******************************************************************/
  class DiscourseAPI {
    static headers() { return { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }; }

    static csrfToken() {
      return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
    }

    static async fetchJson(path, opt = {}) {
      const { method = 'GET', body = null, headers = {} } = opt;

      const init = {
        method,
        credentials: 'same-origin',
        headers: { ...this.headers(), ...(headers || {}) },
      };

      if (body != null) {
        init.headers['Content-Type'] = 'application/json';
        init.headers['X-CSRF-Token'] = this.csrfToken();
        init.body = JSON.stringify(body);
      }

      const res = await fetch(path, init);
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);
      return res.json();
    }

    static topicUrl(topicId, postNo = 1) {
      return `${location.origin}/t/${encodeURIComponent(topicId)}/${postNo}`;
    }

    static userUrl(username) {
      return `${location.origin}/u/${encodeURIComponent(username)}`;
    }

    // ===== search =====
    static async search({ q, page = 1, limit = 8 }) {
      const params = new URLSearchParams();
      params.set('q', q);
      params.set('page', String(page));
      params.set('include_blurbs', 'true');
      params.set('skip_context', 'true');

      const data = await this.fetchJson(`/search.json?${params.toString()}`);
      const topicsMap = new Map((data.topics || []).map(t => [t.id, safeTitle(t.fancy_title || t.title, `话题 ${t.id}`)]));

      const posts = (data.posts || []).slice(0, limit).map(p => ({
        topic_id: p.topic_id,
        post_number: p.post_number,
        title: topicsMap.get(p.topic_id) || `话题 ${p.topic_id}`,
        username: p.username,
        created_at: p.created_at,
        blurb: p.blurb || '',
        url: this.topicUrl(p.topic_id, p.post_number),
      }));

      return { q, page, posts };
    }

    // ===== getTopicAllPosts =====
    static async getTopicAllPosts({ topicId, batchSize = 18, maxPosts = 240 }) {
      const first = await this.fetchJson(`/t/${encodeURIComponent(topicId)}.json`);
      const title = safeTitle(first.title, `话题 ${topicId}`);
      const stream = (first.post_stream?.stream || []).slice(0, maxPosts);
      const got = new Map();

      for (const p of (first.post_stream?.posts || [])) got.set(p.id, p);

      for (let i = 0; i < stream.length; i += batchSize) {
        const chunk = stream.slice(i, i + batchSize);
        const params = new URLSearchParams();
        chunk.forEach(id => params.append('post_ids[]', String(id)));
        const data = await this.fetchJson(`/t/${encodeURIComponent(topicId)}/posts.json?${params.toString()}`);
        for (const p of (data.post_stream?.posts || [])) got.set(p.id, p);
        await sleep(160);
      }

      const posts = stream.map(id => got.get(id)).filter(Boolean).map(p => ({
        id: p.id,
        post_number: p.post_number,
        username: p.username,
        created_at: p.created_at,
        cooked: p.cooked || '',
        url: this.topicUrl(topicId, p.post_number),
        like_count: p.like_count,
        reply_count: p.reply_count,
      }));

      return { topicId, title, count: posts.length, posts };
    }

    // ===== getUserRecent =====
    static async getUserRecent({ username, limit = 10 }) {
      const params = new URLSearchParams();
      params.set('offset', '0');
      params.set('limit', String(limit));
      params.set('username', username);
      params.set('filter', '4,5');

      const data = await this.fetchJson(`/user_actions.json?${params.toString()}`);
      const items = (data.user_actions || []).map(a => ({
        action_type: a.action_type,
        title: safeTitle(a.title, `话题 ${a.topic_id}`),
        topic_id: a.topic_id,
        post_number: a.post_number,
        created_at: a.created_at,
        excerpt: a.excerpt || '',
        url: this.topicUrl(a.topic_id, a.post_number),
      }));

      return { username, items };
    }

    // ===== 分类列表 =====
    static async getCategories() {
      return this.fetchJson('/categories.json');
    }

    // ===== 最新话题列表(✅ 增加 no_definitions=true,兼容 more_topics_url)=====
    static async listLatestTopics({ page = 0 } = {}) {
      const params = new URLSearchParams();
      params.set('page', String(page));
      params.set('no_definitions', 'true');
      return this.fetchJson(`/latest.json?${params.toString()}`);
    }

    // ===== Top 话题 =====
    static async listTopTopics({ period = 'weekly', page = 0 } = {}) {
      const params = new URLSearchParams();
      params.set('period', String(period));
      params.set('page', String(page));
      params.set('no_definitions', 'true');
      return this.fetchJson(`/top.json?${params.toString()}`);
    }

    // ===== Tag 下的话题 =====
    static async getTagTopics({ tag, page = 0 } = {}) {
      if (!tag) throw new Error('tag 不能为空');
      const params = new URLSearchParams();
      params.set('page', String(page));
      params.set('no_definitions', 'true');
      return this.fetchJson(`/tag/${encodeURIComponent(tag)}.json?${params.toString()}`);
    }

    // ===== 站点最新帖子列表(✅ 新增:用于“最新帖子”而非“最新话题”)=====
    // Discourse 常见:/posts.json?before=<post_id>
    static async listLatestPosts({ before = null, limit = 20 } = {}) {
      const params = new URLSearchParams();
      if (before !== null && before !== undefined && before !== '') params.set('before', String(before));
      // 有的站支持 per_page / limit,不保证;这里尽量兼容
      params.set('limit', String(Math.max(1, Math.min(50, parseInt(limit, 10) || 20))));
      params.set('no_definitions', 'true');

      let data;
      try {
        data = await this.fetchJson(`/posts.json?${params.toString()}`);
      } catch (e1) {
        // fallback:某些站可能限制 /posts.json,这里给出更明确的错误
        throw new Error(`获取失败:/posts.json 不可用或被限制。${String(e1?.message || e1)}`);
      }

      const arr = Array.isArray(data?.latest_posts) ? data.latest_posts : (Array.isArray(data) ? data : []);
      const posts = arr.slice(0, Math.max(1, Math.min(50, parseInt(limit, 10) || 20))).map(p => {
        const topic_id = p.topic_id;
        const post_number = p.post_number || p.post_number;
        return {
          id: p.id,
          topic_id,
          post_number,
          username: p.username,
          created_at: p.created_at,
          cooked: p.cooked || '',
          raw: p.raw || '',
          like_count: p.like_count,
          url: (topic_id && post_number) ? this.topicUrl(topic_id, post_number) : '',
        };
      });

      return {
        before: before ?? null,
        returned: posts.length,
        posts,
      };
    }

    // ===== 用户 summary(✅ 升级:不仅用户数据,还包含热门帖子/热门话题/近期内容,一次返回尽量多“有价值信息”)=====
    static async getUserSummary({ username } = {}) {
      if (!username) throw new Error('username 不能为空');

      const out = {
        username,
        urls: {
          profile: this.userUrl(username),
          summary: `${this.userUrl(username)}/summary`,
        },
        profile: null,
        summary: null,
        badges: null,
        hot_topics: [],
        hot_posts: [],
        recent_topics: [],
        recent_posts: [],
        _raw: {
          summary_json: null,
          profile_json: null,
          activity_topics_json: null,
          activity_posts_json: null,
        }
      };

      // 1) summary.json(通常含 user_summary/top_topics/top_replies/badges 等)
      let summaryJson = null;
      try {
        summaryJson = await this.fetchJson(`/u/${encodeURIComponent(username)}/summary.json`);
        out._raw.summary_json = summaryJson;
      } catch (e) {
        throw new Error(`获取 summary.json 失败:${String(e?.message || e)}`);
      }

      // 2) profile:/u/username.json(含 user 字段、user_fields、title、website 等)
      try {
        const profileJson = await this.fetchJson(`/u/${encodeURIComponent(username)}.json`);
        out._raw.profile_json = profileJson;
      } catch {
        // profile 可失败,不强制
      }

      // 3) activity/topics(近期创建的话题列表)
      try {
        const params = new URLSearchParams();
        params.set('page', '0');
        params.set('no_definitions', 'true');
        const topicsJson = await this.fetchJson(`/u/${encodeURIComponent(username)}/activity/topics.json?${params.toString()}`);
        out._raw.activity_topics_json = topicsJson;
      } catch {
        // 可失败,不强制
      }

      // 4) activity/posts(近期发言列表:回帖/发帖)
      try {
        const params = new URLSearchParams();
        params.set('page', '0');
        params.set('no_definitions', 'true');
        const postsJson = await this.fetchJson(`/u/${encodeURIComponent(username)}/activity/posts.json?${params.toString()}`);
        out._raw.activity_posts_json = postsJson;
      } catch {
        // 可失败,不强制
      }

      // ===== 归一化提取 =====
      // profile
      const profileUser = out._raw.profile_json?.user || summaryJson?.user || null;
      if (profileUser) {
        out.profile = {
          id: profileUser.id,
          username: profileUser.username,
          name: profileUser.name ?? '',
          title: profileUser.title ?? '',
          trust_level: profileUser.trust_level,
          avatar_template: profileUser.avatar_template,
          created_at: profileUser.created_at,
          last_seen_at: profileUser.last_seen_at,
          last_posted_at: profileUser.last_posted_at,
          badge_count: profileUser.badge_count,
          website: profileUser.website,
          website_name: profileUser.website_name,
          profile_view_count: profileUser.profile_view_count,
          time_read: profileUser.time_read,
          recent_time_read: profileUser.recent_time_read,
          user_fields: profileUser.user_fields || {},
        };
      }

      // summary 核心统计
      const us = summaryJson?.user_summary || summaryJson?.userSummary || null;
      if (us) {
        out.summary = {
          topic_count: us.topic_count,
          reply_count: us.reply_count,
          likes_given: us.likes_given,
          likes_received: us.likes_received,
          days_visited: us.days_visited,
          posts_read_count: us.posts_read_count,
          time_read: us.time_read,
        };
      }

      // badges(尽量原样保留,便于模型/前端用)
      out.badges = {
        user_badges: summaryJson?.user_badges || summaryJson?.userBadges || null,
        badges: summaryJson?.badges || null,
        badge_types: summaryJson?.badge_types || null,
        users: summaryJson?.users || null,
      };

      // hot_topics / hot_posts:
      // A) summary.json 常见字段:top_topics / top_replies
      const topTopics = Array.isArray(summaryJson?.top_topics) ? summaryJson.top_topics : (Array.isArray(summaryJson?.topTopics) ? summaryJson.topTopics : []);
      const topReplies = Array.isArray(summaryJson?.top_replies) ? summaryJson.top_replies : (Array.isArray(summaryJson?.topReplies) ? summaryJson.topReplies : []);

      out.hot_topics = topTopics.map(t => ({
        topic_id: t.id || t.topic_id || t.topicId,
        title: safeTitle(t.fancy_title || t.title, `话题 ${t.id || t.topic_id || ''}`),
        like_count: t.like_count,
        views: t.views,
        reply_count: t.reply_count ?? t.posts_count,
        last_posted_at: t.last_posted_at ?? t.bumped_at,
        category_id: t.category_id,
        tags: t.tags || [],
        url: this.topicUrl(t.id || t.topic_id || t.topicId, 1),
      })).filter(x => x.topic_id);

      out.hot_posts = topReplies.map(p => ({
        topic_id: p.topic_id,
        post_number: p.post_number,
        post_id: p.id,
        title: safeTitle(p.topic_title || p.title, `话题 ${p.topic_id}`),
        like_count: p.like_count,
        created_at: p.created_at,
        excerpt: p.excerpt || '',
        username: p.username,
        url: this.topicUrl(p.topic_id, p.post_number || 1),
      })).filter(x => x.topic_id);

      // B) activity/topics 作为补充热门候选(按 like_count/views/reply_count/last_posted_at 粗略排序)
      const actTopics = out._raw.activity_topics_json?.topic_list?.topics || out._raw.activity_topics_json?.topics || [];
      if (Array.isArray(actTopics) && actTopics.length) {
        const extra = actTopics.map(t => ({
          topic_id: t.id,
          title: safeTitle(t.fancy_title || t.title, `话题 ${t.id}`),
          like_count: t.like_count,
          views: t.views,
          reply_count: t.reply_count ?? (t.posts_count ? Math.max(0, t.posts_count - 1) : undefined),
          last_posted_at: t.last_posted_at ?? t.bumped_at,
          category_id: t.category_id,
          tags: t.tags || [],
          url: this.topicUrl(t.id, 1),
          _score: (t.like_count || 0) * 4 + (t.views || 0) * 0.01 + (t.reply_count || 0) * 2,
        }));

        // recent_topics:activity 原样取前 12
        out.recent_topics = extra.slice(0, 12).map(({ _score, ...rest }) => rest);

        // hot_topics:如果 summary.top_topics 不足,则用 activity 补齐(去重 topic_id)
        const exist = new Set(out.hot_topics.map(x => x.topic_id));
        extra.sort((a, b) => (b._score - a._score));
        for (const t of extra) {
          if (out.hot_topics.length >= 10) break;
          if (!exist.has(t.topic_id)) {
            exist.add(t.topic_id);
            const { _score, ...rest } = t;
            out.hot_topics.push(rest);
          }
        }
      }

      // C) activity/posts 补充 recent_posts / hot_posts(按 like_count)
      const actPosts = out._raw.activity_posts_json?.user_actions || out._raw.activity_posts_json?.posts || out._raw.activity_posts_json?.activity_stream || [];
      if (Array.isArray(actPosts) && actPosts.length) {
        // user_actions.json 风格兼容(字段可能不同)
        const normPosts = actPosts.map(a => {
          const topic_id = a.topic_id || a?.post?.topic_id;
          const post_number = a.post_number || a?.post?.post_number;
          const like_count = a.like_count ?? a?.post?.like_count;
          const excerpt = a.excerpt || a?.post?.excerpt || '';
          const created_at = a.created_at || a?.post?.created_at;
          const username2 = a.username || a?.post?.username || username;
          const title = safeTitle(a.title || a.topic_title || a?.post?.topic_title, topic_id ? `话题 ${topic_id}` : '帖子');
          return {
            topic_id,
            post_number,
            post_id: a.post_id || a.id || a?.post?.id,
            title,
            like_count,
            created_at,
            excerpt,
            username: username2,
            url: (topic_id && post_number) ? this.topicUrl(topic_id, post_number) : '',
          };
        }).filter(x => x.topic_id && x.post_number);

        out.recent_posts = normPosts.slice(0, 12);

        const existPostKey = new Set(out.hot_posts.map(x => `${x.topic_id}#${x.post_number}`));
        const scored = normPosts.map(p => ({ ...p, _score: (p.like_count || 0) * 5 + (p.excerpt ? Math.min(1, p.excerpt.length / 120) : 0) }));
        scored.sort((a, b) => (b._score - a._score));
        for (const p of scored) {
          if (out.hot_posts.length >= 10) break;
          const k = `${p.topic_id}#${p.post_number}`;
          if (!existPostKey.has(k)) {
            existPostKey.add(k);
            const { _score, ...rest } = p;
            out.hot_posts.push(rest);
          }
        }
      }

      // 限制数量(避免爆上下文)
      out.hot_topics = out.hot_topics.slice(0, 10);
      out.hot_posts = out.hot_posts.slice(0, 10);
      out.recent_topics = out.recent_topics.slice(0, 12);
      out.recent_posts = out.recent_posts.slice(0, 12);

      return out;
    }

    // ===== 单个帖子详情(按 postId)=====
    static async getPost({ postId } = {}) {
      if (!postId) throw new Error('postId 不能为空');
      return this.fetchJson(`/posts/${encodeURIComponent(postId)}.json`);
    }

    // ===== 精细获取某话题的某楼(<=10000)=====
    static async getTopicPostFull({ topicId, postNumber = 1, maxChars = 10000 } = {}) {
      if (topicId === undefined || topicId === null || topicId === '') throw new Error('topicId 不能为空');
      const pn = Math.max(1, parseInt(postNumber, 10) || 1);
      const max = Math.max(1000, Math.min(10000, parseInt(maxChars, 10) || 10000));

      const trunc = (s) => {
        s = String(s || '');
        return s.length > max ? (s.slice(0, max) + '\n...(截断)') : s;
      };

      let post = null;
      let title = '';
      let used = '';

      try {
        const data = await this.fetchJson(`/posts/by_number/${encodeURIComponent(topicId)}/${encodeURIComponent(pn)}.json`);
        post = data?.post || data;
        title = safeTitle(post?.topic_title, '');
        used = 'posts/by_number';
      } catch (e1) {
        try {
          const data2 = await this.fetchJson(`/t/${encodeURIComponent(topicId)}/${encodeURIComponent(pn)}.json`);
          title = safeTitle(data2?.title, `话题 ${topicId}`);
          const ps = data2?.post_stream?.posts || [];
          post = ps.find(x => x?.post_number === pn) || ps[0] || null;
          used = 't/{topicId}/{postNumber}.json';
        } catch (e2) {
          throw new Error(`获取失败:by_number与topic视图都不可用。\n- by_number: ${String(e1?.message || e1)}\n- topic_view: ${String(e2?.message || e2)}`);
        }
      }

      if (!post) throw new Error('未找到该楼层帖子');

      const cooked = trunc(post.cooked || '');
      const raw = trunc(post.raw || '');

      const topic_id = post.topic_id || topicId;
      const post_number = post.post_number || pn;

      return {
        topicId: topic_id,
        title: title || safeTitle(post?.topic_title, `话题 ${topic_id}`),
        postId: post.id,
        post_number,
        username: post.username,
        created_at: post.created_at,
        url: this.topicUrl(topic_id, post_number),
        cooked,
        raw,
        maxChars: max,
        endpointUsed: used,
      };
    }
  }

  /******************************************************************
   * 4) 工具注册表
   ******************************************************************/
  const TOOLS_SPEC = [
    '可用工具:',
    '1) discourse.search: {q:string, page?:number, limit?:number}',
    '2) discourse.getTopicAllPosts: {topicId:number|string, batchSize?:number, maxPosts?:number}',
    '3) discourse.getUserRecent: {username:string, limit?:number}',
    '4) discourse.getCategories: {}',
    '5) discourse.listLatestTopics: {page?:number}', // ✅ no_definitions=true
    '6) discourse.listTopTopics: {period?:string, page?:number}', // ✅ no_definitions=true
    '7) discourse.getTagTopics: {tag:string, page?:number}', // ✅ no_definitions=true
    '8) discourse.getUserSummary: {username:string}  // ✅ Rich: 用户信息 + 徽章 + 热门话题/热门帖子 + 近期内容',
    '9) discourse.getPost: {postId:number|string}',
    '10) discourse.getTopicPostFull: {topicId:number|string, postNumber:number, maxChars?:number}', // <=10000
    '11) discourse.listLatestPosts: {before?:number|string|null, limit?:number}  // ✅ 站点最新帖子列表',
  ].join('\n');

  async function runTool(name, args) {
    if (name === 'discourse.search') return DiscourseAPI.search(args);
    if (name === 'discourse.getTopicAllPosts') return DiscourseAPI.getTopicAllPosts(args);
    if (name === 'discourse.getUserRecent') return DiscourseAPI.getUserRecent(args);
    if (name === 'discourse.getCategories') return DiscourseAPI.getCategories();
    if (name === 'discourse.listLatestTopics') return DiscourseAPI.listLatestTopics(args);
    if (name === 'discourse.listTopTopics') return DiscourseAPI.listTopTopics(args);
    if (name === 'discourse.getTagTopics') return DiscourseAPI.getTagTopics(args);
    if (name === 'discourse.getUserSummary') return DiscourseAPI.getUserSummary(args);
    if (name === 'discourse.getPost') return DiscourseAPI.getPost(args);
    if (name === 'discourse.getTopicPostFull') return DiscourseAPI.getTopicPostFull(args);
    if (name === 'discourse.listLatestPosts') return DiscourseAPI.listLatestPosts(args);
    throw new Error(`未知工具: ${name}`);
  }

  /******************************************************************
   * 4.5) toolResultToContext(增强)
   ******************************************************************/
  function toolResultToContext(name, result) {
    const LIMITS = {
      search_items: 12,
      search_excerpt: 420,
      user_recent_items: 16,
      user_recent_excerpt: 420,
      topic_head_posts: 18,
      topic_tail_posts: 8,
      topic_excerpt: 900,
      topic_op_extra: 2200,
      list_topics_items: 30,
      categories_items: 40,
      post_excerpt: 1600,
      topic_post_full_cooked_hint: 2200,

      user_hot_topics: 10,
      user_hot_posts: 10,
      user_recent_topics: 12,
      user_recent_posts: 12,
      user_excerpt: 260,

      latest_posts_items: 24,
      latest_posts_excerpt: 420,
    };

    const MAX_CONTEXT_CHARS = 22000;

    const norm = (s) => stripHtml(String(s || '')).replace(/\s+/g, ' ').trim();
    const cut = (s, n) => {
      s = String(s || '');
      return s.length > n ? (s.slice(0, n) + '…') : s;
    };

    const kv = (k, v) => (v === undefined || v === null || v === '') ? '' : `${k}: ${v}`;
    const joinNonEmpty = (arr, sep = '\n') => arr.filter(Boolean).join(sep);
    const clampCtx = (text) => clamp(text, MAX_CONTEXT_CHARS);

    if (name === 'discourse.search') {
      const posts = (result?.posts || []).slice(0, LIMITS.search_items);
      const lines = posts.map((p, i) => {
        const ex = cut(norm(p.blurb), LIMITS.search_excerpt);
        return joinNonEmpty([
          `${i + 1}. ${safeTitle(p.title, `话题 ${p.topic_id}`)}`,
          `- topic_id: ${p.topic_id} | post_number: ${p.post_number}`,
          `- author: @${p.username} | created_at: ${p.created_at}`,
          `- 摘要: ${ex}`,
          `- 链接: ${p.url}`,
        ]);
      });
      const header = `【TOOL_RESULT discourse.search | q=${result?.q ?? ''} | page=${result?.page ?? ''} | returned=${posts.length}】`;
      return clampCtx(header + '\n' + lines.join('\n\n'));
    }

    if (name === 'discourse.getUserRecent') {
      const items = (result?.items || []).slice(0, LIMITS.user_recent_items);
      const lines = items.map((x, i) => {
        const ex = cut(norm(x.excerpt), LIMITS.user_recent_excerpt);
        const typ = x.action_type === 4 ? '发帖' : (x.action_type === 5 ? '回帖' : `动作${x.action_type}`);
        return joinNonEmpty([
          `${i + 1}. ${typ} | ${safeTitle(x.title, `话题 ${x.topic_id}`)}`,
          `- topic_id: ${x.topic_id} | post_number: ${x.post_number}`,
          `- created_at: ${x.created_at}`,
          `- 摘要: ${ex}`,
          `- 链接: ${x.url}`,
        ]);
      });
      const header = `【TOOL_RESULT discourse.getUserRecent | @${result?.username ?? ''} | returned=${items.length}】`;
      return clampCtx(header + '\n' + lines.join('\n\n'));
    }

    if (name === 'discourse.getTopicAllPosts') {
      const postsAll = (result?.posts || []);
      const count = postsAll.length;

      const head = postsAll.slice(0, LIMITS.topic_head_posts);
      const tail = (count > LIMITS.topic_head_posts)
        ? postsAll.slice(Math.max(LIMITS.topic_head_posts, count - LIMITS.topic_tail_posts))
        : [];

      const formatPost = (p) => {
        const isOP = (p.post_number === 1);
        const n = isOP ? Math.max(LIMITS.topic_excerpt, LIMITS.topic_op_extra) : LIMITS.topic_excerpt;
        const ex = cut(norm(p.cooked), n);
        const likes = (p.like_count !== undefined) ? ` | likes=${p.like_count}` : '';
        return joinNonEmpty([
          `#${p.post_number} @${p.username} ${p.created_at}${likes}`,
          `- 摘要: ${ex}`,
          `- 链接: ${p.url}`,
        ]);
      };

      const headLines = head.map(formatPost);
      const tailLines = tail.map(formatPost);

      const header = joinNonEmpty([
        `【TOOL_RESULT discourse.getTopicAllPosts | ${safeTitle(result?.title, `话题 ${result?.topicId}`)}】`,
        `topicId: ${result?.topicId} | total_posts: ${count}`,
        `hint: 已提供“前${head.length}楼 + 后${tailLines.length}楼”,用于同时覆盖 OP 与最新进展`,
      ]);

      const midGap = (tailLines.length && count > head.length + tailLines.length)
        ? `\n\n…(中间省略 ${count - head.length - tailLines.length} 楼,为节省上下文;如需可用 discourse.getTopicPostFull 抓取指定楼层全文)…\n\n`
        : '\n\n';

      return clampCtx(header + '\n\n' + headLines.join('\n\n') + midGap + tailLines.join('\n\n'));
    }

    if (name === 'discourse.getCategories') {
      const cats = (result?.category_list?.categories || []).slice(0, LIMITS.categories_items);
      const lines = cats.map((c, i) => {
        const slug = c.slug || c.name || '';
        const url = `${location.origin}/c/${encodeURIComponent(slug)}/${c.id}`;
        const desc = cut(norm(c.description || c.description_text || ''), 260);
        return joinNonEmpty([
          `${i + 1}. ${safeTitle(c.name, `分类 ${c.id}`)} (id=${c.id}, slug=${c.slug || ''})`,
          joinNonEmpty([
            kv('- topics', c.topic_count),
            kv('posts', c.post_count),
            kv('users', c.user_count),
            kv('position', c.position),
          ], ' | ').replace(/^\s*\|\s*/,'- '),
          desc ? `- 描述: ${desc}` : '',
          `- 链接: ${url}`,
        ]);
      });
      const header = `【TOOL_RESULT discourse.getCategories | returned=${cats.length}】`;
      return clampCtx(header + '\n' + lines.join('\n\n'));
    }

    if (name === 'discourse.listLatestTopics' || name === 'discourse.listTopTopics' || name === 'discourse.getTagTopics') {
      const topics = (result?.topic_list?.topics || []).slice(0, LIMITS.list_topics_items);

      const metaBits = [];
      if (name === 'discourse.getTagTopics') metaBits.push(kv('tag', result?.tag || result?.tag_name || ''));
      if (name === 'discourse.listTopTopics') metaBits.push(kv('period', result?.period || ''));
      metaBits.push(kv('page', result?.topic_list?.page || result?.page || ''));

      const moreUrl = result?.topic_list?.more_topics_url || result?.topic_list?.more_topics_url;
      const topTags = Array.isArray(result?.topic_list?.top_tags) ? result.topic_list.top_tags.slice(0, 15) : [];

      const lines = topics.map((t, i) => {
        const url = DiscourseAPI.topicUrl(t.id, 1);
        const title = safeTitle(t.fancy_title || t.title, `话题 ${t.id}`);
        const tags = Array.isArray(t.tags) ? t.tags.join(',') : '';
        const last = t.last_posted_at || t.bumped_at || '';
        const postsCount = (t.posts_count !== undefined) ? t.posts_count : undefined;
        const replies = (t.reply_count !== undefined) ? t.reply_count : (postsCount !== undefined ? Math.max(0, postsCount - 1) : undefined);

        return joinNonEmpty([
          `${i + 1}. ${title}`,
          joinNonEmpty([
            kv('- topic_id', t.id),
            kv('category_id', t.category_id),
            kv('tags', tags),
          ], ' | ').replace(/^\s*\|\s*/,'- '),
          joinNonEmpty([
            kv('- posts_count', postsCount),
            kv('replies', replies),
            kv('views', t.views),
            kv('like_count', t.like_count),
            kv('last', last),
          ], ' | ').replace(/^\s*\|\s*/,'- '),
          `- 链接: ${url}`,
        ]);
      });

      const header = `【TOOL_RESULT ${name} | ${metaBits.filter(Boolean).join(' | ')} | returned=${topics.length}】`;
      const extra = joinNonEmpty([
        moreUrl ? `more_topics_url: ${location.origin}${moreUrl}` : '',
        topTags.length ? `top_tags: ${topTags.join(', ')}` : '',
      ]);

      return clampCtx(header + '\n' + (extra ? (extra + '\n\n') : '') + lines.join('\n\n'));
    }

    // ✅ Rich UserSummary:展示用户信息 + 热门话题/热门帖子 + 近期内容 + 关键链接
    if (name === 'discourse.getUserSummary') {
      const r = result || {};
      const u = r.profile || {};
      const s = r.summary || {};
      const hotTopics = (r.hot_topics || []).slice(0, LIMITS.user_hot_topics);
      const hotPosts = (r.hot_posts || []).slice(0, LIMITS.user_hot_posts);
      const recTopics = (r.recent_topics || []).slice(0, LIMITS.user_recent_topics);
      const recPosts = (r.recent_posts || []).slice(0, LIMITS.user_recent_posts);

      const base = [
        `【TOOL_RESULT discourse.getUserSummary | @${r.username || ''} | Rich】`,
        r.urls?.profile ? `profile: ${r.urls.profile}` : '',
        r.urls?.summary ? `summary: ${r.urls.summary}` : '',
        '',
        '--- 用户信息 ---',
        joinNonEmpty([
          kv('id', u.id),
          kv('username', u.username ? '@' + u.username : ''),
          kv('name', u.name),
          kv('title', u.title),
          kv('trust_level', u.trust_level),
          kv('badge_count', u.badge_count),
        ], ' | '),
        joinNonEmpty([
          kv('created_at', u.created_at),
          kv('last_seen_at', u.last_seen_at),
          kv('last_posted_at', u.last_posted_at),
        ], ' | '),
        u.website ? `website: ${u.website}` : '',
        u.profile_view_count !== undefined ? `profile_view_count: ${u.profile_view_count}` : '',
        '',
        '--- 统计摘要 ---',
        joinNonEmpty([
          kv('topic_count', s.topic_count),
          kv('reply_count', s.reply_count),
          kv('likes_given', s.likes_given),
          kv('likes_received', s.likes_received),
        ], ' | '),
        joinNonEmpty([
          kv('days_visited', s.days_visited),
          kv('posts_read_count', s.posts_read_count),
          kv('time_read', s.time_read),
        ], ' | '),
      ].filter(Boolean);

      const fmtTopic = (t, i) => {
        const tags = Array.isArray(t.tags) ? t.tags.join(',') : '';
        const ex = t.excerpt ? cut(norm(t.excerpt), LIMITS.user_excerpt) : '';
        return joinNonEmpty([
          `${i + 1}. ${safeTitle(t.title, `话题 ${t.topic_id}`)}`,
          joinNonEmpty([
            kv('- topic_id', t.topic_id),
            kv('category_id', t.category_id),
            kv('tags', tags),
          ], ' | ').replace(/^\s*\|\s*/,'- '),
          joinNonEmpty([
            kv('- likes', t.like_count),
            kv('views', t.views),
            kv('replies', t.reply_count),
            kv('last', t.last_posted_at),
          ], ' | ').replace(/^\s*\|\s*/,'- '),
          ex ? `- 摘要: ${ex}` : '',
          t.url ? `- 链接: ${t.url}` : '',
        ]);
      };

      const fmtPost = (p, i) => {
        const ex = cut(norm(p.excerpt || p.cooked || ''), LIMITS.user_excerpt);
        return joinNonEmpty([
          `${i + 1}. ${safeTitle(p.title, `话题 ${p.topic_id}`)} #${p.post_number}`,
          joinNonEmpty([
            kv('- topic_id', p.topic_id),
            kv('post_number', p.post_number),
            kv('likes', p.like_count),
            kv('author', p.username ? '@' + p.username : ''),
            kv('created_at', p.created_at),
          ], ' | ').replace(/^\s*\|\s*/,'- '),
          ex ? `- 摘要: ${ex}` : '',
          p.url ? `- 链接: ${p.url}` : '',
        ]);
      };

      const sections = [];

      if (hotTopics.length) {
        sections.push(['', '--- 热门话题(Top Topics / Hot Topics)---', ...hotTopics.map(fmtTopic)].join('\n'));
      }
      if (hotPosts.length) {
        sections.push(['', '--- 热门帖子(Top Replies / Hot Posts)---', ...hotPosts.map(fmtPost)].join('\n'));
      }
      if (recTopics.length) {
        sections.push(['', '--- 近期话题(Recent Topics)---', ...recTopics.map(fmtTopic)].join('\n'));
      }
      if (recPosts.length) {
        sections.push(['', '--- 近期发言(Recent Posts)---', ...recPosts.map(fmtPost)].join('\n'));
      }

      const badgeHint = (r.badges?.user_badges && r.badges?.badges)
        ? `\n--- 徽章(Badges)---\nuser_badges: ${Array.isArray(r.badges.user_badges) ? r.badges.user_badges.length : 'n/a'} | badges: ${Array.isArray(r.badges.badges) ? r.badges.badges.length : 'n/a'}`
        : '';

      return clampCtx(base.join('\n') + '\n' + sections.join('\n') + badgeHint);
    }

    if (name === 'discourse.getPost') {
      const p = result?.post || result || {};
      const cooked = cut(norm(p.cooked || ''), LIMITS.post_excerpt);
      const raw = cut(String(p.raw || ''), Math.min(1200, LIMITS.post_excerpt));
      const url = (p.topic_id && p.post_number) ? DiscourseAPI.topicUrl(p.topic_id, p.post_number) : '';

      const lines = [
        `【TOOL_RESULT discourse.getPost】`,
        `id: ${p.id || ''}`,
        joinNonEmpty([
          kv('topic_id', p.topic_id),
          kv('post_number', p.post_number),
          kv('author', p.username ? '@' + p.username : ''),
          kv('created_at', p.created_at),
        ], ' | '),
        url ? `- 链接: ${url}` : '',
        cooked ? `- cooked 摘要: ${cooked}` : '',
        raw ? `- raw 摘要: ${raw}` : '',
      ].filter(Boolean);

      return clampCtx(lines.join('\n'));
    }

    if (name === 'discourse.getTopicPostFull') {
      const r = result || {};
      const cookedHint = cut(norm(r.cooked || ''), LIMITS.topic_post_full_cooked_hint);

      const lines = [
        `【TOOL_RESULT discourse.getTopicPostFull | ${safeTitle(r.title, `话题 ${r.topicId}`)}】`,
        `topicId: ${r.topicId} | post_number: ${r.post_number} | postId: ${r.postId || ''}`,
        `author: @${r.username || ''} | created_at: ${r.created_at || ''}`,
        `endpointUsed: ${r.endpointUsed || ''} | maxChars: ${r.maxChars || ''}`,
        r.url ? `- 链接: ${r.url}` : '',
        cookedHint ? `- cooked(提示): ${cookedHint}` : '',
        '',
        '--- raw(全文,已限制 <=10000 字符) ---',
        String(r.raw || ''),
      ].filter(Boolean);

      return clampCtx(lines.join('\n'));
    }

    // ✅ 最新帖子列表(站点级)
    if (name === 'discourse.listLatestPosts') {
      const posts = (result?.posts || []).slice(0, LIMITS.latest_posts_items);
      const lines = posts.map((p, i) => {
        const ex = cut(norm(p.cooked || p.raw || ''), LIMITS.latest_posts_excerpt);
        return joinNonEmpty([
          `${i + 1}. @${p.username || ''} | topic=${p.topic_id} #${p.post_number}`,
          joinNonEmpty([
            kv('- post_id', p.id),
            kv('likes', p.like_count),
            kv('created_at', p.created_at),
          ], ' | ').replace(/^\s*\|\s*/,'- '),
          ex ? `- 摘要: ${ex}` : '',
          p.url ? `- 链接: ${p.url}` : '',
        ]);
      });

      const header = `【TOOL_RESULT discourse.listLatestPosts | before=${result?.before ?? 'null'} | returned=${posts.length}】`;
      return clampCtx(header + '\n' + lines.join('\n\n'));
    }

    try {
      const text = JSON.stringify(result, null, 2);
      return clampCtx(`【TOOL_RESULT ${name} | fallback_json】\n` + text);
    } catch {
      return clampCtx(`【TOOL_RESULT ${name} | fallback_text】\n` + String(result));
    }
  }

  /******************************************************************
   * 5) OpenAI Chat Completions 客户端
   ******************************************************************/
  function parseRetryAfterMs(responseHeaders) {
    try {
      const m = String(responseHeaders || '').match(/^\s*retry-after\s*:\s*([^\r\n]+)\s*$/im);
      if (!m) return null;
      const v = m[1].trim();
      if (/^\d+$/.test(v)) return parseInt(v, 10) * 1000;
      const t = Date.parse(v);
      if (!Number.isNaN(t)) {
        const ms = t - Date.now();
        return ms > 0 ? ms : 0;
      }
    } catch {}
    return null;
  }

  function gmRequestOnce({ url, headers, bodyObj, timeoutMs = 30000 }) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'POST',
        url,
        headers: { 'Content-Type': 'application/json', ...(headers || {}) },
        data: JSON.stringify(bodyObj),
        timeout: timeoutMs,

        onload: (res) => resolve(res),
        onerror: (e) => reject(new Error(`网络错误: ${e?.error || e}`)),
        ontimeout: () => reject(new Error(`请求超时: ${timeoutMs}ms`)),
      });
    });
  }

  async function gmPostJson(url, headers, bodyObj, opt = {}) {
    const {
      retries = 3,
      baseDelayMs = 400,
      maxDelayMs = 8000,
      timeoutMs = 30000,
      onlyStatus200 = true,
    } = opt;

    let lastErr;

    for (let attempt = 0; attempt <= retries; attempt++) {
      try {
        const res = await gmRequestOnce({ url, headers, bodyObj, timeoutMs });

        const ok = onlyStatus200 ? (res.status === 200) : (res.status >= 200 && res.status < 300);
        if (!ok) {
          const headRetryMs = parseRetryAfterMs(res.responseHeaders);
          const bodyPreview = String(res.responseText || '').slice(0, 800);
          const err = new Error(`HTTP ${res.status}: ${bodyPreview}`);
          err._httpStatus = res.status;
          err._retryAfterMs = headRetryMs;
          throw err;
        }

        try {
          return JSON.parse(res.responseText);
        } catch {
          throw new Error('响应 JSON 解析失败');
        }
      } catch (e) {
        lastErr = e;
        if (attempt === retries) break;

        const ra = e?._retryAfterMs;
        const backoff = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
        const jitter = Math.floor(Math.random() * 200);
        const waitMs = (typeof ra === 'number') ? ra : (backoff + jitter);

        await sleep(waitMs);
        continue;
      }
    }

    throw lastErr || new Error('请求失败');
  }

  async function callOpenAIChat(messages, conf) {
    const base = String(conf.baseUrl || '').replace(/\/+$/, '');
    const url = base.endsWith('/chat/completions') ? base : (base + '/chat/completions');

    const payload = {
      model: conf.model,
      temperature: conf.temperature ?? 0.2,
      messages,
    };

    const json = await gmPostJson(
      url,
      { Authorization: `Bearer ${conf.apiKey}` },
      payload,
      { retries: 3, onlyStatus200: true }
    );

    const text = json?.choices?.[0]?.message?.content ?? '';
    return String(text);
  }

  /******************************************************************
   * 6) JSON 修复逻辑(find / rfind + 回写 history)
   ******************************************************************/
  function parseModelJsonWithRepair(raw, sessionId, store) {
    const original = String(raw ?? '');

    try {
      const obj = JSON.parse(original);
      return { ok: true, obj, repaired: false, jsonText: original };
    } catch {}

    // 兼容:有些模型会把 JSON 包在前后文本里
    const first = original.indexOf('{');
    const last = original.lastIndexOf('}');
    if (first >= 0 && last > first) {
      const sliced = original.slice(first, last + 1);

      store.updateLastAgent(sessionId,
        (m) => m && m.kind === 'model_raw',
        (m) => ({ ...m, kind: 'model_json_repaired', content: sliced, repairedFrom: original })
      );

      try {
        const obj = JSON.parse(sliced);
        return { ok: true, obj, repaired: true, jsonText: sliced };
      } catch (e) {
        return { ok: false, err: '切片后仍无法解析 JSON', detail: String(e?.message || e), sliced };
      }
    }

    return { ok: false, err: '未找到可用的 JSON 对象边界 { ... }', detail: original.slice(0, 400) };
  }

  /******************************************************************
   * 7) Agent 引擎(FSM + 多轮工具调用)
   ******************************************************************/
  function buildLLMMessagesFromSession(session, conf) {
    const msgs = [];
    msgs.push({ role: 'system', content: conf.systemPrompt + '\n\n' + TOOLS_SPEC });

    const chunks = [];

    for (const m of (session.chat || [])) {
      if (!m?.role || !m?.content) continue;
      chunks.push({ role: m.role, content: String(m.content) });
    }

    for (const a of (session.agent || [])) {
      if (!a?.content) continue;
      if (a.kind === 'tool_context') {
        chunks.push({ role: 'assistant', content: a.content });
      }
    }

    // maxContextChars=0 表示不限制
    const max = conf.maxContextChars || 0;
    if (max > 0) {
      let total = 0;
      const kept = [];
      for (let i = chunks.length - 1; i >= 0; i--) {
        const c = chunks[i];
        const len = (c.content || '').length;
        if (total + len > max) break;
        kept.push(c);
        total += len;
      }
      kept.reverse();
      msgs.push(...kept);
    } else {
      // 不限制,全部保留
      msgs.push(...chunks);
    }

    return msgs;
  }

  async function runAgentTurn(sessionId, store, conf, ui) {
    const session = store.all().find(s => s.id === sessionId);
    if (!session) throw new Error('session not found');

    store.setFSM(sessionId, { state: FSM.WAITING_MODEL, isRunning: true, step: (session.fsm?.step || 0) + 1, lastError: null });
    ui?.renderAll?.();

    const llmMessages = buildLLMMessagesFromSession(session, conf);
    const raw = await callOpenAIChat(llmMessages, conf);

    store.pushAgent(sessionId, { role: 'agent', kind: 'model_raw', content: raw, ts: now() });

    const parsed = parseModelJsonWithRepair(raw, sessionId, store);
    if (!parsed.ok) {
      store.pushAgent(sessionId, { role: 'agent', kind: 'model_parse_error', content: JSON.stringify(parsed, null, 2), ts: now() });
      store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: parsed.err || 'parse error' });
      ui?.renderAll?.();
      throw new Error(parsed.err || '模型 JSON 解析失败');
    }

    const obj = parsed.obj;

    if (obj.type === 'final') {
      const answer = String(obj.answer ?? '').trim() || '(空回答)';

      let refsMd = '';
      if (Array.isArray(obj.refs) && obj.refs.length) {
        const seen = new Set();
        const cleaned = obj.refs
          .map(x => ({
            title: mdEscapeText(String(x?.title ?? '').trim() || '链接'),
            url: String(x?.url ?? '').trim(),
          }))
          .filter(x => x.url && !seen.has(x.url) && (seen.add(x.url), true));

        if (cleaned.length) {
          refsMd = '\n\n---\n**参考链接(refs)**\n' + cleaned.map((r, i) => `${i + 1}. [${r.title}](${r.url})`).join('\n');
        }
      }

      const finalContent = answer + refsMd;
      store.pushChat(sessionId, { role: 'assistant', content: finalContent, ts: now() });

      if (Array.isArray(obj.refs) && obj.refs.length) {
        store.pushAgent(sessionId, { role: 'agent', kind: 'final_refs', content: JSON.stringify(obj.refs, null, 2), ts: now() });
      }

      store.setFSM(sessionId, { state: FSM.DONE, isRunning: false });
      ui?.renderAll?.();
      return { done: true, obj };
    }

    if (obj.type === 'tool') {
      const name = String(obj.name || '').trim();
      const args = obj.args || {};
      if (!name) throw new Error('工具调用缺少 name');

      store.setFSM(sessionId, { state: FSM.WAITING_TOOL, isRunning: true });
      store.pushAgent(sessionId, { role: 'agent', kind: 'tool_call', content: JSON.stringify({ name, args }, null, 2), ts: now() });
      ui?.renderAll?.();

      let result;
      try {
        result = await runTool(name, args);
      } catch (e) {
        const errMsg = `【TOOL_RESULT ERROR ${name}】\nargs=${JSON.stringify(args)}\nerror=${String(e?.message || e)}`;
        store.pushAgent(sessionId, { role: 'tool', kind: 'tool_context', content: errMsg, ts: now(), toolName: name });

        store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true });
        ui?.renderAll?.();
        return { done: false, obj: { type: 'tool_error' } };
      }

      const toolCtx = toolResultToContext(name, result);
      store.pushAgent(sessionId, { role: 'tool', kind: 'tool_context', content: toolCtx, ts: now(), toolName: name });

      store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true });
      ui?.renderAll?.();
      return { done: false, obj: { type: 'tool_ok' } };
    }

    store.pushAgent(sessionId, { role: 'agent', kind: 'model_unknown_type', content: JSON.stringify(obj, null, 2), ts: now() });
    store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: 'unknown type' });
    ui?.renderAll?.();
    throw new Error(`未知 type: ${obj.type}`);
  }

  async function runAgent(sessionId, store, conf, ui) {
    const session = store.all().find(s => s.id === sessionId);
    if (!session) throw new Error('session not found');
    if (!conf.apiKey) throw new Error('请先在设置中填写 API Key');

    if (session.fsm?.isRunning) return;

    store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true, lastError: null });
    ui?.renderAll?.();

    // maxTurns=0 表示不限制,使用一个很大的数
    const maxTurns = (conf.maxTurns === 0) ? 999 : Math.max(1, Math.min(100, parseInt(conf.maxTurns || 8, 10)));

    try {
      for (let i = 0; i < maxTurns; i++) {
        const r = await runAgentTurn(sessionId, store, conf, ui);
        if (r.done) return r.obj;
        await sleep(80);
      }
      store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: '超过 maxTurns 仍未 final' });
      ui?.renderAll?.();
      throw new Error('超过 maxTurns 仍未得到 final');
    } catch (e) {
      store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: String(e?.message || e) });
      ui?.renderAll?.();
      throw e;
    }
  }

  /******************************************************************
   * 8) 前端 UI(抽屉式 + 多会话管理 + 设置面板 + ✅AG拖动)
   ******************************************************************/
  const STYLES = `
    :root{
      --a-bg: rgba(250,250,252,.97);
      --a-card: rgba(255,255,255,.95);
      --a-text: #0e1116;
      --a-sub: #405060;
      --a-border: rgba(0,0,0,.16);
      --a-shadow: 0 18px 44px rgba(0,0,0,.18);
      --a-primary:#1f6dff;
      --a-user:#e8f0ff;
      --a-ass:#ffffff;
      --a-tool:#fff8db;
      --a-code:#f6f8fa;
      --a-codeText:#24292f;
      --a-debug:#f0f4f8;
    }
    @media (prefers-color-scheme: dark){
      :root{
        --a-bg: rgba(16,18,24,.94);
        --a-card: rgba(25,27,36,.92);
        --a-text: #f2f5f8;
        --a-sub: #c7ced9;
        --a-border: rgba(255,255,255,.18);
        --a-shadow: 0 22px 60px rgba(0,0,0,.6);
        --a-primary:#6aa2ff;
        --a-user:#1f2736;
        --a-ass:#171b23;
        --a-tool:#262c39;
        --a-code:#161b22;
        --a-codeText:#d9e1ee;
        --a-debug:#1c2128;
      }
    }

    /* AG 悬浮球 */
    #${APP_PREFIX}fab{
      position:fixed;
      left: calc(100vw - 70px);
      top: 16px;
      width:52px; height:52px; border-radius:14px;
      background: var(--a-card);
      border:1px solid var(--a-border);
      box-shadow: var(--a-shadow);
      z-index:100003;
      display:flex; align-items:center; justify-content:center;
      cursor:pointer; user-select:none;
      color: var(--a-primary);
      font-weight:900;
      touch-action: none;
    }
    #${APP_PREFIX}fab.dragging{
      cursor: grabbing;
      opacity: .92;
      transform: scale(1.02);
    }

    /* 悬浮窗口(类似DevTools) */
    #${APP_PREFIX}drawer{
      position:fixed;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%) scale(0.95);
      width: 900px;
      height: 600px;
      min-width: 600px;
      min-height: 400px;
      max-width: 95vw;
      max-height: 90vh;
      z-index:100002;
      background: var(--a-bg);
      border:1px solid var(--a-border);
      box-shadow: var(--a-shadow);
      border-radius:12px;
      backdrop-filter: blur(18px);
      display:none;
      flex-direction:column;
      color: var(--a-text);
      font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;
      opacity: 0;
      transition: opacity .2s ease, transform .2s ease;
      overflow: hidden;
      resize: both;
    }
    #${APP_PREFIX}drawer.open{
      display:flex;
      opacity: 1;
      transform: translate(-50%, -50%) scale(1);
    }
    /* 窗口被拖动后使用绝对定位 */
    #${APP_PREFIX}drawer.positioned{
      transform: none;
      left: auto;
      top: auto;
    }

    /* 窗口标题栏(可拖动) */
    .${APP_PREFIX}header{
      padding:10px 14px;
      border-bottom:1px solid var(--a-border);
      display:flex; align-items:center; justify-content:space-between;
      background: var(--a-card);
      flex-shrink:0;
      cursor: move;
      user-select: none;
    }
    .${APP_PREFIX}title{
      font-weight:900; letter-spacing:.2px;
      display:flex; align-items:center; gap:10px;
      color: var(--a-primary);
      font-size: 14px;
    }
    .${APP_PREFIX}badge{
      font-size:11px; padding:2px 6px; border-radius:999px;
      border:1px solid var(--a-border);
      color: var(--a-sub);
      font-weight:700;
    }
    .${APP_PREFIX}actions{ display:flex; align-items:center; gap:8px; color:var(--a-sub); }
    .${APP_PREFIX}icon{
      cursor:pointer; padding:6px 10px; border-radius:8px;
      border:1px solid var(--a-border);
      background: rgba(127,127,127,.06);
      color: var(--a-text);
      font-weight:800;
      font-size: 12px;
    }
    .${APP_PREFIX}icon:hover{ border-color: var(--a-primary); }

    .${APP_PREFIX}body{ flex:1; display:flex; min-height:0; overflow:hidden; }

    /* 侧边栏 */
    .${APP_PREFIX}sidebar{
      width: 240px;
      border-right:1px solid var(--a-border);
      padding:10px;
      overflow:auto;
      background: linear-gradient(180deg, rgba(127,127,127,.04), transparent);
      flex-shrink: 0;
      transition: width .2s ease, padding .2s ease;
    }
    .${APP_PREFIX}sidebar.collapsed{
      width: 0;
      padding: 0;
      overflow: hidden;
    }
    .${APP_PREFIX}sideTop{ display:flex; gap:6px; margin-bottom:10px; }
    .${APP_PREFIX}btn{
      border:none; cursor:pointer; border-radius:10px;
      padding:7px 10px; font-weight:800;
      background: var(--a-primary); color:#fff;
      font-size: 12px;
    }
    .${APP_PREFIX}btn:hover{ filter:brightness(1.05); }
    .${APP_PREFIX}btn:disabled{ opacity: .5; cursor: not-allowed; }
    .${APP_PREFIX}btnGhost{
      background: transparent; color: var(--a-text);
      border:1px solid var(--a-border);
      font-weight:800;
    }
    .${APP_PREFIX}sessions{ display:flex; flex-direction:column; gap:6px; }
    .${APP_PREFIX}session{
      border:1px solid var(--a-border);
      border-radius:10px;
      padding:8px 10px;
      background: var(--a-card);
      display:flex; justify-content:space-between; align-items:center; gap:6px;
      cursor:pointer;
      position: relative;
    }
    .${APP_PREFIX}session.active{ border-color: var(--a-primary); box-shadow: 0 0 0 2px rgba(0,214,255,.18) inset; }
    .${APP_PREFIX}session .info{
      flex: 1;
      min-width: 0;
      display: flex;
      flex-direction: column;
    }
    /* 标题行:默认占满宽度,hover时收缩让出操作按钮空间 */
    .${APP_PREFIX}session .t{
      overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
      font-weight:800; color: var(--a-text);
      font-size: 13px;
      transition: max-width .15s ease;
    }
    .${APP_PREFIX}session:hover .t{
      max-width: 100px;
    }
    .${APP_PREFIX}session .s{
      font-size:11px; color: var(--a-sub); font-weight:700;
    }
    /* 操作按钮:默认隐藏,hover时显示 */
    .${APP_PREFIX}ops{
      display: none;
      gap: 4px;
      flex-shrink: 0;
    }
    .${APP_PREFIX}session:hover .${APP_PREFIX}ops{
      display: flex;
    }
    .${APP_PREFIX}op{
      padding:4px 6px; border-radius:6px; border:1px solid var(--a-border);
      background: rgba(127,127,127,.06);
      cursor:pointer; user-select:none;
      font-size: 12px;
      display: flex; align-items: center; justify-content: center;
    }
    .${APP_PREFIX}op:hover{ border-color: var(--a-primary); background: var(--a-primary); color: #fff; }
    .${APP_PREFIX}op svg{ width: 12px; height: 12px; }

    .${APP_PREFIX}main{ flex:1; display:flex; flex-direction:column; min-width:0; overflow:hidden; }

    /* 聊天区域 */
    .${APP_PREFIX}chat{
      flex:1; overflow:auto; padding: 16px 18px;
      line-height:1.7; font-size:14px;
    }

    /* 消息气泡样式 - 对话布局 */
    .${APP_PREFIX}msgWrap{
      display: flex;
      margin: 8px 0;
    }
    .${APP_PREFIX}msgWrap.user{
      justify-content: flex-end;
    }
    .${APP_PREFIX}msgWrap.assistant,
    .${APP_PREFIX}msgWrap.tool,
    .${APP_PREFIX}msgWrap.error{
      justify-content: flex-start;
    }
    .${APP_PREFIX}msg{
      max-width: 75%;
      padding: 10px 14px;
      border-radius:16px;
      color: var(--a-text);
      font-size: 14px;
      position: relative;
    }
    .${APP_PREFIX}msgWrap.user .${APP_PREFIX}msg{
      background: var(--a-primary);
      color: #fff;
      border-bottom-right-radius: 4px;
    }
    .${APP_PREFIX}msgWrap.user .${APP_PREFIX}msg a{ color: #fff; }
    .${APP_PREFIX}msgWrap.assistant .${APP_PREFIX}msg{
      background: var(--a-card);
      border:1px solid var(--a-border);
      border-bottom-left-radius: 4px;
    }
    .${APP_PREFIX}msgWrap.tool .${APP_PREFIX}msg{
      background: var(--a-tool);
      border:1px dashed var(--a-border);
      border-bottom-left-radius: 4px;
      font-size: 13px;
    }
    .${APP_PREFIX}msgWrap.error .${APP_PREFIX}msg{
      background: #ffebee;
      border:1px solid #f44336;
      border-bottom-left-radius: 4px;
      color: #c62828;
    }
    @media (prefers-color-scheme: dark){
      .${APP_PREFIX}msgWrap.error .${APP_PREFIX}msg{
        background: #2d1f1f;
        border-color: #b71c1c;
        color: #ef9a9a;
      }
    }
    .${APP_PREFIX}meta{
      font-size:11px; color: var(--a-sub);
      margin-bottom:4px;
      font-weight:700;
    }
    .${APP_PREFIX}msgWrap.user .${APP_PREFIX}meta{
      color: rgba(255,255,255,.7);
    }
    .${APP_PREFIX}md a{ color: var(--a-primary); text-decoration: underline; text-underline-offset:2px; }
    .${APP_PREFIX}md code{ background: rgba(127,127,127,.16); padding: 2px 6px; border-radius: 6px; font-size: 13px; }
    .${APP_PREFIX}md pre{
      background: var(--a-code); color: var(--a-codeText);
      padding: 10px; border-radius:10px; overflow:auto;
      border:1px solid var(--a-border);
      font-size: 13px;
    }

    /* 调试信息样式 - 符合主题 */
    .${APP_PREFIX}debugBlock{
      background: var(--a-debug);
      border: 1px solid var(--a-border);
      border-radius: 10px;
      padding: 10px 12px;
      margin: 8px 0;
      font-size: 12px;
      color: var(--a-sub);
    }
    .${APP_PREFIX}debugBlock pre{
      background: var(--a-code);
      color: var(--a-codeText);
      padding: 8px;
      border-radius: 6px;
      overflow: auto;
      margin: 6px 0 0 0;
      font-size: 11px;
      border: 1px solid var(--a-border);
    }

    /* 输入区域 */
    .${APP_PREFIX}composer{
      border-top:1px solid var(--a-border);
      padding:10px 14px;
      display:flex; gap:10px; align-items:flex-end;
      background: var(--a-card);
    }
    .${APP_PREFIX}ta{
      flex:1;
      min-height:40px; max-height:150px;
      resize:none;
      border-radius:12px;
      border:1px solid var(--a-border);
      padding:10px 12px;
      background: var(--a-bg);
      color: var(--a-text);
      outline:none;
      font-size: 14px;
    }
    .${APP_PREFIX}ta:focus{ border-color: var(--a-primary); }
    .${APP_PREFIX}smallToggle{ display:flex; align-items:center; gap:5px; font-size:12px; color: var(--a-sub); font-weight:800; }
    .${APP_PREFIX}smallToggle input{ margin:0; }

    /* 发送中的气泡提示 */
    .${APP_PREFIX}thinking{
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 10px 14px;
      background: var(--a-card);
      border: 1px solid var(--a-border);
      border-radius: 16px;
      border-bottom-left-radius: 4px;
      font-size: 13px;
      color: var(--a-sub);
    }
    .${APP_PREFIX}thinking .dots{
      display: flex;
      gap: 4px;
    }
    .${APP_PREFIX}thinking .dots span{
      width: 6px; height: 6px;
      background: var(--a-primary);
      border-radius: 50%;
      animation: ${APP_PREFIX}bounce .6s infinite alternate;
    }
    .${APP_PREFIX}thinking .dots span:nth-child(2){ animation-delay: .2s; }
    .${APP_PREFIX}thinking .dots span:nth-child(3){ animation-delay: .4s; }
    @keyframes ${APP_PREFIX}bounce{
      from{ opacity: .3; transform: translateY(0); }
      to{ opacity: 1; transform: translateY(-4px); }
    }

    /* 设置弹窗 */
    .${APP_PREFIX}overlay{
      position:fixed; inset:0;
      background: rgba(0,0,0,.6);
      backdrop-filter: blur(4px);
      z-index:100004;
      display:none;
      align-items:center;
      justify-content:center;
    }
    .${APP_PREFIX}overlay.open{ display:flex; }
    .${APP_PREFIX}modal{
      width: 580px; max-width: 92vw;
      max-height: 85vh;
      overflow: auto;
      border-radius: 14px;
      border:1px solid var(--a-border);
      background: var(--a-card);
      box-shadow: var(--a-shadow);
      padding: 16px;
      color: var(--a-text);
    }
    .${APP_PREFIX}formRow{ margin: 8px 0; }
    .${APP_PREFIX}formRow label{ display:block; font-size:12px; font-weight:900; color:var(--a-sub); margin-bottom:4px; }
    .${APP_PREFIX}formRow input, .${APP_PREFIX}formRow textarea{
      width:100%; box-sizing:border-box;
      border-radius: 10px;
      border:1px solid var(--a-border);
      padding: 8px 10px;
      background: var(--a-bg);
      color: var(--a-text);
      outline:none;
      font-size: 13px;
    }
    .${APP_PREFIX}formRow input:focus, .${APP_PREFIX}formRow textarea:focus{ border-color: var(--a-primary); }
    .${APP_PREFIX}formHint{ font-size: 11px; color: var(--a-sub); margin-top: 2px; }
    .${APP_PREFIX}formActions{ display:flex; justify-content:flex-end; gap:8px; margin-top: 14px; }

    #${APP_PREFIX}toast{
      position:fixed; right: 90px; top: 72px;
      z-index:100005;
      background: rgba(0,0,0,.82);
      color:#fff;
      padding: 8px 14px;
      border-radius: 999px;
      opacity:0;
      pointer-events:none;
      transition: .25s;
      font-weight:800;
      font-size: 13px;
      max-width: 60vw;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    #${APP_PREFIX}toast.show{ opacity:1; }

    /* 消息操作按钮 */
    .${APP_PREFIX}msgActions{
      display: flex;
      gap: 4px;
      margin-top: 6px;
      opacity: 0;
      transition: opacity .15s ease;
    }
    .${APP_PREFIX}msgWrap:hover .${APP_PREFIX}msgActions{
      opacity: 1;
    }
    .${APP_PREFIX}msgAction{
      padding: 4px 8px;
      border-radius: 6px;
      border: 1px solid var(--a-border);
      background: rgba(127,127,127,.08);
      cursor: pointer;
      font-size: 11px;
      color: var(--a-sub);
      display: flex;
      align-items: center;
      gap: 4px;
      transition: all .15s ease;
    }
    .${APP_PREFIX}msgAction:hover{
      border-color: var(--a-primary);
      color: var(--a-primary);
      background: rgba(31,109,255,.08);
    }
    .${APP_PREFIX}msgAction svg{
      width: 12px;
      height: 12px;
    }
    .${APP_PREFIX}msgWrap.user .${APP_PREFIX}msgActions{
      justify-content: flex-end;
    }
    .${APP_PREFIX}msgWrap.user .${APP_PREFIX}msgAction{
      background: rgba(255,255,255,.15);
      border-color: rgba(255,255,255,.3);
      color: rgba(255,255,255,.8);
    }
    .${APP_PREFIX}msgWrap.user .${APP_PREFIX}msgAction:hover{
      background: rgba(255,255,255,.25);
      border-color: rgba(255,255,255,.5);
      color: #fff;
    }

    /* 现代化确认弹窗 */
    .${APP_PREFIX}confirmOverlay{
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,.5);
      backdrop-filter: blur(4px);
      z-index: 100010;
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 0;
      visibility: hidden;
      transition: opacity .2s ease, visibility .2s ease;
    }
    .${APP_PREFIX}confirmOverlay.open{
      opacity: 1;
      visibility: visible;
    }
    .${APP_PREFIX}confirmBox{
      background: var(--a-card);
      border: 1px solid var(--a-border);
      border-radius: 14px;
      box-shadow: var(--a-shadow);
      padding: 20px 24px;
      min-width: 300px;
      max-width: 90vw;
      transform: scale(0.9);
      transition: transform .2s ease;
    }
    .${APP_PREFIX}confirmOverlay.open .${APP_PREFIX}confirmBox{
      transform: scale(1);
    }
    .${APP_PREFIX}confirmTitle{
      font-size: 15px;
      font-weight: 800;
      color: var(--a-text);
      margin-bottom: 8px;
    }
    .${APP_PREFIX}confirmMsg{
      font-size: 13px;
      color: var(--a-sub);
      margin-bottom: 18px;
      line-height: 1.5;
    }
    .${APP_PREFIX}confirmActions{
      display: flex;
      justify-content: flex-end;
      gap: 10px;
    }
    .${APP_PREFIX}confirmBtn{
      padding: 8px 16px;
      border-radius: 8px;
      font-size: 13px;
      font-weight: 700;
      cursor: pointer;
      border: 1px solid var(--a-border);
      background: var(--a-bg);
      color: var(--a-text);
      transition: all .15s ease;
    }
    .${APP_PREFIX}confirmBtn:hover{
      border-color: var(--a-primary);
    }
    .${APP_PREFIX}confirmBtn.primary{
      background: var(--a-primary);
      border-color: var(--a-primary);
      color: #fff;
    }
    .${APP_PREFIX}confirmBtn.primary:hover{
      filter: brightness(1.1);
    }
    .${APP_PREFIX}confirmBtn.danger{
      background: #dc3545;
      border-color: #dc3545;
      color: #fff;
    }
    .${APP_PREFIX}confirmBtn.danger:hover{
      filter: brightness(1.1);
    }

    /* 编辑消息弹窗 */
    .${APP_PREFIX}editOverlay{
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,.5);
      backdrop-filter: blur(4px);
      z-index: 100010;
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 0;
      visibility: hidden;
      transition: opacity .2s ease, visibility .2s ease;
    }
    .${APP_PREFIX}editOverlay.open{
      opacity: 1;
      visibility: visible;
    }
    .${APP_PREFIX}editBox{
      background: var(--a-card);
      border: 1px solid var(--a-border);
      border-radius: 14px;
      box-shadow: var(--a-shadow);
      padding: 20px 24px;
      width: 500px;
      max-width: 90vw;
      transform: scale(0.9);
      transition: transform .2s ease;
    }
    .${APP_PREFIX}editOverlay.open .${APP_PREFIX}editBox{
      transform: scale(1);
    }
    .${APP_PREFIX}editTitle{
      font-size: 15px;
      font-weight: 800;
      color: var(--a-text);
      margin-bottom: 12px;
    }
    .${APP_PREFIX}editTextarea{
      width: 100%;
      min-height: 120px;
      max-height: 300px;
      resize: vertical;
      border-radius: 10px;
      border: 1px solid var(--a-border);
      padding: 10px 12px;
      background: var(--a-bg);
      color: var(--a-text);
      font-size: 14px;
      line-height: 1.6;
      outline: none;
      box-sizing: border-box;
    }
    .${APP_PREFIX}editTextarea:focus{
      border-color: var(--a-primary);
    }
    .${APP_PREFIX}editActions{
      display: flex;
      justify-content: flex-end;
      gap: 10px;
      margin-top: 14px;
    }

    /* 输入框弹窗 */
    .${APP_PREFIX}promptOverlay{
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,.5);
      backdrop-filter: blur(4px);
      z-index: 100010;
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 0;
      visibility: hidden;
      transition: opacity .2s ease, visibility .2s ease;
    }
    .${APP_PREFIX}promptOverlay.open{
      opacity: 1;
      visibility: visible;
    }
    .${APP_PREFIX}promptBox{
      background: var(--a-card);
      border: 1px solid var(--a-border);
      border-radius: 14px;
      box-shadow: var(--a-shadow);
      padding: 20px 24px;
      width: 400px;
      max-width: 90vw;
      transform: scale(0.9);
      transition: transform .2s ease;
    }
    .${APP_PREFIX}promptOverlay.open .${APP_PREFIX}promptBox{
      transform: scale(1);
    }
    .${APP_PREFIX}promptTitle{
      font-size: 15px;
      font-weight: 800;
      color: var(--a-text);
      margin-bottom: 12px;
    }
    .${APP_PREFIX}promptInput{
      width: 100%;
      border-radius: 10px;
      border: 1px solid var(--a-border);
      padding: 10px 12px;
      background: var(--a-bg);
      color: var(--a-text);
      font-size: 14px;
      outline: none;
      box-sizing: border-box;
    }
    .${APP_PREFIX}promptInput:focus{
      border-color: var(--a-primary);
    }
    .${APP_PREFIX}promptActions{
      display: flex;
      justify-content: flex-end;
      gap: 10px;
      margin-top: 14px;
    }

    /* 折叠切换按钮 */
    .${APP_PREFIX}sideToggle{
      position: absolute;
      left: 0;
      top: 50%;
      transform: translateY(-50%);
      width: 20px;
      height: 50px;
      background: var(--a-card);
      border: 1px solid var(--a-border);
      border-left: none;
      border-radius: 0 8px 8px 0;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      color: var(--a-sub);
      font-size: 12px;
      z-index: 10;
    }
    .${APP_PREFIX}sideToggle:hover{ color: var(--a-primary); }
    .${APP_PREFIX}mainWrap{
      flex: 1;
      display: flex;
      flex-direction: column;
      min-width: 0;
      position: relative;
    }
  `;

  class UI {
    constructor(store, confStore) {
      this.store = store;
      this.confStore = confStore;
      this.debugVisible = false;
      this.isSending = false;
      this.sideCollapsed = GM_getValue(STORE_KEYS.SIDECOLLAPSED, false);

      this._injectStyle();
      this._renderShell();
      this._applyFabPosFromStore();
      this._applyWinPosFromStore();
      this._bind();
      this._bindFabDrag();
      this._bindWinDrag();
      this.renderAll();

      GM_registerMenuCommand('打开 Linux.do Agent', () => this.toggleDrawer(true));
      GM_registerMenuCommand('清空当前会话', async () => {
        const s = this.store.active();
        try {
          await this.showConfirm('清空会话', `确定清空会话「${s.title}」吗?`, true);
          this.store.clearSession(s.id);
          this.renderAll();
          this.toast('已清空');
        } catch {
          // 取消
        }
      });
    }

    _injectStyle() {
      const el = document.createElement('style');
      el.textContent = STYLES;
      document.head.appendChild(el);
    }

    _renderShell() {
      const fab = document.createElement('div');
      fab.id = `${APP_PREFIX}fab`;
      fab.textContent = 'AG';
      fab.title = 'Linux.do Agent(可拖动)';
      document.body.appendChild(fab);

      const drawer = document.createElement('div');
      drawer.id = `${APP_PREFIX}drawer`;
      drawer.innerHTML = `
        <div class="${APP_PREFIX}header" id="${APP_PREFIX}header">
          <div class="${APP_PREFIX}title">
            Linux.do Agent <span class="${APP_PREFIX}badge">v0.3.2</span>
          </div>
          <div class="${APP_PREFIX}actions">
            <label class="${APP_PREFIX}smallToggle">
              <input type="checkbox" id="${APP_PREFIX}debugToggle" />
              调试
            </label>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnSetting">设置</button>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnClose">✕</button>
          </div>
        </div>

        <div class="${APP_PREFIX}body">
          <div class="${APP_PREFIX}sidebar" id="${APP_PREFIX}sidebar">
            <div class="${APP_PREFIX}sideTop">
              <button class="${APP_PREFIX}btn" id="${APP_PREFIX}btnNew">+ 新建</button>
              <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}btnExport">导出</button>
            </div>
            <div class="${APP_PREFIX}sessions" id="${APP_PREFIX}sessions"></div>
          </div>

          <div class="${APP_PREFIX}mainWrap">
            <div class="${APP_PREFIX}sideToggle" id="${APP_PREFIX}sideToggle" title="折叠/展开侧边栏">◀</div>
            <div class="${APP_PREFIX}main">
              <div class="${APP_PREFIX}chat" id="${APP_PREFIX}chat"></div>
              <div class="${APP_PREFIX}composer">
                <textarea class="${APP_PREFIX}ta" id="${APP_PREFIX}ta" placeholder="输入问题,例如"搜索xxx""总结话题xxx""查看@用户概览"等"></textarea>
                <div style="display:flex;flex-direction:column;gap:6px;">
                  <button class="${APP_PREFIX}btn" id="${APP_PREFIX}btnSend">发送</button>
                  <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}btnResume">恢复</button>
                </div>
              </div>
            </div>
          </div>
        </div>
      `;
      document.body.appendChild(drawer);

      const overlay = document.createElement('div');
      overlay.className = `${APP_PREFIX}overlay`;
      overlay.innerHTML = `
        <div class="${APP_PREFIX}modal">
          <h3 style="margin:0 0 12px 0;font-size:15px;">⚙️ 设置(OpenAI Chat 格式)</h3>

          <div class="${APP_PREFIX}formRow">
            <label>Base URL</label>
            <input id="${APP_PREFIX}cfgBaseUrl" placeholder="https://api.openai.com/v1" />
          </div>
          <div class="${APP_PREFIX}formRow">
            <label>Model</label>
            <input id="${APP_PREFIX}cfgModel" placeholder="gpt-4o-mini" />
          </div>
          <div class="${APP_PREFIX}formRow">
            <label>API Key</label>
            <input id="${APP_PREFIX}cfgKey" type="password" placeholder="sk-..." />
          </div>

          <div style="display:flex;gap:8px;flex-wrap:wrap;">
            <div class="${APP_PREFIX}formRow" style="flex:1;min-width:120px;">
              <label>Temperature</label>
              <input id="${APP_PREFIX}cfgTemp" type="number" step="0.05" min="0" max="1" />
            </div>
            <div class="${APP_PREFIX}formRow" style="flex:1;min-width:120px;">
              <label>maxTurns</label>
              <input id="${APP_PREFIX}cfgMaxTurns" type="number" step="1" min="0" max="100" />
              <div class="${APP_PREFIX}formHint">0 = 不限制</div>
            </div>
            <div class="${APP_PREFIX}formRow" style="flex:1;min-width:140px;">
              <label>maxContextChars</label>
              <input id="${APP_PREFIX}cfgMaxCtx" type="number" step="500" min="0" max="8000000" />
              <div class="${APP_PREFIX}formHint">0 = 不限制</div>
            </div>
          </div>

          <div class="${APP_PREFIX}formRow">
            <label>System Prompt</label>
            <textarea id="${APP_PREFIX}cfgSys" rows="5"></textarea>
          </div>

          <div style="display:flex;align-items:center;gap:8px;margin-top:8px;">
            <label class="${APP_PREFIX}smallToggle" style="color:var(--a-text);">
              <input type="checkbox" id="${APP_PREFIX}cfgToolCtx" />
              将工具结果作为上下文喂给模型
            </label>
          </div>

          <div class="${APP_PREFIX}formActions">
            <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}cfgTest">连通性测试</button>
            <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}cfgCancel">取消</button>
            <button class="${APP_PREFIX}btn" id="${APP_PREFIX}cfgSave">保存</button>
          </div>
        </div>
      `;
      document.body.appendChild(overlay);

      const toast = document.createElement('div');
      toast.id = `${APP_PREFIX}toast`;
      document.body.appendChild(toast);

      // 现代化确认弹窗
      const confirmOverlay = document.createElement('div');
      confirmOverlay.className = `${APP_PREFIX}confirmOverlay`;
      confirmOverlay.innerHTML = `
        <div class="${APP_PREFIX}confirmBox">
          <div class="${APP_PREFIX}confirmTitle" id="${APP_PREFIX}confirmTitle">确认</div>
          <div class="${APP_PREFIX}confirmMsg" id="${APP_PREFIX}confirmMsg">确定要执行此操作吗?</div>
          <div class="${APP_PREFIX}confirmActions">
            <button class="${APP_PREFIX}confirmBtn" id="${APP_PREFIX}confirmCancel">取消</button>
            <button class="${APP_PREFIX}confirmBtn primary" id="${APP_PREFIX}confirmOk">确定</button>
          </div>
        </div>
      `;
      document.body.appendChild(confirmOverlay);

      // 编辑消息弹窗
      const editOverlay = document.createElement('div');
      editOverlay.className = `${APP_PREFIX}editOverlay`;
      editOverlay.innerHTML = `
        <div class="${APP_PREFIX}editBox">
          <div class="${APP_PREFIX}editTitle">编辑消息</div>
          <textarea class="${APP_PREFIX}editTextarea" id="${APP_PREFIX}editTextarea"></textarea>
          <div class="${APP_PREFIX}editActions">
            <button class="${APP_PREFIX}confirmBtn" id="${APP_PREFIX}editCancel">取消</button>
            <button class="${APP_PREFIX}confirmBtn primary" id="${APP_PREFIX}editSave">保存</button>
          </div>
        </div>
      `;
      document.body.appendChild(editOverlay);

      // 输入框弹窗(用于重命名)
      const promptOverlay = document.createElement('div');
      promptOverlay.className = `${APP_PREFIX}promptOverlay`;
      promptOverlay.innerHTML = `
        <div class="${APP_PREFIX}promptBox">
          <div class="${APP_PREFIX}promptTitle" id="${APP_PREFIX}promptTitle">输入</div>
          <input class="${APP_PREFIX}promptInput" id="${APP_PREFIX}promptInput" type="text" />
          <div class="${APP_PREFIX}promptActions">
            <button class="${APP_PREFIX}confirmBtn" id="${APP_PREFIX}promptCancel">取消</button>
            <button class="${APP_PREFIX}confirmBtn primary" id="${APP_PREFIX}promptOk">确定</button>
          </div>
        </div>
      `;
      document.body.appendChild(promptOverlay);

      this.dom = {
        fab, drawer, overlay, toast,
        confirmOverlay, editOverlay, promptOverlay,
        header: drawer.querySelector(`#${APP_PREFIX}header`),
        btnClose: drawer.querySelector(`#${APP_PREFIX}btnClose`),
        btnSetting: drawer.querySelector(`#${APP_PREFIX}btnSetting`),
        debugToggle: drawer.querySelector(`#${APP_PREFIX}debugToggle`),
        sidebar: drawer.querySelector(`#${APP_PREFIX}sidebar`),
        sideToggle: drawer.querySelector(`#${APP_PREFIX}sideToggle`),
        sessions: drawer.querySelector(`#${APP_PREFIX}sessions`),
        chat: drawer.querySelector(`#${APP_PREFIX}chat`),
        ta: drawer.querySelector(`#${APP_PREFIX}ta`),
        btnSend: drawer.querySelector(`#${APP_PREFIX}btnSend`),
        btnResume: drawer.querySelector(`#${APP_PREFIX}btnResume`),
        btnNew: drawer.querySelector(`#${APP_PREFIX}btnNew`),
        btnExport: drawer.querySelector(`#${APP_PREFIX}btnExport`),

        cfgBaseUrl: overlay.querySelector(`#${APP_PREFIX}cfgBaseUrl`),
        cfgModel: overlay.querySelector(`#${APP_PREFIX}cfgModel`),
        cfgKey: overlay.querySelector(`#${APP_PREFIX}cfgKey`),
        cfgTemp: overlay.querySelector(`#${APP_PREFIX}cfgTemp`),
        cfgMaxTurns: overlay.querySelector(`#${APP_PREFIX}cfgMaxTurns`),
        cfgMaxCtx: overlay.querySelector(`#${APP_PREFIX}cfgMaxCtx`),
        cfgSys: overlay.querySelector(`#${APP_PREFIX}cfgSys`),
        cfgToolCtx: overlay.querySelector(`#${APP_PREFIX}cfgToolCtx`),
        cfgTest: overlay.querySelector(`#${APP_PREFIX}cfgTest`),
        cfgCancel: overlay.querySelector(`#${APP_PREFIX}cfgCancel`),
        cfgSave: overlay.querySelector(`#${APP_PREFIX}cfgSave`),

        // 确认弹窗
        confirmTitle: confirmOverlay.querySelector(`#${APP_PREFIX}confirmTitle`),
        confirmMsg: confirmOverlay.querySelector(`#${APP_PREFIX}confirmMsg`),
        confirmCancel: confirmOverlay.querySelector(`#${APP_PREFIX}confirmCancel`),
        confirmOk: confirmOverlay.querySelector(`#${APP_PREFIX}confirmOk`),

        // 编辑弹窗
        editTextarea: editOverlay.querySelector(`#${APP_PREFIX}editTextarea`),
        editCancel: editOverlay.querySelector(`#${APP_PREFIX}editCancel`),
        editSave: editOverlay.querySelector(`#${APP_PREFIX}editSave`),

        // 输入弹窗
        promptTitle: promptOverlay.querySelector(`#${APP_PREFIX}promptTitle`),
        promptInput: promptOverlay.querySelector(`#${APP_PREFIX}promptInput`),
        promptCancel: promptOverlay.querySelector(`#${APP_PREFIX}promptCancel`),
        promptOk: promptOverlay.querySelector(`#${APP_PREFIX}promptOk`),
      };
    }

    // ✅ 恢复悬浮球位置
    _applyFabPosFromStore() {
      const p = GM_getValue(STORE_KEYS.FABPOS, null);
      const pos = (typeof p === 'string') ? safeJsonParse(p, null) : p;

      if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
        const { x, y } = this._clampFabPos(pos.x, pos.y);
        this.dom.fab.style.left = `${x}px`;
        this.dom.fab.style.top = `${y}px`;
      } else {
        // 默认:右上(用 left/top 计算)
        const w = 52, margin = 18;
        const x = Math.max(margin, window.innerWidth - w - margin);
        const y = 16;
        this.dom.fab.style.left = `${x}px`;
        this.dom.fab.style.top = `${y}px`;
      }
    }

    _saveFabPos(x, y) {
      GM_setValue(STORE_KEYS.FABPOS, { x, y });
    }

    _clampFabPos(x, y) {
      const w = this.dom.fab.offsetWidth || 52;
      const h = this.dom.fab.offsetHeight || 52;
      const margin = 8;
      const maxX = Math.max(margin, window.innerWidth - w - margin);
      const maxY = Math.max(margin, window.innerHeight - h - margin);
      return {
        x: Math.max(margin, Math.min(maxX, x)),
        y: Math.max(margin, Math.min(maxY, y)),
      };
    }

    // ✅ AG 拖动(pointer events;短距离视为点击)
    _bindFabDrag() {
      const fab = this.dom.fab;

      let dragging = false;
      let moved = false;
      let startX = 0, startY = 0;
      let origLeft = 0, origTop = 0;

      const getLeftTop = () => {
        const r = fab.getBoundingClientRect();
        return { left: r.left, top: r.top };
      };

      const onPointerDown = (e) => {
        // 仅主按键
        if (e.button !== undefined && e.button !== 0) return;

        dragging = true;
        moved = false;
        fab.classList.add('dragging');

        const lt = getLeftTop();
        origLeft = lt.left;
        origTop = lt.top;

        startX = e.clientX;
        startY = e.clientY;

        try { fab.setPointerCapture(e.pointerId); } catch {}
        e.preventDefault();
        e.stopPropagation();
      };

      const onPointerMove = (e) => {
        if (!dragging) return;

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

        if (!moved && (Math.abs(dx) + Math.abs(dy) > 6)) moved = true;

        const nx = origLeft + dx;
        const ny = origTop + dy;
        const clamped = this._clampFabPos(nx, ny);

        fab.style.left = `${clamped.x}px`;
        fab.style.top = `${clamped.y}px`;

        e.preventDefault();
        e.stopPropagation();
      };

      const onPointerUp = (e) => {
        if (!dragging) return;
        dragging = false;
        fab.classList.remove('dragging');

        const lt = getLeftTop();
        const clamped = this._clampFabPos(lt.left, lt.top);

        fab.style.left = `${clamped.x}px`;
        fab.style.top = `${clamped.y}px`;
        this._saveFabPos(clamped.x, clamped.y);

        // 短距离不算拖动:当点击处理
        if (!moved) this.toggleDrawer();

        try { fab.releasePointerCapture(e.pointerId); } catch {}
        e.preventDefault();
        e.stopPropagation();
      };

      const onResize = () => {
        // 视口变化时把位置夹回去
        const lt = getLeftTop();
        const clamped = this._clampFabPos(lt.left, lt.top);
        fab.style.left = `${clamped.x}px`;
        fab.style.top = `${clamped.y}px`;
        this._saveFabPos(clamped.x, clamped.y);
      };

      fab.addEventListener('pointerdown', onPointerDown, { passive: false });
      window.addEventListener('pointermove', onPointerMove, { passive: false });
      window.addEventListener('pointerup', onPointerUp, { passive: false });
      window.addEventListener('resize', onResize);
    }

    // 恢复窗口位置
    _applyWinPosFromStore() {
      const p = GM_getValue(STORE_KEYS.WINPOS, null);
      const pos = (typeof p === 'string') ? safeJsonParse(p, null) : p;

      if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
        this.dom.drawer.classList.add('positioned');
        this.dom.drawer.style.left = `${pos.x}px`;
        this.dom.drawer.style.top = `${pos.y}px`;
        if (pos.w) this.dom.drawer.style.width = `${pos.w}px`;
        if (pos.h) this.dom.drawer.style.height = `${pos.h}px`;
      }
    }

    _saveWinPos() {
      const rect = this.dom.drawer.getBoundingClientRect();
      GM_setValue(STORE_KEYS.WINPOS, {
        x: rect.left,
        y: rect.top,
        w: rect.width,
        h: rect.height
      });
    }

    // 窗口标题栏拖动
    _bindWinDrag() {
      const header = this.dom.header;
      const drawer = this.dom.drawer;

      let dragging = false;
      let startX = 0, startY = 0;
      let origLeft = 0, origTop = 0;

      const onMouseDown = (e) => {
        if (e.target.closest('button') || e.target.closest('input') || e.target.closest('label')) return;
        if (e.button !== 0) return;

        dragging = true;
        const rect = drawer.getBoundingClientRect();
        origLeft = rect.left;
        origTop = rect.top;
        startX = e.clientX;
        startY = e.clientY;

        // 切换到绝对定位模式
        if (!drawer.classList.contains('positioned')) {
          drawer.classList.add('positioned');
          drawer.style.left = `${rect.left}px`;
          drawer.style.top = `${rect.top}px`;
        }

        e.preventDefault();
      };

      const onMouseMove = (e) => {
        if (!dragging) return;

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

        let nx = origLeft + dx;
        let ny = origTop + dy;

        // 边界限制
        const w = drawer.offsetWidth;
        const h = drawer.offsetHeight;
        nx = Math.max(0, Math.min(window.innerWidth - w, nx));
        ny = Math.max(0, Math.min(window.innerHeight - h, ny));

        drawer.style.left = `${nx}px`;
        drawer.style.top = `${ny}px`;
      };

      const onMouseUp = () => {
        if (!dragging) return;
        dragging = false;
        this._saveWinPos();
      };

      header.addEventListener('mousedown', onMouseDown);
      window.addEventListener('mousemove', onMouseMove);
      window.addEventListener('mouseup', onMouseUp);
    }

    _bind() {
      const d = this.dom;

      d.btnClose.addEventListener('click', () => this.toggleDrawer(false));

      d.debugToggle.addEventListener('change', () => {
        this.debugVisible = !!d.debugToggle.checked;
        this.renderAll();
      });

      // 侧边栏折叠切换
      d.sideToggle.addEventListener('click', () => {
        this.sideCollapsed = !this.sideCollapsed;
        GM_setValue(STORE_KEYS.SIDECOLLAPSED, this.sideCollapsed);
        this._updateSidebarState();
      });

      d.btnSetting.addEventListener('click', () => {
        this.loadConfToUI();
        this.dom.overlay.classList.add('open');
      });
      d.cfgCancel.addEventListener('click', () => this.dom.overlay.classList.remove('open'));
      d.cfgSave.addEventListener('click', () => this.saveConfFromUI());
      d.cfgTest.addEventListener('click', () => this.testConnection());

      d.btnNew.addEventListener('click', () => {
        this.store.create('新会话');
        this.renderAll();
      });

      d.btnExport.addEventListener('click', () => {
        const s = this.store.active();
        const payload = { title: s.title, createdAt: s.createdAt, chat: s.chat, agent: s.agent, fsm: s.fsm };
        const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = `linuxdo-agent-${(s.title || 'session').slice(0, 24)}.json`;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 120);
      });

      d.sessions.addEventListener('click', async (e) => {
        const card = e.target.closest(`.${APP_PREFIX}session`);
        if (!card) return;
        const id = card.dataset.id;

        const op = e.target.closest('[data-op]');
        if (op) {
          const act = op.dataset.op;
          if (act === 'del') {
            try {
              await this.showConfirm('删除会话', '确定要删除该会话吗?', true);
              this.store.remove(id);
              this.renderAll();
            } catch {
              // 取消
            }
            return;
          }
          if (act === 'ren') {
            const s = this.store.all().find(x => x.id === id);
            try {
              const t = await this.showPrompt('重命名会话', s?.title || '新会话');
              if (t != null && t.trim()) {
                this.store.rename(id, t);
                this.renderAll();
              }
            } catch {
              // 取消
            }
            return;
          }
          if (act === 'clr') {
            try {
              await this.showConfirm('清空会话', '确定要清空该会话吗?', true);
              this.store.clearSession(id);
              this.renderAll();
            } catch {
              // 取消
            }
            return;
          }
        }

        this.store.setActive(id);
        this.renderAll();
      });

      d.ta.addEventListener('input', () => this.autoGrow(d.ta));
      d.ta.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
          e.preventDefault();
          this.send();
        }
      });

      d.btnSend.addEventListener('click', () => this.send());
      d.btnResume.addEventListener('click', () => this.resume());

      window.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') {
          this.dom.overlay.classList.remove('open');
          this.dom.confirmOverlay.classList.remove('open');
          this.dom.editOverlay.classList.remove('open');
          this.dom.promptOverlay.classList.remove('open');
        }
      });

      // 点击页面非脚本界面部分关闭窗口
      // 使用 mousedown 事件在捕获阶段处理,避免与其他 click 事件冲突
      document.addEventListener('mousedown', (e) => {
        if (!this.dom.drawer.classList.contains('open')) return;
        // 检查点击是否在 drawer 或 fab 之外
        const target = e.target;
        if (!this.dom.drawer.contains(target) &&
            !this.dom.fab.contains(target) &&
            !this.dom.overlay.contains(target) &&
            !this.dom.confirmOverlay.contains(target) &&
            !this.dom.editOverlay.contains(target) &&
            !this.dom.promptOverlay.contains(target)) {
          this.toggleDrawer(false);
        }
      });

      // 现代化确认弹窗事件
      d.confirmCancel.addEventListener('click', () => {
        this.dom.confirmOverlay.classList.remove('open');
        if (this._confirmReject) this._confirmReject();
      });
      d.confirmOk.addEventListener('click', () => {
        this.dom.confirmOverlay.classList.remove('open');
        if (this._confirmResolve) this._confirmResolve();
      });
      // 点击背景关闭确认弹窗
      this.dom.confirmOverlay.addEventListener('click', (e) => {
        if (e.target === this.dom.confirmOverlay) {
          this.dom.confirmOverlay.classList.remove('open');
          if (this._confirmReject) this._confirmReject();
        }
      });

      // 编辑弹窗事件
      d.editCancel.addEventListener('click', () => {
        this.dom.editOverlay.classList.remove('open');
        if (this._editReject) this._editReject();
      });
      d.editSave.addEventListener('click', () => {
        this.dom.editOverlay.classList.remove('open');
        if (this._editResolve) this._editResolve(this.dom.editTextarea.value);
      });
      // 点击背景关闭编辑弹窗
      this.dom.editOverlay.addEventListener('click', (e) => {
        if (e.target === this.dom.editOverlay) {
          this.dom.editOverlay.classList.remove('open');
          if (this._editReject) this._editReject();
        }
      });

      // 输入弹窗事件
      d.promptCancel.addEventListener('click', () => {
        this.dom.promptOverlay.classList.remove('open');
        if (this._promptReject) this._promptReject();
      });
      d.promptOk.addEventListener('click', () => {
        this.dom.promptOverlay.classList.remove('open');
        if (this._promptResolve) this._promptResolve(this.dom.promptInput.value);
      });
      d.promptInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
          e.preventDefault();
          this.dom.promptOverlay.classList.remove('open');
          if (this._promptResolve) this._promptResolve(this.dom.promptInput.value);
        }
      });
      // 点击背景关闭输入弹窗
      this.dom.promptOverlay.addEventListener('click', (e) => {
        if (e.target === this.dom.promptOverlay) {
          this.dom.promptOverlay.classList.remove('open');
          if (this._promptReject) this._promptReject();
        }
      });

      // 消息操作事件委托
      d.chat.addEventListener('click', (e) => this._handleMsgAction(e));
    }

    // 现代化确认弹窗方法
    showConfirm(title, message, isDanger = false) {
      return new Promise((resolve, reject) => {
        this._confirmResolve = resolve;
        this._confirmReject = reject;
        this.dom.confirmTitle.textContent = title;
        this.dom.confirmMsg.textContent = message;
        const okBtn = this.dom.confirmOk;
        okBtn.className = `${APP_PREFIX}confirmBtn ${isDanger ? 'danger' : 'primary'}`;
        okBtn.textContent = isDanger ? '删除' : '确定';
        this.dom.confirmOverlay.classList.add('open');
      });
    }

    // 现代化编辑弹窗方法
    showEdit(initialValue = '') {
      return new Promise((resolve, reject) => {
        this._editResolve = resolve;
        this._editReject = reject;
        this.dom.editTextarea.value = initialValue;
        this.dom.editOverlay.classList.add('open');
        setTimeout(() => this.dom.editTextarea.focus(), 100);
      });
    }

    // 现代化输入弹窗方法
    showPrompt(title, initialValue = '') {
      return new Promise((resolve, reject) => {
        this._promptResolve = resolve;
        this._promptReject = reject;
        this.dom.promptTitle.textContent = title;
        this.dom.promptInput.value = initialValue;
        this.dom.promptOverlay.classList.add('open');
        setTimeout(() => this.dom.promptInput.focus(), 100);
      });
    }

    // 处理消息操作按钮点击
    async _handleMsgAction(e) {
      const actionBtn = e.target.closest(`.${APP_PREFIX}msgAction`);
      if (!actionBtn) return;

      const action = actionBtn.dataset.action;
      const index = parseInt(actionBtn.dataset.index, 10);
      const s = this.store.active();

      if (action === 'copy') {
        const msg = s.chat[index];
        if (msg) {
          try {
            await navigator.clipboard.writeText(msg.content);
            this.toast('已复制到剪贴板');
          } catch {
            this.toast('复制失败');
          }
        }
      } else if (action === 'delete') {
        try {
          await this.showConfirm('删除消息', '确定要删除这条消息吗?', true);
          this.store.deleteChatAt(s.id, index);
          this.renderAll();
          this.toast('已删除');
        } catch {
          // 取消
        }
      } else if (action === 'edit') {
        const msg = s.chat[index];
        if (msg) {
          try {
            const newContent = await this.showEdit(msg.content);
            if (newContent !== null && newContent !== msg.content) {
              this.store.editChatAt(s.id, index, newContent);
              this.renderAll();
              this.toast('已保存');
            }
          } catch {
            // 取消
          }
        }
      } else if (action === 'retry') {
        // 重试:截断到当前用户消息,然后重新发送
        const msg = s.chat[index];
        if (msg && msg.role === 'user') {
          try {
            await this.showConfirm('重试', '将从此消息重新生成回复,后续的对话记录将被清除。确定继续吗?');
            this.store.truncateChatFrom(s.id, index);
            this.renderAll();
            // 重新发送
            const conf = this.confStore.get();
            try {
              await runAgent(s.id, this.store, conf, this);
              this.toast('完成');
            } catch (err) {
              this.toast(`失败:${err.message || err}`);
            }
            this.renderAll();
          } catch {
            // 取消
          }
        }
      }
    }

    _updateSidebarState() {
      if (this.sideCollapsed) {
        this.dom.sidebar.classList.add('collapsed');
        this.dom.sideToggle.textContent = '▶';
        this.dom.sideToggle.title = '展开侧边栏';
      } else {
        this.dom.sidebar.classList.remove('collapsed');
        this.dom.sideToggle.textContent = '◀';
        this.dom.sideToggle.title = '折叠侧边栏';
      }
    }

    async testConnection() {
      const baseUrl = this.dom.cfgBaseUrl.value.trim() || DEFAULT_CONF.baseUrl;
      const model = this.dom.cfgModel.value.trim() || DEFAULT_CONF.model;
      const apiKey = this.dom.cfgKey.value.trim();

      if (!apiKey) {
        this.toast('请先填写 API Key');
        return;
      }

      this.dom.cfgTest.disabled = true;
      this.dom.cfgTest.textContent = '测试中...';

      try {
        const url = baseUrl.replace(/\/+$/, '').endsWith('/chat/completions')
          ? baseUrl.replace(/\/+$/, '')
          : baseUrl.replace(/\/+$/, '') + '/chat/completions';

        const payload = {
          model,
          messages: [{ role: 'user', content: 'Hi' }],
          max_tokens: 5
        };

        const result = await gmPostJson(
          url,
          { Authorization: `Bearer ${apiKey}` },
          payload,
          { retries: 0, timeoutMs: 15000, onlyStatus200: true }
        );

        if (result?.choices?.[0]) {
          this.toast('连通性测试成功!');
        } else {
          this.toast('连接成功,但响应格式异常');
        }
      } catch (e) {
        this.toast(`连接失败: ${e.message || e}`);
      } finally {
        this.dom.cfgTest.disabled = false;
        this.dom.cfgTest.textContent = '连通性测试';
      }
    }

    toggleDrawer(force) {
      if (typeof force === 'boolean') {
        this.dom.drawer.classList.toggle('open', force);
      } else {
        this.dom.drawer.classList.toggle('open');
      }
    }

    autoGrow(ta) {
      ta.style.height = 'auto';
      ta.style.height = Math.min(180, Math.max(46, ta.scrollHeight)) + 'px';
    }

    toast(msg) {
      const t = this.dom.toast;
      t.textContent = msg;
      t.classList.add('show');
      clearTimeout(t._timer);
      t._timer = setTimeout(() => t.classList.remove('show'), 2200);
    }

    loadConfToUI() {
      const c = this.confStore.get();
      this.dom.cfgBaseUrl.value = c.baseUrl || DEFAULT_CONF.baseUrl;
      this.dom.cfgModel.value = c.model || DEFAULT_CONF.model;
      this.dom.cfgKey.value = c.apiKey || '';
      this.dom.cfgTemp.value = String(c.temperature ?? 0.2);
      this.dom.cfgMaxTurns.value = String(c.maxTurns ?? 8);
      this.dom.cfgMaxCtx.value = String(c.maxContextChars ?? 24000);
      this.dom.cfgSys.value = c.systemPrompt || DEFAULT_CONF.systemPrompt;
      this.dom.cfgToolCtx.checked = !!c.includeToolContext;
    }

    saveConfFromUI() {
      const baseUrl = this.dom.cfgBaseUrl.value.trim() || DEFAULT_CONF.baseUrl;
      const model = this.dom.cfgModel.value.trim() || DEFAULT_CONF.model;
      const apiKey = this.dom.cfgKey.value.trim();
      const temperature = Math.max(0, Math.min(1, parseFloat(this.dom.cfgTemp.value) || DEFAULT_CONF.temperature));
      // 0 表示不限制
      const maxTurnsVal = parseInt(this.dom.cfgMaxTurns.value, 10);
      const maxTurns = (maxTurnsVal === 0) ? 0 : Math.max(1, Math.min(100, maxTurnsVal || DEFAULT_CONF.maxTurns));
      const maxCtxVal = parseInt(this.dom.cfgMaxCtx.value, 10);
      const maxContextChars = (maxCtxVal === 0) ? 0 : Math.max(4000, Math.min(8000000, maxCtxVal || DEFAULT_CONF.maxContextChars));
      const systemPrompt = this.dom.cfgSys.value.trim() || DEFAULT_CONF.systemPrompt;
      const includeToolContext = !!this.dom.cfgToolCtx.checked;

      this.confStore.save({ baseUrl, model, apiKey, temperature, maxTurns, maxContextChars, systemPrompt, includeToolContext });
      this.dom.overlay.classList.remove('open');
      this.toast('设置已保存');
    }

    renderSessions() {
      const wrap = this.dom.sessions;
      const all = this.store.all();
      const activeId = this.store.active().id;

      // SVG 图标
      const iconRename = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
      const iconClear = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>`;
      const iconDelete = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;

      wrap.innerHTML = all.map(s => {
        const state = s.fsm?.state || FSM.IDLE;
        const running = s.fsm?.isRunning ? ' · 运行中' : '';
        const err = (s.fsm?.state === FSM.ERROR && s.fsm?.lastError) ? ' · 错误' : '';
        const sub = `${state}${running}${err}`;
        const title = s.title || '新会话';
        return `
          <div class="${APP_PREFIX}session ${s.id === activeId ? 'active' : ''}" data-id="${s.id}">
            <div class="info">
              <div class="t" title="${title.replace(/"/g,'&quot;')}">${title}</div>
              <div class="s">${sub}</div>
            </div>
            <div class="${APP_PREFIX}ops">
              <span class="${APP_PREFIX}op" data-op="ren" title="重命名">${iconRename}</span>
              <span class="${APP_PREFIX}op" data-op="clr" title="清空">${iconClear}</span>
              <span class="${APP_PREFIX}op" data-op="del" title="删除">${iconDelete}</span>
            </div>
          </div>
        `;
      }).join('');
    }

    renderChat() {
      const s = this.store.active();
      const wrap = this.dom.chat;
      const blocks = [];

      if (!s.chat.length && !s.fsm?.isRunning) {
        blocks.push(`<div style="opacity:.8;text-align:center;margin-top:50px;color:var(--a-sub);font-size:13px;">输入问题发送,Agent 会调用工具检索后汇总作答</div>`);
      } else {
        for (let i = 0; i < s.chat.length; i++) {
          const m = s.chat[i];
          blocks.push(this.renderMessage(m.role, m.content, m.ts, i));
        }
      }

      // 显示错误信息在对话中
      if (s.fsm?.state === FSM.ERROR && s.fsm?.lastError) {
        blocks.push(this.renderMessage('error', `错误: ${s.fsm.lastError}`, now()));
      }

      // 发送中显示气泡
      if (s.fsm?.isRunning) {
        blocks.push(`
          <div class="${APP_PREFIX}msgWrap assistant">
            <div class="${APP_PREFIX}thinking">
              <div class="dots"><span></span><span></span><span></span></div>
              <span>思考中...</span>
            </div>
          </div>
        `);
      }

      // 调试信息
      if (this.debugVisible && (s.agent || []).length > 0) {
        blocks.push(`<div class="${APP_PREFIX}debugBlock"><strong>调试轨迹</strong></div>`);
        for (const a of (s.agent || [])) {
          const label = `${a.role}:${a.kind || ''}`;
          const content = String(a.content || '').slice(0, 2000);
          blocks.push(`
            <div class="${APP_PREFIX}debugBlock">
              <div style="font-weight:700;margin-bottom:4px;">${label}</div>
              <pre>${content.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))}</pre>
            </div>
          `);
        }
      }

      wrap.innerHTML = blocks.join('\n');
      wrap.scrollTop = wrap.scrollHeight;
    }

    renderMessage(role, content, ts, index = -1) {
      const r = role === 'user' ? 'user' : (role === 'tool' ? 'tool' : (role === 'error' ? 'error' : 'assistant'));
      const time = (() => { try { return new Date(ts || now()).toLocaleString('zh-CN', { hour12: false }); } catch { return ''; } })();

      let html = '';
      try {
        html = (window.marked ? marked.parse(content || '') : String(content || '')).replace(/<a /g, '<a target="_blank" rel="noreferrer" ');
      } catch {
        html = `<pre>${String(content || '').replace(/[<>&]/g, s => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[s]))}</pre>`;
      }

      // SVG 图标
      const iconRetry = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>`;
      const iconEdit = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
      const iconCopy = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
      const iconDelete = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>`;

      // 根据角色生成不同的操作按钮
      let actionsHtml = '';
      if (index >= 0 && role !== 'error') {
        if (role === 'user') {
          // 用户消息:重试、编辑、复制、删除
          actionsHtml = `
            <div class="${APP_PREFIX}msgActions">
              <span class="${APP_PREFIX}msgAction" data-action="retry" data-index="${index}" title="重试">${iconRetry}</span>
              <span class="${APP_PREFIX}msgAction" data-action="edit" data-index="${index}" title="编辑">${iconEdit}</span>
              <span class="${APP_PREFIX}msgAction" data-action="copy" data-index="${index}" title="复制">${iconCopy}</span>
              <span class="${APP_PREFIX}msgAction" data-action="delete" data-index="${index}" title="删除">${iconDelete}</span>
            </div>
          `;
        } else if (role === 'assistant') {
          // 模型回复:复制、删除
          actionsHtml = `
            <div class="${APP_PREFIX}msgActions">
              <span class="${APP_PREFIX}msgAction" data-action="copy" data-index="${index}" title="复制">${iconCopy}</span>
              <span class="${APP_PREFIX}msgAction" data-action="delete" data-index="${index}" title="删除">${iconDelete}</span>
            </div>
          `;
        }
      }

      return `
        <div class="${APP_PREFIX}msgWrap ${r}">
          <div class="${APP_PREFIX}msg">
            <div class="${APP_PREFIX}meta">${time}</div>
            <div class="${APP_PREFIX}md">${html}</div>
            ${actionsHtml}
          </div>
        </div>
      `;
    }

    renderAll() {
      this._updateSidebarState();
      this.renderSessions();
      this.renderChat();

      const s = this.store.active();
      const running = !!s.fsm?.isRunning;
      this.dom.btnSend.disabled = running;
      this.dom.btnResume.disabled = running;
      this.dom.ta.disabled = running;
      // 不再显示"运行中"文字,保持"发送"
      this.dom.btnSend.textContent = '发送';
    }

    async send() {
      if (this.isSending) return;
      const text = this.dom.ta.value.trim();
      if (!text) return;

      const conf = this.confStore.get();
      if (!conf.apiKey) {
        this.toast('请先设置 API Key');
        this.dom.overlay.classList.add('open');
        return;
      }

      const s = this.store.active();
      if (s.fsm?.isRunning) return;

      this.isSending = true;
      this.dom.ta.value = '';
      this.autoGrow(this.dom.ta);

      this.store.pushChat(s.id, { role: 'user', content: text, ts: now() });

      if ((s.title || '') === '新会话') {
        const t = text.replace(/\s+/g, ' ').trim().slice(0, 14) || '新会话';
        this.store.rename(s.id, t);
      }

      this.renderAll();
      try {
        await runAgent(s.id, this.store, conf, this);
        this.toast('完成');
      } catch (e) {
        this.toast(`失败:${e.message || e}`);
      } finally {
        this.isSending = false;
        this.renderAll();
      }
    }

    async resume() {
      const conf = this.confStore.get();
      const s = this.store.active();
      if (s.fsm?.isRunning) return;

      this.toast('尝试恢复…');
      try {
        await runAgent(s.id, this.store, conf, this);
        this.toast('恢复完成');
      } catch (e) {
        this.toast(`恢复失败:${e.message || e}`);
      } finally {
        this.renderAll();
      }
    }
  }

  /******************************************************************
   * 9) 启动
   ******************************************************************/
  function init() {
    if (window.top !== window) return;
    if (document.getElementById(`${APP_PREFIX}fab`)) return;

    const confStore = new ConfigStore();
    const store = new SessionStore();
    new UI(store, confStore);
  }

  if (document.readyState === 'complete' || document.readyState === 'interactive') init();
  else window.addEventListener('load', init);

})();