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.2.3
// @description OpenAI Chat格式可配置baseUrl/model/key;多会话跨刷新;Discourse工具:搜索/抓话题全帖/查用户近期帖子/分类/最新话题/Top话题/Tag话题/用户Summary(含热门帖子)/单帖/按(topicId+postNumber)完整抓取指定楼(<=10000)/站点最新帖子列表;模型JSON输出自动find/rfind修复并回写history;final.refs 显示到UI;AG悬浮球支持拖动并记忆位置。
// @author Bytebender
// @match https://linux.do/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @connect *
// @require https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js
// @run-at document-idle
// @license GPL-3.0-or-later
// ==/UserScript==
(() => {
'use strict';
/******************************************************************
* 0) 常量 / 存储 Key
******************************************************************/
const APP_PREFIX = 'ldagent-';
const STORE_KEYS = {
CONF: 'ld_agent_conf_v2',
SESS: 'ld_agent_sessions_v2',
ACTIVE: 'ld_agent_active_session_v2',
FABPOS: 'ld_agent_fab_pos_v1', // ✅ AG 悬浮球位置
};
const FSM = {
IDLE: 'IDLE',
RUNNING: 'RUNNING',
WAITING_MODEL: 'WAITING_MODEL',
WAITING_TOOL: 'WAITING_TOOL',
DONE: 'DONE',
ERROR: 'ERROR',
};
const now = () => Date.now();
const uid = () => 'S' + now().toString(36) + Math.random().toString(36).slice(2, 8);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function clamp(str, max = 20000) {
str = String(str ?? '');
return str.length > max ? (str.slice(0, max) + '\n...(截断)') : str;
}
function stripHtml(html) {
const div = document.createElement('div');
div.innerHTML = html || '';
return (div.textContent || '').trim();
}
function safeTitle(t, fb) {
const s = String(t ?? '').trim();
return s ? s : (fb || '无题');
}
// 轻量 Markdown 文本转义(用于 refs title)
function mdEscapeText(s) {
s = String(s ?? '');
return s.replace(/[\[\]\(\)]/g, (m) => '\\' + m);
}
function safeJsonParse(s, fb = null) {
try { return JSON.parse(s); } catch { return fb; }
}
/******************************************************************
* 1) 配置:OpenAI Chat Completions 兼容
******************************************************************/
const DEFAULT_CONF = {
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiKey: '',
temperature: 0.2,
maxTurns: 8,
maxContextChars: 24000,
// 是否把“工具结果”作为对话上下文喂给模型
includeToolContext: true,
// 系统提示词(工具协议 + 行为约束)
systemPrompt: [
'你是 linux.do (Discourse) 的 AI Agent。',
'你可以通过“工具调用协议(JSON)”来检索论坛:搜索、获取话题全部帖子、查询用户近期帖子、分类列表、最新话题、Top话题、Tag话题、用户概览(含热门帖子)、单帖详情、按(话题ID+楼层号)完整获取指定楼(<=10000字符)、站点最新帖子列表。',
'你必须严格输出 JSON,不得输出任何多余文本(包括解释、markdown、代码块)。',
'',
'### OUTPUT FORMAT RULES (CRITICAL)',
'1. **RAW JSON ONLY**: 严禁输出任何 Markdown 标记(如 ```json ... ```)。严禁输出任何开头或结尾的解释性文本。',
'2. **NO CONVERSATION**: 不要说 "好的"、"这是结果"、"正在搜索"。直接输出 JSON 字符串。',
'3. **SCHEMA**: 你只能输出以下两种 JSON 结构之一:',
'',
' [FORMAT A: 需要调用工具时]',
' {',
' "type": "tool",',
' "name": "<tool_function_name>",',
' "args": { "<key>": "<value>" }',
' }',
'',
' [FORMAT B: 生成最终回答时]',
' {',
' "type": "final",',
' "answer": "<支持简单markdown格式的回答文本>",',
' "refs": [ {"title": "...", "url": "..."} ]',
' }',
'',
'### CONSTRAINTS',
'- refs 数组必须基于工具返回的真实数据,严禁编造 URL。',
'- 如果工具返回结果为空或信息不足,请在 "answer" 中说明情况,并告知用户你尝试了什么但未找到。',
'',
'引用规则:refs 只能使用工具结果里出现过的链接;不要编造 URL。',
'如果信息不足,首先尝试调用更多工具补全信息,如果还不足,请在 answer 明确说明不足。',
].join('\n'),
};
class ConfigStore {
constructor() {
const saved = GM_getValue(STORE_KEYS.CONF, null);
this.conf = { ...DEFAULT_CONF, ...(saved || {}) };
}
get() { return this.conf; }
save(c) { this.conf = { ...this.conf, ...(c || {}) }; GM_setValue(STORE_KEYS.CONF, this.conf); }
}
/******************************************************************
* 2) 多会话存储(跨刷新)
******************************************************************/
class SessionStore {
constructor() {
this.sessions = GM_getValue(STORE_KEYS.SESS, []);
this.activeId = GM_getValue(STORE_KEYS.ACTIVE, null);
if (!Array.isArray(this.sessions) || !this.sessions.length) {
const id = uid();
this.sessions = [this._newSessionObj(id, '新会话')];
this.activeId = id;
this._persist();
}
if (!this.sessions.some(s => s.id === this.activeId)) {
this.activeId = this.sessions[0].id;
this._persist();
}
}
_newSessionObj(id, title) {
return {
id,
title: title || '新会话',
createdAt: now(),
fsm: { state: FSM.IDLE, step: 0, lastError: null, isRunning: false },
chat: [], // {role:'user'|'assistant', content, ts}
agent: [], // {role:'agent'|'tool', kind, content, ts}
};
}
all() { return this.sessions; }
active() { return this.sessions.find(s => s.id === this.activeId) || this.sessions[0]; }
setActive(id) { this.activeId = id; GM_setValue(STORE_KEYS.ACTIVE, id); }
create(title='新会话') {
const s = this._newSessionObj(uid(), title);
this.sessions.unshift(s);
this.activeId = s.id;
this._persist();
return s;
}
rename(id, title) {
const s = this.sessions.find(x => x.id === id);
if (!s) return;
s.title = String(title || '').trim().slice(0, 24) || '新会话';
this._persist();
}
remove(id) {
const idx = this.sessions.findIndex(x => x.id === id);
if (idx < 0) return;
this.sessions.splice(idx, 1);
if (!this.sessions.length) {
const s = this._newSessionObj(uid(), '新会话');
this.sessions = [s];
this.activeId = s.id;
} else if (this.activeId === id) {
this.activeId = this.sessions[0].id;
}
this._persist();
}
pushChat(id, msg) {
const s = this.sessions.find(x => x.id === id);
if (!s) return;
s.chat.push(msg);
this._persist();
}
pushAgent(id, msg) {
const s = this.sessions.find(x => x.id === id);
if (!s) return;
s.agent.push(msg);
this._persist();
}
setFSM(id, patch) {
const s = this.sessions.find(x => x.id === id);
if (!s) return;
s.fsm = { ...(s.fsm || {}), ...(patch || {}) };
this._persist();
}
updateLastAgent(id, predicateFn, updaterFn) {
const s = this.sessions.find(x => x.id === id);
if (!s || !Array.isArray(s.agent)) return;
for (let i = s.agent.length - 1; i >= 0; i--) {
if (predicateFn(s.agent[i])) {
s.agent[i] = updaterFn(s.agent[i]) || s.agent[i];
this._persist();
return;
}
}
}
clearSession(id) {
const s = this.sessions.find(x => x.id === id);
if (!s) return;
s.chat = [];
s.agent = [];
s.fsm = { state: FSM.IDLE, step: 0, lastError: null, isRunning: false };
this._persist();
}
_persist() {
GM_setValue(STORE_KEYS.SESS, this.sessions);
GM_setValue(STORE_KEYS.ACTIVE, this.activeId);
}
}
/******************************************************************
* 3) Discourse 工具(linux.do 标准 JSON 接口)
******************************************************************/
class DiscourseAPI {
static headers() { return { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }; }
static csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
}
static async fetchJson(path, opt = {}) {
const { method = 'GET', body = null, headers = {} } = opt;
const init = {
method,
credentials: 'same-origin',
headers: { ...this.headers(), ...(headers || {}) },
};
if (body != null) {
init.headers['Content-Type'] = 'application/json';
init.headers['X-CSRF-Token'] = this.csrfToken();
init.body = JSON.stringify(body);
}
const res = await fetch(path, init);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);
return res.json();
}
static topicUrl(topicId, postNo = 1) {
return `${location.origin}/t/${encodeURIComponent(topicId)}/${postNo}`;
}
static userUrl(username) {
return `${location.origin}/u/${encodeURIComponent(username)}`;
}
// ===== search =====
static async search({ q, page = 1, limit = 8 }) {
const params = new URLSearchParams();
params.set('q', q);
params.set('page', String(page));
params.set('include_blurbs', 'true');
params.set('skip_context', 'true');
const data = await this.fetchJson(`/search.json?${params.toString()}`);
const topicsMap = new Map((data.topics || []).map(t => [t.id, safeTitle(t.fancy_title || t.title, `话题 ${t.id}`)]));
const posts = (data.posts || []).slice(0, limit).map(p => ({
topic_id: p.topic_id,
post_number: p.post_number,
title: topicsMap.get(p.topic_id) || `话题 ${p.topic_id}`,
username: p.username,
created_at: p.created_at,
blurb: p.blurb || '',
url: this.topicUrl(p.topic_id, p.post_number),
}));
return { q, page, posts };
}
// ===== getTopicAllPosts =====
static async getTopicAllPosts({ topicId, batchSize = 18, maxPosts = 240 }) {
const first = await this.fetchJson(`/t/${encodeURIComponent(topicId)}.json`);
const title = safeTitle(first.title, `话题 ${topicId}`);
const stream = (first.post_stream?.stream || []).slice(0, maxPosts);
const got = new Map();
for (const p of (first.post_stream?.posts || [])) got.set(p.id, p);
for (let i = 0; i < stream.length; i += batchSize) {
const chunk = stream.slice(i, i + batchSize);
const params = new URLSearchParams();
chunk.forEach(id => params.append('post_ids[]', String(id)));
const data = await this.fetchJson(`/t/${encodeURIComponent(topicId)}/posts.json?${params.toString()}`);
for (const p of (data.post_stream?.posts || [])) got.set(p.id, p);
await sleep(160);
}
const posts = stream.map(id => got.get(id)).filter(Boolean).map(p => ({
id: p.id,
post_number: p.post_number,
username: p.username,
created_at: p.created_at,
cooked: p.cooked || '',
url: this.topicUrl(topicId, p.post_number),
like_count: p.like_count,
reply_count: p.reply_count,
}));
return { topicId, title, count: posts.length, posts };
}
// ===== getUserRecent =====
static async getUserRecent({ username, limit = 10 }) {
const params = new URLSearchParams();
params.set('offset', '0');
params.set('limit', String(limit));
params.set('username', username);
params.set('filter', '4,5');
const data = await this.fetchJson(`/user_actions.json?${params.toString()}`);
const items = (data.user_actions || []).map(a => ({
action_type: a.action_type,
title: safeTitle(a.title, `话题 ${a.topic_id}`),
topic_id: a.topic_id,
post_number: a.post_number,
created_at: a.created_at,
excerpt: a.excerpt || '',
url: this.topicUrl(a.topic_id, a.post_number),
}));
return { username, items };
}
// ===== 分类列表 =====
static async getCategories() {
return this.fetchJson('/categories.json');
}
// ===== 最新话题列表(✅ 增加 no_definitions=true,兼容 more_topics_url)=====
static async listLatestTopics({ page = 0 } = {}) {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('no_definitions', 'true');
return this.fetchJson(`/latest.json?${params.toString()}`);
}
// ===== Top 话题 =====
static async listTopTopics({ period = 'weekly', page = 0 } = {}) {
const params = new URLSearchParams();
params.set('period', String(period));
params.set('page', String(page));
params.set('no_definitions', 'true');
return this.fetchJson(`/top.json?${params.toString()}`);
}
// ===== Tag 下的话题 =====
static async getTagTopics({ tag, page = 0 } = {}) {
if (!tag) throw new Error('tag 不能为空');
const params = new URLSearchParams();
params.set('page', String(page));
params.set('no_definitions', 'true');
return this.fetchJson(`/tag/${encodeURIComponent(tag)}.json?${params.toString()}`);
}
// ===== 站点最新帖子列表(✅ 新增:用于“最新帖子”而非“最新话题”)=====
// Discourse 常见:/posts.json?before=<post_id>
static async listLatestPosts({ before = null, limit = 20 } = {}) {
const params = new URLSearchParams();
if (before !== null && before !== undefined && before !== '') params.set('before', String(before));
// 有的站支持 per_page / limit,不保证;这里尽量兼容
params.set('limit', String(Math.max(1, Math.min(50, parseInt(limit, 10) || 20))));
params.set('no_definitions', 'true');
let data;
try {
data = await this.fetchJson(`/posts.json?${params.toString()}`);
} catch (e1) {
// fallback:某些站可能限制 /posts.json,这里给出更明确的错误
throw new Error(`获取失败:/posts.json 不可用或被限制。${String(e1?.message || e1)}`);
}
const arr = Array.isArray(data?.latest_posts) ? data.latest_posts : (Array.isArray(data) ? data : []);
const posts = arr.slice(0, Math.max(1, Math.min(50, parseInt(limit, 10) || 20))).map(p => {
const topic_id = p.topic_id;
const post_number = p.post_number || p.post_number;
return {
id: p.id,
topic_id,
post_number,
username: p.username,
created_at: p.created_at,
cooked: p.cooked || '',
raw: p.raw || '',
like_count: p.like_count,
url: (topic_id && post_number) ? this.topicUrl(topic_id, post_number) : '',
};
});
return {
before: before ?? null,
returned: posts.length,
posts,
};
}
// ===== 用户 summary(✅ 升级:不仅用户数据,还包含热门帖子/热门话题/近期内容,一次返回尽量多“有价值信息”)=====
static async getUserSummary({ username } = {}) {
if (!username) throw new Error('username 不能为空');
const out = {
username,
urls: {
profile: this.userUrl(username),
summary: `${this.userUrl(username)}/summary`,
},
profile: null,
summary: null,
badges: null,
hot_topics: [],
hot_posts: [],
recent_topics: [],
recent_posts: [],
_raw: {
summary_json: null,
profile_json: null,
activity_topics_json: null,
activity_posts_json: null,
}
};
// 1) summary.json(通常含 user_summary/top_topics/top_replies/badges 等)
let summaryJson = null;
try {
summaryJson = await this.fetchJson(`/u/${encodeURIComponent(username)}/summary.json`);
out._raw.summary_json = summaryJson;
} catch (e) {
throw new Error(`获取 summary.json 失败:${String(e?.message || e)}`);
}
// 2) profile:/u/username.json(含 user 字段、user_fields、title、website 等)
try {
const profileJson = await this.fetchJson(`/u/${encodeURIComponent(username)}.json`);
out._raw.profile_json = profileJson;
} catch {
// profile 可失败,不强制
}
// 3) activity/topics(近期创建的话题列表)
try {
const params = new URLSearchParams();
params.set('page', '0');
params.set('no_definitions', 'true');
const topicsJson = await this.fetchJson(`/u/${encodeURIComponent(username)}/activity/topics.json?${params.toString()}`);
out._raw.activity_topics_json = topicsJson;
} catch {
// 可失败,不强制
}
// 4) activity/posts(近期发言列表:回帖/发帖)
try {
const params = new URLSearchParams();
params.set('page', '0');
params.set('no_definitions', 'true');
const postsJson = await this.fetchJson(`/u/${encodeURIComponent(username)}/activity/posts.json?${params.toString()}`);
out._raw.activity_posts_json = postsJson;
} catch {
// 可失败,不强制
}
// ===== 归一化提取 =====
// profile
const profileUser = out._raw.profile_json?.user || summaryJson?.user || null;
if (profileUser) {
out.profile = {
id: profileUser.id,
username: profileUser.username,
name: profileUser.name ?? '',
title: profileUser.title ?? '',
trust_level: profileUser.trust_level,
avatar_template: profileUser.avatar_template,
created_at: profileUser.created_at,
last_seen_at: profileUser.last_seen_at,
last_posted_at: profileUser.last_posted_at,
badge_count: profileUser.badge_count,
website: profileUser.website,
website_name: profileUser.website_name,
profile_view_count: profileUser.profile_view_count,
time_read: profileUser.time_read,
recent_time_read: profileUser.recent_time_read,
user_fields: profileUser.user_fields || {},
};
}
// summary 核心统计
const us = summaryJson?.user_summary || summaryJson?.userSummary || null;
if (us) {
out.summary = {
topic_count: us.topic_count,
reply_count: us.reply_count,
likes_given: us.likes_given,
likes_received: us.likes_received,
days_visited: us.days_visited,
posts_read_count: us.posts_read_count,
time_read: us.time_read,
};
}
// badges(尽量原样保留,便于模型/前端用)
out.badges = {
user_badges: summaryJson?.user_badges || summaryJson?.userBadges || null,
badges: summaryJson?.badges || null,
badge_types: summaryJson?.badge_types || null,
users: summaryJson?.users || null,
};
// hot_topics / hot_posts:
// A) summary.json 常见字段:top_topics / top_replies
const topTopics = Array.isArray(summaryJson?.top_topics) ? summaryJson.top_topics : (Array.isArray(summaryJson?.topTopics) ? summaryJson.topTopics : []);
const topReplies = Array.isArray(summaryJson?.top_replies) ? summaryJson.top_replies : (Array.isArray(summaryJson?.topReplies) ? summaryJson.topReplies : []);
out.hot_topics = topTopics.map(t => ({
topic_id: t.id || t.topic_id || t.topicId,
title: safeTitle(t.fancy_title || t.title, `话题 ${t.id || t.topic_id || ''}`),
like_count: t.like_count,
views: t.views,
reply_count: t.reply_count ?? t.posts_count,
last_posted_at: t.last_posted_at ?? t.bumped_at,
category_id: t.category_id,
tags: t.tags || [],
url: this.topicUrl(t.id || t.topic_id || t.topicId, 1),
})).filter(x => x.topic_id);
out.hot_posts = topReplies.map(p => ({
topic_id: p.topic_id,
post_number: p.post_number,
post_id: p.id,
title: safeTitle(p.topic_title || p.title, `话题 ${p.topic_id}`),
like_count: p.like_count,
created_at: p.created_at,
excerpt: p.excerpt || '',
username: p.username,
url: this.topicUrl(p.topic_id, p.post_number || 1),
})).filter(x => x.topic_id);
// B) activity/topics 作为补充热门候选(按 like_count/views/reply_count/last_posted_at 粗略排序)
const actTopics = out._raw.activity_topics_json?.topic_list?.topics || out._raw.activity_topics_json?.topics || [];
if (Array.isArray(actTopics) && actTopics.length) {
const extra = actTopics.map(t => ({
topic_id: t.id,
title: safeTitle(t.fancy_title || t.title, `话题 ${t.id}`),
like_count: t.like_count,
views: t.views,
reply_count: t.reply_count ?? (t.posts_count ? Math.max(0, t.posts_count - 1) : undefined),
last_posted_at: t.last_posted_at ?? t.bumped_at,
category_id: t.category_id,
tags: t.tags || [],
url: this.topicUrl(t.id, 1),
_score: (t.like_count || 0) * 4 + (t.views || 0) * 0.01 + (t.reply_count || 0) * 2,
}));
// recent_topics:activity 原样取前 12
out.recent_topics = extra.slice(0, 12).map(({ _score, ...rest }) => rest);
// hot_topics:如果 summary.top_topics 不足,则用 activity 补齐(去重 topic_id)
const exist = new Set(out.hot_topics.map(x => x.topic_id));
extra.sort((a, b) => (b._score - a._score));
for (const t of extra) {
if (out.hot_topics.length >= 10) break;
if (!exist.has(t.topic_id)) {
exist.add(t.topic_id);
const { _score, ...rest } = t;
out.hot_topics.push(rest);
}
}
}
// C) activity/posts 补充 recent_posts / hot_posts(按 like_count)
const actPosts = out._raw.activity_posts_json?.user_actions || out._raw.activity_posts_json?.posts || out._raw.activity_posts_json?.activity_stream || [];
if (Array.isArray(actPosts) && actPosts.length) {
// user_actions.json 风格兼容(字段可能不同)
const normPosts = actPosts.map(a => {
const topic_id = a.topic_id || a?.post?.topic_id;
const post_number = a.post_number || a?.post?.post_number;
const like_count = a.like_count ?? a?.post?.like_count;
const excerpt = a.excerpt || a?.post?.excerpt || '';
const created_at = a.created_at || a?.post?.created_at;
const username2 = a.username || a?.post?.username || username;
const title = safeTitle(a.title || a.topic_title || a?.post?.topic_title, topic_id ? `话题 ${topic_id}` : '帖子');
return {
topic_id,
post_number,
post_id: a.post_id || a.id || a?.post?.id,
title,
like_count,
created_at,
excerpt,
username: username2,
url: (topic_id && post_number) ? this.topicUrl(topic_id, post_number) : '',
};
}).filter(x => x.topic_id && x.post_number);
out.recent_posts = normPosts.slice(0, 12);
const existPostKey = new Set(out.hot_posts.map(x => `${x.topic_id}#${x.post_number}`));
const scored = normPosts.map(p => ({ ...p, _score: (p.like_count || 0) * 5 + (p.excerpt ? Math.min(1, p.excerpt.length / 120) : 0) }));
scored.sort((a, b) => (b._score - a._score));
for (const p of scored) {
if (out.hot_posts.length >= 10) break;
const k = `${p.topic_id}#${p.post_number}`;
if (!existPostKey.has(k)) {
existPostKey.add(k);
const { _score, ...rest } = p;
out.hot_posts.push(rest);
}
}
}
// 限制数量(避免爆上下文)
out.hot_topics = out.hot_topics.slice(0, 10);
out.hot_posts = out.hot_posts.slice(0, 10);
out.recent_topics = out.recent_topics.slice(0, 12);
out.recent_posts = out.recent_posts.slice(0, 12);
return out;
}
// ===== 单个帖子详情(按 postId)=====
static async getPost({ postId } = {}) {
if (!postId) throw new Error('postId 不能为空');
return this.fetchJson(`/posts/${encodeURIComponent(postId)}.json`);
}
// ===== 精细获取某话题的某楼(<=10000)=====
static async getTopicPostFull({ topicId, postNumber = 1, maxChars = 10000 } = {}) {
if (topicId === undefined || topicId === null || topicId === '') throw new Error('topicId 不能为空');
const pn = Math.max(1, parseInt(postNumber, 10) || 1);
const max = Math.max(1000, Math.min(10000, parseInt(maxChars, 10) || 10000));
const trunc = (s) => {
s = String(s || '');
return s.length > max ? (s.slice(0, max) + '\n...(截断)') : s;
};
let post = null;
let title = '';
let used = '';
try {
const data = await this.fetchJson(`/posts/by_number/${encodeURIComponent(topicId)}/${encodeURIComponent(pn)}.json`);
post = data?.post || data;
title = safeTitle(post?.topic_title, '');
used = 'posts/by_number';
} catch (e1) {
try {
const data2 = await this.fetchJson(`/t/${encodeURIComponent(topicId)}/${encodeURIComponent(pn)}.json`);
title = safeTitle(data2?.title, `话题 ${topicId}`);
const ps = data2?.post_stream?.posts || [];
post = ps.find(x => x?.post_number === pn) || ps[0] || null;
used = 't/{topicId}/{postNumber}.json';
} catch (e2) {
throw new Error(`获取失败:by_number与topic视图都不可用。\n- by_number: ${String(e1?.message || e1)}\n- topic_view: ${String(e2?.message || e2)}`);
}
}
if (!post) throw new Error('未找到该楼层帖子');
const cooked = trunc(post.cooked || '');
const raw = trunc(post.raw || '');
const topic_id = post.topic_id || topicId;
const post_number = post.post_number || pn;
return {
topicId: topic_id,
title: title || safeTitle(post?.topic_title, `话题 ${topic_id}`),
postId: post.id,
post_number,
username: post.username,
created_at: post.created_at,
url: this.topicUrl(topic_id, post_number),
cooked,
raw,
maxChars: max,
endpointUsed: used,
};
}
}
/******************************************************************
* 4) 工具注册表
******************************************************************/
const TOOLS_SPEC = [
'可用工具:',
'1) discourse.search: {q:string, page?:number, limit?:number}',
'2) discourse.getTopicAllPosts: {topicId:number|string, batchSize?:number, maxPosts?:number}',
'3) discourse.getUserRecent: {username:string, limit?:number}',
'4) discourse.getCategories: {}',
'5) discourse.listLatestTopics: {page?:number}', // ✅ no_definitions=true
'6) discourse.listTopTopics: {period?:string, page?:number}', // ✅ no_definitions=true
'7) discourse.getTagTopics: {tag:string, page?:number}', // ✅ no_definitions=true
'8) discourse.getUserSummary: {username:string} // ✅ Rich: 用户信息 + 徽章 + 热门话题/热门帖子 + 近期内容',
'9) discourse.getPost: {postId:number|string}',
'10) discourse.getTopicPostFull: {topicId:number|string, postNumber:number, maxChars?:number}', // <=10000
'11) discourse.listLatestPosts: {before?:number|string|null, limit?:number} // ✅ 站点最新帖子列表',
].join('\n');
async function runTool(name, args) {
if (name === 'discourse.search') return DiscourseAPI.search(args);
if (name === 'discourse.getTopicAllPosts') return DiscourseAPI.getTopicAllPosts(args);
if (name === 'discourse.getUserRecent') return DiscourseAPI.getUserRecent(args);
if (name === 'discourse.getCategories') return DiscourseAPI.getCategories();
if (name === 'discourse.listLatestTopics') return DiscourseAPI.listLatestTopics(args);
if (name === 'discourse.listTopTopics') return DiscourseAPI.listTopTopics(args);
if (name === 'discourse.getTagTopics') return DiscourseAPI.getTagTopics(args);
if (name === 'discourse.getUserSummary') return DiscourseAPI.getUserSummary(args);
if (name === 'discourse.getPost') return DiscourseAPI.getPost(args);
if (name === 'discourse.getTopicPostFull') return DiscourseAPI.getTopicPostFull(args);
if (name === 'discourse.listLatestPosts') return DiscourseAPI.listLatestPosts(args);
throw new Error(`未知工具: ${name}`);
}
/******************************************************************
* 4.5) toolResultToContext(增强)
******************************************************************/
function toolResultToContext(name, result) {
const LIMITS = {
search_items: 12,
search_excerpt: 420,
user_recent_items: 16,
user_recent_excerpt: 420,
topic_head_posts: 18,
topic_tail_posts: 8,
topic_excerpt: 900,
topic_op_extra: 2200,
list_topics_items: 30,
categories_items: 40,
post_excerpt: 1600,
topic_post_full_cooked_hint: 2200,
user_hot_topics: 10,
user_hot_posts: 10,
user_recent_topics: 12,
user_recent_posts: 12,
user_excerpt: 260,
latest_posts_items: 24,
latest_posts_excerpt: 420,
};
const MAX_CONTEXT_CHARS = 22000;
const norm = (s) => stripHtml(String(s || '')).replace(/\s+/g, ' ').trim();
const cut = (s, n) => {
s = String(s || '');
return s.length > n ? (s.slice(0, n) + '…') : s;
};
const kv = (k, v) => (v === undefined || v === null || v === '') ? '' : `${k}: ${v}`;
const joinNonEmpty = (arr, sep = '\n') => arr.filter(Boolean).join(sep);
const clampCtx = (text) => clamp(text, MAX_CONTEXT_CHARS);
if (name === 'discourse.search') {
const posts = (result?.posts || []).slice(0, LIMITS.search_items);
const lines = posts.map((p, i) => {
const ex = cut(norm(p.blurb), LIMITS.search_excerpt);
return joinNonEmpty([
`${i + 1}. ${safeTitle(p.title, `话题 ${p.topic_id}`)}`,
`- topic_id: ${p.topic_id} | post_number: ${p.post_number}`,
`- author: @${p.username} | created_at: ${p.created_at}`,
`- 摘要: ${ex}`,
`- 链接: ${p.url}`,
]);
});
const header = `【TOOL_RESULT discourse.search | q=${result?.q ?? ''} | page=${result?.page ?? ''} | returned=${posts.length}】`;
return clampCtx(header + '\n' + lines.join('\n\n'));
}
if (name === 'discourse.getUserRecent') {
const items = (result?.items || []).slice(0, LIMITS.user_recent_items);
const lines = items.map((x, i) => {
const ex = cut(norm(x.excerpt), LIMITS.user_recent_excerpt);
const typ = x.action_type === 4 ? '发帖' : (x.action_type === 5 ? '回帖' : `动作${x.action_type}`);
return joinNonEmpty([
`${i + 1}. ${typ} | ${safeTitle(x.title, `话题 ${x.topic_id}`)}`,
`- topic_id: ${x.topic_id} | post_number: ${x.post_number}`,
`- created_at: ${x.created_at}`,
`- 摘要: ${ex}`,
`- 链接: ${x.url}`,
]);
});
const header = `【TOOL_RESULT discourse.getUserRecent | @${result?.username ?? ''} | returned=${items.length}】`;
return clampCtx(header + '\n' + lines.join('\n\n'));
}
if (name === 'discourse.getTopicAllPosts') {
const postsAll = (result?.posts || []);
const count = postsAll.length;
const head = postsAll.slice(0, LIMITS.topic_head_posts);
const tail = (count > LIMITS.topic_head_posts)
? postsAll.slice(Math.max(LIMITS.topic_head_posts, count - LIMITS.topic_tail_posts))
: [];
const formatPost = (p) => {
const isOP = (p.post_number === 1);
const n = isOP ? Math.max(LIMITS.topic_excerpt, LIMITS.topic_op_extra) : LIMITS.topic_excerpt;
const ex = cut(norm(p.cooked), n);
const likes = (p.like_count !== undefined) ? ` | likes=${p.like_count}` : '';
return joinNonEmpty([
`#${p.post_number} @${p.username} ${p.created_at}${likes}`,
`- 摘要: ${ex}`,
`- 链接: ${p.url}`,
]);
};
const headLines = head.map(formatPost);
const tailLines = tail.map(formatPost);
const header = joinNonEmpty([
`【TOOL_RESULT discourse.getTopicAllPosts | ${safeTitle(result?.title, `话题 ${result?.topicId}`)}】`,
`topicId: ${result?.topicId} | total_posts: ${count}`,
`hint: 已提供“前${head.length}楼 + 后${tailLines.length}楼”,用于同时覆盖 OP 与最新进展`,
]);
const midGap = (tailLines.length && count > head.length + tailLines.length)
? `\n\n…(中间省略 ${count - head.length - tailLines.length} 楼,为节省上下文;如需可用 discourse.getTopicPostFull 抓取指定楼层全文)…\n\n`
: '\n\n';
return clampCtx(header + '\n\n' + headLines.join('\n\n') + midGap + tailLines.join('\n\n'));
}
if (name === 'discourse.getCategories') {
const cats = (result?.category_list?.categories || []).slice(0, LIMITS.categories_items);
const lines = cats.map((c, i) => {
const slug = c.slug || c.name || '';
const url = `${location.origin}/c/${encodeURIComponent(slug)}/${c.id}`;
const desc = cut(norm(c.description || c.description_text || ''), 260);
return joinNonEmpty([
`${i + 1}. ${safeTitle(c.name, `分类 ${c.id}`)} (id=${c.id}, slug=${c.slug || ''})`,
joinNonEmpty([
kv('- topics', c.topic_count),
kv('posts', c.post_count),
kv('users', c.user_count),
kv('position', c.position),
], ' | ').replace(/^\s*\|\s*/,'- '),
desc ? `- 描述: ${desc}` : '',
`- 链接: ${url}`,
]);
});
const header = `【TOOL_RESULT discourse.getCategories | returned=${cats.length}】`;
return clampCtx(header + '\n' + lines.join('\n\n'));
}
if (name === 'discourse.listLatestTopics' || name === 'discourse.listTopTopics' || name === 'discourse.getTagTopics') {
const topics = (result?.topic_list?.topics || []).slice(0, LIMITS.list_topics_items);
const metaBits = [];
if (name === 'discourse.getTagTopics') metaBits.push(kv('tag', result?.tag || result?.tag_name || ''));
if (name === 'discourse.listTopTopics') metaBits.push(kv('period', result?.period || ''));
metaBits.push(kv('page', result?.topic_list?.page || result?.page || ''));
const moreUrl = result?.topic_list?.more_topics_url || result?.topic_list?.more_topics_url;
const topTags = Array.isArray(result?.topic_list?.top_tags) ? result.topic_list.top_tags.slice(0, 15) : [];
const lines = topics.map((t, i) => {
const url = DiscourseAPI.topicUrl(t.id, 1);
const title = safeTitle(t.fancy_title || t.title, `话题 ${t.id}`);
const tags = Array.isArray(t.tags) ? t.tags.join(',') : '';
const last = t.last_posted_at || t.bumped_at || '';
const postsCount = (t.posts_count !== undefined) ? t.posts_count : undefined;
const replies = (t.reply_count !== undefined) ? t.reply_count : (postsCount !== undefined ? Math.max(0, postsCount - 1) : undefined);
return joinNonEmpty([
`${i + 1}. ${title}`,
joinNonEmpty([
kv('- topic_id', t.id),
kv('category_id', t.category_id),
kv('tags', tags),
], ' | ').replace(/^\s*\|\s*/,'- '),
joinNonEmpty([
kv('- posts_count', postsCount),
kv('replies', replies),
kv('views', t.views),
kv('like_count', t.like_count),
kv('last', last),
], ' | ').replace(/^\s*\|\s*/,'- '),
`- 链接: ${url}`,
]);
});
const header = `【TOOL_RESULT ${name} | ${metaBits.filter(Boolean).join(' | ')} | returned=${topics.length}】`;
const extra = joinNonEmpty([
moreUrl ? `more_topics_url: ${location.origin}${moreUrl}` : '',
topTags.length ? `top_tags: ${topTags.join(', ')}` : '',
]);
return clampCtx(header + '\n' + (extra ? (extra + '\n\n') : '') + lines.join('\n\n'));
}
// ✅ Rich UserSummary:展示用户信息 + 热门话题/热门帖子 + 近期内容 + 关键链接
if (name === 'discourse.getUserSummary') {
const r = result || {};
const u = r.profile || {};
const s = r.summary || {};
const hotTopics = (r.hot_topics || []).slice(0, LIMITS.user_hot_topics);
const hotPosts = (r.hot_posts || []).slice(0, LIMITS.user_hot_posts);
const recTopics = (r.recent_topics || []).slice(0, LIMITS.user_recent_topics);
const recPosts = (r.recent_posts || []).slice(0, LIMITS.user_recent_posts);
const base = [
`【TOOL_RESULT discourse.getUserSummary | @${r.username || ''} | Rich】`,
r.urls?.profile ? `profile: ${r.urls.profile}` : '',
r.urls?.summary ? `summary: ${r.urls.summary}` : '',
'',
'--- 用户信息 ---',
joinNonEmpty([
kv('id', u.id),
kv('username', u.username ? '@' + u.username : ''),
kv('name', u.name),
kv('title', u.title),
kv('trust_level', u.trust_level),
kv('badge_count', u.badge_count),
], ' | '),
joinNonEmpty([
kv('created_at', u.created_at),
kv('last_seen_at', u.last_seen_at),
kv('last_posted_at', u.last_posted_at),
], ' | '),
u.website ? `website: ${u.website}` : '',
u.profile_view_count !== undefined ? `profile_view_count: ${u.profile_view_count}` : '',
'',
'--- 统计摘要 ---',
joinNonEmpty([
kv('topic_count', s.topic_count),
kv('reply_count', s.reply_count),
kv('likes_given', s.likes_given),
kv('likes_received', s.likes_received),
], ' | '),
joinNonEmpty([
kv('days_visited', s.days_visited),
kv('posts_read_count', s.posts_read_count),
kv('time_read', s.time_read),
], ' | '),
].filter(Boolean);
const fmtTopic = (t, i) => {
const tags = Array.isArray(t.tags) ? t.tags.join(',') : '';
const ex = t.excerpt ? cut(norm(t.excerpt), LIMITS.user_excerpt) : '';
return joinNonEmpty([
`${i + 1}. ${safeTitle(t.title, `话题 ${t.topic_id}`)}`,
joinNonEmpty([
kv('- topic_id', t.topic_id),
kv('category_id', t.category_id),
kv('tags', tags),
], ' | ').replace(/^\s*\|\s*/,'- '),
joinNonEmpty([
kv('- likes', t.like_count),
kv('views', t.views),
kv('replies', t.reply_count),
kv('last', t.last_posted_at),
], ' | ').replace(/^\s*\|\s*/,'- '),
ex ? `- 摘要: ${ex}` : '',
t.url ? `- 链接: ${t.url}` : '',
]);
};
const fmtPost = (p, i) => {
const ex = cut(norm(p.excerpt || p.cooked || ''), LIMITS.user_excerpt);
return joinNonEmpty([
`${i + 1}. ${safeTitle(p.title, `话题 ${p.topic_id}`)} #${p.post_number}`,
joinNonEmpty([
kv('- topic_id', p.topic_id),
kv('post_number', p.post_number),
kv('likes', p.like_count),
kv('author', p.username ? '@' + p.username : ''),
kv('created_at', p.created_at),
], ' | ').replace(/^\s*\|\s*/,'- '),
ex ? `- 摘要: ${ex}` : '',
p.url ? `- 链接: ${p.url}` : '',
]);
};
const sections = [];
if (hotTopics.length) {
sections.push(['', '--- 热门话题(Top Topics / Hot Topics)---', ...hotTopics.map(fmtTopic)].join('\n'));
}
if (hotPosts.length) {
sections.push(['', '--- 热门帖子(Top Replies / Hot Posts)---', ...hotPosts.map(fmtPost)].join('\n'));
}
if (recTopics.length) {
sections.push(['', '--- 近期话题(Recent Topics)---', ...recTopics.map(fmtTopic)].join('\n'));
}
if (recPosts.length) {
sections.push(['', '--- 近期发言(Recent Posts)---', ...recPosts.map(fmtPost)].join('\n'));
}
const badgeHint = (r.badges?.user_badges && r.badges?.badges)
? `\n--- 徽章(Badges)---\nuser_badges: ${Array.isArray(r.badges.user_badges) ? r.badges.user_badges.length : 'n/a'} | badges: ${Array.isArray(r.badges.badges) ? r.badges.badges.length : 'n/a'}`
: '';
return clampCtx(base.join('\n') + '\n' + sections.join('\n') + badgeHint);
}
if (name === 'discourse.getPost') {
const p = result?.post || result || {};
const cooked = cut(norm(p.cooked || ''), LIMITS.post_excerpt);
const raw = cut(String(p.raw || ''), Math.min(1200, LIMITS.post_excerpt));
const url = (p.topic_id && p.post_number) ? DiscourseAPI.topicUrl(p.topic_id, p.post_number) : '';
const lines = [
`【TOOL_RESULT discourse.getPost】`,
`id: ${p.id || ''}`,
joinNonEmpty([
kv('topic_id', p.topic_id),
kv('post_number', p.post_number),
kv('author', p.username ? '@' + p.username : ''),
kv('created_at', p.created_at),
], ' | '),
url ? `- 链接: ${url}` : '',
cooked ? `- cooked 摘要: ${cooked}` : '',
raw ? `- raw 摘要: ${raw}` : '',
].filter(Boolean);
return clampCtx(lines.join('\n'));
}
if (name === 'discourse.getTopicPostFull') {
const r = result || {};
const cookedHint = cut(norm(r.cooked || ''), LIMITS.topic_post_full_cooked_hint);
const lines = [
`【TOOL_RESULT discourse.getTopicPostFull | ${safeTitle(r.title, `话题 ${r.topicId}`)}】`,
`topicId: ${r.topicId} | post_number: ${r.post_number} | postId: ${r.postId || ''}`,
`author: @${r.username || ''} | created_at: ${r.created_at || ''}`,
`endpointUsed: ${r.endpointUsed || ''} | maxChars: ${r.maxChars || ''}`,
r.url ? `- 链接: ${r.url}` : '',
cookedHint ? `- cooked(提示): ${cookedHint}` : '',
'',
'--- raw(全文,已限制 <=10000 字符) ---',
String(r.raw || ''),
].filter(Boolean);
return clampCtx(lines.join('\n'));
}
// ✅ 最新帖子列表(站点级)
if (name === 'discourse.listLatestPosts') {
const posts = (result?.posts || []).slice(0, LIMITS.latest_posts_items);
const lines = posts.map((p, i) => {
const ex = cut(norm(p.cooked || p.raw || ''), LIMITS.latest_posts_excerpt);
return joinNonEmpty([
`${i + 1}. @${p.username || ''} | topic=${p.topic_id} #${p.post_number}`,
joinNonEmpty([
kv('- post_id', p.id),
kv('likes', p.like_count),
kv('created_at', p.created_at),
], ' | ').replace(/^\s*\|\s*/,'- '),
ex ? `- 摘要: ${ex}` : '',
p.url ? `- 链接: ${p.url}` : '',
]);
});
const header = `【TOOL_RESULT discourse.listLatestPosts | before=${result?.before ?? 'null'} | returned=${posts.length}】`;
return clampCtx(header + '\n' + lines.join('\n\n'));
}
try {
const text = JSON.stringify(result, null, 2);
return clampCtx(`【TOOL_RESULT ${name} | fallback_json】\n` + text);
} catch {
return clampCtx(`【TOOL_RESULT ${name} | fallback_text】\n` + String(result));
}
}
/******************************************************************
* 5) OpenAI Chat Completions 客户端
******************************************************************/
function parseRetryAfterMs(responseHeaders) {
try {
const m = String(responseHeaders || '').match(/^\s*retry-after\s*:\s*([^\r\n]+)\s*$/im);
if (!m) return null;
const v = m[1].trim();
if (/^\d+$/.test(v)) return parseInt(v, 10) * 1000;
const t = Date.parse(v);
if (!Number.isNaN(t)) {
const ms = t - Date.now();
return ms > 0 ? ms : 0;
}
} catch {}
return null;
}
function gmRequestOnce({ url, headers, bodyObj, timeoutMs = 30000 }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url,
headers: { 'Content-Type': 'application/json', ...(headers || {}) },
data: JSON.stringify(bodyObj),
timeout: timeoutMs,
onload: (res) => resolve(res),
onerror: (e) => reject(new Error(`网络错误: ${e?.error || e}`)),
ontimeout: () => reject(new Error(`请求超时: ${timeoutMs}ms`)),
});
});
}
async function gmPostJson(url, headers, bodyObj, opt = {}) {
const {
retries = 3,
baseDelayMs = 400,
maxDelayMs = 8000,
timeoutMs = 30000,
onlyStatus200 = true,
} = opt;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await gmRequestOnce({ url, headers, bodyObj, timeoutMs });
const ok = onlyStatus200 ? (res.status === 200) : (res.status >= 200 && res.status < 300);
if (!ok) {
const headRetryMs = parseRetryAfterMs(res.responseHeaders);
const bodyPreview = String(res.responseText || '').slice(0, 800);
const err = new Error(`HTTP ${res.status}: ${bodyPreview}`);
err._httpStatus = res.status;
err._retryAfterMs = headRetryMs;
throw err;
}
try {
return JSON.parse(res.responseText);
} catch {
throw new Error('响应 JSON 解析失败');
}
} catch (e) {
lastErr = e;
if (attempt === retries) break;
const ra = e?._retryAfterMs;
const backoff = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
const jitter = Math.floor(Math.random() * 200);
const waitMs = (typeof ra === 'number') ? ra : (backoff + jitter);
await sleep(waitMs);
continue;
}
}
throw lastErr || new Error('请求失败');
}
async function callOpenAIChat(messages, conf) {
const base = String(conf.baseUrl || '').replace(/\/+$/, '');
const url = base.endsWith('/chat/completions') ? base : (base + '/chat/completions');
const payload = {
model: conf.model,
temperature: conf.temperature ?? 0.2,
messages,
};
const json = await gmPostJson(
url,
{ Authorization: `Bearer ${conf.apiKey}` },
payload,
{ retries: 3, onlyStatus200: true }
);
const text = json?.choices?.[0]?.message?.content ?? '';
return String(text);
}
/******************************************************************
* 6) JSON 修复逻辑(find / rfind + 回写 history)
******************************************************************/
function parseModelJsonWithRepair(raw, sessionId, store) {
const original = String(raw ?? '');
try {
const obj = JSON.parse(original);
return { ok: true, obj, repaired: false, jsonText: original };
} catch {}
// 兼容:有些模型会把 JSON 包在前后文本里
const first = original.indexOf('{');
const last = original.lastIndexOf('}');
if (first >= 0 && last > first) {
const sliced = original.slice(first, last + 1);
store.updateLastAgent(sessionId,
(m) => m && m.kind === 'model_raw',
(m) => ({ ...m, kind: 'model_json_repaired', content: sliced, repairedFrom: original })
);
try {
const obj = JSON.parse(sliced);
return { ok: true, obj, repaired: true, jsonText: sliced };
} catch (e) {
return { ok: false, err: '切片后仍无法解析 JSON', detail: String(e?.message || e), sliced };
}
}
return { ok: false, err: '未找到可用的 JSON 对象边界 { ... }', detail: original.slice(0, 400) };
}
/******************************************************************
* 7) Agent 引擎(FSM + 多轮工具调用)
******************************************************************/
function buildLLMMessagesFromSession(session, conf) {
const msgs = [];
msgs.push({ role: 'system', content: conf.systemPrompt + '\n\n' + TOOLS_SPEC });
const chunks = [];
for (const m of (session.chat || [])) {
if (!m?.role || !m?.content) continue;
chunks.push({ role: m.role, content: String(m.content) });
}
for (const a of (session.agent || [])) {
if (!a?.content) continue;
if (a.kind === 'tool_context') {
chunks.push({ role: 'assistant', content: a.content });
}
}
let total = 0;
const kept = [];
const max = conf.maxContextChars || 24000;
for (let i = chunks.length - 1; i >= 0; i--) {
const c = chunks[i];
const len = (c.content || '').length;
if (total + len > max) break;
kept.push(c);
total += len;
}
kept.reverse();
msgs.push(...kept);
return msgs;
}
async function runAgentTurn(sessionId, store, conf, ui) {
const session = store.all().find(s => s.id === sessionId);
if (!session) throw new Error('session not found');
store.setFSM(sessionId, { state: FSM.WAITING_MODEL, isRunning: true, step: (session.fsm?.step || 0) + 1, lastError: null });
ui?.renderAll?.();
const llmMessages = buildLLMMessagesFromSession(session, conf);
const raw = await callOpenAIChat(llmMessages, conf);
store.pushAgent(sessionId, { role: 'agent', kind: 'model_raw', content: raw, ts: now() });
const parsed = parseModelJsonWithRepair(raw, sessionId, store);
if (!parsed.ok) {
store.pushAgent(sessionId, { role: 'agent', kind: 'model_parse_error', content: JSON.stringify(parsed, null, 2), ts: now() });
store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: parsed.err || 'parse error' });
ui?.renderAll?.();
throw new Error(parsed.err || '模型 JSON 解析失败');
}
const obj = parsed.obj;
if (obj.type === 'final') {
const answer = String(obj.answer ?? '').trim() || '(空回答)';
let refsMd = '';
if (Array.isArray(obj.refs) && obj.refs.length) {
const seen = new Set();
const cleaned = obj.refs
.map(x => ({
title: mdEscapeText(String(x?.title ?? '').trim() || '链接'),
url: String(x?.url ?? '').trim(),
}))
.filter(x => x.url && !seen.has(x.url) && (seen.add(x.url), true));
if (cleaned.length) {
refsMd = '\n\n---\n**参考链接(refs)**\n' + cleaned.map((r, i) => `${i + 1}. [${r.title}](${r.url})`).join('\n');
}
}
const finalContent = answer + refsMd;
store.pushChat(sessionId, { role: 'assistant', content: finalContent, ts: now() });
if (Array.isArray(obj.refs) && obj.refs.length) {
store.pushAgent(sessionId, { role: 'agent', kind: 'final_refs', content: JSON.stringify(obj.refs, null, 2), ts: now() });
}
store.setFSM(sessionId, { state: FSM.DONE, isRunning: false });
ui?.renderAll?.();
return { done: true, obj };
}
if (obj.type === 'tool') {
const name = String(obj.name || '').trim();
const args = obj.args || {};
if (!name) throw new Error('工具调用缺少 name');
store.setFSM(sessionId, { state: FSM.WAITING_TOOL, isRunning: true });
store.pushAgent(sessionId, { role: 'agent', kind: 'tool_call', content: JSON.stringify({ name, args }, null, 2), ts: now() });
ui?.renderAll?.();
let result;
try {
result = await runTool(name, args);
} catch (e) {
const errMsg = `【TOOL_RESULT ERROR ${name}】\nargs=${JSON.stringify(args)}\nerror=${String(e?.message || e)}`;
store.pushAgent(sessionId, { role: 'tool', kind: 'tool_context', content: errMsg, ts: now(), toolName: name });
store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true });
ui?.renderAll?.();
return { done: false, obj: { type: 'tool_error' } };
}
const toolCtx = toolResultToContext(name, result);
store.pushAgent(sessionId, { role: 'tool', kind: 'tool_context', content: toolCtx, ts: now(), toolName: name });
store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true });
ui?.renderAll?.();
return { done: false, obj: { type: 'tool_ok' } };
}
store.pushAgent(sessionId, { role: 'agent', kind: 'model_unknown_type', content: JSON.stringify(obj, null, 2), ts: now() });
store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: 'unknown type' });
ui?.renderAll?.();
throw new Error(`未知 type: ${obj.type}`);
}
async function runAgent(sessionId, store, conf, ui) {
const session = store.all().find(s => s.id === sessionId);
if (!session) throw new Error('session not found');
if (!conf.apiKey) throw new Error('请先在设置中填写 API Key');
if (session.fsm?.isRunning) return;
store.setFSM(sessionId, { state: FSM.RUNNING, isRunning: true, lastError: null });
ui?.renderAll?.();
const maxTurns = Math.max(1, Math.min(30, parseInt(conf.maxTurns || 8, 10)));
try {
for (let i = 0; i < maxTurns; i++) {
const r = await runAgentTurn(sessionId, store, conf, ui);
if (r.done) return r.obj;
await sleep(80);
}
store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: '超过 maxTurns 仍未 final' });
ui?.renderAll?.();
throw new Error('超过 maxTurns 仍未得到 final');
} catch (e) {
store.setFSM(sessionId, { state: FSM.ERROR, isRunning: false, lastError: String(e?.message || e) });
ui?.renderAll?.();
throw e;
}
}
/******************************************************************
* 8) 前端 UI(抽屉式 + 多会话管理 + 设置面板 + ✅AG拖动)
******************************************************************/
const STYLES = `
:root{
--a-bg: rgba(250,250,252,.97);
--a-card: rgba(255,255,255,.95);
--a-text: #0e1116;
--a-sub: #405060;
--a-border: rgba(0,0,0,.16);
--a-shadow: 0 18px 44px rgba(0,0,0,.18);
--a-primary:#1f6dff;
--a-user:#e8f0ff;
--a-ass:#ffffff;
--a-tool:#fff8db;
--a-code:#0b0e14;
--a-codeText:#e6edf3;
}
@media (prefers-color-scheme: dark){
:root{
--a-bg: rgba(16,18,24,.94);
--a-card: rgba(25,27,36,.92);
--a-text: #f2f5f8;
--a-sub: #c7ced9;
--a-border: rgba(255,255,255,.18);
--a-shadow: 0 22px 60px rgba(0,0,0,.6);
--a-primary:#6aa2ff;
--a-user:#1f2736;
--a-ass:#171b23;
--a-tool:#262c39;
--a-code:#0b0e14;
--a-codeText:#d9e1ee;
}
}
/* ✅ AG 悬浮球:默认右上,但用 left/top 便于拖动;touch-action:none 支持移动端拖拽 */
#${APP_PREFIX}fab{
position:fixed;
left: calc(100vw - 70px);
top: 16px;
width:52px; height:52px; border-radius:14px;
background: var(--a-card);
border:1px solid var(--a-border);
box-shadow: var(--a-shadow);
z-index:100003;
display:flex; align-items:center; justify-content:center;
cursor:pointer; user-select:none;
color: var(--a-primary);
font-weight:900;
touch-action: none;
}
#${APP_PREFIX}fab.dragging{
cursor: grabbing;
opacity: .92;
transform: scale(1.02);
}
#${APP_PREFIX}drawer{
position:fixed; left:0; right:0; top:-82vh; height:78vh;
z-index:100002;
background: var(--a-bg);
border-bottom:1px solid var(--a-border);
box-shadow: var(--a-shadow);
border-bottom-left-radius:18px;
border-bottom-right-radius:18px;
transition: top .38s cubic-bezier(0.19,1,0.22,1);
backdrop-filter: blur(18px);
display:flex; flex-direction:column;
min-width: 1000px;
color: var(--a-text);
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;
}
#${APP_PREFIX}drawer.open{ top:0; }
.${APP_PREFIX}header{
padding:12px 16px;
border-bottom:1px solid var(--a-border);
display:flex; align-items:center; justify-content:space-between;
background: radial-gradient(1200px 160px at 18% 0%, rgba(31,109,255,.16), transparent 60%), var(--a-bg);
flex-shrink:0;
}
.${APP_PREFIX}title{
font-weight:900; letter-spacing:.2px;
display:flex; align-items:center; gap:10px;
color: var(--a-primary);
}
.${APP_PREFIX}badge{
font-size:12px; padding:3px 8px; border-radius:999px;
border:1px solid var(--a-border);
color: var(--a-sub);
font-weight:700;
}
.${APP_PREFIX}actions{ display:flex; align-items:center; gap:10px; color:var(--a-sub); }
.${APP_PREFIX}icon{
cursor:pointer; padding:8px; border-radius:10px;
border:1px solid var(--a-border);
background: rgba(127,127,127,.06);
color: var(--a-text);
font-weight:800;
}
.${APP_PREFIX}icon:hover{ border-color: var(--a-primary); }
.${APP_PREFIX}body{ flex:1; display:flex; min-height:0; }
.${APP_PREFIX}sidebar{
width: 300px;
border-right:1px solid var(--a-border);
padding:10px;
overflow:auto;
background: linear-gradient(180deg, rgba(127,127,127,.07), transparent);
}
.${APP_PREFIX}sideTop{ display:flex; gap:8px; margin-bottom:10px; }
.${APP_PREFIX}btn{
border:none; cursor:pointer; border-radius:12px;
padding:9px 10px; font-weight:800;
background: var(--a-primary); color:#fff;
}
.${APP_PREFIX}btn:hover{ filter:brightness(1.05); }
.${APP_PREFIX}btnGhost{
background: transparent; color: var(--a-text);
border:1px solid var(--a-border);
font-weight:800;
}
.${APP_PREFIX}sessions{ display:flex; flex-direction:column; gap:8px; }
.${APP_PREFIX}session{
border:1px solid var(--a-border);
border-radius:14px;
padding:10px;
background: var(--a-card);
display:flex; justify-content:space-between; align-items:center; gap:8px;
cursor:pointer;
}
.${APP_PREFIX}session.active{ border-color: var(--a-primary); box-shadow: 0 0 0 2px rgba(0,214,255,.18) inset; }
.${APP_PREFIX}session .t{
max-width: 170px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
font-weight:800; color: var(--a-text);
}
.${APP_PREFIX}session .s{
font-size:12px; color: var(--a-sub); font-weight:700;
}
.${APP_PREFIX}ops{ display:flex; gap:6px; }
.${APP_PREFIX}op{
padding:6px 8px; border-radius:10px; border:1px solid var(--a-border);
background: rgba(127,127,127,.06);
cursor:pointer; user-select:none; font-weight:900;
}
.${APP_PREFIX}op:hover{ border-color: var(--a-primary); }
.${APP_PREFIX}main{ flex:1; display:flex; flex-direction:column; min-width:0; }
.${APP_PREFIX}chat{
flex:1; overflow:auto; padding: 18px 22px;
line-height:1.75; font-size:15px;
}
.${APP_PREFIX}msg{
max-width: 980px;
margin: 10px 0;
padding: 12px 14px;
border:1px solid var(--a-border);
border-radius:14px;
background: rgba(127,127,127,.06);
color: var(--a-text);
}
.${APP_PREFIX}msg.user{ background: var(--a-user); border-color: rgba(31,109,255,.35); }
.${APP_PREFIX}msg.assistant{ background: var(--a-ass); }
.${APP_PREFIX}msg.tool{ background: var(--a-tool); border-style:dashed; }
.${APP_PREFIX}meta{
font-size:12px; color: var(--a-sub);
display:flex; align-items:center; gap:10px; margin-bottom:6px;
font-weight:700;
}
.${APP_PREFIX}md a{ color: var(--a-primary); text-decoration: underline; text-underline-offset:2px; }
.${APP_PREFIX}md code{ background: rgba(127,127,127,.16); padding: 2px 6px; border-radius: 6px; }
.${APP_PREFIX}md pre{
background: var(--a-code); color: var(--a-codeText);
padding: 12px; border-radius:12px; overflow:auto;
border:1px solid rgba(255,255,255,.10);
}
.${APP_PREFIX}composer{
border-top:1px solid var(--a-border);
padding:10px;
display:flex; gap:10px; align-items:flex-end;
background: rgba(127,127,127,.06);
}
.${APP_PREFIX}ta{
flex:1;
min-height:46px; max-height:180px;
resize:none;
border-radius:14px;
border:1px solid var(--a-border);
padding:10px 12px;
background: rgba(255,255,255,.88);
color: var(--a-text);
outline:none;
}
@media (prefers-color-scheme: dark){
.${APP_PREFIX}ta{ background: rgba(18,20,27,.88); }
}
.${APP_PREFIX}smallToggle{ display:flex; align-items:center; gap:6px; font-size:13px; color: var(--a-sub); font-weight:800; }
.${APP_PREFIX}overlay{
position:fixed; inset:0;
background: rgba(0,0,0,.6);
backdrop-filter: blur(4px);
z-index:100004;
display:none;
align-items:center;
justify-content:center;
}
.${APP_PREFIX}overlay.open{ display:flex; }
.${APP_PREFIX}modal{
width: 620px; max-width: 92vw;
border-radius: 16px;
border:1px solid var(--a-border);
background: var(--a-card);
box-shadow: var(--a-shadow);
padding: 18px;
color: var(--a-text);
}
.${APP_PREFIX}formRow{ margin: 10px 0; }
.${APP_PREFIX}formRow label{ display:block; font-size:13px; font-weight:900; color:var(--a-sub); margin-bottom:6px; }
.${APP_PREFIX}formRow input, .${APP_PREFIX}formRow textarea{
width:100%; box-sizing:border-box;
border-radius: 12px;
border:1px solid var(--a-border);
padding: 10px 12px;
background: rgba(127,127,127,.08);
color: var(--a-text);
outline:none;
}
.${APP_PREFIX}formActions{ display:flex; justify-content:flex-end; gap:10px; margin-top: 12px; }
#${APP_PREFIX}toast{
position:fixed; right: 90px; top: 72px;
z-index:100005;
background: rgba(0,0,0,.82);
color:#fff;
padding: 8px 12px;
border-radius: 999px;
opacity:0;
pointer-events:none;
transition: .25s;
font-weight:800;
max-width: 60vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#${APP_PREFIX}toast.show{ opacity:1; }
`;
class UI {
constructor(store, confStore) {
this.store = store;
this.confStore = confStore;
this.debugVisible = false;
this.isSending = false;
this._injectStyle();
this._renderShell();
this._applyFabPosFromStore(); // ✅ 恢复悬浮球位置
this._bind();
this._bindFabDrag(); // ✅ 允许拖动
this.renderAll();
GM_registerMenuCommand('打开 Linux.do Agent', () => this.toggleDrawer(true));
GM_registerMenuCommand('清空当前会话', () => {
const s = this.store.active();
if (confirm(`确定清空会话「${s.title}」吗?`)) {
this.store.clearSession(s.id);
this.renderAll();
this.toast('已清空');
}
});
}
_injectStyle() {
const el = document.createElement('style');
el.textContent = STYLES;
document.head.appendChild(el);
}
_renderShell() {
const fab = document.createElement('div');
fab.id = `${APP_PREFIX}fab`;
fab.textContent = 'AG';
fab.title = 'Linux.do Agent(可拖动)';
document.body.appendChild(fab);
const drawer = document.createElement('div');
drawer.id = `${APP_PREFIX}drawer`;
drawer.innerHTML = `
<div class="${APP_PREFIX}header">
<div class="${APP_PREFIX}title">
Linux.do Agent <span class="${APP_PREFIX}badge">多会话 · 工具调用 · 可恢复</span>
</div>
<div class="${APP_PREFIX}actions">
<label class="${APP_PREFIX}smallToggle">
<input type="checkbox" id="${APP_PREFIX}debugToggle" />
显示调试
</label>
<button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnSetting">设置</button>
<button class="${APP_PREFIX}icon" id="${APP_PREFIX}btnClose">收起</button>
</div>
</div>
<div class="${APP_PREFIX}body">
<div class="${APP_PREFIX}sidebar">
<div class="${APP_PREFIX}sideTop">
<button class="${APP_PREFIX}btn" id="${APP_PREFIX}btnNew">新建</button>
<button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}btnExport">导出</button>
</div>
<div class="${APP_PREFIX}sessions" id="${APP_PREFIX}sessions"></div>
</div>
<div class="${APP_PREFIX}main">
<div class="${APP_PREFIX}chat" id="${APP_PREFIX}chat"></div>
<div class="${APP_PREFIX}composer">
<textarea class="${APP_PREFIX}ta" id="${APP_PREFIX}ta" placeholder="输入问题:例如“总结某话题”“搜索某关键词”“查看@某用户概览/热门帖子”“列出最新话题/最新帖子/Top话题/某tag话题”“抓取某话题第N楼全文”等"></textarea>
<div style="display:flex;flex-direction:column;gap:8px;">
<button class="${APP_PREFIX}btn" id="${APP_PREFIX}btnSend">发送</button>
<button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}btnResume">恢复</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(drawer);
const overlay = document.createElement('div');
overlay.className = `${APP_PREFIX}overlay`;
overlay.innerHTML = `
<div class="${APP_PREFIX}modal">
<h3 style="margin:0 0 10px 0;">⚙️ 设置(OpenAI Chat 格式)</h3>
<div class="${APP_PREFIX}formRow">
<label>Base URL</label>
<input id="${APP_PREFIX}cfgBaseUrl" placeholder="https://api.openai.com/v1" />
</div>
<div class="${APP_PREFIX}formRow">
<label>Model</label>
<input id="${APP_PREFIX}cfgModel" placeholder="gpt-4o-mini" />
</div>
<div class="${APP_PREFIX}formRow">
<label>API Key</label>
<input id="${APP_PREFIX}cfgKey" type="password" placeholder="sk-..." />
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<div class="${APP_PREFIX}formRow" style="flex:1;min-width:160px;">
<label>Temperature (0-1)</label>
<input id="${APP_PREFIX}cfgTemp" type="number" step="0.05" min="0" max="1" />
</div>
<div class="${APP_PREFIX}formRow" style="flex:1;min-width:160px;">
<label>maxTurns</label>
<input id="${APP_PREFIX}cfgMaxTurns" type="number" step="1" min="1" max="30" />
</div>
<div class="${APP_PREFIX}formRow" style="flex:1;min-width:200px;">
<label>maxContextChars</label>
<input id="${APP_PREFIX}cfgMaxCtx" type="number" step="500" min="4000" max="80000" />
</div>
</div>
<div class="${APP_PREFIX}formRow">
<label>System Prompt</label>
<textarea id="${APP_PREFIX}cfgSys" rows="6"></textarea>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<label class="${APP_PREFIX}smallToggle" style="color:var(--a-text);">
<input type="checkbox" id="${APP_PREFIX}cfgToolCtx" />
将工具结果作为上下文喂给模型
</label>
</div>
<div class="${APP_PREFIX}formActions">
<button class="${APP_PREFIX}btn ${APP_PREFIX}btnGhost" id="${APP_PREFIX}cfgCancel">取消</button>
<button class="${APP_PREFIX}btn" id="${APP_PREFIX}cfgSave">保存</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const toast = document.createElement('div');
toast.id = `${APP_PREFIX}toast`;
document.body.appendChild(toast);
this.dom = {
fab, drawer, overlay, toast,
btnClose: drawer.querySelector(`#${APP_PREFIX}btnClose`),
btnSetting: drawer.querySelector(`#${APP_PREFIX}btnSetting`),
debugToggle: drawer.querySelector(`#${APP_PREFIX}debugToggle`),
sessions: drawer.querySelector(`#${APP_PREFIX}sessions`),
chat: drawer.querySelector(`#${APP_PREFIX}chat`),
ta: drawer.querySelector(`#${APP_PREFIX}ta`),
btnSend: drawer.querySelector(`#${APP_PREFIX}btnSend`),
btnResume: drawer.querySelector(`#${APP_PREFIX}btnResume`),
btnNew: drawer.querySelector(`#${APP_PREFIX}btnNew`),
btnExport: drawer.querySelector(`#${APP_PREFIX}btnExport`),
cfgBaseUrl: overlay.querySelector(`#${APP_PREFIX}cfgBaseUrl`),
cfgModel: overlay.querySelector(`#${APP_PREFIX}cfgModel`),
cfgKey: overlay.querySelector(`#${APP_PREFIX}cfgKey`),
cfgTemp: overlay.querySelector(`#${APP_PREFIX}cfgTemp`),
cfgMaxTurns: overlay.querySelector(`#${APP_PREFIX}cfgMaxTurns`),
cfgMaxCtx: overlay.querySelector(`#${APP_PREFIX}cfgMaxCtx`),
cfgSys: overlay.querySelector(`#${APP_PREFIX}cfgSys`),
cfgToolCtx: overlay.querySelector(`#${APP_PREFIX}cfgToolCtx`),
cfgCancel: overlay.querySelector(`#${APP_PREFIX}cfgCancel`),
cfgSave: overlay.querySelector(`#${APP_PREFIX}cfgSave`),
};
}
// ✅ 恢复悬浮球位置
_applyFabPosFromStore() {
const p = GM_getValue(STORE_KEYS.FABPOS, null);
const pos = (typeof p === 'string') ? safeJsonParse(p, null) : p;
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
const { x, y } = this._clampFabPos(pos.x, pos.y);
this.dom.fab.style.left = `${x}px`;
this.dom.fab.style.top = `${y}px`;
} else {
// 默认:右上(用 left/top 计算)
const w = 52, margin = 18;
const x = Math.max(margin, window.innerWidth - w - margin);
const y = 16;
this.dom.fab.style.left = `${x}px`;
this.dom.fab.style.top = `${y}px`;
}
}
_saveFabPos(x, y) {
GM_setValue(STORE_KEYS.FABPOS, { x, y });
}
_clampFabPos(x, y) {
const w = this.dom.fab.offsetWidth || 52;
const h = this.dom.fab.offsetHeight || 52;
const margin = 8;
const maxX = Math.max(margin, window.innerWidth - w - margin);
const maxY = Math.max(margin, window.innerHeight - h - margin);
return {
x: Math.max(margin, Math.min(maxX, x)),
y: Math.max(margin, Math.min(maxY, y)),
};
}
// ✅ AG 拖动(pointer events;短距离视为点击)
_bindFabDrag() {
const fab = this.dom.fab;
let dragging = false;
let moved = false;
let startX = 0, startY = 0;
let origLeft = 0, origTop = 0;
const getLeftTop = () => {
const r = fab.getBoundingClientRect();
return { left: r.left, top: r.top };
};
const onPointerDown = (e) => {
// 仅主按键
if (e.button !== undefined && e.button !== 0) return;
dragging = true;
moved = false;
fab.classList.add('dragging');
const lt = getLeftTop();
origLeft = lt.left;
origTop = lt.top;
startX = e.clientX;
startY = e.clientY;
try { fab.setPointerCapture(e.pointerId); } catch {}
e.preventDefault();
e.stopPropagation();
};
const onPointerMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) + Math.abs(dy) > 6)) moved = true;
const nx = origLeft + dx;
const ny = origTop + dy;
const clamped = this._clampFabPos(nx, ny);
fab.style.left = `${clamped.x}px`;
fab.style.top = `${clamped.y}px`;
e.preventDefault();
e.stopPropagation();
};
const onPointerUp = (e) => {
if (!dragging) return;
dragging = false;
fab.classList.remove('dragging');
const lt = getLeftTop();
const clamped = this._clampFabPos(lt.left, lt.top);
fab.style.left = `${clamped.x}px`;
fab.style.top = `${clamped.y}px`;
this._saveFabPos(clamped.x, clamped.y);
// 短距离不算拖动:当点击处理
if (!moved) this.toggleDrawer();
try { fab.releasePointerCapture(e.pointerId); } catch {}
e.preventDefault();
e.stopPropagation();
};
const onResize = () => {
// 视口变化时把位置夹回去
const lt = getLeftTop();
const clamped = this._clampFabPos(lt.left, lt.top);
fab.style.left = `${clamped.x}px`;
fab.style.top = `${clamped.y}px`;
this._saveFabPos(clamped.x, clamped.y);
};
fab.addEventListener('pointerdown', onPointerDown, { passive: false });
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp, { passive: false });
window.addEventListener('resize', onResize);
}
_bind() {
const d = this.dom;
// ⚠️ 点击由拖动逻辑统一处理,这里不再绑定 click
d.btnClose.addEventListener('click', () => this.toggleDrawer(false));
d.debugToggle.addEventListener('change', () => {
this.debugVisible = !!d.debugToggle.checked;
this.renderAll();
});
d.btnSetting.addEventListener('click', () => {
this.loadConfToUI();
this.dom.overlay.classList.add('open');
});
d.cfgCancel.addEventListener('click', () => this.dom.overlay.classList.remove('open'));
d.cfgSave.addEventListener('click', () => this.saveConfFromUI());
d.btnNew.addEventListener('click', () => {
this.store.create('新会话');
this.renderAll();
});
d.btnExport.addEventListener('click', () => {
const s = this.store.active();
const payload = { title: s.title, createdAt: s.createdAt, chat: s.chat, agent: s.agent, fsm: s.fsm };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `linuxdo-agent-${(s.title || 'session').slice(0, 24)}.json`;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 120);
});
d.sessions.addEventListener('click', (e) => {
const card = e.target.closest(`.${APP_PREFIX}session`);
if (!card) return;
const id = card.dataset.id;
const op = e.target.closest('[data-op]');
if (op) {
const act = op.dataset.op;
if (act === 'del') {
if (confirm('确定删除该会话吗?')) {
this.store.remove(id);
this.renderAll();
}
return;
}
if (act === 'ren') {
const s = this.store.all().find(x => x.id === id);
const t = prompt('重命名会话:', s?.title || '新会话');
if (t != null) {
this.store.rename(id, t);
this.renderAll();
}
return;
}
if (act === 'clr') {
if (confirm('确定清空该会话吗?')) {
this.store.clearSession(id);
this.renderAll();
}
return;
}
}
this.store.setActive(id);
this.renderAll();
});
d.ta.addEventListener('input', () => this.autoGrow(d.ta));
d.ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.send();
}
});
d.btnSend.addEventListener('click', () => this.send());
d.btnResume.addEventListener('click', () => this.resume());
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.dom.overlay.classList.remove('open');
});
}
toggleDrawer(force) {
if (typeof force === 'boolean') {
this.dom.drawer.classList.toggle('open', force);
} else {
this.dom.drawer.classList.toggle('open');
}
}
autoGrow(ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(180, Math.max(46, ta.scrollHeight)) + 'px';
}
toast(msg) {
const t = this.dom.toast;
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), 2200);
}
loadConfToUI() {
const c = this.confStore.get();
this.dom.cfgBaseUrl.value = c.baseUrl || DEFAULT_CONF.baseUrl;
this.dom.cfgModel.value = c.model || DEFAULT_CONF.model;
this.dom.cfgKey.value = c.apiKey || '';
this.dom.cfgTemp.value = String(c.temperature ?? 0.2);
this.dom.cfgMaxTurns.value = String(c.maxTurns ?? 8);
this.dom.cfgMaxCtx.value = String(c.maxContextChars ?? 24000);
this.dom.cfgSys.value = c.systemPrompt || DEFAULT_CONF.systemPrompt;
this.dom.cfgToolCtx.checked = !!c.includeToolContext;
}
saveConfFromUI() {
const baseUrl = this.dom.cfgBaseUrl.value.trim() || DEFAULT_CONF.baseUrl;
const model = this.dom.cfgModel.value.trim() || DEFAULT_CONF.model;
const apiKey = this.dom.cfgKey.value.trim();
const temperature = Math.max(0, Math.min(1, parseFloat(this.dom.cfgTemp.value) || DEFAULT_CONF.temperature));
const maxTurns = Math.max(1, Math.min(100, parseInt(this.dom.cfgMaxTurns.value, 10) || DEFAULT_CONF.maxTurns));
const maxContextChars = Math.max(4000, Math.min(8000000, parseInt(this.dom.cfgMaxCtx.value, 10) || DEFAULT_CONF.maxContextChars));
const systemPrompt = this.dom.cfgSys.value.trim() || DEFAULT_CONF.systemPrompt;
const includeToolContext = !!this.dom.cfgToolCtx.checked;
this.confStore.save({ baseUrl, model, apiKey, temperature, maxTurns, maxContextChars, systemPrompt, includeToolContext });
this.dom.overlay.classList.remove('open');
this.toast('设置已保存');
}
renderSessions() {
const wrap = this.dom.sessions;
const all = this.store.all();
const activeId = this.store.active().id;
wrap.innerHTML = all.map(s => {
const state = s.fsm?.state || FSM.IDLE;
const running = s.fsm?.isRunning ? ' · 运行中' : '';
const err = (s.fsm?.state === FSM.ERROR && s.fsm?.lastError) ? ' · 错误' : '';
const sub = `${state}${running}${err}`;
return `
<div class="${APP_PREFIX}session ${s.id === activeId ? 'active' : ''}" data-id="${s.id}">
<div style="min-width:0;">
<div class="t" title="${(s.title || '').replace(/"/g,'"')}">${s.title || '新会话'}</div>
<div class="s">${sub}</div>
</div>
<div class="${APP_PREFIX}ops">
<span class="${APP_PREFIX}op" data-op="ren" title="重命名">改</span>
<span class="${APP_PREFIX}op" data-op="clr" title="清空">空</span>
<span class="${APP_PREFIX}op" data-op="del" title="删除">删</span>
</div>
</div>
`;
}).join('');
}
renderChat() {
const s = this.store.active();
const wrap = this.dom.chat;
const blocks = [];
if (!s.chat.length) {
blocks.push(`<div style="opacity:.9;text-align:center;margin-top:42px;color:var(--a-sub);font-weight:800;">输入问题后发送;Agent 会先工具检索,再汇总作答。final.refs 会显示在回答末尾。</div>`);
} else {
for (const m of s.chat) blocks.push(this.renderMessage(m.role, m.content, m.ts));
}
if (this.debugVisible) {
blocks.push(`<div style="margin-top:14px;opacity:.9;color:var(--a-sub);font-weight:900;">—— 调试轨迹(agent/tool)——</div>`);
for (const a of (s.agent || [])) {
const label = `${a.role}:${a.kind || ''}`;
blocks.push(this.renderMessage(a.role === 'tool' ? 'tool' : 'assistant', `**${label}**\n\n\`\`\`\n${String(a.content || '')}\n\`\`\``, a.ts));
}
}
wrap.innerHTML = blocks.join('\n');
wrap.scrollTop = wrap.scrollHeight;
}
renderMessage(role, content, ts) {
const r = role === 'user' ? 'user' : (role === 'tool' ? 'tool' : 'assistant');
const time = (() => { try { return new Date(ts || now()).toLocaleString('zh-CN', { hour12: false }); } catch { return ''; } })();
let html = '';
try {
html = (window.marked ? marked.parse(content || '') : String(content || '')).replace(/<a /g, '<a target="_blank" rel="noreferrer" ');
} catch {
html = `<pre>${String(content || '').replace(/[<>&]/g, s => ({'<':'<','>':'>','&':'&'}[s]))}</pre>`;
}
return `
<div class="${APP_PREFIX}msg ${r}">
<div class="${APP_PREFIX}meta">${r.toUpperCase()} · ${time}</div>
<div class="${APP_PREFIX}md">${html}</div>
</div>
`;
}
renderAll() {
this.renderSessions();
this.renderChat();
const s = this.store.active();
const running = !!s.fsm?.isRunning;
this.dom.btnSend.disabled = running;
this.dom.btnResume.disabled = running;
this.dom.ta.disabled = running;
this.dom.btnSend.textContent = running ? '运行中…' : '发送';
}
async send() {
if (this.isSending) return;
const text = this.dom.ta.value.trim();
if (!text) return;
const conf = this.confStore.get();
if (!conf.apiKey) {
this.toast('请先设置 API Key');
this.dom.overlay.classList.add('open');
return;
}
const s = this.store.active();
if (s.fsm?.isRunning) return;
this.isSending = true;
this.dom.ta.value = '';
this.autoGrow(this.dom.ta);
this.store.pushChat(s.id, { role: 'user', content: text, ts: now() });
if ((s.title || '') === '新会话') {
const t = text.replace(/\s+/g, ' ').trim().slice(0, 14) || '新会话';
this.store.rename(s.id, t);
}
this.renderAll();
try {
await runAgent(s.id, this.store, conf, this);
this.toast('完成');
} catch (e) {
this.toast(`失败:${e.message || e}`);
} finally {
this.isSending = false;
this.renderAll();
}
}
async resume() {
const conf = this.confStore.get();
const s = this.store.active();
if (s.fsm?.isRunning) return;
this.toast('尝试恢复…');
try {
await runAgent(s.id, this.store, conf, this);
this.toast('恢复完成');
} catch (e) {
this.toast(`恢复失败:${e.message || e}`);
} finally {
this.renderAll();
}
}
}
/******************************************************************
* 9) 启动
******************************************************************/
function init() {
if (window.top !== window) return;
if (document.getElementById(`${APP_PREFIX}fab`)) return;
const confStore = new ConfigStore();
const store = new SessionStore();
new UI(store, confStore);
}
if (document.readyState === 'complete' || document.readyState === 'interactive') init();
else window.addEventListener('load', init);
})();