// ==UserScript== // @name Linux.do Agent // @namespace https://example.com/linuxdo-agent // @version 0.3.7 // @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 // @downloadURL https://update.greasyfork.icu/scripts/559450/Linuxdo%20Agent.user.js // @updateURL https://update.greasyfork.icu/scripts/559450/Linuxdo%20Agent.meta.js // ==/UserScript== (() => { "use strict"; /****************************************************************** * 0) 常量 / 存储 Key ******************************************************************/ const APP_PREFIX = "ldagent-"; const STORE_KEYS = { CONF: "ld_agent_conf_v2", SESS: "ld_agent_sessions_v2", ACTIVE: "ld_agent_active_session_v2", FABPOS: "ld_agent_fab_pos_v1", // === UI ENHANCE === UI: "ld_agent_ui_state_v1", // {tab, sidebarCollapsed, theme} THEME: "ld_agent_theme_v1", // 主题模式:'light' | 'dark' | 'auto' }; const FSM = { IDLE: "IDLE", RUNNING: "RUNNING", WAITING_MODEL: "WAITING_MODEL", WAITING_TOOL: "WAITING_TOOL", DONE: "DONE", ERROR: "ERROR", CANCELLED: "CANCELLED", // UI-only }; const now = () => Date.now(); const uid = () => "S" + now().toString(36) + Math.random().toString(36).slice(2, 8); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function clamp(str, max = 20000) { str = String(str ?? ""); return str.length > max ? str.slice(0, max) + "\n...(截断)" : str; } function stripHtml(html) { const div = document.createElement("div"); div.innerHTML = html || ""; return (div.textContent || "").trim(); } function safeTitle(t, fb) { const s = String(t ?? "").trim(); return s ? s : fb || "无题"; } function mdEscapeText(s) { s = String(s ?? ""); return s.replace(/[\[\]\(\)]/g, (m) => "\\" + m); } function safeJsonParse(s, fb = null) { try { return JSON.parse(s); } catch { return fb; } } /****************************************************************** * 0.5) 可取消 Token(Stop / abort) ******************************************************************/ const CANCEL = new Map(); // sessionId -> { cancelled:boolean, aborts:Function[] } function ensureCancelToken(sessionId) { let t = CANCEL.get(sessionId); if (!t) { t = { cancelled: false, aborts: [] }; CANCEL.set(sessionId, t); } return t; } function cancelSession(sessionId) { const t = CANCEL.get(sessionId); if (!t) return; t.cancelled = true; for (const fn of t.aborts || []) { try { fn(); } catch {} } CANCEL.delete(sessionId); } function isCancelled(sessionId) { const t = CANCEL.get(sessionId); return !!t?.cancelled; } /****************************************************************** * 1) 配置:OpenAI Chat Completions 兼容 ******************************************************************/ const DEFAULT_CONF = { baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini", apiKey: "", temperature: 0.2, maxTurns: 10, maxContextChars: 60000, includeToolContext: true, systemPrompt: `# 角色(Role) 你不是聊天助手。你是运行在 linux.do Discourse 前端脚本中的 **JSON 协议路由引擎**。 你的唯一任务:根据用户意图,决定是否调用工具,并且**严格**按协议输出 JSON(仅 JSON,无其它字符)。 # 核心目标(Goal) - 当信息不足:发起工具调用(type="tool") - 当信息充足:产出最终回答(type="final") # 可用工具(Tools) 仅可使用以下工具名称(name 必须完全匹配): - discourse.search - discourse.getTopicAllPosts - discourse.getUserRecent - discourse.getCategories - discourse.listLatestTopics - discourse.listTopTopics - discourse.getTagTopics - discourse.getUserSummary - discourse.getPost - discourse.getTopicPostFull - discourse.listLatestPosts # 输出协议(Protocol)——最高优先级 你的每次响应必须且只能是以下两种 JSON 之一: ## A) 工具调用(需要获取数据时) { "type": "tool", "name": "", "args": { ... } } ## B) 最终回复(已有足够信息时) { "type": "final", "answer": "", "refs": [ {"title":"...","url":"..."} ] } # 绝对禁止(Hard Rules) 1) 严禁输出任何非 JSON 内容(包括“好的/正在搜索/以下是结果/解释原因”等)。 2) 严禁使用 Markdown 代码块包裹 JSON(不要 \`\`\`json)。 3) 每次只输出一个 JSON 对象,不要输出数组,不要输出多个对象。 4) 工具调用 JSON **必须包含 type 字段**,且必须是 "tool"。 5) 最终回复 JSON **必须包含 type 字段**,且必须是 "final"。 6) refs 只能来自工具结果中真实存在的 url,严禁编造链接。 7) 如果工具多轮:每轮只做一个工具调用;拿到工具结果后再决定下一轮工具或 final。 # 工具选择策略(Tool Selection) - 不知道 topicId:优先 discourse.search(用关键词/语义/Discourse 语法)。 - 需要总结整帖:discourse.getTopicAllPosts({topicId,...})。 - 需要指定楼全文:discourse.getTopicPostFull({topicId, postNumber, maxChars})。 - 需要用户画像/热门帖子:discourse.getUserSummary({username})。 - 需要最新动态:discourse.listLatestTopics 或 discourse.listLatestPosts。 # 多轮工具调用策略(Multi-step) 当一次工具结果不足以回答: - 继续输出 type="tool" 的下一次工具调用。 - 工具调用的 args 要尽量小、尽量精确(例如只抓 maxPosts=50,或只抓某楼)。 # 自检(Self-check,必须执行但不要输出) 在输出前,你必须在心里检查: - 我输出的是不是 **纯 JSON**? - 有没有且只有一个顶层对象? - 顶层对象是否包含 "type"? - 若 type="tool":name 是否为允许列表之一?args 是否为 object? - 若 type="final":answer 是否为 string?refs 是否仅来自工具结果? 如果任何一项不满足,立刻修正后再输出。 # 示例(Examples,严格模仿格式) 用户:帮我找一下 Docker 教程 你输出: {"type":"tool","name":"discourse.search","args":{"q":"Docker 教程","page":1,"limit":8}} (工具结果返回后) 你输出: {"type":"final","answer":"我在 linux.do 上找到几条 Docker 教程相关帖子……\\n\\n推荐优先看:……","refs":[{"title":"...","url":"https://linux.do/t/..."}]} # 重要提示(Important) - 即使历史上下文里出现了错误格式(例如缺少 type),也必须忽略,始终遵守本协议输出。 ` }; class ConfigStore { constructor() { const saved = GM_getValue(STORE_KEYS.CONF, null); this.conf = { ...DEFAULT_CONF, ...(saved || {}) }; } get() { return this.conf; } save(c) { this.conf = { ...this.conf, ...(c || {}) }; GM_setValue(STORE_KEYS.CONF, this.conf); } } /****************************************************************** * 2) 多会话存储(跨刷新) ******************************************************************/ class SessionStore { constructor() { this.sessions = GM_getValue(STORE_KEYS.SESS, []); this.activeId = GM_getValue(STORE_KEYS.ACTIVE, null); if (!Array.isArray(this.sessions) || !this.sessions.length) { const id = uid(); this.sessions = [this._newSessionObj(id, "新会话")]; this.activeId = id; this._persist(); } if (!this.sessions.some((s) => s.id === this.activeId)) { this.activeId = this.sessions[0].id; this._persist(); } } _newSessionObj(id, title) { return { id, title: title || "新会话", createdAt: now(), updatedAt: now(), fsm: { state: FSM.IDLE, step: 0, lastError: null, isRunning: false }, chat: [], // {role:'user'|'assistant', content, ts} agent: [], // {role:'agent'|'tool', kind, content, ts} draft: "", // === UI ENHANCE === 输入草稿持久化 }; } all() { return this.sessions; } active() { return ( this.sessions.find((s) => s.id === this.activeId) || this.sessions[0] ); } setActive(id) { this.activeId = id; GM_setValue(STORE_KEYS.ACTIVE, id); } create(title = "新会话") { const s = this._newSessionObj(uid(), title); this.sessions.unshift(s); this.activeId = s.id; this._persist(); return s; } rename(id, title) { const s = this.sessions.find((x) => x.id === id); if (!s) return; s.title = String(title || "") .trim() .slice(0, 24) || "新会话"; s.updatedAt = now(); this._persist(); } remove(id) { const idx = this.sessions.findIndex((x) => x.id === id); if (idx < 0) return; this.sessions.splice(idx, 1); if (!this.sessions.length) { const s = this._newSessionObj(uid(), "新会话"); this.sessions = [s]; this.activeId = s.id; } else if (this.activeId === id) { this.activeId = this.sessions[0].id; } this._persist(); } pushChat(id, msg) { const s = this.sessions.find((x) => x.id === id); if (!s) return; s.chat.push(msg); s.updatedAt = now(); this._persist(); } pushAgent(id, msg) { const s = this.sessions.find((x) => x.id === id); if (!s) return; s.agent.push(msg); s.updatedAt = now(); this._persist(); } setFSM(id, patch) { const s = this.sessions.find((x) => x.id === id); if (!s) return; s.fsm = { ...(s.fsm || {}), ...(patch || {}) }; s.updatedAt = now(); this._persist(); } updateLastAgent(id, predicateFn, updaterFn) { const s = this.sessions.find((x) => x.id === id); if (!s || !Array.isArray(s.agent)) return; for (let i = s.agent.length - 1; i >= 0; i--) { if (predicateFn(s.agent[i])) { s.agent[i] = updaterFn(s.agent[i]) || s.agent[i]; s.updatedAt = now(); this._persist(); return; } } } clearSession(id) { const s = this.sessions.find((x) => x.id === id); if (!s) return; s.chat = []; s.agent = []; s.draft = ""; s.fsm = { state: FSM.IDLE, step: 0, lastError: null, isRunning: false }; s.updatedAt = now(); this._persist(); } setDraft(id, text) { const s = this.sessions.find((x) => x.id === id); if (!s) return; s.draft = String(text ?? ""); s.updatedAt = now(); this._persist(); } _persist() { GM_setValue(STORE_KEYS.SESS, this.sessions); GM_setValue(STORE_KEYS.ACTIVE, this.activeId); } } /****************************************************************** * 3) Discourse 工具(linux.do 标准 JSON 接口) ******************************************************************/ class DiscourseAPI { static headers() { return { "X-Requested-With": "XMLHttpRequest", Accept: "application/json", }; } static csrfToken() { return ( document .querySelector('meta[name="csrf-token"]') ?.getAttribute("content") || "" ); } static async fetchJson(path, opt = {}) { const { method = "GET", body = null, headers = {}, signal } = opt; const init = { method, credentials: "same-origin", headers: { ...this.headers(), ...(headers || {}) }, signal, }; if (body != null) { init.headers["Content-Type"] = "application/json"; init.headers["X-CSRF-Token"] = this.csrfToken(); init.body = JSON.stringify(body); } const res = await fetch(path, init); if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); return res.json(); } static topicUrl(topicId, postNo = 1) { return `${location.origin}/t/${encodeURIComponent(topicId)}/${postNo}`; } static userUrl(username) { return `${location.origin}/u/${encodeURIComponent(username)}`; } static async search({ q, page = 1, limit = 8 }, signal) { const params = new URLSearchParams(); params.set("q", q); params.set("page", String(page)); params.set("include_blurbs", "true"); params.set("skip_context", "true"); const data = await this.fetchJson(`/search.json?${params.toString()}`, { signal, }); const topicsMap = new Map( (data.topics || []).map((t) => [ t.id, safeTitle(t.fancy_title || t.title, `话题 ${t.id}`), ]) ); const posts = (data.posts || []).slice(0, limit).map((p) => ({ topic_id: p.topic_id, post_number: p.post_number, title: topicsMap.get(p.topic_id) || `话题 ${p.topic_id}`, username: p.username, created_at: p.created_at, blurb: p.blurb || "", url: this.topicUrl(p.topic_id, p.post_number), })); return { q, page, posts }; } static async getTopicAllPosts( { topicId, batchSize = 18, maxPosts = 240 }, signal, cancelToken ) { const first = await this.fetchJson( `/t/${encodeURIComponent(topicId)}.json`, { signal } ); const title = safeTitle(first.title, `话题 ${topicId}`); const stream = (first.post_stream?.stream || []).slice(0, maxPosts); const got = new Map(); for (const p of first.post_stream?.posts || []) got.set(p.id, p); for (let i = 0; i < stream.length; i += batchSize) { if (cancelToken?.cancelled) throw new Error("Cancelled"); const chunk = stream.slice(i, i + batchSize); const params = new URLSearchParams(); chunk.forEach((id) => params.append("post_ids[]", String(id))); const data = await this.fetchJson( `/t/${encodeURIComponent(topicId)}/posts.json?${params.toString()}`, { signal } ); for (const p of data.post_stream?.posts || []) got.set(p.id, p); await sleep(160); } const posts = stream .map((id) => got.get(id)) .filter(Boolean) .map((p) => ({ id: p.id, post_number: p.post_number, username: p.username, created_at: p.created_at, cooked: p.cooked || "", url: this.topicUrl(topicId, p.post_number), like_count: p.like_count, reply_count: p.reply_count, })); return { topicId, title, count: posts.length, posts }; } static async getUserRecent({ username, limit = 10 }, signal) { const params = new URLSearchParams(); params.set("offset", "0"); params.set("limit", String(limit)); params.set("username", username); params.set("filter", "4,5"); const data = await this.fetchJson( `/user_actions.json?${params.toString()}`, { signal } ); const items = (data.user_actions || []).map((a) => ({ action_type: a.action_type, title: safeTitle(a.title, `话题 ${a.topic_id}`), topic_id: a.topic_id, post_number: a.post_number, created_at: a.created_at, excerpt: a.excerpt || "", url: this.topicUrl(a.topic_id, a.post_number), })); return { username, items }; } static async getCategories(signal) { return this.fetchJson("/categories.json", { signal }); } static async listLatestTopics({ page = 0 } = {}, signal) { const params = new URLSearchParams(); params.set("page", String(page)); params.set("no_definitions", "true"); return this.fetchJson(`/latest.json?${params.toString()}`, { signal }); } static async listTopTopics({ period = "weekly", page = 0 } = {}, signal) { const params = new URLSearchParams(); params.set("period", String(period)); params.set("page", String(page)); params.set("no_definitions", "true"); return this.fetchJson(`/top.json?${params.toString()}`, { signal }); } static async getTagTopics({ tag, page = 0 } = {}, signal) { if (!tag) throw new Error("tag 不能为空"); const params = new URLSearchParams(); params.set("page", String(page)); params.set("no_definitions", "true"); return this.fetchJson( `/tag/${encodeURIComponent(tag)}.json?${params.toString()}`, { signal } ); } static async listLatestPosts({ before = null, limit = 20 } = {}, signal) { const params = new URLSearchParams(); if (before !== null && before !== undefined && before !== "") params.set("before", String(before)); params.set( "limit", String(Math.max(1, Math.min(50, parseInt(limit, 10) || 20))) ); params.set("no_definitions", "true"); let data; try { data = await this.fetchJson(`/posts.json?${params.toString()}`, { signal, }); } catch (e1) { throw new Error( `获取失败:/posts.json 不可用或被限制。${String(e1?.message || e1)}` ); } const arr = Array.isArray(data?.latest_posts) ? data.latest_posts : Array.isArray(data) ? data : []; const posts = arr .slice(0, Math.max(1, Math.min(50, parseInt(limit, 10) || 20))) .map((p) => { const topic_id = p.topic_id; const post_number = p.post_number || p.post_number; return { id: p.id, topic_id, post_number, username: p.username, created_at: p.created_at, cooked: p.cooked || "", raw: p.raw || "", like_count: p.like_count, url: topic_id && post_number ? this.topicUrl(topic_id, post_number) : "", }; }); return { before: before ?? null, returned: posts.length, posts }; } static async getUserSummary({ username } = {}, signal) { if (!username) throw new Error("username 不能为空"); const out = { username, urls: { profile: this.userUrl(username), summary: `${this.userUrl(username)}/summary`, }, profile: null, summary: null, badges: null, hot_topics: [], hot_posts: [], recent_topics: [], recent_posts: [], _raw: { summary_json: null, profile_json: null, activity_topics_json: null, activity_posts_json: null, }, }; let summaryJson = null; try { summaryJson = await this.fetchJson( `/u/${encodeURIComponent(username)}/summary.json`, { signal } ); out._raw.summary_json = summaryJson; } catch (e) { throw new Error(`获取 summary.json 失败:${String(e?.message || e)}`); } try { const profileJson = await this.fetchJson( `/u/${encodeURIComponent(username)}.json`, { signal } ); out._raw.profile_json = profileJson; } catch {} try { const params = new URLSearchParams(); params.set("page", "0"); params.set("no_definitions", "true"); const topicsJson = await this.fetchJson( `/u/${encodeURIComponent( username )}/activity/topics.json?${params.toString()}`, { signal } ); out._raw.activity_topics_json = topicsJson; } catch {} try { const params = new URLSearchParams(); params.set("page", "0"); params.set("no_definitions", "true"); const postsJson = await this.fetchJson( `/u/${encodeURIComponent( username )}/activity/posts.json?${params.toString()}`, { signal } ); out._raw.activity_posts_json = postsJson; } catch {} const profileUser = out._raw.profile_json?.user || summaryJson?.user || null; if (profileUser) { out.profile = { id: profileUser.id, username: profileUser.username, name: profileUser.name ?? "", title: profileUser.title ?? "", trust_level: profileUser.trust_level, avatar_template: profileUser.avatar_template, created_at: profileUser.created_at, last_seen_at: profileUser.last_seen_at, last_posted_at: profileUser.last_posted_at, badge_count: profileUser.badge_count, website: profileUser.website, website_name: profileUser.website_name, profile_view_count: profileUser.profile_view_count, time_read: profileUser.time_read, recent_time_read: profileUser.recent_time_read, user_fields: profileUser.user_fields || {}, }; } const us = summaryJson?.user_summary || summaryJson?.userSummary || null; if (us) { out.summary = { topic_count: us.topic_count, reply_count: us.reply_count, likes_given: us.likes_given, likes_received: us.likes_received, days_visited: us.days_visited, posts_read_count: us.posts_read_count, time_read: us.time_read, }; } out.badges = { user_badges: summaryJson?.user_badges || summaryJson?.userBadges || null, badges: summaryJson?.badges || null, badge_types: summaryJson?.badge_types || null, users: summaryJson?.users || null, }; const topTopics = Array.isArray(summaryJson?.top_topics) ? summaryJson.top_topics : Array.isArray(summaryJson?.topTopics) ? summaryJson.topTopics : []; const topReplies = Array.isArray(summaryJson?.top_replies) ? summaryJson.top_replies : Array.isArray(summaryJson?.topReplies) ? summaryJson.topReplies : []; out.hot_topics = topTopics .map((t) => ({ topic_id: t.id || t.topic_id || t.topicId, title: safeTitle( t.fancy_title || t.title, `话题 ${t.id || t.topic_id || ""}` ), like_count: t.like_count, views: t.views, reply_count: t.reply_count ?? t.posts_count, last_posted_at: t.last_posted_at ?? t.bumped_at, category_id: t.category_id, tags: t.tags || [], url: this.topicUrl(t.id || t.topic_id || t.topicId, 1), })) .filter((x) => x.topic_id); out.hot_posts = topReplies .map((p) => ({ topic_id: p.topic_id, post_number: p.post_number, post_id: p.id, title: safeTitle(p.topic_title || p.title, `话题 ${p.topic_id}`), like_count: p.like_count, created_at: p.created_at, excerpt: p.excerpt || "", username: p.username, url: this.topicUrl(p.topic_id, p.post_number || 1), })) .filter((x) => x.topic_id); const actTopics = out._raw.activity_topics_json?.topic_list?.topics || out._raw.activity_topics_json?.topics || []; if (Array.isArray(actTopics) && actTopics.length) { const extra = actTopics.map((t) => ({ topic_id: t.id, title: safeTitle(t.fancy_title || t.title, `话题 ${t.id}`), like_count: t.like_count, views: t.views, reply_count: t.reply_count ?? (t.posts_count ? Math.max(0, t.posts_count - 1) : undefined), last_posted_at: t.last_posted_at ?? t.bumped_at, category_id: t.category_id, tags: t.tags || [], url: this.topicUrl(t.id, 1), _score: (t.like_count || 0) * 4 + (t.views || 0) * 0.01 + (t.reply_count || 0) * 2, })); out.recent_topics = extra .slice(0, 12) .map(({ _score, ...rest }) => rest); const exist = new Set(out.hot_topics.map((x) => x.topic_id)); extra.sort((a, b) => b._score - a._score); for (const t of extra) { if (out.hot_topics.length >= 10) break; if (!exist.has(t.topic_id)) { exist.add(t.topic_id); const { _score, ...rest } = t; out.hot_topics.push(rest); } } } const actPosts = out._raw.activity_posts_json?.user_actions || out._raw.activity_posts_json?.posts || out._raw.activity_posts_json?.activity_stream || []; if (Array.isArray(actPosts) && actPosts.length) { const normPosts = actPosts .map((a) => { const topic_id = a.topic_id || a?.post?.topic_id; const post_number = a.post_number || a?.post?.post_number; const like_count = a.like_count ?? a?.post?.like_count; const excerpt = a.excerpt || a?.post?.excerpt || ""; const created_at = a.created_at || a?.post?.created_at; const username2 = a.username || a?.post?.username || username; const title = safeTitle( a.title || a.topic_title || a?.post?.topic_title, topic_id ? `话题 ${topic_id}` : "帖子" ); return { topic_id, post_number, post_id: a.post_id || a.id || a?.post?.id, title, like_count, created_at, excerpt, username: username2, url: topic_id && post_number ? this.topicUrl(topic_id, post_number) : "", }; }) .filter((x) => x.topic_id && x.post_number); out.recent_posts = normPosts.slice(0, 12); const existPostKey = new Set( out.hot_posts.map((x) => `${x.topic_id}#${x.post_number}`) ); const scored = normPosts.map((p) => ({ ...p, _score: (p.like_count || 0) * 5 + (p.excerpt ? Math.min(1, p.excerpt.length / 120) : 0), })); scored.sort((a, b) => b._score - a._score); for (const p of scored) { if (out.hot_posts.length >= 10) break; const k = `${p.topic_id}#${p.post_number}`; if (!existPostKey.has(k)) { existPostKey.add(k); const { _score, ...rest } = p; out.hot_posts.push(rest); } } } out.hot_topics = out.hot_topics.slice(0, 10); out.hot_posts = out.hot_posts.slice(0, 10); out.recent_topics = out.recent_topics.slice(0, 12); out.recent_posts = out.recent_posts.slice(0, 12); return out; } static async getPost({ postId } = {}, signal) { if (!postId) throw new Error("postId 不能为空"); return this.fetchJson(`/posts/${encodeURIComponent(postId)}.json`, { signal, }); } static async getTopicPostFull( { topicId, postNumber = 1, maxChars = 10000 } = {}, signal ) { if (topicId === undefined || topicId === null || topicId === "") throw new Error("topicId 不能为空"); const pn = Math.max(1, parseInt(postNumber, 10) || 1); const max = Math.max( 1000, Math.min(10000, parseInt(maxChars, 10) || 10000) ); const trunc = (s) => { s = String(s || ""); return s.length > max ? s.slice(0, max) + "\n...(截断)" : s; }; let post = null; let title = ""; let used = ""; try { const data = await this.fetchJson( `/posts/by_number/${encodeURIComponent(topicId)}/${encodeURIComponent( pn )}.json`, { signal } ); post = data?.post || data; title = safeTitle(post?.topic_title, ""); used = "posts/by_number"; } catch (e1) { try { const data2 = await this.fetchJson( `/t/${encodeURIComponent(topicId)}/${encodeURIComponent(pn)}.json`, { signal } ); title = safeTitle(data2?.title, `话题 ${topicId}`); const ps = data2?.post_stream?.posts || []; post = ps.find((x) => x?.post_number === pn) || ps[0] || null; used = "t/{topicId}/{postNumber}.json"; } catch (e2) { throw new Error( `获取失败:by_number与topic视图都不可用。\n- by_number: ${String( e1?.message || e1 )}\n- topic_view: ${String(e2?.message || e2)}` ); } } if (!post) throw new Error("未找到该楼层帖子"); const cooked = trunc(post.cooked || ""); const raw = trunc(post.raw || ""); const topic_id = post.topic_id || topicId; const post_number = post.post_number || pn; return { topicId: topic_id, title: title || safeTitle(post?.topic_title, `话题 ${topic_id}`), postId: post.id, post_number, username: post.username, created_at: post.created_at, url: this.topicUrl(topic_id, post_number), cooked, raw, maxChars: max, endpointUsed: used, }; } } /****************************************************************** * 4) 工具注册表 ******************************************************************/ const TOOLS_SPEC = ` # Tool Calling Contract (MUST FOLLOW) 你只能通过输出 JSON 指令来调用工具。每次响应必须且只能输出以下两种之一: (1) 工具调用: { "type": "tool", "name": "", "args": { ... } } (2) 最终回复: { "type": "final", "answer": "<回答内容,允许简单 Markdown,但必须是字符串;换行用 \\n>", "refs": [ {"title":"<引用标题>","url":"<引用链接>"}, ... ] } 重要约束: - 禁止输出任何额外自然语言(包括“好的/正在搜索/以下是结果”等)。 - 禁止使用 Markdown 代码块包裹 JSON(不要 \`\`\`json)。 - refs 只能来自工具返回的真实 url,严禁编造。 - 当信息不足时,优先继续调用工具(可多轮),直到能 final。 - 如果工具连续失败/无结果,必须 final 并在 answer 中说明未找到。 ---------------------------------------- # Available Tools (ONLY THESE 11) 命名规则:name 必须严格匹配以下之一: - discourse.search - discourse.getTopicAllPosts - discourse.getUserRecent - discourse.getCategories - discourse.listLatestTopics - discourse.listTopTopics - discourse.getTagTopics - discourse.getUserSummary - discourse.getPost - discourse.getTopicPostFull - discourse.listLatestPosts ---------------------------------------- # 1) discourse.search 用途:全站搜索(关键词 / 语义),返回匹配的“帖子命中”(带 topic_id + post_number)。 适用场景: - 用户只给关键词:如“找 Docker 教程”“搜某人提到的 XX” - 不知道 topicId 时先 search 再定位 topicId/楼层 Args: { "q": string, // 必填:搜索词。支持 Discourse 搜索语法(如 in:title, status:open, tags:xxx, @user 等) "page": number?, // 可选:默认 1。>=1 "limit": number? // 可选:默认 8。返回 posts 取前 limit 条;建议 5~12 } Return: { "q": string, "page": number, "posts": [ { "topic_id": number, "post_number": number, "title": string, // 话题标题(fancy_title/ title) "username": string, // 作者用户名 "created_at": string, // ISO 时间 "blurb": string, // 摘要(可能含 HTML) "url": string // 绝对链接:/t// } ] } 常见后续: - 需要整帖:用 discourse.getTopicAllPosts({topicId}) - 需要指定楼全文:用 discourse.getTopicPostFull({topicId, postNumber}) ---------------------------------------- # 2) discourse.getTopicAllPosts 用途:抓取某个话题的“全帖帖子流”(按 stream 批量拉 posts.json),用于总结/抽取结论/追踪争论上下文。 适用场景: - “总结这个话题”“整理楼主观点+最新进展” - 需要跨楼层对比观点(但注意上下文长度限制;如要某楼全文用 getTopicPostFull) Args: { "topicId": number|string, // 必填:话题 id "batchSize": number?, // 可选:默认 18。每批 post_ids[] 数量;建议 12~30 "maxPosts": number? // 可选:默认 240。最多抓取多少楼(从 stream 前 maxPosts) } Return: { "topicId": number|string, "title": string, "count": number, "posts": [ { "id": number, // post id "post_number": number, // 楼层号 "username": string, "created_at": string, // ISO "cooked": string, // HTML(可能很长) "url": string, // 绝对链接 "like_count": number?, "reply_count": number? } ] } 注意: - cooked 是 HTML;需要纯文本时自行 strip/抽取要点。 - 若某楼内容很长/很关键,建议改用 getTopicPostFull 精准抓全文(<=10000 chars)。 ---------------------------------------- # 3) discourse.getUserRecent 用途:查询某用户近期“发帖/回帖”等动作流(user_actions)。 适用场景: - “看看 @xxx 最近在讨论什么” - “找这个用户最近发的关于 XXX 的帖子” Args: { "username": string, // 必填:用户名(不带@也可) "limit": number? // 可选:默认 10。建议 8~20 } Return: { "username": string, "items": [ { "action_type": number, // 4=发帖, 5=回帖(脚本里用 filter:4,5) "title": string, "topic_id": number, "post_number": number, "created_at": string, "excerpt": string, // 摘要(可能含 HTML) "url": string } ] } 常见后续: - 想看该楼全文:getTopicPostFull({topicId, postNumber}) - 想看用户整体画像+热门内容:getUserSummary({username}) ---------------------------------------- # 4) discourse.getCategories 用途:获取站点分类列表 categories.json。 适用场景: - “有哪些分类/哪个分类适合发帖” - “列出分类ID/slug/帖子数” Args:{} Return:Discourse 原始 categories.json(结构较大),重点字段: - category_list.categories[]: {id, name, slug, description_text, topic_count, post_count, ...} ---------------------------------------- # 5) discourse.listLatestTopics 用途:获取 /latest.json 的话题列表(最新 bump 的主题)。 适用场景: - “列出最新话题” - “最近大家在聊啥” Args: { "page": number? // 可选:默认 0。>=0 } Return:Discourse 原始 latest.json(重点在 topic_list.topics[]) 常见后续: - 对某 topic 做总结:getTopicAllPosts({topicId}) - 看最新帖子流:listLatestPosts ---------------------------------------- # 6) discourse.listTopTopics 用途:获取 /top.json(按 period 的 Top 话题)。 适用场景: - “本周 Top 话题”“本月最热视频” Args: { "period": string?, // 可选:默认 "weekly" // 常用:daily | weekly | monthly | quarterly | yearly | all "page": number? // 可选:默认 0 } Return:Discourse 原始 top.json(重点在 topic_list.topics[]) ---------------------------------------- # 7) discourse.getTagTopics 用途:获取某个 tag 下的话题列表 /tag/.json 适用场景: - “看 linux tag 下有什么” - “某标签精选/总结” Args: { "tag": string, // 必填 "page": number? // 可选:默认 0 } Return:Discourse 原始 tag/.json(重点在 topic_list.topics[]) ---------------------------------------- # 8) discourse.getUserSummary 用途:用户概览(脚本做了“聚合+补充抓取”):summary.json + profile.json + activity/topics + activity/posts 适用场景: - “@xxx 是什么风格/主要关注什么/热门帖子有哪些” - “给我这个用户的热门话题、热门回复、近期话题、近期发言” Args: { "username": string // 必填 } Return(脚本自定义结构,稳定字段如下): { "username": string, "urls": { "profile": string, "summary": string }, "profile": { "id": number, "username": string, "name": string, "title": string, "trust_level": number, "avatar_template": string, "created_at": string, "last_seen_at": string, "last_posted_at": string, "badge_count": number, "website": string, "website_name": string, "profile_view_count": number, "time_read": number, "recent_time_read": number, "user_fields": object } | null, "summary": { "topic_count": number, "reply_count": number, "likes_given": number, "likes_received": number, "days_visited": number, "posts_read_count": number, "time_read": number } | null, "badges": { ... } | null, "hot_topics": [ {topic_id, title, like_count, views, reply_count, last_posted_at, category_id, tags, url} ], "hot_posts": [ {topic_id, post_number, post_id, title, like_count, created_at, excerpt, username, url} ], "recent_topics": [ ... ], "recent_posts": [ ... ], "_raw": { "summary_json": object|null, "profile_json": object|null, "activity_topics_json": object|null, "activity_posts_json": object|null } } 注意: - hot_* / recent_* 数组已在工具内部做过裁剪(最多 10 或 12)。 - 引用链接必须使用返回内的 url。 ---------------------------------------- # 9) discourse.getPost 用途:按 postId 获取单帖详情 /posts/.json 适用场景: - 已知 postId,想拿到完整 cooked/raw、编辑信息、附件等原始字段 Args: { "postId": number|string // 必填 } Return:Discourse 原始 post JSON(通常形如 { post: {...} } 或 {...}) 常见后续: - 若需要 topicId/post_number 组合链接,优先用返回内字段拼接或直接用现成 url。 ---------------------------------------- # 10) discourse.getTopicPostFull 用途:按 (topicId + postNumber) 精确抓取指定楼层“全文”(raw/cooked 截断<=maxChars)。 适用场景: - “抓这个话题第 N 楼全文” - “给我 OP 全文/某楼关键代码块” - getTopicAllPosts 上下文被省略中间楼层时,补抓指定楼 Args: { "topicId": number|string, // 必填 "postNumber": number, // 必填:>=1 "maxChars": number? // 可选:默认 10000;范围 [1000, 10000] } Return: { "topicId": number|string, "title": string, "postId": number, "post_number": number, "username": string, "created_at": string, "url": string, "cooked": string, // HTML,已按 maxChars 截断 "raw": string, // 纯文本/markdown,已按 maxChars 截断 "maxChars": number, "endpointUsed": string // "posts/by_number" 或 "t/{topicId}/{postNumber}.json" } 注意: - raw 是最适合做“引用/复述”的来源(仍需遵守不要原样大段复制的原则)。 - cooked 可能含 HTML 标签。 ---------------------------------------- # 11) discourse.listLatestPosts 用途:站点“最新帖子流” /posts.json(如果站点允许)。 适用场景: - “站点最新帖子列表” - “最近刚发了哪些回复/新帖子” Args: { "before": number|string|null?, // 可选:默认 null。用于翻页(取更早的) "limit": number? // 可选:默认 20;范围 [1, 50] } Return(脚本自定义结构): { "before": number|string|null, "returned": number, "posts": [ { "id": number, "topic_id": number, "post_number": number, "username": string, "created_at": string, "cooked": string, "raw": string, "like_count": number?, "url": string } ] } ---------------------------------------- # Recommended Multi-step Workflows (GUIDE) - 关键词找资料:discourse.search ->(挑 topic_id/post_number)-> getTopicAllPosts 或 getTopicPostFull -> final - 总结某话题:getTopicAllPosts(topicId) -> 如需补楼:getTopicPostFull -> final - 看用户画像:getUserSummary(username) -> 如需看某楼:getTopicPostFull -> final - 看最新动态:listLatestTopics 或 listLatestPosts -> 选 topic -> getTopicAllPosts -> final `; async function runTool(name, args, cancelToken) { // 每次工具调用都支持 AbortController(Stop) const ac = new AbortController(); if (cancelToken) { cancelToken.aborts.push(() => ac.abort()); if (cancelToken.cancelled) ac.abort(); } if (name === "discourse.search") return DiscourseAPI.search(args, ac.signal); if (name === "discourse.getTopicAllPosts") return DiscourseAPI.getTopicAllPosts(args, ac.signal, cancelToken); if (name === "discourse.getUserRecent") return DiscourseAPI.getUserRecent(args, ac.signal); if (name === "discourse.getCategories") return DiscourseAPI.getCategories(ac.signal); if (name === "discourse.listLatestTopics") return DiscourseAPI.listLatestTopics(args, ac.signal); if (name === "discourse.listTopTopics") return DiscourseAPI.listTopTopics(args, ac.signal); if (name === "discourse.getTagTopics") return DiscourseAPI.getTagTopics(args, ac.signal); if (name === "discourse.getUserSummary") return DiscourseAPI.getUserSummary(args, ac.signal); if (name === "discourse.getPost") return DiscourseAPI.getPost(args, ac.signal); if (name === "discourse.getTopicPostFull") return DiscourseAPI.getTopicPostFull(args, ac.signal); if (name === "discourse.listLatestPosts") return DiscourseAPI.listLatestPosts(args, ac.signal); throw new Error(`未知工具: ${name}`); } /****************************************************************** * 4.5) toolResultToContext(增强) ******************************************************************/ function toolResultToContext(name, result) { const LIMITS = { search_items: 12, search_excerpt: 420, user_recent_items: 16, user_recent_excerpt: 420, topic_head_posts: 18, topic_tail_posts: 8, topic_excerpt: 900, topic_op_extra: 2200, list_topics_items: 30, categories_items: 40, post_excerpt: 1600, topic_post_full_cooked_hint: 2200, user_hot_topics: 10, user_hot_posts: 10, user_recent_topics: 12, user_recent_posts: 12, user_excerpt: 260, latest_posts_items: 24, latest_posts_excerpt: 420, }; const MAX_CONTEXT_CHARS = 22000; const norm = (s) => stripHtml(String(s || "")) .replace(/\s+/g, " ") .trim(); const cut = (s, n) => { s = String(s || ""); return s.length > n ? s.slice(0, n) + "…" : s; }; const kv = (k, v) => v === undefined || v === null || v === "" ? "" : `${k}: ${v}`; const joinNonEmpty = (arr, sep = "\n") => arr.filter(Boolean).join(sep); const clampCtx = (text) => clamp(text, MAX_CONTEXT_CHARS); if (name === "discourse.search") { const posts = (result?.posts || []).slice(0, LIMITS.search_items); const lines = posts.map((p, i) => { const ex = cut(norm(p.blurb), LIMITS.search_excerpt); return joinNonEmpty([ `${i + 1}. ${safeTitle(p.title, `话题 ${p.topic_id}`)}`, `- topic_id: ${p.topic_id} | post_number: ${p.post_number}`, `- author: @${p.username} | created_at: ${p.created_at}`, `- 摘要: ${ex}`, `- 链接: ${p.url}`, ]); }); const header = `【TOOL_RESULT discourse.search | q=${ result?.q ?? "" } | page=${result?.page ?? ""} | returned=${posts.length}】`; return clampCtx(header + "\n" + lines.join("\n\n")); } if (name === "discourse.getUserRecent") { const items = (result?.items || []).slice(0, LIMITS.user_recent_items); const lines = items.map((x, i) => { const ex = cut(norm(x.excerpt), LIMITS.user_recent_excerpt); const typ = x.action_type === 4 ? "发帖" : x.action_type === 5 ? "回帖" : `动作${x.action_type}`; return joinNonEmpty([ `${i + 1}. ${typ} | ${safeTitle(x.title, `话题 ${x.topic_id}`)}`, `- topic_id: ${x.topic_id} | post_number: ${x.post_number}`, `- created_at: ${x.created_at}`, `- 摘要: ${ex}`, `- 链接: ${x.url}`, ]); }); const header = `【TOOL_RESULT discourse.getUserRecent | @${ result?.username ?? "" } | returned=${items.length}】`; return clampCtx(header + "\n" + lines.join("\n\n")); } if (name === "discourse.getTopicAllPosts") { const postsAll = result?.posts || []; const count = postsAll.length; const head = postsAll.slice(0, LIMITS.topic_head_posts); const tail = count > LIMITS.topic_head_posts ? postsAll.slice( Math.max(LIMITS.topic_head_posts, count - LIMITS.topic_tail_posts) ) : []; const formatPost = (p) => { const isOP = p.post_number === 1; const n = isOP ? Math.max(LIMITS.topic_excerpt, LIMITS.topic_op_extra) : LIMITS.topic_excerpt; const ex = cut(norm(p.cooked), n); const likes = p.like_count !== undefined ? ` | likes=${p.like_count}` : ""; return joinNonEmpty([ `#${p.post_number} @${p.username} ${p.created_at}${likes}`, `- 摘要: ${ex}`, `- 链接: ${p.url}`, ]); }; const headLines = head.map(formatPost); const tailLines = tail.map(formatPost); const header = joinNonEmpty([ `【TOOL_RESULT discourse.getTopicAllPosts | ${safeTitle( result?.title, `话题 ${result?.topicId}` )}】`, `topicId: ${result?.topicId} | total_posts: ${count}`, `hint: 已提供“前${head.length}楼 + 后${tailLines.length}楼”,用于同时覆盖 OP 与最新进展`, ]); const midGap = tailLines.length && count > head.length + tailLines.length ? `\n\n…(中间省略 ${ count - head.length - tailLines.length } 楼,为节省上下文;如需可用 discourse.getTopicPostFull 抓取指定楼层全文)…\n\n` : "\n\n"; return clampCtx( header + "\n\n" + headLines.join("\n\n") + midGap + tailLines.join("\n\n") ); } if (name === "discourse.getCategories") { const cats = (result?.category_list?.categories || []).slice( 0, LIMITS.categories_items ); const lines = cats.map((c, i) => { const slug = c.slug || c.name || ""; const url = `${location.origin}/c/${encodeURIComponent(slug)}/${c.id}`; const desc = cut(norm(c.description || c.description_text || ""), 260); return joinNonEmpty([ `${i + 1}. ${safeTitle(c.name, `分类 ${c.id}`)} (id=${c.id}, slug=${ c.slug || "" })`, joinNonEmpty( [ kv("- topics", c.topic_count), kv("posts", c.post_count), kv("users", c.user_count), kv("position", c.position), ], " | " ).replace(/^\s*\|\s*/, "- "), desc ? `- 描述: ${desc}` : "", `- 链接: ${url}`, ]); }); const header = `【TOOL_RESULT discourse.getCategories | returned=${cats.length}】`; return clampCtx(header + "\n" + lines.join("\n\n")); } if ( name === "discourse.listLatestTopics" || name === "discourse.listTopTopics" || name === "discourse.getTagTopics" ) { const topics = (result?.topic_list?.topics || []).slice( 0, LIMITS.list_topics_items ); const metaBits = []; if (name === "discourse.getTagTopics") metaBits.push(kv("tag", result?.tag || result?.tag_name || "")); if (name === "discourse.listTopTopics") metaBits.push(kv("period", result?.period || "")); metaBits.push(kv("page", result?.topic_list?.page || result?.page || "")); const moreUrl = result?.topic_list?.more_topics_url; const topTags = Array.isArray(result?.topic_list?.top_tags) ? result.topic_list.top_tags.slice(0, 15) : []; const lines = topics.map((t, i) => { const url = DiscourseAPI.topicUrl(t.id, 1); const title = safeTitle(t.fancy_title || t.title, `话题 ${t.id}`); const tags = Array.isArray(t.tags) ? t.tags.join(",") : ""; const last = t.last_posted_at || t.bumped_at || ""; const postsCount = t.posts_count !== undefined ? t.posts_count : undefined; const replies = t.reply_count !== undefined ? t.reply_count : postsCount !== undefined ? Math.max(0, postsCount - 1) : undefined; return joinNonEmpty([ `${i + 1}. ${title}`, joinNonEmpty( [ kv("- topic_id", t.id), kv("category_id", t.category_id), kv("tags", tags), ], " | " ).replace(/^\s*\|\s*/, "- "), joinNonEmpty( [ kv("- posts_count", postsCount), kv("replies", replies), kv("views", t.views), kv("like_count", t.like_count), kv("last", last), ], " | " ).replace(/^\s*\|\s*/, "- "), `- 链接: ${url}`, ]); }); const header = `【TOOL_RESULT ${name} | ${metaBits .filter(Boolean) .join(" | ")} | returned=${topics.length}】`; const extra = joinNonEmpty([ moreUrl ? `more_topics_url: ${location.origin}${moreUrl}` : "", topTags.length ? `top_tags: ${topTags.join(", ")}` : "", ]); return clampCtx( header + "\n" + (extra ? extra + "\n\n" : "") + lines.join("\n\n") ); } if (name === "discourse.getUserSummary") { const r = result || {}; const u = r.profile || {}; const s = r.summary || {}; const hotTopics = (r.hot_topics || []).slice(0, LIMITS.user_hot_topics); const hotPosts = (r.hot_posts || []).slice(0, LIMITS.user_hot_posts); const recTopics = (r.recent_topics || []).slice( 0, LIMITS.user_recent_topics ); const recPosts = (r.recent_posts || []).slice( 0, LIMITS.user_recent_posts ); const base = [ `【TOOL_RESULT discourse.getUserSummary | @${ r.username || "" } | Rich】`, r.urls?.profile ? `profile: ${r.urls.profile}` : "", r.urls?.summary ? `summary: ${r.urls.summary}` : "", "", "--- 用户信息 ---", [ kv("id", u.id), kv("username", u.username ? "@" + u.username : ""), kv("name", u.name), kv("title", u.title), kv("trust_level", u.trust_level), kv("badge_count", u.badge_count), ] .filter(Boolean) .join(" | "), [ kv("created_at", u.created_at), kv("last_seen_at", u.last_seen_at), kv("last_posted_at", u.last_posted_at), ] .filter(Boolean) .join(" | "), u.website ? `website: ${u.website}` : "", u.profile_view_count !== undefined ? `profile_view_count: ${u.profile_view_count}` : "", "", "--- 统计摘要 ---", [ kv("topic_count", s.topic_count), kv("reply_count", s.reply_count), kv("likes_given", s.likes_given), kv("likes_received", s.likes_received), ] .filter(Boolean) .join(" | "), [ kv("days_visited", s.days_visited), kv("posts_read_count", s.posts_read_count), kv("time_read", s.time_read), ] .filter(Boolean) .join(" | "), ].filter(Boolean); const norm = (s2) => stripHtml(String(s2 || "")) .replace(/\s+/g, " ") .trim(); const cut = (s2, n) => String(s2 || "").length > n ? String(s2 || "").slice(0, n) + "…" : String(s2 || ""); const fmtTopic = (t, i) => { const tags = Array.isArray(t.tags) ? t.tags.join(",") : ""; const ex = t.excerpt ? cut(norm(t.excerpt), LIMITS.user_excerpt) : ""; return [ `${i + 1}. ${safeTitle(t.title, `话题 ${t.topic_id}`)}`, [ kv("- topic_id", t.topic_id), kv("category_id", t.category_id), kv("tags", tags), ] .filter(Boolean) .join(" | ") .replace(/^\s*\|\s*/, "- "), [ kv("- likes", t.like_count), kv("views", t.views), kv("replies", t.reply_count), kv("last", t.last_posted_at), ] .filter(Boolean) .join(" | ") .replace(/^\s*\|\s*/, "- "), ex ? `- 摘要: ${ex}` : "", t.url ? `- 链接: ${t.url}` : "", ] .filter(Boolean) .join("\n"); }; const fmtPost = (p, i) => { const ex = cut(norm(p.excerpt || p.cooked || ""), LIMITS.user_excerpt); return [ `${i + 1}. ${safeTitle(p.title, `话题 ${p.topic_id}`)} #${ p.post_number }`, [ kv("- topic_id", p.topic_id), kv("post_number", p.post_number), kv("likes", p.like_count), kv("author", p.username ? "@" + p.username : ""), kv("created_at", p.created_at), ] .filter(Boolean) .join(" | ") .replace(/^\s*\|\s*/, "- "), ex ? `- 摘要: ${ex}` : "", p.url ? `- 链接: ${p.url}` : "", ] .filter(Boolean) .join("\n"); }; const sections = []; if (hotTopics.length) sections.push( [ "", "--- 热门话题(Top Topics / Hot Topics)---", ...hotTopics.map(fmtTopic), ].join("\n") ); if (hotPosts.length) sections.push( [ "", "--- 热门帖子(Top Replies / Hot Posts)---", ...hotPosts.map(fmtPost), ].join("\n") ); if (recTopics.length) sections.push( [ "", "--- 近期话题(Recent Topics)---", ...recTopics.map(fmtTopic), ].join("\n") ); if (recPosts.length) sections.push( [ "", "--- 近期发言(Recent Posts)---", ...recPosts.map(fmtPost), ].join("\n") ); const badgeHint = r.badges?.user_badges && r.badges?.badges ? `\n--- 徽章(Badges)---\nuser_badges: ${ Array.isArray(r.badges.user_badges) ? r.badges.user_badges.length : "n/a" } | badges: ${ Array.isArray(r.badges.badges) ? r.badges.badges.length : "n/a" }` : ""; return clamp( base.join("\n") + "\n" + sections.join("\n") + badgeHint, MAX_CONTEXT_CHARS ); } if (name === "discourse.getPost") { const p = result?.post || result || {}; const cooked = cut(norm(p.cooked || ""), LIMITS.post_excerpt); const raw = cut(String(p.raw || ""), Math.min(1200, LIMITS.post_excerpt)); const url = p.topic_id && p.post_number ? DiscourseAPI.topicUrl(p.topic_id, p.post_number) : ""; const lines = [ `【TOOL_RESULT discourse.getPost】`, `id: ${p.id || ""}`, [ kv("topic_id", p.topic_id), kv("post_number", p.post_number), kv("author", p.username ? "@" + p.username : ""), kv("created_at", p.created_at), ] .filter(Boolean) .join(" | "), url ? `- 链接: ${url}` : "", cooked ? `- cooked 摘要: ${cooked}` : "", raw ? `- raw 摘要: ${raw}` : "", ].filter(Boolean); return clampCtx(lines.join("\n")); } if (name === "discourse.getTopicPostFull") { const r = result || {}; const cookedHint = cut( norm(r.cooked || ""), LIMITS.topic_post_full_cooked_hint ); const lines = [ `【TOOL_RESULT discourse.getTopicPostFull | ${safeTitle( r.title, `话题 ${r.topicId}` )}】`, `topicId: ${r.topicId} | post_number: ${r.post_number} | postId: ${ r.postId || "" }`, `author: @${r.username || ""} | created_at: ${r.created_at || ""}`, `endpointUsed: ${r.endpointUsed || ""} | maxChars: ${r.maxChars || ""}`, r.url ? `- 链接: ${r.url}` : "", cookedHint ? `- cooked(提示): ${cookedHint}` : "", "", "--- raw(全文,已限制 <=10000 字符) ---", String(r.raw || ""), ].filter(Boolean); return clampCtx(lines.join("\n")); } if (name === "discourse.listLatestPosts") { const posts = (result?.posts || []).slice(0, LIMITS.latest_posts_items); const lines = posts.map((p, i) => { const ex = cut( norm(p.cooked || p.raw || ""), LIMITS.latest_posts_excerpt ); return joinNonEmpty([ `${i + 1}. @${p.username || ""} | topic=${p.topic_id} #${ p.post_number }`, [ kv("- post_id", p.id), kv("likes", p.like_count), kv("created_at", p.created_at), ] .filter(Boolean) .join(" | ") .replace(/^\s*\|\s*/, "- "), ex ? `- 摘要: ${ex}` : "", p.url ? `- 链接: ${p.url}` : "", ]); }); const header = `【TOOL_RESULT discourse.listLatestPosts | before=${ result?.before ?? "null" } | returned=${posts.length}】`; return clampCtx(header + "\n" + lines.join("\n\n")); } try { const text = JSON.stringify(result, null, 2); return clampCtx(`【TOOL_RESULT ${name} | fallback_json】\n` + text); } catch { return clampCtx( `【TOOL_RESULT ${name} | fallback_text】\n` + String(result) ); } } /****************************************************************** * 5) OpenAI Chat Completions 客户端(支持 Stop abort) ******************************************************************/ function parseRetryAfterMs(responseHeaders) { try { const m = String(responseHeaders || "").match( /^\s*retry-after\s*:\s*([^\r\n]+)\s*$/im ); if (!m) return null; const v = m[1].trim(); if (/^\d+$/.test(v)) return parseInt(v, 10) * 1000; const t = Date.parse(v); if (!Number.isNaN(t)) { const ms = t - Date.now(); return ms > 0 ? ms : 0; } } catch {} return null; } function gmRequestOnce({ url, headers, bodyObj, timeoutMs = 30000, cancelToken, }) { return new Promise((resolve, reject) => { const req = GM_xmlhttpRequest({ method: "POST", url, headers: { "Content-Type": "application/json", ...(headers || {}) }, data: JSON.stringify(bodyObj), timeout: timeoutMs, onload: (res) => resolve(res), onerror: (e) => reject(new Error(`网络错误: ${e?.error || e}`)), ontimeout: () => reject(new Error(`请求超时: ${timeoutMs}ms`)), }); if (cancelToken) { cancelToken.aborts.push(() => { try { req.abort(); } catch {} }); if (cancelToken.cancelled) { try { req.abort(); } catch {} } } }); } async function gmPostJson(url, headers, bodyObj, opt = {}) { const { retries = 3, baseDelayMs = 400, maxDelayMs = 8000, timeoutMs = 30000, onlyStatus200 = true, cancelToken = null, } = opt; let lastErr; for (let attempt = 0; attempt <= retries; attempt++) { if (cancelToken?.cancelled) throw new Error("Cancelled"); try { const res = await gmRequestOnce({ url, headers, bodyObj, timeoutMs, cancelToken, }); const ok = onlyStatus200 ? res.status === 200 : res.status >= 200 && res.status < 300; if (!ok) { const headRetryMs = parseRetryAfterMs(res.responseHeaders); const bodyPreview = String(res.responseText || "").slice(0, 800); const err = new Error(`HTTP ${res.status}: ${bodyPreview}`); err._httpStatus = res.status; err._retryAfterMs = headRetryMs; throw err; } try { return JSON.parse(res.responseText); } catch { throw new Error("响应 JSON 解析失败"); } } catch (e) { lastErr = e; if (attempt === retries) break; const ra = e?._retryAfterMs; const backoff = Math.min( maxDelayMs, baseDelayMs * Math.pow(2, attempt) ); const jitter = Math.floor(Math.random() * 200); const waitMs = typeof ra === "number" ? ra : backoff + jitter; await sleep(waitMs); continue; } } throw lastErr || new Error("请求失败"); } async function callOpenAIChat(messages, conf, cancelToken) { const base = String(conf.baseUrl || "").replace(/\/+$/, ""); const url = base.endsWith("/chat/completions") ? base : base + "/chat/completions"; const payload = { model: conf.model, temperature: conf.temperature ?? 0.2, messages, }; const json = await gmPostJson( url, { Authorization: `Bearer ${conf.apiKey}` }, payload, { retries: 3, onlyStatus200: true, cancelToken } ); const text = json?.choices?.[0]?.message?.content ?? ""; return String(text); } /****************************************************************** * 6) JSON 修复逻辑(find / rfind + 回写 history) ******************************************************************/ function parseModelJsonWithRepair(raw, sessionId, store) { const original = String(raw ?? ""); try { const obj = JSON.parse(original); return { ok: true, obj, repaired: false, jsonText: original }; } catch {} const first = original.indexOf("{"); const last = original.lastIndexOf("}"); if (first >= 0 && last > first) { const sliced = original.slice(first, last + 1); store.updateLastAgent( sessionId, (m) => m && m.kind === "model_raw", (m) => ({ ...m, kind: "model_json_repaired", content: sliced, repairedFrom: original, }) ); try { const obj = JSON.parse(sliced); return { ok: true, obj, repaired: true, jsonText: sliced }; } catch (e) { return { ok: false, err: "切片后仍无法解析 JSON", detail: String(e?.message || e), sliced, }; } } return { ok: false, err: "未找到可用的 JSON 对象边界 { ... }", detail: original.slice(0, 400), }; } /****************************************************************** * 7) Agent 引擎(FSM + 多轮工具调用)+ Stop 支持 ******************************************************************/ function buildLLMMessagesFromSession(session, conf) { const msgs = []; msgs.push({ role: "system", content: conf.systemPrompt + "\n\n" + TOOLS_SPEC, }); const events = []; // 1) 用户/最终回复(你现在的 chat) for (const m of session.chat || []) { if (!m?.role || !m?.content) continue; events.push({ ts: Number(m.ts || 0), role: m.role, content: (m.role == "assistant") ? `${String(m.content)}。请使用正确json返回响应,此处仅为压缩上下文省略json` : String(m.content), }); } // 2) 工具链路(你现在的 agent) if (conf.includeToolContext) { for (const a of session.agent || []) { if (!a?.content) continue; // ✅ 把模型“调用工具时输出的 JSON”也喂回去 if (a.kind === "tool_call") { events.push({ ts: Number(a.ts || 0), role: "assistant", // 给模型一个稳定、醒目的标记,避免跟普通对话混 content: `【TOOL_CALL】\n${String(a.content)}`, }); } // ✅ 工具结果:你之前只喂这个,而且还放错位置 if (a.kind === "tool_context") { events.push({ ts: Number(a.ts || 0), role: "user", content: `【TOOL_RESULT】\n${String(a.content)}`, }); } // (可选)把解析修复后的 JSON 也喂回去,方便模型“知道自己最后被修复成啥” // if (a.kind === "model_json_repaired") { // events.push({ ts: Number(a.ts || 0), role: "assistant", content: `【MODEL_JSON_REPAIRED】\n${String(a.content)}` }); // } } } // ✅ 核心:按 ts 排序,保证时间线正确 events.sort((x, y) => (x.ts || 0) - (y.ts || 0)); // 3) 截断:从尾部开始保留到 maxContextChars(但保持顺序) const max = conf.maxContextChars || 24000; let total = 0; const kept = []; for (let i = events.length - 1; i >= 0; i--) { const e = events[i]; const len = (e.content || "").length; if (total + len > max) break; kept.push({ role: e.role, content: e.content }); total += len; } kept.reverse(); msgs.push(...kept); return msgs; } async function runAgentTurn(sessionId, store, conf, ui, cancelToken) { const session = store.all().find((s) => s.id === sessionId); if (!session) throw new Error("session not found"); if (cancelToken?.cancelled) throw new Error("Cancelled"); store.setFSM(sessionId, { state: FSM.WAITING_MODEL, isRunning: true, step: (session.fsm?.step || 0) + 1, lastError: null, }); ui?.renderAll?.(); const llmMessages = buildLLMMessagesFromSession(session, conf); const raw = await callOpenAIChat(llmMessages, conf, cancelToken); if (cancelToken?.cancelled) throw new Error("Cancelled"); store.pushAgent(sessionId, { role: "agent", kind: "model_raw", content: raw, ts: now(), }); const parsed = parseModelJsonWithRepair(raw, sessionId, store); if (!parsed.ok) { store.pushAgent(sessionId, { role: "agent", kind: "model_parse_error", content: JSON.stringify(parsed, null, 2), ts: now(), }); store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: parsed.err || "parse error", }); ui?.renderAll?.(); throw new Error(parsed.err || "模型 JSON 解析失败"); } const obj = parsed.obj; if (obj.type === "final") { const answer = String(obj.answer ?? "").trim() || "(空回答)"; let refsMd = ""; if (Array.isArray(obj.refs) && obj.refs.length) { const seen = new Set(); const cleaned = obj.refs .map((x) => ({ title: mdEscapeText(String(x?.title ?? "").trim() || "链接"), url: String(x?.url ?? "").trim(), })) .filter((x) => x.url && !seen.has(x.url) && (seen.add(x.url), true)); if (cleaned.length) { refsMd = "\n\n---\n**参考链接(refs)**\n" + cleaned .map((r, i) => `${i + 1}. [${r.title}](${r.url})`) .join("\n"); } } const finalContent = answer + refsMd; store.pushChat(sessionId, { role: "assistant", content: finalContent, ts: now(), }); if (Array.isArray(obj.refs) && obj.refs.length) { store.pushAgent(sessionId, { role: "agent", kind: "final_refs", content: JSON.stringify(obj.refs, null, 2), ts: now(), }); } store.setFSM(sessionId, { state: FSM.DONE, isRunning: false }); ui?.renderAll?.(); return { done: true, obj }; } if (obj.type === "tool") { const name = String(obj.name || "").trim(); const args = obj.args || {}; if (!name) throw new Error("工具调用缺少 name"); store.setFSM(sessionId, { state: FSM.WAITING_TOOL, isRunning: true }); store.pushAgent(sessionId, { role: "agent", kind: "tool_call", content: JSON.stringify({ type: "tool", name, args }, null, 2), ts: now(), }); ui?.renderAll?.(); let result; try { result = await runTool(name, args, cancelToken); } catch (e) { const errMsg = `【TOOL_RESULT ERROR ${name}】\nargs=${JSON.stringify( args )}\nerror=${String(e?.message || e)}`; store.pushAgent(sessionId, { role: "tool", kind: "tool_context", content: errMsg, ts: now(), toolName: name, }); store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true }); ui?.renderAll?.(); return { done: false, obj: { type: "tool_error" } }; } const toolCtx = toolResultToContext(name, result); store.pushAgent(sessionId, { role: "tool", kind: "tool_context", content: toolCtx, ts: now(), toolName: name, }); store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true }); ui?.renderAll?.(); return { done: false, obj: { type: "tool_ok" } }; } store.pushAgent(sessionId, { role: "agent", kind: "model_unknown_type", content: JSON.stringify(obj, null, 2), ts: now(), }); store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: "unknown type", }); ui?.renderAll?.(); throw new Error(`未知 type: ${obj.type}`); } async function runAgent(sessionId, store, conf, ui) { const session = store.all().find((s) => s.id === sessionId); if (!session) throw new Error("session not found"); if (!conf.apiKey) throw new Error("请先在设置中填写 API Key"); if (session.fsm?.isRunning) return; const cancelToken = ensureCancelToken(sessionId); cancelToken.cancelled = false; cancelToken.aborts = cancelToken.aborts || []; store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true, lastError: null, }); ui?.renderAll?.(); const maxTurns = Math.max( 1, Math.min(10000, parseInt(conf.maxTurns || 8, 10)) ); try { for (let i = 0; i < maxTurns; i++) { if (cancelToken.cancelled) throw new Error("Cancelled"); const r = await runAgentTurn(sessionId, store, conf, ui, cancelToken); if (r.done) { CANCEL.delete(sessionId); return r.obj; } await sleep(80); } store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: "超过 maxTurns 仍未 final", }); ui?.renderAll?.(); throw new Error("超过 maxTurns 仍未得到 final"); } catch (e) { const msg = String(e?.message || e); if (msg === "Cancelled") { store.pushAgent(sessionId, { role: "agent", kind: "cancelled", content: "用户点击 Stop 取消运行", ts: now(), }); store.setFSM(sessionId, { state: FSM.IDLE, isRunning: false, lastError: null, }); ui?.renderAll?.(); CANCEL.delete(sessionId); return; } store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: msg, }); ui?.renderAll?.(); CANCEL.delete(sessionId); throw e; } } /****************************************************************** * 8) Workbench UI(Chat/Tools/Debug Tabs + Stop + 过滤 + 折叠/复制/引用) ******************************************************************/ const STYLES = ` :root{ --a-bg: linear-gradient(135deg, rgba(250,250,252,.98), rgba(245,247,252,.98)); --a-card: rgba(255,255,255,.98); --a-text: #0e1116; --a-sub: #546376; --a-border: rgba(31,109,255,.12); --a-shadow: 0 20px 50px rgba(31,109,255,.12), 0 8px 16px rgba(0,0,0,.08); --a-primary: linear-gradient(135deg, #1f6dff, #4a8fff); --a-primary-hover: linear-gradient(135deg, #1557d6, #3d7ee6); --a-user: linear-gradient(135deg, #e8f0ff, #f0f6ff); --a-ass: linear-gradient(135deg, #ffffff, #fafbff); --a-tool: linear-gradient(135deg, #fff8db, #fffaed); --a-code:#0d1117; --a-codeText:#e6edf3; --a-danger: linear-gradient(135deg, #ff4757, #ff6b7a); --a-warn: linear-gradient(135deg, #ffa502, #ffb830); --a-success: linear-gradient(135deg, #26de81, #20e3b2); --a-glow: rgba(31,109,255,.25); } /* 深色主题变量(用于手动切换和系统深色模式) */ @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --a-bg: linear-gradient(135deg, rgba(16,18,24,.96), rgba(20,22,28,.96)); --a-card: linear-gradient(135deg, rgba(28,30,38,.95), rgba(25,27,36,.95)); --a-text: #e8ecf1; --a-sub: #adb5c7; --a-border: rgba(106,162,255,.15); --a-shadow: 0 24px 60px rgba(0,0,0,.7), 0 10px 20px rgba(106,162,255,.08); --a-primary: linear-gradient(135deg, #6aa2ff, #5a8fee); --a-primary-hover: linear-gradient(135deg, #7db0ff, #6a98ff); --a-user: linear-gradient(135deg, #1f2736, #252d3e); --a-ass: linear-gradient(135deg, #1a1e28, #1d212b); --a-tool: linear-gradient(135deg, #2d3340, #32394a); --a-code:#0d1117; --a-codeText:#c9d1d9; --a-danger: linear-gradient(135deg, #ff6b7a, #ff8593); --a-warn: linear-gradient(135deg, #ffb830, #ffc648); --a-success: linear-gradient(135deg, #20e3b2, #29ffc6); --a-glow: rgba(106,162,255,.3); } } /* 强制深色主题 */ :root[data-theme="dark"]{ --a-bg: linear-gradient(135deg, rgba(16,18,24,.96), rgba(20,22,28,.96)); --a-card: linear-gradient(135deg, rgba(28,30,38,.95), rgba(25,27,36,.95)); --a-text: #e8ecf1; --a-sub: #adb5c7; --a-border: rgba(106,162,255,.15); --a-shadow: 0 24px 60px rgba(0,0,0,.7), 0 10px 20px rgba(106,162,255,.08); --a-primary: linear-gradient(135deg, #6aa2ff, #5a8fee); --a-primary-hover: linear-gradient(135deg, #7db0ff, #6a98ff); --a-user: linear-gradient(135deg, #1f2736, #252d3e); --a-ass: linear-gradient(135deg, #1a1e28, #1d212b); --a-tool: linear-gradient(135deg, #2d3340, #32394a); --a-code:#0d1117; --a-codeText:#c9d1d9; --a-danger: linear-gradient(135deg, #ff6b7a, #ff8593); --a-warn: linear-gradient(135deg, #ffb830, #ffc648); --a-success: linear-gradient(135deg, #20e3b2, #29ffc6); --a-glow: rgba(106,162,255,.3); } /* ✅ FAB */ #${APP_PREFIX}fab{ position:fixed; left: calc(100vw - 70px); top: 16px; width:48px; height:48px; border-radius:18px; background: var(--a-card); border:2px solid var(--a-border); box-shadow: var(--a-shadow); z-index:100003; display:flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; background: var(--a-primary); color: #fff; font-weight:900; font-size:18px; touch-action: none; transition: all .3s cubic-bezier(0.34, 1.56, 0.64, 1); } #${APP_PREFIX}fab:hover{ transform: translateY(-4px) scale(1.08); box-shadow: 0 28px 65px rgba(31,109,255,.25), 0 12px 20px rgba(0,0,0,.15); filter: brightness(1.1); } #${APP_PREFIX}fab.dragging{ cursor: grabbing; transform: scale(1.12) rotate(8deg); filter: brightness(1.15); } #${APP_PREFIX}fab .dot{ position:absolute; right: 3px; top: 0px; width:12px; height:12px; border-radius:999px; background: transparent; border:2px solid transparent; transition: all .3s ease; } #${APP_PREFIX}fab.running .dot{ background: var(--a-warn); border-color: #fff; box-shadow: 0 0 12px var(--a-warn), 0 0 24px var(--a-warn); animation: pulse-dot 1.5s ease-in-out infinite; } #${APP_PREFIX}fab.error .dot{ background: var(--a-danger); border-color: #fff; box-shadow: 0 0 12px var(--a-danger); } @keyframes pulse-dot { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.3); opacity: 0.8; } } /* Drawer:响应式,不再 min-width:1000px */ #${APP_PREFIX}drawer{ position:fixed; left:0; right:0; top:-85vh; height:82vh; z-index:100002; background: var(--a-bg); border-bottom:2px solid var(--a-border); box-shadow: var(--a-shadow); border-bottom-left-radius:24px; border-bottom-right-radius:24px; transition: top .45s cubic-bezier(0.16, 1, 0.3, 1), box-shadow .3s ease; backdrop-filter: blur(20px) saturate(180%); display:flex; flex-direction:column; color: var(--a-text); font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif; overflow:hidden; } #${APP_PREFIX}drawer.open{ top:0; box-shadow: 0 28px 80px rgba(0,0,0,.3), 0 0 0 1px var(--a-border); } .${APP_PREFIX}header{ padding:16px 20px; border-bottom:2px solid var(--a-border); display:flex; align-items:center; justify-content:space-between; background: radial-gradient(1400px 180px at 20% 0%, var(--a-glow), transparent 65%); flex-shrink:0; gap:12px; position: relative; } .${APP_PREFIX}header::after{ content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--a-primary); opacity: .15; } .${APP_PREFIX}title{ font-weight:900; letter-spacing:.2px; display:flex; align-items:center; gap:10px; color: var(--a-primary); min-width: 240px; flex-wrap: wrap; } .${APP_PREFIX}badge{ font-size:12px; padding:3px 8px; border-radius:999px; border:1px solid var(--a-border); color: var(--a-sub); font-weight:800; } .${APP_PREFIX}actions{ display:flex; align-items:center; gap:8px; color:var(--a-sub); flex-wrap: wrap; justify-content:flex-end; } .${APP_PREFIX}icon{ cursor:pointer; padding:9px 12px; border-radius:12px; border:1.5px solid var(--a-border); background: rgba(127,127,127,.04); color: var(--a-text); font-weight:900; white-space: nowrap; transition: all .25s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } .${APP_PREFIX}icon::before{ content: ''; position: absolute; inset: 0; background: var(--a-primary); opacity: 0; transition: opacity .25s ease; } .${APP_PREFIX}icon:hover{ border-color: transparent; background: var(--a-primary); color: #fff; transform: translateY(-2px); box-shadow: 0 8px 16px var(--a-glow); } .${APP_PREFIX}pill{ font-size:12px; font-weight:900; padding:6px 10px; border-radius:999px; border:1px solid var(--a-border); background: rgba(127,127,127,.06); color: var(--a-text); display:flex; gap:8px; align-items:center; max-width: 48vw; overflow:hidden; text-overflow: ellipsis; white-space: nowrap; } .${APP_PREFIX}pill .st{ color: var(--a-primary); } .${APP_PREFIX}pill .err{ color: var(--a-danger); } .${APP_PREFIX}tabs{ display:flex; align-items:center; gap:6px; border:1.5px solid var(--a-border); background: rgba(127,127,127,.05); padding:5px; border-radius: 999px; } .${APP_PREFIX}tab{ padding:8px 14px; border-radius:999px; cursor:pointer; user-select:none; font-weight:900; font-size:13px; color: var(--a-sub); transition: all .3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; } .${APP_PREFIX}tab:hover{ color: var(--a-text); transform: translateY(-1px); } .${APP_PREFIX}tab.active{ background: var(--a-primary); border:none; color: #fff; box-shadow: 0 4px 12px var(--a-glow), inset 0 1px 2px rgba(255,255,255,.2); transform: scale(1.05); } .${APP_PREFIX}body{ flex:1; display:flex; min-height:0; } .${APP_PREFIX}sidebar{ width: 300px; border-right:1px solid var(--a-border); padding:10px; overflow:auto; background: linear-gradient(180deg, rgba(127,127,127,.07), transparent); flex-shrink:0; transition: width .2s ease; } .${APP_PREFIX}sidebar.collapsed{ width: 0; padding: 0; border-right:0; overflow:hidden; } .${APP_PREFIX}sideTop{ display:flex; gap:8px; margin-bottom:10px; align-items:center; } .${APP_PREFIX}btn{ border:none; cursor:pointer; border-radius:12px; padding:10px 18px; font-weight:900; background: var(--a-primary); color:#fff; transition: all .3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 4px 12px rgba(31,109,255,.2); position: relative; overflow: hidden; } .${APP_PREFIX}btn::before{ content: ''; position: absolute; top: 50%; left: 50%; width: 0; height: 0; border-radius: 50%; background: rgba(255,255,255,.2); transform: translate(-50%, -50%); transition: width .5s, height .5s; } .${APP_PREFIX}btn:hover{ transform: translateY(-2px); box-shadow: 0 12px 24px rgba(31,109,255,.35); background: var(--a-primary-hover); } .${APP_PREFIX}btn:hover::before{ width: 300px; height: 300px; } .${APP_PREFIX}btn:active{ transform: translateY(0); box-shadow: 0 4px 12px rgba(31,109,255,.2); } .${APP_PREFIX}btnGhost{ background: transparent; color: var(--a-text); border:1px solid var(--a-border); font-weight:900; } .${APP_PREFIX}btnDanger{ background: transparent; color: var(--a-danger); border:1px solid rgba(226,59,59,.55); font-weight:900; } .${APP_PREFIX}filter{ width:100%; box-sizing:border-box; border-radius:12px; border:1px solid var(--a-border); padding:9px 10px; background: rgba(127,127,127,.08); color: var(--a-text); outline:none; font-weight:800; margin-bottom:10px; } .${APP_PREFIX}sessions{ display:flex; flex-direction:column; gap:10px; } .${APP_PREFIX}session{ border:1.5px solid var(--a-border); border-radius:16px; padding:12px 14px; background: var(--a-card); display:flex; justify-content:space-between; align-items:center; gap:8px; cursor:pointer; transition: all .3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } .${APP_PREFIX}session::before{ content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--a-primary); opacity: 0; transition: opacity .3s ease; } .${APP_PREFIX}session:hover{ border-color: var(--a-primary); transform: translateX(4px); box-shadow: 0 4px 12px rgba(0,0,0,.08); } .${APP_PREFIX}session.active{ border-color: transparent; background: var(--a-primary); background: linear-gradient(135deg, rgba(31,109,255,.12), rgba(74,143,255,.08)); box-shadow: 0 4px 16px var(--a-glow), inset 0 0 0 2px var(--a-border); } .${APP_PREFIX}session.active::before{ opacity: 1; } .${APP_PREFIX}session .t{ max-width: 170px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:900; color: var(--a-text); } .${APP_PREFIX}session .s{ font-size:12px; color: var(--a-sub); font-weight:800; } .${APP_PREFIX}ops{ display:flex; gap:6px; } .${APP_PREFIX}op{ padding:6px 8px; border-radius:10px; border:1px solid var(--a-border); background: rgba(127,127,127,.06); cursor:pointer; user-select:none; font-weight:900; } .${APP_PREFIX}op:hover{ border-color: var(--a-primary); } .${APP_PREFIX}main{ flex:1; display:flex; flex-direction:column; min-width:0; } .${APP_PREFIX}panel{ flex:1; overflow:auto; padding: 18px 22px; line-height:1.75; font-size:15px; display:none; width: 100%; box-sizing: border-box; } .${APP_PREFIX}panel.active{ display:flex; flex-direction:column; align-items:center; } .${APP_PREFIX}msg{ width: 100%; max-width: 1200px; margin: 12px auto; padding: 16px 20px; border:1.5px solid var(--a-border); border-radius:16px; background: rgba(127,127,127,.06); color: var(--a-text); position: relative; transition: all .3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing: border-box; } .${APP_PREFIX}msg:hover{ transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0,0,0,.08); } .${APP_PREFIX}msg.user{ background: var(--a-user); border-color: rgba(31,109,255,.35); box-shadow: 0 2px 8px rgba(31,109,255,.1); } .${APP_PREFIX}msg.assistant{ background: var(--a-ass); box-shadow: 0 2px 8px rgba(0,0,0,.06); } .${APP_PREFIX}msg.tool{ background: var(--a-tool); border-style:dashed; border-width: 2px; } .${APP_PREFIX}meta{ font-size:12px; color: var(--a-sub); display:flex; align-items:center; gap:10px; margin-bottom:6px; font-weight:800; justify-content: space-between; } .${APP_PREFIX}mleft{ display:flex; align-items:center; gap:10px; min-width:0; } .${APP_PREFIX}mright{ display:flex; align-items:center; gap:6px; flex-shrink:0; } .${APP_PREFIX}mini{ padding:6px 10px; border-radius:10px; border:1px solid var(--a-border); background: rgba(127,127,127,.06); cursor:pointer; user-select:none; font-weight:900; font-size:12px; color: var(--a-text); transition: all .25s cubic-bezier(0.4, 0, 0.2, 1); } .${APP_PREFIX}mini:hover{ border-color: var(--a-primary); background: var(--a-primary); color: #fff; transform: scale(1.08); box-shadow: 0 4px 8px var(--a-glow); } .${APP_PREFIX}md a{ color: var(--a-primary); text-decoration: underline; text-underline-offset:2px; } .${APP_PREFIX}md code{ background: rgba(127,127,127,.16); padding: 2px 6px; border-radius: 6px; } .${APP_PREFIX}md pre{ background: var(--a-code); color: var(--a-codeText); padding: 12px; border-radius:12px; overflow:auto; border:1px solid rgba(255,255,255,.10); } .${APP_PREFIX}collapsed .${APP_PREFIX}md{ max-height: 210px; overflow: hidden; mask-image: linear-gradient(180deg, rgba(0,0,0,1) 60%, rgba(0,0,0,0)); } .${APP_PREFIX}moreHint{ font-size:12px; color: var(--a-sub); margin-top:8px; font-weight:900; } .${APP_PREFIX}composer{ border-top:1px solid var(--a-border); padding:10px; display:flex; gap:10px; align-items:flex-end; background: rgba(127,127,127,.06); flex-shrink:0; } .${APP_PREFIX}ta{ flex:1; min-height:80px; max-height:200px; resize:none; border-radius:16px; border:2px solid var(--a-border); padding:14px 18px; background: rgba(255,255,255,.92); color: var(--a-text); outline:none; font-weight:800; font-size:15px; line-height:1.6; transition: all .3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: inset 0 2px 6px rgba(0,0,0,.04); } .${APP_PREFIX}ta:focus{ border-color: var(--a-primary); box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.04); transform: translateY(-1px); } /* 强制浅色主题的输入框样式 */ :root[data-theme="light"] .${APP_PREFIX}ta{ background: rgba(255,255,255,.92); box-shadow: inset 0 2px 6px rgba(0,0,0,.04); } :root[data-theme="light"] .${APP_PREFIX}ta:focus{ box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.04); } /* 深色主题的输入框样式 */ @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]) .${APP_PREFIX}ta{ background: rgba(18,20,27,.92); box-shadow: inset 0 2px 6px rgba(0,0,0,.2); } :root:not([data-theme="light"]) .${APP_PREFIX}ta:focus{ box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.2); } } /* 强制深色主题的输入框样式 */ :root[data-theme="dark"] .${APP_PREFIX}ta{ background: rgba(18,20,27,.92); box-shadow: inset 0 2px 6px rgba(0,0,0,.2); } :root[data-theme="dark"] .${APP_PREFIX}ta:focus{ box-shadow: 0 0 0 4px var(--a-glow), inset 0 2px 6px rgba(0,0,0,.2); } .${APP_PREFIX}overlay{ position:fixed; inset:0; background: rgba(0,0,0,.6); backdrop-filter: blur(4px); z-index:100004; display:none; align-items:center; justify-content:center; } .${APP_PREFIX}overlay.open{ display:flex; } .${APP_PREFIX}modal{ width: 620px; max-width: 92vw; border-radius: 16px; border:1px solid var(--a-border); background: var(--a-card); box-shadow: var(--a-shadow); padding: 18px; color: var(--a-text); } .${APP_PREFIX}formRow{ margin: 10px 0; } .${APP_PREFIX}formRow label{ display:block; font-size:13px; font-weight:900; color:var(--a-sub); margin-bottom:6px; } .${APP_PREFIX}formRow input, .${APP_PREFIX}formRow textarea, .${APP_PREFIX}formRow select{ width:100%; box-sizing:border-box; border-radius: 12px; border:1px solid var(--a-border); padding: 10px 12px; background: rgba(127,127,127,.08); color: var(--a-text); outline:none; font-weight:800; } .${APP_PREFIX}formActions{ display:flex; justify-content:flex-end; gap:10px; margin-top: 12px; flex-wrap:wrap; } #${APP_PREFIX}toast{ position:fixed; right: 90px; top: 72px; z-index:100005; background: linear-gradient(135deg, rgba(0,0,0,.88), rgba(20,20,20,.85)); color:#fff; padding: 10px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,.1); opacity:0; pointer-events:none; transition: all .3s cubic-bezier(0.4, 0, 0.2, 1); font-weight:900; font-size: 13px; max-width: 60vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; backdrop-filter: blur(10px); box-shadow: 0 8px 24px rgba(0,0,0,.3); } #${APP_PREFIX}toast.show{ opacity:1; transform: translateY(-4px); } /* Scroll-to-bottom button */ #${APP_PREFIX}toBottom{ position: absolute; right: 24px; bottom: 120px; z-index: 10; display:none; } #${APP_PREFIX}toBottom.show{ display:block; } /* Tools/Debug panels */ .${APP_PREFIX}toolGrid{ max-width: 980px; display:flex; flex-direction:column; gap:12px; } .${APP_PREFIX}toolCard{ border:1px solid var(--a-border); border-radius:14px; background: var(--a-card); padding:12px; } .${APP_PREFIX}toolRow{ display:flex; gap:10px; flex-wrap:wrap; } .${APP_PREFIX}toolRow > *{ flex: 1; min-width: 180px; } .${APP_PREFIX}toolOut{ margin-top:10px; border:1px dashed var(--a-border); border-radius: 12px; padding:10px; background: rgba(127,127,127,.06); white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; line-height:1.6; max-height: 380px; overflow:auto; } .${APP_PREFIX}logList{ max-width:980px; } .${APP_PREFIX}logItem{ border:1px solid var(--a-border); border-radius:14px; background: var(--a-card); margin:10px 0; overflow:hidden; } .${APP_PREFIX}logHead{ padding:10px 12px; display:flex; gap:10px; align-items:center; justify-content:space-between; cursor:pointer; user-select:none; font-weight:900; color: var(--a-text); background: rgba(127,127,127,.06); } .${APP_PREFIX}logBody{ padding:10px 12px; display:none; } .${APP_PREFIX}logItem.open .${APP_PREFIX}logBody{ display:block; } .${APP_PREFIX}logBody pre{ margin:0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; line-height:1.6; background: rgba(127,127,127,.06); border:1px dashed var(--a-border); padding:10px; border-radius:12px; overflow:auto; max-height: 420px; } /* Small screens: auto collapse sidebar */ @media (max-width: 860px){ .${APP_PREFIX}sidebar{ width: 0; padding: 0; border-right:0; overflow:hidden; } } `; const DEFAULT_UI = { tab: "chat", sidebarCollapsed: false, debugFilter: { tool: true, agent: true, errors: true }, }; class UI { constructor(store, confStore) { this.store = store; this.confStore = confStore; this.isSending = false; this.debugVisible = false; // legacy toggle (kept), but debug is now in tab this.toolsState = { lastName: "discourse.search", lastArgs: { q: "linux", page: 1, limit: 8 }, lastResult: "", }; this._uiState = { ...DEFAULT_UI, ...(GM_getValue(STORE_KEYS.UI, null) || {}), }; // 初始化主题 this.theme = GM_getValue(STORE_KEYS.THEME, "auto"); this._applyTheme(); this._injectStyle(); this._renderShell(); this._applyFabPosFromStore(); this._bind(); this._bindFabDrag(); this.renderAll(); GM_registerMenuCommand("打开 Linux.do Agent", () => this.toggleDrawer(true) ); GM_registerMenuCommand("清空当前会话", () => { const s = this.store.active(); if (confirm(`确定清空会话「${s.title}」吗?`)) { this.store.clearSession(s.id); this.renderAll(); this.toast("已清空"); } }); } _saveUIState(patch) { this._uiState = { ...this._uiState, ...(patch || {}) }; GM_setValue(STORE_KEYS.UI, this._uiState); } _injectStyle() { const el = document.createElement("style"); el.textContent = STYLES; document.head.appendChild(el); } _renderShell() { const fab = document.createElement("div"); fab.id = `${APP_PREFIX}fab`; fab.innerHTML = `AG
`; fab.title = "Linux.do Agent(可拖动)"; document.body.appendChild(fab); const drawer = document.createElement("div"); drawer.id = `${APP_PREFIX}drawer`; drawer.innerHTML = `
Linux.do Agent Workbench UI IDLE
Chat
Tools
Debug
`; document.body.appendChild(drawer); const overlay = document.createElement("div"); overlay.className = `${APP_PREFIX}overlay`; overlay.innerHTML = `

⚙️ 设置(OpenAI Chat 格式)

`; document.body.appendChild(overlay); const toast = document.createElement("div"); toast.id = `${APP_PREFIX}toast`; document.body.appendChild(toast); this.dom = { fab, drawer, overlay, toast, btnClose: drawer.querySelector(`#${APP_PREFIX}btnClose`), btnSetting: drawer.querySelector(`#${APP_PREFIX}btnSetting`), btnToggleSide: drawer.querySelector(`#${APP_PREFIX}btnToggleSide`), btnStop: drawer.querySelector(`#${APP_PREFIX}btnStop`), btnTheme: drawer.querySelector(`#${APP_PREFIX}btnTheme`), tabs: drawer.querySelector(`#${APP_PREFIX}tabs`), statusPill: drawer.querySelector(`#${APP_PREFIX}statusPill`), statusStep: drawer.querySelector(`#${APP_PREFIX}statusStep`), statusErr: drawer.querySelector(`#${APP_PREFIX}statusErr`), sidebar: drawer.querySelector(`#${APP_PREFIX}sidebar`), sessionFilter: drawer.querySelector(`#${APP_PREFIX}sessionFilter`), sessions: drawer.querySelector(`#${APP_PREFIX}sessions`), panelChat: drawer.querySelector(`#${APP_PREFIX}panelChat`), panelTools: drawer.querySelector(`#${APP_PREFIX}panelTools`), panelDebug: drawer.querySelector(`#${APP_PREFIX}panelDebug`), chat: drawer.querySelector(`#${APP_PREFIX}chat`), toBottom: drawer.querySelector(`#${APP_PREFIX}toBottom`), toolsWrap: drawer.querySelector(`#${APP_PREFIX}toolsWrap`), dbgTool: drawer.querySelector(`#${APP_PREFIX}dbgTool`), dbgAgent: drawer.querySelector(`#${APP_PREFIX}dbgAgent`), dbgErr: drawer.querySelector(`#${APP_PREFIX}dbgErr`), dbgExpandAll: drawer.querySelector(`#${APP_PREFIX}dbgExpandAll`), dbgCollapseAll: drawer.querySelector(`#${APP_PREFIX}dbgCollapseAll`), debugWrap: drawer.querySelector(`#${APP_PREFIX}debugWrap`), composer: drawer.querySelector(`#${APP_PREFIX}composer`), ta: drawer.querySelector(`#${APP_PREFIX}ta`), btnSend: drawer.querySelector(`#${APP_PREFIX}btnSend`), btnResume: drawer.querySelector(`#${APP_PREFIX}btnResume`), btnNew: drawer.querySelector(`#${APP_PREFIX}btnNew`), btnExport: drawer.querySelector(`#${APP_PREFIX}btnExport`), cfgBaseUrl: overlay.querySelector(`#${APP_PREFIX}cfgBaseUrl`), cfgModel: overlay.querySelector(`#${APP_PREFIX}cfgModel`), cfgKey: overlay.querySelector(`#${APP_PREFIX}cfgKey`), cfgTemp: overlay.querySelector(`#${APP_PREFIX}cfgTemp`), cfgMaxTurns: overlay.querySelector(`#${APP_PREFIX}cfgMaxTurns`), cfgMaxCtx: overlay.querySelector(`#${APP_PREFIX}cfgMaxCtx`), cfgSys: overlay.querySelector(`#${APP_PREFIX}cfgSys`), cfgToolCtx: overlay.querySelector(`#${APP_PREFIX}cfgToolCtx`), cfgCancel: overlay.querySelector(`#${APP_PREFIX}cfgCancel`), cfgSave: overlay.querySelector(`#${APP_PREFIX}cfgSave`), }; } _applyFabPosFromStore() { const p = GM_getValue(STORE_KEYS.FABPOS, null); const pos = typeof p === "string" ? safeJsonParse(p, null) : p; if (pos && typeof pos.x === "number" && typeof pos.y === "number") { const { x, y } = this._clampFabPos(pos.x, pos.y); this.dom.fab.style.left = `${x}px`; this.dom.fab.style.top = `${y}px`; } else { // 使用实际元素尺寸而不是硬编码 const w = this.dom.fab.offsetWidth || 58; const margin = 18; const x = Math.max(margin, window.innerWidth - w - margin); const y = 16; this.dom.fab.style.left = `${x}px`; this.dom.fab.style.top = `${y}px`; // 保存初始位置 this._saveFabPos(x, y); } } _saveFabPos(x, y) { GM_setValue(STORE_KEYS.FABPOS, { x, y }); } _clampFabPos(x, y) { const w = this.dom.fab.offsetWidth || 58; const h = this.dom.fab.offsetHeight || 58; const margin = 8; const maxX = Math.max(margin, window.innerWidth - w - margin); const maxY = Math.max(margin, window.innerHeight - h - margin); return { x: Math.max(margin, Math.min(maxX, x)), y: Math.max(margin, Math.min(maxY, y)), }; } _bindFabDrag() { const fab = this.dom.fab; let dragging = false; let moved = false; let startX = 0, startY = 0; let origLeft = 0, origTop = 0; let pointerId = null; const getLeftTop = () => { const r = fab.getBoundingClientRect(); return { left: r.left, top: r.top }; }; const onPointerDown = (e) => { if (e.button !== undefined && e.button !== 0) return; if (dragging) return; // 防止重复触发 dragging = true; moved = false; pointerId = e.pointerId; fab.classList.add("dragging"); const lt = getLeftTop(); origLeft = lt.left; origTop = lt.top; startX = e.clientX; startY = e.clientY; try { fab.setPointerCapture(e.pointerId); } catch {} e.preventDefault(); e.stopPropagation(); }; const onPointerMove = (e) => { if (!dragging || e.pointerId !== pointerId) return; const dx = e.clientX - startX; const dy = e.clientY - startY; // 检测是否真的移动了(增加阈值) if (!moved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) { moved = true; } if (moved) { const nx = origLeft + dx; const ny = origTop + dy; const clamped = this._clampFabPos(nx, ny); fab.style.left = `${clamped.x}px`; fab.style.top = `${clamped.y}px`; } e.preventDefault(); e.stopPropagation(); }; const onPointerUp = (e) => { if (!dragging || e.pointerId !== pointerId) return; dragging = false; fab.classList.remove("dragging"); if (moved) { // 只有在拖动后才保存位置 const lt = getLeftTop(); const clamped = this._clampFabPos(lt.left, lt.top); fab.style.left = `${clamped.x}px`; fab.style.top = `${clamped.y}px`; this._saveFabPos(clamped.x, clamped.y); } else { // 只有在没有移动时才触发点击 this.toggleDrawer(); } try { fab.releasePointerCapture(e.pointerId); } catch {} pointerId = null; e.preventDefault(); e.stopPropagation(); }; const onResize = () => { if (dragging) return; // 拖动时不触发resize调整 const lt = getLeftTop(); const clamped = this._clampFabPos(lt.left, lt.top); fab.style.left = `${clamped.x}px`; fab.style.top = `${clamped.y}px`; this._saveFabPos(clamped.x, clamped.y); }; fab.addEventListener("pointerdown", onPointerDown, { passive: false }); window.addEventListener("pointermove", onPointerMove, { passive: false }); window.addEventListener("pointerup", onPointerUp, { passive: false }); window.addEventListener("resize", onResize); } _bind() { const d = this.dom; d.btnClose.addEventListener("click", () => this.toggleDrawer(false)); // 主题切换 d.btnTheme.addEventListener("click", () => this._toggleTheme()); d.btnSetting.addEventListener("click", () => { this.loadConfToUI(); this.dom.overlay.classList.add("open"); }); d.cfgCancel.addEventListener("click", () => this.dom.overlay.classList.remove("open") ); d.cfgSave.addEventListener("click", () => this.saveConfFromUI()); d.btnToggleSide.addEventListener("click", () => { const next = !this._uiState.sidebarCollapsed; this._saveUIState({ sidebarCollapsed: next }); this.renderAll(); }); d.tabs.addEventListener("click", (e) => { const t = e.target.closest(`.${APP_PREFIX}tab`); if (!t) return; const tab = t.dataset.tab; this._saveUIState({ tab }); this.renderAll(); }); d.btnStop.addEventListener("click", () => { const s = this.store.active(); if (!s?.id) return; cancelSession(s.id); this.toast("已停止"); this.renderAll(); }); d.btnNew.addEventListener("click", () => { this.store.create("新会话"); this.renderAll(); }); d.btnExport.addEventListener("click", () => { const s = this.store.active(); const payload = { title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, chat: s.chat, agent: s.agent, fsm: s.fsm, draft: s.draft, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json", }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `linuxdo-agent-${(s.title || "session").slice( 0, 24 )}.json`; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 120); }); d.sessionFilter.addEventListener("input", () => this.renderSessions()); d.sessions.addEventListener("click", (e) => { const card = e.target.closest(`.${APP_PREFIX}session`); if (!card) return; const id = card.dataset.id; const op = e.target.closest("[data-op]"); if (op) { const act = op.dataset.op; if (act === "del") { if (confirm("确定删除该会话吗?")) { this.store.remove(id); this.renderAll(); } return; } if (act === "ren") { const s = this.store.all().find((x) => x.id === id); const t = prompt("重命名会话:", s?.title || "新会话"); if (t != null) { this.store.rename(id, t); this.renderAll(); } return; } if (act === "clr") { if (confirm("确定清空该会话吗?")) { this.store.clearSession(id); this.renderAll(); } return; } } this.store.setActive(id); this.renderAll(); }); // composer d.ta.addEventListener("input", () => { this.autoGrow(d.ta); const s = this.store.active(); this.store.setDraft(s.id, d.ta.value); }); d.ta.addEventListener("keydown", (e) => { if ( (e.key === "Enter" && !e.shiftKey) || (e.key === "Enter" && (e.ctrlKey || e.metaKey)) ) { e.preventDefault(); this.send(); } }); d.btnSend.addEventListener("click", () => this.send()); d.btnResume.addEventListener("click", () => this.resume()); // close overlay on ESC window.addEventListener("keydown", (e) => { if (e.key === "Escape") this.dom.overlay.classList.remove("open"); }); // message actions (copy/quote/toggle) d.chat.addEventListener("click", async (e) => { const btn = e.target.closest("[data-action]"); if (!btn) return; const action = btn.dataset.action; const msgEl = e.target.closest(`.${APP_PREFIX}msg`); if (!msgEl) return; const content = msgEl.dataset.raw || ""; if (action === "copy") { try { await navigator.clipboard.writeText(content); this.toast("已复制"); } catch { this.toast("复制失败"); } return; } if (action === "quote") { const ta = this.dom.ta; const quote = content .split("\n") .map((l) => `> ${l}`) .join("\n"); ta.value = (ta.value ? ta.value + "\n\n" : "") + quote + "\n"; this.autoGrow(ta); ta.focus(); const s = this.store.active(); this.store.setDraft(s.id, ta.value); this.toast("已引用到输入框"); return; } if (action === "toggle") { msgEl.classList.toggle("collapsed"); btn.textContent = msgEl.classList.contains("collapsed") ? "展开" : "收起"; return; } }); // scroll-to-bottom const wrap = this.dom.panelChat; wrap.addEventListener("scroll", () => this._updateToBottom()); this.dom.toBottom.addEventListener("click", () => { wrap.scrollTop = wrap.scrollHeight; this._updateToBottom(); }); // debug controls d.dbgTool.addEventListener("change", () => { this._saveUIState({ debugFilter: { ...this._uiState.debugFilter, tool: !!d.dbgTool.checked, }, }); this.renderDebug(); }); d.dbgAgent.addEventListener("change", () => { this._saveUIState({ debugFilter: { ...this._uiState.debugFilter, agent: !!d.dbgAgent.checked, }, }); this.renderDebug(); }); d.dbgErr.addEventListener("change", () => { this._saveUIState({ debugFilter: { ...this._uiState.debugFilter, errors: !!d.dbgErr.checked, }, }); this.renderDebug(); }); d.dbgExpandAll.addEventListener("click", () => { d.debugWrap .querySelectorAll(`.${APP_PREFIX}logItem`) .forEach((x) => x.classList.add("open")); }); d.dbgCollapseAll.addEventListener("click", () => { d.debugWrap .querySelectorAll(`.${APP_PREFIX}logItem`) .forEach((x) => x.classList.remove("open")); }); // debug item toggle d.debugWrap.addEventListener("click", (e) => { const head = e.target.closest(`.${APP_PREFIX}logHead`); if (!head) return; const item = head.closest(`.${APP_PREFIX}logItem`); if (!item) return; item.classList.toggle("open"); }); d.debugWrap.addEventListener("click", async (e) => { const c = e.target.closest("[data-copylog]"); if (!c) return; e.stopPropagation(); const text = c.dataset.copylog || ""; try { await navigator.clipboard.writeText(text); this.toast("已复制"); } catch { this.toast("复制失败"); } }); } _applyTheme() { const root = document.documentElement; if (this.theme === "light") { root.setAttribute("data-theme", "light"); } else if (this.theme === "dark") { root.setAttribute("data-theme", "dark"); } else { // auto: 移除 data-theme,让 CSS media query 生效 root.removeAttribute("data-theme"); } } _toggleTheme() { // 循环切换: auto -> light -> dark -> auto if (this.theme === "auto") { this.theme = "light"; } else if (this.theme === "light") { this.theme = "dark"; } else { this.theme = "auto"; } GM_setValue(STORE_KEYS.THEME, this.theme); this._applyTheme(); const themeNames = { auto: "自动", light: "浅色", dark: "深色" }; this.toast(`主题: ${themeNames[this.theme]}`); } toggleDrawer(force) { if (typeof force === "boolean") this.dom.drawer.classList.toggle("open", force); else this.dom.drawer.classList.toggle("open"); } autoGrow(ta) { ta.style.height = "auto"; ta.style.height = Math.min(180, Math.max(46, ta.scrollHeight)) + "px"; } toast(msg) { const t = this.dom.toast; t.textContent = msg; t.classList.add("show"); clearTimeout(t._timer); t._timer = setTimeout(() => t.classList.remove("show"), 2200); } loadConfToUI() { const c = this.confStore.get(); this.dom.cfgBaseUrl.value = c.baseUrl || DEFAULT_CONF.baseUrl; this.dom.cfgModel.value = c.model || DEFAULT_CONF.model; this.dom.cfgKey.value = c.apiKey || ""; this.dom.cfgTemp.value = String(c.temperature ?? 0.2); this.dom.cfgMaxTurns.value = String(c.maxTurns ?? 8); this.dom.cfgMaxCtx.value = String(c.maxContextChars ?? 24000); this.dom.cfgSys.value = c.systemPrompt || DEFAULT_CONF.systemPrompt; this.dom.cfgToolCtx.checked = !!c.includeToolContext; } saveConfFromUI() { const baseUrl = this.dom.cfgBaseUrl.value.trim() || DEFAULT_CONF.baseUrl; const model = this.dom.cfgModel.value.trim() || DEFAULT_CONF.model; const apiKey = this.dom.cfgKey.value.trim(); const temperature = Math.max( 0, Math.min( 1, parseFloat(this.dom.cfgTemp.value) || DEFAULT_CONF.temperature ) ); const maxTurns = Math.max( 1, Math.min( 100, parseInt(this.dom.cfgMaxTurns.value, 10) || DEFAULT_CONF.maxTurns ) ); const maxContextChars = Math.max( 4000, Math.min( 8000000, parseInt(this.dom.cfgMaxCtx.value, 10) || DEFAULT_CONF.maxContextChars ) ); const systemPrompt = this.dom.cfgSys.value.trim() || DEFAULT_CONF.systemPrompt; const includeToolContext = !!this.dom.cfgToolCtx.checked; this.confStore.save({ baseUrl, model, apiKey, temperature, maxTurns, maxContextChars, systemPrompt, includeToolContext, }); this.dom.overlay.classList.remove("open"); this.toast("设置已保存"); } _formatTime(ts) { try { return new Date(ts || now()).toLocaleString("zh-CN", { hour12: false }); } catch { return ""; } } _renderMd(content) { try { return ( window.marked ? marked.parse(content || "") : String(content || "") ).replace(/${String(content || "").replace( /[<>&]/g, (s) => ({ "<": "<", ">": ">", "&": "&" }[s]) )}`; } } _updateToBottom() { const wrap = this.dom.panelChat; const nearBottom = wrap.scrollHeight - (wrap.scrollTop + wrap.clientHeight) < 180; this.dom.toBottom.classList.toggle("show", !nearBottom); } setActiveTab(tab) { const tabs = this.dom.tabs.querySelectorAll(`.${APP_PREFIX}tab`); tabs.forEach((x) => x.classList.toggle("active", x.dataset.tab === tab)); this.dom.panelChat.classList.toggle("active", tab === "chat"); this.dom.panelTools.classList.toggle("active", tab === "tools"); this.dom.panelDebug.classList.toggle("active", tab === "debug"); this.dom.composer.style.display = tab === "chat" ? "flex" : "none"; } renderStatus() { const s = this.store.active(); const f = s.fsm || {}; const st = f.state || FSM.IDLE; const step = f.step ? `step=${f.step}` : ""; const err = st === FSM.ERROR && f.lastError ? String(f.lastError).slice(0, 180) : ""; this.dom.statusPill.title = err ? String(f.lastError) : st; this.dom.statusPill.querySelector(".st").textContent = st; this.dom.statusStep.textContent = step ? `· ${step}` : ""; this.dom.statusErr.textContent = err ? `· ${err}` : ""; // FAB state dot this.dom.fab.classList.toggle("running", !!f.isRunning); this.dom.fab.classList.toggle("error", st === FSM.ERROR); } renderSessions() { const wrap = this.dom.sessions; const all = this.store.all(); const activeId = this.store.active().id; const q = String(this.dom.sessionFilter.value || "") .trim() .toLowerCase(); const filtered = q ? all.filter((s) => String(s.title || "") .toLowerCase() .includes(q) ) : all; wrap.innerHTML = filtered .map((s) => { const state = s.fsm?.state || FSM.IDLE; const running = s.fsm?.isRunning ? " · 运行中" : ""; const err = s.fsm?.state === FSM.ERROR && s.fsm?.lastError ? " · 错误" : ""; const sub = `${state}${running}${err}`; return `
${s.title || "新会话"}
${sub}
`; }) .join(""); } renderChat() { const s = this.store.active(); const wrap = this.dom.chat; const blocks = []; if (!s.chat.length) { blocks.push( `
Chat:只显示 user/final。工具与调试请切换到 Tools / Debug。
` ); } else { for (const m of s.chat) blocks.push(this.renderMessage(m.role, m.content, m.ts)); } wrap.innerHTML = blocks.join("\n"); // 如果用户在底部附近才自动跟随 const panel = this.dom.panelChat; const nearBottom = panel.scrollHeight - (panel.scrollTop + panel.clientHeight) < 180; if (nearBottom) panel.scrollTop = panel.scrollHeight; this._updateToBottom(); } renderMessage(role, content, ts) { const r = role === "user" ? "user" : role === "tool" ? "tool" : "assistant"; const time = this._formatTime(ts); const raw = String(content || ""); const html = this._renderMd(raw); const lineCount = raw.split("\n").length; const tooLong = raw.length > 2200 || lineCount > 26; const collapsedClass = tooLong ? "collapsed" : ""; return `
${r.toUpperCase()} · ${time}
复制 引用 ${ tooLong ? `展开` : "" }
${html}
${ tooLong ? `
(内容较长,已折叠)
` : "" }
`; } renderTools() { const wrap = this.dom.toolsWrap; const s = this.store.active(); const toolOptions = [ "discourse.search", "discourse.getTopicAllPosts", "discourse.getUserRecent", "discourse.getCategories", "discourse.listLatestTopics", "discourse.listTopTopics", "discourse.getTagTopics", "discourse.getUserSummary", "discourse.getPost", "discourse.getTopicPostFull", "discourse.listLatestPosts", ]; const defaultArgs = (name) => { if (name === "discourse.search") return { q: "linux", page: 1, limit: 8 }; if (name === "discourse.getTopicAllPosts") return { topicId: 1, batchSize: 18, maxPosts: 120 }; if (name === "discourse.getUserRecent") return { username: "someone", limit: 10 }; if (name === "discourse.getCategories") return {}; if (name === "discourse.listLatestTopics") return { page: 0 }; if (name === "discourse.listTopTopics") return { period: "weekly", page: 0 }; if (name === "discourse.getTagTopics") return { tag: "linux", page: 0 }; if (name === "discourse.getUserSummary") return { username: "someone" }; if (name === "discourse.getPost") return { postId: 1 }; if (name === "discourse.getTopicPostFull") return { topicId: 1, postNumber: 1, maxChars: 10000 }; if (name === "discourse.listLatestPosts") return { before: null, limit: 20 }; return {}; }; const name = this.toolsState.lastName; const argsText = JSON.stringify( this.toolsState.lastArgs ?? defaultArgs(name), null, 2 ); wrap.innerHTML = `
Tools(手动运行 Discourse 工具,不走模型)
结果可“一键加入上下文/发到聊天”
${( this.toolsState.lastResult || "(暂无结果)" ).replace(/
Tip:加入上下文后,你可以回到 Chat 再问“请基于工具结果总结/对比/提炼结论…”
`; const toolNameEl = wrap.querySelector(`#${APP_PREFIX}toolName`); const toolArgsEl = wrap.querySelector(`#${APP_PREFIX}toolArgs`); const toolOutEl = wrap.querySelector(`#${APP_PREFIX}toolOut`); toolNameEl.addEventListener("change", () => { const n = toolNameEl.value; this.toolsState.lastName = n; this.toolsState.lastArgs = defaultArgs(n); this.toolsState.lastResult = ""; this.renderTools(); }); wrap .querySelector(`#${APP_PREFIX}toolRun`) .addEventListener("click", async () => { const n = toolNameEl.value; let args; try { args = JSON.parse(toolArgsEl.value); } catch { this.toast("参数 JSON 解析失败"); return; } this.toolsState.lastName = n; this.toolsState.lastArgs = args; const cancelToken = ensureCancelToken(s.id); cancelToken.cancelled = false; cancelToken.aborts = cancelToken.aborts || []; this.toast("运行工具中…"); try { const res = await runTool(n, args, cancelToken); const ctx = toolResultToContext(n, res); this.toolsState.lastResult = ctx; toolOutEl.textContent = ctx; this.toast("工具完成"); } catch (e) { const msg = String(e?.message || e); this.toolsState.lastResult = `工具失败:${msg}`; toolOutEl.textContent = this.toolsState.lastResult; this.toast("工具失败"); } finally { CANCEL.delete(s.id); } }); wrap .querySelector(`#${APP_PREFIX}toolToCtx`) .addEventListener("click", () => { const txt = String(this.toolsState.lastResult || "").trim(); if (!txt) return this.toast("无结果可加入"); this.store.pushAgent(s.id, { role: "tool", kind: "tool_context", content: txt, ts: now(), toolName: this.toolsState.lastName, }); this.toast("已加入上下文"); }); wrap .querySelector(`#${APP_PREFIX}toolToChat`) .addEventListener("click", () => { const txt = String(this.toolsState.lastResult || "").trim(); if (!txt) return this.toast("无结果可发送"); this.store.pushChat(s.id, { role: "assistant", content: `**[Tools] ${this.toolsState.lastName} 结果**\n\n\`\`\`\n${txt}\n\`\`\``, ts: now(), }); this.toast("已发送到 Chat"); this.renderChat(); }); wrap .querySelector(`#${APP_PREFIX}toolCopy`) .addEventListener("click", async () => { const txt = String(this.toolsState.lastResult || "").trim(); if (!txt) return this.toast("无结果可复制"); try { await navigator.clipboard.writeText(txt); this.toast("已复制"); } catch { this.toast("复制失败"); } }); } renderDebug() { const s = this.store.active(); const wrap = this.dom.debugWrap; const filt = this._uiState.debugFilter || { tool: true, agent: true, errors: true, }; this.dom.dbgTool.checked = !!filt.tool; this.dom.dbgAgent.checked = !!filt.agent; this.dom.dbgErr.checked = !!filt.errors; const items = (s.agent || []) .map((a, idx) => { const isTool = a.role === "tool"; const isAgent = a.role === "agent"; const isErr = String(a.kind || "").includes("error") || String(a.kind || "").includes("ERROR") || a.kind === "model_parse_error"; if (isTool && !filt.tool) return null; if (isAgent && !filt.agent) return null; if (isErr && !filt.errors) return null; const title = `${idx + 1}. ${a.role}:${a.kind || ""}`; const time = this._formatTime(a.ts); const txt = String(a.content || ""); const short = txt.length > 160 ? txt.slice(0, 160) + "…" : txt; return `
${title} · ${time}
复制
预览:${short.replace( /
${txt.replace(/
            
`; }) .filter(Boolean); wrap.innerHTML = items.length ? items.join("") : `
(暂无调试日志)
`; } renderAll() { const s = this.store.active(); // apply sidebar collapsed this.dom.sidebar.classList.toggle( "collapsed", !!this._uiState.sidebarCollapsed ); // apply tab const tab = this._uiState.tab || "chat"; this.setActiveTab(tab); // status pill + fab dot this.renderStatus(); // sessions this.renderSessions(); // draft restore if (typeof s.draft === "string" && this.dom.ta.value !== s.draft) { this.dom.ta.value = s.draft; this.autoGrow(this.dom.ta); } // panels this.renderChat(); this.renderTools(); this.renderDebug(); // buttons state const running = !!s.fsm?.isRunning; this.dom.btnSend.disabled = running; this.dom.btnResume.disabled = running; this.dom.ta.disabled = running; this.dom.btnSend.textContent = running ? "运行中…" : "发送"; } async send() { if (this.isSending) return; const text = this.dom.ta.value.trim(); if (!text) return; const conf = this.confStore.get(); if (!conf.apiKey) { this.toast("请先设置 API Key"); this.dom.overlay.classList.add("open"); return; } const s = this.store.active(); if (s.fsm?.isRunning) return; this.isSending = true; this.dom.ta.value = ""; this.autoGrow(this.dom.ta); this.store.setDraft(s.id, ""); this.store.pushChat(s.id, { role: "user", content: text, ts: now() }); if ((s.title || "") === "新会话") { const t = text.replace(/\s+/g, " ").trim().slice(0, 14) || "新会话"; this.store.rename(s.id, t); } this._saveUIState({ tab: "chat" }); this.setActiveTab("chat"); this.renderAll(); try { await runAgent(s.id, this.store, conf, this); this.toast("完成"); } catch (e) { this.toast(`失败:${e.message || e}`); } finally { this.isSending = false; this.renderAll(); } } async resume() { const conf = this.confStore.get(); const s = this.store.active(); if (s.fsm?.isRunning) return; this.toast("尝试恢复…"); try { await runAgent(s.id, this.store, conf, this); this.toast("恢复完成"); } catch (e) { this.toast(`恢复失败:${e.message || e}`); } finally { this.renderAll(); } } } /****************************************************************** * 9) 启动 ******************************************************************/ function init() { if (window.top !== window) return; if (document.getElementById(`${APP_PREFIX}fab`)) return; const confStore = new ConfigStore(); const store = new SessionStore(); new UI(store, confStore); } if ( document.readyState === "complete" || document.readyState === "interactive" ) init(); else window.addEventListener("load", init); })();