Linux.do Agent

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

当前为 2025-12-19 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Linux.do Agent
// @namespace    https://example.com/linuxdo-agent
// @version      0.2.3
// @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', // ✅ AG 悬浮球位置
  };

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

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

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

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

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

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

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

    // 系统提示词(工具协议 + 行为约束)
    systemPrompt: [
      '你是 linux.do (Discourse) 的 AI Agent。',
      '你可以通过“工具调用协议(JSON)”来检索论坛:搜索、获取话题全部帖子、查询用户近期帖子、分类列表、最新话题、Top话题、Tag话题、用户概览(含热门帖子)、单帖详情、按(话题ID+楼层号)完整获取指定楼(<=10000字符)、站点最新帖子列表。',
      '你必须严格输出 JSON,不得输出任何多余文本(包括解释、markdown、代码块)。',
        '',
  '### OUTPUT FORMAT RULES (CRITICAL)',
  '1. **RAW JSON ONLY**: 严禁输出任何 Markdown 标记(如 ```json ... ```)。严禁输出任何开头或结尾的解释性文本。',
  '2. **NO CONVERSATION**: 不要说 "好的"、"这是结果"、"正在搜索"。直接输出 JSON 字符串。',
  '3. **SCHEMA**: 你只能输出以下两种 JSON 结构之一:',
  '',
  '   [FORMAT A: 需要调用工具时]',
  '   {',
  '     "type": "tool",',
  '     "name": "<tool_function_name>",',
  '     "args": { "<key>": "<value>" }',
  '   }',
  '',
  '   [FORMAT B: 生成最终回答时]',
  '   {',
  '     "type": "final",',
  '     "answer": "<支持简单markdown格式的回答文本>",',
  '     "refs": [ {"title": "...", "url": "..."} ]',
  '   }',
  '',
  '### CONSTRAINTS',
  '- refs 数组必须基于工具返回的真实数据,严禁编造 URL。',
  '- 如果工具返回结果为空或信息不足,请在 "answer" 中说明情况,并告知用户你尝试了什么但未找到。',
  '',
      '引用规则:refs 只能使用工具结果里出现过的链接;不要编造 URL。',
      '如果信息不足,首先尝试调用更多工具补全信息,如果还不足,请在 answer 明确说明不足。',
    ].join('\n'),
  };

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      return { q, page, posts };
    }

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

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

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

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

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

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

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

      return { username, items };
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      return out;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

      latest_posts_items: 24,
      latest_posts_excerpt: 420,
    };

    const MAX_CONTEXT_CHARS = 22000;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      const sections = [];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    let lastErr;

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

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

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

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

        await sleep(waitMs);
        continue;
      }
    }

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

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

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

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

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

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

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

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

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

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

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

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

    const chunks = [];

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

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

    let total = 0;
    const kept = [];
    const max = conf.maxContextChars || 24000;
    for (let i = chunks.length - 1; i >= 0; i--) {
      const c = chunks[i];
      const len = (c.content || '').length;
      if (total + len > max) break;
      kept.push(c);
      total += len;
    }
    kept.reverse();

    msgs.push(...kept);
    return msgs;
  }

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

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

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

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

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

    const obj = parsed.obj;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (session.fsm?.isRunning) return;

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

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

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

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

    /* ✅ AG 悬浮球:默认右上,但用 left/top 便于拖动;touch-action:none 支持移动端拖拽 */
    #${APP_PREFIX}fab{
      position:fixed;
      left: calc(100vw - 70px);
      top: 16px;

      width:52px; height:52px; border-radius:14px;
      background: var(--a-card);
      border:1px solid var(--a-border);
      box-shadow: var(--a-shadow);
      z-index:100003;
      display:flex; align-items:center; justify-content:center;
      cursor:pointer; user-select:none;
      color: var(--a-primary);
      font-weight:900;

      touch-action: none;
    }
    #${APP_PREFIX}fab.dragging{
      cursor: grabbing;
      opacity: .92;
      transform: scale(1.02);
    }

    #${APP_PREFIX}drawer{
      position:fixed; left:0; right:0; top:-82vh; height:78vh;
      z-index:100002;
      background: var(--a-bg);
      border-bottom:1px solid var(--a-border);
      box-shadow: var(--a-shadow);
      border-bottom-left-radius:18px;
      border-bottom-right-radius:18px;
      transition: top .38s cubic-bezier(0.19,1,0.22,1);
      backdrop-filter: blur(18px);
      display:flex; flex-direction:column;
      min-width: 1000px;
      color: var(--a-text);
      font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;
    }
    #${APP_PREFIX}drawer.open{ top:0; }

    .${APP_PREFIX}header{
      padding:12px 16px;
      border-bottom:1px solid var(--a-border);
      display:flex; align-items:center; justify-content:space-between;
      background: radial-gradient(1200px 160px at 18% 0%, rgba(31,109,255,.16), transparent 60%), var(--a-bg);
      flex-shrink:0;
    }
    .${APP_PREFIX}title{
      font-weight:900; letter-spacing:.2px;
      display:flex; align-items:center; gap:10px;
      color: var(--a-primary);
    }
    .${APP_PREFIX}badge{
      font-size:12px; padding:3px 8px; border-radius:999px;
      border:1px solid var(--a-border);
      color: var(--a-sub);
      font-weight:700;
    }
    .${APP_PREFIX}actions{ display:flex; align-items:center; gap:10px; color:var(--a-sub); }
    .${APP_PREFIX}icon{
      cursor:pointer; padding:8px; border-radius:10px;
      border:1px solid var(--a-border);
      background: rgba(127,127,127,.06);
      color: var(--a-text);
      font-weight:800;
    }
    .${APP_PREFIX}icon:hover{ border-color: var(--a-primary); }

    .${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);
    }
    .${APP_PREFIX}sideTop{ display:flex; gap:8px; margin-bottom:10px; }
    .${APP_PREFIX}btn{
      border:none; cursor:pointer; border-radius:12px;
      padding:9px 10px; font-weight:800;
      background: var(--a-primary); color:#fff;
    }
    .${APP_PREFIX}btn:hover{ filter:brightness(1.05); }
    .${APP_PREFIX}btnGhost{
      background: transparent; color: var(--a-text);
      border:1px solid var(--a-border);
      font-weight:800;
    }
    .${APP_PREFIX}sessions{ display:flex; flex-direction:column; gap:8px; }
    .${APP_PREFIX}session{
      border:1px solid var(--a-border);
      border-radius:14px;
      padding:10px;
      background: var(--a-card);
      display:flex; justify-content:space-between; align-items:center; gap:8px;
      cursor:pointer;
    }
    .${APP_PREFIX}session.active{ border-color: var(--a-primary); box-shadow: 0 0 0 2px rgba(0,214,255,.18) inset; }
    .${APP_PREFIX}session .t{
      max-width: 170px;
      overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
      font-weight:800; color: var(--a-text);
    }
    .${APP_PREFIX}session .s{
      font-size:12px; color: var(--a-sub); font-weight:700;
    }
    .${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}chat{
      flex:1; overflow:auto; padding: 18px 22px;
      line-height:1.75; font-size:15px;
    }
    .${APP_PREFIX}msg{
      max-width: 980px;
      margin: 10px 0;
      padding: 12px 14px;
      border:1px solid var(--a-border);
      border-radius:14px;
      background: rgba(127,127,127,.06);
      color: var(--a-text);
    }
    .${APP_PREFIX}msg.user{ background: var(--a-user); border-color: rgba(31,109,255,.35); }
    .${APP_PREFIX}msg.assistant{ background: var(--a-ass); }
    .${APP_PREFIX}msg.tool{ background: var(--a-tool); border-style:dashed; }
    .${APP_PREFIX}meta{
      font-size:12px; color: var(--a-sub);
      display:flex; align-items:center; gap:10px; margin-bottom:6px;
      font-weight:700;
    }
    .${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}composer{
      border-top:1px solid var(--a-border);
      padding:10px;
      display:flex; gap:10px; align-items:flex-end;
      background: rgba(127,127,127,.06);
    }
    .${APP_PREFIX}ta{
      flex:1;
      min-height:46px; max-height:180px;
      resize:none;
      border-radius:14px;
      border:1px solid var(--a-border);
      padding:10px 12px;
      background: rgba(255,255,255,.88);
      color: var(--a-text);
      outline:none;
    }
    @media (prefers-color-scheme: dark){
      .${APP_PREFIX}ta{ background: rgba(18,20,27,.88); }
    }
    .${APP_PREFIX}smallToggle{ display:flex; align-items:center; gap:6px; font-size:13px; color: var(--a-sub); font-weight:800; }

    .${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{
      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;
    }
    .${APP_PREFIX}formActions{ display:flex; justify-content:flex-end; gap:10px; margin-top: 12px; }
    #${APP_PREFIX}toast{
      position:fixed; right: 90px; top: 72px;
      z-index:100005;
      background: rgba(0,0,0,.82);
      color:#fff;
      padding: 8px 12px;
      border-radius: 999px;
      opacity:0;
      pointer-events:none;
      transition: .25s;
      font-weight:800;
      max-width: 60vw;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    #${APP_PREFIX}toast.show{ opacity:1; }
  `;

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

      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('已清空');
        }
      });
    }

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

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

      const drawer = document.createElement('div');
      drawer.id = `${APP_PREFIX}drawer`;
      drawer.innerHTML = `
        <div class="${APP_PREFIX}header">
          <div class="${APP_PREFIX}title">
            Linux.do Agent <span class="${APP_PREFIX}badge">多会话 · 工具调用 · 可恢复</span>
          </div>
          <div class="${APP_PREFIX}actions">
            <label class="${APP_PREFIX}smallToggle">
              <input type="checkbox" id="${APP_PREFIX}debugToggle" />
              显示调试
            </label>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnSetting">设置</button>
            <button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnClose">收起</button>
          </div>
        </div>

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

          <div class="${APP_PREFIX}main">
            <div class="${APP_PREFIX}chat" id="${APP_PREFIX}chat"></div>
            <div class="${APP_PREFIX}composer">
              <textarea class="${APP_PREFIX}ta" id="${APP_PREFIX}ta" placeholder="输入问题:例如“总结某话题”“搜索某关键词”“查看@某用户概览/热门帖子”“列出最新话题/最新帖子/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 class="${APP_PREFIX}smallToggle" style="color:var(--a-text);">
              <input type="checkbox" id="${APP_PREFIX}cfgToolCtx" />
              将工具结果作为上下文喂给模型
            </label>
          </div>

          <div class="${APP_PREFIX}formActions">
            <button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}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`),
        debugToggle: drawer.querySelector(`#${APP_PREFIX}debugToggle`),
        sessions: drawer.querySelector(`#${APP_PREFIX}sessions`),
        chat: drawer.querySelector(`#${APP_PREFIX}chat`),
        ta: drawer.querySelector(`#${APP_PREFIX}ta`),
        btnSend: drawer.querySelector(`#${APP_PREFIX}btnSend`),
        btnResume: drawer.querySelector(`#${APP_PREFIX}btnResume`),
        btnNew: drawer.querySelector(`#${APP_PREFIX}btnNew`),
        btnExport: drawer.querySelector(`#${APP_PREFIX}btnExport`),

        cfgBaseUrl: overlay.querySelector(`#${APP_PREFIX}cfgBaseUrl`),
        cfgModel: overlay.querySelector(`#${APP_PREFIX}cfgModel`),
        cfgKey: overlay.querySelector(`#${APP_PREFIX}cfgKey`),
        cfgTemp: overlay.querySelector(`#${APP_PREFIX}cfgTemp`),
        cfgMaxTurns: overlay.querySelector(`#${APP_PREFIX}cfgMaxTurns`),
        cfgMaxCtx: overlay.querySelector(`#${APP_PREFIX}cfgMaxCtx`),
        cfgSys: overlay.querySelector(`#${APP_PREFIX}cfgSys`),
        cfgToolCtx: overlay.querySelector(`#${APP_PREFIX}cfgToolCtx`),
        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 {
        // 默认:右上(用 left/top 计算)
        const w = 52, margin = 18;
        const x = Math.max(margin, window.innerWidth - w - margin);
        const y = 16;
        this.dom.fab.style.left = `${x}px`;
        this.dom.fab.style.top = `${y}px`;
      }
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    _bind() {
      const d = this.dom;

      // ⚠️ 点击由拖动逻辑统一处理,这里不再绑定 click
      d.btnClose.addEventListener('click', () => this.toggleDrawer(false));

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

      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.btnNew.addEventListener('click', () => {
        this.store.create('新会话');
        this.renderAll();
      });

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

      d.sessions.addEventListener('click', (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();
      });

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

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

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

    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('设置已保存');
    }

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

      wrap.innerHTML = all.map(s => {
        const state = s.fsm?.state || FSM.IDLE;
        const running = s.fsm?.isRunning ? ' · 运行中' : '';
        const err = (s.fsm?.state === FSM.ERROR && s.fsm?.lastError) ? ' · 错误' : '';
        const sub = `${state}${running}${err}`;
        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:800;">输入问题后发送;Agent 会先工具检索,再汇总作答。final.refs 会显示在回答末尾。</div>`);
      } else {
        for (const m of s.chat) blocks.push(this.renderMessage(m.role, m.content, m.ts));
      }

      if (this.debugVisible) {
        blocks.push(`<div style="margin-top:14px;opacity:.9;color:var(--a-sub);font-weight:900;">—— 调试轨迹(agent/tool)——</div>`);
        for (const a of (s.agent || [])) {
          const label = `${a.role}:${a.kind || ''}`;
          blocks.push(this.renderMessage(a.role === 'tool' ? 'tool' : 'assistant', `**${label}**\n\n\`\`\`\n${String(a.content || '')}\n\`\`\``, a.ts));
        }
      }

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

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

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

      return `
        <div class="${APP_PREFIX}msg ${r}">
          <div class="${APP_PREFIX}meta">${r.toUpperCase()} · ${time}</div>
          <div class="${APP_PREFIX}md">${html}</div>
        </div>
      `;
    }

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

      const s = this.store.active();
      const running = !!s.fsm?.isRunning;
      this.dom.btnSend.disabled = running;
      this.dom.btnResume.disabled = running;
      this.dom.ta.disabled = running;
      this.dom.btnSend.textContent = 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.pushChat(s.id, { role: 'user', content: text, ts: now() });

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

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

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

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

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

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

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

})();