Linux.do Agent

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Linux.do Agent
// @namespace    https://example.com/linuxdo-agent
// @version      0.3.8
// @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: 10,
    maxContextChars: 60000,
    includeToolContext: true,
    systemPrompt: `# 角色(Role)
你不是聊天助手。你是运行在 linux.do Discourse 前端脚本中的 **JSON 协议路由引擎**。
你的唯一任务:根据用户意图,决定是否调用工具,并且**严格**按协议输出 JSON(仅 JSON,无其它字符)。

# 核心目标(Goal)
- 当信息不足:发起工具调用(type="tool")
- 当信息充足:产出最终回答(type="final")

# 可用工具(Tools)
仅可使用以下工具名称(name 必须完全匹配):
- discourse.search
- discourse.getTopicAllPosts
- discourse.getUserRecent
- discourse.getCategories
- discourse.listLatestTopics
- discourse.listTopTopics
- discourse.getTagTopics
- discourse.getUserSummary
- discourse.getPost
- discourse.getTopicPostFull
- discourse.listLatestPosts

# 输出协议(Protocol)——最高优先级
你的每次响应必须且只能是以下两种 JSON 之一:

## A) 工具调用(需要获取数据时)
{
  "type": "tool",
  "name": "<tool_name>",
  "args": { ... }
}

## B) 最终回复(已有足够信息时)
{
  "type": "final",
  "answer": "<string,允许 Markdown;换行使用 \n;请确保格式符合被json转义的markdown>",
  "refs": [ {"title":"...","url":"..."} ]
}

# 绝对禁止(Hard Rules)
1) 严禁输出任何非 JSON 内容(包括“好的/正在搜索/以下是结果/解释原因”等)。
2) 严禁使用 Markdown 代码块包裹 JSON(不要 \`\`\`json)。
3) 每次只输出一个 JSON 对象,不要输出数组,不要输出多个对象。
4) 工具调用 JSON **必须包含 type 字段**,且必须是 "tool"。
5) 最终回复 JSON **必须包含 type 字段**,且必须是 "final"。
6) refs 只能来自工具结果中真实存在的 url,严禁编造链接。
7) 如果工具多轮:每轮只做一个工具调用;拿到工具结果后再决定下一轮工具或 final。

# 工具选择策略(Tool Selection)
- 不知道 topicId:优先 discourse.search(用关键词/语义/Discourse 语法)。
- 需要总结整帖:discourse.getTopicAllPosts({topicId,...})。
- 需要指定楼全文:discourse.getTopicPostFull({topicId, postNumber, maxChars})。
- 需要用户画像/热门帖子:discourse.getUserSummary({username})。
- 需要最新动态:discourse.listLatestTopics 或 discourse.listLatestPosts。

# 多轮工具调用策略(Multi-step)
当一次工具结果不足以回答:
- 继续输出 type="tool" 的下一次工具调用。
- 工具调用的 args 要尽量小、尽量精确(例如只抓 maxPosts=50,或只抓某楼)。

# 自检(Self-check,必须执行但不要输出)
在输出前,你必须在心里检查:
- 我输出的是不是 **纯 JSON**?
- 有没有且只有一个顶层对象?
- 顶层对象是否包含 "type"?
- 若 type="tool":name 是否为允许列表之一?args 是否为 object?
- 若 type="final":answer 是否为 string?refs 是否仅来自工具结果?

如果任何一项不满足,立刻修正后再输出。

# 示例(Examples,严格模仿格式)
用户:帮我找一下 Docker 教程
你输出:
{"type":"tool","name":"discourse.search","args":{"q":"Docker 教程","page":1,"limit":8}}

(工具结果返回后)
你输出:
{"type":"final","answer":"我在 linux.do 上找到几条 Docker 教程相关帖子……\n\n推荐优先看:……","refs":[{"title":"...","url":"https://linux.do/t/..."}]}

# 重要提示(Important)
- 即使历史上下文里出现了错误格式(例如缺少 type),也必须忽略,始终遵守本协议输出。

`
  };

  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 = `
# Tool Calling Contract (MUST FOLLOW)
你只能通过输出 JSON 指令来调用工具。每次响应必须且只能输出以下两种之一:

(1) 工具调用:
{
  "type": "tool",
  "name": "<tool_name>",
  "args": { ... }
}

(2) 最终回复:
{
  "type": "final",
  "answer": "<回答内容,允许简单 Markdown,但必须是字符串;换行用 \\n>",
  "refs": [
    {"title":"<引用标题>","url":"<引用链接>"},
    ...
  ]
}

重要约束:
- 禁止输出任何额外自然语言(包括“好的/正在搜索/以下是结果”等)。
- 禁止使用 Markdown 代码块包裹 JSON(不要 \`\`\`json)。
- refs 只能来自工具返回的真实 url,严禁编造。
- 当信息不足时,优先继续调用工具(可多轮),直到能 final。
- 如果工具连续失败/无结果,必须 final 并在 answer 中说明未找到。

----------------------------------------
# Available Tools (ONLY THESE 11)
命名规则:name 必须严格匹配以下之一:
- discourse.search
- discourse.getTopicAllPosts
- discourse.getUserRecent
- discourse.getCategories
- discourse.listLatestTopics
- discourse.listTopTopics
- discourse.getTagTopics
- discourse.getUserSummary
- discourse.getPost
- discourse.getTopicPostFull
- discourse.listLatestPosts

----------------------------------------
# 1) discourse.search
用途:全站搜索(关键词 / 语义),返回匹配的“帖子命中”(带 topic_id + post_number)。
适用场景:
- 用户只给关键词:如“找 Docker 教程”“搜某人提到的 XX”
- 不知道 topicId 时先 search 再定位 topicId/楼层

Args:
{
  "q": string,            // 必填:搜索词。支持 Discourse 搜索语法(如 in:title, status:open, tags:xxx, @user 等)
  "page": number?,        // 可选:默认 1。>=1
  "limit": number?        // 可选:默认 8。返回 posts 取前 limit 条;建议 5~12
}

Return:
{
  "q": string,
  "page": number,
  "posts": [
    {
      "topic_id": number,
      "post_number": number,
      "title": string,        // 话题标题(fancy_title/ title)
      "username": string,     // 作者用户名
      "created_at": string,   // ISO 时间
      "blurb": string,        // 摘要(可能含 HTML)
      "url": string           // 绝对链接:/t/<topicId>/<postNo>
    }
  ]
}

常见后续:
- 需要整帖:用 discourse.getTopicAllPosts({topicId})
- 需要指定楼全文:用 discourse.getTopicPostFull({topicId, postNumber})

----------------------------------------
# 2) discourse.getTopicAllPosts
用途:抓取某个话题的“全帖帖子流”(按 stream 批量拉 posts.json),用于总结/抽取结论/追踪争论上下文。
适用场景:
- “总结这个话题”“整理楼主观点+最新进展”
- 需要跨楼层对比观点(但注意上下文长度限制;如要某楼全文用 getTopicPostFull)

Args:
{
  "topicId": number|string,  // 必填:话题 id
  "batchSize": number?,      // 可选:默认 18。每批 post_ids[] 数量;建议 12~30
  "maxPosts": number?        // 可选:默认 240。最多抓取多少楼(从 stream 前 maxPosts)
}

Return:
{
  "topicId": number|string,
  "title": string,
  "count": number,
  "posts": [
    {
      "id": number,             // post id
      "post_number": number,    // 楼层号
      "username": string,
      "created_at": string,     // ISO
      "cooked": string,         // HTML(可能很长)
      "url": string,            // 绝对链接
      "like_count": number?,
      "reply_count": number?
    }
  ]
}

注意:
- cooked 是 HTML;需要纯文本时自行 strip/抽取要点。
- 若某楼内容很长/很关键,建议改用 getTopicPostFull 精准抓全文(<=10000 chars)。

----------------------------------------
# 3) discourse.getUserRecent
用途:查询某用户近期“发帖/回帖”等动作流(user_actions)。
适用场景:
- “看看 @xxx 最近在讨论什么”
- “找这个用户最近发的关于 XXX 的帖子”

Args:
{
  "username": string,     // 必填:用户名(不带@也可)
  "limit": number?        // 可选:默认 10。建议 8~20
}

Return:
{
  "username": string,
  "items": [
    {
      "action_type": number,    // 4=发帖, 5=回帖(脚本里用 filter:4,5)
      "title": string,
      "topic_id": number,
      "post_number": number,
      "created_at": string,
      "excerpt": string,        // 摘要(可能含 HTML)
      "url": string
    }
  ]
}

常见后续:
- 想看该楼全文:getTopicPostFull({topicId, postNumber})
- 想看用户整体画像+热门内容:getUserSummary({username})

----------------------------------------
# 4) discourse.getCategories
用途:获取站点分类列表 categories.json。
适用场景:
- “有哪些分类/哪个分类适合发帖”
- “列出分类ID/slug/帖子数”

Args:{}
Return:Discourse 原始 categories.json(结构较大),重点字段:
- category_list.categories[]: {id, name, slug, description_text, topic_count, post_count, ...}

----------------------------------------
# 5) discourse.listLatestTopics
用途:获取 /latest.json 的话题列表(最新 bump 的主题)。
适用场景:
- “列出最新话题”
- “最近大家在聊啥”

Args:
{
  "page": number?   // 可选:默认 0。>=0
}
Return:Discourse 原始 latest.json(重点在 topic_list.topics[])

常见后续:
- 对某 topic 做总结:getTopicAllPosts({topicId})
- 看最新帖子流:listLatestPosts

----------------------------------------
# 6) discourse.listTopTopics
用途:获取 /top.json(按 period 的 Top 话题)。
适用场景:
- “本周 Top 话题”“本月最热视频”

Args:
{
  "period": string?,   // 可选:默认 "weekly"
                        // 常用:daily | weekly | monthly | quarterly | yearly | all
  "page": number?      // 可选:默认 0
}
Return:Discourse 原始 top.json(重点在 topic_list.topics[])

----------------------------------------
# 7) discourse.getTagTopics
用途:获取某个 tag 下的话题列表 /tag/<tag>.json
适用场景:
- “看 linux tag 下有什么”
- “某标签精选/总结”

Args:
{
  "tag": string,       // 必填
  "page": number?      // 可选:默认 0
}
Return:Discourse 原始 tag/<tag>.json(重点在 topic_list.topics[])

----------------------------------------
# 8) discourse.getUserSummary
用途:用户概览(脚本做了“聚合+补充抓取”):summary.json + profile.json + activity/topics + activity/posts
适用场景:
- “@xxx 是什么风格/主要关注什么/热门帖子有哪些”
- “给我这个用户的热门话题、热门回复、近期话题、近期发言”

Args:
{
  "username": string  // 必填
}

Return(脚本自定义结构,稳定字段如下):
{
  "username": string,
  "urls": { "profile": string, "summary": string },

  "profile": {
    "id": number,
    "username": string,
    "name": string,
    "title": string,
    "trust_level": number,
    "avatar_template": string,
    "created_at": string,
    "last_seen_at": string,
    "last_posted_at": string,
    "badge_count": number,
    "website": string,
    "website_name": string,
    "profile_view_count": number,
    "time_read": number,
    "recent_time_read": number,
    "user_fields": object
  } | null,

  "summary": {
    "topic_count": number,
    "reply_count": number,
    "likes_given": number,
    "likes_received": number,
    "days_visited": number,
    "posts_read_count": number,
    "time_read": number
  } | null,

  "badges": { ... } | null,

  "hot_topics": [ {topic_id, title, like_count, views, reply_count, last_posted_at, category_id, tags, url} ],
  "hot_posts":  [ {topic_id, post_number, post_id, title, like_count, created_at, excerpt, username, url} ],
  "recent_topics": [ ... ],
  "recent_posts":  [ ... ],

  "_raw": {
    "summary_json": object|null,
    "profile_json": object|null,
    "activity_topics_json": object|null,
    "activity_posts_json": object|null
  }
}

注意:
- hot_* / recent_* 数组已在工具内部做过裁剪(最多 10 或 12)。
- 引用链接必须使用返回内的 url。

----------------------------------------
# 9) discourse.getPost
用途:按 postId 获取单帖详情 /posts/<id>.json
适用场景:
- 已知 postId,想拿到完整 cooked/raw、编辑信息、附件等原始字段

Args:
{
  "postId": number|string  // 必填
}
Return:Discourse 原始 post JSON(通常形如 { post: {...} } 或 {...})

常见后续:
- 若需要 topicId/post_number 组合链接,优先用返回内字段拼接或直接用现成 url。

----------------------------------------
# 10) discourse.getTopicPostFull
用途:按 (topicId + postNumber) 精确抓取指定楼层“全文”(raw/cooked 截断<=maxChars)。
适用场景:
- “抓这个话题第 N 楼全文”
- “给我 OP 全文/某楼关键代码块”
- getTopicAllPosts 上下文被省略中间楼层时,补抓指定楼

Args:
{
  "topicId": number|string,    // 必填
  "postNumber": number,        // 必填:>=1
  "maxChars": number?          // 可选:默认 10000;范围 [1000, 10000]
}

Return:
{
  "topicId": number|string,
  "title": string,
  "postId": number,
  "post_number": number,
  "username": string,
  "created_at": string,
  "url": string,
  "cooked": string,            // HTML,已按 maxChars 截断
  "raw": string,               // 纯文本/markdown,已按 maxChars 截断
  "maxChars": number,
  "endpointUsed": string       // "posts/by_number" 或 "t/{topicId}/{postNumber}.json"
}

注意:
- raw 是最适合做“引用/复述”的来源(仍需遵守不要原样大段复制的原则)。
- cooked 可能含 HTML 标签。

----------------------------------------
# 11) discourse.listLatestPosts
用途:站点“最新帖子流” /posts.json(如果站点允许)。
适用场景:
- “站点最新帖子列表”
- “最近刚发了哪些回复/新帖子”

Args:
{
  "before": number|string|null?, // 可选:默认 null。用于翻页(取更早的)
  "limit": number?               // 可选:默认 20;范围 [1, 50]
}

Return(脚本自定义结构):
{
  "before": number|string|null,
  "returned": number,
  "posts": [
    {
      "id": number,
      "topic_id": number,
      "post_number": number,
      "username": string,
      "created_at": string,
      "cooked": string,
      "raw": string,
      "like_count": number?,
      "url": string
    }
  ]
}

----------------------------------------
# Recommended Multi-step Workflows (GUIDE)
- 关键词找资料:discourse.search ->(挑 topic_id/post_number)-> getTopicAllPosts 或 getTopicPostFull -> final
- 总结某话题:getTopicAllPosts(topicId) -> 如需补楼:getTopicPostFull -> final
- 看用户画像:getUserSummary(username) -> 如需看某楼:getTopicPostFull -> final
- 看最新动态:listLatestTopics 或 listLatestPosts -> 选 topic -> getTopicAllPosts -> final
`;


  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 events = [];

  // 1) 用户/最终回复(你现在的 chat)
  for (const m of session.chat || []) {
    if (!m?.role || !m?.content) continue;
    events.push({
      ts: Number(m.ts || 0),
      role: m.role,
      content:  (m.role == "assistant") ? `${String(m.content)}。请使用正确json返回响应,此处仅为压缩上下文省略json` : String(m.content),
    });
  }

  // 2) 工具链路(你现在的 agent)
  if (conf.includeToolContext) {
    for (const a of session.agent || []) {
      if (!a?.content) continue;

      // ✅ 把模型“调用工具时输出的 JSON”也喂回去
      if (a.kind === "tool_call") {
        events.push({
          ts: Number(a.ts || 0),
          role: "assistant",
          // 给模型一个稳定、醒目的标记,避免跟普通对话混
          content: `【TOOL_CALL】\n${String(a.content)}`,
        });
      }

      // ✅ 工具结果:你之前只喂这个,而且还放错位置
      if (a.kind === "tool_context") {
        events.push({
          ts: Number(a.ts || 0),
          role: "user",
          content: `【TOOL_RESULT】\n${String(a.content)}`,
        });
      }

      // (可选)把解析修复后的 JSON 也喂回去,方便模型“知道自己最后被修复成啥”
      // if (a.kind === "model_json_repaired") {
      //   events.push({ ts: Number(a.ts || 0), role: "assistant", content: `【MODEL_JSON_REPAIRED】\n${String(a.content)}` });
      // }
    }
  }

  // ✅ 核心:按 ts 排序,保证时间线正确
  events.sort((x, y) => (x.ts || 0) - (y.ts || 0));

  // 3) 截断:从尾部开始保留到 maxContextChars(但保持顺序)
  const max = conf.maxContextChars || 24000;
  let total = 0;
  const kept = [];

  for (let i = events.length - 1; i >= 0; i--) {
    const e = events[i];
    const len = (e.content || "").length;
    if (total + len > max) break;
    kept.push({ role: e.role, content: e.content });
    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({ type: "tool", 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(10000, 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:48px; height:48px; 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: 3px;
    top: 0px;
      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);
})();