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