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

})();