Linux.do Agent

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

目前為 2025-12-19 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Linux.do Agent
// @namespace    https://example.com/linuxdo-agent
// @version      0.2.4
// @description  OpenAI Chat格式可配置baseUrl/model/key;多会话跨刷新;Discourse工具:搜索/抓话题全帖/查用户近期帖子/分类/最新话题/Top话题/Tag话题/用户Summary(含热门帖子)/单帖/按(topicId+postNumber)完整抓取指定楼(<=10000)/站点最新帖子列表;模型JSON输出自动find/rfind修复并回写history;final.refs 显示到UI;AG悬浮球支持拖动并记忆位置。
// @author       Bytebender
// @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",

    // === UI ENHANCE ===
    UI: "ld_agent_ui_state_v1", // {tab, sidebarCollapsed, theme}
    THEME: "ld_agent_theme_v1", // 主题模式:'light' | 'dark' | 'auto'
  };

  const FSM = {
    IDLE: "IDLE",
    RUNNING: "RUNNING",
    WAITING_MODEL: "WAITING_MODEL",
    WAITING_TOOL: "WAITING_TOOL",
    DONE: "DONE",
    ERROR: "ERROR",
    CANCELLED: "CANCELLED", // UI-only
  };

  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 || "无题";
  }
  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;
    }
  }

  /******************************************************************
   * 0.5) 可取消 Token(Stop / abort)
   ******************************************************************/
  const CANCEL = new Map(); // sessionId -> { cancelled:boolean, aborts:Function[] }
  function ensureCancelToken(sessionId) {
    let t = CANCEL.get(sessionId);
    if (!t) {
      t = { cancelled: false, aborts: [] };
      CANCEL.set(sessionId, t);
    }
    return t;
  }
  function cancelSession(sessionId) {
    const t = CANCEL.get(sessionId);
    if (!t) return;
    t.cancelled = true;
    for (const fn of t.aborts || []) {
      try {
        fn();
      } catch {}
    }
    CANCEL.delete(sessionId);
  }
  function isCancelled(sessionId) {
    const t = CANCEL.get(sessionId);
    return !!t?.cancelled;
  }

  /******************************************************************
   * 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(),
        updatedAt: now(),
        fsm: { state: FSM.IDLE, step: 0, lastError: null, isRunning: false },
        chat: [], // {role:'user'|'assistant', content, ts}
        agent: [], // {role:'agent'|'tool', kind, content, ts}
        draft: "", // === UI ENHANCE === 输入草稿持久化
      };
    }

    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()
          .slice(0, 24) || "新会话";
      s.updatedAt = now();
      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);
      s.updatedAt = now();
      this._persist();
    }
    pushAgent(id, msg) {
      const s = this.sessions.find((x) => x.id === id);
      if (!s) return;
      s.agent.push(msg);
      s.updatedAt = now();
      this._persist();
    }
    setFSM(id, patch) {
      const s = this.sessions.find((x) => x.id === id);
      if (!s) return;
      s.fsm = { ...(s.fsm || {}), ...(patch || {}) };
      s.updatedAt = now();
      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];
          s.updatedAt = now();
          this._persist();
          return;
        }
      }
    }

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

    setDraft(id, text) {
      const s = this.sessions.find((x) => x.id === id);
      if (!s) return;
      s.draft = String(text ?? "");
      s.updatedAt = now();
      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 = {}, signal } = opt;

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

      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)}`;
    }

    static async search({ q, page = 1, limit = 8 }, signal) {
      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()}`, {
        signal,
      });
      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 };
    }

    static async getTopicAllPosts(
      { topicId, batchSize = 18, maxPosts = 240 },
      signal,
      cancelToken
    ) {
      const first = await this.fetchJson(
        `/t/${encodeURIComponent(topicId)}.json`,
        { signal }
      );
      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) {
        if (cancelToken?.cancelled) throw new Error("Cancelled");
        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()}`,
          { signal }
        );
        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 };
    }

    static async getUserRecent({ username, limit = 10 }, signal) {
      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()}`,
        { signal }
      );
      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(signal) {
      return this.fetchJson("/categories.json", { signal });
    }

    static async listLatestTopics({ page = 0 } = {}, signal) {
      const params = new URLSearchParams();
      params.set("page", String(page));
      params.set("no_definitions", "true");
      return this.fetchJson(`/latest.json?${params.toString()}`, { signal });
    }

    static async listTopTopics({ period = "weekly", page = 0 } = {}, signal) {
      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()}`, { signal });
    }

    static async getTagTopics({ tag, page = 0 } = {}, signal) {
      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()}`,
        { signal }
      );
    }

    static async listLatestPosts({ before = null, limit = 20 } = {}, signal) {
      const params = new URLSearchParams();
      if (before !== null && before !== undefined && before !== "")
        params.set("before", String(before));
      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()}`, {
          signal,
        });
      } catch (e1) {
        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 };
    }

    static async getUserSummary({ username } = {}, signal) {
      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,
        },
      };

      let summaryJson = null;
      try {
        summaryJson = await this.fetchJson(
          `/u/${encodeURIComponent(username)}/summary.json`,
          { signal }
        );
        out._raw.summary_json = summaryJson;
      } catch (e) {
        throw new Error(`获取 summary.json 失败:${String(e?.message || e)}`);
      }

      try {
        const profileJson = await this.fetchJson(
          `/u/${encodeURIComponent(username)}.json`,
          { signal }
        );
        out._raw.profile_json = profileJson;
      } catch {}

      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()}`,
          { signal }
        );
        out._raw.activity_topics_json = topicsJson;
      } catch {}

      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()}`,
          { signal }
        );
        out._raw.activity_posts_json = postsJson;
      } catch {}

      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 || {},
        };
      }

      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,
        };
      }

      out.badges = {
        user_badges:
          summaryJson?.user_badges || summaryJson?.userBadges || null,
        badges: summaryJson?.badges || null,
        badge_types: summaryJson?.badge_types || null,
        users: summaryJson?.users || null,
      };

      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);

      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,
        }));

        out.recent_topics = extra
          .slice(0, 12)
          .map(({ _score, ...rest }) => rest);

        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);
          }
        }
      }

      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) {
        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;
    }

    static async getPost({ postId } = {}, signal) {
      if (!postId) throw new Error("postId 不能为空");
      return this.fetchJson(`/posts/${encodeURIComponent(postId)}.json`, {
        signal,
      });
    }

    static async getTopicPostFull(
      { topicId, postNumber = 1, maxChars = 10000 } = {},
      signal
    ) {
      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`,
          { signal }
        );
        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`,
            { signal }
          );
          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}",
    "6) discourse.listTopTopics: {period?:string, page?:number}",
    "7) discourse.getTagTopics: {tag:string, page?:number}",
    "8) discourse.getUserSummary: {username:string}",
    "9) discourse.getPost: {postId:number|string}",
    "10) discourse.getTopicPostFull: {topicId:number|string, postNumber:number, maxChars?:number}",
    "11) discourse.listLatestPosts: {before?:number|string|null, limit?:number}",
  ].join("\n");

  async function runTool(name, args, cancelToken) {
    // 每次工具调用都支持 AbortController(Stop)
    const ac = new AbortController();
    if (cancelToken) {
      cancelToken.aborts.push(() => ac.abort());
      if (cancelToken.cancelled) ac.abort();
    }

    if (name === "discourse.search")
      return DiscourseAPI.search(args, ac.signal);
    if (name === "discourse.getTopicAllPosts")
      return DiscourseAPI.getTopicAllPosts(args, ac.signal, cancelToken);
    if (name === "discourse.getUserRecent")
      return DiscourseAPI.getUserRecent(args, ac.signal);
    if (name === "discourse.getCategories")
      return DiscourseAPI.getCategories(ac.signal);
    if (name === "discourse.listLatestTopics")
      return DiscourseAPI.listLatestTopics(args, ac.signal);
    if (name === "discourse.listTopTopics")
      return DiscourseAPI.listTopTopics(args, ac.signal);
    if (name === "discourse.getTagTopics")
      return DiscourseAPI.getTagTopics(args, ac.signal);
    if (name === "discourse.getUserSummary")
      return DiscourseAPI.getUserSummary(args, ac.signal);
    if (name === "discourse.getPost")
      return DiscourseAPI.getPost(args, ac.signal);
    if (name === "discourse.getTopicPostFull")
      return DiscourseAPI.getTopicPostFull(args, ac.signal);
    if (name === "discourse.listLatestPosts")
      return DiscourseAPI.listLatestPosts(args, ac.signal);
    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;
      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")
      );
    }

    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}` : "",
        "",
        "--- 用户信息 ---",
        [
          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),
        ]
          .filter(Boolean)
          .join(" | "),
        [
          kv("created_at", u.created_at),
          kv("last_seen_at", u.last_seen_at),
          kv("last_posted_at", u.last_posted_at),
        ]
          .filter(Boolean)
          .join(" | "),
        u.website ? `website: ${u.website}` : "",
        u.profile_view_count !== undefined
          ? `profile_view_count: ${u.profile_view_count}`
          : "",
        "",
        "--- 统计摘要 ---",
        [
          kv("topic_count", s.topic_count),
          kv("reply_count", s.reply_count),
          kv("likes_given", s.likes_given),
          kv("likes_received", s.likes_received),
        ]
          .filter(Boolean)
          .join(" | "),
        [
          kv("days_visited", s.days_visited),
          kv("posts_read_count", s.posts_read_count),
          kv("time_read", s.time_read),
        ]
          .filter(Boolean)
          .join(" | "),
      ].filter(Boolean);

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

      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 [
          `${i + 1}. ${safeTitle(t.title, `话题 ${t.topic_id}`)}`,
          [
            kv("- topic_id", t.topic_id),
            kv("category_id", t.category_id),
            kv("tags", tags),
          ]
            .filter(Boolean)
            .join(" | ")
            .replace(/^\s*\|\s*/, "- "),
          [
            kv("- likes", t.like_count),
            kv("views", t.views),
            kv("replies", t.reply_count),
            kv("last", t.last_posted_at),
          ]
            .filter(Boolean)
            .join(" | ")
            .replace(/^\s*\|\s*/, "- "),
          ex ? `- 摘要: ${ex}` : "",
          t.url ? `- 链接: ${t.url}` : "",
        ]
          .filter(Boolean)
          .join("\n");
      };

      const fmtPost = (p, i) => {
        const ex = cut(norm(p.excerpt || p.cooked || ""), LIMITS.user_excerpt);
        return [
          `${i + 1}. ${safeTitle(p.title, `话题 ${p.topic_id}`)} #${
            p.post_number
          }`,
          [
            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),
          ]
            .filter(Boolean)
            .join(" | ")
            .replace(/^\s*\|\s*/, "- "),
          ex ? `- 摘要: ${ex}` : "",
          p.url ? `- 链接: ${p.url}` : "",
        ]
          .filter(Boolean)
          .join("\n");
      };

      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 clamp(
        base.join("\n") + "\n" + sections.join("\n") + badgeHint,
        MAX_CONTEXT_CHARS
      );
    }

    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 || ""}`,
        [
          kv("topic_id", p.topic_id),
          kv("post_number", p.post_number),
          kv("author", p.username ? "@" + p.username : ""),
          kv("created_at", p.created_at),
        ]
          .filter(Boolean)
          .join(" | "),
        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
          }`,
          [
            kv("- post_id", p.id),
            kv("likes", p.like_count),
            kv("created_at", p.created_at),
          ]
            .filter(Boolean)
            .join(" | ")
            .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 客户端(支持 Stop abort)
   ******************************************************************/
  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,
    cancelToken,
  }) {
    return new Promise((resolve, reject) => {
      const req = 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`)),
      });

      if (cancelToken) {
        cancelToken.aborts.push(() => {
          try {
            req.abort();
          } catch {}
        });
        if (cancelToken.cancelled) {
          try {
            req.abort();
          } catch {}
        }
      }
    });
  }

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

    let lastErr;

    for (let attempt = 0; attempt <= retries; attempt++) {
      if (cancelToken?.cancelled) throw new Error("Cancelled");

      try {
        const res = await gmRequestOnce({
          url,
          headers,
          bodyObj,
          timeoutMs,
          cancelToken,
        });

        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, cancelToken) {
    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, cancelToken }
    );

    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 {}

    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 + 多轮工具调用)+ Stop 支持
   ******************************************************************/
  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 });
      }
    }

    let total = 0;
    const kept = [];
    const max = conf.maxContextChars || 24000;
    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);
    return msgs;
  }

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

    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, cancelToken);

    if (cancelToken?.cancelled) throw new Error("Cancelled");

    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, cancelToken);
      } 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;

    const cancelToken = ensureCancelToken(sessionId);
    cancelToken.cancelled = false;
    cancelToken.aborts = cancelToken.aborts || [];

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

    const maxTurns = Math.max(
      1,
      Math.min(30, parseInt(conf.maxTurns || 8, 10))
    );

    try {
      for (let i = 0; i < maxTurns; i++) {
        if (cancelToken.cancelled) throw new Error("Cancelled");
        const r = await runAgentTurn(sessionId, store, conf, ui, cancelToken);
        if (r.done) {
          CANCEL.delete(sessionId);
          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) {
      const msg = String(e?.message || e);
      if (msg === "Cancelled") {
        store.pushAgent(sessionId, {
          role: "agent",
          kind: "cancelled",
          content: "用户点击 Stop 取消运行",
          ts: now(),
        });
        store.setFSM(sessionId, {
          state: FSM.IDLE,
          isRunning: false,
          lastError: null,
        });
        ui?.renderAll?.();
        CANCEL.delete(sessionId);
        return;
      }
      store.setFSM(sessionId, {
        state: FSM.ERROR,
        isRunning: false,
        lastError: msg,
      });
      ui?.renderAll?.();
      CANCEL.delete(sessionId);
      throw e;
    }
  }

  /******************************************************************
   * 8) Workbench UI(Chat/Tools/Debug Tabs + Stop + 过滤 + 折叠/复制/引用)
   ******************************************************************/
  const STYLES = `
    :root{
      --a-bg: linear-gradient(135deg, rgba(250,250,252,.98), rgba(245,247,252,.98));
      --a-card: rgba(255,255,255,.98);
      --a-text: #0e1116;
      --a-sub: #546376;
      --a-border: rgba(31,109,255,.12);
      --a-shadow: 0 20px 50px rgba(31,109,255,.12), 0 8px 16px rgba(0,0,0,.08);
      --a-primary: linear-gradient(135deg, #1f6dff, #4a8fff);
      --a-primary-hover: linear-gradient(135deg, #1557d6, #3d7ee6);
      --a-user: linear-gradient(135deg, #e8f0ff, #f0f6ff);
      --a-ass: linear-gradient(135deg, #ffffff, #fafbff);
      --a-tool: linear-gradient(135deg, #fff8db, #fffaed);
      --a-code:#0d1117;
      --a-codeText:#e6edf3;
      --a-danger: linear-gradient(135deg, #ff4757, #ff6b7a);
      --a-warn: linear-gradient(135deg, #ffa502, #ffb830);
      --a-success: linear-gradient(135deg, #26de81, #20e3b2);
      --a-glow: rgba(31,109,255,.25);
    }

    /* 深色主题变量(用于手动切换和系统深色模式) */
    @media (prefers-color-scheme: dark){
      :root:not([data-theme="light"]){
        --a-bg: linear-gradient(135deg, rgba(16,18,24,.96), rgba(20,22,28,.96));
        --a-card: linear-gradient(135deg, rgba(28,30,38,.95), rgba(25,27,36,.95));
        --a-text: #e8ecf1;
        --a-sub: #adb5c7;
        --a-border: rgba(106,162,255,.15);
        --a-shadow: 0 24px 60px rgba(0,0,0,.7), 0 10px 20px rgba(106,162,255,.08);
        --a-primary: linear-gradient(135deg, #6aa2ff, #5a8fee);
        --a-primary-hover: linear-gradient(135deg, #7db0ff, #6a98ff);
        --a-user: linear-gradient(135deg, #1f2736, #252d3e);
        --a-ass: linear-gradient(135deg, #1a1e28, #1d212b);
        --a-tool: linear-gradient(135deg, #2d3340, #32394a);
        --a-code:#0d1117;
        --a-codeText:#c9d1d9;
        --a-danger: linear-gradient(135deg, #ff6b7a, #ff8593);
        --a-warn: linear-gradient(135deg, #ffb830, #ffc648);
        --a-success: linear-gradient(135deg, #20e3b2, #29ffc6);
        --a-glow: rgba(106,162,255,.3);
      }
    }

    /* 强制深色主题 */
    :root[data-theme="dark"]{
      --a-bg: linear-gradient(135deg, rgba(16,18,24,.96), rgba(20,22,28,.96));
      --a-card: linear-gradient(135deg, rgba(28,30,38,.95), rgba(25,27,36,.95));
      --a-text: #e8ecf1;
      --a-sub: #adb5c7;
      --a-border: rgba(106,162,255,.15);
      --a-shadow: 0 24px 60px rgba(0,0,0,.7), 0 10px 20px rgba(106,162,255,.08);
      --a-primary: linear-gradient(135deg, #6aa2ff, #5a8fee);
      --a-primary-hover: linear-gradient(135deg, #7db0ff, #6a98ff);
      --a-user: linear-gradient(135deg, #1f2736, #252d3e);
      --a-ass: linear-gradient(135deg, #1a1e28, #1d212b);
      --a-tool: linear-gradient(135deg, #2d3340, #32394a);
      --a-code:#0d1117;
      --a-codeText:#c9d1d9;
      --a-danger: linear-gradient(135deg, #ff6b7a, #ff8593);
      --a-warn: linear-gradient(135deg, #ffb830, #ffc648);
      --a-success: linear-gradient(135deg, #20e3b2, #29ffc6);
      --a-glow: rgba(106,162,255,.3);
    }

    /* ✅ FAB */
    #${APP_PREFIX}fab{
      position:fixed;
      left: calc(100vw - 70px);
      top: 16px;
      width:58px; height:58px; border-radius:18px;
      background: var(--a-card);
      border:2px 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;
      background: var(--a-primary);
      color: #fff;
      font-weight:900;
      font-size:18px;
      touch-action: none;
      transition: all .3s cubic-bezier(0.34, 1.56, 0.64, 1);
    }
    #${APP_PREFIX}fab:hover{
      transform: translateY(-4px) scale(1.08);
      box-shadow: 0 28px 65px rgba(31,109,255,.25), 0 12px 20px rgba(0,0,0,.15);
      filter: brightness(1.1);
    }
    #${APP_PREFIX}fab.dragging{
      cursor: grabbing;
      transform: scale(1.12) rotate(8deg);
      filter: brightness(1.15);
    }
    #${APP_PREFIX}fab .dot{
      position:absolute; right:10px; top:10px;
      width:12px; height:12px; border-radius:999px;
      background: transparent; border:2px solid transparent;
      transition: all .3s ease;
    }
    #${APP_PREFIX}fab.running .dot{
      background: var(--a-warn);
      border-color: #fff;
      box-shadow: 0 0 12px var(--a-warn), 0 0 24px var(--a-warn);
      animation: pulse-dot 1.5s ease-in-out infinite;
    }
    #${APP_PREFIX}fab.error .dot{
      background: var(--a-danger);
      border-color: #fff;
      box-shadow: 0 0 12px var(--a-danger);
    }
    @keyframes pulse-dot {
      0%, 100% { transform: scale(1); opacity: 1; }
      50% { transform: scale(1.3); opacity: 0.8; }
    }

    /* Drawer:响应式,不再 min-width:1000px */
    #${APP_PREFIX}drawer{
      position:fixed; left:0; right:0; top:-85vh; height:82vh;
      z-index:100002;
      background: var(--a-bg);
      border-bottom:2px solid var(--a-border);
      box-shadow: var(--a-shadow);
      border-bottom-left-radius:24px;
      border-bottom-right-radius:24px;
      transition: top .45s cubic-bezier(0.16, 1, 0.3, 1), box-shadow .3s ease;
      backdrop-filter: blur(20px) saturate(180%);
      display:flex; 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;
      overflow:hidden;
    }
    #${APP_PREFIX}drawer.open{
      top:0;
      box-shadow: 0 28px 80px rgba(0,0,0,.3), 0 0 0 1px var(--a-border);
    }

    .${APP_PREFIX}header{
      padding:16px 20px;
      border-bottom:2px solid var(--a-border);
      display:flex; align-items:center; justify-content:space-between;
      background: radial-gradient(1400px 180px at 20% 0%, var(--a-glow), transparent 65%);
      flex-shrink:0;
      gap:12px;
      position: relative;
    }
    .${APP_PREFIX}header::after{
      content: '';
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 2px;
      background: var(--a-primary);
      opacity: .15;
    }
    .${APP_PREFIX}title{
      font-weight:900; letter-spacing:.2px;
      display:flex; align-items:center; gap:10px;
      color: var(--a-primary);
      min-width: 240px;
      flex-wrap: wrap;
    }
    .${APP_PREFIX}badge{
      font-size:12px; padding:3px 8px; border-radius:999px;
      border:1px solid var(--a-border);
      color: var(--a-sub);
      font-weight:800;
    }
    .${APP_PREFIX}actions{ display:flex; align-items:center; gap:8px; color:var(--a-sub); flex-wrap: wrap; justify-content:flex-end; }
    .${APP_PREFIX}icon{
      cursor:pointer; padding:9px 12px; border-radius:12px;
      border:1.5px solid var(--a-border);
      background: rgba(127,127,127,.04);
      color: var(--a-text);
      font-weight:900;
      white-space: nowrap;
      transition: all .25s cubic-bezier(0.4, 0, 0.2, 1);
      position: relative;
      overflow: hidden;
    }
    .${APP_PREFIX}icon::before{
      content: '';
      position: absolute;
      inset: 0;
      background: var(--a-primary);
      opacity: 0;
      transition: opacity .25s ease;
    }
    .${APP_PREFIX}icon:hover{
      border-color: transparent;
      background: var(--a-primary);
      color: #fff;
      transform: translateY(-2px);
      box-shadow: 0 8px 16px var(--a-glow);
    }

    .${APP_PREFIX}pill{
      font-size:12px; font-weight:900;
      padding:6px 10px; border-radius:999px;
      border:1px solid var(--a-border);
      background: rgba(127,127,127,.06);
      color: var(--a-text);
      display:flex; gap:8px; align-items:center;
      max-width: 48vw;
      overflow:hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .${APP_PREFIX}pill .st{ color: var(--a-primary); }
    .${APP_PREFIX}pill .err{ color: var(--a-danger); }

    .${APP_PREFIX}tabs{
      display:flex; align-items:center; gap:6px;
      border:1.5px solid var(--a-border);
      background: rgba(127,127,127,.05);
      padding:5px;
      border-radius: 999px;
    }
    .${APP_PREFIX}tab{
      padding:8px 14px; border-radius:999px;
      cursor:pointer; user-select:none;
      font-weight:900; font-size:13px;
      color: var(--a-sub);
      transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
      position: relative;
    }
    .${APP_PREFIX}tab:hover{
      color: var(--a-text);
      transform: translateY(-1px);
    }
    .${APP_PREFIX}tab.active{
      background: var(--a-primary);
      border:none;
      color: #fff;
      box-shadow: 0 4px 12px var(--a-glow), inset 0 1px 2px rgba(255,255,255,.2);
      transform: scale(1.05);
    }

    .${APP_PREFIX}body{ flex:1; display:flex; min-height:0; }
    .${APP_PREFIX}sidebar{
      width: 300px;
      border-right:1px solid var(--a-border);
      padding:10px;
      overflow:auto;
      background: linear-gradient(180deg, rgba(127,127,127,.07), transparent);
      flex-shrink:0;
      transition: width .2s ease;
    }
    .${APP_PREFIX}sidebar.collapsed{ width: 0; padding: 0; border-right:0; overflow:hidden; }
    .${APP_PREFIX}sideTop{ display:flex; gap:8px; margin-bottom:10px; align-items:center; }
    .${APP_PREFIX}btn{
      border:none; cursor:pointer; border-radius:12px;
      padding:10px 18px; font-weight:900;
      background: var(--a-primary); color:#fff;
      transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
      box-shadow: 0 4px 12px rgba(31,109,255,.2);
      position: relative;
      overflow: hidden;
    }
    .${APP_PREFIX}btn::before{
      content: '';
      position: absolute;
      top: 50%;
      left: 50%;
      width: 0;
      height: 0;
      border-radius: 50%;
      background: rgba(255,255,255,.2);
      transform: translate(-50%, -50%);
      transition: width .5s, height .5s;
    }
    .${APP_PREFIX}btn:hover{
      transform: translateY(-2px);
      box-shadow: 0 12px 24px rgba(31,109,255,.35);
      background: var(--a-primary-hover);
    }
    .${APP_PREFIX}btn:hover::before{
      width: 300px;
      height: 300px;
    }
    .${APP_PREFIX}btn:active{
      transform: translateY(0);
      box-shadow: 0 4px 12px rgba(31,109,255,.2);
    }
    .${APP_PREFIX}btnGhost{
      background: transparent; color: var(--a-text);
      border:1px solid var(--a-border);
      font-weight:900;
    }
    .${APP_PREFIX}btnDanger{
      background: transparent; color: var(--a-danger);
      border:1px solid rgba(226,59,59,.55);
      font-weight:900;
    }

    .${APP_PREFIX}filter{
      width:100%; box-sizing:border-box;
      border-radius:12px;
      border:1px solid var(--a-border);
      padding:9px 10px;
      background: rgba(127,127,127,.08);
      color: var(--a-text);
      outline:none;
      font-weight:800;
      margin-bottom:10px;
    }

    .${APP_PREFIX}sessions{ display:flex; flex-direction:column; gap:10px; }
    .${APP_PREFIX}session{
      border:1.5px solid var(--a-border);
      border-radius:16px;
      padding:12px 14px;
      background: var(--a-card);
      display:flex; justify-content:space-between; align-items:center; gap:8px;
      cursor:pointer;
      transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
      position: relative;
      overflow: hidden;
    }
    .${APP_PREFIX}session::before{
      content: '';
      position: absolute;
      left: 0;
      top: 0;
      bottom: 0;
      width: 4px;
      background: var(--a-primary);
      opacity: 0;
      transition: opacity .3s ease;
    }
    .${APP_PREFIX}session:hover{
      border-color: var(--a-primary);
      transform: translateX(4px);
      box-shadow: 0 4px 12px rgba(0,0,0,.08);
    }
    .${APP_PREFIX}session.active{
      border-color: transparent;
      background: var(--a-primary);
      background: linear-gradient(135deg, rgba(31,109,255,.12), rgba(74,143,255,.08));
      box-shadow: 0 4px 16px var(--a-glow), inset 0 0 0 2px var(--a-border);
    }
    .${APP_PREFIX}session.active::before{
      opacity: 1;
    }
    .${APP_PREFIX}session .t{
      max-width: 170px;
      overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
      font-weight:900; color: var(--a-text);
    }
    .${APP_PREFIX}session .s{
      font-size:12px; color: var(--a-sub); font-weight:800;
    }
    .${APP_PREFIX}ops{ display:flex; gap:6px; }
    .${APP_PREFIX}op{
      padding:6px 8px; border-radius:10px; border:1px solid var(--a-border);
      background: rgba(127,127,127,.06);
      cursor:pointer; user-select:none; font-weight:900;
    }
    .${APP_PREFIX}op:hover{ border-color: var(--a-primary); }

    .${APP_PREFIX}main{ flex:1; display:flex; flex-direction:column; min-width:0; }
    .${APP_PREFIX}panel{
      flex:1; overflow:auto; padding: 18px 22px;
      line-height:1.75; font-size:15px;
      display:none;
      width: 100%;
      box-sizing: border-box;
    }
    .${APP_PREFIX}panel.active{ display:flex; flex-direction:column; align-items:center; }

    .${APP_PREFIX}msg{
      width: 100%;
      max-width: 1200px;
      margin: 12px auto;
      padding: 16px 20px;
      border:1.5px solid var(--a-border);
      border-radius:16px;
      background: rgba(127,127,127,.06);
      color: var(--a-text);
      position: relative;
      transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
      box-sizing: border-box;
    }
    .${APP_PREFIX}msg:hover{
      transform: translateY(-2px);
      box-shadow: 0 8px 20px rgba(0,0,0,.08);
    }
    .${APP_PREFIX}msg.user{
      background: var(--a-user);
      border-color: rgba(31,109,255,.35);
      box-shadow: 0 2px 8px rgba(31,109,255,.1);
    }
    .${APP_PREFIX}msg.assistant{
      background: var(--a-ass);
      box-shadow: 0 2px 8px rgba(0,0,0,.06);
    }
    .${APP_PREFIX}msg.tool{
      background: var(--a-tool);
      border-style:dashed;
      border-width: 2px;
    }

    .${APP_PREFIX}meta{
      font-size:12px; color: var(--a-sub);
      display:flex; align-items:center; gap:10px; margin-bottom:6px;
      font-weight:800;
      justify-content: space-between;
    }
    .${APP_PREFIX}mleft{ display:flex; align-items:center; gap:10px; min-width:0; }
    .${APP_PREFIX}mright{ display:flex; align-items:center; gap:6px; flex-shrink:0; }
    .${APP_PREFIX}mini{
      padding:6px 10px; border-radius:10px;
      border:1px solid var(--a-border);
      background: rgba(127,127,127,.06);
      cursor:pointer; user-select:none;
      font-weight:900; font-size:12px;
      color: var(--a-text);
      transition: all .25s cubic-bezier(0.4, 0, 0.2, 1);
    }
    .${APP_PREFIX}mini:hover{
      border-color: var(--a-primary);
      background: var(--a-primary);
      color: #fff;
      transform: scale(1.08);
      box-shadow: 0 4px 8px var(--a-glow);
    }

    .${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; }
    .${APP_PREFIX}md pre{
      background: var(--a-code); color: var(--a-codeText);
      padding: 12px; border-radius:12px; overflow:auto;
      border:1px solid rgba(255,255,255,.10);
    }

    .${APP_PREFIX}collapsed .${APP_PREFIX}md{
      max-height: 210px;
      overflow: hidden;
      mask-image: linear-gradient(180deg, rgba(0,0,0,1) 60%, rgba(0,0,0,0));
    }
    .${APP_PREFIX}moreHint{
      font-size:12px; color: var(--a-sub);
      margin-top:8px; font-weight:900;
    }

    .${APP_PREFIX}composer{
      border-top:1px solid var(--a-border);
      padding:10px;
      display:flex; gap:10px; align-items:flex-end;
      background: rgba(127,127,127,.06);
      flex-shrink:0;
    }
    .${APP_PREFIX}ta{
      flex:1;
      min-height:80px; max-height:200px;
      resize:none;
      border-radius:16px;
      border:2px solid var(--a-border);
      padding:14px 18px;
      background: rgba(255,255,255,.92);
      color: var(--a-text);
      outline:none;
      font-weight:800;
      font-size:15px;
      line-height:1.6;
      transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
      box-shadow: inset 0 2px 6px rgba(0,0,0,.04);
    }
    .${APP_PREFIX}ta:focus{
      border-color: var(--a-primary);
      box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.04);
      transform: translateY(-1px);
    }

    /* 强制浅色主题的输入框样式 */
    :root[data-theme="light"] .${APP_PREFIX}ta{
      background: rgba(255,255,255,.92);
      box-shadow: inset 0 2px 6px rgba(0,0,0,.04);
    }
    :root[data-theme="light"] .${APP_PREFIX}ta:focus{
      box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.04);
    }

    /* 深色主题的输入框样式 */
    @media (prefers-color-scheme: dark){
      :root:not([data-theme="light"]) .${APP_PREFIX}ta{
        background: rgba(18,20,27,.92);
        box-shadow: inset 0 2px 6px rgba(0,0,0,.2);
      }
      :root:not([data-theme="light"]) .${APP_PREFIX}ta:focus{
        box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.2);
      }
    }

    /* 强制深色主题的输入框样式 */
    :root[data-theme="dark"] .${APP_PREFIX}ta{
      background: rgba(18,20,27,.92);
      box-shadow: inset 0 2px 6px rgba(0,0,0,.2);
    }
    :root[data-theme="dark"] .${APP_PREFIX}ta:focus{
      box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.2);
    }

    .${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: 620px; max-width: 92vw;
      border-radius: 16px;
      border:1px solid var(--a-border);
      background: var(--a-card);
      box-shadow: var(--a-shadow);
      padding: 18px;
      color: var(--a-text);
    }
    .${APP_PREFIX}formRow{ margin: 10px 0; }
    .${APP_PREFIX}formRow label{ display:block; font-size:13px; font-weight:900; color:var(--a-sub); margin-bottom:6px; }
    .${APP_PREFIX}formRow input, .${APP_PREFIX}formRow textarea, .${APP_PREFIX}formRow select{
      width:100%; box-sizing:border-box;
      border-radius: 12px;
      border:1px solid var(--a-border);
      padding: 10px 12px;
      background: rgba(127,127,127,.08);
      color: var(--a-text);
      outline:none;
      font-weight:800;
    }
    .${APP_PREFIX}formActions{ display:flex; justify-content:flex-end; gap:10px; margin-top: 12px; flex-wrap:wrap; }

    #${APP_PREFIX}toast{
      position:fixed; right: 90px; top: 72px;
      z-index:100005;
      background: linear-gradient(135deg, rgba(0,0,0,.88), rgba(20,20,20,.85));
      color:#fff;
      padding: 10px 16px;
      border-radius: 999px;
      border: 1px solid rgba(255,255,255,.1);
      opacity:0;
      pointer-events:none;
      transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
      font-weight:900;
      font-size: 13px;
      max-width: 60vw;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      backdrop-filter: blur(10px);
      box-shadow: 0 8px 24px rgba(0,0,0,.3);
    }
    #${APP_PREFIX}toast.show{
      opacity:1;
      transform: translateY(-4px);
    }

    /* Scroll-to-bottom button */
    #${APP_PREFIX}toBottom{
      position: absolute;
    right: 24px;
    bottom: 120px;
      z-index: 10;
      display:none;
    }
    #${APP_PREFIX}toBottom.show{ display:block; }

    /* Tools/Debug panels */
    .${APP_PREFIX}toolGrid{
      max-width: 980px;
      display:flex; flex-direction:column; gap:12px;
    }
    .${APP_PREFIX}toolCard{
      border:1px solid var(--a-border);
      border-radius:14px;
      background: var(--a-card);
      padding:12px;
    }
    .${APP_PREFIX}toolRow{ display:flex; gap:10px; flex-wrap:wrap; }
    .${APP_PREFIX}toolRow > *{ flex: 1; min-width: 180px; }
    .${APP_PREFIX}toolOut{
      margin-top:10px;
      border:1px dashed var(--a-border);
      border-radius: 12px;
      padding:10px;
      background: rgba(127,127,127,.06);
      white-space: pre-wrap;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      font-size:12px;
      line-height:1.6;
      max-height: 380px;
      overflow:auto;
    }
    .${APP_PREFIX}logList{ max-width:980px; }
    .${APP_PREFIX}logItem{
      border:1px solid var(--a-border);
      border-radius:14px;
      background: var(--a-card);
      margin:10px 0;
      overflow:hidden;
    }
    .${APP_PREFIX}logHead{
      padding:10px 12px;
      display:flex; gap:10px; align-items:center; justify-content:space-between;
      cursor:pointer;
      user-select:none;
      font-weight:900;
      color: var(--a-text);
      background: rgba(127,127,127,.06);
    }
    .${APP_PREFIX}logBody{
      padding:10px 12px;
      display:none;
    }
    .${APP_PREFIX}logItem.open .${APP_PREFIX}logBody{ display:block; }
    .${APP_PREFIX}logBody pre{
      margin:0;
      white-space: pre-wrap;
      word-break: break-word;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      font-size:12px;
      line-height:1.6;
      background: rgba(127,127,127,.06);
      border:1px dashed var(--a-border);
      padding:10px;
      border-radius:12px;
      overflow:auto;
      max-height: 420px;
    }

    /* Small screens: auto collapse sidebar */
    @media (max-width: 860px){
      .${APP_PREFIX}sidebar{ width: 0; padding: 0; border-right:0; overflow:hidden; }
    }
  `;

  const DEFAULT_UI = {
    tab: "chat",
    sidebarCollapsed: false,
    debugFilter: { tool: true, agent: true, errors: true },
  };

  class UI {
    constructor(store, confStore) {
      this.store = store;
      this.confStore = confStore;
      this.isSending = false;
      this.debugVisible = false; // legacy toggle (kept), but debug is now in tab
      this.toolsState = {
        lastName: "discourse.search",
        lastArgs: { q: "linux", page: 1, limit: 8 },
        lastResult: "",
      };
      this._uiState = {
        ...DEFAULT_UI,
        ...(GM_getValue(STORE_KEYS.UI, null) || {}),
      };

      // 初始化主题
      this.theme = GM_getValue(STORE_KEYS.THEME, "auto");
      this._applyTheme();

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

      GM_registerMenuCommand("打开 Linux.do Agent", () =>
        this.toggleDrawer(true)
      );
      GM_registerMenuCommand("清空当前会话", () => {
        const s = this.store.active();
        if (confirm(`确定清空会话「${s.title}」吗?`)) {
          this.store.clearSession(s.id);
          this.renderAll();
          this.toast("已清空");
        }
      });
    }

    _saveUIState(patch) {
      this._uiState = { ...this._uiState, ...(patch || {}) };
      GM_setValue(STORE_KEYS.UI, this._uiState);
    }

    _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.innerHTML = `AG<div class="dot"></div>`;
      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">
          <div class="${APP_PREFIX}title">
            Linux.do Agent <span class="${APP_PREFIX}badge">Workbench UI</span>
            <span class="${APP_PREFIX}pill" id="${APP_PREFIX}statusPill" title="">
              <span class="st">IDLE</span>
              <span id="${APP_PREFIX}statusStep"></span>
              <span class="err" id="${APP_PREFIX}statusErr"></span>
            </span>
          </div>
          <div class="${APP_PREFIX}actions">
            <div class="${APP_PREFIX}tabs" id="${APP_PREFIX}tabs">
              <div class="${APP_PREFIX}tab" data-tab="chat">Chat</div>
              <div class="${APP_PREFIX}tab" data-tab="tools">Tools</div>
              <div class="${APP_PREFIX}tab" data-tab="debug">Debug</div>
            </div>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnStop" title="停止当前运行">Stop</button>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnTheme" title="切换主题">🌓</button>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnSetting">设置</button>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnToggleSide" title="折叠侧栏">侧栏</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>
            <input class="${APP_PREFIX}filter" id="${APP_PREFIX}sessionFilter" placeholder="过滤会话(标题)" />
            <div class="${APP_PREFIX}sessions" id="${APP_PREFIX}sessions"></div>
          </div>

          <div class="${APP_PREFIX}main">
            <div class="${APP_PREFIX}panel active" id="${APP_PREFIX}panelChat">
              <div id="${APP_PREFIX}chat"></div>
              <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}toBottom">⬇ 跳到最新</button>
            </div>

            <div class="${APP_PREFIX}panel" id="${APP_PREFIX}panelTools">
              <div class="${APP_PREFIX}toolGrid" id="${APP_PREFIX}toolsWrap"></div>
            </div>

            <div class="${APP_PREFIX}panel" id="${APP_PREFIX}panelDebug">
              <div style="max-width:980px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
                <label style="font-weight:900;color:var(--a-sub);"><input type="checkbox" id="${APP_PREFIX}dbgTool" checked> tool</label>
                <label style="font-weight:900;color:var(--a-sub);"><input type="checkbox" id="${APP_PREFIX}dbgAgent" checked> agent</label>
                <label style="font-weight:900;color:var(--a-sub);"><input type="checkbox" id="${APP_PREFIX}dbgErr" checked> errors</label>
                <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}dbgExpandAll">全部展开</button>
                <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}dbgCollapseAll">全部折叠</button>
              </div>
              <div class="${APP_PREFIX}logList" id="${APP_PREFIX}debugWrap"></div>
            </div>

            <div class="${APP_PREFIX}composer" id="${APP_PREFIX}composer">
              <textarea class="${APP_PREFIX}ta" id="${APP_PREFIX}ta" placeholder="输入问题:例如"总结某话题""搜索某关键词""查看@某用户概览/热门帖子""列出最新话题/最新帖子/Top话题/某tag话题""抓取某话题第N楼全文"等"></textarea>
              <div style="display:flex;flex-direction:column;gap:8px;">
                <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>
      `;
      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 10px 0;">⚙️ 设置(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:10px;flex-wrap:wrap;">
            <div class="${APP_PREFIX}formRow" style="flex:1;min-width:160px;">
              <label>Temperature (0-1)</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:160px;">
              <label>maxTurns</label>
              <input id="${APP_PREFIX}cfgMaxTurns" type="number" step="1" min="1" max="30" />
            </div>
            <div class="${APP_PREFIX}formRow" style="flex:1;min-width:200px;">
              <label>maxContextChars</label>
              <input id="${APP_PREFIX}cfgMaxCtx" type="number" step="500" min="4000" max="80000" />
            </div>
          </div>

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

          <div style="display:flex;align-items:center;gap:8px;">
            <label style="font-weight:900;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}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);

      this.dom = {
        fab,
        drawer,
        overlay,
        toast,

        btnClose: drawer.querySelector(`#${APP_PREFIX}btnClose`),
        btnSetting: drawer.querySelector(`#${APP_PREFIX}btnSetting`),
        btnToggleSide: drawer.querySelector(`#${APP_PREFIX}btnToggleSide`),
        btnStop: drawer.querySelector(`#${APP_PREFIX}btnStop`),
        btnTheme: drawer.querySelector(`#${APP_PREFIX}btnTheme`),

        tabs: drawer.querySelector(`#${APP_PREFIX}tabs`),
        statusPill: drawer.querySelector(`#${APP_PREFIX}statusPill`),
        statusStep: drawer.querySelector(`#${APP_PREFIX}statusStep`),
        statusErr: drawer.querySelector(`#${APP_PREFIX}statusErr`),

        sidebar: drawer.querySelector(`#${APP_PREFIX}sidebar`),
        sessionFilter: drawer.querySelector(`#${APP_PREFIX}sessionFilter`),
        sessions: drawer.querySelector(`#${APP_PREFIX}sessions`),

        panelChat: drawer.querySelector(`#${APP_PREFIX}panelChat`),
        panelTools: drawer.querySelector(`#${APP_PREFIX}panelTools`),
        panelDebug: drawer.querySelector(`#${APP_PREFIX}panelDebug`),

        chat: drawer.querySelector(`#${APP_PREFIX}chat`),
        toBottom: drawer.querySelector(`#${APP_PREFIX}toBottom`),

        toolsWrap: drawer.querySelector(`#${APP_PREFIX}toolsWrap`),

        dbgTool: drawer.querySelector(`#${APP_PREFIX}dbgTool`),
        dbgAgent: drawer.querySelector(`#${APP_PREFIX}dbgAgent`),
        dbgErr: drawer.querySelector(`#${APP_PREFIX}dbgErr`),
        dbgExpandAll: drawer.querySelector(`#${APP_PREFIX}dbgExpandAll`),
        dbgCollapseAll: drawer.querySelector(`#${APP_PREFIX}dbgCollapseAll`),
        debugWrap: drawer.querySelector(`#${APP_PREFIX}debugWrap`),

        composer: drawer.querySelector(`#${APP_PREFIX}composer`),
        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`),
        cfgCancel: overlay.querySelector(`#${APP_PREFIX}cfgCancel`),
        cfgSave: overlay.querySelector(`#${APP_PREFIX}cfgSave`),
      };
    }

    _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 {
        // 使用实际元素尺寸而不是硬编码
        const w = this.dom.fab.offsetWidth || 58;
        const 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`;
        // 保存初始位置
        this._saveFabPos(x, y);
      }
    }

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

    _clampFabPos(x, y) {
      const w = this.dom.fab.offsetWidth || 58;
      const h = this.dom.fab.offsetHeight || 58;
      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)),
      };
    }

    _bindFabDrag() {
      const fab = this.dom.fab;
      let dragging = false;
      let moved = false;
      let startX = 0,
        startY = 0;
      let origLeft = 0,
        origTop = 0;
      let pointerId = null;

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

      const onPointerDown = (e) => {
        if (e.button !== undefined && e.button !== 0) return;
        if (dragging) return; // 防止重复触发

        dragging = true;
        moved = false;
        pointerId = e.pointerId;
        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 || e.pointerId !== pointerId) return;
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;

        // 检测是否真的移动了(增加阈值)
        if (!moved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
          moved = true;
        }

        if (moved) {
          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 || e.pointerId !== pointerId) return;

        dragging = false;
        fab.classList.remove("dragging");

        if (moved) {
          // 只有在拖动后才保存位置
          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);
        } else {
          // 只有在没有移动时才触发点击
          this.toggleDrawer();
        }

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

        pointerId = null;
        e.preventDefault();
        e.stopPropagation();
      };

      const onResize = () => {
        if (dragging) return; // 拖动时不触发resize调整
        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);
    }

    _bind() {
      const d = this.dom;

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

      // 主题切换
      d.btnTheme.addEventListener("click", () => this._toggleTheme());

      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.btnToggleSide.addEventListener("click", () => {
        const next = !this._uiState.sidebarCollapsed;
        this._saveUIState({ sidebarCollapsed: next });
        this.renderAll();
      });

      d.tabs.addEventListener("click", (e) => {
        const t = e.target.closest(`.${APP_PREFIX}tab`);
        if (!t) return;
        const tab = t.dataset.tab;
        this._saveUIState({ tab });
        this.renderAll();
      });

      d.btnStop.addEventListener("click", () => {
        const s = this.store.active();
        if (!s?.id) return;
        cancelSession(s.id);
        this.toast("已停止");
        this.renderAll();
      });

      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,
          updatedAt: s.updatedAt,
          chat: s.chat,
          agent: s.agent,
          fsm: s.fsm,
          draft: s.draft,
        };
        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.sessionFilter.addEventListener("input", () => this.renderSessions());

      d.sessions.addEventListener("click", (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") {
            if (confirm("确定删除该会话吗?")) {
              this.store.remove(id);
              this.renderAll();
            }
            return;
          }
          if (act === "ren") {
            const s = this.store.all().find((x) => x.id === id);
            const t = prompt("重命名会话:", s?.title || "新会话");
            if (t != null) {
              this.store.rename(id, t);
              this.renderAll();
            }
            return;
          }
          if (act === "clr") {
            if (confirm("确定清空该会话吗?")) {
              this.store.clearSession(id);
              this.renderAll();
            }
            return;
          }
        }

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

      // composer
      d.ta.addEventListener("input", () => {
        this.autoGrow(d.ta);
        const s = this.store.active();
        this.store.setDraft(s.id, d.ta.value);
      });
      d.ta.addEventListener("keydown", (e) => {
        if (
          (e.key === "Enter" && !e.shiftKey) ||
          (e.key === "Enter" && (e.ctrlKey || e.metaKey))
        ) {
          e.preventDefault();
          this.send();
        }
      });

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

      // close overlay on ESC
      window.addEventListener("keydown", (e) => {
        if (e.key === "Escape") this.dom.overlay.classList.remove("open");
      });

      // message actions (copy/quote/toggle)
      d.chat.addEventListener("click", async (e) => {
        const btn = e.target.closest("[data-action]");
        if (!btn) return;
        const action = btn.dataset.action;
        const msgEl = e.target.closest(`.${APP_PREFIX}msg`);
        if (!msgEl) return;
        const content = msgEl.dataset.raw || "";

        if (action === "copy") {
          try {
            await navigator.clipboard.writeText(content);
            this.toast("已复制");
          } catch {
            this.toast("复制失败");
          }
          return;
        }
        if (action === "quote") {
          const ta = this.dom.ta;
          const quote = content
            .split("\n")
            .map((l) => `> ${l}`)
            .join("\n");
          ta.value = (ta.value ? ta.value + "\n\n" : "") + quote + "\n";
          this.autoGrow(ta);
          ta.focus();
          const s = this.store.active();
          this.store.setDraft(s.id, ta.value);
          this.toast("已引用到输入框");
          return;
        }
        if (action === "toggle") {
          msgEl.classList.toggle("collapsed");
          btn.textContent = msgEl.classList.contains("collapsed")
            ? "展开"
            : "收起";
          return;
        }
      });

      // scroll-to-bottom
      const wrap = this.dom.panelChat;
      wrap.addEventListener("scroll", () => this._updateToBottom());
      this.dom.toBottom.addEventListener("click", () => {
        wrap.scrollTop = wrap.scrollHeight;
        this._updateToBottom();
      });

      // debug controls
      d.dbgTool.addEventListener("change", () => {
        this._saveUIState({
          debugFilter: {
            ...this._uiState.debugFilter,
            tool: !!d.dbgTool.checked,
          },
        });
        this.renderDebug();
      });
      d.dbgAgent.addEventListener("change", () => {
        this._saveUIState({
          debugFilter: {
            ...this._uiState.debugFilter,
            agent: !!d.dbgAgent.checked,
          },
        });
        this.renderDebug();
      });
      d.dbgErr.addEventListener("change", () => {
        this._saveUIState({
          debugFilter: {
            ...this._uiState.debugFilter,
            errors: !!d.dbgErr.checked,
          },
        });
        this.renderDebug();
      });
      d.dbgExpandAll.addEventListener("click", () => {
        d.debugWrap
          .querySelectorAll(`.${APP_PREFIX}logItem`)
          .forEach((x) => x.classList.add("open"));
      });
      d.dbgCollapseAll.addEventListener("click", () => {
        d.debugWrap
          .querySelectorAll(`.${APP_PREFIX}logItem`)
          .forEach((x) => x.classList.remove("open"));
      });

      // debug item toggle
      d.debugWrap.addEventListener("click", (e) => {
        const head = e.target.closest(`.${APP_PREFIX}logHead`);
        if (!head) return;
        const item = head.closest(`.${APP_PREFIX}logItem`);
        if (!item) return;
        item.classList.toggle("open");
      });
      d.debugWrap.addEventListener("click", async (e) => {
        const c = e.target.closest("[data-copylog]");
        if (!c) return;
        e.stopPropagation();
        const text = c.dataset.copylog || "";
        try {
          await navigator.clipboard.writeText(text);
          this.toast("已复制");
        } catch {
          this.toast("复制失败");
        }
      });
    }

    _applyTheme() {
      const root = document.documentElement;
      if (this.theme === "light") {
        root.setAttribute("data-theme", "light");
      } else if (this.theme === "dark") {
        root.setAttribute("data-theme", "dark");
      } else {
        // auto: 移除 data-theme,让 CSS media query 生效
        root.removeAttribute("data-theme");
      }
    }

    _toggleTheme() {
      // 循环切换: auto -> light -> dark -> auto
      if (this.theme === "auto") {
        this.theme = "light";
      } else if (this.theme === "light") {
        this.theme = "dark";
      } else {
        this.theme = "auto";
      }
      GM_setValue(STORE_KEYS.THEME, this.theme);
      this._applyTheme();

      const themeNames = {
        auto: "自动",
        light: "浅色",
        dark: "深色"
      };
      this.toast(`主题: ${themeNames[this.theme]}`);
    }

    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
        )
      );
      const maxTurns = Math.max(
        1,
        Math.min(
          100,
          parseInt(this.dom.cfgMaxTurns.value, 10) || DEFAULT_CONF.maxTurns
        )
      );
      const maxContextChars = Math.max(
        4000,
        Math.min(
          8000000,
          parseInt(this.dom.cfgMaxCtx.value, 10) || 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("设置已保存");
    }

    _formatTime(ts) {
      try {
        return new Date(ts || now()).toLocaleString("zh-CN", { hour12: false });
      } catch {
        return "";
      }
    }

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

    _updateToBottom() {
      const wrap = this.dom.panelChat;
      const nearBottom =
        wrap.scrollHeight - (wrap.scrollTop + wrap.clientHeight) < 180;
      this.dom.toBottom.classList.toggle("show", !nearBottom);
    }

    setActiveTab(tab) {
      const tabs = this.dom.tabs.querySelectorAll(`.${APP_PREFIX}tab`);
      tabs.forEach((x) => x.classList.toggle("active", x.dataset.tab === tab));
      this.dom.panelChat.classList.toggle("active", tab === "chat");
      this.dom.panelTools.classList.toggle("active", tab === "tools");
      this.dom.panelDebug.classList.toggle("active", tab === "debug");
      this.dom.composer.style.display = tab === "chat" ? "flex" : "none";
    }

    renderStatus() {
      const s = this.store.active();
      const f = s.fsm || {};
      const st = f.state || FSM.IDLE;
      const step = f.step ? `step=${f.step}` : "";
      const err =
        st === FSM.ERROR && f.lastError
          ? String(f.lastError).slice(0, 180)
          : "";
      this.dom.statusPill.title = err ? String(f.lastError) : st;
      this.dom.statusPill.querySelector(".st").textContent = st;
      this.dom.statusStep.textContent = step ? `· ${step}` : "";
      this.dom.statusErr.textContent = err ? `· ${err}` : "";

      // FAB state dot
      this.dom.fab.classList.toggle("running", !!f.isRunning);
      this.dom.fab.classList.toggle("error", st === FSM.ERROR);
    }

    renderSessions() {
      const wrap = this.dom.sessions;
      const all = this.store.all();
      const activeId = this.store.active().id;
      const q = String(this.dom.sessionFilter.value || "")
        .trim()
        .toLowerCase();

      const filtered = q
        ? all.filter((s) =>
            String(s.title || "")
              .toLowerCase()
              .includes(q)
          )
        : all;

      wrap.innerHTML = filtered
        .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}`;
          return `
          <div class="${APP_PREFIX}session ${
            s.id === activeId ? "active" : ""
          }" data-id="${s.id}">
            <div style="min-width:0;">
              <div class="t" title="${(s.title || "").replace(
                /"/g,
                "&quot;"
              )}">${s.title || "新会话"}</div>
              <div class="s">${sub}</div>
            </div>
            <div class="${APP_PREFIX}ops">
              <span class="${APP_PREFIX}op" data-op="ren" title="重命名">改</span>
              <span class="${APP_PREFIX}op" data-op="clr" title="清空">空</span>
              <span class="${APP_PREFIX}op" data-op="del" title="删除">删</span>
            </div>
          </div>
        `;
        })
        .join("");
    }

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

      const blocks = [];

      if (!s.chat.length) {
        blocks.push(
          `<div style="opacity:.9;text-align:center;margin-top:42px;color:var(--a-sub);font-weight:900;">Chat:只显示 user/final。工具与调试请切换到 Tools / Debug。</div>`
        );
      } else {
        for (const m of s.chat)
          blocks.push(this.renderMessage(m.role, m.content, m.ts));
      }

      wrap.innerHTML = blocks.join("\n");
      // 如果用户在底部附近才自动跟随
      const panel = this.dom.panelChat;
      const nearBottom =
        panel.scrollHeight - (panel.scrollTop + panel.clientHeight) < 180;
      if (nearBottom) panel.scrollTop = panel.scrollHeight;
      this._updateToBottom();
    }

    renderMessage(role, content, ts) {
      const r =
        role === "user" ? "user" : role === "tool" ? "tool" : "assistant";
      const time = this._formatTime(ts);

      const raw = String(content || "");
      const html = this._renderMd(raw);

      const lineCount = raw.split("\n").length;
      const tooLong = raw.length > 2200 || lineCount > 26;
      const collapsedClass = tooLong ? "collapsed" : "";

      return `
        <div class="${APP_PREFIX}msg ${r} ${collapsedClass}" data-raw="${raw
        .replace(/&/g, "&amp;")
        .replace(/"/g, "&quot;")
        .replace(/</g, "&lt;")}">
          <div class="${APP_PREFIX}meta">
            <div class="${APP_PREFIX}mleft">
              <span>${r.toUpperCase()}</span>
              <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">· ${time}</span>
            </div>
            <div class="${APP_PREFIX}mright">
              <span class="${APP_PREFIX}mini" data-action="copy">复制</span>
              <span class="${APP_PREFIX}mini" data-action="quote">引用</span>
              ${
                tooLong
                  ? `<span class="${APP_PREFIX}mini" data-action="toggle">展开</span>`
                  : ""
              }
            </div>
          </div>
          <div class="${APP_PREFIX}md">${html}</div>
          ${
            tooLong
              ? `<div class="${APP_PREFIX}moreHint">(内容较长,已折叠)</div>`
              : ""
          }
        </div>
      `;
    }

    renderTools() {
      const wrap = this.dom.toolsWrap;
      const s = this.store.active();

      const toolOptions = [
        "discourse.search",
        "discourse.getTopicAllPosts",
        "discourse.getUserRecent",
        "discourse.getCategories",
        "discourse.listLatestTopics",
        "discourse.listTopTopics",
        "discourse.getTagTopics",
        "discourse.getUserSummary",
        "discourse.getPost",
        "discourse.getTopicPostFull",
        "discourse.listLatestPosts",
      ];

      const defaultArgs = (name) => {
        if (name === "discourse.search")
          return { q: "linux", page: 1, limit: 8 };
        if (name === "discourse.getTopicAllPosts")
          return { topicId: 1, batchSize: 18, maxPosts: 120 };
        if (name === "discourse.getUserRecent")
          return { username: "someone", limit: 10 };
        if (name === "discourse.getCategories") return {};
        if (name === "discourse.listLatestTopics") return { page: 0 };
        if (name === "discourse.listTopTopics")
          return { period: "weekly", page: 0 };
        if (name === "discourse.getTagTopics") return { tag: "linux", page: 0 };
        if (name === "discourse.getUserSummary") return { username: "someone" };
        if (name === "discourse.getPost") return { postId: 1 };
        if (name === "discourse.getTopicPostFull")
          return { topicId: 1, postNumber: 1, maxChars: 10000 };
        if (name === "discourse.listLatestPosts")
          return { before: null, limit: 20 };
        return {};
      };

      const name = this.toolsState.lastName;
      const argsText = JSON.stringify(
        this.toolsState.lastArgs ?? defaultArgs(name),
        null,
        2
      );

      wrap.innerHTML = `
        <div class="${APP_PREFIX}toolCard">
          <div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;">
            <div style="font-weight:900;">Tools(手动运行 Discourse 工具,不走模型)</div>
            <div style="color:var(--a-sub);font-weight:900;">结果可“一键加入上下文/发到聊天”</div>
          </div>

          <div class="${APP_PREFIX}toolRow" style="margin-top:10px;">
            <div>
              <label style="display:block;font-weight:900;color:var(--a-sub);margin-bottom:6px;">工具</label>
              <select id="${APP_PREFIX}toolName">
                ${toolOptions
                  .map(
                    (n) =>
                      `<option value="${n}" ${
                        n === name ? "selected" : ""
                      }>${n}</option>`
                  )
                  .join("")}
              </select>
            </div>
            <div style="flex:2;min-width:260px;">
              <label style="display:block;font-weight:900;color:var(--a-sub);margin-bottom:6px;">参数(JSON)</label>
              <textarea id="${APP_PREFIX}toolArgs" rows="8" style="width:100%;box-sizing:border-box;border-radius:12px;border:1px solid var(--a-border);padding:10px 12px;background:rgba(127,127,127,.08);color:var(--a-text);outline:none;font-weight:800;">${argsText.replace(
        /</g,
        "&lt;"
      )}</textarea>
            </div>
          </div>

          <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
            <button class="${APP_PREFIX}btn" id="${APP_PREFIX}toolRun">运行工具</button>
            <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}toolToCtx">加入上下文</button>
            <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}toolToChat">发到聊天</button>
            <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}toolCopy">复制结果</button>
          </div>

          <div class="${APP_PREFIX}toolOut" id="${APP_PREFIX}toolOut">${(
        this.toolsState.lastResult || "(暂无结果)"
      ).replace(/</g, "&lt;")}</div>

          <div style="margin-top:10px;color:var(--a-sub);font-weight:900;">
            Tip:加入上下文后,你可以回到 Chat 再问“请基于工具结果总结/对比/提炼结论…”
          </div>
        </div>
      `;

      const toolNameEl = wrap.querySelector(`#${APP_PREFIX}toolName`);
      const toolArgsEl = wrap.querySelector(`#${APP_PREFIX}toolArgs`);
      const toolOutEl = wrap.querySelector(`#${APP_PREFIX}toolOut`);

      toolNameEl.addEventListener("change", () => {
        const n = toolNameEl.value;
        this.toolsState.lastName = n;
        this.toolsState.lastArgs = defaultArgs(n);
        this.toolsState.lastResult = "";
        this.renderTools();
      });

      wrap
        .querySelector(`#${APP_PREFIX}toolRun`)
        .addEventListener("click", async () => {
          const n = toolNameEl.value;
          let args;
          try {
            args = JSON.parse(toolArgsEl.value);
          } catch {
            this.toast("参数 JSON 解析失败");
            return;
          }

          this.toolsState.lastName = n;
          this.toolsState.lastArgs = args;

          const cancelToken = ensureCancelToken(s.id);
          cancelToken.cancelled = false;
          cancelToken.aborts = cancelToken.aborts || [];

          this.toast("运行工具中…");
          try {
            const res = await runTool(n, args, cancelToken);
            const ctx = toolResultToContext(n, res);
            this.toolsState.lastResult = ctx;
            toolOutEl.textContent = ctx;
            this.toast("工具完成");
          } catch (e) {
            const msg = String(e?.message || e);
            this.toolsState.lastResult = `工具失败:${msg}`;
            toolOutEl.textContent = this.toolsState.lastResult;
            this.toast("工具失败");
          } finally {
            CANCEL.delete(s.id);
          }
        });

      wrap
        .querySelector(`#${APP_PREFIX}toolToCtx`)
        .addEventListener("click", () => {
          const txt = String(this.toolsState.lastResult || "").trim();
          if (!txt) return this.toast("无结果可加入");
          this.store.pushAgent(s.id, {
            role: "tool",
            kind: "tool_context",
            content: txt,
            ts: now(),
            toolName: this.toolsState.lastName,
          });
          this.toast("已加入上下文");
        });

      wrap
        .querySelector(`#${APP_PREFIX}toolToChat`)
        .addEventListener("click", () => {
          const txt = String(this.toolsState.lastResult || "").trim();
          if (!txt) return this.toast("无结果可发送");
          this.store.pushChat(s.id, {
            role: "assistant",
            content: `**[Tools] ${this.toolsState.lastName} 结果**\n\n\`\`\`\n${txt}\n\`\`\``,
            ts: now(),
          });
          this.toast("已发送到 Chat");
          this.renderChat();
        });

      wrap
        .querySelector(`#${APP_PREFIX}toolCopy`)
        .addEventListener("click", async () => {
          const txt = String(this.toolsState.lastResult || "").trim();
          if (!txt) return this.toast("无结果可复制");
          try {
            await navigator.clipboard.writeText(txt);
            this.toast("已复制");
          } catch {
            this.toast("复制失败");
          }
        });
    }

    renderDebug() {
      const s = this.store.active();
      const wrap = this.dom.debugWrap;

      const filt = this._uiState.debugFilter || {
        tool: true,
        agent: true,
        errors: true,
      };
      this.dom.dbgTool.checked = !!filt.tool;
      this.dom.dbgAgent.checked = !!filt.agent;
      this.dom.dbgErr.checked = !!filt.errors;

      const items = (s.agent || [])
        .map((a, idx) => {
          const isTool = a.role === "tool";
          const isAgent = a.role === "agent";
          const isErr =
            String(a.kind || "").includes("error") ||
            String(a.kind || "").includes("ERROR") ||
            a.kind === "model_parse_error";

          if (isTool && !filt.tool) return null;
          if (isAgent && !filt.agent) return null;
          if (isErr && !filt.errors) return null;

          const title = `${idx + 1}. ${a.role}:${a.kind || ""}`;
          const time = this._formatTime(a.ts);
          const txt = String(a.content || "");
          const short = txt.length > 160 ? txt.slice(0, 160) + "…" : txt;

          return `
          <div class="${APP_PREFIX}logItem">
            <div class="${APP_PREFIX}logHead">
              <div style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
                ${title} · <span style="color:var(--a-sub);">${time}</span>
              </div>
              <div style="display:flex;gap:8px;align-items:center;flex-shrink:0;">
                <span class="${APP_PREFIX}mini" data-copylog="${txt
            .replace(/&/g, "&amp;")
            .replace(/"/g, "&quot;")
            .replace(/</g, "&lt;")}">复制</span>
              </div>
            </div>
            <div class="${APP_PREFIX}logBody">
              <div style="color:var(--a-sub);font-weight:900;margin-bottom:8px;">预览:${short.replace(
                /</g,
                "&lt;"
              )}</div>
              <pre>${txt.replace(/</g, "&lt;")}</pre>
            </div>
          </div>
        `;
        })
        .filter(Boolean);

      wrap.innerHTML = items.length
        ? items.join("")
        : `<div style="opacity:.85;color:var(--a-sub);font-weight:900;margin-top:16px;">(暂无调试日志)</div>`;
    }

    renderAll() {
      const s = this.store.active();

      // apply sidebar collapsed
      this.dom.sidebar.classList.toggle(
        "collapsed",
        !!this._uiState.sidebarCollapsed
      );

      // apply tab
      const tab = this._uiState.tab || "chat";
      this.setActiveTab(tab);

      // status pill + fab dot
      this.renderStatus();

      // sessions
      this.renderSessions();

      // draft restore
      if (typeof s.draft === "string" && this.dom.ta.value !== s.draft) {
        this.dom.ta.value = s.draft;
        this.autoGrow(this.dom.ta);
      }

      // panels
      this.renderChat();
      this.renderTools();
      this.renderDebug();

      // buttons state
      const running = !!s.fsm?.isRunning;
      this.dom.btnSend.disabled = running;
      this.dom.btnResume.disabled = running;
      this.dom.ta.disabled = running;
      this.dom.btnSend.textContent = running ? "运行中…" : "发送";
    }

    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.setDraft(s.id, "");

      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._saveUIState({ tab: "chat" });
      this.setActiveTab("chat");
      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);
})();